Another project
0

Configure Feed

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

bone-jig

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

author
Lewis
date (Jun 14, 2026, 11:04 AM +0300) commit 878bf66f parent fb199bf7 change-id stmxqvqp
+8289 -5710
+31
Cargo.lock
··· 34 34 source = "registry+https://github.com/rust-lang/crates.io-index" 35 35 checksum = "5351dcebb14b579ccab05f288596b2ae097005be7ee50a7c3d4ca9d0d5a66f6a" 36 36 dependencies = [ 37 + "enumn", 38 + "serde", 37 39 "uuid", 38 40 ] 39 41 ··· 453 455 ] 454 456 455 457 [[package]] 458 + name = "bone-jig" 459 + version = "0.0.0" 460 + dependencies = [ 461 + "accesskit", 462 + "bone-app", 463 + "bone-render", 464 + "bone-ui", 465 + "pollster", 466 + "ron", 467 + "serde", 468 + "serde_json", 469 + "tempfile", 470 + "thiserror 2.0.18", 471 + "wgpu", 472 + ] 473 + 474 + [[package]] 456 475 name = "bone-kernel" 457 476 version = "0.0.0" 458 477 dependencies = [ ··· 485 504 "png", 486 505 "pollster", 487 506 "proptest", 507 + "serde", 488 508 "slotmap", 489 509 "swash", 490 510 "thiserror 2.0.18", ··· 982 1002 version = "0.7.12" 983 1003 source = "registry+https://github.com/rust-lang/crates.io-index" 984 1004 checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" 1005 + dependencies = [ 1006 + "proc-macro2", 1007 + "quote", 1008 + "syn 2.0.117", 1009 + ] 1010 + 1011 + [[package]] 1012 + name = "enumn" 1013 + version = "0.1.14" 1014 + source = "registry+https://github.com/rust-lang/crates.io-index" 1015 + checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" 985 1016 dependencies = [ 986 1017 "proc-macro2", 987 1018 "quote",
+4 -1
Cargo.toml
··· 10 10 "crates/bone-render", 11 11 "crates/bone-ui", 12 12 "crates/bone-app", 13 + "crates/bone-jig", 13 14 ] 14 15 15 16 [workspace.package] ··· 32 33 needless_for_each = "allow" 33 34 34 35 [workspace.dependencies] 36 + bone-app = { path = "crates/bone-app", default-features = false } 35 37 bone-types = { path = "crates/bone-types" } 36 38 bone-kernel = { path = "crates/bone-kernel" } 37 39 bone-solver = { path = "crates/bone-solver" } ··· 41 43 bone-text = { path = "crates/bone-text" } 42 44 bone-ui = { path = "crates/bone-ui" } 43 45 44 - accesskit = "0.24" 46 + accesskit = { version = "0.24", features = ["serde"] } 45 47 ashpd = { version = "0.13", default-features = false, features = ["async-io", "file_chooser"] } 46 48 blake3 = { version = "1", default-features = false, features = ["std"] } 47 49 bytemuck = { version = "1", default-features = false, features = ["derive"] } ··· 57 59 proptest = { version = "1", default-features = false, features = ["std"] } 58 60 ron = "0.12" 59 61 serde = { version = "1", default-features = false, features = ["std", "derive", "rc"] } 62 + serde_json = "1" 60 63 slotmap = { version = "1", default-features = false, features = ["std", "serde"] } 61 64 swash = { version = "0.2", default-features = false, features = ["std", "scale", "render"] } 62 65 taffy = { version = "0.10", default-features = false, features = ["std", "flexbox", "grid", "content_size", "serde", "taffy_tree"] }
+12 -3
crates/bone-app/Cargo.toml
··· 5 5 license.workspace = true 6 6 rust-version.workspace = true 7 7 8 + [[bin]] 9 + name = "bone-app" 10 + path = "src/main.rs" 11 + required-features = ["bin"] 12 + 13 + [features] 14 + default = ["bin"] 15 + bin = ["dep:tracing-subscriber", "dep:winit"] 16 + 8 17 [dependencies] 18 + accesskit = { workspace = true } 9 19 bone-types = { workspace = true } 10 20 bone-document = { workspace = true } 11 21 bone-interop = { workspace = true } ··· 20 30 serde = { workspace = true } 21 31 thiserror = { workspace = true } 22 32 tracing = { workspace = true } 23 - tracing-subscriber = { workspace = true } 33 + tracing-subscriber = { workspace = true, optional = true } 24 34 uom = { workspace = true } 25 - winit = { workspace = true } 35 + winit = { workspace = true, optional = true } 26 36 27 37 [dev-dependencies] 28 - accesskit = { workspace = true } 29 38 insta = { workspace = true } 30 39 31 40 [target.'cfg(target_os = "linux")'.dependencies]
+5549
crates/bone-app/src/app.rs
··· 1 + use std::collections::BTreeMap; 2 + use std::num::NonZeroUsize; 3 + use std::path::{Path, PathBuf}; 4 + use std::sync::Arc; 5 + 6 + use bone_document::{ 7 + DimensionKind, DimensionValue, Document, DocumentFolder, EditOutcome, EvaluatedExtrude, 8 + ExtrudeError, ExtrudeFeature, FeatureCache, FeatureNode, LineData, Sketch, SketchDimension, 9 + SketchEdit, SketchEntity, SketchRelation, SketchVersion, SolverError, UndoStack, 10 + }; 11 + use bone_render::{ 12 + Camera2, CameraTween, ChromeInstance, ChromePipeline, ChromeTextPipeline, ConvexInstance, 13 + ConvexPolyPipeline, DragModifiers, EdgeScene, Gpu, NavGesture, PickIdError, PickIndex, 14 + PickQuery, PickedItem, Picker, PixelsPerMm, RenderTargets, SdfGlyphInstance, SketchPreview, 15 + SketchRenderer, SketchScene, SolidFrameView, SolidRenderer, SolidScene, StrokeInstance, 16 + StrokePipeline, Style, ViewportExtent, ViewportNavigator, ViewportPoint, ViewportPx, 17 + ViewportRegion, frame_current, frame_standard_view, frame_view_direction, orbit_pitch, 18 + orbit_yaw, pan_pixels, roll_by, zoom_about_pixel, 19 + }; 20 + use bone_types::{ 21 + Aabb3, Angle, AngleTolerance, BudgetCeiling, Camera3, ChordHeightTolerance, CubicEasing, 22 + DisplayMode, DocumentId, ExtrudeId, FeatureId, GeometryGeneration, Length, Plane3, Point2, 23 + SketchId, SketchItemId, StandardView, Vec2, ZoomFactor, 24 + }; 25 + use bone_ui::a11y::AccessTreeBuilder; 26 + use bone_ui::focus::FocusManager; 27 + use bone_ui::frame::FrameCtx; 28 + use bone_ui::hit_test::{HitFrame, HitState, resolve}; 29 + use bone_ui::hotkey::{ActionId, HotkeyScope, HotkeyScopes, HotkeyTable}; 30 + use bone_ui::input::{ 31 + FrameInstant, InputSnapshot, KeyCode as UiKeyCode, KeyEvent as UiKeyEvent, ModifierMask, 32 + NamedKey, PointerButton, PointerButtonMask, PointerSample, 33 + }; 34 + use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 35 + use bone_ui::strings::StringTable; 36 + use bone_ui::theme::Theme; 37 + use bone_ui::{MaskAtlas, MaskAtlasParams, Shaper}; 38 + use swash::FontRef; 39 + use uom::si::angle::degree; 40 + use uom::si::length::millimeter; 41 + 42 + use crate::clock::FrameClock; 43 + use crate::dimension_editor::{ 44 + DimensionEditorAction, DimensionEditorOutcome, DimensionEditorState, 45 + }; 46 + use crate::input::{InputDispatched, InputEvent, KeyDown, NavKey, ScrollDelta, WindowPoint}; 47 + use crate::selection::Selection; 48 + use crate::sketch_mode::{ 49 + ClickAnchor, DimensionFlow, DragPins, DragSession, ExtrudeArming, FeatureTool, Mode, Pending, 50 + PendingDimension, Plane, SketchTool, 51 + }; 52 + use crate::snap::{Anchor, SnapHit}; 53 + use crate::status_badge::ExtrudeStatus; 54 + use crate::{ 55 + chrome, dimension_editor, file_menu, hotkeys, native_picker, selection, settings, shell, 56 + shortcut_bar, sketch_mode, smart_dimension, snap, step_jobs, strings, tools, view_cube, 57 + }; 58 + const ZOOM_STEP_PER_LINE: f64 = 1.1; 59 + const ZOOM_STEP_PER_PIXEL: f64 = 1.0025; 60 + const ZOOM_KEY_STEP: f64 = 1.25; 61 + const ORBIT_KEY_STEP_DEG: f64 = 15.0; 62 + const ORBIT_KEY_SNAP_DEG: f64 = 90.0; 63 + const ZOOM_MIN: f64 = 0.01; 64 + const ZOOM_MAX: f64 = 1.0e5; 65 + const INITIAL_ZOOM_PX_PER_MM: f64 = 12.0; 66 + const PAN_STEP_PX: f64 = 40.0; 67 + const PAN_FAST_MULTIPLIER: f64 = 5.0; 68 + const ZOOM_FIT_MARGIN: f64 = 0.9; 69 + const UNDO_CAPACITY: usize = 256; 70 + const SNAP_TOLERANCE_PX: f64 = 8.0; 71 + const SNAP_TOLERANCE_MAX_MM: f64 = 5.0; 72 + 73 + struct AppState { 74 + extent: ViewportExtent, 75 + renderer: SketchRenderer, 76 + chrome_pipeline: ChromePipeline, 77 + convex_pipeline: ConvexPolyPipeline, 78 + stroke_pipeline: StrokePipeline, 79 + text_pipeline: ChromeTextPipeline, 80 + sdf_atlas: MaskAtlas, 81 + chrome_shaper: Shaper, 82 + sans_font: FontRef<'static>, 83 + mono_font: FontRef<'static>, 84 + scene: SketchScene, 85 + camera: Camera2, 86 + style: Style, 87 + theme: Arc<Theme>, 88 + shell: shell::Shell, 89 + document: Document, 90 + plane_sketches: BTreeMap<Plane, SketchId>, 91 + mode: Mode, 92 + feature_cache: FeatureCache, 93 + extrude_preview: Option<ExtrudePreview>, 94 + solid_renderer: SolidRenderer, 95 + solid_view: Option<SolidViewData>, 96 + camera3: Option<Camera3>, 97 + framed_extrude: Option<ExtrudeId>, 98 + navigator: ViewportNavigator, 99 + view: view_cube::ViewUi, 100 + focus: FocusManager, 101 + hit_state: HitState, 102 + hotkeys: HotkeyTable, 103 + strings: StringTable, 104 + viewport_rect: LayoutRect, 105 + undo: UndoStack, 106 + selection: Selection, 107 + settings: settings::Settings, 108 + dim_editor: DimensionEditorState, 109 + dim_editor_bounds: Option<LayoutRect>, 110 + pending_exit: bool, 111 + current_folder: Option<DocumentFolder>, 112 + documents_root: PathBuf, 113 + file_picker: Option<file_menu::FilePickerSession>, 114 + native_picker: Option<native_picker::PendingHandle>, 115 + step_job: Option<step_jobs::StepJob>, 116 + pending_overwrite: Option<PendingOverwrite>, 117 + last_saved: Option<Document>, 118 + pending_discard: Option<PendingDiscard>, 119 + notification: Option<Notification>, 120 + shortcut_bar: Option<shortcut_bar::ShortcutBarState>, 121 + } 122 + 123 + #[derive(Clone, Debug, PartialEq)] 124 + enum PendingDiscard { 125 + New, 126 + Open(PathBuf), 127 + ImportStep(PathBuf), 128 + InstallImported { 129 + document: Box<Document>, 130 + file_name: String, 131 + }, 132 + } 133 + 134 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 135 + enum PickedVia { 136 + NativePortal, 137 + CustomPicker, 138 + } 139 + 140 + #[derive(Clone, Debug, PartialEq)] 141 + enum PendingOverwrite { 142 + Document(DocumentFolder), 143 + StepExport(PathBuf), 144 + } 145 + 146 + fn modal_active(state: &AppState) -> bool { 147 + state.file_picker.is_some() 148 + || state.native_picker.is_some() 149 + || state 150 + .step_job 151 + .as_ref() 152 + .is_some_and(|job| job.meta().show_progress) 153 + || state.pending_overwrite.is_some() 154 + || state.pending_discard.is_some() 155 + || state.shortcut_bar.is_some() 156 + || state.shell.state.ribbon_overflow_open.values().any(|v| *v) 157 + } 158 + 159 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 160 + enum NotificationKind { 161 + Info, 162 + Error, 163 + } 164 + 165 + #[derive(Clone, Debug, PartialEq)] 166 + struct Notification { 167 + kind: NotificationKind, 168 + headline: bone_ui::strings::StringKey, 169 + detail: Option<String>, 170 + } 171 + 172 + struct InputState { 173 + cursor_px: Option<WindowPoint>, 174 + left_pan: bool, 175 + middle_pan: bool, 176 + modifiers: ModifierMask, 177 + pending_pressed: PointerButtonMask, 178 + pending_released: PointerButtonMask, 179 + pending_keys: Vec<UiKeyEvent>, 180 + pending_text: String, 181 + } 182 + 183 + impl Default for InputState { 184 + fn default() -> Self { 185 + Self { 186 + cursor_px: None, 187 + left_pan: false, 188 + middle_pan: false, 189 + modifiers: ModifierMask::NONE, 190 + pending_pressed: PointerButtonMask::EMPTY, 191 + pending_released: PointerButtonMask::EMPTY, 192 + pending_keys: Vec::new(), 193 + pending_text: String::new(), 194 + } 195 + } 196 + } 197 + 198 + impl InputState { 199 + fn shift(&self) -> bool { 200 + self.modifiers.contains(ModifierMask::SHIFT) 201 + } 202 + 203 + fn ctrl_or_meta(&self) -> bool { 204 + self.modifiers.contains(ModifierMask::CTRL) || self.modifiers.contains(ModifierMask::META) 205 + } 206 + 207 + fn panning(&self) -> bool { 208 + self.middle_pan || (self.left_pan && self.shift()) 209 + } 210 + 211 + fn pan_step_px(&self) -> f64 { 212 + if self.shift() { 213 + PAN_STEP_PX * PAN_FAST_MULTIPLIER 214 + } else { 215 + PAN_STEP_PX 216 + } 217 + } 218 + 219 + fn pointer_sample(&self) -> Option<PointerSample> { 220 + self.cursor_px 221 + .map(window_to_layout_pos) 222 + .map(PointerSample::new) 223 + } 224 + 225 + fn cursor_in(&self, rect: LayoutRect) -> bool { 226 + self.cursor_px 227 + .map(window_to_layout_pos) 228 + .is_some_and(|p| rect.contains(p)) 229 + } 230 + 231 + fn drain_snapshot(&mut self, now: FrameInstant) -> InputSnapshot { 232 + let mut snap = InputSnapshot::idle(now); 233 + snap.pointer = self.pointer_sample(); 234 + snap.buttons_pressed = 235 + core::mem::replace(&mut self.pending_pressed, PointerButtonMask::EMPTY); 236 + snap.buttons_released = 237 + core::mem::replace(&mut self.pending_released, PointerButtonMask::EMPTY); 238 + snap.keys_pressed = core::mem::take(&mut self.pending_keys); 239 + snap.text_committed = core::mem::take(&mut self.pending_text); 240 + snap.modifiers = self.modifiers; 241 + snap 242 + } 243 + 244 + fn forget_pan_state(&mut self) { 245 + self.cursor_px = None; 246 + self.left_pan = false; 247 + self.middle_pan = false; 248 + self.modifiers = ModifierMask::NONE; 249 + } 250 + } 251 + 252 + #[allow( 253 + clippy::cast_possible_truncation, 254 + reason = "window cursor px (f64) collapses to LayoutPx (f32) at the sub-pixel limit" 255 + )] 256 + fn window_to_layout_pos(p: WindowPoint) -> LayoutPos { 257 + LayoutPos::new( 258 + LayoutPx::saturating(p.x as f32), 259 + LayoutPx::saturating(p.y as f32), 260 + ) 261 + } 262 + 263 + pub trait FrameTarget { 264 + fn picker(&self, index: PickIndex) -> Picker<'_>; 265 + fn render( 266 + &mut self, 267 + build_passes: impl FnOnce( 268 + &mut wgpu::CommandEncoder, 269 + &wgpu::TextureView, 270 + &wgpu::TextureView, 271 + &wgpu::TextureView, 272 + ), 273 + ); 274 + } 275 + 276 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 277 + #[must_use] 278 + pub struct FrameReport { 279 + pub kick: bool, 280 + pub exit: bool, 281 + } 282 + 283 + #[derive(Debug, thiserror::Error)] 284 + pub enum OpenError { 285 + #[error("import step {path}: {source}")] 286 + ImportStep { 287 + path: PathBuf, 288 + #[source] 289 + source: bone_interop::StepError, 290 + }, 291 + #[error("load document folder {path}: {source}")] 292 + LoadFolder { 293 + path: PathBuf, 294 + #[source] 295 + source: bone_document::FolderError, 296 + }, 297 + } 298 + 299 + pub struct AppCore { 300 + state: AppState, 301 + input: InputState, 302 + clock: FrameClock, 303 + a11y: AccessTreeBuilder, 304 + } 305 + 306 + fn default_sketch() -> Sketch { 307 + let sketch = Sketch::new(Plane::Xy.basis()); 308 + let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(-20.0, -12.5)); 309 + let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(20.0, -12.5)); 310 + let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(20.0, 12.5)); 311 + let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(-20.0, 12.5)); 312 + let (sketch, _) = tools::add_line(sketch, p0, p1, false); 313 + let (sketch, _) = tools::add_line(sketch, p1, p2, false); 314 + let (sketch, _) = tools::add_line(sketch, p2, p3, false); 315 + let (sketch, _) = tools::add_line(sketch, p3, p0, false); 316 + let (sketch, origin) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 317 + let (sketch, _) = tools::add_circle(sketch, origin, Length::new::<millimeter>(5.0), false); 318 + sketch 319 + } 320 + 321 + fn initial_document(sketch: Sketch) -> (Document, SketchId) { 322 + let mut document = Document::new(DocumentId::default(), "Untitled".to_owned()); 323 + let sketch_id = SketchId::default(); 324 + document.insert_sketch(sketch_id, "Sketch1".to_owned(), sketch); 325 + (document, sketch_id) 326 + } 327 + 328 + #[allow( 329 + clippy::cast_precision_loss, 330 + reason = "viewport pixel counts at any realistic display size fit f32 mantissa" 331 + )] 332 + fn layout_size_from_extent(extent: ViewportExtent) -> LayoutSize { 333 + LayoutSize::new( 334 + LayoutPx::new(extent.width().value() as f32), 335 + LayoutPx::new(extent.height().value() as f32), 336 + ) 337 + } 338 + 339 + fn empty_rect() -> LayoutRect { 340 + LayoutRect::new(LayoutPos::ORIGIN, LayoutSize::ZERO) 341 + } 342 + 343 + fn zoom_factor(delta: ScrollDelta) -> f64 { 344 + match delta { 345 + ScrollDelta::Lines { y, .. } => ZOOM_STEP_PER_LINE.powf(f64::from(y)), 346 + ScrollDelta::Pixels { y, .. } => ZOOM_STEP_PER_PIXEL.powf(y), 347 + } 348 + } 349 + 350 + fn zoom_about(camera: Camera2, cursor: Option<WindowPoint>, factor: f64) -> Camera2 { 351 + if !factor.is_finite() || factor <= 0.0 { 352 + return camera; 353 + } 354 + let zoom_before = camera.zoom().value(); 355 + let zoom_after = (zoom_before * factor).clamp(ZOOM_MIN, ZOOM_MAX); 356 + if !zoom_after.is_finite() { 357 + return camera; 358 + } 359 + let extent = camera.extent(); 360 + let w = f64::from(extent.width().value()); 361 + let h = f64::from(extent.height().value()); 362 + let (pan_x, pan_y) = camera.pan_mm().coords_mm(); 363 + let (cursor_x, cursor_y) = cursor.map_or((w * 0.5, h * 0.5), |c| (c.x, c.y)); 364 + let horizontal_px = cursor_x - w * 0.5; 365 + let vertical_px = h * 0.5 - cursor_y; 366 + let world_x = pan_x + horizontal_px / zoom_before; 367 + let world_y = pan_y + vertical_px / zoom_before; 368 + let new_pan_x = world_x - horizontal_px / zoom_after; 369 + let new_pan_y = world_y - vertical_px / zoom_after; 370 + camera 371 + .with_zoom(PixelsPerMm::new(zoom_after)) 372 + .with_pan(Vec2::from_mm(new_pan_x, new_pan_y)) 373 + } 374 + 375 + fn dragging_in_sketch(mode: &Mode) -> bool { 376 + matches!( 377 + mode, 378 + Mode::Sketch { session, .. } if session.drag.is_some() 379 + ) 380 + } 381 + 382 + fn dim_flow_active(mode: &Mode) -> bool { 383 + matches!( 384 + mode, 385 + Mode::Sketch { session, .. } if session.dim_flow.is_some() 386 + ) 387 + } 388 + 389 + fn cursor_to_world(camera: Camera2, cursor: WindowPoint) -> Option<Point2> { 390 + let extent = camera.extent(); 391 + let w = f64::from(extent.width().value()); 392 + let h = f64::from(extent.height().value()); 393 + let zoom = camera.zoom().value(); 394 + if w <= 0.0 || h <= 0.0 || !zoom.is_finite() || zoom <= 0.0 { 395 + return None; 396 + } 397 + let (pan_x, pan_y) = camera.pan_mm().coords_mm(); 398 + Some(Point2::from_mm( 399 + pan_x + (cursor.x - w * 0.5) / zoom, 400 + pan_y + (h * 0.5 - cursor.y) / zoom, 401 + )) 402 + } 403 + 404 + fn try_place(state: &mut AppState, world: Point2) { 405 + let Mode::Sketch { sketch_id, session } = &state.mode else { 406 + return; 407 + }; 408 + let Some(tool) = session.tool else { 409 + return; 410 + }; 411 + let prev_pending = session.pending; 412 + let sketch_id = *sketch_id; 413 + let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 414 + return; 415 + }; 416 + let snap = match tool { 417 + SketchTool::Line => { 418 + compute_snap(&sketch, &state.camera, world, latest_anchor(prev_pending)) 419 + } 420 + _ => compute_endpoint_snap(&sketch, &state.camera, world), 421 + }; 422 + let (next_sketch, new_pending) = tools::place(sketch, tool, world, prev_pending, snap); 423 + if let Some(next) = next_sketch { 424 + state.undo.record(state.document.clone()); 425 + state.document.replace_sketch(sketch_id, next); 426 + let Some(stored) = state.document.sketch(sketch_id) else { 427 + return; 428 + }; 429 + match SketchScene::extract(stored) { 430 + Ok(scene) => state.scene = scene, 431 + Err(e) => tracing::warn!(error = %e, "scene extract after place failed"), 432 + } 433 + } 434 + if let Mode::Sketch { 435 + ref mut session, .. 436 + } = state.mode 437 + { 438 + session.pending = new_pending; 439 + } 440 + } 441 + 442 + const fn latest_anchor(pending: Option<Pending>) -> Option<ClickAnchor> { 443 + match pending { 444 + None => None, 445 + Some(Pending::First(a) | Pending::Second(_, a)) => Some(a), 446 + } 447 + } 448 + 449 + fn pick_at(state: &AppState, target: &impl FrameTarget, cursor: WindowPoint) -> Option<PickedItem> { 450 + if !cursor.x.is_finite() || !cursor.y.is_finite() || cursor.x < 0.0 || cursor.y < 0.0 { 451 + return None; 452 + } 453 + let extent = state.extent; 454 + #[allow( 455 + clippy::cast_possible_truncation, 456 + clippy::cast_sign_loss, 457 + reason = "cursor px is bounds-checked against surface extent before the cast" 458 + )] 459 + let qx = cursor.x.round() as u32; 460 + #[allow( 461 + clippy::cast_possible_truncation, 462 + clippy::cast_sign_loss, 463 + reason = "cursor px is bounds-checked against surface extent before the cast" 464 + )] 465 + let qy = cursor.y.round() as u32; 466 + if qx >= extent.width().value() || qy >= extent.height().value() { 467 + return None; 468 + } 469 + let index = match state.scene.pick_index() { 470 + Ok(i) => i, 471 + Err(e) => { 472 + tracing::warn!(error = %e, "build pick index"); 473 + return None; 474 + } 475 + }; 476 + let query = PickQuery::new(ViewportPx::new(qx), ViewportPx::new(qy)) 477 + .with_aperture(state.settings.pick_aperture); 478 + match target.picker(index).at(query) { 479 + Ok(item) => item, 480 + Err(e) => { 481 + tracing::warn!(error = %e, "pick failed"); 482 + None 483 + } 484 + } 485 + } 486 + 487 + fn handle_viewport_click( 488 + state: &mut AppState, 489 + target: &impl FrameTarget, 490 + cursor: WindowPoint, 491 + additive: bool, 492 + ) { 493 + let item = pick_at(state, target, cursor).and_then(selection::picked_to_item); 494 + state.selection = std::mem::take(&mut state.selection).picked(item, additive); 495 + if additive || !state.mode.is_sketch() { 496 + return; 497 + } 498 + let Some(SketchItemId::Entity(entity_id)) = item else { 499 + return; 500 + }; 501 + let Mode::Sketch { sketch_id, .. } = state.mode else { 502 + return; 503 + }; 504 + let Some(sketch) = state.document.sketch(sketch_id) else { 505 + return; 506 + }; 507 + let Some(world) = cursor_to_world(state.camera, cursor) else { 508 + return; 509 + }; 510 + let Some(pins) = DragPins::from_sketch_entity(sketch, entity_id) else { 511 + return; 512 + }; 513 + let drag = DragSession { 514 + entity: entity_id, 515 + press: world, 516 + pins, 517 + }; 518 + state.undo.record(state.document.clone()); 519 + state.mode = core::mem::take(&mut state.mode).start_drag(drag); 520 + } 521 + 522 + fn drag_resolved(sketch: &Sketch, drag: DragSession, world: Point2) -> Option<Sketch> { 523 + let pins = drag.pins.to_targets(drag.press, world); 524 + sketch 525 + .solve_with_drag_pins(&pins, BudgetCeiling::FRAME_16MS) 526 + .map_err(|e| tracing::trace!(error = %e, "drag solve failed, keeping last-good sketch")) 527 + .ok() 528 + } 529 + 530 + fn try_drag_to(state: &mut AppState, world: Point2) { 531 + let Mode::Sketch { sketch_id, session } = &state.mode else { 532 + return; 533 + }; 534 + let Some(drag) = session.drag else { 535 + return; 536 + }; 537 + let sketch_id = *sketch_id; 538 + let Some(sketch) = state.document.sketch(sketch_id) else { 539 + return; 540 + }; 541 + let Some(next) = drag_resolved(sketch, drag, world) else { 542 + return; 543 + }; 544 + state.document.replace_sketch(sketch_id, next); 545 + refresh_active_scene(state); 546 + } 547 + 548 + fn refresh_active_scene(state: &mut AppState) { 549 + let Some(active_id) = active_sketch_id(&state.mode, &state.plane_sketches) else { 550 + return; 551 + }; 552 + let Some(sketch) = state.document.sketch(active_id) else { 553 + return; 554 + }; 555 + match SketchScene::extract(sketch) { 556 + Ok(scene) => state.scene = scene, 557 + Err(e) => tracing::warn!(error = %e, "refresh active scene failed"), 558 + } 559 + } 560 + 561 + fn active_sketch_id(mode: &Mode, plane_sketches: &BTreeMap<Plane, SketchId>) -> Option<SketchId> { 562 + match mode { 563 + Mode::Sketch { sketch_id, .. } => Some(*sketch_id), 564 + Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => Some(feature.sketch), 565 + Mode::Extrude(ExtrudeArming::AwaitingSketch) | Mode::Idle => { 566 + plane_sketches.get(&Plane::Xy).copied() 567 + } 568 + } 569 + } 570 + 571 + enum ProfileChoice { 572 + NoSketch, 573 + Unique(SketchId), 574 + Ambiguous, 575 + } 576 + 577 + fn classify_extrude_profile(document: &Document) -> ProfileChoice { 578 + let mut sketches = document 579 + .feature_tree() 580 + .iter() 581 + .filter_map(|(_, node)| match node { 582 + FeatureNode::Sketch(id) => Some(id), 583 + _ => None, 584 + }); 585 + match (sketches.next(), sketches.next()) { 586 + (None, _) => ProfileChoice::NoSketch, 587 + (Some(id), None) => ProfileChoice::Unique(id), 588 + (Some(_), Some(_)) => ProfileChoice::Ambiguous, 589 + } 590 + } 591 + 592 + fn apply_feature_tool(state: &mut AppState, tool: Option<FeatureTool>) { 593 + match tool { 594 + Some(FeatureTool::ExtrudedBossBase) => arm_extruded_boss_base(state), 595 + Some(FeatureTool::ExtrudedCut) => notify_stub(state, strings::TOOL_EXTRUDED_CUT), 596 + None => {} 597 + } 598 + } 599 + 600 + fn arm_extruded_boss_base(state: &mut AppState) { 601 + match classify_extrude_profile(&state.document) { 602 + ProfileChoice::NoSketch => notify_info(state, strings::NOTIFY_EXTRUDE_NO_SKETCH, None), 603 + ProfileChoice::Unique(id) => state.mode = Mode::Extrude(ExtrudeArming::profile(id)), 604 + ProfileChoice::Ambiguous => state.mode = Mode::Extrude(ExtrudeArming::AwaitingSketch), 605 + } 606 + } 607 + 608 + fn apply_extrude_edit(state: &mut AppState, edit: Option<shell::ExtrudeEdit>) { 609 + let Some(edit) = edit else { return }; 610 + let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = &state.mode else { 611 + return; 612 + }; 613 + let next = edit.apply(*feature); 614 + let target = *target; 615 + state.mode = Mode::Extrude(ExtrudeArming::Profile { 616 + feature: next, 617 + target, 618 + }); 619 + } 620 + 621 + fn apply_extrude_activation(state: &mut AppState, activated: Option<ExtrudeId>) { 622 + if let Some(mode) = extrude_edit_mode(&state.document, &state.mode, activated) { 623 + if let Mode::Extrude(ExtrudeArming::Profile { 624 + target: Some(id), .. 625 + }) = mode 626 + { 627 + state.framed_extrude = Some(id); 628 + } 629 + state.mode = mode; 630 + } 631 + } 632 + 633 + fn extrude_edit_mode( 634 + document: &Document, 635 + current: &Mode, 636 + activated: Option<ExtrudeId>, 637 + ) -> Option<Mode> { 638 + let id = activated?; 639 + if current.is_sketch() { 640 + return None; 641 + } 642 + let feature = document.extrude(id).copied()?; 643 + Some(Mode::Extrude(ExtrudeArming::edit(id, feature))) 644 + } 645 + 646 + fn apply_extrude_confirm(state: &mut AppState, confirm: Option<shell::ConfirmAction>) { 647 + if let Some(id) = 648 + commit_armed_extrude(&mut state.document, &mut state.undo, &state.mode, confirm) 649 + { 650 + state.framed_extrude = Some(id); 651 + } 652 + } 653 + 654 + fn commit_armed_extrude( 655 + document: &mut Document, 656 + undo: &mut UndoStack, 657 + mode: &Mode, 658 + confirm: Option<shell::ConfirmAction>, 659 + ) -> Option<ExtrudeId> { 660 + let Some(shell::ConfirmAction::Accept) = confirm else { 661 + return None; 662 + }; 663 + let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = mode else { 664 + return None; 665 + }; 666 + let snapshot = document.clone(); 667 + let committed = match target { 668 + Some(id) => { 669 + document.insert_extrude(*id, *feature); 670 + *id 671 + } 672 + None => document.commit_extrude(*feature), 673 + }; 674 + undo.record(snapshot); 675 + Some(committed) 676 + } 677 + 678 + struct SolidViewData { 679 + faces: SolidScene, 680 + edges: EdgeScene, 681 + aabb: Aabb3, 682 + } 683 + 684 + struct ExtrudePreview { 685 + feature: ExtrudeFeature, 686 + sketch_version: SketchVersion, 687 + generation: Option<GeometryGeneration>, 688 + failed: bool, 689 + error: Option<ExtrudeError>, 690 + } 691 + 692 + impl ExtrudePreview { 693 + fn status(&self) -> ExtrudeStatus<'_> { 694 + match &self.error { 695 + Some(error) => ExtrudeStatus::Failed(error), 696 + None => ExtrudeStatus::Valid, 697 + } 698 + } 699 + } 700 + 701 + const PREVIEW_CHORD_MM: f64 = 0.05; 702 + const PREVIEW_ANGLE: AngleTolerance = AngleTolerance::from_radians(0.2); 703 + 704 + fn active_solid_feature( 705 + mode: &Mode, 706 + document: &Document, 707 + framed: Option<ExtrudeId>, 708 + ) -> Option<ExtrudeFeature> { 709 + match mode { 710 + Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => Some(*feature), 711 + Mode::Sketch { .. } => None, 712 + Mode::Idle | Mode::Extrude(ExtrudeArming::AwaitingSketch) => framed 713 + .and_then(|id| document.extrude(id).copied()) 714 + .or_else(|| { 715 + document 716 + .feature_tree() 717 + .iter() 718 + .filter_map(|(_, node)| match node { 719 + FeatureNode::Extrude(id) => Some(id), 720 + _ => None, 721 + }) 722 + .last() 723 + .and_then(|id| document.extrude(id).copied()) 724 + }), 725 + } 726 + } 727 + 728 + fn sync_solid_view(state: &mut AppState) { 729 + let Some(feature) = active_solid_feature(&state.mode, &state.document, state.framed_extrude) 730 + else { 731 + state.extrude_preview = None; 732 + state.solid_view = None; 733 + state.camera3 = None; 734 + state.view.home = None; 735 + state.view.tween = None; 736 + return; 737 + }; 738 + let Some(sketch_version) = state.document.sketch(feature.sketch).map(Sketch::version) else { 739 + state.extrude_preview = None; 740 + state.solid_view = None; 741 + state.camera3 = None; 742 + state.view.home = None; 743 + state.view.tween = None; 744 + return; 745 + }; 746 + if extrude_preview_is_current(state.extrude_preview.as_ref(), &feature, sketch_version) { 747 + return; 748 + } 749 + let previous_generation = state 750 + .extrude_preview 751 + .as_ref() 752 + .and_then(|cached| cached.generation); 753 + let previously_failed = state 754 + .extrude_preview 755 + .as_ref() 756 + .is_some_and(|cached| cached.failed); 757 + let first_preview = state.extrude_preview.is_none(); 758 + let preview = compute_extrude_preview(&mut state.feature_cache, &state.document, feature); 759 + let generation = preview.as_ref().and_then(EvaluatedExtrude::generation); 760 + let error = preview 761 + .as_ref() 762 + .and_then(|evaluated| evaluated.result().as_ref().err().cloned()); 763 + let mut failure = error.as_ref().map(ToString::to_string); 764 + if generation != previous_generation { 765 + state.solid_view = match preview.as_ref().and_then(EvaluatedExtrude::solid) { 766 + Some(solid) => match build_solid_view(solid) { 767 + Ok(view) => Some(view), 768 + Err(error) => { 769 + failure = Some(error); 770 + None 771 + } 772 + }, 773 + None => None, 774 + }; 775 + } 776 + let now_failed = failure.is_some(); 777 + state.extrude_preview = Some(ExtrudePreview { 778 + feature, 779 + sketch_version, 780 + generation, 781 + failed: now_failed, 782 + error, 783 + }); 784 + let newly_failed = first_preview || !previously_failed; 785 + if let Some(detail) = failure.filter(|_| newly_failed) { 786 + tracing::warn!(error = %detail, "extrude preview evaluation failed"); 787 + notify_error(state, strings::NOTIFY_EXTRUDE_FAILED, detail); 788 + } 789 + } 790 + 791 + fn extrude_preview_is_current( 792 + cached: Option<&ExtrudePreview>, 793 + feature: &ExtrudeFeature, 794 + sketch_version: SketchVersion, 795 + ) -> bool { 796 + cached 797 + .is_some_and(|cached| cached.feature == *feature && cached.sketch_version == sketch_version) 798 + } 799 + 800 + fn compute_extrude_preview( 801 + cache: &mut FeatureCache, 802 + document: &Document, 803 + feature: ExtrudeFeature, 804 + ) -> Option<EvaluatedExtrude> { 805 + let sketch = document.sketch(feature.sketch)?; 806 + let fid = FeatureId::default(); 807 + let evaluated_sketch = cache.evaluate(fid, sketch).clone(); 808 + Some( 809 + cache 810 + .evaluate_extrude(fid, &evaluated_sketch, &feature) 811 + .clone(), 812 + ) 813 + } 814 + 815 + fn build_solid_view(solid: &bone_document::BrepSolid) -> Result<SolidViewData, String> { 816 + let chord = ChordHeightTolerance::from_mm(PREVIEW_CHORD_MM); 817 + let aabb = solid 818 + .bounding_box() 819 + .ok_or_else(|| "degenerate solid has no bounding box".to_owned())?; 820 + let mesh = solid 821 + .tessellate(chord, PREVIEW_ANGLE) 822 + .map_err(|error| error.to_string())?; 823 + let faces = SolidScene::from_mesh(&mesh).map_err(|error| error.to_string())?; 824 + let edges = EdgeScene::from_solid(solid, &mesh, chord).map_err(|error| error.to_string())?; 825 + Ok(SolidViewData { faces, edges, aabb }) 826 + } 827 + 828 + fn sync_solid_camera(state: &mut AppState, region: Option<ViewportRegion>) { 829 + if let Some(region) = region 830 + && let Some(view) = state.solid_view.as_ref() 831 + && state.camera3.is_none() 832 + { 833 + let framed = 834 + frame_standard_view(view.aabb, region.extent(), StandardView::Isometric, None).ok(); 835 + state.camera3 = framed; 836 + if state.view.home.is_none() { 837 + state.view.home = framed; 838 + } 839 + } 840 + } 841 + 842 + const VIEW_TWEEN_MS: u64 = 220; 843 + 844 + fn step_view_tween(state: &mut AppState, now: FrameInstant) { 845 + let Some(active) = state.view.tween else { 846 + return; 847 + }; 848 + let elapsed = now.since(active.started); 849 + if let Ok(camera) = active.tween.sample(elapsed) { 850 + state.camera3 = Some(camera); 851 + } 852 + if active.tween.is_done(elapsed) { 853 + state.camera3 = Some(active.tween.to()); 854 + state.view.tween = None; 855 + } 856 + } 857 + 858 + fn start_view_tween(state: &mut AppState, target: Camera3, now: FrameInstant) { 859 + let tween = if state.settings.reduce_motion { 860 + CameraTween::immediate(target) 861 + } else { 862 + CameraTween::eased( 863 + state.camera3.unwrap_or(target), 864 + target, 865 + std::time::Duration::from_millis(VIEW_TWEEN_MS), 866 + CubicEasing::STANDARD, 867 + ) 868 + }; 869 + state.view.tween = Some(view_cube::ActiveTween { 870 + tween, 871 + started: now, 872 + }); 873 + } 874 + 875 + fn apply_nav_camera(state: &mut AppState, camera: Camera3) { 876 + state.camera3 = Some(camera); 877 + state.view.tween = None; 878 + } 879 + 880 + fn solid_aabb_and_extent(state: &AppState) -> Option<(Aabb3, ViewportExtent)> { 881 + let region = solid_viewport_region(state.viewport_rect, state.extent)?; 882 + let view = state.solid_view.as_ref()?; 883 + Some((view.aabb, region.extent())) 884 + } 885 + 886 + fn normal_to_plane(state: &AppState) -> Option<Plane3> { 887 + let sketch_id = active_sketch_id(&state.mode, &state.plane_sketches)?; 888 + let plane = state 889 + .plane_sketches 890 + .iter() 891 + .find_map(|(plane, id)| (*id == sketch_id).then_some(*plane))?; 892 + Some(Plane3::from(plane.basis())) 893 + } 894 + 895 + fn frame_target_camera(state: &AppState, pick: view_cube::ViewPick) -> Option<Camera3> { 896 + match pick { 897 + view_cube::ViewPick::Home => state.view.home, 898 + view_cube::ViewPick::Standard(view) => { 899 + let (aabb, extent) = solid_aabb_and_extent(state)?; 900 + frame_standard_view(aabb, extent, view, normal_to_plane(state)).ok() 901 + } 902 + view_cube::ViewPick::Direction(direction) => { 903 + let (aabb, extent) = solid_aabb_and_extent(state)?; 904 + frame_view_direction(aabb, extent, direction).ok() 905 + } 906 + } 907 + } 908 + 909 + fn apply_view_pick(state: &mut AppState, pick: Option<view_cube::ViewPick>, now: FrameInstant) { 910 + let Some(pick) = pick else { 911 + return; 912 + }; 913 + if let Some(target) = frame_target_camera(state, pick) { 914 + start_view_tween(state, target, now); 915 + } 916 + } 917 + 918 + fn view_nav_enabled(state: &AppState) -> bool { 919 + state.solid_view.is_some() 920 + && !modal_active(state) 921 + && state.focus.focused().is_none() 922 + && !dim_flow_active(&state.mode) 923 + } 924 + 925 + fn apply_view_menu( 926 + state: &mut AppState, 927 + action: Option<view_cube::ViewCubeMenuAction>, 928 + now: FrameInstant, 929 + ) { 930 + match action { 931 + Some(view_cube::ViewCubeMenuAction::SetAsHome) => { 932 + state.view.home = state.camera3; 933 + } 934 + Some(view_cube::ViewCubeMenuAction::FitToWindow) => { 935 + if let (Some(camera), Some((aabb, extent))) = 936 + (state.camera3, solid_aabb_and_extent(state)) 937 + && let Ok(target) = frame_current(camera, aabb, extent) 938 + { 939 + start_view_tween(state, target, now); 940 + } 941 + } 942 + Some(view_cube::ViewCubeMenuAction::ViewNormalTo) => { 943 + apply_view_pick( 944 + state, 945 + Some(view_cube::ViewPick::Standard(StandardView::NormalTo)), 946 + now, 947 + ); 948 + } 949 + None => {} 950 + } 951 + } 952 + 953 + fn preview_solid_frame( 954 + solid_view: Option<&SolidViewData>, 955 + camera: Option<Camera3>, 956 + region: ViewportRegion, 957 + ) -> Option<(&SolidViewData, SolidFrameView)> { 958 + let view = solid_view?; 959 + Some((view, SolidFrameView::new(camera?, region).ok()?)) 960 + } 961 + 962 + fn solid_viewport_region(viewport: LayoutRect, surface: ViewportExtent) -> Option<ViewportRegion> { 963 + let (surface_w, surface_h) = (surface.width().value(), surface.height().value()); 964 + let min_x = round_layout_px(viewport.min_x().value()).min(surface_w); 965 + let min_y = round_layout_px(viewport.min_y().value()).min(surface_h); 966 + let width = round_layout_px(viewport.size.width.value()).min(surface_w - min_x); 967 + let height = round_layout_px(viewport.size.height.value()).min(surface_h - min_y); 968 + (width > 0 && height > 0).then(|| { 969 + ViewportRegion::new( 970 + ViewportPx::new(min_x), 971 + ViewportPx::new(min_y), 972 + ViewportExtent::new(ViewportPx::new(width), ViewportPx::new(height)), 973 + ) 974 + }) 975 + } 976 + 977 + #[allow( 978 + clippy::cast_possible_truncation, 979 + clippy::cast_sign_loss, 980 + reason = "the saturating cast of a non-negative rounded px is clamped to the surface extent by the caller" 981 + )] 982 + fn round_layout_px(value: f32) -> u32 { 983 + value.round().max(0.0) as u32 984 + } 985 + 986 + fn viewport_local_point(cursor: WindowPoint, region: ViewportRegion) -> Option<ViewportPoint> { 987 + let (min_x, min_y, _, _) = region.scissor(); 988 + ViewportPoint::new(cursor.x - f64::from(min_x), cursor.y - f64::from(min_y)).ok() 989 + } 990 + 991 + fn drag_gesture(modifiers: ModifierMask) -> NavGesture { 992 + let with_ctrl = 993 + if modifiers.contains(ModifierMask::CTRL) || modifiers.contains(ModifierMask::META) { 994 + DragModifiers::NONE.with_ctrl() 995 + } else { 996 + DragModifiers::NONE 997 + }; 998 + let with_shift = if modifiers.contains(ModifierMask::SHIFT) { 999 + with_ctrl.with_shift() 1000 + } else { 1001 + with_ctrl 1002 + }; 1003 + let resolved = if modifiers.contains(ModifierMask::ALT) { 1004 + with_shift.with_alt() 1005 + } else { 1006 + with_shift 1007 + }; 1008 + resolved.gesture() 1009 + } 1010 + 1011 + fn build_preview( 1012 + mode: &Mode, 1013 + document: &Document, 1014 + cursor_world: Option<Point2>, 1015 + camera: &Camera2, 1016 + ) -> SketchPreview { 1017 + let Mode::Sketch { sketch_id, session } = mode else { 1018 + return SketchPreview::empty(); 1019 + }; 1020 + if session.drag.is_some() || session.dim_flow.is_some() { 1021 + return SketchPreview::empty(); 1022 + } 1023 + let Some(tool) = session.tool else { 1024 + return SketchPreview::empty(); 1025 + }; 1026 + let pending = session.pending; 1027 + let Some(sketch) = document.sketch(*sketch_id) else { 1028 + return SketchPreview::empty(); 1029 + }; 1030 + let Some(cursor) = cursor_world else { 1031 + return tools::preview_anchors_only(sketch, pending); 1032 + }; 1033 + let snap = match tool { 1034 + SketchTool::Line => compute_snap(sketch, camera, cursor, latest_anchor(pending)), 1035 + _ => compute_endpoint_snap(sketch, camera, cursor), 1036 + }; 1037 + tools::preview(sketch, tool, cursor, pending, snap) 1038 + } 1039 + 1040 + fn snap_tolerance(camera: &Camera2) -> Option<Length> { 1041 + let mm_per_px = camera.world_mm_per_pixel(); 1042 + if !mm_per_px.is_finite() || mm_per_px <= 0.0 { 1043 + return None; 1044 + } 1045 + let tol_mm = (SNAP_TOLERANCE_PX * mm_per_px).min(SNAP_TOLERANCE_MAX_MM); 1046 + Some(Length::new::<millimeter>(tol_mm)) 1047 + } 1048 + 1049 + fn compute_snap( 1050 + sketch: &Sketch, 1051 + camera: &Camera2, 1052 + cursor_world: Point2, 1053 + click: Option<ClickAnchor>, 1054 + ) -> Option<SnapHit> { 1055 + snap::detect( 1056 + cursor_world, 1057 + click.and_then(|c| resolve_anchor(Some(sketch), c)), 1058 + sketch, 1059 + snap_tolerance(camera)?, 1060 + ) 1061 + } 1062 + 1063 + fn compute_endpoint_snap( 1064 + sketch: &Sketch, 1065 + camera: &Camera2, 1066 + cursor_world: Point2, 1067 + ) -> Option<SnapHit> { 1068 + snap::detect_endpoint_only(cursor_world, sketch, snap_tolerance(camera)?) 1069 + } 1070 + 1071 + fn resolve_anchor(sketch: Option<&Sketch>, click: ClickAnchor) -> Option<Anchor> { 1072 + match click { 1073 + ClickAnchor::Position(pos) | ClickAnchor::MidpointOf { position: pos, .. } => { 1074 + Some(Anchor { pos, id: None }) 1075 + } 1076 + ClickAnchor::Endpoint(id) => match sketch?.entities().get(id)? { 1077 + SketchEntity::Point(p) => Some(Anchor { 1078 + pos: p.at(), 1079 + id: Some(id), 1080 + }), 1081 + _ => None, 1082 + }, 1083 + } 1084 + } 1085 + 1086 + fn pan_by_px(camera: Camera2, horizontal_px: f64, vertical_px: f64) -> Camera2 { 1087 + let mm_per_px = camera.world_mm_per_pixel(); 1088 + let (pan_x, pan_y) = camera.pan_mm().coords_mm(); 1089 + camera.with_pan(Vec2::from_mm( 1090 + pan_x - horizontal_px * mm_per_px, 1091 + pan_y + vertical_px * mm_per_px, 1092 + )) 1093 + } 1094 + 1095 + fn zoom_fit(camera: Camera2, scene: &SketchScene, viewport: LayoutRect) -> Camera2 { 1096 + let Some(aabb) = scene.aabb() else { 1097 + return camera; 1098 + }; 1099 + let (mnx, mny) = aabb.min().coords_mm(); 1100 + let (mxx, mxy) = aabb.max().coords_mm(); 1101 + let center_x = (mnx + mxx) * 0.5; 1102 + let center_y = (mny + mxy) * 0.5; 1103 + let world_w = mxx - mnx; 1104 + let world_h = mxy - mny; 1105 + let v_w = f64::from(viewport.size.width.value()); 1106 + let v_h = f64::from(viewport.size.height.value()); 1107 + if v_w <= 0.0 || v_h <= 0.0 { 1108 + return camera; 1109 + } 1110 + let axis_zoom = |pixels: f64, world: f64| { 1111 + if world > 0.0 { 1112 + pixels / world 1113 + } else { 1114 + f64::INFINITY 1115 + } 1116 + }; 1117 + let raw_zoom = axis_zoom(v_w, world_w).min(axis_zoom(v_h, world_h)) * ZOOM_FIT_MARGIN; 1118 + let zoom = raw_zoom.clamp(ZOOM_MIN, ZOOM_MAX); 1119 + let pan = pan_centering((center_x, center_y), camera.extent(), viewport, zoom); 1120 + camera.with_zoom(PixelsPerMm::new(zoom)).with_pan(pan) 1121 + } 1122 + 1123 + fn pan_centering( 1124 + target_world: (f64, f64), 1125 + window: ViewportExtent, 1126 + viewport: LayoutRect, 1127 + zoom: f64, 1128 + ) -> Vec2 { 1129 + let v_w = f64::from(viewport.size.width.value()); 1130 + let v_h = f64::from(viewport.size.height.value()); 1131 + let viewport_center_x = f64::from(viewport.origin.x.value()) + v_w * 0.5; 1132 + let viewport_center_y = f64::from(viewport.origin.y.value()) + v_h * 0.5; 1133 + let window_center_x = f64::from(window.width().value()) * 0.5; 1134 + let window_center_y = f64::from(window.height().value()) * 0.5; 1135 + let pan_x = target_world.0 - (viewport_center_x - window_center_x) / zoom; 1136 + let pan_y = target_world.1 + (viewport_center_y - window_center_y) / zoom; 1137 + Vec2::from_mm(pan_x, pan_y) 1138 + } 1139 + 1140 + fn keyboard_camera(nav: NavKey, input: &InputState, state: &AppState) -> Option<Camera2> { 1141 + if input.ctrl_or_meta() { 1142 + return None; 1143 + } 1144 + let camera = state.camera; 1145 + let step = input.pan_step_px(); 1146 + let shift = input.shift(); 1147 + match nav { 1148 + NavKey::Left => Some(pan_by_px(camera, step, 0.0)), 1149 + NavKey::Right => Some(pan_by_px(camera, -step, 0.0)), 1150 + NavKey::Up => Some(pan_by_px(camera, 0.0, step)), 1151 + NavKey::Down => Some(pan_by_px(camera, 0.0, -step)), 1152 + NavKey::Zoom => Some(zoom_about( 1153 + camera, 1154 + input.cursor_px, 1155 + if shift { 1156 + 1.0 / ZOOM_KEY_STEP 1157 + } else { 1158 + ZOOM_KEY_STEP 1159 + }, 1160 + )), 1161 + NavKey::ZoomIn => Some(zoom_about(camera, input.cursor_px, ZOOM_KEY_STEP)), 1162 + NavKey::ZoomOut => Some(zoom_about(camera, input.cursor_px, 1.0 / ZOOM_KEY_STEP)), 1163 + } 1164 + } 1165 + 1166 + fn zoom_key3( 1167 + camera: Camera3, 1168 + extent: ViewportExtent, 1169 + pixel: ViewportPoint, 1170 + factor: f64, 1171 + ) -> Option<Camera3> { 1172 + ZoomFactor::new(factor) 1173 + .ok() 1174 + .and_then(|f| zoom_about_pixel(camera, extent, pixel, f).ok()) 1175 + } 1176 + 1177 + fn keyboard_camera3(nav: NavKey, input: &InputState, state: &AppState) -> Option<Camera3> { 1178 + let camera = state.camera3?; 1179 + let region = solid_viewport_region(state.viewport_rect, state.extent)?; 1180 + let extent = region.extent(); 1181 + let ctrl = input.ctrl_or_meta(); 1182 + let shift = input.shift(); 1183 + let alt = input.modifiers.contains(ModifierMask::ALT); 1184 + let cx = f64::from(extent.width().value()) * 0.5; 1185 + let cy = f64::from(extent.height().value()) * 0.5; 1186 + let center = ViewportPoint::new(cx, cy).ok()?; 1187 + let pan_to = |dx: f64, dy: f64| { 1188 + ViewportPoint::new(cx + dx, cy + dy) 1189 + .ok() 1190 + .and_then(|to| pan_pixels(camera, extent, center, to).ok()) 1191 + }; 1192 + let step = Angle::new::<degree>(if shift { 1193 + ORBIT_KEY_SNAP_DEG 1194 + } else { 1195 + ORBIT_KEY_STEP_DEG 1196 + }); 1197 + match nav { 1198 + NavKey::Left if ctrl => pan_to(-PAN_STEP_PX, 0.0), 1199 + NavKey::Right if ctrl => pan_to(PAN_STEP_PX, 0.0), 1200 + NavKey::Up if ctrl => pan_to(0.0, -PAN_STEP_PX), 1201 + NavKey::Down if ctrl => pan_to(0.0, PAN_STEP_PX), 1202 + NavKey::Left if alt => roll_by(camera, step).ok(), 1203 + NavKey::Right if alt => roll_by(camera, -step).ok(), 1204 + NavKey::Left => orbit_yaw(camera, step).ok(), 1205 + NavKey::Right => orbit_yaw(camera, -step).ok(), 1206 + NavKey::Up => orbit_pitch(camera, step).ok(), 1207 + NavKey::Down => orbit_pitch(camera, -step).ok(), 1208 + NavKey::Zoom if shift => zoom_key3(camera, extent, center, 1.0 / ZOOM_KEY_STEP), 1209 + NavKey::Zoom | NavKey::ZoomIn => zoom_key3(camera, extent, center, ZOOM_KEY_STEP), 1210 + NavKey::ZoomOut => zoom_key3(camera, extent, center, 1.0 / ZOOM_KEY_STEP), 1211 + } 1212 + } 1213 + 1214 + fn build_hotkey_table() -> HotkeyTable { 1215 + let Ok(table) = hotkeys::compose_table(&hotkeys::HotkeyOverrides::default()) else { 1216 + unreachable!("default hotkey bindings are conflict-free"); 1217 + }; 1218 + table 1219 + } 1220 + 1221 + fn scopes_for_mode(mode: &Mode) -> HotkeyScopes { 1222 + let mut scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]); 1223 + if mode.is_sketch() { 1224 + scopes.push(HotkeyScope::Sketch); 1225 + } 1226 + if mode.is_extrude() { 1227 + scopes.push(HotkeyScope::Extrude); 1228 + } 1229 + scopes 1230 + } 1231 + 1232 + fn next_mode( 1233 + mode: Mode, 1234 + frame: &shell::ShellFrame, 1235 + escape_requested: bool, 1236 + plane_sketches: &BTreeMap<Plane, SketchId>, 1237 + ) -> Mode { 1238 + let after_pick = resolve_pick(mode, frame, plane_sketches); 1239 + let after_escape = if escape_requested { 1240 + cancel_pending_or_exit(after_pick) 1241 + } else { 1242 + after_pick 1243 + }; 1244 + let after_exit = if frame.exit_sketch { 1245 + Mode::Idle 1246 + } else { 1247 + after_escape 1248 + }; 1249 + match frame.activated_tool { 1250 + Some(t) => toggle_or_arm(after_exit, t), 1251 + None => after_exit, 1252 + } 1253 + } 1254 + 1255 + fn resolve_pick( 1256 + mode: Mode, 1257 + frame: &shell::ShellFrame, 1258 + plane_sketches: &BTreeMap<Plane, SketchId>, 1259 + ) -> Mode { 1260 + if mode.is_extrude() { 1261 + return match frame.sketch_activated { 1262 + Some(id) => match &mode { 1263 + Mode::Extrude(ExtrudeArming::Profile { feature, .. }) if feature.sketch == id => { 1264 + mode 1265 + } 1266 + _ => Mode::Extrude(ExtrudeArming::profile(id)), 1267 + }, 1268 + None => mode, 1269 + }; 1270 + } 1271 + let plane_pick = frame 1272 + .plane_picked 1273 + .filter(|_| !mode.is_sketch()) 1274 + .and_then(|plane| plane_sketches.get(&plane).copied()); 1275 + let sketch_pick = frame.sketch_activated.filter(|_| !mode.is_sketch()); 1276 + sketch_pick.or(plane_pick).map_or(mode, Mode::enter_sketch) 1277 + } 1278 + 1279 + fn toggle_or_arm(mode: Mode, tool: SketchTool) -> Mode { 1280 + let already_active = matches!( 1281 + &mode, 1282 + Mode::Sketch { session, .. } if session.tool == Some(tool) 1283 + ); 1284 + if already_active { 1285 + mode.disarm_tool() 1286 + } else { 1287 + mode.arm_tool(tool) 1288 + } 1289 + } 1290 + 1291 + fn cancel_pending_or_exit(mode: Mode) -> Mode { 1292 + match &mode { 1293 + Mode::Sketch { session, .. } if session.pending.is_some() => mode.clear_pending(), 1294 + Mode::Sketch { session, .. } if session.tool.is_some() => mode.disarm_tool(), 1295 + Mode::Sketch { .. } | Mode::Idle | Mode::Extrude(_) => Mode::Idle, 1296 + } 1297 + } 1298 + 1299 + impl AppCore { 1300 + pub fn new( 1301 + gpu: &Gpu, 1302 + color_format: wgpu::TextureFormat, 1303 + extent: ViewportExtent, 1304 + ) -> Result<Self, PickIdError> { 1305 + let renderer = SketchRenderer::new(gpu, color_format); 1306 + let solid_renderer = SolidRenderer::new(gpu, color_format); 1307 + let chrome_pipeline = ChromePipeline::new(gpu, color_format); 1308 + let convex_pipeline = ConvexPolyPipeline::new(gpu, color_format); 1309 + let stroke_pipeline = StrokePipeline::new(gpu, color_format); 1310 + let sdf_atlas = MaskAtlas::new(MaskAtlasParams::STANDARD); 1311 + let text_pipeline = ChromeTextPipeline::new(gpu, color_format, sdf_atlas.extent()); 1312 + let chrome_shaper = Shaper::new(); 1313 + let sans_font = bone_text::load_font(bone_text::FontFace::Sans); 1314 + let mono_font = bone_text::load_font(bone_text::FontFace::Mono); 1315 + let sketch = default_sketch(); 1316 + let scene = SketchScene::extract(&sketch)?; 1317 + let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(INITIAL_ZOOM_PX_PER_MM)); 1318 + let style = Style::default(); 1319 + let theme = Arc::new(Theme::light()); 1320 + let shell = shell::Shell::new(); 1321 + let (document, sketch_id) = initial_document(sketch); 1322 + let last_saved_baseline = document.clone(); 1323 + let plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); 1324 + let strings = strings::make_strings(bone_ui::strings::Locale::EnUs); 1325 + let viewport_rect = empty_rect(); 1326 + let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { 1327 + unreachable!("UNDO_CAPACITY constant is non-zero"); 1328 + }; 1329 + let loaded_settings = settings::load(); 1330 + let initial_hotkeys = match hotkeys::compose_table(&loaded_settings.hotkey_overrides) { 1331 + Ok(table) => table, 1332 + Err(e) => { 1333 + tracing::warn!(error = %e, "stored hotkey overrides conflict, using defaults"); 1334 + build_hotkey_table() 1335 + } 1336 + }; 1337 + let state = AppState { 1338 + extent, 1339 + renderer, 1340 + chrome_pipeline, 1341 + convex_pipeline, 1342 + stroke_pipeline, 1343 + text_pipeline, 1344 + sdf_atlas, 1345 + chrome_shaper, 1346 + sans_font, 1347 + mono_font, 1348 + scene, 1349 + camera, 1350 + style, 1351 + theme, 1352 + shell, 1353 + document, 1354 + plane_sketches, 1355 + mode: Mode::Idle, 1356 + feature_cache: FeatureCache::new(), 1357 + extrude_preview: None, 1358 + solid_renderer, 1359 + solid_view: None, 1360 + camera3: None, 1361 + framed_extrude: None, 1362 + navigator: ViewportNavigator::new(), 1363 + view: view_cube::ViewUi::default(), 1364 + focus: FocusManager::new(), 1365 + hit_state: HitState::new(), 1366 + hotkeys: initial_hotkeys, 1367 + strings, 1368 + viewport_rect, 1369 + undo: UndoStack::with_capacity(undo_capacity), 1370 + selection: Selection::default(), 1371 + settings: loaded_settings, 1372 + dim_editor: DimensionEditorState::default(), 1373 + dim_editor_bounds: None, 1374 + pending_exit: false, 1375 + current_folder: None, 1376 + documents_root: file_menu::documents_root(), 1377 + file_picker: None, 1378 + native_picker: None, 1379 + step_job: None, 1380 + pending_overwrite: None, 1381 + last_saved: Some(last_saved_baseline), 1382 + pending_discard: None, 1383 + notification: None, 1384 + shortcut_bar: None, 1385 + }; 1386 + Ok(Self { 1387 + state, 1388 + input: InputState::default(), 1389 + clock: FrameClock::start(), 1390 + a11y: AccessTreeBuilder::new(), 1391 + }) 1392 + } 1393 + 1394 + pub fn handle_input( 1395 + &mut self, 1396 + target: &impl FrameTarget, 1397 + event: InputEvent, 1398 + ) -> InputDispatched { 1399 + match event { 1400 + InputEvent::Resize(extent) => { 1401 + self.state.extent = extent; 1402 + self.state.camera = self.state.camera.with_extent(extent); 1403 + self.state.viewport_rect = empty_rect(); 1404 + } 1405 + InputEvent::Focus(focused) => { 1406 + if !focused { 1407 + self.input.forget_pan_state(); 1408 + self.state.navigator.end_drag(); 1409 + } 1410 + } 1411 + InputEvent::Modifiers(mods) => { 1412 + self.input.modifiers = mods; 1413 + } 1414 + InputEvent::CursorMove(position) => { 1415 + let state = &mut self.state; 1416 + let prev = self.input.cursor_px; 1417 + self.input.cursor_px = Some(position); 1418 + let modal = modal_active(state); 1419 + if !modal 1420 + && state.navigator.is_dragging() 1421 + && let Some(camera) = state.camera3 1422 + && let Some(region) = solid_viewport_region(state.viewport_rect, state.extent) 1423 + && let Some(cursor) = viewport_local_point(position, region) 1424 + && let Ok(next) = state.navigator.drag_to(cursor, camera, region.extent()) 1425 + { 1426 + apply_nav_camera(state, next); 1427 + } else if !modal 1428 + && self.input.panning() 1429 + && let Some(p) = prev 1430 + { 1431 + state.camera = pan_by_px(state.camera, position.x - p.x, position.y - p.y); 1432 + } else if !modal 1433 + && dragging_in_sketch(&state.mode) 1434 + && let Some(world) = cursor_to_world(state.camera, position) 1435 + { 1436 + try_drag_to(state, world); 1437 + } 1438 + } 1439 + InputEvent::CursorLeft => { 1440 + self.input.cursor_px = None; 1441 + } 1442 + InputEvent::CursorEntered => {} 1443 + InputEvent::Pointer { button, pressed } => { 1444 + self.dispatch_pointer_button(target, button, pressed); 1445 + } 1446 + InputEvent::Wheel(delta) => { 1447 + self.dispatch_wheel(delta); 1448 + } 1449 + InputEvent::KeyDown(key) => { 1450 + self.dispatch_keydown(key); 1451 + } 1452 + } 1453 + InputDispatched::after_input() 1454 + } 1455 + 1456 + fn dispatch_wheel(&mut self, delta: ScrollDelta) { 1457 + let state = &mut self.state; 1458 + if modal_active(state) || !self.input.cursor_in(state.viewport_rect) { 1459 + return; 1460 + } 1461 + if state.solid_view.is_none() { 1462 + state.camera = zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); 1463 + return; 1464 + } 1465 + let (Some(camera), Some(region)) = ( 1466 + state.camera3, 1467 + solid_viewport_region(state.viewport_rect, state.extent), 1468 + ) else { 1469 + return; 1470 + }; 1471 + match delta { 1472 + ScrollDelta::Pixels { x, y } => { 1473 + if let Ok(next) = state.navigator.orbit_pixels(camera, region.extent(), x, y) { 1474 + apply_nav_camera(state, next); 1475 + } 1476 + } 1477 + ScrollDelta::Lines { .. } => { 1478 + if let Some(cursor) = self 1479 + .input 1480 + .cursor_px 1481 + .and_then(|p| viewport_local_point(p, region)) 1482 + && let Ok(factor) = ZoomFactor::new(zoom_factor(delta)) 1483 + && let Ok(next) = zoom_about_pixel(camera, region.extent(), cursor, factor) 1484 + { 1485 + apply_nav_camera(state, next); 1486 + } 1487 + } 1488 + } 1489 + } 1490 + 1491 + fn dispatch_pointer_button( 1492 + &mut self, 1493 + target: &impl FrameTarget, 1494 + button: PointerButton, 1495 + pressed: bool, 1496 + ) { 1497 + let state = &mut self.state; 1498 + let modal = modal_active(state); 1499 + match button { 1500 + PointerButton::Primary => { 1501 + if pressed { 1502 + let in_viewport = self.input.cursor_in(state.viewport_rect); 1503 + let over_dim_editor = state 1504 + .dim_editor_bounds 1505 + .is_some_and(|r| self.input.cursor_in(r)); 1506 + let dim_active = dim_flow_active(&state.mode); 1507 + self.input.left_pan = !modal && in_viewport && !over_dim_editor && !dim_active; 1508 + self.input.pending_pressed = 1509 + self.input.pending_pressed.with(PointerButton::Primary); 1510 + if !modal 1511 + && in_viewport 1512 + && !over_dim_editor 1513 + && !dim_active 1514 + && !self.input.shift() 1515 + && let Some(cursor) = self.input.cursor_px 1516 + { 1517 + if state.mode.active_tool().is_some() { 1518 + if let Some(world) = cursor_to_world(state.camera, cursor) { 1519 + try_place(state, world); 1520 + } 1521 + } else { 1522 + let additive = self.input.ctrl_or_meta(); 1523 + handle_viewport_click(state, target, cursor, additive); 1524 + } 1525 + } 1526 + } else { 1527 + self.input.left_pan = false; 1528 + state.mode = core::mem::take(&mut state.mode).end_drag(); 1529 + self.input.pending_released = 1530 + self.input.pending_released.with(PointerButton::Primary); 1531 + } 1532 + } 1533 + PointerButton::Secondary => { 1534 + if pressed { 1535 + self.input.pending_pressed = 1536 + self.input.pending_pressed.with(PointerButton::Secondary); 1537 + if !modal && self.input.cursor_in(state.viewport_rect) { 1538 + state.mode = core::mem::take(&mut state.mode).clear_pending(); 1539 + } 1540 + } else { 1541 + self.input.pending_released = 1542 + self.input.pending_released.with(PointerButton::Secondary); 1543 + } 1544 + } 1545 + PointerButton::Middle => { 1546 + if pressed { 1547 + let in_viewport = !modal && self.input.cursor_in(state.viewport_rect); 1548 + if in_viewport 1549 + && state.solid_view.is_some() 1550 + && let Some(region) = 1551 + solid_viewport_region(state.viewport_rect, state.extent) 1552 + && let Some(cursor) = self 1553 + .input 1554 + .cursor_px 1555 + .and_then(|p| viewport_local_point(p, region)) 1556 + { 1557 + state 1558 + .navigator 1559 + .begin_drag(drag_gesture(self.input.modifiers), cursor); 1560 + } else { 1561 + self.input.middle_pan = in_viewport; 1562 + } 1563 + self.input.pending_pressed = 1564 + self.input.pending_pressed.with(PointerButton::Middle); 1565 + } else { 1566 + self.input.middle_pan = false; 1567 + state.navigator.end_drag(); 1568 + self.input.pending_released = 1569 + self.input.pending_released.with(PointerButton::Middle); 1570 + } 1571 + } 1572 + } 1573 + } 1574 + 1575 + fn dispatch_keydown(&mut self, key: KeyDown) { 1576 + let state = &mut self.state; 1577 + let mods = self.input.modifiers; 1578 + let named = match key.code { 1579 + Some(UiKeyCode::Named(named)) => Some(named), 1580 + Some(UiKeyCode::Char(_)) | None => None, 1581 + }; 1582 + let repeat_space = key.repeat && named == Some(NamedKey::Space); 1583 + if let Some(code) = key.code 1584 + && !repeat_space 1585 + { 1586 + self.input.pending_keys.push(UiKeyEvent::new(code, mods)); 1587 + } 1588 + if let Some(typed) = key.text { 1589 + let filtered: String = typed.chars().filter(|c| !c.is_control()).collect(); 1590 + if !filtered.is_empty() { 1591 + self.input.pending_text.push_str(&filtered); 1592 + } 1593 + } 1594 + let suppress_camera = dim_flow_active(&state.mode) || state.focus.focused().is_some(); 1595 + if matches!(named, Some(NamedKey::Escape)) && state.notification.is_some() { 1596 + state.notification = None; 1597 + } 1598 + if let Some(nav) = key.nav 1599 + && !suppress_camera 1600 + { 1601 + if state.solid_view.is_some() { 1602 + if let Some(next) = keyboard_camera3(nav, &self.input, state) { 1603 + apply_nav_camera(state, next); 1604 + } 1605 + } else if let Some(next) = keyboard_camera(nav, &self.input, state) { 1606 + state.camera = next; 1607 + } 1608 + } 1609 + } 1610 + 1611 + pub fn render_frame(&mut self, target: &mut impl FrameTarget) -> FrameReport { 1612 + render_frame( 1613 + &mut self.state, 1614 + target, 1615 + &mut self.input, 1616 + &mut self.a11y, 1617 + self.clock.now(), 1618 + ) 1619 + } 1620 + 1621 + #[must_use] 1622 + pub fn access_tree(&self) -> accesskit::TreeUpdate { 1623 + self.a11y 1624 + .build(&self.state.strings, self.state.focus.focused()) 1625 + } 1626 + 1627 + pub fn open_document(&mut self, path: PathBuf) -> Result<(), OpenError> { 1628 + if file_menu::is_step_file(&path) { 1629 + let document = 1630 + bone_interop::read(&path, bone_interop::CancelFlag::never()).map_err(|source| { 1631 + OpenError::ImportStep { 1632 + path: path.clone(), 1633 + source, 1634 + } 1635 + })?; 1636 + install_imported_document(&mut self.state, document); 1637 + } else { 1638 + let folder = DocumentFolder::new(path.clone()); 1639 + let document = bone_document::load(&folder) 1640 + .map_err(|source| OpenError::LoadFolder { path, source })?; 1641 + install_loaded_document(&mut self.state, document, Some(folder)); 1642 + } 1643 + Ok(()) 1644 + } 1645 + 1646 + pub fn set_theme(&mut self, mode: bone_ui::theme::ThemeMode) { 1647 + self.state.theme = Arc::new(match mode { 1648 + bone_ui::theme::ThemeMode::Light => Theme::light(), 1649 + bone_ui::theme::ThemeMode::Dark => Theme::dark(), 1650 + }); 1651 + } 1652 + 1653 + pub fn clock_mut(&mut self) -> &mut FrameClock { 1654 + &mut self.clock 1655 + } 1656 + 1657 + #[must_use] 1658 + pub fn next_wake_deadline(&self) -> Option<FrameInstant> { 1659 + next_wake_deadline(&self.state, self.clock.now()) 1660 + } 1661 + } 1662 + 1663 + fn next_wake_deadline(state: &AppState, now: FrameInstant) -> Option<FrameInstant> { 1664 + let native_poll = state 1665 + .native_picker 1666 + .is_some() 1667 + .then(|| now.after(std::time::Duration::from_millis(40))); 1668 + let step_poll = state 1669 + .step_job 1670 + .is_some() 1671 + .then(|| now.after(std::time::Duration::from_millis(40))); 1672 + let rename_deadline = state 1673 + .shell 1674 + .state 1675 + .feature_tree 1676 + .pending_rename 1677 + .map(|pending| { 1678 + let window = bone_ui::input::DoubleClickWindow::DEFAULT.duration(); 1679 + let slack = std::time::Duration::from_millis(8); 1680 + pending.at.after(window + slack) 1681 + }); 1682 + let tween_tick = state 1683 + .view 1684 + .tween 1685 + .is_some() 1686 + .then(|| now.after(std::time::Duration::from_millis(8))); 1687 + [native_poll, step_poll, rename_deadline, tween_tick] 1688 + .into_iter() 1689 + .flatten() 1690 + .min() 1691 + } 1692 + 1693 + #[allow( 1694 + clippy::cast_precision_loss, 1695 + reason = "viewport pixel counts at any realistic display size fit f32 mantissa" 1696 + )] 1697 + #[allow( 1698 + clippy::too_many_lines, 1699 + reason = "splitting hides the per-outcome dispatch table" 1700 + )] 1701 + fn render_frame( 1702 + state: &mut AppState, 1703 + target: &mut impl FrameTarget, 1704 + input_state: &mut InputState, 1705 + a11y: &mut AccessTreeBuilder, 1706 + now: FrameInstant, 1707 + ) -> FrameReport { 1708 + poll_native_picker(state); 1709 + poll_step_job(state); 1710 + let extent = state.extent; 1711 + let layout_size = layout_size_from_extent(extent); 1712 + let theme = Arc::clone(&state.theme); 1713 + let mut input = input_state.drain_snapshot(now); 1714 + step_view_tween(state, input.frame); 1715 + let mut hits = HitFrame::new(); 1716 + a11y.begin_frame(); 1717 + let scopes = scopes_for_mode(&state.mode); 1718 + let chrome_cursor_world = input_state 1719 + .cursor_px 1720 + .filter(|c| state.viewport_rect.contains(window_to_layout_pos(*c))) 1721 + .and_then(|c| cursor_to_world(state.camera, c)); 1722 + let FrameOutcomes { 1723 + mut frame, 1724 + hotkey_actions, 1725 + dim: dim_outcome, 1726 + dim_conflict: conflict_outcome, 1727 + picker: picker_outcome, 1728 + overwrite: overwrite_outcome, 1729 + discard: discard_outcome, 1730 + step_progress: step_progress_outcome, 1731 + notification: notification_outcome, 1732 + shortcut_bar: shortcut_bar_outcome, 1733 + } = run_frame_ui( 1734 + state, 1735 + theme, 1736 + &mut input, 1737 + &mut hits, 1738 + a11y, 1739 + &scopes, 1740 + layout_size, 1741 + chrome_cursor_world, 1742 + ); 1743 + state.dim_editor_bounds = apply_popup_overlays( 1744 + &mut frame.overlay_paints, 1745 + dim_outcome.as_ref(), 1746 + conflict_outcome.as_ref(), 1747 + picker_outcome.as_ref(), 1748 + overwrite_outcome.as_ref(), 1749 + discard_outcome.as_ref(), 1750 + step_progress_outcome.as_ref(), 1751 + notification_outcome.as_ref(), 1752 + shortcut_bar_outcome.as_ref(), 1753 + ); 1754 + apply_shortcut_bar_outcome(state, shortcut_bar_outcome.as_ref()); 1755 + let picker_kind = state.file_picker.as_ref().map(|s| s.kind); 1756 + if let (Some(cmd), Some(kind)) = (picker_outcome.and_then(|o| o.command), picker_kind) { 1757 + apply_picker_command(state, kind, cmd); 1758 + } 1759 + apply_overwrite_outcome(state, overwrite_outcome); 1760 + apply_discard_outcome(state, discard_outcome); 1761 + apply_step_progress_outcome(state, step_progress_outcome); 1762 + apply_notification_outcome(state, notification_outcome); 1763 + let claimed_pointer = dim_outcome.as_ref().is_some_and(|o| o.claimed_pointer); 1764 + let frame = if claimed_pointer { 1765 + suppress_pointer_activations(frame) 1766 + } else { 1767 + frame 1768 + }; 1769 + state.viewport_rect = frame.viewport_rect; 1770 + state.hit_state = resolve(&state.hit_state, &hits, &input, state.focus.focused()); 1771 + let kick = any_actionable_interaction(&state.hit_state); 1772 + if let Some(plane) = frame.plane_picked { 1773 + match ( 1774 + state.mode.is_sketch(), 1775 + state.plane_sketches.contains_key(&plane), 1776 + ) { 1777 + (true, _) => { 1778 + tracing::debug!(?plane, "plane pick ignored: already in sketch mode"); 1779 + } 1780 + (false, false) => { 1781 + tracing::debug!(?plane, "plane pick ignored: no sketch on this plane"); 1782 + } 1783 + (false, true) => {} 1784 + } 1785 + } 1786 + let escape_requested = hotkey_actions.contains(&sketch_mode::ESCAPE_ACTION); 1787 + apply_extrude_edit(state, frame.extrude_edit); 1788 + apply_extrude_confirm(state, frame.confirm_action); 1789 + let prev_active_sketch = active_sketch_id(&state.mode, &state.plane_sketches); 1790 + state.mode = next_mode( 1791 + core::mem::take(&mut state.mode), 1792 + &frame, 1793 + escape_requested, 1794 + &state.plane_sketches, 1795 + ); 1796 + apply_feature_tool(state, frame.activated_feature_tool); 1797 + apply_extrude_activation(state, frame.extrude_activated); 1798 + if active_sketch_id(&state.mode, &state.plane_sketches) != prev_active_sketch { 1799 + refresh_active_scene(state); 1800 + } 1801 + apply_dimension_outcome(state, dim_outcome); 1802 + apply_dim_conflict_outcome(state, conflict_outcome); 1803 + apply_dimension_request(state, frame.activated_dimension); 1804 + let dimension_edit = match frame.confirm_action { 1805 + Some(shell::ConfirmAction::Cancel) => None, 1806 + _ => frame.dimension_edit, 1807 + }; 1808 + apply_dimension_edit(state, dimension_edit); 1809 + sync_solid_view(state); 1810 + let solid_region = solid_viewport_region(state.viewport_rect, extent); 1811 + sync_solid_camera(state, solid_region); 1812 + let cursor_layout = input_state.cursor_px.map(window_to_layout_pos); 1813 + apply_hotkey_actions(state, &hotkey_actions, cursor_layout, input.frame); 1814 + apply_view_pick(state, frame.view_pick, input.frame); 1815 + apply_view_menu(state, frame.view_menu, input.frame); 1816 + apply_menu_action(state, frame.menu_action); 1817 + apply_settings_change(state, frame.settings_change); 1818 + apply_relation_action(state, frame.activated_relation); 1819 + apply_sketch_rename(state, frame.sketch_rename.clone()); 1820 + apply_extrude_rename(state, frame.extrude_rename.clone()); 1821 + let cursor_world = input_state 1822 + .cursor_px 1823 + .filter(|c| state.viewport_rect.contains(window_to_layout_pos(*c))) 1824 + .and_then(|c| cursor_to_world(state.camera, c)); 1825 + let preview = build_preview(&state.mode, &state.document, cursor_world, &state.camera); 1826 + let main_layer = build_chrome_layer(state, &frame.paints); 1827 + let overlay_layer = build_chrome_layer(state, &frame.overlay_paints); 1828 + let atlas_pixels = state.sdf_atlas.pixels(); 1829 + let atlas_version = state.sdf_atlas.version(); 1830 + let viewport_px = [ 1831 + extent.width().value() as f32, 1832 + extent.height().value() as f32, 1833 + ]; 1834 + let renderer = &mut state.renderer; 1835 + let mut chrome_stage = ChromeStage { 1836 + chrome: &mut state.chrome_pipeline, 1837 + convex: &mut state.convex_pipeline, 1838 + stroke: &mut state.stroke_pipeline, 1839 + text: &mut state.text_pipeline, 1840 + atlas_pixels, 1841 + atlas_version, 1842 + viewport_px, 1843 + }; 1844 + let scene = &state.scene; 1845 + let style = &state.style; 1846 + renderer.prepare(scene, style); 1847 + let viewport = ViewportEncode { 1848 + solid: solid_region.and_then(|region| { 1849 + preview_solid_frame(state.solid_view.as_ref(), state.camera3, region) 1850 + }), 1851 + solid_renderer: &state.solid_renderer, 1852 + sketch_renderer: renderer, 1853 + scene, 1854 + preview: &preview, 1855 + camera: state.camera, 1856 + style, 1857 + }; 1858 + target.render(|encoder, color, pick, depth| { 1859 + viewport.encode(encoder, color, pick, depth); 1860 + chrome_stage.encode_layered(encoder, color, &main_layer, &overlay_layer); 1861 + }); 1862 + FrameReport { 1863 + kick, 1864 + exit: state.pending_exit, 1865 + } 1866 + } 1867 + 1868 + struct ViewportEncode<'a> { 1869 + solid: Option<(&'a SolidViewData, SolidFrameView)>, 1870 + solid_renderer: &'a SolidRenderer, 1871 + sketch_renderer: &'a SketchRenderer, 1872 + scene: &'a SketchScene, 1873 + preview: &'a SketchPreview, 1874 + camera: Camera2, 1875 + style: &'a Style, 1876 + } 1877 + 1878 + impl ViewportEncode<'_> { 1879 + fn encode( 1880 + &self, 1881 + encoder: &mut wgpu::CommandEncoder, 1882 + color: &wgpu::TextureView, 1883 + pick: &wgpu::TextureView, 1884 + depth: &wgpu::TextureView, 1885 + ) { 1886 + let targets = RenderTargets::new(color, pick); 1887 + match &self.solid { 1888 + Some((view, frame)) => self.solid_renderer.encode_passes( 1889 + encoder, 1890 + targets, 1891 + depth, 1892 + &view.faces, 1893 + &view.edges, 1894 + &bone_render::SolidDisplay { 1895 + view: frame, 1896 + style: self.style, 1897 + mode: DisplayMode::ShadedWithEdges, 1898 + }, 1899 + ), 1900 + None => self.sketch_renderer.encode_passes( 1901 + encoder, 1902 + targets, 1903 + self.scene, 1904 + self.preview, 1905 + self.camera, 1906 + self.style, 1907 + ), 1908 + } 1909 + } 1910 + } 1911 + 1912 + struct ChromeStage<'a> { 1913 + chrome: &'a mut ChromePipeline, 1914 + convex: &'a mut ConvexPolyPipeline, 1915 + stroke: &'a mut StrokePipeline, 1916 + text: &'a mut ChromeTextPipeline, 1917 + atlas_pixels: &'a [u8], 1918 + atlas_version: u64, 1919 + viewport_px: [f32; 2], 1920 + } 1921 + 1922 + fn merge_layers<T: Copy>(main: &[T], overlay: &[T]) -> Vec<T> { 1923 + main.iter().chain(overlay.iter()).copied().collect() 1924 + } 1925 + 1926 + fn count_u32(len: usize) -> u32 { 1927 + let Ok(count) = u32::try_from(len) else { 1928 + unreachable!("instance counts fit in u32"); 1929 + }; 1930 + count 1931 + } 1932 + 1933 + impl ChromeStage<'_> { 1934 + fn encode_layered( 1935 + &mut self, 1936 + encoder: &mut wgpu::CommandEncoder, 1937 + color: &wgpu::TextureView, 1938 + main: &ChromeLayer, 1939 + overlay: &ChromeLayer, 1940 + ) { 1941 + let chrome = merge_layers(&main.chrome, &overlay.chrome); 1942 + let convex = merge_layers(&main.convex, &overlay.convex); 1943 + let stroke = merge_layers(&main.stroke, &overlay.stroke); 1944 + let glyphs = merge_layers(&main.glyphs, &overlay.glyphs); 1945 + self.chrome.upload(self.viewport_px, &chrome); 1946 + self.convex.upload(self.viewport_px, &convex); 1947 + self.stroke.upload(self.viewport_px, &stroke); 1948 + self.text.upload( 1949 + self.viewport_px, 1950 + self.atlas_pixels, 1951 + self.atlas_version, 1952 + &glyphs, 1953 + ); 1954 + let (mc, tc) = (count_u32(main.chrome.len()), count_u32(chrome.len())); 1955 + let (mv, tv) = (count_u32(main.convex.len()), count_u32(convex.len())); 1956 + let (ms, ts) = (count_u32(main.stroke.len()), count_u32(stroke.len())); 1957 + let (mg, tg) = (count_u32(main.glyphs.len()), count_u32(glyphs.len())); 1958 + self.chrome.draw_range(encoder, color, 0..mc); 1959 + self.convex.draw_range(encoder, color, 0..mv); 1960 + self.stroke.draw_range(encoder, color, 0..ms); 1961 + self.text.draw_range(encoder, color, 0..mg); 1962 + self.chrome.draw_range(encoder, color, mc..tc); 1963 + self.convex.draw_range(encoder, color, mv..tv); 1964 + self.stroke.draw_range(encoder, color, ms..ts); 1965 + self.text.draw_range(encoder, color, mg..tg); 1966 + } 1967 + } 1968 + 1969 + fn any_actionable_interaction(hit_state: &HitState) -> bool { 1970 + use bone_ui::hit_test::InteractionState; 1971 + hit_state.interactions.values().any(|i| { 1972 + i.state.contains(InteractionState::CLICK) 1973 + || i.state.contains(InteractionState::DOUBLE_CLICK) 1974 + || i.state.contains(InteractionState::DRAG_START) 1975 + || i.state.contains(InteractionState::DRAG_RELEASE) 1976 + }) 1977 + } 1978 + 1979 + struct ChromeLayer { 1980 + chrome: Vec<ChromeInstance>, 1981 + convex: Vec<ConvexInstance>, 1982 + stroke: Vec<StrokeInstance>, 1983 + glyphs: Vec<SdfGlyphInstance>, 1984 + } 1985 + 1986 + fn build_chrome_layer( 1987 + state: &mut AppState, 1988 + paints: &[bone_ui::widgets::WidgetPaint], 1989 + ) -> ChromeLayer { 1990 + let chrome = chrome::paint_to_instances(&state.theme, paints); 1991 + let convex = chrome::paint_to_convex_instances(paints); 1992 + let stroke = chrome::paint_to_stroke_instances(paints); 1993 + let spans = chrome::paint_to_text_spans(paints, &state.strings); 1994 + let glyphs = chrome::build_glyph_instances( 1995 + &spans, 1996 + &mut state.sdf_atlas, 1997 + &mut state.chrome_shaper, 1998 + &state.sans_font, 1999 + &state.mono_font, 2000 + ); 2001 + ChromeLayer { 2002 + chrome, 2003 + convex, 2004 + stroke, 2005 + glyphs, 2006 + } 2007 + } 2008 + 2009 + fn apply_hotkey_actions( 2010 + state: &mut AppState, 2011 + actions: &[ActionId], 2012 + cursor_layout: Option<LayoutPos>, 2013 + now: FrameInstant, 2014 + ) { 2015 + actions 2016 + .iter() 2017 + .filter_map(|a| hotkeys::command_for_action(*a)) 2018 + .for_each(|cmd| dispatch_hotkey_command(state, cmd, cursor_layout, now)); 2019 + } 2020 + 2021 + fn dispatch_hotkey_command( 2022 + state: &mut AppState, 2023 + cmd: hotkeys::HotkeyCommand, 2024 + cursor_layout: Option<LayoutPos>, 2025 + now: FrameInstant, 2026 + ) { 2027 + use hotkeys::HotkeyCommand as C; 2028 + match cmd { 2029 + C::Undo => { 2030 + if state.undo.undo(&mut state.document) { 2031 + refresh_active_scene(state); 2032 + } 2033 + } 2034 + C::Redo => { 2035 + if state.undo.redo(&mut state.document) { 2036 + refresh_active_scene(state); 2037 + } 2038 + } 2039 + C::NewDocument => apply_menu_action(state, Some(shell::MenuAction::NewDocument)), 2040 + C::OpenDocument => apply_menu_action(state, Some(shell::MenuAction::OpenDocument)), 2041 + C::SaveDocument => apply_menu_action(state, Some(shell::MenuAction::SaveDocument)), 2042 + C::ImportStep => apply_menu_action(state, Some(shell::MenuAction::ImportStep)), 2043 + C::ExportStep => apply_menu_action(state, Some(shell::MenuAction::ExportStep)), 2044 + C::ZoomFit => apply_menu_action(state, Some(shell::MenuAction::ZoomFit)), 2045 + C::Quit => { 2046 + state.pending_exit = true; 2047 + } 2048 + C::OpenShortcutBar => { 2049 + if state.shortcut_bar.is_none() { 2050 + let anchor = cursor_layout.unwrap_or(LayoutPos::ORIGIN); 2051 + state.shortcut_bar = Some(shortcut_bar::ShortcutBarState { anchor }); 2052 + } 2053 + } 2054 + C::ToggleConstruction => apply_construction_toggle(state), 2055 + C::Mirror => apply_mirror(state), 2056 + C::StandardView(view) => { 2057 + if view_nav_enabled(state) { 2058 + apply_view_pick(state, Some(view_cube::ViewPick::Standard(view)), now); 2059 + } 2060 + } 2061 + C::ToggleViewSelector => { 2062 + if view_nav_enabled(state) { 2063 + state.view.toggle_selector(); 2064 + } 2065 + } 2066 + C::ToggleViewCube => { 2067 + if view_nav_enabled(state) { 2068 + state.view.toggle_cube(); 2069 + } 2070 + } 2071 + C::SelectAll 2072 + | C::DeleteSelection 2073 + | C::EnterSketch 2074 + | C::SmartDimension 2075 + | C::Trim 2076 + | C::Extend => notify_stub(state, hotkeys::label_for_command(cmd)), 2077 + } 2078 + } 2079 + 2080 + fn apply_construction_toggle(state: &mut AppState) { 2081 + let Mode::Sketch { sketch_id, .. } = state.mode else { 2082 + return; 2083 + }; 2084 + let entity_ids: Vec<bone_types::SketchEntityId> = state.selection.entity_ids().to_vec(); 2085 + if entity_ids.is_empty() { 2086 + return; 2087 + } 2088 + let Some(sketch) = state.document.sketch(sketch_id) else { 2089 + return; 2090 + }; 2091 + let pivot = entity_ids 2092 + .iter() 2093 + .find_map(|id| match sketch.entities().get(*id)? { 2094 + SketchEntity::Line(l) => Some(l.for_construction()), 2095 + SketchEntity::Arc(a) => Some(a.for_construction()), 2096 + SketchEntity::Circle(c) => Some(c.for_construction()), 2097 + SketchEntity::Point(_) => None, 2098 + }); 2099 + let Some(current) = pivot else { 2100 + return; 2101 + }; 2102 + let target = !current; 2103 + let snapshot = state.document.clone(); 2104 + let result = 2105 + entity_ids 2106 + .iter() 2107 + .try_fold(sketch.clone(), |acc, id| match acc.entities().get(*id) { 2108 + Some(SketchEntity::Point(_)) | None => Ok(acc), 2109 + Some(_) => acc 2110 + .apply(bone_document::SketchEdit::SetConstruction { 2111 + id: *id, 2112 + for_construction: target, 2113 + }) 2114 + .map(|(s, _)| s), 2115 + }); 2116 + match result { 2117 + Ok(next) => { 2118 + state.undo.record(snapshot); 2119 + state.document.replace_sketch(sketch_id, next); 2120 + refresh_active_scene(state); 2121 + } 2122 + Err(e) => tracing::warn!(error = %e, "construction toggle failed"), 2123 + } 2124 + } 2125 + 2126 + fn apply_mirror(state: &mut AppState) { 2127 + let Mode::Sketch { sketch_id, .. } = state.mode else { 2128 + return; 2129 + }; 2130 + let entity_ids: Vec<bone_types::SketchEntityId> = state.selection.entity_ids().to_vec(); 2131 + if entity_ids.is_empty() { 2132 + return; 2133 + } 2134 + let Some(sketch) = state.document.sketch(sketch_id) else { 2135 + return; 2136 + }; 2137 + let axis_lines: Vec<(bone_types::SketchEntityId, LineData)> = entity_ids 2138 + .iter() 2139 + .filter_map(|id| match sketch.entities().get(*id)? { 2140 + SketchEntity::Line(l) => Some((*id, *l)), 2141 + _ => None, 2142 + }) 2143 + .collect(); 2144 + let [(axis_id, axis_line)] = axis_lines.as_slice() else { 2145 + notify_mirror_hint(state); 2146 + return; 2147 + }; 2148 + let axis_id = *axis_id; 2149 + let Some(pa) = lookup_point(sketch, axis_line.a()) else { 2150 + return; 2151 + }; 2152 + let Some(pb) = lookup_point(sketch, axis_line.b()) else { 2153 + return; 2154 + }; 2155 + let axis = MirrorAxis::from_points(pa, pb); 2156 + if axis.is_degenerate() { 2157 + notify_mirror_hint(state); 2158 + return; 2159 + } 2160 + let source_ids: std::collections::BTreeSet<bone_types::SketchEntityId> = entity_ids 2161 + .iter() 2162 + .copied() 2163 + .filter(|id| *id != axis_id) 2164 + .collect(); 2165 + if source_ids.is_empty() { 2166 + notify_mirror_hint(state); 2167 + return; 2168 + } 2169 + let snapshot = state.document.clone(); 2170 + let result = mirror_targets(sketch.clone(), &source_ids, axis_id, &axis); 2171 + match result { 2172 + Ok(next) => { 2173 + state.undo.record(snapshot); 2174 + state.document.replace_sketch(sketch_id, next); 2175 + state.selection = Selection::default(); 2176 + refresh_active_scene(state); 2177 + } 2178 + Err(e) => tracing::warn!(error = %e, "mirror failed"), 2179 + } 2180 + } 2181 + 2182 + fn notify_mirror_hint(state: &mut AppState) { 2183 + state.notification = Some(Notification { 2184 + kind: NotificationKind::Info, 2185 + headline: strings::HOTKEY_LABEL_MIRROR, 2186 + detail: Some( 2187 + state 2188 + .strings 2189 + .resolve(strings::NOTIFY_MIRROR_SELECTION_HINT) 2190 + .to_owned(), 2191 + ), 2192 + }); 2193 + } 2194 + 2195 + #[derive(Copy, Clone, Debug)] 2196 + struct MirrorAxis { 2197 + anchor_x: f64, 2198 + anchor_y: f64, 2199 + direction_x: f64, 2200 + direction_y: f64, 2201 + length_sq: f64, 2202 + } 2203 + 2204 + impl MirrorAxis { 2205 + fn from_points(a: Point2, b: Point2) -> Self { 2206 + let (ax, ay) = a.coords_mm(); 2207 + let (bx, by) = b.coords_mm(); 2208 + let dx = bx - ax; 2209 + let dy = by - ay; 2210 + Self { 2211 + anchor_x: ax, 2212 + anchor_y: ay, 2213 + direction_x: dx, 2214 + direction_y: dy, 2215 + length_sq: dx * dx + dy * dy, 2216 + } 2217 + } 2218 + 2219 + fn is_degenerate(self) -> bool { 2220 + !self.length_sq.is_finite() || self.length_sq <= f64::EPSILON 2221 + } 2222 + 2223 + fn reflect(self, p: Point2) -> Point2 { 2224 + let (px, py) = p.coords_mm(); 2225 + let vx = px - self.anchor_x; 2226 + let vy = py - self.anchor_y; 2227 + let t = (vx * self.direction_x + vy * self.direction_y) / self.length_sq; 2228 + let foot_x = self.anchor_x + t * self.direction_x; 2229 + let foot_y = self.anchor_y + t * self.direction_y; 2230 + Point2::from_mm(2.0 * foot_x - px, 2.0 * foot_y - py) 2231 + } 2232 + 2233 + fn is_on_axis(self, p: Point2) -> bool { 2234 + let (px, py) = p.coords_mm(); 2235 + let vx = px - self.anchor_x; 2236 + let vy = py - self.anchor_y; 2237 + let cross = vx * self.direction_y - vy * self.direction_x; 2238 + let perp_dist_sq = cross * cross / self.length_sq; 2239 + perp_dist_sq < ON_AXIS_TOLERANCE_MM_SQ 2240 + } 2241 + } 2242 + 2243 + const ON_AXIS_TOLERANCE_MM_SQ: f64 = 1e-12; 2244 + 2245 + struct MirrorBuilder { 2246 + sketch: Sketch, 2247 + point_map: std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 2248 + entity_map: std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 2249 + } 2250 + 2251 + impl MirrorBuilder { 2252 + fn new(sketch: Sketch) -> Self { 2253 + Self { 2254 + sketch, 2255 + point_map: std::collections::BTreeMap::new(), 2256 + entity_map: std::collections::BTreeMap::new(), 2257 + } 2258 + } 2259 + 2260 + fn mirror_point( 2261 + &mut self, 2262 + id: bone_types::SketchEntityId, 2263 + axis: &MirrorAxis, 2264 + ) -> Result<bone_types::SketchEntityId, bone_document::SketchEditError> { 2265 + if let Some(&existing) = self.point_map.get(&id) { 2266 + return Ok(existing); 2267 + } 2268 + let pos = require_point(&self.sketch, id)?; 2269 + if axis.is_on_axis(pos) { 2270 + self.point_map.insert(id, id); 2271 + self.entity_map.insert(id, id); 2272 + return Ok(id); 2273 + } 2274 + let (next, new_id) = add_point(self.sketch.clone(), axis.reflect(pos))?; 2275 + self.sketch = next; 2276 + self.point_map.insert(id, new_id); 2277 + self.entity_map.insert(id, new_id); 2278 + Ok(new_id) 2279 + } 2280 + 2281 + fn mirror_entity( 2282 + &mut self, 2283 + id: bone_types::SketchEntityId, 2284 + axis: &MirrorAxis, 2285 + ) -> Result<(), bone_document::SketchEditError> { 2286 + if self.entity_map.contains_key(&id) { 2287 + return Ok(()); 2288 + } 2289 + let entity = self 2290 + .sketch 2291 + .entities() 2292 + .get(id) 2293 + .copied() 2294 + .ok_or(bone_document::SketchEditError::EntityNotFound(id))?; 2295 + match entity { 2296 + SketchEntity::Point(_) => { 2297 + self.mirror_point(id, axis)?; 2298 + } 2299 + SketchEntity::Line(l) => { 2300 + let new_a = self.mirror_point(l.a(), axis)?; 2301 + let new_b = self.mirror_point(l.b(), axis)?; 2302 + let (next, outcome) = 2303 + self.sketch 2304 + .clone() 2305 + .apply(bone_document::SketchEdit::AddEntity(SketchEntity::line( 2306 + new_a, 2307 + new_b, 2308 + l.for_construction(), 2309 + )))?; 2310 + let EditOutcome::Entity(new_id) = outcome else { 2311 + unreachable!("AddEntity yields Entity outcome") 2312 + }; 2313 + self.sketch = next; 2314 + self.entity_map.insert(id, new_id); 2315 + } 2316 + SketchEntity::Arc(a) => { 2317 + let new_center = self.mirror_point(a.center(), axis)?; 2318 + let new_start = self.mirror_point(a.start(), axis)?; 2319 + let new_end = self.mirror_point(a.end(), axis)?; 2320 + let (next, outcome) = 2321 + self.sketch 2322 + .clone() 2323 + .apply(bone_document::SketchEdit::AddEntity(SketchEntity::arc( 2324 + new_center, 2325 + new_end, 2326 + new_start, 2327 + a.for_construction(), 2328 + )))?; 2329 + let EditOutcome::Entity(new_id) = outcome else { 2330 + unreachable!("AddEntity yields Entity outcome") 2331 + }; 2332 + self.sketch = next; 2333 + self.entity_map.insert(id, new_id); 2334 + } 2335 + SketchEntity::Circle(c) => { 2336 + let new_center = self.mirror_point(c.center(), axis)?; 2337 + let (next, outcome) = 2338 + self.sketch 2339 + .clone() 2340 + .apply(bone_document::SketchEdit::AddEntity(SketchEntity::circle( 2341 + new_center, 2342 + c.radius(), 2343 + c.for_construction(), 2344 + )))?; 2345 + let EditOutcome::Entity(new_id) = outcome else { 2346 + unreachable!("AddEntity yields Entity outcome") 2347 + }; 2348 + self.sketch = next; 2349 + self.entity_map.insert(id, new_id); 2350 + } 2351 + } 2352 + Ok(()) 2353 + } 2354 + } 2355 + 2356 + fn mirror_targets( 2357 + sketch: Sketch, 2358 + source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>, 2359 + axis_id: bone_types::SketchEntityId, 2360 + axis: &MirrorAxis, 2361 + ) -> Result<Sketch, bone_document::SketchEditError> { 2362 + let mut builder = MirrorBuilder::new(sketch); 2363 + source_ids 2364 + .iter() 2365 + .try_for_each(|id| builder.mirror_entity(*id, axis))?; 2366 + builder.entity_map.insert(axis_id, axis_id); 2367 + builder.sketch = symmetric_relations_for_pairs(builder.sketch, &builder.point_map, axis_id)?; 2368 + builder.sketch = copy_relations(builder.sketch, source_ids, axis_id, &builder.entity_map)?; 2369 + builder.sketch = copy_dimensions(builder.sketch, source_ids, axis_id, &builder.entity_map)?; 2370 + Ok(builder.sketch) 2371 + } 2372 + 2373 + fn symmetric_relations_for_pairs( 2374 + sketch: Sketch, 2375 + point_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 2376 + axis_id: bone_types::SketchEntityId, 2377 + ) -> Result<Sketch, bone_document::SketchEditError> { 2378 + point_map 2379 + .iter() 2380 + .filter(|(source, mirrored)| source != mirrored) 2381 + .try_fold(sketch, |acc, (&source, &mirrored)| { 2382 + let (next, _) = acc.apply(bone_document::SketchEdit::AddRelation( 2383 + SketchRelation::Symmetric { 2384 + a: source, 2385 + b: mirrored, 2386 + axis: axis_id, 2387 + }, 2388 + ))?; 2389 + Ok(next) 2390 + }) 2391 + } 2392 + 2393 + fn copy_relations( 2394 + sketch: Sketch, 2395 + source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>, 2396 + axis_id: bone_types::SketchEntityId, 2397 + entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 2398 + ) -> Result<Sketch, bone_document::SketchEditError> { 2399 + let relations: Vec<SketchRelation> = sketch 2400 + .relations() 2401 + .iter() 2402 + .map(|(_, r)| *r) 2403 + .filter(|r| relation_is_mirrorable(r, source_ids, axis_id)) 2404 + .filter_map(|r| remap_relation(r, entity_map)) 2405 + .collect(); 2406 + relations.into_iter().try_fold(sketch, |acc, rel| { 2407 + match acc 2408 + .clone() 2409 + .apply(bone_document::SketchEdit::AddRelation(rel)) 2410 + { 2411 + Ok((next, _)) => Ok(next), 2412 + Err(e) => { 2413 + tracing::warn!(error = %e, relation = ?rel, "mirror: skipped relation"); 2414 + Ok(acc) 2415 + } 2416 + } 2417 + }) 2418 + } 2419 + 2420 + fn relation_is_mirrorable( 2421 + rel: &SketchRelation, 2422 + source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>, 2423 + axis_id: bone_types::SketchEntityId, 2424 + ) -> bool { 2425 + let refs: Vec<_> = rel.references().into_iter().collect(); 2426 + let touches_source = refs.iter().any(|id| source_ids.contains(id)); 2427 + let all_known = refs 2428 + .iter() 2429 + .all(|id| source_ids.contains(id) || *id == axis_id); 2430 + touches_source && all_known 2431 + } 2432 + 2433 + fn remap_relation( 2434 + rel: SketchRelation, 2435 + entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 2436 + ) -> Option<SketchRelation> { 2437 + let get = |id| entity_map.get(&id).copied(); 2438 + match rel { 2439 + SketchRelation::Coincident(a, b) => Some(SketchRelation::Coincident(get(a)?, get(b)?)), 2440 + SketchRelation::Horizontal(a) => Some(SketchRelation::Horizontal(get(a)?)), 2441 + SketchRelation::Vertical(a) => Some(SketchRelation::Vertical(get(a)?)), 2442 + SketchRelation::Parallel(a, b) => Some(SketchRelation::Parallel(get(a)?, get(b)?)), 2443 + SketchRelation::Perpendicular(a, b) => { 2444 + Some(SketchRelation::Perpendicular(get(a)?, get(b)?)) 2445 + } 2446 + SketchRelation::Tangent(a, b) => Some(SketchRelation::Tangent(get(a)?, get(b)?)), 2447 + SketchRelation::Equal(a, b) => Some(SketchRelation::Equal(get(a)?, get(b)?)), 2448 + SketchRelation::Concentric(a, b) => Some(SketchRelation::Concentric(get(a)?, get(b)?)), 2449 + SketchRelation::Midpoint { point, line } => Some(SketchRelation::Midpoint { 2450 + point: get(point)?, 2451 + line: get(line)?, 2452 + }), 2453 + SketchRelation::Symmetric { a, b, axis } => Some(SketchRelation::Symmetric { 2454 + a: get(a)?, 2455 + b: get(b)?, 2456 + axis: get(axis)?, 2457 + }), 2458 + SketchRelation::Fix(a) => Some(SketchRelation::Fix(get(a)?)), 2459 + } 2460 + } 2461 + 2462 + fn copy_dimensions( 2463 + sketch: Sketch, 2464 + source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>, 2465 + axis_id: bone_types::SketchEntityId, 2466 + entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 2467 + ) -> Result<Sketch, bone_document::SketchEditError> { 2468 + let dims: Vec<SketchDimension> = sketch 2469 + .dimensions() 2470 + .iter() 2471 + .map(|(_, d)| *d) 2472 + .filter(|d| dimension_is_mirrorable(d, source_ids, axis_id)) 2473 + .filter_map(|d| remap_dimension(d, entity_map)) 2474 + .collect(); 2475 + dims.into_iter().try_fold(sketch, |acc, dim| { 2476 + match acc 2477 + .clone() 2478 + .apply(bone_document::SketchEdit::AddDimension(dim)) 2479 + { 2480 + Ok((next, _)) => Ok(next), 2481 + Err(e) => { 2482 + tracing::warn!(error = %e, dimension = ?dim, "mirror: skipped dimension"); 2483 + Ok(acc) 2484 + } 2485 + } 2486 + }) 2487 + } 2488 + 2489 + fn dimension_is_mirrorable( 2490 + dim: &SketchDimension, 2491 + source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>, 2492 + axis_id: bone_types::SketchEntityId, 2493 + ) -> bool { 2494 + let refs: Vec<_> = dim.references().into_iter().collect(); 2495 + let touches_source = refs.iter().any(|id| source_ids.contains(id)); 2496 + let all_known = refs 2497 + .iter() 2498 + .all(|id| source_ids.contains(id) || *id == axis_id); 2499 + touches_source && all_known 2500 + } 2501 + 2502 + fn remap_dimension( 2503 + dim: SketchDimension, 2504 + entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 2505 + ) -> Option<SketchDimension> { 2506 + let get = |id| entity_map.get(&id).copied(); 2507 + match dim { 2508 + SketchDimension::Linear { a, b, value, kind } => Some(SketchDimension::Linear { 2509 + a: get(a)?, 2510 + b: get(b)?, 2511 + value, 2512 + kind, 2513 + }), 2514 + SketchDimension::Radius { 2515 + target, 2516 + value, 2517 + kind, 2518 + } => Some(SketchDimension::Radius { 2519 + target: get(target)?, 2520 + value, 2521 + kind, 2522 + }), 2523 + SketchDimension::Diameter { 2524 + target, 2525 + value, 2526 + kind, 2527 + } => Some(SketchDimension::Diameter { 2528 + target: get(target)?, 2529 + value, 2530 + kind, 2531 + }), 2532 + SketchDimension::Angular { a, b, value, kind } => Some(SketchDimension::Angular { 2533 + a: get(a)?, 2534 + b: get(b)?, 2535 + value, 2536 + kind, 2537 + }), 2538 + } 2539 + } 2540 + 2541 + fn add_point( 2542 + sketch: Sketch, 2543 + at: Point2, 2544 + ) -> Result<(Sketch, bone_types::SketchEntityId), bone_document::SketchEditError> { 2545 + let (next, outcome) = sketch.apply(bone_document::SketchEdit::AddEntity( 2546 + SketchEntity::point(at), 2547 + ))?; 2548 + let EditOutcome::Entity(id) = outcome else { 2549 + unreachable!("AddEntity must yield Entity outcome") 2550 + }; 2551 + Ok((next, id)) 2552 + } 2553 + 2554 + fn require_point( 2555 + sketch: &Sketch, 2556 + id: bone_types::SketchEntityId, 2557 + ) -> Result<Point2, bone_document::SketchEditError> { 2558 + match sketch 2559 + .entities() 2560 + .get(id) 2561 + .ok_or(bone_document::SketchEditError::EntityNotFound(id))? 2562 + { 2563 + SketchEntity::Point(p) => Ok(p.at()), 2564 + _ => Err(bone_document::SketchEditError::ExpectedPoint(id)), 2565 + } 2566 + } 2567 + 2568 + fn lookup_point(sketch: &Sketch, id: bone_types::SketchEntityId) -> Option<Point2> { 2569 + match sketch.entities().get(id)? { 2570 + SketchEntity::Point(p) => Some(p.at()), 2571 + _ => None, 2572 + } 2573 + } 2574 + 2575 + fn suppress_pointer_activations(frame: shell::ShellFrame) -> shell::ShellFrame { 2576 + shell::ShellFrame { 2577 + paints: frame.paints, 2578 + overlay_paints: frame.overlay_paints, 2579 + viewport_rect: frame.viewport_rect, 2580 + activated_tool: None, 2581 + activated_feature_tool: None, 2582 + activated_relation: None, 2583 + activated_dimension: None, 2584 + dimension_edit: None, 2585 + extrude_edit: frame.extrude_edit, 2586 + plane_picked: None, 2587 + sketch_activated: None, 2588 + sketch_rename: None, 2589 + extrude_activated: None, 2590 + extrude_rename: None, 2591 + exit_sketch: false, 2592 + confirm_action: None, 2593 + menu_action: None, 2594 + settings_change: None, 2595 + view_pick: None, 2596 + view_menu: None, 2597 + } 2598 + } 2599 + 2600 + #[allow( 2601 + clippy::too_many_arguments, 2602 + reason = "popup overlay dispatch threads every transient surface" 2603 + )] 2604 + fn apply_popup_overlays( 2605 + overlay: &mut Vec<bone_ui::widgets::WidgetPaint>, 2606 + dim_outcome: Option<&DimensionEditorOutcome>, 2607 + conflict_outcome: Option<&DimConflictOutcome>, 2608 + picker_outcome: Option<&file_menu::PickerModalOutcome>, 2609 + overwrite_outcome: Option<&OverwriteOutcome>, 2610 + discard_outcome: Option<&DiscardOutcome>, 2611 + step_progress_outcome: Option<&StepProgressOutcome>, 2612 + notification_outcome: Option<&NotificationOutcome>, 2613 + shortcut_bar_outcome: Option<&shortcut_bar::ShortcutBarOutcome>, 2614 + ) -> Option<LayoutRect> { 2615 + let dim_closing = matches!( 2616 + dim_outcome.map(|o| &o.action), 2617 + Some(DimensionEditorAction::Commit(_) | DimensionEditorAction::Cancel), 2618 + ); 2619 + extend_when_open( 2620 + overlay, 2621 + dim_outcome.map(|o| o.paints.as_slice()), 2622 + dim_closing, 2623 + ); 2624 + let conflict_closing = matches!( 2625 + conflict_outcome.map(|o| o.action), 2626 + Some(DimConflictAction::MakeDriven | DimConflictAction::Cancel), 2627 + ); 2628 + extend_when_open( 2629 + overlay, 2630 + conflict_outcome.map(|o| o.paints.as_slice()), 2631 + conflict_closing, 2632 + ); 2633 + let picker_closing = picker_outcome.and_then(|o| o.command.as_ref()).is_some(); 2634 + extend_when_open( 2635 + overlay, 2636 + picker_outcome.map(|o| o.paints.as_slice()), 2637 + picker_closing, 2638 + ); 2639 + let overwrite_closing = matches!( 2640 + overwrite_outcome.map(|o| o.action), 2641 + Some(OverwriteAction::Replace | OverwriteAction::Cancel), 2642 + ); 2643 + extend_when_open( 2644 + overlay, 2645 + overwrite_outcome.map(|o| o.paints.as_slice()), 2646 + overwrite_closing, 2647 + ); 2648 + let discard_closing = matches!( 2649 + discard_outcome.map(|o| o.action), 2650 + Some(DiscardAction::Confirm | DiscardAction::Cancel), 2651 + ); 2652 + extend_when_open( 2653 + overlay, 2654 + discard_outcome.map(|o| o.paints.as_slice()), 2655 + discard_closing, 2656 + ); 2657 + if let Some(progress) = step_progress_outcome { 2658 + overlay.extend(progress.paints.iter().cloned()); 2659 + } 2660 + if let Some(notification) = notification_outcome { 2661 + overlay.extend(notification.paints.iter().cloned()); 2662 + } 2663 + let bar_closing = shortcut_bar_outcome.is_some_and(|o| o.dismissed || o.activated.is_some()); 2664 + extend_when_open( 2665 + overlay, 2666 + shortcut_bar_outcome.map(|o| o.paints.as_slice()), 2667 + bar_closing, 2668 + ); 2669 + if dim_closing { 2670 + None 2671 + } else { 2672 + dim_outcome.map(|o| o.bounds) 2673 + } 2674 + } 2675 + 2676 + fn extend_when_open( 2677 + overlay: &mut Vec<bone_ui::widgets::WidgetPaint>, 2678 + paints: Option<&[bone_ui::widgets::WidgetPaint]>, 2679 + closing: bool, 2680 + ) { 2681 + if let Some(p) = paints 2682 + && !closing 2683 + { 2684 + overlay.extend(p.iter().cloned()); 2685 + } 2686 + } 2687 + 2688 + struct FrameOutcomes { 2689 + frame: shell::ShellFrame, 2690 + hotkey_actions: Vec<ActionId>, 2691 + dim: Option<DimensionEditorOutcome>, 2692 + dim_conflict: Option<DimConflictOutcome>, 2693 + picker: Option<file_menu::PickerModalOutcome>, 2694 + overwrite: Option<OverwriteOutcome>, 2695 + discard: Option<DiscardOutcome>, 2696 + step_progress: Option<StepProgressOutcome>, 2697 + notification: Option<NotificationOutcome>, 2698 + shortcut_bar: Option<shortcut_bar::ShortcutBarOutcome>, 2699 + } 2700 + 2701 + fn strip_plain_letter_chords(input: &mut InputSnapshot) { 2702 + input.keys_pressed.retain(|event| { 2703 + !matches!(event.code, bone_ui::input::KeyCode::Char(_)) 2704 + || event.modifiers != ModifierMask::NONE 2705 + }); 2706 + } 2707 + 2708 + #[allow( 2709 + clippy::too_many_arguments, 2710 + reason = "run_frame_ui threads every per-frame UI subsystem" 2711 + )] 2712 + fn run_frame_ui( 2713 + state: &mut AppState, 2714 + theme: Arc<Theme>, 2715 + input: &mut InputSnapshot, 2716 + hits: &mut HitFrame, 2717 + a11y: &mut AccessTreeBuilder, 2718 + scopes: &HotkeyScopes, 2719 + layout_size: LayoutSize, 2720 + cursor_world: Option<Point2>, 2721 + ) -> FrameOutcomes { 2722 + let mut ctx = FrameCtx::new( 2723 + theme, 2724 + input, 2725 + &mut state.focus, 2726 + &state.hotkeys, 2727 + &state.strings, 2728 + hits, 2729 + &state.hit_state, 2730 + a11y, 2731 + &mut state.chrome_shaper, 2732 + ); 2733 + let extrude_status = state.extrude_preview.as_ref().map(ExtrudePreview::status); 2734 + let frame = state.shell.render( 2735 + &mut ctx, 2736 + &state.document, 2737 + &state.mode, 2738 + &state.selection, 2739 + &state.settings, 2740 + layout_size, 2741 + cursor_world, 2742 + state.camera3.filter(|_| state.solid_view.is_some()), 2743 + extrude_status, 2744 + &mut state.view, 2745 + ); 2746 + let dim_outcome = pending_dim(&state.mode).map(|pending| { 2747 + let live_anchor = state 2748 + .mode 2749 + .sketch_id() 2750 + .and_then(|id| state.document.sketch(id)) 2751 + .and_then(|s| smart_dimension::live_anchor(s, pending.proto)) 2752 + .unwrap_or(pending.anchor); 2753 + dimension_editor::render( 2754 + &mut ctx, 2755 + pending, 2756 + live_anchor, 2757 + &state.camera, 2758 + frame.viewport_rect, 2759 + &mut state.dim_editor, 2760 + ) 2761 + }); 2762 + let conflict_outcome = 2763 + dim_conflict_pending(&state.mode).map(|_| render_dim_conflict_modal(&mut ctx, layout_size)); 2764 + let picker_outcome = state 2765 + .file_picker 2766 + .as_mut() 2767 + .map(|session| file_menu::render(&mut ctx, session, layout_size)); 2768 + let overwrite_outcome = state 2769 + .pending_overwrite 2770 + .as_ref() 2771 + .map(|pending| render_overwrite_modal(&mut ctx, layout_size, pending)); 2772 + let discard_outcome = state 2773 + .pending_discard 2774 + .as_ref() 2775 + .map(|pending| render_discard_modal(&mut ctx, layout_size, pending)); 2776 + let reduce_motion = state.settings.reduce_motion; 2777 + let step_progress_outcome = state 2778 + .step_job 2779 + .as_ref() 2780 + .filter(|job| job.meta().show_progress) 2781 + .map(|job| render_step_progress_dialog(&mut ctx, layout_size, job, reduce_motion)); 2782 + let notification_outcome = state 2783 + .notification 2784 + .as_ref() 2785 + .map(|notification| render_notification_toast(&mut ctx, layout_size, notification)); 2786 + let is_sketch = state.mode.is_sketch(); 2787 + let shortcut_bar_outcome = state 2788 + .shortcut_bar 2789 + .map(|bar_state| shortcut_bar::render(&mut ctx, bar_state, layout_size, is_sketch)); 2790 + let any_modal_open = state.shell.state.keyboard_dialog_open 2791 + || conflict_outcome.is_some() 2792 + || dim_outcome.is_some() 2793 + || picker_outcome.is_some() 2794 + || overwrite_outcome.is_some() 2795 + || discard_outcome.is_some() 2796 + || step_progress_outcome.is_some() 2797 + || shortcut_bar_outcome.is_some(); 2798 + if !any_modal_open && ctx.focus.is_text_input_focused() { 2799 + strip_plain_letter_chords(ctx.input); 2800 + } 2801 + let mut actions = if any_modal_open { 2802 + Vec::new() 2803 + } else { 2804 + ctx.dispatch_hotkeys(scopes) 2805 + }; 2806 + if let Some(activated) = shortcut_bar_outcome.as_ref().and_then(|o| o.activated) { 2807 + actions.push(activated); 2808 + } 2809 + FrameOutcomes { 2810 + frame, 2811 + hotkey_actions: actions, 2812 + dim: dim_outcome, 2813 + dim_conflict: conflict_outcome, 2814 + picker: picker_outcome, 2815 + overwrite: overwrite_outcome, 2816 + discard: discard_outcome, 2817 + step_progress: step_progress_outcome, 2818 + notification: notification_outcome, 2819 + shortcut_bar: shortcut_bar_outcome, 2820 + } 2821 + } 2822 + 2823 + fn dim_conflict_pending(mode: &Mode) -> Option<PendingDimension> { 2824 + match mode.dim_flow() { 2825 + Some(DimensionFlow::Conflict(p)) => Some(p), 2826 + Some(DimensionFlow::Editing(_)) | None => None, 2827 + } 2828 + } 2829 + 2830 + #[derive(Clone, Debug, PartialEq)] 2831 + struct DimConflictOutcome { 2832 + paints: Vec<bone_ui::widgets::WidgetPaint>, 2833 + action: DimConflictAction, 2834 + } 2835 + 2836 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 2837 + enum DimConflictAction { 2838 + Idle, 2839 + MakeDriven, 2840 + Cancel, 2841 + } 2842 + 2843 + fn render_dim_conflict_modal( 2844 + ctx: &mut FrameCtx<'_>, 2845 + layout_size: LayoutSize, 2846 + ) -> DimConflictOutcome { 2847 + use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; 2848 + use bone_ui::{WidgetId, WidgetKey}; 2849 + let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); 2850 + let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(180.0)); 2851 + let id = WidgetId::ROOT.child(WidgetKey::new("dim.conflict")); 2852 + let response = show_confirmation( 2853 + ctx, 2854 + ConfirmationDialog { 2855 + id, 2856 + viewport, 2857 + size: dialog_size, 2858 + title: strings::DIM_CONFLICT_TITLE, 2859 + message: strings::DIM_CONFLICT_MESSAGE, 2860 + confirm_label: strings::DIM_CONFLICT_MAKE_DRIVEN, 2861 + cancel_label: strings::DIM_CONFLICT_CANCEL, 2862 + destructive: false, 2863 + }, 2864 + ); 2865 + let action = match response.outcome { 2866 + Some(ConfirmationOutcome::Confirm) => DimConflictAction::MakeDriven, 2867 + Some(ConfirmationOutcome::Cancel) => DimConflictAction::Cancel, 2868 + None => DimConflictAction::Idle, 2869 + }; 2870 + DimConflictOutcome { 2871 + paints: response.paint, 2872 + action, 2873 + } 2874 + } 2875 + 2876 + #[derive(Clone, Debug, PartialEq)] 2877 + struct OverwriteOutcome { 2878 + paints: Vec<bone_ui::widgets::WidgetPaint>, 2879 + action: OverwriteAction, 2880 + } 2881 + 2882 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 2883 + enum OverwriteAction { 2884 + Idle, 2885 + Replace, 2886 + Cancel, 2887 + } 2888 + 2889 + fn render_overwrite_modal( 2890 + ctx: &mut FrameCtx<'_>, 2891 + layout_size: LayoutSize, 2892 + pending: &PendingOverwrite, 2893 + ) -> OverwriteOutcome { 2894 + use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; 2895 + use bone_ui::{WidgetId, WidgetKey}; 2896 + let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); 2897 + let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(180.0)); 2898 + let id = WidgetId::ROOT.child(WidgetKey::new("file.overwrite")); 2899 + let (title, message) = match pending { 2900 + PendingOverwrite::Document(_) => ( 2901 + strings::FILE_OVERWRITE_TITLE, 2902 + strings::FILE_OVERWRITE_MESSAGE, 2903 + ), 2904 + PendingOverwrite::StepExport(_) => ( 2905 + strings::FILE_OVERWRITE_TITLE_STEP, 2906 + strings::FILE_OVERWRITE_MESSAGE_STEP, 2907 + ), 2908 + }; 2909 + let response = show_confirmation( 2910 + ctx, 2911 + ConfirmationDialog { 2912 + id, 2913 + viewport, 2914 + size: dialog_size, 2915 + title, 2916 + message, 2917 + confirm_label: strings::FILE_OVERWRITE_REPLACE, 2918 + cancel_label: strings::FILE_OVERWRITE_CANCEL, 2919 + destructive: true, 2920 + }, 2921 + ); 2922 + let action = match response.outcome { 2923 + Some(ConfirmationOutcome::Confirm) => OverwriteAction::Replace, 2924 + Some(ConfirmationOutcome::Cancel) => OverwriteAction::Cancel, 2925 + None => OverwriteAction::Idle, 2926 + }; 2927 + OverwriteOutcome { 2928 + paints: response.paint, 2929 + action, 2930 + } 2931 + } 2932 + 2933 + #[derive(Clone, Debug, PartialEq)] 2934 + struct DiscardOutcome { 2935 + paints: Vec<bone_ui::widgets::WidgetPaint>, 2936 + action: DiscardAction, 2937 + } 2938 + 2939 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 2940 + enum DiscardAction { 2941 + Idle, 2942 + Confirm, 2943 + Cancel, 2944 + } 2945 + 2946 + fn render_discard_modal( 2947 + ctx: &mut FrameCtx<'_>, 2948 + layout_size: LayoutSize, 2949 + pending: &PendingDiscard, 2950 + ) -> DiscardOutcome { 2951 + use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; 2952 + use bone_ui::{WidgetId, WidgetKey}; 2953 + let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); 2954 + let dialog_size = LayoutSize::new(LayoutPx::new(460.0), LayoutPx::new(190.0)); 2955 + let id = WidgetId::ROOT.child(WidgetKey::new("file.discard")); 2956 + let (title, message, confirm_label, cancel_label) = match pending { 2957 + PendingDiscard::New | PendingDiscard::Open(_) | PendingDiscard::ImportStep(_) => ( 2958 + strings::FILE_DISCARD_TITLE, 2959 + strings::FILE_DISCARD_MESSAGE, 2960 + strings::FILE_DISCARD_CONFIRM, 2961 + strings::FILE_DISCARD_CANCEL, 2962 + ), 2963 + PendingDiscard::InstallImported { .. } => ( 2964 + strings::FILE_IMPORT_REPLACE_TITLE, 2965 + strings::FILE_IMPORT_REPLACE_MESSAGE, 2966 + strings::FILE_IMPORT_REPLACE_CONFIRM, 2967 + strings::FILE_IMPORT_REPLACE_CANCEL, 2968 + ), 2969 + }; 2970 + let response = show_confirmation( 2971 + ctx, 2972 + ConfirmationDialog { 2973 + id, 2974 + viewport, 2975 + size: dialog_size, 2976 + title, 2977 + message, 2978 + confirm_label, 2979 + cancel_label, 2980 + destructive: true, 2981 + }, 2982 + ); 2983 + let action = match response.outcome { 2984 + Some(ConfirmationOutcome::Confirm) => DiscardAction::Confirm, 2985 + Some(ConfirmationOutcome::Cancel) => DiscardAction::Cancel, 2986 + None => DiscardAction::Idle, 2987 + }; 2988 + DiscardOutcome { 2989 + paints: response.paint, 2990 + action, 2991 + } 2992 + } 2993 + 2994 + fn apply_discard_outcome(state: &mut AppState, outcome: Option<DiscardOutcome>) { 2995 + let Some(outcome) = outcome else { return }; 2996 + match outcome.action { 2997 + DiscardAction::Idle => {} 2998 + DiscardAction::Cancel => { 2999 + state.pending_discard = None; 3000 + } 3001 + DiscardAction::Confirm => { 3002 + let Some(pending) = state.pending_discard.take() else { 3003 + return; 3004 + }; 3005 + match pending { 3006 + PendingDiscard::New => apply_new_document(state), 3007 + PendingDiscard::Open(path) => apply_open_folder(state, path), 3008 + PendingDiscard::ImportStep(path) => start_step_import(state, path), 3009 + PendingDiscard::InstallImported { 3010 + document, 3011 + file_name, 3012 + } => { 3013 + install_imported_document(state, *document); 3014 + notify_info(state, strings::NOTIFY_IMPORTED, Some(file_name)); 3015 + } 3016 + } 3017 + } 3018 + } 3019 + } 3020 + 3021 + #[derive(Clone, Debug, PartialEq)] 3022 + struct StepProgressOutcome { 3023 + paints: Vec<bone_ui::widgets::WidgetPaint>, 3024 + cancel_requested: bool, 3025 + } 3026 + 3027 + fn render_step_progress_dialog( 3028 + ctx: &mut FrameCtx<'_>, 3029 + layout_size: LayoutSize, 3030 + job: &step_jobs::StepJob, 3031 + reduce_motion: bool, 3032 + ) -> StepProgressOutcome { 3033 + use bone_ui::widgets::{Dialog, DialogButton, LabelText, WidgetPaint, show_dialog}; 3034 + use bone_ui::{WidgetId, WidgetKey}; 3035 + let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); 3036 + let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(170.0)); 3037 + let id = WidgetId::ROOT.child(WidgetKey::new("step.progress")); 3038 + let cancel_id = id.child(WidgetKey::new("cancel")); 3039 + let title = match job { 3040 + step_jobs::StepJob::Import { .. } => strings::STEP_PROGRESS_TITLE_IMPORT, 3041 + step_jobs::StepJob::Export { .. } => strings::STEP_PROGRESS_TITLE_EXPORT, 3042 + }; 3043 + let buttons = [DialogButton::secondary( 3044 + cancel_id, 3045 + strings::STEP_PROGRESS_CANCEL, 3046 + )]; 3047 + let sweep = sweep_phase(ctx.input.frame, reduce_motion); 3048 + let file_name = job.meta().file_name.clone(); 3049 + let (response, ()) = show_dialog( 3050 + ctx, 3051 + Dialog::new(id, viewport, dialog_size, title, &buttons), 3052 + |ctx, body_rect, paint| { 3053 + let label_rect = LayoutRect::new( 3054 + LayoutPos::new( 3055 + LayoutPx::new(body_rect.origin.x.value() + 16.0), 3056 + LayoutPx::new(body_rect.origin.y.value() + 12.0), 3057 + ), 3058 + LayoutSize::new( 3059 + LayoutPx::saturating_nonneg(body_rect.size.width.value() - 32.0), 3060 + LayoutPx::new(20.0), 3061 + ), 3062 + ); 3063 + paint.push(WidgetPaint::Label { 3064 + rect: label_rect, 3065 + text: LabelText::Owned(file_name), 3066 + color: ctx.theme().colors.text_secondary(), 3067 + role: ctx.theme().typography.body, 3068 + }); 3069 + push_progress_bar(ctx, body_rect, sweep, paint); 3070 + }, 3071 + ); 3072 + StepProgressOutcome { 3073 + paints: response.paint, 3074 + cancel_requested: response.dismissed || response.activated == Some(cancel_id), 3075 + } 3076 + } 3077 + 3078 + const SWEEP_PERIOD_SECS: f32 = 1.2; 3079 + const SWEEP_SPAN: f32 = 0.3; 3080 + 3081 + fn sweep_phase(now: bone_ui::input::FrameInstant, reduce_motion: bool) -> f32 { 3082 + if reduce_motion { 3083 + return 0.5; 3084 + } 3085 + (now.duration().as_secs_f32() / SWEEP_PERIOD_SECS).fract() 3086 + } 3087 + 3088 + fn push_progress_bar( 3089 + ctx: &FrameCtx<'_>, 3090 + body: LayoutRect, 3091 + sweep: f32, 3092 + paint: &mut Vec<bone_ui::widgets::WidgetPaint>, 3093 + ) { 3094 + use bone_ui::widgets::WidgetPaint; 3095 + let track = LayoutRect::new( 3096 + LayoutPos::new( 3097 + LayoutPx::new(body.origin.x.value() + 16.0), 3098 + LayoutPx::new(body.origin.y.value() + 48.0), 3099 + ), 3100 + LayoutSize::new( 3101 + LayoutPx::saturating_nonneg(body.size.width.value() - 32.0), 3102 + LayoutPx::new(8.0), 3103 + ), 3104 + ); 3105 + paint.push(WidgetPaint::Surface { 3106 + rect: track, 3107 + fill: ctx.theme().colors.surface(bone_ui::theme::SurfaceLevel::L0), 3108 + border: Some(bone_ui::theme::Border { 3109 + width: bone_ui::theme::StrokeWidth::HAIRLINE, 3110 + color: ctx 3111 + .theme() 3112 + .colors 3113 + .neutral 3114 + .step(bone_ui::theme::Step12::SUBTLE_BORDER), 3115 + }), 3116 + radius: ctx.theme().radius.sm, 3117 + elevation: None, 3118 + }); 3119 + let start = sweep * (1.0 + SWEEP_SPAN) - SWEEP_SPAN; 3120 + let left = start.max(0.0); 3121 + let right = (start + SWEEP_SPAN).min(1.0); 3122 + if right <= left { 3123 + return; 3124 + } 3125 + let width = track.size.width.value(); 3126 + let fill_rect = LayoutRect::new( 3127 + LayoutPos::new( 3128 + LayoutPx::new(track.origin.x.value() + width * left), 3129 + track.origin.y, 3130 + ), 3131 + LayoutSize::new(LayoutPx::new(width * (right - left)), track.size.height), 3132 + ); 3133 + paint.push(WidgetPaint::Surface { 3134 + rect: fill_rect, 3135 + fill: ctx.theme().colors.accent_solid(), 3136 + border: None, 3137 + radius: ctx.theme().radius.sm, 3138 + elevation: None, 3139 + }); 3140 + } 3141 + 3142 + fn apply_step_progress_outcome(state: &AppState, outcome: Option<StepProgressOutcome>) { 3143 + let cancel = outcome.is_some_and(|o| o.cancel_requested); 3144 + if !cancel { 3145 + return; 3146 + } 3147 + if let Some(job) = state.step_job.as_ref() { 3148 + job.meta().request_cancel(); 3149 + } 3150 + } 3151 + 3152 + #[derive(Clone, Debug, PartialEq)] 3153 + struct NotificationOutcome { 3154 + paints: Vec<bone_ui::widgets::WidgetPaint>, 3155 + dismissed: bool, 3156 + } 3157 + 3158 + fn render_notification_toast( 3159 + ctx: &mut FrameCtx<'_>, 3160 + layout_size: LayoutSize, 3161 + notification: &Notification, 3162 + ) -> NotificationOutcome { 3163 + use bone_ui::widgets::{Button, ButtonVariant, WidgetPaint, show_button}; 3164 + use bone_ui::{WidgetId, WidgetKey}; 3165 + let theme = ctx.theme(); 3166 + let bg = match notification.kind { 3167 + NotificationKind::Info => theme.colors.surface(bone_ui::theme::SurfaceLevel::L2), 3168 + NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::SUBTLE_BG), 3169 + }; 3170 + let fg = match notification.kind { 3171 + NotificationKind::Info => theme.colors.text_primary(), 3172 + NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::TEXT_HIGH), 3173 + }; 3174 + let toast_width = layout_size.width.value().clamp(280.0, 420.0); 3175 + let toast_height = if notification.detail.is_some() { 3176 + 72.0 3177 + } else { 3178 + 44.0 3179 + }; 3180 + let margin = 24.0; 3181 + let toast_rect = LayoutRect::new( 3182 + LayoutPos::new( 3183 + LayoutPx::new(margin), 3184 + LayoutPx::new(layout_size.height.value() - toast_height - margin), 3185 + ), 3186 + LayoutSize::new(LayoutPx::new(toast_width), LayoutPx::new(toast_height)), 3187 + ); 3188 + let id = WidgetId::ROOT.child(WidgetKey::new("notification.toast")); 3189 + let mut paints = vec![WidgetPaint::Surface { 3190 + rect: toast_rect, 3191 + fill: bg, 3192 + border: Some(bone_ui::theme::Border { 3193 + width: bone_ui::theme::StrokeWidth::HAIRLINE, 3194 + color: theme.colors.neutral.step(bone_ui::theme::Step12::BORDER), 3195 + }), 3196 + radius: theme.radius.sm, 3197 + elevation: Some(theme.elevation.level1), 3198 + }]; 3199 + let headline_rect = LayoutRect::new( 3200 + LayoutPos::new( 3201 + LayoutPx::new(toast_rect.origin.x.value() + 16.0), 3202 + LayoutPx::new(toast_rect.origin.y.value() + 12.0), 3203 + ), 3204 + LayoutSize::new(LayoutPx::new(toast_width - 120.0), LayoutPx::new(20.0)), 3205 + ); 3206 + paints.push(WidgetPaint::Label { 3207 + rect: headline_rect, 3208 + text: bone_ui::widgets::LabelText::Key(notification.headline), 3209 + color: fg, 3210 + role: theme.typography.label, 3211 + }); 3212 + if let Some(detail) = &notification.detail { 3213 + let detail_rect = LayoutRect::new( 3214 + LayoutPos::new( 3215 + LayoutPx::new(toast_rect.origin.x.value() + 16.0), 3216 + LayoutPx::new(toast_rect.origin.y.value() + 36.0), 3217 + ), 3218 + LayoutSize::new(LayoutPx::new(toast_width - 32.0), LayoutPx::new(24.0)), 3219 + ); 3220 + paints.push(WidgetPaint::Label { 3221 + rect: detail_rect, 3222 + text: bone_ui::widgets::LabelText::Owned(detail.clone()), 3223 + color: theme.colors.text_secondary(), 3224 + role: theme.typography.caption, 3225 + }); 3226 + } 3227 + let dismiss_rect = LayoutRect::new( 3228 + LayoutPos::new( 3229 + LayoutPx::new(toast_rect.origin.x.value() + toast_width - 96.0), 3230 + LayoutPx::new(toast_rect.origin.y.value() + 8.0), 3231 + ), 3232 + LayoutSize::new(LayoutPx::new(84.0), LayoutPx::new(28.0)), 3233 + ); 3234 + let dismiss_id = id.child(WidgetKey::new("dismiss")); 3235 + let response = show_button( 3236 + ctx, 3237 + Button::new( 3238 + dismiss_id, 3239 + dismiss_rect, 3240 + strings::NOTIFY_DISMISS, 3241 + ButtonVariant::Secondary, 3242 + ), 3243 + ); 3244 + paints.extend(response.paint); 3245 + NotificationOutcome { 3246 + paints, 3247 + dismissed: response.activated, 3248 + } 3249 + } 3250 + 3251 + fn apply_notification_outcome(state: &mut AppState, outcome: Option<NotificationOutcome>) { 3252 + let Some(outcome) = outcome else { return }; 3253 + if outcome.dismissed { 3254 + state.notification = None; 3255 + } 3256 + } 3257 + 3258 + fn apply_overwrite_outcome(state: &mut AppState, outcome: Option<OverwriteOutcome>) { 3259 + let Some(outcome) = outcome else { return }; 3260 + match outcome.action { 3261 + OverwriteAction::Idle => {} 3262 + OverwriteAction::Cancel => { 3263 + state.pending_overwrite = None; 3264 + } 3265 + OverwriteAction::Replace => match state.pending_overwrite.take() { 3266 + Some(PendingOverwrite::Document(folder)) => perform_save_to(state, folder), 3267 + Some(PendingOverwrite::StepExport(path)) => start_step_export(state, path), 3268 + None => {} 3269 + }, 3270 + } 3271 + } 3272 + 3273 + fn pending_dim(mode: &Mode) -> Option<PendingDimension> { 3274 + match mode.dim_flow() { 3275 + Some(DimensionFlow::Editing(p)) => Some(p), 3276 + Some(DimensionFlow::Conflict(_)) | None => None, 3277 + } 3278 + } 3279 + 3280 + fn apply_dimension_request(state: &mut AppState, request: Option<PendingDimension>) { 3281 + let Some(request) = request else { return }; 3282 + let Mode::Sketch { .. } = state.mode else { 3283 + return; 3284 + }; 3285 + state.mode = core::mem::take(&mut state.mode).start_dimension(request); 3286 + } 3287 + 3288 + fn apply_dimension_outcome(state: &mut AppState, outcome: Option<DimensionEditorOutcome>) { 3289 + let Some(outcome) = outcome else { return }; 3290 + let Some(pending) = pending_dim(&state.mode) else { 3291 + return; 3292 + }; 3293 + match outcome.action { 3294 + DimensionEditorAction::Idle => {} 3295 + DimensionEditorAction::Cancel => { 3296 + state.mode = core::mem::take(&mut state.mode).cancel_dimension(); 3297 + state.dim_editor.close(); 3298 + } 3299 + DimensionEditorAction::Swap(next_proto) => { 3300 + state.mode = core::mem::take(&mut state.mode).start_dimension(PendingDimension { 3301 + proto: next_proto, 3302 + anchor: pending.anchor, 3303 + }); 3304 + } 3305 + DimensionEditorAction::Commit(value) => { 3306 + commit_pending_dimension(state, pending, value); 3307 + } 3308 + } 3309 + } 3310 + 3311 + fn apply_dim_conflict_outcome(state: &mut AppState, outcome: Option<DimConflictOutcome>) { 3312 + let Some(outcome) = outcome else { return }; 3313 + let Some(pending) = dim_conflict_pending(&state.mode) else { 3314 + return; 3315 + }; 3316 + match outcome.action { 3317 + DimConflictAction::Idle => {} 3318 + DimConflictAction::Cancel => { 3319 + state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 3320 + } 3321 + DimConflictAction::MakeDriven => { 3322 + confirm_dim_conflict_make_driven(state, pending); 3323 + } 3324 + } 3325 + } 3326 + 3327 + fn commit_pending_dimension( 3328 + state: &mut AppState, 3329 + pending: PendingDimension, 3330 + value: DimensionValue, 3331 + ) { 3332 + let Mode::Sketch { sketch_id, .. } = state.mode else { 3333 + return; 3334 + }; 3335 + let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 3336 + return; 3337 + }; 3338 + let proto = match pending.proto.with_value(value) { 3339 + Ok(p) => p, 3340 + Err(e) => { 3341 + tracing::warn!(error = %e, ?pending.proto, ?value, "dimension value type mismatch"); 3342 + return; 3343 + } 3344 + }; 3345 + let (after_add, dim_id) = match sketch.clone().apply(SketchEdit::AddDimension(proto)) { 3346 + Ok((next, EditOutcome::Dimension(id))) => (next, id), 3347 + Ok(_) => { 3348 + tracing::warn!(?proto, "add dimension produced unexpected outcome"); 3349 + return; 3350 + } 3351 + Err(e) => { 3352 + tracing::warn!(error = %e, ?proto, "add dimension failed"); 3353 + return; 3354 + } 3355 + }; 3356 + let solved = match after_add.solve() { 3357 + Ok(s) => s, 3358 + Err(SolverError::OverDefined { .. }) => { 3359 + state.mode = core::mem::take(&mut state.mode).start_dim_conflict(PendingDimension { 3360 + proto, 3361 + anchor: pending.anchor, 3362 + }); 3363 + state.dim_editor.close(); 3364 + return; 3365 + } 3366 + Err(e) => { 3367 + tracing::warn!(error = %e, "solve after add dim did not converge; rejecting"); 3368 + return; 3369 + } 3370 + }; 3371 + state.undo.record(state.document.clone()); 3372 + state.document.replace_sketch(sketch_id, solved); 3373 + state.selection = Selection::Dimension(dim_id); 3374 + state.mode = core::mem::take(&mut state.mode).cancel_dimension(); 3375 + state.dim_editor.close(); 3376 + refresh_active_scene(state); 3377 + } 3378 + 3379 + fn confirm_dim_conflict_make_driven(state: &mut AppState, pending: PendingDimension) { 3380 + let Mode::Sketch { sketch_id, .. } = state.mode else { 3381 + return; 3382 + }; 3383 + let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 3384 + return; 3385 + }; 3386 + let Some(measured) = sketch.measure(pending.proto) else { 3387 + tracing::warn!(?pending.proto, "measure failed for driven conversion; aborting"); 3388 + state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 3389 + return; 3390 + }; 3391 + let Some(driven_proto) = driven_with_value(pending.proto, measured) else { 3392 + tracing::warn!(?pending.proto, "driven conversion failed"); 3393 + state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 3394 + return; 3395 + }; 3396 + let (after_add, dim_id) = match sketch.apply(SketchEdit::AddDimension(driven_proto)) { 3397 + Ok((next, EditOutcome::Dimension(id))) => (next, id), 3398 + Ok(_) => { 3399 + tracing::warn!( 3400 + ?driven_proto, 3401 + "add driven dimension produced unexpected outcome" 3402 + ); 3403 + state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 3404 + return; 3405 + } 3406 + Err(e) => { 3407 + tracing::warn!(error = %e, ?driven_proto, "add driven dimension failed"); 3408 + state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 3409 + return; 3410 + } 3411 + }; 3412 + let solved = after_add.clone().solve().unwrap_or(after_add); 3413 + state.undo.record(state.document.clone()); 3414 + state.document.replace_sketch(sketch_id, solved); 3415 + state.selection = Selection::Dimension(dim_id); 3416 + state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 3417 + refresh_active_scene(state); 3418 + } 3419 + 3420 + fn driven_with_value(proto: SketchDimension, value: DimensionValue) -> Option<SketchDimension> { 3421 + proto 3422 + .with_kind(DimensionKind::Driven) 3423 + .with_value(value) 3424 + .ok() 3425 + } 3426 + 3427 + fn apply_dimension_edit(state: &mut AppState, edit: Option<shell::DimensionEdit>) { 3428 + let Some(edit) = edit else { return }; 3429 + let Mode::Sketch { sketch_id, .. } = state.mode else { 3430 + return; 3431 + }; 3432 + let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 3433 + return; 3434 + }; 3435 + let after = match sketch.apply(SketchEdit::UpdateDimensionValue { 3436 + id: edit.id, 3437 + value: edit.value, 3438 + }) { 3439 + Ok((next, _)) => next, 3440 + Err(e) => { 3441 + tracing::warn!(error = %e, ?edit, "update dimension value failed"); 3442 + return; 3443 + } 3444 + }; 3445 + let solved = match after.solve() { 3446 + Ok(s) => s, 3447 + Err(e) => { 3448 + tracing::warn!(error = %e, ?edit, "solve after dimension edit failed"); 3449 + return; 3450 + } 3451 + }; 3452 + state.undo.record(state.document.clone()); 3453 + state.document.replace_sketch(sketch_id, solved); 3454 + refresh_active_scene(state); 3455 + } 3456 + 3457 + fn apply_relation_action(state: &mut AppState, relation: Option<SketchRelation>) { 3458 + let Some(relation) = relation else { return }; 3459 + let Mode::Sketch { sketch_id, .. } = state.mode else { 3460 + return; 3461 + }; 3462 + let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 3463 + return; 3464 + }; 3465 + let next = match sketch.apply(SketchEdit::AddRelation(relation)) { 3466 + Ok((next, _)) => next, 3467 + Err(e) => { 3468 + tracing::warn!(error = %e, ?relation, "add relation failed"); 3469 + return; 3470 + } 3471 + }; 3472 + state.undo.record(state.document.clone()); 3473 + state.document.replace_sketch(sketch_id, next); 3474 + state.selection = Selection::default(); 3475 + refresh_active_scene(state); 3476 + } 3477 + 3478 + fn apply_menu_action(state: &mut AppState, action: Option<shell::MenuAction>) { 3479 + match action { 3480 + Some(shell::MenuAction::Quit) => { 3481 + state.pending_exit = true; 3482 + } 3483 + Some(shell::MenuAction::Undo) if state.undo.undo(&mut state.document) => { 3484 + refresh_active_scene(state); 3485 + } 3486 + Some(shell::MenuAction::Redo) if state.undo.redo(&mut state.document) => { 3487 + refresh_active_scene(state); 3488 + } 3489 + Some(shell::MenuAction::ZoomFit) => { 3490 + let solid_fit = state 3491 + .solid_view 3492 + .as_ref() 3493 + .map(|view| view.aabb) 3494 + .and_then(|aabb| { 3495 + let region = solid_viewport_region(state.viewport_rect, state.extent)?; 3496 + frame_current(state.camera3?, aabb, region.extent()).ok() 3497 + }); 3498 + if let Some(next) = solid_fit { 3499 + apply_nav_camera(state, next); 3500 + } else { 3501 + state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); 3502 + } 3503 + } 3504 + Some(shell::MenuAction::OpenSettings) => { 3505 + state.shell.state.settings_dialog_open = true; 3506 + } 3507 + Some(shell::MenuAction::OpenKeyboardCustomize) => { 3508 + state.shell.state.keyboard_dialog_open = true; 3509 + } 3510 + Some(shell::MenuAction::NewDocument) => { 3511 + request_new_document(state); 3512 + } 3513 + Some(shell::MenuAction::OpenDocument) => { 3514 + open_picker( 3515 + state, 3516 + bone_ui::widgets::FilePickerMode::Open, 3517 + file_menu::FileKind::Document, 3518 + None, 3519 + ); 3520 + } 3521 + Some(shell::MenuAction::SaveDocument) => { 3522 + apply_save_in_place(state); 3523 + } 3524 + Some(shell::MenuAction::SaveDocumentAs) => { 3525 + let seed = state.document.name().to_owned(); 3526 + open_picker( 3527 + state, 3528 + bone_ui::widgets::FilePickerMode::Save, 3529 + file_menu::FileKind::Document, 3530 + Some(seed), 3531 + ); 3532 + } 3533 + Some(shell::MenuAction::ImportStep) => { 3534 + if state.step_job.is_none() { 3535 + open_picker( 3536 + state, 3537 + bone_ui::widgets::FilePickerMode::Open, 3538 + file_menu::FileKind::Step, 3539 + None, 3540 + ); 3541 + } 3542 + } 3543 + Some(shell::MenuAction::ExportStep) => { 3544 + if state.step_job.is_none() { 3545 + let seed = format!("{}.step", state.document.name()); 3546 + open_picker( 3547 + state, 3548 + bone_ui::widgets::FilePickerMode::Save, 3549 + file_menu::FileKind::Step, 3550 + Some(seed), 3551 + ); 3552 + } 3553 + } 3554 + Some(shell::MenuAction::Undo | shell::MenuAction::Redo | shell::MenuAction::ExitSketch) 3555 + | None => {} 3556 + } 3557 + } 3558 + 3559 + fn request_new_document(state: &mut AppState) { 3560 + if is_dirty(state) { 3561 + state.pending_discard = Some(PendingDiscard::New); 3562 + } else { 3563 + apply_new_document(state); 3564 + } 3565 + } 3566 + 3567 + fn request_open_folder(state: &mut AppState, path: PathBuf) { 3568 + if is_dirty(state) { 3569 + state.pending_discard = Some(PendingDiscard::Open(path)); 3570 + } else { 3571 + apply_open_folder(state, path); 3572 + } 3573 + } 3574 + 3575 + fn request_import_step(state: &mut AppState, path: PathBuf) { 3576 + if is_dirty(state) { 3577 + state.pending_discard = Some(PendingDiscard::ImportStep(path)); 3578 + } else { 3579 + start_step_import(state, path); 3580 + } 3581 + } 3582 + 3583 + fn start_step_import(state: &mut AppState, path: PathBuf) { 3584 + if state.step_job.is_some() { 3585 + return; 3586 + } 3587 + match step_jobs::spawn_import(path, state.document.clone()) { 3588 + Ok(job) => state.step_job = Some(job), 3589 + Err(e) => notify_error(state, strings::NOTIFY_IMPORT_FAILED, e.to_string()), 3590 + } 3591 + } 3592 + 3593 + fn apply_export_step_as(state: &mut AppState, path: PathBuf, via: PickedVia) { 3594 + let extension_appended = !file_menu::is_step_file(&path); 3595 + let path = file_menu::with_step_extension(path); 3596 + let unconfirmed = matches!(via, PickedVia::CustomPicker) || extension_appended; 3597 + if unconfirmed && path.is_file() { 3598 + state.pending_overwrite = Some(PendingOverwrite::StepExport(path)); 3599 + return; 3600 + } 3601 + start_step_export(state, path); 3602 + } 3603 + 3604 + fn start_step_export(state: &mut AppState, path: PathBuf) { 3605 + if state.step_job.is_some() { 3606 + return; 3607 + } 3608 + match step_jobs::spawn_export(state.document.clone(), path) { 3609 + Ok(job) => state.step_job = Some(job), 3610 + Err(e) => notify_error(state, strings::NOTIFY_EXPORT_FAILED, e.to_string()), 3611 + } 3612 + } 3613 + 3614 + fn poll_step_job(state: &mut AppState) { 3615 + let Some(job) = state.step_job.take() else { 3616 + return; 3617 + }; 3618 + match job { 3619 + step_jobs::StepJob::Import { rx, baseline, meta } => match step_jobs::poll(&rx) { 3620 + std::task::Poll::Pending => { 3621 + state.step_job = Some(step_jobs::StepJob::Import { rx, baseline, meta }); 3622 + } 3623 + std::task::Poll::Ready(result) => finish_import(state, result, &baseline, &meta), 3624 + }, 3625 + step_jobs::StepJob::Export { rx, meta } => match step_jobs::poll(&rx) { 3626 + std::task::Poll::Pending => { 3627 + state.step_job = Some(step_jobs::StepJob::Export { rx, meta }); 3628 + } 3629 + std::task::Poll::Ready(result) => finish_export(state, result, &meta), 3630 + }, 3631 + } 3632 + } 3633 + 3634 + fn finish_import( 3635 + state: &mut AppState, 3636 + result: step_jobs::JobResult<Box<Document>>, 3637 + baseline: &Document, 3638 + meta: &step_jobs::JobMeta, 3639 + ) { 3640 + match result { 3641 + step_jobs::JobResult::Finished(_) if meta.cancel_requested() => { 3642 + tracing::info!(file = %meta.file_name, "discarding import that finished after cancel"); 3643 + } 3644 + step_jobs::JobResult::Finished(document) if *baseline != state.document => { 3645 + state.pending_discard = Some(PendingDiscard::InstallImported { 3646 + document, 3647 + file_name: meta.file_name.clone(), 3648 + }); 3649 + } 3650 + step_jobs::JobResult::Finished(document) => { 3651 + install_imported_document(state, *document); 3652 + notify_info( 3653 + state, 3654 + strings::NOTIFY_IMPORTED, 3655 + Some(meta.file_name.clone()), 3656 + ); 3657 + } 3658 + step_jobs::JobResult::Failed(bone_interop::StepError::Canceled) => { 3659 + tracing::info!(file = %meta.file_name, "step import canceled"); 3660 + } 3661 + step_jobs::JobResult::Failed(e) => { 3662 + tracing::warn!(error = %e, file = %meta.file_name, "step import failed"); 3663 + notify_error(state, strings::NOTIFY_IMPORT_FAILED, e.to_string()); 3664 + } 3665 + step_jobs::JobResult::WorkerLost => { 3666 + tracing::error!(file = %meta.file_name, "step import worker stopped before reporting a result"); 3667 + notify_error( 3668 + state, 3669 + strings::NOTIFY_IMPORT_FAILED, 3670 + "worker stopped before reporting a result".to_owned(), 3671 + ); 3672 + } 3673 + } 3674 + } 3675 + 3676 + fn finish_export( 3677 + state: &mut AppState, 3678 + result: step_jobs::JobResult<()>, 3679 + meta: &step_jobs::JobMeta, 3680 + ) { 3681 + match result { 3682 + step_jobs::JobResult::Finished(()) => { 3683 + notify_info( 3684 + state, 3685 + strings::NOTIFY_EXPORTED, 3686 + Some(meta.file_name.clone()), 3687 + ); 3688 + } 3689 + step_jobs::JobResult::Failed(bone_interop::StepError::Canceled) => { 3690 + tracing::info!(file = %meta.file_name, "step export canceled"); 3691 + } 3692 + step_jobs::JobResult::Failed(e) => { 3693 + tracing::warn!(error = %e, file = %meta.file_name, "step export failed"); 3694 + notify_error(state, strings::NOTIFY_EXPORT_FAILED, e.to_string()); 3695 + } 3696 + step_jobs::JobResult::WorkerLost => { 3697 + tracing::error!(file = %meta.file_name, "step export worker stopped before reporting a result"); 3698 + notify_error( 3699 + state, 3700 + strings::NOTIFY_EXPORT_FAILED, 3701 + "worker stopped before reporting a result".to_owned(), 3702 + ); 3703 + } 3704 + } 3705 + } 3706 + 3707 + fn is_dirty(state: &AppState) -> bool { 3708 + state.last_saved.as_ref() != Some(&state.document) 3709 + } 3710 + 3711 + fn apply_new_document(state: &mut AppState) { 3712 + let sketch = default_sketch(); 3713 + let scene = match SketchScene::extract(&sketch) { 3714 + Ok(s) => s, 3715 + Err(e) => { 3716 + tracing::warn!(error = %e, "scene extract failed on new document"); 3717 + notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); 3718 + return; 3719 + } 3720 + }; 3721 + let (document, sketch_id) = initial_document(sketch); 3722 + state.last_saved = Some(document.clone()); 3723 + state.document = document; 3724 + state.plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); 3725 + state.scene = scene; 3726 + state.mode = Mode::Idle; 3727 + state.selection = Selection::default(); 3728 + state.framed_extrude = None; 3729 + state.current_folder = None; 3730 + state.pending_overwrite = None; 3731 + let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { 3732 + unreachable!("UNDO_CAPACITY constant is non-zero"); 3733 + }; 3734 + state.undo = UndoStack::with_capacity(undo_capacity); 3735 + state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); 3736 + } 3737 + 3738 + fn open_picker( 3739 + state: &mut AppState, 3740 + mode: bone_ui::widgets::FilePickerMode, 3741 + kind: file_menu::FileKind, 3742 + seed_filename: Option<String>, 3743 + ) { 3744 + if state.file_picker.is_some() || state.native_picker.is_some() { 3745 + return; 3746 + } 3747 + let starting_folder = state 3748 + .current_folder 3749 + .as_ref() 3750 + .map(|f| f.path().to_owned()) 3751 + .or_else(|| { 3752 + state 3753 + .documents_root 3754 + .is_dir() 3755 + .then(|| state.documents_root.clone()) 3756 + }); 3757 + let title_key = file_menu::title_key(kind, mode); 3758 + let accept_key = file_menu::accept_key(kind, mode); 3759 + let title = state.strings.resolve(title_key).to_owned(); 3760 + let accept_label = state.strings.resolve(accept_key).to_owned(); 3761 + let native_req = native_picker::Request { 3762 + mode, 3763 + kind, 3764 + title: title.as_str(), 3765 + accept_label: accept_label.as_str(), 3766 + seed_filename: seed_filename.as_deref(), 3767 + current_folder: starting_folder.as_deref(), 3768 + }; 3769 + match native_picker::spawn(native_req) { 3770 + Ok(handle) => { 3771 + state.native_picker = Some(handle); 3772 + return; 3773 + } 3774 + Err(native_picker::SpawnError::Unsupported) => { 3775 + tracing::debug!("native picker unavailable, falling back to custom picker"); 3776 + } 3777 + } 3778 + open_custom_picker(state, mode, kind, seed_filename); 3779 + } 3780 + 3781 + fn open_custom_picker( 3782 + state: &mut AppState, 3783 + mode: bone_ui::widgets::FilePickerMode, 3784 + kind: file_menu::FileKind, 3785 + seed_filename: Option<String>, 3786 + ) { 3787 + let scan = match kind { 3788 + file_menu::FileKind::Document => file_menu::scan_document_folders(&state.documents_root), 3789 + file_menu::FileKind::Step => file_menu::scan_step_files(&state.documents_root), 3790 + }; 3791 + let entries = match scan { 3792 + Ok(v) => v, 3793 + Err(e) => { 3794 + tracing::warn!(error = %e, path = %state.documents_root.display(), "scan documents root failed"); 3795 + notify_error(state, strings::NOTIFY_SCAN_FAILED, e.to_string()); 3796 + Vec::new() 3797 + } 3798 + }; 3799 + state.file_picker = Some(file_menu::FilePickerSession::open( 3800 + state.documents_root.clone(), 3801 + mode, 3802 + kind, 3803 + seed_filename, 3804 + entries, 3805 + )); 3806 + } 3807 + 3808 + fn poll_native_picker(state: &mut AppState) { 3809 + let Some(handle) = state.native_picker.as_ref() else { 3810 + return; 3811 + }; 3812 + let outcome = match handle.poll() { 3813 + std::task::Poll::Pending => return, 3814 + std::task::Poll::Ready(o) => o, 3815 + }; 3816 + let mode = handle.mode; 3817 + let kind = handle.kind; 3818 + state.native_picker = None; 3819 + match outcome { 3820 + native_picker::NativeOutcome::Path(path) => { 3821 + route_picked_path(state, kind, mode, path, PickedVia::NativePortal); 3822 + } 3823 + native_picker::NativeOutcome::Cancelled => {} 3824 + native_picker::NativeOutcome::Error(message) => { 3825 + tracing::warn!(error = %message, "native picker errored, falling back to custom picker"); 3826 + let seed = matches!(mode, bone_ui::widgets::FilePickerMode::Save).then(|| match kind { 3827 + file_menu::FileKind::Document => state.document.name().to_owned(), 3828 + file_menu::FileKind::Step => format!("{}.step", state.document.name()), 3829 + }); 3830 + open_custom_picker(state, mode, kind, seed); 3831 + } 3832 + } 3833 + } 3834 + 3835 + fn route_picked_path( 3836 + state: &mut AppState, 3837 + kind: file_menu::FileKind, 3838 + mode: bone_ui::widgets::FilePickerMode, 3839 + path: PathBuf, 3840 + via: PickedVia, 3841 + ) { 3842 + match (kind, mode) { 3843 + (file_menu::FileKind::Document, bone_ui::widgets::FilePickerMode::Open) => { 3844 + request_open_folder(state, path); 3845 + } 3846 + (file_menu::FileKind::Document, bone_ui::widgets::FilePickerMode::Save) => { 3847 + apply_save_as(state, path); 3848 + } 3849 + (file_menu::FileKind::Step, bone_ui::widgets::FilePickerMode::Open) => { 3850 + request_import_step(state, path); 3851 + } 3852 + (file_menu::FileKind::Step, bone_ui::widgets::FilePickerMode::Save) => { 3853 + apply_export_step_as(state, path, via); 3854 + } 3855 + } 3856 + } 3857 + 3858 + fn apply_save_in_place(state: &mut AppState) { 3859 + let Some(folder) = state.current_folder.clone() else { 3860 + let seed = state.document.name().to_owned(); 3861 + open_picker( 3862 + state, 3863 + bone_ui::widgets::FilePickerMode::Save, 3864 + file_menu::FileKind::Document, 3865 + Some(seed), 3866 + ); 3867 + return; 3868 + }; 3869 + if let Err(e) = bone_document::save(&state.document, &folder) { 3870 + tracing::warn!(error = %e, path = %folder.path().display(), "save failed"); 3871 + notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string()); 3872 + return; 3873 + } 3874 + state.last_saved = Some(state.document.clone()); 3875 + notify_info(state, strings::NOTIFY_SAVED, None); 3876 + } 3877 + 3878 + fn apply_picker_command( 3879 + state: &mut AppState, 3880 + kind: file_menu::FileKind, 3881 + command: file_menu::PickerCommand, 3882 + ) { 3883 + state.file_picker = None; 3884 + match command { 3885 + file_menu::PickerCommand::Cancel => {} 3886 + file_menu::PickerCommand::Open(path) => { 3887 + route_picked_path( 3888 + state, 3889 + kind, 3890 + bone_ui::widgets::FilePickerMode::Open, 3891 + path, 3892 + PickedVia::CustomPicker, 3893 + ); 3894 + } 3895 + file_menu::PickerCommand::SaveAs(path) => { 3896 + route_picked_path( 3897 + state, 3898 + kind, 3899 + bone_ui::widgets::FilePickerMode::Save, 3900 + path, 3901 + PickedVia::CustomPicker, 3902 + ); 3903 + } 3904 + } 3905 + } 3906 + 3907 + fn apply_open_folder(state: &mut AppState, path: PathBuf) { 3908 + let folder = DocumentFolder::new(path); 3909 + let document = match bone_document::load(&folder) { 3910 + Ok(d) => d, 3911 + Err(e) => { 3912 + tracing::warn!(error = %e, path = %folder.path().display(), "load failed"); 3913 + notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); 3914 + return; 3915 + } 3916 + }; 3917 + install_loaded_document(state, document, Some(folder)); 3918 + } 3919 + 3920 + fn apply_save_as(state: &mut AppState, path: PathBuf) { 3921 + let folder = DocumentFolder::new(path); 3922 + let in_place = state 3923 + .current_folder 3924 + .as_ref() 3925 + .is_some_and(|current| same_folder(current.path(), folder.path())); 3926 + if folder.document_file().is_file() && !in_place { 3927 + state.pending_overwrite = Some(PendingOverwrite::Document(folder)); 3928 + return; 3929 + } 3930 + perform_save_to(state, folder); 3931 + } 3932 + 3933 + fn perform_save_to(state: &mut AppState, folder: DocumentFolder) { 3934 + let prior_name = state.document.name().to_owned(); 3935 + state 3936 + .document 3937 + .set_name(folder_display_name(&folder, &state.strings)); 3938 + if let Err(e) = bone_document::save(&state.document, &folder) { 3939 + tracing::warn!(error = %e, path = %folder.path().display(), "save as failed"); 3940 + state.document.set_name(prior_name); 3941 + notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string()); 3942 + return; 3943 + } 3944 + state.current_folder = Some(folder); 3945 + state.last_saved = Some(state.document.clone()); 3946 + notify_info(state, strings::NOTIFY_SAVED, None); 3947 + } 3948 + 3949 + fn folder_display_name(folder: &DocumentFolder, string_table: &StringTable) -> String { 3950 + folder.path().file_name().map_or_else( 3951 + || { 3952 + string_table 3953 + .resolve(strings::DEFAULT_DOCUMENT_NAME) 3954 + .to_owned() 3955 + }, 3956 + |s| s.to_string_lossy().into_owned(), 3957 + ) 3958 + } 3959 + 3960 + fn same_folder(a: &Path, b: &Path) -> bool { 3961 + match (resolve_path(a), resolve_path(b)) { 3962 + (Some(x), Some(y)) => x == y, 3963 + _ => false, 3964 + } 3965 + } 3966 + 3967 + fn resolve_path(path: &Path) -> Option<PathBuf> { 3968 + if let Ok(canon) = std::fs::canonicalize(path) { 3969 + return Some(canon); 3970 + } 3971 + let parent = path.parent()?; 3972 + let file_name = path.file_name()?; 3973 + let parent_canon = std::fs::canonicalize(parent).ok()?; 3974 + Some(parent_canon.join(file_name)) 3975 + } 3976 + 3977 + fn notify_error(state: &mut AppState, headline: bone_ui::strings::StringKey, detail: String) { 3978 + state.notification = Some(Notification { 3979 + kind: NotificationKind::Error, 3980 + headline, 3981 + detail: Some(detail), 3982 + }); 3983 + } 3984 + 3985 + fn notify_info( 3986 + state: &mut AppState, 3987 + headline: bone_ui::strings::StringKey, 3988 + detail: Option<String>, 3989 + ) { 3990 + state.notification = Some(Notification { 3991 + kind: NotificationKind::Info, 3992 + headline, 3993 + detail, 3994 + }); 3995 + } 3996 + 3997 + fn notify_stub(state: &mut AppState, label: bone_ui::strings::StringKey) { 3998 + let detail = state.strings.resolve(label).to_owned(); 3999 + tracing::info!(label = %detail, "stub action invoked"); 4000 + notify_info(state, strings::NOTIFY_COMING_SOON, Some(detail)); 4001 + } 4002 + 4003 + fn apply_shortcut_bar_outcome( 4004 + state: &mut AppState, 4005 + outcome: Option<&shortcut_bar::ShortcutBarOutcome>, 4006 + ) { 4007 + let Some(outcome) = outcome else { return }; 4008 + if outcome.dismissed || outcome.activated.is_some() { 4009 + state.shortcut_bar = None; 4010 + } 4011 + } 4012 + 4013 + fn install_imported_document(state: &mut AppState, document: Document) { 4014 + install_loaded_document(state, document, None); 4015 + state.last_saved = None; 4016 + } 4017 + 4018 + fn install_loaded_document( 4019 + state: &mut AppState, 4020 + document: Document, 4021 + folder: Option<DocumentFolder>, 4022 + ) { 4023 + let plane_sketches = plane_sketches_from(&document); 4024 + let active_sketch_id = plane_sketches.get(&Plane::Xy).copied(); 4025 + state.last_saved = Some(document.clone()); 4026 + state.document = document; 4027 + state.plane_sketches = plane_sketches; 4028 + state.mode = Mode::Idle; 4029 + state.selection = Selection::default(); 4030 + state.framed_extrude = None; 4031 + state.current_folder = folder; 4032 + state.pending_overwrite = None; 4033 + let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { 4034 + unreachable!("UNDO_CAPACITY constant is non-zero"); 4035 + }; 4036 + state.undo = UndoStack::with_capacity(undo_capacity); 4037 + let scene_attempt = active_sketch_id 4038 + .and_then(|id| state.document.sketch(id)) 4039 + .map(SketchScene::extract); 4040 + state.scene = match scene_attempt { 4041 + None => SketchScene::empty(), 4042 + Some(Ok(scene)) => scene, 4043 + Some(Err(e)) => { 4044 + tracing::warn!(error = %e, "scene extract on load failed"); 4045 + notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); 4046 + SketchScene::empty() 4047 + } 4048 + }; 4049 + state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); 4050 + } 4051 + 4052 + fn plane_sketches_from(document: &Document) -> BTreeMap<Plane, SketchId> { 4053 + document 4054 + .sketches() 4055 + .map(|(id, _)| (Plane::Xy, id)) 4056 + .take(1) 4057 + .collect() 4058 + } 4059 + 4060 + fn persist_settings(state: &AppState) { 4061 + settings::save(&state.settings); 4062 + } 4063 + 4064 + fn apply_settings_change(state: &mut AppState, change: Option<settings::Settings>) { 4065 + let Some(next) = change else { return }; 4066 + let overrides_changed = next.hotkey_overrides != state.settings.hotkey_overrides; 4067 + if !overrides_changed { 4068 + state.settings = next; 4069 + persist_settings(state); 4070 + return; 4071 + } 4072 + match hotkeys::compose_table(&next.hotkey_overrides) { 4073 + Ok(table) => { 4074 + state.hotkeys = table; 4075 + state.settings = next; 4076 + persist_settings(state); 4077 + } 4078 + Err(error) => { 4079 + tracing::warn!(?error, "hotkey override rejected, retaining prior settings"); 4080 + state.shell.state.hotkey_capture.clear(); 4081 + notify_error(state, strings::NOTIFY_HOTKEY_CONFLICT, format!("{error}")); 4082 + } 4083 + } 4084 + } 4085 + 4086 + fn apply_sketch_rename(state: &mut AppState, request: Option<shell::SketchRenameRequest>) { 4087 + let Some(req) = request else { return }; 4088 + apply_sketch_rename_into(&mut state.document, &mut state.undo, req); 4089 + } 4090 + 4091 + fn apply_extrude_rename(state: &mut AppState, request: Option<shell::ExtrudeRenameRequest>) { 4092 + let Some(req) = request else { return }; 4093 + apply_extrude_rename_into(&mut state.document, &mut state.undo, req); 4094 + } 4095 + 4096 + fn apply_extrude_rename_into( 4097 + document: &mut Document, 4098 + undo: &mut UndoStack, 4099 + request: shell::ExtrudeRenameRequest, 4100 + ) { 4101 + let shell::ExtrudeRenameRequest { id, label } = request; 4102 + let trimmed = label.trim(); 4103 + let Some(current) = document.extrude_label(id) else { 4104 + return; 4105 + }; 4106 + if trimmed.is_empty() || current == trimmed { 4107 + return; 4108 + } 4109 + let snapshot = document.clone(); 4110 + match document.rename_extrude(id, &label) { 4111 + Ok(()) => undo.record(snapshot), 4112 + Err(e) => tracing::warn!(error = %e, ?id, "extrude rename rejected"), 4113 + } 4114 + } 4115 + 4116 + fn apply_sketch_rename_into( 4117 + document: &mut Document, 4118 + undo: &mut UndoStack, 4119 + request: shell::SketchRenameRequest, 4120 + ) { 4121 + let shell::SketchRenameRequest { id, label } = request; 4122 + let trimmed = label.trim(); 4123 + let Some(current) = document.sketch_label(id) else { 4124 + return; 4125 + }; 4126 + if trimmed.is_empty() || current == trimmed { 4127 + return; 4128 + } 4129 + let snapshot = document.clone(); 4130 + match document.rename_sketch(id, &label) { 4131 + Ok(()) => undo.record(snapshot), 4132 + Err(e) => tracing::warn!(error = %e, ?id, "sketch rename rejected"), 4133 + } 4134 + } 4135 + 4136 + #[cfg(test)] 4137 + mod tests { 4138 + use super::*; 4139 + use crate::sketch_mode::SketchSession; 4140 + use bone_ui::hotkey::KeyChord; 4141 + use bone_ui::input::{KeyChar, KeyCode, KeyEvent, NamedKey}; 4142 + 4143 + #[test] 4144 + fn strip_plain_letter_chords_removes_chars_with_no_modifiers() { 4145 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 4146 + let plain_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::NONE); 4147 + let ctrl_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL); 4148 + let esc = KeyEvent::new(KeyCode::Named(NamedKey::Escape), ModifierMask::NONE); 4149 + input.keys_pressed = vec![plain_s, ctrl_s, esc]; 4150 + strip_plain_letter_chords(&mut input); 4151 + assert_eq!( 4152 + input.keys_pressed, 4153 + vec![ctrl_s, esc], 4154 + "strip removes plain letters, keeps modified chords and named keys" 4155 + ); 4156 + } 4157 + 4158 + #[test] 4159 + fn strip_plain_letter_chords_is_idempotent_when_no_chars() { 4160 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 4161 + let enter = KeyEvent::new(KeyCode::Named(NamedKey::Enter), ModifierMask::NONE); 4162 + input.keys_pressed = vec![enter]; 4163 + strip_plain_letter_chords(&mut input); 4164 + assert_eq!(input.keys_pressed, vec![enter]); 4165 + } 4166 + 4167 + #[test] 4168 + fn cursor_to_world_at_window_center_equals_camera_pan() { 4169 + let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100)); 4170 + let camera = Camera2::new(extent) 4171 + .with_pan(Vec2::from_mm(7.0, -3.0)) 4172 + .with_zoom(PixelsPerMm::new(5.0)); 4173 + let Some(world) = cursor_to_world(camera, WindowPoint::new(100.0, 50.0)) else { 4174 + panic!("center maps"); 4175 + }; 4176 + let (x, y) = world.coords_mm(); 4177 + assert!((x - 7.0).abs() < 1e-9); 4178 + assert!((y - -3.0).abs() < 1e-9); 4179 + } 4180 + 4181 + #[test] 4182 + fn cursor_to_world_inverts_y_so_up_in_window_is_up_in_world() { 4183 + let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100)); 4184 + let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(10.0)); 4185 + let Some(above) = cursor_to_world(camera, WindowPoint::new(100.0, 0.0)) else { 4186 + panic!("top"); 4187 + }; 4188 + let Some(below) = cursor_to_world(camera, WindowPoint::new(100.0, 100.0)) else { 4189 + panic!("bottom"); 4190 + }; 4191 + let (_, ya) = above.coords_mm(); 4192 + let (_, yb) = below.coords_mm(); 4193 + assert!(ya > yb); 4194 + } 4195 + 4196 + #[test] 4197 + fn cursor_to_world_rejects_zero_extent() { 4198 + let extent = ViewportExtent::new(ViewportPx::new(0), ViewportPx::new(100)); 4199 + let camera = Camera2::new(extent); 4200 + assert!(cursor_to_world(camera, WindowPoint::new(0.0, 0.0)).is_none()); 4201 + } 4202 + 4203 + fn empty_frame() -> shell::ShellFrame { 4204 + shell::ShellFrame { 4205 + paints: Vec::new(), 4206 + overlay_paints: Vec::new(), 4207 + viewport_rect: empty_rect(), 4208 + activated_tool: None, 4209 + activated_feature_tool: None, 4210 + activated_relation: None, 4211 + activated_dimension: None, 4212 + dimension_edit: None, 4213 + extrude_edit: None, 4214 + plane_picked: None, 4215 + sketch_activated: None, 4216 + sketch_rename: None, 4217 + extrude_activated: None, 4218 + extrude_rename: None, 4219 + exit_sketch: false, 4220 + confirm_action: None, 4221 + menu_action: None, 4222 + settings_change: None, 4223 + view_pick: None, 4224 + view_menu: None, 4225 + } 4226 + } 4227 + 4228 + fn xy_only() -> BTreeMap<Plane, SketchId> { 4229 + BTreeMap::from([(Plane::Xy, SketchId::default())]) 4230 + } 4231 + 4232 + #[test] 4233 + fn classify_extrude_profile_separates_no_sketch_from_unique() { 4234 + let empty = Document::new(DocumentId::default(), "Untitled".to_owned()); 4235 + assert!(matches!( 4236 + super::classify_extrude_profile(&empty), 4237 + super::ProfileChoice::NoSketch 4238 + )); 4239 + 4240 + let (one, id) = super::initial_document(Sketch::new(Plane::Xy.basis())); 4241 + assert!(matches!( 4242 + super::classify_extrude_profile(&one), 4243 + super::ProfileChoice::Unique(found) if found == id 4244 + )); 4245 + } 4246 + 4247 + fn rectangle_sketch() -> Sketch { 4248 + let sketch = Sketch::new(Plane::Xy.basis()); 4249 + let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 4250 + let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); 4251 + let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(10.0, 6.0)); 4252 + let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(0.0, 6.0)); 4253 + let (sketch, _) = tools::add_line(sketch, p0, p1, false); 4254 + let (sketch, _) = tools::add_line(sketch, p1, p2, false); 4255 + let (sketch, _) = tools::add_line(sketch, p2, p3, false); 4256 + let (sketch, _) = tools::add_line(sketch, p3, p0, false); 4257 + sketch 4258 + } 4259 + 4260 + fn tall_rectangle_sketch() -> Sketch { 4261 + let sketch = Sketch::new(Plane::Xy.basis()); 4262 + let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 4263 + let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); 4264 + let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(10.0, 20.0)); 4265 + let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(0.0, 20.0)); 4266 + let (sketch, _) = tools::add_line(sketch, p0, p1, false); 4267 + let (sketch, _) = tools::add_line(sketch, p1, p2, false); 4268 + let (sketch, _) = tools::add_line(sketch, p2, p3, false); 4269 + let (sketch, _) = tools::add_line(sketch, p3, p0, false); 4270 + sketch 4271 + } 4272 + 4273 + #[test] 4274 + fn extrude_preview_cache_is_current_only_for_the_same_feature_and_sketch_version() { 4275 + let id = SketchId::default(); 4276 + let feature = sketch_mode::default_extrude_feature(id); 4277 + let base_version = Sketch::new(Plane::Xy.basis()).version(); 4278 + let edited_version = rectangle_sketch().version(); 4279 + assert_ne!( 4280 + base_version, edited_version, 4281 + "a sketch edit must bump the version this gate keys on" 4282 + ); 4283 + let cached = super::ExtrudePreview { 4284 + feature, 4285 + sketch_version: base_version, 4286 + generation: None, 4287 + failed: false, 4288 + error: None, 4289 + }; 4290 + assert!(super::extrude_preview_is_current( 4291 + Some(&cached), 4292 + &feature, 4293 + base_version 4294 + )); 4295 + assert!( 4296 + !super::extrude_preview_is_current(Some(&cached), &feature, edited_version), 4297 + "editing the sketch under the same feature must invalidate the cached preview", 4298 + ); 4299 + assert!(!super::extrude_preview_is_current( 4300 + None, 4301 + &feature, 4302 + base_version 4303 + )); 4304 + } 4305 + 4306 + #[test] 4307 + fn extrude_preview_refreshes_when_the_sketch_changes_under_one_cache() { 4308 + let (mut document, id) = super::initial_document(rectangle_sketch()); 4309 + let feature = sketch_mode::default_extrude_feature(id); 4310 + let mut cache = super::FeatureCache::new(); 4311 + let first = super::compute_extrude_preview(&mut cache, &document, feature) 4312 + .and_then(|preview| preview.generation()); 4313 + document.replace_sketch(id, tall_rectangle_sketch()); 4314 + let second = super::compute_extrude_preview(&mut cache, &document, feature) 4315 + .and_then(|preview| preview.generation()); 4316 + let (Some(first), Some(second)) = (first, second) else { 4317 + panic!("both rectangles extrude to a solid"); 4318 + }; 4319 + assert_ne!( 4320 + first, second, 4321 + "a sketch edit under the same feature must re-evaluate against the edited geometry", 4322 + ); 4323 + } 4324 + 4325 + #[test] 4326 + fn extrude_preview_evaluates_a_closed_rectangle() { 4327 + let (document, id) = super::initial_document(rectangle_sketch()); 4328 + let feature = sketch_mode::default_extrude_feature(id); 4329 + let mut cache = super::FeatureCache::new(); 4330 + let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { 4331 + panic!("a registered sketch yields an evaluated preview"); 4332 + }; 4333 + assert!( 4334 + preview.solid().is_some(), 4335 + "closed rectangle extrudes to a solid" 4336 + ); 4337 + } 4338 + 4339 + #[test] 4340 + #[cfg_attr( 4341 + debug_assertions, 4342 + ignore = "frame budget assertions are only meaningful in release builds" 4343 + )] 4344 + fn extrude_live_preview_under_frame_budget() { 4345 + use bone_document::ExtrudeEndCondition; 4346 + use bone_types::PositiveLength; 4347 + use std::time::{Duration, Instant}; 4348 + use uom::si::length::millimeter; 4349 + 4350 + const BASE_MM: f64 = 4.0; 4351 + const STEP_MM: f64 = 0.5; 4352 + const STEPS: u32 = 16; 4353 + 4354 + let (document, id) = super::initial_document(rectangle_sketch()); 4355 + let mut cache = super::FeatureCache::new(); 4356 + let blind = |depth_mm: f64| { 4357 + let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(depth_mm)) else { 4358 + panic!("{depth_mm} mm is a positive depth"); 4359 + }; 4360 + ExtrudeFeature { 4361 + end_condition: ExtrudeEndCondition::Blind { depth }, 4362 + ..sketch_mode::default_extrude_feature(id) 4363 + } 4364 + }; 4365 + let edit = |cache: &mut super::FeatureCache, depth_mm: f64| { 4366 + let started = Instant::now(); 4367 + let Some(preview) = super::compute_extrude_preview(cache, &document, blind(depth_mm)) 4368 + else { 4369 + panic!("the rectangle extrudes at {depth_mm} mm"); 4370 + }; 4371 + let Some(solid) = preview.solid() else { 4372 + panic!("the rectangle yields a solid at {depth_mm} mm"); 4373 + }; 4374 + let Ok(_view) = super::build_solid_view(solid) else { 4375 + panic!("the slab tessellates and packs scenes at {depth_mm} mm"); 4376 + }; 4377 + started.elapsed() 4378 + }; 4379 + 4380 + let _warmup = edit(&mut cache, BASE_MM); 4381 + let durations: Vec<Duration> = (0..STEPS) 4382 + .map(|i| edit(&mut cache, BASE_MM + STEP_MM * f64::from(i))) 4383 + .collect(); 4384 + let sorted = { 4385 + let mut v = durations.clone(); 4386 + v.sort(); 4387 + v 4388 + }; 4389 + let median = sorted[sorted.len() / 2]; 4390 + let Some(&worst) = sorted.last() else { 4391 + panic!("preview loop produced zero samples"); 4392 + }; 4393 + let budget = BudgetCeiling::FRAME_16MS.duration(); 4394 + assert!( 4395 + median <= budget, 4396 + "median evaluate+tessellate+scene step {median:?} exceeds {budget:?} frame budget; samples {durations:?}", 4397 + ); 4398 + assert!( 4399 + worst <= budget * 2, 4400 + "worst evaluate+tessellate+scene step {worst:?} exceeds the relaxed ceiling; samples {durations:?}", 4401 + ); 4402 + } 4403 + 4404 + #[test] 4405 + fn extrude_preview_absent_when_sketch_missing() { 4406 + let document = Document::new(DocumentId::default(), "Empty".to_owned()); 4407 + let feature = sketch_mode::default_extrude_feature(SketchId::default()); 4408 + let mut cache = super::FeatureCache::new(); 4409 + assert!(super::compute_extrude_preview(&mut cache, &document, feature).is_none()); 4410 + } 4411 + 4412 + #[test] 4413 + fn default_document_extrudes_to_a_solid() { 4414 + let (document, id) = super::initial_document(super::default_sketch()); 4415 + let feature = sketch_mode::default_extrude_feature(id); 4416 + let mut cache = super::FeatureCache::new(); 4417 + let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { 4418 + panic!("the default sketch is registered"); 4419 + }; 4420 + let Some(solid) = preview.solid() else { 4421 + panic!("the default sketch extrudes to a solid"); 4422 + }; 4423 + assert!(super::build_solid_view(solid).is_ok()); 4424 + } 4425 + 4426 + #[test] 4427 + fn preview_solid_view_tessellates_and_frames() { 4428 + let (document, id) = super::initial_document(rectangle_sketch()); 4429 + let feature = sketch_mode::default_extrude_feature(id); 4430 + let mut cache = super::FeatureCache::new(); 4431 + let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { 4432 + panic!("a registered sketch yields an evaluated preview"); 4433 + }; 4434 + let Some(solid) = preview.solid() else { 4435 + panic!("the rectangle extrudes to a solid"); 4436 + }; 4437 + let Ok(view) = super::build_solid_view(solid) else { 4438 + panic!("the solid tessellates into a renderable view"); 4439 + }; 4440 + let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256)); 4441 + let region = ViewportRegion::at_origin(extent); 4442 + let camera = frame_standard_view(view.aabb, extent, StandardView::Isometric, None).ok(); 4443 + assert!( 4444 + camera.is_some(), 4445 + "the solid aabb frames an isometric camera" 4446 + ); 4447 + assert!( 4448 + super::preview_solid_frame(Some(&view), camera, region).is_some(), 4449 + "a framed preview lowers to a solid frame view", 4450 + ); 4451 + assert!( 4452 + super::preview_solid_frame(Some(&view), None, region).is_none(), 4453 + "without a camera there is nothing to frame", 4454 + ); 4455 + } 4456 + 4457 + fn layout_rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { 4458 + LayoutRect::new( 4459 + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 4460 + LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)), 4461 + ) 4462 + } 4463 + 4464 + #[test] 4465 + fn solid_viewport_region_offsets_inside_the_surface() { 4466 + let surface = ViewportExtent::new(ViewportPx::new(1280), ViewportPx::new(800)); 4467 + let Some(region) = 4468 + super::solid_viewport_region(layout_rect(320.0, 96.0, 800.0, 600.0), surface) 4469 + else { 4470 + panic!("an inset viewport yields a region"); 4471 + }; 4472 + assert_eq!( 4473 + region.scissor(), 4474 + (320, 96, 800, 600), 4475 + "the region carries the viewport offset and size, not the whole window", 4476 + ); 4477 + } 4478 + 4479 + #[test] 4480 + fn solid_viewport_region_clamps_to_the_surface() { 4481 + let surface = ViewportExtent::new(ViewportPx::new(640), ViewportPx::new(480)); 4482 + let Some(region) = 4483 + super::solid_viewport_region(layout_rect(600.0, 400.0, 400.0, 400.0), surface) 4484 + else { 4485 + panic!("a partly off-surface viewport still yields a clamped region"); 4486 + }; 4487 + let (x, y, w, h) = region.scissor(); 4488 + assert!( 4489 + x + w <= 640 && y + h <= 480, 4490 + "the scissor never runs past the surface: {x}+{w}, {y}+{h}", 4491 + ); 4492 + } 4493 + 4494 + #[test] 4495 + fn solid_viewport_region_is_none_for_a_degenerate_viewport() { 4496 + let surface = ViewportExtent::new(ViewportPx::new(1280), ViewportPx::new(800)); 4497 + assert!(super::solid_viewport_region(layout_rect(0.0, 0.0, 0.0, 0.0), surface).is_none()); 4498 + } 4499 + 4500 + #[test] 4501 + fn viewport_local_point_centers_the_inset_viewport_not_the_window() { 4502 + let region = ViewportRegion::new( 4503 + ViewportPx::new(282), 4504 + ViewportPx::new(120), 4505 + ViewportExtent::new(ViewportPx::new(998), ViewportPx::new(636)), 4506 + ); 4507 + let center = WindowPoint::new(282.0 + 499.0, 120.0 + 318.0); 4508 + let Some(local) = super::viewport_local_point(center, region) else { 4509 + panic!("a cursor inside the surface yields a viewport-local point"); 4510 + }; 4511 + assert!( 4512 + (local.x() - 499.0).abs() < 1e-9 && (local.y() - 318.0).abs() < 1e-9, 4513 + "a cursor at the inset viewport center maps to the region-local center: ({}, {})", 4514 + local.x(), 4515 + local.y(), 4516 + ); 4517 + } 4518 + 4519 + #[test] 4520 + fn drag_gesture_maps_modifiers_to_orbit_pan_zoom_roll() { 4521 + assert_eq!(super::drag_gesture(ModifierMask::NONE), NavGesture::Orbit); 4522 + assert_eq!(super::drag_gesture(ModifierMask::CTRL), NavGesture::Pan); 4523 + assert_eq!(super::drag_gesture(ModifierMask::SHIFT), NavGesture::Zoom); 4524 + assert_eq!(super::drag_gesture(ModifierMask::ALT), NavGesture::Roll); 4525 + assert_eq!( 4526 + super::drag_gesture(ModifierMask::CTRL | ModifierMask::SHIFT), 4527 + NavGesture::Pan, 4528 + "ctrl outranks shift so a held ctrl always pans", 4529 + ); 4530 + } 4531 + 4532 + #[test] 4533 + fn plane_pick_from_idle_enters_sketch_for_known_plane() { 4534 + let frame = shell::ShellFrame { 4535 + plane_picked: Some(Plane::Xy), 4536 + ..empty_frame() 4537 + }; 4538 + let next = next_mode(Mode::Idle, &frame, false, &xy_only()); 4539 + assert_eq!(next, Mode::enter_sketch(SketchId::default())); 4540 + } 4541 + 4542 + #[test] 4543 + fn plane_pick_from_idle_with_no_sketch_for_plane_stays_idle() { 4544 + let frame = shell::ShellFrame { 4545 + plane_picked: Some(Plane::Yz), 4546 + ..empty_frame() 4547 + }; 4548 + let next = next_mode(Mode::Idle, &frame, false, &xy_only()); 4549 + assert_eq!(next, Mode::Idle); 4550 + } 4551 + 4552 + #[test] 4553 + fn plane_pick_while_in_sketch_keeps_current_mode() { 4554 + let prev = Mode::enter_sketch(SketchId::default()); 4555 + let frame = shell::ShellFrame { 4556 + plane_picked: Some(Plane::Xy), 4557 + ..empty_frame() 4558 + }; 4559 + assert_eq!(next_mode(prev.clone(), &frame, false, &xy_only()), prev); 4560 + } 4561 + 4562 + #[test] 4563 + fn ribbon_exit_returns_idle() { 4564 + let prev = Mode::enter_sketch(SketchId::default()); 4565 + let frame = shell::ShellFrame { 4566 + exit_sketch: true, 4567 + ..empty_frame() 4568 + }; 4569 + assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); 4570 + } 4571 + 4572 + #[test] 4573 + fn exit_sketch_action_returns_idle() { 4574 + let prev = Mode::enter_sketch(SketchId::default()); 4575 + assert_eq!( 4576 + next_mode(prev, &empty_frame(), true, &xy_only()), 4577 + Mode::Idle 4578 + ); 4579 + } 4580 + 4581 + #[test] 4582 + fn escape_with_pending_clears_pending_keeps_sketch_and_tool() { 4583 + let prev = Mode::Sketch { 4584 + sketch_id: SketchId::default(), 4585 + session: Box::new(SketchSession { 4586 + tool: Some(SketchTool::Line), 4587 + pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm( 4588 + 1.0, 2.0, 4589 + )))), 4590 + ..SketchSession::default() 4591 + }), 4592 + }; 4593 + let next = next_mode(prev, &empty_frame(), true, &xy_only()); 4594 + let Mode::Sketch { session, .. } = next else { 4595 + panic!("escape with pending must keep sketch mode"); 4596 + }; 4597 + assert_eq!(session.tool, Some(SketchTool::Line)); 4598 + assert_eq!(session.pending, None); 4599 + } 4600 + 4601 + fn far_camera() -> Camera2 { 4602 + Camera2::new(ViewportExtent::new( 4603 + ViewportPx::new(800), 4604 + ViewportPx::new(600), 4605 + )) 4606 + .with_zoom(PixelsPerMm::new(1_000_000.0)) 4607 + } 4608 + 4609 + #[test] 4610 + fn build_preview_in_idle_is_empty() { 4611 + let document = Document::new(DocumentId::default(), "doc".to_owned()); 4612 + let preview = build_preview( 4613 + &Mode::Idle, 4614 + &document, 4615 + Some(Point2::from_mm(1.0, 1.0)), 4616 + &far_camera(), 4617 + ); 4618 + assert!(preview.is_empty()); 4619 + } 4620 + 4621 + #[test] 4622 + fn build_preview_without_armed_tool_is_empty() { 4623 + let document = Document::new(DocumentId::default(), "doc".to_owned()); 4624 + let mode = Mode::enter_sketch(SketchId::default()); 4625 + let preview = build_preview( 4626 + &mode, 4627 + &document, 4628 + Some(Point2::from_mm(0.0, 0.0)), 4629 + &far_camera(), 4630 + ); 4631 + assert!(preview.is_empty()); 4632 + } 4633 + 4634 + #[test] 4635 + fn build_preview_with_position_pending_emits_anchor_and_segment() { 4636 + let sketch = Sketch::new(Plane::Xy.basis()); 4637 + let (document, sketch_id) = initial_document(sketch); 4638 + let anchor = Point2::from_mm(2.0, 3.0); 4639 + let mode = Mode::Sketch { 4640 + sketch_id, 4641 + session: Box::new(SketchSession { 4642 + tool: Some(SketchTool::Line), 4643 + pending: Some(Pending::First(ClickAnchor::Position(anchor))), 4644 + ..SketchSession::default() 4645 + }), 4646 + }; 4647 + let cursor = Point2::from_mm(5.0, 7.0); 4648 + let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); 4649 + assert_eq!(preview.anchors, vec![anchor]); 4650 + assert_eq!(preview.segments, vec![(anchor, cursor)]); 4651 + } 4652 + 4653 + #[test] 4654 + fn build_preview_with_endpoint_pending_resolves_via_document() { 4655 + let sketch = Sketch::new(Plane::Xy.basis()); 4656 + let target = Point2::from_mm(-4.0, 6.0); 4657 + let (sketch, endpoint) = tools::add_point(sketch, target); 4658 + let (document, sketch_id) = initial_document(sketch); 4659 + let mode = Mode::Sketch { 4660 + sketch_id, 4661 + session: Box::new(SketchSession { 4662 + tool: Some(SketchTool::Line), 4663 + pending: Some(Pending::First(ClickAnchor::Endpoint(endpoint))), 4664 + ..SketchSession::default() 4665 + }), 4666 + }; 4667 + let cursor = Point2::from_mm(0.0, 0.0); 4668 + let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); 4669 + assert_eq!(preview.anchors, vec![target]); 4670 + assert_eq!(preview.segments, vec![(target, cursor)]); 4671 + } 4672 + 4673 + #[test] 4674 + fn build_preview_keeps_anchor_when_cursor_outside_viewport() { 4675 + let sketch = Sketch::new(Plane::Xy.basis()); 4676 + let (document, sketch_id) = initial_document(sketch); 4677 + let anchor = Point2::from_mm(1.0, 1.0); 4678 + let mode = Mode::Sketch { 4679 + sketch_id, 4680 + session: Box::new(SketchSession { 4681 + tool: Some(SketchTool::Line), 4682 + pending: Some(Pending::First(ClickAnchor::Position(anchor))), 4683 + ..SketchSession::default() 4684 + }), 4685 + }; 4686 + let preview = build_preview(&mode, &document, None, &far_camera()); 4687 + assert_eq!(preview.anchors, vec![anchor]); 4688 + assert!(preview.segments.is_empty()); 4689 + } 4690 + 4691 + #[test] 4692 + fn build_preview_during_drag_is_empty() { 4693 + let document = Document::new(DocumentId::default(), "doc".to_owned()); 4694 + let mode = Mode::Sketch { 4695 + sketch_id: SketchId::default(), 4696 + session: Box::new(SketchSession { 4697 + tool: Some(SketchTool::Line), 4698 + pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm( 4699 + 0.0, 0.0, 4700 + )))), 4701 + drag: Some(DragSession { 4702 + entity: bone_types::SketchEntityId::default(), 4703 + press: Point2::origin(), 4704 + pins: DragPins::from_array([ 4705 + Some((bone_types::SketchEntityId::default(), Point2::origin())), 4706 + None, 4707 + None, 4708 + ]), 4709 + }), 4710 + ..SketchSession::default() 4711 + }), 4712 + }; 4713 + let preview = build_preview( 4714 + &mode, 4715 + &document, 4716 + Some(Point2::from_mm(1.0, 1.0)), 4717 + &far_camera(), 4718 + ); 4719 + assert!(preview.is_empty()); 4720 + } 4721 + 4722 + #[test] 4723 + fn build_preview_circle_emits_ghost_circle() { 4724 + let sketch = Sketch::new(Plane::Xy.basis()); 4725 + let (document, sketch_id) = initial_document(sketch); 4726 + let center = Point2::from_mm(0.0, 0.0); 4727 + let mode = Mode::Sketch { 4728 + sketch_id, 4729 + session: Box::new(SketchSession { 4730 + tool: Some(SketchTool::Circle), 4731 + pending: Some(Pending::First(ClickAnchor::Position(center))), 4732 + ..SketchSession::default() 4733 + }), 4734 + }; 4735 + let cursor = Point2::from_mm(3.0, 4.0); 4736 + let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); 4737 + assert_eq!(preview.anchors, vec![center]); 4738 + assert_eq!(preview.circles.len(), 1); 4739 + let r = preview.circles[0].radius.get::<millimeter>(); 4740 + assert!((r - 5.0).abs() < 1e-9, "r={r}"); 4741 + } 4742 + 4743 + #[test] 4744 + fn build_preview_corner_rectangle_emits_four_segments() { 4745 + let sketch = Sketch::new(Plane::Xy.basis()); 4746 + let (document, sketch_id) = initial_document(sketch); 4747 + let corner = Point2::from_mm(0.0, 0.0); 4748 + let mode = Mode::Sketch { 4749 + sketch_id, 4750 + session: Box::new(SketchSession { 4751 + tool: Some(SketchTool::CornerRectangle), 4752 + pending: Some(Pending::First(ClickAnchor::Position(corner))), 4753 + ..SketchSession::default() 4754 + }), 4755 + }; 4756 + let cursor = Point2::from_mm(5.0, 3.0); 4757 + let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); 4758 + assert_eq!(preview.anchors, vec![corner]); 4759 + assert_eq!(preview.segments.len(), 4); 4760 + } 4761 + 4762 + #[test] 4763 + fn build_preview_tangent_arc_emits_ghost_arc_after_endpoint_click() { 4764 + let sketch = Sketch::new(Plane::Xy.basis()); 4765 + let (sketch, a) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 4766 + let (sketch, b) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); 4767 + let (sketch, _) = tools::add_line(sketch, a, b, false); 4768 + let (document, sketch_id) = initial_document(sketch); 4769 + let mode = Mode::Sketch { 4770 + sketch_id, 4771 + session: Box::new(SketchSession { 4772 + tool: Some(SketchTool::TangentArc), 4773 + pending: Some(Pending::First(ClickAnchor::Endpoint(b))), 4774 + ..SketchSession::default() 4775 + }), 4776 + }; 4777 + let cursor = Point2::from_mm(10.0, 6.0); 4778 + let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); 4779 + assert_eq!(preview.anchors.len(), 1, "start anchor visible"); 4780 + assert_eq!(preview.arcs.len(), 1, "ghost arc emitted"); 4781 + } 4782 + 4783 + #[test] 4784 + fn build_preview_centerpoint_arc_emits_ghost_arc_after_two_clicks() { 4785 + let sketch = Sketch::new(Plane::Xy.basis()); 4786 + let (document, sketch_id) = initial_document(sketch); 4787 + let center = Point2::from_mm(0.0, 0.0); 4788 + let start = Point2::from_mm(5.0, 0.0); 4789 + let mode = Mode::Sketch { 4790 + sketch_id, 4791 + session: Box::new(SketchSession { 4792 + tool: Some(SketchTool::CenterpointArc), 4793 + pending: Some(Pending::Second( 4794 + ClickAnchor::Position(center), 4795 + ClickAnchor::Position(start), 4796 + )), 4797 + ..SketchSession::default() 4798 + }), 4799 + }; 4800 + let cursor = Point2::from_mm(0.0, 5.0); 4801 + let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); 4802 + assert_eq!(preview.arcs.len(), 1); 4803 + assert_eq!(preview.anchors.len(), 2); 4804 + } 4805 + 4806 + #[test] 4807 + fn escape_with_armed_tool_no_pending_disarms_tool() { 4808 + let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); 4809 + let next = next_mode(prev, &empty_frame(), true, &xy_only()); 4810 + let Mode::Sketch { session, .. } = next else { 4811 + panic!("escape with armed tool must keep sketch mode"); 4812 + }; 4813 + assert_eq!(session.tool, None); 4814 + assert_eq!(session.pending, None); 4815 + } 4816 + 4817 + #[test] 4818 + fn clicking_active_tool_disarms_it() { 4819 + let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); 4820 + let frame = shell::ShellFrame { 4821 + activated_tool: Some(SketchTool::Line), 4822 + ..empty_frame() 4823 + }; 4824 + let next = next_mode(prev, &frame, false, &xy_only()); 4825 + let Mode::Sketch { session, .. } = next else { 4826 + panic!("expected sketch mode"); 4827 + }; 4828 + assert_eq!(session.tool, None); 4829 + } 4830 + 4831 + #[test] 4832 + fn clicking_different_tool_swaps() { 4833 + let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); 4834 + let frame = shell::ShellFrame { 4835 + activated_tool: Some(SketchTool::Point), 4836 + ..empty_frame() 4837 + }; 4838 + let next = next_mode(prev, &frame, false, &xy_only()); 4839 + let Mode::Sketch { session, .. } = next else { 4840 + panic!("expected sketch mode"); 4841 + }; 4842 + assert_eq!(session.tool, Some(SketchTool::Point)); 4843 + } 4844 + 4845 + #[test] 4846 + fn ribbon_exit_overrides_pending_chain() { 4847 + let prev = Mode::Sketch { 4848 + sketch_id: SketchId::default(), 4849 + session: Box::new(SketchSession { 4850 + tool: Some(SketchTool::Line), 4851 + pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm( 4852 + 0.0, 0.0, 4853 + )))), 4854 + ..SketchSession::default() 4855 + }), 4856 + }; 4857 + let frame = shell::ShellFrame { 4858 + exit_sketch: true, 4859 + ..empty_frame() 4860 + }; 4861 + assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); 4862 + } 4863 + 4864 + #[test] 4865 + fn tool_in_idle_does_not_promote_to_sketch() { 4866 + let frame = shell::ShellFrame { 4867 + activated_tool: Some(SketchTool::Line), 4868 + ..empty_frame() 4869 + }; 4870 + assert_eq!(next_mode(Mode::Idle, &frame, false, &xy_only()), Mode::Idle); 4871 + } 4872 + 4873 + #[test] 4874 + fn tool_in_sketch_arms_session() { 4875 + let prev = Mode::enter_sketch(SketchId::default()); 4876 + let frame = shell::ShellFrame { 4877 + activated_tool: Some(SketchTool::Line), 4878 + ..empty_frame() 4879 + }; 4880 + let Mode::Sketch { session, .. } = next_mode(prev, &frame, false, &xy_only()) else { 4881 + panic!("expected sketch mode"); 4882 + }; 4883 + assert_eq!(session.tool, Some(SketchTool::Line)); 4884 + } 4885 + 4886 + #[test] 4887 + fn plane_pick_then_tool_enters_and_arms_in_one_frame() { 4888 + let frame = shell::ShellFrame { 4889 + plane_picked: Some(Plane::Xy), 4890 + activated_tool: Some(SketchTool::Line), 4891 + ..empty_frame() 4892 + }; 4893 + let Mode::Sketch { session, .. } = next_mode(Mode::Idle, &frame, false, &xy_only()) else { 4894 + panic!("expected sketch mode"); 4895 + }; 4896 + assert_eq!(session.tool, Some(SketchTool::Line)); 4897 + } 4898 + 4899 + fn doc_with_default_sketch() -> (Document, SketchId) { 4900 + let sketch = bone_document::Sketch::new(Plane::Xy.basis()); 4901 + let mut document = Document::new(DocumentId::default(), "Untitled".to_owned()); 4902 + let id = SketchId::default(); 4903 + document.insert_sketch(id, "Sketch1".to_owned(), sketch); 4904 + (document, id) 4905 + } 4906 + 4907 + #[test] 4908 + fn apply_sketch_rename_into_writes_label_and_records_undo_on_change() { 4909 + let (mut document, id) = doc_with_default_sketch(); 4910 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 4911 + apply_sketch_rename_into( 4912 + &mut document, 4913 + &mut undo, 4914 + shell::SketchRenameRequest { 4915 + id, 4916 + label: "Profile".to_owned(), 4917 + }, 4918 + ); 4919 + assert_eq!(document.sketch_label(id), Some("Profile")); 4920 + assert_eq!( 4921 + undo.past_len(), 4922 + 1, 4923 + "successful rename records one undo snapshot" 4924 + ); 4925 + } 4926 + 4927 + #[test] 4928 + fn apply_sketch_rename_into_drops_empty_label_without_undo() { 4929 + let (mut document, id) = doc_with_default_sketch(); 4930 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 4931 + apply_sketch_rename_into( 4932 + &mut document, 4933 + &mut undo, 4934 + shell::SketchRenameRequest { 4935 + id, 4936 + label: " ".to_owned(), 4937 + }, 4938 + ); 4939 + assert_eq!(document.sketch_label(id), Some("Sketch1")); 4940 + assert_eq!(undo.past_len(), 0); 4941 + } 4942 + 4943 + #[test] 4944 + fn apply_sketch_rename_into_skips_no_op_against_trimmed_match() { 4945 + let (mut document, id) = doc_with_default_sketch(); 4946 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 4947 + apply_sketch_rename_into( 4948 + &mut document, 4949 + &mut undo, 4950 + shell::SketchRenameRequest { 4951 + id, 4952 + label: " Sketch1 ".to_owned(), 4953 + }, 4954 + ); 4955 + assert_eq!(document.sketch_label(id), Some("Sketch1")); 4956 + assert_eq!( 4957 + undo.past_len(), 4958 + 0, 4959 + "trimmed-equal rename must not record undo" 4960 + ); 4961 + } 4962 + 4963 + fn extrude_node_count(document: &Document) -> usize { 4964 + document 4965 + .feature_tree() 4966 + .iter() 4967 + .filter(|(_, node)| matches!(node, FeatureNode::Extrude(_))) 4968 + .count() 4969 + } 4970 + 4971 + #[test] 4972 + fn commit_armed_extrude_on_accept_adds_node_and_records_undo() { 4973 + let (mut document, sketch) = doc_with_default_sketch(); 4974 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 4975 + let mode = Mode::Extrude(ExtrudeArming::profile(sketch)); 4976 + commit_armed_extrude( 4977 + &mut document, 4978 + &mut undo, 4979 + &mode, 4980 + Some(shell::ConfirmAction::Accept), 4981 + ); 4982 + assert_eq!(extrude_node_count(&document), 1); 4983 + assert_eq!(undo.past_len(), 1); 4984 + } 4985 + 4986 + #[test] 4987 + fn commit_armed_extrude_ignores_cancel_and_non_extrude_mode() { 4988 + let (mut document, sketch) = doc_with_default_sketch(); 4989 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 4990 + commit_armed_extrude( 4991 + &mut document, 4992 + &mut undo, 4993 + &Mode::Extrude(ExtrudeArming::profile(sketch)), 4994 + Some(shell::ConfirmAction::Cancel), 4995 + ); 4996 + commit_armed_extrude( 4997 + &mut document, 4998 + &mut undo, 4999 + &Mode::Idle, 5000 + Some(shell::ConfirmAction::Accept), 5001 + ); 5002 + assert_eq!(extrude_node_count(&document), 0); 5003 + assert_eq!(undo.past_len(), 0); 5004 + } 5005 + 5006 + #[test] 5007 + fn commit_armed_extrude_edit_target_updates_in_place_keeping_label() { 5008 + let (mut document, sketch) = doc_with_default_sketch(); 5009 + let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); 5010 + let Ok(()) = document.rename_extrude(id, "Boss") else { 5011 + panic!("rename accepts"); 5012 + }; 5013 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 5014 + let mode = Mode::Extrude(ExtrudeArming::edit( 5015 + id, 5016 + sketch_mode::default_extrude_feature(sketch), 5017 + )); 5018 + commit_armed_extrude( 5019 + &mut document, 5020 + &mut undo, 5021 + &mode, 5022 + Some(shell::ConfirmAction::Accept), 5023 + ); 5024 + assert_eq!(extrude_node_count(&document), 1, "editing reuses the node"); 5025 + assert_eq!(document.extrude_label(id), Some("Boss")); 5026 + } 5027 + 5028 + #[test] 5029 + fn extrude_edit_mode_arms_from_idle_but_not_from_sketch() { 5030 + let (mut document, sketch) = doc_with_default_sketch(); 5031 + let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); 5032 + let from_idle = extrude_edit_mode(&document, &Mode::Idle, Some(id)); 5033 + assert!(matches!( 5034 + from_idle, 5035 + Some(Mode::Extrude(ExtrudeArming::Profile { target: Some(t), .. })) if t == id 5036 + )); 5037 + assert_eq!( 5038 + extrude_edit_mode(&document, &Mode::enter_sketch(sketch), Some(id)), 5039 + None, 5040 + "double-click is inert while sketching", 5041 + ); 5042 + assert_eq!( 5043 + extrude_edit_mode(&document, &Mode::Idle, Some(ExtrudeId::default())), 5044 + None, 5045 + "unknown extrude id arms nothing", 5046 + ); 5047 + } 5048 + 5049 + #[test] 5050 + fn active_solid_feature_tracks_mode_then_falls_back_to_committed() { 5051 + let (mut document, sketch) = doc_with_default_sketch(); 5052 + let armed = sketch_mode::default_extrude_feature(sketch); 5053 + assert_eq!( 5054 + active_solid_feature( 5055 + &Mode::Extrude(ExtrudeArming::profile(sketch)), 5056 + &document, 5057 + None, 5058 + ), 5059 + Some(armed), 5060 + "an armed profile previews its own feature", 5061 + ); 5062 + assert_eq!( 5063 + active_solid_feature(&Mode::enter_sketch(sketch), &document, None), 5064 + None, 5065 + "sketching shows the 2D scene, not a solid", 5066 + ); 5067 + assert_eq!( 5068 + active_solid_feature(&Mode::Idle, &document, None), 5069 + None, 5070 + "idle with no committed extrude shows no solid", 5071 + ); 5072 + let _ = document.commit_extrude(armed); 5073 + assert_eq!( 5074 + active_solid_feature(&Mode::Idle, &document, None), 5075 + Some(armed), 5076 + "idle falls back to the committed extrude", 5077 + ); 5078 + } 5079 + 5080 + #[test] 5081 + fn framed_extrude_overrides_last_committed_in_idle() { 5082 + let (mut document, sketch) = doc_with_default_sketch(); 5083 + let first_feature = sketch_mode::default_extrude_feature(sketch); 5084 + let mut second_feature = first_feature; 5085 + second_feature.merge_result = bone_document::MergeResult::Separate; 5086 + let first = document.commit_extrude(first_feature); 5087 + let _second = document.commit_extrude(second_feature); 5088 + assert_eq!( 5089 + active_solid_feature(&Mode::Idle, &document, None), 5090 + Some(second_feature), 5091 + "with no framed id, idle frames the last-committed extrude", 5092 + ); 5093 + assert_eq!( 5094 + active_solid_feature(&Mode::Idle, &document, Some(first)), 5095 + Some(first_feature), 5096 + "a framed id wins over the tree tip, so editing a non-last extrude stays framed", 5097 + ); 5098 + assert_eq!( 5099 + active_solid_feature(&Mode::Idle, &document, Some(ExtrudeId::default())), 5100 + Some(second_feature), 5101 + "a stale framed id self-heals to the last-committed extrude", 5102 + ); 5103 + } 5104 + 5105 + #[test] 5106 + fn apply_extrude_rename_into_writes_label_and_records_undo() { 5107 + let (mut document, sketch) = doc_with_default_sketch(); 5108 + let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); 5109 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 5110 + apply_extrude_rename_into( 5111 + &mut document, 5112 + &mut undo, 5113 + shell::ExtrudeRenameRequest { 5114 + id, 5115 + label: "Boss".to_owned(), 5116 + }, 5117 + ); 5118 + assert_eq!(document.extrude_label(id), Some("Boss")); 5119 + assert_eq!(undo.past_len(), 1); 5120 + } 5121 + 5122 + #[test] 5123 + fn sketch_activated_from_idle_enters_that_sketch_without_plane_map() { 5124 + let sketch_id = SketchId::default(); 5125 + let frame = shell::ShellFrame { 5126 + sketch_activated: Some(sketch_id), 5127 + ..empty_frame() 5128 + }; 5129 + let next = next_mode(Mode::Idle, &frame, false, &BTreeMap::new()); 5130 + assert_eq!(next, Mode::enter_sketch(sketch_id)); 5131 + } 5132 + 5133 + #[test] 5134 + fn sketch_pick_in_extrude_sets_profile_instead_of_editing() { 5135 + let sketch_id = SketchId::default(); 5136 + let frame = shell::ShellFrame { 5137 + sketch_activated: Some(sketch_id), 5138 + ..empty_frame() 5139 + }; 5140 + assert_eq!( 5141 + next_mode( 5142 + Mode::Extrude(ExtrudeArming::AwaitingSketch), 5143 + &frame, 5144 + false, 5145 + &xy_only(), 5146 + ), 5147 + Mode::Extrude(ExtrudeArming::profile(sketch_id)), 5148 + ); 5149 + assert_eq!( 5150 + next_mode( 5151 + Mode::Extrude(ExtrudeArming::profile(sketch_id)), 5152 + &frame, 5153 + false, 5154 + &xy_only(), 5155 + ), 5156 + Mode::Extrude(ExtrudeArming::profile(sketch_id)), 5157 + "a pick while armed re-targets the profile, never drops into sketch editing", 5158 + ); 5159 + } 5160 + 5161 + #[test] 5162 + fn sketch_activated_while_in_sketch_is_ignored() { 5163 + let prev = Mode::enter_sketch(SketchId::default()); 5164 + let frame = shell::ShellFrame { 5165 + sketch_activated: Some(SketchId::default()), 5166 + ..empty_frame() 5167 + }; 5168 + assert_eq!(next_mode(prev.clone(), &frame, false, &xy_only()), prev); 5169 + } 5170 + 5171 + #[test] 5172 + fn exit_action_wins_over_pending_tool() { 5173 + let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); 5174 + let frame = shell::ShellFrame { 5175 + exit_sketch: true, 5176 + ..empty_frame() 5177 + }; 5178 + assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); 5179 + } 5180 + 5181 + #[test] 5182 + fn idle_scopes_omit_sketch_scope() { 5183 + let scopes = scopes_for_mode(&Mode::Idle); 5184 + let collected: Vec<_> = scopes.innermost_first().copied().collect(); 5185 + assert!(!collected.contains(&HotkeyScope::Sketch)); 5186 + assert!(collected.contains(&HotkeyScope::Global)); 5187 + } 5188 + 5189 + #[test] 5190 + fn sketch_scopes_include_sketch_scope() { 5191 + let scopes = scopes_for_mode(&Mode::enter_sketch(SketchId::default())); 5192 + let collected: Vec<_> = scopes.innermost_first().copied().collect(); 5193 + assert!(collected.contains(&HotkeyScope::Sketch)); 5194 + assert!(collected.contains(&HotkeyScope::Global)); 5195 + } 5196 + 5197 + #[test] 5198 + fn hotkey_table_binds_escape_to_exit_under_sketch_scope() { 5199 + let table = build_hotkey_table(); 5200 + let chord = KeyChord::new(UiKeyCode::Named(NamedKey::Escape), ModifierMask::NONE); 5201 + let in_sketch = scopes_for_mode(&Mode::enter_sketch(SketchId::default())); 5202 + assert_eq!( 5203 + table.dispatch(chord, &in_sketch), 5204 + Some(sketch_mode::ESCAPE_ACTION) 5205 + ); 5206 + let in_idle = scopes_for_mode(&Mode::Idle); 5207 + assert_eq!(table.dispatch(chord, &in_idle), None); 5208 + } 5209 + 5210 + #[test] 5211 + fn extrude_scope_isolates_escape_from_sketch_tools() { 5212 + let extrude = scopes_for_mode(&Mode::Extrude(ExtrudeArming::AwaitingSketch)); 5213 + let in_extrude: Vec<_> = extrude.innermost_first().copied().collect(); 5214 + assert!(in_extrude.contains(&HotkeyScope::Extrude)); 5215 + assert!( 5216 + !in_extrude.contains(&HotkeyScope::Sketch), 5217 + "extrude must not activate the sketch-tool scope", 5218 + ); 5219 + let sketch = scopes_for_mode(&Mode::enter_sketch(SketchId::default())); 5220 + let in_sketch: Vec<_> = sketch.innermost_first().copied().collect(); 5221 + assert!(!in_sketch.contains(&HotkeyScope::Extrude)); 5222 + } 5223 + 5224 + #[test] 5225 + fn hotkey_table_binds_escape_under_extrude_scope() { 5226 + let table = build_hotkey_table(); 5227 + let chord = KeyChord::new(UiKeyCode::Named(NamedKey::Escape), ModifierMask::NONE); 5228 + let in_extrude = scopes_for_mode(&Mode::Extrude(ExtrudeArming::AwaitingSketch)); 5229 + assert_eq!( 5230 + table.dispatch(chord, &in_extrude), 5231 + Some(sketch_mode::ESCAPE_ACTION) 5232 + ); 5233 + } 5234 + 5235 + #[test] 5236 + fn escape_exits_extrude_to_idle() { 5237 + let frame = empty_frame(); 5238 + let awaiting = Mode::Extrude(ExtrudeArming::AwaitingSketch); 5239 + assert_eq!(next_mode(awaiting, &frame, true, &xy_only()), Mode::Idle); 5240 + let profiled = Mode::Extrude(ExtrudeArming::profile(SketchId::default())); 5241 + assert_eq!(next_mode(profiled, &frame, true, &xy_only()), Mode::Idle); 5242 + } 5243 + 5244 + #[test] 5245 + fn cancel_pending_or_exit_drops_extrude_arming() { 5246 + assert_eq!( 5247 + cancel_pending_or_exit(Mode::Extrude(ExtrudeArming::AwaitingSketch)), 5248 + Mode::Idle 5249 + ); 5250 + } 5251 + 5252 + #[test] 5253 + fn driven_with_value_promotes_kind_and_overwrites_value() { 5254 + let proto = SketchDimension::Linear { 5255 + a: bone_types::SketchEntityId::default(), 5256 + b: bone_types::SketchEntityId::default(), 5257 + value: Length::new::<millimeter>(8.0), 5258 + kind: DimensionKind::Driving, 5259 + }; 5260 + let measured = DimensionValue::Length(Length::new::<millimeter>(10.0)); 5261 + let Some(driven) = driven_with_value(proto, measured) else { 5262 + panic!("driven_with_value rejected matched-kind value"); 5263 + }; 5264 + assert_eq!(driven.kind(), DimensionKind::Driven); 5265 + let DimensionValue::Length(length) = driven.value() else { 5266 + panic!("expected Length"); 5267 + }; 5268 + assert!((length.get::<millimeter>() - 10.0).abs() < 1e-9); 5269 + } 5270 + 5271 + #[test] 5272 + fn driven_with_value_rejects_kind_mismatch() { 5273 + let proto = SketchDimension::Linear { 5274 + a: bone_types::SketchEntityId::default(), 5275 + b: bone_types::SketchEntityId::default(), 5276 + value: Length::new::<millimeter>(1.0), 5277 + kind: DimensionKind::Driving, 5278 + }; 5279 + let bad = DimensionValue::Angle(bone_types::Angle::new::<uom::si::angle::radian>(1.0)); 5280 + assert!(driven_with_value(proto, bad).is_none()); 5281 + } 5282 + 5283 + #[test] 5284 + fn dim_conflict_pending_returns_proto_when_set() { 5285 + let proto = SketchDimension::Linear { 5286 + a: bone_types::SketchEntityId::default(), 5287 + b: bone_types::SketchEntityId::default(), 5288 + value: Length::new::<millimeter>(2.0), 5289 + kind: DimensionKind::Driving, 5290 + }; 5291 + let pending = PendingDimension { 5292 + proto, 5293 + anchor: Point2::origin(), 5294 + }; 5295 + let mode = Mode::enter_sketch(SketchId::default()).start_dim_conflict(pending); 5296 + assert_eq!(dim_conflict_pending(&mode), Some(pending)); 5297 + } 5298 + 5299 + #[test] 5300 + fn dim_conflict_pending_returns_none_in_idle() { 5301 + assert_eq!(dim_conflict_pending(&Mode::Idle), None); 5302 + } 5303 + 5304 + fn horizontal_line_fixture() -> ( 5305 + Sketch, 5306 + bone_types::SketchEntityId, 5307 + bone_types::SketchEntityId, 5308 + bone_types::SketchEntityId, 5309 + ) { 5310 + let sketch = Sketch::new(Plane::Xy.basis()); 5311 + let (sketch, a) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 5312 + let (sketch, b) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); 5313 + let (sketch, line) = tools::add_line(sketch, a, b, false); 5314 + let sketch = tools::add_relation(sketch, SketchRelation::Horizontal(line)); 5315 + let sketch = tools::add_relation(sketch, SketchRelation::Fix(a)); 5316 + (sketch, a, b, line) 5317 + } 5318 + 5319 + fn point_at(sketch: &Sketch, id: bone_types::SketchEntityId) -> Point2 { 5320 + let SketchEntity::Point(p) = sketch.entities()[id] else { 5321 + panic!("expected point entity"); 5322 + }; 5323 + p.at() 5324 + } 5325 + 5326 + #[test] 5327 + fn drag_resolved_translates_endpoint_and_preserves_horizontal() { 5328 + let (sketch, a, b, _) = horizontal_line_fixture(); 5329 + let drag = DragSession { 5330 + entity: b, 5331 + press: Point2::from_mm(10.0, 0.0), 5332 + pins: DragPins::from_array([Some((b, Point2::from_mm(10.0, 0.0))), None, None]), 5333 + }; 5334 + let cursor = Point2::from_mm(13.0, 0.0); 5335 + let Some(next) = drag_resolved(&sketch, drag, cursor) else { 5336 + panic!("solve_with_drag_pins must converge on horizontal line") 5337 + }; 5338 + let (bx, by) = point_at(&next, b).coords_mm(); 5339 + assert!((bx - 13.0).abs() < 1e-6, "b.x slides under cursor: {bx}"); 5340 + assert!(by.abs() < 1e-6, "horizontal preserved: by={by}"); 5341 + let (ax, ay) = point_at(&next, a).coords_mm(); 5342 + assert!(ax.abs() < 1e-9, "fixed a.x stays put: {ax}"); 5343 + assert!(ay.abs() < 1e-9, "fixed a.y stays put: {ay}"); 5344 + } 5345 + 5346 + #[test] 5347 + fn mirror_axis_reflects_across_horizontal_x_axis() { 5348 + let axis = MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); 5349 + let (rx, ry) = axis.reflect(Point2::from_mm(3.0, 4.0)).coords_mm(); 5350 + assert!((rx - 3.0).abs() < 1e-9, "x preserved across x-axis"); 5351 + assert!((ry - -4.0).abs() < 1e-9, "y inverted across x-axis"); 5352 + } 5353 + 5354 + #[test] 5355 + fn mirror_axis_reflects_across_diagonal() { 5356 + let axis = MirrorAxis::from_points(Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 1.0)); 5357 + let (rx, ry) = axis.reflect(Point2::from_mm(2.0, 0.0)).coords_mm(); 5358 + assert!((rx - 0.0).abs() < 1e-9, "x reflects to y on y=x diagonal"); 5359 + assert!((ry - 2.0).abs() < 1e-9, "y reflects to x on y=x diagonal"); 5360 + } 5361 + 5362 + #[test] 5363 + fn mirror_axis_detects_degenerate_zero_length() { 5364 + let axis = MirrorAxis::from_points(Point2::from_mm(1.0, 1.0), Point2::from_mm(1.0, 1.0)); 5365 + assert!( 5366 + axis.is_degenerate(), 5367 + "coincident endpoints must be degenerate" 5368 + ); 5369 + } 5370 + 5371 + #[test] 5372 + fn mirror_targets_creates_reflected_circle_with_symmetric_relations() { 5373 + let (sketch, _, _, axis_line) = horizontal_line_fixture(); 5374 + let (sketch, center) = tools::add_point(sketch, Point2::from_mm(0.0, 3.0)); 5375 + let (sketch, circle_id) = 5376 + tools::add_circle(sketch, center, Length::new::<millimeter>(1.0), false); 5377 + let axis_geom = 5378 + MirrorAxis::from_points(Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 0.0)); 5379 + let source_ids: std::collections::BTreeSet<_> = [center, circle_id].into_iter().collect(); 5380 + let Ok(mirrored) = mirror_targets(sketch.clone(), &source_ids, axis_line, &axis_geom) 5381 + else { 5382 + panic!("circle mirror must succeed"); 5383 + }; 5384 + let new_circles: Vec<_> = mirrored 5385 + .entities() 5386 + .iter() 5387 + .filter_map(|(_, e)| match *e { 5388 + SketchEntity::Circle(c) => Some(c), 5389 + _ => None, 5390 + }) 5391 + .collect(); 5392 + assert_eq!(new_circles.len(), 2, "original + mirrored circle"); 5393 + let new_center_pos = new_circles 5394 + .iter() 5395 + .map(|c| { 5396 + let SketchEntity::Point(p) = mirrored.entities()[c.center()] else { 5397 + panic!("circle center is a point"); 5398 + }; 5399 + p.at().coords_mm() 5400 + }) 5401 + .find(|(_, y)| *y < 0.0); 5402 + let Some((cx, cy)) = new_center_pos else { 5403 + panic!("mirrored circle must lie below x-axis"); 5404 + }; 5405 + assert!(cx.abs() < 1e-9 && (cy + 3.0).abs() < 1e-9, "({cx}, {cy})"); 5406 + let symmetric_count = mirrored 5407 + .relations() 5408 + .iter() 5409 + .filter( 5410 + |(_, r)| matches!(r, SketchRelation::Symmetric { axis, .. } if *axis == axis_line), 5411 + ) 5412 + .count(); 5413 + assert!( 5414 + symmetric_count >= 1, 5415 + "mirror must emit at least one Symmetric relation tied to the axis", 5416 + ); 5417 + } 5418 + 5419 + #[test] 5420 + fn mirror_copies_horizontal_relation_to_mirrored_line() { 5421 + use bone_document::SketchEdit; 5422 + let sketch = Sketch::new(Plane::Xy.basis()); 5423 + let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0)); 5424 + let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0)); 5425 + let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true); 5426 + let (sketch, source_p0) = tools::add_point(sketch, Point2::from_mm(-2.0, 3.0)); 5427 + let (sketch, source_p1) = tools::add_point(sketch, Point2::from_mm(2.0, 3.0)); 5428 + let (sketch, source_line) = tools::add_line(sketch, source_p0, source_p1, false); 5429 + let Ok((sketch, _)) = sketch.apply(SketchEdit::AddRelation(SketchRelation::Horizontal( 5430 + source_line, 5431 + ))) else { 5432 + panic!("seed Horizontal must apply"); 5433 + }; 5434 + let axis_geom = 5435 + MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); 5436 + let source_ids: std::collections::BTreeSet<_> = 5437 + [source_p0, source_p1, source_line].into_iter().collect(); 5438 + let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else { 5439 + panic!("mirror must succeed"); 5440 + }; 5441 + let horizontal_lines: Vec<_> = mirrored 5442 + .relations() 5443 + .iter() 5444 + .filter_map(|(_, r)| match r { 5445 + SketchRelation::Horizontal(id) => Some(*id), 5446 + _ => None, 5447 + }) 5448 + .collect(); 5449 + assert_eq!( 5450 + horizontal_lines.len(), 5451 + 2, 5452 + "original + mirrored horizontal must both exist" 5453 + ); 5454 + } 5455 + 5456 + #[test] 5457 + fn construction_toggle_flips_line_flag() { 5458 + let (sketch, _, _, line) = horizontal_line_fixture(); 5459 + let before = match sketch.entities()[line] { 5460 + SketchEntity::Line(l) => l.for_construction(), 5461 + _ => panic!("line"), 5462 + }; 5463 + let Ok((next, _)) = sketch.apply(SketchEdit::SetConstruction { 5464 + id: line, 5465 + for_construction: !before, 5466 + }) else { 5467 + panic!("set construction must succeed"); 5468 + }; 5469 + let after = match next.entities()[line] { 5470 + SketchEntity::Line(l) => l.for_construction(), 5471 + _ => panic!("line"), 5472 + }; 5473 + assert_ne!(before, after); 5474 + } 5475 + 5476 + #[test] 5477 + fn mirror_axis_detects_on_axis_point() { 5478 + let axis = MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); 5479 + assert!(axis.is_on_axis(Point2::from_mm(2.0, 0.0))); 5480 + assert!(axis.is_on_axis(Point2::from_mm(-5.0, 0.0))); 5481 + assert!(!axis.is_on_axis(Point2::from_mm(2.0, 0.5))); 5482 + } 5483 + 5484 + #[test] 5485 + fn mirror_on_axis_source_point_is_identity_no_self_symmetric() { 5486 + let sketch = Sketch::new(Plane::Xy.basis()); 5487 + let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0)); 5488 + let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0)); 5489 + let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true); 5490 + let (sketch, on_axis_point) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 5491 + let axis_geom = 5492 + MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); 5493 + let source_ids: std::collections::BTreeSet<_> = [on_axis_point].into_iter().collect(); 5494 + let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else { 5495 + panic!("mirror must succeed"); 5496 + }; 5497 + let point_count = mirrored 5498 + .entities() 5499 + .iter() 5500 + .filter(|(_, e)| matches!(e, SketchEntity::Point(_))) 5501 + .count(); 5502 + assert_eq!( 5503 + point_count, 3, 5504 + "on-axis source must not produce a duplicate point: {point_count}" 5505 + ); 5506 + let symmetric_count = mirrored 5507 + .relations() 5508 + .iter() 5509 + .filter(|(_, r)| matches!(r, SketchRelation::Symmetric { .. })) 5510 + .count(); 5511 + assert_eq!( 5512 + symmetric_count, 0, 5513 + "on-axis source must not emit a self-pair Symmetric relation" 5514 + ); 5515 + } 5516 + 5517 + #[test] 5518 + fn mirror_copies_relation_referencing_axis_line() { 5519 + use bone_document::SketchEdit; 5520 + let sketch = Sketch::new(Plane::Xy.basis()); 5521 + let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0)); 5522 + let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0)); 5523 + let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true); 5524 + let (sketch, off_a) = tools::add_point(sketch, Point2::from_mm(-2.0, 3.0)); 5525 + let (sketch, off_b) = tools::add_point(sketch, Point2::from_mm(2.0, 3.0)); 5526 + let (sketch, off_line) = tools::add_line(sketch, off_a, off_b, false); 5527 + let Ok((sketch, _)) = sketch.apply(SketchEdit::AddRelation(SketchRelation::Parallel( 5528 + off_line, axis_line, 5529 + ))) else { 5530 + panic!("seed Parallel(off_line, axis_line) must apply"); 5531 + }; 5532 + let axis_geom = 5533 + MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); 5534 + let source_ids: std::collections::BTreeSet<_> = 5535 + [off_a, off_b, off_line].into_iter().collect(); 5536 + let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else { 5537 + panic!("mirror must succeed"); 5538 + }; 5539 + let parallel_count = mirrored 5540 + .relations() 5541 + .iter() 5542 + .filter(|(_, r)| matches!(r, SketchRelation::Parallel(_, b) if *b == axis_line)) 5543 + .count(); 5544 + assert_eq!( 5545 + parallel_count, 2, 5546 + "original + mirrored Parallel(line, axis_line) both expected: {parallel_count}", 5547 + ); 5548 + } 5549 + }
+115
crates/bone-app/src/clock.rs
··· 1 + use core::num::NonZeroU32; 2 + use std::time::Duration; 3 + 4 + use bone_ui::input::FrameInstant; 5 + use serde::{Deserialize, Serialize}; 6 + 7 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 8 + #[serde(try_from = "u32", into = "u32")] 9 + pub struct FrameCount(NonZeroU32); 10 + 11 + #[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)] 12 + #[error("frame count must be a positive integer, got {0}")] 13 + pub struct ZeroFrameCount(u32); 14 + 15 + impl FrameCount { 16 + pub const ONE: Self = Self(NonZeroU32::MIN); 17 + 18 + #[must_use] 19 + pub const fn new(frames: NonZeroU32) -> Self { 20 + Self(frames) 21 + } 22 + 23 + #[must_use] 24 + pub const fn get(self) -> u32 { 25 + self.0.get() 26 + } 27 + } 28 + 29 + impl TryFrom<u32> for FrameCount { 30 + type Error = ZeroFrameCount; 31 + 32 + fn try_from(value: u32) -> Result<Self, Self::Error> { 33 + NonZeroU32::new(value).map(Self).ok_or(ZeroFrameCount(value)) 34 + } 35 + } 36 + 37 + impl From<FrameCount> for u32 { 38 + fn from(value: FrameCount) -> Self { 39 + value.0.get() 40 + } 41 + } 42 + 43 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 44 + pub struct FrameClock(FrameInstant); 45 + 46 + impl FrameClock { 47 + pub const FIXED_STEP: Duration = Duration::from_micros(16_667); 48 + 49 + #[must_use] 50 + pub const fn start() -> Self { 51 + Self(FrameInstant::ZERO) 52 + } 53 + 54 + #[must_use] 55 + pub const fn now(self) -> FrameInstant { 56 + self.0 57 + } 58 + 59 + pub fn feed(&mut self, now: FrameInstant) { 60 + self.0 = self.0.max(now); 61 + } 62 + 63 + pub fn advance(&mut self, frames: FrameCount) { 64 + self.0 = self.0.after(Self::FIXED_STEP.saturating_mul(frames.get())); 65 + } 66 + } 67 + 68 + #[cfg(test)] 69 + mod tests { 70 + use super::*; 71 + 72 + fn frames(n: u32) -> FrameCount { 73 + match NonZeroU32::new(n) { 74 + Some(count) => FrameCount::new(count), 75 + None => panic!("frame count must be nonzero"), 76 + } 77 + } 78 + 79 + #[test] 80 + fn advance_steps_exactly_one_fixed_step_per_frame() { 81 + let mut clock = FrameClock::start(); 82 + clock.advance(frames(60)); 83 + assert_eq!( 84 + clock.now().duration(), 85 + FrameClock::FIXED_STEP * 60, 86 + "sixty fixed steps land on a bit-exact instant" 87 + ); 88 + } 89 + 90 + #[test] 91 + fn feed_never_rewinds() { 92 + let mut clock = FrameClock::start(); 93 + clock.feed(FrameInstant::from_duration(Duration::from_millis(100))); 94 + clock.feed(FrameInstant::from_duration(Duration::from_millis(40))); 95 + assert_eq!( 96 + clock.now(), 97 + FrameInstant::from_duration(Duration::from_millis(100)), 98 + "a stale feed must not move time backward" 99 + ); 100 + } 101 + 102 + #[test] 103 + fn a_180ms_animation_finishes_on_frame_11() { 104 + let finished_at = (1_u32..=20).find(|frame| { 105 + let mut clock = FrameClock::start(); 106 + clock.advance(frames(*frame)); 107 + clock.now().duration() >= Duration::from_millis(180) 108 + }); 109 + assert_eq!( 110 + finished_at, 111 + Some(11), 112 + "a 180 ms tween completes on a known frame under fixed step" 113 + ); 114 + } 115 + }
+184 -33
crates/bone-app/src/event.rs
··· 1 + use bone_app::{InputEvent, KeyDown, NavKey, ScrollDelta, WindowPoint}; 2 + use bone_render::{ViewportExtent, ViewportPx}; 3 + use bone_ui::input::{KeyChar, KeyCode as UiKeyCode, ModifierMask, NamedKey, PointerButton}; 1 4 use winit::{ 2 - dpi::{PhysicalPosition, PhysicalSize}, 3 - event::{ElementState, KeyEvent, Modifiers, MouseButton, MouseScrollDelta, WindowEvent}, 4 - keyboard::{Key, PhysicalKey, SmolStr}, 5 + dpi::PhysicalSize, 6 + event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent}, 7 + keyboard::{Key, KeyCode, ModifiersState, NamedKey as WinitNamed, PhysicalKey, SmolStr}, 5 8 }; 6 9 7 10 pub enum AppEvent { ··· 11 14 Ignored, 12 15 } 13 16 14 - pub enum InputEvent { 15 - Resize(PhysicalSize<u32>), 16 - Focus(bool), 17 - Modifier(Modifiers), 18 - CursorMove(PhysicalPosition<f64>), 19 - CursorLeft, 20 - CursorEntered, 21 - Pointer { 22 - button: MouseButton, 23 - state: ElementState, 24 - }, 25 - Wheel(MouseScrollDelta), 26 - KeyDown { 27 - physical_key: PhysicalKey, 28 - logical_key: Key, 29 - text: Option<SmolStr>, 30 - repeat: bool, 31 - }, 32 - } 33 - 34 17 impl AppEvent { 35 18 pub fn from_winit(event: WindowEvent) -> Self { 36 19 match event { 37 20 WindowEvent::CloseRequested => Self::Close, 38 21 WindowEvent::RedrawRequested => Self::Redraw, 39 - WindowEvent::Resized(size) => Self::Input(InputEvent::Resize(size)), 22 + WindowEvent::Resized(size) => Self::Input(InputEvent::Resize(viewport_extent(size))), 40 23 WindowEvent::Focused(focused) => Self::Input(InputEvent::Focus(focused)), 41 - WindowEvent::ModifiersChanged(mods) => Self::Input(InputEvent::Modifier(mods)), 42 - WindowEvent::CursorMoved { position, .. } => { 43 - Self::Input(InputEvent::CursorMove(position)) 24 + WindowEvent::ModifiersChanged(mods) => { 25 + Self::Input(InputEvent::Modifiers(modifier_mask(mods.state()))) 44 26 } 27 + WindowEvent::CursorMoved { position, .. } => Self::Input(InputEvent::CursorMove( 28 + WindowPoint::new(position.x, position.y), 29 + )), 45 30 WindowEvent::CursorLeft { .. } => Self::Input(InputEvent::CursorLeft), 46 31 WindowEvent::CursorEntered { .. } => Self::Input(InputEvent::CursorEntered), 47 - WindowEvent::MouseInput { state, button, .. } => { 48 - Self::Input(InputEvent::Pointer { button, state }) 32 + WindowEvent::MouseInput { state, button, .. } => match pointer_button(button) { 33 + Some(button) => Self::Input(InputEvent::Pointer { 34 + button, 35 + pressed: state == ElementState::Pressed, 36 + }), 37 + None => Self::Ignored, 38 + }, 39 + WindowEvent::MouseWheel { delta, .. } => { 40 + Self::Input(InputEvent::Wheel(scroll_delta(delta))) 49 41 } 50 - WindowEvent::MouseWheel { delta, .. } => Self::Input(InputEvent::Wheel(delta)), 51 42 WindowEvent::KeyboardInput { 52 43 event: 53 44 KeyEvent { ··· 59 50 .. 60 51 }, 61 52 .. 62 - } => Self::Input(InputEvent::KeyDown { 53 + } => Self::Input(InputEvent::KeyDown(key_down( 63 54 physical_key, 64 - logical_key, 55 + &logical_key, 65 56 text, 66 57 repeat, 67 - }), 58 + ))), 68 59 WindowEvent::KeyboardInput { .. } 69 60 | WindowEvent::ActivationTokenDone { .. } 70 61 | WindowEvent::Moved(_) ··· 86 77 } 87 78 } 88 79 } 80 + 81 + pub fn viewport_extent(size: PhysicalSize<u32>) -> ViewportExtent { 82 + ViewportExtent::new( 83 + ViewportPx::new(size.width.max(1)), 84 + ViewportPx::new(size.height.max(1)), 85 + ) 86 + } 87 + 88 + fn modifier_mask(state: ModifiersState) -> ModifierMask { 89 + let bits = [ 90 + (state.control_key(), ModifierMask::CTRL), 91 + (state.shift_key(), ModifierMask::SHIFT), 92 + (state.alt_key(), ModifierMask::ALT), 93 + (state.super_key(), ModifierMask::META), 94 + ]; 95 + bits.iter() 96 + .filter(|(active, _)| *active) 97 + .fold(ModifierMask::NONE, |acc, (_, m)| acc.union(*m)) 98 + } 99 + 100 + const fn pointer_button(button: MouseButton) -> Option<PointerButton> { 101 + match button { 102 + MouseButton::Left => Some(PointerButton::Primary), 103 + MouseButton::Right => Some(PointerButton::Secondary), 104 + MouseButton::Middle => Some(PointerButton::Middle), 105 + MouseButton::Back | MouseButton::Forward | MouseButton::Other(_) => None, 106 + } 107 + } 108 + 109 + fn scroll_delta(delta: MouseScrollDelta) -> ScrollDelta { 110 + match delta { 111 + MouseScrollDelta::LineDelta(x, y) => ScrollDelta::Lines { x, y }, 112 + MouseScrollDelta::PixelDelta(p) => ScrollDelta::Pixels { x: p.x, y: p.y }, 113 + } 114 + } 115 + 116 + fn key_down( 117 + physical_key: PhysicalKey, 118 + logical_key: &Key, 119 + text: Option<SmolStr>, 120 + repeat: bool, 121 + ) -> KeyDown { 122 + let physical_code = match physical_key { 123 + PhysicalKey::Code(c) => Some(c), 124 + PhysicalKey::Unidentified(_) => None, 125 + }; 126 + let logical_named = match logical_key { 127 + Key::Named(nk) => winit_named_to_ui(*nk), 128 + _ => None, 129 + }; 130 + let named = logical_named.or_else(|| physical_code.and_then(keycode_to_named)); 131 + let code = named.map(UiKeyCode::Named).or_else(|| { 132 + physical_code 133 + .and_then(keycode_to_char) 134 + .map(|c| UiKeyCode::Char(KeyChar::from_char(c))) 135 + }); 136 + KeyDown { 137 + code, 138 + nav: physical_code.and_then(keycode_to_nav), 139 + text: text.map(|t| t.to_string()), 140 + repeat, 141 + } 142 + } 143 + 144 + const fn keycode_to_nav(code: KeyCode) -> Option<NavKey> { 145 + match code { 146 + KeyCode::ArrowLeft => Some(NavKey::Left), 147 + KeyCode::ArrowRight => Some(NavKey::Right), 148 + KeyCode::ArrowUp => Some(NavKey::Up), 149 + KeyCode::ArrowDown => Some(NavKey::Down), 150 + KeyCode::KeyZ => Some(NavKey::Zoom), 151 + KeyCode::Equal => Some(NavKey::ZoomIn), 152 + KeyCode::Minus => Some(NavKey::ZoomOut), 153 + _ => None, 154 + } 155 + } 156 + 157 + fn keycode_to_named(code: KeyCode) -> Option<NamedKey> { 158 + match code { 159 + KeyCode::Tab => Some(NamedKey::Tab), 160 + KeyCode::Enter | KeyCode::NumpadEnter => Some(NamedKey::Enter), 161 + KeyCode::Escape => Some(NamedKey::Escape), 162 + KeyCode::Backspace => Some(NamedKey::Backspace), 163 + KeyCode::Delete => Some(NamedKey::Delete), 164 + KeyCode::Space => Some(NamedKey::Space), 165 + KeyCode::ArrowUp => Some(NamedKey::ArrowUp), 166 + KeyCode::ArrowDown => Some(NamedKey::ArrowDown), 167 + KeyCode::ArrowLeft => Some(NamedKey::ArrowLeft), 168 + KeyCode::ArrowRight => Some(NamedKey::ArrowRight), 169 + KeyCode::Home => Some(NamedKey::Home), 170 + KeyCode::End => Some(NamedKey::End), 171 + KeyCode::PageUp => Some(NamedKey::PageUp), 172 + KeyCode::PageDown => Some(NamedKey::PageDown), 173 + KeyCode::F2 => Some(NamedKey::F2), 174 + _ => None, 175 + } 176 + } 177 + 178 + fn winit_named_to_ui(named: WinitNamed) -> Option<NamedKey> { 179 + match named { 180 + WinitNamed::Tab => Some(NamedKey::Tab), 181 + WinitNamed::Enter => Some(NamedKey::Enter), 182 + WinitNamed::Escape => Some(NamedKey::Escape), 183 + WinitNamed::Backspace => Some(NamedKey::Backspace), 184 + WinitNamed::Delete => Some(NamedKey::Delete), 185 + WinitNamed::Space => Some(NamedKey::Space), 186 + WinitNamed::ArrowUp => Some(NamedKey::ArrowUp), 187 + WinitNamed::ArrowDown => Some(NamedKey::ArrowDown), 188 + WinitNamed::ArrowLeft => Some(NamedKey::ArrowLeft), 189 + WinitNamed::ArrowRight => Some(NamedKey::ArrowRight), 190 + WinitNamed::Home => Some(NamedKey::Home), 191 + WinitNamed::End => Some(NamedKey::End), 192 + WinitNamed::PageUp => Some(NamedKey::PageUp), 193 + WinitNamed::PageDown => Some(NamedKey::PageDown), 194 + WinitNamed::F2 => Some(NamedKey::F2), 195 + _ => None, 196 + } 197 + } 198 + 199 + fn keycode_to_char(code: KeyCode) -> Option<char> { 200 + match code { 201 + KeyCode::KeyA => Some('a'), 202 + KeyCode::KeyB => Some('b'), 203 + KeyCode::KeyC => Some('c'), 204 + KeyCode::KeyD => Some('d'), 205 + KeyCode::KeyE => Some('e'), 206 + KeyCode::KeyF => Some('f'), 207 + KeyCode::KeyG => Some('g'), 208 + KeyCode::KeyH => Some('h'), 209 + KeyCode::KeyI => Some('i'), 210 + KeyCode::KeyJ => Some('j'), 211 + KeyCode::KeyK => Some('k'), 212 + KeyCode::KeyL => Some('l'), 213 + KeyCode::KeyM => Some('m'), 214 + KeyCode::KeyN => Some('n'), 215 + KeyCode::KeyO => Some('o'), 216 + KeyCode::KeyP => Some('p'), 217 + KeyCode::KeyQ => Some('q'), 218 + KeyCode::KeyR => Some('r'), 219 + KeyCode::KeyS => Some('s'), 220 + KeyCode::KeyT => Some('t'), 221 + KeyCode::KeyU => Some('u'), 222 + KeyCode::KeyV => Some('v'), 223 + KeyCode::KeyW => Some('w'), 224 + KeyCode::KeyX => Some('x'), 225 + KeyCode::KeyY => Some('y'), 226 + KeyCode::KeyZ => Some('z'), 227 + KeyCode::Digit0 => Some('0'), 228 + KeyCode::Digit1 => Some('1'), 229 + KeyCode::Digit2 => Some('2'), 230 + KeyCode::Digit3 => Some('3'), 231 + KeyCode::Digit4 => Some('4'), 232 + KeyCode::Digit5 => Some('5'), 233 + KeyCode::Digit6 => Some('6'), 234 + KeyCode::Digit7 => Some('7'), 235 + KeyCode::Digit8 => Some('8'), 236 + KeyCode::Digit9 => Some('9'), 237 + _ => None, 238 + } 239 + }
+66
crates/bone-app/src/input.rs
··· 1 + use bone_render::ViewportExtent; 2 + use bone_ui::input::{KeyCode, ModifierMask, PointerButton}; 3 + use serde::{Deserialize, Serialize}; 4 + 5 + #[derive(Copy, Clone, Debug, PartialEq)] 6 + pub struct WindowPoint { 7 + pub x: f64, 8 + pub y: f64, 9 + } 10 + 11 + impl WindowPoint { 12 + #[must_use] 13 + pub const fn new(x: f64, y: f64) -> Self { 14 + Self { x, y } 15 + } 16 + } 17 + 18 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 19 + pub enum ScrollDelta { 20 + Lines { x: f32, y: f32 }, 21 + Pixels { x: f64, y: f64 }, 22 + } 23 + 24 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 25 + pub enum NavKey { 26 + Left, 27 + Right, 28 + Up, 29 + Down, 30 + Zoom, 31 + ZoomIn, 32 + ZoomOut, 33 + } 34 + 35 + #[derive(Clone, Debug, PartialEq)] 36 + pub struct KeyDown { 37 + pub code: Option<KeyCode>, 38 + pub nav: Option<NavKey>, 39 + pub text: Option<String>, 40 + pub repeat: bool, 41 + } 42 + 43 + #[derive(Clone, Debug, PartialEq)] 44 + pub enum InputEvent { 45 + Resize(ViewportExtent), 46 + Focus(bool), 47 + Modifiers(ModifierMask), 48 + CursorMove(WindowPoint), 49 + CursorLeft, 50 + CursorEntered, 51 + Pointer { 52 + button: PointerButton, 53 + pressed: bool, 54 + }, 55 + Wheel(ScrollDelta), 56 + KeyDown(KeyDown), 57 + } 58 + 59 + #[must_use = "input dispatch must be acknowledged via the redraw scheduler"] 60 + pub struct InputDispatched(()); 61 + 62 + impl InputDispatched { 63 + pub(crate) const fn after_input() -> Self { 64 + Self(()) 65 + } 66 + }
+25
crates/bone-app/src/lib.rs
··· 1 + mod app; 2 + mod chrome; 3 + mod clock; 4 + mod dimension_editor; 5 + mod file_menu; 6 + mod hotkeys; 7 + mod input; 8 + mod native_picker; 9 + mod relation_tools; 10 + mod selection; 11 + mod settings; 12 + mod shell; 13 + mod shortcut_bar; 14 + mod sketch_mode; 15 + mod smart_dimension; 16 + mod snap; 17 + mod status_badge; 18 + mod step_jobs; 19 + mod strings; 20 + mod tools; 21 + mod view_cube; 22 + 23 + pub use app::{AppCore, FrameReport, FrameTarget, OpenError}; 24 + pub use clock::{FrameClock, FrameCount, ZeroFrameCount}; 25 + pub use input::{InputDispatched, InputEvent, KeyDown, NavKey, ScrollDelta, WindowPoint};
+62 -5624
crates/bone-app/src/main.rs
··· 1 - use std::collections::BTreeMap; 2 - use std::num::NonZeroUsize; 3 1 use std::path::{Path, PathBuf}; 4 2 use std::sync::Arc; 5 3 6 - use bone_document::{ 7 - DimensionKind, DimensionValue, Document, DocumentFolder, EditOutcome, EvaluatedExtrude, 8 - ExtrudeError, ExtrudeFeature, FeatureCache, FeatureNode, LineData, Sketch, SketchDimension, 9 - SketchEdit, SketchEntity, SketchRelation, SketchVersion, SolverError, UndoStack, 10 - }; 11 - use bone_render::{ 12 - Camera2, CameraTween, ChromeInstance, ChromePipeline, ChromeTextPipeline, ConvexInstance, 13 - ConvexPolyPipeline, DragModifiers, EdgeScene, NavGesture, PickQuery, PickedItem, PixelsPerMm, 14 - RenderTargets, SdfGlyphInstance, SketchPreview, SketchRenderer, SketchScene, SolidFrameView, 15 - SolidRenderer, SolidScene, StrokeInstance, StrokePipeline, Style, SurfaceContext, 16 - ViewportExtent, ViewportNavigator, ViewportPoint, ViewportPx, ViewportRegion, frame_current, 17 - frame_standard_view, frame_view_direction, orbit_pitch, orbit_yaw, pan_pixels, roll_by, 18 - zoom_about_pixel, 19 - }; 20 - use bone_types::{ 21 - Aabb3, Angle, AngleTolerance, BudgetCeiling, Camera3, ChordHeightTolerance, CubicEasing, 22 - DisplayMode, DocumentId, ExtrudeId, FeatureId, GeometryGeneration, Length, Plane3, Point2, 23 - SketchId, SketchItemId, StandardView, Vec2, ZoomFactor, 24 - }; 25 - use bone_ui::a11y::AccessTreeBuilder; 26 - use bone_ui::focus::FocusManager; 27 - use bone_ui::frame::FrameCtx; 4 + use bone_app::{AppCore, FrameTarget, InputEvent}; 5 + use bone_render::{PickIndex, Picker, SurfaceContext, ViewportExtent}; 28 6 use bone_ui::gallery::{GALLERY_CANVAS, GalleryState, render}; 29 - use bone_ui::hit_test::{HitFrame, HitState, resolve}; 30 - use bone_ui::hotkey::{ActionId, HotkeyScope, HotkeyScopes, HotkeyTable}; 31 - use bone_ui::input::{ 32 - FrameInstant, InputSnapshot, KeyChar, KeyCode as UiKeyCode, KeyEvent as UiKeyEvent, 33 - ModifierMask, NamedKey, PointerButton, PointerButtonMask, PointerSample, 34 - }; 35 - use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 7 + use bone_ui::input::FrameInstant; 36 8 use bone_ui::raster::{PngError, encode_png, rasterize}; 37 9 use bone_ui::strings::StringTable; 38 10 use bone_ui::theme::{Theme, ThemeMode}; 39 - use bone_ui::{MaskAtlas, MaskAtlasParams, Shaper}; 40 - use swash::FontRef; 41 11 use tracing_subscriber::EnvFilter; 42 - use uom::si::angle::degree; 43 - use uom::si::length::millimeter; 44 12 use winit::{ 45 13 application::ApplicationHandler, 46 - dpi::{PhysicalPosition, PhysicalSize}, 47 - event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}, 14 + event::WindowEvent, 48 15 event_loop::{ActiveEventLoop, ControlFlow, EventLoop}, 49 - keyboard::{Key, KeyCode, ModifiersState, NamedKey as WinitNamed, PhysicalKey}, 50 16 window::{Window, WindowId}, 51 17 }; 52 18 53 - mod chrome; 54 - mod dimension_editor; 55 19 mod event; 56 - mod file_menu; 57 - mod hotkeys; 58 - mod native_picker; 59 20 mod redraw; 60 - mod relation_tools; 61 - mod selection; 62 - mod settings; 63 - mod shell; 64 - mod shortcut_bar; 65 - mod sketch_mode; 66 - mod smart_dimension; 67 - mod snap; 68 - mod status_badge; 69 - mod step_jobs; 70 - mod strings; 71 - mod tools; 72 - mod view_cube; 73 - 74 - use dimension_editor::{DimensionEditorAction, DimensionEditorOutcome, DimensionEditorState}; 75 - use selection::Selection; 76 - use sketch_mode::{ 77 - ClickAnchor, DimensionFlow, DragPins, DragSession, ExtrudeArming, FeatureTool, Mode, Pending, 78 - PendingDimension, Plane, SketchTool, 79 - }; 80 - use snap::{Anchor, SnapHit}; 81 - use status_badge::ExtrudeStatus; 82 21 83 22 #[derive(Debug, thiserror::Error)] 84 23 enum AppError { ··· 109 48 } 110 49 111 50 const DEFAULT_LOG_FILTER: &str = "bone_app=info,bone_render=info,bone_document=info,bone_kernel=info,bone_types=info,wgpu_core=warn,wgpu_hal=warn"; 112 - const ZOOM_STEP_PER_LINE: f64 = 1.1; 113 - const ZOOM_STEP_PER_PIXEL: f64 = 1.0025; 114 - const ZOOM_KEY_STEP: f64 = 1.25; 115 - const ORBIT_KEY_STEP_DEG: f64 = 15.0; 116 - const ORBIT_KEY_SNAP_DEG: f64 = 90.0; 117 - const ZOOM_MIN: f64 = 0.01; 118 - const ZOOM_MAX: f64 = 1.0e5; 119 - const INITIAL_ZOOM_PX_PER_MM: f64 = 12.0; 120 - const PAN_STEP_PX: f64 = 40.0; 121 - const PAN_FAST_MULTIPLIER: f64 = 5.0; 122 - const ZOOM_FIT_MARGIN: f64 = 0.9; 123 - const UNDO_CAPACITY: usize = 256; 124 - const SNAP_TOLERANCE_PX: f64 = 8.0; 125 - const SNAP_TOLERANCE_MAX_MM: f64 = 5.0; 126 51 127 - struct RenderState { 52 + struct WindowSurface { 128 53 surface: SurfaceContext, 129 - renderer: SketchRenderer, 130 - chrome_pipeline: ChromePipeline, 131 - convex_pipeline: ConvexPolyPipeline, 132 - stroke_pipeline: StrokePipeline, 133 - text_pipeline: ChromeTextPipeline, 134 - sdf_atlas: MaskAtlas, 135 - chrome_shaper: Shaper, 136 - sans_font: FontRef<'static>, 137 - mono_font: FontRef<'static>, 138 - scene: SketchScene, 139 - camera: Camera2, 140 - style: Style, 141 - theme: Arc<Theme>, 142 - shell: shell::Shell, 143 - document: Document, 144 - plane_sketches: BTreeMap<Plane, SketchId>, 145 - mode: Mode, 146 - feature_cache: FeatureCache, 147 - extrude_preview: Option<ExtrudePreview>, 148 - solid_renderer: SolidRenderer, 149 - solid_view: Option<SolidViewData>, 150 - camera3: Option<Camera3>, 151 - framed_extrude: Option<ExtrudeId>, 152 - navigator: ViewportNavigator, 153 - view: view_cube::ViewUi, 154 - focus: FocusManager, 155 - hit_state: HitState, 156 - hotkeys: HotkeyTable, 157 - strings: StringTable, 158 - viewport_rect: LayoutRect, 159 - undo: UndoStack, 160 - selection: Selection, 161 - settings: settings::Settings, 162 - dim_editor: DimensionEditorState, 163 - dim_editor_bounds: Option<LayoutRect>, 164 - pending_exit: bool, 165 - current_folder: Option<DocumentFolder>, 166 - documents_root: PathBuf, 167 - file_picker: Option<file_menu::FilePickerSession>, 168 - native_picker: Option<native_picker::PendingHandle>, 169 - step_job: Option<step_jobs::StepJob>, 170 - pending_overwrite: Option<PendingOverwrite>, 171 - last_saved: Option<Document>, 172 - pending_discard: Option<PendingDiscard>, 173 - notification: Option<Notification>, 174 - shortcut_bar: Option<shortcut_bar::ShortcutBarState>, 175 - } 176 - 177 - #[derive(Clone, Debug, PartialEq)] 178 - enum PendingDiscard { 179 - New, 180 - Open(PathBuf), 181 - ImportStep(PathBuf), 182 - InstallImported { 183 - document: Box<Document>, 184 - file_name: String, 185 - }, 186 - } 187 - 188 - #[derive(Copy, Clone, Debug, PartialEq, Eq)] 189 - enum PickedVia { 190 - NativePortal, 191 - CustomPicker, 192 - } 193 - 194 - #[derive(Clone, Debug, PartialEq)] 195 - enum PendingOverwrite { 196 - Document(DocumentFolder), 197 - StepExport(PathBuf), 198 - } 199 - 200 - fn modal_active(state: &RenderState) -> bool { 201 - state.file_picker.is_some() 202 - || state.native_picker.is_some() 203 - || state 204 - .step_job 205 - .as_ref() 206 - .is_some_and(|job| job.meta().show_progress) 207 - || state.pending_overwrite.is_some() 208 - || state.pending_discard.is_some() 209 - || state.shortcut_bar.is_some() 210 - || state.shell.state.ribbon_overflow_open.values().any(|v| *v) 211 - } 212 - 213 - #[derive(Copy, Clone, Debug, PartialEq, Eq)] 214 - enum NotificationKind { 215 - Info, 216 - Error, 217 - } 218 - 219 - #[derive(Clone, Debug, PartialEq)] 220 - struct Notification { 221 - kind: NotificationKind, 222 - headline: bone_ui::strings::StringKey, 223 - detail: Option<String>, 54 + window: Arc<Window>, 224 55 } 225 56 226 - struct InputState { 227 - cursor_px: Option<PhysicalPosition<f64>>, 228 - left_pan: bool, 229 - middle_pan: bool, 230 - modifiers: ModifiersState, 231 - pending_pressed: PointerButtonMask, 232 - pending_released: PointerButtonMask, 233 - pending_keys: Vec<UiKeyEvent>, 234 - pending_text: String, 235 - start: std::time::Instant, 236 - } 237 - 238 - impl Default for InputState { 239 - fn default() -> Self { 240 - Self { 241 - cursor_px: None, 242 - left_pan: false, 243 - middle_pan: false, 244 - modifiers: ModifiersState::empty(), 245 - pending_pressed: PointerButtonMask::EMPTY, 246 - pending_released: PointerButtonMask::EMPTY, 247 - pending_keys: Vec::new(), 248 - pending_text: String::new(), 249 - start: std::time::Instant::now(), 250 - } 57 + impl FrameTarget for WindowSurface { 58 + fn picker(&self, index: PickIndex) -> Picker<'_> { 59 + self.surface.picker(index) 251 60 } 252 - } 253 61 254 - impl InputState { 255 - fn panning(&self) -> bool { 256 - self.middle_pan || (self.left_pan && self.modifiers.shift_key()) 257 - } 258 - 259 - fn pan_step_px(&self) -> f64 { 260 - if self.modifiers.shift_key() { 261 - PAN_STEP_PX * PAN_FAST_MULTIPLIER 262 - } else { 263 - PAN_STEP_PX 264 - } 265 - } 266 - 267 - fn modifier_mask(&self) -> ModifierMask { 268 - let bits = [ 269 - (self.modifiers.control_key(), ModifierMask::CTRL), 270 - (self.modifiers.shift_key(), ModifierMask::SHIFT), 271 - (self.modifiers.alt_key(), ModifierMask::ALT), 272 - (self.modifiers.super_key(), ModifierMask::META), 273 - ]; 274 - bits.iter() 275 - .filter(|(active, _)| *active) 276 - .fold(ModifierMask::NONE, |acc, (_, m)| acc.union(*m)) 277 - } 278 - 279 - fn pointer_sample(&self) -> Option<PointerSample> { 280 - self.cursor_px 281 - .map(physical_to_layout_pos) 282 - .map(PointerSample::new) 283 - } 284 - 285 - fn cursor_in(&self, rect: LayoutRect) -> bool { 286 - self.cursor_px 287 - .map(physical_to_layout_pos) 288 - .is_some_and(|p| rect.contains(p)) 289 - } 290 - 291 - fn drain_snapshot(&mut self) -> InputSnapshot { 292 - let mut snap = InputSnapshot::idle(FrameInstant::from_duration(self.start.elapsed())); 293 - snap.pointer = self.pointer_sample(); 294 - snap.buttons_pressed = 295 - core::mem::replace(&mut self.pending_pressed, PointerButtonMask::EMPTY); 296 - snap.buttons_released = 297 - core::mem::replace(&mut self.pending_released, PointerButtonMask::EMPTY); 298 - snap.keys_pressed = core::mem::take(&mut self.pending_keys); 299 - snap.text_committed = core::mem::take(&mut self.pending_text); 300 - snap.modifiers = self.modifier_mask(); 301 - snap 302 - } 303 - 304 - fn forget_pan_state(&mut self) { 305 - self.cursor_px = None; 306 - self.left_pan = false; 307 - self.middle_pan = false; 308 - self.modifiers = ModifiersState::empty(); 62 + fn render( 63 + &mut self, 64 + build_passes: impl FnOnce( 65 + &mut wgpu::CommandEncoder, 66 + &wgpu::TextureView, 67 + &wgpu::TextureView, 68 + &wgpu::TextureView, 69 + ), 70 + ) { 71 + let window = &self.window; 72 + self.surface 73 + .render(build_passes, || window.pre_present_notify()); 309 74 } 310 75 } 311 76 312 - #[allow( 313 - clippy::cast_possible_truncation, 314 - reason = "winit logical px (f64) collapses to LayoutPx (f32) at the sub-pixel limit" 315 - )] 316 - fn physical_to_layout_pos(p: PhysicalPosition<f64>) -> LayoutPos { 317 - LayoutPos::new( 318 - LayoutPx::saturating(p.x as f32), 319 - LayoutPx::saturating(p.y as f32), 320 - ) 77 + struct Running { 78 + scheduler: redraw::Scheduler, 79 + surface: WindowSurface, 80 + core: AppCore, 81 + epoch: std::time::Instant, 321 82 } 322 83 323 84 struct App { 324 - redraw: Option<redraw::Scheduler>, 325 - render: Option<RenderState>, 326 - input: InputState, 327 - } 328 - 329 - fn default_sketch() -> Sketch { 330 - let sketch = Sketch::new(Plane::Xy.basis()); 331 - let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(-20.0, -12.5)); 332 - let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(20.0, -12.5)); 333 - let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(20.0, 12.5)); 334 - let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(-20.0, 12.5)); 335 - let (sketch, _) = tools::add_line(sketch, p0, p1, false); 336 - let (sketch, _) = tools::add_line(sketch, p1, p2, false); 337 - let (sketch, _) = tools::add_line(sketch, p2, p3, false); 338 - let (sketch, _) = tools::add_line(sketch, p3, p0, false); 339 - let (sketch, origin) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 340 - let (sketch, _) = tools::add_circle(sketch, origin, Length::new::<millimeter>(5.0), false); 341 - sketch 342 - } 343 - 344 - fn initial_document(sketch: Sketch) -> (Document, SketchId) { 345 - let mut document = Document::new(DocumentId::default(), "Untitled".to_owned()); 346 - let sketch_id = SketchId::default(); 347 - document.insert_sketch(sketch_id, "Sketch1".to_owned(), sketch); 348 - (document, sketch_id) 349 - } 350 - 351 - fn viewport_extent(size: PhysicalSize<u32>) -> ViewportExtent { 352 - ViewportExtent::new( 353 - ViewportPx::new(size.width.max(1)), 354 - ViewportPx::new(size.height.max(1)), 355 - ) 356 - } 357 - 358 - #[allow( 359 - clippy::cast_precision_loss, 360 - reason = "viewport pixel counts at any realistic display size fit f32 mantissa" 361 - )] 362 - fn layout_size_from_extent(extent: ViewportExtent) -> LayoutSize { 363 - LayoutSize::new( 364 - LayoutPx::new(extent.width().value() as f32), 365 - LayoutPx::new(extent.height().value() as f32), 366 - ) 367 - } 368 - 369 - fn empty_rect() -> LayoutRect { 370 - LayoutRect::new(LayoutPos::ORIGIN, LayoutSize::ZERO) 371 - } 372 - 373 - fn zoom_factor(delta: MouseScrollDelta) -> f64 { 374 - match delta { 375 - MouseScrollDelta::LineDelta(_, y) => ZOOM_STEP_PER_LINE.powf(f64::from(y)), 376 - MouseScrollDelta::PixelDelta(p) => ZOOM_STEP_PER_PIXEL.powf(p.y), 377 - } 378 - } 379 - 380 - fn zoom_about(camera: Camera2, cursor: Option<PhysicalPosition<f64>>, factor: f64) -> Camera2 { 381 - if !factor.is_finite() || factor <= 0.0 { 382 - return camera; 383 - } 384 - let zoom_before = camera.zoom().value(); 385 - let zoom_after = (zoom_before * factor).clamp(ZOOM_MIN, ZOOM_MAX); 386 - if !zoom_after.is_finite() { 387 - return camera; 388 - } 389 - let extent = camera.extent(); 390 - let w = f64::from(extent.width().value()); 391 - let h = f64::from(extent.height().value()); 392 - let (pan_x, pan_y) = camera.pan_mm().coords_mm(); 393 - let (cursor_x, cursor_y) = cursor.map_or((w * 0.5, h * 0.5), |c| (c.x, c.y)); 394 - let horizontal_px = cursor_x - w * 0.5; 395 - let vertical_px = h * 0.5 - cursor_y; 396 - let world_x = pan_x + horizontal_px / zoom_before; 397 - let world_y = pan_y + vertical_px / zoom_before; 398 - let new_pan_x = world_x - horizontal_px / zoom_after; 399 - let new_pan_y = world_y - vertical_px / zoom_after; 400 - camera 401 - .with_zoom(PixelsPerMm::new(zoom_after)) 402 - .with_pan(Vec2::from_mm(new_pan_x, new_pan_y)) 403 - } 404 - 405 - fn dragging_in_sketch(mode: &Mode) -> bool { 406 - matches!( 407 - mode, 408 - Mode::Sketch { session, .. } if session.drag.is_some() 409 - ) 410 - } 411 - 412 - fn dim_flow_active(mode: &Mode) -> bool { 413 - matches!( 414 - mode, 415 - Mode::Sketch { session, .. } if session.dim_flow.is_some() 416 - ) 417 - } 418 - 419 - fn cursor_to_world(camera: Camera2, cursor: PhysicalPosition<f64>) -> Option<Point2> { 420 - let extent = camera.extent(); 421 - let w = f64::from(extent.width().value()); 422 - let h = f64::from(extent.height().value()); 423 - let zoom = camera.zoom().value(); 424 - if w <= 0.0 || h <= 0.0 || !zoom.is_finite() || zoom <= 0.0 { 425 - return None; 426 - } 427 - let (pan_x, pan_y) = camera.pan_mm().coords_mm(); 428 - Some(Point2::from_mm( 429 - pan_x + (cursor.x - w * 0.5) / zoom, 430 - pan_y + (h * 0.5 - cursor.y) / zoom, 431 - )) 432 - } 433 - 434 - fn try_place(state: &mut RenderState, world: Point2) { 435 - let Mode::Sketch { sketch_id, session } = &state.mode else { 436 - return; 437 - }; 438 - let Some(tool) = session.tool else { 439 - return; 440 - }; 441 - let prev_pending = session.pending; 442 - let sketch_id = *sketch_id; 443 - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 444 - return; 445 - }; 446 - let snap = match tool { 447 - SketchTool::Line => { 448 - compute_snap(&sketch, &state.camera, world, latest_anchor(prev_pending)) 449 - } 450 - _ => compute_endpoint_snap(&sketch, &state.camera, world), 451 - }; 452 - let (next_sketch, new_pending) = tools::place(sketch, tool, world, prev_pending, snap); 453 - if let Some(next) = next_sketch { 454 - state.undo.record(state.document.clone()); 455 - state.document.replace_sketch(sketch_id, next); 456 - let Some(stored) = state.document.sketch(sketch_id) else { 457 - return; 458 - }; 459 - match SketchScene::extract(stored) { 460 - Ok(scene) => state.scene = scene, 461 - Err(e) => tracing::warn!(error = %e, "scene extract after place failed"), 462 - } 463 - } 464 - if let Mode::Sketch { 465 - ref mut session, .. 466 - } = state.mode 467 - { 468 - session.pending = new_pending; 469 - } 470 - } 471 - 472 - const fn latest_anchor(pending: Option<Pending>) -> Option<ClickAnchor> { 473 - match pending { 474 - None => None, 475 - Some(Pending::First(a) | Pending::Second(_, a)) => Some(a), 476 - } 477 - } 478 - 479 - fn pick_at(state: &RenderState, cursor: PhysicalPosition<f64>) -> Option<PickedItem> { 480 - if !cursor.x.is_finite() || !cursor.y.is_finite() || cursor.x < 0.0 || cursor.y < 0.0 { 481 - return None; 482 - } 483 - let extent = state.surface.extent(); 484 - #[allow( 485 - clippy::cast_possible_truncation, 486 - clippy::cast_sign_loss, 487 - reason = "cursor px is bounds-checked against surface extent before the cast" 488 - )] 489 - let qx = cursor.x.round() as u32; 490 - #[allow( 491 - clippy::cast_possible_truncation, 492 - clippy::cast_sign_loss, 493 - reason = "cursor px is bounds-checked against surface extent before the cast" 494 - )] 495 - let qy = cursor.y.round() as u32; 496 - if qx >= extent.width().value() || qy >= extent.height().value() { 497 - return None; 498 - } 499 - let index = match state.scene.pick_index() { 500 - Ok(i) => i, 501 - Err(e) => { 502 - tracing::warn!(error = %e, "build pick index"); 503 - return None; 504 - } 505 - }; 506 - let query = PickQuery::new(ViewportPx::new(qx), ViewportPx::new(qy)) 507 - .with_aperture(state.settings.pick_aperture); 508 - match state.surface.picker(index).at(query) { 509 - Ok(item) => item, 510 - Err(e) => { 511 - tracing::warn!(error = %e, "pick failed"); 512 - None 513 - } 514 - } 515 - } 516 - 517 - fn handle_viewport_click(state: &mut RenderState, cursor: PhysicalPosition<f64>, additive: bool) { 518 - let item = pick_at(state, cursor).and_then(selection::picked_to_item); 519 - state.selection = std::mem::take(&mut state.selection).picked(item, additive); 520 - if additive || !state.mode.is_sketch() { 521 - return; 522 - } 523 - let Some(SketchItemId::Entity(entity_id)) = item else { 524 - return; 525 - }; 526 - let Mode::Sketch { sketch_id, .. } = state.mode else { 527 - return; 528 - }; 529 - let Some(sketch) = state.document.sketch(sketch_id) else { 530 - return; 531 - }; 532 - let Some(world) = cursor_to_world(state.camera, cursor) else { 533 - return; 534 - }; 535 - let Some(pins) = DragPins::from_sketch_entity(sketch, entity_id) else { 536 - return; 537 - }; 538 - let drag = DragSession { 539 - entity: entity_id, 540 - press: world, 541 - pins, 542 - }; 543 - state.undo.record(state.document.clone()); 544 - state.mode = core::mem::take(&mut state.mode).start_drag(drag); 545 - } 546 - 547 - fn drag_resolved(sketch: &Sketch, drag: DragSession, world: Point2) -> Option<Sketch> { 548 - let pins = drag.pins.to_targets(drag.press, world); 549 - sketch 550 - .solve_with_drag_pins(&pins, BudgetCeiling::FRAME_16MS) 551 - .map_err(|e| tracing::trace!(error = %e, "drag solve failed, keeping last-good sketch")) 552 - .ok() 553 - } 554 - 555 - fn try_drag_to(state: &mut RenderState, world: Point2) { 556 - let Mode::Sketch { sketch_id, session } = &state.mode else { 557 - return; 558 - }; 559 - let Some(drag) = session.drag else { 560 - return; 561 - }; 562 - let sketch_id = *sketch_id; 563 - let Some(sketch) = state.document.sketch(sketch_id) else { 564 - return; 565 - }; 566 - let Some(next) = drag_resolved(sketch, drag, world) else { 567 - return; 568 - }; 569 - state.document.replace_sketch(sketch_id, next); 570 - refresh_active_scene(state); 571 - } 572 - 573 - fn refresh_active_scene(state: &mut RenderState) { 574 - let Some(active_id) = active_sketch_id(&state.mode, &state.plane_sketches) else { 575 - return; 576 - }; 577 - let Some(sketch) = state.document.sketch(active_id) else { 578 - return; 579 - }; 580 - match SketchScene::extract(sketch) { 581 - Ok(scene) => state.scene = scene, 582 - Err(e) => tracing::warn!(error = %e, "refresh active scene failed"), 583 - } 584 - } 585 - 586 - fn active_sketch_id(mode: &Mode, plane_sketches: &BTreeMap<Plane, SketchId>) -> Option<SketchId> { 587 - match mode { 588 - Mode::Sketch { sketch_id, .. } => Some(*sketch_id), 589 - Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => Some(feature.sketch), 590 - Mode::Extrude(ExtrudeArming::AwaitingSketch) | Mode::Idle => { 591 - plane_sketches.get(&Plane::Xy).copied() 592 - } 593 - } 594 - } 595 - 596 - enum ProfileChoice { 597 - NoSketch, 598 - Unique(SketchId), 599 - Ambiguous, 600 - } 601 - 602 - fn classify_extrude_profile(document: &Document) -> ProfileChoice { 603 - let mut sketches = document 604 - .feature_tree() 605 - .iter() 606 - .filter_map(|(_, node)| match node { 607 - FeatureNode::Sketch(id) => Some(id), 608 - _ => None, 609 - }); 610 - match (sketches.next(), sketches.next()) { 611 - (None, _) => ProfileChoice::NoSketch, 612 - (Some(id), None) => ProfileChoice::Unique(id), 613 - (Some(_), Some(_)) => ProfileChoice::Ambiguous, 614 - } 615 - } 616 - 617 - fn apply_feature_tool(state: &mut RenderState, tool: Option<FeatureTool>) { 618 - match tool { 619 - Some(FeatureTool::ExtrudedBossBase) => arm_extruded_boss_base(state), 620 - Some(FeatureTool::ExtrudedCut) => notify_stub(state, strings::TOOL_EXTRUDED_CUT), 621 - None => {} 622 - } 623 - } 624 - 625 - fn arm_extruded_boss_base(state: &mut RenderState) { 626 - match classify_extrude_profile(&state.document) { 627 - ProfileChoice::NoSketch => notify_info(state, strings::NOTIFY_EXTRUDE_NO_SKETCH, None), 628 - ProfileChoice::Unique(id) => state.mode = Mode::Extrude(ExtrudeArming::profile(id)), 629 - ProfileChoice::Ambiguous => state.mode = Mode::Extrude(ExtrudeArming::AwaitingSketch), 630 - } 631 - } 632 - 633 - fn apply_extrude_edit(state: &mut RenderState, edit: Option<shell::ExtrudeEdit>) { 634 - let Some(edit) = edit else { return }; 635 - let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = &state.mode else { 636 - return; 637 - }; 638 - let next = edit.apply(*feature); 639 - let target = *target; 640 - state.mode = Mode::Extrude(ExtrudeArming::Profile { 641 - feature: next, 642 - target, 643 - }); 644 - } 645 - 646 - fn apply_extrude_activation(state: &mut RenderState, activated: Option<ExtrudeId>) { 647 - if let Some(mode) = extrude_edit_mode(&state.document, &state.mode, activated) { 648 - if let Mode::Extrude(ExtrudeArming::Profile { 649 - target: Some(id), .. 650 - }) = mode 651 - { 652 - state.framed_extrude = Some(id); 653 - } 654 - state.mode = mode; 655 - } 656 - } 657 - 658 - fn extrude_edit_mode( 659 - document: &Document, 660 - current: &Mode, 661 - activated: Option<ExtrudeId>, 662 - ) -> Option<Mode> { 663 - let id = activated?; 664 - if current.is_sketch() { 665 - return None; 666 - } 667 - let feature = document.extrude(id).copied()?; 668 - Some(Mode::Extrude(ExtrudeArming::edit(id, feature))) 669 - } 670 - 671 - fn apply_extrude_confirm(state: &mut RenderState, confirm: Option<shell::ConfirmAction>) { 672 - if let Some(id) = 673 - commit_armed_extrude(&mut state.document, &mut state.undo, &state.mode, confirm) 674 - { 675 - state.framed_extrude = Some(id); 676 - } 677 - } 678 - 679 - fn commit_armed_extrude( 680 - document: &mut Document, 681 - undo: &mut UndoStack, 682 - mode: &Mode, 683 - confirm: Option<shell::ConfirmAction>, 684 - ) -> Option<ExtrudeId> { 685 - let Some(shell::ConfirmAction::Accept) = confirm else { 686 - return None; 687 - }; 688 - let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = mode else { 689 - return None; 690 - }; 691 - let snapshot = document.clone(); 692 - let committed = match target { 693 - Some(id) => { 694 - document.insert_extrude(*id, *feature); 695 - *id 696 - } 697 - None => document.commit_extrude(*feature), 698 - }; 699 - undo.record(snapshot); 700 - Some(committed) 701 - } 702 - 703 - struct SolidViewData { 704 - faces: SolidScene, 705 - edges: EdgeScene, 706 - aabb: Aabb3, 707 - } 708 - 709 - struct ExtrudePreview { 710 - feature: ExtrudeFeature, 711 - sketch_version: SketchVersion, 712 - generation: Option<GeometryGeneration>, 713 - failed: bool, 714 - error: Option<ExtrudeError>, 715 - } 716 - 717 - impl ExtrudePreview { 718 - fn status(&self) -> ExtrudeStatus<'_> { 719 - match &self.error { 720 - Some(error) => ExtrudeStatus::Failed(error), 721 - None => ExtrudeStatus::Valid, 722 - } 723 - } 724 - } 725 - 726 - const PREVIEW_CHORD_MM: f64 = 0.05; 727 - const PREVIEW_ANGLE: AngleTolerance = AngleTolerance::from_radians(0.2); 728 - 729 - fn active_solid_feature( 730 - mode: &Mode, 731 - document: &Document, 732 - framed: Option<ExtrudeId>, 733 - ) -> Option<ExtrudeFeature> { 734 - match mode { 735 - Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => Some(*feature), 736 - Mode::Sketch { .. } => None, 737 - Mode::Idle | Mode::Extrude(ExtrudeArming::AwaitingSketch) => framed 738 - .and_then(|id| document.extrude(id).copied()) 739 - .or_else(|| { 740 - document 741 - .feature_tree() 742 - .iter() 743 - .filter_map(|(_, node)| match node { 744 - FeatureNode::Extrude(id) => Some(id), 745 - _ => None, 746 - }) 747 - .last() 748 - .and_then(|id| document.extrude(id).copied()) 749 - }), 750 - } 751 - } 752 - 753 - fn sync_solid_view(state: &mut RenderState) { 754 - let Some(feature) = active_solid_feature(&state.mode, &state.document, state.framed_extrude) 755 - else { 756 - state.extrude_preview = None; 757 - state.solid_view = None; 758 - state.camera3 = None; 759 - state.view.home = None; 760 - state.view.tween = None; 761 - return; 762 - }; 763 - let Some(sketch_version) = state.document.sketch(feature.sketch).map(Sketch::version) else { 764 - state.extrude_preview = None; 765 - state.solid_view = None; 766 - state.camera3 = None; 767 - state.view.home = None; 768 - state.view.tween = None; 769 - return; 770 - }; 771 - if extrude_preview_is_current(state.extrude_preview.as_ref(), &feature, sketch_version) { 772 - return; 773 - } 774 - let previous_generation = state 775 - .extrude_preview 776 - .as_ref() 777 - .and_then(|cached| cached.generation); 778 - let previously_failed = state 779 - .extrude_preview 780 - .as_ref() 781 - .is_some_and(|cached| cached.failed); 782 - let first_preview = state.extrude_preview.is_none(); 783 - let preview = compute_extrude_preview(&mut state.feature_cache, &state.document, feature); 784 - let generation = preview.as_ref().and_then(EvaluatedExtrude::generation); 785 - let error = preview 786 - .as_ref() 787 - .and_then(|evaluated| evaluated.result().as_ref().err().cloned()); 788 - let mut failure = error.as_ref().map(ToString::to_string); 789 - if generation != previous_generation { 790 - state.solid_view = match preview.as_ref().and_then(EvaluatedExtrude::solid) { 791 - Some(solid) => match build_solid_view(solid) { 792 - Ok(view) => Some(view), 793 - Err(error) => { 794 - failure = Some(error); 795 - None 796 - } 797 - }, 798 - None => None, 799 - }; 800 - } 801 - let now_failed = failure.is_some(); 802 - state.extrude_preview = Some(ExtrudePreview { 803 - feature, 804 - sketch_version, 805 - generation, 806 - failed: now_failed, 807 - error, 808 - }); 809 - let newly_failed = first_preview || !previously_failed; 810 - if let Some(detail) = failure.filter(|_| newly_failed) { 811 - tracing::warn!(error = %detail, "extrude preview evaluation failed"); 812 - notify_error(state, strings::NOTIFY_EXTRUDE_FAILED, detail); 813 - } 814 - } 815 - 816 - fn extrude_preview_is_current( 817 - cached: Option<&ExtrudePreview>, 818 - feature: &ExtrudeFeature, 819 - sketch_version: SketchVersion, 820 - ) -> bool { 821 - cached 822 - .is_some_and(|cached| cached.feature == *feature && cached.sketch_version == sketch_version) 823 - } 824 - 825 - fn compute_extrude_preview( 826 - cache: &mut FeatureCache, 827 - document: &Document, 828 - feature: ExtrudeFeature, 829 - ) -> Option<EvaluatedExtrude> { 830 - let sketch = document.sketch(feature.sketch)?; 831 - let fid = FeatureId::default(); 832 - let evaluated_sketch = cache.evaluate(fid, sketch).clone(); 833 - Some( 834 - cache 835 - .evaluate_extrude(fid, &evaluated_sketch, &feature) 836 - .clone(), 837 - ) 838 - } 839 - 840 - fn build_solid_view(solid: &bone_document::BrepSolid) -> Result<SolidViewData, String> { 841 - let chord = ChordHeightTolerance::from_mm(PREVIEW_CHORD_MM); 842 - let aabb = solid 843 - .bounding_box() 844 - .ok_or_else(|| "degenerate solid has no bounding box".to_owned())?; 845 - let mesh = solid 846 - .tessellate(chord, PREVIEW_ANGLE) 847 - .map_err(|error| error.to_string())?; 848 - let faces = SolidScene::from_mesh(&mesh).map_err(|error| error.to_string())?; 849 - let edges = EdgeScene::from_solid(solid, &mesh, chord).map_err(|error| error.to_string())?; 850 - Ok(SolidViewData { faces, edges, aabb }) 851 - } 852 - 853 - fn sync_solid_camera(state: &mut RenderState, region: Option<ViewportRegion>) { 854 - if let Some(region) = region 855 - && let Some(view) = state.solid_view.as_ref() 856 - && state.camera3.is_none() 857 - { 858 - let framed = 859 - frame_standard_view(view.aabb, region.extent(), StandardView::Isometric, None).ok(); 860 - state.camera3 = framed; 861 - if state.view.home.is_none() { 862 - state.view.home = framed; 863 - } 864 - } 865 - } 866 - 867 - const VIEW_TWEEN_MS: u64 = 220; 868 - 869 - fn step_view_tween(state: &mut RenderState, now: FrameInstant) { 870 - let Some(active) = state.view.tween else { 871 - return; 872 - }; 873 - let elapsed = now.since(active.started); 874 - if let Ok(camera) = active.tween.sample(elapsed) { 875 - state.camera3 = Some(camera); 876 - } 877 - if active.tween.is_done(elapsed) { 878 - state.camera3 = Some(active.tween.to()); 879 - state.view.tween = None; 880 - } 881 - } 882 - 883 - fn start_view_tween(state: &mut RenderState, target: Camera3, now: FrameInstant) { 884 - let tween = if state.settings.reduce_motion { 885 - CameraTween::immediate(target) 886 - } else { 887 - CameraTween::eased( 888 - state.camera3.unwrap_or(target), 889 - target, 890 - std::time::Duration::from_millis(VIEW_TWEEN_MS), 891 - CubicEasing::STANDARD, 892 - ) 893 - }; 894 - state.view.tween = Some(view_cube::ActiveTween { 895 - tween, 896 - started: now, 897 - }); 898 - } 899 - 900 - fn apply_nav_camera(state: &mut RenderState, camera: Camera3) { 901 - state.camera3 = Some(camera); 902 - state.view.tween = None; 903 - } 904 - 905 - fn solid_aabb_and_extent(state: &RenderState) -> Option<(Aabb3, ViewportExtent)> { 906 - let region = solid_viewport_region(state.viewport_rect, state.surface.extent())?; 907 - let view = state.solid_view.as_ref()?; 908 - Some((view.aabb, region.extent())) 909 - } 910 - 911 - fn normal_to_plane(state: &RenderState) -> Option<Plane3> { 912 - let sketch_id = active_sketch_id(&state.mode, &state.plane_sketches)?; 913 - let plane = state 914 - .plane_sketches 915 - .iter() 916 - .find_map(|(plane, id)| (*id == sketch_id).then_some(*plane))?; 917 - Some(Plane3::from(plane.basis())) 918 - } 919 - 920 - fn frame_target_camera(state: &RenderState, pick: view_cube::ViewPick) -> Option<Camera3> { 921 - match pick { 922 - view_cube::ViewPick::Home => state.view.home, 923 - view_cube::ViewPick::Standard(view) => { 924 - let (aabb, extent) = solid_aabb_and_extent(state)?; 925 - frame_standard_view(aabb, extent, view, normal_to_plane(state)).ok() 926 - } 927 - view_cube::ViewPick::Direction(direction) => { 928 - let (aabb, extent) = solid_aabb_and_extent(state)?; 929 - frame_view_direction(aabb, extent, direction).ok() 930 - } 931 - } 932 - } 933 - 934 - fn apply_view_pick(state: &mut RenderState, pick: Option<view_cube::ViewPick>, now: FrameInstant) { 935 - let Some(pick) = pick else { 936 - return; 937 - }; 938 - if let Some(target) = frame_target_camera(state, pick) { 939 - start_view_tween(state, target, now); 940 - } 941 - } 942 - 943 - fn view_nav_enabled(state: &RenderState) -> bool { 944 - state.solid_view.is_some() 945 - && !modal_active(state) 946 - && state.focus.focused().is_none() 947 - && !dim_flow_active(&state.mode) 948 - } 949 - 950 - fn apply_view_menu( 951 - state: &mut RenderState, 952 - action: Option<view_cube::ViewCubeMenuAction>, 953 - now: FrameInstant, 954 - ) { 955 - match action { 956 - Some(view_cube::ViewCubeMenuAction::SetAsHome) => { 957 - state.view.home = state.camera3; 958 - } 959 - Some(view_cube::ViewCubeMenuAction::FitToWindow) => { 960 - if let (Some(camera), Some((aabb, extent))) = 961 - (state.camera3, solid_aabb_and_extent(state)) 962 - && let Ok(target) = frame_current(camera, aabb, extent) 963 - { 964 - start_view_tween(state, target, now); 965 - } 966 - } 967 - Some(view_cube::ViewCubeMenuAction::ViewNormalTo) => { 968 - apply_view_pick( 969 - state, 970 - Some(view_cube::ViewPick::Standard(StandardView::NormalTo)), 971 - now, 972 - ); 973 - } 974 - None => {} 975 - } 976 - } 977 - 978 - fn preview_solid_frame( 979 - solid_view: Option<&SolidViewData>, 980 - camera: Option<Camera3>, 981 - region: ViewportRegion, 982 - ) -> Option<(&SolidViewData, SolidFrameView)> { 983 - let view = solid_view?; 984 - Some((view, SolidFrameView::new(camera?, region).ok()?)) 985 - } 986 - 987 - fn solid_viewport_region(viewport: LayoutRect, surface: ViewportExtent) -> Option<ViewportRegion> { 988 - let (surface_w, surface_h) = (surface.width().value(), surface.height().value()); 989 - let min_x = round_layout_px(viewport.min_x().value()).min(surface_w); 990 - let min_y = round_layout_px(viewport.min_y().value()).min(surface_h); 991 - let width = round_layout_px(viewport.size.width.value()).min(surface_w - min_x); 992 - let height = round_layout_px(viewport.size.height.value()).min(surface_h - min_y); 993 - (width > 0 && height > 0).then(|| { 994 - ViewportRegion::new( 995 - ViewportPx::new(min_x), 996 - ViewportPx::new(min_y), 997 - ViewportExtent::new(ViewportPx::new(width), ViewportPx::new(height)), 998 - ) 999 - }) 1000 - } 1001 - 1002 - #[allow( 1003 - clippy::cast_possible_truncation, 1004 - clippy::cast_sign_loss, 1005 - reason = "the saturating cast of a non-negative rounded px is clamped to the surface extent by the caller" 1006 - )] 1007 - fn round_layout_px(value: f32) -> u32 { 1008 - value.round().max(0.0) as u32 1009 - } 1010 - 1011 - fn viewport_local_point( 1012 - cursor: PhysicalPosition<f64>, 1013 - region: ViewportRegion, 1014 - ) -> Option<ViewportPoint> { 1015 - let (min_x, min_y, _, _) = region.scissor(); 1016 - ViewportPoint::new(cursor.x - f64::from(min_x), cursor.y - f64::from(min_y)).ok() 1017 - } 1018 - 1019 - fn drag_gesture(modifiers: ModifiersState) -> NavGesture { 1020 - let with_ctrl = if modifiers.control_key() || modifiers.super_key() { 1021 - DragModifiers::NONE.with_ctrl() 1022 - } else { 1023 - DragModifiers::NONE 1024 - }; 1025 - let with_shift = if modifiers.shift_key() { 1026 - with_ctrl.with_shift() 1027 - } else { 1028 - with_ctrl 1029 - }; 1030 - let resolved = if modifiers.alt_key() { 1031 - with_shift.with_alt() 1032 - } else { 1033 - with_shift 1034 - }; 1035 - resolved.gesture() 1036 - } 1037 - 1038 - fn build_preview( 1039 - mode: &Mode, 1040 - document: &Document, 1041 - cursor_world: Option<Point2>, 1042 - camera: &Camera2, 1043 - ) -> SketchPreview { 1044 - let Mode::Sketch { sketch_id, session } = mode else { 1045 - return SketchPreview::empty(); 1046 - }; 1047 - if session.drag.is_some() || session.dim_flow.is_some() { 1048 - return SketchPreview::empty(); 1049 - } 1050 - let Some(tool) = session.tool else { 1051 - return SketchPreview::empty(); 1052 - }; 1053 - let pending = session.pending; 1054 - let Some(sketch) = document.sketch(*sketch_id) else { 1055 - return SketchPreview::empty(); 1056 - }; 1057 - let Some(cursor) = cursor_world else { 1058 - return tools::preview_anchors_only(sketch, pending); 1059 - }; 1060 - let snap = match tool { 1061 - SketchTool::Line => compute_snap(sketch, camera, cursor, latest_anchor(pending)), 1062 - _ => compute_endpoint_snap(sketch, camera, cursor), 1063 - }; 1064 - tools::preview(sketch, tool, cursor, pending, snap) 1065 - } 1066 - 1067 - fn snap_tolerance(camera: &Camera2) -> Option<Length> { 1068 - let mm_per_px = camera.world_mm_per_pixel(); 1069 - if !mm_per_px.is_finite() || mm_per_px <= 0.0 { 1070 - return None; 1071 - } 1072 - let tol_mm = (SNAP_TOLERANCE_PX * mm_per_px).min(SNAP_TOLERANCE_MAX_MM); 1073 - Some(Length::new::<millimeter>(tol_mm)) 1074 - } 1075 - 1076 - fn compute_snap( 1077 - sketch: &Sketch, 1078 - camera: &Camera2, 1079 - cursor_world: Point2, 1080 - click: Option<ClickAnchor>, 1081 - ) -> Option<SnapHit> { 1082 - snap::detect( 1083 - cursor_world, 1084 - click.and_then(|c| resolve_anchor(Some(sketch), c)), 1085 - sketch, 1086 - snap_tolerance(camera)?, 1087 - ) 1088 - } 1089 - 1090 - fn compute_endpoint_snap( 1091 - sketch: &Sketch, 1092 - camera: &Camera2, 1093 - cursor_world: Point2, 1094 - ) -> Option<SnapHit> { 1095 - snap::detect_endpoint_only(cursor_world, sketch, snap_tolerance(camera)?) 1096 - } 1097 - 1098 - fn resolve_anchor(sketch: Option<&Sketch>, click: ClickAnchor) -> Option<Anchor> { 1099 - match click { 1100 - ClickAnchor::Position(pos) | ClickAnchor::MidpointOf { position: pos, .. } => { 1101 - Some(Anchor { pos, id: None }) 1102 - } 1103 - ClickAnchor::Endpoint(id) => match sketch?.entities().get(id)? { 1104 - SketchEntity::Point(p) => Some(Anchor { 1105 - pos: p.at(), 1106 - id: Some(id), 1107 - }), 1108 - _ => None, 1109 - }, 1110 - } 1111 - } 1112 - 1113 - fn pan_by_px(camera: Camera2, horizontal_px: f64, vertical_px: f64) -> Camera2 { 1114 - let mm_per_px = camera.world_mm_per_pixel(); 1115 - let (pan_x, pan_y) = camera.pan_mm().coords_mm(); 1116 - camera.with_pan(Vec2::from_mm( 1117 - pan_x - horizontal_px * mm_per_px, 1118 - pan_y + vertical_px * mm_per_px, 1119 - )) 1120 - } 1121 - 1122 - fn zoom_fit(camera: Camera2, scene: &SketchScene, viewport: LayoutRect) -> Camera2 { 1123 - let Some(aabb) = scene.aabb() else { 1124 - return camera; 1125 - }; 1126 - let (mnx, mny) = aabb.min().coords_mm(); 1127 - let (mxx, mxy) = aabb.max().coords_mm(); 1128 - let center_x = (mnx + mxx) * 0.5; 1129 - let center_y = (mny + mxy) * 0.5; 1130 - let world_w = mxx - mnx; 1131 - let world_h = mxy - mny; 1132 - let v_w = f64::from(viewport.size.width.value()); 1133 - let v_h = f64::from(viewport.size.height.value()); 1134 - if v_w <= 0.0 || v_h <= 0.0 { 1135 - return camera; 1136 - } 1137 - let axis_zoom = |pixels: f64, world: f64| { 1138 - if world > 0.0 { 1139 - pixels / world 1140 - } else { 1141 - f64::INFINITY 1142 - } 1143 - }; 1144 - let raw_zoom = axis_zoom(v_w, world_w).min(axis_zoom(v_h, world_h)) * ZOOM_FIT_MARGIN; 1145 - let zoom = raw_zoom.clamp(ZOOM_MIN, ZOOM_MAX); 1146 - let pan = pan_centering((center_x, center_y), camera.extent(), viewport, zoom); 1147 - camera.with_zoom(PixelsPerMm::new(zoom)).with_pan(pan) 1148 - } 1149 - 1150 - fn pan_centering( 1151 - target_world: (f64, f64), 1152 - window: ViewportExtent, 1153 - viewport: LayoutRect, 1154 - zoom: f64, 1155 - ) -> Vec2 { 1156 - let v_w = f64::from(viewport.size.width.value()); 1157 - let v_h = f64::from(viewport.size.height.value()); 1158 - let viewport_center_x = f64::from(viewport.origin.x.value()) + v_w * 0.5; 1159 - let viewport_center_y = f64::from(viewport.origin.y.value()) + v_h * 0.5; 1160 - let window_center_x = f64::from(window.width().value()) * 0.5; 1161 - let window_center_y = f64::from(window.height().value()) * 0.5; 1162 - let pan_x = target_world.0 - (viewport_center_x - window_center_x) / zoom; 1163 - let pan_y = target_world.1 + (viewport_center_y - window_center_y) / zoom; 1164 - Vec2::from_mm(pan_x, pan_y) 1165 - } 1166 - 1167 - fn keyboard_camera(code: KeyCode, input: &InputState, state: &RenderState) -> Option<Camera2> { 1168 - if input.modifiers.control_key() || input.modifiers.super_key() { 1169 - return None; 1170 - } 1171 - let camera = state.camera; 1172 - let step = input.pan_step_px(); 1173 - let shift = input.modifiers.shift_key(); 1174 - match code { 1175 - KeyCode::ArrowLeft => Some(pan_by_px(camera, step, 0.0)), 1176 - KeyCode::ArrowRight => Some(pan_by_px(camera, -step, 0.0)), 1177 - KeyCode::ArrowUp => Some(pan_by_px(camera, 0.0, step)), 1178 - KeyCode::ArrowDown => Some(pan_by_px(camera, 0.0, -step)), 1179 - KeyCode::KeyZ => Some(zoom_about( 1180 - camera, 1181 - input.cursor_px, 1182 - if shift { 1183 - 1.0 / ZOOM_KEY_STEP 1184 - } else { 1185 - ZOOM_KEY_STEP 1186 - }, 1187 - )), 1188 - KeyCode::Equal => Some(zoom_about(camera, input.cursor_px, ZOOM_KEY_STEP)), 1189 - KeyCode::Minus => Some(zoom_about(camera, input.cursor_px, 1.0 / ZOOM_KEY_STEP)), 1190 - _ => None, 1191 - } 1192 - } 1193 - 1194 - fn zoom_key3( 1195 - camera: Camera3, 1196 - extent: ViewportExtent, 1197 - pixel: ViewportPoint, 1198 - factor: f64, 1199 - ) -> Option<Camera3> { 1200 - ZoomFactor::new(factor) 1201 - .ok() 1202 - .and_then(|f| zoom_about_pixel(camera, extent, pixel, f).ok()) 1203 - } 1204 - 1205 - fn keyboard_camera3(code: KeyCode, input: &InputState, state: &RenderState) -> Option<Camera3> { 1206 - let camera = state.camera3?; 1207 - let region = solid_viewport_region(state.viewport_rect, state.surface.extent())?; 1208 - let extent = region.extent(); 1209 - let ctrl = input.modifiers.control_key() || input.modifiers.super_key(); 1210 - let shift = input.modifiers.shift_key(); 1211 - let alt = input.modifiers.alt_key(); 1212 - let cx = f64::from(extent.width().value()) * 0.5; 1213 - let cy = f64::from(extent.height().value()) * 0.5; 1214 - let center = ViewportPoint::new(cx, cy).ok()?; 1215 - let pan_to = |dx: f64, dy: f64| { 1216 - ViewportPoint::new(cx + dx, cy + dy) 1217 - .ok() 1218 - .and_then(|to| pan_pixels(camera, extent, center, to).ok()) 1219 - }; 1220 - let step = Angle::new::<degree>(if shift { 1221 - ORBIT_KEY_SNAP_DEG 1222 - } else { 1223 - ORBIT_KEY_STEP_DEG 1224 - }); 1225 - match code { 1226 - KeyCode::ArrowLeft if ctrl => pan_to(-PAN_STEP_PX, 0.0), 1227 - KeyCode::ArrowRight if ctrl => pan_to(PAN_STEP_PX, 0.0), 1228 - KeyCode::ArrowUp if ctrl => pan_to(0.0, -PAN_STEP_PX), 1229 - KeyCode::ArrowDown if ctrl => pan_to(0.0, PAN_STEP_PX), 1230 - KeyCode::ArrowLeft if alt => roll_by(camera, step).ok(), 1231 - KeyCode::ArrowRight if alt => roll_by(camera, -step).ok(), 1232 - KeyCode::ArrowLeft => orbit_yaw(camera, step).ok(), 1233 - KeyCode::ArrowRight => orbit_yaw(camera, -step).ok(), 1234 - KeyCode::ArrowUp => orbit_pitch(camera, step).ok(), 1235 - KeyCode::ArrowDown => orbit_pitch(camera, -step).ok(), 1236 - KeyCode::KeyZ if shift => zoom_key3(camera, extent, center, 1.0 / ZOOM_KEY_STEP), 1237 - KeyCode::KeyZ | KeyCode::Equal => zoom_key3(camera, extent, center, ZOOM_KEY_STEP), 1238 - KeyCode::Minus => zoom_key3(camera, extent, center, 1.0 / ZOOM_KEY_STEP), 1239 - _ => None, 1240 - } 1241 - } 1242 - 1243 - fn build_hotkey_table() -> HotkeyTable { 1244 - let Ok(table) = hotkeys::compose_table(&hotkeys::HotkeyOverrides::default()) else { 1245 - unreachable!("default hotkey bindings are conflict-free"); 1246 - }; 1247 - table 1248 - } 1249 - 1250 - fn scopes_for_mode(mode: &Mode) -> HotkeyScopes { 1251 - let mut scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]); 1252 - if mode.is_sketch() { 1253 - scopes.push(HotkeyScope::Sketch); 1254 - } 1255 - if mode.is_extrude() { 1256 - scopes.push(HotkeyScope::Extrude); 1257 - } 1258 - scopes 1259 - } 1260 - 1261 - fn next_mode( 1262 - mode: Mode, 1263 - frame: &shell::ShellFrame, 1264 - escape_requested: bool, 1265 - plane_sketches: &BTreeMap<Plane, SketchId>, 1266 - ) -> Mode { 1267 - let after_pick = resolve_pick(mode, frame, plane_sketches); 1268 - let after_escape = if escape_requested { 1269 - cancel_pending_or_exit(after_pick) 1270 - } else { 1271 - after_pick 1272 - }; 1273 - let after_exit = if frame.exit_sketch { 1274 - Mode::Idle 1275 - } else { 1276 - after_escape 1277 - }; 1278 - match frame.activated_tool { 1279 - Some(t) => toggle_or_arm(after_exit, t), 1280 - None => after_exit, 1281 - } 1282 - } 1283 - 1284 - fn resolve_pick( 1285 - mode: Mode, 1286 - frame: &shell::ShellFrame, 1287 - plane_sketches: &BTreeMap<Plane, SketchId>, 1288 - ) -> Mode { 1289 - if mode.is_extrude() { 1290 - return match frame.sketch_activated { 1291 - Some(id) => match &mode { 1292 - Mode::Extrude(ExtrudeArming::Profile { feature, .. }) if feature.sketch == id => { 1293 - mode 1294 - } 1295 - _ => Mode::Extrude(ExtrudeArming::profile(id)), 1296 - }, 1297 - None => mode, 1298 - }; 1299 - } 1300 - let plane_pick = frame 1301 - .plane_picked 1302 - .filter(|_| !mode.is_sketch()) 1303 - .and_then(|plane| plane_sketches.get(&plane).copied()); 1304 - let sketch_pick = frame.sketch_activated.filter(|_| !mode.is_sketch()); 1305 - sketch_pick.or(plane_pick).map_or(mode, Mode::enter_sketch) 1306 - } 1307 - 1308 - fn toggle_or_arm(mode: Mode, tool: SketchTool) -> Mode { 1309 - let already_active = matches!( 1310 - &mode, 1311 - Mode::Sketch { session, .. } if session.tool == Some(tool) 1312 - ); 1313 - if already_active { 1314 - mode.disarm_tool() 1315 - } else { 1316 - mode.arm_tool(tool) 1317 - } 1318 - } 1319 - 1320 - fn cancel_pending_or_exit(mode: Mode) -> Mode { 1321 - match &mode { 1322 - Mode::Sketch { session, .. } if session.pending.is_some() => mode.clear_pending(), 1323 - Mode::Sketch { session, .. } if session.tool.is_some() => mode.disarm_tool(), 1324 - Mode::Sketch { .. } | Mode::Idle | Mode::Extrude(_) => Mode::Idle, 1325 - } 85 + running: Option<Running>, 1326 86 } 1327 87 1328 88 fn create_surface( ··· 1337 97 return None; 1338 98 } 1339 99 }; 1340 - let extent = viewport_extent(window.inner_size()); 100 + let extent = event::viewport_extent(window.inner_size()); 1341 101 let surface = match pollster::block_on(SurfaceContext::new(window.clone(), extent)) { 1342 102 Ok(s) => s, 1343 103 Err(e) => { ··· 1351 111 1352 112 impl ApplicationHandler for App { 1353 113 fn resumed(&mut self, event_loop: &ActiveEventLoop) { 1354 - if self.redraw.is_some() { 114 + if self.running.is_some() { 1355 115 return; 1356 116 } 1357 117 let Some((window, surface, extent)) = create_surface(event_loop) else { 1358 118 return; 1359 119 }; 1360 - let renderer = SketchRenderer::new(surface.gpu(), surface.color_format()); 1361 - let solid_renderer = SolidRenderer::new(surface.gpu(), surface.color_format()); 1362 - let chrome_pipeline = ChromePipeline::new(surface.gpu(), surface.color_format()); 1363 - let convex_pipeline = ConvexPolyPipeline::new(surface.gpu(), surface.color_format()); 1364 - let stroke_pipeline = StrokePipeline::new(surface.gpu(), surface.color_format()); 1365 - let sdf_atlas = MaskAtlas::new(MaskAtlasParams::STANDARD); 1366 - let text_pipeline = 1367 - ChromeTextPipeline::new(surface.gpu(), surface.color_format(), sdf_atlas.extent()); 1368 - let chrome_shaper = Shaper::new(); 1369 - let sans_font = bone_text::load_font(bone_text::FontFace::Sans); 1370 - let mono_font = bone_text::load_font(bone_text::FontFace::Mono); 1371 - let sketch = default_sketch(); 1372 - let scene = match SketchScene::extract(&sketch) { 1373 - Ok(s) => s, 120 + let core = match AppCore::new(surface.gpu(), surface.color_format(), extent) { 121 + Ok(core) => core, 1374 122 Err(e) => { 1375 - tracing::error!(error = %e, "scene extract failed"); 123 + tracing::error!(error = %e, "app core init failed"); 1376 124 event_loop.exit(); 1377 125 return; 1378 126 } 1379 127 }; 1380 - let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(INITIAL_ZOOM_PX_PER_MM)); 1381 - let style = Style::default(); 1382 - let theme = Arc::new(Theme::light()); 1383 - let shell = shell::Shell::new(); 1384 - let (document, sketch_id) = initial_document(sketch); 1385 - let last_saved_baseline = document.clone(); 1386 - let plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); 1387 - let strings = strings::make_strings(bone_ui::strings::Locale::EnUs); 1388 - let viewport_rect = empty_rect(); 1389 - let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { 1390 - unreachable!("UNDO_CAPACITY constant is non-zero"); 1391 - }; 1392 - let loaded_settings = settings::load(); 1393 - let initial_hotkeys = match hotkeys::compose_table(&loaded_settings.hotkey_overrides) { 1394 - Ok(table) => table, 1395 - Err(e) => { 1396 - tracing::warn!(error = %e, "stored hotkey overrides conflict, using defaults"); 1397 - build_hotkey_table() 1398 - } 1399 - }; 1400 128 window.request_redraw(); 1401 - self.redraw = Some(redraw::Scheduler::new(window)); 1402 - self.render = Some(RenderState { 1403 - surface, 1404 - renderer, 1405 - chrome_pipeline, 1406 - convex_pipeline, 1407 - stroke_pipeline, 1408 - text_pipeline, 1409 - sdf_atlas, 1410 - chrome_shaper, 1411 - sans_font, 1412 - mono_font, 1413 - scene, 1414 - camera, 1415 - style, 1416 - theme, 1417 - shell, 1418 - document, 1419 - plane_sketches, 1420 - mode: Mode::Idle, 1421 - feature_cache: FeatureCache::new(), 1422 - extrude_preview: None, 1423 - solid_renderer, 1424 - solid_view: None, 1425 - camera3: None, 1426 - framed_extrude: None, 1427 - navigator: ViewportNavigator::new(), 1428 - view: view_cube::ViewUi::default(), 1429 - focus: FocusManager::new(), 1430 - hit_state: HitState::new(), 1431 - hotkeys: initial_hotkeys, 1432 - strings, 1433 - viewport_rect, 1434 - undo: UndoStack::with_capacity(undo_capacity), 1435 - selection: Selection::default(), 1436 - settings: loaded_settings, 1437 - dim_editor: DimensionEditorState::default(), 1438 - dim_editor_bounds: None, 1439 - pending_exit: false, 1440 - current_folder: None, 1441 - documents_root: file_menu::documents_root(), 1442 - file_picker: None, 1443 - native_picker: None, 1444 - step_job: None, 1445 - pending_overwrite: None, 1446 - last_saved: Some(last_saved_baseline), 1447 - pending_discard: None, 1448 - notification: None, 1449 - shortcut_bar: None, 129 + self.running = Some(Running { 130 + scheduler: redraw::Scheduler::new(Arc::clone(&window)), 131 + surface: WindowSurface { surface, window }, 132 + core, 133 + epoch: std::time::Instant::now(), 1450 134 }); 1451 135 } 1452 136 1453 137 fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { 1454 138 match event::AppEvent::from_winit(event) { 1455 139 event::AppEvent::Input(input) => { 1456 - let ack = self.dispatch_input(event_loop, input); 1457 - if let Some(scheduler) = self.redraw.as_mut() { 1458 - scheduler.schedule(ack); 140 + let Some(running) = self.running.as_mut() else { 141 + return; 142 + }; 143 + if let InputEvent::Resize(extent) = &input { 144 + running.surface.surface.resize(*extent); 1459 145 } 146 + let ack = running.core.handle_input(&running.surface, input); 147 + running.scheduler.schedule(ack); 1460 148 } 1461 149 event::AppEvent::Redraw => self.dispatch_redraw(event_loop), 1462 150 event::AppEvent::Close => event_loop.exit(), ··· 1466 154 1467 155 fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: winit::event::StartCause) { 1468 156 if matches!(cause, winit::event::StartCause::ResumeTimeReached { .. }) 1469 - && let Some(scheduler) = self.redraw.as_mut() 157 + && let Some(running) = self.running.as_mut() 1470 158 { 1471 - scheduler.kick(); 1472 - scheduler.window().request_redraw(); 159 + running.scheduler.kick(); 160 + running.scheduler.window().request_redraw(); 1473 161 } 1474 162 } 1475 163 } 1476 164 1477 165 impl App { 1478 - fn dispatch_input( 1479 - &mut self, 1480 - event_loop: &ActiveEventLoop, 1481 - input: event::InputEvent, 1482 - ) -> redraw::InputDispatched { 1483 - let ack = redraw::InputDispatched::after_input(); 1484 - let Some(state) = self.render.as_mut() else { 1485 - return ack; 1486 - }; 1487 - match input { 1488 - event::InputEvent::Resize(size) => { 1489 - let extent = viewport_extent(size); 1490 - state.surface.resize(extent); 1491 - state.camera = state.camera.with_extent(extent); 1492 - state.viewport_rect = empty_rect(); 1493 - } 1494 - event::InputEvent::Focus(focused) => { 1495 - if !focused { 1496 - self.input.forget_pan_state(); 1497 - state.navigator.end_drag(); 1498 - } 1499 - } 1500 - event::InputEvent::Modifier(mods) => { 1501 - self.input.modifiers = mods.state(); 1502 - } 1503 - event::InputEvent::CursorMove(position) => { 1504 - let prev = self.input.cursor_px; 1505 - self.input.cursor_px = Some(position); 1506 - let modal = modal_active(state); 1507 - if !modal 1508 - && state.navigator.is_dragging() 1509 - && let Some(camera) = state.camera3 1510 - && let Some(region) = 1511 - solid_viewport_region(state.viewport_rect, state.surface.extent()) 1512 - && let Some(cursor) = viewport_local_point(position, region) 1513 - && let Ok(next) = state.navigator.drag_to(cursor, camera, region.extent()) 1514 - { 1515 - apply_nav_camera(state, next); 1516 - } else if !modal 1517 - && self.input.panning() 1518 - && let Some(p) = prev 1519 - { 1520 - state.camera = pan_by_px(state.camera, position.x - p.x, position.y - p.y); 1521 - } else if !modal 1522 - && dragging_in_sketch(&state.mode) 1523 - && let Some(world) = cursor_to_world(state.camera, position) 1524 - { 1525 - try_drag_to(state, world); 1526 - } 1527 - } 1528 - event::InputEvent::CursorLeft => { 1529 - self.input.cursor_px = None; 1530 - } 1531 - event::InputEvent::CursorEntered => {} 1532 - event::InputEvent::Pointer { 1533 - button, 1534 - state: btn_state, 1535 - } => { 1536 - self.dispatch_pointer_button(button, btn_state); 1537 - } 1538 - event::InputEvent::Wheel(delta) => { 1539 - self.dispatch_wheel(delta); 1540 - } 1541 - event::InputEvent::KeyDown { 1542 - physical_key, 1543 - logical_key, 1544 - text, 1545 - repeat, 1546 - } => { 1547 - self.dispatch_keydown( 1548 - event_loop, 1549 - physical_key, 1550 - &logical_key, 1551 - text.as_ref(), 1552 - repeat, 1553 - ); 1554 - } 1555 - } 1556 - ack 1557 - } 1558 - 1559 - fn dispatch_wheel(&mut self, delta: MouseScrollDelta) { 1560 - let Some(state) = self.render.as_mut() else { 1561 - return; 1562 - }; 1563 - if modal_active(state) || !self.input.cursor_in(state.viewport_rect) { 1564 - return; 1565 - } 1566 - if state.solid_view.is_none() { 1567 - state.camera = zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); 1568 - return; 1569 - } 1570 - let (Some(camera), Some(region)) = ( 1571 - state.camera3, 1572 - solid_viewport_region(state.viewport_rect, state.surface.extent()), 1573 - ) else { 1574 - return; 1575 - }; 1576 - match delta { 1577 - MouseScrollDelta::PixelDelta(p) => { 1578 - if let Ok(next) = state 1579 - .navigator 1580 - .orbit_pixels(camera, region.extent(), p.x, p.y) 1581 - { 1582 - apply_nav_camera(state, next); 1583 - } 1584 - } 1585 - MouseScrollDelta::LineDelta(..) => { 1586 - if let Some(cursor) = self 1587 - .input 1588 - .cursor_px 1589 - .and_then(|p| viewport_local_point(p, region)) 1590 - && let Ok(factor) = ZoomFactor::new(zoom_factor(delta)) 1591 - && let Ok(next) = zoom_about_pixel(camera, region.extent(), cursor, factor) 1592 - { 1593 - apply_nav_camera(state, next); 1594 - } 1595 - } 1596 - } 1597 - } 1598 - 1599 - fn dispatch_pointer_button(&mut self, button: MouseButton, btn_state: ElementState) { 1600 - let Some(state) = self.render.as_mut() else { 1601 - return; 1602 - }; 1603 - let modal = modal_active(state); 1604 - match button { 1605 - MouseButton::Left => { 1606 - if btn_state == ElementState::Pressed { 1607 - let in_viewport = self.input.cursor_in(state.viewport_rect); 1608 - let over_dim_editor = state 1609 - .dim_editor_bounds 1610 - .is_some_and(|r| self.input.cursor_in(r)); 1611 - let dim_active = dim_flow_active(&state.mode); 1612 - self.input.left_pan = !modal && in_viewport && !over_dim_editor && !dim_active; 1613 - self.input.pending_pressed = 1614 - self.input.pending_pressed.with(PointerButton::Primary); 1615 - if !modal 1616 - && in_viewport 1617 - && !over_dim_editor 1618 - && !dim_active 1619 - && !self.input.modifiers.shift_key() 1620 - && let Some(cursor) = self.input.cursor_px 1621 - { 1622 - if state.mode.active_tool().is_some() { 1623 - if let Some(world) = cursor_to_world(state.camera, cursor) { 1624 - try_place(state, world); 1625 - } 1626 - } else { 1627 - let additive = self.input.modifiers.control_key() 1628 - || self.input.modifiers.super_key(); 1629 - handle_viewport_click(state, cursor, additive); 1630 - } 1631 - } 1632 - } else { 1633 - self.input.left_pan = false; 1634 - state.mode = core::mem::take(&mut state.mode).end_drag(); 1635 - self.input.pending_released = 1636 - self.input.pending_released.with(PointerButton::Primary); 1637 - } 1638 - } 1639 - MouseButton::Right => { 1640 - if btn_state == ElementState::Pressed { 1641 - self.input.pending_pressed = 1642 - self.input.pending_pressed.with(PointerButton::Secondary); 1643 - if !modal && self.input.cursor_in(state.viewport_rect) { 1644 - state.mode = core::mem::take(&mut state.mode).clear_pending(); 1645 - } 1646 - } else { 1647 - self.input.pending_released = 1648 - self.input.pending_released.with(PointerButton::Secondary); 1649 - } 1650 - } 1651 - MouseButton::Middle => { 1652 - if btn_state == ElementState::Pressed { 1653 - let in_viewport = !modal && self.input.cursor_in(state.viewport_rect); 1654 - if in_viewport 1655 - && state.solid_view.is_some() 1656 - && let Some(region) = 1657 - solid_viewport_region(state.viewport_rect, state.surface.extent()) 1658 - && let Some(cursor) = self 1659 - .input 1660 - .cursor_px 1661 - .and_then(|p| viewport_local_point(p, region)) 1662 - { 1663 - state 1664 - .navigator 1665 - .begin_drag(drag_gesture(self.input.modifiers), cursor); 1666 - } else { 1667 - self.input.middle_pan = in_viewport; 1668 - } 1669 - self.input.pending_pressed = 1670 - self.input.pending_pressed.with(PointerButton::Middle); 1671 - } else { 1672 - self.input.middle_pan = false; 1673 - state.navigator.end_drag(); 1674 - self.input.pending_released = 1675 - self.input.pending_released.with(PointerButton::Middle); 1676 - } 1677 - } 1678 - MouseButton::Back | MouseButton::Forward | MouseButton::Other(_) => {} 1679 - } 1680 - } 1681 - 1682 - fn dispatch_keydown( 1683 - &mut self, 1684 - event_loop: &ActiveEventLoop, 1685 - physical_key: PhysicalKey, 1686 - logical_key: &Key, 1687 - text: Option<&winit::keyboard::SmolStr>, 1688 - repeat: bool, 1689 - ) { 1690 - let Some(state) = self.render.as_mut() else { 1691 - return; 1692 - }; 1693 - let physical_code = match physical_key { 1694 - PhysicalKey::Code(c) => Some(c), 1695 - PhysicalKey::Unidentified(_) => None, 1696 - }; 1697 - let logical_named = match logical_key { 1698 - Key::Named(nk) => winit_named_to_ui(*nk), 1699 - _ => None, 1700 - }; 1701 - let physical_named = physical_code.and_then(keycode_to_named); 1702 - let named = logical_named.or(physical_named); 1703 - let mods = self.input.modifier_mask(); 1704 - let repeat_space = repeat && named == Some(NamedKey::Space); 1705 - if let Some(named) = named { 1706 - if !repeat_space { 1707 - self.input 1708 - .pending_keys 1709 - .push(UiKeyEvent::new(UiKeyCode::Named(named), mods)); 1710 - } 1711 - } else if let Some(c) = physical_code.and_then(keycode_to_char) { 1712 - self.input.pending_keys.push(UiKeyEvent::new( 1713 - UiKeyCode::Char(KeyChar::from_char(c)), 1714 - mods, 1715 - )); 1716 - } 1717 - if let Some(typed) = text { 1718 - let filtered: String = typed.chars().filter(|c| !c.is_control()).collect(); 1719 - if !filtered.is_empty() { 1720 - self.input.pending_text.push_str(&filtered); 1721 - } 1722 - } 1723 - let suppress_camera = dim_flow_active(&state.mode) || state.focus.focused().is_some(); 1724 - if matches!(named, Some(NamedKey::Escape)) && state.notification.is_some() { 1725 - state.notification = None; 1726 - } 1727 - if let Some(code) = physical_code 1728 - && !suppress_camera 1729 - { 1730 - if state.solid_view.is_some() { 1731 - if let Some(next) = keyboard_camera3(code, &self.input, state) { 1732 - apply_nav_camera(state, next); 1733 - } 1734 - } else if let Some(next) = keyboard_camera(code, &self.input, state) { 1735 - state.camera = next; 1736 - } 1737 - } 1738 - let _ = event_loop; 1739 - } 1740 - 1741 166 fn dispatch_redraw(&mut self, event_loop: &ActiveEventLoop) { 1742 - let (Some(state), Some(scheduler)) = (self.render.as_mut(), self.redraw.as_mut()) else { 167 + let Some(running) = self.running.as_mut() else { 1743 168 return; 1744 169 }; 1745 - render_frame(state, scheduler, &mut self.input); 1746 - if state.pending_exit { 170 + running 171 + .core 172 + .clock_mut() 173 + .feed(FrameInstant::from_duration(running.epoch.elapsed())); 174 + let report = running.core.render_frame(&mut running.surface); 175 + if report.exit { 1747 176 event_loop.exit(); 1748 177 return; 1749 178 } 1750 - if let Some(deadline) = next_wake_deadline(state, &self.input) { 1751 - event_loop.set_control_flow(ControlFlow::WaitUntil(deadline)); 179 + if report.kick { 180 + running.scheduler.kick(); 181 + } 182 + running.scheduler.consume_kick(); 183 + if let Some(deadline) = running.core.next_wake_deadline() { 184 + event_loop 185 + .set_control_flow(ControlFlow::WaitUntil(running.epoch + deadline.duration())); 1752 186 } else { 1753 187 event_loop.set_control_flow(ControlFlow::Wait); 1754 188 } 1755 189 } 1756 190 } 1757 191 1758 - fn next_wake_deadline(state: &RenderState, input: &InputState) -> Option<std::time::Instant> { 1759 - let now = std::time::Instant::now(); 1760 - let native_poll = state 1761 - .native_picker 1762 - .is_some() 1763 - .then(|| now + std::time::Duration::from_millis(40)); 1764 - let step_poll = state 1765 - .step_job 1766 - .is_some() 1767 - .then(|| now + std::time::Duration::from_millis(40)); 1768 - let rename_deadline = state 1769 - .shell 1770 - .state 1771 - .feature_tree 1772 - .pending_rename 1773 - .map(|pending| { 1774 - let window = bone_ui::input::DoubleClickWindow::DEFAULT.duration(); 1775 - let slack = std::time::Duration::from_millis(8); 1776 - input.start + pending.at.duration() + window + slack 1777 - }); 1778 - let tween_tick = state 1779 - .view 1780 - .tween 1781 - .is_some() 1782 - .then(|| now + std::time::Duration::from_millis(8)); 1783 - [native_poll, step_poll, rename_deadline, tween_tick] 1784 - .into_iter() 1785 - .flatten() 1786 - .min() 1787 - } 1788 - 1789 - #[allow( 1790 - clippy::cast_precision_loss, 1791 - reason = "viewport pixel counts at any realistic display size fit f32 mantissa" 1792 - )] 1793 - #[allow( 1794 - clippy::too_many_lines, 1795 - reason = "splitting hides the per-outcome dispatch table" 1796 - )] 1797 - fn render_frame( 1798 - state: &mut RenderState, 1799 - scheduler: &mut redraw::Scheduler, 1800 - input_state: &mut InputState, 1801 - ) { 1802 - poll_native_picker(state); 1803 - poll_step_job(state); 1804 - let extent = state.surface.extent(); 1805 - let layout_size = layout_size_from_extent(extent); 1806 - let theme = Arc::clone(&state.theme); 1807 - let mut input = input_state.drain_snapshot(); 1808 - step_view_tween(state, input.frame); 1809 - let mut hits = HitFrame::new(); 1810 - let mut a11y = AccessTreeBuilder::new(); 1811 - let scopes = scopes_for_mode(&state.mode); 1812 - let chrome_cursor_world = input_state 1813 - .cursor_px 1814 - .filter(|c| state.viewport_rect.contains(physical_to_layout_pos(*c))) 1815 - .and_then(|c| cursor_to_world(state.camera, c)); 1816 - let FrameOutcomes { 1817 - mut frame, 1818 - hotkey_actions, 1819 - dim: dim_outcome, 1820 - dim_conflict: conflict_outcome, 1821 - picker: picker_outcome, 1822 - overwrite: overwrite_outcome, 1823 - discard: discard_outcome, 1824 - step_progress: step_progress_outcome, 1825 - notification: notification_outcome, 1826 - shortcut_bar: shortcut_bar_outcome, 1827 - } = run_frame_ui( 1828 - state, 1829 - theme, 1830 - &mut input, 1831 - &mut hits, 1832 - &mut a11y, 1833 - &scopes, 1834 - layout_size, 1835 - chrome_cursor_world, 1836 - ); 1837 - state.dim_editor_bounds = apply_popup_overlays( 1838 - &mut frame.overlay_paints, 1839 - dim_outcome.as_ref(), 1840 - conflict_outcome.as_ref(), 1841 - picker_outcome.as_ref(), 1842 - overwrite_outcome.as_ref(), 1843 - discard_outcome.as_ref(), 1844 - step_progress_outcome.as_ref(), 1845 - notification_outcome.as_ref(), 1846 - shortcut_bar_outcome.as_ref(), 1847 - ); 1848 - apply_shortcut_bar_outcome(state, shortcut_bar_outcome.as_ref()); 1849 - let picker_kind = state.file_picker.as_ref().map(|s| s.kind); 1850 - if let (Some(cmd), Some(kind)) = (picker_outcome.and_then(|o| o.command), picker_kind) { 1851 - apply_picker_command(state, kind, cmd); 1852 - } 1853 - apply_overwrite_outcome(state, overwrite_outcome); 1854 - apply_discard_outcome(state, discard_outcome); 1855 - apply_step_progress_outcome(state, step_progress_outcome); 1856 - apply_notification_outcome(state, notification_outcome); 1857 - let claimed_pointer = dim_outcome.as_ref().is_some_and(|o| o.claimed_pointer); 1858 - let frame = if claimed_pointer { 1859 - suppress_pointer_activations(frame) 1860 - } else { 1861 - frame 1862 - }; 1863 - state.viewport_rect = frame.viewport_rect; 1864 - apply_resolve_and_kick_redraws(state, scheduler, &hits, &input); 1865 - if let Some(plane) = frame.plane_picked { 1866 - match ( 1867 - state.mode.is_sketch(), 1868 - state.plane_sketches.contains_key(&plane), 1869 - ) { 1870 - (true, _) => { 1871 - tracing::debug!(?plane, "plane pick ignored: already in sketch mode"); 1872 - } 1873 - (false, false) => { 1874 - tracing::debug!(?plane, "plane pick ignored: no sketch on this plane"); 1875 - } 1876 - (false, true) => {} 1877 - } 1878 - } 1879 - let escape_requested = hotkey_actions.contains(&sketch_mode::ESCAPE_ACTION); 1880 - apply_extrude_edit(state, frame.extrude_edit); 1881 - apply_extrude_confirm(state, frame.confirm_action); 1882 - let prev_active_sketch = active_sketch_id(&state.mode, &state.plane_sketches); 1883 - state.mode = next_mode( 1884 - core::mem::take(&mut state.mode), 1885 - &frame, 1886 - escape_requested, 1887 - &state.plane_sketches, 1888 - ); 1889 - apply_feature_tool(state, frame.activated_feature_tool); 1890 - apply_extrude_activation(state, frame.extrude_activated); 1891 - if active_sketch_id(&state.mode, &state.plane_sketches) != prev_active_sketch { 1892 - refresh_active_scene(state); 1893 - } 1894 - apply_dimension_outcome(state, dim_outcome); 1895 - apply_dim_conflict_outcome(state, conflict_outcome); 1896 - apply_dimension_request(state, frame.activated_dimension); 1897 - let dimension_edit = match frame.confirm_action { 1898 - Some(shell::ConfirmAction::Cancel) => None, 1899 - _ => frame.dimension_edit, 1900 - }; 1901 - apply_dimension_edit(state, dimension_edit); 1902 - sync_solid_view(state); 1903 - let solid_region = solid_viewport_region(state.viewport_rect, extent); 1904 - sync_solid_camera(state, solid_region); 1905 - let cursor_layout = input_state.cursor_px.map(physical_to_layout_pos); 1906 - apply_hotkey_actions(state, &hotkey_actions, cursor_layout, input.frame); 1907 - apply_view_pick(state, frame.view_pick, input.frame); 1908 - apply_view_menu(state, frame.view_menu, input.frame); 1909 - apply_menu_action(state, frame.menu_action); 1910 - apply_settings_change(state, frame.settings_change); 1911 - apply_relation_action(state, frame.activated_relation); 1912 - apply_sketch_rename(state, frame.sketch_rename.clone()); 1913 - apply_extrude_rename(state, frame.extrude_rename.clone()); 1914 - let cursor_world = input_state 1915 - .cursor_px 1916 - .filter(|c| state.viewport_rect.contains(physical_to_layout_pos(*c))) 1917 - .and_then(|c| cursor_to_world(state.camera, c)); 1918 - let preview = build_preview(&state.mode, &state.document, cursor_world, &state.camera); 1919 - let main_layer = build_chrome_layer(state, &frame.paints); 1920 - let overlay_layer = build_chrome_layer(state, &frame.overlay_paints); 1921 - let atlas_pixels = state.sdf_atlas.pixels(); 1922 - let atlas_version = state.sdf_atlas.version(); 1923 - let viewport_px = [ 1924 - extent.width().value() as f32, 1925 - extent.height().value() as f32, 1926 - ]; 1927 - let surface = &mut state.surface; 1928 - let renderer = &mut state.renderer; 1929 - let mut chrome_stage = ChromeStage { 1930 - chrome: &mut state.chrome_pipeline, 1931 - convex: &mut state.convex_pipeline, 1932 - stroke: &mut state.stroke_pipeline, 1933 - text: &mut state.text_pipeline, 1934 - atlas_pixels, 1935 - atlas_version, 1936 - viewport_px, 1937 - }; 1938 - let scene = &state.scene; 1939 - let style = &state.style; 1940 - renderer.prepare(scene, style); 1941 - let viewport = ViewportEncode { 1942 - solid: solid_region.and_then(|region| { 1943 - preview_solid_frame(state.solid_view.as_ref(), state.camera3, region) 1944 - }), 1945 - solid_renderer: &state.solid_renderer, 1946 - sketch_renderer: renderer, 1947 - scene, 1948 - preview: &preview, 1949 - camera: state.camera, 1950 - style, 1951 - }; 1952 - let pre_present = || scheduler.window().pre_present_notify(); 1953 - surface.render( 1954 - |encoder, color, pick, depth| { 1955 - viewport.encode(encoder, color, pick, depth); 1956 - chrome_stage.encode_layered(encoder, color, &main_layer, &overlay_layer); 1957 - }, 1958 - pre_present, 1959 - ); 1960 - scheduler.consume_kick(); 1961 - } 1962 - 1963 - struct ViewportEncode<'a> { 1964 - solid: Option<(&'a SolidViewData, SolidFrameView)>, 1965 - solid_renderer: &'a SolidRenderer, 1966 - sketch_renderer: &'a SketchRenderer, 1967 - scene: &'a SketchScene, 1968 - preview: &'a SketchPreview, 1969 - camera: Camera2, 1970 - style: &'a Style, 1971 - } 1972 - 1973 - impl ViewportEncode<'_> { 1974 - fn encode( 1975 - &self, 1976 - encoder: &mut wgpu::CommandEncoder, 1977 - color: &wgpu::TextureView, 1978 - pick: &wgpu::TextureView, 1979 - depth: &wgpu::TextureView, 1980 - ) { 1981 - let targets = RenderTargets::new(color, pick); 1982 - match &self.solid { 1983 - Some((view, frame)) => self.solid_renderer.encode_passes( 1984 - encoder, 1985 - targets, 1986 - depth, 1987 - &view.faces, 1988 - &view.edges, 1989 - &bone_render::SolidDisplay { 1990 - view: frame, 1991 - style: self.style, 1992 - mode: DisplayMode::ShadedWithEdges, 1993 - }, 1994 - ), 1995 - None => self.sketch_renderer.encode_passes( 1996 - encoder, 1997 - targets, 1998 - self.scene, 1999 - self.preview, 2000 - self.camera, 2001 - self.style, 2002 - ), 2003 - } 2004 - } 2005 - } 2006 - 2007 - struct ChromeStage<'a> { 2008 - chrome: &'a mut ChromePipeline, 2009 - convex: &'a mut ConvexPolyPipeline, 2010 - stroke: &'a mut StrokePipeline, 2011 - text: &'a mut ChromeTextPipeline, 2012 - atlas_pixels: &'a [u8], 2013 - atlas_version: u64, 2014 - viewport_px: [f32; 2], 2015 - } 2016 - 2017 - fn merge_layers<T: Copy>(main: &[T], overlay: &[T]) -> Vec<T> { 2018 - main.iter().chain(overlay.iter()).copied().collect() 2019 - } 2020 - 2021 - fn count_u32(len: usize) -> u32 { 2022 - let Ok(count) = u32::try_from(len) else { 2023 - unreachable!("instance counts fit in u32"); 2024 - }; 2025 - count 2026 - } 2027 - 2028 - impl ChromeStage<'_> { 2029 - fn encode_layered( 2030 - &mut self, 2031 - encoder: &mut wgpu::CommandEncoder, 2032 - color: &wgpu::TextureView, 2033 - main: &ChromeLayer, 2034 - overlay: &ChromeLayer, 2035 - ) { 2036 - let chrome = merge_layers(&main.chrome, &overlay.chrome); 2037 - let convex = merge_layers(&main.convex, &overlay.convex); 2038 - let stroke = merge_layers(&main.stroke, &overlay.stroke); 2039 - let glyphs = merge_layers(&main.glyphs, &overlay.glyphs); 2040 - self.chrome.upload(self.viewport_px, &chrome); 2041 - self.convex.upload(self.viewport_px, &convex); 2042 - self.stroke.upload(self.viewport_px, &stroke); 2043 - self.text.upload( 2044 - self.viewport_px, 2045 - self.atlas_pixels, 2046 - self.atlas_version, 2047 - &glyphs, 2048 - ); 2049 - let (mc, tc) = (count_u32(main.chrome.len()), count_u32(chrome.len())); 2050 - let (mv, tv) = (count_u32(main.convex.len()), count_u32(convex.len())); 2051 - let (ms, ts) = (count_u32(main.stroke.len()), count_u32(stroke.len())); 2052 - let (mg, tg) = (count_u32(main.glyphs.len()), count_u32(glyphs.len())); 2053 - self.chrome.draw_range(encoder, color, 0..mc); 2054 - self.convex.draw_range(encoder, color, 0..mv); 2055 - self.stroke.draw_range(encoder, color, 0..ms); 2056 - self.text.draw_range(encoder, color, 0..mg); 2057 - self.chrome.draw_range(encoder, color, mc..tc); 2058 - self.convex.draw_range(encoder, color, mv..tv); 2059 - self.stroke.draw_range(encoder, color, ms..ts); 2060 - self.text.draw_range(encoder, color, mg..tg); 2061 - } 2062 - } 2063 - 2064 - fn apply_resolve_and_kick_redraws( 2065 - state: &mut RenderState, 2066 - scheduler: &mut redraw::Scheduler, 2067 - hits: &HitFrame, 2068 - input: &InputSnapshot, 2069 - ) { 2070 - state.hit_state = resolve(&state.hit_state, hits, input, state.focus.focused()); 2071 - if any_actionable_interaction(&state.hit_state) { 2072 - scheduler.kick(); 2073 - } 2074 - } 2075 - 2076 - fn any_actionable_interaction(hit_state: &HitState) -> bool { 2077 - use bone_ui::hit_test::InteractionState; 2078 - hit_state.interactions.values().any(|i| { 2079 - i.state.contains(InteractionState::CLICK) 2080 - || i.state.contains(InteractionState::DOUBLE_CLICK) 2081 - || i.state.contains(InteractionState::DRAG_START) 2082 - || i.state.contains(InteractionState::DRAG_RELEASE) 2083 - }) 2084 - } 2085 - 2086 - struct ChromeLayer { 2087 - chrome: Vec<ChromeInstance>, 2088 - convex: Vec<ConvexInstance>, 2089 - stroke: Vec<StrokeInstance>, 2090 - glyphs: Vec<SdfGlyphInstance>, 2091 - } 2092 - 2093 - fn build_chrome_layer( 2094 - state: &mut RenderState, 2095 - paints: &[bone_ui::widgets::WidgetPaint], 2096 - ) -> ChromeLayer { 2097 - let chrome = chrome::paint_to_instances(&state.theme, paints); 2098 - let convex = chrome::paint_to_convex_instances(paints); 2099 - let stroke = chrome::paint_to_stroke_instances(paints); 2100 - let spans = chrome::paint_to_text_spans(paints, &state.strings); 2101 - let glyphs = chrome::build_glyph_instances( 2102 - &spans, 2103 - &mut state.sdf_atlas, 2104 - &mut state.chrome_shaper, 2105 - &state.sans_font, 2106 - &state.mono_font, 2107 - ); 2108 - ChromeLayer { 2109 - chrome, 2110 - convex, 2111 - stroke, 2112 - glyphs, 2113 - } 2114 - } 2115 - 2116 - fn apply_hotkey_actions( 2117 - state: &mut RenderState, 2118 - actions: &[ActionId], 2119 - cursor_layout: Option<LayoutPos>, 2120 - now: FrameInstant, 2121 - ) { 2122 - actions 2123 - .iter() 2124 - .filter_map(|a| hotkeys::command_for_action(*a)) 2125 - .for_each(|cmd| dispatch_hotkey_command(state, cmd, cursor_layout, now)); 2126 - } 2127 - 2128 - fn dispatch_hotkey_command( 2129 - state: &mut RenderState, 2130 - cmd: hotkeys::HotkeyCommand, 2131 - cursor_layout: Option<LayoutPos>, 2132 - now: FrameInstant, 2133 - ) { 2134 - use hotkeys::HotkeyCommand as C; 2135 - match cmd { 2136 - C::Undo => { 2137 - if state.undo.undo(&mut state.document) { 2138 - refresh_active_scene(state); 2139 - } 2140 - } 2141 - C::Redo => { 2142 - if state.undo.redo(&mut state.document) { 2143 - refresh_active_scene(state); 2144 - } 2145 - } 2146 - C::NewDocument => apply_menu_action(state, Some(shell::MenuAction::NewDocument)), 2147 - C::OpenDocument => apply_menu_action(state, Some(shell::MenuAction::OpenDocument)), 2148 - C::SaveDocument => apply_menu_action(state, Some(shell::MenuAction::SaveDocument)), 2149 - C::ImportStep => apply_menu_action(state, Some(shell::MenuAction::ImportStep)), 2150 - C::ExportStep => apply_menu_action(state, Some(shell::MenuAction::ExportStep)), 2151 - C::ZoomFit => apply_menu_action(state, Some(shell::MenuAction::ZoomFit)), 2152 - C::Quit => { 2153 - state.pending_exit = true; 2154 - } 2155 - C::OpenShortcutBar => { 2156 - if state.shortcut_bar.is_none() { 2157 - let anchor = cursor_layout.unwrap_or(LayoutPos::ORIGIN); 2158 - state.shortcut_bar = Some(shortcut_bar::ShortcutBarState { anchor }); 2159 - } 2160 - } 2161 - C::ToggleConstruction => apply_construction_toggle(state), 2162 - C::Mirror => apply_mirror(state), 2163 - C::StandardView(view) => { 2164 - if view_nav_enabled(state) { 2165 - apply_view_pick(state, Some(view_cube::ViewPick::Standard(view)), now); 2166 - } 2167 - } 2168 - C::ToggleViewSelector => { 2169 - if view_nav_enabled(state) { 2170 - state.view.toggle_selector(); 2171 - } 2172 - } 2173 - C::ToggleViewCube => { 2174 - if view_nav_enabled(state) { 2175 - state.view.toggle_cube(); 2176 - } 2177 - } 2178 - C::SelectAll 2179 - | C::DeleteSelection 2180 - | C::EnterSketch 2181 - | C::SmartDimension 2182 - | C::Trim 2183 - | C::Extend => notify_stub(state, hotkeys::label_for_command(cmd)), 2184 - } 2185 - } 2186 - 2187 - fn apply_construction_toggle(state: &mut RenderState) { 2188 - let Mode::Sketch { sketch_id, .. } = state.mode else { 2189 - return; 2190 - }; 2191 - let entity_ids: Vec<bone_types::SketchEntityId> = state.selection.entity_ids().to_vec(); 2192 - if entity_ids.is_empty() { 2193 - return; 2194 - } 2195 - let Some(sketch) = state.document.sketch(sketch_id) else { 2196 - return; 2197 - }; 2198 - let pivot = entity_ids 2199 - .iter() 2200 - .find_map(|id| match sketch.entities().get(*id)? { 2201 - SketchEntity::Line(l) => Some(l.for_construction()), 2202 - SketchEntity::Arc(a) => Some(a.for_construction()), 2203 - SketchEntity::Circle(c) => Some(c.for_construction()), 2204 - SketchEntity::Point(_) => None, 2205 - }); 2206 - let Some(current) = pivot else { 2207 - return; 2208 - }; 2209 - let target = !current; 2210 - let snapshot = state.document.clone(); 2211 - let result = 2212 - entity_ids 2213 - .iter() 2214 - .try_fold(sketch.clone(), |acc, id| match acc.entities().get(*id) { 2215 - Some(SketchEntity::Point(_)) | None => Ok(acc), 2216 - Some(_) => acc 2217 - .apply(bone_document::SketchEdit::SetConstruction { 2218 - id: *id, 2219 - for_construction: target, 2220 - }) 2221 - .map(|(s, _)| s), 2222 - }); 2223 - match result { 2224 - Ok(next) => { 2225 - state.undo.record(snapshot); 2226 - state.document.replace_sketch(sketch_id, next); 2227 - refresh_active_scene(state); 2228 - } 2229 - Err(e) => tracing::warn!(error = %e, "construction toggle failed"), 2230 - } 2231 - } 2232 - 2233 - fn apply_mirror(state: &mut RenderState) { 2234 - let Mode::Sketch { sketch_id, .. } = state.mode else { 2235 - return; 2236 - }; 2237 - let entity_ids: Vec<bone_types::SketchEntityId> = state.selection.entity_ids().to_vec(); 2238 - if entity_ids.is_empty() { 2239 - return; 2240 - } 2241 - let Some(sketch) = state.document.sketch(sketch_id) else { 2242 - return; 2243 - }; 2244 - let axis_lines: Vec<(bone_types::SketchEntityId, LineData)> = entity_ids 2245 - .iter() 2246 - .filter_map(|id| match sketch.entities().get(*id)? { 2247 - SketchEntity::Line(l) => Some((*id, *l)), 2248 - _ => None, 2249 - }) 2250 - .collect(); 2251 - let [(axis_id, axis_line)] = axis_lines.as_slice() else { 2252 - notify_mirror_hint(state); 2253 - return; 2254 - }; 2255 - let axis_id = *axis_id; 2256 - let Some(pa) = lookup_point(sketch, axis_line.a()) else { 2257 - return; 2258 - }; 2259 - let Some(pb) = lookup_point(sketch, axis_line.b()) else { 2260 - return; 2261 - }; 2262 - let axis = MirrorAxis::from_points(pa, pb); 2263 - if axis.is_degenerate() { 2264 - notify_mirror_hint(state); 2265 - return; 2266 - } 2267 - let source_ids: std::collections::BTreeSet<bone_types::SketchEntityId> = entity_ids 2268 - .iter() 2269 - .copied() 2270 - .filter(|id| *id != axis_id) 2271 - .collect(); 2272 - if source_ids.is_empty() { 2273 - notify_mirror_hint(state); 2274 - return; 2275 - } 2276 - let snapshot = state.document.clone(); 2277 - let result = mirror_targets(sketch.clone(), &source_ids, axis_id, &axis); 2278 - match result { 2279 - Ok(next) => { 2280 - state.undo.record(snapshot); 2281 - state.document.replace_sketch(sketch_id, next); 2282 - state.selection = Selection::default(); 2283 - refresh_active_scene(state); 2284 - } 2285 - Err(e) => tracing::warn!(error = %e, "mirror failed"), 2286 - } 2287 - } 2288 - 2289 - fn notify_mirror_hint(state: &mut RenderState) { 2290 - state.notification = Some(Notification { 2291 - kind: NotificationKind::Info, 2292 - headline: strings::HOTKEY_LABEL_MIRROR, 2293 - detail: Some( 2294 - state 2295 - .strings 2296 - .resolve(strings::NOTIFY_MIRROR_SELECTION_HINT) 2297 - .to_owned(), 2298 - ), 2299 - }); 2300 - } 2301 - 2302 - #[derive(Copy, Clone, Debug)] 2303 - struct MirrorAxis { 2304 - anchor_x: f64, 2305 - anchor_y: f64, 2306 - direction_x: f64, 2307 - direction_y: f64, 2308 - length_sq: f64, 2309 - } 2310 - 2311 - impl MirrorAxis { 2312 - fn from_points(a: Point2, b: Point2) -> Self { 2313 - let (ax, ay) = a.coords_mm(); 2314 - let (bx, by) = b.coords_mm(); 2315 - let dx = bx - ax; 2316 - let dy = by - ay; 2317 - Self { 2318 - anchor_x: ax, 2319 - anchor_y: ay, 2320 - direction_x: dx, 2321 - direction_y: dy, 2322 - length_sq: dx * dx + dy * dy, 2323 - } 2324 - } 2325 - 2326 - fn is_degenerate(self) -> bool { 2327 - !self.length_sq.is_finite() || self.length_sq <= f64::EPSILON 2328 - } 2329 - 2330 - fn reflect(self, p: Point2) -> Point2 { 2331 - let (px, py) = p.coords_mm(); 2332 - let vx = px - self.anchor_x; 2333 - let vy = py - self.anchor_y; 2334 - let t = (vx * self.direction_x + vy * self.direction_y) / self.length_sq; 2335 - let foot_x = self.anchor_x + t * self.direction_x; 2336 - let foot_y = self.anchor_y + t * self.direction_y; 2337 - Point2::from_mm(2.0 * foot_x - px, 2.0 * foot_y - py) 2338 - } 2339 - 2340 - fn is_on_axis(self, p: Point2) -> bool { 2341 - let (px, py) = p.coords_mm(); 2342 - let vx = px - self.anchor_x; 2343 - let vy = py - self.anchor_y; 2344 - let cross = vx * self.direction_y - vy * self.direction_x; 2345 - let perp_dist_sq = cross * cross / self.length_sq; 2346 - perp_dist_sq < ON_AXIS_TOLERANCE_MM_SQ 2347 - } 2348 - } 2349 - 2350 - const ON_AXIS_TOLERANCE_MM_SQ: f64 = 1e-12; 2351 - 2352 - struct MirrorBuilder { 2353 - sketch: Sketch, 2354 - point_map: std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 2355 - entity_map: std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 2356 - } 2357 - 2358 - impl MirrorBuilder { 2359 - fn new(sketch: Sketch) -> Self { 2360 - Self { 2361 - sketch, 2362 - point_map: std::collections::BTreeMap::new(), 2363 - entity_map: std::collections::BTreeMap::new(), 2364 - } 2365 - } 2366 - 2367 - fn mirror_point( 2368 - &mut self, 2369 - id: bone_types::SketchEntityId, 2370 - axis: &MirrorAxis, 2371 - ) -> Result<bone_types::SketchEntityId, bone_document::SketchEditError> { 2372 - if let Some(&existing) = self.point_map.get(&id) { 2373 - return Ok(existing); 2374 - } 2375 - let pos = require_point(&self.sketch, id)?; 2376 - if axis.is_on_axis(pos) { 2377 - self.point_map.insert(id, id); 2378 - self.entity_map.insert(id, id); 2379 - return Ok(id); 2380 - } 2381 - let (next, new_id) = add_point(self.sketch.clone(), axis.reflect(pos))?; 2382 - self.sketch = next; 2383 - self.point_map.insert(id, new_id); 2384 - self.entity_map.insert(id, new_id); 2385 - Ok(new_id) 2386 - } 2387 - 2388 - fn mirror_entity( 2389 - &mut self, 2390 - id: bone_types::SketchEntityId, 2391 - axis: &MirrorAxis, 2392 - ) -> Result<(), bone_document::SketchEditError> { 2393 - if self.entity_map.contains_key(&id) { 2394 - return Ok(()); 2395 - } 2396 - let entity = self 2397 - .sketch 2398 - .entities() 2399 - .get(id) 2400 - .copied() 2401 - .ok_or(bone_document::SketchEditError::EntityNotFound(id))?; 2402 - match entity { 2403 - SketchEntity::Point(_) => { 2404 - self.mirror_point(id, axis)?; 2405 - } 2406 - SketchEntity::Line(l) => { 2407 - let new_a = self.mirror_point(l.a(), axis)?; 2408 - let new_b = self.mirror_point(l.b(), axis)?; 2409 - let (next, outcome) = 2410 - self.sketch 2411 - .clone() 2412 - .apply(bone_document::SketchEdit::AddEntity(SketchEntity::line( 2413 - new_a, 2414 - new_b, 2415 - l.for_construction(), 2416 - )))?; 2417 - let EditOutcome::Entity(new_id) = outcome else { 2418 - unreachable!("AddEntity yields Entity outcome") 2419 - }; 2420 - self.sketch = next; 2421 - self.entity_map.insert(id, new_id); 2422 - } 2423 - SketchEntity::Arc(a) => { 2424 - let new_center = self.mirror_point(a.center(), axis)?; 2425 - let new_start = self.mirror_point(a.start(), axis)?; 2426 - let new_end = self.mirror_point(a.end(), axis)?; 2427 - let (next, outcome) = 2428 - self.sketch 2429 - .clone() 2430 - .apply(bone_document::SketchEdit::AddEntity(SketchEntity::arc( 2431 - new_center, 2432 - new_end, 2433 - new_start, 2434 - a.for_construction(), 2435 - )))?; 2436 - let EditOutcome::Entity(new_id) = outcome else { 2437 - unreachable!("AddEntity yields Entity outcome") 2438 - }; 2439 - self.sketch = next; 2440 - self.entity_map.insert(id, new_id); 2441 - } 2442 - SketchEntity::Circle(c) => { 2443 - let new_center = self.mirror_point(c.center(), axis)?; 2444 - let (next, outcome) = 2445 - self.sketch 2446 - .clone() 2447 - .apply(bone_document::SketchEdit::AddEntity(SketchEntity::circle( 2448 - new_center, 2449 - c.radius(), 2450 - c.for_construction(), 2451 - )))?; 2452 - let EditOutcome::Entity(new_id) = outcome else { 2453 - unreachable!("AddEntity yields Entity outcome") 2454 - }; 2455 - self.sketch = next; 2456 - self.entity_map.insert(id, new_id); 2457 - } 2458 - } 2459 - Ok(()) 2460 - } 2461 - } 2462 - 2463 - fn mirror_targets( 2464 - sketch: Sketch, 2465 - source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>, 2466 - axis_id: bone_types::SketchEntityId, 2467 - axis: &MirrorAxis, 2468 - ) -> Result<Sketch, bone_document::SketchEditError> { 2469 - let mut builder = MirrorBuilder::new(sketch); 2470 - source_ids 2471 - .iter() 2472 - .try_for_each(|id| builder.mirror_entity(*id, axis))?; 2473 - builder.entity_map.insert(axis_id, axis_id); 2474 - builder.sketch = symmetric_relations_for_pairs(builder.sketch, &builder.point_map, axis_id)?; 2475 - builder.sketch = copy_relations(builder.sketch, source_ids, axis_id, &builder.entity_map)?; 2476 - builder.sketch = copy_dimensions(builder.sketch, source_ids, axis_id, &builder.entity_map)?; 2477 - Ok(builder.sketch) 2478 - } 2479 - 2480 - fn symmetric_relations_for_pairs( 2481 - sketch: Sketch, 2482 - point_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 2483 - axis_id: bone_types::SketchEntityId, 2484 - ) -> Result<Sketch, bone_document::SketchEditError> { 2485 - point_map 2486 - .iter() 2487 - .filter(|(source, mirrored)| source != mirrored) 2488 - .try_fold(sketch, |acc, (&source, &mirrored)| { 2489 - let (next, _) = acc.apply(bone_document::SketchEdit::AddRelation( 2490 - SketchRelation::Symmetric { 2491 - a: source, 2492 - b: mirrored, 2493 - axis: axis_id, 2494 - }, 2495 - ))?; 2496 - Ok(next) 2497 - }) 2498 - } 2499 - 2500 - fn copy_relations( 2501 - sketch: Sketch, 2502 - source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>, 2503 - axis_id: bone_types::SketchEntityId, 2504 - entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 2505 - ) -> Result<Sketch, bone_document::SketchEditError> { 2506 - let relations: Vec<SketchRelation> = sketch 2507 - .relations() 2508 - .iter() 2509 - .map(|(_, r)| *r) 2510 - .filter(|r| relation_is_mirrorable(r, source_ids, axis_id)) 2511 - .filter_map(|r| remap_relation(r, entity_map)) 2512 - .collect(); 2513 - relations.into_iter().try_fold(sketch, |acc, rel| { 2514 - match acc 2515 - .clone() 2516 - .apply(bone_document::SketchEdit::AddRelation(rel)) 2517 - { 2518 - Ok((next, _)) => Ok(next), 2519 - Err(e) => { 2520 - tracing::warn!(error = %e, relation = ?rel, "mirror: skipped relation"); 2521 - Ok(acc) 2522 - } 2523 - } 2524 - }) 2525 - } 2526 - 2527 - fn relation_is_mirrorable( 2528 - rel: &SketchRelation, 2529 - source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>, 2530 - axis_id: bone_types::SketchEntityId, 2531 - ) -> bool { 2532 - let refs: Vec<_> = rel.references().into_iter().collect(); 2533 - let touches_source = refs.iter().any(|id| source_ids.contains(id)); 2534 - let all_known = refs 2535 - .iter() 2536 - .all(|id| source_ids.contains(id) || *id == axis_id); 2537 - touches_source && all_known 2538 - } 2539 - 2540 - fn remap_relation( 2541 - rel: SketchRelation, 2542 - entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 2543 - ) -> Option<SketchRelation> { 2544 - let get = |id| entity_map.get(&id).copied(); 2545 - match rel { 2546 - SketchRelation::Coincident(a, b) => Some(SketchRelation::Coincident(get(a)?, get(b)?)), 2547 - SketchRelation::Horizontal(a) => Some(SketchRelation::Horizontal(get(a)?)), 2548 - SketchRelation::Vertical(a) => Some(SketchRelation::Vertical(get(a)?)), 2549 - SketchRelation::Parallel(a, b) => Some(SketchRelation::Parallel(get(a)?, get(b)?)), 2550 - SketchRelation::Perpendicular(a, b) => { 2551 - Some(SketchRelation::Perpendicular(get(a)?, get(b)?)) 2552 - } 2553 - SketchRelation::Tangent(a, b) => Some(SketchRelation::Tangent(get(a)?, get(b)?)), 2554 - SketchRelation::Equal(a, b) => Some(SketchRelation::Equal(get(a)?, get(b)?)), 2555 - SketchRelation::Concentric(a, b) => Some(SketchRelation::Concentric(get(a)?, get(b)?)), 2556 - SketchRelation::Midpoint { point, line } => Some(SketchRelation::Midpoint { 2557 - point: get(point)?, 2558 - line: get(line)?, 2559 - }), 2560 - SketchRelation::Symmetric { a, b, axis } => Some(SketchRelation::Symmetric { 2561 - a: get(a)?, 2562 - b: get(b)?, 2563 - axis: get(axis)?, 2564 - }), 2565 - SketchRelation::Fix(a) => Some(SketchRelation::Fix(get(a)?)), 2566 - } 2567 - } 2568 - 2569 - fn copy_dimensions( 2570 - sketch: Sketch, 2571 - source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>, 2572 - axis_id: bone_types::SketchEntityId, 2573 - entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 2574 - ) -> Result<Sketch, bone_document::SketchEditError> { 2575 - let dims: Vec<SketchDimension> = sketch 2576 - .dimensions() 2577 - .iter() 2578 - .map(|(_, d)| *d) 2579 - .filter(|d| dimension_is_mirrorable(d, source_ids, axis_id)) 2580 - .filter_map(|d| remap_dimension(d, entity_map)) 2581 - .collect(); 2582 - dims.into_iter().try_fold(sketch, |acc, dim| { 2583 - match acc 2584 - .clone() 2585 - .apply(bone_document::SketchEdit::AddDimension(dim)) 2586 - { 2587 - Ok((next, _)) => Ok(next), 2588 - Err(e) => { 2589 - tracing::warn!(error = %e, dimension = ?dim, "mirror: skipped dimension"); 2590 - Ok(acc) 2591 - } 2592 - } 2593 - }) 2594 - } 2595 - 2596 - fn dimension_is_mirrorable( 2597 - dim: &SketchDimension, 2598 - source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>, 2599 - axis_id: bone_types::SketchEntityId, 2600 - ) -> bool { 2601 - let refs: Vec<_> = dim.references().into_iter().collect(); 2602 - let touches_source = refs.iter().any(|id| source_ids.contains(id)); 2603 - let all_known = refs 2604 - .iter() 2605 - .all(|id| source_ids.contains(id) || *id == axis_id); 2606 - touches_source && all_known 2607 - } 2608 - 2609 - fn remap_dimension( 2610 - dim: SketchDimension, 2611 - entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>, 2612 - ) -> Option<SketchDimension> { 2613 - let get = |id| entity_map.get(&id).copied(); 2614 - match dim { 2615 - SketchDimension::Linear { a, b, value, kind } => Some(SketchDimension::Linear { 2616 - a: get(a)?, 2617 - b: get(b)?, 2618 - value, 2619 - kind, 2620 - }), 2621 - SketchDimension::Radius { 2622 - target, 2623 - value, 2624 - kind, 2625 - } => Some(SketchDimension::Radius { 2626 - target: get(target)?, 2627 - value, 2628 - kind, 2629 - }), 2630 - SketchDimension::Diameter { 2631 - target, 2632 - value, 2633 - kind, 2634 - } => Some(SketchDimension::Diameter { 2635 - target: get(target)?, 2636 - value, 2637 - kind, 2638 - }), 2639 - SketchDimension::Angular { a, b, value, kind } => Some(SketchDimension::Angular { 2640 - a: get(a)?, 2641 - b: get(b)?, 2642 - value, 2643 - kind, 2644 - }), 2645 - } 2646 - } 2647 - 2648 - fn add_point( 2649 - sketch: Sketch, 2650 - at: Point2, 2651 - ) -> Result<(Sketch, bone_types::SketchEntityId), bone_document::SketchEditError> { 2652 - let (next, outcome) = sketch.apply(bone_document::SketchEdit::AddEntity( 2653 - SketchEntity::point(at), 2654 - ))?; 2655 - let EditOutcome::Entity(id) = outcome else { 2656 - unreachable!("AddEntity must yield Entity outcome") 2657 - }; 2658 - Ok((next, id)) 2659 - } 2660 - 2661 - fn require_point( 2662 - sketch: &Sketch, 2663 - id: bone_types::SketchEntityId, 2664 - ) -> Result<Point2, bone_document::SketchEditError> { 2665 - match sketch 2666 - .entities() 2667 - .get(id) 2668 - .ok_or(bone_document::SketchEditError::EntityNotFound(id))? 2669 - { 2670 - SketchEntity::Point(p) => Ok(p.at()), 2671 - _ => Err(bone_document::SketchEditError::ExpectedPoint(id)), 2672 - } 2673 - } 2674 - 2675 - fn lookup_point(sketch: &Sketch, id: bone_types::SketchEntityId) -> Option<Point2> { 2676 - match sketch.entities().get(id)? { 2677 - SketchEntity::Point(p) => Some(p.at()), 2678 - _ => None, 2679 - } 2680 - } 2681 - 2682 - fn suppress_pointer_activations(frame: shell::ShellFrame) -> shell::ShellFrame { 2683 - shell::ShellFrame { 2684 - paints: frame.paints, 2685 - overlay_paints: frame.overlay_paints, 2686 - viewport_rect: frame.viewport_rect, 2687 - activated_tool: None, 2688 - activated_feature_tool: None, 2689 - activated_relation: None, 2690 - activated_dimension: None, 2691 - dimension_edit: None, 2692 - extrude_edit: frame.extrude_edit, 2693 - plane_picked: None, 2694 - sketch_activated: None, 2695 - sketch_rename: None, 2696 - extrude_activated: None, 2697 - extrude_rename: None, 2698 - exit_sketch: false, 2699 - confirm_action: None, 2700 - menu_action: None, 2701 - settings_change: None, 2702 - view_pick: None, 2703 - view_menu: None, 2704 - } 2705 - } 2706 - 2707 - #[allow( 2708 - clippy::too_many_arguments, 2709 - reason = "popup overlay dispatch threads every transient surface" 2710 - )] 2711 - fn apply_popup_overlays( 2712 - overlay: &mut Vec<bone_ui::widgets::WidgetPaint>, 2713 - dim_outcome: Option<&DimensionEditorOutcome>, 2714 - conflict_outcome: Option<&DimConflictOutcome>, 2715 - picker_outcome: Option<&file_menu::PickerModalOutcome>, 2716 - overwrite_outcome: Option<&OverwriteOutcome>, 2717 - discard_outcome: Option<&DiscardOutcome>, 2718 - step_progress_outcome: Option<&StepProgressOutcome>, 2719 - notification_outcome: Option<&NotificationOutcome>, 2720 - shortcut_bar_outcome: Option<&shortcut_bar::ShortcutBarOutcome>, 2721 - ) -> Option<LayoutRect> { 2722 - let dim_closing = matches!( 2723 - dim_outcome.map(|o| &o.action), 2724 - Some(DimensionEditorAction::Commit(_) | DimensionEditorAction::Cancel), 2725 - ); 2726 - extend_when_open( 2727 - overlay, 2728 - dim_outcome.map(|o| o.paints.as_slice()), 2729 - dim_closing, 2730 - ); 2731 - let conflict_closing = matches!( 2732 - conflict_outcome.map(|o| o.action), 2733 - Some(DimConflictAction::MakeDriven | DimConflictAction::Cancel), 2734 - ); 2735 - extend_when_open( 2736 - overlay, 2737 - conflict_outcome.map(|o| o.paints.as_slice()), 2738 - conflict_closing, 2739 - ); 2740 - let picker_closing = picker_outcome.and_then(|o| o.command.as_ref()).is_some(); 2741 - extend_when_open( 2742 - overlay, 2743 - picker_outcome.map(|o| o.paints.as_slice()), 2744 - picker_closing, 2745 - ); 2746 - let overwrite_closing = matches!( 2747 - overwrite_outcome.map(|o| o.action), 2748 - Some(OverwriteAction::Replace | OverwriteAction::Cancel), 2749 - ); 2750 - extend_when_open( 2751 - overlay, 2752 - overwrite_outcome.map(|o| o.paints.as_slice()), 2753 - overwrite_closing, 2754 - ); 2755 - let discard_closing = matches!( 2756 - discard_outcome.map(|o| o.action), 2757 - Some(DiscardAction::Confirm | DiscardAction::Cancel), 2758 - ); 2759 - extend_when_open( 2760 - overlay, 2761 - discard_outcome.map(|o| o.paints.as_slice()), 2762 - discard_closing, 2763 - ); 2764 - if let Some(progress) = step_progress_outcome { 2765 - overlay.extend(progress.paints.iter().cloned()); 2766 - } 2767 - if let Some(notification) = notification_outcome { 2768 - overlay.extend(notification.paints.iter().cloned()); 2769 - } 2770 - let bar_closing = shortcut_bar_outcome.is_some_and(|o| o.dismissed || o.activated.is_some()); 2771 - extend_when_open( 2772 - overlay, 2773 - shortcut_bar_outcome.map(|o| o.paints.as_slice()), 2774 - bar_closing, 2775 - ); 2776 - if dim_closing { 2777 - None 2778 - } else { 2779 - dim_outcome.map(|o| o.bounds) 2780 - } 2781 - } 2782 - 2783 - fn extend_when_open( 2784 - overlay: &mut Vec<bone_ui::widgets::WidgetPaint>, 2785 - paints: Option<&[bone_ui::widgets::WidgetPaint]>, 2786 - closing: bool, 2787 - ) { 2788 - if let Some(p) = paints 2789 - && !closing 2790 - { 2791 - overlay.extend(p.iter().cloned()); 2792 - } 2793 - } 2794 - 2795 - struct FrameOutcomes { 2796 - frame: shell::ShellFrame, 2797 - hotkey_actions: Vec<ActionId>, 2798 - dim: Option<DimensionEditorOutcome>, 2799 - dim_conflict: Option<DimConflictOutcome>, 2800 - picker: Option<file_menu::PickerModalOutcome>, 2801 - overwrite: Option<OverwriteOutcome>, 2802 - discard: Option<DiscardOutcome>, 2803 - step_progress: Option<StepProgressOutcome>, 2804 - notification: Option<NotificationOutcome>, 2805 - shortcut_bar: Option<shortcut_bar::ShortcutBarOutcome>, 2806 - } 2807 - 2808 - fn strip_plain_letter_chords(input: &mut InputSnapshot) { 2809 - input.keys_pressed.retain(|event| { 2810 - !matches!(event.code, bone_ui::input::KeyCode::Char(_)) 2811 - || event.modifiers != ModifierMask::NONE 2812 - }); 2813 - } 2814 - 2815 - #[allow( 2816 - clippy::too_many_arguments, 2817 - reason = "run_frame_ui threads every per-frame UI subsystem" 2818 - )] 2819 - fn run_frame_ui( 2820 - state: &mut RenderState, 2821 - theme: Arc<Theme>, 2822 - input: &mut InputSnapshot, 2823 - hits: &mut HitFrame, 2824 - a11y: &mut AccessTreeBuilder, 2825 - scopes: &HotkeyScopes, 2826 - layout_size: LayoutSize, 2827 - cursor_world: Option<Point2>, 2828 - ) -> FrameOutcomes { 2829 - let mut ctx = FrameCtx::new( 2830 - theme, 2831 - input, 2832 - &mut state.focus, 2833 - &state.hotkeys, 2834 - &state.strings, 2835 - hits, 2836 - &state.hit_state, 2837 - a11y, 2838 - &mut state.chrome_shaper, 2839 - ); 2840 - let extrude_status = state.extrude_preview.as_ref().map(ExtrudePreview::status); 2841 - let frame = state.shell.render( 2842 - &mut ctx, 2843 - &state.document, 2844 - &state.mode, 2845 - &state.selection, 2846 - &state.settings, 2847 - layout_size, 2848 - cursor_world, 2849 - state.camera3.filter(|_| state.solid_view.is_some()), 2850 - extrude_status, 2851 - &mut state.view, 2852 - ); 2853 - let dim_outcome = pending_dim(&state.mode).map(|pending| { 2854 - let live_anchor = state 2855 - .mode 2856 - .sketch_id() 2857 - .and_then(|id| state.document.sketch(id)) 2858 - .and_then(|s| smart_dimension::live_anchor(s, pending.proto)) 2859 - .unwrap_or(pending.anchor); 2860 - dimension_editor::render( 2861 - &mut ctx, 2862 - pending, 2863 - live_anchor, 2864 - &state.camera, 2865 - frame.viewport_rect, 2866 - &mut state.dim_editor, 2867 - ) 2868 - }); 2869 - let conflict_outcome = 2870 - dim_conflict_pending(&state.mode).map(|_| render_dim_conflict_modal(&mut ctx, layout_size)); 2871 - let picker_outcome = state 2872 - .file_picker 2873 - .as_mut() 2874 - .map(|session| file_menu::render(&mut ctx, session, layout_size)); 2875 - let overwrite_outcome = state 2876 - .pending_overwrite 2877 - .as_ref() 2878 - .map(|pending| render_overwrite_modal(&mut ctx, layout_size, pending)); 2879 - let discard_outcome = state 2880 - .pending_discard 2881 - .as_ref() 2882 - .map(|pending| render_discard_modal(&mut ctx, layout_size, pending)); 2883 - let reduce_motion = state.settings.reduce_motion; 2884 - let step_progress_outcome = state 2885 - .step_job 2886 - .as_ref() 2887 - .filter(|job| job.meta().show_progress) 2888 - .map(|job| render_step_progress_dialog(&mut ctx, layout_size, job, reduce_motion)); 2889 - let notification_outcome = state 2890 - .notification 2891 - .as_ref() 2892 - .map(|notification| render_notification_toast(&mut ctx, layout_size, notification)); 2893 - let is_sketch = state.mode.is_sketch(); 2894 - let shortcut_bar_outcome = state 2895 - .shortcut_bar 2896 - .map(|bar_state| shortcut_bar::render(&mut ctx, bar_state, layout_size, is_sketch)); 2897 - let any_modal_open = state.shell.state.keyboard_dialog_open 2898 - || conflict_outcome.is_some() 2899 - || dim_outcome.is_some() 2900 - || picker_outcome.is_some() 2901 - || overwrite_outcome.is_some() 2902 - || discard_outcome.is_some() 2903 - || step_progress_outcome.is_some() 2904 - || shortcut_bar_outcome.is_some(); 2905 - if !any_modal_open && ctx.focus.is_text_input_focused() { 2906 - strip_plain_letter_chords(ctx.input); 2907 - } 2908 - let mut actions = if any_modal_open { 2909 - Vec::new() 2910 - } else { 2911 - ctx.dispatch_hotkeys(scopes) 2912 - }; 2913 - if let Some(activated) = shortcut_bar_outcome.as_ref().and_then(|o| o.activated) { 2914 - actions.push(activated); 2915 - } 2916 - FrameOutcomes { 2917 - frame, 2918 - hotkey_actions: actions, 2919 - dim: dim_outcome, 2920 - dim_conflict: conflict_outcome, 2921 - picker: picker_outcome, 2922 - overwrite: overwrite_outcome, 2923 - discard: discard_outcome, 2924 - step_progress: step_progress_outcome, 2925 - notification: notification_outcome, 2926 - shortcut_bar: shortcut_bar_outcome, 2927 - } 2928 - } 2929 - 2930 - fn dim_conflict_pending(mode: &Mode) -> Option<PendingDimension> { 2931 - match mode.dim_flow() { 2932 - Some(DimensionFlow::Conflict(p)) => Some(p), 2933 - Some(DimensionFlow::Editing(_)) | None => None, 2934 - } 2935 - } 2936 - 2937 - #[derive(Clone, Debug, PartialEq)] 2938 - struct DimConflictOutcome { 2939 - paints: Vec<bone_ui::widgets::WidgetPaint>, 2940 - action: DimConflictAction, 2941 - } 2942 - 2943 - #[derive(Copy, Clone, Debug, PartialEq, Eq)] 2944 - enum DimConflictAction { 2945 - Idle, 2946 - MakeDriven, 2947 - Cancel, 2948 - } 2949 - 2950 - fn render_dim_conflict_modal( 2951 - ctx: &mut FrameCtx<'_>, 2952 - layout_size: LayoutSize, 2953 - ) -> DimConflictOutcome { 2954 - use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; 2955 - use bone_ui::{WidgetId, WidgetKey}; 2956 - let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); 2957 - let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(180.0)); 2958 - let id = WidgetId::ROOT.child(WidgetKey::new("dim.conflict")); 2959 - let response = show_confirmation( 2960 - ctx, 2961 - ConfirmationDialog { 2962 - id, 2963 - viewport, 2964 - size: dialog_size, 2965 - title: strings::DIM_CONFLICT_TITLE, 2966 - message: strings::DIM_CONFLICT_MESSAGE, 2967 - confirm_label: strings::DIM_CONFLICT_MAKE_DRIVEN, 2968 - cancel_label: strings::DIM_CONFLICT_CANCEL, 2969 - destructive: false, 2970 - }, 2971 - ); 2972 - let action = match response.outcome { 2973 - Some(ConfirmationOutcome::Confirm) => DimConflictAction::MakeDriven, 2974 - Some(ConfirmationOutcome::Cancel) => DimConflictAction::Cancel, 2975 - None => DimConflictAction::Idle, 2976 - }; 2977 - DimConflictOutcome { 2978 - paints: response.paint, 2979 - action, 2980 - } 2981 - } 2982 - 2983 - #[derive(Clone, Debug, PartialEq)] 2984 - struct OverwriteOutcome { 2985 - paints: Vec<bone_ui::widgets::WidgetPaint>, 2986 - action: OverwriteAction, 2987 - } 2988 - 2989 - #[derive(Copy, Clone, Debug, PartialEq, Eq)] 2990 - enum OverwriteAction { 2991 - Idle, 2992 - Replace, 2993 - Cancel, 2994 - } 2995 - 2996 - fn render_overwrite_modal( 2997 - ctx: &mut FrameCtx<'_>, 2998 - layout_size: LayoutSize, 2999 - pending: &PendingOverwrite, 3000 - ) -> OverwriteOutcome { 3001 - use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; 3002 - use bone_ui::{WidgetId, WidgetKey}; 3003 - let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); 3004 - let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(180.0)); 3005 - let id = WidgetId::ROOT.child(WidgetKey::new("file.overwrite")); 3006 - let (title, message) = match pending { 3007 - PendingOverwrite::Document(_) => ( 3008 - strings::FILE_OVERWRITE_TITLE, 3009 - strings::FILE_OVERWRITE_MESSAGE, 3010 - ), 3011 - PendingOverwrite::StepExport(_) => ( 3012 - strings::FILE_OVERWRITE_TITLE_STEP, 3013 - strings::FILE_OVERWRITE_MESSAGE_STEP, 3014 - ), 3015 - }; 3016 - let response = show_confirmation( 3017 - ctx, 3018 - ConfirmationDialog { 3019 - id, 3020 - viewport, 3021 - size: dialog_size, 3022 - title, 3023 - message, 3024 - confirm_label: strings::FILE_OVERWRITE_REPLACE, 3025 - cancel_label: strings::FILE_OVERWRITE_CANCEL, 3026 - destructive: true, 3027 - }, 3028 - ); 3029 - let action = match response.outcome { 3030 - Some(ConfirmationOutcome::Confirm) => OverwriteAction::Replace, 3031 - Some(ConfirmationOutcome::Cancel) => OverwriteAction::Cancel, 3032 - None => OverwriteAction::Idle, 3033 - }; 3034 - OverwriteOutcome { 3035 - paints: response.paint, 3036 - action, 3037 - } 3038 - } 3039 - 3040 - #[derive(Clone, Debug, PartialEq)] 3041 - struct DiscardOutcome { 3042 - paints: Vec<bone_ui::widgets::WidgetPaint>, 3043 - action: DiscardAction, 3044 - } 3045 - 3046 - #[derive(Copy, Clone, Debug, PartialEq, Eq)] 3047 - enum DiscardAction { 3048 - Idle, 3049 - Confirm, 3050 - Cancel, 3051 - } 3052 - 3053 - fn render_discard_modal( 3054 - ctx: &mut FrameCtx<'_>, 3055 - layout_size: LayoutSize, 3056 - pending: &PendingDiscard, 3057 - ) -> DiscardOutcome { 3058 - use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; 3059 - use bone_ui::{WidgetId, WidgetKey}; 3060 - let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); 3061 - let dialog_size = LayoutSize::new(LayoutPx::new(460.0), LayoutPx::new(190.0)); 3062 - let id = WidgetId::ROOT.child(WidgetKey::new("file.discard")); 3063 - let (title, message, confirm_label, cancel_label) = match pending { 3064 - PendingDiscard::New | PendingDiscard::Open(_) | PendingDiscard::ImportStep(_) => ( 3065 - strings::FILE_DISCARD_TITLE, 3066 - strings::FILE_DISCARD_MESSAGE, 3067 - strings::FILE_DISCARD_CONFIRM, 3068 - strings::FILE_DISCARD_CANCEL, 3069 - ), 3070 - PendingDiscard::InstallImported { .. } => ( 3071 - strings::FILE_IMPORT_REPLACE_TITLE, 3072 - strings::FILE_IMPORT_REPLACE_MESSAGE, 3073 - strings::FILE_IMPORT_REPLACE_CONFIRM, 3074 - strings::FILE_IMPORT_REPLACE_CANCEL, 3075 - ), 3076 - }; 3077 - let response = show_confirmation( 3078 - ctx, 3079 - ConfirmationDialog { 3080 - id, 3081 - viewport, 3082 - size: dialog_size, 3083 - title, 3084 - message, 3085 - confirm_label, 3086 - cancel_label, 3087 - destructive: true, 3088 - }, 3089 - ); 3090 - let action = match response.outcome { 3091 - Some(ConfirmationOutcome::Confirm) => DiscardAction::Confirm, 3092 - Some(ConfirmationOutcome::Cancel) => DiscardAction::Cancel, 3093 - None => DiscardAction::Idle, 3094 - }; 3095 - DiscardOutcome { 3096 - paints: response.paint, 3097 - action, 3098 - } 3099 - } 3100 - 3101 - fn apply_discard_outcome(state: &mut RenderState, outcome: Option<DiscardOutcome>) { 3102 - let Some(outcome) = outcome else { return }; 3103 - match outcome.action { 3104 - DiscardAction::Idle => {} 3105 - DiscardAction::Cancel => { 3106 - state.pending_discard = None; 3107 - } 3108 - DiscardAction::Confirm => { 3109 - let Some(pending) = state.pending_discard.take() else { 3110 - return; 3111 - }; 3112 - match pending { 3113 - PendingDiscard::New => apply_new_document(state), 3114 - PendingDiscard::Open(path) => apply_open_folder(state, path), 3115 - PendingDiscard::ImportStep(path) => start_step_import(state, path), 3116 - PendingDiscard::InstallImported { 3117 - document, 3118 - file_name, 3119 - } => { 3120 - install_imported_document(state, *document); 3121 - notify_info(state, strings::NOTIFY_IMPORTED, Some(file_name)); 3122 - } 3123 - } 3124 - } 3125 - } 3126 - } 3127 - 3128 - #[derive(Clone, Debug, PartialEq)] 3129 - struct StepProgressOutcome { 3130 - paints: Vec<bone_ui::widgets::WidgetPaint>, 3131 - cancel_requested: bool, 3132 - } 3133 - 3134 - fn render_step_progress_dialog( 3135 - ctx: &mut FrameCtx<'_>, 3136 - layout_size: LayoutSize, 3137 - job: &step_jobs::StepJob, 3138 - reduce_motion: bool, 3139 - ) -> StepProgressOutcome { 3140 - use bone_ui::widgets::{Dialog, DialogButton, LabelText, WidgetPaint, show_dialog}; 3141 - use bone_ui::{WidgetId, WidgetKey}; 3142 - let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); 3143 - let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(170.0)); 3144 - let id = WidgetId::ROOT.child(WidgetKey::new("step.progress")); 3145 - let cancel_id = id.child(WidgetKey::new("cancel")); 3146 - let title = match job { 3147 - step_jobs::StepJob::Import { .. } => strings::STEP_PROGRESS_TITLE_IMPORT, 3148 - step_jobs::StepJob::Export { .. } => strings::STEP_PROGRESS_TITLE_EXPORT, 3149 - }; 3150 - let buttons = [DialogButton::secondary( 3151 - cancel_id, 3152 - strings::STEP_PROGRESS_CANCEL, 3153 - )]; 3154 - let sweep = sweep_phase(ctx.input.frame, reduce_motion); 3155 - let file_name = job.meta().file_name.clone(); 3156 - let (response, ()) = show_dialog( 3157 - ctx, 3158 - Dialog::new(id, viewport, dialog_size, title, &buttons), 3159 - |ctx, body_rect, paint| { 3160 - let label_rect = LayoutRect::new( 3161 - LayoutPos::new( 3162 - LayoutPx::new(body_rect.origin.x.value() + 16.0), 3163 - LayoutPx::new(body_rect.origin.y.value() + 12.0), 3164 - ), 3165 - LayoutSize::new( 3166 - LayoutPx::saturating_nonneg(body_rect.size.width.value() - 32.0), 3167 - LayoutPx::new(20.0), 3168 - ), 3169 - ); 3170 - paint.push(WidgetPaint::Label { 3171 - rect: label_rect, 3172 - text: LabelText::Owned(file_name), 3173 - color: ctx.theme().colors.text_secondary(), 3174 - role: ctx.theme().typography.body, 3175 - }); 3176 - push_progress_bar(ctx, body_rect, sweep, paint); 3177 - }, 3178 - ); 3179 - StepProgressOutcome { 3180 - paints: response.paint, 3181 - cancel_requested: response.dismissed || response.activated == Some(cancel_id), 3182 - } 3183 - } 3184 - 3185 - const SWEEP_PERIOD_SECS: f32 = 1.2; 3186 - const SWEEP_SPAN: f32 = 0.3; 3187 - 3188 - fn sweep_phase(now: bone_ui::input::FrameInstant, reduce_motion: bool) -> f32 { 3189 - if reduce_motion { 3190 - return 0.5; 3191 - } 3192 - (now.duration().as_secs_f32() / SWEEP_PERIOD_SECS).fract() 3193 - } 3194 - 3195 - fn push_progress_bar( 3196 - ctx: &FrameCtx<'_>, 3197 - body: LayoutRect, 3198 - sweep: f32, 3199 - paint: &mut Vec<bone_ui::widgets::WidgetPaint>, 3200 - ) { 3201 - use bone_ui::widgets::WidgetPaint; 3202 - let track = LayoutRect::new( 3203 - LayoutPos::new( 3204 - LayoutPx::new(body.origin.x.value() + 16.0), 3205 - LayoutPx::new(body.origin.y.value() + 48.0), 3206 - ), 3207 - LayoutSize::new( 3208 - LayoutPx::saturating_nonneg(body.size.width.value() - 32.0), 3209 - LayoutPx::new(8.0), 3210 - ), 3211 - ); 3212 - paint.push(WidgetPaint::Surface { 3213 - rect: track, 3214 - fill: ctx.theme().colors.surface(bone_ui::theme::SurfaceLevel::L0), 3215 - border: Some(bone_ui::theme::Border { 3216 - width: bone_ui::theme::StrokeWidth::HAIRLINE, 3217 - color: ctx 3218 - .theme() 3219 - .colors 3220 - .neutral 3221 - .step(bone_ui::theme::Step12::SUBTLE_BORDER), 3222 - }), 3223 - radius: ctx.theme().radius.sm, 3224 - elevation: None, 3225 - }); 3226 - let start = sweep * (1.0 + SWEEP_SPAN) - SWEEP_SPAN; 3227 - let left = start.max(0.0); 3228 - let right = (start + SWEEP_SPAN).min(1.0); 3229 - if right <= left { 3230 - return; 3231 - } 3232 - let width = track.size.width.value(); 3233 - let fill_rect = LayoutRect::new( 3234 - LayoutPos::new( 3235 - LayoutPx::new(track.origin.x.value() + width * left), 3236 - track.origin.y, 3237 - ), 3238 - LayoutSize::new(LayoutPx::new(width * (right - left)), track.size.height), 3239 - ); 3240 - paint.push(WidgetPaint::Surface { 3241 - rect: fill_rect, 3242 - fill: ctx.theme().colors.accent_solid(), 3243 - border: None, 3244 - radius: ctx.theme().radius.sm, 3245 - elevation: None, 3246 - }); 3247 - } 3248 - 3249 - fn apply_step_progress_outcome(state: &RenderState, outcome: Option<StepProgressOutcome>) { 3250 - let cancel = outcome.is_some_and(|o| o.cancel_requested); 3251 - if !cancel { 3252 - return; 3253 - } 3254 - if let Some(job) = state.step_job.as_ref() { 3255 - job.meta().request_cancel(); 3256 - } 3257 - } 3258 - 3259 - #[derive(Clone, Debug, PartialEq)] 3260 - struct NotificationOutcome { 3261 - paints: Vec<bone_ui::widgets::WidgetPaint>, 3262 - dismissed: bool, 3263 - } 3264 - 3265 - fn render_notification_toast( 3266 - ctx: &mut FrameCtx<'_>, 3267 - layout_size: LayoutSize, 3268 - notification: &Notification, 3269 - ) -> NotificationOutcome { 3270 - use bone_ui::widgets::{Button, ButtonVariant, WidgetPaint, show_button}; 3271 - use bone_ui::{WidgetId, WidgetKey}; 3272 - let theme = ctx.theme(); 3273 - let bg = match notification.kind { 3274 - NotificationKind::Info => theme.colors.surface(bone_ui::theme::SurfaceLevel::L2), 3275 - NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::SUBTLE_BG), 3276 - }; 3277 - let fg = match notification.kind { 3278 - NotificationKind::Info => theme.colors.text_primary(), 3279 - NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::TEXT_HIGH), 3280 - }; 3281 - let toast_width = layout_size.width.value().clamp(280.0, 420.0); 3282 - let toast_height = if notification.detail.is_some() { 3283 - 72.0 3284 - } else { 3285 - 44.0 3286 - }; 3287 - let margin = 24.0; 3288 - let toast_rect = LayoutRect::new( 3289 - LayoutPos::new( 3290 - LayoutPx::new(margin), 3291 - LayoutPx::new(layout_size.height.value() - toast_height - margin), 3292 - ), 3293 - LayoutSize::new(LayoutPx::new(toast_width), LayoutPx::new(toast_height)), 3294 - ); 3295 - let id = WidgetId::ROOT.child(WidgetKey::new("notification.toast")); 3296 - let mut paints = vec![WidgetPaint::Surface { 3297 - rect: toast_rect, 3298 - fill: bg, 3299 - border: Some(bone_ui::theme::Border { 3300 - width: bone_ui::theme::StrokeWidth::HAIRLINE, 3301 - color: theme.colors.neutral.step(bone_ui::theme::Step12::BORDER), 3302 - }), 3303 - radius: theme.radius.sm, 3304 - elevation: Some(theme.elevation.level1), 3305 - }]; 3306 - let headline_rect = LayoutRect::new( 3307 - LayoutPos::new( 3308 - LayoutPx::new(toast_rect.origin.x.value() + 16.0), 3309 - LayoutPx::new(toast_rect.origin.y.value() + 12.0), 3310 - ), 3311 - LayoutSize::new(LayoutPx::new(toast_width - 120.0), LayoutPx::new(20.0)), 3312 - ); 3313 - paints.push(WidgetPaint::Label { 3314 - rect: headline_rect, 3315 - text: bone_ui::widgets::LabelText::Key(notification.headline), 3316 - color: fg, 3317 - role: theme.typography.label, 3318 - }); 3319 - if let Some(detail) = &notification.detail { 3320 - let detail_rect = LayoutRect::new( 3321 - LayoutPos::new( 3322 - LayoutPx::new(toast_rect.origin.x.value() + 16.0), 3323 - LayoutPx::new(toast_rect.origin.y.value() + 36.0), 3324 - ), 3325 - LayoutSize::new(LayoutPx::new(toast_width - 32.0), LayoutPx::new(24.0)), 3326 - ); 3327 - paints.push(WidgetPaint::Label { 3328 - rect: detail_rect, 3329 - text: bone_ui::widgets::LabelText::Owned(detail.clone()), 3330 - color: theme.colors.text_secondary(), 3331 - role: theme.typography.caption, 3332 - }); 3333 - } 3334 - let dismiss_rect = LayoutRect::new( 3335 - LayoutPos::new( 3336 - LayoutPx::new(toast_rect.origin.x.value() + toast_width - 96.0), 3337 - LayoutPx::new(toast_rect.origin.y.value() + 8.0), 3338 - ), 3339 - LayoutSize::new(LayoutPx::new(84.0), LayoutPx::new(28.0)), 3340 - ); 3341 - let dismiss_id = id.child(WidgetKey::new("dismiss")); 3342 - let response = show_button( 3343 - ctx, 3344 - Button::new( 3345 - dismiss_id, 3346 - dismiss_rect, 3347 - strings::NOTIFY_DISMISS, 3348 - ButtonVariant::Secondary, 3349 - ), 3350 - ); 3351 - paints.extend(response.paint); 3352 - NotificationOutcome { 3353 - paints, 3354 - dismissed: response.activated, 3355 - } 3356 - } 3357 - 3358 - fn apply_notification_outcome(state: &mut RenderState, outcome: Option<NotificationOutcome>) { 3359 - let Some(outcome) = outcome else { return }; 3360 - if outcome.dismissed { 3361 - state.notification = None; 3362 - } 3363 - } 3364 - 3365 - fn apply_overwrite_outcome(state: &mut RenderState, outcome: Option<OverwriteOutcome>) { 3366 - let Some(outcome) = outcome else { return }; 3367 - match outcome.action { 3368 - OverwriteAction::Idle => {} 3369 - OverwriteAction::Cancel => { 3370 - state.pending_overwrite = None; 3371 - } 3372 - OverwriteAction::Replace => match state.pending_overwrite.take() { 3373 - Some(PendingOverwrite::Document(folder)) => perform_save_to(state, folder), 3374 - Some(PendingOverwrite::StepExport(path)) => start_step_export(state, path), 3375 - None => {} 3376 - }, 3377 - } 3378 - } 3379 - 3380 - fn pending_dim(mode: &Mode) -> Option<PendingDimension> { 3381 - match mode.dim_flow() { 3382 - Some(DimensionFlow::Editing(p)) => Some(p), 3383 - Some(DimensionFlow::Conflict(_)) | None => None, 3384 - } 3385 - } 3386 - 3387 - fn apply_dimension_request(state: &mut RenderState, request: Option<PendingDimension>) { 3388 - let Some(request) = request else { return }; 3389 - let Mode::Sketch { .. } = state.mode else { 3390 - return; 3391 - }; 3392 - state.mode = core::mem::take(&mut state.mode).start_dimension(request); 3393 - } 3394 - 3395 - fn apply_dimension_outcome(state: &mut RenderState, outcome: Option<DimensionEditorOutcome>) { 3396 - let Some(outcome) = outcome else { return }; 3397 - let Some(pending) = pending_dim(&state.mode) else { 3398 - return; 3399 - }; 3400 - match outcome.action { 3401 - DimensionEditorAction::Idle => {} 3402 - DimensionEditorAction::Cancel => { 3403 - state.mode = core::mem::take(&mut state.mode).cancel_dimension(); 3404 - state.dim_editor.close(); 3405 - } 3406 - DimensionEditorAction::Swap(next_proto) => { 3407 - state.mode = core::mem::take(&mut state.mode).start_dimension(PendingDimension { 3408 - proto: next_proto, 3409 - anchor: pending.anchor, 3410 - }); 3411 - } 3412 - DimensionEditorAction::Commit(value) => { 3413 - commit_pending_dimension(state, pending, value); 3414 - } 3415 - } 3416 - } 3417 - 3418 - fn apply_dim_conflict_outcome(state: &mut RenderState, outcome: Option<DimConflictOutcome>) { 3419 - let Some(outcome) = outcome else { return }; 3420 - let Some(pending) = dim_conflict_pending(&state.mode) else { 3421 - return; 3422 - }; 3423 - match outcome.action { 3424 - DimConflictAction::Idle => {} 3425 - DimConflictAction::Cancel => { 3426 - state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 3427 - } 3428 - DimConflictAction::MakeDriven => { 3429 - confirm_dim_conflict_make_driven(state, pending); 3430 - } 3431 - } 3432 - } 3433 - 3434 - fn commit_pending_dimension( 3435 - state: &mut RenderState, 3436 - pending: PendingDimension, 3437 - value: DimensionValue, 3438 - ) { 3439 - let Mode::Sketch { sketch_id, .. } = state.mode else { 3440 - return; 3441 - }; 3442 - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 3443 - return; 3444 - }; 3445 - let proto = match pending.proto.with_value(value) { 3446 - Ok(p) => p, 3447 - Err(e) => { 3448 - tracing::warn!(error = %e, ?pending.proto, ?value, "dimension value type mismatch"); 3449 - return; 3450 - } 3451 - }; 3452 - let (after_add, dim_id) = match sketch.clone().apply(SketchEdit::AddDimension(proto)) { 3453 - Ok((next, EditOutcome::Dimension(id))) => (next, id), 3454 - Ok(_) => { 3455 - tracing::warn!(?proto, "add dimension produced unexpected outcome"); 3456 - return; 3457 - } 3458 - Err(e) => { 3459 - tracing::warn!(error = %e, ?proto, "add dimension failed"); 3460 - return; 3461 - } 3462 - }; 3463 - let solved = match after_add.solve() { 3464 - Ok(s) => s, 3465 - Err(SolverError::OverDefined { .. }) => { 3466 - state.mode = core::mem::take(&mut state.mode).start_dim_conflict(PendingDimension { 3467 - proto, 3468 - anchor: pending.anchor, 3469 - }); 3470 - state.dim_editor.close(); 3471 - return; 3472 - } 3473 - Err(e) => { 3474 - tracing::warn!(error = %e, "solve after add dim did not converge; rejecting"); 3475 - return; 3476 - } 3477 - }; 3478 - state.undo.record(state.document.clone()); 3479 - state.document.replace_sketch(sketch_id, solved); 3480 - state.selection = Selection::Dimension(dim_id); 3481 - state.mode = core::mem::take(&mut state.mode).cancel_dimension(); 3482 - state.dim_editor.close(); 3483 - refresh_active_scene(state); 3484 - } 3485 - 3486 - fn confirm_dim_conflict_make_driven(state: &mut RenderState, pending: PendingDimension) { 3487 - let Mode::Sketch { sketch_id, .. } = state.mode else { 3488 - return; 3489 - }; 3490 - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 3491 - return; 3492 - }; 3493 - let Some(measured) = sketch.measure(pending.proto) else { 3494 - tracing::warn!(?pending.proto, "measure failed for driven conversion; aborting"); 3495 - state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 3496 - return; 3497 - }; 3498 - let Some(driven_proto) = driven_with_value(pending.proto, measured) else { 3499 - tracing::warn!(?pending.proto, "driven conversion failed"); 3500 - state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 3501 - return; 3502 - }; 3503 - let (after_add, dim_id) = match sketch.apply(SketchEdit::AddDimension(driven_proto)) { 3504 - Ok((next, EditOutcome::Dimension(id))) => (next, id), 3505 - Ok(_) => { 3506 - tracing::warn!( 3507 - ?driven_proto, 3508 - "add driven dimension produced unexpected outcome" 3509 - ); 3510 - state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 3511 - return; 3512 - } 3513 - Err(e) => { 3514 - tracing::warn!(error = %e, ?driven_proto, "add driven dimension failed"); 3515 - state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 3516 - return; 3517 - } 3518 - }; 3519 - let solved = after_add.clone().solve().unwrap_or(after_add); 3520 - state.undo.record(state.document.clone()); 3521 - state.document.replace_sketch(sketch_id, solved); 3522 - state.selection = Selection::Dimension(dim_id); 3523 - state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 3524 - refresh_active_scene(state); 3525 - } 3526 - 3527 - fn driven_with_value(proto: SketchDimension, value: DimensionValue) -> Option<SketchDimension> { 3528 - proto 3529 - .with_kind(DimensionKind::Driven) 3530 - .with_value(value) 3531 - .ok() 3532 - } 3533 - 3534 - fn apply_dimension_edit(state: &mut RenderState, edit: Option<shell::DimensionEdit>) { 3535 - let Some(edit) = edit else { return }; 3536 - let Mode::Sketch { sketch_id, .. } = state.mode else { 3537 - return; 3538 - }; 3539 - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 3540 - return; 3541 - }; 3542 - let after = match sketch.apply(SketchEdit::UpdateDimensionValue { 3543 - id: edit.id, 3544 - value: edit.value, 3545 - }) { 3546 - Ok((next, _)) => next, 3547 - Err(e) => { 3548 - tracing::warn!(error = %e, ?edit, "update dimension value failed"); 3549 - return; 3550 - } 3551 - }; 3552 - let solved = match after.solve() { 3553 - Ok(s) => s, 3554 - Err(e) => { 3555 - tracing::warn!(error = %e, ?edit, "solve after dimension edit failed"); 3556 - return; 3557 - } 3558 - }; 3559 - state.undo.record(state.document.clone()); 3560 - state.document.replace_sketch(sketch_id, solved); 3561 - refresh_active_scene(state); 3562 - } 3563 - 3564 - fn apply_relation_action(state: &mut RenderState, relation: Option<SketchRelation>) { 3565 - let Some(relation) = relation else { return }; 3566 - let Mode::Sketch { sketch_id, .. } = state.mode else { 3567 - return; 3568 - }; 3569 - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 3570 - return; 3571 - }; 3572 - let next = match sketch.apply(SketchEdit::AddRelation(relation)) { 3573 - Ok((next, _)) => next, 3574 - Err(e) => { 3575 - tracing::warn!(error = %e, ?relation, "add relation failed"); 3576 - return; 3577 - } 3578 - }; 3579 - state.undo.record(state.document.clone()); 3580 - state.document.replace_sketch(sketch_id, next); 3581 - state.selection = Selection::default(); 3582 - refresh_active_scene(state); 3583 - } 3584 - 3585 - fn apply_menu_action(state: &mut RenderState, action: Option<shell::MenuAction>) { 3586 - match action { 3587 - Some(shell::MenuAction::Quit) => { 3588 - state.pending_exit = true; 3589 - } 3590 - Some(shell::MenuAction::Undo) if state.undo.undo(&mut state.document) => { 3591 - refresh_active_scene(state); 3592 - } 3593 - Some(shell::MenuAction::Redo) if state.undo.redo(&mut state.document) => { 3594 - refresh_active_scene(state); 3595 - } 3596 - Some(shell::MenuAction::ZoomFit) => { 3597 - let solid_fit = state 3598 - .solid_view 3599 - .as_ref() 3600 - .map(|view| view.aabb) 3601 - .and_then(|aabb| { 3602 - let region = 3603 - solid_viewport_region(state.viewport_rect, state.surface.extent())?; 3604 - frame_current(state.camera3?, aabb, region.extent()).ok() 3605 - }); 3606 - if let Some(next) = solid_fit { 3607 - apply_nav_camera(state, next); 3608 - } else { 3609 - state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); 3610 - } 3611 - } 3612 - Some(shell::MenuAction::OpenSettings) => { 3613 - state.shell.state.settings_dialog_open = true; 3614 - } 3615 - Some(shell::MenuAction::OpenKeyboardCustomize) => { 3616 - state.shell.state.keyboard_dialog_open = true; 3617 - } 3618 - Some(shell::MenuAction::NewDocument) => { 3619 - request_new_document(state); 3620 - } 3621 - Some(shell::MenuAction::OpenDocument) => { 3622 - open_picker( 3623 - state, 3624 - bone_ui::widgets::FilePickerMode::Open, 3625 - file_menu::FileKind::Document, 3626 - None, 3627 - ); 3628 - } 3629 - Some(shell::MenuAction::SaveDocument) => { 3630 - apply_save_in_place(state); 3631 - } 3632 - Some(shell::MenuAction::SaveDocumentAs) => { 3633 - let seed = state.document.name().to_owned(); 3634 - open_picker( 3635 - state, 3636 - bone_ui::widgets::FilePickerMode::Save, 3637 - file_menu::FileKind::Document, 3638 - Some(seed), 3639 - ); 3640 - } 3641 - Some(shell::MenuAction::ImportStep) => { 3642 - if state.step_job.is_none() { 3643 - open_picker( 3644 - state, 3645 - bone_ui::widgets::FilePickerMode::Open, 3646 - file_menu::FileKind::Step, 3647 - None, 3648 - ); 3649 - } 3650 - } 3651 - Some(shell::MenuAction::ExportStep) => { 3652 - if state.step_job.is_none() { 3653 - let seed = format!("{}.step", state.document.name()); 3654 - open_picker( 3655 - state, 3656 - bone_ui::widgets::FilePickerMode::Save, 3657 - file_menu::FileKind::Step, 3658 - Some(seed), 3659 - ); 3660 - } 3661 - } 3662 - Some(shell::MenuAction::Undo | shell::MenuAction::Redo | shell::MenuAction::ExitSketch) 3663 - | None => {} 3664 - } 3665 - } 3666 - 3667 - fn request_new_document(state: &mut RenderState) { 3668 - if is_dirty(state) { 3669 - state.pending_discard = Some(PendingDiscard::New); 3670 - } else { 3671 - apply_new_document(state); 3672 - } 3673 - } 3674 - 3675 - fn request_open_folder(state: &mut RenderState, path: PathBuf) { 3676 - if is_dirty(state) { 3677 - state.pending_discard = Some(PendingDiscard::Open(path)); 3678 - } else { 3679 - apply_open_folder(state, path); 3680 - } 3681 - } 3682 - 3683 - fn request_import_step(state: &mut RenderState, path: PathBuf) { 3684 - if is_dirty(state) { 3685 - state.pending_discard = Some(PendingDiscard::ImportStep(path)); 3686 - } else { 3687 - start_step_import(state, path); 3688 - } 3689 - } 3690 - 3691 - fn start_step_import(state: &mut RenderState, path: PathBuf) { 3692 - if state.step_job.is_some() { 3693 - return; 3694 - } 3695 - match step_jobs::spawn_import(path, state.document.clone()) { 3696 - Ok(job) => state.step_job = Some(job), 3697 - Err(e) => notify_error(state, strings::NOTIFY_IMPORT_FAILED, e.to_string()), 3698 - } 3699 - } 3700 - 3701 - fn apply_export_step_as(state: &mut RenderState, path: PathBuf, via: PickedVia) { 3702 - let extension_appended = !file_menu::is_step_file(&path); 3703 - let path = file_menu::with_step_extension(path); 3704 - let unconfirmed = matches!(via, PickedVia::CustomPicker) || extension_appended; 3705 - if unconfirmed && path.is_file() { 3706 - state.pending_overwrite = Some(PendingOverwrite::StepExport(path)); 3707 - return; 3708 - } 3709 - start_step_export(state, path); 3710 - } 3711 - 3712 - fn start_step_export(state: &mut RenderState, path: PathBuf) { 3713 - if state.step_job.is_some() { 3714 - return; 3715 - } 3716 - match step_jobs::spawn_export(state.document.clone(), path) { 3717 - Ok(job) => state.step_job = Some(job), 3718 - Err(e) => notify_error(state, strings::NOTIFY_EXPORT_FAILED, e.to_string()), 3719 - } 3720 - } 3721 - 3722 - fn poll_step_job(state: &mut RenderState) { 3723 - let Some(job) = state.step_job.take() else { 3724 - return; 3725 - }; 3726 - match job { 3727 - step_jobs::StepJob::Import { rx, baseline, meta } => match step_jobs::poll(&rx) { 3728 - std::task::Poll::Pending => { 3729 - state.step_job = Some(step_jobs::StepJob::Import { rx, baseline, meta }); 3730 - } 3731 - std::task::Poll::Ready(result) => finish_import(state, result, &baseline, &meta), 3732 - }, 3733 - step_jobs::StepJob::Export { rx, meta } => match step_jobs::poll(&rx) { 3734 - std::task::Poll::Pending => { 3735 - state.step_job = Some(step_jobs::StepJob::Export { rx, meta }); 3736 - } 3737 - std::task::Poll::Ready(result) => finish_export(state, result, &meta), 3738 - }, 3739 - } 3740 - } 3741 - 3742 - fn finish_import( 3743 - state: &mut RenderState, 3744 - result: step_jobs::JobResult<Box<Document>>, 3745 - baseline: &Document, 3746 - meta: &step_jobs::JobMeta, 3747 - ) { 3748 - match result { 3749 - step_jobs::JobResult::Finished(_) if meta.cancel_requested() => { 3750 - tracing::info!(file = %meta.file_name, "discarding import that finished after cancel"); 3751 - } 3752 - step_jobs::JobResult::Finished(document) if *baseline != state.document => { 3753 - state.pending_discard = Some(PendingDiscard::InstallImported { 3754 - document, 3755 - file_name: meta.file_name.clone(), 3756 - }); 3757 - } 3758 - step_jobs::JobResult::Finished(document) => { 3759 - install_imported_document(state, *document); 3760 - notify_info( 3761 - state, 3762 - strings::NOTIFY_IMPORTED, 3763 - Some(meta.file_name.clone()), 3764 - ); 3765 - } 3766 - step_jobs::JobResult::Failed(bone_interop::StepError::Canceled) => { 3767 - tracing::info!(file = %meta.file_name, "step import canceled"); 3768 - } 3769 - step_jobs::JobResult::Failed(e) => { 3770 - tracing::warn!(error = %e, file = %meta.file_name, "step import failed"); 3771 - notify_error(state, strings::NOTIFY_IMPORT_FAILED, e.to_string()); 3772 - } 3773 - step_jobs::JobResult::WorkerLost => { 3774 - tracing::error!(file = %meta.file_name, "step import worker stopped before reporting a result"); 3775 - notify_error( 3776 - state, 3777 - strings::NOTIFY_IMPORT_FAILED, 3778 - "worker stopped before reporting a result".to_owned(), 3779 - ); 3780 - } 3781 - } 3782 - } 3783 - 3784 - fn finish_export( 3785 - state: &mut RenderState, 3786 - result: step_jobs::JobResult<()>, 3787 - meta: &step_jobs::JobMeta, 3788 - ) { 3789 - match result { 3790 - step_jobs::JobResult::Finished(()) => { 3791 - notify_info( 3792 - state, 3793 - strings::NOTIFY_EXPORTED, 3794 - Some(meta.file_name.clone()), 3795 - ); 3796 - } 3797 - step_jobs::JobResult::Failed(bone_interop::StepError::Canceled) => { 3798 - tracing::info!(file = %meta.file_name, "step export canceled"); 3799 - } 3800 - step_jobs::JobResult::Failed(e) => { 3801 - tracing::warn!(error = %e, file = %meta.file_name, "step export failed"); 3802 - notify_error(state, strings::NOTIFY_EXPORT_FAILED, e.to_string()); 3803 - } 3804 - step_jobs::JobResult::WorkerLost => { 3805 - tracing::error!(file = %meta.file_name, "step export worker stopped before reporting a result"); 3806 - notify_error( 3807 - state, 3808 - strings::NOTIFY_EXPORT_FAILED, 3809 - "worker stopped before reporting a result".to_owned(), 3810 - ); 3811 - } 3812 - } 3813 - } 3814 - 3815 - fn is_dirty(state: &RenderState) -> bool { 3816 - state.last_saved.as_ref() != Some(&state.document) 3817 - } 3818 - 3819 - fn apply_new_document(state: &mut RenderState) { 3820 - let sketch = default_sketch(); 3821 - let scene = match SketchScene::extract(&sketch) { 3822 - Ok(s) => s, 3823 - Err(e) => { 3824 - tracing::warn!(error = %e, "scene extract failed on new document"); 3825 - notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); 3826 - return; 3827 - } 3828 - }; 3829 - let (document, sketch_id) = initial_document(sketch); 3830 - state.last_saved = Some(document.clone()); 3831 - state.document = document; 3832 - state.plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); 3833 - state.scene = scene; 3834 - state.mode = Mode::Idle; 3835 - state.selection = Selection::default(); 3836 - state.framed_extrude = None; 3837 - state.current_folder = None; 3838 - state.pending_overwrite = None; 3839 - let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { 3840 - unreachable!("UNDO_CAPACITY constant is non-zero"); 3841 - }; 3842 - state.undo = UndoStack::with_capacity(undo_capacity); 3843 - state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); 3844 - } 3845 - 3846 - fn open_picker( 3847 - state: &mut RenderState, 3848 - mode: bone_ui::widgets::FilePickerMode, 3849 - kind: file_menu::FileKind, 3850 - seed_filename: Option<String>, 3851 - ) { 3852 - if state.file_picker.is_some() || state.native_picker.is_some() { 3853 - return; 3854 - } 3855 - let starting_folder = state 3856 - .current_folder 3857 - .as_ref() 3858 - .map(|f| f.path().to_owned()) 3859 - .or_else(|| { 3860 - state 3861 - .documents_root 3862 - .is_dir() 3863 - .then(|| state.documents_root.clone()) 3864 - }); 3865 - let title_key = file_menu::title_key(kind, mode); 3866 - let accept_key = file_menu::accept_key(kind, mode); 3867 - let title = state.strings.resolve(title_key).to_owned(); 3868 - let accept_label = state.strings.resolve(accept_key).to_owned(); 3869 - let native_req = native_picker::Request { 3870 - mode, 3871 - kind, 3872 - title: title.as_str(), 3873 - accept_label: accept_label.as_str(), 3874 - seed_filename: seed_filename.as_deref(), 3875 - current_folder: starting_folder.as_deref(), 3876 - }; 3877 - match native_picker::spawn(native_req) { 3878 - Ok(handle) => { 3879 - state.native_picker = Some(handle); 3880 - return; 3881 - } 3882 - Err(native_picker::SpawnError::Unsupported) => { 3883 - tracing::debug!("native picker unavailable, falling back to custom picker"); 3884 - } 3885 - } 3886 - open_custom_picker(state, mode, kind, seed_filename); 3887 - } 3888 - 3889 - fn open_custom_picker( 3890 - state: &mut RenderState, 3891 - mode: bone_ui::widgets::FilePickerMode, 3892 - kind: file_menu::FileKind, 3893 - seed_filename: Option<String>, 3894 - ) { 3895 - let scan = match kind { 3896 - file_menu::FileKind::Document => file_menu::scan_document_folders(&state.documents_root), 3897 - file_menu::FileKind::Step => file_menu::scan_step_files(&state.documents_root), 3898 - }; 3899 - let entries = match scan { 3900 - Ok(v) => v, 3901 - Err(e) => { 3902 - tracing::warn!(error = %e, path = %state.documents_root.display(), "scan documents root failed"); 3903 - notify_error(state, strings::NOTIFY_SCAN_FAILED, e.to_string()); 3904 - Vec::new() 3905 - } 3906 - }; 3907 - state.file_picker = Some(file_menu::FilePickerSession::open( 3908 - state.documents_root.clone(), 3909 - mode, 3910 - kind, 3911 - seed_filename, 3912 - entries, 3913 - )); 3914 - } 3915 - 3916 - fn poll_native_picker(state: &mut RenderState) { 3917 - let Some(handle) = state.native_picker.as_ref() else { 3918 - return; 3919 - }; 3920 - let outcome = match handle.poll() { 3921 - std::task::Poll::Pending => return, 3922 - std::task::Poll::Ready(o) => o, 3923 - }; 3924 - let mode = handle.mode; 3925 - let kind = handle.kind; 3926 - state.native_picker = None; 3927 - match outcome { 3928 - native_picker::NativeOutcome::Path(path) => { 3929 - route_picked_path(state, kind, mode, path, PickedVia::NativePortal); 3930 - } 3931 - native_picker::NativeOutcome::Cancelled => {} 3932 - native_picker::NativeOutcome::Error(message) => { 3933 - tracing::warn!(error = %message, "native picker errored, falling back to custom picker"); 3934 - let seed = matches!(mode, bone_ui::widgets::FilePickerMode::Save).then(|| match kind { 3935 - file_menu::FileKind::Document => state.document.name().to_owned(), 3936 - file_menu::FileKind::Step => format!("{}.step", state.document.name()), 3937 - }); 3938 - open_custom_picker(state, mode, kind, seed); 3939 - } 3940 - } 3941 - } 3942 - 3943 - fn route_picked_path( 3944 - state: &mut RenderState, 3945 - kind: file_menu::FileKind, 3946 - mode: bone_ui::widgets::FilePickerMode, 3947 - path: PathBuf, 3948 - via: PickedVia, 3949 - ) { 3950 - match (kind, mode) { 3951 - (file_menu::FileKind::Document, bone_ui::widgets::FilePickerMode::Open) => { 3952 - request_open_folder(state, path); 3953 - } 3954 - (file_menu::FileKind::Document, bone_ui::widgets::FilePickerMode::Save) => { 3955 - apply_save_as(state, path); 3956 - } 3957 - (file_menu::FileKind::Step, bone_ui::widgets::FilePickerMode::Open) => { 3958 - request_import_step(state, path); 3959 - } 3960 - (file_menu::FileKind::Step, bone_ui::widgets::FilePickerMode::Save) => { 3961 - apply_export_step_as(state, path, via); 3962 - } 3963 - } 3964 - } 3965 - 3966 - fn apply_save_in_place(state: &mut RenderState) { 3967 - let Some(folder) = state.current_folder.clone() else { 3968 - let seed = state.document.name().to_owned(); 3969 - open_picker( 3970 - state, 3971 - bone_ui::widgets::FilePickerMode::Save, 3972 - file_menu::FileKind::Document, 3973 - Some(seed), 3974 - ); 3975 - return; 3976 - }; 3977 - if let Err(e) = bone_document::save(&state.document, &folder) { 3978 - tracing::warn!(error = %e, path = %folder.path().display(), "save failed"); 3979 - notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string()); 3980 - return; 3981 - } 3982 - state.last_saved = Some(state.document.clone()); 3983 - notify_info(state, strings::NOTIFY_SAVED, None); 3984 - } 3985 - 3986 - fn apply_picker_command( 3987 - state: &mut RenderState, 3988 - kind: file_menu::FileKind, 3989 - command: file_menu::PickerCommand, 3990 - ) { 3991 - state.file_picker = None; 3992 - match command { 3993 - file_menu::PickerCommand::Cancel => {} 3994 - file_menu::PickerCommand::Open(path) => { 3995 - route_picked_path( 3996 - state, 3997 - kind, 3998 - bone_ui::widgets::FilePickerMode::Open, 3999 - path, 4000 - PickedVia::CustomPicker, 4001 - ); 4002 - } 4003 - file_menu::PickerCommand::SaveAs(path) => { 4004 - route_picked_path( 4005 - state, 4006 - kind, 4007 - bone_ui::widgets::FilePickerMode::Save, 4008 - path, 4009 - PickedVia::CustomPicker, 4010 - ); 4011 - } 4012 - } 4013 - } 4014 - 4015 - fn apply_open_folder(state: &mut RenderState, path: PathBuf) { 4016 - let folder = DocumentFolder::new(path); 4017 - let document = match bone_document::load(&folder) { 4018 - Ok(d) => d, 4019 - Err(e) => { 4020 - tracing::warn!(error = %e, path = %folder.path().display(), "load failed"); 4021 - notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); 4022 - return; 4023 - } 4024 - }; 4025 - install_loaded_document(state, document, Some(folder)); 4026 - } 4027 - 4028 - fn apply_save_as(state: &mut RenderState, path: PathBuf) { 4029 - let folder = DocumentFolder::new(path); 4030 - let in_place = state 4031 - .current_folder 4032 - .as_ref() 4033 - .is_some_and(|current| same_folder(current.path(), folder.path())); 4034 - if folder.document_file().is_file() && !in_place { 4035 - state.pending_overwrite = Some(PendingOverwrite::Document(folder)); 4036 - return; 4037 - } 4038 - perform_save_to(state, folder); 4039 - } 4040 - 4041 - fn perform_save_to(state: &mut RenderState, folder: DocumentFolder) { 4042 - let prior_name = state.document.name().to_owned(); 4043 - state 4044 - .document 4045 - .set_name(folder_display_name(&folder, &state.strings)); 4046 - if let Err(e) = bone_document::save(&state.document, &folder) { 4047 - tracing::warn!(error = %e, path = %folder.path().display(), "save as failed"); 4048 - state.document.set_name(prior_name); 4049 - notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string()); 4050 - return; 4051 - } 4052 - state.current_folder = Some(folder); 4053 - state.last_saved = Some(state.document.clone()); 4054 - notify_info(state, strings::NOTIFY_SAVED, None); 4055 - } 4056 - 4057 - fn folder_display_name(folder: &DocumentFolder, string_table: &StringTable) -> String { 4058 - folder.path().file_name().map_or_else( 4059 - || { 4060 - string_table 4061 - .resolve(strings::DEFAULT_DOCUMENT_NAME) 4062 - .to_owned() 4063 - }, 4064 - |s| s.to_string_lossy().into_owned(), 4065 - ) 4066 - } 4067 - 4068 - fn same_folder(a: &Path, b: &Path) -> bool { 4069 - match (resolve_path(a), resolve_path(b)) { 4070 - (Some(x), Some(y)) => x == y, 4071 - _ => false, 4072 - } 4073 - } 4074 - 4075 - fn resolve_path(path: &Path) -> Option<PathBuf> { 4076 - if let Ok(canon) = std::fs::canonicalize(path) { 4077 - return Some(canon); 4078 - } 4079 - let parent = path.parent()?; 4080 - let file_name = path.file_name()?; 4081 - let parent_canon = std::fs::canonicalize(parent).ok()?; 4082 - Some(parent_canon.join(file_name)) 4083 - } 4084 - 4085 - fn notify_error(state: &mut RenderState, headline: bone_ui::strings::StringKey, detail: String) { 4086 - state.notification = Some(Notification { 4087 - kind: NotificationKind::Error, 4088 - headline, 4089 - detail: Some(detail), 4090 - }); 4091 - } 4092 - 4093 - fn notify_info( 4094 - state: &mut RenderState, 4095 - headline: bone_ui::strings::StringKey, 4096 - detail: Option<String>, 4097 - ) { 4098 - state.notification = Some(Notification { 4099 - kind: NotificationKind::Info, 4100 - headline, 4101 - detail, 4102 - }); 4103 - } 4104 - 4105 - fn notify_stub(state: &mut RenderState, label: bone_ui::strings::StringKey) { 4106 - let detail = state.strings.resolve(label).to_owned(); 4107 - tracing::info!(label = %detail, "stub action invoked"); 4108 - notify_info(state, strings::NOTIFY_COMING_SOON, Some(detail)); 4109 - } 4110 - 4111 - fn apply_shortcut_bar_outcome( 4112 - state: &mut RenderState, 4113 - outcome: Option<&shortcut_bar::ShortcutBarOutcome>, 4114 - ) { 4115 - let Some(outcome) = outcome else { return }; 4116 - if outcome.dismissed || outcome.activated.is_some() { 4117 - state.shortcut_bar = None; 4118 - } 4119 - } 4120 - 4121 - fn install_imported_document(state: &mut RenderState, document: Document) { 4122 - install_loaded_document(state, document, None); 4123 - state.last_saved = None; 4124 - } 4125 - 4126 - fn install_loaded_document( 4127 - state: &mut RenderState, 4128 - document: Document, 4129 - folder: Option<DocumentFolder>, 4130 - ) { 4131 - let plane_sketches = plane_sketches_from(&document); 4132 - let active_sketch_id = plane_sketches.get(&Plane::Xy).copied(); 4133 - state.last_saved = Some(document.clone()); 4134 - state.document = document; 4135 - state.plane_sketches = plane_sketches; 4136 - state.mode = Mode::Idle; 4137 - state.selection = Selection::default(); 4138 - state.framed_extrude = None; 4139 - state.current_folder = folder; 4140 - state.pending_overwrite = None; 4141 - let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { 4142 - unreachable!("UNDO_CAPACITY constant is non-zero"); 4143 - }; 4144 - state.undo = UndoStack::with_capacity(undo_capacity); 4145 - let scene_attempt = active_sketch_id 4146 - .and_then(|id| state.document.sketch(id)) 4147 - .map(SketchScene::extract); 4148 - state.scene = match scene_attempt { 4149 - None => SketchScene::empty(), 4150 - Some(Ok(scene)) => scene, 4151 - Some(Err(e)) => { 4152 - tracing::warn!(error = %e, "scene extract on load failed"); 4153 - notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); 4154 - SketchScene::empty() 4155 - } 4156 - }; 4157 - state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); 4158 - } 4159 - 4160 - fn plane_sketches_from(document: &Document) -> BTreeMap<Plane, SketchId> { 4161 - document 4162 - .sketches() 4163 - .map(|(id, _)| (Plane::Xy, id)) 4164 - .take(1) 4165 - .collect() 4166 - } 4167 - 4168 - fn persist_settings(state: &RenderState) { 4169 - settings::save(&state.settings); 4170 - } 4171 - 4172 - fn apply_settings_change(state: &mut RenderState, change: Option<settings::Settings>) { 4173 - let Some(next) = change else { return }; 4174 - let overrides_changed = next.hotkey_overrides != state.settings.hotkey_overrides; 4175 - if !overrides_changed { 4176 - state.settings = next; 4177 - persist_settings(state); 4178 - return; 4179 - } 4180 - match hotkeys::compose_table(&next.hotkey_overrides) { 4181 - Ok(table) => { 4182 - state.hotkeys = table; 4183 - state.settings = next; 4184 - persist_settings(state); 4185 - } 4186 - Err(error) => { 4187 - tracing::warn!(?error, "hotkey override rejected, retaining prior settings"); 4188 - state.shell.state.hotkey_capture.clear(); 4189 - notify_error(state, strings::NOTIFY_HOTKEY_CONFLICT, format!("{error}")); 4190 - } 4191 - } 4192 - } 4193 - 4194 - fn apply_sketch_rename(state: &mut RenderState, request: Option<shell::SketchRenameRequest>) { 4195 - let Some(req) = request else { return }; 4196 - apply_sketch_rename_into(&mut state.document, &mut state.undo, req); 4197 - } 4198 - 4199 - fn apply_extrude_rename(state: &mut RenderState, request: Option<shell::ExtrudeRenameRequest>) { 4200 - let Some(req) = request else { return }; 4201 - apply_extrude_rename_into(&mut state.document, &mut state.undo, req); 4202 - } 4203 - 4204 - fn apply_extrude_rename_into( 4205 - document: &mut Document, 4206 - undo: &mut UndoStack, 4207 - request: shell::ExtrudeRenameRequest, 4208 - ) { 4209 - let shell::ExtrudeRenameRequest { id, label } = request; 4210 - let trimmed = label.trim(); 4211 - let Some(current) = document.extrude_label(id) else { 4212 - return; 4213 - }; 4214 - if trimmed.is_empty() || current == trimmed { 4215 - return; 4216 - } 4217 - let snapshot = document.clone(); 4218 - match document.rename_extrude(id, &label) { 4219 - Ok(()) => undo.record(snapshot), 4220 - Err(e) => tracing::warn!(error = %e, ?id, "extrude rename rejected"), 4221 - } 4222 - } 4223 - 4224 - fn apply_sketch_rename_into( 4225 - document: &mut Document, 4226 - undo: &mut UndoStack, 4227 - request: shell::SketchRenameRequest, 4228 - ) { 4229 - let shell::SketchRenameRequest { id, label } = request; 4230 - let trimmed = label.trim(); 4231 - let Some(current) = document.sketch_label(id) else { 4232 - return; 4233 - }; 4234 - if trimmed.is_empty() || current == trimmed { 4235 - return; 4236 - } 4237 - let snapshot = document.clone(); 4238 - match document.rename_sketch(id, &label) { 4239 - Ok(()) => undo.record(snapshot), 4240 - Err(e) => tracing::warn!(error = %e, ?id, "sketch rename rejected"), 4241 - } 4242 - } 4243 - 4244 192 enum RunMode { 4245 193 Window, 4246 194 Gallery(PathBuf), ··· 4303 251 } 4304 252 } 4305 253 4306 - fn keycode_to_named(code: KeyCode) -> Option<NamedKey> { 4307 - match code { 4308 - KeyCode::Tab => Some(NamedKey::Tab), 4309 - KeyCode::Enter | KeyCode::NumpadEnter => Some(NamedKey::Enter), 4310 - KeyCode::Escape => Some(NamedKey::Escape), 4311 - KeyCode::Backspace => Some(NamedKey::Backspace), 4312 - KeyCode::Delete => Some(NamedKey::Delete), 4313 - KeyCode::Space => Some(NamedKey::Space), 4314 - KeyCode::ArrowUp => Some(NamedKey::ArrowUp), 4315 - KeyCode::ArrowDown => Some(NamedKey::ArrowDown), 4316 - KeyCode::ArrowLeft => Some(NamedKey::ArrowLeft), 4317 - KeyCode::ArrowRight => Some(NamedKey::ArrowRight), 4318 - KeyCode::Home => Some(NamedKey::Home), 4319 - KeyCode::End => Some(NamedKey::End), 4320 - KeyCode::PageUp => Some(NamedKey::PageUp), 4321 - KeyCode::PageDown => Some(NamedKey::PageDown), 4322 - KeyCode::F2 => Some(NamedKey::F2), 4323 - _ => None, 4324 - } 4325 - } 4326 - 4327 - fn winit_named_to_ui(named: WinitNamed) -> Option<NamedKey> { 4328 - match named { 4329 - WinitNamed::Tab => Some(NamedKey::Tab), 4330 - WinitNamed::Enter => Some(NamedKey::Enter), 4331 - WinitNamed::Escape => Some(NamedKey::Escape), 4332 - WinitNamed::Backspace => Some(NamedKey::Backspace), 4333 - WinitNamed::Delete => Some(NamedKey::Delete), 4334 - WinitNamed::Space => Some(NamedKey::Space), 4335 - WinitNamed::ArrowUp => Some(NamedKey::ArrowUp), 4336 - WinitNamed::ArrowDown => Some(NamedKey::ArrowDown), 4337 - WinitNamed::ArrowLeft => Some(NamedKey::ArrowLeft), 4338 - WinitNamed::ArrowRight => Some(NamedKey::ArrowRight), 4339 - WinitNamed::Home => Some(NamedKey::Home), 4340 - WinitNamed::End => Some(NamedKey::End), 4341 - WinitNamed::PageUp => Some(NamedKey::PageUp), 4342 - WinitNamed::PageDown => Some(NamedKey::PageDown), 4343 - WinitNamed::F2 => Some(NamedKey::F2), 4344 - _ => None, 4345 - } 4346 - } 4347 - 4348 - fn keycode_to_char(code: KeyCode) -> Option<char> { 4349 - match code { 4350 - KeyCode::KeyA => Some('a'), 4351 - KeyCode::KeyB => Some('b'), 4352 - KeyCode::KeyC => Some('c'), 4353 - KeyCode::KeyD => Some('d'), 4354 - KeyCode::KeyE => Some('e'), 4355 - KeyCode::KeyF => Some('f'), 4356 - KeyCode::KeyG => Some('g'), 4357 - KeyCode::KeyH => Some('h'), 4358 - KeyCode::KeyI => Some('i'), 4359 - KeyCode::KeyJ => Some('j'), 4360 - KeyCode::KeyK => Some('k'), 4361 - KeyCode::KeyL => Some('l'), 4362 - KeyCode::KeyM => Some('m'), 4363 - KeyCode::KeyN => Some('n'), 4364 - KeyCode::KeyO => Some('o'), 4365 - KeyCode::KeyP => Some('p'), 4366 - KeyCode::KeyQ => Some('q'), 4367 - KeyCode::KeyR => Some('r'), 4368 - KeyCode::KeyS => Some('s'), 4369 - KeyCode::KeyT => Some('t'), 4370 - KeyCode::KeyU => Some('u'), 4371 - KeyCode::KeyV => Some('v'), 4372 - KeyCode::KeyW => Some('w'), 4373 - KeyCode::KeyX => Some('x'), 4374 - KeyCode::KeyY => Some('y'), 4375 - KeyCode::KeyZ => Some('z'), 4376 - KeyCode::Digit0 => Some('0'), 4377 - KeyCode::Digit1 => Some('1'), 4378 - KeyCode::Digit2 => Some('2'), 4379 - KeyCode::Digit3 => Some('3'), 4380 - KeyCode::Digit4 => Some('4'), 4381 - KeyCode::Digit5 => Some('5'), 4382 - KeyCode::Digit6 => Some('6'), 4383 - KeyCode::Digit7 => Some('7'), 4384 - KeyCode::Digit8 => Some('8'), 4385 - KeyCode::Digit9 => Some('9'), 4386 - _ => None, 4387 - } 4388 - } 4389 - 4390 254 fn run_window() -> Result<(), AppError> { 4391 255 let event_loop = EventLoop::new()?; 4392 256 event_loop.set_control_flow(ControlFlow::Wait); 4393 - let mut app = App { 4394 - redraw: None, 4395 - render: None, 4396 - input: InputState::default(), 4397 - }; 257 + let mut app = App { running: None }; 4398 258 event_loop.run_app(&mut app)?; 4399 259 Ok(()) 4400 260 } 4401 - 4402 - #[cfg(test)] 4403 - mod tests { 4404 - use super::*; 4405 - use crate::sketch_mode::SketchSession; 4406 - use bone_ui::hotkey::KeyChord; 4407 - use bone_ui::input::{KeyChar, KeyCode, KeyEvent, NamedKey}; 4408 - 4409 - #[test] 4410 - fn strip_plain_letter_chords_removes_chars_with_no_modifiers() { 4411 - let mut input = InputSnapshot::idle(FrameInstant::ZERO); 4412 - let plain_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::NONE); 4413 - let ctrl_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL); 4414 - let esc = KeyEvent::new(KeyCode::Named(NamedKey::Escape), ModifierMask::NONE); 4415 - input.keys_pressed = vec![plain_s, ctrl_s, esc]; 4416 - strip_plain_letter_chords(&mut input); 4417 - assert_eq!( 4418 - input.keys_pressed, 4419 - vec![ctrl_s, esc], 4420 - "strip removes plain letters, keeps modified chords and named keys" 4421 - ); 4422 - } 4423 - 4424 - #[test] 4425 - fn strip_plain_letter_chords_is_idempotent_when_no_chars() { 4426 - let mut input = InputSnapshot::idle(FrameInstant::ZERO); 4427 - let enter = KeyEvent::new(KeyCode::Named(NamedKey::Enter), ModifierMask::NONE); 4428 - input.keys_pressed = vec![enter]; 4429 - strip_plain_letter_chords(&mut input); 4430 - assert_eq!(input.keys_pressed, vec![enter]); 4431 - } 4432 - 4433 - #[test] 4434 - fn cursor_to_world_at_window_center_equals_camera_pan() { 4435 - let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100)); 4436 - let camera = Camera2::new(extent) 4437 - .with_pan(Vec2::from_mm(7.0, -3.0)) 4438 - .with_zoom(PixelsPerMm::new(5.0)); 4439 - let Some(world) = cursor_to_world(camera, PhysicalPosition::new(100.0, 50.0)) else { 4440 - panic!("center maps"); 4441 - }; 4442 - let (x, y) = world.coords_mm(); 4443 - assert!((x - 7.0).abs() < 1e-9); 4444 - assert!((y - -3.0).abs() < 1e-9); 4445 - } 4446 - 4447 - #[test] 4448 - fn cursor_to_world_inverts_y_so_up_in_window_is_up_in_world() { 4449 - let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100)); 4450 - let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(10.0)); 4451 - let Some(above) = cursor_to_world(camera, PhysicalPosition::new(100.0, 0.0)) else { 4452 - panic!("top"); 4453 - }; 4454 - let Some(below) = cursor_to_world(camera, PhysicalPosition::new(100.0, 100.0)) else { 4455 - panic!("bottom"); 4456 - }; 4457 - let (_, ya) = above.coords_mm(); 4458 - let (_, yb) = below.coords_mm(); 4459 - assert!(ya > yb); 4460 - } 4461 - 4462 - #[test] 4463 - fn cursor_to_world_rejects_zero_extent() { 4464 - let extent = ViewportExtent::new(ViewportPx::new(0), ViewportPx::new(100)); 4465 - let camera = Camera2::new(extent); 4466 - assert!(cursor_to_world(camera, PhysicalPosition::new(0.0, 0.0)).is_none()); 4467 - } 4468 - 4469 - fn empty_frame() -> shell::ShellFrame { 4470 - shell::ShellFrame { 4471 - paints: Vec::new(), 4472 - overlay_paints: Vec::new(), 4473 - viewport_rect: empty_rect(), 4474 - activated_tool: None, 4475 - activated_feature_tool: None, 4476 - activated_relation: None, 4477 - activated_dimension: None, 4478 - dimension_edit: None, 4479 - extrude_edit: None, 4480 - plane_picked: None, 4481 - sketch_activated: None, 4482 - sketch_rename: None, 4483 - extrude_activated: None, 4484 - extrude_rename: None, 4485 - exit_sketch: false, 4486 - confirm_action: None, 4487 - menu_action: None, 4488 - settings_change: None, 4489 - view_pick: None, 4490 - view_menu: None, 4491 - } 4492 - } 4493 - 4494 - fn xy_only() -> BTreeMap<Plane, SketchId> { 4495 - BTreeMap::from([(Plane::Xy, SketchId::default())]) 4496 - } 4497 - 4498 - #[test] 4499 - fn classify_extrude_profile_separates_no_sketch_from_unique() { 4500 - let empty = Document::new(DocumentId::default(), "Untitled".to_owned()); 4501 - assert!(matches!( 4502 - super::classify_extrude_profile(&empty), 4503 - super::ProfileChoice::NoSketch 4504 - )); 4505 - 4506 - let (one, id) = super::initial_document(Sketch::new(Plane::Xy.basis())); 4507 - assert!(matches!( 4508 - super::classify_extrude_profile(&one), 4509 - super::ProfileChoice::Unique(found) if found == id 4510 - )); 4511 - } 4512 - 4513 - fn rectangle_sketch() -> Sketch { 4514 - let sketch = Sketch::new(Plane::Xy.basis()); 4515 - let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 4516 - let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); 4517 - let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(10.0, 6.0)); 4518 - let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(0.0, 6.0)); 4519 - let (sketch, _) = tools::add_line(sketch, p0, p1, false); 4520 - let (sketch, _) = tools::add_line(sketch, p1, p2, false); 4521 - let (sketch, _) = tools::add_line(sketch, p2, p3, false); 4522 - let (sketch, _) = tools::add_line(sketch, p3, p0, false); 4523 - sketch 4524 - } 4525 - 4526 - fn tall_rectangle_sketch() -> Sketch { 4527 - let sketch = Sketch::new(Plane::Xy.basis()); 4528 - let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 4529 - let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); 4530 - let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(10.0, 20.0)); 4531 - let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(0.0, 20.0)); 4532 - let (sketch, _) = tools::add_line(sketch, p0, p1, false); 4533 - let (sketch, _) = tools::add_line(sketch, p1, p2, false); 4534 - let (sketch, _) = tools::add_line(sketch, p2, p3, false); 4535 - let (sketch, _) = tools::add_line(sketch, p3, p0, false); 4536 - sketch 4537 - } 4538 - 4539 - #[test] 4540 - fn extrude_preview_cache_is_current_only_for_the_same_feature_and_sketch_version() { 4541 - let id = SketchId::default(); 4542 - let feature = sketch_mode::default_extrude_feature(id); 4543 - let base_version = Sketch::new(Plane::Xy.basis()).version(); 4544 - let edited_version = rectangle_sketch().version(); 4545 - assert_ne!( 4546 - base_version, edited_version, 4547 - "a sketch edit must bump the version this gate keys on" 4548 - ); 4549 - let cached = super::ExtrudePreview { 4550 - feature, 4551 - sketch_version: base_version, 4552 - generation: None, 4553 - failed: false, 4554 - error: None, 4555 - }; 4556 - assert!(super::extrude_preview_is_current( 4557 - Some(&cached), 4558 - &feature, 4559 - base_version 4560 - )); 4561 - assert!( 4562 - !super::extrude_preview_is_current(Some(&cached), &feature, edited_version), 4563 - "editing the sketch under the same feature must invalidate the cached preview", 4564 - ); 4565 - assert!(!super::extrude_preview_is_current( 4566 - None, 4567 - &feature, 4568 - base_version 4569 - )); 4570 - } 4571 - 4572 - #[test] 4573 - fn extrude_preview_refreshes_when_the_sketch_changes_under_one_cache() { 4574 - let (mut document, id) = super::initial_document(rectangle_sketch()); 4575 - let feature = sketch_mode::default_extrude_feature(id); 4576 - let mut cache = super::FeatureCache::new(); 4577 - let first = super::compute_extrude_preview(&mut cache, &document, feature) 4578 - .and_then(|preview| preview.generation()); 4579 - document.replace_sketch(id, tall_rectangle_sketch()); 4580 - let second = super::compute_extrude_preview(&mut cache, &document, feature) 4581 - .and_then(|preview| preview.generation()); 4582 - let (Some(first), Some(second)) = (first, second) else { 4583 - panic!("both rectangles extrude to a solid"); 4584 - }; 4585 - assert_ne!( 4586 - first, second, 4587 - "a sketch edit under the same feature must re-evaluate against the edited geometry", 4588 - ); 4589 - } 4590 - 4591 - #[test] 4592 - fn extrude_preview_evaluates_a_closed_rectangle() { 4593 - let (document, id) = super::initial_document(rectangle_sketch()); 4594 - let feature = sketch_mode::default_extrude_feature(id); 4595 - let mut cache = super::FeatureCache::new(); 4596 - let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { 4597 - panic!("a registered sketch yields an evaluated preview"); 4598 - }; 4599 - assert!( 4600 - preview.solid().is_some(), 4601 - "closed rectangle extrudes to a solid" 4602 - ); 4603 - } 4604 - 4605 - #[test] 4606 - #[cfg_attr( 4607 - debug_assertions, 4608 - ignore = "frame budget assertions are only meaningful in release builds" 4609 - )] 4610 - fn extrude_live_preview_under_frame_budget() { 4611 - use bone_document::ExtrudeEndCondition; 4612 - use bone_types::PositiveLength; 4613 - use std::time::{Duration, Instant}; 4614 - use uom::si::length::millimeter; 4615 - 4616 - const BASE_MM: f64 = 4.0; 4617 - const STEP_MM: f64 = 0.5; 4618 - const STEPS: u32 = 16; 4619 - 4620 - let (document, id) = super::initial_document(rectangle_sketch()); 4621 - let mut cache = super::FeatureCache::new(); 4622 - let blind = |depth_mm: f64| { 4623 - let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(depth_mm)) else { 4624 - panic!("{depth_mm} mm is a positive depth"); 4625 - }; 4626 - ExtrudeFeature { 4627 - end_condition: ExtrudeEndCondition::Blind { depth }, 4628 - ..sketch_mode::default_extrude_feature(id) 4629 - } 4630 - }; 4631 - let edit = |cache: &mut super::FeatureCache, depth_mm: f64| { 4632 - let started = Instant::now(); 4633 - let Some(preview) = super::compute_extrude_preview(cache, &document, blind(depth_mm)) 4634 - else { 4635 - panic!("the rectangle extrudes at {depth_mm} mm"); 4636 - }; 4637 - let Some(solid) = preview.solid() else { 4638 - panic!("the rectangle yields a solid at {depth_mm} mm"); 4639 - }; 4640 - let Ok(_view) = super::build_solid_view(solid) else { 4641 - panic!("the slab tessellates and packs scenes at {depth_mm} mm"); 4642 - }; 4643 - started.elapsed() 4644 - }; 4645 - 4646 - let _warmup = edit(&mut cache, BASE_MM); 4647 - let durations: Vec<Duration> = (0..STEPS) 4648 - .map(|i| edit(&mut cache, BASE_MM + STEP_MM * f64::from(i))) 4649 - .collect(); 4650 - let sorted = { 4651 - let mut v = durations.clone(); 4652 - v.sort(); 4653 - v 4654 - }; 4655 - let median = sorted[sorted.len() / 2]; 4656 - let Some(&worst) = sorted.last() else { 4657 - panic!("preview loop produced zero samples"); 4658 - }; 4659 - let budget = BudgetCeiling::FRAME_16MS.duration(); 4660 - assert!( 4661 - median <= budget, 4662 - "median evaluate+tessellate+scene step {median:?} exceeds {budget:?} frame budget; samples {durations:?}", 4663 - ); 4664 - assert!( 4665 - worst <= budget * 2, 4666 - "worst evaluate+tessellate+scene step {worst:?} exceeds the relaxed ceiling; samples {durations:?}", 4667 - ); 4668 - } 4669 - 4670 - #[test] 4671 - fn extrude_preview_absent_when_sketch_missing() { 4672 - let document = Document::new(DocumentId::default(), "Empty".to_owned()); 4673 - let feature = sketch_mode::default_extrude_feature(SketchId::default()); 4674 - let mut cache = super::FeatureCache::new(); 4675 - assert!(super::compute_extrude_preview(&mut cache, &document, feature).is_none()); 4676 - } 4677 - 4678 - #[test] 4679 - fn default_document_extrudes_to_a_solid() { 4680 - let (document, id) = super::initial_document(super::default_sketch()); 4681 - let feature = sketch_mode::default_extrude_feature(id); 4682 - let mut cache = super::FeatureCache::new(); 4683 - let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { 4684 - panic!("the default sketch is registered"); 4685 - }; 4686 - let Some(solid) = preview.solid() else { 4687 - panic!("the default sketch extrudes to a solid"); 4688 - }; 4689 - assert!(super::build_solid_view(solid).is_ok()); 4690 - } 4691 - 4692 - #[test] 4693 - fn preview_solid_view_tessellates_and_frames() { 4694 - let (document, id) = super::initial_document(rectangle_sketch()); 4695 - let feature = sketch_mode::default_extrude_feature(id); 4696 - let mut cache = super::FeatureCache::new(); 4697 - let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { 4698 - panic!("a registered sketch yields an evaluated preview"); 4699 - }; 4700 - let Some(solid) = preview.solid() else { 4701 - panic!("the rectangle extrudes to a solid"); 4702 - }; 4703 - let Ok(view) = super::build_solid_view(solid) else { 4704 - panic!("the solid tessellates into a renderable view"); 4705 - }; 4706 - let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256)); 4707 - let region = ViewportRegion::at_origin(extent); 4708 - let camera = frame_standard_view(view.aabb, extent, StandardView::Isometric, None).ok(); 4709 - assert!( 4710 - camera.is_some(), 4711 - "the solid aabb frames an isometric camera" 4712 - ); 4713 - assert!( 4714 - super::preview_solid_frame(Some(&view), camera, region).is_some(), 4715 - "a framed preview lowers to a solid frame view", 4716 - ); 4717 - assert!( 4718 - super::preview_solid_frame(Some(&view), None, region).is_none(), 4719 - "without a camera there is nothing to frame", 4720 - ); 4721 - } 4722 - 4723 - fn layout_rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { 4724 - LayoutRect::new( 4725 - LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 4726 - LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)), 4727 - ) 4728 - } 4729 - 4730 - #[test] 4731 - fn solid_viewport_region_offsets_inside_the_surface() { 4732 - let surface = ViewportExtent::new(ViewportPx::new(1280), ViewportPx::new(800)); 4733 - let Some(region) = 4734 - super::solid_viewport_region(layout_rect(320.0, 96.0, 800.0, 600.0), surface) 4735 - else { 4736 - panic!("an inset viewport yields a region"); 4737 - }; 4738 - assert_eq!( 4739 - region.scissor(), 4740 - (320, 96, 800, 600), 4741 - "the region carries the viewport offset and size, not the whole window", 4742 - ); 4743 - } 4744 - 4745 - #[test] 4746 - fn solid_viewport_region_clamps_to_the_surface() { 4747 - let surface = ViewportExtent::new(ViewportPx::new(640), ViewportPx::new(480)); 4748 - let Some(region) = 4749 - super::solid_viewport_region(layout_rect(600.0, 400.0, 400.0, 400.0), surface) 4750 - else { 4751 - panic!("a partly off-surface viewport still yields a clamped region"); 4752 - }; 4753 - let (x, y, w, h) = region.scissor(); 4754 - assert!( 4755 - x + w <= 640 && y + h <= 480, 4756 - "the scissor never runs past the surface: {x}+{w}, {y}+{h}", 4757 - ); 4758 - } 4759 - 4760 - #[test] 4761 - fn solid_viewport_region_is_none_for_a_degenerate_viewport() { 4762 - let surface = ViewportExtent::new(ViewportPx::new(1280), ViewportPx::new(800)); 4763 - assert!(super::solid_viewport_region(layout_rect(0.0, 0.0, 0.0, 0.0), surface).is_none()); 4764 - } 4765 - 4766 - #[test] 4767 - fn viewport_local_point_centers_the_inset_viewport_not_the_window() { 4768 - let region = ViewportRegion::new( 4769 - ViewportPx::new(282), 4770 - ViewportPx::new(120), 4771 - ViewportExtent::new(ViewportPx::new(998), ViewportPx::new(636)), 4772 - ); 4773 - let center = PhysicalPosition::new(282.0 + 499.0, 120.0 + 318.0); 4774 - let Some(local) = super::viewport_local_point(center, region) else { 4775 - panic!("a cursor inside the surface yields a viewport-local point"); 4776 - }; 4777 - assert!( 4778 - (local.x() - 499.0).abs() < 1e-9 && (local.y() - 318.0).abs() < 1e-9, 4779 - "a cursor at the inset viewport center maps to the region-local center: ({}, {})", 4780 - local.x(), 4781 - local.y(), 4782 - ); 4783 - } 4784 - 4785 - #[test] 4786 - fn drag_gesture_maps_modifiers_to_orbit_pan_zoom_roll() { 4787 - use winit::keyboard::ModifiersState; 4788 - assert_eq!( 4789 - super::drag_gesture(ModifiersState::empty()), 4790 - NavGesture::Orbit 4791 - ); 4792 - assert_eq!( 4793 - super::drag_gesture(ModifiersState::CONTROL), 4794 - NavGesture::Pan 4795 - ); 4796 - assert_eq!(super::drag_gesture(ModifiersState::SHIFT), NavGesture::Zoom); 4797 - assert_eq!(super::drag_gesture(ModifiersState::ALT), NavGesture::Roll); 4798 - assert_eq!( 4799 - super::drag_gesture(ModifiersState::CONTROL | ModifiersState::SHIFT), 4800 - NavGesture::Pan, 4801 - "ctrl outranks shift so a held ctrl always pans", 4802 - ); 4803 - } 4804 - 4805 - #[test] 4806 - fn plane_pick_from_idle_enters_sketch_for_known_plane() { 4807 - let frame = shell::ShellFrame { 4808 - plane_picked: Some(Plane::Xy), 4809 - ..empty_frame() 4810 - }; 4811 - let next = next_mode(Mode::Idle, &frame, false, &xy_only()); 4812 - assert_eq!(next, Mode::enter_sketch(SketchId::default())); 4813 - } 4814 - 4815 - #[test] 4816 - fn plane_pick_from_idle_with_no_sketch_for_plane_stays_idle() { 4817 - let frame = shell::ShellFrame { 4818 - plane_picked: Some(Plane::Yz), 4819 - ..empty_frame() 4820 - }; 4821 - let next = next_mode(Mode::Idle, &frame, false, &xy_only()); 4822 - assert_eq!(next, Mode::Idle); 4823 - } 4824 - 4825 - #[test] 4826 - fn plane_pick_while_in_sketch_keeps_current_mode() { 4827 - let prev = Mode::enter_sketch(SketchId::default()); 4828 - let frame = shell::ShellFrame { 4829 - plane_picked: Some(Plane::Xy), 4830 - ..empty_frame() 4831 - }; 4832 - assert_eq!(next_mode(prev.clone(), &frame, false, &xy_only()), prev); 4833 - } 4834 - 4835 - #[test] 4836 - fn ribbon_exit_returns_idle() { 4837 - let prev = Mode::enter_sketch(SketchId::default()); 4838 - let frame = shell::ShellFrame { 4839 - exit_sketch: true, 4840 - ..empty_frame() 4841 - }; 4842 - assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); 4843 - } 4844 - 4845 - #[test] 4846 - fn exit_sketch_action_returns_idle() { 4847 - let prev = Mode::enter_sketch(SketchId::default()); 4848 - assert_eq!( 4849 - next_mode(prev, &empty_frame(), true, &xy_only()), 4850 - Mode::Idle 4851 - ); 4852 - } 4853 - 4854 - #[test] 4855 - fn escape_with_pending_clears_pending_keeps_sketch_and_tool() { 4856 - let prev = Mode::Sketch { 4857 - sketch_id: SketchId::default(), 4858 - session: Box::new(SketchSession { 4859 - tool: Some(SketchTool::Line), 4860 - pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm( 4861 - 1.0, 2.0, 4862 - )))), 4863 - ..SketchSession::default() 4864 - }), 4865 - }; 4866 - let next = next_mode(prev, &empty_frame(), true, &xy_only()); 4867 - let Mode::Sketch { session, .. } = next else { 4868 - panic!("escape with pending must keep sketch mode"); 4869 - }; 4870 - assert_eq!(session.tool, Some(SketchTool::Line)); 4871 - assert_eq!(session.pending, None); 4872 - } 4873 - 4874 - fn far_camera() -> Camera2 { 4875 - Camera2::new(ViewportExtent::new( 4876 - ViewportPx::new(800), 4877 - ViewportPx::new(600), 4878 - )) 4879 - .with_zoom(PixelsPerMm::new(1_000_000.0)) 4880 - } 4881 - 4882 - #[test] 4883 - fn build_preview_in_idle_is_empty() { 4884 - let document = Document::new(DocumentId::default(), "doc".to_owned()); 4885 - let preview = build_preview( 4886 - &Mode::Idle, 4887 - &document, 4888 - Some(Point2::from_mm(1.0, 1.0)), 4889 - &far_camera(), 4890 - ); 4891 - assert!(preview.is_empty()); 4892 - } 4893 - 4894 - #[test] 4895 - fn build_preview_without_armed_tool_is_empty() { 4896 - let document = Document::new(DocumentId::default(), "doc".to_owned()); 4897 - let mode = Mode::enter_sketch(SketchId::default()); 4898 - let preview = build_preview( 4899 - &mode, 4900 - &document, 4901 - Some(Point2::from_mm(0.0, 0.0)), 4902 - &far_camera(), 4903 - ); 4904 - assert!(preview.is_empty()); 4905 - } 4906 - 4907 - #[test] 4908 - fn build_preview_with_position_pending_emits_anchor_and_segment() { 4909 - let sketch = Sketch::new(Plane::Xy.basis()); 4910 - let (document, sketch_id) = initial_document(sketch); 4911 - let anchor = Point2::from_mm(2.0, 3.0); 4912 - let mode = Mode::Sketch { 4913 - sketch_id, 4914 - session: Box::new(SketchSession { 4915 - tool: Some(SketchTool::Line), 4916 - pending: Some(Pending::First(ClickAnchor::Position(anchor))), 4917 - ..SketchSession::default() 4918 - }), 4919 - }; 4920 - let cursor = Point2::from_mm(5.0, 7.0); 4921 - let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); 4922 - assert_eq!(preview.anchors, vec![anchor]); 4923 - assert_eq!(preview.segments, vec![(anchor, cursor)]); 4924 - } 4925 - 4926 - #[test] 4927 - fn build_preview_with_endpoint_pending_resolves_via_document() { 4928 - let sketch = Sketch::new(Plane::Xy.basis()); 4929 - let target = Point2::from_mm(-4.0, 6.0); 4930 - let (sketch, endpoint) = tools::add_point(sketch, target); 4931 - let (document, sketch_id) = initial_document(sketch); 4932 - let mode = Mode::Sketch { 4933 - sketch_id, 4934 - session: Box::new(SketchSession { 4935 - tool: Some(SketchTool::Line), 4936 - pending: Some(Pending::First(ClickAnchor::Endpoint(endpoint))), 4937 - ..SketchSession::default() 4938 - }), 4939 - }; 4940 - let cursor = Point2::from_mm(0.0, 0.0); 4941 - let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); 4942 - assert_eq!(preview.anchors, vec![target]); 4943 - assert_eq!(preview.segments, vec![(target, cursor)]); 4944 - } 4945 - 4946 - #[test] 4947 - fn build_preview_keeps_anchor_when_cursor_outside_viewport() { 4948 - let sketch = Sketch::new(Plane::Xy.basis()); 4949 - let (document, sketch_id) = initial_document(sketch); 4950 - let anchor = Point2::from_mm(1.0, 1.0); 4951 - let mode = Mode::Sketch { 4952 - sketch_id, 4953 - session: Box::new(SketchSession { 4954 - tool: Some(SketchTool::Line), 4955 - pending: Some(Pending::First(ClickAnchor::Position(anchor))), 4956 - ..SketchSession::default() 4957 - }), 4958 - }; 4959 - let preview = build_preview(&mode, &document, None, &far_camera()); 4960 - assert_eq!(preview.anchors, vec![anchor]); 4961 - assert!(preview.segments.is_empty()); 4962 - } 4963 - 4964 - #[test] 4965 - fn build_preview_during_drag_is_empty() { 4966 - let document = Document::new(DocumentId::default(), "doc".to_owned()); 4967 - let mode = Mode::Sketch { 4968 - sketch_id: SketchId::default(), 4969 - session: Box::new(SketchSession { 4970 - tool: Some(SketchTool::Line), 4971 - pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm( 4972 - 0.0, 0.0, 4973 - )))), 4974 - drag: Some(DragSession { 4975 - entity: bone_types::SketchEntityId::default(), 4976 - press: Point2::origin(), 4977 - pins: DragPins::from_array([ 4978 - Some((bone_types::SketchEntityId::default(), Point2::origin())), 4979 - None, 4980 - None, 4981 - ]), 4982 - }), 4983 - ..SketchSession::default() 4984 - }), 4985 - }; 4986 - let preview = build_preview( 4987 - &mode, 4988 - &document, 4989 - Some(Point2::from_mm(1.0, 1.0)), 4990 - &far_camera(), 4991 - ); 4992 - assert!(preview.is_empty()); 4993 - } 4994 - 4995 - #[test] 4996 - fn build_preview_circle_emits_ghost_circle() { 4997 - let sketch = Sketch::new(Plane::Xy.basis()); 4998 - let (document, sketch_id) = initial_document(sketch); 4999 - let center = Point2::from_mm(0.0, 0.0); 5000 - let mode = Mode::Sketch { 5001 - sketch_id, 5002 - session: Box::new(SketchSession { 5003 - tool: Some(SketchTool::Circle), 5004 - pending: Some(Pending::First(ClickAnchor::Position(center))), 5005 - ..SketchSession::default() 5006 - }), 5007 - }; 5008 - let cursor = Point2::from_mm(3.0, 4.0); 5009 - let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); 5010 - assert_eq!(preview.anchors, vec![center]); 5011 - assert_eq!(preview.circles.len(), 1); 5012 - let r = preview.circles[0].radius.get::<millimeter>(); 5013 - assert!((r - 5.0).abs() < 1e-9, "r={r}"); 5014 - } 5015 - 5016 - #[test] 5017 - fn build_preview_corner_rectangle_emits_four_segments() { 5018 - let sketch = Sketch::new(Plane::Xy.basis()); 5019 - let (document, sketch_id) = initial_document(sketch); 5020 - let corner = Point2::from_mm(0.0, 0.0); 5021 - let mode = Mode::Sketch { 5022 - sketch_id, 5023 - session: Box::new(SketchSession { 5024 - tool: Some(SketchTool::CornerRectangle), 5025 - pending: Some(Pending::First(ClickAnchor::Position(corner))), 5026 - ..SketchSession::default() 5027 - }), 5028 - }; 5029 - let cursor = Point2::from_mm(5.0, 3.0); 5030 - let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); 5031 - assert_eq!(preview.anchors, vec![corner]); 5032 - assert_eq!(preview.segments.len(), 4); 5033 - } 5034 - 5035 - #[test] 5036 - fn build_preview_tangent_arc_emits_ghost_arc_after_endpoint_click() { 5037 - let sketch = Sketch::new(Plane::Xy.basis()); 5038 - let (sketch, a) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 5039 - let (sketch, b) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); 5040 - let (sketch, _) = tools::add_line(sketch, a, b, false); 5041 - let (document, sketch_id) = initial_document(sketch); 5042 - let mode = Mode::Sketch { 5043 - sketch_id, 5044 - session: Box::new(SketchSession { 5045 - tool: Some(SketchTool::TangentArc), 5046 - pending: Some(Pending::First(ClickAnchor::Endpoint(b))), 5047 - ..SketchSession::default() 5048 - }), 5049 - }; 5050 - let cursor = Point2::from_mm(10.0, 6.0); 5051 - let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); 5052 - assert_eq!(preview.anchors.len(), 1, "start anchor visible"); 5053 - assert_eq!(preview.arcs.len(), 1, "ghost arc emitted"); 5054 - } 5055 - 5056 - #[test] 5057 - fn build_preview_centerpoint_arc_emits_ghost_arc_after_two_clicks() { 5058 - let sketch = Sketch::new(Plane::Xy.basis()); 5059 - let (document, sketch_id) = initial_document(sketch); 5060 - let center = Point2::from_mm(0.0, 0.0); 5061 - let start = Point2::from_mm(5.0, 0.0); 5062 - let mode = Mode::Sketch { 5063 - sketch_id, 5064 - session: Box::new(SketchSession { 5065 - tool: Some(SketchTool::CenterpointArc), 5066 - pending: Some(Pending::Second( 5067 - ClickAnchor::Position(center), 5068 - ClickAnchor::Position(start), 5069 - )), 5070 - ..SketchSession::default() 5071 - }), 5072 - }; 5073 - let cursor = Point2::from_mm(0.0, 5.0); 5074 - let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); 5075 - assert_eq!(preview.arcs.len(), 1); 5076 - assert_eq!(preview.anchors.len(), 2); 5077 - } 5078 - 5079 - #[test] 5080 - fn escape_with_armed_tool_no_pending_disarms_tool() { 5081 - let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); 5082 - let next = next_mode(prev, &empty_frame(), true, &xy_only()); 5083 - let Mode::Sketch { session, .. } = next else { 5084 - panic!("escape with armed tool must keep sketch mode"); 5085 - }; 5086 - assert_eq!(session.tool, None); 5087 - assert_eq!(session.pending, None); 5088 - } 5089 - 5090 - #[test] 5091 - fn clicking_active_tool_disarms_it() { 5092 - let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); 5093 - let frame = shell::ShellFrame { 5094 - activated_tool: Some(SketchTool::Line), 5095 - ..empty_frame() 5096 - }; 5097 - let next = next_mode(prev, &frame, false, &xy_only()); 5098 - let Mode::Sketch { session, .. } = next else { 5099 - panic!("expected sketch mode"); 5100 - }; 5101 - assert_eq!(session.tool, None); 5102 - } 5103 - 5104 - #[test] 5105 - fn clicking_different_tool_swaps() { 5106 - let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); 5107 - let frame = shell::ShellFrame { 5108 - activated_tool: Some(SketchTool::Point), 5109 - ..empty_frame() 5110 - }; 5111 - let next = next_mode(prev, &frame, false, &xy_only()); 5112 - let Mode::Sketch { session, .. } = next else { 5113 - panic!("expected sketch mode"); 5114 - }; 5115 - assert_eq!(session.tool, Some(SketchTool::Point)); 5116 - } 5117 - 5118 - #[test] 5119 - fn ribbon_exit_overrides_pending_chain() { 5120 - let prev = Mode::Sketch { 5121 - sketch_id: SketchId::default(), 5122 - session: Box::new(SketchSession { 5123 - tool: Some(SketchTool::Line), 5124 - pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm( 5125 - 0.0, 0.0, 5126 - )))), 5127 - ..SketchSession::default() 5128 - }), 5129 - }; 5130 - let frame = shell::ShellFrame { 5131 - exit_sketch: true, 5132 - ..empty_frame() 5133 - }; 5134 - assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); 5135 - } 5136 - 5137 - #[test] 5138 - fn tool_in_idle_does_not_promote_to_sketch() { 5139 - let frame = shell::ShellFrame { 5140 - activated_tool: Some(SketchTool::Line), 5141 - ..empty_frame() 5142 - }; 5143 - assert_eq!(next_mode(Mode::Idle, &frame, false, &xy_only()), Mode::Idle); 5144 - } 5145 - 5146 - #[test] 5147 - fn tool_in_sketch_arms_session() { 5148 - let prev = Mode::enter_sketch(SketchId::default()); 5149 - let frame = shell::ShellFrame { 5150 - activated_tool: Some(SketchTool::Line), 5151 - ..empty_frame() 5152 - }; 5153 - let Mode::Sketch { session, .. } = next_mode(prev, &frame, false, &xy_only()) else { 5154 - panic!("expected sketch mode"); 5155 - }; 5156 - assert_eq!(session.tool, Some(SketchTool::Line)); 5157 - } 5158 - 5159 - #[test] 5160 - fn plane_pick_then_tool_enters_and_arms_in_one_frame() { 5161 - let frame = shell::ShellFrame { 5162 - plane_picked: Some(Plane::Xy), 5163 - activated_tool: Some(SketchTool::Line), 5164 - ..empty_frame() 5165 - }; 5166 - let Mode::Sketch { session, .. } = next_mode(Mode::Idle, &frame, false, &xy_only()) else { 5167 - panic!("expected sketch mode"); 5168 - }; 5169 - assert_eq!(session.tool, Some(SketchTool::Line)); 5170 - } 5171 - 5172 - fn doc_with_default_sketch() -> (Document, SketchId) { 5173 - let sketch = bone_document::Sketch::new(Plane::Xy.basis()); 5174 - let mut document = Document::new(DocumentId::default(), "Untitled".to_owned()); 5175 - let id = SketchId::default(); 5176 - document.insert_sketch(id, "Sketch1".to_owned(), sketch); 5177 - (document, id) 5178 - } 5179 - 5180 - #[test] 5181 - fn apply_sketch_rename_into_writes_label_and_records_undo_on_change() { 5182 - let (mut document, id) = doc_with_default_sketch(); 5183 - let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 5184 - apply_sketch_rename_into( 5185 - &mut document, 5186 - &mut undo, 5187 - shell::SketchRenameRequest { 5188 - id, 5189 - label: "Profile".to_owned(), 5190 - }, 5191 - ); 5192 - assert_eq!(document.sketch_label(id), Some("Profile")); 5193 - assert_eq!( 5194 - undo.past_len(), 5195 - 1, 5196 - "successful rename records one undo snapshot" 5197 - ); 5198 - } 5199 - 5200 - #[test] 5201 - fn apply_sketch_rename_into_drops_empty_label_without_undo() { 5202 - let (mut document, id) = doc_with_default_sketch(); 5203 - let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 5204 - apply_sketch_rename_into( 5205 - &mut document, 5206 - &mut undo, 5207 - shell::SketchRenameRequest { 5208 - id, 5209 - label: " ".to_owned(), 5210 - }, 5211 - ); 5212 - assert_eq!(document.sketch_label(id), Some("Sketch1")); 5213 - assert_eq!(undo.past_len(), 0); 5214 - } 5215 - 5216 - #[test] 5217 - fn apply_sketch_rename_into_skips_no_op_against_trimmed_match() { 5218 - let (mut document, id) = doc_with_default_sketch(); 5219 - let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 5220 - apply_sketch_rename_into( 5221 - &mut document, 5222 - &mut undo, 5223 - shell::SketchRenameRequest { 5224 - id, 5225 - label: " Sketch1 ".to_owned(), 5226 - }, 5227 - ); 5228 - assert_eq!(document.sketch_label(id), Some("Sketch1")); 5229 - assert_eq!( 5230 - undo.past_len(), 5231 - 0, 5232 - "trimmed-equal rename must not record undo" 5233 - ); 5234 - } 5235 - 5236 - fn extrude_node_count(document: &Document) -> usize { 5237 - document 5238 - .feature_tree() 5239 - .iter() 5240 - .filter(|(_, node)| matches!(node, FeatureNode::Extrude(_))) 5241 - .count() 5242 - } 5243 - 5244 - #[test] 5245 - fn commit_armed_extrude_on_accept_adds_node_and_records_undo() { 5246 - let (mut document, sketch) = doc_with_default_sketch(); 5247 - let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 5248 - let mode = Mode::Extrude(ExtrudeArming::profile(sketch)); 5249 - commit_armed_extrude( 5250 - &mut document, 5251 - &mut undo, 5252 - &mode, 5253 - Some(shell::ConfirmAction::Accept), 5254 - ); 5255 - assert_eq!(extrude_node_count(&document), 1); 5256 - assert_eq!(undo.past_len(), 1); 5257 - } 5258 - 5259 - #[test] 5260 - fn commit_armed_extrude_ignores_cancel_and_non_extrude_mode() { 5261 - let (mut document, sketch) = doc_with_default_sketch(); 5262 - let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 5263 - commit_armed_extrude( 5264 - &mut document, 5265 - &mut undo, 5266 - &Mode::Extrude(ExtrudeArming::profile(sketch)), 5267 - Some(shell::ConfirmAction::Cancel), 5268 - ); 5269 - commit_armed_extrude( 5270 - &mut document, 5271 - &mut undo, 5272 - &Mode::Idle, 5273 - Some(shell::ConfirmAction::Accept), 5274 - ); 5275 - assert_eq!(extrude_node_count(&document), 0); 5276 - assert_eq!(undo.past_len(), 0); 5277 - } 5278 - 5279 - #[test] 5280 - fn commit_armed_extrude_edit_target_updates_in_place_keeping_label() { 5281 - let (mut document, sketch) = doc_with_default_sketch(); 5282 - let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); 5283 - let Ok(()) = document.rename_extrude(id, "Boss") else { 5284 - panic!("rename accepts"); 5285 - }; 5286 - let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 5287 - let mode = Mode::Extrude(ExtrudeArming::edit( 5288 - id, 5289 - sketch_mode::default_extrude_feature(sketch), 5290 - )); 5291 - commit_armed_extrude( 5292 - &mut document, 5293 - &mut undo, 5294 - &mode, 5295 - Some(shell::ConfirmAction::Accept), 5296 - ); 5297 - assert_eq!(extrude_node_count(&document), 1, "editing reuses the node"); 5298 - assert_eq!(document.extrude_label(id), Some("Boss")); 5299 - } 5300 - 5301 - #[test] 5302 - fn extrude_edit_mode_arms_from_idle_but_not_from_sketch() { 5303 - let (mut document, sketch) = doc_with_default_sketch(); 5304 - let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); 5305 - let from_idle = extrude_edit_mode(&document, &Mode::Idle, Some(id)); 5306 - assert!(matches!( 5307 - from_idle, 5308 - Some(Mode::Extrude(ExtrudeArming::Profile { target: Some(t), .. })) if t == id 5309 - )); 5310 - assert_eq!( 5311 - extrude_edit_mode(&document, &Mode::enter_sketch(sketch), Some(id)), 5312 - None, 5313 - "double-click is inert while sketching", 5314 - ); 5315 - assert_eq!( 5316 - extrude_edit_mode(&document, &Mode::Idle, Some(ExtrudeId::default())), 5317 - None, 5318 - "unknown extrude id arms nothing", 5319 - ); 5320 - } 5321 - 5322 - #[test] 5323 - fn active_solid_feature_tracks_mode_then_falls_back_to_committed() { 5324 - let (mut document, sketch) = doc_with_default_sketch(); 5325 - let armed = sketch_mode::default_extrude_feature(sketch); 5326 - assert_eq!( 5327 - active_solid_feature( 5328 - &Mode::Extrude(ExtrudeArming::profile(sketch)), 5329 - &document, 5330 - None, 5331 - ), 5332 - Some(armed), 5333 - "an armed profile previews its own feature", 5334 - ); 5335 - assert_eq!( 5336 - active_solid_feature(&Mode::enter_sketch(sketch), &document, None), 5337 - None, 5338 - "sketching shows the 2D scene, not a solid", 5339 - ); 5340 - assert_eq!( 5341 - active_solid_feature(&Mode::Idle, &document, None), 5342 - None, 5343 - "idle with no committed extrude shows no solid", 5344 - ); 5345 - let _ = document.commit_extrude(armed); 5346 - assert_eq!( 5347 - active_solid_feature(&Mode::Idle, &document, None), 5348 - Some(armed), 5349 - "idle falls back to the committed extrude", 5350 - ); 5351 - } 5352 - 5353 - #[test] 5354 - fn framed_extrude_overrides_last_committed_in_idle() { 5355 - let (mut document, sketch) = doc_with_default_sketch(); 5356 - let first_feature = sketch_mode::default_extrude_feature(sketch); 5357 - let mut second_feature = first_feature; 5358 - second_feature.merge_result = bone_document::MergeResult::Separate; 5359 - let first = document.commit_extrude(first_feature); 5360 - let _second = document.commit_extrude(second_feature); 5361 - assert_eq!( 5362 - active_solid_feature(&Mode::Idle, &document, None), 5363 - Some(second_feature), 5364 - "with no framed id, idle frames the last-committed extrude", 5365 - ); 5366 - assert_eq!( 5367 - active_solid_feature(&Mode::Idle, &document, Some(first)), 5368 - Some(first_feature), 5369 - "a framed id wins over the tree tip, so editing a non-last extrude stays framed", 5370 - ); 5371 - assert_eq!( 5372 - active_solid_feature(&Mode::Idle, &document, Some(ExtrudeId::default())), 5373 - Some(second_feature), 5374 - "a stale framed id self-heals to the last-committed extrude", 5375 - ); 5376 - } 5377 - 5378 - #[test] 5379 - fn apply_extrude_rename_into_writes_label_and_records_undo() { 5380 - let (mut document, sketch) = doc_with_default_sketch(); 5381 - let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); 5382 - let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 5383 - apply_extrude_rename_into( 5384 - &mut document, 5385 - &mut undo, 5386 - shell::ExtrudeRenameRequest { 5387 - id, 5388 - label: "Boss".to_owned(), 5389 - }, 5390 - ); 5391 - assert_eq!(document.extrude_label(id), Some("Boss")); 5392 - assert_eq!(undo.past_len(), 1); 5393 - } 5394 - 5395 - #[test] 5396 - fn sketch_activated_from_idle_enters_that_sketch_without_plane_map() { 5397 - let sketch_id = SketchId::default(); 5398 - let frame = shell::ShellFrame { 5399 - sketch_activated: Some(sketch_id), 5400 - ..empty_frame() 5401 - }; 5402 - let next = next_mode(Mode::Idle, &frame, false, &BTreeMap::new()); 5403 - assert_eq!(next, Mode::enter_sketch(sketch_id)); 5404 - } 5405 - 5406 - #[test] 5407 - fn sketch_pick_in_extrude_sets_profile_instead_of_editing() { 5408 - let sketch_id = SketchId::default(); 5409 - let frame = shell::ShellFrame { 5410 - sketch_activated: Some(sketch_id), 5411 - ..empty_frame() 5412 - }; 5413 - assert_eq!( 5414 - next_mode( 5415 - Mode::Extrude(ExtrudeArming::AwaitingSketch), 5416 - &frame, 5417 - false, 5418 - &xy_only(), 5419 - ), 5420 - Mode::Extrude(ExtrudeArming::profile(sketch_id)), 5421 - ); 5422 - assert_eq!( 5423 - next_mode( 5424 - Mode::Extrude(ExtrudeArming::profile(sketch_id)), 5425 - &frame, 5426 - false, 5427 - &xy_only(), 5428 - ), 5429 - Mode::Extrude(ExtrudeArming::profile(sketch_id)), 5430 - "a pick while armed re-targets the profile, never drops into sketch editing", 5431 - ); 5432 - } 5433 - 5434 - #[test] 5435 - fn sketch_activated_while_in_sketch_is_ignored() { 5436 - let prev = Mode::enter_sketch(SketchId::default()); 5437 - let frame = shell::ShellFrame { 5438 - sketch_activated: Some(SketchId::default()), 5439 - ..empty_frame() 5440 - }; 5441 - assert_eq!(next_mode(prev.clone(), &frame, false, &xy_only()), prev); 5442 - } 5443 - 5444 - #[test] 5445 - fn exit_action_wins_over_pending_tool() { 5446 - let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); 5447 - let frame = shell::ShellFrame { 5448 - exit_sketch: true, 5449 - ..empty_frame() 5450 - }; 5451 - assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); 5452 - } 5453 - 5454 - #[test] 5455 - fn idle_scopes_omit_sketch_scope() { 5456 - let scopes = scopes_for_mode(&Mode::Idle); 5457 - let collected: Vec<_> = scopes.innermost_first().copied().collect(); 5458 - assert!(!collected.contains(&HotkeyScope::Sketch)); 5459 - assert!(collected.contains(&HotkeyScope::Global)); 5460 - } 5461 - 5462 - #[test] 5463 - fn sketch_scopes_include_sketch_scope() { 5464 - let scopes = scopes_for_mode(&Mode::enter_sketch(SketchId::default())); 5465 - let collected: Vec<_> = scopes.innermost_first().copied().collect(); 5466 - assert!(collected.contains(&HotkeyScope::Sketch)); 5467 - assert!(collected.contains(&HotkeyScope::Global)); 5468 - } 5469 - 5470 - #[test] 5471 - fn hotkey_table_binds_escape_to_exit_under_sketch_scope() { 5472 - let table = build_hotkey_table(); 5473 - let chord = KeyChord::new(UiKeyCode::Named(NamedKey::Escape), ModifierMask::NONE); 5474 - let in_sketch = scopes_for_mode(&Mode::enter_sketch(SketchId::default())); 5475 - assert_eq!( 5476 - table.dispatch(chord, &in_sketch), 5477 - Some(sketch_mode::ESCAPE_ACTION) 5478 - ); 5479 - let in_idle = scopes_for_mode(&Mode::Idle); 5480 - assert_eq!(table.dispatch(chord, &in_idle), None); 5481 - } 5482 - 5483 - #[test] 5484 - fn extrude_scope_isolates_escape_from_sketch_tools() { 5485 - let extrude = scopes_for_mode(&Mode::Extrude(ExtrudeArming::AwaitingSketch)); 5486 - let in_extrude: Vec<_> = extrude.innermost_first().copied().collect(); 5487 - assert!(in_extrude.contains(&HotkeyScope::Extrude)); 5488 - assert!( 5489 - !in_extrude.contains(&HotkeyScope::Sketch), 5490 - "extrude must not activate the sketch-tool scope", 5491 - ); 5492 - let sketch = scopes_for_mode(&Mode::enter_sketch(SketchId::default())); 5493 - let in_sketch: Vec<_> = sketch.innermost_first().copied().collect(); 5494 - assert!(!in_sketch.contains(&HotkeyScope::Extrude)); 5495 - } 5496 - 5497 - #[test] 5498 - fn hotkey_table_binds_escape_under_extrude_scope() { 5499 - let table = build_hotkey_table(); 5500 - let chord = KeyChord::new(UiKeyCode::Named(NamedKey::Escape), ModifierMask::NONE); 5501 - let in_extrude = scopes_for_mode(&Mode::Extrude(ExtrudeArming::AwaitingSketch)); 5502 - assert_eq!( 5503 - table.dispatch(chord, &in_extrude), 5504 - Some(sketch_mode::ESCAPE_ACTION) 5505 - ); 5506 - } 5507 - 5508 - #[test] 5509 - fn escape_exits_extrude_to_idle() { 5510 - let frame = empty_frame(); 5511 - let awaiting = Mode::Extrude(ExtrudeArming::AwaitingSketch); 5512 - assert_eq!(next_mode(awaiting, &frame, true, &xy_only()), Mode::Idle); 5513 - let profiled = Mode::Extrude(ExtrudeArming::profile(SketchId::default())); 5514 - assert_eq!(next_mode(profiled, &frame, true, &xy_only()), Mode::Idle); 5515 - } 5516 - 5517 - #[test] 5518 - fn cancel_pending_or_exit_drops_extrude_arming() { 5519 - assert_eq!( 5520 - cancel_pending_or_exit(Mode::Extrude(ExtrudeArming::AwaitingSketch)), 5521 - Mode::Idle 5522 - ); 5523 - } 5524 - 5525 - #[test] 5526 - fn driven_with_value_promotes_kind_and_overwrites_value() { 5527 - let proto = SketchDimension::Linear { 5528 - a: bone_types::SketchEntityId::default(), 5529 - b: bone_types::SketchEntityId::default(), 5530 - value: Length::new::<millimeter>(8.0), 5531 - kind: DimensionKind::Driving, 5532 - }; 5533 - let measured = DimensionValue::Length(Length::new::<millimeter>(10.0)); 5534 - let Some(driven) = driven_with_value(proto, measured) else { 5535 - panic!("driven_with_value rejected matched-kind value"); 5536 - }; 5537 - assert_eq!(driven.kind(), DimensionKind::Driven); 5538 - let DimensionValue::Length(length) = driven.value() else { 5539 - panic!("expected Length"); 5540 - }; 5541 - assert!((length.get::<millimeter>() - 10.0).abs() < 1e-9); 5542 - } 5543 - 5544 - #[test] 5545 - fn driven_with_value_rejects_kind_mismatch() { 5546 - let proto = SketchDimension::Linear { 5547 - a: bone_types::SketchEntityId::default(), 5548 - b: bone_types::SketchEntityId::default(), 5549 - value: Length::new::<millimeter>(1.0), 5550 - kind: DimensionKind::Driving, 5551 - }; 5552 - let bad = DimensionValue::Angle(bone_types::Angle::new::<uom::si::angle::radian>(1.0)); 5553 - assert!(driven_with_value(proto, bad).is_none()); 5554 - } 5555 - 5556 - #[test] 5557 - fn dim_conflict_pending_returns_proto_when_set() { 5558 - let proto = SketchDimension::Linear { 5559 - a: bone_types::SketchEntityId::default(), 5560 - b: bone_types::SketchEntityId::default(), 5561 - value: Length::new::<millimeter>(2.0), 5562 - kind: DimensionKind::Driving, 5563 - }; 5564 - let pending = PendingDimension { 5565 - proto, 5566 - anchor: Point2::origin(), 5567 - }; 5568 - let mode = Mode::enter_sketch(SketchId::default()).start_dim_conflict(pending); 5569 - assert_eq!(dim_conflict_pending(&mode), Some(pending)); 5570 - } 5571 - 5572 - #[test] 5573 - fn dim_conflict_pending_returns_none_in_idle() { 5574 - assert_eq!(dim_conflict_pending(&Mode::Idle), None); 5575 - } 5576 - 5577 - fn horizontal_line_fixture() -> ( 5578 - Sketch, 5579 - bone_types::SketchEntityId, 5580 - bone_types::SketchEntityId, 5581 - bone_types::SketchEntityId, 5582 - ) { 5583 - let sketch = Sketch::new(Plane::Xy.basis()); 5584 - let (sketch, a) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 5585 - let (sketch, b) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); 5586 - let (sketch, line) = tools::add_line(sketch, a, b, false); 5587 - let sketch = tools::add_relation(sketch, SketchRelation::Horizontal(line)); 5588 - let sketch = tools::add_relation(sketch, SketchRelation::Fix(a)); 5589 - (sketch, a, b, line) 5590 - } 5591 - 5592 - fn point_at(sketch: &Sketch, id: bone_types::SketchEntityId) -> Point2 { 5593 - let SketchEntity::Point(p) = sketch.entities()[id] else { 5594 - panic!("expected point entity"); 5595 - }; 5596 - p.at() 5597 - } 5598 - 5599 - #[test] 5600 - fn drag_resolved_translates_endpoint_and_preserves_horizontal() { 5601 - let (sketch, a, b, _) = horizontal_line_fixture(); 5602 - let drag = DragSession { 5603 - entity: b, 5604 - press: Point2::from_mm(10.0, 0.0), 5605 - pins: DragPins::from_array([Some((b, Point2::from_mm(10.0, 0.0))), None, None]), 5606 - }; 5607 - let cursor = Point2::from_mm(13.0, 0.0); 5608 - let Some(next) = drag_resolved(&sketch, drag, cursor) else { 5609 - panic!("solve_with_drag_pins must converge on horizontal line") 5610 - }; 5611 - let (bx, by) = point_at(&next, b).coords_mm(); 5612 - assert!((bx - 13.0).abs() < 1e-6, "b.x slides under cursor: {bx}"); 5613 - assert!(by.abs() < 1e-6, "horizontal preserved: by={by}"); 5614 - let (ax, ay) = point_at(&next, a).coords_mm(); 5615 - assert!(ax.abs() < 1e-9, "fixed a.x stays put: {ax}"); 5616 - assert!(ay.abs() < 1e-9, "fixed a.y stays put: {ay}"); 5617 - } 5618 - 5619 - #[test] 5620 - fn mirror_axis_reflects_across_horizontal_x_axis() { 5621 - let axis = MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); 5622 - let (rx, ry) = axis.reflect(Point2::from_mm(3.0, 4.0)).coords_mm(); 5623 - assert!((rx - 3.0).abs() < 1e-9, "x preserved across x-axis"); 5624 - assert!((ry - -4.0).abs() < 1e-9, "y inverted across x-axis"); 5625 - } 5626 - 5627 - #[test] 5628 - fn mirror_axis_reflects_across_diagonal() { 5629 - let axis = MirrorAxis::from_points(Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 1.0)); 5630 - let (rx, ry) = axis.reflect(Point2::from_mm(2.0, 0.0)).coords_mm(); 5631 - assert!((rx - 0.0).abs() < 1e-9, "x reflects to y on y=x diagonal"); 5632 - assert!((ry - 2.0).abs() < 1e-9, "y reflects to x on y=x diagonal"); 5633 - } 5634 - 5635 - #[test] 5636 - fn mirror_axis_detects_degenerate_zero_length() { 5637 - let axis = MirrorAxis::from_points(Point2::from_mm(1.0, 1.0), Point2::from_mm(1.0, 1.0)); 5638 - assert!( 5639 - axis.is_degenerate(), 5640 - "coincident endpoints must be degenerate" 5641 - ); 5642 - } 5643 - 5644 - #[test] 5645 - fn mirror_targets_creates_reflected_circle_with_symmetric_relations() { 5646 - let (sketch, _, _, axis_line) = horizontal_line_fixture(); 5647 - let (sketch, center) = tools::add_point(sketch, Point2::from_mm(0.0, 3.0)); 5648 - let (sketch, circle_id) = 5649 - tools::add_circle(sketch, center, Length::new::<millimeter>(1.0), false); 5650 - let axis_geom = 5651 - MirrorAxis::from_points(Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 0.0)); 5652 - let source_ids: std::collections::BTreeSet<_> = [center, circle_id].into_iter().collect(); 5653 - let Ok(mirrored) = mirror_targets(sketch.clone(), &source_ids, axis_line, &axis_geom) 5654 - else { 5655 - panic!("circle mirror must succeed"); 5656 - }; 5657 - let new_circles: Vec<_> = mirrored 5658 - .entities() 5659 - .iter() 5660 - .filter_map(|(_, e)| match *e { 5661 - SketchEntity::Circle(c) => Some(c), 5662 - _ => None, 5663 - }) 5664 - .collect(); 5665 - assert_eq!(new_circles.len(), 2, "original + mirrored circle"); 5666 - let new_center_pos = new_circles 5667 - .iter() 5668 - .map(|c| { 5669 - let SketchEntity::Point(p) = mirrored.entities()[c.center()] else { 5670 - panic!("circle center is a point"); 5671 - }; 5672 - p.at().coords_mm() 5673 - }) 5674 - .find(|(_, y)| *y < 0.0); 5675 - let Some((cx, cy)) = new_center_pos else { 5676 - panic!("mirrored circle must lie below x-axis"); 5677 - }; 5678 - assert!(cx.abs() < 1e-9 && (cy + 3.0).abs() < 1e-9, "({cx}, {cy})"); 5679 - let symmetric_count = mirrored 5680 - .relations() 5681 - .iter() 5682 - .filter( 5683 - |(_, r)| matches!(r, SketchRelation::Symmetric { axis, .. } if *axis == axis_line), 5684 - ) 5685 - .count(); 5686 - assert!( 5687 - symmetric_count >= 1, 5688 - "mirror must emit at least one Symmetric relation tied to the axis", 5689 - ); 5690 - } 5691 - 5692 - #[test] 5693 - fn mirror_copies_horizontal_relation_to_mirrored_line() { 5694 - use bone_document::SketchEdit; 5695 - let sketch = Sketch::new(Plane::Xy.basis()); 5696 - let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0)); 5697 - let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0)); 5698 - let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true); 5699 - let (sketch, source_p0) = tools::add_point(sketch, Point2::from_mm(-2.0, 3.0)); 5700 - let (sketch, source_p1) = tools::add_point(sketch, Point2::from_mm(2.0, 3.0)); 5701 - let (sketch, source_line) = tools::add_line(sketch, source_p0, source_p1, false); 5702 - let Ok((sketch, _)) = sketch.apply(SketchEdit::AddRelation(SketchRelation::Horizontal( 5703 - source_line, 5704 - ))) else { 5705 - panic!("seed Horizontal must apply"); 5706 - }; 5707 - let axis_geom = 5708 - MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); 5709 - let source_ids: std::collections::BTreeSet<_> = 5710 - [source_p0, source_p1, source_line].into_iter().collect(); 5711 - let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else { 5712 - panic!("mirror must succeed"); 5713 - }; 5714 - let horizontal_lines: Vec<_> = mirrored 5715 - .relations() 5716 - .iter() 5717 - .filter_map(|(_, r)| match r { 5718 - SketchRelation::Horizontal(id) => Some(*id), 5719 - _ => None, 5720 - }) 5721 - .collect(); 5722 - assert_eq!( 5723 - horizontal_lines.len(), 5724 - 2, 5725 - "original + mirrored horizontal must both exist" 5726 - ); 5727 - } 5728 - 5729 - #[test] 5730 - fn construction_toggle_flips_line_flag() { 5731 - let (sketch, _, _, line) = horizontal_line_fixture(); 5732 - let before = match sketch.entities()[line] { 5733 - SketchEntity::Line(l) => l.for_construction(), 5734 - _ => panic!("line"), 5735 - }; 5736 - let Ok((next, _)) = sketch.apply(SketchEdit::SetConstruction { 5737 - id: line, 5738 - for_construction: !before, 5739 - }) else { 5740 - panic!("set construction must succeed"); 5741 - }; 5742 - let after = match next.entities()[line] { 5743 - SketchEntity::Line(l) => l.for_construction(), 5744 - _ => panic!("line"), 5745 - }; 5746 - assert_ne!(before, after); 5747 - } 5748 - 5749 - #[test] 5750 - fn mirror_axis_detects_on_axis_point() { 5751 - let axis = MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); 5752 - assert!(axis.is_on_axis(Point2::from_mm(2.0, 0.0))); 5753 - assert!(axis.is_on_axis(Point2::from_mm(-5.0, 0.0))); 5754 - assert!(!axis.is_on_axis(Point2::from_mm(2.0, 0.5))); 5755 - } 5756 - 5757 - #[test] 5758 - fn mirror_on_axis_source_point_is_identity_no_self_symmetric() { 5759 - let sketch = Sketch::new(Plane::Xy.basis()); 5760 - let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0)); 5761 - let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0)); 5762 - let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true); 5763 - let (sketch, on_axis_point) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 5764 - let axis_geom = 5765 - MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); 5766 - let source_ids: std::collections::BTreeSet<_> = [on_axis_point].into_iter().collect(); 5767 - let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else { 5768 - panic!("mirror must succeed"); 5769 - }; 5770 - let point_count = mirrored 5771 - .entities() 5772 - .iter() 5773 - .filter(|(_, e)| matches!(e, SketchEntity::Point(_))) 5774 - .count(); 5775 - assert_eq!( 5776 - point_count, 3, 5777 - "on-axis source must not produce a duplicate point: {point_count}" 5778 - ); 5779 - let symmetric_count = mirrored 5780 - .relations() 5781 - .iter() 5782 - .filter(|(_, r)| matches!(r, SketchRelation::Symmetric { .. })) 5783 - .count(); 5784 - assert_eq!( 5785 - symmetric_count, 0, 5786 - "on-axis source must not emit a self-pair Symmetric relation" 5787 - ); 5788 - } 5789 - 5790 - #[test] 5791 - fn mirror_copies_relation_referencing_axis_line() { 5792 - use bone_document::SketchEdit; 5793 - let sketch = Sketch::new(Plane::Xy.basis()); 5794 - let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0)); 5795 - let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0)); 5796 - let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true); 5797 - let (sketch, off_a) = tools::add_point(sketch, Point2::from_mm(-2.0, 3.0)); 5798 - let (sketch, off_b) = tools::add_point(sketch, Point2::from_mm(2.0, 3.0)); 5799 - let (sketch, off_line) = tools::add_line(sketch, off_a, off_b, false); 5800 - let Ok((sketch, _)) = sketch.apply(SketchEdit::AddRelation(SketchRelation::Parallel( 5801 - off_line, axis_line, 5802 - ))) else { 5803 - panic!("seed Parallel(off_line, axis_line) must apply"); 5804 - }; 5805 - let axis_geom = 5806 - MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); 5807 - let source_ids: std::collections::BTreeSet<_> = 5808 - [off_a, off_b, off_line].into_iter().collect(); 5809 - let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else { 5810 - panic!("mirror must succeed"); 5811 - }; 5812 - let parallel_count = mirrored 5813 - .relations() 5814 - .iter() 5815 - .filter(|(_, r)| matches!(r, SketchRelation::Parallel(_, b) if *b == axis_line)) 5816 - .count(); 5817 - assert_eq!( 5818 - parallel_count, 2, 5819 - "original + mirrored Parallel(line, axis_line) both expected: {parallel_count}", 5820 - ); 5821 - } 5822 - }
+1 -9
crates/bone-app/src/redraw.rs
··· 1 + use bone_app::InputDispatched; 1 2 use std::sync::Arc; 2 3 use winit::window::Window; 3 4 ··· 6 7 pub struct Scheduler { 7 8 window: Arc<Window>, 8 9 kicks: u8, 9 - } 10 - 11 - #[must_use = "input dispatch must be acknowledged via Scheduler::schedule"] 12 - pub struct InputDispatched(()); 13 - 14 - impl InputDispatched { 15 - pub(crate) fn after_input() -> Self { 16 - Self(()) 17 - } 18 10 } 19 11 20 12 impl Scheduler {
+25
crates/bone-jig/Cargo.toml
··· 1 + [package] 2 + name = "bone-jig" 3 + version.workspace = true 4 + edition.workspace = true 5 + license.workspace = true 6 + rust-version.workspace = true 7 + 8 + [dependencies] 9 + bone-app = { workspace = true } 10 + bone-render = { workspace = true } 11 + bone-ui = { workspace = true } 12 + 13 + accesskit = { workspace = true } 14 + pollster = { workspace = true } 15 + ron = { workspace = true } 16 + serde = { workspace = true } 17 + serde_json = { workspace = true } 18 + thiserror = { workspace = true } 19 + wgpu = { workspace = true } 20 + 21 + [dev-dependencies] 22 + tempfile = { workspace = true } 23 + 24 + [lints] 25 + workspace = true
+7
crates/bone-jig/scenarios/cold_shell.ron
··· 1 + Scenario( 2 + steps: [ 3 + Advance(2), 4 + Snapshot("cold-shell"), 5 + DumpTree("cold-shell"), 6 + ], 7 + )
+8
crates/bone-jig/scenarios/cold_shell_dark.ron
··· 1 + Scenario( 2 + steps: [ 3 + Theme(Dark), 4 + Advance(2), 5 + Snapshot("cold-shell-dark"), 6 + DumpTree("cold-shell-dark"), 7 + ], 8 + )
+16
crates/bone-jig/scenarios/dialog_open.ron
··· 1 + Scenario( 2 + steps: [ 3 + Advance(2), 4 + Click(target: Label("Sketch1")), 5 + Click(target: Label("Sketch1")), 6 + Click(target: Label("Point")), 7 + Click(target: At(x: 700, y: 400)), 8 + Key(code: Named(Escape)), 9 + Key(code: Named(Escape)), 10 + Advance(2), 11 + Key(code: Char('n'), modifiers: [Ctrl]), 12 + Advance(2), 13 + Snapshot("dialog-open"), 14 + DumpTree("dialog-open"), 15 + ], 16 + )
+17
crates/bone-jig/scenarios/dialog_open_dark.ron
··· 1 + Scenario( 2 + steps: [ 3 + Theme(Dark), 4 + Advance(2), 5 + Click(target: Label("Sketch1")), 6 + Click(target: Label("Sketch1")), 7 + Click(target: Label("Point")), 8 + Click(target: At(x: 700, y: 400)), 9 + Key(code: Named(Escape)), 10 + Key(code: Named(Escape)), 11 + Advance(2), 12 + Key(code: Char('n'), modifiers: [Ctrl]), 13 + Advance(2), 14 + Snapshot("dialog-open-dark"), 15 + DumpTree("dialog-open-dark"), 16 + ], 17 + )
+10
crates/bone-jig/scenarios/sketch_mode.ron
··· 1 + Scenario( 2 + steps: [ 3 + Advance(2), 4 + Click(target: Label("Sketch1")), 5 + Click(target: Label("Sketch1")), 6 + Advance(2), 7 + Snapshot("sketch-mode"), 8 + DumpTree("sketch-mode"), 9 + ], 10 + )
+11
crates/bone-jig/scenarios/sketch_mode_dark.ron
··· 1 + Scenario( 2 + steps: [ 3 + Theme(Dark), 4 + Advance(2), 5 + Click(target: Label("Sketch1")), 6 + Click(target: Label("Sketch1")), 7 + Advance(2), 8 + Snapshot("sketch-mode-dark"), 9 + DumpTree("sketch-mode-dark"), 10 + ], 11 + )
+14
crates/bone-jig/scenarios/smoke.ron
··· 1 + Scenario( 2 + steps: [ 3 + Advance(2), 4 + PointerMove(Label("Point")), 5 + Click(target: Label("File")), 6 + Key(code: Named(Escape)), 7 + Click(target: Label("Features")), 8 + Click(target: Label("Extruded Boss/Base")), 9 + Click(target: Label("Right")), 10 + Advance(30), 11 + Snapshot("final"), 12 + DumpTree("final"), 13 + ], 14 + )
+21
crates/bone-jig/src/lib.rs
··· 1 + mod run; 2 + mod scenario; 3 + mod target; 4 + mod tree; 5 + 6 + pub use run::{JigError, RunResult, RunStatus, StepOutcome, StepRecord, run_scenario}; 7 + pub use scenario::{ 8 + ArtifactName, ArtifactNameError, Modifier, NodeRef, NodeRefParseError, PointerTarget, Scenario, 9 + ScenarioError, ScenarioSource, Step, modifier_mask, 10 + }; 11 + pub use target::{Candidate, ResolveError, resolve_target}; 12 + pub use tree::{DumpNode, DumpStates, TreeDump, TreeDumpError}; 13 + 14 + use bone_render::{AdapterPolicy, OffscreenContext, Result, ViewportExtent, ViewportPx}; 15 + 16 + pub const DEFAULT_VIEWPORT: ViewportExtent = 17 + ViewportExtent::new(ViewportPx::new(1280), ViewportPx::new(800)); 18 + 19 + pub fn offscreen_context(extent: ViewportExtent) -> Result<OffscreenContext> { 20 + pollster::block_on(OffscreenContext::new(extent, AdapterPolicy::Software)) 21 + }
+433
crates/bone-jig/src/main.rs
··· 1 + use std::path::PathBuf; 2 + use std::process::ExitCode; 3 + 4 + use bone_jig::{RunResult, RunStatus, Scenario, ScenarioSource, Step, StepOutcome, run_scenario}; 5 + use bone_render::{PixelDiff, PixelDiffThreshold, decode_png, encode_png_rgba}; 6 + use thiserror::Error; 7 + 8 + const USAGE: &str = "usage: 9 + bone-jig run <scenario.ron|-> --out <dir> [--document <part>] 10 + bone-jig diff <a.png> <b.png> [--out <diff.png>] [--threshold <0-255>]"; 11 + 12 + const EXIT_FAILED: u8 = 1; 13 + const EXIT_USAGE: u8 = 2; 14 + const EXIT_ERROR: u8 = 3; 15 + 16 + #[derive(Debug, PartialEq, Eq, Error)] 17 + enum CliError { 18 + #[error("missing command")] 19 + MissingCommand, 20 + #[error("unknown command: {0}")] 21 + UnknownCommand(String), 22 + #[error("missing argument: {0}")] 23 + MissingArg(&'static str), 24 + #[error("unexpected argument: {0}")] 25 + UnexpectedArg(String), 26 + #[error("threshold {0:?} is not an integer in 0-255")] 27 + BadThreshold(String), 28 + } 29 + 30 + #[derive(Debug, PartialEq)] 31 + enum Command { 32 + Run { 33 + scenario: ScenarioSource, 34 + out: PathBuf, 35 + document: Option<PathBuf>, 36 + }, 37 + Diff { 38 + left: PathBuf, 39 + right: PathBuf, 40 + out: Option<PathBuf>, 41 + threshold: PixelDiffThreshold, 42 + }, 43 + } 44 + 45 + fn parse(args: &[String]) -> Result<Command, CliError> { 46 + match args { 47 + [] => Err(CliError::MissingCommand), 48 + [command, rest @ ..] if command == "run" => parse_run(rest, RunArgs::default()), 49 + [command, rest @ ..] if command == "diff" => parse_diff(rest, DiffArgs::default()), 50 + [command, ..] => Err(CliError::UnknownCommand(command.clone())), 51 + } 52 + } 53 + 54 + #[derive(Default)] 55 + struct RunArgs { 56 + scenario: Option<ScenarioSource>, 57 + out: Option<PathBuf>, 58 + document: Option<PathBuf>, 59 + } 60 + 61 + fn scenario_source(arg: &str) -> ScenarioSource { 62 + if arg == "-" { 63 + ScenarioSource::Stdin 64 + } else { 65 + ScenarioSource::Path(PathBuf::from(arg)) 66 + } 67 + } 68 + 69 + fn parse_run(args: &[String], acc: RunArgs) -> Result<Command, CliError> { 70 + match args { 71 + [] => Ok(Command::Run { 72 + scenario: acc.scenario.ok_or(CliError::MissingArg("<scenario.ron>"))?, 73 + out: acc.out.ok_or(CliError::MissingArg("--out <dir>"))?, 74 + document: acc.document, 75 + }), 76 + [flag, value, rest @ ..] if flag == "--out" => parse_run( 77 + rest, 78 + RunArgs { 79 + out: Some(PathBuf::from(value)), 80 + ..acc 81 + }, 82 + ), 83 + [flag, value, rest @ ..] if flag == "--document" => parse_run( 84 + rest, 85 + RunArgs { 86 + document: Some(PathBuf::from(value)), 87 + ..acc 88 + }, 89 + ), 90 + [flag] if flag == "--out" => Err(CliError::MissingArg("--out <dir>")), 91 + [flag] if flag == "--document" => Err(CliError::MissingArg("--document <part>")), 92 + [positional, rest @ ..] if acc.scenario.is_none() && !positional.starts_with("--") => { 93 + parse_run( 94 + rest, 95 + RunArgs { 96 + scenario: Some(scenario_source(positional)), 97 + ..acc 98 + }, 99 + ) 100 + } 101 + [unexpected, ..] => Err(CliError::UnexpectedArg(unexpected.clone())), 102 + } 103 + } 104 + 105 + #[derive(Default)] 106 + struct DiffArgs { 107 + images: Vec<PathBuf>, 108 + out: Option<PathBuf>, 109 + threshold: Option<PixelDiffThreshold>, 110 + } 111 + 112 + fn parse_threshold(value: &str) -> Result<PixelDiffThreshold, CliError> { 113 + value 114 + .parse::<u8>() 115 + .map(|n| PixelDiffThreshold::new(f64::from(n) / 255.0)) 116 + .map_err(|_| CliError::BadThreshold(value.to_owned())) 117 + } 118 + 119 + fn parse_diff(args: &[String], acc: DiffArgs) -> Result<Command, CliError> { 120 + match args { 121 + [] => match <[PathBuf; 2]>::try_from(acc.images) { 122 + Ok([left, right]) => Ok(Command::Diff { 123 + left, 124 + right, 125 + out: acc.out, 126 + threshold: acc.threshold.unwrap_or(PixelDiffThreshold::EXACT), 127 + }), 128 + Err(_) => Err(CliError::MissingArg("<a.png> <b.png>")), 129 + }, 130 + [flag, value, rest @ ..] if flag == "--out" => parse_diff( 131 + rest, 132 + DiffArgs { 133 + out: Some(PathBuf::from(value)), 134 + ..acc 135 + }, 136 + ), 137 + [flag, value, rest @ ..] if flag == "--threshold" => parse_diff( 138 + rest, 139 + DiffArgs { 140 + threshold: Some(parse_threshold(value)?), 141 + ..acc 142 + }, 143 + ), 144 + [flag] if flag == "--out" => Err(CliError::MissingArg("--out <diff.png>")), 145 + [flag] if flag == "--threshold" => Err(CliError::MissingArg("--threshold <0-255>")), 146 + [positional, rest @ ..] if acc.images.len() < 2 && !positional.starts_with("--") => { 147 + let mut images = acc.images; 148 + images.push(PathBuf::from(positional)); 149 + parse_diff(rest, DiffArgs { images, ..acc }) 150 + } 151 + [unexpected, ..] => Err(CliError::UnexpectedArg(unexpected.clone())), 152 + } 153 + } 154 + 155 + fn main() -> ExitCode { 156 + let args: Vec<String> = std::env::args().skip(1).collect(); 157 + match parse(&args) { 158 + Ok(Command::Run { 159 + scenario, 160 + out, 161 + document, 162 + }) => execute_run(&scenario, &out, document), 163 + Ok(Command::Diff { 164 + left, 165 + right, 166 + out, 167 + threshold, 168 + }) => execute_diff(&left, &right, out.as_deref(), threshold), 169 + Err(e) => { 170 + eprintln!("{e}"); 171 + eprintln!("{USAGE}"); 172 + ExitCode::from(EXIT_USAGE) 173 + } 174 + } 175 + } 176 + 177 + fn with_document(scenario: Scenario, document: Option<PathBuf>) -> Scenario { 178 + match document { 179 + None => scenario, 180 + Some(path) => Scenario { 181 + steps: std::iter::once(Step::OpenDocument(path)) 182 + .chain(scenario.steps) 183 + .collect(), 184 + }, 185 + } 186 + } 187 + 188 + fn report_failure(result: &RunResult) { 189 + result 190 + .steps 191 + .iter() 192 + .enumerate() 193 + .filter_map(|(index, record)| match &record.outcome { 194 + StepOutcome::Failed(message) => Some((index, message)), 195 + StepOutcome::Ok => None, 196 + }) 197 + .for_each(|(index, message)| eprintln!("step {index} failed: {message}")); 198 + } 199 + 200 + fn execute_run( 201 + source: &ScenarioSource, 202 + out: &std::path::Path, 203 + document: Option<PathBuf>, 204 + ) -> ExitCode { 205 + let scenario = match Scenario::load(source) { 206 + Ok(s) => with_document(s, document), 207 + Err(e) => { 208 + eprintln!("{e}"); 209 + return ExitCode::from(EXIT_ERROR); 210 + } 211 + }; 212 + match run_scenario(&scenario, out, &mut std::io::stdout().lock()) { 213 + Ok(result) if result.status == RunStatus::Passed => ExitCode::SUCCESS, 214 + Ok(result) => { 215 + report_failure(&result); 216 + ExitCode::from(EXIT_FAILED) 217 + } 218 + Err(e) => { 219 + eprintln!("{e}"); 220 + ExitCode::from(EXIT_ERROR) 221 + } 222 + } 223 + } 224 + 225 + fn load_png(path: &std::path::Path) -> Result<(bone_render::ViewportExtent, Vec<u8>), String> { 226 + let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?; 227 + decode_png(&bytes).map_err(|e| format!("decode {}: {e}", path.display())) 228 + } 229 + 230 + fn diff_image(left: &[u8], right: &[u8], limit: u8) -> Vec<u8> { 231 + left.chunks_exact(4) 232 + .zip(right.chunks_exact(4)) 233 + .flat_map(|(a, b)| { 234 + let over = a 235 + .iter() 236 + .zip(b.iter()) 237 + .map(|(x, y)| x.abs_diff(*y)) 238 + .max() 239 + .unwrap_or(0) 240 + > limit; 241 + if over { 242 + [255, 0, 0, 255] 243 + } else { 244 + [0, 0, 0, 255] 245 + } 246 + }) 247 + .collect() 248 + } 249 + 250 + fn execute_diff( 251 + left: &std::path::Path, 252 + right: &std::path::Path, 253 + out: Option<&std::path::Path>, 254 + threshold: PixelDiffThreshold, 255 + ) -> ExitCode { 256 + let ((extent_left, rgba_left), (extent_right, rgba_right)) = 257 + match (load_png(left), load_png(right)) { 258 + (Ok(l), Ok(r)) => (l, r), 259 + (Err(e), _) | (_, Err(e)) => { 260 + eprintln!("{e}"); 261 + return ExitCode::from(EXIT_ERROR); 262 + } 263 + }; 264 + if extent_left != extent_right { 265 + eprintln!("size mismatch: {extent_left} vs {extent_right}"); 266 + return ExitCode::from(EXIT_FAILED); 267 + } 268 + let report = match PixelDiff::compare_bytes(extent_left, &rgba_left, &rgba_right, threshold) { 269 + Ok(r) => r, 270 + Err(e) => { 271 + eprintln!("{e}"); 272 + return ExitCode::from(EXIT_ERROR); 273 + } 274 + }; 275 + println!( 276 + "pixels over threshold: {} of {}", 277 + report.over_threshold(), 278 + extent_left.pixel_count(), 279 + ); 280 + if let Some(worst) = report.worst() { 281 + println!( 282 + "worst: ({},{}) delta={}", 283 + worst.x(), 284 + worst.y(), 285 + worst.max_delta(), 286 + ); 287 + } 288 + if let Some(path) = out { 289 + let rgba = diff_image(&rgba_left, &rgba_right, threshold.as_u8()); 290 + let written = encode_png_rgba(extent_left, &rgba) 291 + .map_err(|e| e.to_string()) 292 + .and_then(|png| { 293 + std::fs::write(path, png).map_err(|e| format!("write {}: {e}", path.display())) 294 + }); 295 + if let Err(e) = written { 296 + eprintln!("{e}"); 297 + return ExitCode::from(EXIT_ERROR); 298 + } 299 + } 300 + if report.is_clean() { 301 + ExitCode::SUCCESS 302 + } else { 303 + ExitCode::from(EXIT_FAILED) 304 + } 305 + } 306 + 307 + #[cfg(test)] 308 + mod tests { 309 + use super::*; 310 + 311 + fn strings(parts: &[&str]) -> Vec<String> { 312 + parts.iter().map(|s| (*s).to_owned()).collect() 313 + } 314 + 315 + #[test] 316 + fn run_parses_scenario_out_and_document() { 317 + let cmd = parse(&strings(&[ 318 + "run", 319 + "shell.ron", 320 + "--out", 321 + "artifacts", 322 + "--document", 323 + "clamp.step", 324 + ])); 325 + assert_eq!( 326 + cmd, 327 + Ok(Command::Run { 328 + scenario: ScenarioSource::Path(PathBuf::from("shell.ron")), 329 + out: PathBuf::from("artifacts"), 330 + document: Some(PathBuf::from("clamp.step")), 331 + }), 332 + ); 333 + } 334 + 335 + #[test] 336 + fn run_dash_reads_stdin() { 337 + let cmd = parse(&strings(&["run", "-", "--out", "artifacts"])); 338 + assert_eq!( 339 + cmd, 340 + Ok(Command::Run { 341 + scenario: ScenarioSource::Stdin, 342 + out: PathBuf::from("artifacts"), 343 + document: None, 344 + }), 345 + ); 346 + } 347 + 348 + #[test] 349 + fn run_requires_out() { 350 + assert_eq!( 351 + parse(&strings(&["run", "shell.ron"])), 352 + Err(CliError::MissingArg("--out <dir>")), 353 + ); 354 + } 355 + 356 + #[test] 357 + fn diff_parses_threshold_and_out() { 358 + let cmd = parse(&strings(&[ 359 + "diff", 360 + "a.png", 361 + "b.png", 362 + "--threshold", 363 + "8", 364 + "--out", 365 + "d.png", 366 + ])); 367 + assert_eq!( 368 + cmd, 369 + Ok(Command::Diff { 370 + left: PathBuf::from("a.png"), 371 + right: PathBuf::from("b.png"), 372 + out: Some(PathBuf::from("d.png")), 373 + threshold: PixelDiffThreshold::new(8.0 / 255.0), 374 + }), 375 + ); 376 + } 377 + 378 + #[test] 379 + fn diff_defaults_to_exact_threshold() { 380 + let cmd = parse(&strings(&["diff", "a.png", "b.png"])); 381 + assert_eq!( 382 + cmd, 383 + Ok(Command::Diff { 384 + left: PathBuf::from("a.png"), 385 + right: PathBuf::from("b.png"), 386 + out: None, 387 + threshold: PixelDiffThreshold::EXACT, 388 + }), 389 + ); 390 + } 391 + 392 + #[test] 393 + fn diff_rejects_bad_threshold() { 394 + assert_eq!( 395 + parse(&strings(&["diff", "a.png", "b.png", "--threshold", "256"])), 396 + Err(CliError::BadThreshold("256".to_owned())), 397 + ); 398 + assert_eq!( 399 + parse(&strings(&["diff", "a.png"])), 400 + Err(CliError::MissingArg("<a.png> <b.png>")), 401 + ); 402 + } 403 + 404 + #[test] 405 + fn unknown_command_is_rejected() { 406 + assert_eq!( 407 + parse(&strings(&["serve"])), 408 + Err(CliError::UnknownCommand("serve".to_owned())), 409 + ); 410 + assert_eq!(parse(&[]), Err(CliError::MissingCommand)); 411 + } 412 + 413 + #[test] 414 + fn with_document_prepends_an_open_step() { 415 + let scenario = Scenario { 416 + steps: vec![Step::Advance(bone_app::FrameCount::ONE)], 417 + }; 418 + let amended = with_document(scenario, Some(PathBuf::from("clamp.step"))); 419 + assert_eq!( 420 + amended.steps[0], 421 + Step::OpenDocument(PathBuf::from("clamp.step")), 422 + ); 423 + assert_eq!(amended.steps.len(), 2); 424 + } 425 + 426 + #[test] 427 + fn diff_image_marks_only_over_threshold_pixels() { 428 + let left = [0_u8, 0, 0, 255, 100, 0, 0, 255]; 429 + let right = [0_u8, 0, 0, 255, 0, 0, 0, 255]; 430 + let image = diff_image(&left, &right, 8); 431 + assert_eq!(image, vec![0, 0, 0, 255, 255, 0, 0, 255]); 432 + } 433 + }
+419
crates/bone-jig/src/run.rs
··· 1 + use core::ops::ControlFlow; 2 + use std::io::Write; 3 + use std::path::{Path, PathBuf}; 4 + 5 + use bone_app::{AppCore, FrameCount, FrameTarget, InputEvent, KeyDown, NavKey, WindowPoint}; 6 + use bone_render::{ 7 + OffscreenContext, PickIdError, PickIndex, Picker, RenderError, ViewportExtent, encode_png, 8 + }; 9 + use bone_ui::input::{KeyCode, ModifierMask, NamedKey}; 10 + use serde::Serialize; 11 + use thiserror::Error; 12 + 13 + use crate::scenario::{PointerTarget, Scenario, Step, modifier_mask}; 14 + use crate::target::resolve_target; 15 + use crate::tree::TreeDump; 16 + use crate::{DEFAULT_VIEWPORT, offscreen_context}; 17 + 18 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] 19 + pub enum RunStatus { 20 + Passed, 21 + Failed, 22 + } 23 + 24 + #[derive(Clone, Debug, PartialEq, Serialize)] 25 + pub enum StepOutcome { 26 + Ok, 27 + Failed(String), 28 + } 29 + 30 + #[derive(Clone, Debug, PartialEq, Serialize)] 31 + pub struct StepRecord { 32 + pub step: Step, 33 + pub outcome: StepOutcome, 34 + } 35 + 36 + #[derive(Clone, Debug, PartialEq, Serialize)] 37 + pub struct RunResult { 38 + pub steps: Vec<StepRecord>, 39 + pub status: RunStatus, 40 + } 41 + 42 + #[derive(Debug, Error)] 43 + pub enum JigError { 44 + #[error("render: {0}")] 45 + Render(#[from] RenderError), 46 + #[error("pick id space: {0}")] 47 + PickId(#[from] PickIdError), 48 + #[error("{path}: {error}")] 49 + Io { 50 + path: PathBuf, 51 + error: std::io::Error, 52 + }, 53 + #[error("encode result: {0}")] 54 + ResultEncode(#[from] ron::Error), 55 + } 56 + 57 + struct OffscreenTarget(OffscreenContext); 58 + 59 + impl FrameTarget for OffscreenTarget { 60 + fn picker(&self, index: PickIndex) -> Picker<'_> { 61 + self.0.picker(index) 62 + } 63 + 64 + fn render( 65 + &mut self, 66 + build_passes: impl FnOnce( 67 + &mut wgpu::CommandEncoder, 68 + &wgpu::TextureView, 69 + &wgpu::TextureView, 70 + &wgpu::TextureView, 71 + ), 72 + ) { 73 + self.0.render_passes(build_passes); 74 + } 75 + } 76 + 77 + pub fn run_scenario( 78 + scenario: &Scenario, 79 + out_dir: &Path, 80 + sink: &mut impl Write, 81 + ) -> Result<RunResult, JigError> { 82 + std::fs::create_dir_all(out_dir).map_err(|error| JigError::Io { 83 + path: out_dir.to_path_buf(), 84 + error, 85 + })?; 86 + let context = offscreen_context(DEFAULT_VIEWPORT)?; 87 + let core = AppCore::new(context.gpu(), context.color_format(), DEFAULT_VIEWPORT)?; 88 + let mut session = Session { 89 + core, 90 + target: OffscreenTarget(context), 91 + out_dir, 92 + sink, 93 + }; 94 + session.frame(); 95 + let outcome = 96 + scenario 97 + .steps 98 + .iter() 99 + .enumerate() 100 + .try_fold(Vec::new(), |mut records, (index, step)| { 101 + let (outcome, flow) = match session.exec(index, step) { 102 + Ok(()) => (StepOutcome::Ok, ControlFlow::Continue(())), 103 + Err(message) => (StepOutcome::Failed(message), ControlFlow::Break(())), 104 + }; 105 + records.push(StepRecord { 106 + step: step.clone(), 107 + outcome, 108 + }); 109 + match flow { 110 + ControlFlow::Continue(()) => ControlFlow::Continue(records), 111 + ControlFlow::Break(()) => ControlFlow::Break(records), 112 + } 113 + }); 114 + let (steps, status) = match outcome { 115 + ControlFlow::Continue(records) => (records, RunStatus::Passed), 116 + ControlFlow::Break(records) => (records, RunStatus::Failed), 117 + }; 118 + let result = RunResult { steps, status }; 119 + let text = ron::ser::to_string_pretty(&result, ron::ser::PrettyConfig::default())?; 120 + let path = out_dir.join("result.ron"); 121 + std::fs::write(&path, text).map_err(|error| JigError::Io { path, error })?; 122 + Ok(result) 123 + } 124 + 125 + struct Session<'a, W: Write> { 126 + core: AppCore, 127 + target: OffscreenTarget, 128 + out_dir: &'a Path, 129 + sink: &'a mut W, 130 + } 131 + 132 + impl<W: Write> Session<'_, W> { 133 + fn exec(&mut self, index: usize, step: &Step) -> Result<(), String> { 134 + match step { 135 + Step::Resize { width, height } => { 136 + let extent = ViewportExtent::new(*width, *height); 137 + self.target.0.resize(extent).map_err(|e| e.to_string())?; 138 + self.input(InputEvent::Resize(extent)); 139 + self.frame(); 140 + Ok(()) 141 + } 142 + Step::Theme(mode) => { 143 + self.core.set_theme(*mode); 144 + self.frame(); 145 + Ok(()) 146 + } 147 + Step::PointerMove(target) => { 148 + let point = self.resolve(index, target)?; 149 + self.input(InputEvent::CursorMove(point)); 150 + self.frame(); 151 + Ok(()) 152 + } 153 + Step::Click { target, button } => { 154 + let point = self.resolve(index, target)?; 155 + self.input(InputEvent::CursorMove(point)); 156 + self.frame(); 157 + self.press_release(*button); 158 + Ok(()) 159 + } 160 + Step::Drag { from, to, button } => { 161 + let start = self.resolve(index, from)?; 162 + self.input(InputEvent::CursorMove(start)); 163 + self.frame(); 164 + self.input(InputEvent::Pointer { 165 + button: *button, 166 + pressed: true, 167 + }); 168 + self.frame(); 169 + let end = self.resolve(index, to)?; 170 + self.input(InputEvent::CursorMove(end)); 171 + self.frame(); 172 + self.input(InputEvent::Pointer { 173 + button: *button, 174 + pressed: false, 175 + }); 176 + self.frame(); 177 + self.frame(); 178 + self.frame(); 179 + Ok(()) 180 + } 181 + Step::Wheel(delta) => { 182 + self.input(InputEvent::Wheel(*delta)); 183 + self.frame(); 184 + Ok(()) 185 + } 186 + Step::Key { code, modifiers } => { 187 + self.key_down(*code, modifiers); 188 + Ok(()) 189 + } 190 + Step::Text(text) => { 191 + self.input(InputEvent::KeyDown(KeyDown { 192 + code: None, 193 + nav: None, 194 + text: Some(text.clone()), 195 + repeat: false, 196 + })); 197 + self.frame(); 198 + Ok(()) 199 + } 200 + Step::Advance(frames) => { 201 + (0..frames.get()).for_each(|_| self.frame()); 202 + Ok(()) 203 + } 204 + Step::OpenDocument(path) => { 205 + self.core 206 + .open_document(path.clone()) 207 + .map_err(|e| e.to_string())?; 208 + self.frame(); 209 + Ok(()) 210 + } 211 + Step::Snapshot(name) => { 212 + let frame = self.target.0.capture().map_err(|e| e.to_string())?; 213 + let png = encode_png(&frame).map_err(|e| e.to_string())?; 214 + self.write_file(&artifact_file(index, &name.to_string(), "png"), &png) 215 + } 216 + Step::DumpTree(name) => { 217 + let dump = self.dump()?; 218 + let file = artifact_file(index, &name.to_string(), "tree.json"); 219 + self.write_tree(&file, &dump)?; 220 + writeln!(self.sink, "# {file}").map_err(|e| e.to_string())?; 221 + self.sink 222 + .write_all(dump.to_text().as_bytes()) 223 + .map_err(|e| e.to_string()) 224 + } 225 + } 226 + } 227 + 228 + fn resolve(&mut self, index: usize, target: &PointerTarget) -> Result<WindowPoint, String> { 229 + let update = self.core.access_tree(); 230 + resolve_target(target, &update).map_err(|resolve_error| { 231 + let file = artifact_file(index, "unresolved", "tree.json"); 232 + let flushed = TreeDump::from_update(&update) 233 + .map_err(|e| e.to_string()) 234 + .and_then(|dump| self.write_tree(&file, &dump)); 235 + match flushed { 236 + Ok(()) => format!("{resolve_error}. searched tree written to {file}"), 237 + Err(write_error) => format!("{resolve_error}. searched tree lost: {write_error}"), 238 + } 239 + }) 240 + } 241 + 242 + fn dump(&self) -> Result<TreeDump, String> { 243 + TreeDump::from_update(&self.core.access_tree()).map_err(|e| e.to_string()) 244 + } 245 + 246 + fn write_tree(&self, file: &str, dump: &TreeDump) -> Result<(), String> { 247 + let json = serde_json::to_vec_pretty(dump).map_err(|e| e.to_string())?; 248 + self.write_file(file, &json) 249 + } 250 + 251 + fn write_file(&self, file: &str, bytes: &[u8]) -> Result<(), String> { 252 + let path = self.out_dir.join(file); 253 + std::fs::write(&path, bytes).map_err(|e| format!("write {}: {e}", path.display())) 254 + } 255 + 256 + fn key_down(&mut self, code: KeyCode, modifiers: &[crate::scenario::Modifier]) { 257 + let mask = modifier_mask(modifiers); 258 + if mask != ModifierMask::NONE { 259 + self.input(InputEvent::Modifiers(mask)); 260 + } 261 + self.input(InputEvent::KeyDown(KeyDown { 262 + code: Some(code), 263 + nav: nav_key(code), 264 + text: None, 265 + repeat: false, 266 + })); 267 + self.frame(); 268 + if mask != ModifierMask::NONE { 269 + self.input(InputEvent::Modifiers(ModifierMask::NONE)); 270 + self.frame(); 271 + } 272 + } 273 + 274 + fn press_release(&mut self, button: bone_ui::input::PointerButton) { 275 + self.input(InputEvent::Pointer { 276 + button, 277 + pressed: true, 278 + }); 279 + self.frame(); 280 + self.input(InputEvent::Pointer { 281 + button, 282 + pressed: false, 283 + }); 284 + self.frame(); 285 + self.frame(); 286 + self.frame(); 287 + } 288 + 289 + fn input(&mut self, event: InputEvent) { 290 + let _ack = self.core.handle_input(&self.target, event); 291 + } 292 + 293 + fn frame(&mut self) { 294 + self.core.clock_mut().advance(FrameCount::ONE); 295 + let _report = self.core.render_frame(&mut self.target); 296 + } 297 + } 298 + 299 + fn artifact_file(index: usize, name: &str, ext: &str) -> String { 300 + format!("{index:03}_{name}.{ext}") 301 + } 302 + 303 + fn nav_key(code: KeyCode) -> Option<NavKey> { 304 + match code { 305 + KeyCode::Named(NamedKey::ArrowLeft) => Some(NavKey::Left), 306 + KeyCode::Named(NamedKey::ArrowRight) => Some(NavKey::Right), 307 + KeyCode::Named(NamedKey::ArrowUp) => Some(NavKey::Up), 308 + KeyCode::Named(NamedKey::ArrowDown) => Some(NavKey::Down), 309 + KeyCode::Named(_) => None, 310 + KeyCode::Char(c) => match c.get().to_ascii_lowercase() { 311 + 'z' => Some(NavKey::Zoom), 312 + '=' | '+' => Some(NavKey::ZoomIn), 313 + '-' | '_' => Some(NavKey::ZoomOut), 314 + _ => None, 315 + }, 316 + } 317 + } 318 + 319 + #[cfg(test)] 320 + mod tests { 321 + use crate::scenario::Scenario; 322 + 323 + use super::*; 324 + 325 + fn run_in_tempdir(scenario_text: &str) -> (tempfile::TempDir, RunResult, String) { 326 + let dir = match tempfile::tempdir() { 327 + Ok(d) => d, 328 + Err(e) => panic!("tempdir: {e}"), 329 + }; 330 + let scenario = match Scenario::parse(scenario_text) { 331 + Ok(s) => s, 332 + Err(e) => panic!("parse: {e}"), 333 + }; 334 + let mut sink = Vec::new(); 335 + let result = match run_scenario(&scenario, dir.path(), &mut sink) { 336 + Ok(r) => r, 337 + Err(e) => panic!("run: {e}"), 338 + }; 339 + let text = String::from_utf8_lossy(&sink).into_owned(); 340 + (dir, result, text) 341 + } 342 + 343 + #[test] 344 + fn snapshot_and_dump_write_numbered_artifacts() { 345 + let (dir, result, text) = run_in_tempdir( 346 + r#"Scenario( 347 + steps: [ 348 + Advance(2), 349 + Snapshot("shell"), 350 + DumpTree("shell"), 351 + ], 352 + )"#, 353 + ); 354 + assert_eq!(result.status, RunStatus::Passed); 355 + assert_eq!(result.steps.len(), 3); 356 + let png = match std::fs::read(dir.path().join("001_shell.png")) { 357 + Ok(bytes) => bytes, 358 + Err(e) => panic!("png: {e}"), 359 + }; 360 + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); 361 + assert!(dir.path().join("002_shell.tree.json").is_file()); 362 + assert!(dir.path().join("result.ron").is_file()); 363 + assert!(text.starts_with("# 002_shell.tree.json\n")); 364 + assert!(text.contains("window"), "tree text was: {text}"); 365 + } 366 + 367 + #[test] 368 + fn failed_resolution_flushes_the_searched_tree_and_halts() { 369 + let (dir, result, _text) = run_in_tempdir( 370 + r#"Scenario( 371 + steps: [ 372 + Snapshot("before"), 373 + Click(target: Label("No Such Widget")), 374 + Snapshot("after"), 375 + ], 376 + )"#, 377 + ); 378 + assert_eq!(result.status, RunStatus::Failed); 379 + assert_eq!( 380 + result.steps.len(), 381 + 2, 382 + "steps after the failure must not run" 383 + ); 384 + assert!(matches!(result.steps[1].outcome, StepOutcome::Failed(_))); 385 + assert!(dir.path().join("000_before.png").is_file()); 386 + assert!(dir.path().join("001_unresolved.tree.json").is_file()); 387 + let result_text = match std::fs::read_to_string(dir.path().join("result.ron")) { 388 + Ok(t) => t, 389 + Err(e) => panic!("result.ron: {e}"), 390 + }; 391 + assert!(result_text.contains("Failed"), "result was: {result_text}"); 392 + } 393 + 394 + #[test] 395 + fn nav_key_zoom_is_case_insensitive() { 396 + use bone_ui::input::KeyChar; 397 + let zoom = |c| nav_key(KeyCode::Char(KeyChar::from_char(c))); 398 + assert_eq!(zoom('z'), Some(NavKey::Zoom)); 399 + assert_eq!(zoom('Z'), Some(NavKey::Zoom)); 400 + assert_eq!(zoom('='), Some(NavKey::ZoomIn)); 401 + assert_eq!(zoom('+'), Some(NavKey::ZoomIn)); 402 + assert_eq!(zoom('-'), Some(NavKey::ZoomOut)); 403 + assert_eq!(zoom('_'), Some(NavKey::ZoomOut)); 404 + } 405 + 406 + #[test] 407 + fn clicking_a_labeled_button_resolves_and_passes() { 408 + let (_dir, result, _text) = run_in_tempdir( 409 + r#"Scenario( 410 + steps: [ 411 + Advance(1), 412 + Click(target: Label("File")), 413 + DumpTree("after-click"), 414 + ], 415 + )"#, 416 + ); 417 + assert_eq!(result.status, RunStatus::Passed); 418 + } 419 + }
+372
crates/bone-jig/src/scenario.rs
··· 1 + use core::fmt; 2 + use core::num::NonZeroU64; 3 + use core::str::FromStr; 4 + use std::path::PathBuf; 5 + 6 + use accesskit::NodeId; 7 + use bone_app::{FrameCount, ScrollDelta}; 8 + use bone_render::ViewportPx; 9 + use bone_ui::WidgetId; 10 + use bone_ui::input::{KeyCode, ModifierMask, PointerButton}; 11 + use bone_ui::theme::ThemeMode; 12 + use serde::{Deserialize, Serialize}; 13 + use thiserror::Error; 14 + 15 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] 16 + #[serde(try_from = "String", into = "String")] 17 + pub struct NodeRef(NonZeroU64); 18 + 19 + impl NodeRef { 20 + #[must_use] 21 + pub const fn from_widget(id: WidgetId) -> Self { 22 + Self(id.raw()) 23 + } 24 + 25 + #[must_use] 26 + pub const fn node_id(self) -> NodeId { 27 + NodeId(self.0.get()) 28 + } 29 + 30 + #[must_use] 31 + pub const fn from_node_id(id: NodeId) -> Option<Self> { 32 + match NonZeroU64::new(id.0) { 33 + Some(raw) => Some(Self(raw)), 34 + None => None, 35 + } 36 + } 37 + } 38 + 39 + impl fmt::Display for NodeRef { 40 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 41 + write!(f, "{:016x}", self.0) 42 + } 43 + } 44 + 45 + #[derive(Clone, Debug, PartialEq, Eq, Error)] 46 + #[error("node ref {text:?} is not a non-zero 64-bit hex value")] 47 + pub struct NodeRefParseError { 48 + text: String, 49 + } 50 + 51 + impl FromStr for NodeRef { 52 + type Err = NodeRefParseError; 53 + 54 + fn from_str(s: &str) -> Result<Self, Self::Err> { 55 + u64::from_str_radix(s, 16) 56 + .ok() 57 + .and_then(NonZeroU64::new) 58 + .map(Self) 59 + .ok_or_else(|| NodeRefParseError { text: s.to_owned() }) 60 + } 61 + } 62 + 63 + impl TryFrom<String> for NodeRef { 64 + type Error = NodeRefParseError; 65 + 66 + fn try_from(value: String) -> Result<Self, Self::Error> { 67 + value.parse() 68 + } 69 + } 70 + 71 + impl From<NodeRef> for String { 72 + fn from(value: NodeRef) -> Self { 73 + value.to_string() 74 + } 75 + } 76 + 77 + #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 78 + #[serde(try_from = "String", into = "String")] 79 + pub struct ArtifactName(String); 80 + 81 + impl fmt::Display for ArtifactName { 82 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 83 + f.write_str(&self.0) 84 + } 85 + } 86 + 87 + #[derive(Clone, Debug, PartialEq, Eq, Error)] 88 + #[error("artifact name {text:?} must be non-empty ascii alphanumeric with '-' or '_'")] 89 + pub struct ArtifactNameError { 90 + text: String, 91 + } 92 + 93 + impl TryFrom<String> for ArtifactName { 94 + type Error = ArtifactNameError; 95 + 96 + fn try_from(value: String) -> Result<Self, Self::Error> { 97 + let valid = !value.is_empty() 98 + && value 99 + .chars() 100 + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); 101 + if valid { 102 + Ok(Self(value)) 103 + } else { 104 + Err(ArtifactNameError { text: value }) 105 + } 106 + } 107 + } 108 + 109 + impl From<ArtifactName> for String { 110 + fn from(value: ArtifactName) -> Self { 111 + value.0 112 + } 113 + } 114 + 115 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 116 + pub enum Modifier { 117 + Ctrl, 118 + Shift, 119 + Alt, 120 + Meta, 121 + } 122 + 123 + #[must_use] 124 + pub fn modifier_mask(modifiers: &[Modifier]) -> ModifierMask { 125 + modifiers.iter().fold(ModifierMask::NONE, |acc, m| { 126 + acc.union(match m { 127 + Modifier::Ctrl => ModifierMask::CTRL, 128 + Modifier::Shift => ModifierMask::SHIFT, 129 + Modifier::Alt => ModifierMask::ALT, 130 + Modifier::Meta => ModifierMask::META, 131 + }) 132 + }) 133 + } 134 + 135 + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 136 + pub enum PointerTarget { 137 + At { x: f64, y: f64 }, 138 + Node(NodeRef), 139 + Label(String), 140 + } 141 + 142 + fn primary_button() -> PointerButton { 143 + PointerButton::Primary 144 + } 145 + 146 + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 147 + pub enum Step { 148 + Resize { 149 + width: ViewportPx, 150 + height: ViewportPx, 151 + }, 152 + Theme(ThemeMode), 153 + PointerMove(PointerTarget), 154 + Click { 155 + target: PointerTarget, 156 + #[serde(default = "primary_button")] 157 + button: PointerButton, 158 + }, 159 + Drag { 160 + from: PointerTarget, 161 + to: PointerTarget, 162 + #[serde(default = "primary_button")] 163 + button: PointerButton, 164 + }, 165 + Wheel(ScrollDelta), 166 + Key { 167 + code: KeyCode, 168 + #[serde(default)] 169 + modifiers: Vec<Modifier>, 170 + }, 171 + Text(String), 172 + Advance(FrameCount), 173 + OpenDocument(PathBuf), 174 + Snapshot(ArtifactName), 175 + DumpTree(ArtifactName), 176 + } 177 + 178 + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 179 + pub struct Scenario { 180 + pub steps: Vec<Step>, 181 + } 182 + 183 + #[derive(Clone, Debug, PartialEq, Eq)] 184 + pub enum ScenarioSource { 185 + Path(PathBuf), 186 + Stdin, 187 + } 188 + 189 + impl fmt::Display for ScenarioSource { 190 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 191 + match self { 192 + Self::Path(path) => write!(f, "{}", path.display()), 193 + Self::Stdin => f.write_str("stdin"), 194 + } 195 + } 196 + } 197 + 198 + #[derive(Debug, Error)] 199 + pub enum ScenarioError { 200 + #[error("read scenario from {from}: {error}")] 201 + Read { 202 + from: ScenarioSource, 203 + error: std::io::Error, 204 + }, 205 + #[error("parse scenario: {0}")] 206 + Parse(#[from] ron::error::SpannedError), 207 + } 208 + 209 + impl Scenario { 210 + pub fn parse(text: &str) -> Result<Self, ron::error::SpannedError> { 211 + ron::de::from_str(text) 212 + } 213 + 214 + pub fn load(source: &ScenarioSource) -> Result<Self, ScenarioError> { 215 + let text = match source { 216 + ScenarioSource::Path(path) => std::fs::read_to_string(path), 217 + ScenarioSource::Stdin => std::io::read_to_string(std::io::stdin()), 218 + } 219 + .map_err(|error| ScenarioError::Read { 220 + from: source.clone(), 221 + error, 222 + })?; 223 + Ok(Self::parse(&text)?) 224 + } 225 + } 226 + 227 + #[cfg(test)] 228 + mod tests { 229 + use bone_ui::input::NamedKey; 230 + 231 + use super::*; 232 + 233 + fn parsed(text: &str) -> Scenario { 234 + match Scenario::parse(text) { 235 + Ok(scenario) => scenario, 236 + Err(e) => panic!("parse failed: {e}"), 237 + } 238 + } 239 + 240 + fn frames(n: u32) -> FrameCount { 241 + match FrameCount::try_from(n) { 242 + Ok(count) => count, 243 + Err(e) => panic!("frame count: {e}"), 244 + } 245 + } 246 + 247 + #[test] 248 + fn full_step_vocabulary_parses() { 249 + let scenario = parsed( 250 + r#"Scenario( 251 + steps: [ 252 + Resize(width: 1280, height: 800), 253 + OpenDocument("parts/bracket.step"), 254 + Advance(3), 255 + PointerMove(Label("Extrude")), 256 + Click(target: At(x: 640.0, y: 400.0)), 257 + Click(target: Node("00000000000000ff"), button: Secondary), 258 + Drag(from: At(x: 100.0, y: 100.0), to: At(x: 220.0, y: 160.0)), 259 + Wheel(Lines(x: 0.0, y: 1.0)), 260 + Key(code: Named(Escape)), 261 + Key(code: Char('z'), modifiers: [Ctrl, Shift]), 262 + Text("12.5"), 263 + Snapshot("after-extrude"), 264 + DumpTree("final"), 265 + ], 266 + )"#, 267 + ); 268 + assert_eq!(scenario.steps.len(), 13); 269 + assert_eq!( 270 + scenario.steps[4], 271 + Step::Click { 272 + target: PointerTarget::At { x: 640.0, y: 400.0 }, 273 + button: PointerButton::Primary, 274 + }, 275 + "click button defaults to primary", 276 + ); 277 + assert_eq!( 278 + scenario.steps[8], 279 + Step::Key { 280 + code: KeyCode::Named(NamedKey::Escape), 281 + modifiers: vec![], 282 + }, 283 + "key modifiers default to empty", 284 + ); 285 + assert_eq!(scenario.steps[2], Step::Advance(frames(3))); 286 + } 287 + 288 + #[test] 289 + fn advance_rejects_zero_frames() { 290 + assert!( 291 + Scenario::parse("Scenario(steps: [Advance(0)])").is_err(), 292 + "a zero-frame advance is not representable", 293 + ); 294 + assert!(Scenario::parse("Scenario(steps: [Advance(1)])").is_ok()); 295 + } 296 + 297 + #[test] 298 + fn scenario_round_trips_through_ron() { 299 + let scenario = parsed( 300 + r#"Scenario( 301 + steps: [ 302 + Click(target: Label("OK")), 303 + Wheel(Pixels(x: 0.0, y: -40.0)), 304 + Snapshot("dialog"), 305 + ], 306 + )"#, 307 + ); 308 + let text = match ron::ser::to_string(&scenario) { 309 + Ok(t) => t, 310 + Err(e) => panic!("serialize failed: {e}"), 311 + }; 312 + assert_eq!(parsed(&text), scenario); 313 + } 314 + 315 + #[test] 316 + fn node_ref_round_trips_as_hex() { 317 + let node = NodeRef::from_widget(WidgetId::ROOT); 318 + assert_eq!(node.to_string(), "b0feb0feb0feb0fe"); 319 + assert_eq!("b0feb0feb0feb0fe".parse(), Ok(node)); 320 + } 321 + 322 + #[test] 323 + fn node_ref_rejects_zero_and_garbage() { 324 + assert!(NodeRef::from_str("0").is_err()); 325 + assert!(NodeRef::from_str("not-hex").is_err()); 326 + assert!(NodeRef::from_str("").is_err()); 327 + assert!(NodeRef::from_node_id(NodeId(0)).is_none()); 328 + } 329 + 330 + #[test] 331 + fn artifact_name_rejects_path_traversal() { 332 + assert!(ArtifactName::try_from("../escape".to_owned()).is_err()); 333 + assert!(ArtifactName::try_from(String::new()).is_err()); 334 + assert!(ArtifactName::try_from("a/b".to_owned()).is_err()); 335 + assert!(ArtifactName::try_from("cold_shell-01".to_owned()).is_ok()); 336 + } 337 + 338 + #[test] 339 + fn modifier_mask_folds_all_flags() { 340 + let mask = modifier_mask(&[Modifier::Ctrl, Modifier::Shift]); 341 + assert!(mask.contains(ModifierMask::CTRL)); 342 + assert!(mask.contains(ModifierMask::SHIFT)); 343 + assert!(!mask.contains(ModifierMask::ALT)); 344 + assert_eq!(modifier_mask(&[]), ModifierMask::NONE); 345 + } 346 + 347 + #[test] 348 + fn load_reads_a_scenario_file() { 349 + let dir = match tempfile::tempdir() { 350 + Ok(d) => d, 351 + Err(e) => panic!("tempdir: {e}"), 352 + }; 353 + let path = dir.path().join("smoke.ron"); 354 + if let Err(e) = std::fs::write(&path, "Scenario(steps: [Advance(1)])") { 355 + panic!("write: {e}"); 356 + } 357 + let scenario = match Scenario::load(&ScenarioSource::Path(path)) { 358 + Ok(s) => s, 359 + Err(e) => panic!("load: {e}"), 360 + }; 361 + assert_eq!(scenario.steps, vec![Step::Advance(FrameCount::ONE)]); 362 + } 363 + 364 + #[test] 365 + fn load_reports_the_missing_path() { 366 + let source = ScenarioSource::Path(PathBuf::from("/nonexistent/limpet.ron")); 367 + match Scenario::load(&source) { 368 + Err(ScenarioError::Read { from, .. }) => assert_eq!(from, source), 369 + other => panic!("expected read error, got {other:?}"), 370 + } 371 + } 372 + }
+261
crates/bone-jig/src/target.rs
··· 1 + use core::fmt; 2 + 3 + use accesskit::{Node, TreeUpdate}; 4 + use bone_app::WindowPoint; 5 + use thiserror::Error; 6 + 7 + use crate::scenario::{NodeRef, PointerTarget}; 8 + 9 + #[derive(Clone, Debug, PartialEq, Eq)] 10 + pub struct Candidate { 11 + pub node: NodeRef, 12 + pub label: String, 13 + } 14 + 15 + impl fmt::Display for Candidate { 16 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 17 + write!(f, "{} {:?}", self.node, self.label) 18 + } 19 + } 20 + 21 + fn list(candidates: &[Candidate]) -> String { 22 + if candidates.is_empty() { 23 + return "none".to_owned(); 24 + } 25 + candidates 26 + .iter() 27 + .map(ToString::to_string) 28 + .collect::<Vec<_>>() 29 + .join(", ") 30 + } 31 + 32 + #[derive(Clone, Debug, PartialEq, Eq, Error)] 33 + pub enum ResolveError { 34 + #[error("node {node} not in tree; labeled nodes: {}", list(.candidates))] 35 + NodeNotFound { 36 + node: NodeRef, 37 + candidates: Vec<Candidate>, 38 + }, 39 + #[error("node {node} has no bounds")] 40 + NodeWithoutBounds { node: NodeRef }, 41 + #[error("no node label contains {label:?}; labeled nodes: {}", list(.candidates))] 42 + LabelNotFound { 43 + label: String, 44 + candidates: Vec<Candidate>, 45 + }, 46 + #[error("label {label:?} is ambiguous; matches: {}", list(.matches))] 47 + LabelAmbiguous { 48 + label: String, 49 + matches: Vec<Candidate>, 50 + }, 51 + } 52 + 53 + pub fn resolve_target( 54 + target: &PointerTarget, 55 + tree: &TreeUpdate, 56 + ) -> Result<WindowPoint, ResolveError> { 57 + match target { 58 + PointerTarget::At { x, y } => Ok(WindowPoint::new(*x, *y)), 59 + PointerTarget::Node(node) => resolve_node(*node, tree), 60 + PointerTarget::Label(label) => resolve_label(label, tree), 61 + } 62 + } 63 + 64 + fn resolve_node(node: NodeRef, tree: &TreeUpdate) -> Result<WindowPoint, ResolveError> { 65 + tree.nodes 66 + .iter() 67 + .find(|(id, _)| *id == node.node_id()) 68 + .ok_or_else(|| ResolveError::NodeNotFound { 69 + node, 70 + candidates: labeled(tree), 71 + }) 72 + .and_then(|(_, n)| center(node, n)) 73 + } 74 + 75 + fn resolve_label(needle: &str, tree: &TreeUpdate) -> Result<WindowPoint, ResolveError> { 76 + let needle_fold = needle.to_lowercase(); 77 + let matches: Vec<(NodeRef, &Node, String)> = tree 78 + .nodes 79 + .iter() 80 + .filter_map(|(id, n)| { 81 + NodeRef::from_node_id(*id) 82 + .zip(n.label()) 83 + .filter(|(_, label)| label.to_lowercase().contains(&needle_fold)) 84 + .map(|(node, label)| (node, n, label.to_owned())) 85 + }) 86 + .collect(); 87 + let exact: Vec<&(NodeRef, &Node, String)> = matches 88 + .iter() 89 + .filter(|(_, _, label)| label.to_lowercase() == needle_fold) 90 + .collect(); 91 + match (matches.as_slice(), exact.as_slice()) { 92 + ([], _) => Err(ResolveError::LabelNotFound { 93 + label: needle.to_owned(), 94 + candidates: labeled(tree), 95 + }), 96 + ([(node, n, _)], _) | (_, [(node, n, _)]) => center(*node, n), 97 + _ => Err(ResolveError::LabelAmbiguous { 98 + label: needle.to_owned(), 99 + matches: matches 100 + .iter() 101 + .map(|(node, _, label)| Candidate { 102 + node: *node, 103 + label: label.clone(), 104 + }) 105 + .collect(), 106 + }), 107 + } 108 + } 109 + 110 + fn center(node: NodeRef, n: &Node) -> Result<WindowPoint, ResolveError> { 111 + n.bounds() 112 + .map(|r| WindowPoint::new((r.x0 + r.x1) * 0.5, (r.y0 + r.y1) * 0.5)) 113 + .ok_or(ResolveError::NodeWithoutBounds { node }) 114 + } 115 + 116 + fn labeled(tree: &TreeUpdate) -> Vec<Candidate> { 117 + tree.nodes 118 + .iter() 119 + .filter_map(|(id, n)| { 120 + NodeRef::from_node_id(*id) 121 + .zip(n.label()) 122 + .map(|(node, label)| Candidate { 123 + node, 124 + label: label.to_owned(), 125 + }) 126 + }) 127 + .collect() 128 + } 129 + 130 + #[cfg(test)] 131 + mod tests { 132 + use accesskit::{NodeId, Rect, Role, Tree, TreeId}; 133 + 134 + use super::*; 135 + 136 + fn button(label: &str, bounds: Option<Rect>) -> Node { 137 + let mut node = Node::new(Role::Button); 138 + node.set_label(label); 139 + if let Some(rect) = bounds { 140 + node.set_bounds(rect); 141 + } 142 + node 143 + } 144 + 145 + fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Rect { 146 + Rect { x0, y0, x1, y1 } 147 + } 148 + 149 + fn tree(nodes: Vec<(NodeId, Node)>) -> TreeUpdate { 150 + TreeUpdate { 151 + nodes, 152 + tree: Some(Tree::new(NodeId(1))), 153 + tree_id: TreeId::ROOT, 154 + focus: NodeId(1), 155 + } 156 + } 157 + 158 + fn node_ref(raw: u64) -> NodeRef { 159 + match NodeRef::from_node_id(NodeId(raw)) { 160 + Some(node) => node, 161 + None => panic!("raw must be non-zero"), 162 + } 163 + } 164 + 165 + fn shell() -> TreeUpdate { 166 + tree(vec![ 167 + (NodeId(1), Node::new(Role::Window)), 168 + ( 169 + NodeId(2), 170 + button("Extrude", Some(rect(10.0, 20.0, 30.0, 40.0))), 171 + ), 172 + ( 173 + NodeId(3), 174 + button("Extrude Cut", Some(rect(40.0, 20.0, 60.0, 40.0))), 175 + ), 176 + ( 177 + NodeId(4), 178 + button("Fillet", Some(rect(70.0, 20.0, 90.0, 40.0))), 179 + ), 180 + ]) 181 + } 182 + 183 + fn resolved(target: &PointerTarget, tree: &TreeUpdate) -> WindowPoint { 184 + match resolve_target(target, tree) { 185 + Ok(point) => point, 186 + Err(e) => panic!("resolve failed: {e}"), 187 + } 188 + } 189 + 190 + #[test] 191 + fn coordinates_pass_through() { 192 + let point = resolved(&PointerTarget::At { x: 12.5, y: 7.0 }, &shell()); 193 + assert_eq!(point, WindowPoint::new(12.5, 7.0)); 194 + } 195 + 196 + #[test] 197 + fn unique_label_substring_hits_bounds_center() { 198 + let point = resolved(&PointerTarget::Label("fill".to_owned()), &shell()); 199 + assert_eq!(point, WindowPoint::new(80.0, 30.0)); 200 + } 201 + 202 + #[test] 203 + fn exact_label_wins_over_substring_ambiguity() { 204 + let point = resolved(&PointerTarget::Label("Extrude".to_owned()), &shell()); 205 + assert_eq!(point, WindowPoint::new(20.0, 30.0)); 206 + } 207 + 208 + #[test] 209 + fn ambiguous_label_names_the_matches() { 210 + let needle = PointerTarget::Label("trude".to_owned()); 211 + match resolve_target(&needle, &shell()) { 212 + Err(ResolveError::LabelAmbiguous { matches, .. }) => { 213 + let labels: Vec<&str> = matches.iter().map(|c| c.label.as_str()).collect(); 214 + assert_eq!(labels, vec!["Extrude", "Extrude Cut"]); 215 + } 216 + other => panic!("expected ambiguity, got {other:?}"), 217 + } 218 + } 219 + 220 + #[test] 221 + fn missing_label_names_the_candidates() { 222 + let needle = PointerTarget::Label("Revolve".to_owned()); 223 + match resolve_target(&needle, &shell()) { 224 + Err(ref err @ ResolveError::LabelNotFound { ref candidates, .. }) => { 225 + assert_eq!(candidates.len(), 3); 226 + let message = err.to_string(); 227 + assert!(message.contains("Extrude Cut"), "message was: {message}"); 228 + assert!(message.contains("Fillet"), "message was: {message}"); 229 + } 230 + other => panic!("expected not-found, got {other:?}"), 231 + } 232 + } 233 + 234 + #[test] 235 + fn node_ref_resolves_to_bounds_center() { 236 + let point = resolved(&PointerTarget::Node(node_ref(3)), &shell()); 237 + assert_eq!(point, WindowPoint::new(50.0, 30.0)); 238 + } 239 + 240 + #[test] 241 + fn missing_node_ref_names_the_candidates() { 242 + match resolve_target(&PointerTarget::Node(node_ref(99)), &shell()) { 243 + Err(ResolveError::NodeNotFound { candidates, .. }) => { 244 + assert_eq!(candidates.len(), 3); 245 + } 246 + other => panic!("expected not-found, got {other:?}"), 247 + } 248 + } 249 + 250 + #[test] 251 + fn node_without_bounds_is_an_error() { 252 + let update = tree(vec![ 253 + (NodeId(1), Node::new(Role::Window)), 254 + (NodeId(2), button("Ghost", None)), 255 + ]); 256 + match resolve_target(&PointerTarget::Label("Ghost".to_owned()), &update) { 257 + Err(ResolveError::NodeWithoutBounds { node }) => assert_eq!(node, node_ref(2)), 258 + other => panic!("expected missing bounds, got {other:?}"), 259 + } 260 + } 261 + }
+298
crates/bone-jig/src/tree.rs
··· 1 + use std::collections::BTreeMap; 2 + 3 + use accesskit::{Node, NodeId, Rect, Role, Toggled, TreeUpdate}; 4 + use serde::Serialize; 5 + use thiserror::Error; 6 + 7 + use crate::scenario::NodeRef; 8 + 9 + #[derive(Clone, Debug, PartialEq, Serialize)] 10 + pub struct TreeDump { 11 + pub root: DumpNode, 12 + } 13 + 14 + #[derive(Clone, Debug, PartialEq, Serialize)] 15 + pub struct DumpNode { 16 + #[serde(rename = "ref")] 17 + pub node: NodeRef, 18 + pub role: Role, 19 + #[serde(skip_serializing_if = "Option::is_none")] 20 + pub label: Option<String>, 21 + #[serde(skip_serializing_if = "Option::is_none")] 22 + pub bounds: Option<Rect>, 23 + pub states: DumpStates, 24 + #[serde(skip_serializing_if = "Vec::is_empty")] 25 + pub children: Vec<DumpNode>, 26 + } 27 + 28 + #[derive(Clone, Debug, PartialEq, Serialize)] 29 + pub struct DumpStates { 30 + pub enabled: bool, 31 + #[serde(skip_serializing_if = "Option::is_none")] 32 + pub selected: Option<bool>, 33 + #[serde(skip_serializing_if = "Option::is_none")] 34 + pub expanded: Option<bool>, 35 + #[serde(skip_serializing_if = "Option::is_none")] 36 + pub toggled: Option<Toggled>, 37 + } 38 + 39 + #[derive(Clone, Debug, PartialEq, Eq, Error)] 40 + pub enum TreeDumpError { 41 + #[error("tree update carries no root")] 42 + MissingTree, 43 + #[error("node {0:?} referenced but absent from update")] 44 + MissingNode(NodeId), 45 + #[error("node id zero cannot become a ref")] 46 + ZeroNodeId, 47 + } 48 + 49 + impl TreeDump { 50 + pub fn from_update(update: &TreeUpdate) -> Result<Self, TreeDumpError> { 51 + let nodes: BTreeMap<NodeId, &Node> = 52 + update.nodes.iter().map(|(id, node)| (*id, node)).collect(); 53 + let root = update.tree.as_ref().ok_or(TreeDumpError::MissingTree)?.root; 54 + Ok(Self { 55 + root: dump_node(root, &nodes)?, 56 + }) 57 + } 58 + 59 + #[must_use] 60 + pub fn to_text(&self) -> String { 61 + lines(&self.root, 0).into_iter().map(|l| l + "\n").collect() 62 + } 63 + } 64 + 65 + fn dump_node(id: NodeId, nodes: &BTreeMap<NodeId, &Node>) -> Result<DumpNode, TreeDumpError> { 66 + let node = nodes.get(&id).ok_or(TreeDumpError::MissingNode(id))?; 67 + let node_ref = NodeRef::from_node_id(id).ok_or(TreeDumpError::ZeroNodeId)?; 68 + let children = node 69 + .children() 70 + .iter() 71 + .map(|child| dump_node(*child, nodes)) 72 + .collect::<Result<Vec<_>, _>>()?; 73 + Ok(DumpNode { 74 + node: node_ref, 75 + role: node.role(), 76 + label: node.label().map(str::to_owned), 77 + bounds: node.bounds(), 78 + states: DumpStates { 79 + enabled: !node.is_disabled(), 80 + selected: node.is_selected(), 81 + expanded: node.is_expanded(), 82 + toggled: node.toggled(), 83 + }, 84 + children, 85 + }) 86 + } 87 + 88 + fn lines(node: &DumpNode, depth: usize) -> Vec<String> { 89 + std::iter::once(line(node, depth)) 90 + .chain( 91 + node.children 92 + .iter() 93 + .flat_map(|child| lines(child, depth + 1)), 94 + ) 95 + .collect() 96 + } 97 + 98 + fn line(node: &DumpNode, depth: usize) -> String { 99 + [ 100 + Some(format!( 101 + "{}{} {}", 102 + " ".repeat(depth), 103 + node.node, 104 + role_token(node.role), 105 + )), 106 + node.label.as_ref().map(|label| format!("{label:?}")), 107 + node.bounds.map(bounds_token), 108 + (!node.states.enabled).then(|| "disabled".to_owned()), 109 + node.states.selected.map(|v| format!("selected={v}")), 110 + node.states.expanded.map(|v| format!("expanded={v}")), 111 + node.states 112 + .toggled 113 + .map(|t| format!("toggled={}", toggled_token(t))), 114 + ] 115 + .into_iter() 116 + .flatten() 117 + .collect::<Vec<_>>() 118 + .join(" ") 119 + } 120 + 121 + fn role_token(role: Role) -> String { 122 + serde_json::to_string(&role).map_or_else( 123 + |_| format!("{role:?}"), 124 + |quoted| quoted.trim_matches('"').to_owned(), 125 + ) 126 + } 127 + 128 + fn bounds_token(rect: Rect) -> String { 129 + format!("({},{})-({},{})", rect.x0, rect.y0, rect.x1, rect.y1) 130 + } 131 + 132 + const fn toggled_token(toggled: Toggled) -> &'static str { 133 + match toggled { 134 + Toggled::False => "false", 135 + Toggled::True => "true", 136 + Toggled::Mixed => "mixed", 137 + } 138 + } 139 + 140 + #[cfg(test)] 141 + mod tests { 142 + use accesskit::Toggled; 143 + use bone_ui::a11y::{AccessNode, AccessTreeBuilder}; 144 + use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 145 + use bone_ui::strings::StringTable; 146 + use bone_ui::widgets::LabelText; 147 + use bone_ui::{Role, WidgetId, WidgetKey}; 148 + 149 + use super::*; 150 + 151 + const RIBBON: WidgetKey = WidgetKey::new("ribbon"); 152 + const BUTTON: WidgetKey = WidgetKey::new("button"); 153 + 154 + fn rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { 155 + LayoutRect::new( 156 + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 157 + LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)), 158 + ) 159 + } 160 + 161 + fn labeled_button(label: &str) -> AccessNode { 162 + AccessNode::new(Role::Button).with_label_text(LabelText::Owned(label.to_owned())) 163 + } 164 + 165 + fn dumped(builder: &AccessTreeBuilder) -> TreeDump { 166 + let update = builder.build(&StringTable::new(), None); 167 + match TreeDump::from_update(&update) { 168 + Ok(dump) => dump, 169 + Err(e) => panic!("dump failed: {e}"), 170 + } 171 + } 172 + 173 + #[test] 174 + fn dump_nests_children_under_the_window_root() { 175 + let mut builder = AccessTreeBuilder::new(); 176 + builder.push( 177 + WidgetId::ROOT.child(RIBBON), 178 + rect(10.0, 20.0, 20.0, 20.0), 179 + labeled_button("Extrude").with_toggled(Toggled::True), 180 + ); 181 + builder.push( 182 + WidgetId::ROOT.child(BUTTON), 183 + rect(40.0, 20.0, 20.0, 20.0), 184 + labeled_button("Fillet").with_disabled(true), 185 + ); 186 + let dump = dumped(&builder); 187 + assert_eq!(dump.root.role, Role::Window); 188 + assert_eq!(dump.root.node, NodeRef::from_widget(WidgetId::ROOT)); 189 + let labels: Vec<Option<&str>> = dump 190 + .root 191 + .children 192 + .iter() 193 + .map(|c| c.label.as_deref()) 194 + .collect(); 195 + assert_eq!(labels, vec![Some("Extrude"), Some("Fillet")]); 196 + assert_eq!(dump.root.children[0].states.toggled, Some(Toggled::True)); 197 + assert!(!dump.root.children[1].states.enabled); 198 + } 199 + 200 + #[test] 201 + fn refs_stay_stable_across_frames() { 202 + let extrude = WidgetId::ROOT.child(RIBBON).child_named(BUTTON, "extrude"); 203 + let mut builder = AccessTreeBuilder::new(); 204 + builder.push( 205 + extrude, 206 + rect(10.0, 20.0, 20.0, 20.0), 207 + labeled_button("Extrude"), 208 + ); 209 + let first = dumped(&builder); 210 + builder.begin_frame(); 211 + builder.push( 212 + WidgetId::ROOT.child(BUTTON), 213 + rect(0.0, 0.0, 5.0, 5.0), 214 + labeled_button("New"), 215 + ); 216 + builder.push( 217 + extrude, 218 + rect(10.0, 60.0, 20.0, 20.0), 219 + labeled_button("Extrude"), 220 + ); 221 + let second = dumped(&builder); 222 + let ref_of = |dump: &TreeDump, label: &str| { 223 + dump.root 224 + .children 225 + .iter() 226 + .find(|c| c.label.as_deref() == Some(label)) 227 + .map(|c| c.node) 228 + }; 229 + assert_eq!(ref_of(&first, "Extrude"), ref_of(&second, "Extrude")); 230 + assert_eq!( 231 + ref_of(&first, "Extrude"), 232 + Some(NodeRef::from_widget(extrude)) 233 + ); 234 + } 235 + 236 + #[test] 237 + fn text_form_is_one_indented_line_per_node() { 238 + let mut builder = AccessTreeBuilder::new(); 239 + builder.push( 240 + WidgetId::ROOT.child(RIBBON), 241 + rect(10.0, 20.0, 20.0, 20.0), 242 + labeled_button("Extrude").with_selected(true), 243 + ); 244 + let dump = dumped(&builder); 245 + let child = NodeRef::from_widget(WidgetId::ROOT.child(RIBBON)); 246 + let expected = format!( 247 + "b0feb0feb0feb0fe window\n {child} button \"Extrude\" (10,20)-(30,40) selected=true\n", 248 + ); 249 + assert_eq!(dump.to_text(), expected); 250 + } 251 + 252 + #[test] 253 + fn json_form_carries_refs_roles_bounds_and_states() { 254 + let mut builder = AccessTreeBuilder::new(); 255 + builder.push( 256 + WidgetId::ROOT.child(RIBBON), 257 + rect(10.0, 20.0, 20.0, 20.0), 258 + labeled_button("Extrude").with_expanded(false), 259 + ); 260 + let dump = dumped(&builder); 261 + let child = NodeRef::from_widget(WidgetId::ROOT.child(RIBBON)); 262 + let value = match serde_json::to_value(&dump) { 263 + Ok(v) => v, 264 + Err(e) => panic!("serialize failed: {e}"), 265 + }; 266 + assert_eq!( 267 + value, 268 + serde_json::json!({ 269 + "root": { 270 + "ref": "b0feb0feb0feb0fe", 271 + "role": "window", 272 + "states": { "enabled": true }, 273 + "children": [{ 274 + "ref": child.to_string(), 275 + "role": "button", 276 + "label": "Extrude", 277 + "bounds": { "x0": 10.0, "y0": 20.0, "x1": 30.0, "y1": 40.0 }, 278 + "states": { "enabled": true, "expanded": false }, 279 + }], 280 + }, 281 + }), 282 + ); 283 + } 284 + 285 + #[test] 286 + fn missing_root_is_a_typed_error() { 287 + let update = TreeUpdate { 288 + nodes: vec![], 289 + tree: None, 290 + tree_id: accesskit::TreeId::ROOT, 291 + focus: NodeId(1), 292 + }; 293 + assert_eq!( 294 + TreeDump::from_update(&update), 295 + Err(TreeDumpError::MissingTree), 296 + ); 297 + } 298 + }
crates/bone-jig/tests/goldens/smoke_final.png

This is a binary file and will not be displayed.

+218
crates/bone-jig/tests/smoke.rs
··· 1 + use std::path::{Path, PathBuf}; 2 + 3 + use bone_jig::{RunStatus, Scenario, ScenarioSource, StepOutcome, run_scenario}; 4 + use bone_render::{PixelDiff, PixelDiffThreshold, decode_png}; 5 + 6 + const GOLDEN_DIFF_TOLERANCE: f64 = 16.0 / 255.0; 7 + const UPDATE_ENV: &str = "BONE_UPDATE_JIG_SMOKE"; 8 + 9 + fn manifest_path(rel: &str) -> PathBuf { 10 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel) 11 + } 12 + 13 + fn run_library_scenario(name: &str, out_dir: &Path) -> bone_jig::RunResult { 14 + let source = ScenarioSource::Path(manifest_path(&format!("scenarios/{name}"))); 15 + let scenario = match Scenario::load(&source) { 16 + Ok(s) => s, 17 + Err(e) => panic!("load {name}: {e}"), 18 + }; 19 + let mut sink = Vec::new(); 20 + match run_scenario(&scenario, out_dir, &mut sink) { 21 + Ok(result) => result, 22 + Err(e) => panic!("run {name}: {e}"), 23 + } 24 + } 25 + 26 + fn find_artifact(out_dir: &Path, suffix: &str) -> PathBuf { 27 + let entries = match std::fs::read_dir(out_dir) { 28 + Ok(it) => it, 29 + Err(e) => panic!("read {}: {e}", out_dir.display()), 30 + }; 31 + entries 32 + .filter_map(Result::ok) 33 + .map(|entry| entry.path()) 34 + .find(|path| { 35 + path.file_name() 36 + .and_then(|n| n.to_str()) 37 + .is_some_and(|n| n.ends_with(suffix)) 38 + }) 39 + .unwrap_or_else(|| panic!("no artifact ending in {suffix} under {}", out_dir.display())) 40 + } 41 + 42 + fn check_golden(actual_png: &Path, golden: &Path) { 43 + let bytes = match std::fs::read(actual_png) { 44 + Ok(b) => b, 45 + Err(e) => panic!("read {}: {e}", actual_png.display()), 46 + }; 47 + if std::env::var(UPDATE_ENV).is_ok() { 48 + if let Some(parent) = golden.parent() 49 + && let Err(e) = std::fs::create_dir_all(parent) 50 + { 51 + panic!("create {}: {e}", parent.display()); 52 + } 53 + if let Err(e) = std::fs::write(golden, &bytes) { 54 + panic!("write {}: {e}", golden.display()); 55 + } 56 + return; 57 + } 58 + let Ok(golden_bytes) = std::fs::read(golden) else { 59 + panic!( 60 + "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate", 61 + golden.display(), 62 + ); 63 + }; 64 + let ((actual_extent, actual_rgba), (golden_extent, golden_rgba)) = 65 + match (decode_png(&bytes), decode_png(&golden_bytes)) { 66 + (Ok(a), Ok(g)) => (a, g), 67 + (Err(e), _) | (_, Err(e)) => panic!("decode png: {e}"), 68 + }; 69 + assert_eq!(actual_extent, golden_extent, "golden extent drift"); 70 + let threshold = PixelDiffThreshold::new(GOLDEN_DIFF_TOLERANCE); 71 + let report = 72 + match PixelDiff::compare_bytes(actual_extent, &actual_rgba, &golden_rgba, threshold) { 73 + Ok(r) => r, 74 + Err(e) => panic!("PixelDiff rejected inputs: {e}"), 75 + }; 76 + assert!( 77 + report.is_clean(), 78 + "smoke frame drifted from golden: {} mismatches, worst {:?}", 79 + report.over_threshold(), 80 + report.worst(), 81 + ); 82 + } 83 + 84 + #[test] 85 + fn shell_smoke_matches_golden() { 86 + let dir = match tempfile::tempdir() { 87 + Ok(d) => d, 88 + Err(e) => panic!("tempdir: {e}"), 89 + }; 90 + let result = run_library_scenario("smoke.ron", dir.path()); 91 + assert_eq!(result.status, RunStatus::Passed, "result: {result:?}"); 92 + let snapshot = find_artifact(dir.path(), "_final.png"); 93 + check_golden(&snapshot, &manifest_path("tests/goldens/smoke_final.png")); 94 + } 95 + 96 + fn run_text(text: &str, out_dir: &Path) -> bone_jig::RunResult { 97 + let scenario = match Scenario::parse(text) { 98 + Ok(s) => s, 99 + Err(e) => panic!("parse scenario: {e}"), 100 + }; 101 + let mut sink = Vec::new(); 102 + match run_scenario(&scenario, out_dir, &mut sink) { 103 + Ok(result) => result, 104 + Err(e) => panic!("run scenario: {e}"), 105 + } 106 + } 107 + 108 + fn read_bytes(path: &Path) -> Vec<u8> { 109 + match std::fs::read(path) { 110 + Ok(bytes) => bytes, 111 + Err(e) => panic!("read {}: {e}", path.display()), 112 + } 113 + } 114 + 115 + #[test] 116 + fn step_import_lands_one_frame_after_open() { 117 + let dir = match tempfile::tempdir() { 118 + Ok(d) => d, 119 + Err(e) => panic!("tempdir: {e}"), 120 + }; 121 + let fixture = manifest_path("../bone-interop/tests/goldens/cube.step"); 122 + let Some(path) = fixture.to_str() else { 123 + panic!("fixture path is not utf-8: {}", fixture.display()); 124 + }; 125 + let text = format!( 126 + r#"Scenario(steps: [Advance(2), OpenDocument({path:?}), Advance(1), DumpTree("cube")])"# 127 + ); 128 + let result = run_text(&text, dir.path()); 129 + assert_eq!(result.status, RunStatus::Passed, "result: {result:?}"); 130 + let tree = match std::fs::read_to_string(dir.path().join("003_cube.tree.json")) { 131 + Ok(t) => t, 132 + Err(e) => panic!("read tree: {e}"), 133 + }; 134 + assert!( 135 + tree.contains("cube") && !tree.contains("Untitled"), 136 + "a synchronous open replaces the default document within one frame; tree: {tree}", 137 + ); 138 + } 139 + 140 + #[test] 141 + fn missing_step_document_fails_the_step_and_halts() { 142 + let dir = match tempfile::tempdir() { 143 + Ok(d) => d, 144 + Err(e) => panic!("tempdir: {e}"), 145 + }; 146 + let result = run_text( 147 + r#"Scenario(steps: [OpenDocument("/nonexistent/clam.step"), Snapshot("after")])"#, 148 + dir.path(), 149 + ); 150 + assert_eq!( 151 + result.status, 152 + RunStatus::Failed, 153 + "a missing import must fail the run", 154 + ); 155 + assert_eq!( 156 + result.steps.len(), 157 + 1, 158 + "the snapshot after a failed open must not run", 159 + ); 160 + assert!(matches!(result.steps[0].outcome, StepOutcome::Failed(_))); 161 + assert!(!dir.path().join("001_after.png").exists()); 162 + } 163 + 164 + #[test] 165 + fn smoke_run_is_byte_identical_across_runs() { 166 + let first = match tempfile::tempdir() { 167 + Ok(d) => d, 168 + Err(e) => panic!("tempdir: {e}"), 169 + }; 170 + let second = match tempfile::tempdir() { 171 + Ok(d) => d, 172 + Err(e) => panic!("tempdir: {e}"), 173 + }; 174 + let a = run_library_scenario("smoke.ron", first.path()); 175 + let b = run_library_scenario("smoke.ron", second.path()); 176 + assert_eq!(a.status, RunStatus::Passed, "first run: {a:?}"); 177 + assert_eq!(b.status, RunStatus::Passed, "second run: {b:?}"); 178 + assert_eq!( 179 + read_bytes(&find_artifact(first.path(), "_final.png")), 180 + read_bytes(&find_artifact(second.path(), "_final.png")), 181 + "two runs of the same scenario must produce byte-identical pixels", 182 + ); 183 + assert_eq!( 184 + read_bytes(&find_artifact(first.path(), "_final.tree.json")), 185 + read_bytes(&find_artifact(second.path(), "_final.tree.json")), 186 + "two runs must produce byte-identical trees", 187 + ); 188 + } 189 + 190 + #[test] 191 + fn every_library_scenario_runs_clean() { 192 + let dir = manifest_path("scenarios"); 193 + let entries = match std::fs::read_dir(&dir) { 194 + Ok(it) => it, 195 + Err(e) => panic!("read {}: {e}", dir.display()), 196 + }; 197 + let ran: Vec<PathBuf> = entries 198 + .filter_map(Result::ok) 199 + .map(|entry| entry.path()) 200 + .filter(|path| path.extension().is_some_and(|ext| ext == "ron")) 201 + .inspect(|path| { 202 + let out = match tempfile::tempdir() { 203 + Ok(d) => d, 204 + Err(e) => panic!("tempdir: {e}"), 205 + }; 206 + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { 207 + panic!("scenario path is not utf-8: {}", path.display()); 208 + }; 209 + let result = run_library_scenario(name, out.path()); 210 + assert_eq!( 211 + result.status, 212 + RunStatus::Passed, 213 + "{name} did not run clean: {result:?}", 214 + ); 215 + }) 216 + .collect(); 217 + assert!(ran.len() >= 7, "library shrank: {ran:?}"); 218 + }
+1
crates/bone-render/Cargo.toml
··· 14 14 lyon_tessellation = { workspace = true } 15 15 nalgebra = { workspace = true } 16 16 png = { workspace = true } 17 + serde = { workspace = true } 17 18 slotmap = { workspace = true } 18 19 swash = { workspace = true } 19 20 thiserror = { workspace = true }
+3 -1
crates/bone-render/src/camera.rs
··· 1 1 use bone_types::{Length, Vec2}; 2 + use serde::{Deserialize, Serialize}; 2 3 use uom::si::length::millimeter; 3 4 4 - #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 5 + #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] 6 + #[serde(transparent)] 5 7 pub struct ViewportPx(u32); 6 8 7 9 impl ViewportPx {
+84 -25
crates/bone-render/src/gpu.rs
··· 10 10 const MSAA_SAMPLE_COUNT: u32 = 4; 11 11 12 12 #[derive(Copy, Clone, Debug, PartialEq, Eq)] 13 + pub enum AdapterPolicy { 14 + Platform, 15 + Software, 16 + } 17 + 18 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 13 19 pub struct BackendTag(wgpu::Backend); 14 20 15 21 impl BackendTag { ··· 121 127 gpu: Gpu, 122 128 color: wgpu::Texture, 123 129 pick: wgpu::Texture, 130 + depth: wgpu::Texture, 124 131 pick_staging: wgpu::Buffer, 125 132 extent: ViewportExtent, 126 133 } 127 134 135 + fn require_nonzero(extent: ViewportExtent) -> Result<()> { 136 + if extent.width().value() == 0 || extent.height().value() == 0 { 137 + return Err(RenderError::ZeroExtent); 138 + } 139 + Ok(()) 140 + } 141 + 142 + fn offscreen_color_texture(device: &wgpu::Device, extent: ViewportExtent) -> wgpu::Texture { 143 + device.create_texture(&wgpu::TextureDescriptor { 144 + label: Some("bone-render:offscreen-color"), 145 + size: texture_size(extent), 146 + mip_level_count: 1, 147 + sample_count: 1, 148 + dimension: wgpu::TextureDimension::D2, 149 + format: COLOR_FORMAT, 150 + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, 151 + view_formats: &[], 152 + }) 153 + } 154 + 155 + fn offscreen_pick_texture(device: &wgpu::Device, extent: ViewportExtent) -> wgpu::Texture { 156 + device.create_texture(&wgpu::TextureDescriptor { 157 + label: Some("bone-render:offscreen-pick"), 158 + size: texture_size(extent), 159 + mip_level_count: 1, 160 + sample_count: 1, 161 + dimension: wgpu::TextureDimension::D2, 162 + format: PICK_FORMAT, 163 + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, 164 + view_formats: &[], 165 + }) 166 + } 167 + 128 168 impl OffscreenContext { 129 - pub async fn new(extent: ViewportExtent) -> Result<Self> { 130 - if extent.width().value() == 0 || extent.height().value() == 0 { 131 - return Err(RenderError::ZeroExtent); 132 - } 169 + pub async fn new(extent: ViewportExtent, policy: AdapterPolicy) -> Result<Self> { 170 + require_nonzero(extent)?; 133 171 let instance = 134 172 wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); 135 173 let adapter = instance 136 174 .request_adapter(&wgpu::RequestAdapterOptions { 137 175 power_preference: wgpu::PowerPreference::LowPower, 138 - force_fallback_adapter: false, 176 + force_fallback_adapter: matches!(policy, AdapterPolicy::Software), 139 177 compatible_surface: None, 140 178 }) 141 179 .await?; ··· 150 188 }) 151 189 .await?; 152 190 let capabilities = Capabilities::probe(&adapter); 153 - let color = device.create_texture(&wgpu::TextureDescriptor { 154 - label: Some("bone-render:offscreen-color"), 155 - size: texture_size(extent), 156 - mip_level_count: 1, 157 - sample_count: 1, 158 - dimension: wgpu::TextureDimension::D2, 159 - format: COLOR_FORMAT, 160 - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, 161 - view_formats: &[], 162 - }); 163 - let pick = device.create_texture(&wgpu::TextureDescriptor { 164 - label: Some("bone-render:offscreen-pick"), 165 - size: texture_size(extent), 166 - mip_level_count: 1, 167 - sample_count: 1, 168 - dimension: wgpu::TextureDimension::D2, 169 - format: PICK_FORMAT, 170 - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, 171 - view_formats: &[], 172 - }); 191 + let color = offscreen_color_texture(&device, extent); 192 + let pick = offscreen_pick_texture(&device, extent); 193 + let depth = crate::depth_texture(&device, extent); 173 194 let pick_staging = device.create_buffer(&wgpu::BufferDescriptor { 174 195 label: Some("bone-render:pick-readback"), 175 196 size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT), ··· 184 205 }, 185 206 color, 186 207 pick, 208 + depth, 187 209 pick_staging, 188 210 extent, 189 211 }) 212 + } 213 + 214 + pub fn resize(&mut self, extent: ViewportExtent) -> Result<()> { 215 + require_nonzero(extent)?; 216 + self.color = offscreen_color_texture(&self.gpu.device, extent); 217 + self.pick = offscreen_pick_texture(&self.gpu.device, extent); 218 + self.depth = crate::depth_texture(&self.gpu.device, extent); 219 + self.extent = extent; 220 + Ok(()) 221 + } 222 + 223 + pub fn render_passes<F>(&self, build_passes: F) 224 + where 225 + F: FnOnce( 226 + &mut wgpu::CommandEncoder, 227 + &wgpu::TextureView, 228 + &wgpu::TextureView, 229 + &wgpu::TextureView, 230 + ), 231 + { 232 + let mut encoder = self 233 + .gpu 234 + .device 235 + .create_command_encoder(&wgpu::CommandEncoderDescriptor { 236 + label: Some("bone-render:offscreen-frame"), 237 + }); 238 + let color_view = self.color_view(); 239 + let pick_view = self.pick_view(); 240 + let depth_view = self 241 + .depth 242 + .create_view(&wgpu::TextureViewDescriptor::default()); 243 + build_passes(&mut encoder, &color_view, &pick_view, &depth_view); 244 + self.gpu.queue.submit(Some(encoder.finish())); 245 + } 246 + 247 + pub fn capture(&self) -> Result<SnapshotFrame> { 248 + self.render(|_, _, _| {}) 190 249 } 191 250 192 251 #[must_use]
+2 -2
crates/bone-render/src/lib.rs
··· 19 19 world_ray, zoom_about_pixel, 20 20 }; 21 21 pub use diff::{PixelDiff, PixelDiffError, PixelDiffReport, PixelDiffThreshold, PixelMismatch}; 22 - pub use gpu::{BackendTag, Capabilities, Gpu, OffscreenContext}; 22 + pub use gpu::{AdapterPolicy, BackendTag, Capabilities, Gpu, OffscreenContext}; 23 23 pub use navigate::{DragModifiers, NavGesture, ViewportNavigator}; 24 24 pub use pick::{ 25 25 EntityKindTag, PickAperture, PickId, PickIdError, PickIndex, PickQuery, PickedItem, Picker, ··· 39 39 }; 40 40 pub use snapshot::{ 41 41 ClearColor, EdgeStyle, GlyphStyle, GridStyle, SnapshotFrame, StrokeStyle, Style, TextStyle, 42 - decode_png, encode_png, 42 + decode_png, encode_png, encode_png_rgba, 43 43 }; 44 44 pub use surface::{SurfaceContext, SurfaceError}; 45 45 pub use tween::CameraTween;
+5 -3
crates/bone-render/src/pipelines/vector.rs
··· 355 355 #[cfg(test)] 356 356 mod tests { 357 357 use super::*; 358 - use crate::{OffscreenContext, ViewportExtent, ViewportPx}; 358 + use crate::{AdapterPolicy, OffscreenContext, ViewportExtent, ViewportPx}; 359 359 360 360 fn clear_black(encoder: &mut wgpu::CommandEncoder, color: &wgpu::TextureView) { 361 361 let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { ··· 415 415 #[test] 416 416 fn a_translucent_polyline_joint_blends_once_on_the_gpu() { 417 417 let extent = ViewportExtent::square(ViewportPx::new(64)); 418 - let Ok(ctx) = pollster::block_on(OffscreenContext::new(extent)) else { 418 + let Ok(ctx) = pollster::block_on(OffscreenContext::new(extent, AdapterPolicy::Platform)) 419 + else { 419 420 return; 420 421 }; 421 422 let mut stroke = StrokePipeline::new(ctx.gpu(), ctx.color_format()); ··· 449 450 #[test] 450 451 fn convex_fill_and_stroke_paint_their_pixels_on_the_gpu() { 451 452 let extent = ViewportExtent::square(ViewportPx::new(64)); 452 - let Ok(ctx) = pollster::block_on(OffscreenContext::new(extent)) else { 453 + let Ok(ctx) = pollster::block_on(OffscreenContext::new(extent, AdapterPolicy::Platform)) 454 + else { 453 455 return; 454 456 }; 455 457 let mut convex = ConvexPolyPipeline::new(ctx.gpu(), ctx.color_format());
+7 -6
crates/bone-render/src/snapshot.rs
··· 400 400 } 401 401 402 402 pub fn encode_png(frame: &SnapshotFrame) -> Result<Vec<u8>> { 403 + encode_png_rgba(frame.extent, frame.rgba()) 404 + } 405 + 406 + pub fn encode_png_rgba(extent: ViewportExtent, rgba: &[u8]) -> Result<Vec<u8>> { 403 407 let mut out: Vec<u8> = Vec::new(); 404 408 { 405 - let mut encoder = png::Encoder::new( 406 - &mut out, 407 - frame.extent.width().value(), 408 - frame.extent.height().value(), 409 - ); 409 + let mut encoder = 410 + png::Encoder::new(&mut out, extent.width().value(), extent.height().value()); 410 411 encoder.set_color(png::ColorType::Rgba); 411 412 encoder.set_depth(png::BitDepth::Eight); 412 413 let mut writer = encoder.write_header()?; 413 - writer.write_image_data(frame.rgba())?; 414 + writer.write_image_data(rgba)?; 414 415 } 415 416 Ok(out) 416 417 }
+3 -3
crates/bone-render/tests/common/mod.rs
··· 6 6 use std::path::PathBuf; 7 7 8 8 use bone_render::{ 9 - OffscreenContext, PixelDiff, PixelDiffThreshold, RenderError, SnapshotFrame, ViewportExtent, 10 - ViewportPx, decode_png, encode_png, 9 + AdapterPolicy, OffscreenContext, PixelDiff, PixelDiffThreshold, RenderError, SnapshotFrame, 10 + ViewportExtent, ViewportPx, decode_png, encode_png, 11 11 }; 12 12 13 13 const ADAPTER_RETRIES: u32 = 3; ··· 23 23 pub fn make_context(extent: ViewportExtent) -> OffscreenContext { 24 24 let mut last_err: Option<RenderError> = None; 25 25 for attempt in 0..ADAPTER_RETRIES { 26 - match pollster::block_on(OffscreenContext::new(extent)) { 26 + match pollster::block_on(OffscreenContext::new(extent, AdapterPolicy::Platform)) { 27 27 Ok(ctx) => return ctx, 28 28 Err(e @ (RenderError::NoAdapter(_) | RenderError::Device(_))) => { 29 29 last_err = Some(e);
+5
crates/bone-ui/src/input/pointer.rs
··· 80 80 pub fn since(self, earlier: Self) -> Duration { 81 81 self.0.saturating_sub(earlier.0) 82 82 } 83 + 84 + #[must_use] 85 + pub fn after(self, delay: Duration) -> Self { 86 + Self(self.0.saturating_add(delay)) 87 + } 83 88 } 84 89 85 90 #[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Serialize, Deserialize)]