Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(app): snap mod w/ endpoint, midpoint, tangent, axis kinds

Lewis: May this revision serve well! <lu5a@proton.me>

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