Another project
1use bone_document::{Sketch, SketchEntity};
2use bone_types::{Length, Point2, SketchEntityId};
3use uom::si::length::millimeter;
4
5#[derive(Copy, Clone, Debug, PartialEq)]
6pub enum SnapKind {
7 Endpoint(SketchEntityId),
8 Midpoint(SketchEntityId),
9 Tangent(SketchEntityId),
10 Horizontal,
11 Vertical,
12}
13
14#[derive(Copy, Clone, Debug, PartialEq)]
15pub struct SnapHit {
16 pub kind: SnapKind,
17 pub world: Point2,
18}
19
20#[derive(Copy, Clone, Debug, PartialEq)]
21pub struct Anchor {
22 pub pos: Point2,
23 pub id: Option<SketchEntityId>,
24}
25
26#[must_use]
27pub fn detect(
28 cursor: Point2,
29 anchor: Option<Anchor>,
30 sketch: &Sketch,
31 tolerance: Length,
32) -> Option<SnapHit> {
33 let tol = finite_tol(tolerance)?;
34 endpoint_snap(cursor, anchor, sketch, tol)
35 .or_else(|| midpoint_snap(cursor, anchor, sketch, tol))
36 .or_else(|| anchor.and_then(|a| tangent_snap(cursor, a.pos, sketch, tol)))
37 .or_else(|| anchor.and_then(|a| axis_snap(cursor, a.pos, tol)))
38}
39
40#[must_use]
41pub fn detect_endpoint_only(cursor: Point2, sketch: &Sketch, tolerance: Length) -> Option<SnapHit> {
42 endpoint_snap(cursor, None, sketch, finite_tol(tolerance)?)
43}
44
45fn finite_tol(tolerance: Length) -> Option<f64> {
46 let tol = tolerance.get::<millimeter>();
47 (tol.is_finite() && tol > 0.0).then_some(tol)
48}
49
50fn endpoint_snap(
51 cursor: Point2,
52 anchor: Option<Anchor>,
53 sketch: &Sketch,
54 tol: f64,
55) -> Option<SnapHit> {
56 let anchor_id = anchor.and_then(|a| a.id);
57 sketch
58 .entity_order()
59 .iter()
60 .filter_map(|id| match sketch.entities().get(*id) {
61 Some(SketchEntity::Point(p)) => Some((*id, p.at())),
62 _ => None,
63 })
64 .filter(|(id, _)| anchor_id != Some(*id))
65 .filter_map(|(id, at)| {
66 let d = distance(at, cursor);
67 (d <= tol).then_some((d, id, at))
68 })
69 .min_by(|a, b| a.0.total_cmp(&b.0))
70 .map(|(_, id, at)| SnapHit {
71 kind: SnapKind::Endpoint(id),
72 world: at,
73 })
74}
75
76fn midpoint_snap(
77 cursor: Point2,
78 anchor: Option<Anchor>,
79 sketch: &Sketch,
80 tol: f64,
81) -> Option<SnapHit> {
82 let anchor_id = anchor.and_then(|a| a.id);
83 sketch
84 .entity_order()
85 .iter()
86 .filter_map(|id| line_midpoint(sketch, *id, anchor_id).map(|mid| (*id, mid)))
87 .filter_map(|(id, mid)| {
88 let d = distance(mid, cursor);
89 (d <= tol).then_some((d, id, mid))
90 })
91 .min_by(|a, b| a.0.total_cmp(&b.0))
92 .map(|(_, id, mid)| SnapHit {
93 kind: SnapKind::Midpoint(id),
94 world: mid,
95 })
96}
97
98fn tangent_snap(cursor: Point2, anchor: Point2, sketch: &Sketch, tol: f64) -> Option<SnapHit> {
99 sketch
100 .entity_order()
101 .iter()
102 .filter_map(|id| curve_target(sketch, *id).map(|t| (*id, t)))
103 .filter_map(|(id, target)| {
104 tangent_world(anchor, cursor, target).and_then(|world| {
105 let d = distance(world, cursor);
106 (d <= tol).then_some((d, id, world))
107 })
108 })
109 .min_by(|a, b| a.0.total_cmp(&b.0))
110 .map(|(_, id, world)| SnapHit {
111 kind: SnapKind::Tangent(id),
112 world,
113 })
114}
115
116fn axis_snap(cursor: Point2, anchor: Point2, tol: f64) -> Option<SnapHit> {
117 let (cx, cy) = cursor.coords_mm();
118 let (ax, ay) = anchor.coords_mm();
119 let dx = (cx - ax).abs();
120 let dy = (cy - ay).abs();
121 if dx == 0.0 && dy == 0.0 {
122 return None;
123 }
124 if dy <= tol && dy < dx {
125 Some(SnapHit {
126 kind: SnapKind::Horizontal,
127 world: Point2::from_mm(cx, ay),
128 })
129 } else if dx <= tol && dx <= dy {
130 Some(SnapHit {
131 kind: SnapKind::Vertical,
132 world: Point2::from_mm(ax, cy),
133 })
134 } else {
135 None
136 }
137}
138
139fn line_midpoint(
140 sketch: &Sketch,
141 id: SketchEntityId,
142 exclude_anchor: Option<SketchEntityId>,
143) -> Option<Point2> {
144 let SketchEntity::Line(line) = sketch.entities().get(id)? else {
145 return None;
146 };
147 if exclude_anchor.is_some_and(|aid| line.a() == aid || line.b() == aid) {
148 return None;
149 }
150 let a = endpoint_position(sketch, line.a())?;
151 let b = endpoint_position(sketch, line.b())?;
152 let (ax, ay) = a.coords_mm();
153 let (bx, by) = b.coords_mm();
154 Some(Point2::from_mm(0.5 * (ax + bx), 0.5 * (ay + by)))
155}
156
157fn endpoint_position(sketch: &Sketch, id: SketchEntityId) -> Option<Point2> {
158 match sketch.entities().get(id)? {
159 SketchEntity::Point(p) => Some(p.at()),
160 _ => None,
161 }
162}
163
164#[derive(Copy, Clone)]
165struct CurveTarget {
166 center: Point2,
167 radius: f64,
168 arc_sweep: Option<(f64, f64)>,
169}
170
171fn curve_target(sketch: &Sketch, id: SketchEntityId) -> Option<CurveTarget> {
172 match sketch.entities().get(id)? {
173 SketchEntity::Circle(c) => {
174 let center = endpoint_position(sketch, c.center())?;
175 let radius = c.radius().get::<millimeter>();
176 (radius.is_finite() && radius > 0.0).then_some(CurveTarget {
177 center,
178 radius,
179 arc_sweep: None,
180 })
181 }
182 SketchEntity::Arc(a) => {
183 let center = endpoint_position(sketch, a.center())?;
184 let start = endpoint_position(sketch, a.start())?;
185 let end = endpoint_position(sketch, a.end())?;
186 let radius = distance(center, start);
187 if !(radius.is_finite() && radius > 0.0) {
188 return None;
189 }
190 let (cx, cy) = center.coords_mm();
191 let (sx, sy) = start.coords_mm();
192 let (ex, ey) = end.coords_mm();
193 let start_angle = (sy - cy).atan2(sx - cx);
194 let end_angle = (ey - cy).atan2(ex - cx);
195 let sweep = (end_angle - start_angle).rem_euclid(std::f64::consts::TAU);
196 (sweep.is_finite() && sweep > 0.0).then_some(CurveTarget {
197 center,
198 radius,
199 arc_sweep: Some((start_angle, sweep)),
200 })
201 }
202 _ => None,
203 }
204}
205
206fn tangent_world(anchor: Point2, cursor: Point2, target: CurveTarget) -> Option<Point2> {
207 let (ax, ay) = anchor.coords_mm();
208 let (cx, cy) = target.center.coords_mm();
209 let dx = ax - cx;
210 let dy = ay - cy;
211 let d2 = dx * dx + dy * dy;
212 let r2 = target.radius * target.radius;
213 if d2 <= r2 {
214 return None;
215 }
216 let l = (d2 - r2).sqrt();
217 let along = r2 / d2;
218 let perp = target.radius * l / d2;
219 let along_xy = (dx * along, dy * along);
220 let perp_xy = (-dy * perp, dx * perp);
221 let t1 = Point2::from_mm(cx + along_xy.0 + perp_xy.0, cy + along_xy.1 + perp_xy.1);
222 let t2 = Point2::from_mm(cx + along_xy.0 - perp_xy.0, cy + along_xy.1 - perp_xy.1);
223 [t1, t2]
224 .into_iter()
225 .filter(|t| in_arc_sweep(*t, target))
226 .min_by(|a, b| distance(*a, cursor).total_cmp(&distance(*b, cursor)))
227}
228
229fn in_arc_sweep(point: Point2, target: CurveTarget) -> bool {
230 let Some((start_angle, sweep)) = target.arc_sweep else {
231 return true;
232 };
233 let (cx, cy) = target.center.coords_mm();
234 let (px, py) = point.coords_mm();
235 let theta = (py - cy).atan2(px - cx);
236 (theta - start_angle).rem_euclid(std::f64::consts::TAU) <= sweep
237}
238
239fn distance(a: Point2, b: Point2) -> f64 {
240 let (ax, ay) = a.coords_mm();
241 let (bx, by) = b.coords_mm();
242 (ax - bx).hypot(ay - by)
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use bone_document::{EditOutcome, SketchEdit, SketchEntity};
249 use bone_types::{Point3, SketchPlaneBasis, Tolerance, UnitVec3};
250
251 fn xy_basis() -> SketchPlaneBasis {
252 let Ok(basis) = SketchPlaneBasis::new(
253 Point3::origin(),
254 UnitVec3::x_axis(),
255 UnitVec3::y_axis(),
256 Tolerance::new(1e-9),
257 ) else {
258 panic!("xy basis");
259 };
260 basis
261 }
262
263 fn add_point(s: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) {
264 let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(
265 SketchEntity::point(Point2::from_mm(x, y)),
266 )) else {
267 panic!("add point");
268 };
269 (next, id)
270 }
271
272 fn add_line(s: Sketch, a: SketchEntityId, b: SketchEntityId) -> (Sketch, SketchEntityId) {
273 let Ok((next, EditOutcome::Entity(id))) =
274 s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false)))
275 else {
276 panic!("add line");
277 };
278 (next, id)
279 }
280
281 fn add_circle(s: Sketch, center: SketchEntityId, radius_mm: f64) -> (Sketch, SketchEntityId) {
282 let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(
283 SketchEntity::circle(center, Length::new::<millimeter>(radius_mm), false),
284 )) else {
285 panic!("add circle");
286 };
287 (next, id)
288 }
289
290 fn add_arc(
291 s: Sketch,
292 center: SketchEntityId,
293 start: SketchEntityId,
294 end: SketchEntityId,
295 ) -> (Sketch, SketchEntityId) {
296 let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(
297 SketchEntity::arc(center, start, end, false),
298 )) else {
299 panic!("add arc");
300 };
301 (next, id)
302 }
303
304 fn tol() -> Length {
305 Length::new::<millimeter>(0.8)
306 }
307
308 fn anchor_at(pos: Point2) -> Anchor {
309 Anchor { pos, id: None }
310 }
311
312 #[test]
313 fn no_snap_on_empty_sketch() {
314 let sketch = Sketch::new(xy_basis());
315 assert!(detect(Point2::from_mm(0.0, 0.0), None, &sketch, tol()).is_none());
316 }
317
318 #[test]
319 fn endpoint_within_tolerance_wins() {
320 let sketch = Sketch::new(xy_basis());
321 let (sketch, p) = add_point(sketch, 5.0, 5.0);
322 let cursor = Point2::from_mm(5.05, 5.0);
323 let Some(hit) = detect(cursor, None, &sketch, tol()) else {
324 panic!("expected endpoint snap");
325 };
326 assert_eq!(hit.kind, SnapKind::Endpoint(p));
327 let (x, y) = hit.world.coords_mm();
328 assert!((x - 5.0).abs() < 1e-9);
329 assert!((y - 5.0).abs() < 1e-9);
330 }
331
332 #[test]
333 fn endpoint_outside_tolerance_no_snap() {
334 let sketch = Sketch::new(xy_basis());
335 let (sketch, _) = add_point(sketch, 0.0, 0.0);
336 let cursor = Point2::from_mm(10.0, 0.0);
337 assert!(detect(cursor, None, &sketch, tol()).is_none());
338 }
339
340 #[test]
341 fn midpoint_of_line_snaps() {
342 let sketch = Sketch::new(xy_basis());
343 let (sketch, a) = add_point(sketch, 0.0, 0.0);
344 let (sketch, b) = add_point(sketch, 10.0, 0.0);
345 let (sketch, line) = add_line(sketch, a, b);
346 let cursor = Point2::from_mm(5.05, 0.05);
347 let Some(hit) = detect(cursor, None, &sketch, tol()) else {
348 panic!("expected midpoint snap");
349 };
350 assert_eq!(hit.kind, SnapKind::Midpoint(line));
351 let (x, y) = hit.world.coords_mm();
352 assert!((x - 5.0).abs() < 1e-9);
353 assert!((y - 0.0).abs() < 1e-9);
354 }
355
356 #[test]
357 fn endpoint_beats_midpoint_when_both_in_range() {
358 let sketch = Sketch::new(xy_basis());
359 let (sketch, a) = add_point(sketch, 0.0, 0.0);
360 let (sketch, b) = add_point(sketch, 4.0, 0.0);
361 let (sketch, _) = add_line(sketch, a, b);
362 let cursor = Point2::from_mm(0.05, 0.05);
363 let Some(hit) = detect(cursor, None, &sketch, tol()) else {
364 panic!("expected snap");
365 };
366 assert!(matches!(hit.kind, SnapKind::Endpoint(_)));
367 }
368
369 #[test]
370 fn horizontal_axis_with_anchor() {
371 let sketch = Sketch::new(xy_basis());
372 let cursor = Point2::from_mm(5.0, 0.05);
373 let Some(hit) = detect(
374 cursor,
375 Some(anchor_at(Point2::from_mm(0.0, 0.0))),
376 &sketch,
377 tol(),
378 ) else {
379 panic!("expected horizontal snap");
380 };
381 assert_eq!(hit.kind, SnapKind::Horizontal);
382 let (x, y) = hit.world.coords_mm();
383 assert!((x - 5.0).abs() < 1e-9);
384 assert!((y - 0.0).abs() < 1e-9);
385 }
386
387 #[test]
388 fn vertical_axis_with_anchor() {
389 let sketch = Sketch::new(xy_basis());
390 let cursor = Point2::from_mm(0.05, 5.0);
391 let Some(hit) = detect(
392 cursor,
393 Some(anchor_at(Point2::from_mm(0.0, 0.0))),
394 &sketch,
395 tol(),
396 ) else {
397 panic!("expected vertical snap");
398 };
399 assert_eq!(hit.kind, SnapKind::Vertical);
400 let (x, y) = hit.world.coords_mm();
401 assert!((x - 0.0).abs() < 1e-9);
402 assert!((y - 5.0).abs() < 1e-9);
403 }
404
405 #[test]
406 fn axis_snap_requires_anchor() {
407 let sketch = Sketch::new(xy_basis());
408 let cursor = Point2::from_mm(5.0, 0.05);
409 assert!(detect(cursor, None, &sketch, tol()).is_none());
410 }
411
412 #[test]
413 fn axis_snap_skipped_when_cursor_equals_anchor() {
414 let sketch = Sketch::new(xy_basis());
415 let pos = Point2::from_mm(3.0, 4.0);
416 assert!(detect(pos, Some(anchor_at(pos)), &sketch, tol()).is_none());
417 }
418
419 #[test]
420 fn tangent_snap_to_circle_picks_real_tangent_point() {
421 let sketch = Sketch::new(xy_basis());
422 let (sketch, c) = add_point(sketch, 5.0, 0.0);
423 let (sketch, circle) = add_circle(sketch, c, 3.0);
424 let cursor = Point2::from_mm(3.2, 2.4);
425 let Some(hit) = detect(
426 cursor,
427 Some(anchor_at(Point2::from_mm(0.0, 0.0))),
428 &sketch,
429 tol(),
430 ) else {
431 panic!("expected tangent snap");
432 };
433 assert_eq!(hit.kind, SnapKind::Tangent(circle));
434 let (x, y) = hit.world.coords_mm();
435 assert!((x - 3.2).abs() < 1e-9, "x={x}");
436 assert!((y - 2.4).abs() < 1e-9, "y={y}");
437 }
438
439 #[test]
440 fn tangent_snap_skips_when_anchor_inside_circle() {
441 let sketch = Sketch::new(xy_basis());
442 let (sketch, c) = add_point(sketch, 0.0, 0.0);
443 let (sketch, _) = add_circle(sketch, c, 5.0);
444 let cursor = Point2::from_mm(3.5, 1.5);
445 assert!(
446 detect(
447 cursor,
448 Some(anchor_at(Point2::from_mm(1.0, 0.0))),
449 &sketch,
450 tol(),
451 )
452 .is_none(),
453 );
454 }
455
456 #[test]
457 fn tangent_snap_skips_when_anchor_on_circle() {
458 let sketch = Sketch::new(xy_basis());
459 let (sketch, c) = add_point(sketch, 0.0, 0.0);
460 let (sketch, _) = add_circle(sketch, c, 5.0);
461 let cursor = Point2::from_mm(3.5, 1.5);
462 assert!(
463 detect(
464 cursor,
465 Some(anchor_at(Point2::from_mm(5.0, 0.0))),
466 &sketch,
467 tol(),
468 )
469 .is_none(),
470 );
471 }
472
473 #[test]
474 fn tangent_snap_to_arc_picks_tangent_in_sweep() {
475 let sketch = Sketch::new(xy_basis());
476 let (sketch, center) = add_point(sketch, 5.0, 0.0);
477 let (sketch, start) = add_point(sketch, 5.0, 3.0);
478 let (sketch, end) = add_point(sketch, 5.0, -3.0);
479 let (sketch, arc) = add_arc(sketch, center, start, end);
480 let cursor = Point2::from_mm(3.2, 2.4);
481 let Some(hit) = detect(
482 cursor,
483 Some(anchor_at(Point2::from_mm(0.0, 0.0))),
484 &sketch,
485 tol(),
486 ) else {
487 panic!("expected tangent snap on arc");
488 };
489 assert_eq!(hit.kind, SnapKind::Tangent(arc));
490 let (x, y) = hit.world.coords_mm();
491 assert!((x - 3.2).abs() < 1e-9, "x={x}");
492 assert!((y - 2.4).abs() < 1e-9, "y={y}");
493 }
494
495 #[test]
496 fn tangent_snap_to_arc_skips_when_no_tangent_in_sweep() {
497 let sketch = Sketch::new(xy_basis());
498 let (sketch, center) = add_point(sketch, 5.0, 0.0);
499 let (sketch, start) = add_point(sketch, 8.0, 0.0);
500 let (sketch, end) = add_point(sketch, 5.0, 3.0);
501 let (sketch, _) = add_arc(sketch, center, start, end);
502 let cursor = Point2::from_mm(3.2, 2.4);
503 assert!(
504 detect(
505 cursor,
506 Some(anchor_at(Point2::from_mm(0.0, 0.0))),
507 &sketch,
508 tol()
509 )
510 .is_none(),
511 );
512 }
513
514 #[test]
515 fn midpoint_snap_skips_line_anchored_at_endpoint() {
516 let sketch = Sketch::new(xy_basis());
517 let (sketch, a) = add_point(sketch, 0.0, 0.0);
518 let (sketch, b) = add_point(sketch, 4.0, 4.0);
519 let (sketch, _) = add_line(sketch, a, b);
520 let cursor = Point2::from_mm(2.5, 2.5);
521 let anchor = Anchor {
522 pos: Point2::from_mm(0.0, 0.0),
523 id: Some(a),
524 };
525 assert!(detect(cursor, Some(anchor), &sketch, tol()).is_none());
526 }
527
528 #[test]
529 fn endpoint_filter_uses_anchor_id_not_position() {
530 let sketch = Sketch::new(xy_basis());
531 let (sketch, anchor_pt) = add_point(sketch, 0.0, 0.0);
532 let (sketch, twin) = add_point(sketch, 0.0, 0.0);
533 let cursor = Point2::from_mm(0.01, 0.01);
534 let anchor = Anchor {
535 pos: Point2::from_mm(0.0, 0.0),
536 id: Some(anchor_pt),
537 };
538 let Some(hit) = detect(cursor, Some(anchor), &sketch, tol()) else {
539 panic!("twin should still snap");
540 };
541 assert_eq!(hit.kind, SnapKind::Endpoint(twin));
542 }
543
544 #[test]
545 fn endpoint_skips_active_anchor_point() {
546 let sketch = Sketch::new(xy_basis());
547 let (sketch, p) = add_point(sketch, 0.0, 0.0);
548 let cursor = Point2::from_mm(0.01, 0.01);
549 let anchor = Anchor {
550 pos: Point2::from_mm(0.0, 0.0),
551 id: Some(p),
552 };
553 let hit = detect(cursor, Some(anchor), &sketch, tol());
554 assert!(
555 !matches!(hit.map(|h| h.kind), Some(SnapKind::Endpoint(_))),
556 "endpoint snap must skip the active anchor by id",
557 );
558 }
559
560 #[test]
561 fn position_anchor_does_not_suppress_endpoint() {
562 let sketch = Sketch::new(xy_basis());
563 let (sketch, p) = add_point(sketch, 0.0, 0.0);
564 let cursor = Point2::from_mm(0.01, 0.01);
565 let anchor = anchor_at(Point2::from_mm(0.0, 0.0));
566 let Some(hit) = detect(cursor, Some(anchor), &sketch, tol()) else {
567 panic!("position anchor must not filter coincident endpoint");
568 };
569 assert_eq!(hit.kind, SnapKind::Endpoint(p));
570 }
571
572 #[test]
573 fn zero_tolerance_returns_none() {
574 let sketch = Sketch::new(xy_basis());
575 let (sketch, _) = add_point(sketch, 0.0, 0.0);
576 assert!(
577 detect(
578 Point2::from_mm(0.0, 0.0),
579 None,
580 &sketch,
581 Length::new::<millimeter>(0.0),
582 )
583 .is_none()
584 );
585 }
586}