Another project
0

Configure Feed

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

at main 228 kB View raw
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) = &notification.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}