Another project
0

Configure Feed

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

feat(app,render): snap goes in sketch tools and preview render

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

author
Lewis
date (May 10, 2026, 10:39 AM +0300) commit 65f345eb parent 3a30a40a change-id kpquyvrl
+531 -151
+393 -87
crates/bone-app/src/main.rs
··· 3 3 use std::path::{Path, PathBuf}; 4 4 use std::sync::Arc; 5 5 6 - use bone_document::{Document, EditOutcome, Sketch, SketchEdit, SketchEntity, UndoStack}; 6 + use bone_document::{ 7 + Document, EditOutcome, Sketch, SketchEdit, SketchEntity, SketchRelation, UndoStack, 8 + }; 7 9 use bone_render::{ 8 10 Camera2, ChromeInstance, ChromePipeline, ChromeTextPipeline, PickQuery, PickedItem, 9 - PixelsPerMm, SketchPreview, SketchRenderer, SketchScene, Style, SurfaceContext, ViewportExtent, 10 - ViewportPx, 11 + PixelsPerMm, RenderTargets, SketchPreview, SketchRenderer, SketchScene, Style, SurfaceContext, 12 + ViewportExtent, ViewportPx, 11 13 }; 12 - use bone_types::{DocumentId, Length, Point2, SketchEntityId, SketchId, Vec2}; 14 + use bone_types::{BudgetCeiling, DocumentId, Length, Point2, SketchEntityId, SketchId, Vec2}; 13 15 use bone_ui::a11y::AccessTreeBuilder; 14 16 use bone_ui::focus::FocusManager; 15 17 use bone_ui::frame::FrameCtx; ··· 40 42 mod chrome; 41 43 mod shell; 42 44 mod sketch_mode; 45 + mod snap; 43 46 mod strings; 44 47 45 48 use sketch_mode::{Mode, Pending, Plane, SketchSession, SketchTool}; 49 + use snap::{Anchor, SnapHit, SnapKind}; 46 50 47 51 #[derive(Debug, thiserror::Error)] 48 52 enum AppError { ··· 83 87 const PAN_FAST_MULTIPLIER: f64 = 5.0; 84 88 const ZOOM_FIT_MARGIN: f64 = 0.9; 85 89 const UNDO_CAPACITY: usize = 256; 90 + const SNAP_TOLERANCE_PX: f64 = 8.0; 91 + const SNAP_TOLERANCE_MAX_MM: f64 = 5.0; 86 92 87 93 struct RenderState { 88 94 surface: SurfaceContext, ··· 208 214 } 209 215 210 216 fn add_line(sketch: Sketch, a: SketchEntityId, b: SketchEntityId) -> Sketch { 211 - let Ok((next, _)) = sketch.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) else { 212 - unreachable!("AddEntity(Line) referencing freshly added Points succeeds"); 217 + add_line_with_id(sketch, a, b).0 218 + } 219 + 220 + fn add_line_with_id( 221 + sketch: Sketch, 222 + a: SketchEntityId, 223 + b: SketchEntityId, 224 + ) -> (Sketch, SketchEntityId) { 225 + let Ok((next, EditOutcome::Entity(id))) = 226 + sketch.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 227 + else { 228 + unreachable!("AddEntity(Line) referencing extant points yields Entity outcome"); 213 229 }; 214 - next 230 + (next, id) 231 + } 232 + 233 + fn add_relation(sketch: Sketch, relation: SketchRelation) -> Sketch { 234 + match sketch.clone().apply(SketchEdit::AddRelation(relation)) { 235 + Ok((next, _)) => next, 236 + Err(e) => { 237 + tracing::warn!(error = %e, ?relation, "snap inferred relation rejected; skipping"); 238 + sketch 239 + } 240 + } 215 241 } 216 242 217 243 fn add_circle(sketch: Sketch, center: SketchEntityId, radius_mm: f64) -> Sketch { ··· 358 384 tool: SketchTool, 359 385 world: Point2, 360 386 pending: Option<Pending>, 387 + snap: Option<SnapHit>, 361 388 ) -> (Option<Sketch>, Option<Pending>) { 362 389 let (wx, wy) = world.coords_mm(); 363 390 match tool { 391 + SketchTool::Point if matches!(snap.map(|s| s.kind), Some(SnapKind::Endpoint(_))) => { 392 + (None, None) 393 + } 364 394 SketchTool::Point => { 365 395 let (next, _) = add_point(sketch, wx, wy); 366 396 (Some(next), None) 367 397 } 368 398 SketchTool::Line => match pending { 369 - None => (None, Some(Pending::Position(world))), 370 - Some(Pending::Position(start)) => { 371 - let (sx, sy) = start.coords_mm(); 372 - let (s, p1) = add_point(sketch, sx, sy); 373 - let (s, p2) = add_point(s, wx, wy); 374 - (Some(add_line(s, p1, p2)), Some(Pending::Endpoint(p2))) 375 - } 376 - Some(Pending::Endpoint(prev)) => { 377 - let (s, p2) = add_point(sketch, wx, wy); 378 - (Some(add_line(s, prev, p2)), Some(Pending::Endpoint(p2))) 379 - } 399 + None => first_click_line(sketch, world, snap), 400 + Some(prev) => second_click_line(sketch, world, prev, snap), 380 401 }, 381 402 _ => (None, pending), 382 403 } 383 404 } 384 405 406 + fn first_click_line( 407 + sketch: Sketch, 408 + world: Point2, 409 + snap: Option<SnapHit>, 410 + ) -> (Option<Sketch>, Option<Pending>) { 411 + match snap.map(|s| s.kind) { 412 + Some(SnapKind::Endpoint(id)) => (None, Some(Pending::Endpoint(id))), 413 + Some(SnapKind::Midpoint(line)) => { 414 + let snap_world = snap.map_or(world, |s| s.world); 415 + let (sx, sy) = snap_world.coords_mm(); 416 + let (sketch, point) = add_point(sketch, sx, sy); 417 + let sketch = add_relation(sketch, SketchRelation::Midpoint { point, line }); 418 + (Some(sketch), Some(Pending::Endpoint(point))) 419 + } 420 + None | Some(SnapKind::Tangent(_) | SnapKind::Horizontal | SnapKind::Vertical) => ( 421 + None, 422 + Some(Pending::Position(snap.map_or(world, |s| s.world))), 423 + ), 424 + } 425 + } 426 + 427 + fn second_click_line( 428 + sketch: Sketch, 429 + world: Point2, 430 + pending: Pending, 431 + snap: Option<SnapHit>, 432 + ) -> (Option<Sketch>, Option<Pending>) { 433 + let (sketch, prev_id) = match pending { 434 + Pending::Position(start) => { 435 + let (sx, sy) = start.coords_mm(); 436 + add_point(sketch, sx, sy) 437 + } 438 + Pending::Endpoint(id) => (sketch, id), 439 + }; 440 + let snap_world = snap.map_or(world, |s| s.world); 441 + let (sx, sy) = snap_world.coords_mm(); 442 + let (sketch, end_id) = match snap.map(|s| s.kind) { 443 + Some(SnapKind::Endpoint(id)) => (sketch, id), 444 + Some(_) | None => add_point(sketch, sx, sy), 445 + }; 446 + if end_id == prev_id { 447 + return (Some(sketch), Some(Pending::Endpoint(prev_id))); 448 + } 449 + let (sketch, line_id) = add_line_with_id(sketch, prev_id, end_id); 450 + let sketch = match snap.map(|s| s.kind) { 451 + Some(SnapKind::Midpoint(line)) => add_relation( 452 + sketch, 453 + SketchRelation::Midpoint { 454 + point: end_id, 455 + line, 456 + }, 457 + ), 458 + Some(SnapKind::Tangent(curve)) => { 459 + add_relation(sketch, SketchRelation::Tangent(line_id, curve)) 460 + } 461 + Some(SnapKind::Horizontal) => add_relation(sketch, SketchRelation::Horizontal(line_id)), 462 + Some(SnapKind::Vertical) => add_relation(sketch, SketchRelation::Vertical(line_id)), 463 + Some(SnapKind::Endpoint(_)) | None => sketch, 464 + }; 465 + (Some(sketch), Some(Pending::Endpoint(end_id))) 466 + } 467 + 385 468 fn try_place(state: &mut RenderState, world: Point2) { 386 469 let Mode::Sketch { 387 470 sketch_id, ··· 398 481 let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 399 482 return; 400 483 }; 401 - let (next_sketch, new_pending) = place_in_sketch(sketch, tool, world, prev_pending); 484 + let snap = match tool { 485 + SketchTool::Line => compute_snap(&sketch, &state.camera, world, prev_pending), 486 + SketchTool::Point => compute_endpoint_snap(&sketch, &state.camera, world), 487 + _ => None, 488 + }; 489 + let (next_sketch, new_pending) = place_in_sketch(sketch, tool, world, prev_pending, snap); 402 490 if let Some(next) = next_sketch { 403 491 state.undo.record(state.document.clone()); 404 492 state.document.replace_sketch(sketch_id, next); ··· 458 546 fn handle_viewport_click(state: &mut RenderState, cursor: PhysicalPosition<f64>) { 459 547 let picked = pick_at(state, cursor); 460 548 state.selection = match picked { 461 - Some(PickedItem::Point(id)) => Some(id), 462 - Some(PickedItem::Line(id)) => Some(id), 463 - Some(PickedItem::Arc(id)) => Some(id), 464 - Some(PickedItem::Circle(id)) => Some(id), 549 + Some( 550 + PickedItem::Point(id) 551 + | PickedItem::Line(id) 552 + | PickedItem::Arc(id) 553 + | PickedItem::Circle(id), 554 + ) => Some(id), 465 555 Some(_) | None => None, 466 556 }; 467 557 if let Some(PickedItem::Point(entity)) = picked ··· 475 565 fn try_drag_to(state: &mut RenderState, world: Point2) { 476 566 let Mode::Sketch { 477 567 sketch_id, 478 - session: 479 - SketchSession { 480 - drag: Some(entity), 481 - .. 482 - }, 568 + session: SketchSession { 569 + drag: Some(entity), .. 570 + }, 483 571 } = state.mode 484 572 else { 485 573 return; ··· 487 575 let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 488 576 return; 489 577 }; 490 - let edit = SketchEdit::MovePoint { 491 - id: entity, 492 - position: world, 493 - }; 494 - let Ok((next, _)) = sketch.apply(edit) else { 495 - return; 578 + let next = match sketch.solve_with_drag(entity, world, BudgetCeiling::FRAME_16MS) { 579 + Ok(solved) => solved, 580 + Err(e) => { 581 + tracing::trace!(error = %e, "drag solve failed; falling back to raw move"); 582 + let Ok((next, _)) = sketch.apply(SketchEdit::MovePoint { 583 + id: entity, 584 + position: world, 585 + }) else { 586 + return; 587 + }; 588 + next 589 + } 496 590 }; 497 591 state.document.replace_sketch(sketch_id, next); 498 592 refresh_active_scene(state); ··· 522 616 mode: &Mode, 523 617 document: &Document, 524 618 cursor_world: Option<Point2>, 619 + camera: &Camera2, 525 620 ) -> SketchPreview { 526 621 let Mode::Sketch { 527 622 sketch_id, ··· 535 630 else { 536 631 return SketchPreview::empty(); 537 632 }; 538 - let Some(start) = pending_anchor_world(*sketch_id, *pending, document) else { 539 - return SketchPreview::empty(); 633 + let snap = cursor_world.and_then(|cursor| { 634 + document 635 + .sketch(*sketch_id) 636 + .and_then(|sketch| compute_snap(sketch, camera, cursor, *pending)) 637 + }); 638 + let snap_world = snap.map(|s| s.world); 639 + let Some(start) = pending 640 + .and_then(|p| resolve_anchor(document.sketch(*sketch_id), p)) 641 + .map(|a| a.pos) 642 + else { 643 + return SketchPreview { 644 + anchor: None, 645 + rubber_band: None, 646 + snap: snap_world, 647 + }; 540 648 }; 541 649 let Some(cursor) = cursor_world else { 542 650 return SketchPreview { 543 651 anchor: Some(start), 544 652 rubber_band: None, 653 + snap: snap_world, 545 654 }; 546 655 }; 656 + let endpoint = snap_world.unwrap_or(cursor); 547 657 SketchPreview { 548 658 anchor: Some(start), 549 - rubber_band: Some((start, cursor)), 659 + rubber_band: Some((start, endpoint)), 660 + snap: snap_world, 550 661 } 551 662 } 552 663 553 - fn pending_anchor_world( 554 - sketch_id: SketchId, 664 + fn snap_tolerance(camera: &Camera2) -> Option<Length> { 665 + let mm_per_px = camera.world_mm_per_pixel(); 666 + if !mm_per_px.is_finite() || mm_per_px <= 0.0 { 667 + return None; 668 + } 669 + let tol_mm = (SNAP_TOLERANCE_PX * mm_per_px).min(SNAP_TOLERANCE_MAX_MM); 670 + Some(Length::new::<millimeter>(tol_mm)) 671 + } 672 + 673 + fn compute_snap( 674 + sketch: &Sketch, 675 + camera: &Camera2, 676 + cursor_world: Point2, 555 677 pending: Option<Pending>, 556 - document: &Document, 557 - ) -> Option<Point2> { 558 - match pending? { 559 - Pending::Position(p) => Some(p), 560 - Pending::Endpoint(id) => match document.sketch(sketch_id)?.entities().get(id)? { 561 - SketchEntity::Point(p) => Some(p.at()), 678 + ) -> Option<SnapHit> { 679 + snap::detect( 680 + cursor_world, 681 + pending.and_then(|p| resolve_anchor(Some(sketch), p)), 682 + sketch, 683 + snap_tolerance(camera)?, 684 + ) 685 + } 686 + 687 + fn compute_endpoint_snap( 688 + sketch: &Sketch, 689 + camera: &Camera2, 690 + cursor_world: Point2, 691 + ) -> Option<SnapHit> { 692 + snap::detect_endpoint_only(cursor_world, sketch, snap_tolerance(camera)?) 693 + } 694 + 695 + fn resolve_anchor(sketch: Option<&Sketch>, pending: Pending) -> Option<Anchor> { 696 + match pending { 697 + Pending::Position(pos) => Some(Anchor { pos, id: None }), 698 + Pending::Endpoint(id) => match sketch?.entities().get(id)? { 699 + SketchEntity::Point(p) => Some(Anchor { 700 + pos: p.at(), 701 + id: Some(id), 702 + }), 562 703 _ => None, 563 704 }, 564 705 } ··· 666 807 sketch_mode::EXIT_SKETCH_ACTION, 667 808 ), 668 809 HotkeyBinding::new( 669 - KeyChord::new( 670 - UiKeyCode::Char(KeyChar::from_char('z')), 671 - ModifierMask::CTRL, 672 - ), 810 + KeyChord::new(UiKeyCode::Char(KeyChar::from_char('z')), ModifierMask::CTRL), 673 811 HotkeyScope::Global, 674 812 sketch_mode::UNDO_ACTION, 675 813 ), ··· 682 820 sketch_mode::REDO_ACTION, 683 821 ), 684 822 HotkeyBinding::new( 685 - KeyChord::new( 686 - UiKeyCode::Char(KeyChar::from_char('y')), 687 - ModifierMask::CTRL, 688 - ), 823 + KeyChord::new(UiKeyCode::Char(KeyChar::from_char('y')), ModifierMask::CTRL), 689 824 HotkeyScope::Global, 690 825 sketch_mode::REDO_ACTION, 691 826 ), ··· 734 869 fn toggle_or_arm(mode: Mode, tool: SketchTool) -> Mode { 735 870 match mode { 736 871 Mode::Sketch { 737 - session: 738 - SketchSession { 739 - tool: Some(active), .. 740 - }, 872 + session: SketchSession { 873 + tool: Some(active), .. 874 + }, 741 875 .. 742 876 } if active == tool => mode.disarm_tool(), 743 877 _ => mode.arm_tool(tool), ··· 747 881 fn cancel_pending_or_exit(mode: Mode) -> Mode { 748 882 match mode { 749 883 Mode::Sketch { 750 - session: 751 - SketchSession { 752 - pending: Some(_), .. 753 - }, 884 + session: SketchSession { 885 + pending: Some(_), .. 886 + }, 754 887 .. 755 888 } => mode.clear_pending(), 756 889 Mode::Sketch { 757 - session: 758 - SketchSession { 759 - tool: Some(_), .. 760 - }, 890 + session: SketchSession { tool: Some(_), .. }, 761 891 .. 762 892 } => mode.disarm_tool(), 763 893 Mode::Sketch { .. } | Mode::Idle => Mode::Idle, ··· 1052 1182 .cursor_px 1053 1183 .filter(|c| state.viewport_rect.contains(physical_to_layout_pos(*c))) 1054 1184 .and_then(|c| cursor_to_world(state.camera, c)); 1055 - let preview = build_preview(&state.mode, &state.document, cursor_world); 1185 + let preview = build_preview(&state.mode, &state.document, cursor_world, &state.camera); 1056 1186 let chrome_instances: Vec<ChromeInstance> = 1057 1187 chrome::paint_to_instances(&state.theme, &frame.paints); 1058 1188 let text_spans = chrome::paint_to_text_spans(&frame.paints, &state.strings); ··· 1079 1209 renderer.prepare(scene, style); 1080 1210 surface.render( 1081 1211 |encoder, color, pick| { 1082 - renderer.encode_passes(encoder, color, pick, scene, &preview, camera, style); 1212 + renderer.encode_passes( 1213 + encoder, 1214 + RenderTargets::new(color, pick), 1215 + scene, 1216 + &preview, 1217 + camera, 1218 + style, 1219 + ); 1083 1220 chrome_pipeline.draw(encoder, color, viewport_px, &chrome_instances); 1084 1221 text_pipeline.draw( 1085 1222 encoder, ··· 1108 1245 Some(shell::MenuAction::Quit) => { 1109 1246 state.pending_exit = true; 1110 1247 } 1111 - Some(shell::MenuAction::Undo) => { 1112 - if state.undo.undo(&mut state.document) { 1113 - refresh_active_scene(state); 1114 - } 1248 + Some(shell::MenuAction::Undo) if state.undo.undo(&mut state.document) => { 1249 + refresh_active_scene(state); 1115 1250 } 1116 - Some(shell::MenuAction::Redo) => { 1117 - if state.undo.redo(&mut state.document) { 1118 - refresh_active_scene(state); 1119 - } 1251 + Some(shell::MenuAction::Redo) if state.undo.redo(&mut state.document) => { 1252 + refresh_active_scene(state); 1120 1253 } 1121 1254 Some(shell::MenuAction::ZoomFit) => { 1122 1255 state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); 1123 1256 } 1124 - None => {} 1257 + Some(shell::MenuAction::Undo | shell::MenuAction::Redo) | None => {} 1125 1258 } 1126 1259 } 1127 1260 ··· 1277 1410 #[test] 1278 1411 fn point_tool_commits_a_point_per_click() { 1279 1412 let sketch = Sketch::new(Plane::Xy.basis()); 1280 - let (next, pending) = 1281 - place_in_sketch(sketch, SketchTool::Point, Point2::from_mm(3.0, 4.0), None); 1413 + let (next, pending) = place_in_sketch( 1414 + sketch, 1415 + SketchTool::Point, 1416 + Point2::from_mm(3.0, 4.0), 1417 + None, 1418 + None, 1419 + ); 1282 1420 let Some(next) = next else { 1283 1421 panic!("point tool must commit"); 1284 1422 }; ··· 1287 1425 } 1288 1426 1289 1427 #[test] 1428 + fn point_tool_endpoint_snap_suppresses_duplicate() { 1429 + let sketch = Sketch::new(Plane::Xy.basis()); 1430 + let (sketch, existing) = add_point(sketch, 1.0, 1.0); 1431 + let (next, pending) = place_in_sketch( 1432 + sketch, 1433 + SketchTool::Point, 1434 + Point2::from_mm(1.05, 1.0), 1435 + None, 1436 + Some(SnapHit { 1437 + kind: SnapKind::Endpoint(existing), 1438 + world: Point2::from_mm(1.0, 1.0), 1439 + }), 1440 + ); 1441 + assert!(next.is_none(), "endpoint snap must suppress new point"); 1442 + assert_eq!(pending, None); 1443 + } 1444 + 1445 + #[test] 1290 1446 fn line_tool_first_click_pends_anchor_only() { 1291 1447 let sketch = Sketch::new(Plane::Xy.basis()); 1292 1448 let world = Point2::from_mm(1.0, 2.0); 1293 - let (next, pending) = place_in_sketch(sketch, SketchTool::Line, world, None); 1449 + let (next, pending) = place_in_sketch(sketch, SketchTool::Line, world, None, None); 1294 1450 assert!(next.is_none()); 1295 1451 assert_eq!(pending, Some(Pending::Position(world))); 1296 1452 } ··· 1300 1456 let sketch = Sketch::new(Plane::Xy.basis()); 1301 1457 let start = Point2::from_mm(0.0, 0.0); 1302 1458 let end = Point2::from_mm(5.0, 5.0); 1303 - let (next, pending) = 1304 - place_in_sketch(sketch, SketchTool::Line, end, Some(Pending::Position(start))); 1459 + let (next, pending) = place_in_sketch( 1460 + sketch, 1461 + SketchTool::Line, 1462 + end, 1463 + Some(Pending::Position(start)), 1464 + None, 1465 + ); 1305 1466 let Some(next) = next else { 1306 1467 panic!("second click must commit"); 1307 1468 }; ··· 1321 1482 SketchTool::Line, 1322 1483 Point2::from_mm(5.0, 0.0), 1323 1484 Some(Pending::Endpoint(anchor_id)), 1485 + None, 1324 1486 ); 1325 1487 let Some(next) = next else { 1326 1488 panic!("chain click commits"); ··· 1342 1504 SketchTool::Circle, 1343 1505 Point2::from_mm(2.0, 2.0), 1344 1506 pending, 1507 + None, 1345 1508 ); 1346 1509 assert!(next.is_none()); 1347 1510 assert_eq!(kept, pending); 1348 1511 } 1349 1512 1513 + fn snap_endpoint(id: SketchEntityId, world: Point2) -> SnapHit { 1514 + SnapHit { 1515 + kind: SnapKind::Endpoint(id), 1516 + world, 1517 + } 1518 + } 1519 + 1520 + fn snap_midpoint(line: SketchEntityId, world: Point2) -> SnapHit { 1521 + SnapHit { 1522 + kind: SnapKind::Midpoint(line), 1523 + world, 1524 + } 1525 + } 1526 + 1527 + fn snap_axis(kind: SnapKind, world: Point2) -> SnapHit { 1528 + SnapHit { kind, world } 1529 + } 1530 + 1531 + #[test] 1532 + fn line_first_click_endpoint_snap_pends_existing_id_no_edit() { 1533 + let sketch = Sketch::new(Plane::Xy.basis()); 1534 + let (sketch, existing) = add_point(sketch, 1.0, 1.0); 1535 + let (next, pending) = place_in_sketch( 1536 + sketch, 1537 + SketchTool::Line, 1538 + Point2::from_mm(1.05, 1.05), 1539 + None, 1540 + Some(snap_endpoint(existing, Point2::from_mm(1.0, 1.0))), 1541 + ); 1542 + assert!(next.is_none()); 1543 + assert_eq!(pending, Some(Pending::Endpoint(existing))); 1544 + } 1545 + 1546 + #[test] 1547 + fn line_first_click_midpoint_snap_emits_point_and_relation() { 1548 + let sketch = Sketch::new(Plane::Xy.basis()); 1549 + let (sketch, a) = add_point(sketch, 0.0, 0.0); 1550 + let (sketch, b) = add_point(sketch, 10.0, 0.0); 1551 + let Ok((sketch, EditOutcome::Entity(line_id))) = 1552 + sketch.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 1553 + else { 1554 + panic!("seed line"); 1555 + }; 1556 + let (next, pending) = place_in_sketch( 1557 + sketch, 1558 + SketchTool::Line, 1559 + Point2::from_mm(5.0, 0.0), 1560 + None, 1561 + Some(snap_midpoint(line_id, Point2::from_mm(5.0, 0.0))), 1562 + ); 1563 + let Some(next) = next else { 1564 + panic!("midpoint snap must materialise point + relation"); 1565 + }; 1566 + assert_eq!(count_kind(&next, SketchEntityKind::Point), 3); 1567 + assert_eq!(next.relation_order().len(), 1); 1568 + let Some(Pending::Endpoint(_)) = pending else { 1569 + panic!("first-click midpoint pends the materialised point"); 1570 + }; 1571 + } 1572 + 1573 + #[test] 1574 + fn line_second_click_endpoint_snap_does_not_duplicate_point() { 1575 + let sketch = Sketch::new(Plane::Xy.basis()); 1576 + let (sketch, existing) = add_point(sketch, 5.0, 0.0); 1577 + let start = Point2::from_mm(0.0, 0.0); 1578 + let (next, pending) = place_in_sketch( 1579 + sketch, 1580 + SketchTool::Line, 1581 + Point2::from_mm(5.05, 0.05), 1582 + Some(Pending::Position(start)), 1583 + Some(snap_endpoint(existing, Point2::from_mm(5.0, 0.0))), 1584 + ); 1585 + let Some(next) = next else { 1586 + panic!("commit"); 1587 + }; 1588 + assert_eq!( 1589 + count_kind(&next, SketchEntityKind::Point), 1590 + 2, 1591 + "endpoint snap reuses existing id, only adds the start point", 1592 + ); 1593 + assert_eq!(count_kind(&next, SketchEntityKind::Line), 1); 1594 + assert_eq!(pending, Some(Pending::Endpoint(existing))); 1595 + } 1596 + 1597 + #[test] 1598 + fn line_second_click_horizontal_snap_emits_horizontal_relation() { 1599 + let sketch = Sketch::new(Plane::Xy.basis()); 1600 + let start = Point2::from_mm(0.0, 0.0); 1601 + let snapped = Point2::from_mm(5.0, 0.0); 1602 + let (next, _) = place_in_sketch( 1603 + sketch, 1604 + SketchTool::Line, 1605 + Point2::from_mm(5.0, 0.05), 1606 + Some(Pending::Position(start)), 1607 + Some(snap_axis(SnapKind::Horizontal, snapped)), 1608 + ); 1609 + let Some(next) = next else { 1610 + panic!("commit"); 1611 + }; 1612 + assert_eq!(next.relation_order().len(), 1); 1613 + } 1614 + 1615 + #[test] 1616 + fn line_second_click_endpoint_back_to_anchor_skips_zero_length_line() { 1617 + let sketch = Sketch::new(Plane::Xy.basis()); 1618 + let (sketch, anchor) = add_point(sketch, 0.0, 0.0); 1619 + let (next, pending) = place_in_sketch( 1620 + sketch, 1621 + SketchTool::Line, 1622 + Point2::from_mm(0.05, 0.05), 1623 + Some(Pending::Endpoint(anchor)), 1624 + Some(snap_endpoint(anchor, Point2::from_mm(0.0, 0.0))), 1625 + ); 1626 + let Some(next) = next else { 1627 + panic!("zero-length must not error"); 1628 + }; 1629 + assert_eq!(count_kind(&next, SketchEntityKind::Line), 0); 1630 + assert_eq!(pending, Some(Pending::Endpoint(anchor))); 1631 + } 1632 + 1350 1633 #[test] 1351 1634 fn cursor_to_world_at_window_center_equals_camera_pan() { 1352 1635 let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100)); ··· 1475 1758 assert_eq!(session.pending, None); 1476 1759 } 1477 1760 1761 + fn far_camera() -> Camera2 { 1762 + Camera2::new(ViewportExtent::new( 1763 + ViewportPx::new(800), 1764 + ViewportPx::new(600), 1765 + )) 1766 + .with_zoom(PixelsPerMm::new(1_000_000.0)) 1767 + } 1768 + 1478 1769 #[test] 1479 1770 fn build_preview_in_idle_is_empty() { 1480 1771 let document = Document::new(DocumentId::default(), "doc".to_owned()); 1481 - let preview = build_preview(&Mode::Idle, &document, Some(Point2::from_mm(1.0, 1.0))); 1772 + let preview = build_preview( 1773 + &Mode::Idle, 1774 + &document, 1775 + Some(Point2::from_mm(1.0, 1.0)), 1776 + &far_camera(), 1777 + ); 1482 1778 assert!(preview.is_empty()); 1483 1779 } 1484 1780 ··· 1486 1782 fn build_preview_without_armed_line_tool_is_empty() { 1487 1783 let document = Document::new(DocumentId::default(), "doc".to_owned()); 1488 1784 let mode = Mode::enter_sketch(SketchId::default()); 1489 - let preview = build_preview(&mode, &document, Some(Point2::from_mm(0.0, 0.0))); 1785 + let preview = build_preview( 1786 + &mode, 1787 + &document, 1788 + Some(Point2::from_mm(0.0, 0.0)), 1789 + &far_camera(), 1790 + ); 1490 1791 assert!(preview.is_empty()); 1491 1792 } 1492 1793 ··· 1503 1804 }, 1504 1805 }; 1505 1806 let cursor = Point2::from_mm(5.0, 7.0); 1506 - let preview = build_preview(&mode, &document, Some(cursor)); 1807 + let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); 1507 1808 assert_eq!(preview.anchor, Some(anchor)); 1508 1809 assert_eq!(preview.rubber_band, Some((anchor, cursor))); 1509 1810 } ··· 1523 1824 }, 1524 1825 }; 1525 1826 let cursor = Point2::from_mm(0.0, 0.0); 1526 - let preview = build_preview(&mode, &document, Some(cursor)); 1827 + let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); 1527 1828 assert_eq!(preview.anchor, Some(target)); 1528 1829 assert_eq!(preview.rubber_band, Some((target, cursor))); 1529 1830 } ··· 1540 1841 drag: None, 1541 1842 }, 1542 1843 }; 1543 - let preview = build_preview(&mode, &document, None); 1844 + let preview = build_preview(&mode, &document, None, &far_camera()); 1544 1845 assert_eq!(preview.anchor, Some(anchor)); 1545 1846 assert_eq!(preview.rubber_band, None); 1546 1847 } ··· 1556 1857 drag: Some(SketchEntityId::default()), 1557 1858 }, 1558 1859 }; 1559 - let preview = build_preview(&mode, &document, Some(Point2::from_mm(1.0, 1.0))); 1860 + let preview = build_preview( 1861 + &mode, 1862 + &document, 1863 + Some(Point2::from_mm(1.0, 1.0)), 1864 + &far_camera(), 1865 + ); 1560 1866 assert!(preview.is_empty()); 1561 1867 } 1562 1868
+41 -16
crates/bone-app/src/shell.rs
··· 304 304 property_rect, 305 305 self.ids.property_pane, 306 306 &mut self.state.clipboard, 307 - document, 308 - mode, 309 - selection, 307 + PropertyState { 308 + document, 309 + mode, 310 + selection, 311 + }, 310 312 &mut paints, 311 313 ); 312 314 render_status_bar(ctx, status_rect, self.ids.status_bar, mode, &mut paints); ··· 573 575 response.double_activated 574 576 } 575 577 578 + #[derive(Copy, Clone)] 579 + struct PropertyState<'a> { 580 + document: &'a Document, 581 + mode: &'a Mode, 582 + selection: Option<SketchEntityId>, 583 + } 584 + 576 585 fn render_property_pane( 577 586 ctx: &mut FrameCtx<'_>, 578 587 rect: LayoutRect, 579 588 id: WidgetId, 580 589 clipboard: &mut MemoryClipboard, 581 - document: &Document, 582 - mode: &Mode, 583 - selection: Option<SketchEntityId>, 590 + state: PropertyState<'_>, 584 591 paints: &mut Vec<WidgetPaint>, 585 592 ) { 586 593 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 587 594 return; 588 595 } 589 - let active_sketch = match mode { 590 - Mode::Sketch { sketch_id, .. } => document.sketch(*sketch_id), 596 + let active_sketch = match state.mode { 597 + Mode::Sketch { sketch_id, .. } => state.document.sketch(*sketch_id), 591 598 Mode::Idle => None, 592 599 }; 593 - let resolved = selection 600 + let resolved = state 601 + .selection 594 602 .zip(active_sketch) 595 603 .and_then(|(sel, sketch)| sketch.entities().get(sel).map(|e| (*e, sketch))); 596 604 let mut editors = match resolved { ··· 631 639 ) -> Vec<PropertyRowSpec> { 632 640 let yes_no = |b: bool| { 633 641 if b { 634 - strings_table.resolve(strings::PROPERTY_VALUE_YES).to_owned() 642 + strings_table 643 + .resolve(strings::PROPERTY_VALUE_YES) 644 + .to_owned() 635 645 } else { 636 646 strings_table.resolve(strings::PROPERTY_VALUE_NO).to_owned() 637 647 } ··· 642 652 vec![ 643 653 row_editor( 644 654 strings::PROPERTY_ROW_KIND, 645 - strings_table.resolve(strings::PROPERTY_KIND_POINT).to_owned(), 655 + strings_table 656 + .resolve(strings::PROPERTY_KIND_POINT) 657 + .to_owned(), 646 658 ), 647 659 row_editor(strings::PROPERTY_ROW_X, format_mm(x)), 648 660 row_editor(strings::PROPERTY_ROW_Y, format_mm(y)), ··· 654 666 vec![ 655 667 row_editor( 656 668 strings::PROPERTY_ROW_KIND, 657 - strings_table.resolve(strings::PROPERTY_KIND_LINE).to_owned(), 669 + strings_table 670 + .resolve(strings::PROPERTY_KIND_LINE) 671 + .to_owned(), 658 672 ), 659 673 row_editor(strings::PROPERTY_ROW_FROM, from), 660 674 row_editor(strings::PROPERTY_ROW_TO, to), 661 - row_editor(strings::PROPERTY_ROW_CONSTRUCTION, yes_no(l.for_construction())), 675 + row_editor( 676 + strings::PROPERTY_ROW_CONSTRUCTION, 677 + yes_no(l.for_construction()), 678 + ), 662 679 ] 663 680 } 664 681 SketchEntity::Arc(a) => { ··· 673 690 row_editor(strings::PROPERTY_ROW_CENTER, center), 674 691 row_editor(strings::PROPERTY_ROW_START, start), 675 692 row_editor(strings::PROPERTY_ROW_END, end), 676 - row_editor(strings::PROPERTY_ROW_CONSTRUCTION, yes_no(a.for_construction())), 693 + row_editor( 694 + strings::PROPERTY_ROW_CONSTRUCTION, 695 + yes_no(a.for_construction()), 696 + ), 677 697 ] 678 698 } 679 699 SketchEntity::Circle(c) => { ··· 681 701 vec![ 682 702 row_editor( 683 703 strings::PROPERTY_ROW_KIND, 684 - strings_table.resolve(strings::PROPERTY_KIND_CIRCLE).to_owned(), 704 + strings_table 705 + .resolve(strings::PROPERTY_KIND_CIRCLE) 706 + .to_owned(), 685 707 ), 686 708 row_editor(strings::PROPERTY_ROW_CENTER, center), 687 709 row_editor(strings::PROPERTY_ROW_RADIUS, format_length(c.radius())), 688 - row_editor(strings::PROPERTY_ROW_CONSTRUCTION, yes_no(c.for_construction())), 710 + row_editor( 711 + strings::PROPERTY_ROW_CONSTRUCTION, 712 + yes_no(c.for_construction()), 713 + ), 689 714 ] 690 715 } 691 716 }
+1 -4
crates/bone-app/src/sketch_mode.rs
··· 290 290 291 291 #[test] 292 292 fn start_drag_in_idle_is_noop() { 293 - assert_eq!( 294 - Mode::Idle.start_drag(SketchEntityId::default()), 295 - Mode::Idle 296 - ); 293 + assert_eq!(Mode::Idle.start_drag(SketchEntityId::default()), Mode::Idle); 297 294 } 298 295 299 296 #[test]
+28 -12
crates/bone-render/src/lib.rs
··· 27 27 }; 28 28 pub use surface::{SurfaceContext, SurfaceError}; 29 29 30 + #[derive(Copy, Clone)] 31 + pub struct RenderTargets<'a> { 32 + pub color: &'a wgpu::TextureView, 33 + pub pick: &'a wgpu::TextureView, 34 + } 35 + 36 + impl<'a> RenderTargets<'a> { 37 + #[must_use] 38 + pub const fn new(color: &'a wgpu::TextureView, pick: &'a wgpu::TextureView) -> Self { 39 + Self { color, pick } 40 + } 41 + } 42 + 30 43 #[derive(Debug, thiserror::Error)] 31 44 pub enum RenderError { 32 45 #[error("no wgpu adapter matched the offscreen request: {0}")] ··· 94 107 pub fn encode_passes( 95 108 &self, 96 109 encoder: &mut wgpu::CommandEncoder, 97 - color_view: &wgpu::TextureView, 98 - pick_view: &wgpu::TextureView, 110 + targets: RenderTargets<'_>, 99 111 scene: &SketchScene, 100 112 preview: &SketchPreview, 101 113 camera: Camera2, 102 114 style: &Style, 103 115 ) { 104 - self.grid.draw(encoder, color_view, camera, style); 105 - gpu::clear_pick_attachment(encoder, pick_view); 106 - self.arcs 107 - .draw(encoder, color_view, pick_view, camera, style, scene); 116 + self.grid.draw(encoder, targets.color, camera, style); 117 + gpu::clear_pick_attachment(encoder, targets.pick); 118 + self.arcs.draw(encoder, targets, camera, style, scene); 108 119 self.lines 109 - .draw(encoder, color_view, pick_view, camera, style, scene, preview); 110 - self.glyphs 111 - .draw(encoder, color_view, pick_view, camera, style, scene); 112 - self.text 113 - .draw(encoder, color_view, pick_view, camera, style, scene); 120 + .draw(encoder, targets, camera, style, scene, preview); 121 + self.glyphs.draw(encoder, targets, camera, style, scene); 122 + self.text.draw(encoder, targets, camera, style, scene); 114 123 } 115 124 116 125 pub fn render( ··· 128 137 self.prepare(scene, style); 129 138 let preview = SketchPreview::empty(); 130 139 ctx.render(|encoder, color_view, pick_view| { 131 - self.encode_passes(encoder, color_view, pick_view, scene, &preview, camera, style); 140 + self.encode_passes( 141 + encoder, 142 + RenderTargets::new(color_view, pick_view), 143 + scene, 144 + &preview, 145 + camera, 146 + style, 147 + ); 132 148 }) 133 149 } 134 150 }
+4 -4
crates/bone-render/src/pipelines/arc.rs
··· 3 3 use uom::si::length::millimeter; 4 4 use wgpu::util::DeviceExt; 5 5 6 + use crate::RenderTargets; 6 7 use crate::camera::Camera2; 7 8 use crate::gpu::{Gpu, PICK_FORMAT}; 8 9 use crate::pipelines::{CONSTRUCTION_BIT, FRAME_UNIFORM_SIZE, build_frame_uniform}; ··· 130 131 pub fn draw( 131 132 &self, 132 133 encoder: &mut wgpu::CommandEncoder, 133 - color_view: &wgpu::TextureView, 134 - pick_view: &wgpu::TextureView, 134 + targets: RenderTargets<'_>, 135 135 camera: Camera2, 136 136 style: &Style, 137 137 scene: &SketchScene, ··· 154 154 label: Some("bone-render:arc-pass"), 155 155 color_attachments: &[ 156 156 Some(wgpu::RenderPassColorAttachment { 157 - view: color_view, 157 + view: targets.color, 158 158 resolve_target: None, 159 159 depth_slice: None, 160 160 ops: wgpu::Operations { ··· 163 163 }, 164 164 }), 165 165 Some(wgpu::RenderPassColorAttachment { 166 - view: pick_view, 166 + view: targets.pick, 167 167 resolve_target: None, 168 168 depth_slice: None, 169 169 ops: wgpu::Operations {
+24 -4
crates/bone-render/src/pipelines/lines.rs
··· 1 1 use wgpu::util::DeviceExt; 2 2 3 + use crate::RenderTargets; 3 4 use crate::camera::Camera2; 4 5 use crate::gpu::{Gpu, PICK_FORMAT}; 5 6 use crate::pick::PickId; ··· 126 127 pub fn draw( 127 128 &self, 128 129 encoder: &mut wgpu::CommandEncoder, 129 - color_view: &wgpu::TextureView, 130 - pick_view: &wgpu::TextureView, 130 + targets: RenderTargets<'_>, 131 131 camera: Camera2, 132 132 style: &Style, 133 133 scene: &SketchScene, ··· 151 151 label: Some("bone-render:lines-pass"), 152 152 color_attachments: &[ 153 153 Some(wgpu::RenderPassColorAttachment { 154 - view: color_view, 154 + view: targets.color, 155 155 resolve_target: None, 156 156 depth_slice: None, 157 157 ops: wgpu::Operations { ··· 160 160 }, 161 161 }), 162 162 Some(wgpu::RenderPassColorAttachment { 163 - view: pick_view, 163 + view: targets.pick, 164 164 resolve_target: None, 165 165 depth_slice: None, 166 166 ops: wgpu::Operations { ··· 214 214 .anchor 215 215 .iter() 216 216 .map(|p| preview_point_instance(*p, style)); 217 + let preview_snap = preview 218 + .snap 219 + .iter() 220 + .map(|p| preview_snap_instance(*p, style)); 217 221 lines 218 222 .chain(points) 219 223 .chain(preview_line) 220 224 .chain(preview_anchor) 225 + .chain(preview_snap) 221 226 .collect() 222 227 } 223 228 ··· 277 282 style_bits: 0, 278 283 } 279 284 } 285 + 286 + const SNAP_RADIUS_RATIO: f32 = 1.8; 287 + 288 + #[allow(clippy::cast_possible_truncation)] 289 + fn preview_snap_instance(at: Point2, style: &Style) -> LineInstance { 290 + let (x, y) = at.coords_mm(); 291 + let xy = [x as f32, y as f32]; 292 + LineInstance { 293 + a: xy, 294 + b: xy, 295 + half_width_px: style.strokes().point_radius_px() * SNAP_RADIUS_RATIO, 296 + pick_id: PickId::NONE.raw(), 297 + style_bits: CONSTRUCTION_BIT, 298 + } 299 + }
+4 -4
crates/bone-render/src/pipelines/text.rs
··· 6 6 use swash::{FontRef, scale::ScaleContext, zeno::Point as ZenoPoint}; 7 7 use wgpu::util::DeviceExt; 8 8 9 + use crate::RenderTargets; 9 10 use crate::camera::Camera2; 10 11 use crate::gpu::{Gpu, PICK_FORMAT}; 11 12 use crate::pipelines::text_common::{shape_line, tessellate_at}; ··· 128 129 pub fn draw( 129 130 &self, 130 131 encoder: &mut wgpu::CommandEncoder, 131 - color_view: &wgpu::TextureView, 132 - pick_view: &wgpu::TextureView, 132 + targets: RenderTargets<'_>, 133 133 camera: Camera2, 134 134 style: &Style, 135 135 scene: &SketchScene, ··· 159 159 label: Some("bone-render:text-pass"), 160 160 color_attachments: &[ 161 161 Some(wgpu::RenderPassColorAttachment { 162 - view: color_view, 162 + view: targets.color, 163 163 resolve_target: None, 164 164 depth_slice: None, 165 165 ops: wgpu::Operations { ··· 168 168 }, 169 169 }), 170 170 Some(wgpu::RenderPassColorAttachment { 171 - view: pick_view, 171 + view: targets.pick, 172 172 resolve_target: None, 173 173 depth_slice: None, 174 174 ops: wgpu::Operations {
+14 -3
crates/bone-render/src/preview.rs
··· 4 4 pub struct SketchPreview { 5 5 pub anchor: Option<Point2>, 6 6 pub rubber_band: Option<(Point2, Point2)>, 7 + pub snap: Option<Point2>, 7 8 } 8 9 9 10 impl SketchPreview { ··· 12 13 Self { 13 14 anchor: None, 14 15 rubber_band: None, 16 + snap: None, 15 17 } 16 18 } 17 19 18 20 #[must_use] 19 21 pub const fn is_empty(&self) -> bool { 20 - self.anchor.is_none() && self.rubber_band.is_none() 22 + self.anchor.is_none() && self.rubber_band.is_none() && self.snap.is_none() 21 23 } 22 24 } 23 25 ··· 36 38 fn anchor_only_is_non_empty() { 37 39 let preview = SketchPreview { 38 40 anchor: Some(Point2::from_mm(1.0, 2.0)), 39 - rubber_band: None, 41 + ..SketchPreview::empty() 40 42 }; 41 43 assert!(!preview.is_empty()); 42 44 } ··· 44 46 #[test] 45 47 fn rubber_band_only_is_non_empty() { 46 48 let preview = SketchPreview { 47 - anchor: None, 48 49 rubber_band: Some((Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 1.0))), 50 + ..SketchPreview::empty() 51 + }; 52 + assert!(!preview.is_empty()); 53 + } 54 + 55 + #[test] 56 + fn snap_only_is_non_empty() { 57 + let preview = SketchPreview { 58 + snap: Some(Point2::from_mm(2.0, 3.0)), 59 + ..SketchPreview::empty() 49 60 }; 50 61 assert!(!preview.is_empty()); 51 62 }
+16 -9
crates/bone-ui/src/layout/dock.rs
··· 188 188 const RIBBON_RATIO: SplitFraction = SplitFraction::clamped(0.10); 189 189 const MENU_RATIO: SplitFraction = SplitFraction::clamped(0.035); 190 190 const STATUS_RATIO: SplitFraction = SplitFraction::clamped(0.97); 191 - [menu_bar, feature_tree, property_pane, ribbon, viewport, status] 192 - .into_iter() 193 - .try_fold(BTreeSet::<PanelId>::new(), |mut seen, id| { 194 - if seen.insert(id) { 195 - Ok(seen) 196 - } else { 197 - Err(DockStateError::DuplicatePanelId(id)) 198 - } 199 - })?; 191 + [ 192 + menu_bar, 193 + feature_tree, 194 + property_pane, 195 + ribbon, 196 + viewport, 197 + status, 198 + ] 199 + .into_iter() 200 + .try_fold(BTreeSet::<PanelId>::new(), |mut seen, id| { 201 + if seen.insert(id) { 202 + Ok(seen) 203 + } else { 204 + Err(DockStateError::DuplicatePanelId(id)) 205 + } 206 + })?; 200 207 let center = DockNode::split( 201 208 Axis::Horizontal, 202 209 FEATURE_TREE_RATIO,
+4 -5
crates/bone-ui/src/layout/tests.rs
··· 650 650 #[test] 651 651 fn dock_solidworks_default_panel_set_is_complete() { 652 652 let panels = (pid(1), pid(2), pid(3), pid(4), pid(5), pid(6)); 653 - let Ok(dock) = DockState::solidworks_default( 654 - panels.0, panels.1, panels.2, panels.3, panels.4, panels.5, 655 - ) else { 653 + let Ok(dock) = 654 + DockState::solidworks_default(panels.0, panels.1, panels.2, panels.3, panels.4, panels.5) 655 + else { 656 656 panic!("solidworks_default rejected distinct ids"); 657 657 }; 658 658 let mut ids = dock.main.panel_ids(); ··· 670 670 let property = pid(4); 671 671 let ribbon = pid(5); 672 672 let status = pid(6); 673 - let Ok(dock) = 674 - DockState::solidworks_default(menu, feature, property, ribbon, viewport, status) 673 + let Ok(dock) = DockState::solidworks_default(menu, feature, property, ribbon, viewport, status) 675 674 else { 676 675 panic!("solidworks_default rejected distinct ids"); 677 676 };
+1 -1
crates/bone-ui/src/widgets/toolbar.rs
··· 312 312 items 313 313 .iter() 314 314 .enumerate() 315 - .scan((), |_, (i, item)| { 315 + .scan((), |(), (i, item)| { 316 316 let extent = item_extent(item, item_size); 317 317 let lead = if i == 0 { 0.0 } else { gap.value() }; 318 318 let next = offset + lead + extent;
+1 -2
crates/bone-ui/tests/dock_snapshot.rs
··· 7 7 } 8 8 9 9 fn solidworks_default() -> DockState { 10 - let Ok(state) = 11 - DockState::solidworks_default(pid(1), pid(2), pid(3), pid(4), pid(5), pid(6)) 10 + let Ok(state) = DockState::solidworks_default(pid(1), pid(2), pid(3), pid(4), pid(5), pid(6)) 12 11 else { 13 12 panic!("solidworks_default rejected distinct ids"); 14 13 };