Skip to main content

geop_ops_parts/operation/
datum.rs

1//! [`AddDatum`]: reference geometry — a point, an axis or a plane — built
2//! from entities picked in the part, in one of the ways CAD systems commonly
3//! offer (see [`Construction`]).
4//!
5//! Which constructions can be chosen depends on what is selected, and on its
6//! shape, not only its kind: a straight edge is a line, a circular one has a
7//! center and an axis, a flat face is a plane (see [`Geometry`]).
8//! [`inspect_selection`] tells an editor which constructions fit a
9//! selection, by exactly the matching a step applies.
10
11use geop_core_geometry::shape::Plane;
12use geop_core_math::{
13    geop_error::{GeopError, GeopResult, WithContext},
14    primitives::CoordinateSystem,
15    scalars::Scalar,
16    vector::Vector3,
17    with_context,
18};
19use geop_core_part::{Datum, DatumKind, Part};
20use geop_ops_parts_derive::OperationArgs;
21use serde::{Deserialize, Serialize};
22
23use super::{
24    ArgKind, ArgSchema, EntityRef, Handle, HandleGroup, HandleMotion, Operation, arg_path,
25    entity::{Geometry, Role, frame_along, world_frame},
26    extrude::to_f64,
27    schema::ConstructionSchema,
28};
29
30/// Declares [`Construction`] and [`CONSTRUCTIONS`] from one table, so the
31/// two can't disagree: each construction's variant, method name, label,
32/// the inputs it needs selected, what it builds, what it does, and the
33/// values it takes besides — each with its kind, as an argument's.
34macro_rules! constructions {
35    ($(
36        $variant:ident $method:literal $label:literal [$($role:ident),*] -> $result:ident,
37        $doc:literal {
38            $($param:ident: $pty:ty = $pkind:ident { $($kfield:ident: $kval:expr),* }, $pdoc:literal;)*
39        }
40    )*) => {
41        /// How a datum is built from its selection: one variant per way,
42        /// with the values it takes besides. Serialized with its method:
43        /// `{"method": "offset", "distance": 1.0}`.
44        #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
45        #[serde(tag = "method")]
46        pub enum Construction {
47            $(
48                #[doc = $doc]
49                #[serde(rename = $method)]
50                $variant { $(#[doc = $pdoc] $param: $pty),* },
51            )*
52        }
53
54        /// Every [`Construction`], described for an editor.
55        pub const CONSTRUCTIONS: &[ConstructionSchema] = &[$(
56            ConstructionSchema {
57                method: $method,
58                label: $label,
59                doc: $doc,
60                result: DatumKind::$result,
61                inputs: &[$(Role::$role),*],
62                params: &[$(ArgSchema {
63                    name: stringify!($param),
64                    doc: $pdoc,
65                    kind: ArgKind::$pkind { $($kfield: $kval),* },
66                }),*],
67            },
68        )*];
69
70        impl Construction {
71            /// How it is described among [`CONSTRUCTIONS`].
72            pub fn schema(&self) -> &'static ConstructionSchema {
73                let method = match self {
74                    $(Construction::$variant { .. } => $method,)*
75                };
76                CONSTRUCTIONS
77                    .iter()
78                    .find(|c| c.method == method)
79                    .expect("every construction is described")
80            }
81        }
82    };
83}
84
85constructions! {
86    // ── points ──
87    Point "point" "Point" [Point] -> Point,
88    "A point offset from the selected one: along its own axes if it is a datum or the origin, along the world's otherwise. Its frame is that point's, moved." {
89        x: f64 = Number { default: 0.0, min: -10.0, max: 10.0 }, "How far along x.";
90        y: f64 = Number { default: 0.0, min: -10.0, max: 10.0 }, "How far along y.";
91        z: f64 = Number { default: 0.0, min: -10.0, max: 10.0 }, "How far along z.";
92    }
93    Midpoint "midpoint" "Midpoint" [Point, Point] -> Point,
94    "The point halfway between two points." {}
95    EdgePoint "edge_point" "Point on edge" [Edge] -> Point,
96    "A point along an edge, its z axis along the edge." {
97        position: f64 = Number { default: 0.5, min: 0.0, max: 1.0 }, "Where along the edge, from its start (0) to its end (1): by length on a straight or circular edge, by parameter on any other.";
98    }
99    Center "center" "Center" [Circle] -> Point,
100    "The center of a circular edge, its z axis the one the arc turns around." {}
101    ProjectOnPlane "project_on_plane" "Projection onto plane" [Point, Plane] -> Point,
102    "The foot of the perpendicular dropped from a point onto a plane." {}
103    ProjectOnLine "project_on_line" "Projection onto line" [Point, Line] -> Point,
104    "The foot of the perpendicular dropped from a point onto a line." {}
105    LinePlane "line_plane" "Line meets plane" [Line, Plane] -> Point,
106    "Where a line pierces a plane." {}
107    LineLine "line_line" "Lines meet" [Line, Line] -> Point,
108    "Where two lines cross — or, if they miss each other, halfway between where they come closest. Its z axis is normal to both." {}
109    ThreePlanes "three_planes" "Three planes meet" [Plane, Plane, Plane] -> Point,
110    "The one point three planes share." {}
111
112    // ── axes ──
113    TwoPoints "two_points" "Line through points" [Point, Point] -> Axis,
114    "The line from one point through another." {}
115    AlongLine "along_line" "Along line" [Line] -> Axis,
116    "The line a straight edge or an axis runs along." {}
117    AxisOf "axis_of" "Axis of arc or cylinder" [Round] -> Axis,
118    "The axis a circular edge, or a cylindrical, conical or spherical face, turns around." {}
119    PlanePlane "plane_plane" "Two planes meet" [Plane, Plane] -> Axis,
120    "The line two planes meet in." {}
121    Perpendicular "perpendicular" "Perpendicular to plane" [Point, Plane] -> Axis,
122    "The perpendicular dropped from a point onto a plane: the line through the point along the plane's normal." {}
123    Parallel "parallel" "Parallel through point" [Point, Line] -> Axis,
124    "The line through a point parallel to a line." {}
125    PerpendicularToLine "perpendicular_to_line" "Perpendicular to line" [Point, Line] -> Axis,
126    "The perpendicular dropped from a point onto a line: from the point to its foot on the line." {}
127    Bisector "bisector" "Angle bisector" [Line, Line] -> Axis,
128    "The line halving the angle between two crossing lines, through where they cross — or, between parallel lines, the line halfway between them." {
129        other: bool = Bool { default: false }, "Halve the other angle: the one between the first line and the second one reversed.";
130    }
131    Tangent "tangent" "Tangent to edge" [Edge] -> Axis,
132    "The tangent to an edge at a point along it." {
133        position: f64 = Number { default: 0.5, min: 0.0, max: 1.0 }, "Where along the edge, from its start (0) to its end (1): by length on a straight or circular edge, by parameter on any other.";
134    }
135
136    // ── planes ──
137    Offset "offset" "Offset plane" [Plane] -> Plane,
138    "A plane parallel to the selected one, a distance along its normal." {
139        distance: f64 = Number { default: 1.0, min: -10.0, max: 10.0 }, "How far along the plane's normal; backwards if negative.";
140    }
141    Midplane "midplane" "Midplane" [Plane, Plane] -> Plane,
142    "The plane halfway between two parallel planes, or halving the angle between two that meet." {
143        other: bool = Bool { default: false }, "For planes that meet: halve the other angle between them.";
144    }
145    ThreePoints "three_points" "Plane through points" [Point, Point, Point] -> Plane,
146    "The plane through three points." {}
147    Angle "angle" "Plane at angle" [Plane, Line] -> Plane,
148    "The plane through a line at an angle to a plane: turned around the line from the plane through it most nearly parallel to the selected one — which, for a line parallel to that plane, is parallel to it." {
149        angle: f64 = Number { default: 45.0, min: -180.0, max: 180.0 }, "How far to turn, in degrees, right-handed about the line's direction.";
150    }
151    LinePoint "line_point" "Plane through line and point" [Line, Point] -> Plane,
152    "The plane through a line and a point off it." {}
153    TwoLines "two_lines" "Plane through lines" [Line, Line] -> Plane,
154    "The plane two crossing or parallel lines lie in — for lines that miss each other, the plane through the first parallel to the second." {}
155    ParallelPlane "parallel_plane" "Parallel plane through point" [Plane, Point] -> Plane,
156    "The plane through a point parallel to a plane." {}
157    NormalToLine "normal_to_line" "Plane normal to line" [Line, Point] -> Plane,
158    "The plane through a point perpendicular to a line." {}
159    NormalToEdge "normal_to_edge" "Plane normal to edge" [Edge] -> Plane,
160    "The plane perpendicular to an edge at a point along it." {
161        position: f64 = Number { default: 0.5, min: 0.0, max: 1.0 }, "Where along the edge, from its start (0) to its end (1): by length on a straight or circular edge, by parameter on any other.";
162    }
163}
164
165/// Adds a datum to the part, named by the operation's id: a point, an axis
166/// or a plane built from the selected entities by one of the
167/// [`Construction`]s. Like a sketch's plane, it is built when the step runs
168/// and stays where it was, whatever later steps do to what it was built
169/// from.
170#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
171pub struct AddDatum;
172
173#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, OperationArgs)]
174pub struct AddDatumArgs {
175    /// What it is built from: points, edges, faces and other references,
176    /// picked in the viewport.
177    #[arg(Selection)]
178    pub selection: Vec<EntityRef>,
179    /// How it is built from them; only the ways that fit what is selected
180    /// can be chosen.
181    #[arg(Construction { selection: "selection", options: CONSTRUCTIONS })]
182    pub construction: Construction,
183}
184
185/// Which selected entity fills each of `inputs`, in order: one entity per
186/// input, each able to fill its role, and of all such assignments the first
187/// in selection order. `None` if there is none.
188fn assign<S: Scalar>(inputs: &[Role], selection: &[Geometry<S>]) -> Option<Vec<usize>> {
189    fn extend<S: Scalar>(
190        inputs: &[Role],
191        selection: &[Geometry<S>],
192        chosen: &mut Vec<usize>,
193    ) -> bool {
194        let Some(role) = inputs.get(chosen.len()) else {
195            return true;
196        };
197        for (i, geometry) in selection.iter().enumerate() {
198            if !chosen.contains(&i) && role.fits(geometry) {
199                chosen.push(i);
200                if extend(inputs, selection, chosen) {
201                    return true;
202                }
203                chosen.pop();
204            }
205        }
206        false
207    }
208    let mut chosen = Vec::new();
209    (inputs.len() == selection.len() && extend(inputs, selection, &mut chosen)).then_some(chosen)
210}
211
212/// What an editor offers for a selection: what each entity can be used as,
213/// and which constructions fit — by the same matching a step applies.
214#[derive(Clone, Debug, PartialEq, Serialize)]
215pub struct SelectionFit {
216    /// Per selected entity; none for one the part does not have.
217    pub roles: Vec<Vec<Role>>,
218    /// The method of every construction that fits.
219    pub fits: Vec<&'static str>,
220}
221
222/// Which constructions fit `selection` in `part` (see [`SelectionFit`]).
223pub fn inspect_selection<S: Scalar>(part: &Part<S>, selection: &[EntityRef]) -> SelectionFit {
224    let resolved: Option<Vec<Geometry<S>>> =
225        selection.iter().map(|e| e.resolve(part).ok()).collect();
226    let roles = selection
227        .iter()
228        .map(|e| e.resolve(part).map(|g| g.roles()).unwrap_or_default())
229        .collect();
230    let fits = match &resolved {
231        Some(resolved) => CONSTRUCTIONS
232            .iter()
233            .filter(|c| assign(c.inputs, resolved).is_some())
234            .map(|c| c.method)
235            .collect(),
236        None => Vec::new(),
237    };
238    SelectionFit { roles, fits }
239}
240
241/// A role as a construction's requirement reads: `a point`.
242fn describe_role(role: Role) -> &'static str {
243    match role {
244        Role::Point => "a point",
245        Role::Line => "a line",
246        Role::Plane => "a plane",
247        Role::Edge => "an edge",
248        Role::Circle => "a circular edge",
249        Role::Round => "a circular edge or a round face",
250    }
251}
252
253impl AddDatumArgs {
254    /// The selection resolved in `part` and ordered as the construction
255    /// takes it.
256    fn inputs<S: Scalar>(&self, part: &Part<S>) -> GeopResult<Vec<Geometry<S>>> {
257        let schema = self.construction.schema();
258        let resolved = self
259            .selection
260            .iter()
261            .map(|e| e.resolve(part))
262            .collect::<GeopResult<Vec<_>>>()?;
263        let Some(order) = assign(schema.inputs, &resolved) else {
264            let needs: Vec<&str> = schema.inputs.iter().map(|&r| describe_role(r)).collect();
265            return Err(GeopError::new(format!(
266                "{} needs {} selected, one each, and nothing else",
267                schema.label,
268                needs.join(" and ")
269            )));
270        };
271        Ok(order.into_iter().map(|i| resolved[i].clone()).collect())
272    }
273}
274
275/// The plane of a frame whose `w` is its normal.
276fn plane_of<S: Scalar>(frame: &CoordinateSystem<S>) -> Plane<S> {
277    Plane {
278        point: *frame.origin(),
279        normal: *frame.w(),
280    }
281}
282
283/// `frame`'s axes, at `origin`.
284fn moved<S: Scalar>(
285    frame: &CoordinateSystem<S>,
286    origin: Vector3<S>,
287) -> GeopResult<CoordinateSystem<S>> {
288    CoordinateSystem::try_new(origin, *frame.u(), *frame.v(), *frame.w())
289}
290
291/// The point `position` of the way along the edge `edge` (see
292/// [`Construction::EdgePoint`]), and the unit tangent there.
293fn along_edge<S: Scalar>(
294    edge: &Geometry<S>,
295    position: f64,
296) -> GeopResult<(Vector3<S>, Vector3<S>)> {
297    if !(0.0..=1.0).contains(&position) {
298        return Err(GeopError::new(format!(
299            "position {position} is not along the edge: it must be from 0 to 1"
300        )));
301    }
302    let curve = edge.curve.as_ref().expect("an edge has a curve");
303    let (t0, t1) = curve.domain();
304    let fraction = S::from_f64(position);
305    if let Some(line) = &edge.line {
306        let (a, b) = (curve.evaluate(t0)?, curve.evaluate(t1)?);
307        return Ok((Vector3::interpolate(&a, &b, fraction), line.direction));
308    }
309    if let Some(arc) = &edge.arc {
310        let p = arc.point_at(position)?;
311        return Ok((p, arc.tangent_at(&p)?));
312    }
313    let t = S::interpolate(t0, t1, fraction);
314    Ok((curve.evaluate(t)?, curve.tangent(t)?.normalize()?))
315}
316
317/// `x` degrees, in radians.
318fn radians<S: Scalar>(degrees: f64) -> S {
319    S::from_f64(degrees.to_radians())
320}
321
322impl Construction {
323    /// The frame it builds from `inputs`, ordered as it takes them (see
324    /// [`AddDatumArgs::inputs`]).
325    fn build<S: Scalar>(&self, inputs: &[Geometry<S>]) -> GeopResult<CoordinateSystem<S>> {
326        let point = |i: usize| inputs[i].point.expect("assigned a point");
327        let line = |i: usize| inputs[i].line.clone().expect("assigned a line");
328        let frame = |i: usize| inputs[i].plane.clone().expect("assigned a plane");
329        let plane = |i: usize| plane_of(&frame(i));
330        let half = S::ONE.div(S::TWO)?;
331        match self {
332            Construction::Point { x, y, z } => {
333                let p = point(0);
334                let base = match &inputs[0].frame {
335                    Some(frame) => frame.clone(),
336                    None => world_frame(p)?,
337                };
338                let offset = base.to_xyz(&Vector3::from_array([*x, *y, *z].map(S::from_f64)));
339                moved(&base, offset)
340            }
341            Construction::Midpoint {} => {
342                world_frame(Vector3::interpolate(&point(0), &point(1), half))
343            }
344            Construction::EdgePoint { position } => {
345                let (p, tangent) = along_edge(&inputs[0], *position)?;
346                frame_along(p, &tangent)
347            }
348            Construction::Center {} => {
349                let arc = inputs[0].arc.clone().expect("assigned an arc");
350                let c = arc.circle;
351                let u = arc.start.sub(&c.center).normalize()?;
352                CoordinateSystem::try_new(c.center, u, c.normal.prod_cross(&u), c.normal)
353            }
354            Construction::ProjectOnPlane {} => {
355                let foot = plane(1).project(&point(0));
356                moved(&frame(1), foot)
357            }
358            Construction::ProjectOnLine {} => {
359                let l = line(1);
360                frame_along(l.project(&point(0)), &l.direction)
361            }
362            Construction::LinePlane {} => {
363                let at = plane(1).intersect_axis(&line(0))?;
364                moved(&frame(1), at)
365            }
366            Construction::LineLine {} => {
367                let (a, b) = (line(0), line(1));
368                let at = a.nearest(&b)?;
369                let w = a.direction.prod_cross(&b.direction).normalize()?;
370                CoordinateSystem::try_new(at, a.direction, w.prod_cross(&a.direction), w)
371            }
372            Construction::ThreePlanes {} => {
373                let meet = plane(0).intersect_plane(&plane(1))?;
374                world_frame(plane(2).intersect_axis(&meet)?)
375            }
376            Construction::TwoPoints {} => {
377                let (a, b) = (point(0), point(1));
378                let d = b.sub(&a);
379                if d.norm_sq().could_be_equal(S::ZERO) {
380                    return Err(GeopError::new(
381                        "the points coincide: no line runs through both",
382                    ));
383                }
384                frame_along(a, &d)
385            }
386            Construction::AlongLine {} => {
387                let l = line(0);
388                frame_along(l.point, &l.direction)
389            }
390            Construction::AxisOf {} => {
391                let axis = inputs[0].round.clone().expect("assigned something round");
392                frame_along(axis.point, &axis.direction)
393            }
394            Construction::PlanePlane {} => {
395                let meet = plane(0).intersect_plane(&plane(1))?;
396                frame_along(meet.point, &meet.direction)
397            }
398            Construction::Perpendicular {} => frame_along(point(0), &plane(1).normal),
399            Construction::Parallel {} => frame_along(point(0), &line(1).direction),
400            Construction::PerpendicularToLine {} => {
401                let p = point(0);
402                let d = line(1).project(&p).sub(&p);
403                if d.norm_sq().could_be_equal(S::ZERO) {
404                    return Err(GeopError::new(
405                        "the point lies on the line: there is no perpendicular to drop",
406                    ));
407                }
408                frame_along(p, &d)
409            }
410            Construction::Bisector { other } => {
411                let (a, b) = (line(0), line(1));
412                if a.could_be_parallel(&b) {
413                    let mid = Vector3::interpolate(&a.point, &b.project(&a.point), half);
414                    return frame_along(mid, &a.direction);
415                }
416                let at = a.nearest(&b)?;
417                let second = if *other {
418                    b.direction.neg()
419                } else {
420                    b.direction
421                };
422                frame_along(at, &a.direction.add(&second))
423            }
424            Construction::Tangent { position } => {
425                let (p, tangent) = along_edge(&inputs[0], *position)?;
426                frame_along(p, &tangent)
427            }
428            Construction::Offset { distance } => {
429                let f = frame(0);
430                moved(
431                    &f,
432                    f.origin().add(&f.w().prod_scalar(S::from_f64(*distance))),
433                )
434            }
435            Construction::Midplane { other } => {
436                // Every point as far in front of one plane as behind the
437                // other (`inner`: halving the angle between them, or the
438                // gap between parallel planes facing apart), or as far in
439                // front of both (`outer`): with `d_k = n_k . p_k`, the
440                // planes `(n1 -+ n2) . x = d1 -+ d2`. Neither needs the line
441                // the planes meet in, which runs off to infinity as they
442                // turn parallel.
443                let (p, q) = (plane(0), plane(1));
444                let (d1, d2) = (p.point.prod_dot(&p.normal), q.point.prod_dot(&q.normal));
445                let inner = (p.normal.sub(&q.normal), d1.sub(d2));
446                let outer = (p.normal.add(&q.normal), d1.add(d2));
447                let (first, second) = if *other {
448                    (outer, inner)
449                } else {
450                    (inner, outer)
451                };
452                // One of the two is only degenerate for parallel planes,
453                // where the other is the plane halfway between them.
454                let (normal, offset) = if first.0.norm_sq().could_be_equal(S::ZERO) {
455                    second
456                } else {
457                    first
458                };
459                let n2 = normal.norm_sq();
460                let midplane = Plane::try_new(normal.prod_scalar(offset.div(n2)?), normal)?;
461                let near = Vector3::interpolate(frame(0).origin(), frame(1).origin(), half);
462                frame_along(midplane.project(&near), &midplane.normal)
463            }
464            Construction::ThreePoints {} => {
465                let (a, b, c) = (point(0), point(1), point(2));
466                let n = b.sub(&a).prod_cross(&c.sub(&a));
467                if n.norm_sq().could_be_equal(S::ZERO) {
468                    return Err(GeopError::new(
469                        "the points lie on one line: every plane through that line runs through them",
470                    ));
471                }
472                frame_along(a, &n)
473            }
474            Construction::Angle { angle } => {
475                let (n, l) = (plane(0).normal, line(1));
476                // The selected plane's normal, turned square to the line: the
477                // normal of the plane through the line most nearly parallel
478                // to the selected one.
479                let square = n.sub(&l.direction.prod_scalar(l.direction.prod_dot(&n)));
480                if square.norm_sq().could_be_equal(S::ZERO) {
481                    return Err(GeopError::new(
482                        "the line is perpendicular to the plane: every plane through it is at right angles to it",
483                    ));
484                }
485                let a: S = radians(*angle);
486                let normal = square
487                    .prod_scalar(a.cos())
488                    .add(&l.direction.prod_cross(&square).prod_scalar(a.sin()));
489                frame_along(l.point, &normal)
490            }
491            Construction::LinePoint {} => {
492                let l = line(0);
493                let n = l.direction.prod_cross(&point(1).sub(&l.point));
494                if n.norm_sq().could_be_equal(S::ZERO) {
495                    return Err(GeopError::new(
496                        "the point lies on the line: every plane through the line runs through it",
497                    ));
498                }
499                frame_along(l.point, &n)
500            }
501            Construction::TwoLines {} => {
502                let (a, b) = (line(0), line(1));
503                let n = if a.could_be_parallel(&b) {
504                    let n = a.direction.prod_cross(&b.point.sub(&a.point));
505                    if n.norm_sq().could_be_equal(S::ZERO) {
506                        return Err(GeopError::new(
507                            "the lines coincide: every plane through one runs through the other",
508                        ));
509                    }
510                    n
511                } else {
512                    a.direction.prod_cross(&b.direction)
513                };
514                frame_along(a.point, &n)
515            }
516            Construction::ParallelPlane {} => {
517                let f = frame(0);
518                let d = plane(0).signed_distance(&point(1));
519                moved(&f, f.origin().add(&f.w().prod_scalar(d)))
520            }
521            Construction::NormalToLine {} => frame_along(point(1), &line(0).direction),
522            Construction::NormalToEdge { position } => {
523                let (p, tangent) = along_edge(&inputs[0], *position)?;
524                frame_along(p, &tangent)
525            }
526        }
527    }
528}
529
530/// How far out along its axis an offset point's handle sits, in world units.
531const POINT_HANDLE_OUT: f64 = 0.3;
532
533impl<S: Scalar> Operation<S> for AddDatum {
534    type Args = AddDatumArgs;
535
536    fn apply(
537        &self,
538        mut part: Part<S>,
539        operation_id: &str,
540        args: &AddDatumArgs,
541    ) -> GeopResult<Part<S>> {
542        let ctx = with_context!("add_datum({operation_id}, {args:?})");
543        let frame = args
544            .construction
545            .build(&args.inputs(&part).with_context(ctx)?)
546            .with_context(ctx)?;
547        let datum = Datum {
548            kind: args.construction.schema().result,
549            frame,
550        };
551        part.add_datum(datum, operation_id).with_context(ctx)?;
552        Ok(part)
553    }
554
555    /// An offset plane's distance, as a handle on the plane sliding along
556    /// its normal; an offset point's offsets, as a handle on the point per
557    /// axis, sliding along it.
558    fn handles(&self, before: &Part<S>, args: &AddDatumArgs) -> GeopResult<Vec<Handle>> {
559        let inputs = args.inputs(before)?;
560        let built = args.construction.build(&inputs)?;
561        let at = to_f64(built.origin());
562        // Handles of one point sit a little out along their axes, so each
563        // can be grabbed.
564        let handle = |label: &str, direction: &Vector3<S>, value: f64, out: f64| Handle {
565            label: label.into(),
566            group: HandleGroup::Feature,
567            position: {
568                let d = to_f64(direction);
569                [0, 1, 2].map(|k| at[k] + d[k] * out)
570            },
571            motion: HandleMotion::Linear {
572                direction: to_f64(direction),
573                arg: arg_path(&["construction", label]),
574                value,
575                scale: 1.0,
576            },
577        };
578        Ok(match &args.construction {
579            Construction::Offset { distance } => {
580                vec![handle("distance", built.w(), *distance, 0.0)]
581            }
582            Construction::Point { x, y, z } => vec![
583                handle("x", built.u(), *x, POINT_HANDLE_OUT),
584                handle("y", built.v(), *y, POINT_HANDLE_OUT),
585                handle("z", built.w(), *z, POINT_HANDLE_OUT),
586            ],
587            _ => Vec::new(),
588        })
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use geop_core_math::scalars::ScalInF64 as S;
595
596    use super::*;
597    use crate::{PartOperation, Program, WorldAxis, examples};
598
599    fn v(x: f64, y: f64, z: f64) -> Vector3<S> {
600        Vector3::from_array([x, y, z].map(S::from_f64))
601    }
602
603    fn face(name: &str) -> EntityRef {
604        EntityRef::Face { name: name.into() }
605    }
606    fn edge(name: &str) -> EntityRef {
607        EntityRef::Edge { name: name.into() }
608    }
609    fn vertex(name: &str) -> EntityRef {
610        EntityRef::Vertex { name: name.into() }
611    }
612    fn axis(axis: WorldAxis) -> EntityRef {
613        EntityRef::Axis { axis }
614    }
615    fn base(normal: WorldAxis) -> EntityRef {
616        EntityRef::Plane { normal }
617    }
618
619    /// The 2 x 2 x 1 box with a hole of radius 0.4 drilled 0.5 deep into
620    /// the middle of its top.
621    fn drilled_box() -> Part<S> {
622        examples::box_with_drill_hole().apply(Part::new()).unwrap()
623    }
624
625    /// The datum built from `selection` by `construction` in `part`.
626    fn datum(part: &Part<S>, selection: Vec<EntityRef>, construction: Construction) -> Datum<S> {
627        let args = AddDatumArgs {
628            selection,
629            construction,
630        };
631        let part = AddDatum.apply(part.clone(), "d", &args).unwrap();
632        part.datum(part.datum_id("d").unwrap()).unwrap().clone()
633    }
634
635    /// Sketch data is solved, not exact: agreeing to 1e-9 is agreeing.
636    fn assert_at(frame: &CoordinateSystem<S>, origin: [f64; 3], w: [f64; 3]) {
637        let [x, y, z] = origin;
638        let off = frame.origin().sub(&v(x, y, z)).norm().to_f64();
639        assert!(off < 1e-9, "origin of {frame} is {off} off {origin:?}");
640        let [x, y, z] = w;
641        let w = v(x, y, z).normalize().unwrap();
642        let off = frame.w().sub(&w).norm().to_f64();
643        assert!(off < 1e-9, "w of {frame} is {off} off {w:?}");
644    }
645
646    /// Whether `p` lies on the plane (a frame whose `w` is its normal) —
647    /// to within what solved sketch data is.
648    fn on_plane(frame: &CoordinateSystem<S>, p: [f64; 3]) -> bool {
649        let [x, y, z] = p;
650        plane_of(frame).signed_distance(&v(x, y, z)).to_f64().abs() < 1e-9
651    }
652
653    /// Every construction serializes under its own method, with exactly the
654    /// values its schema lists, and back.
655    #[test]
656    fn constructions_serialize_as_described() {
657        for schema in CONSTRUCTIONS {
658            let mut json = serde_json::json!({ "method": schema.method });
659            for param in schema.params {
660                json[param.name] = match param.kind {
661                    ArgKind::Number { default, .. } => default.into(),
662                    ArgKind::Bool { default } => default.into(),
663                    ref other => panic!("{}: unexpected parameter kind {other:?}", schema.method),
664                };
665            }
666            let construction: Construction = serde_json::from_value(json.clone())
667                .unwrap_or_else(|e| panic!("{}: {e}", schema.method));
668            assert_eq!(construction.schema(), schema);
669            assert_eq!(serde_json::to_value(&construction).unwrap(), json);
670        }
671    }
672
673    /// What a selection fits follows from the shape of what is selected,
674    /// not only its kind.
675    #[test]
676    fn selections_fit_by_shape() {
677        let part = drilled_box();
678        let fits = |selection: Vec<EntityRef>| inspect_selection(&part, &selection).fits;
679
680        let top = fits(vec![face("extrude(box,end)")]);
681        assert!(top.contains(&"offset"), "{top:?}");
682        assert!(!top.contains(&"axis_of"), "{top:?}");
683        // The hole's wall is round, not flat.
684        let wall = fits(vec![face("extrude(hole,hole_sketch,c1)")]);
685        assert_eq!(wall, ["axis_of"]);
686        // A straight edge is a line and an edge; a circular one has a
687        // center and an axis.
688        let straight = fits(vec![edge("extrude(box,outline,c4,end)")]);
689        for method in ["along_line", "edge_point", "tangent", "normal_to_edge"] {
690            assert!(straight.contains(&method), "{method}: {straight:?}");
691        }
692        assert!(!straight.contains(&"center"), "{straight:?}");
693        let rim = fits(vec![edge("extrude(hole,hole_sketch,c1,start)")]);
694        for method in ["center", "axis_of", "edge_point"] {
695            assert!(rim.contains(&method), "{method}: {rim:?}");
696        }
697        assert!(!rim.contains(&"along_line"), "{rim:?}");
698        // In any order.
699        let point_plane = fits(vec![
700            base(WorldAxis::Z),
701            vertex("extrude(box,outline,p2,end)"),
702        ]);
703        for method in ["project_on_plane", "perpendicular", "parallel_plane"] {
704            assert!(point_plane.contains(&method), "{method}: {point_plane:?}");
705        }
706        assert!(
707            fits(vec![
708                EntityRef::Origin,
709                EntityRef::Origin,
710                EntityRef::Origin
711            ])
712            .contains(&"three_points")
713        );
714        assert!(fits(Vec::new()).is_empty());
715        // Nothing fits what the part does not have.
716        let missing = inspect_selection(&part, &[face("nowhere")]);
717        assert!(missing.fits.is_empty());
718        assert_eq!(missing.roles, [Vec::<Role>::new()]);
719    }
720
721    #[test]
722    fn a_selection_that_does_not_fit_says_what_it_needs() {
723        let args = AddDatumArgs {
724            selection: vec![EntityRef::Origin],
725            construction: Construction::Offset { distance: 1.0 },
726        };
727        let Err(e) = AddDatum.apply(Part::<S>::new(), "d", &args) else {
728            panic!("an offset plane from a point");
729        };
730        assert!(e.to_string().contains("needs a plane selected"), "{e}");
731    }
732
733    #[test]
734    fn points() {
735        let part = drilled_box();
736        let corner = vertex("extrude(box,outline,p2,end)");
737        let d = datum(
738            &part,
739            vec![corner.clone()],
740            Construction::Point {
741                x: 0.5,
742                y: 0.0,
743                z: 1.0,
744            },
745        );
746        assert_eq!(d.kind, DatumKind::Point);
747        assert_at(&d.frame, [2.5, 2.0, 2.0], [0., 0., 1.]);
748        // The top's front edge runs from (2, 0, 1) back to (0, 0, 1); a
749        // point on it has its z axis along it.
750        let on_edge = AddDatumArgs {
751            selection: vec![edge("extrude(box,outline,c4,end)")],
752            construction: Construction::EdgePoint { position: 0.25 },
753        };
754        let with_point = AddDatum.apply(part.clone(), "on_edge", &on_edge).unwrap();
755        let d = with_point
756            .datum(with_point.datum_id("on_edge").unwrap())
757            .unwrap();
758        assert_at(&d.frame, [1.5, 0.0, 1.0], [-1., 0., 0.]);
759        // Offset from a datum point: along its own axes.
760        let d = datum(
761            &with_point,
762            vec![EntityRef::Datum {
763                name: "on_edge".into(),
764            }],
765            Construction::Point {
766                x: 0.0,
767                y: 0.0,
768                z: 0.5,
769            },
770        );
771        assert_at(&d.frame, [1.0, 0.0, 1.0], [-1., 0., 0.]);
772
773        let d = datum(
774            &part,
775            vec![EntityRef::Origin, corner.clone()],
776            Construction::Midpoint {},
777        );
778        assert_at(&d.frame, [1.0, 1.0, 0.5], [0., 0., 1.]);
779        let d = datum(
780            &part,
781            vec![edge("extrude(hole,hole_sketch,c1,start)")],
782            Construction::Center {},
783        );
784        assert_at(&d.frame, [1.0, 1.0, 1.0], [0., 0., 1.]);
785        let d = datum(
786            &part,
787            vec![corner.clone(), base(WorldAxis::Z)],
788            Construction::ProjectOnPlane {},
789        );
790        assert_at(&d.frame, [2.0, 2.0, 0.0], [0., 0., 1.]);
791        let d = datum(
792            &part,
793            vec![axis(WorldAxis::X), corner.clone()],
794            Construction::ProjectOnLine {},
795        );
796        assert_at(&d.frame, [2.0, 0.0, 0.0], [1., 0., 0.]);
797        let d = datum(
798            &part,
799            vec![edge("extrude(box,outline,p2)"), base(WorldAxis::Z)],
800            Construction::LinePlane {},
801        );
802        assert_at(&d.frame, [2.0, 2.0, 0.0], [0., 0., 1.]);
803        let d = datum(
804            &part,
805            vec![axis(WorldAxis::Z), edge("extrude(box,outline,c4,start)")],
806            Construction::LineLine {},
807        );
808        assert_at(&d.frame, [0.0, 0.0, 0.0], [0., -1., 0.]);
809        let d = datum(
810            &part,
811            vec![
812                face("extrude(box,end)"),
813                face("extrude(box,outline,c5)"),
814                face("extrude(box,outline,c6)"),
815            ],
816            Construction::ThreePlanes {},
817        );
818        assert_at(&d.frame, [2.0, 2.0, 1.0], [0., 0., 1.]);
819    }
820
821    #[test]
822    fn axes() {
823        let part = drilled_box();
824        let corner = vertex("extrude(box,outline,p2,end)");
825        let d = datum(
826            &part,
827            vec![EntityRef::Origin, corner.clone()],
828            Construction::TwoPoints {},
829        );
830        assert_eq!(d.kind, DatumKind::Axis);
831        assert_at(&d.frame, [0., 0., 0.], [2., 2., 1.]);
832        let d = datum(
833            &part,
834            vec![edge("extrude(box,outline,p1)")],
835            Construction::AlongLine {},
836        );
837        assert_at(&d.frame, [2., 0., 0.], [0., 0., 1.]);
838        // The hole's wall turns around the vertical through its center.
839        let d = datum(
840            &part,
841            vec![face("extrude(hole,hole_sketch,c1#2)")],
842            Construction::AxisOf {},
843        );
844        assert!(datum_line(&d).could_contain(&v(1., 1., 7.)));
845        let d = datum(
846            &part,
847            vec![base(WorldAxis::X), base(WorldAxis::Y)],
848            Construction::PlanePlane {},
849        );
850        assert_at(&d.frame, [0., 0., 0.], [0., 0., 1.]);
851        // The perpendicular dropped from the corner onto the base plane.
852        let d = datum(
853            &part,
854            vec![corner.clone(), base(WorldAxis::Z)],
855            Construction::Perpendicular {},
856        );
857        assert_at(&d.frame, [2., 2., 1.], [0., 0., 1.]);
858        let d = datum(
859            &part,
860            vec![corner.clone(), axis(WorldAxis::X)],
861            Construction::Parallel {},
862        );
863        assert_at(&d.frame, [2., 2., 1.], [1., 0., 0.]);
864        let d = datum(
865            &part,
866            vec![corner.clone(), axis(WorldAxis::X)],
867            Construction::PerpendicularToLine {},
868        );
869        assert_at(&d.frame, [2., 2., 1.], [0., -2., -1.]);
870        let d = datum(
871            &part,
872            vec![axis(WorldAxis::X), axis(WorldAxis::Y)],
873            Construction::Bisector { other: false },
874        );
875        assert_at(&d.frame, [0., 0., 0.], [1., 1., 0.]);
876        let d = datum(
877            &part,
878            vec![axis(WorldAxis::X), axis(WorldAxis::Y)],
879            Construction::Bisector { other: true },
880        );
881        assert_at(&d.frame, [0., 0., 0.], [1., -1., 0.]);
882        // Between two parallel edges: halfway.
883        let d = datum(
884            &part,
885            vec![
886                edge("extrude(box,outline,p0)"),
887                edge("extrude(box,outline,p2)"),
888            ],
889            Construction::Bisector { other: false },
890        );
891        let off = datum_line(&d).project(&v(1., 1., 0.)).sub(&v(1., 1., 0.));
892        assert!(off.norm().to_f64() < 1e-9, "{off:?}");
893        let d = datum(
894            &part,
895            vec![edge("extrude(hole,hole_sketch,c1,start)")],
896            Construction::Tangent { position: 0.0 },
897        );
898        assert_at(&d.frame, [1.4, 1.0, 1.0], [0., 1., 0.]);
899    }
900
901    fn datum_line(d: &Datum<S>) -> geop_core_geometry::shape::Axis<S> {
902        geop_core_geometry::shape::Axis::try_new(*d.frame.origin(), *d.frame.w()).unwrap()
903    }
904
905    #[test]
906    fn planes() {
907        let part = drilled_box();
908        let top = face("extrude(box,end)");
909        let d = datum(
910            &part,
911            vec![top.clone()],
912            Construction::Offset { distance: 0.5 },
913        );
914        assert_eq!(d.kind, DatumKind::Plane);
915        assert_at(&d.frame, [0., 0., 1.5], [0., 0., 1.]);
916        // Between the box's top and bottom, which face apart.
917        let d = datum(
918            &part,
919            vec![top.clone(), face("extrude(box,start)")],
920            Construction::Midplane { other: false },
921        );
922        assert!(on_plane(&d.frame, [1., 1., 0.5]));
923        assert_at(&d.frame, [0., 0., 0.5], [0., 0., 1.]);
924        // Between two planes facing the same way.
925        let lifted = AddDatum
926            .apply(
927                part.clone(),
928                "lifted",
929                &AddDatumArgs {
930                    selection: vec![top.clone()],
931                    construction: Construction::Offset { distance: 1.0 },
932                },
933            )
934            .unwrap();
935        let d = datum(
936            &lifted,
937            vec![
938                top.clone(),
939                EntityRef::Datum {
940                    name: "lifted".into(),
941                },
942            ],
943            Construction::Midplane { other: false },
944        );
945        assert!(on_plane(&d.frame, [5., 5., 1.5]));
946        // Halving the angle between two sides that meet at an edge.
947        let d = datum(
948            &part,
949            vec![
950                face("extrude(box,outline,c4)"),
951                face("extrude(box,outline,c5)"),
952            ],
953            Construction::Midplane { other: false },
954        );
955        assert!(on_plane(&d.frame, [2., 0., 0.]) && on_plane(&d.frame, [1., 1., 0.]));
956        let d = datum(
957            &part,
958            vec![
959                EntityRef::Origin,
960                vertex("extrude(box,outline,p1,start)"),
961                vertex("extrude(box,outline,p2,end)"),
962            ],
963            Construction::ThreePoints {},
964        );
965        assert!(
966            on_plane(&d.frame, [0., 0., 0.])
967                && on_plane(&d.frame, [2., 0., 0.])
968                && on_plane(&d.frame, [2., 2., 1.])
969        );
970        // Hinged on the top's front edge, turned up by 90 degrees: the
971        // front face's plane.
972        let d = datum(
973            &part,
974            vec![top.clone(), edge("extrude(box,outline,c4,end)")],
975            Construction::Angle { angle: 90.0 },
976        );
977        assert!(on_plane(&d.frame, [0.5, 0., 0.]) && on_plane(&d.frame, [0.5, 0., 7.]));
978        let d = datum(
979            &part,
980            vec![axis(WorldAxis::Z), vertex("extrude(box,outline,p1,start)")],
981            Construction::LinePoint {},
982        );
983        assert!(on_plane(&d.frame, [5., 0., 3.]));
984        let d = datum(
985            &part,
986            vec![
987                edge("extrude(box,outline,p0)"),
988                edge("extrude(box,outline,p2)"),
989            ],
990            Construction::TwoLines {},
991        );
992        assert!(on_plane(&d.frame, [1., 1., 3.]));
993        let d = datum(
994            &part,
995            vec![top.clone(), EntityRef::Origin],
996            Construction::ParallelPlane {},
997        );
998        assert_at(&d.frame, [0., 0., 0.], [0., 0., 1.]);
999        let d = datum(
1000            &part,
1001            vec![axis(WorldAxis::Y), vertex("extrude(box,outline,p2,end)")],
1002            Construction::NormalToLine {},
1003        );
1004        assert_at(&d.frame, [2., 2., 1.], [0., 1., 0.]);
1005        let d = datum(
1006            &part,
1007            vec![edge("extrude(hole,hole_sketch,c1,start)")],
1008            Construction::NormalToEdge { position: 0.0 },
1009        );
1010        assert!(on_plane(&d.frame, [1.4, 1., 1.]) && on_plane(&d.frame, [1., 1., 1.]));
1011    }
1012
1013    /// Degenerate selections are refused, saying why.
1014    #[test]
1015    fn degenerate_selections_fail() {
1016        let part = Part::<S>::new();
1017        let origin = EntityRef::Origin;
1018        let err = |selection: Vec<EntityRef>, construction: Construction| {
1019            let args = AddDatumArgs {
1020                selection,
1021                construction,
1022            };
1023            match AddDatum.apply(part.clone(), "d", &args) {
1024                Ok(_) => panic!("{args:?} built a datum"),
1025                Err(e) => format!("{e:?}"),
1026            }
1027        };
1028        assert!(
1029            err(
1030                vec![origin.clone(), origin.clone()],
1031                Construction::TwoPoints {}
1032            )
1033            .contains("coincide")
1034        );
1035        assert!(
1036            err(
1037                vec![base(WorldAxis::Z), base(WorldAxis::Z)],
1038                Construction::PlanePlane {}
1039            )
1040            .contains("parallel")
1041        );
1042        assert!(
1043            err(
1044                vec![origin.clone(), axis(WorldAxis::X)],
1045                Construction::PerpendicularToLine {}
1046            )
1047            .contains("lies on the line")
1048        );
1049        assert!(
1050            err(
1051                vec![base(WorldAxis::Z), axis(WorldAxis::Z)],
1052                Construction::Angle { angle: 10.0 }
1053            )
1054            .contains("perpendicular")
1055        );
1056        assert!(err(vec![edge("nowhere")], Construction::AlongLine {}).contains("nowhere"));
1057    }
1058
1059    /// A datum is a named entity, and a sketch can be placed on a datum
1060    /// plane: its frame, exactly.
1061    #[test]
1062    fn sketches_go_on_datum_planes() {
1063        let program = examples::boss_on_reference_plane();
1064        let part = program.apply(Part::<S>::new()).unwrap();
1065        part.check_names().unwrap();
1066        let placed = part.sketch(part.sketch_id("boss_sketch").unwrap()).unwrap();
1067        let lifted = part.datum(part.datum_id("lifted").unwrap()).unwrap();
1068        assert!(placed.plane.origin().could_be_equal(lifted.frame.origin()));
1069        assert!(placed.plane.u().could_be_equal(lifted.frame.u()));
1070        assert_at(&placed.plane, [0., 0., 1.5], [0., 0., 1.]);
1071    }
1072
1073    /// Offsets are handles: dragging one edits the construction's value.
1074    #[test]
1075    fn offsets_have_handles() {
1076        let mut program = Program::new();
1077        program.push(
1078            "p",
1079            AddDatumArgs {
1080                selection: vec![EntityRef::Origin],
1081                construction: Construction::Point {
1082                    x: 1.0,
1083                    y: 2.0,
1084                    z: 3.0,
1085                },
1086            },
1087        );
1088        let PartOperation::AddDatum(args) = &program.steps[0].operation else {
1089            unreachable!()
1090        };
1091        let handles = AddDatum.handles(&Part::<S>::new(), args).unwrap();
1092        let labels: Vec<&str> = handles.iter().map(|h| h.label.as_str()).collect();
1093        assert_eq!(labels, ["x", "y", "z"]);
1094        let HandleMotion::Linear { arg, value, .. } = &handles[2].motion else {
1095            panic!("a linear handle")
1096        };
1097        assert_eq!(arg, &arg_path(&["construction", "z"]));
1098        assert_eq!(*value, 3.0);
1099    }
1100}