Another project
1use std::collections::{BTreeMap, BTreeSet};
2use std::num::NonZeroUsize;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use bone_document::{
7 DimensionKind, DimensionValue, Document, DocumentFolder, EditOutcome, EvaluatedExtrude,
8 EvaluatedModel, ExtrudeError, ExtrudeFeature, FeatureNode, LineData, RebuildBudget,
9 RebuildCost, RebuildPass, RecomputeScope, Sketch, SketchDimension, SketchEdit, SketchEntity,
10 SketchRelation, SketchVersion, SolverError, UndoStack, evaluate_extrude, evaluate_sketch,
11};
12use bone_render::{
13 Camera2, CameraTween, ChromeInstance, ChromePipeline, ChromeTextPipeline, ConvexInstance,
14 ConvexPolyPipeline, DragModifiers, EdgeScene, Gpu, IconInstance, IconPipeline, NavGesture,
15 PickIdError, PickIndex, PickQuery, PickedItem, Picker, PixelsPerMm, RenderTargets,
16 SdfGlyphInstance, SketchPreview, SketchRenderer, SketchScene, SolidFrameView, SolidRenderer,
17 SolidScene, StrokeInstance, StrokePipeline, Style, ViewportExtent, ViewportNavigator,
18 ViewportPoint, ViewportPx, ViewportRegion, frame_current, frame_standard_view,
19 frame_view_direction, orbit_pitch, orbit_yaw, pan_pixels, roll_by, zoom_about_pixel,
20};
21use bone_types::{
22 Aabb3, Angle, AngleTolerance, BrepFaceId, BudgetCeiling, Camera3, ChordHeightTolerance,
23 CubicEasing, DisplayMode, DocumentId, ExtrudeId, FeatureId, GeometryGeneration, Length, Plane3,
24 Point2, RebuildError, RebuildStatus, SketchId, SketchItemId, StandardView, Vec2, ZoomFactor,
25};
26use bone_ui::a11y::AccessTreeBuilder;
27use bone_ui::focus::FocusManager;
28use bone_ui::frame::FrameCtx;
29use bone_ui::hit_test::{HitFrame, HitState, resolve};
30use bone_ui::hotkey::{ActionId, HotkeyScope, HotkeyScopes, HotkeyTable};
31use bone_ui::input::{
32 FrameInstant, InputSnapshot, KeyCode as UiKeyCode, KeyEvent as UiKeyEvent, ModifierMask,
33 NamedKey, PointerButton, PointerButtonMask, PointerSample,
34};
35use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
36use bone_ui::strings::StringTable;
37use bone_ui::theme::Theme;
38use bone_ui::widgets::TreeBadge;
39use bone_ui::{MaskAtlas, MaskAtlasParams, Shaper};
40use swash::FontRef;
41use uom::si::angle::degree;
42use uom::si::length::millimeter;
43
44use crate::clock::FrameClock;
45use crate::dimension_editor::{
46 DimensionEditorAction, DimensionEditorOutcome, DimensionEditorState,
47};
48use crate::input::{InputDispatched, InputEvent, KeyDown, NavKey, ScrollDelta, WindowPoint};
49use crate::selection::Selection;
50use crate::sketch_mode::{
51 ClickAnchor, DimensionFlow, DragPins, DragSession, ExtrudeArming, FeatureTool, Mode, Pending,
52 PendingDimension, Plane, SketchTool,
53};
54use crate::snap::{Anchor, SnapHit};
55use crate::status_badge::ExtrudeStatus;
56use crate::{
57 chrome, dimension_editor, file_menu, hotkeys, native_picker, selection, settings, shell,
58 shortcut_bar, sketch_mode, smart_dimension, snap, step_jobs, strings, tools, view_cube,
59};
60const ZOOM_STEP_PER_LINE: f64 = 1.1;
61const ZOOM_STEP_PER_PIXEL: f64 = 1.0025;
62const ZOOM_KEY_STEP: f64 = 1.25;
63const ORBIT_KEY_STEP_DEG: f64 = 15.0;
64const ORBIT_KEY_SNAP_DEG: f64 = 90.0;
65const ZOOM_MIN: f64 = 0.01;
66const ZOOM_MAX: f64 = 1.0e5;
67const INITIAL_ZOOM_PX_PER_MM: f64 = 12.0;
68const PAN_STEP_PX: f64 = 40.0;
69const PAN_FAST_MULTIPLIER: f64 = 5.0;
70const ZOOM_FIT_MARGIN: f64 = 0.9;
71const UNDO_CAPACITY: usize = 256;
72const SNAP_TOLERANCE_PX: f64 = 8.0;
73const SNAP_TOLERANCE_MAX_MM: f64 = 5.0;
74
75struct AppState {
76 extent: ViewportExtent,
77 renderer: SketchRenderer,
78 chrome_pipeline: ChromePipeline,
79 convex_pipeline: ConvexPolyPipeline,
80 stroke_pipeline: StrokePipeline,
81 icon_pipeline: IconPipeline,
82 text_pipeline: ChromeTextPipeline,
83 sdf_atlas: MaskAtlas,
84 chrome_shaper: Shaper,
85 sans_font: FontRef<'static>,
86 mono_font: FontRef<'static>,
87 scene: SketchScene,
88 camera: Camera2,
89 style: Style,
90 theme: Arc<Theme>,
91 shell: shell::Shell,
92 document: Document,
93 plane_sketches: BTreeMap<Plane, SketchId>,
94 mode: Mode,
95 extrude_preview: Option<ExtrudePreview>,
96 model: EvaluatedModel,
97 model_passes: Vec<(FeatureId, RebuildPass)>,
98 changed_features: BTreeSet<FeatureId>,
99 needs_rebuild: bool,
100 pending_reattach: Option<SketchId>,
101 solid_renderer: SolidRenderer,
102 solid_view: Option<SolidViewData>,
103 camera3: Option<Camera3>,
104 framed_extrude: Option<ExtrudeId>,
105 navigator: ViewportNavigator,
106 view: view_cube::ViewUi,
107 focus: FocusManager,
108 hit_state: HitState,
109 hotkeys: HotkeyTable,
110 strings: StringTable,
111 viewport_rect: LayoutRect,
112 undo: UndoStack,
113 selection: Selection,
114 settings: settings::Settings,
115 dim_editor: DimensionEditorState,
116 dim_editor_bounds: Option<LayoutRect>,
117 pending_exit: bool,
118 current_folder: Option<DocumentFolder>,
119 documents_root: PathBuf,
120 file_picker: Option<file_menu::FilePickerSession>,
121 native_picker: Option<native_picker::PendingHandle>,
122 step_job: Option<step_jobs::StepJob>,
123 pending_overwrite: Option<PendingOverwrite>,
124 last_saved: Option<Document>,
125 pending_discard: Option<PendingDiscard>,
126 notification: Option<Notification>,
127 shortcut_bar: Option<shortcut_bar::ShortcutBarState>,
128}
129
130#[derive(Clone, Debug, PartialEq)]
131enum PendingDiscard {
132 New,
133 Open(PathBuf),
134 ImportStep(PathBuf),
135 InstallImported {
136 document: Box<Document>,
137 file_name: String,
138 },
139}
140
141#[derive(Copy, Clone, Debug, PartialEq, Eq)]
142enum PickedVia {
143 NativePortal,
144 CustomPicker,
145}
146
147#[derive(Clone, Debug, PartialEq)]
148enum PendingOverwrite {
149 Document(DocumentFolder),
150 StepExport(PathBuf),
151}
152
153fn modal_active(state: &AppState) -> bool {
154 state.file_picker.is_some()
155 || state.native_picker.is_some()
156 || state
157 .step_job
158 .as_ref()
159 .is_some_and(|job| job.meta().show_progress)
160 || state.pending_overwrite.is_some()
161 || state.pending_discard.is_some()
162 || state.shortcut_bar.is_some()
163 || state.shell.state.ribbon_overflow_open.values().any(|v| *v)
164}
165
166#[derive(Copy, Clone, Debug, PartialEq, Eq)]
167enum NotificationKind {
168 Info,
169 Error,
170}
171
172#[derive(Clone, Debug, PartialEq)]
173struct Notification {
174 kind: NotificationKind,
175 headline: bone_ui::strings::StringKey,
176 detail: Option<String>,
177}
178
179struct InputState {
180 cursor_px: Option<WindowPoint>,
181 left_pan: bool,
182 middle_pan: bool,
183 modifiers: ModifierMask,
184 pending_pressed: PointerButtonMask,
185 pending_released: PointerButtonMask,
186 pending_keys: Vec<UiKeyEvent>,
187 pending_text: String,
188 pending_scroll_y: f32,
189}
190
191impl Default for InputState {
192 fn default() -> Self {
193 Self {
194 cursor_px: None,
195 left_pan: false,
196 middle_pan: false,
197 modifiers: ModifierMask::NONE,
198 pending_pressed: PointerButtonMask::EMPTY,
199 pending_released: PointerButtonMask::EMPTY,
200 pending_keys: Vec::new(),
201 pending_text: String::new(),
202 pending_scroll_y: 0.0,
203 }
204 }
205}
206
207impl InputState {
208 fn shift(&self) -> bool {
209 self.modifiers.contains(ModifierMask::SHIFT)
210 }
211
212 fn ctrl_or_meta(&self) -> bool {
213 self.modifiers.contains(ModifierMask::CTRL) || self.modifiers.contains(ModifierMask::META)
214 }
215
216 fn panning(&self) -> bool {
217 self.middle_pan || (self.left_pan && self.shift())
218 }
219
220 fn pan_step_px(&self) -> f64 {
221 if self.shift() {
222 PAN_STEP_PX * PAN_FAST_MULTIPLIER
223 } else {
224 PAN_STEP_PX
225 }
226 }
227
228 fn pointer_sample(&self) -> Option<PointerSample> {
229 self.cursor_px
230 .map(window_to_layout_pos)
231 .map(PointerSample::new)
232 }
233
234 fn cursor_in(&self, rect: LayoutRect) -> bool {
235 self.cursor_px
236 .map(window_to_layout_pos)
237 .is_some_and(|p| rect.contains(p))
238 }
239
240 fn drain_snapshot(&mut self, now: FrameInstant) -> InputSnapshot {
241 let mut snap = InputSnapshot::idle(now);
242 snap.pointer = self.pointer_sample();
243 snap.buttons_pressed =
244 core::mem::replace(&mut self.pending_pressed, PointerButtonMask::EMPTY);
245 snap.buttons_released =
246 core::mem::replace(&mut self.pending_released, PointerButtonMask::EMPTY);
247 snap.keys_pressed = core::mem::take(&mut self.pending_keys);
248 snap.text_committed = core::mem::take(&mut self.pending_text);
249 snap.modifiers = self.modifiers;
250 snap.scroll_y = core::mem::replace(&mut self.pending_scroll_y, 0.0);
251 snap
252 }
253
254 fn forget_pan_state(&mut self) {
255 self.cursor_px = None;
256 self.left_pan = false;
257 self.middle_pan = false;
258 self.modifiers = ModifierMask::NONE;
259 }
260}
261
262#[allow(
263 clippy::cast_possible_truncation,
264 reason = "window cursor px (f64) collapses to LayoutPx (f32) at the sub-pixel limit"
265)]
266fn window_to_layout_pos(p: WindowPoint) -> LayoutPos {
267 LayoutPos::new(
268 LayoutPx::saturating(p.x as f32),
269 LayoutPx::saturating(p.y as f32),
270 )
271}
272
273pub trait FrameTarget {
274 fn picker(&self, index: PickIndex) -> Picker<'_>;
275 fn render(
276 &mut self,
277 build_passes: impl FnOnce(
278 &mut wgpu::CommandEncoder,
279 &wgpu::TextureView,
280 &wgpu::TextureView,
281 &wgpu::TextureView,
282 ),
283 );
284}
285
286#[derive(Copy, Clone, Debug, PartialEq, Eq)]
287#[must_use]
288pub struct FrameReport {
289 pub kick: bool,
290 pub exit: bool,
291}
292
293#[derive(Debug, thiserror::Error)]
294pub enum OpenError {
295 #[error("import step {path}: {source}")]
296 ImportStep {
297 path: PathBuf,
298 #[source]
299 source: bone_interop::StepError,
300 },
301 #[error("load document folder {path}: {source}")]
302 LoadFolder {
303 path: PathBuf,
304 #[source]
305 source: bone_document::FolderError,
306 },
307}
308
309pub struct AppCore {
310 state: AppState,
311 input: InputState,
312 clock: FrameClock,
313 a11y: AccessTreeBuilder,
314}
315
316fn default_sketch() -> Sketch {
317 let sketch = Sketch::new(Plane::Xy.basis());
318 let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(-20.0, -12.5));
319 let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(20.0, -12.5));
320 let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(20.0, 12.5));
321 let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(-20.0, 12.5));
322 let (sketch, _) = tools::add_line(sketch, p0, p1, false);
323 let (sketch, _) = tools::add_line(sketch, p1, p2, false);
324 let (sketch, _) = tools::add_line(sketch, p2, p3, false);
325 let (sketch, _) = tools::add_line(sketch, p3, p0, false);
326 let (sketch, origin) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0));
327 let (sketch, _) = tools::add_circle(sketch, origin, Length::new::<millimeter>(5.0), false);
328 sketch
329}
330
331fn initial_document(sketch: Sketch) -> (Document, SketchId) {
332 let mut document = Document::new(DocumentId::default(), "Untitled".to_owned());
333 let sketch_id = SketchId::default();
334 document.insert_sketch(sketch_id, "Sketch1".to_owned(), sketch);
335 (document, sketch_id)
336}
337
338#[allow(
339 clippy::cast_precision_loss,
340 reason = "viewport pixel counts at any realistic display size fit f32 mantissa"
341)]
342fn layout_size_from_extent(extent: ViewportExtent) -> LayoutSize {
343 LayoutSize::new(
344 LayoutPx::new(extent.width().value() as f32),
345 LayoutPx::new(extent.height().value() as f32),
346 )
347}
348
349fn empty_rect() -> LayoutRect {
350 LayoutRect::new(LayoutPos::ORIGIN, LayoutSize::ZERO)
351}
352
353fn zoom_factor(delta: ScrollDelta) -> f64 {
354 match delta {
355 ScrollDelta::Lines { y, .. } => ZOOM_STEP_PER_LINE.powf(f64::from(y)),
356 ScrollDelta::Pixels { y, .. } => ZOOM_STEP_PER_PIXEL.powf(y),
357 }
358}
359
360const WHEEL_LINE_PX: f32 = 48.0;
361
362fn wheel_offset_px(delta: ScrollDelta) -> f32 {
363 match delta {
364 ScrollDelta::Lines { y, .. } => -y * WHEEL_LINE_PX,
365 #[allow(
366 clippy::cast_possible_truncation,
367 reason = "trackpad pixel delta collapses to f32 at the sub-pixel limit"
368 )]
369 ScrollDelta::Pixels { y, .. } => -(y as f32),
370 }
371}
372
373fn zoom_about(camera: Camera2, cursor: Option<WindowPoint>, factor: f64) -> Camera2 {
374 if !factor.is_finite() || factor <= 0.0 {
375 return camera;
376 }
377 let zoom_before = camera.zoom().value();
378 let zoom_after = (zoom_before * factor).clamp(ZOOM_MIN, ZOOM_MAX);
379 if !zoom_after.is_finite() {
380 return camera;
381 }
382 let extent = camera.extent();
383 let w = f64::from(extent.width().value());
384 let h = f64::from(extent.height().value());
385 let (pan_x, pan_y) = camera.pan_mm().coords_mm();
386 let (cursor_x, cursor_y) = cursor.map_or((w * 0.5, h * 0.5), |c| (c.x, c.y));
387 let horizontal_px = cursor_x - w * 0.5;
388 let vertical_px = h * 0.5 - cursor_y;
389 let world_x = pan_x + horizontal_px / zoom_before;
390 let world_y = pan_y + vertical_px / zoom_before;
391 let new_pan_x = world_x - horizontal_px / zoom_after;
392 let new_pan_y = world_y - vertical_px / zoom_after;
393 camera
394 .with_zoom(PixelsPerMm::new(zoom_after))
395 .with_pan(Vec2::from_mm(new_pan_x, new_pan_y))
396}
397
398fn dragging_in_sketch(mode: &Mode) -> bool {
399 matches!(
400 mode,
401 Mode::Sketch { session, .. } if session.drag.is_some()
402 )
403}
404
405fn dim_flow_active(mode: &Mode) -> bool {
406 matches!(
407 mode,
408 Mode::Sketch { session, .. } if session.dim_flow.is_some()
409 )
410}
411
412fn cursor_to_world(camera: Camera2, cursor: WindowPoint) -> Option<Point2> {
413 let extent = camera.extent();
414 let w = f64::from(extent.width().value());
415 let h = f64::from(extent.height().value());
416 let zoom = camera.zoom().value();
417 if w <= 0.0 || h <= 0.0 || !zoom.is_finite() || zoom <= 0.0 {
418 return None;
419 }
420 let (pan_x, pan_y) = camera.pan_mm().coords_mm();
421 Some(Point2::from_mm(
422 pan_x + (cursor.x - w * 0.5) / zoom,
423 pan_y + (h * 0.5 - cursor.y) / zoom,
424 ))
425}
426
427fn try_place(state: &mut AppState, world: Point2) {
428 let Mode::Sketch { sketch_id, session } = &state.mode else {
429 return;
430 };
431 let Some(tool) = session.tool else {
432 return;
433 };
434 let prev_pending = session.pending;
435 let sketch_id = *sketch_id;
436 let Some(sketch) = state.document.sketch(sketch_id).cloned() else {
437 return;
438 };
439 let snap = match tool {
440 SketchTool::Line => {
441 compute_snap(&sketch, &state.camera, world, latest_anchor(prev_pending))
442 }
443 _ => compute_endpoint_snap(&sketch, &state.camera, world),
444 };
445 let (next_sketch, new_pending) = tools::place(sketch, tool, world, prev_pending, snap);
446 if let Some(next) = next_sketch {
447 state.undo.record(state.document.clone());
448 state.document.replace_sketch(sketch_id, next);
449 let Some(stored) = state.document.sketch(sketch_id) else {
450 return;
451 };
452 match SketchScene::extract(stored) {
453 Ok(scene) => state.scene = scene,
454 Err(e) => tracing::warn!(error = %e, "scene extract after place failed"),
455 }
456 }
457 if let Mode::Sketch {
458 ref mut session, ..
459 } = state.mode
460 {
461 session.pending = new_pending;
462 }
463}
464
465const fn latest_anchor(pending: Option<Pending>) -> Option<ClickAnchor> {
466 match pending {
467 None => None,
468 Some(Pending::First(a) | Pending::Second(_, a)) => Some(a),
469 }
470}
471
472fn solid_pick_index(model: &EvaluatedModel) -> Option<PickIndex> {
473 let faces: BTreeSet<BrepFaceId> = model
474 .bodies()
475 .flat_map(|(_, solid)| solid.iter_faces().map(bone_document::BrepFace::id))
476 .collect();
477 if faces.is_empty() {
478 return None;
479 }
480 PickIndex::build_solid(faces, core::iter::empty(), core::iter::empty()).ok()
481}
482
483fn active_pick_index(state: &AppState) -> Option<PickIndex> {
484 if matches!(state.mode, Mode::Idle) && state.solid_view.is_some() {
485 return solid_pick_index(&state.model);
486 }
487 match state.scene.pick_index() {
488 Ok(index) => Some(index),
489 Err(error) => {
490 tracing::warn!(error = %error, "build sketch pick index");
491 None
492 }
493 }
494}
495
496fn pick_at(state: &AppState, target: &impl FrameTarget, cursor: WindowPoint) -> Option<PickedItem> {
497 if !cursor.x.is_finite() || !cursor.y.is_finite() || cursor.x < 0.0 || cursor.y < 0.0 {
498 return None;
499 }
500 let extent = state.extent;
501 #[allow(
502 clippy::cast_possible_truncation,
503 clippy::cast_sign_loss,
504 reason = "cursor px is bounds-checked against surface extent before the cast"
505 )]
506 let qx = cursor.x.round() as u32;
507 #[allow(
508 clippy::cast_possible_truncation,
509 clippy::cast_sign_loss,
510 reason = "cursor px is bounds-checked against surface extent before the cast"
511 )]
512 let qy = cursor.y.round() as u32;
513 if qx >= extent.width().value() || qy >= extent.height().value() {
514 return None;
515 }
516 let index = active_pick_index(state)?;
517 let query = PickQuery::new(ViewportPx::new(qx), ViewportPx::new(qy))
518 .with_aperture(state.settings.pick_aperture);
519 match target.picker(index).at(query) {
520 Ok(item) => item,
521 Err(e) => {
522 tracing::warn!(error = %e, "pick failed");
523 None
524 }
525 }
526}
527
528fn handle_viewport_click(
529 state: &mut AppState,
530 target: &impl FrameTarget,
531 cursor: WindowPoint,
532 additive: bool,
533) {
534 let picked = pick_at(state, target, cursor);
535 if let Some(sketch) = state.pending_reattach {
536 if let Some(PickedItem::BrepFace(face)) = picked {
537 apply_reattach(state, sketch, face);
538 }
539 return;
540 }
541 if !additive
542 && matches!(state.mode, Mode::Idle)
543 && let Some(PickedItem::BrepFace(face)) = picked
544 && begin_face_sketch(state, face)
545 {
546 return;
547 }
548 let item = picked.and_then(selection::picked_to_item);
549 state.selection = std::mem::take(&mut state.selection).picked(item, additive);
550 if additive || !state.mode.is_sketch() {
551 return;
552 }
553 let Some(SketchItemId::Entity(entity_id)) = item else {
554 return;
555 };
556 let Mode::Sketch { sketch_id, .. } = state.mode else {
557 return;
558 };
559 let Some(sketch) = state.document.sketch(sketch_id) else {
560 return;
561 };
562 let Some(world) = cursor_to_world(state.camera, cursor) else {
563 return;
564 };
565 let Some(pins) = DragPins::from_sketch_entity(sketch, entity_id) else {
566 return;
567 };
568 let drag = DragSession {
569 entity: entity_id,
570 press: world,
571 pins,
572 };
573 state.undo.record(state.document.clone());
574 state.mode = core::mem::take(&mut state.mode).start_drag(drag);
575}
576
577fn drag_resolved(sketch: &Sketch, drag: DragSession, world: Point2) -> Option<Sketch> {
578 let pins = drag.pins.to_targets(drag.press, world);
579 sketch
580 .solve_with_drag_pins(&pins, BudgetCeiling::FRAME_16MS)
581 .map_err(|e| tracing::trace!(error = %e, "drag solve failed, keeping last-good sketch"))
582 .ok()
583}
584
585fn try_drag_to(state: &mut AppState, world: Point2) {
586 let Mode::Sketch { sketch_id, session } = &state.mode else {
587 return;
588 };
589 let Some(drag) = session.drag else {
590 return;
591 };
592 let sketch_id = *sketch_id;
593 let Some(sketch) = state.document.sketch(sketch_id) else {
594 return;
595 };
596 let Some(next) = drag_resolved(sketch, drag, world) else {
597 return;
598 };
599 state.document.replace_sketch(sketch_id, next);
600 refresh_active_scene(state);
601}
602
603fn refresh_active_scene(state: &mut AppState) {
604 let Some(active_id) = active_sketch_id(&state.mode, &state.plane_sketches) else {
605 return;
606 };
607 let Some(sketch) = state.document.sketch(active_id) else {
608 return;
609 };
610 match SketchScene::extract(sketch) {
611 Ok(scene) => state.scene = scene,
612 Err(e) => tracing::warn!(error = %e, "refresh active scene failed"),
613 }
614}
615
616fn active_sketch_id(mode: &Mode, plane_sketches: &BTreeMap<Plane, SketchId>) -> Option<SketchId> {
617 match mode {
618 Mode::Sketch { sketch_id, .. } => Some(*sketch_id),
619 Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => Some(feature.sketch),
620 Mode::Extrude(ExtrudeArming::AwaitingSketch) | Mode::Idle => {
621 plane_sketches.get(&Plane::Xy).copied()
622 }
623 }
624}
625
626enum ProfileChoice {
627 NoSketch,
628 Unique(SketchId),
629 Ambiguous,
630}
631
632fn classify_extrude_profile(document: &Document) -> ProfileChoice {
633 let mut sketches = document
634 .feature_tree()
635 .iter()
636 .filter_map(|(_, node)| match node {
637 FeatureNode::Sketch(id) => Some(id),
638 _ => None,
639 });
640 match (sketches.next(), sketches.next()) {
641 (None, _) => ProfileChoice::NoSketch,
642 (Some(id), None) => ProfileChoice::Unique(id),
643 (Some(_), Some(_)) => ProfileChoice::Ambiguous,
644 }
645}
646
647fn apply_feature_tool(state: &mut AppState, tool: Option<FeatureTool>) {
648 match tool {
649 Some(FeatureTool::ExtrudedBossBase) => arm_extruded_boss_base(state),
650 Some(FeatureTool::ExtrudedCut) => notify_stub(state, strings::TOOL_EXTRUDED_CUT),
651 None => {}
652 }
653}
654
655fn arm_extruded_boss_base(state: &mut AppState) {
656 match classify_extrude_profile(&state.document) {
657 ProfileChoice::NoSketch => notify_info(state, strings::NOTIFY_EXTRUDE_NO_SKETCH, None),
658 ProfileChoice::Unique(id) => state.mode = Mode::Extrude(ExtrudeArming::profile(id)),
659 ProfileChoice::Ambiguous => state.mode = Mode::Extrude(ExtrudeArming::AwaitingSketch),
660 }
661}
662
663fn apply_extrude_edit(state: &mut AppState, edit: Option<shell::ExtrudeEdit>) {
664 let Some(edit) = edit else { return };
665 let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = &state.mode else {
666 return;
667 };
668 let next = edit.apply(*feature);
669 let target = *target;
670 state.mode = Mode::Extrude(ExtrudeArming::Profile {
671 feature: next,
672 target,
673 });
674}
675
676fn apply_extrude_activation(state: &mut AppState, activated: Option<ExtrudeId>) {
677 if let Some(mode) = extrude_edit_mode(&state.document, &state.mode, activated) {
678 if let Mode::Extrude(ExtrudeArming::Profile {
679 target: Some(id), ..
680 }) = mode
681 {
682 state.framed_extrude = Some(id);
683 }
684 state.mode = mode;
685 }
686}
687
688fn extrude_edit_mode(
689 document: &Document,
690 current: &Mode,
691 activated: Option<ExtrudeId>,
692) -> Option<Mode> {
693 let id = activated?;
694 if current.is_sketch() {
695 return None;
696 }
697 let feature = document.extrude(id).copied()?;
698 Some(Mode::Extrude(ExtrudeArming::edit(id, feature)))
699}
700
701fn apply_extrude_confirm(state: &mut AppState, confirm: Option<shell::ConfirmAction>) {
702 if let Some(id) =
703 commit_armed_extrude(&mut state.document, &mut state.undo, &state.mode, confirm)
704 {
705 state.framed_extrude = Some(id);
706 }
707}
708
709fn commit_armed_extrude(
710 document: &mut Document,
711 undo: &mut UndoStack,
712 mode: &Mode,
713 confirm: Option<shell::ConfirmAction>,
714) -> Option<ExtrudeId> {
715 let Some(shell::ConfirmAction::Accept) = confirm else {
716 return None;
717 };
718 let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = mode else {
719 return None;
720 };
721 let snapshot = document.clone();
722 let committed = match target {
723 Some(id) => {
724 document.insert_extrude(*id, *feature);
725 *id
726 }
727 None => document.commit_extrude(*feature),
728 };
729 undo.record(snapshot);
730 Some(committed)
731}
732
733struct SolidViewData {
734 faces: SolidScene,
735 edges: EdgeScene,
736 aabb: Aabb3,
737}
738
739struct ExtrudePreview {
740 feature: ExtrudeFeature,
741 sketch_version: SketchVersion,
742 generation: Option<GeometryGeneration>,
743 failed: bool,
744 error: Option<ExtrudeError>,
745}
746
747impl ExtrudePreview {
748 fn status(&self) -> ExtrudeStatus<'_> {
749 match &self.error {
750 Some(error) => ExtrudeStatus::Failed(error),
751 None => ExtrudeStatus::Valid,
752 }
753 }
754}
755
756const PREVIEW_CHORD_MM: f64 = 0.05;
757const PREVIEW_ANGLE: AngleTolerance = AngleTolerance::from_radians(0.2);
758
759fn active_solid_feature(
760 mode: &Mode,
761 document: &Document,
762 framed: Option<ExtrudeId>,
763) -> Option<ExtrudeFeature> {
764 match mode {
765 Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => Some(*feature),
766 Mode::Sketch { .. } => None,
767 Mode::Idle | Mode::Extrude(ExtrudeArming::AwaitingSketch) => framed
768 .and_then(|id| document.extrude(id).copied())
769 .or_else(|| {
770 document
771 .feature_tree()
772 .iter()
773 .filter_map(|(_, node)| match node {
774 FeatureNode::Extrude(id) => Some(id),
775 _ => None,
776 })
777 .last()
778 .and_then(|id| document.extrude(id).copied())
779 }),
780 }
781}
782
783fn clear_solid(state: &mut AppState) {
784 state.solid_view = None;
785 state.camera3 = None;
786 state.view.home = None;
787 state.view.tween = None;
788}
789
790fn body_passes(model: &EvaluatedModel) -> Vec<(FeatureId, RebuildPass)> {
791 model
792 .bodies()
793 .filter_map(|(feature, _)| model.built_at(feature).map(|pass| (feature, pass)))
794 .collect()
795}
796
797fn build_combined_view(model: &EvaluatedModel) -> Option<SolidViewData> {
798 let mut parts = model
799 .bodies()
800 .filter_map(|(_, solid)| build_solid_view(solid).ok());
801 let first = parts.next()?;
802 Some(parts.fold(first, |acc, part| SolidViewData {
803 faces: acc.faces.merge(part.faces),
804 edges: acc.edges.merge(part.edges),
805 aabb: acc.aabb.union(part.aabb),
806 }))
807}
808
809fn recompute_model(state: &mut AppState, scope: RecomputeScope) {
810 state.model.recompute(
811 &state.document,
812 state.document.suppressed(),
813 state.document.rollback(),
814 scope,
815 );
816}
817
818fn rebuild_model_view(state: &mut AppState) {
819 state.model_passes = body_passes(&state.model);
820 state.solid_view = build_combined_view(&state.model);
821 if state.solid_view.is_none() {
822 clear_solid(state);
823 }
824}
825
826fn defer_rebuild(first: bool, cost: RebuildCost, budget: RebuildBudget) -> bool {
827 !first && !cost.within(budget)
828}
829
830fn sync_model_view(state: &mut AppState) {
831 state.extrude_preview = None;
832 recompute_model(state, RecomputeScope::Full);
833 let passes = body_passes(&state.model);
834 if passes.is_empty() {
835 if state.solid_view.is_some() || !state.model_passes.is_empty() {
836 state.model_passes.clear();
837 clear_solid(state);
838 }
839 state.changed_features.clear();
840 state.needs_rebuild = false;
841 return;
842 }
843 if passes == state.model_passes && state.solid_view.is_some() {
844 return;
845 }
846 let first = state.model_passes.is_empty() || state.solid_view.is_none();
847 if defer_rebuild(
848 first,
849 state.model.rebuild_cost(),
850 RebuildBudget::INTERACTIVE,
851 ) {
852 let changed = passes
853 .iter()
854 .filter(|entry| !state.model_passes.contains(entry))
855 .map(|(feature, _)| *feature);
856 state.changed_features.extend(changed);
857 state.needs_rebuild = true;
858 return;
859 }
860 rebuild_model_view(state);
861 state.changed_features.clear();
862 state.needs_rebuild = false;
863}
864
865fn status_to_badge(status: RebuildStatus) -> Option<TreeBadge> {
866 match status {
867 RebuildStatus::Error(_) => Some(TreeBadge::Error),
868 RebuildStatus::Warning(_) => Some(TreeBadge::Warning),
869 RebuildStatus::NeedsRebuild => Some(TreeBadge::RebuildNeeded),
870 RebuildStatus::UpToDate => None,
871 }
872}
873
874fn feature_badge(state: &AppState, feature: FeatureId) -> Option<TreeBadge> {
875 let status = state.model.status(feature).and_then(status_to_badge);
876 let rebuild = state
877 .changed_features
878 .contains(&feature)
879 .then_some(TreeBadge::RebuildNeeded);
880 [status, rebuild].into_iter().flatten().max()
881}
882
883fn compute_feature_badges(state: &AppState) -> BTreeMap<FeatureId, TreeBadge> {
884 state
885 .document
886 .feature_tree()
887 .iter()
888 .filter_map(|(feature, _)| feature_badge(state, feature).map(|badge| (feature, badge)))
889 .collect()
890}
891
892fn frame_badges(state: &mut AppState) -> (BTreeMap<FeatureId, TreeBadge>, bool) {
893 recompute_model(state, RecomputeScope::Full);
894 (compute_feature_badges(state), state.needs_rebuild)
895}
896
897fn feature_label_text(document: &Document, feature: FeatureId) -> String {
898 match document.feature_tree().node(feature) {
899 Some(FeatureNode::Sketch(sketch_id)) => {
900 document.sketch_label(sketch_id).unwrap_or("").to_owned()
901 }
902 Some(FeatureNode::Extrude(extrude_id)) => {
903 document.extrude_label(extrude_id).unwrap_or("").to_owned()
904 }
905 _ => String::new(),
906 }
907}
908
909fn sketch_of_feature(document: &Document, feature: FeatureId) -> Option<SketchId> {
910 match document.feature_tree().node(feature)? {
911 FeatureNode::Sketch(sketch_id) => Some(sketch_id),
912 _ => None,
913 }
914}
915
916struct WhatsWrongKind {
917 message: bone_ui::strings::StringKey,
918 is_error: bool,
919 offers_reattach: bool,
920}
921
922fn whats_wrong_kind(status: RebuildStatus) -> Option<WhatsWrongKind> {
923 let kind = match status {
924 RebuildStatus::Error(RebuildError::DanglingReference(_)) => WhatsWrongKind {
925 message: strings::WHATS_WRONG_DANGLING,
926 is_error: true,
927 offers_reattach: true,
928 },
929 RebuildStatus::Error(RebuildError::NonPlanarSketchTarget) => WhatsWrongKind {
930 message: strings::WHATS_WRONG_NON_PLANAR,
931 is_error: true,
932 offers_reattach: false,
933 },
934 RebuildStatus::Error(RebuildError::UpstreamUnresolved) => WhatsWrongKind {
935 message: strings::WHATS_WRONG_UPSTREAM,
936 is_error: true,
937 offers_reattach: false,
938 },
939 RebuildStatus::Error(RebuildError::Build(_)) => WhatsWrongKind {
940 message: strings::WHATS_WRONG_BUILD_FAILED,
941 is_error: true,
942 offers_reattach: false,
943 },
944 RebuildStatus::Warning(_) => WhatsWrongKind {
945 message: strings::WHATS_WRONG_REPAIRED,
946 is_error: false,
947 offers_reattach: false,
948 },
949 RebuildStatus::UpToDate | RebuildStatus::NeedsRebuild => return None,
950 };
951 Some(kind)
952}
953
954fn whats_wrong_entry(
955 model: &EvaluatedModel,
956 document: &Document,
957 feature: FeatureId,
958) -> Option<shell::WhatsWrong> {
959 let kind = whats_wrong_kind(model.status(feature)?)?;
960 let reattach = kind
961 .offers_reattach
962 .then(|| sketch_of_feature(document, feature))
963 .flatten();
964 Some(shell::WhatsWrong {
965 label: feature_label_text(document, feature),
966 message: kind.message,
967 is_error: kind.is_error,
968 reattach,
969 })
970}
971
972fn compute_whats_wrong(model: &EvaluatedModel, document: &Document) -> Vec<shell::WhatsWrong> {
973 document
974 .feature_tree()
975 .iter()
976 .filter_map(|(feature, _)| whats_wrong_entry(model, document, feature))
977 .collect()
978}
979
980fn live_dim_anchor(mode: &Mode, document: &Document, pending: &PendingDimension) -> Point2 {
981 mode.sketch_id()
982 .and_then(|id| document.sketch(id))
983 .and_then(|sketch| smart_dimension::live_anchor(sketch, pending.proto))
984 .unwrap_or(pending.anchor)
985}
986
987fn rebuild_changed(state: &mut AppState) {
988 recompute_model(state, RecomputeScope::Full);
989 if matches!(state.mode, Mode::Idle) {
990 rebuild_model_view(state);
991 }
992 state.needs_rebuild = false;
993 state.changed_features.clear();
994}
995
996fn force_rebuild_all(state: &mut AppState) {
997 state.model = EvaluatedModel::new();
998 recompute_model(state, RecomputeScope::Full);
999 if matches!(state.mode, Mode::Idle) {
1000 rebuild_model_view(state);
1001 }
1002 state.needs_rebuild = false;
1003 state.changed_features.clear();
1004}
1005
1006fn sync_solid_view(state: &mut AppState) {
1007 if matches!(state.mode, Mode::Idle) {
1008 sync_model_view(state);
1009 return;
1010 }
1011 state.model_passes.clear();
1012 let Some(feature) = active_solid_feature(&state.mode, &state.document, state.framed_extrude)
1013 else {
1014 state.extrude_preview = None;
1015 state.solid_view = None;
1016 state.camera3 = None;
1017 state.view.home = None;
1018 state.view.tween = None;
1019 return;
1020 };
1021 let Some(sketch_version) = state.document.sketch(feature.sketch).map(Sketch::version) else {
1022 state.extrude_preview = None;
1023 state.solid_view = None;
1024 state.camera3 = None;
1025 state.view.home = None;
1026 state.view.tween = None;
1027 return;
1028 };
1029 if extrude_preview_is_current(state.extrude_preview.as_ref(), &feature, sketch_version) {
1030 return;
1031 }
1032 let previous_generation = state
1033 .extrude_preview
1034 .as_ref()
1035 .and_then(|cached| cached.generation);
1036 let previously_failed = state
1037 .extrude_preview
1038 .as_ref()
1039 .is_some_and(|cached| cached.failed);
1040 let first_preview = state.extrude_preview.is_none();
1041 let preview = compute_extrude_preview(&state.document, feature);
1042 let generation = preview.as_ref().and_then(EvaluatedExtrude::generation);
1043 let error = preview
1044 .as_ref()
1045 .and_then(|evaluated| evaluated.result().as_ref().err().cloned());
1046 let mut failure = error.as_ref().map(ToString::to_string);
1047 if generation != previous_generation {
1048 state.solid_view = match preview.as_ref().and_then(EvaluatedExtrude::solid) {
1049 Some(solid) => match build_solid_view(solid) {
1050 Ok(view) => Some(view),
1051 Err(error) => {
1052 failure = Some(error);
1053 None
1054 }
1055 },
1056 None => None,
1057 };
1058 }
1059 let now_failed = failure.is_some();
1060 state.extrude_preview = Some(ExtrudePreview {
1061 feature,
1062 sketch_version,
1063 generation,
1064 failed: now_failed,
1065 error,
1066 });
1067 let newly_failed = first_preview || !previously_failed;
1068 if let Some(detail) = failure.filter(|_| newly_failed) {
1069 tracing::warn!(error = %detail, "extrude preview evaluation failed");
1070 notify_error(state, strings::NOTIFY_EXTRUDE_FAILED, detail);
1071 }
1072}
1073
1074fn extrude_preview_is_current(
1075 cached: Option<&ExtrudePreview>,
1076 feature: &ExtrudeFeature,
1077 sketch_version: SketchVersion,
1078) -> bool {
1079 cached
1080 .is_some_and(|cached| cached.feature == *feature && cached.sketch_version == sketch_version)
1081}
1082
1083fn compute_extrude_preview(
1084 document: &Document,
1085 feature: ExtrudeFeature,
1086) -> Option<EvaluatedExtrude> {
1087 let sketch = document.sketch(feature.sketch)?;
1088 let evaluated_sketch = evaluate_sketch(sketch);
1089 Some(evaluate_extrude(
1090 FeatureId::default(),
1091 &evaluated_sketch,
1092 &feature,
1093 ))
1094}
1095
1096fn build_solid_view(solid: &bone_document::BrepSolid) -> Result<SolidViewData, String> {
1097 let chord = ChordHeightTolerance::from_mm(PREVIEW_CHORD_MM);
1098 let aabb = solid
1099 .bounding_box()
1100 .ok_or_else(|| "degenerate solid has no bounding box".to_owned())?;
1101 let mesh = solid
1102 .tessellate(chord, PREVIEW_ANGLE)
1103 .map_err(|error| error.to_string())?;
1104 let faces = SolidScene::from_mesh(&mesh).map_err(|error| error.to_string())?;
1105 let edges = EdgeScene::from_solid(solid, &mesh, chord).map_err(|error| error.to_string())?;
1106 Ok(SolidViewData { faces, edges, aabb })
1107}
1108
1109fn sync_solid_camera(state: &mut AppState, region: Option<ViewportRegion>) {
1110 if let Some(region) = region
1111 && let Some(view) = state.solid_view.as_ref()
1112 && state.camera3.is_none()
1113 {
1114 let framed =
1115 frame_standard_view(view.aabb, region.extent(), StandardView::Isometric, None).ok();
1116 state.camera3 = framed;
1117 if state.view.home.is_none() {
1118 state.view.home = framed;
1119 }
1120 }
1121}
1122
1123const VIEW_TWEEN_MS: u64 = 220;
1124
1125fn step_view_tween(state: &mut AppState, now: FrameInstant) {
1126 let Some(active) = state.view.tween else {
1127 return;
1128 };
1129 let elapsed = now.since(active.started);
1130 if let Ok(camera) = active.tween.sample(elapsed) {
1131 state.camera3 = Some(camera);
1132 }
1133 if active.tween.is_done(elapsed) {
1134 state.camera3 = Some(active.tween.to());
1135 state.view.tween = None;
1136 }
1137}
1138
1139fn start_view_tween(state: &mut AppState, target: Camera3, now: FrameInstant) {
1140 let tween = if state.settings.reduce_motion {
1141 CameraTween::immediate(target)
1142 } else {
1143 CameraTween::eased(
1144 state.camera3.unwrap_or(target),
1145 target,
1146 std::time::Duration::from_millis(VIEW_TWEEN_MS),
1147 CubicEasing::STANDARD,
1148 )
1149 };
1150 state.view.tween = Some(view_cube::ActiveTween {
1151 tween,
1152 started: now,
1153 });
1154}
1155
1156fn apply_nav_camera(state: &mut AppState, camera: Camera3) {
1157 state.camera3 = Some(camera);
1158 state.view.tween = None;
1159}
1160
1161fn solid_aabb_and_extent(state: &AppState) -> Option<(Aabb3, ViewportExtent)> {
1162 let region = solid_viewport_region(state.viewport_rect, state.extent)?;
1163 let view = state.solid_view.as_ref()?;
1164 Some((view.aabb, region.extent()))
1165}
1166
1167fn normal_to_plane(state: &AppState) -> Option<Plane3> {
1168 let sketch_id = active_sketch_id(&state.mode, &state.plane_sketches)?;
1169 let plane = state
1170 .plane_sketches
1171 .iter()
1172 .find_map(|(plane, id)| (*id == sketch_id).then_some(*plane))?;
1173 Some(Plane3::from(plane.basis()))
1174}
1175
1176fn frame_target_camera(state: &AppState, pick: view_cube::ViewPick) -> Option<Camera3> {
1177 match pick {
1178 view_cube::ViewPick::Home => state.view.home,
1179 view_cube::ViewPick::Standard(view) => {
1180 let (aabb, extent) = solid_aabb_and_extent(state)?;
1181 frame_standard_view(aabb, extent, view, normal_to_plane(state)).ok()
1182 }
1183 view_cube::ViewPick::Direction(direction) => {
1184 let (aabb, extent) = solid_aabb_and_extent(state)?;
1185 frame_view_direction(aabb, extent, direction).ok()
1186 }
1187 }
1188}
1189
1190fn apply_view_pick(state: &mut AppState, pick: Option<view_cube::ViewPick>, now: FrameInstant) {
1191 let Some(pick) = pick else {
1192 return;
1193 };
1194 if let Some(target) = frame_target_camera(state, pick) {
1195 start_view_tween(state, target, now);
1196 }
1197}
1198
1199fn view_nav_enabled(state: &AppState) -> bool {
1200 state.solid_view.is_some()
1201 && !modal_active(state)
1202 && state.focus.focused().is_none()
1203 && !dim_flow_active(&state.mode)
1204}
1205
1206fn apply_view_menu(
1207 state: &mut AppState,
1208 action: Option<view_cube::ViewCubeMenuAction>,
1209 now: FrameInstant,
1210) {
1211 match action {
1212 Some(view_cube::ViewCubeMenuAction::SetAsHome) => {
1213 state.view.home = state.camera3;
1214 }
1215 Some(view_cube::ViewCubeMenuAction::FitToWindow) => {
1216 if let (Some(camera), Some((aabb, extent))) =
1217 (state.camera3, solid_aabb_and_extent(state))
1218 && let Ok(target) = frame_current(camera, aabb, extent)
1219 {
1220 start_view_tween(state, target, now);
1221 }
1222 }
1223 Some(view_cube::ViewCubeMenuAction::ViewNormalTo) => {
1224 apply_view_pick(
1225 state,
1226 Some(view_cube::ViewPick::Standard(StandardView::NormalTo)),
1227 now,
1228 );
1229 }
1230 None => {}
1231 }
1232}
1233
1234fn preview_solid_frame(
1235 solid_view: Option<&SolidViewData>,
1236 camera: Option<Camera3>,
1237 region: ViewportRegion,
1238) -> Option<(&SolidViewData, SolidFrameView)> {
1239 let view = solid_view?;
1240 Some((view, SolidFrameView::new(camera?, region).ok()?))
1241}
1242
1243fn solid_viewport_region(viewport: LayoutRect, surface: ViewportExtent) -> Option<ViewportRegion> {
1244 let (surface_w, surface_h) = (surface.width().value(), surface.height().value());
1245 let min_x = round_layout_px(viewport.min_x().value()).min(surface_w);
1246 let min_y = round_layout_px(viewport.min_y().value()).min(surface_h);
1247 let width = round_layout_px(viewport.size.width.value()).min(surface_w - min_x);
1248 let height = round_layout_px(viewport.size.height.value()).min(surface_h - min_y);
1249 (width > 0 && height > 0).then(|| {
1250 ViewportRegion::new(
1251 ViewportPx::new(min_x),
1252 ViewportPx::new(min_y),
1253 ViewportExtent::new(ViewportPx::new(width), ViewportPx::new(height)),
1254 )
1255 })
1256}
1257
1258#[allow(
1259 clippy::cast_possible_truncation,
1260 clippy::cast_sign_loss,
1261 reason = "the saturating cast of a non-negative rounded px is clamped to the surface extent by the caller"
1262)]
1263fn round_layout_px(value: f32) -> u32 {
1264 value.round().max(0.0) as u32
1265}
1266
1267fn viewport_local_point(cursor: WindowPoint, region: ViewportRegion) -> Option<ViewportPoint> {
1268 let (min_x, min_y, _, _) = region.scissor();
1269 ViewportPoint::new(cursor.x - f64::from(min_x), cursor.y - f64::from(min_y)).ok()
1270}
1271
1272fn drag_gesture(modifiers: ModifierMask) -> NavGesture {
1273 let with_ctrl =
1274 if modifiers.contains(ModifierMask::CTRL) || modifiers.contains(ModifierMask::META) {
1275 DragModifiers::NONE.with_ctrl()
1276 } else {
1277 DragModifiers::NONE
1278 };
1279 let with_shift = if modifiers.contains(ModifierMask::SHIFT) {
1280 with_ctrl.with_shift()
1281 } else {
1282 with_ctrl
1283 };
1284 let resolved = if modifiers.contains(ModifierMask::ALT) {
1285 with_shift.with_alt()
1286 } else {
1287 with_shift
1288 };
1289 resolved.gesture()
1290}
1291
1292fn build_preview(
1293 mode: &Mode,
1294 document: &Document,
1295 cursor_world: Option<Point2>,
1296 camera: &Camera2,
1297) -> SketchPreview {
1298 let Mode::Sketch { sketch_id, session } = mode else {
1299 return SketchPreview::empty();
1300 };
1301 if session.drag.is_some() || session.dim_flow.is_some() {
1302 return SketchPreview::empty();
1303 }
1304 let Some(tool) = session.tool else {
1305 return SketchPreview::empty();
1306 };
1307 let pending = session.pending;
1308 let Some(sketch) = document.sketch(*sketch_id) else {
1309 return SketchPreview::empty();
1310 };
1311 let Some(cursor) = cursor_world else {
1312 return tools::preview_anchors_only(sketch, pending);
1313 };
1314 let snap = match tool {
1315 SketchTool::Line => compute_snap(sketch, camera, cursor, latest_anchor(pending)),
1316 _ => compute_endpoint_snap(sketch, camera, cursor),
1317 };
1318 tools::preview(sketch, tool, cursor, pending, snap)
1319}
1320
1321fn snap_tolerance(camera: &Camera2) -> Option<Length> {
1322 let mm_per_px = camera.world_mm_per_pixel();
1323 if !mm_per_px.is_finite() || mm_per_px <= 0.0 {
1324 return None;
1325 }
1326 let tol_mm = (SNAP_TOLERANCE_PX * mm_per_px).min(SNAP_TOLERANCE_MAX_MM);
1327 Some(Length::new::<millimeter>(tol_mm))
1328}
1329
1330fn compute_snap(
1331 sketch: &Sketch,
1332 camera: &Camera2,
1333 cursor_world: Point2,
1334 click: Option<ClickAnchor>,
1335) -> Option<SnapHit> {
1336 snap::detect(
1337 cursor_world,
1338 click.and_then(|c| resolve_anchor(Some(sketch), c)),
1339 sketch,
1340 snap_tolerance(camera)?,
1341 )
1342}
1343
1344fn compute_endpoint_snap(
1345 sketch: &Sketch,
1346 camera: &Camera2,
1347 cursor_world: Point2,
1348) -> Option<SnapHit> {
1349 snap::detect_endpoint_only(cursor_world, sketch, snap_tolerance(camera)?)
1350}
1351
1352fn resolve_anchor(sketch: Option<&Sketch>, click: ClickAnchor) -> Option<Anchor> {
1353 match click {
1354 ClickAnchor::Position(pos) | ClickAnchor::MidpointOf { position: pos, .. } => {
1355 Some(Anchor { pos, id: None })
1356 }
1357 ClickAnchor::Endpoint(id) => match sketch?.entities().get(id)? {
1358 SketchEntity::Point(p) => Some(Anchor {
1359 pos: p.at(),
1360 id: Some(id),
1361 }),
1362 _ => None,
1363 },
1364 }
1365}
1366
1367fn pan_by_px(camera: Camera2, horizontal_px: f64, vertical_px: f64) -> Camera2 {
1368 let mm_per_px = camera.world_mm_per_pixel();
1369 let (pan_x, pan_y) = camera.pan_mm().coords_mm();
1370 camera.with_pan(Vec2::from_mm(
1371 pan_x - horizontal_px * mm_per_px,
1372 pan_y + vertical_px * mm_per_px,
1373 ))
1374}
1375
1376fn zoom_fit(camera: Camera2, scene: &SketchScene, viewport: LayoutRect) -> Camera2 {
1377 let Some(aabb) = scene.aabb() else {
1378 return camera;
1379 };
1380 let (mnx, mny) = aabb.min().coords_mm();
1381 let (mxx, mxy) = aabb.max().coords_mm();
1382 let center_x = (mnx + mxx) * 0.5;
1383 let center_y = (mny + mxy) * 0.5;
1384 let world_w = mxx - mnx;
1385 let world_h = mxy - mny;
1386 let v_w = f64::from(viewport.size.width.value());
1387 let v_h = f64::from(viewport.size.height.value());
1388 if v_w <= 0.0 || v_h <= 0.0 {
1389 return camera;
1390 }
1391 let axis_zoom = |pixels: f64, world: f64| {
1392 if world > 0.0 {
1393 pixels / world
1394 } else {
1395 f64::INFINITY
1396 }
1397 };
1398 let raw_zoom = axis_zoom(v_w, world_w).min(axis_zoom(v_h, world_h)) * ZOOM_FIT_MARGIN;
1399 let zoom = raw_zoom.clamp(ZOOM_MIN, ZOOM_MAX);
1400 let pan = pan_centering((center_x, center_y), camera.extent(), viewport, zoom);
1401 camera.with_zoom(PixelsPerMm::new(zoom)).with_pan(pan)
1402}
1403
1404fn pan_centering(
1405 target_world: (f64, f64),
1406 window: ViewportExtent,
1407 viewport: LayoutRect,
1408 zoom: f64,
1409) -> Vec2 {
1410 let v_w = f64::from(viewport.size.width.value());
1411 let v_h = f64::from(viewport.size.height.value());
1412 let viewport_center_x = f64::from(viewport.origin.x.value()) + v_w * 0.5;
1413 let viewport_center_y = f64::from(viewport.origin.y.value()) + v_h * 0.5;
1414 let window_center_x = f64::from(window.width().value()) * 0.5;
1415 let window_center_y = f64::from(window.height().value()) * 0.5;
1416 let pan_x = target_world.0 - (viewport_center_x - window_center_x) / zoom;
1417 let pan_y = target_world.1 + (viewport_center_y - window_center_y) / zoom;
1418 Vec2::from_mm(pan_x, pan_y)
1419}
1420
1421fn keyboard_camera(nav: NavKey, input: &InputState, state: &AppState) -> Option<Camera2> {
1422 if input.ctrl_or_meta() {
1423 return None;
1424 }
1425 let camera = state.camera;
1426 let step = input.pan_step_px();
1427 let shift = input.shift();
1428 match nav {
1429 NavKey::Left => Some(pan_by_px(camera, step, 0.0)),
1430 NavKey::Right => Some(pan_by_px(camera, -step, 0.0)),
1431 NavKey::Up => Some(pan_by_px(camera, 0.0, step)),
1432 NavKey::Down => Some(pan_by_px(camera, 0.0, -step)),
1433 NavKey::Zoom => Some(zoom_about(
1434 camera,
1435 input.cursor_px,
1436 if shift {
1437 1.0 / ZOOM_KEY_STEP
1438 } else {
1439 ZOOM_KEY_STEP
1440 },
1441 )),
1442 NavKey::ZoomIn => Some(zoom_about(camera, input.cursor_px, ZOOM_KEY_STEP)),
1443 NavKey::ZoomOut => Some(zoom_about(camera, input.cursor_px, 1.0 / ZOOM_KEY_STEP)),
1444 }
1445}
1446
1447fn zoom_key3(
1448 camera: Camera3,
1449 extent: ViewportExtent,
1450 pixel: ViewportPoint,
1451 factor: f64,
1452) -> Option<Camera3> {
1453 ZoomFactor::new(factor)
1454 .ok()
1455 .and_then(|f| zoom_about_pixel(camera, extent, pixel, f).ok())
1456}
1457
1458fn keyboard_camera3(nav: NavKey, input: &InputState, state: &AppState) -> Option<Camera3> {
1459 let camera = state.camera3?;
1460 let region = solid_viewport_region(state.viewport_rect, state.extent)?;
1461 let extent = region.extent();
1462 let ctrl = input.ctrl_or_meta();
1463 let shift = input.shift();
1464 let alt = input.modifiers.contains(ModifierMask::ALT);
1465 let cx = f64::from(extent.width().value()) * 0.5;
1466 let cy = f64::from(extent.height().value()) * 0.5;
1467 let center = ViewportPoint::new(cx, cy).ok()?;
1468 let pan_to = |dx: f64, dy: f64| {
1469 ViewportPoint::new(cx + dx, cy + dy)
1470 .ok()
1471 .and_then(|to| pan_pixels(camera, extent, center, to).ok())
1472 };
1473 let step = Angle::new::<degree>(if shift {
1474 ORBIT_KEY_SNAP_DEG
1475 } else {
1476 ORBIT_KEY_STEP_DEG
1477 });
1478 match nav {
1479 NavKey::Left if ctrl => pan_to(-PAN_STEP_PX, 0.0),
1480 NavKey::Right if ctrl => pan_to(PAN_STEP_PX, 0.0),
1481 NavKey::Up if ctrl => pan_to(0.0, -PAN_STEP_PX),
1482 NavKey::Down if ctrl => pan_to(0.0, PAN_STEP_PX),
1483 NavKey::Left if alt => roll_by(camera, step).ok(),
1484 NavKey::Right if alt => roll_by(camera, -step).ok(),
1485 NavKey::Left => orbit_yaw(camera, step).ok(),
1486 NavKey::Right => orbit_yaw(camera, -step).ok(),
1487 NavKey::Up => orbit_pitch(camera, step).ok(),
1488 NavKey::Down => orbit_pitch(camera, -step).ok(),
1489 NavKey::Zoom if shift => zoom_key3(camera, extent, center, 1.0 / ZOOM_KEY_STEP),
1490 NavKey::Zoom | NavKey::ZoomIn => zoom_key3(camera, extent, center, ZOOM_KEY_STEP),
1491 NavKey::ZoomOut => zoom_key3(camera, extent, center, 1.0 / ZOOM_KEY_STEP),
1492 }
1493}
1494
1495fn build_hotkey_table() -> HotkeyTable {
1496 let Ok(table) = hotkeys::compose_table(&hotkeys::HotkeyOverrides::default()) else {
1497 unreachable!("default hotkey bindings are conflict-free");
1498 };
1499 table
1500}
1501
1502fn scopes_for_mode(mode: &Mode) -> HotkeyScopes {
1503 let mut scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]);
1504 if mode.is_sketch() {
1505 scopes.push(HotkeyScope::Sketch);
1506 }
1507 if mode.is_extrude() {
1508 scopes.push(HotkeyScope::Extrude);
1509 }
1510 scopes
1511}
1512
1513fn next_mode(
1514 mode: Mode,
1515 frame: &shell::ShellFrame,
1516 escape_requested: bool,
1517 plane_sketches: &BTreeMap<Plane, SketchId>,
1518) -> Mode {
1519 let after_pick = resolve_pick(mode, frame, plane_sketches);
1520 let after_escape = if escape_requested {
1521 cancel_pending_or_exit(after_pick)
1522 } else {
1523 after_pick
1524 };
1525 let after_exit = if frame.exit_sketch {
1526 Mode::Idle
1527 } else {
1528 after_escape
1529 };
1530 match frame.activated_tool {
1531 Some(t) => toggle_or_arm(after_exit, t),
1532 None => after_exit,
1533 }
1534}
1535
1536fn resolve_pick(
1537 mode: Mode,
1538 frame: &shell::ShellFrame,
1539 plane_sketches: &BTreeMap<Plane, SketchId>,
1540) -> Mode {
1541 if mode.is_extrude() {
1542 return match frame.sketch_activated {
1543 Some(id) => match &mode {
1544 Mode::Extrude(ExtrudeArming::Profile { feature, .. }) if feature.sketch == id => {
1545 mode
1546 }
1547 _ => Mode::Extrude(ExtrudeArming::profile(id)),
1548 },
1549 None => mode,
1550 };
1551 }
1552 let plane_pick = frame
1553 .plane_picked
1554 .filter(|_| !mode.is_sketch())
1555 .and_then(|plane| plane_sketches.get(&plane).copied());
1556 let sketch_pick = frame.sketch_activated.filter(|_| !mode.is_sketch());
1557 sketch_pick.or(plane_pick).map_or(mode, Mode::enter_sketch)
1558}
1559
1560fn toggle_or_arm(mode: Mode, tool: SketchTool) -> Mode {
1561 let already_active = matches!(
1562 &mode,
1563 Mode::Sketch { session, .. } if session.tool == Some(tool)
1564 );
1565 if already_active {
1566 mode.disarm_tool()
1567 } else {
1568 mode.arm_tool(tool)
1569 }
1570}
1571
1572fn cancel_pending_or_exit(mode: Mode) -> Mode {
1573 match &mode {
1574 Mode::Sketch { session, .. } if session.pending.is_some() => mode.clear_pending(),
1575 Mode::Sketch { session, .. } if session.tool.is_some() => mode.disarm_tool(),
1576 Mode::Sketch { .. } | Mode::Idle | Mode::Extrude(_) => Mode::Idle,
1577 }
1578}
1579
1580impl AppCore {
1581 pub fn new(
1582 gpu: &Gpu,
1583 color_format: wgpu::TextureFormat,
1584 extent: ViewportExtent,
1585 ) -> Result<Self, PickIdError> {
1586 let renderer = SketchRenderer::new(gpu, color_format);
1587 let solid_renderer = SolidRenderer::new(gpu, color_format);
1588 let chrome_pipeline = ChromePipeline::new(gpu, color_format);
1589 let convex_pipeline = ConvexPolyPipeline::new(gpu, color_format);
1590 let stroke_pipeline = StrokePipeline::new(gpu, color_format);
1591 let icon_pipeline = IconPipeline::new(gpu, color_format);
1592 let sdf_atlas = MaskAtlas::new(MaskAtlasParams::STANDARD);
1593 let text_pipeline = ChromeTextPipeline::new(gpu, color_format, sdf_atlas.extent());
1594 let chrome_shaper = Shaper::new();
1595 let sans_font = bone_text::load_font(bone_text::FontFace::Sans);
1596 let mono_font = bone_text::load_font(bone_text::FontFace::Mono);
1597 let sketch = default_sketch();
1598 let scene = SketchScene::extract(&sketch)?;
1599 let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(INITIAL_ZOOM_PX_PER_MM));
1600 let style = Style::light();
1601 let theme = Arc::new(Theme::light());
1602 let shell = shell::Shell::new();
1603 let (document, sketch_id) = initial_document(sketch);
1604 let last_saved_baseline = document.clone();
1605 let plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]);
1606 let strings = strings::make_strings(bone_ui::strings::Locale::EnUs);
1607 let viewport_rect = empty_rect();
1608 let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else {
1609 unreachable!("UNDO_CAPACITY constant is non-zero");
1610 };
1611 let loaded_settings = settings::load();
1612 let initial_hotkeys = match hotkeys::compose_table(&loaded_settings.hotkey_overrides) {
1613 Ok(table) => table,
1614 Err(e) => {
1615 tracing::warn!(error = %e, "stored hotkey overrides conflict, using defaults");
1616 build_hotkey_table()
1617 }
1618 };
1619 let state = AppState {
1620 extent,
1621 renderer,
1622 chrome_pipeline,
1623 convex_pipeline,
1624 stroke_pipeline,
1625 icon_pipeline,
1626 text_pipeline,
1627 sdf_atlas,
1628 chrome_shaper,
1629 sans_font,
1630 mono_font,
1631 scene,
1632 camera,
1633 style,
1634 theme,
1635 shell,
1636 document,
1637 plane_sketches,
1638 mode: Mode::Idle,
1639 extrude_preview: None,
1640 model: EvaluatedModel::new(),
1641 model_passes: Vec::new(),
1642 changed_features: BTreeSet::new(),
1643 needs_rebuild: false,
1644 pending_reattach: None,
1645 solid_renderer,
1646 solid_view: None,
1647 camera3: None,
1648 framed_extrude: None,
1649 navigator: ViewportNavigator::new(),
1650 view: view_cube::ViewUi::default(),
1651 focus: FocusManager::new(),
1652 hit_state: HitState::new(),
1653 hotkeys: initial_hotkeys,
1654 strings,
1655 viewport_rect,
1656 undo: UndoStack::with_capacity(undo_capacity),
1657 selection: Selection::default(),
1658 settings: loaded_settings,
1659 dim_editor: DimensionEditorState::default(),
1660 dim_editor_bounds: None,
1661 pending_exit: false,
1662 current_folder: None,
1663 documents_root: file_menu::documents_root(),
1664 file_picker: None,
1665 native_picker: None,
1666 step_job: None,
1667 pending_overwrite: None,
1668 last_saved: Some(last_saved_baseline),
1669 pending_discard: None,
1670 notification: None,
1671 shortcut_bar: None,
1672 };
1673 Ok(Self {
1674 state,
1675 input: InputState::default(),
1676 clock: FrameClock::start(),
1677 a11y: AccessTreeBuilder::new(),
1678 })
1679 }
1680
1681 pub fn handle_input(
1682 &mut self,
1683 target: &impl FrameTarget,
1684 event: InputEvent,
1685 ) -> InputDispatched {
1686 match event {
1687 InputEvent::Resize(extent) => {
1688 self.state.extent = extent;
1689 self.state.camera = self.state.camera.with_extent(extent);
1690 self.state.viewport_rect = empty_rect();
1691 }
1692 InputEvent::Focus(focused) => {
1693 if !focused {
1694 self.input.forget_pan_state();
1695 self.state.navigator.end_drag();
1696 }
1697 }
1698 InputEvent::Modifiers(mods) => {
1699 self.input.modifiers = mods;
1700 }
1701 InputEvent::CursorMove(position) => {
1702 let state = &mut self.state;
1703 let prev = self.input.cursor_px;
1704 self.input.cursor_px = Some(position);
1705 let modal = modal_active(state);
1706 if !modal
1707 && state.navigator.is_dragging()
1708 && let Some(camera) = state.camera3
1709 && let Some(region) = solid_viewport_region(state.viewport_rect, state.extent)
1710 && let Some(cursor) = viewport_local_point(position, region)
1711 && let Ok(next) = state.navigator.drag_to(cursor, camera, region.extent())
1712 {
1713 apply_nav_camera(state, next);
1714 } else if !modal
1715 && self.input.panning()
1716 && let Some(p) = prev
1717 {
1718 state.camera = pan_by_px(state.camera, position.x - p.x, position.y - p.y);
1719 } else if !modal
1720 && dragging_in_sketch(&state.mode)
1721 && let Some(world) = cursor_to_world(state.camera, position)
1722 {
1723 try_drag_to(state, world);
1724 }
1725 }
1726 InputEvent::CursorLeft => {
1727 self.input.cursor_px = None;
1728 }
1729 InputEvent::CursorEntered => {}
1730 InputEvent::Pointer { button, pressed } => {
1731 self.dispatch_pointer_button(target, button, pressed);
1732 }
1733 InputEvent::Wheel(delta) => {
1734 self.dispatch_wheel(delta);
1735 }
1736 InputEvent::KeyDown(key) => {
1737 self.dispatch_keydown(key);
1738 }
1739 }
1740 InputDispatched::after_input()
1741 }
1742
1743 fn dispatch_wheel(&mut self, delta: ScrollDelta) {
1744 let over_viewport = self.input.cursor_in(self.state.viewport_rect);
1745 if !over_viewport || modal_active(&self.state) {
1746 self.input.pending_scroll_y += wheel_offset_px(delta);
1747 return;
1748 }
1749 self.dispatch_viewport_wheel(delta);
1750 }
1751
1752 fn dispatch_viewport_wheel(&mut self, delta: ScrollDelta) {
1753 let state = &mut self.state;
1754 if state.solid_view.is_none() {
1755 state.camera = zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta));
1756 return;
1757 }
1758 let (Some(camera), Some(region)) = (
1759 state.camera3,
1760 solid_viewport_region(state.viewport_rect, state.extent),
1761 ) else {
1762 return;
1763 };
1764 match delta {
1765 ScrollDelta::Pixels { x, y } => {
1766 if let Ok(next) = state.navigator.orbit_pixels(camera, region.extent(), x, y) {
1767 apply_nav_camera(state, next);
1768 }
1769 }
1770 ScrollDelta::Lines { .. } => {
1771 if let Some(cursor) = self
1772 .input
1773 .cursor_px
1774 .and_then(|p| viewport_local_point(p, region))
1775 && let Ok(factor) = ZoomFactor::new(zoom_factor(delta))
1776 && let Ok(next) = zoom_about_pixel(camera, region.extent(), cursor, factor)
1777 {
1778 apply_nav_camera(state, next);
1779 }
1780 }
1781 }
1782 }
1783
1784 fn dispatch_pointer_button(
1785 &mut self,
1786 target: &impl FrameTarget,
1787 button: PointerButton,
1788 pressed: bool,
1789 ) {
1790 let state = &mut self.state;
1791 let modal = modal_active(state);
1792 match button {
1793 PointerButton::Primary => {
1794 if pressed {
1795 let in_viewport = self.input.cursor_in(state.viewport_rect);
1796 let over_dim_editor = state
1797 .dim_editor_bounds
1798 .is_some_and(|r| self.input.cursor_in(r));
1799 let dim_active = dim_flow_active(&state.mode);
1800 self.input.left_pan = !modal && in_viewport && !over_dim_editor && !dim_active;
1801 self.input.pending_pressed =
1802 self.input.pending_pressed.with(PointerButton::Primary);
1803 if !modal
1804 && in_viewport
1805 && !over_dim_editor
1806 && !dim_active
1807 && !self.input.shift()
1808 && let Some(cursor) = self.input.cursor_px
1809 {
1810 if state.mode.active_tool().is_some() {
1811 if let Some(world) = cursor_to_world(state.camera, cursor) {
1812 try_place(state, world);
1813 }
1814 } else {
1815 let additive = self.input.ctrl_or_meta();
1816 handle_viewport_click(state, target, cursor, additive);
1817 }
1818 }
1819 } else {
1820 self.input.left_pan = false;
1821 state.mode = core::mem::take(&mut state.mode).end_drag();
1822 self.input.pending_released =
1823 self.input.pending_released.with(PointerButton::Primary);
1824 }
1825 }
1826 PointerButton::Secondary => {
1827 if pressed {
1828 self.input.pending_pressed =
1829 self.input.pending_pressed.with(PointerButton::Secondary);
1830 if !modal && self.input.cursor_in(state.viewport_rect) {
1831 state.mode = core::mem::take(&mut state.mode).clear_pending();
1832 }
1833 } else {
1834 self.input.pending_released =
1835 self.input.pending_released.with(PointerButton::Secondary);
1836 }
1837 }
1838 PointerButton::Middle => {
1839 if pressed {
1840 let in_viewport = !modal && self.input.cursor_in(state.viewport_rect);
1841 if in_viewport
1842 && state.solid_view.is_some()
1843 && let Some(region) =
1844 solid_viewport_region(state.viewport_rect, state.extent)
1845 && let Some(cursor) = self
1846 .input
1847 .cursor_px
1848 .and_then(|p| viewport_local_point(p, region))
1849 {
1850 state
1851 .navigator
1852 .begin_drag(drag_gesture(self.input.modifiers), cursor);
1853 } else {
1854 self.input.middle_pan = in_viewport;
1855 }
1856 self.input.pending_pressed =
1857 self.input.pending_pressed.with(PointerButton::Middle);
1858 } else {
1859 self.input.middle_pan = false;
1860 state.navigator.end_drag();
1861 self.input.pending_released =
1862 self.input.pending_released.with(PointerButton::Middle);
1863 }
1864 }
1865 }
1866 }
1867
1868 fn dispatch_keydown(&mut self, key: KeyDown) {
1869 let state = &mut self.state;
1870 let mods = self.input.modifiers;
1871 let named = match key.code {
1872 Some(UiKeyCode::Named(named)) => Some(named),
1873 Some(UiKeyCode::Char(_)) | None => None,
1874 };
1875 let repeat_space = key.repeat && named == Some(NamedKey::Space);
1876 if let Some(code) = key.code
1877 && !repeat_space
1878 {
1879 self.input.pending_keys.push(UiKeyEvent::new(code, mods));
1880 }
1881 if let Some(typed) = key.text {
1882 let filtered: String = typed.chars().filter(|c| !c.is_control()).collect();
1883 if !filtered.is_empty() {
1884 self.input.pending_text.push_str(&filtered);
1885 }
1886 }
1887 let suppress_camera = dim_flow_active(&state.mode) || state.focus.focused().is_some();
1888 if matches!(named, Some(NamedKey::Escape)) && state.notification.is_some() {
1889 state.notification = None;
1890 }
1891 if let Some(nav) = key.nav
1892 && !suppress_camera
1893 {
1894 if state.solid_view.is_some() {
1895 if let Some(next) = keyboard_camera3(nav, &self.input, state) {
1896 apply_nav_camera(state, next);
1897 }
1898 } else if let Some(next) = keyboard_camera(nav, &self.input, state) {
1899 state.camera = next;
1900 }
1901 }
1902 }
1903
1904 pub fn render_frame(&mut self, target: &mut impl FrameTarget) -> FrameReport {
1905 render_frame(
1906 &mut self.state,
1907 target,
1908 &mut self.input,
1909 &mut self.a11y,
1910 self.clock.now(),
1911 )
1912 }
1913
1914 #[must_use]
1915 pub fn access_tree(&self) -> accesskit::TreeUpdate {
1916 self.a11y
1917 .build(&self.state.strings, self.state.focus.focused())
1918 }
1919
1920 pub fn open_document(&mut self, path: PathBuf) -> Result<(), OpenError> {
1921 if file_menu::is_step_file(&path) {
1922 let document =
1923 bone_interop::read(&path, bone_interop::CancelFlag::never()).map_err(|source| {
1924 OpenError::ImportStep {
1925 path: path.clone(),
1926 source,
1927 }
1928 })?;
1929 install_imported_document(&mut self.state, document);
1930 } else {
1931 let folder = DocumentFolder::new(path.clone());
1932 let document = bone_document::load(&folder)
1933 .map_err(|source| OpenError::LoadFolder { path, source })?;
1934 install_loaded_document(&mut self.state, document, Some(folder));
1935 }
1936 Ok(())
1937 }
1938
1939 pub fn set_theme(&mut self, mode: bone_ui::theme::ThemeMode) {
1940 self.state.theme = Arc::new(match mode {
1941 bone_ui::theme::ThemeMode::Light => Theme::light(),
1942 bone_ui::theme::ThemeMode::Dark => Theme::dark(),
1943 });
1944 self.state.style = match mode {
1945 bone_ui::theme::ThemeMode::Light => Style::light(),
1946 bone_ui::theme::ThemeMode::Dark => Style::default(),
1947 };
1948 }
1949
1950 pub fn clock_mut(&mut self) -> &mut FrameClock {
1951 &mut self.clock
1952 }
1953
1954 #[must_use]
1955 pub fn next_wake_deadline(&self) -> Option<FrameInstant> {
1956 next_wake_deadline(&self.state, self.clock.now())
1957 }
1958}
1959
1960fn next_wake_deadline(state: &AppState, now: FrameInstant) -> Option<FrameInstant> {
1961 let native_poll = state
1962 .native_picker
1963 .is_some()
1964 .then(|| now.after(std::time::Duration::from_millis(40)));
1965 let step_poll = state
1966 .step_job
1967 .is_some()
1968 .then(|| now.after(std::time::Duration::from_millis(40)));
1969 let rename_deadline = state
1970 .shell
1971 .state
1972 .feature_tree
1973 .pending_rename
1974 .map(|pending| {
1975 let window = bone_ui::input::DoubleClickWindow::DEFAULT.duration();
1976 let slack = std::time::Duration::from_millis(8);
1977 pending.at.after(window + slack)
1978 });
1979 let tween_tick = state
1980 .view
1981 .tween
1982 .is_some()
1983 .then(|| now.after(std::time::Duration::from_millis(8)));
1984 [native_poll, step_poll, rename_deadline, tween_tick]
1985 .into_iter()
1986 .flatten()
1987 .min()
1988}
1989
1990#[allow(
1991 clippy::cast_precision_loss,
1992 reason = "viewport pixel counts at any realistic display size fit f32 mantissa"
1993)]
1994#[allow(
1995 clippy::too_many_lines,
1996 reason = "splitting hides the per-outcome dispatch table"
1997)]
1998fn render_frame(
1999 state: &mut AppState,
2000 target: &mut impl FrameTarget,
2001 input_state: &mut InputState,
2002 a11y: &mut AccessTreeBuilder,
2003 now: FrameInstant,
2004) -> FrameReport {
2005 poll_native_picker(state);
2006 poll_step_job(state);
2007 let extent = state.extent;
2008 let layout_size = layout_size_from_extent(extent);
2009 let theme = Arc::clone(&state.theme);
2010 let mut input = input_state.drain_snapshot(now);
2011 step_view_tween(state, input.frame);
2012 let mut hits = HitFrame::new();
2013 a11y.begin_frame();
2014 let scopes = scopes_for_mode(&state.mode);
2015 let chrome_cursor_world = input_state
2016 .cursor_px
2017 .filter(|c| state.viewport_rect.contains(window_to_layout_pos(*c)))
2018 .and_then(|c| cursor_to_world(state.camera, c));
2019 let FrameOutcomes {
2020 mut frame,
2021 hotkey_actions,
2022 dim: dim_outcome,
2023 dim_conflict: conflict_outcome,
2024 picker: picker_outcome,
2025 overwrite: overwrite_outcome,
2026 discard: discard_outcome,
2027 step_progress: step_progress_outcome,
2028 notification: notification_outcome,
2029 shortcut_bar: shortcut_bar_outcome,
2030 } = run_frame_ui(
2031 state,
2032 theme,
2033 &mut input,
2034 &mut hits,
2035 a11y,
2036 &scopes,
2037 layout_size,
2038 chrome_cursor_world,
2039 );
2040 state.dim_editor_bounds = apply_popup_overlays(
2041 &mut frame.overlay_paints,
2042 dim_outcome.as_ref(),
2043 conflict_outcome.as_ref(),
2044 picker_outcome.as_ref(),
2045 overwrite_outcome.as_ref(),
2046 discard_outcome.as_ref(),
2047 step_progress_outcome.as_ref(),
2048 notification_outcome.as_ref(),
2049 shortcut_bar_outcome.as_ref(),
2050 );
2051 apply_shortcut_bar_outcome(state, shortcut_bar_outcome.as_ref());
2052 let picker_kind = state.file_picker.as_ref().map(|s| s.kind);
2053 if let (Some(cmd), Some(kind)) = (picker_outcome.and_then(|o| o.command), picker_kind) {
2054 apply_picker_command(state, kind, cmd);
2055 }
2056 apply_overwrite_outcome(state, overwrite_outcome);
2057 apply_discard_outcome(state, discard_outcome);
2058 apply_step_progress_outcome(state, step_progress_outcome);
2059 apply_notification_outcome(state, notification_outcome);
2060 let claimed_pointer = dim_outcome.as_ref().is_some_and(|o| o.claimed_pointer);
2061 let frame = if claimed_pointer {
2062 suppress_pointer_activations(frame)
2063 } else {
2064 frame
2065 };
2066 state.viewport_rect = frame.viewport_rect;
2067 state.hit_state = resolve(&state.hit_state, &hits, &input, state.focus.focused());
2068 let kick = any_actionable_interaction(&state.hit_state);
2069 if let Some(plane) = frame.plane_picked {
2070 match (
2071 state.mode.is_sketch(),
2072 state.plane_sketches.contains_key(&plane),
2073 ) {
2074 (true, _) => {
2075 tracing::debug!(?plane, "plane pick ignored: already in sketch mode");
2076 }
2077 (false, false) => {
2078 tracing::debug!(?plane, "plane pick ignored: no sketch on this plane");
2079 }
2080 (false, true) => {}
2081 }
2082 }
2083 let escape_requested = hotkey_actions.contains(&sketch_mode::ESCAPE_ACTION);
2084 if escape_requested {
2085 state.pending_reattach = None;
2086 }
2087 apply_extrude_edit(state, frame.extrude_edit);
2088 apply_extrude_confirm(state, frame.confirm_action);
2089 let prev_active_sketch = active_sketch_id(&state.mode, &state.plane_sketches);
2090 state.mode = next_mode(
2091 core::mem::take(&mut state.mode),
2092 &frame,
2093 escape_requested,
2094 &state.plane_sketches,
2095 );
2096 apply_feature_tool(state, frame.activated_feature_tool);
2097 apply_extrude_activation(state, frame.extrude_activated);
2098 if active_sketch_id(&state.mode, &state.plane_sketches) != prev_active_sketch {
2099 refresh_active_scene(state);
2100 }
2101 apply_dimension_outcome(state, dim_outcome);
2102 apply_dim_conflict_outcome(state, conflict_outcome);
2103 apply_dimension_request(state, frame.activated_dimension);
2104 let dimension_edit = match frame.confirm_action {
2105 Some(shell::ConfirmAction::Cancel) => None,
2106 _ => frame.dimension_edit,
2107 };
2108 apply_dimension_edit(state, dimension_edit);
2109 sync_solid_view(state);
2110 let solid_region = solid_viewport_region(state.viewport_rect, extent);
2111 sync_solid_camera(state, solid_region);
2112 let cursor_layout = input_state.cursor_px.map(window_to_layout_pos);
2113 apply_hotkey_actions(state, &hotkey_actions, cursor_layout, input.frame);
2114 apply_view_pick(state, frame.view_pick, input.frame);
2115 apply_view_menu(state, frame.view_menu, input.frame);
2116 apply_menu_action(state, frame.menu_action);
2117 apply_settings_change(state, frame.settings_change);
2118 apply_relation_action(state, frame.activated_relation);
2119 apply_sketch_rename(state, frame.sketch_rename.clone());
2120 apply_extrude_rename(state, frame.extrude_rename.clone());
2121 apply_feature_command(state, frame.feature_command);
2122 apply_feature_reorder(state, frame.feature_reorder);
2123 apply_rollback_change(state, frame.rollback_change);
2124 begin_reattach(state, frame.reattach_request);
2125 let cursor_world = input_state
2126 .cursor_px
2127 .filter(|c| state.viewport_rect.contains(window_to_layout_pos(*c)))
2128 .and_then(|c| cursor_to_world(state.camera, c));
2129 let preview = build_preview(&state.mode, &state.document, cursor_world, &state.camera);
2130 let main_layer = build_chrome_layer(state, &frame.paints);
2131 let overlay_layer = build_chrome_layer(state, &frame.overlay_paints);
2132 let atlas_pixels = state.sdf_atlas.pixels();
2133 let atlas_version = state.sdf_atlas.version();
2134 let viewport_px = [
2135 extent.width().value() as f32,
2136 extent.height().value() as f32,
2137 ];
2138 let renderer = &mut state.renderer;
2139 let mut chrome_stage = ChromeStage {
2140 chrome: &mut state.chrome_pipeline,
2141 convex: &mut state.convex_pipeline,
2142 stroke: &mut state.stroke_pipeline,
2143 icon: &mut state.icon_pipeline,
2144 text: &mut state.text_pipeline,
2145 atlas_pixels,
2146 atlas_version,
2147 viewport_px,
2148 };
2149 let scene = &state.scene;
2150 let style = &state.style;
2151 renderer.prepare(scene, style);
2152 let viewport = ViewportEncode {
2153 solid: solid_region.and_then(|region| {
2154 preview_solid_frame(state.solid_view.as_ref(), state.camera3, region)
2155 }),
2156 solid_renderer: &state.solid_renderer,
2157 sketch_renderer: renderer,
2158 scene,
2159 preview: &preview,
2160 camera: state.camera,
2161 style,
2162 };
2163 target.render(|encoder, color, pick, depth| {
2164 viewport.encode(encoder, color, pick, depth);
2165 chrome_stage.encode_layered(encoder, color, &main_layer, &overlay_layer);
2166 });
2167 FrameReport {
2168 kick,
2169 exit: state.pending_exit,
2170 }
2171}
2172
2173struct ViewportEncode<'a> {
2174 solid: Option<(&'a SolidViewData, SolidFrameView)>,
2175 solid_renderer: &'a SolidRenderer,
2176 sketch_renderer: &'a SketchRenderer,
2177 scene: &'a SketchScene,
2178 preview: &'a SketchPreview,
2179 camera: Camera2,
2180 style: &'a Style,
2181}
2182
2183impl ViewportEncode<'_> {
2184 fn encode(
2185 &self,
2186 encoder: &mut wgpu::CommandEncoder,
2187 color: &wgpu::TextureView,
2188 pick: &wgpu::TextureView,
2189 depth: &wgpu::TextureView,
2190 ) {
2191 let targets = RenderTargets::new(color, pick);
2192 match &self.solid {
2193 Some((view, frame)) => self.solid_renderer.encode_passes(
2194 encoder,
2195 targets,
2196 depth,
2197 &view.faces,
2198 &view.edges,
2199 &bone_render::SolidDisplay {
2200 view: frame,
2201 style: self.style,
2202 mode: DisplayMode::ShadedWithEdges,
2203 },
2204 ),
2205 None => self.sketch_renderer.encode_passes(
2206 encoder,
2207 targets,
2208 self.scene,
2209 self.preview,
2210 self.camera,
2211 self.style,
2212 ),
2213 }
2214 }
2215}
2216
2217struct ChromeStage<'a> {
2218 chrome: &'a mut ChromePipeline,
2219 convex: &'a mut ConvexPolyPipeline,
2220 stroke: &'a mut StrokePipeline,
2221 icon: &'a mut IconPipeline,
2222 text: &'a mut ChromeTextPipeline,
2223 atlas_pixels: &'a [u8],
2224 atlas_version: u64,
2225 viewport_px: [f32; 2],
2226}
2227
2228fn merge_layers<T: Copy>(main: &[T], overlay: &[T]) -> Vec<T> {
2229 main.iter().chain(overlay.iter()).copied().collect()
2230}
2231
2232fn count_u32(len: usize) -> u32 {
2233 let Ok(count) = u32::try_from(len) else {
2234 unreachable!("instance counts fit in u32");
2235 };
2236 count
2237}
2238
2239impl ChromeStage<'_> {
2240 fn encode_layered(
2241 &mut self,
2242 encoder: &mut wgpu::CommandEncoder,
2243 color: &wgpu::TextureView,
2244 main: &ChromeLayer,
2245 overlay: &ChromeLayer,
2246 ) {
2247 let chrome = merge_layers(&main.chrome, &overlay.chrome);
2248 let convex = merge_layers(&main.convex, &overlay.convex);
2249 let stroke = merge_layers(&main.stroke, &overlay.stroke);
2250 let icons = merge_layers(&main.icons, &overlay.icons);
2251 let glyphs = merge_layers(&main.glyphs, &overlay.glyphs);
2252 self.chrome.upload(self.viewport_px, &chrome);
2253 self.convex.upload(self.viewport_px, &convex);
2254 self.stroke.upload(self.viewport_px, &stroke);
2255 self.icon.upload(self.viewport_px, &icons);
2256 self.text.upload(
2257 self.viewport_px,
2258 self.atlas_pixels,
2259 self.atlas_version,
2260 &glyphs,
2261 );
2262 let (mc, tc) = (count_u32(main.chrome.len()), count_u32(chrome.len()));
2263 let (mv, tv) = (count_u32(main.convex.len()), count_u32(convex.len()));
2264 let (ms, ts) = (count_u32(main.stroke.len()), count_u32(stroke.len()));
2265 let (mi, ti) = (count_u32(main.icons.len()), count_u32(icons.len()));
2266 let (mg, tg) = (count_u32(main.glyphs.len()), count_u32(glyphs.len()));
2267 self.chrome.draw_range(encoder, color, 0..mc);
2268 self.convex.draw_range(encoder, color, 0..mv);
2269 self.stroke.draw_range(encoder, color, 0..ms);
2270 self.icon.draw_range(encoder, color, 0..mi);
2271 self.text.draw_range(encoder, color, 0..mg);
2272 self.chrome.draw_range(encoder, color, mc..tc);
2273 self.convex.draw_range(encoder, color, mv..tv);
2274 self.stroke.draw_range(encoder, color, ms..ts);
2275 self.icon.draw_range(encoder, color, mi..ti);
2276 self.text.draw_range(encoder, color, mg..tg);
2277 }
2278}
2279
2280fn any_actionable_interaction(hit_state: &HitState) -> bool {
2281 use bone_ui::hit_test::InteractionState;
2282 hit_state.interactions.values().any(|i| {
2283 i.state.contains(InteractionState::CLICK)
2284 || i.state.contains(InteractionState::DOUBLE_CLICK)
2285 || i.state.contains(InteractionState::DRAG_START)
2286 || i.state.contains(InteractionState::DRAG_RELEASE)
2287 })
2288}
2289
2290struct ChromeLayer {
2291 chrome: Vec<ChromeInstance>,
2292 convex: Vec<ConvexInstance>,
2293 stroke: Vec<StrokeInstance>,
2294 icons: Vec<IconInstance>,
2295 glyphs: Vec<SdfGlyphInstance>,
2296}
2297
2298fn build_chrome_layer(
2299 state: &mut AppState,
2300 paints: &[bone_ui::widgets::WidgetPaint],
2301) -> ChromeLayer {
2302 let chrome = chrome::paint_to_instances(&state.theme, paints);
2303 let convex = chrome::paint_to_convex_instances(paints);
2304 let stroke = chrome::paint_to_stroke_instances(paints);
2305 let spans = chrome::paint_to_text_spans(paints, &state.strings);
2306 let glyphs = chrome::build_glyph_instances(
2307 &spans,
2308 &mut state.sdf_atlas,
2309 &mut state.chrome_shaper,
2310 &state.sans_font,
2311 &state.mono_font,
2312 );
2313 ChromeLayer {
2314 chrome,
2315 convex,
2316 stroke,
2317 icons: chrome::paint_to_icon_instances(paints),
2318 glyphs,
2319 }
2320}
2321
2322fn apply_hotkey_actions(
2323 state: &mut AppState,
2324 actions: &[ActionId],
2325 cursor_layout: Option<LayoutPos>,
2326 now: FrameInstant,
2327) {
2328 actions
2329 .iter()
2330 .filter_map(|a| hotkeys::command_for_action(*a))
2331 .for_each(|cmd| dispatch_hotkey_command(state, cmd, cursor_layout, now));
2332}
2333
2334fn dispatch_hotkey_command(
2335 state: &mut AppState,
2336 cmd: hotkeys::HotkeyCommand,
2337 cursor_layout: Option<LayoutPos>,
2338 now: FrameInstant,
2339) {
2340 use hotkeys::HotkeyCommand as C;
2341 match cmd {
2342 C::Undo => {
2343 if state.undo.undo(&mut state.document) {
2344 refresh_active_scene(state);
2345 }
2346 }
2347 C::Redo => {
2348 if state.undo.redo(&mut state.document) {
2349 refresh_active_scene(state);
2350 }
2351 }
2352 C::NewDocument => apply_menu_action(state, Some(shell::MenuAction::NewDocument)),
2353 C::OpenDocument => apply_menu_action(state, Some(shell::MenuAction::OpenDocument)),
2354 C::SaveDocument => apply_menu_action(state, Some(shell::MenuAction::SaveDocument)),
2355 C::ImportStep => apply_menu_action(state, Some(shell::MenuAction::ImportStep)),
2356 C::ExportStep => apply_menu_action(state, Some(shell::MenuAction::ExportStep)),
2357 C::ZoomFit => apply_menu_action(state, Some(shell::MenuAction::ZoomFit)),
2358 C::Quit => {
2359 state.pending_exit = true;
2360 }
2361 C::RebuildChanged => rebuild_changed(state),
2362 C::ForceRebuild => force_rebuild_all(state),
2363 C::OpenShortcutBar => {
2364 if state.shortcut_bar.is_none() {
2365 let anchor = cursor_layout.unwrap_or(LayoutPos::ORIGIN);
2366 state.shortcut_bar = Some(shortcut_bar::ShortcutBarState { anchor });
2367 }
2368 }
2369 C::ToggleConstruction => apply_construction_toggle(state),
2370 C::Mirror => apply_mirror(state),
2371 C::StandardView(view) => {
2372 if view_nav_enabled(state) {
2373 apply_view_pick(state, Some(view_cube::ViewPick::Standard(view)), now);
2374 }
2375 }
2376 C::ToggleViewSelector => {
2377 if view_nav_enabled(state) {
2378 state.view.toggle_selector();
2379 }
2380 }
2381 C::ToggleViewCube => {
2382 if view_nav_enabled(state) {
2383 state.view.toggle_cube();
2384 }
2385 }
2386 C::SelectAll
2387 | C::DeleteSelection
2388 | C::EnterSketch
2389 | C::SmartDimension
2390 | C::Trim
2391 | C::Extend => notify_stub(state, hotkeys::label_for_command(cmd)),
2392 }
2393}
2394
2395fn apply_construction_toggle(state: &mut AppState) {
2396 let Mode::Sketch { sketch_id, .. } = state.mode else {
2397 return;
2398 };
2399 let entity_ids: Vec<bone_types::SketchEntityId> = state.selection.entity_ids().to_vec();
2400 if entity_ids.is_empty() {
2401 return;
2402 }
2403 let Some(sketch) = state.document.sketch(sketch_id) else {
2404 return;
2405 };
2406 let pivot = entity_ids
2407 .iter()
2408 .find_map(|id| match sketch.entities().get(*id)? {
2409 SketchEntity::Line(l) => Some(l.for_construction()),
2410 SketchEntity::Arc(a) => Some(a.for_construction()),
2411 SketchEntity::Circle(c) => Some(c.for_construction()),
2412 SketchEntity::Point(_) => None,
2413 });
2414 let Some(current) = pivot else {
2415 return;
2416 };
2417 let target = !current;
2418 let snapshot = state.document.clone();
2419 let result =
2420 entity_ids
2421 .iter()
2422 .try_fold(sketch.clone(), |acc, id| match acc.entities().get(*id) {
2423 Some(SketchEntity::Point(_)) | None => Ok(acc),
2424 Some(_) => acc
2425 .apply(bone_document::SketchEdit::SetConstruction {
2426 id: *id,
2427 for_construction: target,
2428 })
2429 .map(|(s, _)| s),
2430 });
2431 match result {
2432 Ok(next) => {
2433 state.undo.record(snapshot);
2434 state.document.replace_sketch(sketch_id, next);
2435 refresh_active_scene(state);
2436 }
2437 Err(e) => tracing::warn!(error = %e, "construction toggle failed"),
2438 }
2439}
2440
2441fn apply_mirror(state: &mut AppState) {
2442 let Mode::Sketch { sketch_id, .. } = state.mode else {
2443 return;
2444 };
2445 let entity_ids: Vec<bone_types::SketchEntityId> = state.selection.entity_ids().to_vec();
2446 if entity_ids.is_empty() {
2447 return;
2448 }
2449 let Some(sketch) = state.document.sketch(sketch_id) else {
2450 return;
2451 };
2452 let axis_lines: Vec<(bone_types::SketchEntityId, LineData)> = entity_ids
2453 .iter()
2454 .filter_map(|id| match sketch.entities().get(*id)? {
2455 SketchEntity::Line(l) => Some((*id, *l)),
2456 _ => None,
2457 })
2458 .collect();
2459 let [(axis_id, axis_line)] = axis_lines.as_slice() else {
2460 notify_mirror_hint(state);
2461 return;
2462 };
2463 let axis_id = *axis_id;
2464 let Some(pa) = lookup_point(sketch, axis_line.a()) else {
2465 return;
2466 };
2467 let Some(pb) = lookup_point(sketch, axis_line.b()) else {
2468 return;
2469 };
2470 let axis = MirrorAxis::from_points(pa, pb);
2471 if axis.is_degenerate() {
2472 notify_mirror_hint(state);
2473 return;
2474 }
2475 let source_ids: std::collections::BTreeSet<bone_types::SketchEntityId> = entity_ids
2476 .iter()
2477 .copied()
2478 .filter(|id| *id != axis_id)
2479 .collect();
2480 if source_ids.is_empty() {
2481 notify_mirror_hint(state);
2482 return;
2483 }
2484 let snapshot = state.document.clone();
2485 let result = mirror_targets(sketch.clone(), &source_ids, axis_id, &axis);
2486 match result {
2487 Ok(next) => {
2488 state.undo.record(snapshot);
2489 state.document.replace_sketch(sketch_id, next);
2490 state.selection = Selection::default();
2491 refresh_active_scene(state);
2492 }
2493 Err(e) => tracing::warn!(error = %e, "mirror failed"),
2494 }
2495}
2496
2497fn notify_mirror_hint(state: &mut AppState) {
2498 state.notification = Some(Notification {
2499 kind: NotificationKind::Info,
2500 headline: strings::HOTKEY_LABEL_MIRROR,
2501 detail: Some(
2502 state
2503 .strings
2504 .resolve(strings::NOTIFY_MIRROR_SELECTION_HINT)
2505 .to_owned(),
2506 ),
2507 });
2508}
2509
2510#[derive(Copy, Clone, Debug)]
2511struct MirrorAxis {
2512 anchor_x: f64,
2513 anchor_y: f64,
2514 direction_x: f64,
2515 direction_y: f64,
2516 length_sq: f64,
2517}
2518
2519impl MirrorAxis {
2520 fn from_points(a: Point2, b: Point2) -> Self {
2521 let (ax, ay) = a.coords_mm();
2522 let (bx, by) = b.coords_mm();
2523 let dx = bx - ax;
2524 let dy = by - ay;
2525 Self {
2526 anchor_x: ax,
2527 anchor_y: ay,
2528 direction_x: dx,
2529 direction_y: dy,
2530 length_sq: dx * dx + dy * dy,
2531 }
2532 }
2533
2534 fn is_degenerate(self) -> bool {
2535 !self.length_sq.is_finite() || self.length_sq <= f64::EPSILON
2536 }
2537
2538 fn reflect(self, p: Point2) -> Point2 {
2539 let (px, py) = p.coords_mm();
2540 let vx = px - self.anchor_x;
2541 let vy = py - self.anchor_y;
2542 let t = (vx * self.direction_x + vy * self.direction_y) / self.length_sq;
2543 let foot_x = self.anchor_x + t * self.direction_x;
2544 let foot_y = self.anchor_y + t * self.direction_y;
2545 Point2::from_mm(2.0 * foot_x - px, 2.0 * foot_y - py)
2546 }
2547
2548 fn is_on_axis(self, p: Point2) -> bool {
2549 let (px, py) = p.coords_mm();
2550 let vx = px - self.anchor_x;
2551 let vy = py - self.anchor_y;
2552 let cross = vx * self.direction_y - vy * self.direction_x;
2553 let perp_dist_sq = cross * cross / self.length_sq;
2554 perp_dist_sq < ON_AXIS_TOLERANCE_MM_SQ
2555 }
2556}
2557
2558const ON_AXIS_TOLERANCE_MM_SQ: f64 = 1e-12;
2559
2560struct MirrorBuilder {
2561 sketch: Sketch,
2562 point_map: std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
2563 entity_map: std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
2564}
2565
2566impl MirrorBuilder {
2567 fn new(sketch: Sketch) -> Self {
2568 Self {
2569 sketch,
2570 point_map: std::collections::BTreeMap::new(),
2571 entity_map: std::collections::BTreeMap::new(),
2572 }
2573 }
2574
2575 fn mirror_point(
2576 &mut self,
2577 id: bone_types::SketchEntityId,
2578 axis: &MirrorAxis,
2579 ) -> Result<bone_types::SketchEntityId, bone_document::SketchEditError> {
2580 if let Some(&existing) = self.point_map.get(&id) {
2581 return Ok(existing);
2582 }
2583 let pos = require_point(&self.sketch, id)?;
2584 if axis.is_on_axis(pos) {
2585 self.point_map.insert(id, id);
2586 self.entity_map.insert(id, id);
2587 return Ok(id);
2588 }
2589 let (next, new_id) = add_point(self.sketch.clone(), axis.reflect(pos))?;
2590 self.sketch = next;
2591 self.point_map.insert(id, new_id);
2592 self.entity_map.insert(id, new_id);
2593 Ok(new_id)
2594 }
2595
2596 fn mirror_entity(
2597 &mut self,
2598 id: bone_types::SketchEntityId,
2599 axis: &MirrorAxis,
2600 ) -> Result<(), bone_document::SketchEditError> {
2601 if self.entity_map.contains_key(&id) {
2602 return Ok(());
2603 }
2604 let entity = self
2605 .sketch
2606 .entities()
2607 .get(id)
2608 .copied()
2609 .ok_or(bone_document::SketchEditError::EntityNotFound(id))?;
2610 match entity {
2611 SketchEntity::Point(_) => {
2612 self.mirror_point(id, axis)?;
2613 }
2614 SketchEntity::Line(l) => {
2615 let new_a = self.mirror_point(l.a(), axis)?;
2616 let new_b = self.mirror_point(l.b(), axis)?;
2617 let (next, outcome) =
2618 self.sketch
2619 .clone()
2620 .apply(bone_document::SketchEdit::AddEntity(SketchEntity::line(
2621 new_a,
2622 new_b,
2623 l.for_construction(),
2624 )))?;
2625 let EditOutcome::Entity(new_id) = outcome else {
2626 unreachable!("AddEntity yields Entity outcome")
2627 };
2628 self.sketch = next;
2629 self.entity_map.insert(id, new_id);
2630 }
2631 SketchEntity::Arc(a) => {
2632 let new_center = self.mirror_point(a.center(), axis)?;
2633 let new_start = self.mirror_point(a.start(), axis)?;
2634 let new_end = self.mirror_point(a.end(), axis)?;
2635 let (next, outcome) =
2636 self.sketch
2637 .clone()
2638 .apply(bone_document::SketchEdit::AddEntity(SketchEntity::arc(
2639 new_center,
2640 new_end,
2641 new_start,
2642 a.for_construction(),
2643 )))?;
2644 let EditOutcome::Entity(new_id) = outcome else {
2645 unreachable!("AddEntity yields Entity outcome")
2646 };
2647 self.sketch = next;
2648 self.entity_map.insert(id, new_id);
2649 }
2650 SketchEntity::Circle(c) => {
2651 let new_center = self.mirror_point(c.center(), axis)?;
2652 let (next, outcome) =
2653 self.sketch
2654 .clone()
2655 .apply(bone_document::SketchEdit::AddEntity(SketchEntity::circle(
2656 new_center,
2657 c.radius(),
2658 c.for_construction(),
2659 )))?;
2660 let EditOutcome::Entity(new_id) = outcome else {
2661 unreachable!("AddEntity yields Entity outcome")
2662 };
2663 self.sketch = next;
2664 self.entity_map.insert(id, new_id);
2665 }
2666 }
2667 Ok(())
2668 }
2669}
2670
2671fn mirror_targets(
2672 sketch: Sketch,
2673 source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>,
2674 axis_id: bone_types::SketchEntityId,
2675 axis: &MirrorAxis,
2676) -> Result<Sketch, bone_document::SketchEditError> {
2677 let mut builder = MirrorBuilder::new(sketch);
2678 source_ids
2679 .iter()
2680 .try_for_each(|id| builder.mirror_entity(*id, axis))?;
2681 builder.entity_map.insert(axis_id, axis_id);
2682 builder.sketch = symmetric_relations_for_pairs(builder.sketch, &builder.point_map, axis_id)?;
2683 builder.sketch = copy_relations(builder.sketch, source_ids, axis_id, &builder.entity_map)?;
2684 builder.sketch = copy_dimensions(builder.sketch, source_ids, axis_id, &builder.entity_map)?;
2685 Ok(builder.sketch)
2686}
2687
2688fn symmetric_relations_for_pairs(
2689 sketch: Sketch,
2690 point_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
2691 axis_id: bone_types::SketchEntityId,
2692) -> Result<Sketch, bone_document::SketchEditError> {
2693 point_map
2694 .iter()
2695 .filter(|(source, mirrored)| source != mirrored)
2696 .try_fold(sketch, |acc, (&source, &mirrored)| {
2697 let (next, _) = acc.apply(bone_document::SketchEdit::AddRelation(
2698 SketchRelation::Symmetric {
2699 a: source,
2700 b: mirrored,
2701 axis: axis_id,
2702 },
2703 ))?;
2704 Ok(next)
2705 })
2706}
2707
2708fn copy_relations(
2709 sketch: Sketch,
2710 source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>,
2711 axis_id: bone_types::SketchEntityId,
2712 entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
2713) -> Result<Sketch, bone_document::SketchEditError> {
2714 let relations: Vec<SketchRelation> = sketch
2715 .relations()
2716 .iter()
2717 .map(|(_, r)| *r)
2718 .filter(|r| relation_is_mirrorable(r, source_ids, axis_id))
2719 .filter_map(|r| remap_relation(r, entity_map))
2720 .collect();
2721 relations.into_iter().try_fold(sketch, |acc, rel| {
2722 match acc
2723 .clone()
2724 .apply(bone_document::SketchEdit::AddRelation(rel))
2725 {
2726 Ok((next, _)) => Ok(next),
2727 Err(e) => {
2728 tracing::warn!(error = %e, relation = ?rel, "mirror: skipped relation");
2729 Ok(acc)
2730 }
2731 }
2732 })
2733}
2734
2735fn relation_is_mirrorable(
2736 rel: &SketchRelation,
2737 source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>,
2738 axis_id: bone_types::SketchEntityId,
2739) -> bool {
2740 let refs: Vec<_> = rel.references().into_iter().collect();
2741 let touches_source = refs.iter().any(|id| source_ids.contains(id));
2742 let all_known = refs
2743 .iter()
2744 .all(|id| source_ids.contains(id) || *id == axis_id);
2745 touches_source && all_known
2746}
2747
2748fn remap_relation(
2749 rel: SketchRelation,
2750 entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
2751) -> Option<SketchRelation> {
2752 let get = |id| entity_map.get(&id).copied();
2753 match rel {
2754 SketchRelation::Coincident(a, b) => Some(SketchRelation::Coincident(get(a)?, get(b)?)),
2755 SketchRelation::Horizontal(a) => Some(SketchRelation::Horizontal(get(a)?)),
2756 SketchRelation::Vertical(a) => Some(SketchRelation::Vertical(get(a)?)),
2757 SketchRelation::Parallel(a, b) => Some(SketchRelation::Parallel(get(a)?, get(b)?)),
2758 SketchRelation::Perpendicular(a, b) => {
2759 Some(SketchRelation::Perpendicular(get(a)?, get(b)?))
2760 }
2761 SketchRelation::Tangent(a, b) => Some(SketchRelation::Tangent(get(a)?, get(b)?)),
2762 SketchRelation::Equal(a, b) => Some(SketchRelation::Equal(get(a)?, get(b)?)),
2763 SketchRelation::Concentric(a, b) => Some(SketchRelation::Concentric(get(a)?, get(b)?)),
2764 SketchRelation::Midpoint { point, line } => Some(SketchRelation::Midpoint {
2765 point: get(point)?,
2766 line: get(line)?,
2767 }),
2768 SketchRelation::Symmetric { a, b, axis } => Some(SketchRelation::Symmetric {
2769 a: get(a)?,
2770 b: get(b)?,
2771 axis: get(axis)?,
2772 }),
2773 SketchRelation::Fix(a) => Some(SketchRelation::Fix(get(a)?)),
2774 }
2775}
2776
2777fn copy_dimensions(
2778 sketch: Sketch,
2779 source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>,
2780 axis_id: bone_types::SketchEntityId,
2781 entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
2782) -> Result<Sketch, bone_document::SketchEditError> {
2783 let dims: Vec<SketchDimension> = sketch
2784 .dimensions()
2785 .iter()
2786 .map(|(_, d)| *d)
2787 .filter(|d| dimension_is_mirrorable(d, source_ids, axis_id))
2788 .filter_map(|d| remap_dimension(d, entity_map))
2789 .collect();
2790 dims.into_iter().try_fold(sketch, |acc, dim| {
2791 match acc
2792 .clone()
2793 .apply(bone_document::SketchEdit::AddDimension(dim))
2794 {
2795 Ok((next, _)) => Ok(next),
2796 Err(e) => {
2797 tracing::warn!(error = %e, dimension = ?dim, "mirror: skipped dimension");
2798 Ok(acc)
2799 }
2800 }
2801 })
2802}
2803
2804fn dimension_is_mirrorable(
2805 dim: &SketchDimension,
2806 source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>,
2807 axis_id: bone_types::SketchEntityId,
2808) -> bool {
2809 let refs: Vec<_> = dim.references().into_iter().collect();
2810 let touches_source = refs.iter().any(|id| source_ids.contains(id));
2811 let all_known = refs
2812 .iter()
2813 .all(|id| source_ids.contains(id) || *id == axis_id);
2814 touches_source && all_known
2815}
2816
2817fn remap_dimension(
2818 dim: SketchDimension,
2819 entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
2820) -> Option<SketchDimension> {
2821 let get = |id| entity_map.get(&id).copied();
2822 match dim {
2823 SketchDimension::Linear { a, b, value, kind } => Some(SketchDimension::Linear {
2824 a: get(a)?,
2825 b: get(b)?,
2826 value,
2827 kind,
2828 }),
2829 SketchDimension::Radius {
2830 target,
2831 value,
2832 kind,
2833 } => Some(SketchDimension::Radius {
2834 target: get(target)?,
2835 value,
2836 kind,
2837 }),
2838 SketchDimension::Diameter {
2839 target,
2840 value,
2841 kind,
2842 } => Some(SketchDimension::Diameter {
2843 target: get(target)?,
2844 value,
2845 kind,
2846 }),
2847 SketchDimension::Angular { a, b, value, kind } => Some(SketchDimension::Angular {
2848 a: get(a)?,
2849 b: get(b)?,
2850 value,
2851 kind,
2852 }),
2853 }
2854}
2855
2856fn add_point(
2857 sketch: Sketch,
2858 at: Point2,
2859) -> Result<(Sketch, bone_types::SketchEntityId), bone_document::SketchEditError> {
2860 let (next, outcome) = sketch.apply(bone_document::SketchEdit::AddEntity(
2861 SketchEntity::point(at),
2862 ))?;
2863 let EditOutcome::Entity(id) = outcome else {
2864 unreachable!("AddEntity must yield Entity outcome")
2865 };
2866 Ok((next, id))
2867}
2868
2869fn require_point(
2870 sketch: &Sketch,
2871 id: bone_types::SketchEntityId,
2872) -> Result<Point2, bone_document::SketchEditError> {
2873 match sketch
2874 .entities()
2875 .get(id)
2876 .ok_or(bone_document::SketchEditError::EntityNotFound(id))?
2877 {
2878 SketchEntity::Point(p) => Ok(p.at()),
2879 _ => Err(bone_document::SketchEditError::ExpectedPoint(id)),
2880 }
2881}
2882
2883fn lookup_point(sketch: &Sketch, id: bone_types::SketchEntityId) -> Option<Point2> {
2884 match sketch.entities().get(id)? {
2885 SketchEntity::Point(p) => Some(p.at()),
2886 _ => None,
2887 }
2888}
2889
2890fn suppress_pointer_activations(frame: shell::ShellFrame) -> shell::ShellFrame {
2891 shell::ShellFrame {
2892 paints: frame.paints,
2893 overlay_paints: frame.overlay_paints,
2894 viewport_rect: frame.viewport_rect,
2895 activated_tool: None,
2896 activated_feature_tool: None,
2897 activated_relation: None,
2898 activated_dimension: None,
2899 dimension_edit: None,
2900 extrude_edit: frame.extrude_edit,
2901 plane_picked: None,
2902 sketch_activated: None,
2903 sketch_rename: None,
2904 extrude_activated: None,
2905 extrude_rename: None,
2906 feature_command: None,
2907 feature_reorder: None,
2908 rollback_change: None,
2909 reattach_request: None,
2910 exit_sketch: false,
2911 confirm_action: None,
2912 menu_action: None,
2913 settings_change: None,
2914 view_pick: None,
2915 view_menu: None,
2916 }
2917}
2918
2919#[allow(
2920 clippy::too_many_arguments,
2921 reason = "popup overlay dispatch threads every transient surface"
2922)]
2923fn apply_popup_overlays(
2924 overlay: &mut Vec<bone_ui::widgets::WidgetPaint>,
2925 dim_outcome: Option<&DimensionEditorOutcome>,
2926 conflict_outcome: Option<&DimConflictOutcome>,
2927 picker_outcome: Option<&file_menu::PickerModalOutcome>,
2928 overwrite_outcome: Option<&OverwriteOutcome>,
2929 discard_outcome: Option<&DiscardOutcome>,
2930 step_progress_outcome: Option<&StepProgressOutcome>,
2931 notification_outcome: Option<&NotificationOutcome>,
2932 shortcut_bar_outcome: Option<&shortcut_bar::ShortcutBarOutcome>,
2933) -> Option<LayoutRect> {
2934 let dim_closing = matches!(
2935 dim_outcome.map(|o| &o.action),
2936 Some(DimensionEditorAction::Commit(_) | DimensionEditorAction::Cancel),
2937 );
2938 extend_when_open(
2939 overlay,
2940 dim_outcome.map(|o| o.paints.as_slice()),
2941 dim_closing,
2942 );
2943 let conflict_closing = matches!(
2944 conflict_outcome.map(|o| o.action),
2945 Some(DimConflictAction::MakeDriven | DimConflictAction::Cancel),
2946 );
2947 extend_when_open(
2948 overlay,
2949 conflict_outcome.map(|o| o.paints.as_slice()),
2950 conflict_closing,
2951 );
2952 let picker_closing = picker_outcome.and_then(|o| o.command.as_ref()).is_some();
2953 extend_when_open(
2954 overlay,
2955 picker_outcome.map(|o| o.paints.as_slice()),
2956 picker_closing,
2957 );
2958 let overwrite_closing = matches!(
2959 overwrite_outcome.map(|o| o.action),
2960 Some(OverwriteAction::Replace | OverwriteAction::Cancel),
2961 );
2962 extend_when_open(
2963 overlay,
2964 overwrite_outcome.map(|o| o.paints.as_slice()),
2965 overwrite_closing,
2966 );
2967 let discard_closing = matches!(
2968 discard_outcome.map(|o| o.action),
2969 Some(DiscardAction::Confirm | DiscardAction::Cancel),
2970 );
2971 extend_when_open(
2972 overlay,
2973 discard_outcome.map(|o| o.paints.as_slice()),
2974 discard_closing,
2975 );
2976 if let Some(progress) = step_progress_outcome {
2977 overlay.extend(progress.paints.iter().cloned());
2978 }
2979 if let Some(notification) = notification_outcome {
2980 overlay.extend(notification.paints.iter().cloned());
2981 }
2982 let bar_closing = shortcut_bar_outcome.is_some_and(|o| o.dismissed || o.activated.is_some());
2983 extend_when_open(
2984 overlay,
2985 shortcut_bar_outcome.map(|o| o.paints.as_slice()),
2986 bar_closing,
2987 );
2988 if dim_closing {
2989 None
2990 } else {
2991 dim_outcome.map(|o| o.bounds)
2992 }
2993}
2994
2995fn extend_when_open(
2996 overlay: &mut Vec<bone_ui::widgets::WidgetPaint>,
2997 paints: Option<&[bone_ui::widgets::WidgetPaint]>,
2998 closing: bool,
2999) {
3000 if let Some(p) = paints
3001 && !closing
3002 {
3003 overlay.extend(p.iter().cloned());
3004 }
3005}
3006
3007struct FrameOutcomes {
3008 frame: shell::ShellFrame,
3009 hotkey_actions: Vec<ActionId>,
3010 dim: Option<DimensionEditorOutcome>,
3011 dim_conflict: Option<DimConflictOutcome>,
3012 picker: Option<file_menu::PickerModalOutcome>,
3013 overwrite: Option<OverwriteOutcome>,
3014 discard: Option<DiscardOutcome>,
3015 step_progress: Option<StepProgressOutcome>,
3016 notification: Option<NotificationOutcome>,
3017 shortcut_bar: Option<shortcut_bar::ShortcutBarOutcome>,
3018}
3019
3020fn strip_plain_letter_chords(input: &mut InputSnapshot) {
3021 input.keys_pressed.retain(|event| {
3022 !matches!(event.code, bone_ui::input::KeyCode::Char(_))
3023 || event.modifiers != ModifierMask::NONE
3024 });
3025}
3026
3027#[allow(
3028 clippy::too_many_arguments,
3029 reason = "run_frame_ui threads every per-frame UI subsystem"
3030)]
3031fn run_frame_ui(
3032 state: &mut AppState,
3033 theme: Arc<Theme>,
3034 input: &mut InputSnapshot,
3035 hits: &mut HitFrame,
3036 a11y: &mut AccessTreeBuilder,
3037 scopes: &HotkeyScopes,
3038 layout_size: LayoutSize,
3039 cursor_world: Option<Point2>,
3040) -> FrameOutcomes {
3041 let (feature_badges, needs_rebuild) = frame_badges(state);
3042 let whats_wrong = compute_whats_wrong(&state.model, &state.document);
3043 let mut ctx = FrameCtx::new(
3044 theme,
3045 input,
3046 &mut state.focus,
3047 &state.hotkeys,
3048 &state.strings,
3049 hits,
3050 &state.hit_state,
3051 a11y,
3052 &mut state.chrome_shaper,
3053 );
3054 let extrude_status = state.extrude_preview.as_ref().map(ExtrudePreview::status);
3055 let frame = state.shell.render(
3056 &mut ctx,
3057 &state.document,
3058 &state.mode,
3059 &state.selection,
3060 &state.settings,
3061 layout_size,
3062 cursor_world,
3063 state.camera3.filter(|_| state.solid_view.is_some()),
3064 extrude_status,
3065 &mut state.view,
3066 &feature_badges,
3067 needs_rebuild,
3068 &whats_wrong,
3069 );
3070 let dim_outcome = pending_dim(&state.mode).map(|pending| {
3071 let live_anchor = live_dim_anchor(&state.mode, &state.document, &pending);
3072 dimension_editor::render(
3073 &mut ctx,
3074 pending,
3075 live_anchor,
3076 &state.camera,
3077 frame.viewport_rect,
3078 &mut state.dim_editor,
3079 )
3080 });
3081 let conflict_outcome =
3082 dim_conflict_pending(&state.mode).map(|_| render_dim_conflict_modal(&mut ctx, layout_size));
3083 let picker_outcome = state
3084 .file_picker
3085 .as_mut()
3086 .map(|session| file_menu::render(&mut ctx, session, layout_size));
3087 let overwrite_outcome = state
3088 .pending_overwrite
3089 .as_ref()
3090 .map(|pending| render_overwrite_modal(&mut ctx, layout_size, pending));
3091 let discard_outcome = state
3092 .pending_discard
3093 .as_ref()
3094 .map(|pending| render_discard_modal(&mut ctx, layout_size, pending));
3095 let reduce_motion = state.settings.reduce_motion;
3096 let step_progress_outcome = state
3097 .step_job
3098 .as_ref()
3099 .filter(|job| job.meta().show_progress)
3100 .map(|job| render_step_progress_dialog(&mut ctx, layout_size, job, reduce_motion));
3101 let notification_outcome = state
3102 .notification
3103 .as_ref()
3104 .map(|notification| render_notification_toast(&mut ctx, layout_size, notification));
3105 let is_sketch = state.mode.is_sketch();
3106 let shortcut_bar_outcome = state
3107 .shortcut_bar
3108 .map(|bar_state| shortcut_bar::render(&mut ctx, bar_state, layout_size, is_sketch));
3109 let any_modal_open = state.shell.state.keyboard_dialog_open
3110 || conflict_outcome.is_some()
3111 || dim_outcome.is_some()
3112 || picker_outcome.is_some()
3113 || overwrite_outcome.is_some()
3114 || discard_outcome.is_some()
3115 || step_progress_outcome.is_some()
3116 || shortcut_bar_outcome.is_some();
3117 if !any_modal_open && ctx.focus.is_text_input_focused() {
3118 strip_plain_letter_chords(ctx.input);
3119 }
3120 let mut actions = if any_modal_open {
3121 Vec::new()
3122 } else {
3123 ctx.dispatch_hotkeys(scopes)
3124 };
3125 if let Some(activated) = shortcut_bar_outcome.as_ref().and_then(|o| o.activated) {
3126 actions.push(activated);
3127 }
3128 FrameOutcomes {
3129 frame,
3130 hotkey_actions: actions,
3131 dim: dim_outcome,
3132 dim_conflict: conflict_outcome,
3133 picker: picker_outcome,
3134 overwrite: overwrite_outcome,
3135 discard: discard_outcome,
3136 step_progress: step_progress_outcome,
3137 notification: notification_outcome,
3138 shortcut_bar: shortcut_bar_outcome,
3139 }
3140}
3141
3142fn dim_conflict_pending(mode: &Mode) -> Option<PendingDimension> {
3143 match mode.dim_flow() {
3144 Some(DimensionFlow::Conflict(p)) => Some(p),
3145 Some(DimensionFlow::Editing(_)) | None => None,
3146 }
3147}
3148
3149#[derive(Clone, Debug, PartialEq)]
3150struct DimConflictOutcome {
3151 paints: Vec<bone_ui::widgets::WidgetPaint>,
3152 action: DimConflictAction,
3153}
3154
3155#[derive(Copy, Clone, Debug, PartialEq, Eq)]
3156enum DimConflictAction {
3157 Idle,
3158 MakeDriven,
3159 Cancel,
3160}
3161
3162fn render_dim_conflict_modal(
3163 ctx: &mut FrameCtx<'_>,
3164 layout_size: LayoutSize,
3165) -> DimConflictOutcome {
3166 use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation};
3167 use bone_ui::{WidgetId, WidgetKey};
3168 let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size);
3169 let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(180.0));
3170 let id = WidgetId::ROOT.child(WidgetKey::new("dim.conflict"));
3171 let response = show_confirmation(
3172 ctx,
3173 ConfirmationDialog {
3174 id,
3175 viewport,
3176 size: dialog_size,
3177 title: strings::DIM_CONFLICT_TITLE,
3178 message: strings::DIM_CONFLICT_MESSAGE,
3179 confirm_label: strings::DIM_CONFLICT_MAKE_DRIVEN,
3180 cancel_label: strings::DIM_CONFLICT_CANCEL,
3181 destructive: false,
3182 },
3183 );
3184 let action = match response.outcome {
3185 Some(ConfirmationOutcome::Confirm) => DimConflictAction::MakeDriven,
3186 Some(ConfirmationOutcome::Cancel) => DimConflictAction::Cancel,
3187 None => DimConflictAction::Idle,
3188 };
3189 DimConflictOutcome {
3190 paints: response.paint,
3191 action,
3192 }
3193}
3194
3195#[derive(Clone, Debug, PartialEq)]
3196struct OverwriteOutcome {
3197 paints: Vec<bone_ui::widgets::WidgetPaint>,
3198 action: OverwriteAction,
3199}
3200
3201#[derive(Copy, Clone, Debug, PartialEq, Eq)]
3202enum OverwriteAction {
3203 Idle,
3204 Replace,
3205 Cancel,
3206}
3207
3208fn render_overwrite_modal(
3209 ctx: &mut FrameCtx<'_>,
3210 layout_size: LayoutSize,
3211 pending: &PendingOverwrite,
3212) -> OverwriteOutcome {
3213 use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation};
3214 use bone_ui::{WidgetId, WidgetKey};
3215 let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size);
3216 let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(180.0));
3217 let id = WidgetId::ROOT.child(WidgetKey::new("file.overwrite"));
3218 let (title, message) = match pending {
3219 PendingOverwrite::Document(_) => (
3220 strings::FILE_OVERWRITE_TITLE,
3221 strings::FILE_OVERWRITE_MESSAGE,
3222 ),
3223 PendingOverwrite::StepExport(_) => (
3224 strings::FILE_OVERWRITE_TITLE_STEP,
3225 strings::FILE_OVERWRITE_MESSAGE_STEP,
3226 ),
3227 };
3228 let response = show_confirmation(
3229 ctx,
3230 ConfirmationDialog {
3231 id,
3232 viewport,
3233 size: dialog_size,
3234 title,
3235 message,
3236 confirm_label: strings::FILE_OVERWRITE_REPLACE,
3237 cancel_label: strings::FILE_OVERWRITE_CANCEL,
3238 destructive: true,
3239 },
3240 );
3241 let action = match response.outcome {
3242 Some(ConfirmationOutcome::Confirm) => OverwriteAction::Replace,
3243 Some(ConfirmationOutcome::Cancel) => OverwriteAction::Cancel,
3244 None => OverwriteAction::Idle,
3245 };
3246 OverwriteOutcome {
3247 paints: response.paint,
3248 action,
3249 }
3250}
3251
3252#[derive(Clone, Debug, PartialEq)]
3253struct DiscardOutcome {
3254 paints: Vec<bone_ui::widgets::WidgetPaint>,
3255 action: DiscardAction,
3256}
3257
3258#[derive(Copy, Clone, Debug, PartialEq, Eq)]
3259enum DiscardAction {
3260 Idle,
3261 Confirm,
3262 Cancel,
3263}
3264
3265fn render_discard_modal(
3266 ctx: &mut FrameCtx<'_>,
3267 layout_size: LayoutSize,
3268 pending: &PendingDiscard,
3269) -> DiscardOutcome {
3270 use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation};
3271 use bone_ui::{WidgetId, WidgetKey};
3272 let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size);
3273 let dialog_size = LayoutSize::new(LayoutPx::new(460.0), LayoutPx::new(190.0));
3274 let id = WidgetId::ROOT.child(WidgetKey::new("file.discard"));
3275 let (title, message, confirm_label, cancel_label) = match pending {
3276 PendingDiscard::New | PendingDiscard::Open(_) | PendingDiscard::ImportStep(_) => (
3277 strings::FILE_DISCARD_TITLE,
3278 strings::FILE_DISCARD_MESSAGE,
3279 strings::FILE_DISCARD_CONFIRM,
3280 strings::FILE_DISCARD_CANCEL,
3281 ),
3282 PendingDiscard::InstallImported { .. } => (
3283 strings::FILE_IMPORT_REPLACE_TITLE,
3284 strings::FILE_IMPORT_REPLACE_MESSAGE,
3285 strings::FILE_IMPORT_REPLACE_CONFIRM,
3286 strings::FILE_IMPORT_REPLACE_CANCEL,
3287 ),
3288 };
3289 let response = show_confirmation(
3290 ctx,
3291 ConfirmationDialog {
3292 id,
3293 viewport,
3294 size: dialog_size,
3295 title,
3296 message,
3297 confirm_label,
3298 cancel_label,
3299 destructive: true,
3300 },
3301 );
3302 let action = match response.outcome {
3303 Some(ConfirmationOutcome::Confirm) => DiscardAction::Confirm,
3304 Some(ConfirmationOutcome::Cancel) => DiscardAction::Cancel,
3305 None => DiscardAction::Idle,
3306 };
3307 DiscardOutcome {
3308 paints: response.paint,
3309 action,
3310 }
3311}
3312
3313fn apply_discard_outcome(state: &mut AppState, outcome: Option<DiscardOutcome>) {
3314 let Some(outcome) = outcome else { return };
3315 match outcome.action {
3316 DiscardAction::Idle => {}
3317 DiscardAction::Cancel => {
3318 state.pending_discard = None;
3319 }
3320 DiscardAction::Confirm => {
3321 let Some(pending) = state.pending_discard.take() else {
3322 return;
3323 };
3324 match pending {
3325 PendingDiscard::New => apply_new_document(state),
3326 PendingDiscard::Open(path) => apply_open_folder(state, path),
3327 PendingDiscard::ImportStep(path) => start_step_import(state, path),
3328 PendingDiscard::InstallImported {
3329 document,
3330 file_name,
3331 } => {
3332 install_imported_document(state, *document);
3333 notify_info(state, strings::NOTIFY_IMPORTED, Some(file_name));
3334 }
3335 }
3336 }
3337 }
3338}
3339
3340#[derive(Clone, Debug, PartialEq)]
3341struct StepProgressOutcome {
3342 paints: Vec<bone_ui::widgets::WidgetPaint>,
3343 cancel_requested: bool,
3344}
3345
3346fn render_step_progress_dialog(
3347 ctx: &mut FrameCtx<'_>,
3348 layout_size: LayoutSize,
3349 job: &step_jobs::StepJob,
3350 reduce_motion: bool,
3351) -> StepProgressOutcome {
3352 use bone_ui::widgets::{Dialog, DialogButton, LabelText, WidgetPaint, show_dialog};
3353 use bone_ui::{WidgetId, WidgetKey};
3354 let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size);
3355 let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(170.0));
3356 let id = WidgetId::ROOT.child(WidgetKey::new("step.progress"));
3357 let cancel_id = id.child(WidgetKey::new("cancel"));
3358 let title = match job {
3359 step_jobs::StepJob::Import { .. } => strings::STEP_PROGRESS_TITLE_IMPORT,
3360 step_jobs::StepJob::Export { .. } => strings::STEP_PROGRESS_TITLE_EXPORT,
3361 };
3362 let buttons = [DialogButton::secondary(
3363 cancel_id,
3364 strings::STEP_PROGRESS_CANCEL,
3365 )];
3366 let sweep = sweep_phase(ctx.input.frame, reduce_motion);
3367 let file_name = job.meta().file_name.clone();
3368 let (response, ()) = show_dialog(
3369 ctx,
3370 Dialog::new(id, viewport, dialog_size, title, &buttons),
3371 |ctx, body_rect, paint| {
3372 let label_rect = LayoutRect::new(
3373 LayoutPos::new(
3374 LayoutPx::new(body_rect.origin.x.value() + 16.0),
3375 LayoutPx::new(body_rect.origin.y.value() + 12.0),
3376 ),
3377 LayoutSize::new(
3378 LayoutPx::saturating_nonneg(body_rect.size.width.value() - 32.0),
3379 LayoutPx::new(20.0),
3380 ),
3381 );
3382 paint.push(WidgetPaint::Label {
3383 rect: label_rect,
3384 text: LabelText::Owned(file_name),
3385 color: ctx.theme().colors.text_secondary(),
3386 role: ctx.theme().typography.body,
3387 });
3388 push_progress_bar(ctx, body_rect, sweep, paint);
3389 },
3390 );
3391 StepProgressOutcome {
3392 paints: response.paint,
3393 cancel_requested: response.dismissed || response.activated == Some(cancel_id),
3394 }
3395}
3396
3397const SWEEP_PERIOD_SECS: f32 = 1.2;
3398const SWEEP_SPAN: f32 = 0.3;
3399
3400fn sweep_phase(now: bone_ui::input::FrameInstant, reduce_motion: bool) -> f32 {
3401 if reduce_motion {
3402 return 0.5;
3403 }
3404 (now.duration().as_secs_f32() / SWEEP_PERIOD_SECS).fract()
3405}
3406
3407fn push_progress_bar(
3408 ctx: &FrameCtx<'_>,
3409 body: LayoutRect,
3410 sweep: f32,
3411 paint: &mut Vec<bone_ui::widgets::WidgetPaint>,
3412) {
3413 use bone_ui::widgets::WidgetPaint;
3414 let track = LayoutRect::new(
3415 LayoutPos::new(
3416 LayoutPx::new(body.origin.x.value() + 16.0),
3417 LayoutPx::new(body.origin.y.value() + 48.0),
3418 ),
3419 LayoutSize::new(
3420 LayoutPx::saturating_nonneg(body.size.width.value() - 32.0),
3421 LayoutPx::new(8.0),
3422 ),
3423 );
3424 paint.push(WidgetPaint::Surface {
3425 rect: track,
3426 fill: ctx.theme().colors.surface(bone_ui::theme::SurfaceLevel::L0),
3427 border: Some(bone_ui::theme::Border {
3428 width: bone_ui::theme::StrokeWidth::HAIRLINE,
3429 color: ctx
3430 .theme()
3431 .colors
3432 .neutral
3433 .step(bone_ui::theme::Step12::SUBTLE_BORDER),
3434 }),
3435 radius: ctx.theme().radius.sm,
3436 elevation: None,
3437 });
3438 let start = sweep * (1.0 + SWEEP_SPAN) - SWEEP_SPAN;
3439 let left = start.max(0.0);
3440 let right = (start + SWEEP_SPAN).min(1.0);
3441 if right <= left {
3442 return;
3443 }
3444 let width = track.size.width.value();
3445 let fill_rect = LayoutRect::new(
3446 LayoutPos::new(
3447 LayoutPx::new(track.origin.x.value() + width * left),
3448 track.origin.y,
3449 ),
3450 LayoutSize::new(LayoutPx::new(width * (right - left)), track.size.height),
3451 );
3452 paint.push(WidgetPaint::Surface {
3453 rect: fill_rect,
3454 fill: ctx.theme().colors.accent_solid(),
3455 border: None,
3456 radius: ctx.theme().radius.sm,
3457 elevation: None,
3458 });
3459}
3460
3461fn apply_step_progress_outcome(state: &AppState, outcome: Option<StepProgressOutcome>) {
3462 let cancel = outcome.is_some_and(|o| o.cancel_requested);
3463 if !cancel {
3464 return;
3465 }
3466 if let Some(job) = state.step_job.as_ref() {
3467 job.meta().request_cancel();
3468 }
3469}
3470
3471#[derive(Clone, Debug, PartialEq)]
3472struct NotificationOutcome {
3473 paints: Vec<bone_ui::widgets::WidgetPaint>,
3474 dismissed: bool,
3475}
3476
3477fn render_notification_toast(
3478 ctx: &mut FrameCtx<'_>,
3479 layout_size: LayoutSize,
3480 notification: &Notification,
3481) -> NotificationOutcome {
3482 use bone_ui::widgets::{Button, ButtonVariant, WidgetPaint, show_button};
3483 use bone_ui::{WidgetId, WidgetKey};
3484 let theme = ctx.theme();
3485 let bg = match notification.kind {
3486 NotificationKind::Info => theme.colors.surface(bone_ui::theme::SurfaceLevel::L2),
3487 NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::SUBTLE_BG),
3488 };
3489 let fg = match notification.kind {
3490 NotificationKind::Info => theme.colors.text_primary(),
3491 NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::TEXT_HIGH),
3492 };
3493 let toast_width = layout_size.width.value().clamp(280.0, 420.0);
3494 let toast_height = if notification.detail.is_some() {
3495 72.0
3496 } else {
3497 44.0
3498 };
3499 let margin = 24.0;
3500 let toast_rect = LayoutRect::new(
3501 LayoutPos::new(
3502 LayoutPx::new(margin),
3503 LayoutPx::new(layout_size.height.value() - toast_height - margin),
3504 ),
3505 LayoutSize::new(LayoutPx::new(toast_width), LayoutPx::new(toast_height)),
3506 );
3507 let id = WidgetId::ROOT.child(WidgetKey::new("notification.toast"));
3508 let mut paints = vec![WidgetPaint::Surface {
3509 rect: toast_rect,
3510 fill: bg,
3511 border: Some(bone_ui::theme::Border {
3512 width: bone_ui::theme::StrokeWidth::HAIRLINE,
3513 color: theme.colors.neutral.step(bone_ui::theme::Step12::BORDER),
3514 }),
3515 radius: theme.radius.sm,
3516 elevation: Some(theme.elevation.level1),
3517 }];
3518 let headline_rect = LayoutRect::new(
3519 LayoutPos::new(
3520 LayoutPx::new(toast_rect.origin.x.value() + 16.0),
3521 LayoutPx::new(toast_rect.origin.y.value() + 12.0),
3522 ),
3523 LayoutSize::new(LayoutPx::new(toast_width - 120.0), LayoutPx::new(20.0)),
3524 );
3525 paints.push(WidgetPaint::Label {
3526 rect: headline_rect,
3527 text: bone_ui::widgets::LabelText::Key(notification.headline),
3528 color: fg,
3529 role: theme.typography.label,
3530 });
3531 if let Some(detail) = ¬ification.detail {
3532 let detail_rect = LayoutRect::new(
3533 LayoutPos::new(
3534 LayoutPx::new(toast_rect.origin.x.value() + 16.0),
3535 LayoutPx::new(toast_rect.origin.y.value() + 36.0),
3536 ),
3537 LayoutSize::new(LayoutPx::new(toast_width - 32.0), LayoutPx::new(24.0)),
3538 );
3539 paints.push(WidgetPaint::Label {
3540 rect: detail_rect,
3541 text: bone_ui::widgets::LabelText::Owned(detail.clone()),
3542 color: theme.colors.text_secondary(),
3543 role: theme.typography.caption,
3544 });
3545 }
3546 let dismiss_rect = LayoutRect::new(
3547 LayoutPos::new(
3548 LayoutPx::new(toast_rect.origin.x.value() + toast_width - 96.0),
3549 LayoutPx::new(toast_rect.origin.y.value() + 8.0),
3550 ),
3551 LayoutSize::new(LayoutPx::new(84.0), LayoutPx::new(28.0)),
3552 );
3553 let dismiss_id = id.child(WidgetKey::new("dismiss"));
3554 let response = show_button(
3555 ctx,
3556 Button::new(
3557 dismiss_id,
3558 dismiss_rect,
3559 strings::NOTIFY_DISMISS,
3560 ButtonVariant::Secondary,
3561 ),
3562 );
3563 paints.extend(response.paint);
3564 NotificationOutcome {
3565 paints,
3566 dismissed: response.activated,
3567 }
3568}
3569
3570fn apply_notification_outcome(state: &mut AppState, outcome: Option<NotificationOutcome>) {
3571 let Some(outcome) = outcome else { return };
3572 if outcome.dismissed {
3573 state.notification = None;
3574 }
3575}
3576
3577fn apply_overwrite_outcome(state: &mut AppState, outcome: Option<OverwriteOutcome>) {
3578 let Some(outcome) = outcome else { return };
3579 match outcome.action {
3580 OverwriteAction::Idle => {}
3581 OverwriteAction::Cancel => {
3582 state.pending_overwrite = None;
3583 }
3584 OverwriteAction::Replace => match state.pending_overwrite.take() {
3585 Some(PendingOverwrite::Document(folder)) => perform_save_to(state, folder),
3586 Some(PendingOverwrite::StepExport(path)) => start_step_export(state, path),
3587 None => {}
3588 },
3589 }
3590}
3591
3592fn pending_dim(mode: &Mode) -> Option<PendingDimension> {
3593 match mode.dim_flow() {
3594 Some(DimensionFlow::Editing(p)) => Some(p),
3595 Some(DimensionFlow::Conflict(_)) | None => None,
3596 }
3597}
3598
3599fn apply_dimension_request(state: &mut AppState, request: Option<PendingDimension>) {
3600 let Some(request) = request else { return };
3601 let Mode::Sketch { .. } = state.mode else {
3602 return;
3603 };
3604 state.mode = core::mem::take(&mut state.mode).start_dimension(request);
3605}
3606
3607fn apply_dimension_outcome(state: &mut AppState, outcome: Option<DimensionEditorOutcome>) {
3608 let Some(outcome) = outcome else { return };
3609 let Some(pending) = pending_dim(&state.mode) else {
3610 return;
3611 };
3612 match outcome.action {
3613 DimensionEditorAction::Idle => {}
3614 DimensionEditorAction::Cancel => {
3615 state.mode = core::mem::take(&mut state.mode).cancel_dimension();
3616 state.dim_editor.close();
3617 }
3618 DimensionEditorAction::Swap(next_proto) => {
3619 state.mode = core::mem::take(&mut state.mode).start_dimension(PendingDimension {
3620 proto: next_proto,
3621 anchor: pending.anchor,
3622 });
3623 }
3624 DimensionEditorAction::Commit(value) => {
3625 commit_pending_dimension(state, pending, value);
3626 }
3627 }
3628}
3629
3630fn apply_dim_conflict_outcome(state: &mut AppState, outcome: Option<DimConflictOutcome>) {
3631 let Some(outcome) = outcome else { return };
3632 let Some(pending) = dim_conflict_pending(&state.mode) else {
3633 return;
3634 };
3635 match outcome.action {
3636 DimConflictAction::Idle => {}
3637 DimConflictAction::Cancel => {
3638 state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict();
3639 }
3640 DimConflictAction::MakeDriven => {
3641 confirm_dim_conflict_make_driven(state, pending);
3642 }
3643 }
3644}
3645
3646fn commit_pending_dimension(
3647 state: &mut AppState,
3648 pending: PendingDimension,
3649 value: DimensionValue,
3650) {
3651 let Mode::Sketch { sketch_id, .. } = state.mode else {
3652 return;
3653 };
3654 let Some(sketch) = state.document.sketch(sketch_id).cloned() else {
3655 return;
3656 };
3657 let proto = match pending.proto.with_value(value) {
3658 Ok(p) => p,
3659 Err(e) => {
3660 tracing::warn!(error = %e, ?pending.proto, ?value, "dimension value type mismatch");
3661 return;
3662 }
3663 };
3664 let (after_add, dim_id) = match sketch.clone().apply(SketchEdit::AddDimension(proto)) {
3665 Ok((next, EditOutcome::Dimension(id))) => (next, id),
3666 Ok(_) => {
3667 tracing::warn!(?proto, "add dimension produced unexpected outcome");
3668 return;
3669 }
3670 Err(e) => {
3671 tracing::warn!(error = %e, ?proto, "add dimension failed");
3672 return;
3673 }
3674 };
3675 let solved = match after_add.solve() {
3676 Ok(s) => s,
3677 Err(SolverError::OverDefined { .. }) => {
3678 state.mode = core::mem::take(&mut state.mode).start_dim_conflict(PendingDimension {
3679 proto,
3680 anchor: pending.anchor,
3681 });
3682 state.dim_editor.close();
3683 return;
3684 }
3685 Err(e) => {
3686 tracing::warn!(error = %e, "solve after add dim did not converge; rejecting");
3687 return;
3688 }
3689 };
3690 state.undo.record(state.document.clone());
3691 state.document.replace_sketch(sketch_id, solved);
3692 state.selection = Selection::Dimension(dim_id);
3693 state.mode = core::mem::take(&mut state.mode).cancel_dimension();
3694 state.dim_editor.close();
3695 refresh_active_scene(state);
3696}
3697
3698fn confirm_dim_conflict_make_driven(state: &mut AppState, pending: PendingDimension) {
3699 let Mode::Sketch { sketch_id, .. } = state.mode else {
3700 return;
3701 };
3702 let Some(sketch) = state.document.sketch(sketch_id).cloned() else {
3703 return;
3704 };
3705 let Some(measured) = sketch.measure(pending.proto) else {
3706 tracing::warn!(?pending.proto, "measure failed for driven conversion; aborting");
3707 state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict();
3708 return;
3709 };
3710 let Some(driven_proto) = driven_with_value(pending.proto, measured) else {
3711 tracing::warn!(?pending.proto, "driven conversion failed");
3712 state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict();
3713 return;
3714 };
3715 let (after_add, dim_id) = match sketch.apply(SketchEdit::AddDimension(driven_proto)) {
3716 Ok((next, EditOutcome::Dimension(id))) => (next, id),
3717 Ok(_) => {
3718 tracing::warn!(
3719 ?driven_proto,
3720 "add driven dimension produced unexpected outcome"
3721 );
3722 state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict();
3723 return;
3724 }
3725 Err(e) => {
3726 tracing::warn!(error = %e, ?driven_proto, "add driven dimension failed");
3727 state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict();
3728 return;
3729 }
3730 };
3731 let solved = after_add.clone().solve().unwrap_or(after_add);
3732 state.undo.record(state.document.clone());
3733 state.document.replace_sketch(sketch_id, solved);
3734 state.selection = Selection::Dimension(dim_id);
3735 state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict();
3736 refresh_active_scene(state);
3737}
3738
3739fn driven_with_value(proto: SketchDimension, value: DimensionValue) -> Option<SketchDimension> {
3740 proto
3741 .with_kind(DimensionKind::Driven)
3742 .with_value(value)
3743 .ok()
3744}
3745
3746fn apply_dimension_edit(state: &mut AppState, edit: Option<shell::DimensionEdit>) {
3747 let Some(edit) = edit else { return };
3748 let Mode::Sketch { sketch_id, .. } = state.mode else {
3749 return;
3750 };
3751 let Some(sketch) = state.document.sketch(sketch_id).cloned() else {
3752 return;
3753 };
3754 let after = match sketch.apply(SketchEdit::UpdateDimensionValue {
3755 id: edit.id,
3756 value: edit.value,
3757 }) {
3758 Ok((next, _)) => next,
3759 Err(e) => {
3760 tracing::warn!(error = %e, ?edit, "update dimension value failed");
3761 return;
3762 }
3763 };
3764 let solved = match after.solve() {
3765 Ok(s) => s,
3766 Err(e) => {
3767 tracing::warn!(error = %e, ?edit, "solve after dimension edit failed");
3768 return;
3769 }
3770 };
3771 state.undo.record(state.document.clone());
3772 state.document.replace_sketch(sketch_id, solved);
3773 refresh_active_scene(state);
3774}
3775
3776fn apply_relation_action(state: &mut AppState, relation: Option<SketchRelation>) {
3777 let Some(relation) = relation else { return };
3778 let Mode::Sketch { sketch_id, .. } = state.mode else {
3779 return;
3780 };
3781 let Some(sketch) = state.document.sketch(sketch_id).cloned() else {
3782 return;
3783 };
3784 let next = match sketch.apply(SketchEdit::AddRelation(relation)) {
3785 Ok((next, _)) => next,
3786 Err(e) => {
3787 tracing::warn!(error = %e, ?relation, "add relation failed");
3788 return;
3789 }
3790 };
3791 state.undo.record(state.document.clone());
3792 state.document.replace_sketch(sketch_id, next);
3793 state.selection = Selection::default();
3794 refresh_active_scene(state);
3795}
3796
3797fn apply_menu_action(state: &mut AppState, action: Option<shell::MenuAction>) {
3798 match action {
3799 Some(shell::MenuAction::Quit) => {
3800 state.pending_exit = true;
3801 }
3802 Some(shell::MenuAction::Undo) if state.undo.undo(&mut state.document) => {
3803 refresh_active_scene(state);
3804 }
3805 Some(shell::MenuAction::Redo) if state.undo.redo(&mut state.document) => {
3806 refresh_active_scene(state);
3807 }
3808 Some(shell::MenuAction::ZoomFit) => {
3809 let solid_fit = state
3810 .solid_view
3811 .as_ref()
3812 .map(|view| view.aabb)
3813 .and_then(|aabb| {
3814 let region = solid_viewport_region(state.viewport_rect, state.extent)?;
3815 frame_current(state.camera3?, aabb, region.extent()).ok()
3816 });
3817 if let Some(next) = solid_fit {
3818 apply_nav_camera(state, next);
3819 } else {
3820 state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect);
3821 }
3822 }
3823 Some(shell::MenuAction::OpenSettings) => {
3824 state.shell.state.settings_dialog_open = true;
3825 }
3826 Some(shell::MenuAction::OpenKeyboardCustomize) => {
3827 state.shell.state.keyboard_dialog_open = true;
3828 }
3829 Some(shell::MenuAction::NewDocument) => {
3830 request_new_document(state);
3831 }
3832 Some(shell::MenuAction::OpenDocument) => {
3833 open_picker(
3834 state,
3835 bone_ui::widgets::FilePickerMode::Open,
3836 file_menu::FileKind::Document,
3837 None,
3838 );
3839 }
3840 Some(shell::MenuAction::SaveDocument) => {
3841 apply_save_in_place(state);
3842 }
3843 Some(shell::MenuAction::SaveDocumentAs) => {
3844 let seed = state.document.name().to_owned();
3845 open_picker(
3846 state,
3847 bone_ui::widgets::FilePickerMode::Save,
3848 file_menu::FileKind::Document,
3849 Some(seed),
3850 );
3851 }
3852 Some(shell::MenuAction::ImportStep) => {
3853 if state.step_job.is_none() {
3854 open_picker(
3855 state,
3856 bone_ui::widgets::FilePickerMode::Open,
3857 file_menu::FileKind::Step,
3858 None,
3859 );
3860 }
3861 }
3862 Some(shell::MenuAction::ExportStep) => {
3863 if state.step_job.is_none() {
3864 let seed = format!("{}.step", state.document.name());
3865 open_picker(
3866 state,
3867 bone_ui::widgets::FilePickerMode::Save,
3868 file_menu::FileKind::Step,
3869 Some(seed),
3870 );
3871 }
3872 }
3873 Some(shell::MenuAction::Undo | shell::MenuAction::Redo | shell::MenuAction::ExitSketch)
3874 | None => {}
3875 }
3876}
3877
3878fn request_new_document(state: &mut AppState) {
3879 if is_dirty(state) {
3880 state.pending_discard = Some(PendingDiscard::New);
3881 } else {
3882 apply_new_document(state);
3883 }
3884}
3885
3886fn request_open_folder(state: &mut AppState, path: PathBuf) {
3887 if is_dirty(state) {
3888 state.pending_discard = Some(PendingDiscard::Open(path));
3889 } else {
3890 apply_open_folder(state, path);
3891 }
3892}
3893
3894fn request_import_step(state: &mut AppState, path: PathBuf) {
3895 if is_dirty(state) {
3896 state.pending_discard = Some(PendingDiscard::ImportStep(path));
3897 } else {
3898 start_step_import(state, path);
3899 }
3900}
3901
3902fn start_step_import(state: &mut AppState, path: PathBuf) {
3903 if state.step_job.is_some() {
3904 return;
3905 }
3906 match step_jobs::spawn_import(path, state.document.clone()) {
3907 Ok(job) => state.step_job = Some(job),
3908 Err(e) => notify_error(state, strings::NOTIFY_IMPORT_FAILED, e.to_string()),
3909 }
3910}
3911
3912fn apply_export_step_as(state: &mut AppState, path: PathBuf, via: PickedVia) {
3913 let extension_appended = !file_menu::is_step_file(&path);
3914 let path = file_menu::with_step_extension(path);
3915 let unconfirmed = matches!(via, PickedVia::CustomPicker) || extension_appended;
3916 if unconfirmed && path.is_file() {
3917 state.pending_overwrite = Some(PendingOverwrite::StepExport(path));
3918 return;
3919 }
3920 start_step_export(state, path);
3921}
3922
3923fn start_step_export(state: &mut AppState, path: PathBuf) {
3924 if state.step_job.is_some() {
3925 return;
3926 }
3927 match step_jobs::spawn_export(state.document.clone(), path) {
3928 Ok(job) => state.step_job = Some(job),
3929 Err(e) => notify_error(state, strings::NOTIFY_EXPORT_FAILED, e.to_string()),
3930 }
3931}
3932
3933fn poll_step_job(state: &mut AppState) {
3934 let Some(job) = state.step_job.take() else {
3935 return;
3936 };
3937 match job {
3938 step_jobs::StepJob::Import { rx, baseline, meta } => match step_jobs::poll(&rx) {
3939 std::task::Poll::Pending => {
3940 state.step_job = Some(step_jobs::StepJob::Import { rx, baseline, meta });
3941 }
3942 std::task::Poll::Ready(result) => finish_import(state, result, &baseline, &meta),
3943 },
3944 step_jobs::StepJob::Export { rx, meta } => match step_jobs::poll(&rx) {
3945 std::task::Poll::Pending => {
3946 state.step_job = Some(step_jobs::StepJob::Export { rx, meta });
3947 }
3948 std::task::Poll::Ready(result) => finish_export(state, result, &meta),
3949 },
3950 }
3951}
3952
3953fn finish_import(
3954 state: &mut AppState,
3955 result: step_jobs::JobResult<Box<Document>>,
3956 baseline: &Document,
3957 meta: &step_jobs::JobMeta,
3958) {
3959 match result {
3960 step_jobs::JobResult::Finished(_) if meta.cancel_requested() => {
3961 tracing::info!(file = %meta.file_name, "discarding import that finished after cancel");
3962 }
3963 step_jobs::JobResult::Finished(document) if *baseline != state.document => {
3964 state.pending_discard = Some(PendingDiscard::InstallImported {
3965 document,
3966 file_name: meta.file_name.clone(),
3967 });
3968 }
3969 step_jobs::JobResult::Finished(document) => {
3970 install_imported_document(state, *document);
3971 notify_info(
3972 state,
3973 strings::NOTIFY_IMPORTED,
3974 Some(meta.file_name.clone()),
3975 );
3976 }
3977 step_jobs::JobResult::Failed(bone_interop::StepError::Canceled) => {
3978 tracing::info!(file = %meta.file_name, "step import canceled");
3979 }
3980 step_jobs::JobResult::Failed(e) => {
3981 tracing::warn!(error = %e, file = %meta.file_name, "step import failed");
3982 notify_error(state, strings::NOTIFY_IMPORT_FAILED, e.to_string());
3983 }
3984 step_jobs::JobResult::WorkerLost => {
3985 tracing::error!(file = %meta.file_name, "step import worker stopped before reporting a result");
3986 notify_error(
3987 state,
3988 strings::NOTIFY_IMPORT_FAILED,
3989 "worker stopped before reporting a result".to_owned(),
3990 );
3991 }
3992 }
3993}
3994
3995fn finish_export(
3996 state: &mut AppState,
3997 result: step_jobs::JobResult<()>,
3998 meta: &step_jobs::JobMeta,
3999) {
4000 match result {
4001 step_jobs::JobResult::Finished(()) => {
4002 notify_info(
4003 state,
4004 strings::NOTIFY_EXPORTED,
4005 Some(meta.file_name.clone()),
4006 );
4007 }
4008 step_jobs::JobResult::Failed(bone_interop::StepError::Canceled) => {
4009 tracing::info!(file = %meta.file_name, "step export canceled");
4010 }
4011 step_jobs::JobResult::Failed(e) => {
4012 tracing::warn!(error = %e, file = %meta.file_name, "step export failed");
4013 notify_error(state, strings::NOTIFY_EXPORT_FAILED, e.to_string());
4014 }
4015 step_jobs::JobResult::WorkerLost => {
4016 tracing::error!(file = %meta.file_name, "step export worker stopped before reporting a result");
4017 notify_error(
4018 state,
4019 strings::NOTIFY_EXPORT_FAILED,
4020 "worker stopped before reporting a result".to_owned(),
4021 );
4022 }
4023 }
4024}
4025
4026fn is_dirty(state: &AppState) -> bool {
4027 state.last_saved.as_ref() != Some(&state.document)
4028}
4029
4030fn apply_new_document(state: &mut AppState) {
4031 let sketch = default_sketch();
4032 let scene = match SketchScene::extract(&sketch) {
4033 Ok(s) => s,
4034 Err(e) => {
4035 tracing::warn!(error = %e, "scene extract failed on new document");
4036 notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string());
4037 return;
4038 }
4039 };
4040 let (document, sketch_id) = initial_document(sketch);
4041 state.last_saved = Some(document.clone());
4042 state.document = document;
4043 state.plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]);
4044 state.scene = scene;
4045 state.mode = Mode::Idle;
4046 state.selection = Selection::default();
4047 state.framed_extrude = None;
4048 state.current_folder = None;
4049 state.pending_overwrite = None;
4050 let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else {
4051 unreachable!("UNDO_CAPACITY constant is non-zero");
4052 };
4053 state.undo = UndoStack::with_capacity(undo_capacity);
4054 state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect);
4055}
4056
4057fn open_picker(
4058 state: &mut AppState,
4059 mode: bone_ui::widgets::FilePickerMode,
4060 kind: file_menu::FileKind,
4061 seed_filename: Option<String>,
4062) {
4063 if state.file_picker.is_some() || state.native_picker.is_some() {
4064 return;
4065 }
4066 let starting_folder = state
4067 .current_folder
4068 .as_ref()
4069 .map(|f| f.path().to_owned())
4070 .or_else(|| {
4071 state
4072 .documents_root
4073 .is_dir()
4074 .then(|| state.documents_root.clone())
4075 });
4076 let title_key = file_menu::title_key(kind, mode);
4077 let accept_key = file_menu::accept_key(kind, mode);
4078 let title = state.strings.resolve(title_key).to_owned();
4079 let accept_label = state.strings.resolve(accept_key).to_owned();
4080 let native_req = native_picker::Request {
4081 mode,
4082 kind,
4083 title: title.as_str(),
4084 accept_label: accept_label.as_str(),
4085 seed_filename: seed_filename.as_deref(),
4086 current_folder: starting_folder.as_deref(),
4087 };
4088 match native_picker::spawn(native_req) {
4089 Ok(handle) => {
4090 state.native_picker = Some(handle);
4091 return;
4092 }
4093 Err(native_picker::SpawnError::Unsupported) => {
4094 tracing::debug!("native picker unavailable, falling back to custom picker");
4095 }
4096 }
4097 open_custom_picker(state, mode, kind, seed_filename);
4098}
4099
4100fn open_custom_picker(
4101 state: &mut AppState,
4102 mode: bone_ui::widgets::FilePickerMode,
4103 kind: file_menu::FileKind,
4104 seed_filename: Option<String>,
4105) {
4106 let scan = match kind {
4107 file_menu::FileKind::Document => file_menu::scan_document_folders(&state.documents_root),
4108 file_menu::FileKind::Step => file_menu::scan_step_files(&state.documents_root),
4109 };
4110 let entries = match scan {
4111 Ok(v) => v,
4112 Err(e) => {
4113 tracing::warn!(error = %e, path = %state.documents_root.display(), "scan documents root failed");
4114 notify_error(state, strings::NOTIFY_SCAN_FAILED, e.to_string());
4115 Vec::new()
4116 }
4117 };
4118 state.file_picker = Some(file_menu::FilePickerSession::open(
4119 state.documents_root.clone(),
4120 mode,
4121 kind,
4122 seed_filename,
4123 entries,
4124 ));
4125}
4126
4127fn poll_native_picker(state: &mut AppState) {
4128 let Some(handle) = state.native_picker.as_ref() else {
4129 return;
4130 };
4131 let outcome = match handle.poll() {
4132 std::task::Poll::Pending => return,
4133 std::task::Poll::Ready(o) => o,
4134 };
4135 let mode = handle.mode;
4136 let kind = handle.kind;
4137 state.native_picker = None;
4138 match outcome {
4139 native_picker::NativeOutcome::Path(path) => {
4140 route_picked_path(state, kind, mode, path, PickedVia::NativePortal);
4141 }
4142 native_picker::NativeOutcome::Cancelled => {}
4143 native_picker::NativeOutcome::Error(message) => {
4144 tracing::warn!(error = %message, "native picker errored, falling back to custom picker");
4145 let seed = matches!(mode, bone_ui::widgets::FilePickerMode::Save).then(|| match kind {
4146 file_menu::FileKind::Document => state.document.name().to_owned(),
4147 file_menu::FileKind::Step => format!("{}.step", state.document.name()),
4148 });
4149 open_custom_picker(state, mode, kind, seed);
4150 }
4151 }
4152}
4153
4154fn route_picked_path(
4155 state: &mut AppState,
4156 kind: file_menu::FileKind,
4157 mode: bone_ui::widgets::FilePickerMode,
4158 path: PathBuf,
4159 via: PickedVia,
4160) {
4161 match (kind, mode) {
4162 (file_menu::FileKind::Document, bone_ui::widgets::FilePickerMode::Open) => {
4163 request_open_folder(state, path);
4164 }
4165 (file_menu::FileKind::Document, bone_ui::widgets::FilePickerMode::Save) => {
4166 apply_save_as(state, path);
4167 }
4168 (file_menu::FileKind::Step, bone_ui::widgets::FilePickerMode::Open) => {
4169 request_import_step(state, path);
4170 }
4171 (file_menu::FileKind::Step, bone_ui::widgets::FilePickerMode::Save) => {
4172 apply_export_step_as(state, path, via);
4173 }
4174 }
4175}
4176
4177fn apply_save_in_place(state: &mut AppState) {
4178 let Some(folder) = state.current_folder.clone() else {
4179 let seed = state.document.name().to_owned();
4180 open_picker(
4181 state,
4182 bone_ui::widgets::FilePickerMode::Save,
4183 file_menu::FileKind::Document,
4184 Some(seed),
4185 );
4186 return;
4187 };
4188 if let Err(e) = bone_document::save(&state.document, &folder) {
4189 tracing::warn!(error = %e, path = %folder.path().display(), "save failed");
4190 notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string());
4191 return;
4192 }
4193 state.last_saved = Some(state.document.clone());
4194 notify_info(state, strings::NOTIFY_SAVED, None);
4195}
4196
4197fn apply_picker_command(
4198 state: &mut AppState,
4199 kind: file_menu::FileKind,
4200 command: file_menu::PickerCommand,
4201) {
4202 state.file_picker = None;
4203 match command {
4204 file_menu::PickerCommand::Cancel => {}
4205 file_menu::PickerCommand::Open(path) => {
4206 route_picked_path(
4207 state,
4208 kind,
4209 bone_ui::widgets::FilePickerMode::Open,
4210 path,
4211 PickedVia::CustomPicker,
4212 );
4213 }
4214 file_menu::PickerCommand::SaveAs(path) => {
4215 route_picked_path(
4216 state,
4217 kind,
4218 bone_ui::widgets::FilePickerMode::Save,
4219 path,
4220 PickedVia::CustomPicker,
4221 );
4222 }
4223 }
4224}
4225
4226fn apply_open_folder(state: &mut AppState, path: PathBuf) {
4227 let folder = DocumentFolder::new(path);
4228 let document = match bone_document::load(&folder) {
4229 Ok(d) => d,
4230 Err(e) => {
4231 tracing::warn!(error = %e, path = %folder.path().display(), "load failed");
4232 notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string());
4233 return;
4234 }
4235 };
4236 install_loaded_document(state, document, Some(folder));
4237}
4238
4239fn apply_save_as(state: &mut AppState, path: PathBuf) {
4240 let folder = DocumentFolder::new(path);
4241 let in_place = state
4242 .current_folder
4243 .as_ref()
4244 .is_some_and(|current| same_folder(current.path(), folder.path()));
4245 if folder.document_file().is_file() && !in_place {
4246 state.pending_overwrite = Some(PendingOverwrite::Document(folder));
4247 return;
4248 }
4249 perform_save_to(state, folder);
4250}
4251
4252fn perform_save_to(state: &mut AppState, folder: DocumentFolder) {
4253 let prior_name = state.document.name().to_owned();
4254 state
4255 .document
4256 .set_name(folder_display_name(&folder, &state.strings));
4257 if let Err(e) = bone_document::save(&state.document, &folder) {
4258 tracing::warn!(error = %e, path = %folder.path().display(), "save as failed");
4259 state.document.set_name(prior_name);
4260 notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string());
4261 return;
4262 }
4263 state.current_folder = Some(folder);
4264 state.last_saved = Some(state.document.clone());
4265 notify_info(state, strings::NOTIFY_SAVED, None);
4266}
4267
4268fn folder_display_name(folder: &DocumentFolder, string_table: &StringTable) -> String {
4269 folder.path().file_name().map_or_else(
4270 || {
4271 string_table
4272 .resolve(strings::DEFAULT_DOCUMENT_NAME)
4273 .to_owned()
4274 },
4275 |s| s.to_string_lossy().into_owned(),
4276 )
4277}
4278
4279fn same_folder(a: &Path, b: &Path) -> bool {
4280 match (resolve_path(a), resolve_path(b)) {
4281 (Some(x), Some(y)) => x == y,
4282 _ => false,
4283 }
4284}
4285
4286fn resolve_path(path: &Path) -> Option<PathBuf> {
4287 if let Ok(canon) = std::fs::canonicalize(path) {
4288 return Some(canon);
4289 }
4290 let parent = path.parent()?;
4291 let file_name = path.file_name()?;
4292 let parent_canon = std::fs::canonicalize(parent).ok()?;
4293 Some(parent_canon.join(file_name))
4294}
4295
4296fn notify_error(state: &mut AppState, headline: bone_ui::strings::StringKey, detail: String) {
4297 state.notification = Some(Notification {
4298 kind: NotificationKind::Error,
4299 headline,
4300 detail: Some(detail),
4301 });
4302}
4303
4304fn notify_info(
4305 state: &mut AppState,
4306 headline: bone_ui::strings::StringKey,
4307 detail: Option<String>,
4308) {
4309 state.notification = Some(Notification {
4310 kind: NotificationKind::Info,
4311 headline,
4312 detail,
4313 });
4314}
4315
4316fn notify_stub(state: &mut AppState, label: bone_ui::strings::StringKey) {
4317 let detail = state.strings.resolve(label).to_owned();
4318 tracing::info!(label = %detail, "stub action invoked");
4319 notify_info(state, strings::NOTIFY_COMING_SOON, Some(detail));
4320}
4321
4322fn apply_shortcut_bar_outcome(
4323 state: &mut AppState,
4324 outcome: Option<&shortcut_bar::ShortcutBarOutcome>,
4325) {
4326 let Some(outcome) = outcome else { return };
4327 if outcome.dismissed || outcome.activated.is_some() {
4328 state.shortcut_bar = None;
4329 }
4330}
4331
4332fn install_imported_document(state: &mut AppState, document: Document) {
4333 install_loaded_document(state, document, None);
4334 state.last_saved = None;
4335}
4336
4337fn install_loaded_document(
4338 state: &mut AppState,
4339 document: Document,
4340 folder: Option<DocumentFolder>,
4341) {
4342 let plane_sketches = plane_sketches_from(&document);
4343 let active_sketch_id = plane_sketches.get(&Plane::Xy).copied();
4344 state.last_saved = Some(document.clone());
4345 state.document = document;
4346 state.plane_sketches = plane_sketches;
4347 state.mode = Mode::Idle;
4348 state.selection = Selection::default();
4349 state.framed_extrude = None;
4350 state.current_folder = folder;
4351 state.pending_overwrite = None;
4352 let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else {
4353 unreachable!("UNDO_CAPACITY constant is non-zero");
4354 };
4355 state.undo = UndoStack::with_capacity(undo_capacity);
4356 let scene_attempt = active_sketch_id
4357 .and_then(|id| state.document.sketch(id))
4358 .map(SketchScene::extract);
4359 state.scene = match scene_attempt {
4360 None => SketchScene::empty(),
4361 Some(Ok(scene)) => scene,
4362 Some(Err(e)) => {
4363 tracing::warn!(error = %e, "scene extract on load failed");
4364 notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string());
4365 SketchScene::empty()
4366 }
4367 };
4368 state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect);
4369}
4370
4371fn plane_sketches_from(document: &Document) -> BTreeMap<Plane, SketchId> {
4372 document
4373 .sketches()
4374 .map(|(id, _)| (Plane::Xy, id))
4375 .take(1)
4376 .collect()
4377}
4378
4379fn persist_settings(state: &AppState) {
4380 settings::save(&state.settings);
4381}
4382
4383fn apply_settings_change(state: &mut AppState, change: Option<settings::Settings>) {
4384 let Some(next) = change else { return };
4385 let overrides_changed = next.hotkey_overrides != state.settings.hotkey_overrides;
4386 if !overrides_changed {
4387 state.settings = next;
4388 persist_settings(state);
4389 return;
4390 }
4391 match hotkeys::compose_table(&next.hotkey_overrides) {
4392 Ok(table) => {
4393 state.hotkeys = table;
4394 state.settings = next;
4395 persist_settings(state);
4396 }
4397 Err(error) => {
4398 tracing::warn!(?error, "hotkey override rejected, retaining prior settings");
4399 state.shell.state.hotkey_capture.clear();
4400 notify_error(state, strings::NOTIFY_HOTKEY_CONFLICT, format!("{error}"));
4401 }
4402 }
4403}
4404
4405fn apply_sketch_rename(state: &mut AppState, request: Option<shell::SketchRenameRequest>) {
4406 let Some(req) = request else { return };
4407 apply_sketch_rename_into(&mut state.document, &mut state.undo, req);
4408}
4409
4410fn apply_extrude_rename(state: &mut AppState, request: Option<shell::ExtrudeRenameRequest>) {
4411 let Some(req) = request else { return };
4412 apply_extrude_rename_into(&mut state.document, &mut state.undo, req);
4413}
4414
4415fn apply_feature_command(state: &mut AppState, command: Option<shell::FeatureCommand>) {
4416 let Some(command) = command else { return };
4417 if apply_feature_command_into(&mut state.document, &mut state.undo, command) {
4418 refresh_active_scene(state);
4419 sync_solid_view(state);
4420 }
4421}
4422
4423fn apply_feature_command_into(
4424 document: &mut Document,
4425 undo: &mut UndoStack,
4426 command: shell::FeatureCommand,
4427) -> bool {
4428 let snapshot = document.clone();
4429 let changed = match command {
4430 shell::FeatureCommand::Suppress(feature) => {
4431 document.suppress(feature);
4432 true
4433 }
4434 shell::FeatureCommand::Unsuppress(feature) => {
4435 document.unsuppress(feature);
4436 true
4437 }
4438 shell::FeatureCommand::RollbackToHere(feature) => {
4439 document.roll_to_here(feature);
4440 true
4441 }
4442 shell::FeatureCommand::Delete(target) => apply_feature_delete(document, target),
4443 };
4444 if changed {
4445 undo.record(snapshot);
4446 }
4447 changed
4448}
4449
4450fn apply_feature_delete(document: &mut Document, target: shell::FeatureTarget) -> bool {
4451 match target {
4452 shell::FeatureTarget::Sketch(sketch_id) => document.remove_sketch(sketch_id).is_some(),
4453 shell::FeatureTarget::Extrude(extrude_id) => document.remove_extrude(extrude_id).is_some(),
4454 }
4455}
4456
4457fn apply_feature_reorder(state: &mut AppState, reorder: Option<shell::FeatureReorder>) {
4458 let Some(reorder) = reorder else { return };
4459 let snapshot = state.document.clone();
4460 if state
4461 .document
4462 .reorder_feature(reorder.moved, reorder.anchor, reorder.before)
4463 {
4464 state.undo.record(snapshot);
4465 refresh_active_scene(state);
4466 sync_solid_view(state);
4467 }
4468}
4469
4470fn begin_reattach(state: &mut AppState, request: Option<SketchId>) {
4471 let Some(sketch) = request else { return };
4472 state.pending_reattach = Some(sketch);
4473 notify_info(state, strings::NOTIFY_REATTACH_PICK, None);
4474}
4475
4476fn apply_reattach(state: &mut AppState, sketch: SketchId, face: BrepFaceId) {
4477 let Some((face_ref, _)) = state.model.face_for_sketch(face) else {
4478 notify_info(state, strings::NOTIFY_REATTACH_NON_PLANAR, None);
4479 return;
4480 };
4481 let snapshot = state.document.clone();
4482 if state.document.bind_sketch_to_face(sketch, face_ref).is_ok() {
4483 state.pending_reattach = None;
4484 state.undo.record(snapshot);
4485 refresh_active_scene(state);
4486 sync_solid_view(state);
4487 }
4488}
4489
4490fn next_sketch_label(document: &Document) -> String {
4491 format!("Sketch{}", document.sketches().count() + 1)
4492}
4493
4494fn begin_face_sketch(state: &mut AppState, face: BrepFaceId) -> bool {
4495 let Some((face_ref, basis)) = state.model.face_for_sketch(face) else {
4496 return false;
4497 };
4498 let snapshot = state.document.clone();
4499 let sketch_id = state.document.allocate_sketch();
4500 let label = next_sketch_label(&state.document);
4501 state
4502 .document
4503 .insert_sketch(sketch_id, label, Sketch::new(basis));
4504 if state
4505 .document
4506 .bind_sketch_to_face(sketch_id, face_ref)
4507 .is_err()
4508 {
4509 state.document = snapshot;
4510 return false;
4511 }
4512 state.undo.record(snapshot);
4513 state.mode = Mode::enter_sketch(sketch_id);
4514 refresh_active_scene(state);
4515 sync_solid_view(state);
4516 true
4517}
4518
4519fn apply_rollback_change(state: &mut AppState, change: Option<shell::RollbackChange>) {
4520 let Some(change) = change else { return };
4521 let before = state.document.rollback();
4522 let snapshot = state.document.clone();
4523 match change {
4524 shell::RollbackChange::ToEnd => state.document.roll_to_end(),
4525 shell::RollbackChange::ToFeature(feature) => state.document.roll_to_here(feature),
4526 }
4527 if state.document.rollback() != before {
4528 state.undo.record(snapshot);
4529 refresh_active_scene(state);
4530 sync_solid_view(state);
4531 }
4532}
4533
4534fn apply_extrude_rename_into(
4535 document: &mut Document,
4536 undo: &mut UndoStack,
4537 request: shell::ExtrudeRenameRequest,
4538) {
4539 let shell::ExtrudeRenameRequest { id, label } = request;
4540 let trimmed = label.trim();
4541 let Some(current) = document.extrude_label(id) else {
4542 return;
4543 };
4544 if trimmed.is_empty() || current == trimmed {
4545 return;
4546 }
4547 let snapshot = document.clone();
4548 match document.rename_extrude(id, &label) {
4549 Ok(()) => undo.record(snapshot),
4550 Err(e) => tracing::warn!(error = %e, ?id, "extrude rename rejected"),
4551 }
4552}
4553
4554fn apply_sketch_rename_into(
4555 document: &mut Document,
4556 undo: &mut UndoStack,
4557 request: shell::SketchRenameRequest,
4558) {
4559 let shell::SketchRenameRequest { id, label } = request;
4560 let trimmed = label.trim();
4561 let Some(current) = document.sketch_label(id) else {
4562 return;
4563 };
4564 if trimmed.is_empty() || current == trimmed {
4565 return;
4566 }
4567 let snapshot = document.clone();
4568 match document.rename_sketch(id, &label) {
4569 Ok(()) => undo.record(snapshot),
4570 Err(e) => tracing::warn!(error = %e, ?id, "sketch rename rejected"),
4571 }
4572}
4573
4574#[cfg(test)]
4575mod tests {
4576 use super::*;
4577 use crate::sketch_mode::SketchSession;
4578 use bone_ui::hotkey::KeyChord;
4579 use bone_ui::input::{KeyChar, KeyCode, KeyEvent, NamedKey};
4580
4581 #[test]
4582 fn strip_plain_letter_chords_removes_chars_with_no_modifiers() {
4583 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
4584 let plain_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::NONE);
4585 let ctrl_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL);
4586 let esc = KeyEvent::new(KeyCode::Named(NamedKey::Escape), ModifierMask::NONE);
4587 input.keys_pressed = vec![plain_s, ctrl_s, esc];
4588 strip_plain_letter_chords(&mut input);
4589 assert_eq!(
4590 input.keys_pressed,
4591 vec![ctrl_s, esc],
4592 "strip removes plain letters, keeps modified chords and named keys"
4593 );
4594 }
4595
4596 #[test]
4597 fn strip_plain_letter_chords_is_idempotent_when_no_chars() {
4598 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
4599 let enter = KeyEvent::new(KeyCode::Named(NamedKey::Enter), ModifierMask::NONE);
4600 input.keys_pressed = vec![enter];
4601 strip_plain_letter_chords(&mut input);
4602 assert_eq!(input.keys_pressed, vec![enter]);
4603 }
4604
4605 #[test]
4606 fn cursor_to_world_at_window_center_equals_camera_pan() {
4607 let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100));
4608 let camera = Camera2::new(extent)
4609 .with_pan(Vec2::from_mm(7.0, -3.0))
4610 .with_zoom(PixelsPerMm::new(5.0));
4611 let Some(world) = cursor_to_world(camera, WindowPoint::new(100.0, 50.0)) else {
4612 panic!("center maps");
4613 };
4614 let (x, y) = world.coords_mm();
4615 assert!((x - 7.0).abs() < 1e-9);
4616 assert!((y - -3.0).abs() < 1e-9);
4617 }
4618
4619 #[test]
4620 fn cursor_to_world_inverts_y_so_up_in_window_is_up_in_world() {
4621 let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100));
4622 let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(10.0));
4623 let Some(above) = cursor_to_world(camera, WindowPoint::new(100.0, 0.0)) else {
4624 panic!("top");
4625 };
4626 let Some(below) = cursor_to_world(camera, WindowPoint::new(100.0, 100.0)) else {
4627 panic!("bottom");
4628 };
4629 let (_, ya) = above.coords_mm();
4630 let (_, yb) = below.coords_mm();
4631 assert!(ya > yb);
4632 }
4633
4634 #[test]
4635 fn cursor_to_world_rejects_zero_extent() {
4636 let extent = ViewportExtent::new(ViewportPx::new(0), ViewportPx::new(100));
4637 let camera = Camera2::new(extent);
4638 assert!(cursor_to_world(camera, WindowPoint::new(0.0, 0.0)).is_none());
4639 }
4640
4641 fn empty_frame() -> shell::ShellFrame {
4642 shell::ShellFrame {
4643 paints: Vec::new(),
4644 overlay_paints: Vec::new(),
4645 viewport_rect: empty_rect(),
4646 activated_tool: None,
4647 activated_feature_tool: None,
4648 activated_relation: None,
4649 activated_dimension: None,
4650 dimension_edit: None,
4651 extrude_edit: None,
4652 plane_picked: None,
4653 sketch_activated: None,
4654 sketch_rename: None,
4655 extrude_activated: None,
4656 extrude_rename: None,
4657 feature_command: None,
4658 feature_reorder: None,
4659 rollback_change: None,
4660 reattach_request: None,
4661 exit_sketch: false,
4662 confirm_action: None,
4663 menu_action: None,
4664 settings_change: None,
4665 view_pick: None,
4666 view_menu: None,
4667 }
4668 }
4669
4670 fn xy_only() -> BTreeMap<Plane, SketchId> {
4671 BTreeMap::from([(Plane::Xy, SketchId::default())])
4672 }
4673
4674 #[test]
4675 fn classify_extrude_profile_separates_no_sketch_from_unique() {
4676 let empty = Document::new(DocumentId::default(), "Untitled".to_owned());
4677 assert!(matches!(
4678 super::classify_extrude_profile(&empty),
4679 super::ProfileChoice::NoSketch
4680 ));
4681
4682 let (one, id) = super::initial_document(Sketch::new(Plane::Xy.basis()));
4683 assert!(matches!(
4684 super::classify_extrude_profile(&one),
4685 super::ProfileChoice::Unique(found) if found == id
4686 ));
4687 }
4688
4689 fn rectangle_sketch() -> Sketch {
4690 let sketch = Sketch::new(Plane::Xy.basis());
4691 let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0));
4692 let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0));
4693 let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(10.0, 6.0));
4694 let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(0.0, 6.0));
4695 let (sketch, _) = tools::add_line(sketch, p0, p1, false);
4696 let (sketch, _) = tools::add_line(sketch, p1, p2, false);
4697 let (sketch, _) = tools::add_line(sketch, p2, p3, false);
4698 let (sketch, _) = tools::add_line(sketch, p3, p0, false);
4699 sketch
4700 }
4701
4702 fn tall_rectangle_sketch() -> Sketch {
4703 let sketch = Sketch::new(Plane::Xy.basis());
4704 let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0));
4705 let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0));
4706 let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(10.0, 20.0));
4707 let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(0.0, 20.0));
4708 let (sketch, _) = tools::add_line(sketch, p0, p1, false);
4709 let (sketch, _) = tools::add_line(sketch, p1, p2, false);
4710 let (sketch, _) = tools::add_line(sketch, p2, p3, false);
4711 let (sketch, _) = tools::add_line(sketch, p3, p0, false);
4712 sketch
4713 }
4714
4715 #[test]
4716 fn extrude_preview_cache_is_current_only_for_the_same_feature_and_sketch_version() {
4717 let id = SketchId::default();
4718 let feature = sketch_mode::default_extrude_feature(id);
4719 let base_version = Sketch::new(Plane::Xy.basis()).version();
4720 let edited_version = rectangle_sketch().version();
4721 assert_ne!(
4722 base_version, edited_version,
4723 "a sketch edit must bump the version this gate keys on"
4724 );
4725 let cached = super::ExtrudePreview {
4726 feature,
4727 sketch_version: base_version,
4728 generation: None,
4729 failed: false,
4730 error: None,
4731 };
4732 assert!(super::extrude_preview_is_current(
4733 Some(&cached),
4734 &feature,
4735 base_version
4736 ));
4737 assert!(
4738 !super::extrude_preview_is_current(Some(&cached), &feature, edited_version),
4739 "editing the sketch under the same feature must invalidate the cached preview",
4740 );
4741 assert!(!super::extrude_preview_is_current(
4742 None,
4743 &feature,
4744 base_version
4745 ));
4746 }
4747
4748 #[test]
4749 fn extrude_preview_refreshes_when_the_sketch_changes() {
4750 let (mut document, id) = super::initial_document(rectangle_sketch());
4751 let feature = sketch_mode::default_extrude_feature(id);
4752 let first = super::compute_extrude_preview(&document, feature)
4753 .and_then(|preview| preview.generation());
4754 document.replace_sketch(id, tall_rectangle_sketch());
4755 let second = super::compute_extrude_preview(&document, feature)
4756 .and_then(|preview| preview.generation());
4757 let (Some(first), Some(second)) = (first, second) else {
4758 panic!("both rectangles extrude to a solid");
4759 };
4760 assert_ne!(
4761 first, second,
4762 "a sketch edit under the same feature must re-evaluate against the edited geometry",
4763 );
4764 }
4765
4766 #[test]
4767 fn extrude_preview_evaluates_a_closed_rectangle() {
4768 let (document, id) = super::initial_document(rectangle_sketch());
4769 let feature = sketch_mode::default_extrude_feature(id);
4770 let Some(preview) = super::compute_extrude_preview(&document, feature) else {
4771 panic!("a registered sketch yields an evaluated preview");
4772 };
4773 assert!(
4774 preview.solid().is_some(),
4775 "closed rectangle extrudes to a solid"
4776 );
4777 }
4778
4779 #[test]
4780 #[cfg_attr(
4781 debug_assertions,
4782 ignore = "frame budget assertions are only meaningful in release builds"
4783 )]
4784 fn extrude_live_preview_under_frame_budget() {
4785 use bone_document::ExtrudeEndCondition;
4786 use bone_types::PositiveLength;
4787 use std::time::{Duration, Instant};
4788 use uom::si::length::millimeter;
4789
4790 const BASE_MM: f64 = 4.0;
4791 const STEP_MM: f64 = 0.5;
4792 const STEPS: u32 = 16;
4793
4794 let (document, id) = super::initial_document(rectangle_sketch());
4795 let blind = |depth_mm: f64| {
4796 let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(depth_mm)) else {
4797 panic!("{depth_mm} mm is a positive depth");
4798 };
4799 ExtrudeFeature {
4800 end_condition: ExtrudeEndCondition::Blind { depth },
4801 ..sketch_mode::default_extrude_feature(id)
4802 }
4803 };
4804 let edit = |depth_mm: f64| {
4805 let started = Instant::now();
4806 let Some(preview) = super::compute_extrude_preview(&document, blind(depth_mm)) else {
4807 panic!("the rectangle extrudes at {depth_mm} mm");
4808 };
4809 let Some(solid) = preview.solid() else {
4810 panic!("the rectangle yields a solid at {depth_mm} mm");
4811 };
4812 let Ok(_view) = super::build_solid_view(solid) else {
4813 panic!("the slab tessellates and packs scenes at {depth_mm} mm");
4814 };
4815 started.elapsed()
4816 };
4817
4818 let _warmup = edit(BASE_MM);
4819 let durations: Vec<Duration> = (0..STEPS)
4820 .map(|i| edit(BASE_MM + STEP_MM * f64::from(i)))
4821 .collect();
4822 let sorted = {
4823 let mut v = durations.clone();
4824 v.sort();
4825 v
4826 };
4827 let median = sorted[sorted.len() / 2];
4828 let Some(&worst) = sorted.last() else {
4829 panic!("preview loop produced zero samples");
4830 };
4831 let budget = BudgetCeiling::FRAME_16MS.duration();
4832 assert!(
4833 median <= budget,
4834 "median evaluate+tessellate+scene step {median:?} exceeds {budget:?} frame budget; samples {durations:?}",
4835 );
4836 assert!(
4837 worst <= budget * 2,
4838 "worst evaluate+tessellate+scene step {worst:?} exceeds the relaxed ceiling; samples {durations:?}",
4839 );
4840 }
4841
4842 #[test]
4843 fn extrude_preview_absent_when_sketch_missing() {
4844 let document = Document::new(DocumentId::default(), "Empty".to_owned());
4845 let feature = sketch_mode::default_extrude_feature(SketchId::default());
4846 assert!(super::compute_extrude_preview(&document, feature).is_none());
4847 }
4848
4849 #[test]
4850 fn default_document_extrudes_to_a_solid() {
4851 let (document, id) = super::initial_document(super::default_sketch());
4852 let feature = sketch_mode::default_extrude_feature(id);
4853 let Some(preview) = super::compute_extrude_preview(&document, feature) else {
4854 panic!("the default sketch is registered");
4855 };
4856 let Some(solid) = preview.solid() else {
4857 panic!("the default sketch extrudes to a solid");
4858 };
4859 assert!(super::build_solid_view(solid).is_ok());
4860 }
4861
4862 #[test]
4863 fn preview_solid_view_tessellates_and_frames() {
4864 let (document, id) = super::initial_document(rectangle_sketch());
4865 let feature = sketch_mode::default_extrude_feature(id);
4866 let Some(preview) = super::compute_extrude_preview(&document, feature) else {
4867 panic!("a registered sketch yields an evaluated preview");
4868 };
4869 let Some(solid) = preview.solid() else {
4870 panic!("the rectangle extrudes to a solid");
4871 };
4872 let Ok(view) = super::build_solid_view(solid) else {
4873 panic!("the solid tessellates into a renderable view");
4874 };
4875 let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256));
4876 let region = ViewportRegion::at_origin(extent);
4877 let camera = frame_standard_view(view.aabb, extent, StandardView::Isometric, None).ok();
4878 assert!(
4879 camera.is_some(),
4880 "the solid aabb frames an isometric camera"
4881 );
4882 assert!(
4883 super::preview_solid_frame(Some(&view), camera, region).is_some(),
4884 "a framed preview lowers to a solid frame view",
4885 );
4886 assert!(
4887 super::preview_solid_frame(Some(&view), None, region).is_none(),
4888 "without a camera there is nothing to frame",
4889 );
4890 }
4891
4892 #[test]
4893 fn build_combined_view_merges_every_evaluated_body() {
4894 let (mut document, s1) = super::initial_document(rectangle_sketch());
4895 let e1 = document.commit_extrude(sketch_mode::default_extrude_feature(s1));
4896 let s2 = document.allocate_sketch();
4897 document.insert_sketch(s2, "Sketch2".to_owned(), rectangle_sketch());
4898 let _e2 = document.commit_extrude(sketch_mode::default_extrude_feature(s2));
4899 let mut model = EvaluatedModel::new();
4900 model.recompute(
4901 &document,
4902 document.suppressed(),
4903 document.rollback(),
4904 RecomputeScope::Full,
4905 );
4906 assert_eq!(
4907 super::body_passes(&model).len(),
4908 2,
4909 "two separate bodies build"
4910 );
4911 let Some(f1) = document.feature_tree().feature_of_extrude(e1) else {
4912 panic!("the first extrude resolves to a feature id");
4913 };
4914 let Some(solid) = model.body(f1) else {
4915 panic!("the first body is evaluated");
4916 };
4917 let Ok(single) = super::build_solid_view(solid) else {
4918 panic!("a single body tessellates");
4919 };
4920 let Some(combined) = super::build_combined_view(&model) else {
4921 panic!("the combined view is built from both bodies");
4922 };
4923 assert_eq!(
4924 combined.faces.triangles().len(),
4925 2 * single.faces.triangles().len(),
4926 "the combined mesh holds both bodies",
4927 );
4928 }
4929
4930 fn full_recompute(model: &mut EvaluatedModel, document: &Document) {
4931 model.recompute(
4932 document,
4933 document.suppressed(),
4934 document.rollback(),
4935 RecomputeScope::Full,
4936 );
4937 }
4938
4939 #[test]
4940 fn face_bound_sketch_creation_then_break_offers_reattach() {
4941 use bone_types::FaceRole;
4942 let (mut document, base_sketch) = super::initial_document(rectangle_sketch());
4943 let base = document.commit_extrude(sketch_mode::default_extrude_feature(base_sketch));
4944 let mut model = EvaluatedModel::new();
4945 full_recompute(&mut model, &document);
4946
4947 let Some(body) = document.feature_tree().feature_of_extrude(base) else {
4948 panic!("the base extrude resolves to a feature id");
4949 };
4950 let Some(solid) = model.body(body) else {
4951 panic!("the base body is evaluated");
4952 };
4953 let Some(face) = solid.iter_faces().find_map(|candidate| {
4954 matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id())
4955 }) else {
4956 panic!("the box has a planar cap");
4957 };
4958 let Some((face_ref, _basis)) = model.face_for_sketch(face) else {
4959 panic!("a planar cap yields a face reference and basis");
4960 };
4961
4962 let face_sketch = document.allocate_sketch();
4963 document.insert_sketch(face_sketch, "Sketch2".to_owned(), rectangle_sketch());
4964 let Ok(()) = document.bind_sketch_to_face(face_sketch, face_ref) else {
4965 panic!("binding a fresh sketch to a face is acyclic");
4966 };
4967 let face_extrude =
4968 document.commit_extrude(sketch_mode::default_extrude_feature(face_sketch));
4969 full_recompute(&mut model, &document);
4970 assert_eq!(
4971 super::body_passes(&model).len(),
4972 2,
4973 "the face-bound extrude is a separate second body",
4974 );
4975
4976 document.remove_extrude(base);
4977 full_recompute(&mut model, &document);
4978 let Some(face_feature) = document.feature_tree().feature_of_sketch(face_sketch) else {
4979 panic!("the face-bound sketch keeps its feature id");
4980 };
4981 assert!(
4982 matches!(
4983 model.status(face_feature),
4984 Some(RebuildStatus::Error(RebuildError::DanglingReference(_)))
4985 ),
4986 "deleting the base dangles the face-bound sketch",
4987 );
4988 let Some(face_extrude_feature) = document.feature_tree().feature_of_extrude(face_extrude)
4989 else {
4990 panic!("the face-bound extrude keeps its feature id");
4991 };
4992 assert_eq!(
4993 model.status(face_extrude_feature),
4994 Some(RebuildStatus::Error(RebuildError::UpstreamUnresolved)),
4995 "the downstream extrude is blamed as upstream-unresolved, not a second dangling owner",
4996 );
4997 let wrong = super::compute_whats_wrong(&model, &document);
4998 assert_eq!(
4999 wrong
5000 .iter()
5001 .filter(|entry| entry.reattach.is_some())
5002 .count(),
5003 1,
5004 "only the sketch that owns the dangling reference offers reattach",
5005 );
5006 assert!(
5007 wrong
5008 .iter()
5009 .any(|entry| entry.reattach == Some(face_sketch)),
5010 "the dangling face-bound sketch offers reattach",
5011 );
5012 assert!(
5013 wrong
5014 .iter()
5015 .any(|entry| entry.message == strings::WHATS_WRONG_UPSTREAM
5016 && entry.reattach.is_none()),
5017 "the downstream body reports an upstream failure with no reattach of its own",
5018 );
5019 }
5020
5021 fn offscreen_context(extent: ViewportExtent, remaining: u32) -> bone_render::OffscreenContext {
5022 use bone_render::{AdapterPolicy, OffscreenContext, RenderError};
5023 match pollster::block_on(OffscreenContext::new(extent, AdapterPolicy::Platform)) {
5024 Ok(ctx) => ctx,
5025 Err(RenderError::NoAdapter(_) | RenderError::Device(_)) if remaining > 0 => {
5026 std::thread::sleep(std::time::Duration::from_millis(100));
5027 offscreen_context(extent, remaining - 1)
5028 }
5029 Err(e) => panic!("offscreen context init failed: {e}"),
5030 }
5031 }
5032
5033 fn check_solid_golden(frame: &bone_render::SnapshotFrame, golden_rel: &str) {
5034 use bone_render::{PixelDiff, PixelDiffThreshold, decode_png, encode_png};
5035 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(golden_rel);
5036 if std::env::var_os("BONE_UPDATE_REBUILD_GOLDENS").is_some() {
5037 let Ok(bytes) = encode_png(frame) else {
5038 panic!("encode_png failed");
5039 };
5040 if let Some(parent) = path.parent() {
5041 let Ok(()) = std::fs::create_dir_all(parent) else {
5042 panic!("create goldens dir failed");
5043 };
5044 }
5045 let Ok(()) = std::fs::write(&path, &bytes) else {
5046 panic!("write golden {} failed", path.display());
5047 };
5048 return;
5049 }
5050 let Ok(bytes) = std::fs::read(&path) else {
5051 panic!(
5052 "golden {} missing; rerun with BONE_UPDATE_REBUILD_GOLDENS=1 to bless",
5053 path.display(),
5054 );
5055 };
5056 let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else {
5057 panic!("decode golden failed");
5058 };
5059 assert_eq!(golden_extent, frame.extent(), "golden extent drift");
5060 let threshold = PixelDiffThreshold::new(16.0 / 255.0);
5061 let Ok(report) = PixelDiff::compare(frame, &golden_rgba, threshold) else {
5062 panic!("PixelDiff rejected inputs");
5063 };
5064 assert!(
5065 report.is_clean(),
5066 "rebuild-downstream golden drifted: {} mismatches, worst {:?}",
5067 report.over_threshold(),
5068 report.worst(),
5069 );
5070 }
5071
5072 #[test]
5073 fn rebuild_downstream_scene_matches_golden() {
5074 use bone_types::FaceRole;
5075 let (mut document, base_sketch) = super::initial_document(rectangle_sketch());
5076 let base = document.commit_extrude(sketch_mode::default_extrude_feature(base_sketch));
5077 let mut model = EvaluatedModel::new();
5078 full_recompute(&mut model, &document);
5079
5080 let Some(body) = document.feature_tree().feature_of_extrude(base) else {
5081 panic!("the base extrude resolves to a feature id");
5082 };
5083 let Some(solid) = model.body(body) else {
5084 panic!("the base body is evaluated");
5085 };
5086 let Some(cap) = solid.iter_faces().find_map(|candidate| {
5087 matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id())
5088 }) else {
5089 panic!("the box has a planar top cap");
5090 };
5091 let Some((face_ref, _basis)) = model.face_for_sketch(cap) else {
5092 panic!("a planar cap yields a face reference and basis");
5093 };
5094
5095 let face_sketch = document.allocate_sketch();
5096 document.insert_sketch(face_sketch, "Sketch2".to_owned(), rectangle_sketch());
5097 let Ok(()) = document.bind_sketch_to_face(face_sketch, face_ref) else {
5098 panic!("binding the second sketch to the cap is acyclic");
5099 };
5100 let _ = document.commit_extrude(sketch_mode::default_extrude_feature(face_sketch));
5101 full_recompute(&mut model, &document);
5102
5103 document.replace_sketch(base_sketch, tall_rectangle_sketch());
5104 full_recompute(&mut model, &document);
5105 assert_eq!(
5106 super::body_passes(&model).len(),
5107 2,
5108 "editing the base sketch keeps both bodies",
5109 );
5110
5111 let Some(combined) = super::build_combined_view(&model) else {
5112 panic!("the rebuilt model lowers to a combined view");
5113 };
5114 let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256));
5115 let Ok(camera) = frame_standard_view(combined.aabb, extent, StandardView::Isometric, None)
5116 else {
5117 panic!("the combined aabb frames an isometric camera");
5118 };
5119
5120 let ctx = offscreen_context(extent, 3);
5121 let mut renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format());
5122 let Ok(frame) = renderer.render_display(
5123 &ctx,
5124 &combined.faces,
5125 &combined.edges,
5126 camera,
5127 &Style::default(),
5128 DisplayMode::ShadedWithEdges,
5129 ) else {
5130 panic!("the rebuilt two-body scene renders");
5131 };
5132 check_solid_golden(&frame, "tests/goldens/rebuild_downstream_iso_256.png");
5133 }
5134
5135 #[test]
5136 fn solid_pick_index_builds_for_an_evaluated_body() {
5137 let (mut document, sketch) = super::initial_document(rectangle_sketch());
5138 let _extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch));
5139 let mut model = EvaluatedModel::new();
5140 model.recompute(
5141 &document,
5142 document.suppressed(),
5143 document.rollback(),
5144 RecomputeScope::Full,
5145 );
5146 assert!(
5147 super::solid_pick_index(&model).is_some(),
5148 "an evaluated body yields a solid pick index",
5149 );
5150 assert!(
5151 super::solid_pick_index(&EvaluatedModel::new()).is_none(),
5152 "an empty model has nothing to pick",
5153 );
5154 }
5155
5156 #[test]
5157 fn reattach_binds_a_sketch_to_a_picked_cap_face() {
5158 use bone_types::FaceRole;
5159 let (mut document, base_sketch) = super::initial_document(rectangle_sketch());
5160 let base = document.commit_extrude(sketch_mode::default_extrude_feature(base_sketch));
5161 let mut model = EvaluatedModel::new();
5162 model.recompute(
5163 &document,
5164 document.suppressed(),
5165 document.rollback(),
5166 RecomputeScope::Full,
5167 );
5168 let Some(body) = document.feature_tree().feature_of_extrude(base) else {
5169 panic!("the base extrude resolves to a feature id");
5170 };
5171 let Some(solid) = model.body(body) else {
5172 panic!("the base body is evaluated");
5173 };
5174 let Some(face) = solid.iter_faces().find_map(|candidate| {
5175 matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id())
5176 }) else {
5177 panic!("the box has a planar cap");
5178 };
5179 let Some(face_ref) = model.face_ref_any(face) else {
5180 panic!("a picked cap yields a face reference");
5181 };
5182 let new_sketch = document.allocate_sketch();
5183 document.insert_sketch(new_sketch, "Sketch2".to_owned(), rectangle_sketch());
5184 assert!(
5185 document.bind_sketch_to_face(new_sketch, face_ref).is_ok(),
5186 "reattaching a sketch to a planar cap binds cleanly",
5187 );
5188 }
5189
5190 #[test]
5191 fn compute_whats_wrong_is_empty_for_a_healthy_document() {
5192 let (mut document, sketch) = super::initial_document(rectangle_sketch());
5193 let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch));
5194 let mut model = EvaluatedModel::new();
5195 model.recompute(
5196 &document,
5197 document.suppressed(),
5198 document.rollback(),
5199 RecomputeScope::Full,
5200 );
5201 assert!(
5202 super::compute_whats_wrong(&model, &document).is_empty(),
5203 "a clean rebuild has nothing wrong",
5204 );
5205 let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else {
5206 panic!("the extrude resolves to a feature id");
5207 };
5208 let Ok(()) = document.rename_extrude(extrude, "Boss") else {
5209 panic!("rename accepts");
5210 };
5211 assert_eq!(super::feature_label_text(&document, feature), "Boss");
5212 }
5213
5214 fn layout_rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect {
5215 LayoutRect::new(
5216 LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)),
5217 LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)),
5218 )
5219 }
5220
5221 #[test]
5222 fn solid_viewport_region_offsets_inside_the_surface() {
5223 let surface = ViewportExtent::new(ViewportPx::new(1280), ViewportPx::new(800));
5224 let Some(region) =
5225 super::solid_viewport_region(layout_rect(320.0, 96.0, 800.0, 600.0), surface)
5226 else {
5227 panic!("an inset viewport yields a region");
5228 };
5229 assert_eq!(
5230 region.scissor(),
5231 (320, 96, 800, 600),
5232 "the region carries the viewport offset and size, not the whole window",
5233 );
5234 }
5235
5236 #[test]
5237 fn solid_viewport_region_clamps_to_the_surface() {
5238 let surface = ViewportExtent::new(ViewportPx::new(640), ViewportPx::new(480));
5239 let Some(region) =
5240 super::solid_viewport_region(layout_rect(600.0, 400.0, 400.0, 400.0), surface)
5241 else {
5242 panic!("a partly off-surface viewport still yields a clamped region");
5243 };
5244 let (x, y, w, h) = region.scissor();
5245 assert!(
5246 x + w <= 640 && y + h <= 480,
5247 "the scissor never runs past the surface: {x}+{w}, {y}+{h}",
5248 );
5249 }
5250
5251 #[test]
5252 fn solid_viewport_region_is_none_for_a_degenerate_viewport() {
5253 let surface = ViewportExtent::new(ViewportPx::new(1280), ViewportPx::new(800));
5254 assert!(super::solid_viewport_region(layout_rect(0.0, 0.0, 0.0, 0.0), surface).is_none());
5255 }
5256
5257 #[test]
5258 fn defer_rebuild_holds_only_for_a_costly_later_build() {
5259 let cheap = RebuildCost::new(10);
5260 let costly = RebuildCost::new(10_000);
5261 let budget = RebuildBudget::new(100);
5262 assert!(
5263 !super::defer_rebuild(true, costly, budget),
5264 "the first build always renders, however heavy",
5265 );
5266 assert!(
5267 super::defer_rebuild(false, costly, budget),
5268 "a heavy later rebuild defers behind the rebuild-needed badge",
5269 );
5270 assert!(
5271 !super::defer_rebuild(false, cheap, budget),
5272 "a cheap later rebuild stays eager and never raises the badge",
5273 );
5274 }
5275
5276 #[test]
5277 fn whats_wrong_classifies_failures_and_withholds_reattach_from_downstream() {
5278 let Some(upstream) =
5279 super::whats_wrong_kind(RebuildStatus::Error(RebuildError::UpstreamUnresolved))
5280 else {
5281 panic!("an upstream failure is reported");
5282 };
5283 assert_eq!(upstream.message, strings::WHATS_WRONG_UPSTREAM);
5284 assert!(upstream.is_error);
5285 assert!(
5286 !upstream.offers_reattach,
5287 "a downstream feature blames the upstream, it never offers its own reattach",
5288 );
5289
5290 let Some(non_planar) =
5291 super::whats_wrong_kind(RebuildStatus::Error(RebuildError::NonPlanarSketchTarget))
5292 else {
5293 panic!("a non-planar target is reported");
5294 };
5295 assert!(non_planar.is_error && !non_planar.offers_reattach);
5296
5297 assert!(super::whats_wrong_kind(RebuildStatus::UpToDate).is_none());
5298 assert!(super::whats_wrong_kind(RebuildStatus::NeedsRebuild).is_none());
5299 }
5300
5301 #[test]
5302 fn viewport_local_point_centers_the_inset_viewport_not_the_window() {
5303 let region = ViewportRegion::new(
5304 ViewportPx::new(282),
5305 ViewportPx::new(120),
5306 ViewportExtent::new(ViewportPx::new(998), ViewportPx::new(636)),
5307 );
5308 let center = WindowPoint::new(282.0 + 499.0, 120.0 + 318.0);
5309 let Some(local) = super::viewport_local_point(center, region) else {
5310 panic!("a cursor inside the surface yields a viewport-local point");
5311 };
5312 assert!(
5313 (local.x() - 499.0).abs() < 1e-9 && (local.y() - 318.0).abs() < 1e-9,
5314 "a cursor at the inset viewport center maps to the region-local center: ({}, {})",
5315 local.x(),
5316 local.y(),
5317 );
5318 }
5319
5320 #[test]
5321 fn drag_gesture_maps_modifiers_to_orbit_pan_zoom_roll() {
5322 assert_eq!(super::drag_gesture(ModifierMask::NONE), NavGesture::Orbit);
5323 assert_eq!(super::drag_gesture(ModifierMask::CTRL), NavGesture::Pan);
5324 assert_eq!(super::drag_gesture(ModifierMask::SHIFT), NavGesture::Zoom);
5325 assert_eq!(super::drag_gesture(ModifierMask::ALT), NavGesture::Roll);
5326 assert_eq!(
5327 super::drag_gesture(ModifierMask::CTRL | ModifierMask::SHIFT),
5328 NavGesture::Pan,
5329 "ctrl outranks shift so a held ctrl always pans",
5330 );
5331 }
5332
5333 #[test]
5334 fn plane_pick_from_idle_enters_sketch_for_known_plane() {
5335 let frame = shell::ShellFrame {
5336 plane_picked: Some(Plane::Xy),
5337 ..empty_frame()
5338 };
5339 let next = next_mode(Mode::Idle, &frame, false, &xy_only());
5340 assert_eq!(next, Mode::enter_sketch(SketchId::default()));
5341 }
5342
5343 #[test]
5344 fn plane_pick_from_idle_with_no_sketch_for_plane_stays_idle() {
5345 let frame = shell::ShellFrame {
5346 plane_picked: Some(Plane::Yz),
5347 ..empty_frame()
5348 };
5349 let next = next_mode(Mode::Idle, &frame, false, &xy_only());
5350 assert_eq!(next, Mode::Idle);
5351 }
5352
5353 #[test]
5354 fn plane_pick_while_in_sketch_keeps_current_mode() {
5355 let prev = Mode::enter_sketch(SketchId::default());
5356 let frame = shell::ShellFrame {
5357 plane_picked: Some(Plane::Xy),
5358 ..empty_frame()
5359 };
5360 assert_eq!(next_mode(prev.clone(), &frame, false, &xy_only()), prev);
5361 }
5362
5363 #[test]
5364 fn ribbon_exit_returns_idle() {
5365 let prev = Mode::enter_sketch(SketchId::default());
5366 let frame = shell::ShellFrame {
5367 exit_sketch: true,
5368 ..empty_frame()
5369 };
5370 assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle);
5371 }
5372
5373 #[test]
5374 fn exit_sketch_action_returns_idle() {
5375 let prev = Mode::enter_sketch(SketchId::default());
5376 assert_eq!(
5377 next_mode(prev, &empty_frame(), true, &xy_only()),
5378 Mode::Idle
5379 );
5380 }
5381
5382 #[test]
5383 fn escape_with_pending_clears_pending_keeps_sketch_and_tool() {
5384 let prev = Mode::Sketch {
5385 sketch_id: SketchId::default(),
5386 session: Box::new(SketchSession {
5387 tool: Some(SketchTool::Line),
5388 pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm(
5389 1.0, 2.0,
5390 )))),
5391 ..SketchSession::default()
5392 }),
5393 };
5394 let next = next_mode(prev, &empty_frame(), true, &xy_only());
5395 let Mode::Sketch { session, .. } = next else {
5396 panic!("escape with pending must keep sketch mode");
5397 };
5398 assert_eq!(session.tool, Some(SketchTool::Line));
5399 assert_eq!(session.pending, None);
5400 }
5401
5402 fn far_camera() -> Camera2 {
5403 Camera2::new(ViewportExtent::new(
5404 ViewportPx::new(800),
5405 ViewportPx::new(600),
5406 ))
5407 .with_zoom(PixelsPerMm::new(1_000_000.0))
5408 }
5409
5410 #[test]
5411 fn build_preview_in_idle_is_empty() {
5412 let document = Document::new(DocumentId::default(), "doc".to_owned());
5413 let preview = build_preview(
5414 &Mode::Idle,
5415 &document,
5416 Some(Point2::from_mm(1.0, 1.0)),
5417 &far_camera(),
5418 );
5419 assert!(preview.is_empty());
5420 }
5421
5422 #[test]
5423 fn build_preview_without_armed_tool_is_empty() {
5424 let document = Document::new(DocumentId::default(), "doc".to_owned());
5425 let mode = Mode::enter_sketch(SketchId::default());
5426 let preview = build_preview(
5427 &mode,
5428 &document,
5429 Some(Point2::from_mm(0.0, 0.0)),
5430 &far_camera(),
5431 );
5432 assert!(preview.is_empty());
5433 }
5434
5435 #[test]
5436 fn build_preview_with_position_pending_emits_anchor_and_segment() {
5437 let sketch = Sketch::new(Plane::Xy.basis());
5438 let (document, sketch_id) = initial_document(sketch);
5439 let anchor = Point2::from_mm(2.0, 3.0);
5440 let mode = Mode::Sketch {
5441 sketch_id,
5442 session: Box::new(SketchSession {
5443 tool: Some(SketchTool::Line),
5444 pending: Some(Pending::First(ClickAnchor::Position(anchor))),
5445 ..SketchSession::default()
5446 }),
5447 };
5448 let cursor = Point2::from_mm(5.0, 7.0);
5449 let preview = build_preview(&mode, &document, Some(cursor), &far_camera());
5450 assert_eq!(preview.anchors, vec![anchor]);
5451 assert_eq!(preview.segments, vec![(anchor, cursor)]);
5452 }
5453
5454 #[test]
5455 fn build_preview_with_endpoint_pending_resolves_via_document() {
5456 let sketch = Sketch::new(Plane::Xy.basis());
5457 let target = Point2::from_mm(-4.0, 6.0);
5458 let (sketch, endpoint) = tools::add_point(sketch, target);
5459 let (document, sketch_id) = initial_document(sketch);
5460 let mode = Mode::Sketch {
5461 sketch_id,
5462 session: Box::new(SketchSession {
5463 tool: Some(SketchTool::Line),
5464 pending: Some(Pending::First(ClickAnchor::Endpoint(endpoint))),
5465 ..SketchSession::default()
5466 }),
5467 };
5468 let cursor = Point2::from_mm(0.0, 0.0);
5469 let preview = build_preview(&mode, &document, Some(cursor), &far_camera());
5470 assert_eq!(preview.anchors, vec![target]);
5471 assert_eq!(preview.segments, vec![(target, cursor)]);
5472 }
5473
5474 #[test]
5475 fn build_preview_keeps_anchor_when_cursor_outside_viewport() {
5476 let sketch = Sketch::new(Plane::Xy.basis());
5477 let (document, sketch_id) = initial_document(sketch);
5478 let anchor = Point2::from_mm(1.0, 1.0);
5479 let mode = Mode::Sketch {
5480 sketch_id,
5481 session: Box::new(SketchSession {
5482 tool: Some(SketchTool::Line),
5483 pending: Some(Pending::First(ClickAnchor::Position(anchor))),
5484 ..SketchSession::default()
5485 }),
5486 };
5487 let preview = build_preview(&mode, &document, None, &far_camera());
5488 assert_eq!(preview.anchors, vec![anchor]);
5489 assert!(preview.segments.is_empty());
5490 }
5491
5492 #[test]
5493 fn build_preview_during_drag_is_empty() {
5494 let document = Document::new(DocumentId::default(), "doc".to_owned());
5495 let mode = Mode::Sketch {
5496 sketch_id: SketchId::default(),
5497 session: Box::new(SketchSession {
5498 tool: Some(SketchTool::Line),
5499 pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm(
5500 0.0, 0.0,
5501 )))),
5502 drag: Some(DragSession {
5503 entity: bone_types::SketchEntityId::default(),
5504 press: Point2::origin(),
5505 pins: DragPins::from_array([
5506 Some((bone_types::SketchEntityId::default(), Point2::origin())),
5507 None,
5508 None,
5509 ]),
5510 }),
5511 ..SketchSession::default()
5512 }),
5513 };
5514 let preview = build_preview(
5515 &mode,
5516 &document,
5517 Some(Point2::from_mm(1.0, 1.0)),
5518 &far_camera(),
5519 );
5520 assert!(preview.is_empty());
5521 }
5522
5523 #[test]
5524 fn build_preview_circle_emits_ghost_circle() {
5525 let sketch = Sketch::new(Plane::Xy.basis());
5526 let (document, sketch_id) = initial_document(sketch);
5527 let center = Point2::from_mm(0.0, 0.0);
5528 let mode = Mode::Sketch {
5529 sketch_id,
5530 session: Box::new(SketchSession {
5531 tool: Some(SketchTool::Circle),
5532 pending: Some(Pending::First(ClickAnchor::Position(center))),
5533 ..SketchSession::default()
5534 }),
5535 };
5536 let cursor = Point2::from_mm(3.0, 4.0);
5537 let preview = build_preview(&mode, &document, Some(cursor), &far_camera());
5538 assert_eq!(preview.anchors, vec![center]);
5539 assert_eq!(preview.circles.len(), 1);
5540 let r = preview.circles[0].radius.get::<millimeter>();
5541 assert!((r - 5.0).abs() < 1e-9, "r={r}");
5542 }
5543
5544 #[test]
5545 fn build_preview_corner_rectangle_emits_four_segments() {
5546 let sketch = Sketch::new(Plane::Xy.basis());
5547 let (document, sketch_id) = initial_document(sketch);
5548 let corner = Point2::from_mm(0.0, 0.0);
5549 let mode = Mode::Sketch {
5550 sketch_id,
5551 session: Box::new(SketchSession {
5552 tool: Some(SketchTool::CornerRectangle),
5553 pending: Some(Pending::First(ClickAnchor::Position(corner))),
5554 ..SketchSession::default()
5555 }),
5556 };
5557 let cursor = Point2::from_mm(5.0, 3.0);
5558 let preview = build_preview(&mode, &document, Some(cursor), &far_camera());
5559 assert_eq!(preview.anchors, vec![corner]);
5560 assert_eq!(preview.segments.len(), 4);
5561 }
5562
5563 #[test]
5564 fn build_preview_tangent_arc_emits_ghost_arc_after_endpoint_click() {
5565 let sketch = Sketch::new(Plane::Xy.basis());
5566 let (sketch, a) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0));
5567 let (sketch, b) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0));
5568 let (sketch, _) = tools::add_line(sketch, a, b, false);
5569 let (document, sketch_id) = initial_document(sketch);
5570 let mode = Mode::Sketch {
5571 sketch_id,
5572 session: Box::new(SketchSession {
5573 tool: Some(SketchTool::TangentArc),
5574 pending: Some(Pending::First(ClickAnchor::Endpoint(b))),
5575 ..SketchSession::default()
5576 }),
5577 };
5578 let cursor = Point2::from_mm(10.0, 6.0);
5579 let preview = build_preview(&mode, &document, Some(cursor), &far_camera());
5580 assert_eq!(preview.anchors.len(), 1, "start anchor visible");
5581 assert_eq!(preview.arcs.len(), 1, "ghost arc emitted");
5582 }
5583
5584 #[test]
5585 fn build_preview_centerpoint_arc_emits_ghost_arc_after_two_clicks() {
5586 let sketch = Sketch::new(Plane::Xy.basis());
5587 let (document, sketch_id) = initial_document(sketch);
5588 let center = Point2::from_mm(0.0, 0.0);
5589 let start = Point2::from_mm(5.0, 0.0);
5590 let mode = Mode::Sketch {
5591 sketch_id,
5592 session: Box::new(SketchSession {
5593 tool: Some(SketchTool::CenterpointArc),
5594 pending: Some(Pending::Second(
5595 ClickAnchor::Position(center),
5596 ClickAnchor::Position(start),
5597 )),
5598 ..SketchSession::default()
5599 }),
5600 };
5601 let cursor = Point2::from_mm(0.0, 5.0);
5602 let preview = build_preview(&mode, &document, Some(cursor), &far_camera());
5603 assert_eq!(preview.arcs.len(), 1);
5604 assert_eq!(preview.anchors.len(), 2);
5605 }
5606
5607 #[test]
5608 fn escape_with_armed_tool_no_pending_disarms_tool() {
5609 let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line);
5610 let next = next_mode(prev, &empty_frame(), true, &xy_only());
5611 let Mode::Sketch { session, .. } = next else {
5612 panic!("escape with armed tool must keep sketch mode");
5613 };
5614 assert_eq!(session.tool, None);
5615 assert_eq!(session.pending, None);
5616 }
5617
5618 #[test]
5619 fn clicking_active_tool_disarms_it() {
5620 let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line);
5621 let frame = shell::ShellFrame {
5622 activated_tool: Some(SketchTool::Line),
5623 ..empty_frame()
5624 };
5625 let next = next_mode(prev, &frame, false, &xy_only());
5626 let Mode::Sketch { session, .. } = next else {
5627 panic!("expected sketch mode");
5628 };
5629 assert_eq!(session.tool, None);
5630 }
5631
5632 #[test]
5633 fn clicking_different_tool_swaps() {
5634 let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line);
5635 let frame = shell::ShellFrame {
5636 activated_tool: Some(SketchTool::Point),
5637 ..empty_frame()
5638 };
5639 let next = next_mode(prev, &frame, false, &xy_only());
5640 let Mode::Sketch { session, .. } = next else {
5641 panic!("expected sketch mode");
5642 };
5643 assert_eq!(session.tool, Some(SketchTool::Point));
5644 }
5645
5646 #[test]
5647 fn ribbon_exit_overrides_pending_chain() {
5648 let prev = Mode::Sketch {
5649 sketch_id: SketchId::default(),
5650 session: Box::new(SketchSession {
5651 tool: Some(SketchTool::Line),
5652 pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm(
5653 0.0, 0.0,
5654 )))),
5655 ..SketchSession::default()
5656 }),
5657 };
5658 let frame = shell::ShellFrame {
5659 exit_sketch: true,
5660 ..empty_frame()
5661 };
5662 assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle);
5663 }
5664
5665 #[test]
5666 fn tool_in_idle_does_not_promote_to_sketch() {
5667 let frame = shell::ShellFrame {
5668 activated_tool: Some(SketchTool::Line),
5669 ..empty_frame()
5670 };
5671 assert_eq!(next_mode(Mode::Idle, &frame, false, &xy_only()), Mode::Idle);
5672 }
5673
5674 #[test]
5675 fn tool_in_sketch_arms_session() {
5676 let prev = Mode::enter_sketch(SketchId::default());
5677 let frame = shell::ShellFrame {
5678 activated_tool: Some(SketchTool::Line),
5679 ..empty_frame()
5680 };
5681 let Mode::Sketch { session, .. } = next_mode(prev, &frame, false, &xy_only()) else {
5682 panic!("expected sketch mode");
5683 };
5684 assert_eq!(session.tool, Some(SketchTool::Line));
5685 }
5686
5687 #[test]
5688 fn plane_pick_then_tool_enters_and_arms_in_one_frame() {
5689 let frame = shell::ShellFrame {
5690 plane_picked: Some(Plane::Xy),
5691 activated_tool: Some(SketchTool::Line),
5692 ..empty_frame()
5693 };
5694 let Mode::Sketch { session, .. } = next_mode(Mode::Idle, &frame, false, &xy_only()) else {
5695 panic!("expected sketch mode");
5696 };
5697 assert_eq!(session.tool, Some(SketchTool::Line));
5698 }
5699
5700 fn doc_with_default_sketch() -> (Document, SketchId) {
5701 let sketch = bone_document::Sketch::new(Plane::Xy.basis());
5702 let mut document = Document::new(DocumentId::default(), "Untitled".to_owned());
5703 let id = SketchId::default();
5704 document.insert_sketch(id, "Sketch1".to_owned(), sketch);
5705 (document, id)
5706 }
5707
5708 #[test]
5709 fn apply_sketch_rename_into_writes_label_and_records_undo_on_change() {
5710 let (mut document, id) = doc_with_default_sketch();
5711 let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
5712 apply_sketch_rename_into(
5713 &mut document,
5714 &mut undo,
5715 shell::SketchRenameRequest {
5716 id,
5717 label: "Profile".to_owned(),
5718 },
5719 );
5720 assert_eq!(document.sketch_label(id), Some("Profile"));
5721 assert_eq!(
5722 undo.past_len(),
5723 1,
5724 "successful rename records one undo snapshot"
5725 );
5726 }
5727
5728 #[test]
5729 fn apply_sketch_rename_into_drops_empty_label_without_undo() {
5730 let (mut document, id) = doc_with_default_sketch();
5731 let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
5732 apply_sketch_rename_into(
5733 &mut document,
5734 &mut undo,
5735 shell::SketchRenameRequest {
5736 id,
5737 label: " ".to_owned(),
5738 },
5739 );
5740 assert_eq!(document.sketch_label(id), Some("Sketch1"));
5741 assert_eq!(undo.past_len(), 0);
5742 }
5743
5744 #[test]
5745 fn apply_sketch_rename_into_skips_no_op_against_trimmed_match() {
5746 let (mut document, id) = doc_with_default_sketch();
5747 let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
5748 apply_sketch_rename_into(
5749 &mut document,
5750 &mut undo,
5751 shell::SketchRenameRequest {
5752 id,
5753 label: " Sketch1 ".to_owned(),
5754 },
5755 );
5756 assert_eq!(document.sketch_label(id), Some("Sketch1"));
5757 assert_eq!(
5758 undo.past_len(),
5759 0,
5760 "trimmed-equal rename must not record undo"
5761 );
5762 }
5763
5764 fn extrude_node_count(document: &Document) -> usize {
5765 document
5766 .feature_tree()
5767 .iter()
5768 .filter(|(_, node)| matches!(node, FeatureNode::Extrude(_)))
5769 .count()
5770 }
5771
5772 #[test]
5773 fn commit_armed_extrude_on_accept_adds_node_and_records_undo() {
5774 let (mut document, sketch) = doc_with_default_sketch();
5775 let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
5776 let mode = Mode::Extrude(ExtrudeArming::profile(sketch));
5777 commit_armed_extrude(
5778 &mut document,
5779 &mut undo,
5780 &mode,
5781 Some(shell::ConfirmAction::Accept),
5782 );
5783 assert_eq!(extrude_node_count(&document), 1);
5784 assert_eq!(undo.past_len(), 1);
5785 }
5786
5787 #[test]
5788 fn commit_armed_extrude_ignores_cancel_and_non_extrude_mode() {
5789 let (mut document, sketch) = doc_with_default_sketch();
5790 let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
5791 commit_armed_extrude(
5792 &mut document,
5793 &mut undo,
5794 &Mode::Extrude(ExtrudeArming::profile(sketch)),
5795 Some(shell::ConfirmAction::Cancel),
5796 );
5797 commit_armed_extrude(
5798 &mut document,
5799 &mut undo,
5800 &Mode::Idle,
5801 Some(shell::ConfirmAction::Accept),
5802 );
5803 assert_eq!(extrude_node_count(&document), 0);
5804 assert_eq!(undo.past_len(), 0);
5805 }
5806
5807 #[test]
5808 fn commit_armed_extrude_edit_target_updates_in_place_keeping_label() {
5809 let (mut document, sketch) = doc_with_default_sketch();
5810 let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch));
5811 let Ok(()) = document.rename_extrude(id, "Boss") else {
5812 panic!("rename accepts");
5813 };
5814 let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
5815 let mode = Mode::Extrude(ExtrudeArming::edit(
5816 id,
5817 sketch_mode::default_extrude_feature(sketch),
5818 ));
5819 commit_armed_extrude(
5820 &mut document,
5821 &mut undo,
5822 &mode,
5823 Some(shell::ConfirmAction::Accept),
5824 );
5825 assert_eq!(extrude_node_count(&document), 1, "editing reuses the node");
5826 assert_eq!(document.extrude_label(id), Some("Boss"));
5827 }
5828
5829 #[test]
5830 fn apply_feature_command_suppresses_and_records_undo() {
5831 let (mut document, sketch) = doc_with_default_sketch();
5832 let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch));
5833 let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else {
5834 panic!("the committed extrude has a feature id");
5835 };
5836 let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
5837 let changed = apply_feature_command_into(
5838 &mut document,
5839 &mut undo,
5840 shell::FeatureCommand::Suppress(feature),
5841 );
5842 assert!(changed);
5843 assert!(document.suppression_state(feature).is_suppressed());
5844 assert_eq!(
5845 undo.past_len(),
5846 1,
5847 "a suppression records one undo snapshot"
5848 );
5849 }
5850
5851 #[test]
5852 fn apply_feature_command_deletes_extrude_and_records_undo() {
5853 let (mut document, sketch) = doc_with_default_sketch();
5854 let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch));
5855 let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
5856 let changed = apply_feature_command_into(
5857 &mut document,
5858 &mut undo,
5859 shell::FeatureCommand::Delete(shell::FeatureTarget::Extrude(extrude)),
5860 );
5861 assert!(changed);
5862 assert_eq!(document.extrude(extrude), None);
5863 assert_eq!(undo.past_len(), 1);
5864 }
5865
5866 #[test]
5867 fn apply_feature_command_rolls_back_to_feature() {
5868 let (mut document, sketch) = doc_with_default_sketch();
5869 let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch));
5870 let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else {
5871 panic!("the committed extrude has a feature id");
5872 };
5873 let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
5874 apply_feature_command_into(
5875 &mut document,
5876 &mut undo,
5877 shell::FeatureCommand::RollbackToHere(feature),
5878 );
5879 assert!(document.is_rolled_back(feature));
5880 }
5881
5882 #[test]
5883 fn extrude_edit_mode_arms_from_idle_but_not_from_sketch() {
5884 let (mut document, sketch) = doc_with_default_sketch();
5885 let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch));
5886 let from_idle = extrude_edit_mode(&document, &Mode::Idle, Some(id));
5887 assert!(matches!(
5888 from_idle,
5889 Some(Mode::Extrude(ExtrudeArming::Profile { target: Some(t), .. })) if t == id
5890 ));
5891 assert_eq!(
5892 extrude_edit_mode(&document, &Mode::enter_sketch(sketch), Some(id)),
5893 None,
5894 "double-click is inert while sketching",
5895 );
5896 assert_eq!(
5897 extrude_edit_mode(&document, &Mode::Idle, Some(ExtrudeId::default())),
5898 None,
5899 "unknown extrude id arms nothing",
5900 );
5901 }
5902
5903 #[test]
5904 fn active_solid_feature_tracks_mode_then_falls_back_to_committed() {
5905 let (mut document, sketch) = doc_with_default_sketch();
5906 let armed = sketch_mode::default_extrude_feature(sketch);
5907 assert_eq!(
5908 active_solid_feature(
5909 &Mode::Extrude(ExtrudeArming::profile(sketch)),
5910 &document,
5911 None,
5912 ),
5913 Some(armed),
5914 "an armed profile previews its own feature",
5915 );
5916 assert_eq!(
5917 active_solid_feature(&Mode::enter_sketch(sketch), &document, None),
5918 None,
5919 "sketching shows the 2D scene, not a solid",
5920 );
5921 assert_eq!(
5922 active_solid_feature(&Mode::Idle, &document, None),
5923 None,
5924 "idle with no committed extrude shows no solid",
5925 );
5926 let _ = document.commit_extrude(armed);
5927 assert_eq!(
5928 active_solid_feature(&Mode::Idle, &document, None),
5929 Some(armed),
5930 "idle falls back to the committed extrude",
5931 );
5932 }
5933
5934 #[test]
5935 fn framed_extrude_overrides_last_committed_in_idle() {
5936 let (mut document, sketch) = doc_with_default_sketch();
5937 let first_feature = sketch_mode::default_extrude_feature(sketch);
5938 let mut second_feature = first_feature;
5939 second_feature.merge_result = bone_document::MergeResult::Separate;
5940 let first = document.commit_extrude(first_feature);
5941 let _second = document.commit_extrude(second_feature);
5942 assert_eq!(
5943 active_solid_feature(&Mode::Idle, &document, None),
5944 Some(second_feature),
5945 "with no framed id, idle frames the last-committed extrude",
5946 );
5947 assert_eq!(
5948 active_solid_feature(&Mode::Idle, &document, Some(first)),
5949 Some(first_feature),
5950 "a framed id wins over the tree tip, so editing a non-last extrude stays framed",
5951 );
5952 assert_eq!(
5953 active_solid_feature(&Mode::Idle, &document, Some(ExtrudeId::default())),
5954 Some(second_feature),
5955 "a stale framed id self-heals to the last-committed extrude",
5956 );
5957 }
5958
5959 #[test]
5960 fn apply_extrude_rename_into_writes_label_and_records_undo() {
5961 let (mut document, sketch) = doc_with_default_sketch();
5962 let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch));
5963 let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
5964 apply_extrude_rename_into(
5965 &mut document,
5966 &mut undo,
5967 shell::ExtrudeRenameRequest {
5968 id,
5969 label: "Boss".to_owned(),
5970 },
5971 );
5972 assert_eq!(document.extrude_label(id), Some("Boss"));
5973 assert_eq!(undo.past_len(), 1);
5974 }
5975
5976 #[test]
5977 fn sketch_activated_from_idle_enters_that_sketch_without_plane_map() {
5978 let sketch_id = SketchId::default();
5979 let frame = shell::ShellFrame {
5980 sketch_activated: Some(sketch_id),
5981 ..empty_frame()
5982 };
5983 let next = next_mode(Mode::Idle, &frame, false, &BTreeMap::new());
5984 assert_eq!(next, Mode::enter_sketch(sketch_id));
5985 }
5986
5987 #[test]
5988 fn sketch_pick_in_extrude_sets_profile_instead_of_editing() {
5989 let sketch_id = SketchId::default();
5990 let frame = shell::ShellFrame {
5991 sketch_activated: Some(sketch_id),
5992 ..empty_frame()
5993 };
5994 assert_eq!(
5995 next_mode(
5996 Mode::Extrude(ExtrudeArming::AwaitingSketch),
5997 &frame,
5998 false,
5999 &xy_only(),
6000 ),
6001 Mode::Extrude(ExtrudeArming::profile(sketch_id)),
6002 );
6003 assert_eq!(
6004 next_mode(
6005 Mode::Extrude(ExtrudeArming::profile(sketch_id)),
6006 &frame,
6007 false,
6008 &xy_only(),
6009 ),
6010 Mode::Extrude(ExtrudeArming::profile(sketch_id)),
6011 "a pick while armed re-targets the profile, never drops into sketch editing",
6012 );
6013 }
6014
6015 #[test]
6016 fn sketch_activated_while_in_sketch_is_ignored() {
6017 let prev = Mode::enter_sketch(SketchId::default());
6018 let frame = shell::ShellFrame {
6019 sketch_activated: Some(SketchId::default()),
6020 ..empty_frame()
6021 };
6022 assert_eq!(next_mode(prev.clone(), &frame, false, &xy_only()), prev);
6023 }
6024
6025 #[test]
6026 fn exit_action_wins_over_pending_tool() {
6027 let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line);
6028 let frame = shell::ShellFrame {
6029 exit_sketch: true,
6030 ..empty_frame()
6031 };
6032 assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle);
6033 }
6034
6035 #[test]
6036 fn idle_scopes_omit_sketch_scope() {
6037 let scopes = scopes_for_mode(&Mode::Idle);
6038 let collected: Vec<_> = scopes.innermost_first().copied().collect();
6039 assert!(!collected.contains(&HotkeyScope::Sketch));
6040 assert!(collected.contains(&HotkeyScope::Global));
6041 }
6042
6043 #[test]
6044 fn sketch_scopes_include_sketch_scope() {
6045 let scopes = scopes_for_mode(&Mode::enter_sketch(SketchId::default()));
6046 let collected: Vec<_> = scopes.innermost_first().copied().collect();
6047 assert!(collected.contains(&HotkeyScope::Sketch));
6048 assert!(collected.contains(&HotkeyScope::Global));
6049 }
6050
6051 #[test]
6052 fn hotkey_table_binds_escape_to_exit_under_sketch_scope() {
6053 let table = build_hotkey_table();
6054 let chord = KeyChord::new(UiKeyCode::Named(NamedKey::Escape), ModifierMask::NONE);
6055 let in_sketch = scopes_for_mode(&Mode::enter_sketch(SketchId::default()));
6056 assert_eq!(
6057 table.dispatch(chord, &in_sketch),
6058 Some(sketch_mode::ESCAPE_ACTION)
6059 );
6060 let in_idle = scopes_for_mode(&Mode::Idle);
6061 assert_eq!(table.dispatch(chord, &in_idle), None);
6062 }
6063
6064 #[test]
6065 fn extrude_scope_isolates_escape_from_sketch_tools() {
6066 let extrude = scopes_for_mode(&Mode::Extrude(ExtrudeArming::AwaitingSketch));
6067 let in_extrude: Vec<_> = extrude.innermost_first().copied().collect();
6068 assert!(in_extrude.contains(&HotkeyScope::Extrude));
6069 assert!(
6070 !in_extrude.contains(&HotkeyScope::Sketch),
6071 "extrude must not activate the sketch-tool scope",
6072 );
6073 let sketch = scopes_for_mode(&Mode::enter_sketch(SketchId::default()));
6074 let in_sketch: Vec<_> = sketch.innermost_first().copied().collect();
6075 assert!(!in_sketch.contains(&HotkeyScope::Extrude));
6076 }
6077
6078 #[test]
6079 fn hotkey_table_binds_escape_under_extrude_scope() {
6080 let table = build_hotkey_table();
6081 let chord = KeyChord::new(UiKeyCode::Named(NamedKey::Escape), ModifierMask::NONE);
6082 let in_extrude = scopes_for_mode(&Mode::Extrude(ExtrudeArming::AwaitingSketch));
6083 assert_eq!(
6084 table.dispatch(chord, &in_extrude),
6085 Some(sketch_mode::ESCAPE_ACTION)
6086 );
6087 }
6088
6089 #[test]
6090 fn escape_exits_extrude_to_idle() {
6091 let frame = empty_frame();
6092 let awaiting = Mode::Extrude(ExtrudeArming::AwaitingSketch);
6093 assert_eq!(next_mode(awaiting, &frame, true, &xy_only()), Mode::Idle);
6094 let profiled = Mode::Extrude(ExtrudeArming::profile(SketchId::default()));
6095 assert_eq!(next_mode(profiled, &frame, true, &xy_only()), Mode::Idle);
6096 }
6097
6098 #[test]
6099 fn cancel_pending_or_exit_drops_extrude_arming() {
6100 assert_eq!(
6101 cancel_pending_or_exit(Mode::Extrude(ExtrudeArming::AwaitingSketch)),
6102 Mode::Idle
6103 );
6104 }
6105
6106 #[test]
6107 fn driven_with_value_promotes_kind_and_overwrites_value() {
6108 let proto = SketchDimension::Linear {
6109 a: bone_types::SketchEntityId::default(),
6110 b: bone_types::SketchEntityId::default(),
6111 value: Length::new::<millimeter>(8.0),
6112 kind: DimensionKind::Driving,
6113 };
6114 let measured = DimensionValue::Length(Length::new::<millimeter>(10.0));
6115 let Some(driven) = driven_with_value(proto, measured) else {
6116 panic!("driven_with_value rejected matched-kind value");
6117 };
6118 assert_eq!(driven.kind(), DimensionKind::Driven);
6119 let DimensionValue::Length(length) = driven.value() else {
6120 panic!("expected Length");
6121 };
6122 assert!((length.get::<millimeter>() - 10.0).abs() < 1e-9);
6123 }
6124
6125 #[test]
6126 fn driven_with_value_rejects_kind_mismatch() {
6127 let proto = SketchDimension::Linear {
6128 a: bone_types::SketchEntityId::default(),
6129 b: bone_types::SketchEntityId::default(),
6130 value: Length::new::<millimeter>(1.0),
6131 kind: DimensionKind::Driving,
6132 };
6133 let bad = DimensionValue::Angle(bone_types::Angle::new::<uom::si::angle::radian>(1.0));
6134 assert!(driven_with_value(proto, bad).is_none());
6135 }
6136
6137 #[test]
6138 fn dim_conflict_pending_returns_proto_when_set() {
6139 let proto = SketchDimension::Linear {
6140 a: bone_types::SketchEntityId::default(),
6141 b: bone_types::SketchEntityId::default(),
6142 value: Length::new::<millimeter>(2.0),
6143 kind: DimensionKind::Driving,
6144 };
6145 let pending = PendingDimension {
6146 proto,
6147 anchor: Point2::origin(),
6148 };
6149 let mode = Mode::enter_sketch(SketchId::default()).start_dim_conflict(pending);
6150 assert_eq!(dim_conflict_pending(&mode), Some(pending));
6151 }
6152
6153 #[test]
6154 fn dim_conflict_pending_returns_none_in_idle() {
6155 assert_eq!(dim_conflict_pending(&Mode::Idle), None);
6156 }
6157
6158 fn horizontal_line_fixture() -> (
6159 Sketch,
6160 bone_types::SketchEntityId,
6161 bone_types::SketchEntityId,
6162 bone_types::SketchEntityId,
6163 ) {
6164 let sketch = Sketch::new(Plane::Xy.basis());
6165 let (sketch, a) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0));
6166 let (sketch, b) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0));
6167 let (sketch, line) = tools::add_line(sketch, a, b, false);
6168 let sketch = tools::add_relation(sketch, SketchRelation::Horizontal(line));
6169 let sketch = tools::add_relation(sketch, SketchRelation::Fix(a));
6170 (sketch, a, b, line)
6171 }
6172
6173 fn point_at(sketch: &Sketch, id: bone_types::SketchEntityId) -> Point2 {
6174 let SketchEntity::Point(p) = sketch.entities()[id] else {
6175 panic!("expected point entity");
6176 };
6177 p.at()
6178 }
6179
6180 #[test]
6181 fn drag_resolved_translates_endpoint_and_preserves_horizontal() {
6182 let (sketch, a, b, _) = horizontal_line_fixture();
6183 let drag = DragSession {
6184 entity: b,
6185 press: Point2::from_mm(10.0, 0.0),
6186 pins: DragPins::from_array([Some((b, Point2::from_mm(10.0, 0.0))), None, None]),
6187 };
6188 let cursor = Point2::from_mm(13.0, 0.0);
6189 let Some(next) = drag_resolved(&sketch, drag, cursor) else {
6190 panic!("solve_with_drag_pins must converge on horizontal line")
6191 };
6192 let (bx, by) = point_at(&next, b).coords_mm();
6193 assert!((bx - 13.0).abs() < 1e-6, "b.x slides under cursor: {bx}");
6194 assert!(by.abs() < 1e-6, "horizontal preserved: by={by}");
6195 let (ax, ay) = point_at(&next, a).coords_mm();
6196 assert!(ax.abs() < 1e-9, "fixed a.x stays put: {ax}");
6197 assert!(ay.abs() < 1e-9, "fixed a.y stays put: {ay}");
6198 }
6199
6200 #[test]
6201 fn mirror_axis_reflects_across_horizontal_x_axis() {
6202 let axis = MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0));
6203 let (rx, ry) = axis.reflect(Point2::from_mm(3.0, 4.0)).coords_mm();
6204 assert!((rx - 3.0).abs() < 1e-9, "x preserved across x-axis");
6205 assert!((ry - -4.0).abs() < 1e-9, "y inverted across x-axis");
6206 }
6207
6208 #[test]
6209 fn mirror_axis_reflects_across_diagonal() {
6210 let axis = MirrorAxis::from_points(Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 1.0));
6211 let (rx, ry) = axis.reflect(Point2::from_mm(2.0, 0.0)).coords_mm();
6212 assert!((rx - 0.0).abs() < 1e-9, "x reflects to y on y=x diagonal");
6213 assert!((ry - 2.0).abs() < 1e-9, "y reflects to x on y=x diagonal");
6214 }
6215
6216 #[test]
6217 fn mirror_axis_detects_degenerate_zero_length() {
6218 let axis = MirrorAxis::from_points(Point2::from_mm(1.0, 1.0), Point2::from_mm(1.0, 1.0));
6219 assert!(
6220 axis.is_degenerate(),
6221 "coincident endpoints must be degenerate"
6222 );
6223 }
6224
6225 #[test]
6226 fn mirror_targets_creates_reflected_circle_with_symmetric_relations() {
6227 let (sketch, _, _, axis_line) = horizontal_line_fixture();
6228 let (sketch, center) = tools::add_point(sketch, Point2::from_mm(0.0, 3.0));
6229 let (sketch, circle_id) =
6230 tools::add_circle(sketch, center, Length::new::<millimeter>(1.0), false);
6231 let axis_geom =
6232 MirrorAxis::from_points(Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 0.0));
6233 let source_ids: std::collections::BTreeSet<_> = [center, circle_id].into_iter().collect();
6234 let Ok(mirrored) = mirror_targets(sketch.clone(), &source_ids, axis_line, &axis_geom)
6235 else {
6236 panic!("circle mirror must succeed");
6237 };
6238 let new_circles: Vec<_> = mirrored
6239 .entities()
6240 .iter()
6241 .filter_map(|(_, e)| match *e {
6242 SketchEntity::Circle(c) => Some(c),
6243 _ => None,
6244 })
6245 .collect();
6246 assert_eq!(new_circles.len(), 2, "original + mirrored circle");
6247 let new_center_pos = new_circles
6248 .iter()
6249 .map(|c| {
6250 let SketchEntity::Point(p) = mirrored.entities()[c.center()] else {
6251 panic!("circle center is a point");
6252 };
6253 p.at().coords_mm()
6254 })
6255 .find(|(_, y)| *y < 0.0);
6256 let Some((cx, cy)) = new_center_pos else {
6257 panic!("mirrored circle must lie below x-axis");
6258 };
6259 assert!(cx.abs() < 1e-9 && (cy + 3.0).abs() < 1e-9, "({cx}, {cy})");
6260 let symmetric_count = mirrored
6261 .relations()
6262 .iter()
6263 .filter(
6264 |(_, r)| matches!(r, SketchRelation::Symmetric { axis, .. } if *axis == axis_line),
6265 )
6266 .count();
6267 assert!(
6268 symmetric_count >= 1,
6269 "mirror must emit at least one Symmetric relation tied to the axis",
6270 );
6271 }
6272
6273 #[test]
6274 fn mirror_copies_horizontal_relation_to_mirrored_line() {
6275 use bone_document::SketchEdit;
6276 let sketch = Sketch::new(Plane::Xy.basis());
6277 let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0));
6278 let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0));
6279 let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true);
6280 let (sketch, source_p0) = tools::add_point(sketch, Point2::from_mm(-2.0, 3.0));
6281 let (sketch, source_p1) = tools::add_point(sketch, Point2::from_mm(2.0, 3.0));
6282 let (sketch, source_line) = tools::add_line(sketch, source_p0, source_p1, false);
6283 let Ok((sketch, _)) = sketch.apply(SketchEdit::AddRelation(SketchRelation::Horizontal(
6284 source_line,
6285 ))) else {
6286 panic!("seed Horizontal must apply");
6287 };
6288 let axis_geom =
6289 MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0));
6290 let source_ids: std::collections::BTreeSet<_> =
6291 [source_p0, source_p1, source_line].into_iter().collect();
6292 let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else {
6293 panic!("mirror must succeed");
6294 };
6295 let horizontal_lines: Vec<_> = mirrored
6296 .relations()
6297 .iter()
6298 .filter_map(|(_, r)| match r {
6299 SketchRelation::Horizontal(id) => Some(*id),
6300 _ => None,
6301 })
6302 .collect();
6303 assert_eq!(
6304 horizontal_lines.len(),
6305 2,
6306 "original + mirrored horizontal must both exist"
6307 );
6308 }
6309
6310 #[test]
6311 fn construction_toggle_flips_line_flag() {
6312 let (sketch, _, _, line) = horizontal_line_fixture();
6313 let before = match sketch.entities()[line] {
6314 SketchEntity::Line(l) => l.for_construction(),
6315 _ => panic!("line"),
6316 };
6317 let Ok((next, _)) = sketch.apply(SketchEdit::SetConstruction {
6318 id: line,
6319 for_construction: !before,
6320 }) else {
6321 panic!("set construction must succeed");
6322 };
6323 let after = match next.entities()[line] {
6324 SketchEntity::Line(l) => l.for_construction(),
6325 _ => panic!("line"),
6326 };
6327 assert_ne!(before, after);
6328 }
6329
6330 #[test]
6331 fn mirror_axis_detects_on_axis_point() {
6332 let axis = MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0));
6333 assert!(axis.is_on_axis(Point2::from_mm(2.0, 0.0)));
6334 assert!(axis.is_on_axis(Point2::from_mm(-5.0, 0.0)));
6335 assert!(!axis.is_on_axis(Point2::from_mm(2.0, 0.5)));
6336 }
6337
6338 #[test]
6339 fn mirror_on_axis_source_point_is_identity_no_self_symmetric() {
6340 let sketch = Sketch::new(Plane::Xy.basis());
6341 let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0));
6342 let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0));
6343 let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true);
6344 let (sketch, on_axis_point) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0));
6345 let axis_geom =
6346 MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0));
6347 let source_ids: std::collections::BTreeSet<_> = [on_axis_point].into_iter().collect();
6348 let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else {
6349 panic!("mirror must succeed");
6350 };
6351 let point_count = mirrored
6352 .entities()
6353 .iter()
6354 .filter(|(_, e)| matches!(e, SketchEntity::Point(_)))
6355 .count();
6356 assert_eq!(
6357 point_count, 3,
6358 "on-axis source must not produce a duplicate point: {point_count}"
6359 );
6360 let symmetric_count = mirrored
6361 .relations()
6362 .iter()
6363 .filter(|(_, r)| matches!(r, SketchRelation::Symmetric { .. }))
6364 .count();
6365 assert_eq!(
6366 symmetric_count, 0,
6367 "on-axis source must not emit a self-pair Symmetric relation"
6368 );
6369 }
6370
6371 #[test]
6372 fn mirror_copies_relation_referencing_axis_line() {
6373 use bone_document::SketchEdit;
6374 let sketch = Sketch::new(Plane::Xy.basis());
6375 let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0));
6376 let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0));
6377 let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true);
6378 let (sketch, off_a) = tools::add_point(sketch, Point2::from_mm(-2.0, 3.0));
6379 let (sketch, off_b) = tools::add_point(sketch, Point2::from_mm(2.0, 3.0));
6380 let (sketch, off_line) = tools::add_line(sketch, off_a, off_b, false);
6381 let Ok((sketch, _)) = sketch.apply(SketchEdit::AddRelation(SketchRelation::Parallel(
6382 off_line, axis_line,
6383 ))) else {
6384 panic!("seed Parallel(off_line, axis_line) must apply");
6385 };
6386 let axis_geom =
6387 MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0));
6388 let source_ids: std::collections::BTreeSet<_> =
6389 [off_a, off_b, off_line].into_iter().collect();
6390 let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else {
6391 panic!("mirror must succeed");
6392 };
6393 let parallel_count = mirrored
6394 .relations()
6395 .iter()
6396 .filter(|(_, r)| matches!(r, SketchRelation::Parallel(_, b) if *b == axis_line))
6397 .count();
6398 assert_eq!(
6399 parallel_count, 2,
6400 "original + mirrored Parallel(line, axis_line) both expected: {parallel_count}",
6401 );
6402 }
6403}