Another project
0

Configure Feed

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

featuretree: extrude nodes w/ edit & rename

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

author
Lewis
date (Jun 11, 2026, 2:13 PM +0300) commit 7a497d3b parent e069fe07 change-id opurmpzn
+1142 -138
+1 -1
crates/bone-app/src/hotkeys.rs
··· 455 455 }); 456 456 assert!( 457 457 !bound_in_sketch, 458 - "{action:?} must ship unbound; SolidWorks stock has no default for it" 458 + "{action:?} must ship unbound with no stock default" 459 459 ); 460 460 }); 461 461 }
+405 -34
crates/bone-app/src/main.rs
··· 12 12 Camera2, ChromeInstance, ChromePipeline, ChromeTextPipeline, DragModifiers, EdgeScene, 13 13 NavGesture, PickQuery, PickedItem, PixelsPerMm, RenderTargets, SdfGlyphInstance, SketchPreview, 14 14 SketchRenderer, SketchScene, SolidFrameView, SolidRenderer, SolidScene, Style, SurfaceContext, 15 - ViewportExtent, ViewportNavigator, ViewportPoint, ViewportPx, ViewportRegion, 16 - frame_standard_view, zoom_about_pixel, 15 + ViewportExtent, ViewportNavigator, ViewportPoint, ViewportPx, ViewportRegion, frame_current, 16 + frame_standard_view, orbit_pitch, orbit_yaw, pan_pixels, roll_by, zoom_about_pixel, 17 17 }; 18 18 use bone_types::{ 19 - Aabb3, AngleTolerance, BudgetCeiling, Camera3, ChordHeightTolerance, DisplayMode, DocumentId, 20 - FeatureId, GeometryGeneration, Length, Point2, SketchId, SketchItemId, StandardView, Vec2, 21 - ZoomFactor, 19 + Aabb3, Angle, AngleTolerance, BudgetCeiling, Camera3, ChordHeightTolerance, DisplayMode, 20 + DocumentId, ExtrudeId, FeatureId, GeometryGeneration, Length, Point2, SketchId, SketchItemId, 21 + StandardView, Vec2, ZoomFactor, 22 22 }; 23 23 use bone_ui::a11y::AccessTreeBuilder; 24 24 use bone_ui::focus::FocusManager; ··· 37 37 use bone_ui::{MaskAtlas, MaskAtlasParams, Shaper}; 38 38 use swash::FontRef; 39 39 use tracing_subscriber::EnvFilter; 40 + use uom::si::angle::degree; 40 41 use uom::si::length::millimeter; 41 42 use winit::{ 42 43 application::ApplicationHandler, ··· 106 107 const ZOOM_STEP_PER_LINE: f64 = 1.1; 107 108 const ZOOM_STEP_PER_PIXEL: f64 = 1.0025; 108 109 const ZOOM_KEY_STEP: f64 = 1.25; 110 + const ORBIT_KEY_STEP_DEG: f64 = 15.0; 111 + const ORBIT_KEY_SNAP_DEG: f64 = 90.0; 109 112 const ZOOM_MIN: f64 = 0.01; 110 113 const ZOOM_MAX: f64 = 1.0e5; 111 114 const INITIAL_ZOOM_PX_PER_MM: f64 = 12.0; ··· 138 141 solid_renderer: SolidRenderer, 139 142 solid_view: Option<SolidViewData>, 140 143 camera3: Option<Camera3>, 144 + framed_extrude: Option<ExtrudeId>, 141 145 navigator: ViewportNavigator, 142 146 focus: FocusManager, 143 147 hit_state: HitState, ··· 552 556 fn active_sketch_id(mode: &Mode, plane_sketches: &BTreeMap<Plane, SketchId>) -> Option<SketchId> { 553 557 match mode { 554 558 Mode::Sketch { sketch_id, .. } => Some(*sketch_id), 555 - Mode::Extrude(ExtrudeArming::Profile(feature)) => Some(feature.sketch), 559 + Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => Some(feature.sketch), 556 560 Mode::Extrude(ExtrudeArming::AwaitingSketch) | Mode::Idle => { 557 561 plane_sketches.get(&Plane::Xy).copied() 558 562 } ··· 598 602 599 603 fn apply_extrude_edit(state: &mut RenderState, edit: Option<shell::ExtrudeEdit>) { 600 604 let Some(edit) = edit else { return }; 601 - let Mode::Extrude(ExtrudeArming::Profile(feature)) = &state.mode else { 605 + let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = &state.mode else { 602 606 return; 603 607 }; 604 608 let next = edit.apply(*feature); 605 - state.mode = Mode::Extrude(ExtrudeArming::Profile(next)); 609 + let target = *target; 610 + state.mode = Mode::Extrude(ExtrudeArming::Profile { 611 + feature: next, 612 + target, 613 + }); 614 + } 615 + 616 + fn apply_extrude_activation(state: &mut RenderState, activated: Option<ExtrudeId>) { 617 + if let Some(mode) = extrude_edit_mode(&state.document, &state.mode, activated) { 618 + if let Mode::Extrude(ExtrudeArming::Profile { 619 + target: Some(id), .. 620 + }) = mode 621 + { 622 + state.framed_extrude = Some(id); 623 + } 624 + state.mode = mode; 625 + } 626 + } 627 + 628 + fn extrude_edit_mode( 629 + document: &Document, 630 + current: &Mode, 631 + activated: Option<ExtrudeId>, 632 + ) -> Option<Mode> { 633 + let id = activated?; 634 + if current.is_sketch() { 635 + return None; 636 + } 637 + let feature = document.extrude(id).copied()?; 638 + Some(Mode::Extrude(ExtrudeArming::edit(id, feature))) 639 + } 640 + 641 + fn apply_extrude_confirm(state: &mut RenderState, confirm: Option<shell::ConfirmAction>) { 642 + if let Some(id) = 643 + commit_armed_extrude(&mut state.document, &mut state.undo, &state.mode, confirm) 644 + { 645 + state.framed_extrude = Some(id); 646 + } 647 + } 648 + 649 + fn commit_armed_extrude( 650 + document: &mut Document, 651 + undo: &mut UndoStack, 652 + mode: &Mode, 653 + confirm: Option<shell::ConfirmAction>, 654 + ) -> Option<ExtrudeId> { 655 + let Some(shell::ConfirmAction::Accept) = confirm else { 656 + return None; 657 + }; 658 + let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = mode else { 659 + return None; 660 + }; 661 + let snapshot = document.clone(); 662 + let committed = match target { 663 + Some(id) => { 664 + document.insert_extrude(*id, *feature); 665 + *id 666 + } 667 + None => document.commit_extrude(*feature), 668 + }; 669 + undo.record(snapshot); 670 + Some(committed) 606 671 } 607 672 608 673 struct SolidViewData { ··· 621 686 const PREVIEW_CHORD_MM: f64 = 0.05; 622 687 const PREVIEW_ANGLE: AngleTolerance = AngleTolerance::from_radians(0.2); 623 688 624 - fn sync_extrude_preview(state: &mut RenderState) { 625 - let Mode::Extrude(ExtrudeArming::Profile(feature)) = &state.mode else { 689 + fn active_solid_feature( 690 + mode: &Mode, 691 + document: &Document, 692 + framed: Option<ExtrudeId>, 693 + ) -> Option<ExtrudeFeature> { 694 + match mode { 695 + Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => Some(*feature), 696 + Mode::Sketch { .. } => None, 697 + Mode::Idle | Mode::Extrude(ExtrudeArming::AwaitingSketch) => framed 698 + .and_then(|id| document.extrude(id).copied()) 699 + .or_else(|| { 700 + document 701 + .feature_tree() 702 + .iter() 703 + .filter_map(|(_, node)| match node { 704 + FeatureNode::Extrude(id) => Some(id), 705 + _ => None, 706 + }) 707 + .last() 708 + .and_then(|id| document.extrude(id).copied()) 709 + }), 710 + } 711 + } 712 + 713 + fn sync_solid_view(state: &mut RenderState) { 714 + let Some(feature) = active_solid_feature(&state.mode, &state.document, state.framed_extrude) 715 + else { 626 716 state.extrude_preview = None; 627 717 state.solid_view = None; 628 718 state.camera3 = None; 629 719 return; 630 720 }; 631 - let feature = *feature; 632 721 let Some(sketch_version) = state.document.sketch(feature.sketch).map(Sketch::version) else { 633 722 state.extrude_preview = None; 634 723 state.solid_view = None; ··· 770 859 } 771 860 772 861 fn drag_gesture(modifiers: ModifiersState) -> NavGesture { 773 - let base = if modifiers.shift_key() { 774 - DragModifiers::NONE.with_shift() 862 + let with_ctrl = if modifiers.control_key() || modifiers.super_key() { 863 + DragModifiers::NONE.with_ctrl() 775 864 } else { 776 865 DragModifiers::NONE 777 866 }; 867 + let with_shift = if modifiers.shift_key() { 868 + with_ctrl.with_shift() 869 + } else { 870 + with_ctrl 871 + }; 778 872 let resolved = if modifiers.alt_key() { 779 - base.with_alt() 873 + with_shift.with_alt() 780 874 } else { 781 - base 875 + with_shift 782 876 }; 783 877 resolved.gesture() 784 878 } ··· 939 1033 } 940 1034 } 941 1035 1036 + fn zoom_key3( 1037 + camera: Camera3, 1038 + extent: ViewportExtent, 1039 + pixel: ViewportPoint, 1040 + factor: f64, 1041 + ) -> Option<Camera3> { 1042 + ZoomFactor::new(factor) 1043 + .ok() 1044 + .and_then(|f| zoom_about_pixel(camera, extent, pixel, f).ok()) 1045 + } 1046 + 1047 + fn keyboard_camera3(code: KeyCode, input: &InputState, state: &RenderState) -> Option<Camera3> { 1048 + let camera = state.camera3?; 1049 + let region = solid_viewport_region(state.viewport_rect, state.surface.extent())?; 1050 + let extent = region.extent(); 1051 + let ctrl = input.modifiers.control_key() || input.modifiers.super_key(); 1052 + let shift = input.modifiers.shift_key(); 1053 + let alt = input.modifiers.alt_key(); 1054 + let cx = f64::from(extent.width().value()) * 0.5; 1055 + let cy = f64::from(extent.height().value()) * 0.5; 1056 + let center = ViewportPoint::new(cx, cy).ok()?; 1057 + let pan_to = |dx: f64, dy: f64| { 1058 + ViewportPoint::new(cx + dx, cy + dy) 1059 + .ok() 1060 + .and_then(|to| pan_pixels(camera, extent, center, to).ok()) 1061 + }; 1062 + let step = Angle::new::<degree>(if shift { 1063 + ORBIT_KEY_SNAP_DEG 1064 + } else { 1065 + ORBIT_KEY_STEP_DEG 1066 + }); 1067 + match code { 1068 + KeyCode::ArrowLeft if ctrl => pan_to(-PAN_STEP_PX, 0.0), 1069 + KeyCode::ArrowRight if ctrl => pan_to(PAN_STEP_PX, 0.0), 1070 + KeyCode::ArrowUp if ctrl => pan_to(0.0, -PAN_STEP_PX), 1071 + KeyCode::ArrowDown if ctrl => pan_to(0.0, PAN_STEP_PX), 1072 + KeyCode::ArrowLeft if alt => roll_by(camera, step).ok(), 1073 + KeyCode::ArrowRight if alt => roll_by(camera, -step).ok(), 1074 + KeyCode::ArrowLeft => orbit_yaw(camera, step).ok(), 1075 + KeyCode::ArrowRight => orbit_yaw(camera, -step).ok(), 1076 + KeyCode::ArrowUp => orbit_pitch(camera, step).ok(), 1077 + KeyCode::ArrowDown => orbit_pitch(camera, -step).ok(), 1078 + KeyCode::KeyZ if shift => zoom_key3(camera, extent, center, 1.0 / ZOOM_KEY_STEP), 1079 + KeyCode::KeyZ | KeyCode::Equal => zoom_key3(camera, extent, center, ZOOM_KEY_STEP), 1080 + KeyCode::Minus => zoom_key3(camera, extent, center, 1.0 / ZOOM_KEY_STEP), 1081 + _ => None, 1082 + } 1083 + } 1084 + 942 1085 fn build_hotkey_table() -> HotkeyTable { 943 1086 let Ok(table) = hotkeys::compose_table(&hotkeys::HotkeyOverrides::default()) else { 944 1087 unreachable!("default hotkey bindings are conflict-free"); ··· 988 1131 if mode.is_extrude() { 989 1132 return match frame.sketch_activated { 990 1133 Some(id) => match &mode { 991 - Mode::Extrude(ExtrudeArming::Profile(feature)) if feature.sketch == id => mode, 1134 + Mode::Extrude(ExtrudeArming::Profile { feature, .. }) if feature.sketch == id => { 1135 + mode 1136 + } 992 1137 _ => Mode::Extrude(ExtrudeArming::profile(id)), 993 1138 }, 994 1139 None => mode, ··· 1116 1261 solid_renderer, 1117 1262 solid_view: None, 1118 1263 camera3: None, 1264 + framed_extrude: None, 1119 1265 navigator: ViewportNavigator::new(), 1120 1266 focus: FocusManager::new(), 1121 1267 hit_state: HitState::new(), ··· 1231 1377 if let Some(camera) = state.camera3 1232 1378 && let Some(region) = 1233 1379 solid_viewport_region(state.viewport_rect, state.surface.extent()) 1234 - && let Some(cursor) = self 1235 - .input 1236 - .cursor_px 1237 - .and_then(|p| viewport_local_point(p, region)) 1238 - && let Ok(factor) = ZoomFactor::new(zoom_factor(delta)) 1239 - && let Ok(next) = 1240 - zoom_about_pixel(camera, region.extent(), cursor, factor) 1241 1380 { 1242 - state.camera3 = Some(next); 1381 + match delta { 1382 + MouseScrollDelta::PixelDelta(p) => { 1383 + if let Ok(next) = state 1384 + .navigator 1385 + .orbit_pixels(camera, region.extent(), p.x, p.y) 1386 + { 1387 + state.camera3 = Some(next); 1388 + } 1389 + } 1390 + MouseScrollDelta::LineDelta(..) => { 1391 + if let Some(cursor) = self 1392 + .input 1393 + .cursor_px 1394 + .and_then(|p| viewport_local_point(p, region)) 1395 + && let Ok(factor) = ZoomFactor::new(zoom_factor(delta)) 1396 + && let Ok(next) = 1397 + zoom_about_pixel(camera, region.extent(), cursor, factor) 1398 + { 1399 + state.camera3 = Some(next); 1400 + } 1401 + } 1402 + } 1243 1403 } 1244 1404 } else { 1245 1405 state.camera = ··· 1384 1544 } 1385 1545 if let Some(code) = physical_code 1386 1546 && !suppress_camera 1387 - && let Some(next) = keyboard_camera(code, &self.input, state) 1388 1547 { 1389 - state.camera = next; 1548 + if state.solid_view.is_some() { 1549 + if let Some(next) = keyboard_camera3(code, &self.input, state) { 1550 + state.camera3 = Some(next); 1551 + } 1552 + } else if let Some(next) = keyboard_camera(code, &self.input, state) { 1553 + state.camera = next; 1554 + } 1390 1555 } 1391 1556 let _ = event_loop; 1392 1557 } ··· 1512 1677 } 1513 1678 } 1514 1679 let escape_requested = hotkey_actions.contains(&sketch_mode::ESCAPE_ACTION); 1680 + apply_extrude_edit(state, frame.extrude_edit); 1681 + apply_extrude_confirm(state, frame.confirm_action); 1515 1682 let prev_active_sketch = active_sketch_id(&state.mode, &state.plane_sketches); 1516 1683 state.mode = next_mode( 1517 1684 core::mem::take(&mut state.mode), ··· 1520 1687 &state.plane_sketches, 1521 1688 ); 1522 1689 apply_feature_tool(state, frame.activated_feature_tool); 1690 + apply_extrude_activation(state, frame.extrude_activated); 1523 1691 if active_sketch_id(&state.mode, &state.plane_sketches) != prev_active_sketch { 1524 1692 refresh_active_scene(state); 1525 1693 } ··· 1531 1699 _ => frame.dimension_edit, 1532 1700 }; 1533 1701 apply_dimension_edit(state, dimension_edit); 1534 - apply_extrude_edit(state, frame.extrude_edit); 1535 - sync_extrude_preview(state); 1702 + sync_solid_view(state); 1536 1703 let solid_region = solid_viewport_region(state.viewport_rect, extent); 1537 1704 sync_solid_camera(state, solid_region); 1538 1705 let cursor_layout = input_state.cursor_px.map(physical_to_layout_pos); ··· 1541 1708 apply_settings_change(state, frame.settings_change); 1542 1709 apply_relation_action(state, frame.activated_relation); 1543 1710 apply_sketch_rename(state, frame.sketch_rename.clone()); 1711 + apply_extrude_rename(state, frame.extrude_rename.clone()); 1544 1712 let cursor_world = input_state 1545 1713 .cursor_px 1546 1714 .filter(|c| state.viewport_rect.contains(physical_to_layout_pos(*c))) ··· 2292 2460 plane_picked: None, 2293 2461 sketch_activated: None, 2294 2462 sketch_rename: None, 2463 + extrude_activated: None, 2464 + extrude_rename: None, 2295 2465 exit_sketch: false, 2296 2466 confirm_action: None, 2297 2467 menu_action: None, ··· 3001 3171 refresh_active_scene(state); 3002 3172 } 3003 3173 Some(shell::MenuAction::ZoomFit) => { 3004 - state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); 3174 + let solid_fit = state.solid_view.as_ref().map(|view| view.aabb).and_then(|aabb| { 3175 + let region = solid_viewport_region(state.viewport_rect, state.surface.extent())?; 3176 + frame_current(state.camera3?, aabb, region.extent()).ok() 3177 + }); 3178 + if let Some(next) = solid_fit { 3179 + state.camera3 = Some(next); 3180 + } else { 3181 + state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); 3182 + } 3005 3183 } 3006 3184 Some(shell::MenuAction::OpenSettings) => { 3007 3185 state.shell.state.settings_dialog_open = true; ··· 3064 3242 state.scene = scene; 3065 3243 state.mode = Mode::Idle; 3066 3244 state.selection = Selection::default(); 3245 + state.framed_extrude = None; 3067 3246 state.current_folder = None; 3068 3247 state.pending_overwrite = None; 3069 3248 let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { ··· 3308 3487 state.plane_sketches = plane_sketches; 3309 3488 state.mode = Mode::Idle; 3310 3489 state.selection = Selection::default(); 3490 + state.framed_extrude = None; 3311 3491 state.current_folder = folder; 3312 3492 state.pending_overwrite = None; 3313 3493 let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { ··· 3368 3548 apply_sketch_rename_into(&mut state.document, &mut state.undo, req); 3369 3549 } 3370 3550 3551 + fn apply_extrude_rename(state: &mut RenderState, request: Option<shell::ExtrudeRenameRequest>) { 3552 + let Some(req) = request else { return }; 3553 + apply_extrude_rename_into(&mut state.document, &mut state.undo, req); 3554 + } 3555 + 3556 + fn apply_extrude_rename_into( 3557 + document: &mut Document, 3558 + undo: &mut UndoStack, 3559 + request: shell::ExtrudeRenameRequest, 3560 + ) { 3561 + let shell::ExtrudeRenameRequest { id, label } = request; 3562 + let trimmed = label.trim(); 3563 + let Some(current) = document.extrude_label(id) else { 3564 + return; 3565 + }; 3566 + if trimmed.is_empty() || current == trimmed { 3567 + return; 3568 + } 3569 + let snapshot = document.clone(); 3570 + match document.rename_extrude(id, &label) { 3571 + Ok(()) => undo.record(snapshot), 3572 + Err(e) => tracing::warn!(error = %e, ?id, "extrude rename rejected"), 3573 + } 3574 + } 3575 + 3371 3576 fn apply_sketch_rename_into( 3372 3577 document: &mut Document, 3373 3578 undo: &mut UndoStack, 3374 3579 request: shell::SketchRenameRequest, 3375 3580 ) { 3376 3581 let shell::SketchRenameRequest { id, label } = request; 3377 - if document.sketch_label(id).is_some_and(|l| l == label.trim()) { 3582 + let trimmed = label.trim(); 3583 + let Some(current) = document.sketch_label(id) else { 3584 + return; 3585 + }; 3586 + if trimmed.is_empty() || current == trimmed { 3378 3587 return; 3379 3588 } 3380 3589 let snapshot = document.clone(); ··· 3623 3832 plane_picked: None, 3624 3833 sketch_activated: None, 3625 3834 sketch_rename: None, 3835 + extrude_activated: None, 3836 + extrude_rename: None, 3626 3837 exit_sketch: false, 3627 3838 confirm_action: None, 3628 3839 menu_action: None, ··· 3856 4067 } 3857 4068 3858 4069 #[test] 3859 - fn drag_gesture_maps_modifiers_to_solidworks_navigation() { 4070 + fn drag_gesture_maps_modifiers_to_orbit_pan_zoom_roll() { 3860 4071 use winit::keyboard::ModifiersState; 3861 4072 assert_eq!( 3862 4073 super::drag_gesture(ModifiersState::empty()), 3863 4074 NavGesture::Orbit 3864 4075 ); 3865 - assert_eq!(super::drag_gesture(ModifiersState::SHIFT), NavGesture::Pan); 4076 + assert_eq!(super::drag_gesture(ModifiersState::CONTROL), NavGesture::Pan); 4077 + assert_eq!(super::drag_gesture(ModifiersState::SHIFT), NavGesture::Zoom); 3866 4078 assert_eq!(super::drag_gesture(ModifiersState::ALT), NavGesture::Roll); 3867 4079 assert_eq!( 3868 - super::drag_gesture(ModifiersState::SHIFT | ModifiersState::ALT), 4080 + super::drag_gesture(ModifiersState::CONTROL | ModifiersState::SHIFT), 3869 4081 NavGesture::Pan, 3870 - "shift wins so a held shift never rolls", 4082 + "ctrl outranks shift so a held ctrl always pans", 3871 4083 ); 3872 4084 } 3873 4085 ··· 4300 4512 0, 4301 4513 "trimmed-equal rename must not record undo" 4302 4514 ); 4515 + } 4516 + 4517 + fn extrude_node_count(document: &Document) -> usize { 4518 + document 4519 + .feature_tree() 4520 + .iter() 4521 + .filter(|(_, node)| matches!(node, FeatureNode::Extrude(_))) 4522 + .count() 4523 + } 4524 + 4525 + #[test] 4526 + fn commit_armed_extrude_on_accept_adds_node_and_records_undo() { 4527 + let (mut document, sketch) = doc_with_default_sketch(); 4528 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 4529 + let mode = Mode::Extrude(ExtrudeArming::profile(sketch)); 4530 + commit_armed_extrude( 4531 + &mut document, 4532 + &mut undo, 4533 + &mode, 4534 + Some(shell::ConfirmAction::Accept), 4535 + ); 4536 + assert_eq!(extrude_node_count(&document), 1); 4537 + assert_eq!(undo.past_len(), 1); 4538 + } 4539 + 4540 + #[test] 4541 + fn commit_armed_extrude_ignores_cancel_and_non_extrude_mode() { 4542 + let (mut document, sketch) = doc_with_default_sketch(); 4543 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 4544 + commit_armed_extrude( 4545 + &mut document, 4546 + &mut undo, 4547 + &Mode::Extrude(ExtrudeArming::profile(sketch)), 4548 + Some(shell::ConfirmAction::Cancel), 4549 + ); 4550 + commit_armed_extrude( 4551 + &mut document, 4552 + &mut undo, 4553 + &Mode::Idle, 4554 + Some(shell::ConfirmAction::Accept), 4555 + ); 4556 + assert_eq!(extrude_node_count(&document), 0); 4557 + assert_eq!(undo.past_len(), 0); 4558 + } 4559 + 4560 + #[test] 4561 + fn commit_armed_extrude_edit_target_updates_in_place_keeping_label() { 4562 + let (mut document, sketch) = doc_with_default_sketch(); 4563 + let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); 4564 + let Ok(()) = document.rename_extrude(id, "Boss") else { 4565 + panic!("rename accepts"); 4566 + }; 4567 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 4568 + let mode = Mode::Extrude(ExtrudeArming::edit( 4569 + id, 4570 + sketch_mode::default_extrude_feature(sketch), 4571 + )); 4572 + commit_armed_extrude( 4573 + &mut document, 4574 + &mut undo, 4575 + &mode, 4576 + Some(shell::ConfirmAction::Accept), 4577 + ); 4578 + assert_eq!(extrude_node_count(&document), 1, "editing reuses the node"); 4579 + assert_eq!(document.extrude_label(id), Some("Boss")); 4580 + } 4581 + 4582 + #[test] 4583 + fn extrude_edit_mode_arms_from_idle_but_not_from_sketch() { 4584 + let (mut document, sketch) = doc_with_default_sketch(); 4585 + let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); 4586 + let from_idle = extrude_edit_mode(&document, &Mode::Idle, Some(id)); 4587 + assert!(matches!( 4588 + from_idle, 4589 + Some(Mode::Extrude(ExtrudeArming::Profile { target: Some(t), .. })) if t == id 4590 + )); 4591 + assert_eq!( 4592 + extrude_edit_mode(&document, &Mode::enter_sketch(sketch), Some(id)), 4593 + None, 4594 + "double-click is inert while sketching", 4595 + ); 4596 + assert_eq!( 4597 + extrude_edit_mode(&document, &Mode::Idle, Some(ExtrudeId::default())), 4598 + None, 4599 + "unknown extrude id arms nothing", 4600 + ); 4601 + } 4602 + 4603 + #[test] 4604 + fn active_solid_feature_tracks_mode_then_falls_back_to_committed() { 4605 + let (mut document, sketch) = doc_with_default_sketch(); 4606 + let armed = sketch_mode::default_extrude_feature(sketch); 4607 + assert_eq!( 4608 + active_solid_feature( 4609 + &Mode::Extrude(ExtrudeArming::profile(sketch)), 4610 + &document, 4611 + None, 4612 + ), 4613 + Some(armed), 4614 + "an armed profile previews its own feature", 4615 + ); 4616 + assert_eq!( 4617 + active_solid_feature(&Mode::enter_sketch(sketch), &document, None), 4618 + None, 4619 + "sketching shows the 2D scene, not a solid", 4620 + ); 4621 + assert_eq!( 4622 + active_solid_feature(&Mode::Idle, &document, None), 4623 + None, 4624 + "idle with no committed extrude shows no solid", 4625 + ); 4626 + let _ = document.commit_extrude(armed); 4627 + assert_eq!( 4628 + active_solid_feature(&Mode::Idle, &document, None), 4629 + Some(armed), 4630 + "idle falls back to the committed extrude", 4631 + ); 4632 + } 4633 + 4634 + #[test] 4635 + fn framed_extrude_overrides_last_committed_in_idle() { 4636 + let (mut document, sketch) = doc_with_default_sketch(); 4637 + let first_feature = sketch_mode::default_extrude_feature(sketch); 4638 + let mut second_feature = first_feature; 4639 + second_feature.merge_result = bone_document::MergeResult::Separate; 4640 + let first = document.commit_extrude(first_feature); 4641 + let _second = document.commit_extrude(second_feature); 4642 + assert_eq!( 4643 + active_solid_feature(&Mode::Idle, &document, None), 4644 + Some(second_feature), 4645 + "with no framed id, idle frames the last-committed extrude", 4646 + ); 4647 + assert_eq!( 4648 + active_solid_feature(&Mode::Idle, &document, Some(first)), 4649 + Some(first_feature), 4650 + "a framed id wins over the tree tip, so editing a non-last extrude stays framed", 4651 + ); 4652 + assert_eq!( 4653 + active_solid_feature(&Mode::Idle, &document, Some(ExtrudeId::default())), 4654 + Some(second_feature), 4655 + "a stale framed id self-heals to the last-committed extrude", 4656 + ); 4657 + } 4658 + 4659 + #[test] 4660 + fn apply_extrude_rename_into_writes_label_and_records_undo() { 4661 + let (mut document, sketch) = doc_with_default_sketch(); 4662 + let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); 4663 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 4664 + apply_extrude_rename_into( 4665 + &mut document, 4666 + &mut undo, 4667 + shell::ExtrudeRenameRequest { 4668 + id, 4669 + label: "Boss".to_owned(), 4670 + }, 4671 + ); 4672 + assert_eq!(document.extrude_label(id), Some("Boss")); 4673 + assert_eq!(undo.past_len(), 1); 4303 4674 } 4304 4675 4305 4676 #[test]
+236 -18
crates/bone-app/src/shell.rs
··· 3 3 use std::sync::Arc; 4 4 5 5 use bone_document::{ 6 - DimensionKind, DimensionValue, Document, ExtrudeEndCondition, ExtrudeFeature, MergeResult, 7 - Sketch, SketchDimension, SketchEntity, SketchRelation, SketchStatusReport, SketchVersion, 6 + DimensionKind, DimensionValue, Document, ExtrudeEndCondition, ExtrudeFeature, FeatureNode, 7 + MergeResult, Sketch, SketchDimension, SketchEntity, SketchRelation, SketchStatusReport, 8 + SketchVersion, 8 9 }; 9 10 use bone_types::{ 10 - Angle, Length, Point2, PositiveLength, SketchDimensionId, SketchEntityId, SketchId, 11 + Angle, ExtrudeId, Length, Point2, PositiveLength, SketchDimensionId, SketchEntityId, SketchId, 11 12 }; 12 13 use bone_ui::a11y::{AccessNode, Role}; 13 14 use bone_ui::frame::{FrameCtx, InteractDeclaration}; ··· 430 431 pub plane_picked: Option<Plane>, 431 432 pub sketch_activated: Option<SketchId>, 432 433 pub sketch_rename: Option<SketchRenameRequest>, 434 + pub extrude_activated: Option<ExtrudeId>, 435 + pub extrude_rename: Option<ExtrudeRenameRequest>, 433 436 pub exit_sketch: bool, 434 437 pub confirm_action: Option<ConfirmAction>, 435 438 pub menu_action: Option<MenuAction>, ··· 457 460 plane_picked: None, 458 461 sketch_activated: None, 459 462 sketch_rename: None, 463 + extrude_activated: None, 464 + extrude_rename: None, 460 465 exit_sketch: false, 461 466 confirm_action: None, 462 467 menu_action: None, ··· 674 679 self.state.status_panel_open = false; 675 680 } 676 681 } 682 + let confirm_visible = 683 + mode.is_sketch() || matches!(mode, Mode::Extrude(ExtrudeArming::Profile { .. })); 677 684 let confirm = 678 - render_confirm_corner(ctx, viewport_rect, &self.ids, mode.is_sketch(), &mut paints); 685 + render_confirm_corner(ctx, viewport_rect, &self.ids, confirm_visible, &mut paints); 679 686 let confirm_action = confirm; 680 687 let exit_sketch = confirm_action.is_some() || menu_action == Some(MenuAction::ExitSketch); 681 688 let activated_tool = activated_widget.and_then(|id| self.tool_index.get(&id).copied()); ··· 698 705 .and_then(|id| self.ids.plane_for(id)); 699 706 let sketch_activated = feature_tree.sketch_activated; 700 707 let sketch_rename = feature_tree.sketch_rename; 708 + let extrude_activated = feature_tree.extrude_activated; 709 + let extrude_rename = feature_tree.extrude_rename; 701 710 let mut dialog_paints: Vec<WidgetPaint> = Vec::new(); 702 711 let settings_change = render_settings_dialog( 703 712 ctx, ··· 732 741 plane_picked, 733 742 sketch_activated, 734 743 sketch_rename, 744 + extrude_activated, 745 + extrude_rename, 735 746 exit_sketch, 736 747 confirm_action, 737 748 menu_action, ··· 1614 1625 pub label: String, 1615 1626 } 1616 1627 1628 + #[derive(Clone, Debug, PartialEq)] 1629 + pub struct ExtrudeRenameRequest { 1630 + pub id: ExtrudeId, 1631 + pub label: String, 1632 + } 1633 + 1617 1634 struct FeatureTreeOutcome { 1618 1635 double_activated: Option<WidgetId>, 1619 1636 sketch_activated: Option<SketchId>, 1620 1637 sketch_rename: Option<SketchRenameRequest>, 1638 + extrude_activated: Option<ExtrudeId>, 1639 + extrude_rename: Option<ExtrudeRenameRequest>, 1621 1640 } 1622 1641 1623 1642 fn sketch_widget_id(part_id: WidgetId, sketch_id: SketchId) -> WidgetId { 1624 1643 part_id.child_indexed(WidgetKey::new("sketch"), sketch_id.as_u64()) 1625 1644 } 1626 1645 1646 + fn extrude_widget_id(part_id: WidgetId, extrude_id: ExtrudeId) -> WidgetId { 1647 + part_id.child_indexed(WidgetKey::new("extrude"), extrude_id.as_u64()) 1648 + } 1649 + 1650 + fn sketch_tree_rows(document: &Document, part_id: WidgetId) -> Vec<(SketchId, WidgetId, TreeNode)> { 1651 + document 1652 + .sketches() 1653 + .map(|(sketch_id, _)| { 1654 + let widget_id = sketch_widget_id(part_id, sketch_id); 1655 + let label = document.sketch_label(sketch_id).unwrap_or("").to_owned(); 1656 + let node = TreeNode::leaf_owned(widget_id, label).with_glyph(GlyphMark::TreeSketch); 1657 + (sketch_id, widget_id, node) 1658 + }) 1659 + .collect() 1660 + } 1661 + 1662 + fn extrude_tree_rows( 1663 + document: &Document, 1664 + part_id: WidgetId, 1665 + ) -> Vec<(ExtrudeId, WidgetId, TreeNode)> { 1666 + document 1667 + .feature_tree() 1668 + .iter() 1669 + .filter_map(|(_, node)| match node { 1670 + FeatureNode::Extrude(extrude_id) => Some(extrude_id), 1671 + _ => None, 1672 + }) 1673 + .map(|extrude_id| { 1674 + let widget_id = extrude_widget_id(part_id, extrude_id); 1675 + let label = document.extrude_label(extrude_id).unwrap_or("").to_owned(); 1676 + let node = TreeNode::leaf_owned(widget_id, label).with_glyph(GlyphMark::TreeFeature); 1677 + (extrude_id, widget_id, node) 1678 + }) 1679 + .collect() 1680 + } 1681 + 1627 1682 fn render_feature_tree( 1628 1683 ctx: &mut FrameCtx<'_>, 1629 1684 rect: LayoutRect, ··· 1638 1693 double_activated: None, 1639 1694 sketch_activated: None, 1640 1695 sketch_rename: None, 1696 + extrude_activated: None, 1697 + extrude_rename: None, 1641 1698 }; 1642 1699 } 1643 1700 let leaf = |key: &'static str, label: StringKey| { ··· 1648 1705 let placeholder = |key: &'static str, label: StringKey| feature_leaf(key, label).disabled(true); 1649 1706 let plane_leaf = 1650 1707 |key: &'static str, label: StringKey| leaf(key, label).with_glyph(GlyphMark::TreePlane); 1651 - let sketch_rows: Vec<(SketchId, WidgetId, TreeNode)> = document 1652 - .sketches() 1653 - .map(|(sketch_id, _)| { 1654 - let widget_id = sketch_widget_id(part_id, sketch_id); 1655 - let label = document.sketch_label(sketch_id).unwrap_or("").to_owned(); 1656 - let node = TreeNode::leaf_owned(widget_id, label).with_glyph(GlyphMark::TreeSketch); 1657 - (sketch_id, widget_id, node) 1658 - }) 1708 + let sketch_rows = sketch_tree_rows(document, part_id); 1709 + let extrude_rows = extrude_tree_rows(document, part_id); 1710 + let renamable: Vec<WidgetId> = sketch_rows 1711 + .iter() 1712 + .map(|(_, w, _)| *w) 1713 + .chain(extrude_rows.iter().map(|(_, w, _)| *w)) 1659 1714 .collect(); 1660 - let renamable: Vec<WidgetId> = sketch_rows.iter().map(|(_, w, _)| *w).collect(); 1661 1715 let widget_to_sketch: BTreeMap<WidgetId, SketchId> = 1662 1716 sketch_rows.iter().map(|(s, w, _)| (*w, *s)).collect(); 1717 + let widget_to_extrude: BTreeMap<WidgetId, ExtrudeId> = 1718 + extrude_rows.iter().map(|(e, w, _)| (*w, *e)).collect(); 1663 1719 let children: Vec<TreeNode> = [ 1664 1720 placeholder("history", strings::FEATURE_HISTORY), 1665 1721 placeholder("sensors", strings::FEATURE_SENSORS), ··· 1673 1729 ] 1674 1730 .into_iter() 1675 1731 .chain(sketch_rows.into_iter().map(|(_, _, node)| node)) 1732 + .chain(extrude_rows.into_iter().map(|(_, _, node)| node)) 1676 1733 .collect(); 1677 1734 let part = TreeNode::parent_owned(part_id, document.name().to_owned(), children); 1678 1735 let roots = [part]; ··· 1685 1742 let sketch_activated = response 1686 1743 .double_activated 1687 1744 .and_then(|id| widget_to_sketch.get(&id).copied()); 1745 + let extrude_activated = response 1746 + .double_activated 1747 + .and_then(|id| widget_to_extrude.get(&id).copied()); 1688 1748 let sketch_rename = response 1689 1749 .rename_committed 1750 + .as_ref() 1690 1751 .and_then(|RenameCommit { id, text }| { 1691 1752 widget_to_sketch 1692 - .get(&id) 1753 + .get(id) 1693 1754 .copied() 1694 1755 .map(|sketch_id| SketchRenameRequest { 1695 1756 id: sketch_id, 1696 - label: text, 1757 + label: text.clone(), 1697 1758 }) 1698 1759 }); 1760 + let extrude_rename = 1761 + response 1762 + .rename_committed 1763 + .as_ref() 1764 + .and_then(|RenameCommit { id, text }| { 1765 + widget_to_extrude 1766 + .get(id) 1767 + .copied() 1768 + .map(|extrude_id| ExtrudeRenameRequest { 1769 + id: extrude_id, 1770 + label: text.clone(), 1771 + }) 1772 + }); 1699 1773 FeatureTreeOutcome { 1700 1774 double_activated: response.double_activated, 1701 1775 sketch_activated, 1702 1776 sketch_rename, 1777 + extrude_activated, 1778 + extrude_rename, 1703 1779 } 1704 1780 } 1705 1781 ··· 1739 1815 if !matches!(resolved, Some((_, SelectionTarget::Dimension(_, _)))) { 1740 1816 *editors.dim = None; 1741 1817 } 1742 - if !matches!(state.mode, Mode::Extrude(ExtrudeArming::Profile(_))) { 1818 + if !matches!(state.mode, Mode::Extrude(ExtrudeArming::Profile { .. })) { 1743 1819 *editors.extrude = None; 1744 1820 } 1745 1821 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { ··· 1747 1823 } 1748 1824 if let Mode::Extrude(arming) = state.mode { 1749 1825 return match arming { 1750 - ExtrudeArming::Profile(feature) => PropertyPaneOutcome { 1826 + ExtrudeArming::Profile { feature, .. } => PropertyPaneOutcome { 1751 1827 dimension_edit: None, 1752 1828 extrude_edit: render_extrude_rows( 1753 1829 ctx, ··· 3468 3544 } 3469 3545 3470 3546 fn profile_feature() -> ExtrudeFeature { 3471 - let ExtrudeArming::Profile(feature) = ExtrudeArming::profile(SketchId::default()) else { 3547 + let ExtrudeArming::Profile { feature, .. } = ExtrudeArming::profile(SketchId::default()) 3548 + else { 3472 3549 unreachable!("profile arming holds a feature"); 3473 3550 }; 3474 3551 feature ··· 4282 4359 .child_indexed(WidgetKey::new("sketch"), sketch_id.as_u64()) 4283 4360 } 4284 4361 4362 + fn extrude_widget(ids: &ShellIds, extrude_id: ExtrudeId) -> WidgetId { 4363 + ids.feature_part 4364 + .child_indexed(WidgetKey::new("extrude"), extrude_id.as_u64()) 4365 + } 4366 + 4367 + fn document_with_extrude() -> (Document, ExtrudeId) { 4368 + let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 4369 + let (mut document, sketch_id) = document_with_sketch(sketch); 4370 + let extrude_id = 4371 + document.commit_extrude(crate::sketch_mode::default_extrude_feature(sketch_id)); 4372 + (document, extrude_id) 4373 + } 4374 + 4285 4375 #[test] 4286 4376 fn f2_with_focused_sketch_row_starts_rename_in_full_shell() { 4287 4377 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); ··· 4601 4691 frame.sketch_activated, 4602 4692 Some(sketch_id), 4603 4693 "double-click on sketch row must emit sketch_activated for that sketch", 4694 + ); 4695 + } 4696 + 4697 + #[test] 4698 + fn double_click_extrude_row_emits_extrude_activated() { 4699 + use bone_ui::input::{PointerButton, PointerButtonMask, PointerSample}; 4700 + let (document, extrude_id) = document_with_extrude(); 4701 + let mut shell = Shell::new(); 4702 + let widget = extrude_widget(&shell.ids, extrude_id); 4703 + let mut focus = FocusManager::new(); 4704 + let mut prev = HitState::new(); 4705 + 4706 + let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 4707 + let (_, hits) = shell_drive( 4708 + &mut shell, 4709 + &document, 4710 + &Mode::Idle, 4711 + &Selection::default(), 4712 + &mut focus, 4713 + &mut prev, 4714 + &mut warm, 4715 + ); 4716 + let Some(row_rect) = hits 4717 + .items() 4718 + .iter() 4719 + .find(|item| item.id == widget) 4720 + .map(|item| item.rect) 4721 + else { 4722 + panic!("extrude row must register a hit item"); 4723 + }; 4724 + let center = LayoutPos::new( 4725 + LayoutPx::new(row_rect.origin.x.value() + row_rect.size.width.value() / 2.0), 4726 + LayoutPx::new(row_rect.origin.y.value() + row_rect.size.height.value() / 2.0), 4727 + ); 4728 + let click = |shell: &mut Shell, focus: &mut FocusManager, prev: &mut HitState| { 4729 + let mut press = InputSnapshot::idle(FrameInstant::ZERO); 4730 + press.pointer = Some(PointerSample::new(center)); 4731 + press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 4732 + let _ = drive_with_snap( 4733 + shell, 4734 + &document, 4735 + &Mode::Idle, 4736 + &Selection::default(), 4737 + focus, 4738 + prev, 4739 + press, 4740 + ); 4741 + let mut release = InputSnapshot::idle(FrameInstant::ZERO); 4742 + release.pointer = Some(PointerSample::new(center)); 4743 + release.buttons_released = PointerButtonMask::just(PointerButton::Primary); 4744 + drive_with_snap( 4745 + shell, 4746 + &document, 4747 + &Mode::Idle, 4748 + &Selection::default(), 4749 + focus, 4750 + prev, 4751 + release, 4752 + ) 4753 + }; 4754 + let _ = click(&mut shell, &mut focus, &mut prev); 4755 + let _ = click(&mut shell, &mut focus, &mut prev); 4756 + let mut idle = InputSnapshot::idle(FrameInstant::ZERO); 4757 + idle.pointer = Some(PointerSample::new(center)); 4758 + let (frame, _) = drive_with_snap( 4759 + &mut shell, 4760 + &document, 4761 + &Mode::Idle, 4762 + &Selection::default(), 4763 + &mut focus, 4764 + &mut prev, 4765 + idle, 4766 + ); 4767 + assert_eq!( 4768 + frame.extrude_activated, 4769 + Some(extrude_id), 4770 + "double-click on extrude row must emit extrude_activated for that extrude", 4771 + ); 4772 + } 4773 + 4774 + #[test] 4775 + fn f2_with_focused_extrude_row_starts_rename_in_full_shell() { 4776 + let (document, extrude_id) = document_with_extrude(); 4777 + let mut shell = Shell::new(); 4778 + let widget = extrude_widget(&shell.ids, extrude_id); 4779 + let mut focus = FocusManager::new(); 4780 + let mut prev = HitState::new(); 4781 + 4782 + let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 4783 + let (_, _) = shell_drive( 4784 + &mut shell, 4785 + &document, 4786 + &Mode::Idle, 4787 + &Selection::default(), 4788 + &mut focus, 4789 + &mut prev, 4790 + &mut warm, 4791 + ); 4792 + focus.request_focus(widget); 4793 + let mut warm2 = InputSnapshot::idle(FrameInstant::ZERO); 4794 + let (_, _) = shell_drive( 4795 + &mut shell, 4796 + &document, 4797 + &Mode::Idle, 4798 + &Selection::default(), 4799 + &mut focus, 4800 + &mut prev, 4801 + &mut warm2, 4802 + ); 4803 + 4804 + let mut f2 = InputSnapshot::idle(FrameInstant::ZERO); 4805 + f2.keys_pressed.push(bone_ui::input::KeyEvent::new( 4806 + bone_ui::input::KeyCode::Named(bone_ui::input::NamedKey::F2), 4807 + bone_ui::input::ModifierMask::NONE, 4808 + )); 4809 + let (_, _) = shell_drive( 4810 + &mut shell, 4811 + &document, 4812 + &Mode::Idle, 4813 + &Selection::default(), 4814 + &mut focus, 4815 + &mut prev, 4816 + &mut f2, 4817 + ); 4818 + assert_eq!( 4819 + shell.state.feature_tree.renaming, 4820 + Some(widget), 4821 + "F2 with extrude row focused must enter rename", 4604 4822 ); 4605 4823 } 4606 4824
+20 -5
crates/bone-app/src/sketch_mode.rs
··· 5 5 SketchDimension, SketchEntity, 6 6 }; 7 7 use bone_types::{ 8 - Length, Point2, Point3, PositiveLength, SketchEntityId, SketchId, SketchPlaneBasis, Tolerance, 9 - UnitVec3, 8 + ExtrudeId, Length, Point2, Point3, PositiveLength, SketchEntityId, SketchId, SketchPlaneBasis, 9 + Tolerance, UnitVec3, 10 10 }; 11 11 use bone_ui::hotkey::ActionId; 12 12 use uom::si::length::millimeter; ··· 56 56 57 57 #[derive(Copy, Clone, Debug, PartialEq)] 58 58 pub enum ExtrudeArming { 59 - Profile(ExtrudeFeature), 59 + Profile { 60 + feature: ExtrudeFeature, 61 + target: Option<ExtrudeId>, 62 + }, 60 63 AwaitingSketch, 61 64 } 62 65 63 66 impl ExtrudeArming { 64 67 #[must_use] 65 68 pub fn profile(sketch: SketchId) -> Self { 66 - Self::Profile(default_extrude_feature(sketch)) 69 + Self::Profile { 70 + feature: default_extrude_feature(sketch), 71 + target: None, 72 + } 73 + } 74 + 75 + #[must_use] 76 + pub fn edit(target: ExtrudeId, feature: ExtrudeFeature) -> Self { 77 + Self::Profile { 78 + feature, 79 + target: Some(target), 80 + } 67 81 } 68 82 } 69 83 ··· 745 759 #[test] 746 760 fn extrude_arming_profile_carries_the_sketch() { 747 761 let id = SketchId::default(); 748 - let ExtrudeArming::Profile(feature) = ExtrudeArming::profile(id) else { 762 + let ExtrudeArming::Profile { feature, target } = ExtrudeArming::profile(id) else { 749 763 panic!("profile arming holds a feature"); 750 764 }; 751 765 assert_eq!(feature.sketch, id); 766 + assert_eq!(target, None); 752 767 } 753 768 }
+2 -11
crates/bone-document/src/document/feature_tree.rs
··· 2 2 use bone_types::{BodyId, ExtrudeId, FeatureId, SketchId}; 3 3 use serde::{Deserialize, Serialize}; 4 4 5 - use super::{key_from_index, key_index}; 5 + use super::{key_from_index, next_key}; 6 6 7 7 #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 8 8 pub enum PrincipalPlane { ··· 174 174 } 175 175 176 176 pub(crate) fn allocate(&self) -> FeatureId { 177 - let highest = self 178 - .entries 179 - .iter() 180 - .map(|e| key_index(e.id)) 181 - .max() 182 - .unwrap_or(0); 183 - let Some(next) = highest.checked_add(1) else { 184 - panic!("FeatureTree exhausted 32-bit feature id space"); 185 - }; 186 - key_from_index(next) 177 + next_key(self.entries.iter().map(|e| e.id)) 187 178 } 188 179 } 189 180
+134 -17
crates/bone-document/src/document/mod.rs
··· 132 132 EmptyLabel, 133 133 } 134 134 135 + #[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)] 136 + pub enum RenameExtrudeError { 137 + #[error("extrude {0:?} not found")] 138 + UnknownExtrude(ExtrudeId), 139 + #[error("extrude label must contain non-whitespace characters")] 140 + EmptyLabel, 141 + } 142 + 135 143 #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] 136 144 #[serde(deny_unknown_fields)] 137 145 pub struct DocumentParameters { ··· 180 188 pub sketches: SketchRegistry, 181 189 #[serde(skip)] 182 190 pub extrudes: BTreeMap<ExtrudeId, ExtrudeFeature>, 191 + #[serde(skip)] 192 + pub extrude_labels: BTreeMap<ExtrudeId, String>, 183 193 } 184 194 185 195 impl DocumentHeader { ··· 194 204 feature_tree: FeatureTree::seeded(), 195 205 sketches: SketchRegistry::new(), 196 206 extrudes: BTreeMap::new(), 207 + extrude_labels: BTreeMap::new(), 197 208 } 198 209 } 199 210 ··· 225 236 pub struct ExtrudeFile { 226 237 pub schema: SchemaHeader, 227 238 pub feature: ExtrudeFeature, 239 + pub label: String, 228 240 } 229 241 230 242 impl ExtrudeFile { 231 243 #[must_use] 232 - pub fn new(feature: ExtrudeFeature) -> Self { 244 + pub fn new(feature: ExtrudeFeature, label: String) -> Self { 233 245 Self { 234 246 schema: SchemaHeader::bone_document(), 235 247 feature, 248 + label, 236 249 } 237 250 } 238 251 } ··· 369 382 pub fn insert_extrude(&mut self, id: ExtrudeId, feature: ExtrudeFeature) { 370 383 self.header.feature_tree.push_extrude(id, &feature); 371 384 self.header.extrudes.insert(id, feature); 385 + self.header 386 + .extrude_labels 387 + .entry(id) 388 + .or_insert_with(|| format!("Extrude{}", key_index(id))); 389 + } 390 + 391 + #[must_use] 392 + pub fn commit_extrude(&mut self, feature: ExtrudeFeature) -> ExtrudeId { 393 + let id = next_key(self.header.extrudes.keys().copied()); 394 + self.insert_extrude(id, feature); 395 + id 372 396 } 373 397 374 398 pub fn remove_extrude(&mut self, id: ExtrudeId) -> Option<ExtrudeFeature> { 375 399 self.header.feature_tree.remove_extrude(id); 400 + self.header.extrude_labels.remove(&id); 376 401 self.header.extrudes.remove(&id) 377 402 } 378 403 404 + #[must_use] 405 + pub fn extrude(&self, id: ExtrudeId) -> Option<&ExtrudeFeature> { 406 + self.header.extrudes.get(&id) 407 + } 408 + 409 + #[must_use] 410 + pub fn extrude_label(&self, id: ExtrudeId) -> Option<&str> { 411 + self.header.extrude_labels.get(&id).map(String::as_str) 412 + } 413 + 414 + pub fn rename_extrude(&mut self, id: ExtrudeId, label: &str) -> Result<(), RenameExtrudeError> { 415 + let trimmed = label.trim(); 416 + if trimmed.is_empty() { 417 + return Err(RenameExtrudeError::EmptyLabel); 418 + } 419 + let slot = self 420 + .header 421 + .extrude_labels 422 + .get_mut(&id) 423 + .ok_or(RenameExtrudeError::UnknownExtrude(id))?; 424 + trimmed.clone_into(slot); 425 + Ok(()) 426 + } 427 + 379 428 fn extrudes_of_sketch(&self, sketch: SketchId) -> impl Iterator<Item = ExtrudeId> + '_ { 380 429 self.header 381 430 .extrudes ··· 425 474 build: impl FnOnce(FeatureId) -> Result<BrepSolid, E>, 426 475 ) -> Result<(FeatureId, BodyId), E> { 427 476 let feature = self.header.feature_tree.allocate(); 428 - let body = self.next_body_id(); 477 + let body = next_key(self.bodies.keys().copied()); 429 478 let solid = build(feature)?; 430 479 self.header.feature_tree.push_imported_body(feature, body); 431 480 self.bodies.insert(body, ImportedSolid::new(solid)); ··· 451 500 pub fn imported_bodies(&self) -> impl Iterator<Item = (BodyId, &BrepSolid)> + '_ { 452 501 self.bodies.iter().map(|(id, body)| (*id, body.solid())) 453 502 } 454 - 455 - fn next_body_id(&self) -> BodyId { 456 - let highest = self 457 - .bodies 458 - .keys() 459 - .copied() 460 - .map(key_index) 461 - .max() 462 - .unwrap_or(0); 463 - let Some(next) = highest.checked_add(1) else { 464 - panic!("document exhausted 32-bit body id space"); 465 - }; 466 - key_from_index(next) 467 - } 468 503 } 469 504 470 505 pub(crate) fn key_index<K: slotmap::Key>(id: K) -> u32 { ··· 478 513 K::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 479 514 } 480 515 516 + pub(crate) fn next_key<K: slotmap::Key>(existing: impl Iterator<Item = K>) -> K { 517 + let highest = existing.map(key_index).max().unwrap_or(0); 518 + let Some(next) = highest.checked_add(1) else { 519 + panic!( 520 + "exhausted 32-bit key space for {}", 521 + core::any::type_name::<K>() 522 + ); 523 + }; 524 + key_from_index(next) 525 + } 526 + 481 527 fn id_stem<K: slotmap::Key>(id: K) -> String { 482 528 format!("{:016x}", id.data().as_ffi()) 483 529 } ··· 505 551 #[cfg(test)] 506 552 mod tests { 507 553 use super::feature_tree::sample_blind_extrude; 508 - use super::{Document, RenameSketchError, Sketch, SketchId}; 554 + use super::{Document, RenameExtrudeError, RenameSketchError, Sketch, SketchId}; 509 555 use bone_types::{DocumentId, ExtrudeId, Point3, SketchPlaneBasis, Tolerance, UnitVec3}; 510 556 511 557 fn xy_basis() -> SketchPlaneBasis { ··· 631 677 extrude: extrude_feature, 632 678 }] 633 679 ); 680 + } 681 + 682 + #[test] 683 + fn commit_extrude_assigns_incrementing_default_labels() { 684 + let (mut document, sketch) = doc_with_sketch(); 685 + let first = document.commit_extrude(sample_blind_extrude(sketch)); 686 + let second = document.commit_extrude(sample_blind_extrude(sketch)); 687 + assert_ne!(first, second); 688 + assert_eq!(document.extrude_label(first), Some("Extrude1")); 689 + assert_eq!(document.extrude_label(second), Some("Extrude2")); 690 + } 691 + 692 + #[test] 693 + fn rename_extrude_updates_and_trims_label() { 694 + let (mut document, sketch) = doc_with_sketch(); 695 + let id = document.commit_extrude(sample_blind_extrude(sketch)); 696 + let Ok(()) = document.rename_extrude(id, " Boss \n") else { 697 + panic!("rename should trim and accept"); 698 + }; 699 + assert_eq!(document.extrude_label(id), Some("Boss")); 700 + } 701 + 702 + #[test] 703 + fn rename_extrude_rejects_empty_and_unknown() { 704 + let (mut document, sketch) = doc_with_sketch(); 705 + let id = document.commit_extrude(sample_blind_extrude(sketch)); 706 + assert_eq!( 707 + document.rename_extrude(id, " "), 708 + Err(RenameExtrudeError::EmptyLabel) 709 + ); 710 + assert_eq!(document.extrude_label(id), Some("Extrude1")); 711 + let stranger = ExtrudeId::default(); 712 + assert_eq!( 713 + document.rename_extrude(stranger, "Boss"), 714 + Err(RenameExtrudeError::UnknownExtrude(stranger)) 715 + ); 716 + } 717 + 718 + #[test] 719 + fn remove_extrude_drops_label() { 720 + let (mut document, sketch) = doc_with_sketch(); 721 + let id = document.commit_extrude(sample_blind_extrude(sketch)); 722 + document.remove_extrude(id); 723 + assert_eq!(document.extrude_label(id), None); 724 + } 725 + 726 + #[test] 727 + fn commit_after_removing_earlier_extrude_keeps_labels_unique() { 728 + let (mut document, sketch) = doc_with_sketch(); 729 + let first = document.commit_extrude(sample_blind_extrude(sketch)); 730 + let second = document.commit_extrude(sample_blind_extrude(sketch)); 731 + document.remove_extrude(first); 732 + let third = document.commit_extrude(sample_blind_extrude(sketch)); 733 + assert_eq!(document.extrude_label(second), Some("Extrude2")); 734 + assert_eq!(document.extrude_label(third), Some("Extrude3")); 735 + assert_ne!( 736 + document.extrude_label(second), 737 + document.extrude_label(third), 738 + "default labels track the id, so remove + re-commit cannot collide", 739 + ); 740 + } 741 + 742 + #[test] 743 + fn reinsert_existing_extrude_preserves_renamed_label() { 744 + let (mut document, sketch) = doc_with_sketch(); 745 + let id = document.commit_extrude(sample_blind_extrude(sketch)); 746 + let Ok(()) = document.rename_extrude(id, "Boss") else { 747 + panic!("rename accepts"); 748 + }; 749 + document.insert_extrude(id, sample_blind_extrude(sketch)); 750 + assert_eq!(document.extrude_label(id), Some("Boss")); 634 751 } 635 752 }
+18 -9
crates/bone-document/src/io/folder.rs
··· 239 239 .filter(|(id, _)| tree_extrudes.contains(*id)) 240 240 .try_for_each(|(id, feature)| -> Result<(), FolderError> { 241 241 let path = folder.extrude_path(*id); 242 - let ron = to_ron(&path, &ExtrudeFile::new(*feature))?; 242 + let label = document.extrude_label(*id).unwrap_or_default().to_owned(); 243 + let ron = to_ron(&path, &ExtrudeFile::new(*feature, label))?; 243 244 write_if_different(&path, &ron) 244 245 })?; 245 246 ··· 335 336 let header_text = read_to_string(&header_path)?; 336 337 let mut header: DocumentHeader = from_ron(&header_path, &header_text)?; 337 338 check_schema(&header.schema)?; 338 - let extrudes = read_extrudes(folder, &header)?; 339 + let (extrudes, extrude_labels) = read_extrudes(folder, &header)?; 339 340 header.extrudes = extrudes; 341 + header.extrude_labels = extrude_labels; 340 342 validate_header(&header)?; 341 343 342 344 let sketches = ··· 420 422 } 421 423 } 422 424 425 + type ExtrudeData = ( 426 + BTreeMap<ExtrudeId, ExtrudeFeature>, 427 + BTreeMap<ExtrudeId, String>, 428 + ); 429 + 423 430 fn read_extrudes( 424 431 folder: &DocumentFolder, 425 432 header: &DocumentHeader, 426 - ) -> Result<BTreeMap<ExtrudeId, ExtrudeFeature>, FolderError> { 427 - tree_extrude_ids(header) 428 - .into_iter() 429 - .try_fold(BTreeMap::new(), |mut acc, id| { 433 + ) -> Result<ExtrudeData, FolderError> { 434 + tree_extrude_ids(header).into_iter().try_fold( 435 + (BTreeMap::new(), BTreeMap::new()), 436 + |(mut features, mut labels), id| { 430 437 let path = folder.extrude_path(id); 431 438 let text = read_to_string(&path).map_err(|e| match e.into_kind() { 432 439 FolderErrorKind::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => { ··· 436 443 })?; 437 444 let file: ExtrudeFile = from_ron(&path, &text)?; 438 445 check_schema(&file.schema)?; 439 - acc.insert(id, file.feature); 440 - Ok::<_, FolderError>(acc) 441 - }) 446 + features.insert(id, file.feature); 447 + labels.insert(id, file.label); 448 + Ok::<_, FolderError>((features, labels)) 449 + }, 450 + ) 442 451 } 443 452 444 453 fn validate_header(header: &DocumentHeader) -> Result<(), FolderError> {
+2 -2
crates/bone-document/src/lib.rs
··· 11 11 }; 12 12 pub use document::{ 13 13 Document, DocumentHeader, DocumentParameters, ExtrudeFile, FeatureEdge, FeatureNode, 14 - FeatureTree, ImportedSolid, PrincipalPlane, RenameSketchError, SketchFile, SketchRegistry, 15 - SketchRegistryEntry, UnitsPreference, extrude_filename, sketch_filename, 14 + FeatureTree, ImportedSolid, PrincipalPlane, RenameExtrudeError, RenameSketchError, SketchFile, 15 + SketchRegistry, SketchRegistryEntry, UnitsPreference, extrude_filename, sketch_filename, 16 16 }; 17 17 pub use evaluator::{ 18 18 EvaluatedExtrude, EvaluatedSketch, ExtrudeError, FeatureCache, evaluate_extrude,
+21 -1
crates/bone-document/tests/folder_roundtrip.rs
··· 229 229 else { 230 230 panic!("expected UnsupportedMajor"); 231 231 }; 232 - assert_eq!(found, SchemaVersion::new(9999, 1)); 232 + assert_eq!(found, SchemaVersion::new(9999, 2)); 233 233 assert_eq!( 234 234 supported, 235 235 SchemaVersion::new( ··· 590 590 Some(&blind_extrude(sketch_id(1))) 591 591 ); 592 592 assert_eq!(loaded.feature_tree().edges().len(), 1); 593 + } 594 + 595 + #[test] 596 + fn renamed_extrude_label_survives_folder_roundtrip() { 597 + let dir = ok_dir(); 598 + let folder = DocumentFolder::new(dir.path().join("labeled.bone")); 599 + let mut doc = Document::new(document_id(1), "labeled".to_owned()); 600 + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 601 + doc.insert_extrude(extrude_id(1), blind_extrude(sketch_id(1))); 602 + let Ok(()) = doc.rename_extrude(extrude_id(1), "Boss") else { 603 + panic!("rename accepts a non-empty label"); 604 + }; 605 + assert_save(&doc, &folder); 606 + 607 + let loaded = assert_load(&folder); 608 + assert_eq!( 609 + loaded.extrude_label(extrude_id(1)), 610 + Some("Boss"), 611 + "the file's stored label is read back, not recomputed from the id", 612 + ); 593 613 } 594 614 595 615 #[test]
+12 -9
crates/bone-document/tests/folder_snapshots.rs
··· 203 203 let Ok(depth) = PositiveLength::new(mm(10.0)) else { 204 204 panic!("positive depth"); 205 205 }; 206 - let file = ExtrudeFile::new(ExtrudeFeature { 207 - sketch: sketch_id(7), 208 - direction: ExtrudeDirection::Normal { 209 - sense: ExtrudeSense::Forward, 206 + let file = ExtrudeFile::new( 207 + ExtrudeFeature { 208 + sketch: sketch_id(7), 209 + direction: ExtrudeDirection::Normal { 210 + sense: ExtrudeSense::Forward, 211 + }, 212 + end_condition: ExtrudeEndCondition::Blind { depth }, 213 + draft: None, 214 + thin_wall: None, 215 + merge_result: MergeResult::Merge, 210 216 }, 211 - end_condition: ExtrudeEndCondition::Blind { depth }, 212 - draft: None, 213 - thin_wall: None, 214 - merge_result: MergeResult::Merge, 215 - }); 217 + "Extrude1".to_owned(), 218 + ); 216 219 let ron = assert_ron(&file); 217 220 insta::assert_snapshot!("extrude_file", ron); 218 221 }
+1 -1
crates/bone-document/tests/snapshots/folder_snapshots__document_header.snap
··· 10 10 name: "bone-document", 11 11 version: SchemaVersion( 12 12 major: 1, 13 - minor: 1, 13 + minor: 2, 14 14 ), 15 15 ), 16 16 id: SerKey(
+2 -1
crates/bone-document/tests/snapshots/folder_snapshots__extrude_file.snap
··· 10 10 name: "bone-document", 11 11 version: SchemaVersion( 12 12 major: 1, 13 - minor: 1, 13 + minor: 2, 14 14 ), 15 15 ), 16 16 feature: ExtrudeFeature( ··· 28 28 thin_wall: None, 29 29 merge_result: Merge, 30 30 ), 31 + label: "Extrude1", 31 32 )
+1 -1
crates/bone-document/tests/snapshots/folder_snapshots__sketch_file.snap
··· 10 10 name: "bone-document", 11 11 version: SchemaVersion( 12 12 major: 1, 13 - minor: 1, 13 + minor: 2, 14 14 ), 15 15 ), 16 16 sketch: Sketch(
+110
crates/bone-render/src/camera3.rs
··· 150 150 )) 151 151 } 152 152 153 + pub fn orbit_yaw(camera: Camera3, angle: Angle) -> Result<Camera3> { 154 + orbit_about_point(camera, camera.target(), AxisAngle::new(camera.up(), angle)) 155 + } 156 + 157 + pub fn orbit_pitch(camera: Camera3, angle: Angle) -> Result<Camera3> { 158 + let right = screen_right(camera)?; 159 + orbit_about_point(camera, camera.target(), AxisAngle::new(right, angle)) 160 + } 161 + 162 + pub fn roll_by(camera: Camera3, angle: Angle) -> Result<Camera3> { 163 + let forward = view_direction(camera)?; 164 + Camera3::new( 165 + camera.eye(), 166 + camera.target(), 167 + camera.up().rotated(AxisAngle::new(forward, angle)), 168 + camera.projection(), 169 + ) 170 + } 171 + 153 172 pub fn roll_about_view( 154 173 camera: Camera3, 155 174 extent: ViewportExtent, ··· 170 189 .rotated(AxisAngle::new(forward, Angle::new::<radian>(angle))), 171 190 camera.projection(), 172 191 ) 192 + } 193 + 194 + pub fn frame_current(camera: Camera3, aabb: Aabb3, extent: ViewportExtent) -> Result<Camera3> { 195 + let to_eye = (camera.eye() - camera.target()).try_normalize(RAY_TOLERANCE)?; 196 + frame_along(aabb, extent, to_eye, camera.up()) 173 197 } 174 198 175 199 pub fn frame_isometric(aabb: Aabb3, extent: ViewportExtent) -> Result<Camera3> { ··· 330 354 331 355 fn view_direction(camera: Camera3) -> Result<UnitVec3> { 332 356 (camera.target() - camera.eye()).try_normalize(RAY_TOLERANCE) 357 + } 358 + 359 + fn screen_right(camera: Camera3) -> Result<UnitVec3> { 360 + let (fx, fy, fz) = view_direction(camera)?.components(); 361 + let (ux, uy, uz) = camera.up().components(); 362 + let right = NVec3::new(fx, fy, fz).cross(&NVec3::new(ux, uy, uz)); 363 + let norm = right.norm(); 364 + if norm < ARCBALL_MIN_AXIS { 365 + return Err(TypesError::ZeroLengthAxis); 366 + } 367 + let unit = right / norm; 368 + Ok(UnitVec3::new_unchecked(unit.x, unit.y, unit.z)) 333 369 } 334 370 335 371 fn arcball_vector( ··· 714 750 assert!( 715 751 (ux - 1.0).abs() < 1e-9 && uy.abs() < 1e-9 && uz.abs() < 1e-9, 716 752 "a right-then-below screen sweep rolls up from +z to +x: ({ux}, {uy}, {uz})" 753 + ); 754 + } 755 + 756 + #[test] 757 + fn orbit_yaw_holds_the_target_and_swings_the_eye() { 758 + let Ok(yawed) = orbit_yaw(ortho_camera(), Angle::new::<degree>(30.0)) else { 759 + panic!("a yaw rotates the camera"); 760 + }; 761 + assert!( 762 + close(yawed.target(), ortho_camera().target(), 1e-9), 763 + "a yaw pivots on the target" 764 + ); 765 + assert_ne!( 766 + yawed.eye(), 767 + ortho_camera().eye(), 768 + "a yaw swings the eye about the up axis" 769 + ); 770 + } 771 + 772 + #[test] 773 + fn orbit_pitch_holds_the_target_and_swings_the_eye() { 774 + let Ok(pitched) = orbit_pitch(ortho_camera(), Angle::new::<degree>(20.0)) else { 775 + panic!("a pitch rotates the camera"); 776 + }; 777 + assert!( 778 + close(pitched.target(), ortho_camera().target(), 1e-9), 779 + "a pitch pivots on the target" 780 + ); 781 + assert_ne!( 782 + pitched.eye(), 783 + ortho_camera().eye(), 784 + "a pitch swings the eye about the screen-right axis" 785 + ); 786 + } 787 + 788 + #[test] 789 + fn roll_by_keeps_eye_and_target_but_reorients_up() { 790 + let Ok(rolled) = roll_by(ortho_camera(), Angle::new::<degree>(25.0)) else { 791 + panic!("a roll rotates the camera"); 792 + }; 793 + assert!( 794 + close(rolled.eye(), ortho_camera().eye(), 1e-9) 795 + && close(rolled.target(), ortho_camera().target(), 1e-9), 796 + "a roll keeps the eye and target fixed" 797 + ); 798 + assert_ne!( 799 + rolled.up(), 800 + ortho_camera().up(), 801 + "a roll reorients the up vector" 802 + ); 803 + } 804 + 805 + #[test] 806 + fn frame_current_keeps_the_view_direction_and_centers_the_box() { 807 + let cube = Aabb3::from_corners( 808 + Point3::from_mm(0.0, 0.0, 0.0), 809 + Point3::from_mm(2.0, 2.0, 2.0), 810 + ); 811 + let before = ortho_camera(); 812 + let Ok(framed) = frame_current(before, cube, extent()) else { 813 + panic!("a non-degenerate box frames"); 814 + }; 815 + assert!( 816 + close(focal(framed, center()), cube.center(), 1e-6), 817 + "fit looks at the box center" 818 + ); 819 + let (Ok(before_dir), Ok(after_dir)) = (view_direction(before), view_direction(framed)) 820 + else { 821 + panic!("both cameras have a view direction"); 822 + }; 823 + let dot = before_dir.dot(after_dir); 824 + assert!( 825 + (dot - 1.0).abs() < 1e-6, 826 + "fit reframes without changing the view direction: {dot}" 717 827 ); 718 828 } 719 829
+3 -3
crates/bone-render/src/lib.rs
··· 13 13 14 14 pub use camera::{Camera2, GridSpacing, PixelsPerMm, ViewportExtent, ViewportPx, ViewportRegion}; 15 15 pub use camera3::{ 16 - ViewportPoint, arcball_rotation, clip_from_world, frame_isometric, frame_standard_view, 17 - orbit_about_pixel, orbit_about_point, pan_pixels, roll_about_view, world_from_clip, 18 - world_on_focal_plane, world_ray, zoom_about_pixel, 16 + ViewportPoint, arcball_rotation, clip_from_world, frame_current, frame_isometric, 17 + frame_standard_view, orbit_about_pixel, orbit_about_point, orbit_pitch, orbit_yaw, pan_pixels, 18 + roll_about_view, roll_by, world_from_clip, world_on_focal_plane, world_ray, zoom_about_pixel, 19 19 }; 20 20 pub use diff::{PixelDiff, PixelDiffError, PixelDiffReport, PixelDiffThreshold, PixelMismatch}; 21 21 pub use gpu::{BackendTag, Capabilities, Gpu, OffscreenContext};
+103 -13
crates/bone-render/src/navigate.rs
··· 1 - use bone_types::{AxisAngle, Camera3, OrbitState, Result}; 1 + use bone_types::{AxisAngle, Camera3, OrbitState, Result, ZoomFactor}; 2 2 3 3 use crate::camera::ViewportExtent; 4 4 use crate::camera3::{ 5 5 ViewportPoint, arcball_rotation, orbit_about_point, pan_pixels, roll_about_view, 6 + zoom_about_pixel, 6 7 }; 7 8 9 + const ZOOM_DRAG_PER_PIXEL: f64 = 1.0075; 10 + 8 11 #[derive(Copy, Clone, Debug, PartialEq, Eq)] 9 12 pub struct DragModifiers { 13 + ctrl: bool, 10 14 shift: bool, 11 15 alt: bool, 12 16 } 13 17 14 18 impl DragModifiers { 15 19 pub const NONE: Self = Self { 20 + ctrl: false, 16 21 shift: false, 17 22 alt: false, 18 23 }; 19 24 20 25 #[must_use] 26 + pub const fn with_ctrl(self) -> Self { 27 + Self { 28 + ctrl: true, 29 + shift: self.shift, 30 + alt: self.alt, 31 + } 32 + } 33 + 34 + #[must_use] 21 35 pub const fn with_shift(self) -> Self { 22 36 Self { 37 + ctrl: self.ctrl, 23 38 shift: true, 24 39 alt: self.alt, 25 40 } ··· 28 43 #[must_use] 29 44 pub const fn with_alt(self) -> Self { 30 45 Self { 46 + ctrl: self.ctrl, 31 47 shift: self.shift, 32 48 alt: true, 33 49 } ··· 35 51 36 52 #[must_use] 37 53 pub const fn gesture(self) -> NavGesture { 38 - if self.shift { 54 + if self.ctrl { 39 55 NavGesture::Pan 56 + } else if self.shift { 57 + NavGesture::Zoom 40 58 } else if self.alt { 41 59 NavGesture::Roll 42 60 } else { ··· 50 68 Orbit, 51 69 Pan, 52 70 Roll, 71 + Zoom, 53 72 } 54 73 55 74 #[derive(Copy, Clone, Debug, PartialEq)] ··· 104 123 return Ok(camera); 105 124 }; 106 125 let next = match drag.gesture { 107 - NavGesture::Orbit => { 108 - let delta = arcball_rotation(camera, extent, cursor, drag.last)?; 109 - let oriented = orbit_about_point(camera, camera.target(), delta)?; 110 - self.orbit = self.orbit.rotated(delta); 111 - oriented 112 - } 126 + NavGesture::Orbit => self.orbit_step(camera, extent, cursor, drag.last)?, 113 127 NavGesture::Pan => pan_pixels(camera, extent, drag.last, cursor)?, 114 128 NavGesture::Roll => roll_about_view(camera, extent, cursor, drag.last)?, 129 + NavGesture::Zoom => { 130 + let factor = ZoomFactor::new(ZOOM_DRAG_PER_PIXEL.powf(drag.last.y() - cursor.y()))?; 131 + zoom_about_pixel(camera, extent, cursor, factor)? 132 + } 115 133 }; 116 134 self.drag = Some(Drag { 117 135 gesture: drag.gesture, ··· 119 137 }); 120 138 Ok(next) 121 139 } 140 + 141 + pub fn orbit_pixels( 142 + &mut self, 143 + camera: Camera3, 144 + extent: ViewportExtent, 145 + dx: f64, 146 + dy: f64, 147 + ) -> Result<Camera3> { 148 + let cx = f64::from(extent.width().value()) * 0.5; 149 + let cy = f64::from(extent.height().value()) * 0.5; 150 + let from = ViewportPoint::new(cx, cy)?; 151 + let to = ViewportPoint::new(cx + dx, cy + dy)?; 152 + self.orbit_step(camera, extent, to, from) 153 + } 154 + 155 + fn orbit_step( 156 + &mut self, 157 + camera: Camera3, 158 + extent: ViewportExtent, 159 + from: ViewportPoint, 160 + to: ViewportPoint, 161 + ) -> Result<Camera3> { 162 + let delta = arcball_rotation(camera, extent, from, to)?; 163 + let oriented = orbit_about_point(camera, camera.target(), delta)?; 164 + self.orbit = self.orbit.rotated(delta); 165 + Ok(oriented) 166 + } 122 167 } 123 168 124 169 impl Default for ViewportNavigator { ··· 170 215 #[test] 171 216 fn modifiers_select_the_gesture() { 172 217 assert_eq!(DragModifiers::NONE.gesture(), NavGesture::Orbit); 173 - assert_eq!(DragModifiers::NONE.with_shift().gesture(), NavGesture::Pan); 218 + assert_eq!(DragModifiers::NONE.with_ctrl().gesture(), NavGesture::Pan); 219 + assert_eq!(DragModifiers::NONE.with_shift().gesture(), NavGesture::Zoom); 174 220 assert_eq!(DragModifiers::NONE.with_alt().gesture(), NavGesture::Roll); 175 221 } 176 222 177 223 #[test] 178 - fn shift_takes_precedence_over_alt() { 224 + fn ctrl_outranks_shift_outranks_alt() { 179 225 assert_eq!( 180 - DragModifiers::NONE.with_shift().with_alt().gesture(), 226 + DragModifiers::NONE.with_ctrl().with_shift().gesture(), 181 227 NavGesture::Pan, 182 - "holding both shift and alt resolves to pan, never roll" 228 + "ctrl pans even when shift is also held" 183 229 ); 184 230 assert_eq!( 185 - DragModifiers::NONE.with_alt().with_shift().gesture(), 231 + DragModifiers::NONE.with_shift().with_alt().gesture(), 232 + NavGesture::Zoom, 233 + "shift zooms even when alt is also held" 234 + ); 235 + assert_eq!( 236 + DragModifiers::NONE.with_alt().with_ctrl().gesture(), 186 237 NavGesture::Pan, 187 238 "the precedence is independent of the order the modifiers were set" 188 239 ); ··· 270 321 "a roll keeps the eye and target fixed" 271 322 ); 272 323 assert_ne!(rolled.up(), camera().up(), "a roll reorients the up vector"); 324 + } 325 + 326 + #[test] 327 + fn scroll_orbit_holds_the_target_and_accumulates_rotation() { 328 + let mut nav = ViewportNavigator::new(); 329 + let Ok(orbited) = nav.orbit_pixels(camera(), extent(), 60.0, 0.0) else { 330 + panic!("a scroll delta orbits the camera"); 331 + }; 332 + assert!( 333 + close(orbited.target(), camera().target(), 1e-9), 334 + "a scroll orbit pivots on the target" 335 + ); 336 + assert_ne!( 337 + orbited.eye(), 338 + camera().eye(), 339 + "a horizontal scroll orbit moves the eye" 340 + ); 341 + let rotated = nav.orbit_rotation().angle().get::<uom::si::angle::radian>(); 342 + assert!( 343 + rotated.abs() > 1e-3, 344 + "a scroll orbit accumulates rotation like a drag: {rotated}" 345 + ); 346 + } 347 + 348 + #[test] 349 + fn zoom_drag_down_enlarges_the_orthographic_view() { 350 + use bone_types::ProjectionKind; 351 + let mut nav = ViewportNavigator::new(); 352 + nav.begin_drag(NavGesture::Zoom, vp(128.0, 100.0)); 353 + let Ok(zoomed) = nav.drag_to(vp(128.0, 160.0), camera(), extent()) else { 354 + panic!("a zoom drag transforms the camera"); 355 + }; 356 + let ProjectionKind::Orthographic { half_height } = zoomed.projection().kind() else { 357 + panic!("the seed camera is orthographic"); 358 + }; 359 + assert!( 360 + half_height.get::<uom::si::length::millimeter>() > 2.0, 361 + "dragging the zoom gesture downward zooms out, growing the half height" 362 + ); 273 363 } 274 364 275 365 #[test]
+15 -9
crates/bone-types/src/lib.rs
··· 112 112 pub struct BrepLoopId; 113 113 } 114 114 115 - impl SketchId { 116 - /// Encodes this id as an opaque `u64`, stable for the same slotmap slot+version 117 - /// within a single process. Use only for deterministic widget-key derivation; the 118 - /// representation is not portable across builds or persisted artifacts. 119 - #[must_use] 120 - pub fn as_u64(self) -> u64 { 121 - use slotmap::Key; 122 - self.data().as_ffi() 123 - } 115 + macro_rules! impl_as_u64 { 116 + ($($key:ty),+ $(,)?) => { 117 + $(impl $key { 118 + /// Encodes this id as an opaque `u64`, stable for the same slotmap slot+version 119 + /// within a single process. Use only for deterministic widget-key derivation; the 120 + /// representation is not portable across builds or persisted artifacts. 121 + #[must_use] 122 + pub fn as_u64(self) -> u64 { 123 + use slotmap::Key; 124 + self.data().as_ffi() 125 + } 126 + })+ 127 + }; 124 128 } 129 + 130 + impl_as_u64!(SketchId, ExtrudeId); 125 131 126 132 #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] 127 133 pub struct Tolerance(f64);
+1 -1
crates/bone-types/src/schema.rs
··· 30 30 impl SchemaHeader { 31 31 pub const BONE_DOCUMENT_NAME: &'static str = "bone-document"; 32 32 pub const BONE_DOCUMENT_MAJOR: u32 = 1; 33 - pub const BONE_DOCUMENT_MINOR: u32 = 1; 33 + pub const BONE_DOCUMENT_MINOR: u32 = 2; 34 34 35 35 #[must_use] 36 36 pub fn bone_document() -> Self {
+55 -2
crates/bone-ui/src/widgets/tree_view.rs
··· 3 3 use crate::a11y::{AccessNode, Role}; 4 4 use crate::frame::{FrameCtx, InteractDeclaration}; 5 5 use crate::hit_test::Sense; 6 - use crate::input::{KeyCode, ModifierMask, NamedKey}; 6 + use crate::input::{KeyCode, ModifierMask, NamedKey, PointerButton}; 7 7 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 8 8 use crate::strings::StringKey; 9 9 use crate::theme::{Color, Step12}; ··· 231 231 acc 232 232 }); 233 233 paint.extend(row_paint); 234 - commit_pending_rename(ctx, &visible, state, renamable); 234 + let pending_row_rect = state.pending_rename.and_then(|pending| { 235 + visible 236 + .iter() 237 + .position(|row| row.id == pending.id) 238 + .map(|idx| row_rect_at(rect, idx, row_height)) 239 + }); 240 + commit_pending_rename(ctx, &visible, state, renamable, pending_row_rect); 235 241 let rename_committed = if let Some(id) = state.renaming 236 242 && take_key(ctx.input, &[TakeKey::named(NamedKey::Enter)]).is_some() 237 243 { ··· 440 446 visible: &[VisibleRow], 441 447 state: &mut TreeViewState, 442 448 renamable: &[WidgetId], 449 + pending_row_rect: Option<LayoutRect>, 443 450 ) { 444 451 let Some(pending) = state.pending_rename else { 445 452 return; 446 453 }; 447 454 if state.renaming.is_some() || !renamable.contains(&pending.id) { 455 + state.pending_rename = None; 456 + return; 457 + } 458 + let pressed_off_row = ctx.input.buttons_pressed.contains(PointerButton::Primary) 459 + && !pending_row_rect 460 + .zip(ctx.input.pointer.map(|sample| sample.position)) 461 + .is_some_and(|(row, cursor)| row.contains(cursor)); 462 + if pressed_off_row && pending.at != ctx.input.frame { 448 463 state.pending_rename = None; 449 464 return; 450 465 } ··· 1321 1336 }); 1322 1337 assert_eq!(state.renaming, Some(sketch_id)); 1323 1338 assert_eq!(state.rename_buffer.text, "Profile"); 1339 + } 1340 + 1341 + #[test] 1342 + fn primary_press_outside_pending_row_cancels_rename() { 1343 + let sketch_id = root_id("sketch"); 1344 + let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())]; 1345 + let mut state = TreeViewState { 1346 + selection: BTreeSet::from([sketch_id]), 1347 + ..TreeViewState::default() 1348 + }; 1349 + let mut focus = FocusManager::new(); 1350 + let mut prev = HitState::new(); 1351 + let row_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 1352 + let elsewhere = LayoutPos::new(LayoutPx::new(500.0), LayoutPx::new(500.0)); 1353 + let armed = FrameInstant::from_duration(core::time::Duration::from_millis(10)); 1354 + let pressed_away = FrameInstant::from_duration(core::time::Duration::from_millis(20)); 1355 + let frames = [ 1356 + press_at(row_pos, FrameInstant::ZERO), 1357 + release_at(row_pos, FrameInstant::ZERO), 1358 + idle_at(row_pos, armed), 1359 + press_at(elsewhere, pressed_away), 1360 + ]; 1361 + frames.into_iter().for_each(|mut snap| { 1362 + let (_, next) = render_with( 1363 + &roots, 1364 + &mut state, 1365 + &mut focus, 1366 + &mut snap, 1367 + &prev, 1368 + &[sketch_id], 1369 + ); 1370 + prev = next; 1371 + }); 1372 + assert_eq!( 1373 + state.pending_rename, None, 1374 + "pressing outside the pending row cancels the slow-click rename", 1375 + ); 1376 + assert_eq!(state.renaming, None, "no rename editor opens"); 1324 1377 } 1325 1378 1326 1379 #[test]