1use 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#[derive(Clone, Copy, Debug, PartialEq)]
31pub enum ParamKind {
32 Number {
34 default: f64,
35 min: f64,
36 max: f64,
37 unit: Unit,
38 },
39 Bool {
40 default: bool,
41 },
42}
43
44#[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#[derive(Clone, Copy, Debug, PartialEq)]
56pub struct ConstructionSchema {
57 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
66macro_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 #[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 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 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 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 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 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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
212pub struct AddDatum;
213
214#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
215pub struct AddDatumArgs {
216 pub selection: Vec<EntityRef>,
219 pub construction: Construction,
222}
223
224fn 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
251pub 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 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
294fn plane_of<S: Scalar>(frame: &CoordinateSystem<S>) -> Plane<S> {
296 Plane {
297 point: *frame.origin(),
298 normal: *frame.w(),
299 }
300}
301
302fn 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
310fn 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
333fn radians<S: Scalar>(degrees: f64) -> S {
335 S::from_f64(degrees.to_radians())
336}
337
338impl Construction {
339 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 pub fn param(&self, name: &str) -> Option<serde_json::Value> {
353 serde_json::to_value(self).ok()?.get(name).cloned()
354 }
355
356 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 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 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 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 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 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 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 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 #[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 #[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 #[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 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 #[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 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 #[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 #[test]
851 fn picks_build_the_selection() {
852 let part = Part::<S>::new();
853 let args = AddDatum.new_args(&part);
854 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 #[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}