Skip to main content

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