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