Another project
0

Configure Feed

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

viewport: make 3D visible in the app

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

author
Lewis
date (Jun 9, 2026, 10:16 PM +0300) commit e069fe07 parent 1300e4f3 change-id kpkonylx
+1388 -140
+579 -47
crates/bone-app/src/main.rs
··· 4 4 use std::sync::Arc; 5 5 6 6 use bone_document::{ 7 - DimensionKind, DimensionValue, Document, DocumentFolder, EditOutcome, FeatureNode, LineData, 8 - Sketch, SketchDimension, SketchEdit, SketchEntity, SketchRelation, SolverError, UndoStack, 7 + DimensionKind, DimensionValue, Document, DocumentFolder, EditOutcome, EvaluatedExtrude, 8 + ExtrudeFeature, FeatureCache, FeatureNode, LineData, Sketch, SketchDimension, SketchEdit, 9 + SketchEntity, SketchRelation, SketchVersion, SolverError, UndoStack, 9 10 }; 10 11 use bone_render::{ 11 - Camera2, ChromeInstance, ChromePipeline, ChromeTextPipeline, PickQuery, PickedItem, 12 - PixelsPerMm, RenderTargets, SdfGlyphInstance, SketchPreview, SketchRenderer, SketchScene, 13 - Style, SurfaceContext, ViewportExtent, ViewportPx, 12 + Camera2, ChromeInstance, ChromePipeline, ChromeTextPipeline, DragModifiers, EdgeScene, 13 + NavGesture, PickQuery, PickedItem, PixelsPerMm, RenderTargets, SdfGlyphInstance, SketchPreview, 14 + SketchRenderer, SketchScene, SolidFrameView, SolidRenderer, SolidScene, Style, SurfaceContext, 15 + ViewportExtent, ViewportNavigator, ViewportPoint, ViewportPx, ViewportRegion, 16 + frame_standard_view, zoom_about_pixel, 17 + }; 18 + use bone_types::{ 19 + Aabb3, AngleTolerance, BudgetCeiling, Camera3, ChordHeightTolerance, DisplayMode, DocumentId, 20 + FeatureId, GeometryGeneration, Length, Point2, SketchId, SketchItemId, StandardView, Vec2, 21 + ZoomFactor, 14 22 }; 15 - use bone_types::{BudgetCeiling, DocumentId, Length, Point2, SketchId, SketchItemId, Vec2}; 16 23 use bone_ui::a11y::AccessTreeBuilder; 17 24 use bone_ui::focus::FocusManager; 18 25 use bone_ui::frame::FrameCtx; ··· 126 133 document: Document, 127 134 plane_sketches: BTreeMap<Plane, SketchId>, 128 135 mode: Mode, 136 + feature_cache: FeatureCache, 137 + extrude_preview: Option<ExtrudePreview>, 138 + solid_renderer: SolidRenderer, 139 + solid_view: Option<SolidViewData>, 140 + camera3: Option<Camera3>, 141 + navigator: ViewportNavigator, 129 142 focus: FocusManager, 130 143 hit_state: HitState, 131 144 hotkeys: HotkeyTable, ··· 291 304 let (sketch, _) = tools::add_line(sketch, p3, p0, false); 292 305 let (sketch, origin) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 293 306 let (sketch, _) = tools::add_circle(sketch, origin, Length::new::<millimeter>(5.0), false); 294 - let (sketch, arc_center) = tools::add_point(sketch, Point2::from_mm(-12.0, 0.0)); 295 - let (sketch, arc_start) = tools::add_point(sketch, Point2::from_mm(-8.0, 0.0)); 296 - let (sketch, arc_end) = tools::add_point(sketch, Point2::from_mm(-12.0, 4.0)); 297 - let (sketch, _) = tools::add_arc(sketch, arc_center, arc_start, arc_end, false); 298 307 sketch 299 308 } 300 309 ··· 543 552 fn active_sketch_id(mode: &Mode, plane_sketches: &BTreeMap<Plane, SketchId>) -> Option<SketchId> { 544 553 match mode { 545 554 Mode::Sketch { sketch_id, .. } => Some(*sketch_id), 546 - Mode::Extrude(ExtrudeArming::Profile(id)) => Some(*id), 555 + Mode::Extrude(ExtrudeArming::Profile(feature)) => Some(feature.sketch), 547 556 Mode::Extrude(ExtrudeArming::AwaitingSketch) | Mode::Idle => { 548 557 plane_sketches.get(&Plane::Xy).copied() 549 558 } ··· 582 591 fn arm_extruded_boss_base(state: &mut RenderState) { 583 592 match classify_extrude_profile(&state.document) { 584 593 ProfileChoice::NoSketch => notify_info(state, strings::NOTIFY_EXTRUDE_NO_SKETCH, None), 585 - ProfileChoice::Unique(id) => state.mode = Mode::Extrude(ExtrudeArming::Profile(id)), 594 + ProfileChoice::Unique(id) => state.mode = Mode::Extrude(ExtrudeArming::profile(id)), 586 595 ProfileChoice::Ambiguous => state.mode = Mode::Extrude(ExtrudeArming::AwaitingSketch), 587 596 } 588 597 } 589 598 599 + fn apply_extrude_edit(state: &mut RenderState, edit: Option<shell::ExtrudeEdit>) { 600 + let Some(edit) = edit else { return }; 601 + let Mode::Extrude(ExtrudeArming::Profile(feature)) = &state.mode else { 602 + return; 603 + }; 604 + let next = edit.apply(*feature); 605 + state.mode = Mode::Extrude(ExtrudeArming::Profile(next)); 606 + } 607 + 608 + struct SolidViewData { 609 + faces: SolidScene, 610 + edges: EdgeScene, 611 + aabb: Aabb3, 612 + } 613 + 614 + struct ExtrudePreview { 615 + feature: ExtrudeFeature, 616 + sketch_version: SketchVersion, 617 + generation: Option<GeometryGeneration>, 618 + failed: bool, 619 + } 620 + 621 + const PREVIEW_CHORD_MM: f64 = 0.05; 622 + const PREVIEW_ANGLE: AngleTolerance = AngleTolerance::from_radians(0.2); 623 + 624 + fn sync_extrude_preview(state: &mut RenderState) { 625 + let Mode::Extrude(ExtrudeArming::Profile(feature)) = &state.mode else { 626 + state.extrude_preview = None; 627 + state.solid_view = None; 628 + state.camera3 = None; 629 + return; 630 + }; 631 + let feature = *feature; 632 + let Some(sketch_version) = state.document.sketch(feature.sketch).map(Sketch::version) else { 633 + state.extrude_preview = None; 634 + state.solid_view = None; 635 + state.camera3 = None; 636 + return; 637 + }; 638 + if extrude_preview_is_current(state.extrude_preview.as_ref(), &feature, sketch_version) { 639 + return; 640 + } 641 + let previous_generation = state 642 + .extrude_preview 643 + .as_ref() 644 + .and_then(|cached| cached.generation); 645 + let previously_failed = state 646 + .extrude_preview 647 + .as_ref() 648 + .is_some_and(|cached| cached.failed); 649 + let first_preview = state.extrude_preview.is_none(); 650 + let preview = compute_extrude_preview(&mut state.feature_cache, &state.document, feature); 651 + let generation = preview.as_ref().and_then(EvaluatedExtrude::generation); 652 + let mut failure = preview 653 + .as_ref() 654 + .and_then(|evaluated| match evaluated.result() { 655 + Ok(_) => None, 656 + Err(error) => Some(error.to_string()), 657 + }); 658 + if generation != previous_generation { 659 + state.solid_view = match preview.as_ref().and_then(EvaluatedExtrude::solid) { 660 + Some(solid) => match build_solid_view(solid) { 661 + Ok(view) => Some(view), 662 + Err(error) => { 663 + failure = Some(error); 664 + None 665 + } 666 + }, 667 + None => None, 668 + }; 669 + } 670 + let now_failed = failure.is_some(); 671 + state.extrude_preview = Some(ExtrudePreview { 672 + feature, 673 + sketch_version, 674 + generation, 675 + failed: now_failed, 676 + }); 677 + let newly_failed = first_preview || !previously_failed; 678 + if let Some(detail) = failure.filter(|_| newly_failed) { 679 + tracing::warn!(error = %detail, "extrude preview evaluation failed"); 680 + notify_error(state, strings::NOTIFY_EXTRUDE_FAILED, detail); 681 + } 682 + } 683 + 684 + fn extrude_preview_is_current( 685 + cached: Option<&ExtrudePreview>, 686 + feature: &ExtrudeFeature, 687 + sketch_version: SketchVersion, 688 + ) -> bool { 689 + cached 690 + .is_some_and(|cached| cached.feature == *feature && cached.sketch_version == sketch_version) 691 + } 692 + 693 + fn compute_extrude_preview( 694 + cache: &mut FeatureCache, 695 + document: &Document, 696 + feature: ExtrudeFeature, 697 + ) -> Option<EvaluatedExtrude> { 698 + let sketch = document.sketch(feature.sketch)?; 699 + let fid = FeatureId::default(); 700 + let evaluated_sketch = cache.evaluate(fid, sketch).clone(); 701 + Some( 702 + cache 703 + .evaluate_extrude(fid, &evaluated_sketch, &feature) 704 + .clone(), 705 + ) 706 + } 707 + 708 + fn build_solid_view(solid: &bone_document::BrepSolid) -> Result<SolidViewData, String> { 709 + let chord = ChordHeightTolerance::from_mm(PREVIEW_CHORD_MM); 710 + let aabb = solid 711 + .bounding_box() 712 + .ok_or_else(|| "degenerate solid has no bounding box".to_owned())?; 713 + let mesh = solid 714 + .tessellate(chord, PREVIEW_ANGLE) 715 + .map_err(|error| error.to_string())?; 716 + let faces = SolidScene::from_mesh(&mesh).map_err(|error| error.to_string())?; 717 + let edges = EdgeScene::from_solid(solid, &mesh, chord).map_err(|error| error.to_string())?; 718 + Ok(SolidViewData { faces, edges, aabb }) 719 + } 720 + 721 + fn sync_solid_camera(state: &mut RenderState, region: Option<ViewportRegion>) { 722 + if let Some(region) = region 723 + && let Some(view) = state.solid_view.as_ref() 724 + && state.camera3.is_none() 725 + { 726 + state.camera3 = 727 + frame_standard_view(view.aabb, region.extent(), StandardView::Isometric, None).ok(); 728 + } 729 + } 730 + 731 + fn preview_solid_frame( 732 + solid_view: Option<&SolidViewData>, 733 + camera: Option<Camera3>, 734 + region: ViewportRegion, 735 + ) -> Option<(&SolidViewData, SolidFrameView)> { 736 + let view = solid_view?; 737 + Some((view, SolidFrameView::new(camera?, region).ok()?)) 738 + } 739 + 740 + fn solid_viewport_region(viewport: LayoutRect, surface: ViewportExtent) -> Option<ViewportRegion> { 741 + let (surface_w, surface_h) = (surface.width().value(), surface.height().value()); 742 + let min_x = round_layout_px(viewport.min_x().value()).min(surface_w); 743 + let min_y = round_layout_px(viewport.min_y().value()).min(surface_h); 744 + let width = round_layout_px(viewport.size.width.value()).min(surface_w - min_x); 745 + let height = round_layout_px(viewport.size.height.value()).min(surface_h - min_y); 746 + (width > 0 && height > 0).then(|| { 747 + ViewportRegion::new( 748 + ViewportPx::new(min_x), 749 + ViewportPx::new(min_y), 750 + ViewportExtent::new(ViewportPx::new(width), ViewportPx::new(height)), 751 + ) 752 + }) 753 + } 754 + 755 + #[allow( 756 + clippy::cast_possible_truncation, 757 + clippy::cast_sign_loss, 758 + reason = "the saturating cast of a non-negative rounded px is clamped to the surface extent by the caller" 759 + )] 760 + fn round_layout_px(value: f32) -> u32 { 761 + value.round().max(0.0) as u32 762 + } 763 + 764 + fn viewport_local_point( 765 + cursor: PhysicalPosition<f64>, 766 + region: ViewportRegion, 767 + ) -> Option<ViewportPoint> { 768 + let (min_x, min_y, _, _) = region.scissor(); 769 + ViewportPoint::new(cursor.x - f64::from(min_x), cursor.y - f64::from(min_y)).ok() 770 + } 771 + 772 + fn drag_gesture(modifiers: ModifiersState) -> NavGesture { 773 + let base = if modifiers.shift_key() { 774 + DragModifiers::NONE.with_shift() 775 + } else { 776 + DragModifiers::NONE 777 + }; 778 + let resolved = if modifiers.alt_key() { 779 + base.with_alt() 780 + } else { 781 + base 782 + }; 783 + resolved.gesture() 784 + } 785 + 590 786 fn build_preview( 591 787 mode: &Mode, 592 788 document: &Document, ··· 791 987 ) -> Mode { 792 988 if mode.is_extrude() { 793 989 return match frame.sketch_activated { 794 - Some(id) => Mode::Extrude(ExtrudeArming::Profile(id)), 990 + Some(id) => match &mode { 991 + Mode::Extrude(ExtrudeArming::Profile(feature)) if feature.sketch == id => mode, 992 + _ => Mode::Extrude(ExtrudeArming::profile(id)), 993 + }, 795 994 None => mode, 796 995 }; 797 996 } ··· 823 1022 } 824 1023 } 825 1024 1025 + fn create_surface( 1026 + event_loop: &ActiveEventLoop, 1027 + ) -> Option<(Arc<Window>, SurfaceContext, ViewportExtent)> { 1028 + let attrs = Window::default_attributes().with_title("bone"); 1029 + let window = match event_loop.create_window(attrs) { 1030 + Ok(w) => Arc::new(w), 1031 + Err(e) => { 1032 + tracing::error!(error = %e, "create_window failed"); 1033 + event_loop.exit(); 1034 + return None; 1035 + } 1036 + }; 1037 + let extent = viewport_extent(window.inner_size()); 1038 + let surface = match pollster::block_on(SurfaceContext::new(window.clone(), extent)) { 1039 + Ok(s) => s, 1040 + Err(e) => { 1041 + tracing::error!(error = %e, "SurfaceContext::new failed"); 1042 + event_loop.exit(); 1043 + return None; 1044 + } 1045 + }; 1046 + Some((window, surface, extent)) 1047 + } 1048 + 826 1049 impl ApplicationHandler for App { 827 1050 fn resumed(&mut self, event_loop: &ActiveEventLoop) { 828 1051 if self.redraw.is_some() { 829 1052 return; 830 1053 } 831 - let attrs = Window::default_attributes().with_title("bone"); 832 - let window = match event_loop.create_window(attrs) { 833 - Ok(w) => Arc::new(w), 834 - Err(e) => { 835 - tracing::error!(error = %e, "create_window failed"); 836 - event_loop.exit(); 837 - return; 838 - } 839 - }; 840 - let extent = viewport_extent(window.inner_size()); 841 - let surface = match pollster::block_on(SurfaceContext::new(window.clone(), extent)) { 842 - Ok(s) => s, 843 - Err(e) => { 844 - tracing::error!(error = %e, "SurfaceContext::new failed"); 845 - event_loop.exit(); 846 - return; 847 - } 1054 + let Some((window, surface, extent)) = create_surface(event_loop) else { 1055 + return; 848 1056 }; 849 1057 let renderer = SketchRenderer::new(surface.gpu(), surface.color_format()); 1058 + let solid_renderer = SolidRenderer::new(surface.gpu(), surface.color_format()); 850 1059 let chrome_pipeline = ChromePipeline::new(surface.gpu(), surface.color_format()); 851 1060 let sdf_atlas = MaskAtlas::new(MaskAtlasParams::STANDARD); 852 1061 let text_pipeline = ··· 902 1111 document, 903 1112 plane_sketches, 904 1113 mode: Mode::Idle, 1114 + feature_cache: FeatureCache::new(), 1115 + extrude_preview: None, 1116 + solid_renderer, 1117 + solid_view: None, 1118 + camera3: None, 1119 + navigator: ViewportNavigator::new(), 905 1120 focus: FocusManager::new(), 906 1121 hit_state: HitState::new(), 907 1122 hotkeys: initial_hotkeys, ··· 969 1184 event::InputEvent::Focus(focused) => { 970 1185 if !focused { 971 1186 self.input.forget_pan_state(); 1187 + state.navigator.end_drag(); 972 1188 } 973 1189 } 974 1190 event::InputEvent::Modifier(mods) => { ··· 979 1195 self.input.cursor_px = Some(position); 980 1196 let modal = modal_active(state); 981 1197 if !modal 1198 + && state.navigator.is_dragging() 1199 + && let Some(camera) = state.camera3 1200 + && let Some(region) = 1201 + solid_viewport_region(state.viewport_rect, state.surface.extent()) 1202 + && let Some(cursor) = viewport_local_point(position, region) 1203 + && let Ok(next) = state.navigator.drag_to(cursor, camera, region.extent()) 1204 + { 1205 + state.camera3 = Some(next); 1206 + } else if !modal 982 1207 && self.input.panning() 983 1208 && let Some(p) = prev 984 1209 { ··· 1002 1227 } 1003 1228 event::InputEvent::Wheel(delta) => { 1004 1229 if !modal_active(state) && self.input.cursor_in(state.viewport_rect) { 1005 - state.camera = 1006 - zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); 1230 + if state.solid_view.is_some() { 1231 + if let Some(camera) = state.camera3 1232 + && let Some(region) = 1233 + 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 + { 1242 + state.camera3 = Some(next); 1243 + } 1244 + } else { 1245 + state.camera = 1246 + zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); 1247 + } 1007 1248 } 1008 1249 } 1009 1250 event::InputEvent::KeyDown { ··· 1071 1312 } 1072 1313 MouseButton::Middle => { 1073 1314 if btn_state == ElementState::Pressed { 1074 - self.input.middle_pan = !modal && self.input.cursor_in(state.viewport_rect); 1315 + let in_viewport = !modal && self.input.cursor_in(state.viewport_rect); 1316 + if in_viewport 1317 + && state.solid_view.is_some() 1318 + && let Some(region) = 1319 + solid_viewport_region(state.viewport_rect, state.surface.extent()) 1320 + && let Some(cursor) = self 1321 + .input 1322 + .cursor_px 1323 + .and_then(|p| viewport_local_point(p, region)) 1324 + { 1325 + state 1326 + .navigator 1327 + .begin_drag(drag_gesture(self.input.modifiers), cursor); 1328 + } else { 1329 + self.input.middle_pan = in_viewport; 1330 + } 1075 1331 self.input.pending_pressed = 1076 1332 self.input.pending_pressed.with(PointerButton::Middle); 1077 1333 } else { 1078 1334 self.input.middle_pan = false; 1335 + state.navigator.end_drag(); 1079 1336 self.input.pending_released = 1080 1337 self.input.pending_released.with(PointerButton::Middle); 1081 1338 } ··· 1274 1531 _ => frame.dimension_edit, 1275 1532 }; 1276 1533 apply_dimension_edit(state, dimension_edit); 1534 + apply_extrude_edit(state, frame.extrude_edit); 1535 + sync_extrude_preview(state); 1536 + let solid_region = solid_viewport_region(state.viewport_rect, extent); 1537 + sync_solid_camera(state, solid_region); 1277 1538 let cursor_layout = input_state.cursor_px.map(physical_to_layout_pos); 1278 1539 apply_hotkey_actions(state, &hotkey_actions, cursor_layout); 1279 1540 apply_menu_action(state, frame.menu_action); ··· 1303 1564 viewport_px, 1304 1565 }; 1305 1566 let scene = &state.scene; 1306 - let camera = state.camera; 1307 1567 let style = &state.style; 1308 1568 renderer.prepare(scene, style); 1569 + let viewport = ViewportEncode { 1570 + solid: solid_region.and_then(|region| { 1571 + preview_solid_frame(state.solid_view.as_ref(), state.camera3, region) 1572 + }), 1573 + solid_renderer: &state.solid_renderer, 1574 + sketch_renderer: renderer, 1575 + scene, 1576 + preview: &preview, 1577 + camera: state.camera, 1578 + style, 1579 + }; 1309 1580 let pre_present = || scheduler.window().pre_present_notify(); 1310 1581 surface.render( 1311 - |encoder, color, pick, _depth| { 1312 - renderer.encode_passes( 1313 - encoder, 1314 - RenderTargets::new(color, pick), 1315 - scene, 1316 - &preview, 1317 - camera, 1318 - style, 1319 - ); 1582 + |encoder, color, pick, depth| { 1583 + viewport.encode(encoder, color, pick, depth); 1320 1584 chrome_stage.encode_layered(encoder, color, &main_layer, &overlay_layer); 1321 1585 }, 1322 1586 pre_present, 1323 1587 ); 1324 1588 scheduler.consume_kick(); 1589 + } 1590 + 1591 + struct ViewportEncode<'a> { 1592 + solid: Option<(&'a SolidViewData, SolidFrameView)>, 1593 + solid_renderer: &'a SolidRenderer, 1594 + sketch_renderer: &'a SketchRenderer, 1595 + scene: &'a SketchScene, 1596 + preview: &'a SketchPreview, 1597 + camera: Camera2, 1598 + style: &'a Style, 1599 + } 1600 + 1601 + impl ViewportEncode<'_> { 1602 + fn encode( 1603 + &self, 1604 + encoder: &mut wgpu::CommandEncoder, 1605 + color: &wgpu::TextureView, 1606 + pick: &wgpu::TextureView, 1607 + depth: &wgpu::TextureView, 1608 + ) { 1609 + let targets = RenderTargets::new(color, pick); 1610 + match &self.solid { 1611 + Some((view, frame)) => self.solid_renderer.encode_passes( 1612 + encoder, 1613 + targets, 1614 + depth, 1615 + &view.faces, 1616 + &view.edges, 1617 + &bone_render::SolidDisplay { 1618 + view: frame, 1619 + style: self.style, 1620 + mode: DisplayMode::ShadedWithEdges, 1621 + }, 1622 + ), 1623 + None => self.sketch_renderer.encode_passes( 1624 + encoder, 1625 + targets, 1626 + self.scene, 1627 + self.preview, 1628 + self.camera, 1629 + self.style, 1630 + ), 1631 + } 1632 + } 1325 1633 } 1326 1634 1327 1635 struct ChromeStage<'a> { ··· 1980 2288 activated_relation: None, 1981 2289 activated_dimension: None, 1982 2290 dimension_edit: None, 2291 + extrude_edit: frame.extrude_edit, 1983 2292 plane_picked: None, 1984 2293 sketch_activated: None, 1985 2294 sketch_rename: None, ··· 3310 3619 activated_relation: None, 3311 3620 activated_dimension: None, 3312 3621 dimension_edit: None, 3622 + extrude_edit: None, 3313 3623 plane_picked: None, 3314 3624 sketch_activated: None, 3315 3625 sketch_rename: None, ··· 3339 3649 )); 3340 3650 } 3341 3651 3652 + fn rectangle_sketch() -> Sketch { 3653 + let sketch = Sketch::new(Plane::Xy.basis()); 3654 + let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 3655 + let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); 3656 + let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(10.0, 6.0)); 3657 + let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(0.0, 6.0)); 3658 + let (sketch, _) = tools::add_line(sketch, p0, p1, false); 3659 + let (sketch, _) = tools::add_line(sketch, p1, p2, false); 3660 + let (sketch, _) = tools::add_line(sketch, p2, p3, false); 3661 + let (sketch, _) = tools::add_line(sketch, p3, p0, false); 3662 + sketch 3663 + } 3664 + 3665 + fn tall_rectangle_sketch() -> Sketch { 3666 + let sketch = Sketch::new(Plane::Xy.basis()); 3667 + let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 3668 + let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); 3669 + let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(10.0, 20.0)); 3670 + let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(0.0, 20.0)); 3671 + let (sketch, _) = tools::add_line(sketch, p0, p1, false); 3672 + let (sketch, _) = tools::add_line(sketch, p1, p2, false); 3673 + let (sketch, _) = tools::add_line(sketch, p2, p3, false); 3674 + let (sketch, _) = tools::add_line(sketch, p3, p0, false); 3675 + sketch 3676 + } 3677 + 3678 + #[test] 3679 + fn extrude_preview_cache_is_current_only_for_the_same_feature_and_sketch_version() { 3680 + let id = SketchId::default(); 3681 + let feature = sketch_mode::default_extrude_feature(id); 3682 + let base_version = Sketch::new(Plane::Xy.basis()).version(); 3683 + let edited_version = rectangle_sketch().version(); 3684 + assert_ne!( 3685 + base_version, edited_version, 3686 + "a sketch edit must bump the version this gate keys on" 3687 + ); 3688 + let cached = super::ExtrudePreview { 3689 + feature, 3690 + sketch_version: base_version, 3691 + generation: None, 3692 + failed: false, 3693 + }; 3694 + assert!(super::extrude_preview_is_current( 3695 + Some(&cached), 3696 + &feature, 3697 + base_version 3698 + )); 3699 + assert!( 3700 + !super::extrude_preview_is_current(Some(&cached), &feature, edited_version), 3701 + "editing the sketch under the same feature must invalidate the cached preview", 3702 + ); 3703 + assert!(!super::extrude_preview_is_current( 3704 + None, 3705 + &feature, 3706 + base_version 3707 + )); 3708 + } 3709 + 3710 + #[test] 3711 + fn extrude_preview_refreshes_when_the_sketch_changes_under_one_cache() { 3712 + let (mut document, id) = super::initial_document(rectangle_sketch()); 3713 + let feature = sketch_mode::default_extrude_feature(id); 3714 + let mut cache = super::FeatureCache::new(); 3715 + let first = super::compute_extrude_preview(&mut cache, &document, feature) 3716 + .and_then(|preview| preview.generation()); 3717 + document.replace_sketch(id, tall_rectangle_sketch()); 3718 + let second = super::compute_extrude_preview(&mut cache, &document, feature) 3719 + .and_then(|preview| preview.generation()); 3720 + let (Some(first), Some(second)) = (first, second) else { 3721 + panic!("both rectangles extrude to a solid"); 3722 + }; 3723 + assert_ne!( 3724 + first, second, 3725 + "a sketch edit under the same feature must re-evaluate against the edited geometry", 3726 + ); 3727 + } 3728 + 3729 + #[test] 3730 + fn extrude_preview_evaluates_a_closed_rectangle() { 3731 + let (document, id) = super::initial_document(rectangle_sketch()); 3732 + let feature = sketch_mode::default_extrude_feature(id); 3733 + let mut cache = super::FeatureCache::new(); 3734 + let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { 3735 + panic!("a registered sketch yields an evaluated preview"); 3736 + }; 3737 + assert!( 3738 + preview.solid().is_some(), 3739 + "closed rectangle extrudes to a solid" 3740 + ); 3741 + } 3742 + 3743 + #[test] 3744 + fn extrude_preview_absent_when_sketch_missing() { 3745 + let document = Document::new(DocumentId::default(), "Empty".to_owned()); 3746 + let feature = sketch_mode::default_extrude_feature(SketchId::default()); 3747 + let mut cache = super::FeatureCache::new(); 3748 + assert!(super::compute_extrude_preview(&mut cache, &document, feature).is_none()); 3749 + } 3750 + 3751 + #[test] 3752 + fn default_document_extrudes_to_a_solid() { 3753 + let (document, id) = super::initial_document(super::default_sketch()); 3754 + let feature = sketch_mode::default_extrude_feature(id); 3755 + let mut cache = super::FeatureCache::new(); 3756 + let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { 3757 + panic!("the default sketch is registered"); 3758 + }; 3759 + let Some(solid) = preview.solid() else { 3760 + panic!("the default sketch extrudes to a solid"); 3761 + }; 3762 + assert!(super::build_solid_view(solid).is_ok()); 3763 + } 3764 + 3765 + #[test] 3766 + fn preview_solid_view_tessellates_and_frames() { 3767 + let (document, id) = super::initial_document(rectangle_sketch()); 3768 + let feature = sketch_mode::default_extrude_feature(id); 3769 + let mut cache = super::FeatureCache::new(); 3770 + let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { 3771 + panic!("a registered sketch yields an evaluated preview"); 3772 + }; 3773 + let Some(solid) = preview.solid() else { 3774 + panic!("the rectangle extrudes to a solid"); 3775 + }; 3776 + let Ok(view) = super::build_solid_view(solid) else { 3777 + panic!("the solid tessellates into a renderable view"); 3778 + }; 3779 + let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256)); 3780 + let region = ViewportRegion::at_origin(extent); 3781 + let camera = frame_standard_view(view.aabb, extent, StandardView::Isometric, None).ok(); 3782 + assert!( 3783 + camera.is_some(), 3784 + "the solid aabb frames an isometric camera" 3785 + ); 3786 + assert!( 3787 + super::preview_solid_frame(Some(&view), camera, region).is_some(), 3788 + "a framed preview lowers to a solid frame view", 3789 + ); 3790 + assert!( 3791 + super::preview_solid_frame(Some(&view), None, region).is_none(), 3792 + "without a camera there is nothing to frame", 3793 + ); 3794 + } 3795 + 3796 + fn layout_rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { 3797 + LayoutRect::new( 3798 + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 3799 + LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)), 3800 + ) 3801 + } 3802 + 3803 + #[test] 3804 + fn solid_viewport_region_offsets_inside_the_surface() { 3805 + let surface = ViewportExtent::new(ViewportPx::new(1280), ViewportPx::new(800)); 3806 + let Some(region) = 3807 + super::solid_viewport_region(layout_rect(320.0, 96.0, 800.0, 600.0), surface) 3808 + else { 3809 + panic!("an inset viewport yields a region"); 3810 + }; 3811 + assert_eq!( 3812 + region.scissor(), 3813 + (320, 96, 800, 600), 3814 + "the region carries the viewport offset and size, not the whole window", 3815 + ); 3816 + } 3817 + 3818 + #[test] 3819 + fn solid_viewport_region_clamps_to_the_surface() { 3820 + let surface = ViewportExtent::new(ViewportPx::new(640), ViewportPx::new(480)); 3821 + let Some(region) = 3822 + super::solid_viewport_region(layout_rect(600.0, 400.0, 400.0, 400.0), surface) 3823 + else { 3824 + panic!("a partly off-surface viewport still yields a clamped region"); 3825 + }; 3826 + let (x, y, w, h) = region.scissor(); 3827 + assert!( 3828 + x + w <= 640 && y + h <= 480, 3829 + "the scissor never runs past the surface: {x}+{w}, {y}+{h}", 3830 + ); 3831 + } 3832 + 3833 + #[test] 3834 + fn solid_viewport_region_is_none_for_a_degenerate_viewport() { 3835 + let surface = ViewportExtent::new(ViewportPx::new(1280), ViewportPx::new(800)); 3836 + assert!(super::solid_viewport_region(layout_rect(0.0, 0.0, 0.0, 0.0), surface).is_none()); 3837 + } 3838 + 3839 + #[test] 3840 + fn viewport_local_point_centers_the_inset_viewport_not_the_window() { 3841 + let region = ViewportRegion::new( 3842 + ViewportPx::new(282), 3843 + ViewportPx::new(120), 3844 + ViewportExtent::new(ViewportPx::new(998), ViewportPx::new(636)), 3845 + ); 3846 + let center = PhysicalPosition::new(282.0 + 499.0, 120.0 + 318.0); 3847 + let Some(local) = super::viewport_local_point(center, region) else { 3848 + panic!("a cursor inside the surface yields a viewport-local point"); 3849 + }; 3850 + assert!( 3851 + (local.x() - 499.0).abs() < 1e-9 && (local.y() - 318.0).abs() < 1e-9, 3852 + "a cursor at the inset viewport center maps to the region-local center: ({}, {})", 3853 + local.x(), 3854 + local.y(), 3855 + ); 3856 + } 3857 + 3858 + #[test] 3859 + fn drag_gesture_maps_modifiers_to_solidworks_navigation() { 3860 + use winit::keyboard::ModifiersState; 3861 + assert_eq!( 3862 + super::drag_gesture(ModifiersState::empty()), 3863 + NavGesture::Orbit 3864 + ); 3865 + assert_eq!(super::drag_gesture(ModifiersState::SHIFT), NavGesture::Pan); 3866 + assert_eq!(super::drag_gesture(ModifiersState::ALT), NavGesture::Roll); 3867 + assert_eq!( 3868 + super::drag_gesture(ModifiersState::SHIFT | ModifiersState::ALT), 3869 + NavGesture::Pan, 3870 + "shift wins so a held shift never rolls", 3871 + ); 3872 + } 3873 + 3342 3874 #[test] 3343 3875 fn plane_pick_from_idle_enters_sketch_for_known_plane() { 3344 3876 let frame = shell::ShellFrame { ··· 3795 4327 false, 3796 4328 &xy_only(), 3797 4329 ), 3798 - Mode::Extrude(ExtrudeArming::Profile(sketch_id)), 4330 + Mode::Extrude(ExtrudeArming::profile(sketch_id)), 3799 4331 ); 3800 4332 assert_eq!( 3801 4333 next_mode( 3802 - Mode::Extrude(ExtrudeArming::Profile(sketch_id)), 4334 + Mode::Extrude(ExtrudeArming::profile(sketch_id)), 3803 4335 &frame, 3804 4336 false, 3805 4337 &xy_only(), 3806 4338 ), 3807 - Mode::Extrude(ExtrudeArming::Profile(sketch_id)), 4339 + Mode::Extrude(ExtrudeArming::profile(sketch_id)), 3808 4340 "a pick while armed re-targets the profile, never drops into sketch editing", 3809 4341 ); 3810 4342 } ··· 3888 4420 let frame = empty_frame(); 3889 4421 let awaiting = Mode::Extrude(ExtrudeArming::AwaitingSketch); 3890 4422 assert_eq!(next_mode(awaiting, &frame, true, &xy_only()), Mode::Idle); 3891 - let profiled = Mode::Extrude(ExtrudeArming::Profile(SketchId::default())); 4423 + let profiled = Mode::Extrude(ExtrudeArming::profile(SketchId::default())); 3892 4424 assert_eq!(next_mode(profiled, &frame, true, &xy_only()), Mode::Idle); 3893 4425 } 3894 4426
+416 -39
crates/bone-app/src/shell.rs
··· 3 3 use std::sync::Arc; 4 4 5 5 use bone_document::{ 6 - DimensionKind, DimensionValue, Document, Sketch, SketchDimension, SketchEntity, SketchRelation, 7 - SketchStatusReport, SketchVersion, 6 + DimensionKind, DimensionValue, Document, ExtrudeEndCondition, ExtrudeFeature, MergeResult, 7 + Sketch, SketchDimension, SketchEntity, SketchRelation, SketchStatusReport, SketchVersion, 8 8 }; 9 - use bone_types::{Length, Point2, SketchDimensionId, SketchEntityId, SketchId}; 9 + use bone_types::{ 10 + Angle, Length, Point2, PositiveLength, SketchDimensionId, SketchEntityId, SketchId, 11 + }; 10 12 use bone_ui::a11y::{AccessNode, Role}; 11 13 use bone_ui::frame::{FrameCtx, InteractDeclaration}; 12 14 use bone_ui::hit_test::Sense; ··· 19 21 use bone_ui::theme::{Border, ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme}; 20 22 use bone_ui::widgets::GlyphMark; 21 23 use bone_ui::widgets::{ 22 - AngleEditor, Clipboard, Dialog, DialogButton, HotkeyCapture, HotkeyCaptureState, LabelText, 23 - LengthEditor, MemoryClipboard, MenuBar, MenuBarEntry, MenuBarState, MenuItem, PanelState, 24 - PropertyCell, PropertyEditor, PropertyGrid, PropertyRow, RenameCommit, Ribbon, RibbonGroup, 25 - RibbonIconSize, RibbonTab, Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, 26 - Tab, Tabs, TabsOrientation, ToolbarItem, TreeNode, TreeView, TreeViewState, WidgetPaint, 27 - show_dialog, show_hotkey_capture, show_menu_bar, show_property_grid, show_ribbon, show_slider, 28 - show_status_bar, show_tabs, show_tree_view, 24 + AngleEditor, BoolEditor, Clipboard, Dialog, DialogButton, HotkeyCapture, HotkeyCaptureState, 25 + LabelText, LengthEditor, MemoryClipboard, MenuBar, MenuBarEntry, MenuBarState, MenuItem, 26 + PanelState, PropertyCell, PropertyEditor, PropertyGrid, PropertyOption, PropertyRow, 27 + RenameCommit, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, SelectionEditor, Slider, 28 + SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, Tabs, TabsOrientation, 29 + ToolbarItem, TreeNode, TreeView, TreeViewState, WidgetPaint, show_dialog, show_hotkey_capture, 30 + show_menu_bar, show_property_grid, show_ribbon, show_slider, show_status_bar, show_tabs, 31 + show_tree_view, 29 32 }; 30 33 use bone_ui::{WidgetId, WidgetKey}; 34 + use uom::si::angle::degree; 31 35 use uom::si::length::millimeter; 32 36 33 37 use bone_render::PickAperture; ··· 36 40 use crate::selection::Selection; 37 41 use crate::settings::Settings; 38 42 use crate::sketch_mode::PendingDimension; 39 - use crate::sketch_mode::{ExtrudeArming, FeatureTool, Mode, Plane, SketchTool}; 43 + use crate::sketch_mode::{ 44 + EndConditionKind, ExtrudeArming, FeatureTool, Mode, Plane, SketchTool, default_extrude_depth, 45 + end_condition_depth, 46 + }; 40 47 use crate::smart_dimension; 41 48 use crate::status_badge::{ 42 49 render_status_panel, status_badge_widget_id, status_color, status_label_key, ··· 255 262 pub clipboard: MemoryClipboard, 256 263 pub menu_bar: MenuBarState, 257 264 pub dim_property: Option<DimPropertyEditor>, 265 + pub extrude_property: Option<ExtrudePropertyEditor>, 258 266 pub settings_dialog_open: bool, 259 267 pub keyboard_dialog_open: bool, 260 268 pub hotkey_capture: BTreeMap<bone_ui::hotkey::ActionId, HotkeyCaptureState>, ··· 288 296 }, 289 297 } 290 298 299 + #[derive(Copy, Clone, Debug, PartialEq)] 300 + pub enum ExtrudeEdit { 301 + EndCondition(EndConditionKind), 302 + Depth(PositiveLength), 303 + Merge(MergeResult), 304 + } 305 + 306 + impl ExtrudeEdit { 307 + #[must_use] 308 + pub fn apply(self, feature: ExtrudeFeature) -> ExtrudeFeature { 309 + match self { 310 + Self::EndCondition(kind) => match end_condition_depth(&feature.end_condition) { 311 + Some(depth) => ExtrudeFeature { 312 + end_condition: kind.with_depth(depth), 313 + ..feature 314 + }, 315 + None => feature, 316 + }, 317 + Self::Depth(depth) => match EndConditionKind::of(&feature.end_condition) { 318 + Some(kind) => ExtrudeFeature { 319 + end_condition: kind.with_depth(depth), 320 + ..feature 321 + }, 322 + None => feature, 323 + }, 324 + Self::Merge(merge_result) => ExtrudeFeature { 325 + merge_result, 326 + ..feature 327 + }, 328 + } 329 + } 330 + } 331 + 332 + pub struct ExtrudePropertyEditor { 333 + sketch: SketchId, 334 + end_condition: SelectionEditor, 335 + depth: LengthEditor, 336 + draft_enabled: BoolEditor, 337 + draft_angle: AngleEditor, 338 + direction_two: BoolEditor, 339 + thin: BoolEditor, 340 + merge: BoolEditor, 341 + } 342 + 343 + impl ExtrudePropertyEditor { 344 + fn new(feature: ExtrudeFeature) -> Self { 345 + Self { 346 + sketch: feature.sketch, 347 + end_condition: SelectionEditor::new( 348 + end_condition_options(), 349 + Some(kind_index(&feature.end_condition)), 350 + ), 351 + depth: LengthEditor::new(current_depth(&feature)), 352 + draft_enabled: BoolEditor::new(feature.draft.is_some()), 353 + draft_angle: AngleEditor::new(current_draft_angle(&feature)), 354 + direction_two: BoolEditor::new(false), 355 + thin: BoolEditor::new(feature.thin_wall.is_some()), 356 + merge: BoolEditor::new(matches!(feature.merge_result, MergeResult::Merge)), 357 + } 358 + } 359 + 360 + fn synced(mut self, feature: ExtrudeFeature) -> Self { 361 + self.end_condition.current = Some(kind_index(&feature.end_condition)); 362 + self.depth.value = current_depth(&feature); 363 + self.draft_enabled.value = feature.draft.is_some(); 364 + self.draft_angle.value = current_draft_angle(&feature); 365 + self.direction_two.value = false; 366 + self.thin.value = feature.thin_wall.is_some(); 367 + self.merge.value = matches!(feature.merge_result, MergeResult::Merge); 368 + self 369 + } 370 + } 371 + 372 + fn end_condition_options() -> Vec<PropertyOption> { 373 + EndConditionKind::SUPPORTED 374 + .iter() 375 + .map(|kind| PropertyOption { 376 + label: end_condition_label(*kind), 377 + }) 378 + .collect() 379 + } 380 + 381 + fn end_condition_label(kind: EndConditionKind) -> StringKey { 382 + match kind { 383 + EndConditionKind::Blind => strings::EXTRUDE_END_BLIND, 384 + EndConditionKind::MidPlane => strings::EXTRUDE_END_MIDPLANE, 385 + } 386 + } 387 + 388 + fn kind_index(condition: &ExtrudeEndCondition) -> usize { 389 + let kind = EndConditionKind::of(condition).unwrap_or(EndConditionKind::Blind); 390 + EndConditionKind::SUPPORTED 391 + .iter() 392 + .position(|candidate| *candidate == kind) 393 + .unwrap_or(0) 394 + } 395 + 396 + fn kind_from_index(index: Option<usize>) -> Option<EndConditionKind> { 397 + index.and_then(|i| EndConditionKind::SUPPORTED.get(i).copied()) 398 + } 399 + 400 + fn current_depth(feature: &ExtrudeFeature) -> Length { 401 + end_condition_depth(&feature.end_condition).map_or_else( 402 + || { 403 + debug_assert!( 404 + false, 405 + "an armed extrude always carries a Blind or MidPlane depth" 406 + ); 407 + default_extrude_depth().get() 408 + }, 409 + PositiveLength::get, 410 + ) 411 + } 412 + 413 + fn current_draft_angle(feature: &ExtrudeFeature) -> Angle { 414 + feature 415 + .draft 416 + .map_or_else(|| Angle::new::<degree>(0.0), |draft| draft.angle().get()) 417 + } 418 + 291 419 #[derive(Clone, Debug, PartialEq)] 292 420 pub struct ShellFrame { 293 421 pub paints: Vec<WidgetPaint>, ··· 298 426 pub activated_relation: Option<SketchRelation>, 299 427 pub activated_dimension: Option<PendingDimension>, 300 428 pub dimension_edit: Option<DimensionEdit>, 429 + pub extrude_edit: Option<ExtrudeEdit>, 301 430 pub plane_picked: Option<Plane>, 302 431 pub sketch_activated: Option<SketchId>, 303 432 pub sketch_rename: Option<SketchRenameRequest>, ··· 324 453 activated_relation: None, 325 454 activated_dimension: None, 326 455 dimension_edit: None, 456 + extrude_edit: None, 327 457 plane_picked: None, 328 458 sketch_activated: None, 329 459 sketch_rename: None, ··· 477 607 document, 478 608 &mut paints, 479 609 ); 480 - let dimension_edit = render_property_pane( 610 + let pane = render_property_pane( 481 611 ctx, 482 612 property_rect, 483 613 self.ids.property_pane, 484 614 &mut self.state.clipboard, 485 - &mut self.state.dim_property, 615 + &mut PaneEditors { 616 + dim: &mut self.state.dim_property, 617 + extrude: &mut self.state.extrude_property, 618 + }, 486 619 PropertyState { 487 620 mode, 488 621 sketch: active_sketch, ··· 490 623 }, 491 624 &mut paints, 492 625 ); 626 + let dimension_edit = pane.dimension_edit; 627 + let extrude_edit = pane.extrude_edit; 493 628 render_doc_tabs(ctx, doc_tabs_rect, &self.ids, &mut paints); 494 629 let status_report: Option<&SketchStatusReport> = if let Some(s) = active_sketch { 495 630 let v = s.version(); ··· 593 728 activated_relation, 594 729 activated_dimension, 595 730 dimension_edit, 731 + extrude_edit, 596 732 plane_picked, 597 733 sketch_activated, 598 734 sketch_rename, ··· 1036 1172 role: theme.typography.caption, 1037 1173 }); 1038 1174 } 1175 + WidgetPaint::Popup { paints } => { 1176 + let (inner_main, inner_overlay) = partition_overlay(paints, theme); 1177 + overlay.extend(inner_main); 1178 + overlay.extend(inner_overlay); 1179 + } 1039 1180 other => main.push(other), 1040 1181 } 1041 1182 (main, overlay) ··· 1569 1710 selection: &'a Selection, 1570 1711 } 1571 1712 1713 + #[derive(Default)] 1714 + struct PropertyPaneOutcome { 1715 + dimension_edit: Option<DimensionEdit>, 1716 + extrude_edit: Option<ExtrudeEdit>, 1717 + } 1718 + 1719 + struct PaneEditors<'a> { 1720 + dim: &'a mut Option<DimPropertyEditor>, 1721 + extrude: &'a mut Option<ExtrudePropertyEditor>, 1722 + } 1723 + 1572 1724 fn render_property_pane( 1573 1725 ctx: &mut FrameCtx<'_>, 1574 1726 rect: LayoutRect, 1575 1727 id: WidgetId, 1576 1728 clipboard: &mut MemoryClipboard, 1577 - dim_property: &mut Option<DimPropertyEditor>, 1729 + editors: &mut PaneEditors<'_>, 1578 1730 state: PropertyState<'_>, 1579 1731 paints: &mut Vec<WidgetPaint>, 1580 - ) -> Option<DimensionEdit> { 1732 + ) -> PropertyPaneOutcome { 1581 1733 let in_sketch = matches!(state.mode, Mode::Sketch { .. }); 1582 1734 let active_sketch_id = state.mode.sketch_id(); 1583 1735 let resolved = state ··· 1585 1737 .filter(|_| in_sketch) 1586 1738 .and_then(|s| resolve_selection_target(s, state.selection).map(|t| (s, t))); 1587 1739 if !matches!(resolved, Some((_, SelectionTarget::Dimension(_, _)))) { 1588 - *dim_property = None; 1740 + *editors.dim = None; 1741 + } 1742 + if !matches!(state.mode, Mode::Extrude(ExtrudeArming::Profile(_))) { 1743 + *editors.extrude = None; 1589 1744 } 1590 1745 if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 1591 - return None; 1746 + return PropertyPaneOutcome::default(); 1592 1747 } 1593 1748 if let Mode::Extrude(arming) = state.mode { 1594 - let prompt = match arming { 1595 - ExtrudeArming::Profile(_) => strings::EXTRUDE_PROFILE_SELECTED, 1596 - ExtrudeArming::AwaitingSketch => strings::EXTRUDE_PROMPT_SELECT_SKETCH, 1749 + return match arming { 1750 + ExtrudeArming::Profile(feature) => PropertyPaneOutcome { 1751 + dimension_edit: None, 1752 + extrude_edit: render_extrude_rows( 1753 + ctx, 1754 + rect, 1755 + id, 1756 + clipboard, 1757 + editors.extrude, 1758 + *feature, 1759 + paints, 1760 + ), 1761 + }, 1762 + ExtrudeArming::AwaitingSketch => { 1763 + let mut editors = vec![row_editor(strings::EXTRUDE_PROMPT_SELECT_SKETCH, "")]; 1764 + render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); 1765 + PropertyPaneOutcome::default() 1766 + } 1597 1767 }; 1598 - let mut editors = vec![row_editor(prompt, "")]; 1599 - render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); 1600 - return None; 1601 1768 } 1602 1769 match resolved { 1603 1770 Some((sketch, SelectionTarget::Entity(entity))) => { 1604 1771 let mut editors = entity_editors(ctx.strings, entity, sketch); 1605 1772 render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); 1606 - None 1773 + PropertyPaneOutcome::default() 1607 1774 } 1608 1775 Some((sketch, SelectionTarget::Relation(rel))) => { 1609 1776 let mut editors = relation_editors(ctx.strings, rel, sketch); 1610 1777 render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); 1611 - None 1778 + PropertyPaneOutcome::default() 1612 1779 } 1613 1780 Some((sketch, SelectionTarget::Dimension(dim_id, dim))) => { 1614 - let sketch_id = active_sketch_id?; 1615 - render_dimension_rows( 1616 - ctx, 1617 - rect, 1618 - id, 1619 - clipboard, 1620 - dim_property, 1621 - sketch_id, 1622 - dim_id, 1623 - dim, 1624 - sketch, 1625 - paints, 1626 - ) 1781 + let Some(sketch_id) = active_sketch_id else { 1782 + return PropertyPaneOutcome::default(); 1783 + }; 1784 + PropertyPaneOutcome { 1785 + dimension_edit: render_dimension_rows( 1786 + ctx, 1787 + rect, 1788 + id, 1789 + clipboard, 1790 + editors.dim, 1791 + sketch_id, 1792 + dim_id, 1793 + dim, 1794 + sketch, 1795 + paints, 1796 + ), 1797 + extrude_edit: None, 1798 + } 1627 1799 } 1628 1800 None => { 1629 1801 let mut editors = vec![row_editor(strings::PROPERTY_PANE_NO_SELECTION, "")]; 1630 1802 render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); 1631 - None 1803 + PropertyPaneOutcome::default() 1632 1804 } 1805 + } 1806 + } 1807 + 1808 + fn sync_extrude_editor( 1809 + slot: &mut Option<ExtrudePropertyEditor>, 1810 + feature: ExtrudeFeature, 1811 + ) -> &mut ExtrudePropertyEditor { 1812 + let editor = match slot.take() { 1813 + Some(editor) if editor.sketch == feature.sketch => editor.synced(feature), 1814 + _ => ExtrudePropertyEditor::new(feature), 1815 + }; 1816 + slot.insert(editor) 1817 + } 1818 + 1819 + fn render_extrude_rows( 1820 + ctx: &mut FrameCtx<'_>, 1821 + rect: LayoutRect, 1822 + id: WidgetId, 1823 + clipboard: &mut MemoryClipboard, 1824 + extrude_property: &mut Option<ExtrudePropertyEditor>, 1825 + feature: ExtrudeFeature, 1826 + paints: &mut Vec<WidgetPaint>, 1827 + ) -> Option<ExtrudeEdit> { 1828 + let editor = sync_extrude_editor(extrude_property, feature); 1829 + let row = |key: &'static str| { 1830 + WidgetId::ROOT 1831 + .child(WidgetKey::new("props.extrude")) 1832 + .child(WidgetKey::new(key)) 1833 + }; 1834 + let end_id = row("end"); 1835 + let depth_id = row("depth"); 1836 + let draft_id = row("draft"); 1837 + let draft_angle_id = row("draft_angle"); 1838 + let direction_two_id = row("direction_two"); 1839 + let thin_id = row("thin"); 1840 + let merge_id = row("merge"); 1841 + let changed = { 1842 + let mut rows = vec![ 1843 + PropertyRow { 1844 + id: end_id, 1845 + label: strings::PROPERTY_ROW_EXTRUDE_END, 1846 + editor: &mut editor.end_condition, 1847 + read_only: false, 1848 + }, 1849 + PropertyRow { 1850 + id: depth_id, 1851 + label: strings::PROPERTY_ROW_EXTRUDE_DEPTH, 1852 + editor: &mut editor.depth, 1853 + read_only: false, 1854 + }, 1855 + PropertyRow { 1856 + id: draft_id, 1857 + label: strings::PROPERTY_ROW_EXTRUDE_DRAFT, 1858 + editor: &mut editor.draft_enabled, 1859 + read_only: true, 1860 + }, 1861 + PropertyRow { 1862 + id: draft_angle_id, 1863 + label: strings::PROPERTY_ROW_EXTRUDE_DRAFT_ANGLE, 1864 + editor: &mut editor.draft_angle, 1865 + read_only: true, 1866 + }, 1867 + PropertyRow { 1868 + id: direction_two_id, 1869 + label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION_TWO, 1870 + editor: &mut editor.direction_two, 1871 + read_only: true, 1872 + }, 1873 + PropertyRow { 1874 + id: thin_id, 1875 + label: strings::PROPERTY_ROW_EXTRUDE_THIN, 1876 + editor: &mut editor.thin, 1877 + read_only: true, 1878 + }, 1879 + PropertyRow { 1880 + id: merge_id, 1881 + label: strings::PROPERTY_ROW_EXTRUDE_MERGE, 1882 + editor: &mut editor.merge, 1883 + read_only: false, 1884 + }, 1885 + ]; 1886 + let response = show_property_grid( 1887 + ctx, 1888 + PropertyGrid::new(id, rect, strings::PROPERTY_PANE_LABEL, &mut rows), 1889 + clipboard, 1890 + ); 1891 + paints.extend(response.paint); 1892 + response.changed_rows 1893 + }; 1894 + if changed.contains(&end_id) { 1895 + kind_from_index(editor.end_condition.current).map(ExtrudeEdit::EndCondition) 1896 + } else if changed.contains(&depth_id) { 1897 + PositiveLength::new(editor.depth.value) 1898 + .ok() 1899 + .map(ExtrudeEdit::Depth) 1900 + } else if changed.contains(&merge_id) { 1901 + Some(ExtrudeEdit::Merge(if editor.merge.value { 1902 + MergeResult::Merge 1903 + } else { 1904 + MergeResult::Separate 1905 + })) 1906 + } else { 1907 + None 1633 1908 } 1634 1909 } 1635 1910 ··· 3192 3467 ); 3193 3468 } 3194 3469 3470 + fn profile_feature() -> ExtrudeFeature { 3471 + let ExtrudeArming::Profile(feature) = ExtrudeArming::profile(SketchId::default()) else { 3472 + unreachable!("profile arming holds a feature"); 3473 + }; 3474 + feature 3475 + } 3476 + 3477 + #[test] 3478 + fn extrude_edit_sets_depth_keeping_kind() { 3479 + let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(3.0)) else { 3480 + unreachable!("3 mm is positive"); 3481 + }; 3482 + let next = ExtrudeEdit::Depth(depth).apply(profile_feature()); 3483 + assert!(matches!( 3484 + next.end_condition, 3485 + ExtrudeEndCondition::Blind { depth: d } if d == depth 3486 + )); 3487 + } 3488 + 3489 + #[test] 3490 + fn extrude_edit_changes_kind_keeping_depth() { 3491 + let feature = profile_feature(); 3492 + let before = end_condition_depth(&feature.end_condition); 3493 + let next = ExtrudeEdit::EndCondition(EndConditionKind::MidPlane).apply(feature); 3494 + assert!(matches!( 3495 + next.end_condition, 3496 + ExtrudeEndCondition::MidPlane { .. } 3497 + )); 3498 + assert_eq!(end_condition_depth(&next.end_condition), before); 3499 + } 3500 + 3501 + #[test] 3502 + fn extrude_edit_toggles_merge_result() { 3503 + let next = ExtrudeEdit::Merge(MergeResult::Separate).apply(profile_feature()); 3504 + assert_eq!(next.merge_result, MergeResult::Separate); 3505 + } 3506 + 3507 + #[test] 3508 + fn profiled_extrude_shows_property_controls() { 3509 + let frame = render_with( 3510 + Theme::light(), 3511 + layout_size(1280.0, 800.0), 3512 + &sample_document(), 3513 + &Mode::Extrude(ExtrudeArming::profile(SketchId::default())), 3514 + ); 3515 + [ 3516 + strings::PROPERTY_ROW_EXTRUDE_END, 3517 + strings::PROPERTY_ROW_EXTRUDE_DEPTH, 3518 + strings::PROPERTY_ROW_EXTRUDE_MERGE, 3519 + ] 3520 + .into_iter() 3521 + .for_each(|key| { 3522 + assert!( 3523 + label_rect(&frame.paints, key).is_some(), 3524 + "extrude property pane shows {key:?}", 3525 + ); 3526 + }); 3527 + } 3528 + 3195 3529 #[test] 3196 3530 fn feature_tools_disabled_while_sketching() { 3197 3531 let theme = Arc::new(Theme::light()); ··· 3635 3969 assert_eq!(main.len(), 1, "non-tooltip stays in main"); 3636 3970 assert!(matches!(main[0], WidgetPaint::Surface { .. })); 3637 3971 assert_eq!(overlay.len(), 2, "tooltip expands to surface + label"); 3972 + assert!(matches!(overlay[0], WidgetPaint::Surface { .. })); 3973 + assert!(matches!(overlay[1], WidgetPaint::Label { .. })); 3974 + } 3975 + 3976 + #[test] 3977 + fn partition_overlay_floats_popup_paints_above_main() { 3978 + let theme = Theme::light(); 3979 + let rect = LayoutRect::new( 3980 + LayoutPos::new(LayoutPx::new(4.0), LayoutPx::new(6.0)), 3981 + LayoutSize::new(LayoutPx::new(40.0), LayoutPx::new(24.0)), 3982 + ); 3983 + let surface = |fill| WidgetPaint::Surface { 3984 + rect, 3985 + fill, 3986 + border: None, 3987 + radius: theme.radius.none, 3988 + elevation: None, 3989 + }; 3990 + let inputs = vec![ 3991 + surface(theme.colors.surface(bone_ui::theme::SurfaceLevel::L0)), 3992 + WidgetPaint::Popup { 3993 + paints: vec![ 3994 + surface(theme.colors.surface(bone_ui::theme::SurfaceLevel::L1)), 3995 + WidgetPaint::Label { 3996 + rect, 3997 + text: LabelText::Owned("Blind".to_owned()), 3998 + color: theme.colors.text_primary(), 3999 + role: theme.typography.body, 4000 + }, 4001 + ], 4002 + }, 4003 + ]; 4004 + let (main, overlay) = partition_overlay(inputs, &theme); 4005 + assert_eq!( 4006 + main.len(), 4007 + 1, 4008 + "the row surface beneath the popup stays in main" 4009 + ); 4010 + assert_eq!( 4011 + overlay.len(), 4012 + 2, 4013 + "the popup's surface + label float into the overlay so they draw on top", 4014 + ); 3638 4015 assert!(matches!(overlay[0], WidgetPaint::Surface { .. })); 3639 4016 assert!(matches!(overlay[1], WidgetPaint::Label { .. })); 3640 4017 }
+138 -7
crates/bone-app/src/sketch_mode.rs
··· 1 1 use core::num::NonZeroU32; 2 2 3 - use bone_document::{Sketch, SketchDimension, SketchEntity}; 4 - use bone_types::{Point2, Point3, SketchEntityId, SketchId, SketchPlaneBasis, Tolerance, UnitVec3}; 3 + use bone_document::{ 4 + ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, Sketch, 5 + SketchDimension, SketchEntity, 6 + }; 7 + use bone_types::{ 8 + Length, Point2, Point3, PositiveLength, SketchEntityId, SketchId, SketchPlaneBasis, Tolerance, 9 + UnitVec3, 10 + }; 5 11 use bone_ui::hotkey::ActionId; 12 + use uom::si::length::millimeter; 6 13 7 14 #[derive(Copy, Clone, Debug, PartialEq, Eq)] 8 15 pub enum SketchTool { ··· 47 54 pub const ALL: &'static [Self] = &[Self::ExtrudedBossBase, Self::ExtrudedCut]; 48 55 } 49 56 50 - #[derive(Copy, Clone, Debug, PartialEq, Eq)] 57 + #[derive(Copy, Clone, Debug, PartialEq)] 51 58 pub enum ExtrudeArming { 52 - Profile(SketchId), 59 + Profile(ExtrudeFeature), 53 60 AwaitingSketch, 54 61 } 55 62 63 + impl ExtrudeArming { 64 + #[must_use] 65 + pub fn profile(sketch: SketchId) -> Self { 66 + Self::Profile(default_extrude_feature(sketch)) 67 + } 68 + } 69 + 70 + const DEFAULT_EXTRUDE_DEPTH_MM: f64 = 10.0; 71 + 72 + #[must_use] 73 + pub fn default_extrude_depth() -> PositiveLength { 74 + let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(DEFAULT_EXTRUDE_DEPTH_MM)) else { 75 + unreachable!("constant default extrude depth is positive and finite"); 76 + }; 77 + depth 78 + } 79 + 80 + #[must_use] 81 + pub fn default_extrude_feature(sketch: SketchId) -> ExtrudeFeature { 82 + ExtrudeFeature { 83 + sketch, 84 + direction: ExtrudeDirection::Normal { 85 + sense: ExtrudeSense::Forward, 86 + }, 87 + end_condition: ExtrudeEndCondition::Blind { 88 + depth: default_extrude_depth(), 89 + }, 90 + draft: None, 91 + thin_wall: None, 92 + merge_result: MergeResult::Merge, 93 + } 94 + } 95 + 96 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 97 + pub enum EndConditionKind { 98 + Blind, 99 + MidPlane, 100 + } 101 + 102 + impl EndConditionKind { 103 + pub const SUPPORTED: &'static [Self] = &[Self::Blind, Self::MidPlane]; 104 + 105 + #[must_use] 106 + pub fn of(condition: &ExtrudeEndCondition) -> Option<Self> { 107 + match condition { 108 + ExtrudeEndCondition::Blind { .. } => Some(Self::Blind), 109 + ExtrudeEndCondition::MidPlane { .. } => Some(Self::MidPlane), 110 + _ => None, 111 + } 112 + } 113 + 114 + #[must_use] 115 + pub fn with_depth(self, depth: PositiveLength) -> ExtrudeEndCondition { 116 + match self { 117 + Self::Blind => ExtrudeEndCondition::Blind { depth }, 118 + Self::MidPlane => ExtrudeEndCondition::MidPlane { depth }, 119 + } 120 + } 121 + } 122 + 123 + #[must_use] 124 + pub fn end_condition_depth(condition: &ExtrudeEndCondition) -> Option<PositiveLength> { 125 + match condition { 126 + ExtrudeEndCondition::Blind { depth } | ExtrudeEndCondition::MidPlane { depth } => { 127 + Some(*depth) 128 + } 129 + _ => None, 130 + } 131 + } 132 + 56 133 #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] 57 134 pub enum Plane { 58 135 Xy, ··· 318 395 #[cfg(test)] 319 396 mod tests { 320 397 use super::{ 321 - ClickAnchor, DimensionFlow, DragPins, DragSession, ExtrudeArming, FeatureTool, Mode, 322 - Pending, Plane, SketchSession, SketchTool, 398 + ClickAnchor, DEFAULT_EXTRUDE_DEPTH_MM, DimensionFlow, DragPins, DragSession, 399 + EndConditionKind, ExtrudeArming, ExtrudeDirection, ExtrudeEndCondition, ExtrudeSense, 400 + FeatureTool, MergeResult, Mode, Pending, Plane, SketchSession, SketchTool, 401 + default_extrude_feature, end_condition_depth, 323 402 }; 324 - use bone_types::{Point2, SketchEntityId, SketchId}; 403 + use bone_types::{Length, Point2, PositiveLength, SketchEntityId, SketchId}; 404 + use uom::si::length::millimeter; 325 405 326 406 #[test] 327 407 fn sketch_mode_projections_read_session() { ··· 618 698 assert_ne!(a, b, "{:?} == {:?}", planes[i], planes[j]); 619 699 }); 620 700 }); 701 + } 702 + 703 + #[test] 704 + fn default_extrude_feature_is_blind_forward_merge() { 705 + let feature = default_extrude_feature(SketchId::default()); 706 + assert!(matches!( 707 + feature.direction, 708 + ExtrudeDirection::Normal { 709 + sense: ExtrudeSense::Forward 710 + } 711 + )); 712 + assert!(matches!( 713 + feature.end_condition, 714 + ExtrudeEndCondition::Blind { .. } 715 + )); 716 + assert_eq!(feature.merge_result, MergeResult::Merge); 717 + assert!(feature.draft.is_none()); 718 + assert!(feature.thin_wall.is_none()); 719 + let Some(depth) = end_condition_depth(&feature.end_condition) else { 720 + panic!("blind carries depth"); 721 + }; 722 + assert!((depth.get().get::<millimeter>() - DEFAULT_EXTRUDE_DEPTH_MM).abs() < 1e-9); 723 + } 724 + 725 + #[test] 726 + fn supported_end_conditions_are_blind_and_midplane() { 727 + assert_eq!( 728 + EndConditionKind::SUPPORTED, 729 + &[EndConditionKind::Blind, EndConditionKind::MidPlane], 730 + ); 731 + } 732 + 733 + #[test] 734 + fn end_condition_kind_round_trips_through_depth() { 735 + let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(7.0)) else { 736 + unreachable!("7 mm is positive and finite"); 737 + }; 738 + EndConditionKind::SUPPORTED.iter().for_each(|kind| { 739 + let condition = kind.with_depth(depth); 740 + assert_eq!(EndConditionKind::of(&condition), Some(*kind)); 741 + assert_eq!(end_condition_depth(&condition), Some(depth)); 742 + }); 743 + } 744 + 745 + #[test] 746 + fn extrude_arming_profile_carries_the_sketch() { 747 + let id = SketchId::default(); 748 + let ExtrudeArming::Profile(feature) = ExtrudeArming::profile(id) else { 749 + panic!("profile arming holds a feature"); 750 + }; 751 + assert_eq!(feature.sketch, id); 621 752 } 622 753 }
+33
crates/bone-app/src/strings.rs
··· 197 197 pub const EXTRUDE_PROFILE_SELECTED: StringKey = StringKey::new("extrude.profile.selected"); 198 198 pub const FEATURE_HINT_EXIT_SKETCH: StringKey = StringKey::new("feature.hint.exit_sketch"); 199 199 pub const NOTIFY_EXTRUDE_NO_SKETCH: StringKey = StringKey::new("notify.extrude.no_sketch"); 200 + pub const NOTIFY_EXTRUDE_FAILED: StringKey = StringKey::new("notify.extrude.failed"); 201 + pub const PROPERTY_ROW_EXTRUDE_END: StringKey = 202 + StringKey::new("property.row.extrude.end_condition"); 203 + pub const PROPERTY_ROW_EXTRUDE_DEPTH: StringKey = StringKey::new("property.row.extrude.depth"); 204 + pub const PROPERTY_ROW_EXTRUDE_DRAFT: StringKey = StringKey::new("property.row.extrude.draft"); 205 + pub const PROPERTY_ROW_EXTRUDE_DRAFT_ANGLE: StringKey = 206 + StringKey::new("property.row.extrude.draft_angle"); 207 + pub const PROPERTY_ROW_EXTRUDE_DIRECTION_TWO: StringKey = 208 + StringKey::new("property.row.extrude.direction_two"); 209 + pub const PROPERTY_ROW_EXTRUDE_THIN: StringKey = StringKey::new("property.row.extrude.thin"); 210 + pub const PROPERTY_ROW_EXTRUDE_MERGE: StringKey = StringKey::new("property.row.extrude.merge"); 211 + pub const EXTRUDE_END_BLIND: StringKey = StringKey::new("extrude.end.blind"); 212 + pub const EXTRUDE_END_MIDPLANE: StringKey = StringKey::new("extrude.end.midplane"); 200 213 pub const PROPERTY_ROW_KIND: StringKey = StringKey::new("property.row.kind"); 201 214 pub const PROPERTY_ROW_X: StringKey = StringKey::new("property.row.x"); 202 215 pub const PROPERTY_ROW_Y: StringKey = StringKey::new("property.row.y"); ··· 448 461 (EXTRUDE_PROFILE_SELECTED, "Sketch selected"), 449 462 (FEATURE_HINT_EXIT_SKETCH, "Exit the sketch to add a feature"), 450 463 (NOTIFY_EXTRUDE_NO_SKETCH, "Create a sketch first"), 464 + (NOTIFY_EXTRUDE_FAILED, "Can't preview this extrude"), 465 + (PROPERTY_ROW_EXTRUDE_END, "End Condition"), 466 + (PROPERTY_ROW_EXTRUDE_DEPTH, "Depth"), 467 + (PROPERTY_ROW_EXTRUDE_DRAFT, "Draft"), 468 + (PROPERTY_ROW_EXTRUDE_DRAFT_ANGLE, "Draft Angle"), 469 + (PROPERTY_ROW_EXTRUDE_DIRECTION_TWO, "Direction 2"), 470 + (PROPERTY_ROW_EXTRUDE_THIN, "Thin Feature"), 471 + (PROPERTY_ROW_EXTRUDE_MERGE, "Merge Result"), 472 + (EXTRUDE_END_BLIND, "Blind"), 473 + (EXTRUDE_END_MIDPLANE, "Mid Plane"), 451 474 (PROPERTY_ROW_KIND, "Type"), 452 475 (PROPERTY_ROW_X, "X"), 453 476 (PROPERTY_ROW_Y, "Y"), ··· 708 731 "[!! Êxit the skêtch to add a fêature !!]", 709 732 ), 710 733 (NOTIFY_EXTRUDE_NO_SKETCH, "[!! Crêate a skêtch fîrst !!]"), 734 + (NOTIFY_EXTRUDE_FAILED, "[!! Cân't prêview this êxtrude !!]"), 735 + (PROPERTY_ROW_EXTRUDE_END, "[!! Énd Condîtion !!]"), 736 + (PROPERTY_ROW_EXTRUDE_DEPTH, "[!! Dépth !!]"), 737 + (PROPERTY_ROW_EXTRUDE_DRAFT, "[!! Drâft !!]"), 738 + (PROPERTY_ROW_EXTRUDE_DRAFT_ANGLE, "[!! Drâft Ângle !!]"), 739 + (PROPERTY_ROW_EXTRUDE_DIRECTION_TWO, "[!! Dîrection 2 !!]"), 740 + (PROPERTY_ROW_EXTRUDE_THIN, "[!! Thîn Fêature !!]"), 741 + (PROPERTY_ROW_EXTRUDE_MERGE, "[!! Mêrge Rêsult !!]"), 742 + (EXTRUDE_END_BLIND, "[!! Blînd !!]"), 743 + (EXTRUDE_END_MIDPLANE, "[!! Mîd Plâne !!]"), 711 744 (PROPERTY_ROW_KIND, "[!! Týpe !!]"), 712 745 (PROPERTY_ROW_X, "X"), 713 746 (PROPERTY_ROW_Y, "Y"),
+4
crates/bone-document/src/lib.rs
··· 5 5 pub mod sketch; 6 6 pub mod undo; 7 7 8 + pub use bone_kernel::{ 9 + BrepSolid, DraftAngle, DraftDirection, DraftMagnitude, ExtrudeDirection, ExtrudeEndCondition, 10 + ExtrudeFeature, ExtrudeSense, MergeResult, ThinWall, ThinWallDirection, 11 + }; 8 12 pub use document::{ 9 13 Document, DocumentHeader, DocumentParameters, ExtrudeFile, FeatureEdge, FeatureNode, 10 14 FeatureTree, ImportedSolid, PrincipalPlane, RenameSketchError, SketchFile, SketchRegistry,
+73
crates/bone-render/src/camera.rs
··· 64 64 } 65 65 } 66 66 67 + #[derive(Copy, Clone, Debug, PartialEq)] 68 + pub struct ViewportRegion { 69 + min_x: ViewportPx, 70 + min_y: ViewportPx, 71 + extent: ViewportExtent, 72 + } 73 + 74 + impl ViewportRegion { 75 + #[must_use] 76 + pub const fn new(min_x: ViewportPx, min_y: ViewportPx, extent: ViewportExtent) -> Self { 77 + Self { 78 + min_x, 79 + min_y, 80 + extent, 81 + } 82 + } 83 + 84 + #[must_use] 85 + pub const fn at_origin(extent: ViewportExtent) -> Self { 86 + Self::new(ViewportPx::new(0), ViewportPx::new(0), extent) 87 + } 88 + 89 + #[must_use] 90 + pub const fn extent(self) -> ViewportExtent { 91 + self.extent 92 + } 93 + 94 + #[must_use] 95 + pub const fn scissor(self) -> (u32, u32, u32, u32) { 96 + ( 97 + self.min_x.value(), 98 + self.min_y.value(), 99 + self.extent.width().value(), 100 + self.extent.height().value(), 101 + ) 102 + } 103 + 104 + #[must_use] 105 + pub fn viewport(self) -> [f32; 4] { 106 + let (x, y, width, height) = self.scissor(); 107 + [ 108 + crate::lower_f32(f64::from(x)), 109 + crate::lower_f32(f64::from(y)), 110 + crate::lower_f32(f64::from(width)), 111 + crate::lower_f32(f64::from(height)), 112 + ] 113 + } 114 + } 115 + 67 116 #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] 68 117 pub struct PixelsPerMm(f64); 69 118 ··· 241 290 242 291 fn approx_eq(a: f32, b: f32) -> bool { 243 292 (a - b).abs() < 1e-6 293 + } 294 + 295 + fn viewport_approx(region: ViewportRegion, expected: [f32; 4]) -> bool { 296 + region 297 + .viewport() 298 + .iter() 299 + .zip(expected) 300 + .all(|(got, want)| approx_eq(*got, want)) 301 + } 302 + 303 + #[test] 304 + fn viewport_region_at_origin_spans_the_extent() { 305 + let region = ViewportRegion::at_origin(extent(800, 600)); 306 + assert_eq!(region.scissor(), (0, 0, 800, 600)); 307 + assert!(viewport_approx(region, [0.0, 0.0, 800.0, 600.0])); 308 + assert_eq!(region.extent(), extent(800, 600)); 309 + } 310 + 311 + #[test] 312 + fn viewport_region_carries_its_offset() { 313 + let region = 314 + ViewportRegion::new(ViewportPx::new(64), ViewportPx::new(32), extent(320, 240)); 315 + assert_eq!(region.scissor(), (64, 32, 320, 240)); 316 + assert!(viewport_approx(region, [64.0, 32.0, 320.0, 240.0])); 244 317 } 245 318 246 319 #[test]
+98 -37
crates/bone-render/src/lib.rs
··· 11 11 pub mod surface; 12 12 pub mod tween; 13 13 14 - pub use camera::{Camera2, GridSpacing, PixelsPerMm, ViewportExtent, ViewportPx}; 14 + pub use camera::{Camera2, GridSpacing, PixelsPerMm, ViewportExtent, ViewportPx, ViewportRegion}; 15 15 pub use camera3::{ 16 16 ViewportPoint, arcball_rotation, clip_from_world, frame_isometric, frame_standard_view, 17 17 orbit_about_pixel, orbit_about_point, pan_pixels, roll_about_view, world_from_clip, ··· 243 243 style: &Style, 244 244 mode: bone_types::DisplayMode, 245 245 ) -> Result<SnapshotFrame> { 246 - let plan = DisplayPlan::for_mode(mode); 247 246 let extent = ctx.extent(); 248 - let clip_from_world = camera3::clip_from_world(camera, extent)?; 249 - let (ex, ey, ez) = camera.eye().coords_mm(); 250 - let eye_world = [lower_f32(ex), lower_f32(ey), lower_f32(ez)]; 247 + let view = SolidFrameView::new(camera, ViewportRegion::at_origin(extent))?; 251 248 if !matches!(&self.depth, Some(target) if target.extent == extent) { 252 249 self.depth = Some(DepthTarget { 253 250 extent, ··· 260 257 let depth_view = target 261 258 .texture 262 259 .create_view(&wgpu::TextureViewDescriptor::default()); 263 - let solid = &self.solid; 264 - let edge_pipeline = &self.edges; 265 - let shading = self.shading; 266 - let edge_view = EdgeView { 260 + ctx.render(|encoder, color_view, pick_view| { 261 + self.encode_passes( 262 + encoder, 263 + RenderTargets::new(color_view, pick_view), 264 + &depth_view, 265 + scene, 266 + edges, 267 + &SolidDisplay { 268 + view: &view, 269 + style, 270 + mode, 271 + }, 272 + ); 273 + }) 274 + } 275 + 276 + pub fn encode_passes( 277 + &self, 278 + encoder: &mut wgpu::CommandEncoder, 279 + targets: RenderTargets<'_>, 280 + depth_view: &wgpu::TextureView, 281 + scene: &SolidScene, 282 + edges: &EdgeScene, 283 + display: &SolidDisplay<'_>, 284 + ) { 285 + let &SolidDisplay { view, style, mode } = display; 286 + let plan = DisplayPlan::for_mode(mode); 287 + match plan.solid { 288 + Some(fill) => self.solid.draw( 289 + encoder, 290 + targets, 291 + depth_view, 292 + scene, 293 + SolidView { 294 + clip_from_world: view.clip_from_world, 295 + eye_world: view.eye_world, 296 + shading: self.shading, 297 + fill, 298 + region: view.region, 299 + }, 300 + style, 301 + ), 302 + None => clear_solid_targets(encoder, targets, depth_view, style), 303 + } 304 + if plan.edges && !edges.is_empty() { 305 + let edge_view = EdgeView { 306 + clip_from_world: view.clip_from_world, 307 + projection: EdgeProjection::from_camera(view.camera), 308 + viewport_px: view.viewport_px, 309 + crease_threshold_rad: CREASE_THRESHOLD_RAD, 310 + dash_period_px: HIDDEN_DASH_PERIOD_PX, 311 + dash_on_ratio: HIDDEN_DASH_ON_RATIO, 312 + edge_color: style.edges().visible().to_rgba_array(), 313 + hidden_color: style.edges().hidden().to_rgba_array(), 314 + region: view.region, 315 + }; 316 + self.edges 317 + .draw(encoder, targets, depth_view, edges, edge_view, plan.hidden); 318 + } 319 + } 320 + } 321 + 322 + pub(crate) fn apply_viewport_region(pass: &mut wgpu::RenderPass<'_>, region: ViewportRegion) { 323 + let (x, y, width, height) = region.scissor(); 324 + if width == 0 || height == 0 { 325 + return; 326 + } 327 + let [vx, vy, vw, vh] = region.viewport(); 328 + pass.set_viewport(vx, vy, vw, vh, 0.0, 1.0); 329 + pass.set_scissor_rect(x, y, width, height); 330 + } 331 + 332 + pub struct SolidDisplay<'a> { 333 + pub view: &'a SolidFrameView, 334 + pub style: &'a Style, 335 + pub mode: bone_types::DisplayMode, 336 + } 337 + 338 + #[derive(Copy, Clone)] 339 + pub struct SolidFrameView { 340 + camera: bone_types::Camera3, 341 + clip_from_world: [f32; 16], 342 + eye_world: [f32; 3], 343 + viewport_px: [f32; 2], 344 + region: ViewportRegion, 345 + } 346 + 347 + impl SolidFrameView { 348 + pub fn new(camera: bone_types::Camera3, region: ViewportRegion) -> Result<Self> { 349 + let extent = region.extent(); 350 + let clip_from_world = camera3::clip_from_world(camera, extent)?; 351 + let (ex, ey, ez) = camera.eye().coords_mm(); 352 + Ok(Self { 353 + camera, 267 354 clip_from_world, 268 - projection: EdgeProjection::from_camera(camera), 355 + eye_world: [lower_f32(ex), lower_f32(ey), lower_f32(ez)], 269 356 viewport_px: [ 270 357 lower_f32(f64::from(extent.width().value())), 271 358 lower_f32(f64::from(extent.height().value())), 272 359 ], 273 - crease_threshold_rad: CREASE_THRESHOLD_RAD, 274 - dash_period_px: HIDDEN_DASH_PERIOD_PX, 275 - dash_on_ratio: HIDDEN_DASH_ON_RATIO, 276 - edge_color: style.edges().visible().to_rgba_array(), 277 - hidden_color: style.edges().hidden().to_rgba_array(), 278 - }; 279 - ctx.render(|encoder, color_view, pick_view| { 280 - let targets = RenderTargets::new(color_view, pick_view); 281 - match plan.solid { 282 - Some(fill) => solid.draw( 283 - encoder, 284 - targets, 285 - &depth_view, 286 - scene, 287 - SolidView { 288 - clip_from_world, 289 - eye_world, 290 - shading, 291 - fill, 292 - }, 293 - style, 294 - ), 295 - None => clear_solid_targets(encoder, targets, &depth_view, style), 296 - } 297 - if plan.edges && !edges.is_empty() { 298 - edge_pipeline.draw(encoder, targets, &depth_view, edges, edge_view, plan.hidden); 299 - } 360 + region, 300 361 }) 301 362 } 302 363 }
+5 -5
crates/bone-render/src/navigate.rs
··· 105 105 }; 106 106 let next = match drag.gesture { 107 107 NavGesture::Orbit => { 108 - let delta = arcball_rotation(camera, extent, drag.last, cursor)?; 108 + let delta = arcball_rotation(camera, extent, cursor, drag.last)?; 109 109 let oriented = orbit_about_point(camera, camera.target(), delta)?; 110 110 self.orbit = self.orbit.rotated(delta); 111 111 oriented 112 112 } 113 113 NavGesture::Pan => pan_pixels(camera, extent, drag.last, cursor)?, 114 - NavGesture::Roll => roll_about_view(camera, extent, drag.last, cursor)?, 114 + NavGesture::Roll => roll_about_view(camera, extent, cursor, drag.last)?, 115 115 }; 116 116 self.drag = Some(Drag { 117 117 gesture: drag.gesture, ··· 223 223 } 224 224 225 225 #[test] 226 - fn orbit_drag_right_swings_the_eye_toward_positive_x() { 226 + fn orbit_drag_right_pulls_the_grabbed_model_with_the_cursor() { 227 227 let mut nav = ViewportNavigator::new(); 228 228 nav.begin_drag(NavGesture::Orbit, vp(128.0, 128.0)); 229 229 let Ok(orbited) = nav.drag_to(vp(190.0, 128.0), camera(), extent()) else { ··· 231 231 }; 232 232 let (ex, _, _) = orbited.eye().coords_mm(); 233 233 assert!( 234 - ex > 0.0, 235 - "dragging right orbits the eye to the +x side of the model: {ex}" 234 + ex < 0.0, 235 + "dragging right swings the eye to -x so the grabbed face follows the cursor like pan: {ex}" 236 236 ); 237 237 } 238 238
+3
crates/bone-render/src/pipelines/edge_3d.rs
··· 3 3 use bone_types::{Camera3, Point3, ProjectionKind, UnitVec3, Vec3}; 4 4 5 5 use crate::RenderTargets; 6 + use crate::ViewportRegion; 6 7 use crate::gpu::{Gpu, PICK_FORMAT}; 7 8 use crate::lower_f32; 8 9 use crate::pick::PickId; ··· 134 135 pub dash_on_ratio: f32, 135 136 pub edge_color: [f32; 4], 136 137 pub hidden_color: [f32; 4], 138 + pub region: ViewportRegion, 137 139 } 138 140 139 141 pub(crate) struct Edge3dPipeline { ··· 277 279 occlusion_query_set: None, 278 280 multiview_mask: None, 279 281 }); 282 + crate::apply_viewport_region(&mut pass, view.region); 280 283 pass.set_bind_group(0, &self.bind_group, &[]); 281 284 if let Some(buffer) = &visible_buffer { 282 285 record_layer(&mut pass, &self.visible, buffer, visible.len());
+3
crates/bone-render/src/pipelines/solid.rs
··· 3 3 use bone_types::ShadingModel; 4 4 5 5 use crate::RenderTargets; 6 + use crate::ViewportRegion; 6 7 use crate::gpu::{Gpu, PICK_FORMAT}; 7 8 use crate::lower_f32; 8 9 use crate::scene::SolidScene; ··· 47 48 pub eye_world: [f32; 3], 48 49 pub shading: ShadingModel, 49 50 pub fill: FaceFill, 51 + pub region: ViewportRegion, 50 52 } 51 53 52 54 #[repr(C)] ··· 223 225 occlusion_query_set: None, 224 226 multiview_mask: None, 225 227 }); 228 + crate::apply_viewport_region(&mut pass, view.region); 226 229 let Ok(count) = u32::try_from(indices.len()) else { 227 230 panic!("solid index count {} exceeds u32::MAX", indices.len()); 228 231 };
+5
crates/bone-ui/src/raster.rs
··· 325 325 }, 326 326 ); 327 327 } 328 + WidgetPaint::Popup { paints } => { 329 + paints 330 + .iter() 331 + .for_each(|inner| draw(canvas, inner, theme, strings, painter)); 332 + } 328 333 other => { 329 334 let prim = lower_paint(theme, other); 330 335 draw_prim(canvas, &prim);
+7 -3
crates/bone-ui/src/widgets/dropdown.rs
··· 247 247 popup_rect, 248 248 AccessNode::new(Role::ListBox).with_label(label), 249 249 ); 250 - paint.push(WidgetPaint::Surface { 250 + let mut popup_paints: Vec<WidgetPaint> = Vec::new(); 251 + popup_paints.push(WidgetPaint::Surface { 251 252 rect: popup_rect, 252 253 fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L1), 253 254 border: Some(Border { ··· 285 286 state.open = false; 286 287 } 287 288 let highlighted = state.highlighted == Some(index); 288 - paint.push(WidgetPaint::Surface { 289 + popup_paints.push(WidgetPaint::Surface { 289 290 rect: item_rect, 290 291 fill: if highlighted { 291 292 ctx.theme().colors.neutral.step(Step12::HOVER_BG) ··· 298 299 radius: ctx.theme().radius.none, 299 300 elevation: None, 300 301 }); 301 - paint.push(WidgetPaint::Label { 302 + popup_paints.push(WidgetPaint::Label { 302 303 rect: item_rect, 303 304 text: LabelText::Key(item.label), 304 305 color: ctx.theme().colors.text_primary(), ··· 306 307 }); 307 308 }); 308 309 state.last_hovered = current_hovered; 310 + paint.push(WidgetPaint::Popup { 311 + paints: popup_paints, 312 + }); 309 313 } 310 314 311 315 fn pointer_pressed_outside(
+10
crates/bone-ui/src/widgets/paint.rs
··· 156 156 anchor: WidgetId, 157 157 elevation: ElevationLevel, 158 158 }, 159 + Popup { 160 + paints: Vec<WidgetPaint>, 161 + }, 159 162 } 160 163 161 164 #[derive(Copy, Clone, Debug, PartialEq)] ··· 232 235 border: elevation.border, 233 236 radius: Radius::px(0.0), 234 237 }, 238 + WidgetPaint::Popup { .. } => PaintPrim::solid( 239 + LayoutRect::new( 240 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 241 + LayoutSize::ZERO, 242 + ), 243 + Color::TRANSPARENT, 244 + ), 235 245 } 236 246 } 237 247
+14 -2
crates/bone-ui/tests/gallery_snapshot.rs
··· 244 244 ); 245 245 } 246 246 247 + fn flatten_paints(paints: &[WidgetPaint]) -> Vec<&WidgetPaint> { 248 + paints 249 + .iter() 250 + .flat_map(|paint| match paint { 251 + WidgetPaint::Popup { paints } => flatten_paints(paints), 252 + other => vec![other], 253 + }) 254 + .collect() 255 + } 256 + 247 257 #[test] 248 258 fn paint_rects_stay_inside_gallery_canvas() { 249 259 let mut state = GalleryState::new(); ··· 267 277 )] 268 278 let max_y = GALLERY_CANVAS.height.value() as f32; 269 279 let lookup = name_lookup(); 270 - let oob: Vec<String> = paint 271 - .iter() 280 + let oob: Vec<String> = flatten_paints(&paint) 281 + .into_iter() 272 282 .filter_map(|p| { 273 283 let (rect, anchor) = match p { 274 284 WidgetPaint::Surface { rect, .. } ··· 279 289 | WidgetPaint::SelectionHighlight { rect, .. } 280 290 | WidgetPaint::Caret { rect, .. } => (*rect, None), 281 291 WidgetPaint::Tooltip { rect, anchor, .. } => (*rect, Some(*anchor)), 292 + WidgetPaint::Popup { .. } => return None, 282 293 }; 283 294 let outside = rect.min_x().value() < 0.0 284 295 || rect.min_y().value() < 0.0 ··· 306 317 WidgetPaint::SelectionHighlight { .. } => "SelectionHighlight", 307 318 WidgetPaint::Caret { .. } => "Caret", 308 319 WidgetPaint::Tooltip { .. } => "Tooltip", 320 + WidgetPaint::Popup { .. } => "Popup", 309 321 } 310 322 } 311 323