Skip to main content

geop_ops_datums/
add_datum.rs

1//! [`AddDatum`]: reference geometry — a point, an axis, a plane or a
2//! coordinate system — built from entities picked in the part, in one of the
3//! ways CAD systems commonly 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 [`Aspects`]).
8//! [`fitting_constructions`] 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, Datum, DatumKind},
15    scalars::Scalar,
16    vector::Vector3,
17    with_context,
18};
19use serde::{Deserialize, Serialize};
20
21use geop_ops::{
22    Part,
23    operation::{Aspects, EntityRef, Operation, Role, frame_along},
24    ui::{Form, Unit},
25};
26
27use crate::editor;
28
29/// What kind of value a construction takes besides its selection.
30#[derive(Clone, Copy, Debug, PartialEq)]
31pub enum ParamKind {
32    /// `min`/`max` bound what a slider offers, not what is valid.
33    Number {
34        default: f64,
35        min: f64,
36        max: f64,
37        unit: Unit,
38    },
39    Bool {
40        default: bool,
41    },
42}
43
44/// A value a construction takes besides its selection.
45#[derive(Clone, Copy, Debug, PartialEq)]
46pub struct Param {
47    pub name: &'static str,
48    pub doc: &'static str,
49    pub kind: ParamKind,
50}
51
52/// One way to build a datum (see [`Construction`]): what it builds, what it
53/// needs selected — one entity per input, in any order — and the values it
54/// takes besides.
55#[derive(Clone, Copy, Debug, PartialEq)]
56pub struct ConstructionSchema {
57    /// How the construction is spelled: `offset`.
58    pub method: &'static str,
59    pub label: &'static str,
60    pub doc: &'static str,
61    pub result: DatumKind,
62    pub inputs: &'static [Role],
63    pub params: &'static [Param],
64}
65
66/// Declares [`Construction`] and [`CONSTRUCTIONS`] from one table, so the
67/// two can't disagree: each construction's variant, method name, label,
68/// the inputs it needs selected, what it builds, what it does, and the
69/// values it takes besides — each with its kind.
70macro_rules! constructions {
71    ($(
72        $variant:ident $method:literal $label:literal [$($role:ident),*] -> $result:ident,
73        $doc:literal {
74            $($param:ident: $pty:ty = $pkind:ident { $($kfield:ident: $kval:expr),* }, $pdoc:literal;)*
75        }
76    )*) => {
77        /// How a datum is built from its selection: one variant per way,
78        /// with the values it takes besides. Serialized with its method:
79        /// `{"method": "offset", "distance": 1.0}`.
80        #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
81        #[serde(tag = "method")]
82        pub enum Construction {
83            $(
84                #[doc = $doc]
85                #[serde(rename = $method)]
86                $variant { $(#[doc = $pdoc] $param: $pty),* },
87            )*
88        }
89
90        /// Every [`Construction`], described for an editor.
91        pub const CONSTRUCTIONS: &[ConstructionSchema] = &[$(
92            ConstructionSchema {
93                method: $method,
94                label: $label,
95                doc: $doc,
96                result: DatumKind::$result,
97                inputs: &[$(Role::$role),*],
98                params: &[$(Param {
99                    name: stringify!($param),
100                    doc: $pdoc,
101                    kind: ParamKind::$pkind { $($kfield: $kval),* },
102                }),*],
103            },
104        )*];
105
106        impl Construction {
107            /// How it is described among [`CONSTRUCTIONS`].
108            pub fn schema(&self) -> &'static ConstructionSchema {
109                let method = match self {
110                    $(Construction::$variant { .. } => $method,)*
111                };
112                CONSTRUCTIONS
113                    .iter()
114                    .find(|c| c.method == method)
115                    .expect("every construction is described")
116            }
117        }
118    };
119}
120
121constructions! {
122    // ── points ──
123    Point "point" "Point" [Point] -> Point,
124    "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." {
125        x: f64 = Number { default: 0.0, min: -10.0, max: 10.0, unit: Unit::Length }, "How far along x.";
126        y: f64 = Number { default: 0.0, min: -10.0, max: 10.0, unit: Unit::Length }, "How far along y.";
127        z: f64 = Number { default: 0.0, min: -10.0, max: 10.0, unit: Unit::Length }, "How far along z.";
128    }
129    Midpoint "midpoint" "Midpoint" [Point, Point] -> Point,
130    "The point halfway between two points." {}
131    EdgePoint "edge_point" "Point on edge" [Edge] -> Point,
132    "A point along an edge, its z axis along the edge." {
133        position: f64 = Number { default: 0.5, min: 0.0, max: 1.0, unit: Unit::Fraction }, "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    Center "center" "Center" [Circle] -> Point,
136    "The center of a circular edge, its z axis the one the arc turns around." {}
137    ProjectOnPlane "project_on_plane" "Projection onto plane" [Point, Plane] -> Point,
138    "The foot of the perpendicular dropped from a point onto a plane." {}
139    ProjectOnLine "project_on_line" "Projection onto line" [Point, Line] -> Point,
140    "The foot of the perpendicular dropped from a point onto a line." {}
141    LinePlane "line_plane" "Line meets plane" [Line, Plane] -> Point,
142    "Where a line pierces a plane." {}
143    LineLine "line_line" "Lines meet" [Line, Line] -> Point,
144    "Where two lines cross — or, if they miss each other, halfway between where they come closest. Its z axis is normal to both." {}
145    ThreePlanes "three_planes" "Three planes meet" [Plane, Plane, Plane] -> Point,
146    "The one point three planes share." {}
147
148    // ── axes ──
149    TwoPoints "two_points" "Line through points" [Point, Point] -> Axis,
150    "The line from one point through another." {}
151    AlongLine "along_line" "Along line" [Line] -> Axis,
152    "The line a straight edge or an axis runs along." {}
153    AxisOf "axis_of" "Axis of arc or cylinder" [Round] -> Axis,
154    "The axis a circular edge, or a cylindrical, conical or spherical face, turns around." {}
155    PlanePlane "plane_plane" "Two planes meet" [Plane, Plane] -> Axis,
156    "The line two planes meet in." {}
157    Perpendicular "perpendicular" "Perpendicular to plane" [Point, Plane] -> Axis,
158    "The perpendicular dropped from a point onto a plane: the line through the point along the plane's normal." {}
159    Parallel "parallel" "Parallel through point" [Point, Line] -> Axis,
160    "The line through a point parallel to a line." {}
161    PerpendicularToLine "perpendicular_to_line" "Perpendicular to line" [Point, Line] -> Axis,
162    "The perpendicular dropped from a point onto a line: from the point to its foot on the line." {}
163    Bisector "bisector" "Angle bisector" [Line, Line] -> Axis,
164    "The line halving the angle between two crossing lines, through where they cross — or, between parallel lines, the line halfway between them." {
165        other: bool = Bool { default: false }, "Halve the other angle: the one between the first line and the second one reversed.";
166    }
167    Tangent "tangent" "Tangent to edge" [Edge] -> Axis,
168    "The tangent to an edge at a point along it." {
169        position: f64 = Number { default: 0.5, min: 0.0, max: 1.0, unit: Unit::Fraction }, "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.";
170    }
171
172    // ── planes ──
173    Offset "offset" "Offset plane" [Plane] -> Plane,
174    "A plane parallel to the selected one, a distance along its normal." {
175        distance: f64 = Number { default: 1.0, min: -10.0, max: 10.0, unit: Unit::Length }, "How far along the plane's normal; backwards if negative.";
176    }
177    Midplane "midplane" "Midplane" [Plane, Plane] -> Plane,
178    "The plane halfway between two parallel planes, or halving the angle between two that meet." {
179        other: bool = Bool { default: false }, "For planes that meet: halve the other angle between them.";
180    }
181    ThreePoints "three_points" "Plane through points" [Point, Point, Point] -> Plane,
182    "The plane through three points." {}
183    Angle "angle" "Plane at angle" [Plane, Line] -> Plane,
184    "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." {
185        angle: f64 = Number { default: 45.0, min: -180.0, max: 180.0, unit: Unit::Angle }, "How far to turn, in degrees, right-handed about the line's direction.";
186    }
187    LinePoint "line_point" "Plane through line and point" [Line, Point] -> Plane,
188    "The plane through a line and a point off it." {}
189    TwoLines "two_lines" "Plane through lines" [Line, Line] -> Plane,
190    "The plane two crossing or parallel lines lie in — for lines that miss each other, the plane through the first parallel to the second." {}
191    ParallelPlane "parallel_plane" "Parallel plane through point" [Plane, Point] -> Plane,
192    "The plane through a point parallel to a plane." {}
193    NormalToLine "normal_to_line" "Plane normal to line" [Line, Point] -> Plane,
194    "The plane through a point perpendicular to a line." {}
195    NormalToEdge "normal_to_edge" "Plane normal to edge" [Edge] -> Plane,
196    "The plane perpendicular to an edge at a point along it." {
197        position: f64 = Number { default: 0.5, min: 0.0, max: 1.0, unit: Unit::Fraction }, "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.";
198    }
199
200    // ── coordinate systems ──
201    FrameThreePoints "frame_three_points" "Coordinate system through points" [Point, Point, Point] -> Frame,
202    "The coordinate system at the first point, its x axis towards the second and its xy plane through the third." {}
203}
204
205/// Adds a datum to the part, named by the operation's id: a point, an
206/// axis, a plane or a coordinate system built from the selected entities by
207/// one of the
208/// [`Construction`]s. Like a sketch's plane, it is built when the step runs
209/// and stays where it was, whatever later steps do to what it was built
210/// from.
211#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
212pub struct AddDatum;
213
214#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
215pub struct AddDatumArgs {
216    /// What it is built from: points, edges, faces and other references,
217    /// picked in the viewport.
218    pub selection: Vec<EntityRef>,
219    /// How it is built from them; only the ways that fit what is selected
220    /// can be chosen.
221    pub construction: Construction,
222}
223
224/// Which selected entity fills each of `inputs`, in order: one entity per
225/// input, each able to fill its role, and of all such assignments the first
226/// in selection order. `None` if there is none.
227fn assign<S: Scalar>(inputs: &[Role], selection: &[Aspects<S>]) -> Option<Vec<usize>> {
228    fn extend<S: Scalar>(
229        inputs: &[Role],
230        selection: &[Aspects<S>],
231        chosen: &mut Vec<usize>,
232    ) -> bool {
233        let Some(role) = inputs.get(chosen.len()) else {
234            return true;
235        };
236        for (i, geometry) in selection.iter().enumerate() {
237            if !chosen.contains(&i) && role.fits(geometry) {
238                chosen.push(i);
239                if extend(inputs, selection, chosen) {
240                    return true;
241                }
242                chosen.pop();
243            }
244        }
245        false
246    }
247    let mut chosen = Vec::new();
248    (inputs.len() == selection.len() && extend(inputs, selection, &mut chosen)).then_some(chosen)
249}
250
251/// The method of every construction that fits `selection` in `part` — by
252/// the same matching a step applies. None, if the part lacks an entity of
253/// it.
254pub fn fitting_constructions<S: Scalar>(
255    part: &Part<S>,
256    selection: &[EntityRef],
257) -> Vec<&'static str> {
258    let Ok(resolved) = selection
259        .iter()
260        .map(|e| Aspects::of(e, part))
261        .collect::<GeopResult<Vec<_>>>()
262    else {
263        return Vec::new();
264    };
265    CONSTRUCTIONS
266        .iter()
267        .filter(|c| assign(c.inputs, &resolved).is_some())
268        .map(|c| c.method)
269        .collect()
270}
271
272impl AddDatumArgs {
273    /// The selection resolved in `part` and ordered as the construction
274    /// takes it.
275    pub(crate) fn inputs<S: Scalar>(&self, part: &Part<S>) -> GeopResult<Vec<Aspects<S>>> {
276        let schema = self.construction.schema();
277        let resolved = self
278            .selection
279            .iter()
280            .map(|e| Aspects::of(e, part))
281            .collect::<GeopResult<Vec<_>>>()?;
282        let Some(order) = assign(schema.inputs, &resolved) else {
283            let needs: Vec<&str> = schema.inputs.iter().map(|&r| r.describe()).collect();
284            return Err(GeopError::new(format!(
285                "{} needs {} selected, one each, and nothing else",
286                schema.label,
287                needs.join(" and ")
288            )));
289        };
290        Ok(order.into_iter().map(|i| resolved[i].clone()).collect())
291    }
292}
293
294/// The plane of a frame whose `w` is its normal.
295fn plane_of<S: Scalar>(frame: &CoordinateSystem<S>) -> Plane<S> {
296    Plane {
297        point: *frame.origin(),
298        normal: *frame.w(),
299    }
300}
301
302/// `frame`'s axes, at `origin`.
303fn moved<S: Scalar>(
304    frame: &CoordinateSystem<S>,
305    origin: Vector3<S>,
306) -> GeopResult<CoordinateSystem<S>> {
307    CoordinateSystem::try_new(origin, *frame.u(), *frame.v(), *frame.w())
308}
309
310/// The point `position` of the way along the edge `edge` (see
311/// [`Construction::EdgePoint`]), and the unit tangent there.
312fn along_edge<S: Scalar>(edge: &Aspects<S>, position: f64) -> GeopResult<(Vector3<S>, Vector3<S>)> {
313    if !(0.0..=1.0).contains(&position) {
314        return Err(GeopError::new(format!(
315            "position {position} is not along the edge: it must be from 0 to 1"
316        )));
317    }
318    let curve = edge.curve.as_ref().expect("an edge has a curve");
319    let (t0, t1) = curve.domain();
320    let fraction = S::from_f64(position);
321    if let Some(line) = &edge.line {
322        let (a, b) = (curve.evaluate(t0)?, curve.evaluate(t1)?);
323        return Ok((Vector3::interpolate(&a, &b, fraction), line.direction));
324    }
325    if let Some(arc) = &edge.arc {
326        let p = arc.point_at(position)?;
327        return Ok((p, arc.tangent_at(&p)?));
328    }
329    let t = S::interpolate(t0, t1, fraction);
330    Ok((curve.evaluate(t)?, curve.tangent(t)?.normalize()?))
331}
332
333/// `x` degrees, in radians.
334fn radians<S: Scalar>(degrees: f64) -> S {
335    S::from_f64(degrees.to_radians())
336}
337
338impl Construction {
339    /// The construction `schema` describes, every value at its default.
340    pub fn default_of(schema: &ConstructionSchema) -> Self {
341        let mut json = serde_json::json!({ "method": schema.method });
342        for param in schema.params {
343            json[param.name] = match param.kind {
344                ParamKind::Number { default, .. } => default.into(),
345                ParamKind::Bool { default } => default.into(),
346            };
347        }
348        serde_json::from_value(json).expect("every construction reads from its schema")
349    }
350
351    /// The value it takes named `name`, as it serializes.
352    pub fn param(&self, name: &str) -> Option<serde_json::Value> {
353        serde_json::to_value(self).ok()?.get(name).cloned()
354    }
355
356    /// It with the value named `name` set to `value` — `None` if it takes
357    /// no such value, or not of that kind.
358    pub fn with_param(&self, name: &str, value: serde_json::Value) -> Option<Self> {
359        let mut json = serde_json::to_value(self).ok()?;
360        json.get(name)?;
361        json[name] = value;
362        serde_json::from_value(json).ok()
363    }
364
365    /// The frame it builds from `inputs`, ordered as it takes them (see
366    /// [`AddDatumArgs::inputs`]).
367    pub(crate) fn build<S: Scalar>(
368        &self,
369        inputs: &[Aspects<S>],
370    ) -> GeopResult<CoordinateSystem<S>> {
371        let point = |i: usize| inputs[i].point.expect("assigned a point");
372        let line = |i: usize| inputs[i].line.clone().expect("assigned a line");
373        let frame = |i: usize| inputs[i].plane.clone().expect("assigned a plane");
374        let plane = |i: usize| plane_of(&frame(i));
375        let half = S::ONE.div(S::TWO)?;
376        match self {
377            Construction::Point { x, y, z } => {
378                let p = point(0);
379                let base = match &inputs[0].frame {
380                    Some(frame) => frame.clone(),
381                    None => CoordinateSystem::world_at(p),
382                };
383                let offset = base.to_xyz(&Vector3::from_array([*x, *y, *z].map(S::from_f64)));
384                moved(&base, offset)
385            }
386            Construction::Midpoint {} => Ok(CoordinateSystem::world_at(Vector3::interpolate(
387                &point(0),
388                &point(1),
389                half,
390            ))),
391            Construction::EdgePoint { position } => {
392                let (p, tangent) = along_edge(&inputs[0], *position)?;
393                frame_along(p, &tangent)
394            }
395            Construction::Center {} => {
396                let arc = inputs[0].arc.clone().expect("assigned an arc");
397                let c = arc.circle;
398                let u = arc.start.sub(&c.center).normalize()?;
399                CoordinateSystem::try_new(c.center, u, c.normal.prod_cross(&u), c.normal)
400            }
401            Construction::ProjectOnPlane {} => {
402                let foot = plane(1).project(&point(0));
403                moved(&frame(1), foot)
404            }
405            Construction::ProjectOnLine {} => {
406                let l = line(1);
407                frame_along(l.project(&point(0)), &l.direction)
408            }
409            Construction::LinePlane {} => {
410                let at = plane(1).intersect_axis(&line(0))?;
411                moved(&frame(1), at)
412            }
413            Construction::LineLine {} => {
414                let (a, b) = (line(0), line(1));
415                let at = a.nearest(&b)?;
416                let w = a.direction.prod_cross(&b.direction).normalize()?;
417                CoordinateSystem::try_new(at, a.direction, w.prod_cross(&a.direction), w)
418            }
419            Construction::ThreePlanes {} => {
420                let meet = plane(0).intersect_plane(&plane(1))?;
421                Ok(CoordinateSystem::world_at(plane(2).intersect_axis(&meet)?))
422            }
423            Construction::TwoPoints {} => {
424                let (a, b) = (point(0), point(1));
425                let d = b.sub(&a);
426                if d.norm_sq().could_be_equal(S::ZERO) {
427                    return Err(GeopError::new(
428                        "the points coincide: no line runs through both",
429                    ));
430                }
431                frame_along(a, &d)
432            }
433            Construction::AlongLine {} => {
434                let l = line(0);
435                frame_along(l.point, &l.direction)
436            }
437            Construction::AxisOf {} => {
438                let axis = inputs[0].round.clone().expect("assigned something round");
439                frame_along(axis.point, &axis.direction)
440            }
441            Construction::PlanePlane {} => {
442                let meet = plane(0).intersect_plane(&plane(1))?;
443                frame_along(meet.point, &meet.direction)
444            }
445            Construction::Perpendicular {} => frame_along(point(0), &plane(1).normal),
446            Construction::Parallel {} => frame_along(point(0), &line(1).direction),
447            Construction::PerpendicularToLine {} => {
448                let p = point(0);
449                let d = line(1).project(&p).sub(&p);
450                if d.norm_sq().could_be_equal(S::ZERO) {
451                    return Err(GeopError::new(
452                        "the point lies on the line: there is no perpendicular to drop",
453                    ));
454                }
455                frame_along(p, &d)
456            }
457            Construction::Bisector { other } => {
458                let (a, b) = (line(0), line(1));
459                if a.could_be_parallel(&b) {
460                    let mid = Vector3::interpolate(&a.point, &b.project(&a.point), half);
461                    return frame_along(mid, &a.direction);
462                }
463                let at = a.nearest(&b)?;
464                let second = if *other {
465                    b.direction.neg()
466                } else {
467                    b.direction
468                };
469                frame_along(at, &a.direction.add(&second))
470            }
471            Construction::Tangent { position } => {
472                let (p, tangent) = along_edge(&inputs[0], *position)?;
473                frame_along(p, &tangent)
474            }
475            Construction::Offset { distance } => {
476                let f = frame(0);
477                moved(
478                    &f,
479                    f.origin().add(&f.w().prod_scalar(S::from_f64(*distance))),
480                )
481            }
482            Construction::Midplane { other } => {
483                // Every point as far in front of one plane as behind the
484                // other (`inner`: halving the angle between them, or the
485                // gap between parallel planes facing apart), or as far in
486                // front of both (`outer`): with `d_k = n_k . p_k`, the
487                // planes `(n1 -+ n2) . x = d1 -+ d2`. Neither needs the line
488                // the planes meet in, which runs off to infinity as they
489                // turn parallel.
490                let (p, q) = (plane(0), plane(1));
491                let (d1, d2) = (p.point.prod_dot(&p.normal), q.point.prod_dot(&q.normal));
492                let inner = (p.normal.sub(&q.normal), d1.sub(d2));
493                let outer = (p.normal.add(&q.normal), d1.add(d2));
494                let (first, second) = if *other {
495                    (outer, inner)
496                } else {
497                    (inner, outer)
498                };
499                // One of the two is only degenerate for parallel planes,
500                // where the other is the plane halfway between them.
501                let (normal, offset) = if first.0.norm_sq().could_be_equal(S::ZERO) {
502                    second
503                } else {
504                    first
505                };
506                let n2 = normal.norm_sq();
507                let midplane = Plane::try_new(normal.prod_scalar(offset.div(n2)?), normal)?;
508                let near = Vector3::interpolate(frame(0).origin(), frame(1).origin(), half);
509                frame_along(midplane.project(&near), &midplane.normal)
510            }
511            Construction::ThreePoints {} => {
512                let (a, b, c) = (point(0), point(1), point(2));
513                let n = b.sub(&a).prod_cross(&c.sub(&a));
514                if n.norm_sq().could_be_equal(S::ZERO) {
515                    return Err(GeopError::new(
516                        "the points lie on one line: every plane through that line runs through them",
517                    ));
518                }
519                frame_along(a, &n)
520            }
521            Construction::Angle { angle } => {
522                let (n, l) = (plane(0).normal, line(1));
523                // The selected plane's normal, turned square to the line: the
524                // normal of the plane through the line most nearly parallel
525                // to the selected one.
526                let square = n.sub(&l.direction.prod_scalar(l.direction.prod_dot(&n)));
527                if square.norm_sq().could_be_equal(S::ZERO) {
528                    return Err(GeopError::new(
529                        "the line is perpendicular to the plane: every plane through it is at right angles to it",
530                    ));
531                }
532                let a: S = radians(*angle);
533                let normal = square
534                    .prod_scalar(a.cos())
535                    .add(&l.direction.prod_cross(&square).prod_scalar(a.sin()));
536                frame_along(l.point, &normal)
537            }
538            Construction::LinePoint {} => {
539                let l = line(0);
540                let n = l.direction.prod_cross(&point(1).sub(&l.point));
541                if n.norm_sq().could_be_equal(S::ZERO) {
542                    return Err(GeopError::new(
543                        "the point lies on the line: every plane through the line runs through it",
544                    ));
545                }
546                frame_along(l.point, &n)
547            }
548            Construction::TwoLines {} => {
549                let (a, b) = (line(0), line(1));
550                let n = if a.could_be_parallel(&b) {
551                    let n = a.direction.prod_cross(&b.point.sub(&a.point));
552                    if n.norm_sq().could_be_equal(S::ZERO) {
553                        return Err(GeopError::new(
554                            "the lines coincide: every plane through one runs through the other",
555                        ));
556                    }
557                    n
558                } else {
559                    a.direction.prod_cross(&b.direction)
560                };
561                frame_along(a.point, &n)
562            }
563            Construction::ParallelPlane {} => {
564                let f = frame(0);
565                let d = plane(0).signed_distance(&point(1));
566                moved(&f, f.origin().add(&f.w().prod_scalar(d)))
567            }
568            Construction::NormalToLine {} => frame_along(point(1), &line(0).direction),
569            Construction::NormalToEdge { position } => {
570                let (p, tangent) = along_edge(&inputs[0], *position)?;
571                frame_along(p, &tangent)
572            }
573            Construction::FrameThreePoints {} => {
574                let (a, b, c) = (point(0), point(1), point(2));
575                let x = b.sub(&a);
576                let z = x.prod_cross(&c.sub(&a));
577                if z.norm_sq().could_be_equal(S::ZERO) {
578                    return Err(GeopError::new(
579                        "the points lie on one line: they pick out no plane for x and y",
580                    ));
581                }
582                let (u, w) = (x.normalize()?, z.normalize()?);
583                CoordinateSystem::try_new(a, u, w.prod_cross(&u), w)
584            }
585        }
586    }
587}
588
589impl Operation for AddDatum {
590    type Args = AddDatumArgs;
591    type Session = ();
592
593    /// Nothing selected yet, and the first construction.
594    fn new_args<S: Scalar>(&self, _before: &Part<S>) -> AddDatumArgs {
595        AddDatumArgs {
596            selection: Vec::new(),
597            construction: Construction::default_of(&CONSTRUCTIONS[0]),
598        }
599    }
600
601    fn apply<S: Scalar>(
602        &self,
603        mut part: Part<S>,
604        operation_id: &str,
605        args: &AddDatumArgs,
606    ) -> GeopResult<Part<S>> {
607        let ctx = with_context!("add_datum({operation_id}, {args:?})");
608        let frame = args
609            .construction
610            .build(&args.inputs(&part).with_context(ctx)?)
611            .with_context(ctx)?;
612        let datum = Datum {
613            kind: args.construction.schema().result,
614            frame,
615        };
616        part.add_datum(datum, operation_id).with_context(ctx)?;
617        Ok(part)
618    }
619
620    /// Picking the selection, choosing among the constructions that fit
621    /// it, and offsets as handles: see [`crate::editor`].
622    fn form<'a, S: Scalar>(
623        &self,
624        before: &'a Part<S>,
625        args: &AddDatumArgs,
626        _: &(),
627        _: &[String],
628    ) -> Form<'a, S, AddDatumArgs> {
629        editor::form(before, args)
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use geop_core_math::{
636        primitives::{DatumComponent, FrameAxis, Ray},
637        scalars::ScalInF64 as S,
638    };
639
640    use geop_ops::{
641        ORIGIN, Operations,
642        ui::{
643            Button, Control, PartView, Pointer, Presentation, Reach, Shape, StepEditEvent,
644            StepEditor, Tone, Value,
645        },
646    };
647
648    use super::*;
649
650    fn edge(name: &str) -> EntityRef {
651        EntityRef::Edge { name: name.into() }
652    }
653    fn origin() -> EntityRef {
654        EntityRef::datum(ORIGIN)
655    }
656    fn axis(axis: FrameAxis) -> EntityRef {
657        EntityRef::datum_component(ORIGIN, DatumComponent::Axis(axis))
658    }
659    fn base(normal: FrameAxis) -> EntityRef {
660        EntityRef::datum_component(ORIGIN, DatumComponent::Plane(normal))
661    }
662    fn v(p: [f64; 3]) -> Vector3<S> {
663        Vector3::from_array(p.map(S::from_f64))
664    }
665    /// A pointer from `origin` along `dir`, reaching a hundredth of a unit.
666    fn pointer(origin: [f64; 3], dir: [f64; 3]) -> Pointer<S> {
667        Pointer {
668            ray: Ray::try_new(v(origin), v(dir)).unwrap(),
669            reach: Reach::Tube {
670                radius: S::from_f64(0.01),
671            },
672        }
673    }
674
675    /// Every construction serializes under its own method, with exactly the
676    /// values its schema lists, and back.
677    #[test]
678    fn constructions_serialize_as_described() {
679        for schema in CONSTRUCTIONS {
680            let mut json = serde_json::json!({ "method": schema.method });
681            for param in schema.params {
682                json[param.name] = match param.kind {
683                    ParamKind::Number { default, .. } => default.into(),
684                    ParamKind::Bool { default } => default.into(),
685                };
686            }
687            let construction: Construction = serde_json::from_value(json.clone())
688                .unwrap_or_else(|e| panic!("{}: {e}", schema.method));
689            assert_eq!(construction.schema(), schema);
690            assert_eq!(serde_json::to_value(&construction).unwrap(), json);
691            assert_eq!(Construction::default_of(schema), construction);
692        }
693    }
694
695    #[test]
696    fn a_selection_that_does_not_fit_says_what_it_needs() {
697        let args = AddDatumArgs {
698            selection: vec![origin()],
699            construction: Construction::Offset { distance: 1.0 },
700        };
701        let Err(e) = AddDatum.apply(Part::<S>::new(), "d", &args) else {
702            panic!("an offset plane from a point");
703        };
704        assert!(e.to_string().contains("needs a plane selected"), "{e}");
705    }
706
707    /// Degenerate selections are refused, saying why.
708    #[test]
709    fn degenerate_selections_fail() {
710        let part = Part::<S>::new();
711        let origin = origin();
712        let err = |selection: Vec<EntityRef>, construction: Construction| {
713            let args = AddDatumArgs {
714                selection,
715                construction,
716            };
717            match AddDatum.apply(part.clone(), "d", &args) {
718                Ok(_) => panic!("{args:?} built a datum"),
719                Err(e) => format!("{e:?}"),
720            }
721        };
722        assert!(
723            err(
724                vec![origin.clone(), origin.clone()],
725                Construction::TwoPoints {}
726            )
727            .contains("coincide")
728        );
729        assert!(
730            err(
731                vec![base(FrameAxis::Z), base(FrameAxis::Z)],
732                Construction::PlanePlane {}
733            )
734            .contains("parallel")
735        );
736        assert!(
737            err(
738                vec![origin.clone(), axis(FrameAxis::X)],
739                Construction::PerpendicularToLine {}
740            )
741            .contains("lies on the line")
742        );
743        assert!(
744            err(
745                vec![base(FrameAxis::Z), axis(FrameAxis::Z)],
746                Construction::Angle { angle: 10.0 }
747            )
748            .contains("perpendicular")
749        );
750        assert!(err(vec![edge("nowhere")], Construction::AlongLine {}).contains("nowhere"));
751    }
752
753    /// The one operation, as a set an editor edits.
754    #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Operations)]
755    #[serde(tag = "operation", content = "args", rename_all = "snake_case")]
756    enum Ops {
757        AddDatum(AddDatumArgs),
758    }
759
760    /// A datum step edited as an editor drives it — `new`, starting with a
761    /// pick — and what it shows after `events`.
762    fn edited(
763        part: &Part<S>,
764        args: AddDatumArgs,
765        new: bool,
766        events: &[StepEditEvent<S>],
767    ) -> (AddDatumArgs, Presentation<S>) {
768        let view = PartView::of(part).unwrap();
769        let mut editor = StepEditor::new(Ops::AddDatum(args), part, new);
770        for event in events {
771            editor.handle(part, &view, event);
772        }
773        let Ops::AddDatum(args) = editor.step().clone();
774        (args, editor.presentation(part))
775    }
776
777    /// Offsets are handles: dragging one along its direction edits the
778    /// construction's value.
779    #[test]
780    fn offsets_are_dragged() {
781        let args = AddDatumArgs {
782            selection: vec![origin()],
783            construction: Construction::Point {
784                x: 1.0,
785                y: 2.0,
786                z: 3.0,
787            },
788        };
789        let part = Part::<S>::new();
790        let handles = edited(&part, args.clone(), false, &[]).1.visuals;
791        let keys: Vec<&str> = handles.iter().map(|h| h.key.as_str()).collect();
792        assert_eq!(keys, ["x", "y", "z"]);
793        let Shape::Handle { at, direction } = handles[2].shape else {
794            panic!("a handle");
795        };
796        assert_eq!(direction, v([0.0, 0.0, 1.0]));
797        assert!(at.could_be_equal(&v([1.0, 2.0, 3.3])), "{at:?}");
798        // Seen from the side, dragged half a unit up.
799        let side = |z: f64| pointer([1.0, -10.0, z], [0.0, 1.0, 0.0]);
800        let drag = StepEditEvent::Drag {
801            from: side(3.3),
802            to: side(3.8),
803            done: true,
804        };
805        let (dragged, _) = edited(&part, args, false, &[drag]);
806        assert_eq!(
807            dragged.construction,
808            Construction::Point {
809                x: 1.0,
810                y: 2.0,
811                z: 3.5
812            }
813        );
814    }
815
816    /// The dialog says what each selected entity can be used as, and offers
817    /// only the constructions that fit the selection — for a step that does
818    /// not build too.
819    #[test]
820    fn dialogs_say_what_a_selection_fits() {
821        let args = AddDatumArgs {
822            selection: vec![origin(), edge("nowhere")],
823            construction: Construction::Offset { distance: 1.0 },
824        };
825        let part = Part::<S>::new();
826        assert!(AddDatum.apply(part.clone(), "d", &args).is_err());
827        let dialog = edited(&part, args.clone(), false, &[]).1.dialog;
828        let Some(Control::Reference(selection)) = dialog.get("selection") else {
829            panic!("the selection is a reference field");
830        };
831        assert_eq!(selection.value[0].detail.as_deref(), Some("point"));
832        assert_eq!(selection.value[1].tone, Tone::Error);
833        assert!(dialog.get("construction_needs").is_some());
834
835        let args = AddDatumArgs {
836            selection: vec![base(FrameAxis::Z)],
837            ..args
838        };
839        let dialog = edited(&part, args, false, &[]).1.dialog;
840        let Some(Control::Actions { actions }) = dialog.get("construction") else {
841            panic!("the constructions are actions");
842        };
843        let enabled = |method: &str| actions.iter().find(|a| a.value == method).unwrap().enabled;
844        assert!(enabled("offset"));
845        assert!(!enabled("midpoint"));
846    }
847
848    /// Picking in the viewport adds to the selection, and again takes out;
849    /// the construction follows what fits.
850    #[test]
851    fn picks_build_the_selection() {
852        let part = Part::<S>::new();
853        let args = AddDatum.new_args(&part);
854        // The origin's ball, from above.
855        let click = StepEditEvent::Click {
856            pointer: pointer([0.0, 0.0, 10.0], [0.0, 0.0, -1.0]),
857            button: Button::Primary,
858            double: false,
859            shift: false,
860        };
861        let (once, shown) = edited(&part, args.clone(), true, std::slice::from_ref(&click));
862        assert_eq!(once.selection, [origin()]);
863        assert!(shown.pickable.contains(&Role::Point));
864        let (twice, _) = edited(&part, args, true, &[click.clone(), click]);
865        assert!(twice.selection.is_empty());
866    }
867
868    /// Entities are taken out of the selection in its field, one or all —
869    /// by the editor, as for any reference field; the construction follows.
870    #[test]
871    fn selections_are_edited_in_their_field() {
872        let part = Part::<S>::new();
873        let args = AddDatumArgs {
874            selection: vec![origin(), axis(FrameAxis::X)],
875            construction: Construction::Perpendicular {},
876        };
877        let field = |value| StepEditEvent::Dialog {
878            key: "selection".into(),
879            value,
880        };
881        let (removed, _) = edited(&part, args.clone(), false, &[field(Value::RemoveAt(0))]);
882        assert_eq!(removed.selection, [axis(FrameAxis::X)]);
883        assert_eq!(removed.construction.schema().method, "along_line");
884        let (cleared, _) = edited(&part, args, false, &[field(Value::Clear)]);
885        assert!(cleared.selection.is_empty());
886    }
887}