Another project
0

Configure Feed

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

at main 19 kB View raw
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}