diff --git a/Cargo.lock b/Cargo.lock index 69dda9c..7691100 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,8 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5351dcebb14b579ccab05f288596b2ae097005be7ee50a7c3d4ca9d0d5a66f6a" dependencies = [ + "enumn", + "serde", "uuid", ] @@ -452,6 +454,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "bone-jig" +version = "0.0.0" +dependencies = [ + "accesskit", + "bone-app", + "bone-render", + "bone-ui", + "pollster", + "ron", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "wgpu", +] + [[package]] name = "bone-kernel" version = "0.0.0" @@ -485,6 +504,7 @@ dependencies = [ "png", "pollster", "proptest", + "serde", "slotmap", "swash", "thiserror 2.0.18", @@ -988,6 +1008,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equator" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 8062cf1..9625ea2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/bone-render", "crates/bone-ui", "crates/bone-app", + "crates/bone-jig", ] [workspace.package] @@ -32,6 +33,7 @@ missing_errors_doc = "allow" needless_for_each = "allow" [workspace.dependencies] +bone-app = { path = "crates/bone-app", default-features = false } bone-types = { path = "crates/bone-types" } bone-kernel = { path = "crates/bone-kernel" } bone-solver = { path = "crates/bone-solver" } @@ -41,7 +43,7 @@ bone-render = { path = "crates/bone-render" } bone-text = { path = "crates/bone-text" } bone-ui = { path = "crates/bone-ui" } -accesskit = "0.24" +accesskit = { version = "0.24", features = ["serde"] } ashpd = { version = "0.13", default-features = false, features = ["async-io", "file_chooser"] } blake3 = { version = "1", default-features = false, features = ["std"] } bytemuck = { version = "1", default-features = false, features = ["derive"] } @@ -57,6 +59,7 @@ pollster = "0.4" proptest = { version = "1", default-features = false, features = ["std"] } ron = "0.12" serde = { version = "1", default-features = false, features = ["std", "derive", "rc"] } +serde_json = "1" slotmap = { version = "1", default-features = false, features = ["std", "serde"] } swash = { version = "0.2", default-features = false, features = ["std", "scale", "render"] } taffy = { version = "0.10", default-features = false, features = ["std", "flexbox", "grid", "content_size", "serde", "taffy_tree"] } diff --git a/crates/bone-app/Cargo.toml b/crates/bone-app/Cargo.toml index 8cbfe7c..f71ebcd 100644 --- a/crates/bone-app/Cargo.toml +++ b/crates/bone-app/Cargo.toml @@ -5,7 +5,17 @@ edition.workspace = true license.workspace = true rust-version.workspace = true +[[bin]] +name = "bone-app" +path = "src/main.rs" +required-features = ["bin"] + +[features] +default = ["bin"] +bin = ["dep:tracing-subscriber", "dep:winit"] + [dependencies] +accesskit = { workspace = true } bone-types = { workspace = true } bone-document = { workspace = true } bone-interop = { workspace = true } @@ -20,12 +30,11 @@ ron = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } -tracing-subscriber = { workspace = true } +tracing-subscriber = { workspace = true, optional = true } uom = { workspace = true } -winit = { workspace = true } +winit = { workspace = true, optional = true } [dev-dependencies] -accesskit = { workspace = true } insta = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] diff --git a/crates/bone-app/src/app.rs b/crates/bone-app/src/app.rs new file mode 100644 index 0000000..d881b97 --- /dev/null +++ b/crates/bone-app/src/app.rs @@ -0,0 +1,5549 @@ +use std::collections::BTreeMap; +use std::num::NonZeroUsize; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use bone_document::{ + DimensionKind, DimensionValue, Document, DocumentFolder, EditOutcome, EvaluatedExtrude, + ExtrudeError, ExtrudeFeature, FeatureCache, FeatureNode, LineData, Sketch, SketchDimension, + SketchEdit, SketchEntity, SketchRelation, SketchVersion, SolverError, UndoStack, +}; +use bone_render::{ + Camera2, CameraTween, ChromeInstance, ChromePipeline, ChromeTextPipeline, ConvexInstance, + ConvexPolyPipeline, DragModifiers, EdgeScene, Gpu, NavGesture, PickIdError, PickIndex, + PickQuery, PickedItem, Picker, PixelsPerMm, RenderTargets, SdfGlyphInstance, SketchPreview, + SketchRenderer, SketchScene, SolidFrameView, SolidRenderer, SolidScene, StrokeInstance, + StrokePipeline, Style, ViewportExtent, ViewportNavigator, ViewportPoint, ViewportPx, + ViewportRegion, frame_current, frame_standard_view, frame_view_direction, orbit_pitch, + orbit_yaw, pan_pixels, roll_by, zoom_about_pixel, +}; +use bone_types::{ + Aabb3, Angle, AngleTolerance, BudgetCeiling, Camera3, ChordHeightTolerance, CubicEasing, + DisplayMode, DocumentId, ExtrudeId, FeatureId, GeometryGeneration, Length, Plane3, Point2, + SketchId, SketchItemId, StandardView, Vec2, ZoomFactor, +}; +use bone_ui::a11y::AccessTreeBuilder; +use bone_ui::focus::FocusManager; +use bone_ui::frame::FrameCtx; +use bone_ui::hit_test::{HitFrame, HitState, resolve}; +use bone_ui::hotkey::{ActionId, HotkeyScope, HotkeyScopes, HotkeyTable}; +use bone_ui::input::{ + FrameInstant, InputSnapshot, KeyCode as UiKeyCode, KeyEvent as UiKeyEvent, ModifierMask, + NamedKey, PointerButton, PointerButtonMask, PointerSample, +}; +use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; +use bone_ui::strings::StringTable; +use bone_ui::theme::Theme; +use bone_ui::{MaskAtlas, MaskAtlasParams, Shaper}; +use swash::FontRef; +use uom::si::angle::degree; +use uom::si::length::millimeter; + +use crate::clock::FrameClock; +use crate::dimension_editor::{ + DimensionEditorAction, DimensionEditorOutcome, DimensionEditorState, +}; +use crate::input::{InputDispatched, InputEvent, KeyDown, NavKey, ScrollDelta, WindowPoint}; +use crate::selection::Selection; +use crate::sketch_mode::{ + ClickAnchor, DimensionFlow, DragPins, DragSession, ExtrudeArming, FeatureTool, Mode, Pending, + PendingDimension, Plane, SketchTool, +}; +use crate::snap::{Anchor, SnapHit}; +use crate::status_badge::ExtrudeStatus; +use crate::{ + chrome, dimension_editor, file_menu, hotkeys, native_picker, selection, settings, shell, + shortcut_bar, sketch_mode, smart_dimension, snap, step_jobs, strings, tools, view_cube, +}; +const ZOOM_STEP_PER_LINE: f64 = 1.1; +const ZOOM_STEP_PER_PIXEL: f64 = 1.0025; +const ZOOM_KEY_STEP: f64 = 1.25; +const ORBIT_KEY_STEP_DEG: f64 = 15.0; +const ORBIT_KEY_SNAP_DEG: f64 = 90.0; +const ZOOM_MIN: f64 = 0.01; +const ZOOM_MAX: f64 = 1.0e5; +const INITIAL_ZOOM_PX_PER_MM: f64 = 12.0; +const PAN_STEP_PX: f64 = 40.0; +const PAN_FAST_MULTIPLIER: f64 = 5.0; +const ZOOM_FIT_MARGIN: f64 = 0.9; +const UNDO_CAPACITY: usize = 256; +const SNAP_TOLERANCE_PX: f64 = 8.0; +const SNAP_TOLERANCE_MAX_MM: f64 = 5.0; + +struct AppState { + extent: ViewportExtent, + renderer: SketchRenderer, + chrome_pipeline: ChromePipeline, + convex_pipeline: ConvexPolyPipeline, + stroke_pipeline: StrokePipeline, + text_pipeline: ChromeTextPipeline, + sdf_atlas: MaskAtlas, + chrome_shaper: Shaper, + sans_font: FontRef<'static>, + mono_font: FontRef<'static>, + scene: SketchScene, + camera: Camera2, + style: Style, + theme: Arc, + shell: shell::Shell, + document: Document, + plane_sketches: BTreeMap, + mode: Mode, + feature_cache: FeatureCache, + extrude_preview: Option, + solid_renderer: SolidRenderer, + solid_view: Option, + camera3: Option, + framed_extrude: Option, + navigator: ViewportNavigator, + view: view_cube::ViewUi, + focus: FocusManager, + hit_state: HitState, + hotkeys: HotkeyTable, + strings: StringTable, + viewport_rect: LayoutRect, + undo: UndoStack, + selection: Selection, + settings: settings::Settings, + dim_editor: DimensionEditorState, + dim_editor_bounds: Option, + pending_exit: bool, + current_folder: Option, + documents_root: PathBuf, + file_picker: Option, + native_picker: Option, + step_job: Option, + pending_overwrite: Option, + last_saved: Option, + pending_discard: Option, + notification: Option, + shortcut_bar: Option, +} + +#[derive(Clone, Debug, PartialEq)] +enum PendingDiscard { + New, + Open(PathBuf), + ImportStep(PathBuf), + InstallImported { + document: Box, + file_name: String, + }, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum PickedVia { + NativePortal, + CustomPicker, +} + +#[derive(Clone, Debug, PartialEq)] +enum PendingOverwrite { + Document(DocumentFolder), + StepExport(PathBuf), +} + +fn modal_active(state: &AppState) -> bool { + state.file_picker.is_some() + || state.native_picker.is_some() + || state + .step_job + .as_ref() + .is_some_and(|job| job.meta().show_progress) + || state.pending_overwrite.is_some() + || state.pending_discard.is_some() + || state.shortcut_bar.is_some() + || state.shell.state.ribbon_overflow_open.values().any(|v| *v) +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum NotificationKind { + Info, + Error, +} + +#[derive(Clone, Debug, PartialEq)] +struct Notification { + kind: NotificationKind, + headline: bone_ui::strings::StringKey, + detail: Option, +} + +struct InputState { + cursor_px: Option, + left_pan: bool, + middle_pan: bool, + modifiers: ModifierMask, + pending_pressed: PointerButtonMask, + pending_released: PointerButtonMask, + pending_keys: Vec, + pending_text: String, +} + +impl Default for InputState { + fn default() -> Self { + Self { + cursor_px: None, + left_pan: false, + middle_pan: false, + modifiers: ModifierMask::NONE, + pending_pressed: PointerButtonMask::EMPTY, + pending_released: PointerButtonMask::EMPTY, + pending_keys: Vec::new(), + pending_text: String::new(), + } + } +} + +impl InputState { + fn shift(&self) -> bool { + self.modifiers.contains(ModifierMask::SHIFT) + } + + fn ctrl_or_meta(&self) -> bool { + self.modifiers.contains(ModifierMask::CTRL) || self.modifiers.contains(ModifierMask::META) + } + + fn panning(&self) -> bool { + self.middle_pan || (self.left_pan && self.shift()) + } + + fn pan_step_px(&self) -> f64 { + if self.shift() { + PAN_STEP_PX * PAN_FAST_MULTIPLIER + } else { + PAN_STEP_PX + } + } + + fn pointer_sample(&self) -> Option { + self.cursor_px + .map(window_to_layout_pos) + .map(PointerSample::new) + } + + fn cursor_in(&self, rect: LayoutRect) -> bool { + self.cursor_px + .map(window_to_layout_pos) + .is_some_and(|p| rect.contains(p)) + } + + fn drain_snapshot(&mut self, now: FrameInstant) -> InputSnapshot { + let mut snap = InputSnapshot::idle(now); + snap.pointer = self.pointer_sample(); + snap.buttons_pressed = + core::mem::replace(&mut self.pending_pressed, PointerButtonMask::EMPTY); + snap.buttons_released = + core::mem::replace(&mut self.pending_released, PointerButtonMask::EMPTY); + snap.keys_pressed = core::mem::take(&mut self.pending_keys); + snap.text_committed = core::mem::take(&mut self.pending_text); + snap.modifiers = self.modifiers; + snap + } + + fn forget_pan_state(&mut self) { + self.cursor_px = None; + self.left_pan = false; + self.middle_pan = false; + self.modifiers = ModifierMask::NONE; + } +} + +#[allow( + clippy::cast_possible_truncation, + reason = "window cursor px (f64) collapses to LayoutPx (f32) at the sub-pixel limit" +)] +fn window_to_layout_pos(p: WindowPoint) -> LayoutPos { + LayoutPos::new( + LayoutPx::saturating(p.x as f32), + LayoutPx::saturating(p.y as f32), + ) +} + +pub trait FrameTarget { + fn picker(&self, index: PickIndex) -> Picker<'_>; + fn render( + &mut self, + build_passes: impl FnOnce( + &mut wgpu::CommandEncoder, + &wgpu::TextureView, + &wgpu::TextureView, + &wgpu::TextureView, + ), + ); +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[must_use] +pub struct FrameReport { + pub kick: bool, + pub exit: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum OpenError { + #[error("import step {path}: {source}")] + ImportStep { + path: PathBuf, + #[source] + source: bone_interop::StepError, + }, + #[error("load document folder {path}: {source}")] + LoadFolder { + path: PathBuf, + #[source] + source: bone_document::FolderError, + }, +} + +pub struct AppCore { + state: AppState, + input: InputState, + clock: FrameClock, + a11y: AccessTreeBuilder, +} + +fn default_sketch() -> Sketch { + let sketch = Sketch::new(Plane::Xy.basis()); + let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(-20.0, -12.5)); + let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(20.0, -12.5)); + let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(20.0, 12.5)); + let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(-20.0, 12.5)); + let (sketch, _) = tools::add_line(sketch, p0, p1, false); + let (sketch, _) = tools::add_line(sketch, p1, p2, false); + let (sketch, _) = tools::add_line(sketch, p2, p3, false); + let (sketch, _) = tools::add_line(sketch, p3, p0, false); + let (sketch, origin) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); + let (sketch, _) = tools::add_circle(sketch, origin, Length::new::(5.0), false); + sketch +} + +fn initial_document(sketch: Sketch) -> (Document, SketchId) { + let mut document = Document::new(DocumentId::default(), "Untitled".to_owned()); + let sketch_id = SketchId::default(); + document.insert_sketch(sketch_id, "Sketch1".to_owned(), sketch); + (document, sketch_id) +} + +#[allow( + clippy::cast_precision_loss, + reason = "viewport pixel counts at any realistic display size fit f32 mantissa" +)] +fn layout_size_from_extent(extent: ViewportExtent) -> LayoutSize { + LayoutSize::new( + LayoutPx::new(extent.width().value() as f32), + LayoutPx::new(extent.height().value() as f32), + ) +} + +fn empty_rect() -> LayoutRect { + LayoutRect::new(LayoutPos::ORIGIN, LayoutSize::ZERO) +} + +fn zoom_factor(delta: ScrollDelta) -> f64 { + match delta { + ScrollDelta::Lines { y, .. } => ZOOM_STEP_PER_LINE.powf(f64::from(y)), + ScrollDelta::Pixels { y, .. } => ZOOM_STEP_PER_PIXEL.powf(y), + } +} + +fn zoom_about(camera: Camera2, cursor: Option, factor: f64) -> Camera2 { + if !factor.is_finite() || factor <= 0.0 { + return camera; + } + let zoom_before = camera.zoom().value(); + let zoom_after = (zoom_before * factor).clamp(ZOOM_MIN, ZOOM_MAX); + if !zoom_after.is_finite() { + return camera; + } + let extent = camera.extent(); + let w = f64::from(extent.width().value()); + let h = f64::from(extent.height().value()); + let (pan_x, pan_y) = camera.pan_mm().coords_mm(); + let (cursor_x, cursor_y) = cursor.map_or((w * 0.5, h * 0.5), |c| (c.x, c.y)); + let horizontal_px = cursor_x - w * 0.5; + let vertical_px = h * 0.5 - cursor_y; + let world_x = pan_x + horizontal_px / zoom_before; + let world_y = pan_y + vertical_px / zoom_before; + let new_pan_x = world_x - horizontal_px / zoom_after; + let new_pan_y = world_y - vertical_px / zoom_after; + camera + .with_zoom(PixelsPerMm::new(zoom_after)) + .with_pan(Vec2::from_mm(new_pan_x, new_pan_y)) +} + +fn dragging_in_sketch(mode: &Mode) -> bool { + matches!( + mode, + Mode::Sketch { session, .. } if session.drag.is_some() + ) +} + +fn dim_flow_active(mode: &Mode) -> bool { + matches!( + mode, + Mode::Sketch { session, .. } if session.dim_flow.is_some() + ) +} + +fn cursor_to_world(camera: Camera2, cursor: WindowPoint) -> Option { + let extent = camera.extent(); + let w = f64::from(extent.width().value()); + let h = f64::from(extent.height().value()); + let zoom = camera.zoom().value(); + if w <= 0.0 || h <= 0.0 || !zoom.is_finite() || zoom <= 0.0 { + return None; + } + let (pan_x, pan_y) = camera.pan_mm().coords_mm(); + Some(Point2::from_mm( + pan_x + (cursor.x - w * 0.5) / zoom, + pan_y + (h * 0.5 - cursor.y) / zoom, + )) +} + +fn try_place(state: &mut AppState, world: Point2) { + let Mode::Sketch { sketch_id, session } = &state.mode else { + return; + }; + let Some(tool) = session.tool else { + return; + }; + let prev_pending = session.pending; + let sketch_id = *sketch_id; + let Some(sketch) = state.document.sketch(sketch_id).cloned() else { + return; + }; + let snap = match tool { + SketchTool::Line => { + compute_snap(&sketch, &state.camera, world, latest_anchor(prev_pending)) + } + _ => compute_endpoint_snap(&sketch, &state.camera, world), + }; + let (next_sketch, new_pending) = tools::place(sketch, tool, world, prev_pending, snap); + if let Some(next) = next_sketch { + state.undo.record(state.document.clone()); + state.document.replace_sketch(sketch_id, next); + let Some(stored) = state.document.sketch(sketch_id) else { + return; + }; + match SketchScene::extract(stored) { + Ok(scene) => state.scene = scene, + Err(e) => tracing::warn!(error = %e, "scene extract after place failed"), + } + } + if let Mode::Sketch { + ref mut session, .. + } = state.mode + { + session.pending = new_pending; + } +} + +const fn latest_anchor(pending: Option) -> Option { + match pending { + None => None, + Some(Pending::First(a) | Pending::Second(_, a)) => Some(a), + } +} + +fn pick_at(state: &AppState, target: &impl FrameTarget, cursor: WindowPoint) -> Option { + if !cursor.x.is_finite() || !cursor.y.is_finite() || cursor.x < 0.0 || cursor.y < 0.0 { + return None; + } + let extent = state.extent; + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "cursor px is bounds-checked against surface extent before the cast" + )] + let qx = cursor.x.round() as u32; + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "cursor px is bounds-checked against surface extent before the cast" + )] + let qy = cursor.y.round() as u32; + if qx >= extent.width().value() || qy >= extent.height().value() { + return None; + } + let index = match state.scene.pick_index() { + Ok(i) => i, + Err(e) => { + tracing::warn!(error = %e, "build pick index"); + return None; + } + }; + let query = PickQuery::new(ViewportPx::new(qx), ViewportPx::new(qy)) + .with_aperture(state.settings.pick_aperture); + match target.picker(index).at(query) { + Ok(item) => item, + Err(e) => { + tracing::warn!(error = %e, "pick failed"); + None + } + } +} + +fn handle_viewport_click( + state: &mut AppState, + target: &impl FrameTarget, + cursor: WindowPoint, + additive: bool, +) { + let item = pick_at(state, target, cursor).and_then(selection::picked_to_item); + state.selection = std::mem::take(&mut state.selection).picked(item, additive); + if additive || !state.mode.is_sketch() { + return; + } + let Some(SketchItemId::Entity(entity_id)) = item else { + return; + }; + let Mode::Sketch { sketch_id, .. } = state.mode else { + return; + }; + let Some(sketch) = state.document.sketch(sketch_id) else { + return; + }; + let Some(world) = cursor_to_world(state.camera, cursor) else { + return; + }; + let Some(pins) = DragPins::from_sketch_entity(sketch, entity_id) else { + return; + }; + let drag = DragSession { + entity: entity_id, + press: world, + pins, + }; + state.undo.record(state.document.clone()); + state.mode = core::mem::take(&mut state.mode).start_drag(drag); +} + +fn drag_resolved(sketch: &Sketch, drag: DragSession, world: Point2) -> Option { + let pins = drag.pins.to_targets(drag.press, world); + sketch + .solve_with_drag_pins(&pins, BudgetCeiling::FRAME_16MS) + .map_err(|e| tracing::trace!(error = %e, "drag solve failed, keeping last-good sketch")) + .ok() +} + +fn try_drag_to(state: &mut AppState, world: Point2) { + let Mode::Sketch { sketch_id, session } = &state.mode else { + return; + }; + let Some(drag) = session.drag else { + return; + }; + let sketch_id = *sketch_id; + let Some(sketch) = state.document.sketch(sketch_id) else { + return; + }; + let Some(next) = drag_resolved(sketch, drag, world) else { + return; + }; + state.document.replace_sketch(sketch_id, next); + refresh_active_scene(state); +} + +fn refresh_active_scene(state: &mut AppState) { + let Some(active_id) = active_sketch_id(&state.mode, &state.plane_sketches) else { + return; + }; + let Some(sketch) = state.document.sketch(active_id) else { + return; + }; + match SketchScene::extract(sketch) { + Ok(scene) => state.scene = scene, + Err(e) => tracing::warn!(error = %e, "refresh active scene failed"), + } +} + +fn active_sketch_id(mode: &Mode, plane_sketches: &BTreeMap) -> Option { + match mode { + Mode::Sketch { sketch_id, .. } => Some(*sketch_id), + Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => Some(feature.sketch), + Mode::Extrude(ExtrudeArming::AwaitingSketch) | Mode::Idle => { + plane_sketches.get(&Plane::Xy).copied() + } + } +} + +enum ProfileChoice { + NoSketch, + Unique(SketchId), + Ambiguous, +} + +fn classify_extrude_profile(document: &Document) -> ProfileChoice { + let mut sketches = document + .feature_tree() + .iter() + .filter_map(|(_, node)| match node { + FeatureNode::Sketch(id) => Some(id), + _ => None, + }); + match (sketches.next(), sketches.next()) { + (None, _) => ProfileChoice::NoSketch, + (Some(id), None) => ProfileChoice::Unique(id), + (Some(_), Some(_)) => ProfileChoice::Ambiguous, + } +} + +fn apply_feature_tool(state: &mut AppState, tool: Option) { + match tool { + Some(FeatureTool::ExtrudedBossBase) => arm_extruded_boss_base(state), + Some(FeatureTool::ExtrudedCut) => notify_stub(state, strings::TOOL_EXTRUDED_CUT), + None => {} + } +} + +fn arm_extruded_boss_base(state: &mut AppState) { + match classify_extrude_profile(&state.document) { + ProfileChoice::NoSketch => notify_info(state, strings::NOTIFY_EXTRUDE_NO_SKETCH, None), + ProfileChoice::Unique(id) => state.mode = Mode::Extrude(ExtrudeArming::profile(id)), + ProfileChoice::Ambiguous => state.mode = Mode::Extrude(ExtrudeArming::AwaitingSketch), + } +} + +fn apply_extrude_edit(state: &mut AppState, edit: Option) { + let Some(edit) = edit else { return }; + let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = &state.mode else { + return; + }; + let next = edit.apply(*feature); + let target = *target; + state.mode = Mode::Extrude(ExtrudeArming::Profile { + feature: next, + target, + }); +} + +fn apply_extrude_activation(state: &mut AppState, activated: Option) { + if let Some(mode) = extrude_edit_mode(&state.document, &state.mode, activated) { + if let Mode::Extrude(ExtrudeArming::Profile { + target: Some(id), .. + }) = mode + { + state.framed_extrude = Some(id); + } + state.mode = mode; + } +} + +fn extrude_edit_mode( + document: &Document, + current: &Mode, + activated: Option, +) -> Option { + let id = activated?; + if current.is_sketch() { + return None; + } + let feature = document.extrude(id).copied()?; + Some(Mode::Extrude(ExtrudeArming::edit(id, feature))) +} + +fn apply_extrude_confirm(state: &mut AppState, confirm: Option) { + if let Some(id) = + commit_armed_extrude(&mut state.document, &mut state.undo, &state.mode, confirm) + { + state.framed_extrude = Some(id); + } +} + +fn commit_armed_extrude( + document: &mut Document, + undo: &mut UndoStack, + mode: &Mode, + confirm: Option, +) -> Option { + let Some(shell::ConfirmAction::Accept) = confirm else { + return None; + }; + let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = mode else { + return None; + }; + let snapshot = document.clone(); + let committed = match target { + Some(id) => { + document.insert_extrude(*id, *feature); + *id + } + None => document.commit_extrude(*feature), + }; + undo.record(snapshot); + Some(committed) +} + +struct SolidViewData { + faces: SolidScene, + edges: EdgeScene, + aabb: Aabb3, +} + +struct ExtrudePreview { + feature: ExtrudeFeature, + sketch_version: SketchVersion, + generation: Option, + failed: bool, + error: Option, +} + +impl ExtrudePreview { + fn status(&self) -> ExtrudeStatus<'_> { + match &self.error { + Some(error) => ExtrudeStatus::Failed(error), + None => ExtrudeStatus::Valid, + } + } +} + +const PREVIEW_CHORD_MM: f64 = 0.05; +const PREVIEW_ANGLE: AngleTolerance = AngleTolerance::from_radians(0.2); + +fn active_solid_feature( + mode: &Mode, + document: &Document, + framed: Option, +) -> Option { + match mode { + Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => Some(*feature), + Mode::Sketch { .. } => None, + Mode::Idle | Mode::Extrude(ExtrudeArming::AwaitingSketch) => framed + .and_then(|id| document.extrude(id).copied()) + .or_else(|| { + document + .feature_tree() + .iter() + .filter_map(|(_, node)| match node { + FeatureNode::Extrude(id) => Some(id), + _ => None, + }) + .last() + .and_then(|id| document.extrude(id).copied()) + }), + } +} + +fn sync_solid_view(state: &mut AppState) { + let Some(feature) = active_solid_feature(&state.mode, &state.document, state.framed_extrude) + else { + state.extrude_preview = None; + state.solid_view = None; + state.camera3 = None; + state.view.home = None; + state.view.tween = None; + return; + }; + let Some(sketch_version) = state.document.sketch(feature.sketch).map(Sketch::version) else { + state.extrude_preview = None; + state.solid_view = None; + state.camera3 = None; + state.view.home = None; + state.view.tween = None; + return; + }; + if extrude_preview_is_current(state.extrude_preview.as_ref(), &feature, sketch_version) { + return; + } + let previous_generation = state + .extrude_preview + .as_ref() + .and_then(|cached| cached.generation); + let previously_failed = state + .extrude_preview + .as_ref() + .is_some_and(|cached| cached.failed); + let first_preview = state.extrude_preview.is_none(); + let preview = compute_extrude_preview(&mut state.feature_cache, &state.document, feature); + let generation = preview.as_ref().and_then(EvaluatedExtrude::generation); + let error = preview + .as_ref() + .and_then(|evaluated| evaluated.result().as_ref().err().cloned()); + let mut failure = error.as_ref().map(ToString::to_string); + if generation != previous_generation { + state.solid_view = match preview.as_ref().and_then(EvaluatedExtrude::solid) { + Some(solid) => match build_solid_view(solid) { + Ok(view) => Some(view), + Err(error) => { + failure = Some(error); + None + } + }, + None => None, + }; + } + let now_failed = failure.is_some(); + state.extrude_preview = Some(ExtrudePreview { + feature, + sketch_version, + generation, + failed: now_failed, + error, + }); + let newly_failed = first_preview || !previously_failed; + if let Some(detail) = failure.filter(|_| newly_failed) { + tracing::warn!(error = %detail, "extrude preview evaluation failed"); + notify_error(state, strings::NOTIFY_EXTRUDE_FAILED, detail); + } +} + +fn extrude_preview_is_current( + cached: Option<&ExtrudePreview>, + feature: &ExtrudeFeature, + sketch_version: SketchVersion, +) -> bool { + cached + .is_some_and(|cached| cached.feature == *feature && cached.sketch_version == sketch_version) +} + +fn compute_extrude_preview( + cache: &mut FeatureCache, + document: &Document, + feature: ExtrudeFeature, +) -> Option { + let sketch = document.sketch(feature.sketch)?; + let fid = FeatureId::default(); + let evaluated_sketch = cache.evaluate(fid, sketch).clone(); + Some( + cache + .evaluate_extrude(fid, &evaluated_sketch, &feature) + .clone(), + ) +} + +fn build_solid_view(solid: &bone_document::BrepSolid) -> Result { + let chord = ChordHeightTolerance::from_mm(PREVIEW_CHORD_MM); + let aabb = solid + .bounding_box() + .ok_or_else(|| "degenerate solid has no bounding box".to_owned())?; + let mesh = solid + .tessellate(chord, PREVIEW_ANGLE) + .map_err(|error| error.to_string())?; + let faces = SolidScene::from_mesh(&mesh).map_err(|error| error.to_string())?; + let edges = EdgeScene::from_solid(solid, &mesh, chord).map_err(|error| error.to_string())?; + Ok(SolidViewData { faces, edges, aabb }) +} + +fn sync_solid_camera(state: &mut AppState, region: Option) { + if let Some(region) = region + && let Some(view) = state.solid_view.as_ref() + && state.camera3.is_none() + { + let framed = + frame_standard_view(view.aabb, region.extent(), StandardView::Isometric, None).ok(); + state.camera3 = framed; + if state.view.home.is_none() { + state.view.home = framed; + } + } +} + +const VIEW_TWEEN_MS: u64 = 220; + +fn step_view_tween(state: &mut AppState, now: FrameInstant) { + let Some(active) = state.view.tween else { + return; + }; + let elapsed = now.since(active.started); + if let Ok(camera) = active.tween.sample(elapsed) { + state.camera3 = Some(camera); + } + if active.tween.is_done(elapsed) { + state.camera3 = Some(active.tween.to()); + state.view.tween = None; + } +} + +fn start_view_tween(state: &mut AppState, target: Camera3, now: FrameInstant) { + let tween = if state.settings.reduce_motion { + CameraTween::immediate(target) + } else { + CameraTween::eased( + state.camera3.unwrap_or(target), + target, + std::time::Duration::from_millis(VIEW_TWEEN_MS), + CubicEasing::STANDARD, + ) + }; + state.view.tween = Some(view_cube::ActiveTween { + tween, + started: now, + }); +} + +fn apply_nav_camera(state: &mut AppState, camera: Camera3) { + state.camera3 = Some(camera); + state.view.tween = None; +} + +fn solid_aabb_and_extent(state: &AppState) -> Option<(Aabb3, ViewportExtent)> { + let region = solid_viewport_region(state.viewport_rect, state.extent)?; + let view = state.solid_view.as_ref()?; + Some((view.aabb, region.extent())) +} + +fn normal_to_plane(state: &AppState) -> Option { + let sketch_id = active_sketch_id(&state.mode, &state.plane_sketches)?; + let plane = state + .plane_sketches + .iter() + .find_map(|(plane, id)| (*id == sketch_id).then_some(*plane))?; + Some(Plane3::from(plane.basis())) +} + +fn frame_target_camera(state: &AppState, pick: view_cube::ViewPick) -> Option { + match pick { + view_cube::ViewPick::Home => state.view.home, + view_cube::ViewPick::Standard(view) => { + let (aabb, extent) = solid_aabb_and_extent(state)?; + frame_standard_view(aabb, extent, view, normal_to_plane(state)).ok() + } + view_cube::ViewPick::Direction(direction) => { + let (aabb, extent) = solid_aabb_and_extent(state)?; + frame_view_direction(aabb, extent, direction).ok() + } + } +} + +fn apply_view_pick(state: &mut AppState, pick: Option, now: FrameInstant) { + let Some(pick) = pick else { + return; + }; + if let Some(target) = frame_target_camera(state, pick) { + start_view_tween(state, target, now); + } +} + +fn view_nav_enabled(state: &AppState) -> bool { + state.solid_view.is_some() + && !modal_active(state) + && state.focus.focused().is_none() + && !dim_flow_active(&state.mode) +} + +fn apply_view_menu( + state: &mut AppState, + action: Option, + now: FrameInstant, +) { + match action { + Some(view_cube::ViewCubeMenuAction::SetAsHome) => { + state.view.home = state.camera3; + } + Some(view_cube::ViewCubeMenuAction::FitToWindow) => { + if let (Some(camera), Some((aabb, extent))) = + (state.camera3, solid_aabb_and_extent(state)) + && let Ok(target) = frame_current(camera, aabb, extent) + { + start_view_tween(state, target, now); + } + } + Some(view_cube::ViewCubeMenuAction::ViewNormalTo) => { + apply_view_pick( + state, + Some(view_cube::ViewPick::Standard(StandardView::NormalTo)), + now, + ); + } + None => {} + } +} + +fn preview_solid_frame( + solid_view: Option<&SolidViewData>, + camera: Option, + region: ViewportRegion, +) -> Option<(&SolidViewData, SolidFrameView)> { + let view = solid_view?; + Some((view, SolidFrameView::new(camera?, region).ok()?)) +} + +fn solid_viewport_region(viewport: LayoutRect, surface: ViewportExtent) -> Option { + let (surface_w, surface_h) = (surface.width().value(), surface.height().value()); + let min_x = round_layout_px(viewport.min_x().value()).min(surface_w); + let min_y = round_layout_px(viewport.min_y().value()).min(surface_h); + let width = round_layout_px(viewport.size.width.value()).min(surface_w - min_x); + let height = round_layout_px(viewport.size.height.value()).min(surface_h - min_y); + (width > 0 && height > 0).then(|| { + ViewportRegion::new( + ViewportPx::new(min_x), + ViewportPx::new(min_y), + ViewportExtent::new(ViewportPx::new(width), ViewportPx::new(height)), + ) + }) +} + +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "the saturating cast of a non-negative rounded px is clamped to the surface extent by the caller" +)] +fn round_layout_px(value: f32) -> u32 { + value.round().max(0.0) as u32 +} + +fn viewport_local_point(cursor: WindowPoint, region: ViewportRegion) -> Option { + let (min_x, min_y, _, _) = region.scissor(); + ViewportPoint::new(cursor.x - f64::from(min_x), cursor.y - f64::from(min_y)).ok() +} + +fn drag_gesture(modifiers: ModifierMask) -> NavGesture { + let with_ctrl = + if modifiers.contains(ModifierMask::CTRL) || modifiers.contains(ModifierMask::META) { + DragModifiers::NONE.with_ctrl() + } else { + DragModifiers::NONE + }; + let with_shift = if modifiers.contains(ModifierMask::SHIFT) { + with_ctrl.with_shift() + } else { + with_ctrl + }; + let resolved = if modifiers.contains(ModifierMask::ALT) { + with_shift.with_alt() + } else { + with_shift + }; + resolved.gesture() +} + +fn build_preview( + mode: &Mode, + document: &Document, + cursor_world: Option, + camera: &Camera2, +) -> SketchPreview { + let Mode::Sketch { sketch_id, session } = mode else { + return SketchPreview::empty(); + }; + if session.drag.is_some() || session.dim_flow.is_some() { + return SketchPreview::empty(); + } + let Some(tool) = session.tool else { + return SketchPreview::empty(); + }; + let pending = session.pending; + let Some(sketch) = document.sketch(*sketch_id) else { + return SketchPreview::empty(); + }; + let Some(cursor) = cursor_world else { + return tools::preview_anchors_only(sketch, pending); + }; + let snap = match tool { + SketchTool::Line => compute_snap(sketch, camera, cursor, latest_anchor(pending)), + _ => compute_endpoint_snap(sketch, camera, cursor), + }; + tools::preview(sketch, tool, cursor, pending, snap) +} + +fn snap_tolerance(camera: &Camera2) -> Option { + let mm_per_px = camera.world_mm_per_pixel(); + if !mm_per_px.is_finite() || mm_per_px <= 0.0 { + return None; + } + let tol_mm = (SNAP_TOLERANCE_PX * mm_per_px).min(SNAP_TOLERANCE_MAX_MM); + Some(Length::new::(tol_mm)) +} + +fn compute_snap( + sketch: &Sketch, + camera: &Camera2, + cursor_world: Point2, + click: Option, +) -> Option { + snap::detect( + cursor_world, + click.and_then(|c| resolve_anchor(Some(sketch), c)), + sketch, + snap_tolerance(camera)?, + ) +} + +fn compute_endpoint_snap( + sketch: &Sketch, + camera: &Camera2, + cursor_world: Point2, +) -> Option { + snap::detect_endpoint_only(cursor_world, sketch, snap_tolerance(camera)?) +} + +fn resolve_anchor(sketch: Option<&Sketch>, click: ClickAnchor) -> Option { + match click { + ClickAnchor::Position(pos) | ClickAnchor::MidpointOf { position: pos, .. } => { + Some(Anchor { pos, id: None }) + } + ClickAnchor::Endpoint(id) => match sketch?.entities().get(id)? { + SketchEntity::Point(p) => Some(Anchor { + pos: p.at(), + id: Some(id), + }), + _ => None, + }, + } +} + +fn pan_by_px(camera: Camera2, horizontal_px: f64, vertical_px: f64) -> Camera2 { + let mm_per_px = camera.world_mm_per_pixel(); + let (pan_x, pan_y) = camera.pan_mm().coords_mm(); + camera.with_pan(Vec2::from_mm( + pan_x - horizontal_px * mm_per_px, + pan_y + vertical_px * mm_per_px, + )) +} + +fn zoom_fit(camera: Camera2, scene: &SketchScene, viewport: LayoutRect) -> Camera2 { + let Some(aabb) = scene.aabb() else { + return camera; + }; + let (mnx, mny) = aabb.min().coords_mm(); + let (mxx, mxy) = aabb.max().coords_mm(); + let center_x = (mnx + mxx) * 0.5; + let center_y = (mny + mxy) * 0.5; + let world_w = mxx - mnx; + let world_h = mxy - mny; + let v_w = f64::from(viewport.size.width.value()); + let v_h = f64::from(viewport.size.height.value()); + if v_w <= 0.0 || v_h <= 0.0 { + return camera; + } + let axis_zoom = |pixels: f64, world: f64| { + if world > 0.0 { + pixels / world + } else { + f64::INFINITY + } + }; + let raw_zoom = axis_zoom(v_w, world_w).min(axis_zoom(v_h, world_h)) * ZOOM_FIT_MARGIN; + let zoom = raw_zoom.clamp(ZOOM_MIN, ZOOM_MAX); + let pan = pan_centering((center_x, center_y), camera.extent(), viewport, zoom); + camera.with_zoom(PixelsPerMm::new(zoom)).with_pan(pan) +} + +fn pan_centering( + target_world: (f64, f64), + window: ViewportExtent, + viewport: LayoutRect, + zoom: f64, +) -> Vec2 { + let v_w = f64::from(viewport.size.width.value()); + let v_h = f64::from(viewport.size.height.value()); + let viewport_center_x = f64::from(viewport.origin.x.value()) + v_w * 0.5; + let viewport_center_y = f64::from(viewport.origin.y.value()) + v_h * 0.5; + let window_center_x = f64::from(window.width().value()) * 0.5; + let window_center_y = f64::from(window.height().value()) * 0.5; + let pan_x = target_world.0 - (viewport_center_x - window_center_x) / zoom; + let pan_y = target_world.1 + (viewport_center_y - window_center_y) / zoom; + Vec2::from_mm(pan_x, pan_y) +} + +fn keyboard_camera(nav: NavKey, input: &InputState, state: &AppState) -> Option { + if input.ctrl_or_meta() { + return None; + } + let camera = state.camera; + let step = input.pan_step_px(); + let shift = input.shift(); + match nav { + NavKey::Left => Some(pan_by_px(camera, step, 0.0)), + NavKey::Right => Some(pan_by_px(camera, -step, 0.0)), + NavKey::Up => Some(pan_by_px(camera, 0.0, step)), + NavKey::Down => Some(pan_by_px(camera, 0.0, -step)), + NavKey::Zoom => Some(zoom_about( + camera, + input.cursor_px, + if shift { + 1.0 / ZOOM_KEY_STEP + } else { + ZOOM_KEY_STEP + }, + )), + NavKey::ZoomIn => Some(zoom_about(camera, input.cursor_px, ZOOM_KEY_STEP)), + NavKey::ZoomOut => Some(zoom_about(camera, input.cursor_px, 1.0 / ZOOM_KEY_STEP)), + } +} + +fn zoom_key3( + camera: Camera3, + extent: ViewportExtent, + pixel: ViewportPoint, + factor: f64, +) -> Option { + ZoomFactor::new(factor) + .ok() + .and_then(|f| zoom_about_pixel(camera, extent, pixel, f).ok()) +} + +fn keyboard_camera3(nav: NavKey, input: &InputState, state: &AppState) -> Option { + let camera = state.camera3?; + let region = solid_viewport_region(state.viewport_rect, state.extent)?; + let extent = region.extent(); + let ctrl = input.ctrl_or_meta(); + let shift = input.shift(); + let alt = input.modifiers.contains(ModifierMask::ALT); + let cx = f64::from(extent.width().value()) * 0.5; + let cy = f64::from(extent.height().value()) * 0.5; + let center = ViewportPoint::new(cx, cy).ok()?; + let pan_to = |dx: f64, dy: f64| { + ViewportPoint::new(cx + dx, cy + dy) + .ok() + .and_then(|to| pan_pixels(camera, extent, center, to).ok()) + }; + let step = Angle::new::(if shift { + ORBIT_KEY_SNAP_DEG + } else { + ORBIT_KEY_STEP_DEG + }); + match nav { + NavKey::Left if ctrl => pan_to(-PAN_STEP_PX, 0.0), + NavKey::Right if ctrl => pan_to(PAN_STEP_PX, 0.0), + NavKey::Up if ctrl => pan_to(0.0, -PAN_STEP_PX), + NavKey::Down if ctrl => pan_to(0.0, PAN_STEP_PX), + NavKey::Left if alt => roll_by(camera, step).ok(), + NavKey::Right if alt => roll_by(camera, -step).ok(), + NavKey::Left => orbit_yaw(camera, step).ok(), + NavKey::Right => orbit_yaw(camera, -step).ok(), + NavKey::Up => orbit_pitch(camera, step).ok(), + NavKey::Down => orbit_pitch(camera, -step).ok(), + NavKey::Zoom if shift => zoom_key3(camera, extent, center, 1.0 / ZOOM_KEY_STEP), + NavKey::Zoom | NavKey::ZoomIn => zoom_key3(camera, extent, center, ZOOM_KEY_STEP), + NavKey::ZoomOut => zoom_key3(camera, extent, center, 1.0 / ZOOM_KEY_STEP), + } +} + +fn build_hotkey_table() -> HotkeyTable { + let Ok(table) = hotkeys::compose_table(&hotkeys::HotkeyOverrides::default()) else { + unreachable!("default hotkey bindings are conflict-free"); + }; + table +} + +fn scopes_for_mode(mode: &Mode) -> HotkeyScopes { + let mut scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]); + if mode.is_sketch() { + scopes.push(HotkeyScope::Sketch); + } + if mode.is_extrude() { + scopes.push(HotkeyScope::Extrude); + } + scopes +} + +fn next_mode( + mode: Mode, + frame: &shell::ShellFrame, + escape_requested: bool, + plane_sketches: &BTreeMap, +) -> Mode { + let after_pick = resolve_pick(mode, frame, plane_sketches); + let after_escape = if escape_requested { + cancel_pending_or_exit(after_pick) + } else { + after_pick + }; + let after_exit = if frame.exit_sketch { + Mode::Idle + } else { + after_escape + }; + match frame.activated_tool { + Some(t) => toggle_or_arm(after_exit, t), + None => after_exit, + } +} + +fn resolve_pick( + mode: Mode, + frame: &shell::ShellFrame, + plane_sketches: &BTreeMap, +) -> Mode { + if mode.is_extrude() { + return match frame.sketch_activated { + Some(id) => match &mode { + Mode::Extrude(ExtrudeArming::Profile { feature, .. }) if feature.sketch == id => { + mode + } + _ => Mode::Extrude(ExtrudeArming::profile(id)), + }, + None => mode, + }; + } + let plane_pick = frame + .plane_picked + .filter(|_| !mode.is_sketch()) + .and_then(|plane| plane_sketches.get(&plane).copied()); + let sketch_pick = frame.sketch_activated.filter(|_| !mode.is_sketch()); + sketch_pick.or(plane_pick).map_or(mode, Mode::enter_sketch) +} + +fn toggle_or_arm(mode: Mode, tool: SketchTool) -> Mode { + let already_active = matches!( + &mode, + Mode::Sketch { session, .. } if session.tool == Some(tool) + ); + if already_active { + mode.disarm_tool() + } else { + mode.arm_tool(tool) + } +} + +fn cancel_pending_or_exit(mode: Mode) -> Mode { + match &mode { + Mode::Sketch { session, .. } if session.pending.is_some() => mode.clear_pending(), + Mode::Sketch { session, .. } if session.tool.is_some() => mode.disarm_tool(), + Mode::Sketch { .. } | Mode::Idle | Mode::Extrude(_) => Mode::Idle, + } +} + +impl AppCore { + pub fn new( + gpu: &Gpu, + color_format: wgpu::TextureFormat, + extent: ViewportExtent, + ) -> Result { + let renderer = SketchRenderer::new(gpu, color_format); + let solid_renderer = SolidRenderer::new(gpu, color_format); + let chrome_pipeline = ChromePipeline::new(gpu, color_format); + let convex_pipeline = ConvexPolyPipeline::new(gpu, color_format); + let stroke_pipeline = StrokePipeline::new(gpu, color_format); + let sdf_atlas = MaskAtlas::new(MaskAtlasParams::STANDARD); + let text_pipeline = ChromeTextPipeline::new(gpu, color_format, sdf_atlas.extent()); + let chrome_shaper = Shaper::new(); + let sans_font = bone_text::load_font(bone_text::FontFace::Sans); + let mono_font = bone_text::load_font(bone_text::FontFace::Mono); + let sketch = default_sketch(); + let scene = SketchScene::extract(&sketch)?; + let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(INITIAL_ZOOM_PX_PER_MM)); + let style = Style::default(); + let theme = Arc::new(Theme::light()); + let shell = shell::Shell::new(); + let (document, sketch_id) = initial_document(sketch); + let last_saved_baseline = document.clone(); + let plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); + let strings = strings::make_strings(bone_ui::strings::Locale::EnUs); + let viewport_rect = empty_rect(); + let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { + unreachable!("UNDO_CAPACITY constant is non-zero"); + }; + let loaded_settings = settings::load(); + let initial_hotkeys = match hotkeys::compose_table(&loaded_settings.hotkey_overrides) { + Ok(table) => table, + Err(e) => { + tracing::warn!(error = %e, "stored hotkey overrides conflict, using defaults"); + build_hotkey_table() + } + }; + let state = AppState { + extent, + renderer, + chrome_pipeline, + convex_pipeline, + stroke_pipeline, + text_pipeline, + sdf_atlas, + chrome_shaper, + sans_font, + mono_font, + scene, + camera, + style, + theme, + shell, + document, + plane_sketches, + mode: Mode::Idle, + feature_cache: FeatureCache::new(), + extrude_preview: None, + solid_renderer, + solid_view: None, + camera3: None, + framed_extrude: None, + navigator: ViewportNavigator::new(), + view: view_cube::ViewUi::default(), + focus: FocusManager::new(), + hit_state: HitState::new(), + hotkeys: initial_hotkeys, + strings, + viewport_rect, + undo: UndoStack::with_capacity(undo_capacity), + selection: Selection::default(), + settings: loaded_settings, + dim_editor: DimensionEditorState::default(), + dim_editor_bounds: None, + pending_exit: false, + current_folder: None, + documents_root: file_menu::documents_root(), + file_picker: None, + native_picker: None, + step_job: None, + pending_overwrite: None, + last_saved: Some(last_saved_baseline), + pending_discard: None, + notification: None, + shortcut_bar: None, + }; + Ok(Self { + state, + input: InputState::default(), + clock: FrameClock::start(), + a11y: AccessTreeBuilder::new(), + }) + } + + pub fn handle_input( + &mut self, + target: &impl FrameTarget, + event: InputEvent, + ) -> InputDispatched { + match event { + InputEvent::Resize(extent) => { + self.state.extent = extent; + self.state.camera = self.state.camera.with_extent(extent); + self.state.viewport_rect = empty_rect(); + } + InputEvent::Focus(focused) => { + if !focused { + self.input.forget_pan_state(); + self.state.navigator.end_drag(); + } + } + InputEvent::Modifiers(mods) => { + self.input.modifiers = mods; + } + InputEvent::CursorMove(position) => { + let state = &mut self.state; + let prev = self.input.cursor_px; + self.input.cursor_px = Some(position); + let modal = modal_active(state); + if !modal + && state.navigator.is_dragging() + && let Some(camera) = state.camera3 + && let Some(region) = solid_viewport_region(state.viewport_rect, state.extent) + && let Some(cursor) = viewport_local_point(position, region) + && let Ok(next) = state.navigator.drag_to(cursor, camera, region.extent()) + { + apply_nav_camera(state, next); + } else if !modal + && self.input.panning() + && let Some(p) = prev + { + state.camera = pan_by_px(state.camera, position.x - p.x, position.y - p.y); + } else if !modal + && dragging_in_sketch(&state.mode) + && let Some(world) = cursor_to_world(state.camera, position) + { + try_drag_to(state, world); + } + } + InputEvent::CursorLeft => { + self.input.cursor_px = None; + } + InputEvent::CursorEntered => {} + InputEvent::Pointer { button, pressed } => { + self.dispatch_pointer_button(target, button, pressed); + } + InputEvent::Wheel(delta) => { + self.dispatch_wheel(delta); + } + InputEvent::KeyDown(key) => { + self.dispatch_keydown(key); + } + } + InputDispatched::after_input() + } + + fn dispatch_wheel(&mut self, delta: ScrollDelta) { + let state = &mut self.state; + if modal_active(state) || !self.input.cursor_in(state.viewport_rect) { + return; + } + if state.solid_view.is_none() { + state.camera = zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); + return; + } + let (Some(camera), Some(region)) = ( + state.camera3, + solid_viewport_region(state.viewport_rect, state.extent), + ) else { + return; + }; + match delta { + ScrollDelta::Pixels { x, y } => { + if let Ok(next) = state.navigator.orbit_pixels(camera, region.extent(), x, y) { + apply_nav_camera(state, next); + } + } + ScrollDelta::Lines { .. } => { + if let Some(cursor) = self + .input + .cursor_px + .and_then(|p| viewport_local_point(p, region)) + && let Ok(factor) = ZoomFactor::new(zoom_factor(delta)) + && let Ok(next) = zoom_about_pixel(camera, region.extent(), cursor, factor) + { + apply_nav_camera(state, next); + } + } + } + } + + fn dispatch_pointer_button( + &mut self, + target: &impl FrameTarget, + button: PointerButton, + pressed: bool, + ) { + let state = &mut self.state; + let modal = modal_active(state); + match button { + PointerButton::Primary => { + if pressed { + let in_viewport = self.input.cursor_in(state.viewport_rect); + let over_dim_editor = state + .dim_editor_bounds + .is_some_and(|r| self.input.cursor_in(r)); + let dim_active = dim_flow_active(&state.mode); + self.input.left_pan = !modal && in_viewport && !over_dim_editor && !dim_active; + self.input.pending_pressed = + self.input.pending_pressed.with(PointerButton::Primary); + if !modal + && in_viewport + && !over_dim_editor + && !dim_active + && !self.input.shift() + && let Some(cursor) = self.input.cursor_px + { + if state.mode.active_tool().is_some() { + if let Some(world) = cursor_to_world(state.camera, cursor) { + try_place(state, world); + } + } else { + let additive = self.input.ctrl_or_meta(); + handle_viewport_click(state, target, cursor, additive); + } + } + } else { + self.input.left_pan = false; + state.mode = core::mem::take(&mut state.mode).end_drag(); + self.input.pending_released = + self.input.pending_released.with(PointerButton::Primary); + } + } + PointerButton::Secondary => { + if pressed { + self.input.pending_pressed = + self.input.pending_pressed.with(PointerButton::Secondary); + if !modal && self.input.cursor_in(state.viewport_rect) { + state.mode = core::mem::take(&mut state.mode).clear_pending(); + } + } else { + self.input.pending_released = + self.input.pending_released.with(PointerButton::Secondary); + } + } + PointerButton::Middle => { + if pressed { + let in_viewport = !modal && self.input.cursor_in(state.viewport_rect); + if in_viewport + && state.solid_view.is_some() + && let Some(region) = + solid_viewport_region(state.viewport_rect, state.extent) + && let Some(cursor) = self + .input + .cursor_px + .and_then(|p| viewport_local_point(p, region)) + { + state + .navigator + .begin_drag(drag_gesture(self.input.modifiers), cursor); + } else { + self.input.middle_pan = in_viewport; + } + self.input.pending_pressed = + self.input.pending_pressed.with(PointerButton::Middle); + } else { + self.input.middle_pan = false; + state.navigator.end_drag(); + self.input.pending_released = + self.input.pending_released.with(PointerButton::Middle); + } + } + } + } + + fn dispatch_keydown(&mut self, key: KeyDown) { + let state = &mut self.state; + let mods = self.input.modifiers; + let named = match key.code { + Some(UiKeyCode::Named(named)) => Some(named), + Some(UiKeyCode::Char(_)) | None => None, + }; + let repeat_space = key.repeat && named == Some(NamedKey::Space); + if let Some(code) = key.code + && !repeat_space + { + self.input.pending_keys.push(UiKeyEvent::new(code, mods)); + } + if let Some(typed) = key.text { + let filtered: String = typed.chars().filter(|c| !c.is_control()).collect(); + if !filtered.is_empty() { + self.input.pending_text.push_str(&filtered); + } + } + let suppress_camera = dim_flow_active(&state.mode) || state.focus.focused().is_some(); + if matches!(named, Some(NamedKey::Escape)) && state.notification.is_some() { + state.notification = None; + } + if let Some(nav) = key.nav + && !suppress_camera + { + if state.solid_view.is_some() { + if let Some(next) = keyboard_camera3(nav, &self.input, state) { + apply_nav_camera(state, next); + } + } else if let Some(next) = keyboard_camera(nav, &self.input, state) { + state.camera = next; + } + } + } + + pub fn render_frame(&mut self, target: &mut impl FrameTarget) -> FrameReport { + render_frame( + &mut self.state, + target, + &mut self.input, + &mut self.a11y, + self.clock.now(), + ) + } + + #[must_use] + pub fn access_tree(&self) -> accesskit::TreeUpdate { + self.a11y + .build(&self.state.strings, self.state.focus.focused()) + } + + pub fn open_document(&mut self, path: PathBuf) -> Result<(), OpenError> { + if file_menu::is_step_file(&path) { + let document = + bone_interop::read(&path, bone_interop::CancelFlag::never()).map_err(|source| { + OpenError::ImportStep { + path: path.clone(), + source, + } + })?; + install_imported_document(&mut self.state, document); + } else { + let folder = DocumentFolder::new(path.clone()); + let document = bone_document::load(&folder) + .map_err(|source| OpenError::LoadFolder { path, source })?; + install_loaded_document(&mut self.state, document, Some(folder)); + } + Ok(()) + } + + pub fn set_theme(&mut self, mode: bone_ui::theme::ThemeMode) { + self.state.theme = Arc::new(match mode { + bone_ui::theme::ThemeMode::Light => Theme::light(), + bone_ui::theme::ThemeMode::Dark => Theme::dark(), + }); + } + + pub fn clock_mut(&mut self) -> &mut FrameClock { + &mut self.clock + } + + #[must_use] + pub fn next_wake_deadline(&self) -> Option { + next_wake_deadline(&self.state, self.clock.now()) + } +} + +fn next_wake_deadline(state: &AppState, now: FrameInstant) -> Option { + let native_poll = state + .native_picker + .is_some() + .then(|| now.after(std::time::Duration::from_millis(40))); + let step_poll = state + .step_job + .is_some() + .then(|| now.after(std::time::Duration::from_millis(40))); + let rename_deadline = state + .shell + .state + .feature_tree + .pending_rename + .map(|pending| { + let window = bone_ui::input::DoubleClickWindow::DEFAULT.duration(); + let slack = std::time::Duration::from_millis(8); + pending.at.after(window + slack) + }); + let tween_tick = state + .view + .tween + .is_some() + .then(|| now.after(std::time::Duration::from_millis(8))); + [native_poll, step_poll, rename_deadline, tween_tick] + .into_iter() + .flatten() + .min() +} + +#[allow( + clippy::cast_precision_loss, + reason = "viewport pixel counts at any realistic display size fit f32 mantissa" +)] +#[allow( + clippy::too_many_lines, + reason = "splitting hides the per-outcome dispatch table" +)] +fn render_frame( + state: &mut AppState, + target: &mut impl FrameTarget, + input_state: &mut InputState, + a11y: &mut AccessTreeBuilder, + now: FrameInstant, +) -> FrameReport { + poll_native_picker(state); + poll_step_job(state); + let extent = state.extent; + let layout_size = layout_size_from_extent(extent); + let theme = Arc::clone(&state.theme); + let mut input = input_state.drain_snapshot(now); + step_view_tween(state, input.frame); + let mut hits = HitFrame::new(); + a11y.begin_frame(); + let scopes = scopes_for_mode(&state.mode); + let chrome_cursor_world = input_state + .cursor_px + .filter(|c| state.viewport_rect.contains(window_to_layout_pos(*c))) + .and_then(|c| cursor_to_world(state.camera, c)); + let FrameOutcomes { + mut frame, + hotkey_actions, + dim: dim_outcome, + dim_conflict: conflict_outcome, + picker: picker_outcome, + overwrite: overwrite_outcome, + discard: discard_outcome, + step_progress: step_progress_outcome, + notification: notification_outcome, + shortcut_bar: shortcut_bar_outcome, + } = run_frame_ui( + state, + theme, + &mut input, + &mut hits, + a11y, + &scopes, + layout_size, + chrome_cursor_world, + ); + state.dim_editor_bounds = apply_popup_overlays( + &mut frame.overlay_paints, + dim_outcome.as_ref(), + conflict_outcome.as_ref(), + picker_outcome.as_ref(), + overwrite_outcome.as_ref(), + discard_outcome.as_ref(), + step_progress_outcome.as_ref(), + notification_outcome.as_ref(), + shortcut_bar_outcome.as_ref(), + ); + apply_shortcut_bar_outcome(state, shortcut_bar_outcome.as_ref()); + let picker_kind = state.file_picker.as_ref().map(|s| s.kind); + if let (Some(cmd), Some(kind)) = (picker_outcome.and_then(|o| o.command), picker_kind) { + apply_picker_command(state, kind, cmd); + } + apply_overwrite_outcome(state, overwrite_outcome); + apply_discard_outcome(state, discard_outcome); + apply_step_progress_outcome(state, step_progress_outcome); + apply_notification_outcome(state, notification_outcome); + let claimed_pointer = dim_outcome.as_ref().is_some_and(|o| o.claimed_pointer); + let frame = if claimed_pointer { + suppress_pointer_activations(frame) + } else { + frame + }; + state.viewport_rect = frame.viewport_rect; + state.hit_state = resolve(&state.hit_state, &hits, &input, state.focus.focused()); + let kick = any_actionable_interaction(&state.hit_state); + if let Some(plane) = frame.plane_picked { + match ( + state.mode.is_sketch(), + state.plane_sketches.contains_key(&plane), + ) { + (true, _) => { + tracing::debug!(?plane, "plane pick ignored: already in sketch mode"); + } + (false, false) => { + tracing::debug!(?plane, "plane pick ignored: no sketch on this plane"); + } + (false, true) => {} + } + } + let escape_requested = hotkey_actions.contains(&sketch_mode::ESCAPE_ACTION); + apply_extrude_edit(state, frame.extrude_edit); + apply_extrude_confirm(state, frame.confirm_action); + let prev_active_sketch = active_sketch_id(&state.mode, &state.plane_sketches); + state.mode = next_mode( + core::mem::take(&mut state.mode), + &frame, + escape_requested, + &state.plane_sketches, + ); + apply_feature_tool(state, frame.activated_feature_tool); + apply_extrude_activation(state, frame.extrude_activated); + if active_sketch_id(&state.mode, &state.plane_sketches) != prev_active_sketch { + refresh_active_scene(state); + } + apply_dimension_outcome(state, dim_outcome); + apply_dim_conflict_outcome(state, conflict_outcome); + apply_dimension_request(state, frame.activated_dimension); + let dimension_edit = match frame.confirm_action { + Some(shell::ConfirmAction::Cancel) => None, + _ => frame.dimension_edit, + }; + apply_dimension_edit(state, dimension_edit); + sync_solid_view(state); + let solid_region = solid_viewport_region(state.viewport_rect, extent); + sync_solid_camera(state, solid_region); + let cursor_layout = input_state.cursor_px.map(window_to_layout_pos); + apply_hotkey_actions(state, &hotkey_actions, cursor_layout, input.frame); + apply_view_pick(state, frame.view_pick, input.frame); + apply_view_menu(state, frame.view_menu, input.frame); + apply_menu_action(state, frame.menu_action); + apply_settings_change(state, frame.settings_change); + apply_relation_action(state, frame.activated_relation); + apply_sketch_rename(state, frame.sketch_rename.clone()); + apply_extrude_rename(state, frame.extrude_rename.clone()); + let cursor_world = input_state + .cursor_px + .filter(|c| state.viewport_rect.contains(window_to_layout_pos(*c))) + .and_then(|c| cursor_to_world(state.camera, c)); + let preview = build_preview(&state.mode, &state.document, cursor_world, &state.camera); + let main_layer = build_chrome_layer(state, &frame.paints); + let overlay_layer = build_chrome_layer(state, &frame.overlay_paints); + let atlas_pixels = state.sdf_atlas.pixels(); + let atlas_version = state.sdf_atlas.version(); + let viewport_px = [ + extent.width().value() as f32, + extent.height().value() as f32, + ]; + let renderer = &mut state.renderer; + let mut chrome_stage = ChromeStage { + chrome: &mut state.chrome_pipeline, + convex: &mut state.convex_pipeline, + stroke: &mut state.stroke_pipeline, + text: &mut state.text_pipeline, + atlas_pixels, + atlas_version, + viewport_px, + }; + let scene = &state.scene; + let style = &state.style; + renderer.prepare(scene, style); + let viewport = ViewportEncode { + solid: solid_region.and_then(|region| { + preview_solid_frame(state.solid_view.as_ref(), state.camera3, region) + }), + solid_renderer: &state.solid_renderer, + sketch_renderer: renderer, + scene, + preview: &preview, + camera: state.camera, + style, + }; + target.render(|encoder, color, pick, depth| { + viewport.encode(encoder, color, pick, depth); + chrome_stage.encode_layered(encoder, color, &main_layer, &overlay_layer); + }); + FrameReport { + kick, + exit: state.pending_exit, + } +} + +struct ViewportEncode<'a> { + solid: Option<(&'a SolidViewData, SolidFrameView)>, + solid_renderer: &'a SolidRenderer, + sketch_renderer: &'a SketchRenderer, + scene: &'a SketchScene, + preview: &'a SketchPreview, + camera: Camera2, + style: &'a Style, +} + +impl ViewportEncode<'_> { + fn encode( + &self, + encoder: &mut wgpu::CommandEncoder, + color: &wgpu::TextureView, + pick: &wgpu::TextureView, + depth: &wgpu::TextureView, + ) { + let targets = RenderTargets::new(color, pick); + match &self.solid { + Some((view, frame)) => self.solid_renderer.encode_passes( + encoder, + targets, + depth, + &view.faces, + &view.edges, + &bone_render::SolidDisplay { + view: frame, + style: self.style, + mode: DisplayMode::ShadedWithEdges, + }, + ), + None => self.sketch_renderer.encode_passes( + encoder, + targets, + self.scene, + self.preview, + self.camera, + self.style, + ), + } + } +} + +struct ChromeStage<'a> { + chrome: &'a mut ChromePipeline, + convex: &'a mut ConvexPolyPipeline, + stroke: &'a mut StrokePipeline, + text: &'a mut ChromeTextPipeline, + atlas_pixels: &'a [u8], + atlas_version: u64, + viewport_px: [f32; 2], +} + +fn merge_layers(main: &[T], overlay: &[T]) -> Vec { + main.iter().chain(overlay.iter()).copied().collect() +} + +fn count_u32(len: usize) -> u32 { + let Ok(count) = u32::try_from(len) else { + unreachable!("instance counts fit in u32"); + }; + count +} + +impl ChromeStage<'_> { + fn encode_layered( + &mut self, + encoder: &mut wgpu::CommandEncoder, + color: &wgpu::TextureView, + main: &ChromeLayer, + overlay: &ChromeLayer, + ) { + let chrome = merge_layers(&main.chrome, &overlay.chrome); + let convex = merge_layers(&main.convex, &overlay.convex); + let stroke = merge_layers(&main.stroke, &overlay.stroke); + let glyphs = merge_layers(&main.glyphs, &overlay.glyphs); + self.chrome.upload(self.viewport_px, &chrome); + self.convex.upload(self.viewport_px, &convex); + self.stroke.upload(self.viewport_px, &stroke); + self.text.upload( + self.viewport_px, + self.atlas_pixels, + self.atlas_version, + &glyphs, + ); + let (mc, tc) = (count_u32(main.chrome.len()), count_u32(chrome.len())); + let (mv, tv) = (count_u32(main.convex.len()), count_u32(convex.len())); + let (ms, ts) = (count_u32(main.stroke.len()), count_u32(stroke.len())); + let (mg, tg) = (count_u32(main.glyphs.len()), count_u32(glyphs.len())); + self.chrome.draw_range(encoder, color, 0..mc); + self.convex.draw_range(encoder, color, 0..mv); + self.stroke.draw_range(encoder, color, 0..ms); + self.text.draw_range(encoder, color, 0..mg); + self.chrome.draw_range(encoder, color, mc..tc); + self.convex.draw_range(encoder, color, mv..tv); + self.stroke.draw_range(encoder, color, ms..ts); + self.text.draw_range(encoder, color, mg..tg); + } +} + +fn any_actionable_interaction(hit_state: &HitState) -> bool { + use bone_ui::hit_test::InteractionState; + hit_state.interactions.values().any(|i| { + i.state.contains(InteractionState::CLICK) + || i.state.contains(InteractionState::DOUBLE_CLICK) + || i.state.contains(InteractionState::DRAG_START) + || i.state.contains(InteractionState::DRAG_RELEASE) + }) +} + +struct ChromeLayer { + chrome: Vec, + convex: Vec, + stroke: Vec, + glyphs: Vec, +} + +fn build_chrome_layer( + state: &mut AppState, + paints: &[bone_ui::widgets::WidgetPaint], +) -> ChromeLayer { + let chrome = chrome::paint_to_instances(&state.theme, paints); + let convex = chrome::paint_to_convex_instances(paints); + let stroke = chrome::paint_to_stroke_instances(paints); + let spans = chrome::paint_to_text_spans(paints, &state.strings); + let glyphs = chrome::build_glyph_instances( + &spans, + &mut state.sdf_atlas, + &mut state.chrome_shaper, + &state.sans_font, + &state.mono_font, + ); + ChromeLayer { + chrome, + convex, + stroke, + glyphs, + } +} + +fn apply_hotkey_actions( + state: &mut AppState, + actions: &[ActionId], + cursor_layout: Option, + now: FrameInstant, +) { + actions + .iter() + .filter_map(|a| hotkeys::command_for_action(*a)) + .for_each(|cmd| dispatch_hotkey_command(state, cmd, cursor_layout, now)); +} + +fn dispatch_hotkey_command( + state: &mut AppState, + cmd: hotkeys::HotkeyCommand, + cursor_layout: Option, + now: FrameInstant, +) { + use hotkeys::HotkeyCommand as C; + match cmd { + C::Undo => { + if state.undo.undo(&mut state.document) { + refresh_active_scene(state); + } + } + C::Redo => { + if state.undo.redo(&mut state.document) { + refresh_active_scene(state); + } + } + C::NewDocument => apply_menu_action(state, Some(shell::MenuAction::NewDocument)), + C::OpenDocument => apply_menu_action(state, Some(shell::MenuAction::OpenDocument)), + C::SaveDocument => apply_menu_action(state, Some(shell::MenuAction::SaveDocument)), + C::ImportStep => apply_menu_action(state, Some(shell::MenuAction::ImportStep)), + C::ExportStep => apply_menu_action(state, Some(shell::MenuAction::ExportStep)), + C::ZoomFit => apply_menu_action(state, Some(shell::MenuAction::ZoomFit)), + C::Quit => { + state.pending_exit = true; + } + C::OpenShortcutBar => { + if state.shortcut_bar.is_none() { + let anchor = cursor_layout.unwrap_or(LayoutPos::ORIGIN); + state.shortcut_bar = Some(shortcut_bar::ShortcutBarState { anchor }); + } + } + C::ToggleConstruction => apply_construction_toggle(state), + C::Mirror => apply_mirror(state), + C::StandardView(view) => { + if view_nav_enabled(state) { + apply_view_pick(state, Some(view_cube::ViewPick::Standard(view)), now); + } + } + C::ToggleViewSelector => { + if view_nav_enabled(state) { + state.view.toggle_selector(); + } + } + C::ToggleViewCube => { + if view_nav_enabled(state) { + state.view.toggle_cube(); + } + } + C::SelectAll + | C::DeleteSelection + | C::EnterSketch + | C::SmartDimension + | C::Trim + | C::Extend => notify_stub(state, hotkeys::label_for_command(cmd)), + } +} + +fn apply_construction_toggle(state: &mut AppState) { + let Mode::Sketch { sketch_id, .. } = state.mode else { + return; + }; + let entity_ids: Vec = state.selection.entity_ids().to_vec(); + if entity_ids.is_empty() { + return; + } + let Some(sketch) = state.document.sketch(sketch_id) else { + return; + }; + let pivot = entity_ids + .iter() + .find_map(|id| match sketch.entities().get(*id)? { + SketchEntity::Line(l) => Some(l.for_construction()), + SketchEntity::Arc(a) => Some(a.for_construction()), + SketchEntity::Circle(c) => Some(c.for_construction()), + SketchEntity::Point(_) => None, + }); + let Some(current) = pivot else { + return; + }; + let target = !current; + let snapshot = state.document.clone(); + let result = + entity_ids + .iter() + .try_fold(sketch.clone(), |acc, id| match acc.entities().get(*id) { + Some(SketchEntity::Point(_)) | None => Ok(acc), + Some(_) => acc + .apply(bone_document::SketchEdit::SetConstruction { + id: *id, + for_construction: target, + }) + .map(|(s, _)| s), + }); + match result { + Ok(next) => { + state.undo.record(snapshot); + state.document.replace_sketch(sketch_id, next); + refresh_active_scene(state); + } + Err(e) => tracing::warn!(error = %e, "construction toggle failed"), + } +} + +fn apply_mirror(state: &mut AppState) { + let Mode::Sketch { sketch_id, .. } = state.mode else { + return; + }; + let entity_ids: Vec = state.selection.entity_ids().to_vec(); + if entity_ids.is_empty() { + return; + } + let Some(sketch) = state.document.sketch(sketch_id) else { + return; + }; + let axis_lines: Vec<(bone_types::SketchEntityId, LineData)> = entity_ids + .iter() + .filter_map(|id| match sketch.entities().get(*id)? { + SketchEntity::Line(l) => Some((*id, *l)), + _ => None, + }) + .collect(); + let [(axis_id, axis_line)] = axis_lines.as_slice() else { + notify_mirror_hint(state); + return; + }; + let axis_id = *axis_id; + let Some(pa) = lookup_point(sketch, axis_line.a()) else { + return; + }; + let Some(pb) = lookup_point(sketch, axis_line.b()) else { + return; + }; + let axis = MirrorAxis::from_points(pa, pb); + if axis.is_degenerate() { + notify_mirror_hint(state); + return; + } + let source_ids: std::collections::BTreeSet = entity_ids + .iter() + .copied() + .filter(|id| *id != axis_id) + .collect(); + if source_ids.is_empty() { + notify_mirror_hint(state); + return; + } + let snapshot = state.document.clone(); + let result = mirror_targets(sketch.clone(), &source_ids, axis_id, &axis); + match result { + Ok(next) => { + state.undo.record(snapshot); + state.document.replace_sketch(sketch_id, next); + state.selection = Selection::default(); + refresh_active_scene(state); + } + Err(e) => tracing::warn!(error = %e, "mirror failed"), + } +} + +fn notify_mirror_hint(state: &mut AppState) { + state.notification = Some(Notification { + kind: NotificationKind::Info, + headline: strings::HOTKEY_LABEL_MIRROR, + detail: Some( + state + .strings + .resolve(strings::NOTIFY_MIRROR_SELECTION_HINT) + .to_owned(), + ), + }); +} + +#[derive(Copy, Clone, Debug)] +struct MirrorAxis { + anchor_x: f64, + anchor_y: f64, + direction_x: f64, + direction_y: f64, + length_sq: f64, +} + +impl MirrorAxis { + fn from_points(a: Point2, b: Point2) -> Self { + let (ax, ay) = a.coords_mm(); + let (bx, by) = b.coords_mm(); + let dx = bx - ax; + let dy = by - ay; + Self { + anchor_x: ax, + anchor_y: ay, + direction_x: dx, + direction_y: dy, + length_sq: dx * dx + dy * dy, + } + } + + fn is_degenerate(self) -> bool { + !self.length_sq.is_finite() || self.length_sq <= f64::EPSILON + } + + fn reflect(self, p: Point2) -> Point2 { + let (px, py) = p.coords_mm(); + let vx = px - self.anchor_x; + let vy = py - self.anchor_y; + let t = (vx * self.direction_x + vy * self.direction_y) / self.length_sq; + let foot_x = self.anchor_x + t * self.direction_x; + let foot_y = self.anchor_y + t * self.direction_y; + Point2::from_mm(2.0 * foot_x - px, 2.0 * foot_y - py) + } + + fn is_on_axis(self, p: Point2) -> bool { + let (px, py) = p.coords_mm(); + let vx = px - self.anchor_x; + let vy = py - self.anchor_y; + let cross = vx * self.direction_y - vy * self.direction_x; + let perp_dist_sq = cross * cross / self.length_sq; + perp_dist_sq < ON_AXIS_TOLERANCE_MM_SQ + } +} + +const ON_AXIS_TOLERANCE_MM_SQ: f64 = 1e-12; + +struct MirrorBuilder { + sketch: Sketch, + point_map: std::collections::BTreeMap, + entity_map: std::collections::BTreeMap, +} + +impl MirrorBuilder { + fn new(sketch: Sketch) -> Self { + Self { + sketch, + point_map: std::collections::BTreeMap::new(), + entity_map: std::collections::BTreeMap::new(), + } + } + + fn mirror_point( + &mut self, + id: bone_types::SketchEntityId, + axis: &MirrorAxis, + ) -> Result { + if let Some(&existing) = self.point_map.get(&id) { + return Ok(existing); + } + let pos = require_point(&self.sketch, id)?; + if axis.is_on_axis(pos) { + self.point_map.insert(id, id); + self.entity_map.insert(id, id); + return Ok(id); + } + let (next, new_id) = add_point(self.sketch.clone(), axis.reflect(pos))?; + self.sketch = next; + self.point_map.insert(id, new_id); + self.entity_map.insert(id, new_id); + Ok(new_id) + } + + fn mirror_entity( + &mut self, + id: bone_types::SketchEntityId, + axis: &MirrorAxis, + ) -> Result<(), bone_document::SketchEditError> { + if self.entity_map.contains_key(&id) { + return Ok(()); + } + let entity = self + .sketch + .entities() + .get(id) + .copied() + .ok_or(bone_document::SketchEditError::EntityNotFound(id))?; + match entity { + SketchEntity::Point(_) => { + self.mirror_point(id, axis)?; + } + SketchEntity::Line(l) => { + let new_a = self.mirror_point(l.a(), axis)?; + let new_b = self.mirror_point(l.b(), axis)?; + let (next, outcome) = + self.sketch + .clone() + .apply(bone_document::SketchEdit::AddEntity(SketchEntity::line( + new_a, + new_b, + l.for_construction(), + )))?; + let EditOutcome::Entity(new_id) = outcome else { + unreachable!("AddEntity yields Entity outcome") + }; + self.sketch = next; + self.entity_map.insert(id, new_id); + } + SketchEntity::Arc(a) => { + let new_center = self.mirror_point(a.center(), axis)?; + let new_start = self.mirror_point(a.start(), axis)?; + let new_end = self.mirror_point(a.end(), axis)?; + let (next, outcome) = + self.sketch + .clone() + .apply(bone_document::SketchEdit::AddEntity(SketchEntity::arc( + new_center, + new_end, + new_start, + a.for_construction(), + )))?; + let EditOutcome::Entity(new_id) = outcome else { + unreachable!("AddEntity yields Entity outcome") + }; + self.sketch = next; + self.entity_map.insert(id, new_id); + } + SketchEntity::Circle(c) => { + let new_center = self.mirror_point(c.center(), axis)?; + let (next, outcome) = + self.sketch + .clone() + .apply(bone_document::SketchEdit::AddEntity(SketchEntity::circle( + new_center, + c.radius(), + c.for_construction(), + )))?; + let EditOutcome::Entity(new_id) = outcome else { + unreachable!("AddEntity yields Entity outcome") + }; + self.sketch = next; + self.entity_map.insert(id, new_id); + } + } + Ok(()) + } +} + +fn mirror_targets( + sketch: Sketch, + source_ids: &std::collections::BTreeSet, + axis_id: bone_types::SketchEntityId, + axis: &MirrorAxis, +) -> Result { + let mut builder = MirrorBuilder::new(sketch); + source_ids + .iter() + .try_for_each(|id| builder.mirror_entity(*id, axis))?; + builder.entity_map.insert(axis_id, axis_id); + builder.sketch = symmetric_relations_for_pairs(builder.sketch, &builder.point_map, axis_id)?; + builder.sketch = copy_relations(builder.sketch, source_ids, axis_id, &builder.entity_map)?; + builder.sketch = copy_dimensions(builder.sketch, source_ids, axis_id, &builder.entity_map)?; + Ok(builder.sketch) +} + +fn symmetric_relations_for_pairs( + sketch: Sketch, + point_map: &std::collections::BTreeMap, + axis_id: bone_types::SketchEntityId, +) -> Result { + point_map + .iter() + .filter(|(source, mirrored)| source != mirrored) + .try_fold(sketch, |acc, (&source, &mirrored)| { + let (next, _) = acc.apply(bone_document::SketchEdit::AddRelation( + SketchRelation::Symmetric { + a: source, + b: mirrored, + axis: axis_id, + }, + ))?; + Ok(next) + }) +} + +fn copy_relations( + sketch: Sketch, + source_ids: &std::collections::BTreeSet, + axis_id: bone_types::SketchEntityId, + entity_map: &std::collections::BTreeMap, +) -> Result { + let relations: Vec = sketch + .relations() + .iter() + .map(|(_, r)| *r) + .filter(|r| relation_is_mirrorable(r, source_ids, axis_id)) + .filter_map(|r| remap_relation(r, entity_map)) + .collect(); + relations.into_iter().try_fold(sketch, |acc, rel| { + match acc + .clone() + .apply(bone_document::SketchEdit::AddRelation(rel)) + { + Ok((next, _)) => Ok(next), + Err(e) => { + tracing::warn!(error = %e, relation = ?rel, "mirror: skipped relation"); + Ok(acc) + } + } + }) +} + +fn relation_is_mirrorable( + rel: &SketchRelation, + source_ids: &std::collections::BTreeSet, + axis_id: bone_types::SketchEntityId, +) -> bool { + let refs: Vec<_> = rel.references().into_iter().collect(); + let touches_source = refs.iter().any(|id| source_ids.contains(id)); + let all_known = refs + .iter() + .all(|id| source_ids.contains(id) || *id == axis_id); + touches_source && all_known +} + +fn remap_relation( + rel: SketchRelation, + entity_map: &std::collections::BTreeMap, +) -> Option { + let get = |id| entity_map.get(&id).copied(); + match rel { + SketchRelation::Coincident(a, b) => Some(SketchRelation::Coincident(get(a)?, get(b)?)), + SketchRelation::Horizontal(a) => Some(SketchRelation::Horizontal(get(a)?)), + SketchRelation::Vertical(a) => Some(SketchRelation::Vertical(get(a)?)), + SketchRelation::Parallel(a, b) => Some(SketchRelation::Parallel(get(a)?, get(b)?)), + SketchRelation::Perpendicular(a, b) => { + Some(SketchRelation::Perpendicular(get(a)?, get(b)?)) + } + SketchRelation::Tangent(a, b) => Some(SketchRelation::Tangent(get(a)?, get(b)?)), + SketchRelation::Equal(a, b) => Some(SketchRelation::Equal(get(a)?, get(b)?)), + SketchRelation::Concentric(a, b) => Some(SketchRelation::Concentric(get(a)?, get(b)?)), + SketchRelation::Midpoint { point, line } => Some(SketchRelation::Midpoint { + point: get(point)?, + line: get(line)?, + }), + SketchRelation::Symmetric { a, b, axis } => Some(SketchRelation::Symmetric { + a: get(a)?, + b: get(b)?, + axis: get(axis)?, + }), + SketchRelation::Fix(a) => Some(SketchRelation::Fix(get(a)?)), + } +} + +fn copy_dimensions( + sketch: Sketch, + source_ids: &std::collections::BTreeSet, + axis_id: bone_types::SketchEntityId, + entity_map: &std::collections::BTreeMap, +) -> Result { + let dims: Vec = sketch + .dimensions() + .iter() + .map(|(_, d)| *d) + .filter(|d| dimension_is_mirrorable(d, source_ids, axis_id)) + .filter_map(|d| remap_dimension(d, entity_map)) + .collect(); + dims.into_iter().try_fold(sketch, |acc, dim| { + match acc + .clone() + .apply(bone_document::SketchEdit::AddDimension(dim)) + { + Ok((next, _)) => Ok(next), + Err(e) => { + tracing::warn!(error = %e, dimension = ?dim, "mirror: skipped dimension"); + Ok(acc) + } + } + }) +} + +fn dimension_is_mirrorable( + dim: &SketchDimension, + source_ids: &std::collections::BTreeSet, + axis_id: bone_types::SketchEntityId, +) -> bool { + let refs: Vec<_> = dim.references().into_iter().collect(); + let touches_source = refs.iter().any(|id| source_ids.contains(id)); + let all_known = refs + .iter() + .all(|id| source_ids.contains(id) || *id == axis_id); + touches_source && all_known +} + +fn remap_dimension( + dim: SketchDimension, + entity_map: &std::collections::BTreeMap, +) -> Option { + let get = |id| entity_map.get(&id).copied(); + match dim { + SketchDimension::Linear { a, b, value, kind } => Some(SketchDimension::Linear { + a: get(a)?, + b: get(b)?, + value, + kind, + }), + SketchDimension::Radius { + target, + value, + kind, + } => Some(SketchDimension::Radius { + target: get(target)?, + value, + kind, + }), + SketchDimension::Diameter { + target, + value, + kind, + } => Some(SketchDimension::Diameter { + target: get(target)?, + value, + kind, + }), + SketchDimension::Angular { a, b, value, kind } => Some(SketchDimension::Angular { + a: get(a)?, + b: get(b)?, + value, + kind, + }), + } +} + +fn add_point( + sketch: Sketch, + at: Point2, +) -> Result<(Sketch, bone_types::SketchEntityId), bone_document::SketchEditError> { + let (next, outcome) = sketch.apply(bone_document::SketchEdit::AddEntity( + SketchEntity::point(at), + ))?; + let EditOutcome::Entity(id) = outcome else { + unreachable!("AddEntity must yield Entity outcome") + }; + Ok((next, id)) +} + +fn require_point( + sketch: &Sketch, + id: bone_types::SketchEntityId, +) -> Result { + match sketch + .entities() + .get(id) + .ok_or(bone_document::SketchEditError::EntityNotFound(id))? + { + SketchEntity::Point(p) => Ok(p.at()), + _ => Err(bone_document::SketchEditError::ExpectedPoint(id)), + } +} + +fn lookup_point(sketch: &Sketch, id: bone_types::SketchEntityId) -> Option { + match sketch.entities().get(id)? { + SketchEntity::Point(p) => Some(p.at()), + _ => None, + } +} + +fn suppress_pointer_activations(frame: shell::ShellFrame) -> shell::ShellFrame { + shell::ShellFrame { + paints: frame.paints, + overlay_paints: frame.overlay_paints, + viewport_rect: frame.viewport_rect, + activated_tool: None, + activated_feature_tool: None, + activated_relation: None, + activated_dimension: None, + dimension_edit: None, + extrude_edit: frame.extrude_edit, + plane_picked: None, + sketch_activated: None, + sketch_rename: None, + extrude_activated: None, + extrude_rename: None, + exit_sketch: false, + confirm_action: None, + menu_action: None, + settings_change: None, + view_pick: None, + view_menu: None, + } +} + +#[allow( + clippy::too_many_arguments, + reason = "popup overlay dispatch threads every transient surface" +)] +fn apply_popup_overlays( + overlay: &mut Vec, + dim_outcome: Option<&DimensionEditorOutcome>, + conflict_outcome: Option<&DimConflictOutcome>, + picker_outcome: Option<&file_menu::PickerModalOutcome>, + overwrite_outcome: Option<&OverwriteOutcome>, + discard_outcome: Option<&DiscardOutcome>, + step_progress_outcome: Option<&StepProgressOutcome>, + notification_outcome: Option<&NotificationOutcome>, + shortcut_bar_outcome: Option<&shortcut_bar::ShortcutBarOutcome>, +) -> Option { + let dim_closing = matches!( + dim_outcome.map(|o| &o.action), + Some(DimensionEditorAction::Commit(_) | DimensionEditorAction::Cancel), + ); + extend_when_open( + overlay, + dim_outcome.map(|o| o.paints.as_slice()), + dim_closing, + ); + let conflict_closing = matches!( + conflict_outcome.map(|o| o.action), + Some(DimConflictAction::MakeDriven | DimConflictAction::Cancel), + ); + extend_when_open( + overlay, + conflict_outcome.map(|o| o.paints.as_slice()), + conflict_closing, + ); + let picker_closing = picker_outcome.and_then(|o| o.command.as_ref()).is_some(); + extend_when_open( + overlay, + picker_outcome.map(|o| o.paints.as_slice()), + picker_closing, + ); + let overwrite_closing = matches!( + overwrite_outcome.map(|o| o.action), + Some(OverwriteAction::Replace | OverwriteAction::Cancel), + ); + extend_when_open( + overlay, + overwrite_outcome.map(|o| o.paints.as_slice()), + overwrite_closing, + ); + let discard_closing = matches!( + discard_outcome.map(|o| o.action), + Some(DiscardAction::Confirm | DiscardAction::Cancel), + ); + extend_when_open( + overlay, + discard_outcome.map(|o| o.paints.as_slice()), + discard_closing, + ); + if let Some(progress) = step_progress_outcome { + overlay.extend(progress.paints.iter().cloned()); + } + if let Some(notification) = notification_outcome { + overlay.extend(notification.paints.iter().cloned()); + } + let bar_closing = shortcut_bar_outcome.is_some_and(|o| o.dismissed || o.activated.is_some()); + extend_when_open( + overlay, + shortcut_bar_outcome.map(|o| o.paints.as_slice()), + bar_closing, + ); + if dim_closing { + None + } else { + dim_outcome.map(|o| o.bounds) + } +} + +fn extend_when_open( + overlay: &mut Vec, + paints: Option<&[bone_ui::widgets::WidgetPaint]>, + closing: bool, +) { + if let Some(p) = paints + && !closing + { + overlay.extend(p.iter().cloned()); + } +} + +struct FrameOutcomes { + frame: shell::ShellFrame, + hotkey_actions: Vec, + dim: Option, + dim_conflict: Option, + picker: Option, + overwrite: Option, + discard: Option, + step_progress: Option, + notification: Option, + shortcut_bar: Option, +} + +fn strip_plain_letter_chords(input: &mut InputSnapshot) { + input.keys_pressed.retain(|event| { + !matches!(event.code, bone_ui::input::KeyCode::Char(_)) + || event.modifiers != ModifierMask::NONE + }); +} + +#[allow( + clippy::too_many_arguments, + reason = "run_frame_ui threads every per-frame UI subsystem" +)] +fn run_frame_ui( + state: &mut AppState, + theme: Arc, + input: &mut InputSnapshot, + hits: &mut HitFrame, + a11y: &mut AccessTreeBuilder, + scopes: &HotkeyScopes, + layout_size: LayoutSize, + cursor_world: Option, +) -> FrameOutcomes { + let mut ctx = FrameCtx::new( + theme, + input, + &mut state.focus, + &state.hotkeys, + &state.strings, + hits, + &state.hit_state, + a11y, + &mut state.chrome_shaper, + ); + let extrude_status = state.extrude_preview.as_ref().map(ExtrudePreview::status); + let frame = state.shell.render( + &mut ctx, + &state.document, + &state.mode, + &state.selection, + &state.settings, + layout_size, + cursor_world, + state.camera3.filter(|_| state.solid_view.is_some()), + extrude_status, + &mut state.view, + ); + let dim_outcome = pending_dim(&state.mode).map(|pending| { + let live_anchor = state + .mode + .sketch_id() + .and_then(|id| state.document.sketch(id)) + .and_then(|s| smart_dimension::live_anchor(s, pending.proto)) + .unwrap_or(pending.anchor); + dimension_editor::render( + &mut ctx, + pending, + live_anchor, + &state.camera, + frame.viewport_rect, + &mut state.dim_editor, + ) + }); + let conflict_outcome = + dim_conflict_pending(&state.mode).map(|_| render_dim_conflict_modal(&mut ctx, layout_size)); + let picker_outcome = state + .file_picker + .as_mut() + .map(|session| file_menu::render(&mut ctx, session, layout_size)); + let overwrite_outcome = state + .pending_overwrite + .as_ref() + .map(|pending| render_overwrite_modal(&mut ctx, layout_size, pending)); + let discard_outcome = state + .pending_discard + .as_ref() + .map(|pending| render_discard_modal(&mut ctx, layout_size, pending)); + let reduce_motion = state.settings.reduce_motion; + let step_progress_outcome = state + .step_job + .as_ref() + .filter(|job| job.meta().show_progress) + .map(|job| render_step_progress_dialog(&mut ctx, layout_size, job, reduce_motion)); + let notification_outcome = state + .notification + .as_ref() + .map(|notification| render_notification_toast(&mut ctx, layout_size, notification)); + let is_sketch = state.mode.is_sketch(); + let shortcut_bar_outcome = state + .shortcut_bar + .map(|bar_state| shortcut_bar::render(&mut ctx, bar_state, layout_size, is_sketch)); + let any_modal_open = state.shell.state.keyboard_dialog_open + || conflict_outcome.is_some() + || dim_outcome.is_some() + || picker_outcome.is_some() + || overwrite_outcome.is_some() + || discard_outcome.is_some() + || step_progress_outcome.is_some() + || shortcut_bar_outcome.is_some(); + if !any_modal_open && ctx.focus.is_text_input_focused() { + strip_plain_letter_chords(ctx.input); + } + let mut actions = if any_modal_open { + Vec::new() + } else { + ctx.dispatch_hotkeys(scopes) + }; + if let Some(activated) = shortcut_bar_outcome.as_ref().and_then(|o| o.activated) { + actions.push(activated); + } + FrameOutcomes { + frame, + hotkey_actions: actions, + dim: dim_outcome, + dim_conflict: conflict_outcome, + picker: picker_outcome, + overwrite: overwrite_outcome, + discard: discard_outcome, + step_progress: step_progress_outcome, + notification: notification_outcome, + shortcut_bar: shortcut_bar_outcome, + } +} + +fn dim_conflict_pending(mode: &Mode) -> Option { + match mode.dim_flow() { + Some(DimensionFlow::Conflict(p)) => Some(p), + Some(DimensionFlow::Editing(_)) | None => None, + } +} + +#[derive(Clone, Debug, PartialEq)] +struct DimConflictOutcome { + paints: Vec, + action: DimConflictAction, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum DimConflictAction { + Idle, + MakeDriven, + Cancel, +} + +fn render_dim_conflict_modal( + ctx: &mut FrameCtx<'_>, + layout_size: LayoutSize, +) -> DimConflictOutcome { + use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; + use bone_ui::{WidgetId, WidgetKey}; + let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); + let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(180.0)); + let id = WidgetId::ROOT.child(WidgetKey::new("dim.conflict")); + let response = show_confirmation( + ctx, + ConfirmationDialog { + id, + viewport, + size: dialog_size, + title: strings::DIM_CONFLICT_TITLE, + message: strings::DIM_CONFLICT_MESSAGE, + confirm_label: strings::DIM_CONFLICT_MAKE_DRIVEN, + cancel_label: strings::DIM_CONFLICT_CANCEL, + destructive: false, + }, + ); + let action = match response.outcome { + Some(ConfirmationOutcome::Confirm) => DimConflictAction::MakeDriven, + Some(ConfirmationOutcome::Cancel) => DimConflictAction::Cancel, + None => DimConflictAction::Idle, + }; + DimConflictOutcome { + paints: response.paint, + action, + } +} + +#[derive(Clone, Debug, PartialEq)] +struct OverwriteOutcome { + paints: Vec, + action: OverwriteAction, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum OverwriteAction { + Idle, + Replace, + Cancel, +} + +fn render_overwrite_modal( + ctx: &mut FrameCtx<'_>, + layout_size: LayoutSize, + pending: &PendingOverwrite, +) -> OverwriteOutcome { + use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; + use bone_ui::{WidgetId, WidgetKey}; + let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); + let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(180.0)); + let id = WidgetId::ROOT.child(WidgetKey::new("file.overwrite")); + let (title, message) = match pending { + PendingOverwrite::Document(_) => ( + strings::FILE_OVERWRITE_TITLE, + strings::FILE_OVERWRITE_MESSAGE, + ), + PendingOverwrite::StepExport(_) => ( + strings::FILE_OVERWRITE_TITLE_STEP, + strings::FILE_OVERWRITE_MESSAGE_STEP, + ), + }; + let response = show_confirmation( + ctx, + ConfirmationDialog { + id, + viewport, + size: dialog_size, + title, + message, + confirm_label: strings::FILE_OVERWRITE_REPLACE, + cancel_label: strings::FILE_OVERWRITE_CANCEL, + destructive: true, + }, + ); + let action = match response.outcome { + Some(ConfirmationOutcome::Confirm) => OverwriteAction::Replace, + Some(ConfirmationOutcome::Cancel) => OverwriteAction::Cancel, + None => OverwriteAction::Idle, + }; + OverwriteOutcome { + paints: response.paint, + action, + } +} + +#[derive(Clone, Debug, PartialEq)] +struct DiscardOutcome { + paints: Vec, + action: DiscardAction, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum DiscardAction { + Idle, + Confirm, + Cancel, +} + +fn render_discard_modal( + ctx: &mut FrameCtx<'_>, + layout_size: LayoutSize, + pending: &PendingDiscard, +) -> DiscardOutcome { + use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; + use bone_ui::{WidgetId, WidgetKey}; + let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); + let dialog_size = LayoutSize::new(LayoutPx::new(460.0), LayoutPx::new(190.0)); + let id = WidgetId::ROOT.child(WidgetKey::new("file.discard")); + let (title, message, confirm_label, cancel_label) = match pending { + PendingDiscard::New | PendingDiscard::Open(_) | PendingDiscard::ImportStep(_) => ( + strings::FILE_DISCARD_TITLE, + strings::FILE_DISCARD_MESSAGE, + strings::FILE_DISCARD_CONFIRM, + strings::FILE_DISCARD_CANCEL, + ), + PendingDiscard::InstallImported { .. } => ( + strings::FILE_IMPORT_REPLACE_TITLE, + strings::FILE_IMPORT_REPLACE_MESSAGE, + strings::FILE_IMPORT_REPLACE_CONFIRM, + strings::FILE_IMPORT_REPLACE_CANCEL, + ), + }; + let response = show_confirmation( + ctx, + ConfirmationDialog { + id, + viewport, + size: dialog_size, + title, + message, + confirm_label, + cancel_label, + destructive: true, + }, + ); + let action = match response.outcome { + Some(ConfirmationOutcome::Confirm) => DiscardAction::Confirm, + Some(ConfirmationOutcome::Cancel) => DiscardAction::Cancel, + None => DiscardAction::Idle, + }; + DiscardOutcome { + paints: response.paint, + action, + } +} + +fn apply_discard_outcome(state: &mut AppState, outcome: Option) { + let Some(outcome) = outcome else { return }; + match outcome.action { + DiscardAction::Idle => {} + DiscardAction::Cancel => { + state.pending_discard = None; + } + DiscardAction::Confirm => { + let Some(pending) = state.pending_discard.take() else { + return; + }; + match pending { + PendingDiscard::New => apply_new_document(state), + PendingDiscard::Open(path) => apply_open_folder(state, path), + PendingDiscard::ImportStep(path) => start_step_import(state, path), + PendingDiscard::InstallImported { + document, + file_name, + } => { + install_imported_document(state, *document); + notify_info(state, strings::NOTIFY_IMPORTED, Some(file_name)); + } + } + } + } +} + +#[derive(Clone, Debug, PartialEq)] +struct StepProgressOutcome { + paints: Vec, + cancel_requested: bool, +} + +fn render_step_progress_dialog( + ctx: &mut FrameCtx<'_>, + layout_size: LayoutSize, + job: &step_jobs::StepJob, + reduce_motion: bool, +) -> StepProgressOutcome { + use bone_ui::widgets::{Dialog, DialogButton, LabelText, WidgetPaint, show_dialog}; + use bone_ui::{WidgetId, WidgetKey}; + let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); + let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(170.0)); + let id = WidgetId::ROOT.child(WidgetKey::new("step.progress")); + let cancel_id = id.child(WidgetKey::new("cancel")); + let title = match job { + step_jobs::StepJob::Import { .. } => strings::STEP_PROGRESS_TITLE_IMPORT, + step_jobs::StepJob::Export { .. } => strings::STEP_PROGRESS_TITLE_EXPORT, + }; + let buttons = [DialogButton::secondary( + cancel_id, + strings::STEP_PROGRESS_CANCEL, + )]; + let sweep = sweep_phase(ctx.input.frame, reduce_motion); + let file_name = job.meta().file_name.clone(); + let (response, ()) = show_dialog( + ctx, + Dialog::new(id, viewport, dialog_size, title, &buttons), + |ctx, body_rect, paint| { + let label_rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(body_rect.origin.x.value() + 16.0), + LayoutPx::new(body_rect.origin.y.value() + 12.0), + ), + LayoutSize::new( + LayoutPx::saturating_nonneg(body_rect.size.width.value() - 32.0), + LayoutPx::new(20.0), + ), + ); + paint.push(WidgetPaint::Label { + rect: label_rect, + text: LabelText::Owned(file_name), + color: ctx.theme().colors.text_secondary(), + role: ctx.theme().typography.body, + }); + push_progress_bar(ctx, body_rect, sweep, paint); + }, + ); + StepProgressOutcome { + paints: response.paint, + cancel_requested: response.dismissed || response.activated == Some(cancel_id), + } +} + +const SWEEP_PERIOD_SECS: f32 = 1.2; +const SWEEP_SPAN: f32 = 0.3; + +fn sweep_phase(now: bone_ui::input::FrameInstant, reduce_motion: bool) -> f32 { + if reduce_motion { + return 0.5; + } + (now.duration().as_secs_f32() / SWEEP_PERIOD_SECS).fract() +} + +fn push_progress_bar( + ctx: &FrameCtx<'_>, + body: LayoutRect, + sweep: f32, + paint: &mut Vec, +) { + use bone_ui::widgets::WidgetPaint; + let track = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(body.origin.x.value() + 16.0), + LayoutPx::new(body.origin.y.value() + 48.0), + ), + LayoutSize::new( + LayoutPx::saturating_nonneg(body.size.width.value() - 32.0), + LayoutPx::new(8.0), + ), + ); + paint.push(WidgetPaint::Surface { + rect: track, + fill: ctx.theme().colors.surface(bone_ui::theme::SurfaceLevel::L0), + border: Some(bone_ui::theme::Border { + width: bone_ui::theme::StrokeWidth::HAIRLINE, + color: ctx + .theme() + .colors + .neutral + .step(bone_ui::theme::Step12::SUBTLE_BORDER), + }), + radius: ctx.theme().radius.sm, + elevation: None, + }); + let start = sweep * (1.0 + SWEEP_SPAN) - SWEEP_SPAN; + let left = start.max(0.0); + let right = (start + SWEEP_SPAN).min(1.0); + if right <= left { + return; + } + let width = track.size.width.value(); + let fill_rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(track.origin.x.value() + width * left), + track.origin.y, + ), + LayoutSize::new(LayoutPx::new(width * (right - left)), track.size.height), + ); + paint.push(WidgetPaint::Surface { + rect: fill_rect, + fill: ctx.theme().colors.accent_solid(), + border: None, + radius: ctx.theme().radius.sm, + elevation: None, + }); +} + +fn apply_step_progress_outcome(state: &AppState, outcome: Option) { + let cancel = outcome.is_some_and(|o| o.cancel_requested); + if !cancel { + return; + } + if let Some(job) = state.step_job.as_ref() { + job.meta().request_cancel(); + } +} + +#[derive(Clone, Debug, PartialEq)] +struct NotificationOutcome { + paints: Vec, + dismissed: bool, +} + +fn render_notification_toast( + ctx: &mut FrameCtx<'_>, + layout_size: LayoutSize, + notification: &Notification, +) -> NotificationOutcome { + use bone_ui::widgets::{Button, ButtonVariant, WidgetPaint, show_button}; + use bone_ui::{WidgetId, WidgetKey}; + let theme = ctx.theme(); + let bg = match notification.kind { + NotificationKind::Info => theme.colors.surface(bone_ui::theme::SurfaceLevel::L2), + NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::SUBTLE_BG), + }; + let fg = match notification.kind { + NotificationKind::Info => theme.colors.text_primary(), + NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::TEXT_HIGH), + }; + let toast_width = layout_size.width.value().clamp(280.0, 420.0); + let toast_height = if notification.detail.is_some() { + 72.0 + } else { + 44.0 + }; + let margin = 24.0; + let toast_rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(margin), + LayoutPx::new(layout_size.height.value() - toast_height - margin), + ), + LayoutSize::new(LayoutPx::new(toast_width), LayoutPx::new(toast_height)), + ); + let id = WidgetId::ROOT.child(WidgetKey::new("notification.toast")); + let mut paints = vec![WidgetPaint::Surface { + rect: toast_rect, + fill: bg, + border: Some(bone_ui::theme::Border { + width: bone_ui::theme::StrokeWidth::HAIRLINE, + color: theme.colors.neutral.step(bone_ui::theme::Step12::BORDER), + }), + radius: theme.radius.sm, + elevation: Some(theme.elevation.level1), + }]; + let headline_rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(toast_rect.origin.x.value() + 16.0), + LayoutPx::new(toast_rect.origin.y.value() + 12.0), + ), + LayoutSize::new(LayoutPx::new(toast_width - 120.0), LayoutPx::new(20.0)), + ); + paints.push(WidgetPaint::Label { + rect: headline_rect, + text: bone_ui::widgets::LabelText::Key(notification.headline), + color: fg, + role: theme.typography.label, + }); + if let Some(detail) = ¬ification.detail { + let detail_rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(toast_rect.origin.x.value() + 16.0), + LayoutPx::new(toast_rect.origin.y.value() + 36.0), + ), + LayoutSize::new(LayoutPx::new(toast_width - 32.0), LayoutPx::new(24.0)), + ); + paints.push(WidgetPaint::Label { + rect: detail_rect, + text: bone_ui::widgets::LabelText::Owned(detail.clone()), + color: theme.colors.text_secondary(), + role: theme.typography.caption, + }); + } + let dismiss_rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(toast_rect.origin.x.value() + toast_width - 96.0), + LayoutPx::new(toast_rect.origin.y.value() + 8.0), + ), + LayoutSize::new(LayoutPx::new(84.0), LayoutPx::new(28.0)), + ); + let dismiss_id = id.child(WidgetKey::new("dismiss")); + let response = show_button( + ctx, + Button::new( + dismiss_id, + dismiss_rect, + strings::NOTIFY_DISMISS, + ButtonVariant::Secondary, + ), + ); + paints.extend(response.paint); + NotificationOutcome { + paints, + dismissed: response.activated, + } +} + +fn apply_notification_outcome(state: &mut AppState, outcome: Option) { + let Some(outcome) = outcome else { return }; + if outcome.dismissed { + state.notification = None; + } +} + +fn apply_overwrite_outcome(state: &mut AppState, outcome: Option) { + let Some(outcome) = outcome else { return }; + match outcome.action { + OverwriteAction::Idle => {} + OverwriteAction::Cancel => { + state.pending_overwrite = None; + } + OverwriteAction::Replace => match state.pending_overwrite.take() { + Some(PendingOverwrite::Document(folder)) => perform_save_to(state, folder), + Some(PendingOverwrite::StepExport(path)) => start_step_export(state, path), + None => {} + }, + } +} + +fn pending_dim(mode: &Mode) -> Option { + match mode.dim_flow() { + Some(DimensionFlow::Editing(p)) => Some(p), + Some(DimensionFlow::Conflict(_)) | None => None, + } +} + +fn apply_dimension_request(state: &mut AppState, request: Option) { + let Some(request) = request else { return }; + let Mode::Sketch { .. } = state.mode else { + return; + }; + state.mode = core::mem::take(&mut state.mode).start_dimension(request); +} + +fn apply_dimension_outcome(state: &mut AppState, outcome: Option) { + let Some(outcome) = outcome else { return }; + let Some(pending) = pending_dim(&state.mode) else { + return; + }; + match outcome.action { + DimensionEditorAction::Idle => {} + DimensionEditorAction::Cancel => { + state.mode = core::mem::take(&mut state.mode).cancel_dimension(); + state.dim_editor.close(); + } + DimensionEditorAction::Swap(next_proto) => { + state.mode = core::mem::take(&mut state.mode).start_dimension(PendingDimension { + proto: next_proto, + anchor: pending.anchor, + }); + } + DimensionEditorAction::Commit(value) => { + commit_pending_dimension(state, pending, value); + } + } +} + +fn apply_dim_conflict_outcome(state: &mut AppState, outcome: Option) { + let Some(outcome) = outcome else { return }; + let Some(pending) = dim_conflict_pending(&state.mode) else { + return; + }; + match outcome.action { + DimConflictAction::Idle => {} + DimConflictAction::Cancel => { + state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); + } + DimConflictAction::MakeDriven => { + confirm_dim_conflict_make_driven(state, pending); + } + } +} + +fn commit_pending_dimension( + state: &mut AppState, + pending: PendingDimension, + value: DimensionValue, +) { + let Mode::Sketch { sketch_id, .. } = state.mode else { + return; + }; + let Some(sketch) = state.document.sketch(sketch_id).cloned() else { + return; + }; + let proto = match pending.proto.with_value(value) { + Ok(p) => p, + Err(e) => { + tracing::warn!(error = %e, ?pending.proto, ?value, "dimension value type mismatch"); + return; + } + }; + let (after_add, dim_id) = match sketch.clone().apply(SketchEdit::AddDimension(proto)) { + Ok((next, EditOutcome::Dimension(id))) => (next, id), + Ok(_) => { + tracing::warn!(?proto, "add dimension produced unexpected outcome"); + return; + } + Err(e) => { + tracing::warn!(error = %e, ?proto, "add dimension failed"); + return; + } + }; + let solved = match after_add.solve() { + Ok(s) => s, + Err(SolverError::OverDefined { .. }) => { + state.mode = core::mem::take(&mut state.mode).start_dim_conflict(PendingDimension { + proto, + anchor: pending.anchor, + }); + state.dim_editor.close(); + return; + } + Err(e) => { + tracing::warn!(error = %e, "solve after add dim did not converge; rejecting"); + return; + } + }; + state.undo.record(state.document.clone()); + state.document.replace_sketch(sketch_id, solved); + state.selection = Selection::Dimension(dim_id); + state.mode = core::mem::take(&mut state.mode).cancel_dimension(); + state.dim_editor.close(); + refresh_active_scene(state); +} + +fn confirm_dim_conflict_make_driven(state: &mut AppState, pending: PendingDimension) { + let Mode::Sketch { sketch_id, .. } = state.mode else { + return; + }; + let Some(sketch) = state.document.sketch(sketch_id).cloned() else { + return; + }; + let Some(measured) = sketch.measure(pending.proto) else { + tracing::warn!(?pending.proto, "measure failed for driven conversion; aborting"); + state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); + return; + }; + let Some(driven_proto) = driven_with_value(pending.proto, measured) else { + tracing::warn!(?pending.proto, "driven conversion failed"); + state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); + return; + }; + let (after_add, dim_id) = match sketch.apply(SketchEdit::AddDimension(driven_proto)) { + Ok((next, EditOutcome::Dimension(id))) => (next, id), + Ok(_) => { + tracing::warn!( + ?driven_proto, + "add driven dimension produced unexpected outcome" + ); + state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); + return; + } + Err(e) => { + tracing::warn!(error = %e, ?driven_proto, "add driven dimension failed"); + state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); + return; + } + }; + let solved = after_add.clone().solve().unwrap_or(after_add); + state.undo.record(state.document.clone()); + state.document.replace_sketch(sketch_id, solved); + state.selection = Selection::Dimension(dim_id); + state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); + refresh_active_scene(state); +} + +fn driven_with_value(proto: SketchDimension, value: DimensionValue) -> Option { + proto + .with_kind(DimensionKind::Driven) + .with_value(value) + .ok() +} + +fn apply_dimension_edit(state: &mut AppState, edit: Option) { + let Some(edit) = edit else { return }; + let Mode::Sketch { sketch_id, .. } = state.mode else { + return; + }; + let Some(sketch) = state.document.sketch(sketch_id).cloned() else { + return; + }; + let after = match sketch.apply(SketchEdit::UpdateDimensionValue { + id: edit.id, + value: edit.value, + }) { + Ok((next, _)) => next, + Err(e) => { + tracing::warn!(error = %e, ?edit, "update dimension value failed"); + return; + } + }; + let solved = match after.solve() { + Ok(s) => s, + Err(e) => { + tracing::warn!(error = %e, ?edit, "solve after dimension edit failed"); + return; + } + }; + state.undo.record(state.document.clone()); + state.document.replace_sketch(sketch_id, solved); + refresh_active_scene(state); +} + +fn apply_relation_action(state: &mut AppState, relation: Option) { + let Some(relation) = relation else { return }; + let Mode::Sketch { sketch_id, .. } = state.mode else { + return; + }; + let Some(sketch) = state.document.sketch(sketch_id).cloned() else { + return; + }; + let next = match sketch.apply(SketchEdit::AddRelation(relation)) { + Ok((next, _)) => next, + Err(e) => { + tracing::warn!(error = %e, ?relation, "add relation failed"); + return; + } + }; + state.undo.record(state.document.clone()); + state.document.replace_sketch(sketch_id, next); + state.selection = Selection::default(); + refresh_active_scene(state); +} + +fn apply_menu_action(state: &mut AppState, action: Option) { + match action { + Some(shell::MenuAction::Quit) => { + state.pending_exit = true; + } + Some(shell::MenuAction::Undo) if state.undo.undo(&mut state.document) => { + refresh_active_scene(state); + } + Some(shell::MenuAction::Redo) if state.undo.redo(&mut state.document) => { + refresh_active_scene(state); + } + Some(shell::MenuAction::ZoomFit) => { + let solid_fit = state + .solid_view + .as_ref() + .map(|view| view.aabb) + .and_then(|aabb| { + let region = solid_viewport_region(state.viewport_rect, state.extent)?; + frame_current(state.camera3?, aabb, region.extent()).ok() + }); + if let Some(next) = solid_fit { + apply_nav_camera(state, next); + } else { + state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); + } + } + Some(shell::MenuAction::OpenSettings) => { + state.shell.state.settings_dialog_open = true; + } + Some(shell::MenuAction::OpenKeyboardCustomize) => { + state.shell.state.keyboard_dialog_open = true; + } + Some(shell::MenuAction::NewDocument) => { + request_new_document(state); + } + Some(shell::MenuAction::OpenDocument) => { + open_picker( + state, + bone_ui::widgets::FilePickerMode::Open, + file_menu::FileKind::Document, + None, + ); + } + Some(shell::MenuAction::SaveDocument) => { + apply_save_in_place(state); + } + Some(shell::MenuAction::SaveDocumentAs) => { + let seed = state.document.name().to_owned(); + open_picker( + state, + bone_ui::widgets::FilePickerMode::Save, + file_menu::FileKind::Document, + Some(seed), + ); + } + Some(shell::MenuAction::ImportStep) => { + if state.step_job.is_none() { + open_picker( + state, + bone_ui::widgets::FilePickerMode::Open, + file_menu::FileKind::Step, + None, + ); + } + } + Some(shell::MenuAction::ExportStep) => { + if state.step_job.is_none() { + let seed = format!("{}.step", state.document.name()); + open_picker( + state, + bone_ui::widgets::FilePickerMode::Save, + file_menu::FileKind::Step, + Some(seed), + ); + } + } + Some(shell::MenuAction::Undo | shell::MenuAction::Redo | shell::MenuAction::ExitSketch) + | None => {} + } +} + +fn request_new_document(state: &mut AppState) { + if is_dirty(state) { + state.pending_discard = Some(PendingDiscard::New); + } else { + apply_new_document(state); + } +} + +fn request_open_folder(state: &mut AppState, path: PathBuf) { + if is_dirty(state) { + state.pending_discard = Some(PendingDiscard::Open(path)); + } else { + apply_open_folder(state, path); + } +} + +fn request_import_step(state: &mut AppState, path: PathBuf) { + if is_dirty(state) { + state.pending_discard = Some(PendingDiscard::ImportStep(path)); + } else { + start_step_import(state, path); + } +} + +fn start_step_import(state: &mut AppState, path: PathBuf) { + if state.step_job.is_some() { + return; + } + match step_jobs::spawn_import(path, state.document.clone()) { + Ok(job) => state.step_job = Some(job), + Err(e) => notify_error(state, strings::NOTIFY_IMPORT_FAILED, e.to_string()), + } +} + +fn apply_export_step_as(state: &mut AppState, path: PathBuf, via: PickedVia) { + let extension_appended = !file_menu::is_step_file(&path); + let path = file_menu::with_step_extension(path); + let unconfirmed = matches!(via, PickedVia::CustomPicker) || extension_appended; + if unconfirmed && path.is_file() { + state.pending_overwrite = Some(PendingOverwrite::StepExport(path)); + return; + } + start_step_export(state, path); +} + +fn start_step_export(state: &mut AppState, path: PathBuf) { + if state.step_job.is_some() { + return; + } + match step_jobs::spawn_export(state.document.clone(), path) { + Ok(job) => state.step_job = Some(job), + Err(e) => notify_error(state, strings::NOTIFY_EXPORT_FAILED, e.to_string()), + } +} + +fn poll_step_job(state: &mut AppState) { + let Some(job) = state.step_job.take() else { + return; + }; + match job { + step_jobs::StepJob::Import { rx, baseline, meta } => match step_jobs::poll(&rx) { + std::task::Poll::Pending => { + state.step_job = Some(step_jobs::StepJob::Import { rx, baseline, meta }); + } + std::task::Poll::Ready(result) => finish_import(state, result, &baseline, &meta), + }, + step_jobs::StepJob::Export { rx, meta } => match step_jobs::poll(&rx) { + std::task::Poll::Pending => { + state.step_job = Some(step_jobs::StepJob::Export { rx, meta }); + } + std::task::Poll::Ready(result) => finish_export(state, result, &meta), + }, + } +} + +fn finish_import( + state: &mut AppState, + result: step_jobs::JobResult>, + baseline: &Document, + meta: &step_jobs::JobMeta, +) { + match result { + step_jobs::JobResult::Finished(_) if meta.cancel_requested() => { + tracing::info!(file = %meta.file_name, "discarding import that finished after cancel"); + } + step_jobs::JobResult::Finished(document) if *baseline != state.document => { + state.pending_discard = Some(PendingDiscard::InstallImported { + document, + file_name: meta.file_name.clone(), + }); + } + step_jobs::JobResult::Finished(document) => { + install_imported_document(state, *document); + notify_info( + state, + strings::NOTIFY_IMPORTED, + Some(meta.file_name.clone()), + ); + } + step_jobs::JobResult::Failed(bone_interop::StepError::Canceled) => { + tracing::info!(file = %meta.file_name, "step import canceled"); + } + step_jobs::JobResult::Failed(e) => { + tracing::warn!(error = %e, file = %meta.file_name, "step import failed"); + notify_error(state, strings::NOTIFY_IMPORT_FAILED, e.to_string()); + } + step_jobs::JobResult::WorkerLost => { + tracing::error!(file = %meta.file_name, "step import worker stopped before reporting a result"); + notify_error( + state, + strings::NOTIFY_IMPORT_FAILED, + "worker stopped before reporting a result".to_owned(), + ); + } + } +} + +fn finish_export( + state: &mut AppState, + result: step_jobs::JobResult<()>, + meta: &step_jobs::JobMeta, +) { + match result { + step_jobs::JobResult::Finished(()) => { + notify_info( + state, + strings::NOTIFY_EXPORTED, + Some(meta.file_name.clone()), + ); + } + step_jobs::JobResult::Failed(bone_interop::StepError::Canceled) => { + tracing::info!(file = %meta.file_name, "step export canceled"); + } + step_jobs::JobResult::Failed(e) => { + tracing::warn!(error = %e, file = %meta.file_name, "step export failed"); + notify_error(state, strings::NOTIFY_EXPORT_FAILED, e.to_string()); + } + step_jobs::JobResult::WorkerLost => { + tracing::error!(file = %meta.file_name, "step export worker stopped before reporting a result"); + notify_error( + state, + strings::NOTIFY_EXPORT_FAILED, + "worker stopped before reporting a result".to_owned(), + ); + } + } +} + +fn is_dirty(state: &AppState) -> bool { + state.last_saved.as_ref() != Some(&state.document) +} + +fn apply_new_document(state: &mut AppState) { + let sketch = default_sketch(); + let scene = match SketchScene::extract(&sketch) { + Ok(s) => s, + Err(e) => { + tracing::warn!(error = %e, "scene extract failed on new document"); + notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); + return; + } + }; + let (document, sketch_id) = initial_document(sketch); + state.last_saved = Some(document.clone()); + state.document = document; + state.plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); + state.scene = scene; + state.mode = Mode::Idle; + state.selection = Selection::default(); + state.framed_extrude = None; + state.current_folder = None; + state.pending_overwrite = None; + let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { + unreachable!("UNDO_CAPACITY constant is non-zero"); + }; + state.undo = UndoStack::with_capacity(undo_capacity); + state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); +} + +fn open_picker( + state: &mut AppState, + mode: bone_ui::widgets::FilePickerMode, + kind: file_menu::FileKind, + seed_filename: Option, +) { + if state.file_picker.is_some() || state.native_picker.is_some() { + return; + } + let starting_folder = state + .current_folder + .as_ref() + .map(|f| f.path().to_owned()) + .or_else(|| { + state + .documents_root + .is_dir() + .then(|| state.documents_root.clone()) + }); + let title_key = file_menu::title_key(kind, mode); + let accept_key = file_menu::accept_key(kind, mode); + let title = state.strings.resolve(title_key).to_owned(); + let accept_label = state.strings.resolve(accept_key).to_owned(); + let native_req = native_picker::Request { + mode, + kind, + title: title.as_str(), + accept_label: accept_label.as_str(), + seed_filename: seed_filename.as_deref(), + current_folder: starting_folder.as_deref(), + }; + match native_picker::spawn(native_req) { + Ok(handle) => { + state.native_picker = Some(handle); + return; + } + Err(native_picker::SpawnError::Unsupported) => { + tracing::debug!("native picker unavailable, falling back to custom picker"); + } + } + open_custom_picker(state, mode, kind, seed_filename); +} + +fn open_custom_picker( + state: &mut AppState, + mode: bone_ui::widgets::FilePickerMode, + kind: file_menu::FileKind, + seed_filename: Option, +) { + let scan = match kind { + file_menu::FileKind::Document => file_menu::scan_document_folders(&state.documents_root), + file_menu::FileKind::Step => file_menu::scan_step_files(&state.documents_root), + }; + let entries = match scan { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, path = %state.documents_root.display(), "scan documents root failed"); + notify_error(state, strings::NOTIFY_SCAN_FAILED, e.to_string()); + Vec::new() + } + }; + state.file_picker = Some(file_menu::FilePickerSession::open( + state.documents_root.clone(), + mode, + kind, + seed_filename, + entries, + )); +} + +fn poll_native_picker(state: &mut AppState) { + let Some(handle) = state.native_picker.as_ref() else { + return; + }; + let outcome = match handle.poll() { + std::task::Poll::Pending => return, + std::task::Poll::Ready(o) => o, + }; + let mode = handle.mode; + let kind = handle.kind; + state.native_picker = None; + match outcome { + native_picker::NativeOutcome::Path(path) => { + route_picked_path(state, kind, mode, path, PickedVia::NativePortal); + } + native_picker::NativeOutcome::Cancelled => {} + native_picker::NativeOutcome::Error(message) => { + tracing::warn!(error = %message, "native picker errored, falling back to custom picker"); + let seed = matches!(mode, bone_ui::widgets::FilePickerMode::Save).then(|| match kind { + file_menu::FileKind::Document => state.document.name().to_owned(), + file_menu::FileKind::Step => format!("{}.step", state.document.name()), + }); + open_custom_picker(state, mode, kind, seed); + } + } +} + +fn route_picked_path( + state: &mut AppState, + kind: file_menu::FileKind, + mode: bone_ui::widgets::FilePickerMode, + path: PathBuf, + via: PickedVia, +) { + match (kind, mode) { + (file_menu::FileKind::Document, bone_ui::widgets::FilePickerMode::Open) => { + request_open_folder(state, path); + } + (file_menu::FileKind::Document, bone_ui::widgets::FilePickerMode::Save) => { + apply_save_as(state, path); + } + (file_menu::FileKind::Step, bone_ui::widgets::FilePickerMode::Open) => { + request_import_step(state, path); + } + (file_menu::FileKind::Step, bone_ui::widgets::FilePickerMode::Save) => { + apply_export_step_as(state, path, via); + } + } +} + +fn apply_save_in_place(state: &mut AppState) { + let Some(folder) = state.current_folder.clone() else { + let seed = state.document.name().to_owned(); + open_picker( + state, + bone_ui::widgets::FilePickerMode::Save, + file_menu::FileKind::Document, + Some(seed), + ); + return; + }; + if let Err(e) = bone_document::save(&state.document, &folder) { + tracing::warn!(error = %e, path = %folder.path().display(), "save failed"); + notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string()); + return; + } + state.last_saved = Some(state.document.clone()); + notify_info(state, strings::NOTIFY_SAVED, None); +} + +fn apply_picker_command( + state: &mut AppState, + kind: file_menu::FileKind, + command: file_menu::PickerCommand, +) { + state.file_picker = None; + match command { + file_menu::PickerCommand::Cancel => {} + file_menu::PickerCommand::Open(path) => { + route_picked_path( + state, + kind, + bone_ui::widgets::FilePickerMode::Open, + path, + PickedVia::CustomPicker, + ); + } + file_menu::PickerCommand::SaveAs(path) => { + route_picked_path( + state, + kind, + bone_ui::widgets::FilePickerMode::Save, + path, + PickedVia::CustomPicker, + ); + } + } +} + +fn apply_open_folder(state: &mut AppState, path: PathBuf) { + let folder = DocumentFolder::new(path); + let document = match bone_document::load(&folder) { + Ok(d) => d, + Err(e) => { + tracing::warn!(error = %e, path = %folder.path().display(), "load failed"); + notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); + return; + } + }; + install_loaded_document(state, document, Some(folder)); +} + +fn apply_save_as(state: &mut AppState, path: PathBuf) { + let folder = DocumentFolder::new(path); + let in_place = state + .current_folder + .as_ref() + .is_some_and(|current| same_folder(current.path(), folder.path())); + if folder.document_file().is_file() && !in_place { + state.pending_overwrite = Some(PendingOverwrite::Document(folder)); + return; + } + perform_save_to(state, folder); +} + +fn perform_save_to(state: &mut AppState, folder: DocumentFolder) { + let prior_name = state.document.name().to_owned(); + state + .document + .set_name(folder_display_name(&folder, &state.strings)); + if let Err(e) = bone_document::save(&state.document, &folder) { + tracing::warn!(error = %e, path = %folder.path().display(), "save as failed"); + state.document.set_name(prior_name); + notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string()); + return; + } + state.current_folder = Some(folder); + state.last_saved = Some(state.document.clone()); + notify_info(state, strings::NOTIFY_SAVED, None); +} + +fn folder_display_name(folder: &DocumentFolder, string_table: &StringTable) -> String { + folder.path().file_name().map_or_else( + || { + string_table + .resolve(strings::DEFAULT_DOCUMENT_NAME) + .to_owned() + }, + |s| s.to_string_lossy().into_owned(), + ) +} + +fn same_folder(a: &Path, b: &Path) -> bool { + match (resolve_path(a), resolve_path(b)) { + (Some(x), Some(y)) => x == y, + _ => false, + } +} + +fn resolve_path(path: &Path) -> Option { + if let Ok(canon) = std::fs::canonicalize(path) { + return Some(canon); + } + let parent = path.parent()?; + let file_name = path.file_name()?; + let parent_canon = std::fs::canonicalize(parent).ok()?; + Some(parent_canon.join(file_name)) +} + +fn notify_error(state: &mut AppState, headline: bone_ui::strings::StringKey, detail: String) { + state.notification = Some(Notification { + kind: NotificationKind::Error, + headline, + detail: Some(detail), + }); +} + +fn notify_info( + state: &mut AppState, + headline: bone_ui::strings::StringKey, + detail: Option, +) { + state.notification = Some(Notification { + kind: NotificationKind::Info, + headline, + detail, + }); +} + +fn notify_stub(state: &mut AppState, label: bone_ui::strings::StringKey) { + let detail = state.strings.resolve(label).to_owned(); + tracing::info!(label = %detail, "stub action invoked"); + notify_info(state, strings::NOTIFY_COMING_SOON, Some(detail)); +} + +fn apply_shortcut_bar_outcome( + state: &mut AppState, + outcome: Option<&shortcut_bar::ShortcutBarOutcome>, +) { + let Some(outcome) = outcome else { return }; + if outcome.dismissed || outcome.activated.is_some() { + state.shortcut_bar = None; + } +} + +fn install_imported_document(state: &mut AppState, document: Document) { + install_loaded_document(state, document, None); + state.last_saved = None; +} + +fn install_loaded_document( + state: &mut AppState, + document: Document, + folder: Option, +) { + let plane_sketches = plane_sketches_from(&document); + let active_sketch_id = plane_sketches.get(&Plane::Xy).copied(); + state.last_saved = Some(document.clone()); + state.document = document; + state.plane_sketches = plane_sketches; + state.mode = Mode::Idle; + state.selection = Selection::default(); + state.framed_extrude = None; + state.current_folder = folder; + state.pending_overwrite = None; + let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { + unreachable!("UNDO_CAPACITY constant is non-zero"); + }; + state.undo = UndoStack::with_capacity(undo_capacity); + let scene_attempt = active_sketch_id + .and_then(|id| state.document.sketch(id)) + .map(SketchScene::extract); + state.scene = match scene_attempt { + None => SketchScene::empty(), + Some(Ok(scene)) => scene, + Some(Err(e)) => { + tracing::warn!(error = %e, "scene extract on load failed"); + notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); + SketchScene::empty() + } + }; + state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); +} + +fn plane_sketches_from(document: &Document) -> BTreeMap { + document + .sketches() + .map(|(id, _)| (Plane::Xy, id)) + .take(1) + .collect() +} + +fn persist_settings(state: &AppState) { + settings::save(&state.settings); +} + +fn apply_settings_change(state: &mut AppState, change: Option) { + let Some(next) = change else { return }; + let overrides_changed = next.hotkey_overrides != state.settings.hotkey_overrides; + if !overrides_changed { + state.settings = next; + persist_settings(state); + return; + } + match hotkeys::compose_table(&next.hotkey_overrides) { + Ok(table) => { + state.hotkeys = table; + state.settings = next; + persist_settings(state); + } + Err(error) => { + tracing::warn!(?error, "hotkey override rejected, retaining prior settings"); + state.shell.state.hotkey_capture.clear(); + notify_error(state, strings::NOTIFY_HOTKEY_CONFLICT, format!("{error}")); + } + } +} + +fn apply_sketch_rename(state: &mut AppState, request: Option) { + let Some(req) = request else { return }; + apply_sketch_rename_into(&mut state.document, &mut state.undo, req); +} + +fn apply_extrude_rename(state: &mut AppState, request: Option) { + let Some(req) = request else { return }; + apply_extrude_rename_into(&mut state.document, &mut state.undo, req); +} + +fn apply_extrude_rename_into( + document: &mut Document, + undo: &mut UndoStack, + request: shell::ExtrudeRenameRequest, +) { + let shell::ExtrudeRenameRequest { id, label } = request; + let trimmed = label.trim(); + let Some(current) = document.extrude_label(id) else { + return; + }; + if trimmed.is_empty() || current == trimmed { + return; + } + let snapshot = document.clone(); + match document.rename_extrude(id, &label) { + Ok(()) => undo.record(snapshot), + Err(e) => tracing::warn!(error = %e, ?id, "extrude rename rejected"), + } +} + +fn apply_sketch_rename_into( + document: &mut Document, + undo: &mut UndoStack, + request: shell::SketchRenameRequest, +) { + let shell::SketchRenameRequest { id, label } = request; + let trimmed = label.trim(); + let Some(current) = document.sketch_label(id) else { + return; + }; + if trimmed.is_empty() || current == trimmed { + return; + } + let snapshot = document.clone(); + match document.rename_sketch(id, &label) { + Ok(()) => undo.record(snapshot), + Err(e) => tracing::warn!(error = %e, ?id, "sketch rename rejected"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sketch_mode::SketchSession; + use bone_ui::hotkey::KeyChord; + use bone_ui::input::{KeyChar, KeyCode, KeyEvent, NamedKey}; + + #[test] + fn strip_plain_letter_chords_removes_chars_with_no_modifiers() { + let mut input = InputSnapshot::idle(FrameInstant::ZERO); + let plain_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::NONE); + let ctrl_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL); + let esc = KeyEvent::new(KeyCode::Named(NamedKey::Escape), ModifierMask::NONE); + input.keys_pressed = vec![plain_s, ctrl_s, esc]; + strip_plain_letter_chords(&mut input); + assert_eq!( + input.keys_pressed, + vec![ctrl_s, esc], + "strip removes plain letters, keeps modified chords and named keys" + ); + } + + #[test] + fn strip_plain_letter_chords_is_idempotent_when_no_chars() { + let mut input = InputSnapshot::idle(FrameInstant::ZERO); + let enter = KeyEvent::new(KeyCode::Named(NamedKey::Enter), ModifierMask::NONE); + input.keys_pressed = vec![enter]; + strip_plain_letter_chords(&mut input); + assert_eq!(input.keys_pressed, vec![enter]); + } + + #[test] + fn cursor_to_world_at_window_center_equals_camera_pan() { + let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100)); + let camera = Camera2::new(extent) + .with_pan(Vec2::from_mm(7.0, -3.0)) + .with_zoom(PixelsPerMm::new(5.0)); + let Some(world) = cursor_to_world(camera, WindowPoint::new(100.0, 50.0)) else { + panic!("center maps"); + }; + let (x, y) = world.coords_mm(); + assert!((x - 7.0).abs() < 1e-9); + assert!((y - -3.0).abs() < 1e-9); + } + + #[test] + fn cursor_to_world_inverts_y_so_up_in_window_is_up_in_world() { + let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100)); + let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(10.0)); + let Some(above) = cursor_to_world(camera, WindowPoint::new(100.0, 0.0)) else { + panic!("top"); + }; + let Some(below) = cursor_to_world(camera, WindowPoint::new(100.0, 100.0)) else { + panic!("bottom"); + }; + let (_, ya) = above.coords_mm(); + let (_, yb) = below.coords_mm(); + assert!(ya > yb); + } + + #[test] + fn cursor_to_world_rejects_zero_extent() { + let extent = ViewportExtent::new(ViewportPx::new(0), ViewportPx::new(100)); + let camera = Camera2::new(extent); + assert!(cursor_to_world(camera, WindowPoint::new(0.0, 0.0)).is_none()); + } + + fn empty_frame() -> shell::ShellFrame { + shell::ShellFrame { + paints: Vec::new(), + overlay_paints: Vec::new(), + viewport_rect: empty_rect(), + activated_tool: None, + activated_feature_tool: None, + activated_relation: None, + activated_dimension: None, + dimension_edit: None, + extrude_edit: None, + plane_picked: None, + sketch_activated: None, + sketch_rename: None, + extrude_activated: None, + extrude_rename: None, + exit_sketch: false, + confirm_action: None, + menu_action: None, + settings_change: None, + view_pick: None, + view_menu: None, + } + } + + fn xy_only() -> BTreeMap { + BTreeMap::from([(Plane::Xy, SketchId::default())]) + } + + #[test] + fn classify_extrude_profile_separates_no_sketch_from_unique() { + let empty = Document::new(DocumentId::default(), "Untitled".to_owned()); + assert!(matches!( + super::classify_extrude_profile(&empty), + super::ProfileChoice::NoSketch + )); + + let (one, id) = super::initial_document(Sketch::new(Plane::Xy.basis())); + assert!(matches!( + super::classify_extrude_profile(&one), + super::ProfileChoice::Unique(found) if found == id + )); + } + + fn rectangle_sketch() -> Sketch { + let sketch = Sketch::new(Plane::Xy.basis()); + let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); + let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); + let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(10.0, 6.0)); + let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(0.0, 6.0)); + let (sketch, _) = tools::add_line(sketch, p0, p1, false); + let (sketch, _) = tools::add_line(sketch, p1, p2, false); + let (sketch, _) = tools::add_line(sketch, p2, p3, false); + let (sketch, _) = tools::add_line(sketch, p3, p0, false); + sketch + } + + fn tall_rectangle_sketch() -> Sketch { + let sketch = Sketch::new(Plane::Xy.basis()); + let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); + let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); + let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(10.0, 20.0)); + let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(0.0, 20.0)); + let (sketch, _) = tools::add_line(sketch, p0, p1, false); + let (sketch, _) = tools::add_line(sketch, p1, p2, false); + let (sketch, _) = tools::add_line(sketch, p2, p3, false); + let (sketch, _) = tools::add_line(sketch, p3, p0, false); + sketch + } + + #[test] + fn extrude_preview_cache_is_current_only_for_the_same_feature_and_sketch_version() { + let id = SketchId::default(); + let feature = sketch_mode::default_extrude_feature(id); + let base_version = Sketch::new(Plane::Xy.basis()).version(); + let edited_version = rectangle_sketch().version(); + assert_ne!( + base_version, edited_version, + "a sketch edit must bump the version this gate keys on" + ); + let cached = super::ExtrudePreview { + feature, + sketch_version: base_version, + generation: None, + failed: false, + error: None, + }; + assert!(super::extrude_preview_is_current( + Some(&cached), + &feature, + base_version + )); + assert!( + !super::extrude_preview_is_current(Some(&cached), &feature, edited_version), + "editing the sketch under the same feature must invalidate the cached preview", + ); + assert!(!super::extrude_preview_is_current( + None, + &feature, + base_version + )); + } + + #[test] + fn extrude_preview_refreshes_when_the_sketch_changes_under_one_cache() { + let (mut document, id) = super::initial_document(rectangle_sketch()); + let feature = sketch_mode::default_extrude_feature(id); + let mut cache = super::FeatureCache::new(); + let first = super::compute_extrude_preview(&mut cache, &document, feature) + .and_then(|preview| preview.generation()); + document.replace_sketch(id, tall_rectangle_sketch()); + let second = super::compute_extrude_preview(&mut cache, &document, feature) + .and_then(|preview| preview.generation()); + let (Some(first), Some(second)) = (first, second) else { + panic!("both rectangles extrude to a solid"); + }; + assert_ne!( + first, second, + "a sketch edit under the same feature must re-evaluate against the edited geometry", + ); + } + + #[test] + fn extrude_preview_evaluates_a_closed_rectangle() { + let (document, id) = super::initial_document(rectangle_sketch()); + let feature = sketch_mode::default_extrude_feature(id); + let mut cache = super::FeatureCache::new(); + let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { + panic!("a registered sketch yields an evaluated preview"); + }; + assert!( + preview.solid().is_some(), + "closed rectangle extrudes to a solid" + ); + } + + #[test] + #[cfg_attr( + debug_assertions, + ignore = "frame budget assertions are only meaningful in release builds" + )] + fn extrude_live_preview_under_frame_budget() { + use bone_document::ExtrudeEndCondition; + use bone_types::PositiveLength; + use std::time::{Duration, Instant}; + use uom::si::length::millimeter; + + const BASE_MM: f64 = 4.0; + const STEP_MM: f64 = 0.5; + const STEPS: u32 = 16; + + let (document, id) = super::initial_document(rectangle_sketch()); + let mut cache = super::FeatureCache::new(); + let blind = |depth_mm: f64| { + let Ok(depth) = PositiveLength::new(Length::new::(depth_mm)) else { + panic!("{depth_mm} mm is a positive depth"); + }; + ExtrudeFeature { + end_condition: ExtrudeEndCondition::Blind { depth }, + ..sketch_mode::default_extrude_feature(id) + } + }; + let edit = |cache: &mut super::FeatureCache, depth_mm: f64| { + let started = Instant::now(); + let Some(preview) = super::compute_extrude_preview(cache, &document, blind(depth_mm)) + else { + panic!("the rectangle extrudes at {depth_mm} mm"); + }; + let Some(solid) = preview.solid() else { + panic!("the rectangle yields a solid at {depth_mm} mm"); + }; + let Ok(_view) = super::build_solid_view(solid) else { + panic!("the slab tessellates and packs scenes at {depth_mm} mm"); + }; + started.elapsed() + }; + + let _warmup = edit(&mut cache, BASE_MM); + let durations: Vec = (0..STEPS) + .map(|i| edit(&mut cache, BASE_MM + STEP_MM * f64::from(i))) + .collect(); + let sorted = { + let mut v = durations.clone(); + v.sort(); + v + }; + let median = sorted[sorted.len() / 2]; + let Some(&worst) = sorted.last() else { + panic!("preview loop produced zero samples"); + }; + let budget = BudgetCeiling::FRAME_16MS.duration(); + assert!( + median <= budget, + "median evaluate+tessellate+scene step {median:?} exceeds {budget:?} frame budget; samples {durations:?}", + ); + assert!( + worst <= budget * 2, + "worst evaluate+tessellate+scene step {worst:?} exceeds the relaxed ceiling; samples {durations:?}", + ); + } + + #[test] + fn extrude_preview_absent_when_sketch_missing() { + let document = Document::new(DocumentId::default(), "Empty".to_owned()); + let feature = sketch_mode::default_extrude_feature(SketchId::default()); + let mut cache = super::FeatureCache::new(); + assert!(super::compute_extrude_preview(&mut cache, &document, feature).is_none()); + } + + #[test] + fn default_document_extrudes_to_a_solid() { + let (document, id) = super::initial_document(super::default_sketch()); + let feature = sketch_mode::default_extrude_feature(id); + let mut cache = super::FeatureCache::new(); + let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { + panic!("the default sketch is registered"); + }; + let Some(solid) = preview.solid() else { + panic!("the default sketch extrudes to a solid"); + }; + assert!(super::build_solid_view(solid).is_ok()); + } + + #[test] + fn preview_solid_view_tessellates_and_frames() { + let (document, id) = super::initial_document(rectangle_sketch()); + let feature = sketch_mode::default_extrude_feature(id); + let mut cache = super::FeatureCache::new(); + let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { + panic!("a registered sketch yields an evaluated preview"); + }; + let Some(solid) = preview.solid() else { + panic!("the rectangle extrudes to a solid"); + }; + let Ok(view) = super::build_solid_view(solid) else { + panic!("the solid tessellates into a renderable view"); + }; + let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256)); + let region = ViewportRegion::at_origin(extent); + let camera = frame_standard_view(view.aabb, extent, StandardView::Isometric, None).ok(); + assert!( + camera.is_some(), + "the solid aabb frames an isometric camera" + ); + assert!( + super::preview_solid_frame(Some(&view), camera, region).is_some(), + "a framed preview lowers to a solid frame view", + ); + assert!( + super::preview_solid_frame(Some(&view), None, region).is_none(), + "without a camera there is nothing to frame", + ); + } + + fn layout_rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { + LayoutRect::new( + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), + LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)), + ) + } + + #[test] + fn solid_viewport_region_offsets_inside_the_surface() { + let surface = ViewportExtent::new(ViewportPx::new(1280), ViewportPx::new(800)); + let Some(region) = + super::solid_viewport_region(layout_rect(320.0, 96.0, 800.0, 600.0), surface) + else { + panic!("an inset viewport yields a region"); + }; + assert_eq!( + region.scissor(), + (320, 96, 800, 600), + "the region carries the viewport offset and size, not the whole window", + ); + } + + #[test] + fn solid_viewport_region_clamps_to_the_surface() { + let surface = ViewportExtent::new(ViewportPx::new(640), ViewportPx::new(480)); + let Some(region) = + super::solid_viewport_region(layout_rect(600.0, 400.0, 400.0, 400.0), surface) + else { + panic!("a partly off-surface viewport still yields a clamped region"); + }; + let (x, y, w, h) = region.scissor(); + assert!( + x + w <= 640 && y + h <= 480, + "the scissor never runs past the surface: {x}+{w}, {y}+{h}", + ); + } + + #[test] + fn solid_viewport_region_is_none_for_a_degenerate_viewport() { + let surface = ViewportExtent::new(ViewportPx::new(1280), ViewportPx::new(800)); + assert!(super::solid_viewport_region(layout_rect(0.0, 0.0, 0.0, 0.0), surface).is_none()); + } + + #[test] + fn viewport_local_point_centers_the_inset_viewport_not_the_window() { + let region = ViewportRegion::new( + ViewportPx::new(282), + ViewportPx::new(120), + ViewportExtent::new(ViewportPx::new(998), ViewportPx::new(636)), + ); + let center = WindowPoint::new(282.0 + 499.0, 120.0 + 318.0); + let Some(local) = super::viewport_local_point(center, region) else { + panic!("a cursor inside the surface yields a viewport-local point"); + }; + assert!( + (local.x() - 499.0).abs() < 1e-9 && (local.y() - 318.0).abs() < 1e-9, + "a cursor at the inset viewport center maps to the region-local center: ({}, {})", + local.x(), + local.y(), + ); + } + + #[test] + fn drag_gesture_maps_modifiers_to_orbit_pan_zoom_roll() { + assert_eq!(super::drag_gesture(ModifierMask::NONE), NavGesture::Orbit); + assert_eq!(super::drag_gesture(ModifierMask::CTRL), NavGesture::Pan); + assert_eq!(super::drag_gesture(ModifierMask::SHIFT), NavGesture::Zoom); + assert_eq!(super::drag_gesture(ModifierMask::ALT), NavGesture::Roll); + assert_eq!( + super::drag_gesture(ModifierMask::CTRL | ModifierMask::SHIFT), + NavGesture::Pan, + "ctrl outranks shift so a held ctrl always pans", + ); + } + + #[test] + fn plane_pick_from_idle_enters_sketch_for_known_plane() { + let frame = shell::ShellFrame { + plane_picked: Some(Plane::Xy), + ..empty_frame() + }; + let next = next_mode(Mode::Idle, &frame, false, &xy_only()); + assert_eq!(next, Mode::enter_sketch(SketchId::default())); + } + + #[test] + fn plane_pick_from_idle_with_no_sketch_for_plane_stays_idle() { + let frame = shell::ShellFrame { + plane_picked: Some(Plane::Yz), + ..empty_frame() + }; + let next = next_mode(Mode::Idle, &frame, false, &xy_only()); + assert_eq!(next, Mode::Idle); + } + + #[test] + fn plane_pick_while_in_sketch_keeps_current_mode() { + let prev = Mode::enter_sketch(SketchId::default()); + let frame = shell::ShellFrame { + plane_picked: Some(Plane::Xy), + ..empty_frame() + }; + assert_eq!(next_mode(prev.clone(), &frame, false, &xy_only()), prev); + } + + #[test] + fn ribbon_exit_returns_idle() { + let prev = Mode::enter_sketch(SketchId::default()); + let frame = shell::ShellFrame { + exit_sketch: true, + ..empty_frame() + }; + assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); + } + + #[test] + fn exit_sketch_action_returns_idle() { + let prev = Mode::enter_sketch(SketchId::default()); + assert_eq!( + next_mode(prev, &empty_frame(), true, &xy_only()), + Mode::Idle + ); + } + + #[test] + fn escape_with_pending_clears_pending_keeps_sketch_and_tool() { + let prev = Mode::Sketch { + sketch_id: SketchId::default(), + session: Box::new(SketchSession { + tool: Some(SketchTool::Line), + pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm( + 1.0, 2.0, + )))), + ..SketchSession::default() + }), + }; + let next = next_mode(prev, &empty_frame(), true, &xy_only()); + let Mode::Sketch { session, .. } = next else { + panic!("escape with pending must keep sketch mode"); + }; + assert_eq!(session.tool, Some(SketchTool::Line)); + assert_eq!(session.pending, None); + } + + fn far_camera() -> Camera2 { + Camera2::new(ViewportExtent::new( + ViewportPx::new(800), + ViewportPx::new(600), + )) + .with_zoom(PixelsPerMm::new(1_000_000.0)) + } + + #[test] + fn build_preview_in_idle_is_empty() { + let document = Document::new(DocumentId::default(), "doc".to_owned()); + let preview = build_preview( + &Mode::Idle, + &document, + Some(Point2::from_mm(1.0, 1.0)), + &far_camera(), + ); + assert!(preview.is_empty()); + } + + #[test] + fn build_preview_without_armed_tool_is_empty() { + let document = Document::new(DocumentId::default(), "doc".to_owned()); + let mode = Mode::enter_sketch(SketchId::default()); + let preview = build_preview( + &mode, + &document, + Some(Point2::from_mm(0.0, 0.0)), + &far_camera(), + ); + assert!(preview.is_empty()); + } + + #[test] + fn build_preview_with_position_pending_emits_anchor_and_segment() { + let sketch = Sketch::new(Plane::Xy.basis()); + let (document, sketch_id) = initial_document(sketch); + let anchor = Point2::from_mm(2.0, 3.0); + let mode = Mode::Sketch { + sketch_id, + session: Box::new(SketchSession { + tool: Some(SketchTool::Line), + pending: Some(Pending::First(ClickAnchor::Position(anchor))), + ..SketchSession::default() + }), + }; + let cursor = Point2::from_mm(5.0, 7.0); + let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); + assert_eq!(preview.anchors, vec![anchor]); + assert_eq!(preview.segments, vec![(anchor, cursor)]); + } + + #[test] + fn build_preview_with_endpoint_pending_resolves_via_document() { + let sketch = Sketch::new(Plane::Xy.basis()); + let target = Point2::from_mm(-4.0, 6.0); + let (sketch, endpoint) = tools::add_point(sketch, target); + let (document, sketch_id) = initial_document(sketch); + let mode = Mode::Sketch { + sketch_id, + session: Box::new(SketchSession { + tool: Some(SketchTool::Line), + pending: Some(Pending::First(ClickAnchor::Endpoint(endpoint))), + ..SketchSession::default() + }), + }; + let cursor = Point2::from_mm(0.0, 0.0); + let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); + assert_eq!(preview.anchors, vec![target]); + assert_eq!(preview.segments, vec![(target, cursor)]); + } + + #[test] + fn build_preview_keeps_anchor_when_cursor_outside_viewport() { + let sketch = Sketch::new(Plane::Xy.basis()); + let (document, sketch_id) = initial_document(sketch); + let anchor = Point2::from_mm(1.0, 1.0); + let mode = Mode::Sketch { + sketch_id, + session: Box::new(SketchSession { + tool: Some(SketchTool::Line), + pending: Some(Pending::First(ClickAnchor::Position(anchor))), + ..SketchSession::default() + }), + }; + let preview = build_preview(&mode, &document, None, &far_camera()); + assert_eq!(preview.anchors, vec![anchor]); + assert!(preview.segments.is_empty()); + } + + #[test] + fn build_preview_during_drag_is_empty() { + let document = Document::new(DocumentId::default(), "doc".to_owned()); + let mode = Mode::Sketch { + sketch_id: SketchId::default(), + session: Box::new(SketchSession { + tool: Some(SketchTool::Line), + pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm( + 0.0, 0.0, + )))), + drag: Some(DragSession { + entity: bone_types::SketchEntityId::default(), + press: Point2::origin(), + pins: DragPins::from_array([ + Some((bone_types::SketchEntityId::default(), Point2::origin())), + None, + None, + ]), + }), + ..SketchSession::default() + }), + }; + let preview = build_preview( + &mode, + &document, + Some(Point2::from_mm(1.0, 1.0)), + &far_camera(), + ); + assert!(preview.is_empty()); + } + + #[test] + fn build_preview_circle_emits_ghost_circle() { + let sketch = Sketch::new(Plane::Xy.basis()); + let (document, sketch_id) = initial_document(sketch); + let center = Point2::from_mm(0.0, 0.0); + let mode = Mode::Sketch { + sketch_id, + session: Box::new(SketchSession { + tool: Some(SketchTool::Circle), + pending: Some(Pending::First(ClickAnchor::Position(center))), + ..SketchSession::default() + }), + }; + let cursor = Point2::from_mm(3.0, 4.0); + let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); + assert_eq!(preview.anchors, vec![center]); + assert_eq!(preview.circles.len(), 1); + let r = preview.circles[0].radius.get::(); + assert!((r - 5.0).abs() < 1e-9, "r={r}"); + } + + #[test] + fn build_preview_corner_rectangle_emits_four_segments() { + let sketch = Sketch::new(Plane::Xy.basis()); + let (document, sketch_id) = initial_document(sketch); + let corner = Point2::from_mm(0.0, 0.0); + let mode = Mode::Sketch { + sketch_id, + session: Box::new(SketchSession { + tool: Some(SketchTool::CornerRectangle), + pending: Some(Pending::First(ClickAnchor::Position(corner))), + ..SketchSession::default() + }), + }; + let cursor = Point2::from_mm(5.0, 3.0); + let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); + assert_eq!(preview.anchors, vec![corner]); + assert_eq!(preview.segments.len(), 4); + } + + #[test] + fn build_preview_tangent_arc_emits_ghost_arc_after_endpoint_click() { + let sketch = Sketch::new(Plane::Xy.basis()); + let (sketch, a) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); + let (sketch, b) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); + let (sketch, _) = tools::add_line(sketch, a, b, false); + let (document, sketch_id) = initial_document(sketch); + let mode = Mode::Sketch { + sketch_id, + session: Box::new(SketchSession { + tool: Some(SketchTool::TangentArc), + pending: Some(Pending::First(ClickAnchor::Endpoint(b))), + ..SketchSession::default() + }), + }; + let cursor = Point2::from_mm(10.0, 6.0); + let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); + assert_eq!(preview.anchors.len(), 1, "start anchor visible"); + assert_eq!(preview.arcs.len(), 1, "ghost arc emitted"); + } + + #[test] + fn build_preview_centerpoint_arc_emits_ghost_arc_after_two_clicks() { + let sketch = Sketch::new(Plane::Xy.basis()); + let (document, sketch_id) = initial_document(sketch); + let center = Point2::from_mm(0.0, 0.0); + let start = Point2::from_mm(5.0, 0.0); + let mode = Mode::Sketch { + sketch_id, + session: Box::new(SketchSession { + tool: Some(SketchTool::CenterpointArc), + pending: Some(Pending::Second( + ClickAnchor::Position(center), + ClickAnchor::Position(start), + )), + ..SketchSession::default() + }), + }; + let cursor = Point2::from_mm(0.0, 5.0); + let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); + assert_eq!(preview.arcs.len(), 1); + assert_eq!(preview.anchors.len(), 2); + } + + #[test] + fn escape_with_armed_tool_no_pending_disarms_tool() { + let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); + let next = next_mode(prev, &empty_frame(), true, &xy_only()); + let Mode::Sketch { session, .. } = next else { + panic!("escape with armed tool must keep sketch mode"); + }; + assert_eq!(session.tool, None); + assert_eq!(session.pending, None); + } + + #[test] + fn clicking_active_tool_disarms_it() { + let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); + let frame = shell::ShellFrame { + activated_tool: Some(SketchTool::Line), + ..empty_frame() + }; + let next = next_mode(prev, &frame, false, &xy_only()); + let Mode::Sketch { session, .. } = next else { + panic!("expected sketch mode"); + }; + assert_eq!(session.tool, None); + } + + #[test] + fn clicking_different_tool_swaps() { + let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); + let frame = shell::ShellFrame { + activated_tool: Some(SketchTool::Point), + ..empty_frame() + }; + let next = next_mode(prev, &frame, false, &xy_only()); + let Mode::Sketch { session, .. } = next else { + panic!("expected sketch mode"); + }; + assert_eq!(session.tool, Some(SketchTool::Point)); + } + + #[test] + fn ribbon_exit_overrides_pending_chain() { + let prev = Mode::Sketch { + sketch_id: SketchId::default(), + session: Box::new(SketchSession { + tool: Some(SketchTool::Line), + pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm( + 0.0, 0.0, + )))), + ..SketchSession::default() + }), + }; + let frame = shell::ShellFrame { + exit_sketch: true, + ..empty_frame() + }; + assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); + } + + #[test] + fn tool_in_idle_does_not_promote_to_sketch() { + let frame = shell::ShellFrame { + activated_tool: Some(SketchTool::Line), + ..empty_frame() + }; + assert_eq!(next_mode(Mode::Idle, &frame, false, &xy_only()), Mode::Idle); + } + + #[test] + fn tool_in_sketch_arms_session() { + let prev = Mode::enter_sketch(SketchId::default()); + let frame = shell::ShellFrame { + activated_tool: Some(SketchTool::Line), + ..empty_frame() + }; + let Mode::Sketch { session, .. } = next_mode(prev, &frame, false, &xy_only()) else { + panic!("expected sketch mode"); + }; + assert_eq!(session.tool, Some(SketchTool::Line)); + } + + #[test] + fn plane_pick_then_tool_enters_and_arms_in_one_frame() { + let frame = shell::ShellFrame { + plane_picked: Some(Plane::Xy), + activated_tool: Some(SketchTool::Line), + ..empty_frame() + }; + let Mode::Sketch { session, .. } = next_mode(Mode::Idle, &frame, false, &xy_only()) else { + panic!("expected sketch mode"); + }; + assert_eq!(session.tool, Some(SketchTool::Line)); + } + + fn doc_with_default_sketch() -> (Document, SketchId) { + let sketch = bone_document::Sketch::new(Plane::Xy.basis()); + let mut document = Document::new(DocumentId::default(), "Untitled".to_owned()); + let id = SketchId::default(); + document.insert_sketch(id, "Sketch1".to_owned(), sketch); + (document, id) + } + + #[test] + fn apply_sketch_rename_into_writes_label_and_records_undo_on_change() { + let (mut document, id) = doc_with_default_sketch(); + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); + apply_sketch_rename_into( + &mut document, + &mut undo, + shell::SketchRenameRequest { + id, + label: "Profile".to_owned(), + }, + ); + assert_eq!(document.sketch_label(id), Some("Profile")); + assert_eq!( + undo.past_len(), + 1, + "successful rename records one undo snapshot" + ); + } + + #[test] + fn apply_sketch_rename_into_drops_empty_label_without_undo() { + let (mut document, id) = doc_with_default_sketch(); + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); + apply_sketch_rename_into( + &mut document, + &mut undo, + shell::SketchRenameRequest { + id, + label: " ".to_owned(), + }, + ); + assert_eq!(document.sketch_label(id), Some("Sketch1")); + assert_eq!(undo.past_len(), 0); + } + + #[test] + fn apply_sketch_rename_into_skips_no_op_against_trimmed_match() { + let (mut document, id) = doc_with_default_sketch(); + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); + apply_sketch_rename_into( + &mut document, + &mut undo, + shell::SketchRenameRequest { + id, + label: " Sketch1 ".to_owned(), + }, + ); + assert_eq!(document.sketch_label(id), Some("Sketch1")); + assert_eq!( + undo.past_len(), + 0, + "trimmed-equal rename must not record undo" + ); + } + + fn extrude_node_count(document: &Document) -> usize { + document + .feature_tree() + .iter() + .filter(|(_, node)| matches!(node, FeatureNode::Extrude(_))) + .count() + } + + #[test] + fn commit_armed_extrude_on_accept_adds_node_and_records_undo() { + let (mut document, sketch) = doc_with_default_sketch(); + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); + let mode = Mode::Extrude(ExtrudeArming::profile(sketch)); + commit_armed_extrude( + &mut document, + &mut undo, + &mode, + Some(shell::ConfirmAction::Accept), + ); + assert_eq!(extrude_node_count(&document), 1); + assert_eq!(undo.past_len(), 1); + } + + #[test] + fn commit_armed_extrude_ignores_cancel_and_non_extrude_mode() { + let (mut document, sketch) = doc_with_default_sketch(); + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); + commit_armed_extrude( + &mut document, + &mut undo, + &Mode::Extrude(ExtrudeArming::profile(sketch)), + Some(shell::ConfirmAction::Cancel), + ); + commit_armed_extrude( + &mut document, + &mut undo, + &Mode::Idle, + Some(shell::ConfirmAction::Accept), + ); + assert_eq!(extrude_node_count(&document), 0); + assert_eq!(undo.past_len(), 0); + } + + #[test] + fn commit_armed_extrude_edit_target_updates_in_place_keeping_label() { + let (mut document, sketch) = doc_with_default_sketch(); + let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); + let Ok(()) = document.rename_extrude(id, "Boss") else { + panic!("rename accepts"); + }; + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); + let mode = Mode::Extrude(ExtrudeArming::edit( + id, + sketch_mode::default_extrude_feature(sketch), + )); + commit_armed_extrude( + &mut document, + &mut undo, + &mode, + Some(shell::ConfirmAction::Accept), + ); + assert_eq!(extrude_node_count(&document), 1, "editing reuses the node"); + assert_eq!(document.extrude_label(id), Some("Boss")); + } + + #[test] + fn extrude_edit_mode_arms_from_idle_but_not_from_sketch() { + let (mut document, sketch) = doc_with_default_sketch(); + let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); + let from_idle = extrude_edit_mode(&document, &Mode::Idle, Some(id)); + assert!(matches!( + from_idle, + Some(Mode::Extrude(ExtrudeArming::Profile { target: Some(t), .. })) if t == id + )); + assert_eq!( + extrude_edit_mode(&document, &Mode::enter_sketch(sketch), Some(id)), + None, + "double-click is inert while sketching", + ); + assert_eq!( + extrude_edit_mode(&document, &Mode::Idle, Some(ExtrudeId::default())), + None, + "unknown extrude id arms nothing", + ); + } + + #[test] + fn active_solid_feature_tracks_mode_then_falls_back_to_committed() { + let (mut document, sketch) = doc_with_default_sketch(); + let armed = sketch_mode::default_extrude_feature(sketch); + assert_eq!( + active_solid_feature( + &Mode::Extrude(ExtrudeArming::profile(sketch)), + &document, + None, + ), + Some(armed), + "an armed profile previews its own feature", + ); + assert_eq!( + active_solid_feature(&Mode::enter_sketch(sketch), &document, None), + None, + "sketching shows the 2D scene, not a solid", + ); + assert_eq!( + active_solid_feature(&Mode::Idle, &document, None), + None, + "idle with no committed extrude shows no solid", + ); + let _ = document.commit_extrude(armed); + assert_eq!( + active_solid_feature(&Mode::Idle, &document, None), + Some(armed), + "idle falls back to the committed extrude", + ); + } + + #[test] + fn framed_extrude_overrides_last_committed_in_idle() { + let (mut document, sketch) = doc_with_default_sketch(); + let first_feature = sketch_mode::default_extrude_feature(sketch); + let mut second_feature = first_feature; + second_feature.merge_result = bone_document::MergeResult::Separate; + let first = document.commit_extrude(first_feature); + let _second = document.commit_extrude(second_feature); + assert_eq!( + active_solid_feature(&Mode::Idle, &document, None), + Some(second_feature), + "with no framed id, idle frames the last-committed extrude", + ); + assert_eq!( + active_solid_feature(&Mode::Idle, &document, Some(first)), + Some(first_feature), + "a framed id wins over the tree tip, so editing a non-last extrude stays framed", + ); + assert_eq!( + active_solid_feature(&Mode::Idle, &document, Some(ExtrudeId::default())), + Some(second_feature), + "a stale framed id self-heals to the last-committed extrude", + ); + } + + #[test] + fn apply_extrude_rename_into_writes_label_and_records_undo() { + let (mut document, sketch) = doc_with_default_sketch(); + let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); + apply_extrude_rename_into( + &mut document, + &mut undo, + shell::ExtrudeRenameRequest { + id, + label: "Boss".to_owned(), + }, + ); + assert_eq!(document.extrude_label(id), Some("Boss")); + assert_eq!(undo.past_len(), 1); + } + + #[test] + fn sketch_activated_from_idle_enters_that_sketch_without_plane_map() { + let sketch_id = SketchId::default(); + let frame = shell::ShellFrame { + sketch_activated: Some(sketch_id), + ..empty_frame() + }; + let next = next_mode(Mode::Idle, &frame, false, &BTreeMap::new()); + assert_eq!(next, Mode::enter_sketch(sketch_id)); + } + + #[test] + fn sketch_pick_in_extrude_sets_profile_instead_of_editing() { + let sketch_id = SketchId::default(); + let frame = shell::ShellFrame { + sketch_activated: Some(sketch_id), + ..empty_frame() + }; + assert_eq!( + next_mode( + Mode::Extrude(ExtrudeArming::AwaitingSketch), + &frame, + false, + &xy_only(), + ), + Mode::Extrude(ExtrudeArming::profile(sketch_id)), + ); + assert_eq!( + next_mode( + Mode::Extrude(ExtrudeArming::profile(sketch_id)), + &frame, + false, + &xy_only(), + ), + Mode::Extrude(ExtrudeArming::profile(sketch_id)), + "a pick while armed re-targets the profile, never drops into sketch editing", + ); + } + + #[test] + fn sketch_activated_while_in_sketch_is_ignored() { + let prev = Mode::enter_sketch(SketchId::default()); + let frame = shell::ShellFrame { + sketch_activated: Some(SketchId::default()), + ..empty_frame() + }; + assert_eq!(next_mode(prev.clone(), &frame, false, &xy_only()), prev); + } + + #[test] + fn exit_action_wins_over_pending_tool() { + let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); + let frame = shell::ShellFrame { + exit_sketch: true, + ..empty_frame() + }; + assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); + } + + #[test] + fn idle_scopes_omit_sketch_scope() { + let scopes = scopes_for_mode(&Mode::Idle); + let collected: Vec<_> = scopes.innermost_first().copied().collect(); + assert!(!collected.contains(&HotkeyScope::Sketch)); + assert!(collected.contains(&HotkeyScope::Global)); + } + + #[test] + fn sketch_scopes_include_sketch_scope() { + let scopes = scopes_for_mode(&Mode::enter_sketch(SketchId::default())); + let collected: Vec<_> = scopes.innermost_first().copied().collect(); + assert!(collected.contains(&HotkeyScope::Sketch)); + assert!(collected.contains(&HotkeyScope::Global)); + } + + #[test] + fn hotkey_table_binds_escape_to_exit_under_sketch_scope() { + let table = build_hotkey_table(); + let chord = KeyChord::new(UiKeyCode::Named(NamedKey::Escape), ModifierMask::NONE); + let in_sketch = scopes_for_mode(&Mode::enter_sketch(SketchId::default())); + assert_eq!( + table.dispatch(chord, &in_sketch), + Some(sketch_mode::ESCAPE_ACTION) + ); + let in_idle = scopes_for_mode(&Mode::Idle); + assert_eq!(table.dispatch(chord, &in_idle), None); + } + + #[test] + fn extrude_scope_isolates_escape_from_sketch_tools() { + let extrude = scopes_for_mode(&Mode::Extrude(ExtrudeArming::AwaitingSketch)); + let in_extrude: Vec<_> = extrude.innermost_first().copied().collect(); + assert!(in_extrude.contains(&HotkeyScope::Extrude)); + assert!( + !in_extrude.contains(&HotkeyScope::Sketch), + "extrude must not activate the sketch-tool scope", + ); + let sketch = scopes_for_mode(&Mode::enter_sketch(SketchId::default())); + let in_sketch: Vec<_> = sketch.innermost_first().copied().collect(); + assert!(!in_sketch.contains(&HotkeyScope::Extrude)); + } + + #[test] + fn hotkey_table_binds_escape_under_extrude_scope() { + let table = build_hotkey_table(); + let chord = KeyChord::new(UiKeyCode::Named(NamedKey::Escape), ModifierMask::NONE); + let in_extrude = scopes_for_mode(&Mode::Extrude(ExtrudeArming::AwaitingSketch)); + assert_eq!( + table.dispatch(chord, &in_extrude), + Some(sketch_mode::ESCAPE_ACTION) + ); + } + + #[test] + fn escape_exits_extrude_to_idle() { + let frame = empty_frame(); + let awaiting = Mode::Extrude(ExtrudeArming::AwaitingSketch); + assert_eq!(next_mode(awaiting, &frame, true, &xy_only()), Mode::Idle); + let profiled = Mode::Extrude(ExtrudeArming::profile(SketchId::default())); + assert_eq!(next_mode(profiled, &frame, true, &xy_only()), Mode::Idle); + } + + #[test] + fn cancel_pending_or_exit_drops_extrude_arming() { + assert_eq!( + cancel_pending_or_exit(Mode::Extrude(ExtrudeArming::AwaitingSketch)), + Mode::Idle + ); + } + + #[test] + fn driven_with_value_promotes_kind_and_overwrites_value() { + let proto = SketchDimension::Linear { + a: bone_types::SketchEntityId::default(), + b: bone_types::SketchEntityId::default(), + value: Length::new::(8.0), + kind: DimensionKind::Driving, + }; + let measured = DimensionValue::Length(Length::new::(10.0)); + let Some(driven) = driven_with_value(proto, measured) else { + panic!("driven_with_value rejected matched-kind value"); + }; + assert_eq!(driven.kind(), DimensionKind::Driven); + let DimensionValue::Length(length) = driven.value() else { + panic!("expected Length"); + }; + assert!((length.get::() - 10.0).abs() < 1e-9); + } + + #[test] + fn driven_with_value_rejects_kind_mismatch() { + let proto = SketchDimension::Linear { + a: bone_types::SketchEntityId::default(), + b: bone_types::SketchEntityId::default(), + value: Length::new::(1.0), + kind: DimensionKind::Driving, + }; + let bad = DimensionValue::Angle(bone_types::Angle::new::(1.0)); + assert!(driven_with_value(proto, bad).is_none()); + } + + #[test] + fn dim_conflict_pending_returns_proto_when_set() { + let proto = SketchDimension::Linear { + a: bone_types::SketchEntityId::default(), + b: bone_types::SketchEntityId::default(), + value: Length::new::(2.0), + kind: DimensionKind::Driving, + }; + let pending = PendingDimension { + proto, + anchor: Point2::origin(), + }; + let mode = Mode::enter_sketch(SketchId::default()).start_dim_conflict(pending); + assert_eq!(dim_conflict_pending(&mode), Some(pending)); + } + + #[test] + fn dim_conflict_pending_returns_none_in_idle() { + assert_eq!(dim_conflict_pending(&Mode::Idle), None); + } + + fn horizontal_line_fixture() -> ( + Sketch, + bone_types::SketchEntityId, + bone_types::SketchEntityId, + bone_types::SketchEntityId, + ) { + let sketch = Sketch::new(Plane::Xy.basis()); + let (sketch, a) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); + let (sketch, b) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); + let (sketch, line) = tools::add_line(sketch, a, b, false); + let sketch = tools::add_relation(sketch, SketchRelation::Horizontal(line)); + let sketch = tools::add_relation(sketch, SketchRelation::Fix(a)); + (sketch, a, b, line) + } + + fn point_at(sketch: &Sketch, id: bone_types::SketchEntityId) -> Point2 { + let SketchEntity::Point(p) = sketch.entities()[id] else { + panic!("expected point entity"); + }; + p.at() + } + + #[test] + fn drag_resolved_translates_endpoint_and_preserves_horizontal() { + let (sketch, a, b, _) = horizontal_line_fixture(); + let drag = DragSession { + entity: b, + press: Point2::from_mm(10.0, 0.0), + pins: DragPins::from_array([Some((b, Point2::from_mm(10.0, 0.0))), None, None]), + }; + let cursor = Point2::from_mm(13.0, 0.0); + let Some(next) = drag_resolved(&sketch, drag, cursor) else { + panic!("solve_with_drag_pins must converge on horizontal line") + }; + let (bx, by) = point_at(&next, b).coords_mm(); + assert!((bx - 13.0).abs() < 1e-6, "b.x slides under cursor: {bx}"); + assert!(by.abs() < 1e-6, "horizontal preserved: by={by}"); + let (ax, ay) = point_at(&next, a).coords_mm(); + assert!(ax.abs() < 1e-9, "fixed a.x stays put: {ax}"); + assert!(ay.abs() < 1e-9, "fixed a.y stays put: {ay}"); + } + + #[test] + fn mirror_axis_reflects_across_horizontal_x_axis() { + let axis = MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); + let (rx, ry) = axis.reflect(Point2::from_mm(3.0, 4.0)).coords_mm(); + assert!((rx - 3.0).abs() < 1e-9, "x preserved across x-axis"); + assert!((ry - -4.0).abs() < 1e-9, "y inverted across x-axis"); + } + + #[test] + fn mirror_axis_reflects_across_diagonal() { + let axis = MirrorAxis::from_points(Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 1.0)); + let (rx, ry) = axis.reflect(Point2::from_mm(2.0, 0.0)).coords_mm(); + assert!((rx - 0.0).abs() < 1e-9, "x reflects to y on y=x diagonal"); + assert!((ry - 2.0).abs() < 1e-9, "y reflects to x on y=x diagonal"); + } + + #[test] + fn mirror_axis_detects_degenerate_zero_length() { + let axis = MirrorAxis::from_points(Point2::from_mm(1.0, 1.0), Point2::from_mm(1.0, 1.0)); + assert!( + axis.is_degenerate(), + "coincident endpoints must be degenerate" + ); + } + + #[test] + fn mirror_targets_creates_reflected_circle_with_symmetric_relations() { + let (sketch, _, _, axis_line) = horizontal_line_fixture(); + let (sketch, center) = tools::add_point(sketch, Point2::from_mm(0.0, 3.0)); + let (sketch, circle_id) = + tools::add_circle(sketch, center, Length::new::(1.0), false); + let axis_geom = + MirrorAxis::from_points(Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 0.0)); + let source_ids: std::collections::BTreeSet<_> = [center, circle_id].into_iter().collect(); + let Ok(mirrored) = mirror_targets(sketch.clone(), &source_ids, axis_line, &axis_geom) + else { + panic!("circle mirror must succeed"); + }; + let new_circles: Vec<_> = mirrored + .entities() + .iter() + .filter_map(|(_, e)| match *e { + SketchEntity::Circle(c) => Some(c), + _ => None, + }) + .collect(); + assert_eq!(new_circles.len(), 2, "original + mirrored circle"); + let new_center_pos = new_circles + .iter() + .map(|c| { + let SketchEntity::Point(p) = mirrored.entities()[c.center()] else { + panic!("circle center is a point"); + }; + p.at().coords_mm() + }) + .find(|(_, y)| *y < 0.0); + let Some((cx, cy)) = new_center_pos else { + panic!("mirrored circle must lie below x-axis"); + }; + assert!(cx.abs() < 1e-9 && (cy + 3.0).abs() < 1e-9, "({cx}, {cy})"); + let symmetric_count = mirrored + .relations() + .iter() + .filter( + |(_, r)| matches!(r, SketchRelation::Symmetric { axis, .. } if *axis == axis_line), + ) + .count(); + assert!( + symmetric_count >= 1, + "mirror must emit at least one Symmetric relation tied to the axis", + ); + } + + #[test] + fn mirror_copies_horizontal_relation_to_mirrored_line() { + use bone_document::SketchEdit; + let sketch = Sketch::new(Plane::Xy.basis()); + let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0)); + let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0)); + let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true); + let (sketch, source_p0) = tools::add_point(sketch, Point2::from_mm(-2.0, 3.0)); + let (sketch, source_p1) = tools::add_point(sketch, Point2::from_mm(2.0, 3.0)); + let (sketch, source_line) = tools::add_line(sketch, source_p0, source_p1, false); + let Ok((sketch, _)) = sketch.apply(SketchEdit::AddRelation(SketchRelation::Horizontal( + source_line, + ))) else { + panic!("seed Horizontal must apply"); + }; + let axis_geom = + MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); + let source_ids: std::collections::BTreeSet<_> = + [source_p0, source_p1, source_line].into_iter().collect(); + let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else { + panic!("mirror must succeed"); + }; + let horizontal_lines: Vec<_> = mirrored + .relations() + .iter() + .filter_map(|(_, r)| match r { + SketchRelation::Horizontal(id) => Some(*id), + _ => None, + }) + .collect(); + assert_eq!( + horizontal_lines.len(), + 2, + "original + mirrored horizontal must both exist" + ); + } + + #[test] + fn construction_toggle_flips_line_flag() { + let (sketch, _, _, line) = horizontal_line_fixture(); + let before = match sketch.entities()[line] { + SketchEntity::Line(l) => l.for_construction(), + _ => panic!("line"), + }; + let Ok((next, _)) = sketch.apply(SketchEdit::SetConstruction { + id: line, + for_construction: !before, + }) else { + panic!("set construction must succeed"); + }; + let after = match next.entities()[line] { + SketchEntity::Line(l) => l.for_construction(), + _ => panic!("line"), + }; + assert_ne!(before, after); + } + + #[test] + fn mirror_axis_detects_on_axis_point() { + let axis = MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); + assert!(axis.is_on_axis(Point2::from_mm(2.0, 0.0))); + assert!(axis.is_on_axis(Point2::from_mm(-5.0, 0.0))); + assert!(!axis.is_on_axis(Point2::from_mm(2.0, 0.5))); + } + + #[test] + fn mirror_on_axis_source_point_is_identity_no_self_symmetric() { + let sketch = Sketch::new(Plane::Xy.basis()); + let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0)); + let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0)); + let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true); + let (sketch, on_axis_point) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); + let axis_geom = + MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); + let source_ids: std::collections::BTreeSet<_> = [on_axis_point].into_iter().collect(); + let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else { + panic!("mirror must succeed"); + }; + let point_count = mirrored + .entities() + .iter() + .filter(|(_, e)| matches!(e, SketchEntity::Point(_))) + .count(); + assert_eq!( + point_count, 3, + "on-axis source must not produce a duplicate point: {point_count}" + ); + let symmetric_count = mirrored + .relations() + .iter() + .filter(|(_, r)| matches!(r, SketchRelation::Symmetric { .. })) + .count(); + assert_eq!( + symmetric_count, 0, + "on-axis source must not emit a self-pair Symmetric relation" + ); + } + + #[test] + fn mirror_copies_relation_referencing_axis_line() { + use bone_document::SketchEdit; + let sketch = Sketch::new(Plane::Xy.basis()); + let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0)); + let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0)); + let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true); + let (sketch, off_a) = tools::add_point(sketch, Point2::from_mm(-2.0, 3.0)); + let (sketch, off_b) = tools::add_point(sketch, Point2::from_mm(2.0, 3.0)); + let (sketch, off_line) = tools::add_line(sketch, off_a, off_b, false); + let Ok((sketch, _)) = sketch.apply(SketchEdit::AddRelation(SketchRelation::Parallel( + off_line, axis_line, + ))) else { + panic!("seed Parallel(off_line, axis_line) must apply"); + }; + let axis_geom = + MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); + let source_ids: std::collections::BTreeSet<_> = + [off_a, off_b, off_line].into_iter().collect(); + let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else { + panic!("mirror must succeed"); + }; + let parallel_count = mirrored + .relations() + .iter() + .filter(|(_, r)| matches!(r, SketchRelation::Parallel(_, b) if *b == axis_line)) + .count(); + assert_eq!( + parallel_count, 2, + "original + mirrored Parallel(line, axis_line) both expected: {parallel_count}", + ); + } +} diff --git a/crates/bone-app/src/clock.rs b/crates/bone-app/src/clock.rs new file mode 100644 index 0000000..625c068 --- /dev/null +++ b/crates/bone-app/src/clock.rs @@ -0,0 +1,115 @@ +use core::num::NonZeroU32; +use std::time::Duration; + +use bone_ui::input::FrameInstant; +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "u32", into = "u32")] +pub struct FrameCount(NonZeroU32); + +#[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("frame count must be a positive integer, got {0}")] +pub struct ZeroFrameCount(u32); + +impl FrameCount { + pub const ONE: Self = Self(NonZeroU32::MIN); + + #[must_use] + pub const fn new(frames: NonZeroU32) -> Self { + Self(frames) + } + + #[must_use] + pub const fn get(self) -> u32 { + self.0.get() + } +} + +impl TryFrom for FrameCount { + type Error = ZeroFrameCount; + + fn try_from(value: u32) -> Result { + NonZeroU32::new(value).map(Self).ok_or(ZeroFrameCount(value)) + } +} + +impl From for u32 { + fn from(value: FrameCount) -> Self { + value.0.get() + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct FrameClock(FrameInstant); + +impl FrameClock { + pub const FIXED_STEP: Duration = Duration::from_micros(16_667); + + #[must_use] + pub const fn start() -> Self { + Self(FrameInstant::ZERO) + } + + #[must_use] + pub const fn now(self) -> FrameInstant { + self.0 + } + + pub fn feed(&mut self, now: FrameInstant) { + self.0 = self.0.max(now); + } + + pub fn advance(&mut self, frames: FrameCount) { + self.0 = self.0.after(Self::FIXED_STEP.saturating_mul(frames.get())); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frames(n: u32) -> FrameCount { + match NonZeroU32::new(n) { + Some(count) => FrameCount::new(count), + None => panic!("frame count must be nonzero"), + } + } + + #[test] + fn advance_steps_exactly_one_fixed_step_per_frame() { + let mut clock = FrameClock::start(); + clock.advance(frames(60)); + assert_eq!( + clock.now().duration(), + FrameClock::FIXED_STEP * 60, + "sixty fixed steps land on a bit-exact instant" + ); + } + + #[test] + fn feed_never_rewinds() { + let mut clock = FrameClock::start(); + clock.feed(FrameInstant::from_duration(Duration::from_millis(100))); + clock.feed(FrameInstant::from_duration(Duration::from_millis(40))); + assert_eq!( + clock.now(), + FrameInstant::from_duration(Duration::from_millis(100)), + "a stale feed must not move time backward" + ); + } + + #[test] + fn a_180ms_animation_finishes_on_frame_11() { + let finished_at = (1_u32..=20).find(|frame| { + let mut clock = FrameClock::start(); + clock.advance(frames(*frame)); + clock.now().duration() >= Duration::from_millis(180) + }); + assert_eq!( + finished_at, + Some(11), + "a 180 ms tween completes on a known frame under fixed step" + ); + } +} diff --git a/crates/bone-app/src/event.rs b/crates/bone-app/src/event.rs index 37b769f..85d571b 100644 --- a/crates/bone-app/src/event.rs +++ b/crates/bone-app/src/event.rs @@ -1,7 +1,10 @@ +use bone_app::{InputEvent, KeyDown, NavKey, ScrollDelta, WindowPoint}; +use bone_render::{ViewportExtent, ViewportPx}; +use bone_ui::input::{KeyChar, KeyCode as UiKeyCode, ModifierMask, NamedKey, PointerButton}; use winit::{ - dpi::{PhysicalPosition, PhysicalSize}, - event::{ElementState, KeyEvent, Modifiers, MouseButton, MouseScrollDelta, WindowEvent}, - keyboard::{Key, PhysicalKey, SmolStr}, + dpi::PhysicalSize, + event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent}, + keyboard::{Key, KeyCode, ModifiersState, NamedKey as WinitNamed, PhysicalKey, SmolStr}, }; pub enum AppEvent { @@ -11,43 +14,31 @@ pub enum AppEvent { Ignored, } -pub enum InputEvent { - Resize(PhysicalSize), - Focus(bool), - Modifier(Modifiers), - CursorMove(PhysicalPosition), - CursorLeft, - CursorEntered, - Pointer { - button: MouseButton, - state: ElementState, - }, - Wheel(MouseScrollDelta), - KeyDown { - physical_key: PhysicalKey, - logical_key: Key, - text: Option, - repeat: bool, - }, -} - impl AppEvent { pub fn from_winit(event: WindowEvent) -> Self { match event { WindowEvent::CloseRequested => Self::Close, WindowEvent::RedrawRequested => Self::Redraw, - WindowEvent::Resized(size) => Self::Input(InputEvent::Resize(size)), + WindowEvent::Resized(size) => Self::Input(InputEvent::Resize(viewport_extent(size))), WindowEvent::Focused(focused) => Self::Input(InputEvent::Focus(focused)), - WindowEvent::ModifiersChanged(mods) => Self::Input(InputEvent::Modifier(mods)), - WindowEvent::CursorMoved { position, .. } => { - Self::Input(InputEvent::CursorMove(position)) + WindowEvent::ModifiersChanged(mods) => { + Self::Input(InputEvent::Modifiers(modifier_mask(mods.state()))) } + WindowEvent::CursorMoved { position, .. } => Self::Input(InputEvent::CursorMove( + WindowPoint::new(position.x, position.y), + )), WindowEvent::CursorLeft { .. } => Self::Input(InputEvent::CursorLeft), WindowEvent::CursorEntered { .. } => Self::Input(InputEvent::CursorEntered), - WindowEvent::MouseInput { state, button, .. } => { - Self::Input(InputEvent::Pointer { button, state }) + WindowEvent::MouseInput { state, button, .. } => match pointer_button(button) { + Some(button) => Self::Input(InputEvent::Pointer { + button, + pressed: state == ElementState::Pressed, + }), + None => Self::Ignored, + }, + WindowEvent::MouseWheel { delta, .. } => { + Self::Input(InputEvent::Wheel(scroll_delta(delta))) } - WindowEvent::MouseWheel { delta, .. } => Self::Input(InputEvent::Wheel(delta)), WindowEvent::KeyboardInput { event: KeyEvent { @@ -59,12 +50,12 @@ impl AppEvent { .. }, .. - } => Self::Input(InputEvent::KeyDown { + } => Self::Input(InputEvent::KeyDown(key_down( physical_key, - logical_key, + &logical_key, text, repeat, - }), + ))), WindowEvent::KeyboardInput { .. } | WindowEvent::ActivationTokenDone { .. } | WindowEvent::Moved(_) @@ -86,3 +77,163 @@ impl AppEvent { } } } + +pub fn viewport_extent(size: PhysicalSize) -> ViewportExtent { + ViewportExtent::new( + ViewportPx::new(size.width.max(1)), + ViewportPx::new(size.height.max(1)), + ) +} + +fn modifier_mask(state: ModifiersState) -> ModifierMask { + let bits = [ + (state.control_key(), ModifierMask::CTRL), + (state.shift_key(), ModifierMask::SHIFT), + (state.alt_key(), ModifierMask::ALT), + (state.super_key(), ModifierMask::META), + ]; + bits.iter() + .filter(|(active, _)| *active) + .fold(ModifierMask::NONE, |acc, (_, m)| acc.union(*m)) +} + +const fn pointer_button(button: MouseButton) -> Option { + match button { + MouseButton::Left => Some(PointerButton::Primary), + MouseButton::Right => Some(PointerButton::Secondary), + MouseButton::Middle => Some(PointerButton::Middle), + MouseButton::Back | MouseButton::Forward | MouseButton::Other(_) => None, + } +} + +fn scroll_delta(delta: MouseScrollDelta) -> ScrollDelta { + match delta { + MouseScrollDelta::LineDelta(x, y) => ScrollDelta::Lines { x, y }, + MouseScrollDelta::PixelDelta(p) => ScrollDelta::Pixels { x: p.x, y: p.y }, + } +} + +fn key_down( + physical_key: PhysicalKey, + logical_key: &Key, + text: Option, + repeat: bool, +) -> KeyDown { + let physical_code = match physical_key { + PhysicalKey::Code(c) => Some(c), + PhysicalKey::Unidentified(_) => None, + }; + let logical_named = match logical_key { + Key::Named(nk) => winit_named_to_ui(*nk), + _ => None, + }; + let named = logical_named.or_else(|| physical_code.and_then(keycode_to_named)); + let code = named.map(UiKeyCode::Named).or_else(|| { + physical_code + .and_then(keycode_to_char) + .map(|c| UiKeyCode::Char(KeyChar::from_char(c))) + }); + KeyDown { + code, + nav: physical_code.and_then(keycode_to_nav), + text: text.map(|t| t.to_string()), + repeat, + } +} + +const fn keycode_to_nav(code: KeyCode) -> Option { + match code { + KeyCode::ArrowLeft => Some(NavKey::Left), + KeyCode::ArrowRight => Some(NavKey::Right), + KeyCode::ArrowUp => Some(NavKey::Up), + KeyCode::ArrowDown => Some(NavKey::Down), + KeyCode::KeyZ => Some(NavKey::Zoom), + KeyCode::Equal => Some(NavKey::ZoomIn), + KeyCode::Minus => Some(NavKey::ZoomOut), + _ => None, + } +} + +fn keycode_to_named(code: KeyCode) -> Option { + match code { + KeyCode::Tab => Some(NamedKey::Tab), + KeyCode::Enter | KeyCode::NumpadEnter => Some(NamedKey::Enter), + KeyCode::Escape => Some(NamedKey::Escape), + KeyCode::Backspace => Some(NamedKey::Backspace), + KeyCode::Delete => Some(NamedKey::Delete), + KeyCode::Space => Some(NamedKey::Space), + KeyCode::ArrowUp => Some(NamedKey::ArrowUp), + KeyCode::ArrowDown => Some(NamedKey::ArrowDown), + KeyCode::ArrowLeft => Some(NamedKey::ArrowLeft), + KeyCode::ArrowRight => Some(NamedKey::ArrowRight), + KeyCode::Home => Some(NamedKey::Home), + KeyCode::End => Some(NamedKey::End), + KeyCode::PageUp => Some(NamedKey::PageUp), + KeyCode::PageDown => Some(NamedKey::PageDown), + KeyCode::F2 => Some(NamedKey::F2), + _ => None, + } +} + +fn winit_named_to_ui(named: WinitNamed) -> Option { + match named { + WinitNamed::Tab => Some(NamedKey::Tab), + WinitNamed::Enter => Some(NamedKey::Enter), + WinitNamed::Escape => Some(NamedKey::Escape), + WinitNamed::Backspace => Some(NamedKey::Backspace), + WinitNamed::Delete => Some(NamedKey::Delete), + WinitNamed::Space => Some(NamedKey::Space), + WinitNamed::ArrowUp => Some(NamedKey::ArrowUp), + WinitNamed::ArrowDown => Some(NamedKey::ArrowDown), + WinitNamed::ArrowLeft => Some(NamedKey::ArrowLeft), + WinitNamed::ArrowRight => Some(NamedKey::ArrowRight), + WinitNamed::Home => Some(NamedKey::Home), + WinitNamed::End => Some(NamedKey::End), + WinitNamed::PageUp => Some(NamedKey::PageUp), + WinitNamed::PageDown => Some(NamedKey::PageDown), + WinitNamed::F2 => Some(NamedKey::F2), + _ => None, + } +} + +fn keycode_to_char(code: KeyCode) -> Option { + match code { + KeyCode::KeyA => Some('a'), + KeyCode::KeyB => Some('b'), + KeyCode::KeyC => Some('c'), + KeyCode::KeyD => Some('d'), + KeyCode::KeyE => Some('e'), + KeyCode::KeyF => Some('f'), + KeyCode::KeyG => Some('g'), + KeyCode::KeyH => Some('h'), + KeyCode::KeyI => Some('i'), + KeyCode::KeyJ => Some('j'), + KeyCode::KeyK => Some('k'), + KeyCode::KeyL => Some('l'), + KeyCode::KeyM => Some('m'), + KeyCode::KeyN => Some('n'), + KeyCode::KeyO => Some('o'), + KeyCode::KeyP => Some('p'), + KeyCode::KeyQ => Some('q'), + KeyCode::KeyR => Some('r'), + KeyCode::KeyS => Some('s'), + KeyCode::KeyT => Some('t'), + KeyCode::KeyU => Some('u'), + KeyCode::KeyV => Some('v'), + KeyCode::KeyW => Some('w'), + KeyCode::KeyX => Some('x'), + KeyCode::KeyY => Some('y'), + KeyCode::KeyZ => Some('z'), + KeyCode::Digit0 => Some('0'), + KeyCode::Digit1 => Some('1'), + KeyCode::Digit2 => Some('2'), + KeyCode::Digit3 => Some('3'), + KeyCode::Digit4 => Some('4'), + KeyCode::Digit5 => Some('5'), + KeyCode::Digit6 => Some('6'), + KeyCode::Digit7 => Some('7'), + KeyCode::Digit8 => Some('8'), + KeyCode::Digit9 => Some('9'), + _ => None, + } +} diff --git a/crates/bone-app/src/input.rs b/crates/bone-app/src/input.rs new file mode 100644 index 0000000..fc56daf --- /dev/null +++ b/crates/bone-app/src/input.rs @@ -0,0 +1,66 @@ +use bone_render::ViewportExtent; +use bone_ui::input::{KeyCode, ModifierMask, PointerButton}; +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct WindowPoint { + pub x: f64, + pub y: f64, +} + +impl WindowPoint { + #[must_use] + pub const fn new(x: f64, y: f64) -> Self { + Self { x, y } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum ScrollDelta { + Lines { x: f32, y: f32 }, + Pixels { x: f64, y: f64 }, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum NavKey { + Left, + Right, + Up, + Down, + Zoom, + ZoomIn, + ZoomOut, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct KeyDown { + pub code: Option, + pub nav: Option, + pub text: Option, + pub repeat: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum InputEvent { + Resize(ViewportExtent), + Focus(bool), + Modifiers(ModifierMask), + CursorMove(WindowPoint), + CursorLeft, + CursorEntered, + Pointer { + button: PointerButton, + pressed: bool, + }, + Wheel(ScrollDelta), + KeyDown(KeyDown), +} + +#[must_use = "input dispatch must be acknowledged via the redraw scheduler"] +pub struct InputDispatched(()); + +impl InputDispatched { + pub(crate) const fn after_input() -> Self { + Self(()) + } +} diff --git a/crates/bone-app/src/lib.rs b/crates/bone-app/src/lib.rs new file mode 100644 index 0000000..c0bac96 --- /dev/null +++ b/crates/bone-app/src/lib.rs @@ -0,0 +1,25 @@ +mod app; +mod chrome; +mod clock; +mod dimension_editor; +mod file_menu; +mod hotkeys; +mod input; +mod native_picker; +mod relation_tools; +mod selection; +mod settings; +mod shell; +mod shortcut_bar; +mod sketch_mode; +mod smart_dimension; +mod snap; +mod status_badge; +mod step_jobs; +mod strings; +mod tools; +mod view_cube; + +pub use app::{AppCore, FrameReport, FrameTarget, OpenError}; +pub use clock::{FrameClock, FrameCount, ZeroFrameCount}; +pub use input::{InputDispatched, InputEvent, KeyDown, NavKey, ScrollDelta, WindowPoint}; diff --git a/crates/bone-app/src/main.rs b/crates/bone-app/src/main.rs index 8b5bb56..0a3bda3 100644 --- a/crates/bone-app/src/main.rs +++ b/crates/bone-app/src/main.rs @@ -1,84 +1,23 @@ -use std::collections::BTreeMap; -use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::sync::Arc; -use bone_document::{ - DimensionKind, DimensionValue, Document, DocumentFolder, EditOutcome, EvaluatedExtrude, - ExtrudeError, ExtrudeFeature, FeatureCache, FeatureNode, LineData, Sketch, SketchDimension, - SketchEdit, SketchEntity, SketchRelation, SketchVersion, SolverError, UndoStack, -}; -use bone_render::{ - Camera2, CameraTween, ChromeInstance, ChromePipeline, ChromeTextPipeline, ConvexInstance, - ConvexPolyPipeline, DragModifiers, EdgeScene, NavGesture, PickQuery, PickedItem, PixelsPerMm, - RenderTargets, SdfGlyphInstance, SketchPreview, SketchRenderer, SketchScene, SolidFrameView, - SolidRenderer, SolidScene, StrokeInstance, StrokePipeline, Style, SurfaceContext, - ViewportExtent, ViewportNavigator, ViewportPoint, ViewportPx, ViewportRegion, frame_current, - frame_standard_view, frame_view_direction, orbit_pitch, orbit_yaw, pan_pixels, roll_by, - zoom_about_pixel, -}; -use bone_types::{ - Aabb3, Angle, AngleTolerance, BudgetCeiling, Camera3, ChordHeightTolerance, CubicEasing, - DisplayMode, DocumentId, ExtrudeId, FeatureId, GeometryGeneration, Length, Plane3, Point2, - SketchId, SketchItemId, StandardView, Vec2, ZoomFactor, -}; -use bone_ui::a11y::AccessTreeBuilder; -use bone_ui::focus::FocusManager; -use bone_ui::frame::FrameCtx; +use bone_app::{AppCore, FrameTarget, InputEvent}; +use bone_render::{PickIndex, Picker, SurfaceContext, ViewportExtent}; use bone_ui::gallery::{GALLERY_CANVAS, GalleryState, render}; -use bone_ui::hit_test::{HitFrame, HitState, resolve}; -use bone_ui::hotkey::{ActionId, HotkeyScope, HotkeyScopes, HotkeyTable}; -use bone_ui::input::{ - FrameInstant, InputSnapshot, KeyChar, KeyCode as UiKeyCode, KeyEvent as UiKeyEvent, - ModifierMask, NamedKey, PointerButton, PointerButtonMask, PointerSample, -}; -use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; +use bone_ui::input::FrameInstant; use bone_ui::raster::{PngError, encode_png, rasterize}; use bone_ui::strings::StringTable; use bone_ui::theme::{Theme, ThemeMode}; -use bone_ui::{MaskAtlas, MaskAtlasParams, Shaper}; -use swash::FontRef; use tracing_subscriber::EnvFilter; -use uom::si::angle::degree; -use uom::si::length::millimeter; use winit::{ application::ApplicationHandler, - dpi::{PhysicalPosition, PhysicalSize}, - event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}, + event::WindowEvent, event_loop::{ActiveEventLoop, ControlFlow, EventLoop}, - keyboard::{Key, KeyCode, ModifiersState, NamedKey as WinitNamed, PhysicalKey}, window::{Window, WindowId}, }; -mod chrome; -mod dimension_editor; mod event; -mod file_menu; -mod hotkeys; -mod native_picker; mod redraw; -mod relation_tools; -mod selection; -mod settings; -mod shell; -mod shortcut_bar; -mod sketch_mode; -mod smart_dimension; -mod snap; -mod status_badge; -mod step_jobs; -mod strings; -mod tools; -mod view_cube; - -use dimension_editor::{DimensionEditorAction, DimensionEditorOutcome, DimensionEditorState}; -use selection::Selection; -use sketch_mode::{ - ClickAnchor, DimensionFlow, DragPins, DragSession, ExtrudeArming, FeatureTool, Mode, Pending, - PendingDimension, Plane, SketchTool, -}; -use snap::{Anchor, SnapHit}; -use status_badge::ExtrudeStatus; #[derive(Debug, thiserror::Error)] enum AppError { @@ -109,5714 +48,213 @@ enum AppError { } 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"; -const ZOOM_STEP_PER_LINE: f64 = 1.1; -const ZOOM_STEP_PER_PIXEL: f64 = 1.0025; -const ZOOM_KEY_STEP: f64 = 1.25; -const ORBIT_KEY_STEP_DEG: f64 = 15.0; -const ORBIT_KEY_SNAP_DEG: f64 = 90.0; -const ZOOM_MIN: f64 = 0.01; -const ZOOM_MAX: f64 = 1.0e5; -const INITIAL_ZOOM_PX_PER_MM: f64 = 12.0; -const PAN_STEP_PX: f64 = 40.0; -const PAN_FAST_MULTIPLIER: f64 = 5.0; -const ZOOM_FIT_MARGIN: f64 = 0.9; -const UNDO_CAPACITY: usize = 256; -const SNAP_TOLERANCE_PX: f64 = 8.0; -const SNAP_TOLERANCE_MAX_MM: f64 = 5.0; -struct RenderState { +struct WindowSurface { surface: SurfaceContext, - renderer: SketchRenderer, - chrome_pipeline: ChromePipeline, - convex_pipeline: ConvexPolyPipeline, - stroke_pipeline: StrokePipeline, - text_pipeline: ChromeTextPipeline, - sdf_atlas: MaskAtlas, - chrome_shaper: Shaper, - sans_font: FontRef<'static>, - mono_font: FontRef<'static>, - scene: SketchScene, - camera: Camera2, - style: Style, - theme: Arc, - shell: shell::Shell, - document: Document, - plane_sketches: BTreeMap, - mode: Mode, - feature_cache: FeatureCache, - extrude_preview: Option, - solid_renderer: SolidRenderer, - solid_view: Option, - camera3: Option, - framed_extrude: Option, - navigator: ViewportNavigator, - view: view_cube::ViewUi, - focus: FocusManager, - hit_state: HitState, - hotkeys: HotkeyTable, - strings: StringTable, - viewport_rect: LayoutRect, - undo: UndoStack, - selection: Selection, - settings: settings::Settings, - dim_editor: DimensionEditorState, - dim_editor_bounds: Option, - pending_exit: bool, - current_folder: Option, - documents_root: PathBuf, - file_picker: Option, - native_picker: Option, - step_job: Option, - pending_overwrite: Option, - last_saved: Option, - pending_discard: Option, - notification: Option, - shortcut_bar: Option, -} - -#[derive(Clone, Debug, PartialEq)] -enum PendingDiscard { - New, - Open(PathBuf), - ImportStep(PathBuf), - InstallImported { - document: Box, - file_name: String, - }, -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -enum PickedVia { - NativePortal, - CustomPicker, -} - -#[derive(Clone, Debug, PartialEq)] -enum PendingOverwrite { - Document(DocumentFolder), - StepExport(PathBuf), -} - -fn modal_active(state: &RenderState) -> bool { - state.file_picker.is_some() - || state.native_picker.is_some() - || state - .step_job - .as_ref() - .is_some_and(|job| job.meta().show_progress) - || state.pending_overwrite.is_some() - || state.pending_discard.is_some() - || state.shortcut_bar.is_some() - || state.shell.state.ribbon_overflow_open.values().any(|v| *v) -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -enum NotificationKind { - Info, - Error, -} - -#[derive(Clone, Debug, PartialEq)] -struct Notification { - kind: NotificationKind, - headline: bone_ui::strings::StringKey, - detail: Option, -} - -struct InputState { - cursor_px: Option>, - left_pan: bool, - middle_pan: bool, - modifiers: ModifiersState, - pending_pressed: PointerButtonMask, - pending_released: PointerButtonMask, - pending_keys: Vec, - pending_text: String, - start: std::time::Instant, -} - -impl Default for InputState { - fn default() -> Self { - Self { - cursor_px: None, - left_pan: false, - middle_pan: false, - modifiers: ModifiersState::empty(), - pending_pressed: PointerButtonMask::EMPTY, - pending_released: PointerButtonMask::EMPTY, - pending_keys: Vec::new(), - pending_text: String::new(), - start: std::time::Instant::now(), - } - } + window: Arc, } -impl InputState { - fn panning(&self) -> bool { - self.middle_pan || (self.left_pan && self.modifiers.shift_key()) - } - - fn pan_step_px(&self) -> f64 { - if self.modifiers.shift_key() { - PAN_STEP_PX * PAN_FAST_MULTIPLIER - } else { - PAN_STEP_PX - } - } - - fn modifier_mask(&self) -> ModifierMask { - let bits = [ - (self.modifiers.control_key(), ModifierMask::CTRL), - (self.modifiers.shift_key(), ModifierMask::SHIFT), - (self.modifiers.alt_key(), ModifierMask::ALT), - (self.modifiers.super_key(), ModifierMask::META), - ]; - bits.iter() - .filter(|(active, _)| *active) - .fold(ModifierMask::NONE, |acc, (_, m)| acc.union(*m)) - } - - fn pointer_sample(&self) -> Option { - self.cursor_px - .map(physical_to_layout_pos) - .map(PointerSample::new) - } - - fn cursor_in(&self, rect: LayoutRect) -> bool { - self.cursor_px - .map(physical_to_layout_pos) - .is_some_and(|p| rect.contains(p)) - } - - fn drain_snapshot(&mut self) -> InputSnapshot { - let mut snap = InputSnapshot::idle(FrameInstant::from_duration(self.start.elapsed())); - snap.pointer = self.pointer_sample(); - snap.buttons_pressed = - core::mem::replace(&mut self.pending_pressed, PointerButtonMask::EMPTY); - snap.buttons_released = - core::mem::replace(&mut self.pending_released, PointerButtonMask::EMPTY); - snap.keys_pressed = core::mem::take(&mut self.pending_keys); - snap.text_committed = core::mem::take(&mut self.pending_text); - snap.modifiers = self.modifier_mask(); - snap +impl FrameTarget for WindowSurface { + fn picker(&self, index: PickIndex) -> Picker<'_> { + self.surface.picker(index) } - fn forget_pan_state(&mut self) { - self.cursor_px = None; - self.left_pan = false; - self.middle_pan = false; - self.modifiers = ModifiersState::empty(); + fn render( + &mut self, + build_passes: impl FnOnce( + &mut wgpu::CommandEncoder, + &wgpu::TextureView, + &wgpu::TextureView, + &wgpu::TextureView, + ), + ) { + let window = &self.window; + self.surface + .render(build_passes, || window.pre_present_notify()); } } -#[allow( - clippy::cast_possible_truncation, - reason = "winit logical px (f64) collapses to LayoutPx (f32) at the sub-pixel limit" -)] -fn physical_to_layout_pos(p: PhysicalPosition) -> LayoutPos { - LayoutPos::new( - LayoutPx::saturating(p.x as f32), - LayoutPx::saturating(p.y as f32), - ) +struct Running { + scheduler: redraw::Scheduler, + surface: WindowSurface, + core: AppCore, + epoch: std::time::Instant, } struct App { - redraw: Option, - render: Option, - input: InputState, -} - -fn default_sketch() -> Sketch { - let sketch = Sketch::new(Plane::Xy.basis()); - let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(-20.0, -12.5)); - let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(20.0, -12.5)); - let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(20.0, 12.5)); - let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(-20.0, 12.5)); - let (sketch, _) = tools::add_line(sketch, p0, p1, false); - let (sketch, _) = tools::add_line(sketch, p1, p2, false); - let (sketch, _) = tools::add_line(sketch, p2, p3, false); - let (sketch, _) = tools::add_line(sketch, p3, p0, false); - let (sketch, origin) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); - let (sketch, _) = tools::add_circle(sketch, origin, Length::new::(5.0), false); - sketch -} - -fn initial_document(sketch: Sketch) -> (Document, SketchId) { - let mut document = Document::new(DocumentId::default(), "Untitled".to_owned()); - let sketch_id = SketchId::default(); - document.insert_sketch(sketch_id, "Sketch1".to_owned(), sketch); - (document, sketch_id) -} - -fn viewport_extent(size: PhysicalSize) -> ViewportExtent { - ViewportExtent::new( - ViewportPx::new(size.width.max(1)), - ViewportPx::new(size.height.max(1)), - ) -} - -#[allow( - clippy::cast_precision_loss, - reason = "viewport pixel counts at any realistic display size fit f32 mantissa" -)] -fn layout_size_from_extent(extent: ViewportExtent) -> LayoutSize { - LayoutSize::new( - LayoutPx::new(extent.width().value() as f32), - LayoutPx::new(extent.height().value() as f32), - ) -} - -fn empty_rect() -> LayoutRect { - LayoutRect::new(LayoutPos::ORIGIN, LayoutSize::ZERO) -} - -fn zoom_factor(delta: MouseScrollDelta) -> f64 { - match delta { - MouseScrollDelta::LineDelta(_, y) => ZOOM_STEP_PER_LINE.powf(f64::from(y)), - MouseScrollDelta::PixelDelta(p) => ZOOM_STEP_PER_PIXEL.powf(p.y), - } -} - -fn zoom_about(camera: Camera2, cursor: Option>, factor: f64) -> Camera2 { - if !factor.is_finite() || factor <= 0.0 { - return camera; - } - let zoom_before = camera.zoom().value(); - let zoom_after = (zoom_before * factor).clamp(ZOOM_MIN, ZOOM_MAX); - if !zoom_after.is_finite() { - return camera; - } - let extent = camera.extent(); - let w = f64::from(extent.width().value()); - let h = f64::from(extent.height().value()); - let (pan_x, pan_y) = camera.pan_mm().coords_mm(); - let (cursor_x, cursor_y) = cursor.map_or((w * 0.5, h * 0.5), |c| (c.x, c.y)); - let horizontal_px = cursor_x - w * 0.5; - let vertical_px = h * 0.5 - cursor_y; - let world_x = pan_x + horizontal_px / zoom_before; - let world_y = pan_y + vertical_px / zoom_before; - let new_pan_x = world_x - horizontal_px / zoom_after; - let new_pan_y = world_y - vertical_px / zoom_after; - camera - .with_zoom(PixelsPerMm::new(zoom_after)) - .with_pan(Vec2::from_mm(new_pan_x, new_pan_y)) -} - -fn dragging_in_sketch(mode: &Mode) -> bool { - matches!( - mode, - Mode::Sketch { session, .. } if session.drag.is_some() - ) -} - -fn dim_flow_active(mode: &Mode) -> bool { - matches!( - mode, - Mode::Sketch { session, .. } if session.dim_flow.is_some() - ) -} - -fn cursor_to_world(camera: Camera2, cursor: PhysicalPosition) -> Option { - let extent = camera.extent(); - let w = f64::from(extent.width().value()); - let h = f64::from(extent.height().value()); - let zoom = camera.zoom().value(); - if w <= 0.0 || h <= 0.0 || !zoom.is_finite() || zoom <= 0.0 { - return None; - } - let (pan_x, pan_y) = camera.pan_mm().coords_mm(); - Some(Point2::from_mm( - pan_x + (cursor.x - w * 0.5) / zoom, - pan_y + (h * 0.5 - cursor.y) / zoom, - )) + running: Option, } -fn try_place(state: &mut RenderState, world: Point2) { - let Mode::Sketch { sketch_id, session } = &state.mode else { - return; - }; - let Some(tool) = session.tool else { - return; - }; - let prev_pending = session.pending; - let sketch_id = *sketch_id; - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { - return; - }; - let snap = match tool { - SketchTool::Line => { - compute_snap(&sketch, &state.camera, world, latest_anchor(prev_pending)) - } - _ => compute_endpoint_snap(&sketch, &state.camera, world), - }; - let (next_sketch, new_pending) = tools::place(sketch, tool, world, prev_pending, snap); - if let Some(next) = next_sketch { - state.undo.record(state.document.clone()); - state.document.replace_sketch(sketch_id, next); - let Some(stored) = state.document.sketch(sketch_id) else { - return; - }; - match SketchScene::extract(stored) { - Ok(scene) => state.scene = scene, - Err(e) => tracing::warn!(error = %e, "scene extract after place failed"), - } - } - if let Mode::Sketch { - ref mut session, .. - } = state.mode - { - session.pending = new_pending; - } -} - -const fn latest_anchor(pending: Option) -> Option { - match pending { - None => None, - Some(Pending::First(a) | Pending::Second(_, a)) => Some(a), - } -} - -fn pick_at(state: &RenderState, cursor: PhysicalPosition) -> Option { - if !cursor.x.is_finite() || !cursor.y.is_finite() || cursor.x < 0.0 || cursor.y < 0.0 { - return None; - } - let extent = state.surface.extent(); - #[allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "cursor px is bounds-checked against surface extent before the cast" - )] - let qx = cursor.x.round() as u32; - #[allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "cursor px is bounds-checked against surface extent before the cast" - )] - let qy = cursor.y.round() as u32; - if qx >= extent.width().value() || qy >= extent.height().value() { - return None; - } - let index = match state.scene.pick_index() { - Ok(i) => i, +fn create_surface( + event_loop: &ActiveEventLoop, +) -> Option<(Arc, SurfaceContext, ViewportExtent)> { + let attrs = Window::default_attributes().with_title("bone"); + let window = match event_loop.create_window(attrs) { + Ok(w) => Arc::new(w), Err(e) => { - tracing::warn!(error = %e, "build pick index"); + tracing::error!(error = %e, "create_window failed"); + event_loop.exit(); return None; } }; - let query = PickQuery::new(ViewportPx::new(qx), ViewportPx::new(qy)) - .with_aperture(state.settings.pick_aperture); - match state.surface.picker(index).at(query) { - Ok(item) => item, + let extent = event::viewport_extent(window.inner_size()); + let surface = match pollster::block_on(SurfaceContext::new(window.clone(), extent)) { + Ok(s) => s, Err(e) => { - tracing::warn!(error = %e, "pick failed"); - None + tracing::error!(error = %e, "SurfaceContext::new failed"); + event_loop.exit(); + return None; } - } -} - -fn handle_viewport_click(state: &mut RenderState, cursor: PhysicalPosition, additive: bool) { - let item = pick_at(state, cursor).and_then(selection::picked_to_item); - state.selection = std::mem::take(&mut state.selection).picked(item, additive); - if additive || !state.mode.is_sketch() { - return; - } - let Some(SketchItemId::Entity(entity_id)) = item else { - return; - }; - let Mode::Sketch { sketch_id, .. } = state.mode else { - return; - }; - let Some(sketch) = state.document.sketch(sketch_id) else { - return; - }; - let Some(world) = cursor_to_world(state.camera, cursor) else { - return; - }; - let Some(pins) = DragPins::from_sketch_entity(sketch, entity_id) else { - return; - }; - let drag = DragSession { - entity: entity_id, - press: world, - pins, - }; - state.undo.record(state.document.clone()); - state.mode = core::mem::take(&mut state.mode).start_drag(drag); -} - -fn drag_resolved(sketch: &Sketch, drag: DragSession, world: Point2) -> Option { - let pins = drag.pins.to_targets(drag.press, world); - sketch - .solve_with_drag_pins(&pins, BudgetCeiling::FRAME_16MS) - .map_err(|e| tracing::trace!(error = %e, "drag solve failed, keeping last-good sketch")) - .ok() -} - -fn try_drag_to(state: &mut RenderState, world: Point2) { - let Mode::Sketch { sketch_id, session } = &state.mode else { - return; - }; - let Some(drag) = session.drag else { - return; - }; - let sketch_id = *sketch_id; - let Some(sketch) = state.document.sketch(sketch_id) else { - return; - }; - let Some(next) = drag_resolved(sketch, drag, world) else { - return; - }; - state.document.replace_sketch(sketch_id, next); - refresh_active_scene(state); -} - -fn refresh_active_scene(state: &mut RenderState) { - let Some(active_id) = active_sketch_id(&state.mode, &state.plane_sketches) else { - return; }; - let Some(sketch) = state.document.sketch(active_id) else { - return; - }; - match SketchScene::extract(sketch) { - Ok(scene) => state.scene = scene, - Err(e) => tracing::warn!(error = %e, "refresh active scene failed"), - } + Some((window, surface, extent)) } -fn active_sketch_id(mode: &Mode, plane_sketches: &BTreeMap) -> Option { - match mode { - Mode::Sketch { sketch_id, .. } => Some(*sketch_id), - Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => Some(feature.sketch), - Mode::Extrude(ExtrudeArming::AwaitingSketch) | Mode::Idle => { - plane_sketches.get(&Plane::Xy).copied() +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.running.is_some() { + return; } - } -} - -enum ProfileChoice { - NoSketch, - Unique(SketchId), - Ambiguous, -} - -fn classify_extrude_profile(document: &Document) -> ProfileChoice { - let mut sketches = document - .feature_tree() - .iter() - .filter_map(|(_, node)| match node { - FeatureNode::Sketch(id) => Some(id), - _ => None, + let Some((window, surface, extent)) = create_surface(event_loop) else { + return; + }; + let core = match AppCore::new(surface.gpu(), surface.color_format(), extent) { + Ok(core) => core, + Err(e) => { + tracing::error!(error = %e, "app core init failed"); + event_loop.exit(); + return; + } + }; + window.request_redraw(); + self.running = Some(Running { + scheduler: redraw::Scheduler::new(Arc::clone(&window)), + surface: WindowSurface { surface, window }, + core, + epoch: std::time::Instant::now(), }); - match (sketches.next(), sketches.next()) { - (None, _) => ProfileChoice::NoSketch, - (Some(id), None) => ProfileChoice::Unique(id), - (Some(_), Some(_)) => ProfileChoice::Ambiguous, - } -} - -fn apply_feature_tool(state: &mut RenderState, tool: Option) { - match tool { - Some(FeatureTool::ExtrudedBossBase) => arm_extruded_boss_base(state), - Some(FeatureTool::ExtrudedCut) => notify_stub(state, strings::TOOL_EXTRUDED_CUT), - None => {} } -} -fn arm_extruded_boss_base(state: &mut RenderState) { - match classify_extrude_profile(&state.document) { - ProfileChoice::NoSketch => notify_info(state, strings::NOTIFY_EXTRUDE_NO_SKETCH, None), - ProfileChoice::Unique(id) => state.mode = Mode::Extrude(ExtrudeArming::profile(id)), - ProfileChoice::Ambiguous => state.mode = Mode::Extrude(ExtrudeArming::AwaitingSketch), + fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + match event::AppEvent::from_winit(event) { + event::AppEvent::Input(input) => { + let Some(running) = self.running.as_mut() else { + return; + }; + if let InputEvent::Resize(extent) = &input { + running.surface.surface.resize(*extent); + } + let ack = running.core.handle_input(&running.surface, input); + running.scheduler.schedule(ack); + } + event::AppEvent::Redraw => self.dispatch_redraw(event_loop), + event::AppEvent::Close => event_loop.exit(), + event::AppEvent::Ignored => {} + } } -} - -fn apply_extrude_edit(state: &mut RenderState, edit: Option) { - let Some(edit) = edit else { return }; - let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = &state.mode else { - return; - }; - let next = edit.apply(*feature); - let target = *target; - state.mode = Mode::Extrude(ExtrudeArming::Profile { - feature: next, - target, - }); -} -fn apply_extrude_activation(state: &mut RenderState, activated: Option) { - if let Some(mode) = extrude_edit_mode(&state.document, &state.mode, activated) { - if let Mode::Extrude(ExtrudeArming::Profile { - target: Some(id), .. - }) = mode + fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: winit::event::StartCause) { + if matches!(cause, winit::event::StartCause::ResumeTimeReached { .. }) + && let Some(running) = self.running.as_mut() { - state.framed_extrude = Some(id); + running.scheduler.kick(); + running.scheduler.window().request_redraw(); } - state.mode = mode; - } -} - -fn extrude_edit_mode( - document: &Document, - current: &Mode, - activated: Option, -) -> Option { - let id = activated?; - if current.is_sketch() { - return None; - } - let feature = document.extrude(id).copied()?; - Some(Mode::Extrude(ExtrudeArming::edit(id, feature))) -} - -fn apply_extrude_confirm(state: &mut RenderState, confirm: Option) { - if let Some(id) = - commit_armed_extrude(&mut state.document, &mut state.undo, &state.mode, confirm) - { - state.framed_extrude = Some(id); } } -fn commit_armed_extrude( - document: &mut Document, - undo: &mut UndoStack, - mode: &Mode, - confirm: Option, -) -> Option { - let Some(shell::ConfirmAction::Accept) = confirm else { - return None; - }; - let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = mode else { - return None; - }; - let snapshot = document.clone(); - let committed = match target { - Some(id) => { - document.insert_extrude(*id, *feature); - *id +impl App { + fn dispatch_redraw(&mut self, event_loop: &ActiveEventLoop) { + let Some(running) = self.running.as_mut() else { + return; + }; + running + .core + .clock_mut() + .feed(FrameInstant::from_duration(running.epoch.elapsed())); + let report = running.core.render_frame(&mut running.surface); + if report.exit { + event_loop.exit(); + return; } - None => document.commit_extrude(*feature), - }; - undo.record(snapshot); - Some(committed) -} - -struct SolidViewData { - faces: SolidScene, - edges: EdgeScene, - aabb: Aabb3, -} - -struct ExtrudePreview { - feature: ExtrudeFeature, - sketch_version: SketchVersion, - generation: Option, - failed: bool, - error: Option, -} - -impl ExtrudePreview { - fn status(&self) -> ExtrudeStatus<'_> { - match &self.error { - Some(error) => ExtrudeStatus::Failed(error), - None => ExtrudeStatus::Valid, + if report.kick { + running.scheduler.kick(); + } + running.scheduler.consume_kick(); + if let Some(deadline) = running.core.next_wake_deadline() { + event_loop + .set_control_flow(ControlFlow::WaitUntil(running.epoch + deadline.duration())); + } else { + event_loop.set_control_flow(ControlFlow::Wait); } } } -const PREVIEW_CHORD_MM: f64 = 0.05; -const PREVIEW_ANGLE: AngleTolerance = AngleTolerance::from_radians(0.2); - -fn active_solid_feature( - mode: &Mode, - document: &Document, - framed: Option, -) -> Option { - match mode { - Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => Some(*feature), - Mode::Sketch { .. } => None, - Mode::Idle | Mode::Extrude(ExtrudeArming::AwaitingSketch) => framed - .and_then(|id| document.extrude(id).copied()) - .or_else(|| { - document - .feature_tree() - .iter() - .filter_map(|(_, node)| match node { - FeatureNode::Extrude(id) => Some(id), - _ => None, - }) - .last() - .and_then(|id| document.extrude(id).copied()) - }), - } -} - -fn sync_solid_view(state: &mut RenderState) { - let Some(feature) = active_solid_feature(&state.mode, &state.document, state.framed_extrude) - else { - state.extrude_preview = None; - state.solid_view = None; - state.camera3 = None; - state.view.home = None; - state.view.tween = None; - return; - }; - let Some(sketch_version) = state.document.sketch(feature.sketch).map(Sketch::version) else { - state.extrude_preview = None; - state.solid_view = None; - state.camera3 = None; - state.view.home = None; - state.view.tween = None; - return; - }; - if extrude_preview_is_current(state.extrude_preview.as_ref(), &feature, sketch_version) { - return; - } - let previous_generation = state - .extrude_preview - .as_ref() - .and_then(|cached| cached.generation); - let previously_failed = state - .extrude_preview - .as_ref() - .is_some_and(|cached| cached.failed); - let first_preview = state.extrude_preview.is_none(); - let preview = compute_extrude_preview(&mut state.feature_cache, &state.document, feature); - let generation = preview.as_ref().and_then(EvaluatedExtrude::generation); - let error = preview - .as_ref() - .and_then(|evaluated| evaluated.result().as_ref().err().cloned()); - let mut failure = error.as_ref().map(ToString::to_string); - if generation != previous_generation { - state.solid_view = match preview.as_ref().and_then(EvaluatedExtrude::solid) { - Some(solid) => match build_solid_view(solid) { - Ok(view) => Some(view), - Err(error) => { - failure = Some(error); - None - } - }, - None => None, - }; - } - let now_failed = failure.is_some(); - state.extrude_preview = Some(ExtrudePreview { - feature, - sketch_version, - generation, - failed: now_failed, - error, - }); - let newly_failed = first_preview || !previously_failed; - if let Some(detail) = failure.filter(|_| newly_failed) { - tracing::warn!(error = %detail, "extrude preview evaluation failed"); - notify_error(state, strings::NOTIFY_EXTRUDE_FAILED, detail); - } -} - -fn extrude_preview_is_current( - cached: Option<&ExtrudePreview>, - feature: &ExtrudeFeature, - sketch_version: SketchVersion, -) -> bool { - cached - .is_some_and(|cached| cached.feature == *feature && cached.sketch_version == sketch_version) -} - -fn compute_extrude_preview( - cache: &mut FeatureCache, - document: &Document, - feature: ExtrudeFeature, -) -> Option { - let sketch = document.sketch(feature.sketch)?; - let fid = FeatureId::default(); - let evaluated_sketch = cache.evaluate(fid, sketch).clone(); - Some( - cache - .evaluate_extrude(fid, &evaluated_sketch, &feature) - .clone(), - ) +enum RunMode { + Window, + Gallery(PathBuf), } -fn build_solid_view(solid: &bone_document::BrepSolid) -> Result { - let chord = ChordHeightTolerance::from_mm(PREVIEW_CHORD_MM); - let aabb = solid - .bounding_box() - .ok_or_else(|| "degenerate solid has no bounding box".to_owned())?; - let mesh = solid - .tessellate(chord, PREVIEW_ANGLE) - .map_err(|error| error.to_string())?; - let faces = SolidScene::from_mesh(&mesh).map_err(|error| error.to_string())?; - let edges = EdgeScene::from_solid(solid, &mesh, chord).map_err(|error| error.to_string())?; - Ok(SolidViewData { faces, edges, aabb }) +fn parse_mode(args: impl IntoIterator) -> Result { + let collected: Vec = args.into_iter().skip(1).collect(); + parse_args(&collected, RunMode::Window) } -fn sync_solid_camera(state: &mut RenderState, region: Option) { - if let Some(region) = region - && let Some(view) = state.solid_view.as_ref() - && state.camera3.is_none() - { - let framed = - frame_standard_view(view.aabb, region.extent(), StandardView::Isometric, None).ok(); - state.camera3 = framed; - if state.view.home.is_none() { - state.view.home = framed; +fn parse_args(args: &[String], acc: RunMode) -> Result { + match args { + [] => Ok(acc), + [first, second, rest @ ..] if first == "--gallery" => { + parse_args(rest, RunMode::Gallery(PathBuf::from(second))) } + [only] if only == "--gallery" => Err(AppError::MissingArg { + flag: "--gallery ", + }), + [unknown, ..] => Err(AppError::UnknownArg(unknown.clone())), } } -const VIEW_TWEEN_MS: u64 = 220; - -fn step_view_tween(state: &mut RenderState, now: FrameInstant) { - let Some(active) = state.view.tween else { - return; - }; - let elapsed = now.since(active.started); - if let Ok(camera) = active.tween.sample(elapsed) { - state.camera3 = Some(camera); - } - if active.tween.is_done(elapsed) { - state.camera3 = Some(active.tween.to()); - state.view.tween = None; - } +fn emit_gallery(out_dir: &Path) -> Result<(), AppError> { + std::fs::create_dir_all(out_dir).map_err(|e| AppError::GalleryDir { + path: out_dir.to_path_buf(), + source: e, + })?; + [ + (ThemeMode::Light, Theme::light()), + (ThemeMode::Dark, Theme::dark()), + ] + .into_iter() + .try_for_each(|(mode, theme)| -> Result<(), AppError> { + let mut state = GalleryState::new(); + let paint = render(Arc::new(theme.clone()), &mut state); + let rgba = rasterize(&theme, &paint, GALLERY_CANVAS, StringTable::empty()); + let png = encode_png(&rgba, GALLERY_CANVAS) + .map_err(|source| AppError::GalleryEncode { mode, source })?; + let target = out_dir.join(format!("gallery_{mode}.png")); + std::fs::write(&target, &png).map_err(|source| AppError::GalleryWrite { + path: target.clone(), + source, + })?; + tracing::info!(path = %target.display(), "gallery png written"); + Ok(()) + }) } -fn start_view_tween(state: &mut RenderState, target: Camera3, now: FrameInstant) { - let tween = if state.settings.reduce_motion { - CameraTween::immediate(target) - } else { - CameraTween::eased( - state.camera3.unwrap_or(target), - target, - std::time::Duration::from_millis(VIEW_TWEEN_MS), - CubicEasing::STANDARD, +fn main() -> Result<(), AppError> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_FILTER)), ) - }; - state.view.tween = Some(view_cube::ActiveTween { - tween, - started: now, - }); -} - -fn apply_nav_camera(state: &mut RenderState, camera: Camera3) { - state.camera3 = Some(camera); - state.view.tween = None; -} - -fn solid_aabb_and_extent(state: &RenderState) -> Option<(Aabb3, ViewportExtent)> { - let region = solid_viewport_region(state.viewport_rect, state.surface.extent())?; - let view = state.solid_view.as_ref()?; - Some((view.aabb, region.extent())) -} - -fn normal_to_plane(state: &RenderState) -> Option { - let sketch_id = active_sketch_id(&state.mode, &state.plane_sketches)?; - let plane = state - .plane_sketches - .iter() - .find_map(|(plane, id)| (*id == sketch_id).then_some(*plane))?; - Some(Plane3::from(plane.basis())) -} - -fn frame_target_camera(state: &RenderState, pick: view_cube::ViewPick) -> Option { - match pick { - view_cube::ViewPick::Home => state.view.home, - view_cube::ViewPick::Standard(view) => { - let (aabb, extent) = solid_aabb_and_extent(state)?; - frame_standard_view(aabb, extent, view, normal_to_plane(state)).ok() - } - view_cube::ViewPick::Direction(direction) => { - let (aabb, extent) = solid_aabb_and_extent(state)?; - frame_view_direction(aabb, extent, direction).ok() - } - } -} - -fn apply_view_pick(state: &mut RenderState, pick: Option, now: FrameInstant) { - let Some(pick) = pick else { - return; - }; - if let Some(target) = frame_target_camera(state, pick) { - start_view_tween(state, target, now); - } -} - -fn view_nav_enabled(state: &RenderState) -> bool { - state.solid_view.is_some() - && !modal_active(state) - && state.focus.focused().is_none() - && !dim_flow_active(&state.mode) -} - -fn apply_view_menu( - state: &mut RenderState, - action: Option, - now: FrameInstant, -) { - match action { - Some(view_cube::ViewCubeMenuAction::SetAsHome) => { - state.view.home = state.camera3; - } - Some(view_cube::ViewCubeMenuAction::FitToWindow) => { - if let (Some(camera), Some((aabb, extent))) = - (state.camera3, solid_aabb_and_extent(state)) - && let Ok(target) = frame_current(camera, aabb, extent) - { - start_view_tween(state, target, now); - } - } - Some(view_cube::ViewCubeMenuAction::ViewNormalTo) => { - apply_view_pick( - state, - Some(view_cube::ViewPick::Standard(StandardView::NormalTo)), - now, - ); - } - None => {} - } -} - -fn preview_solid_frame( - solid_view: Option<&SolidViewData>, - camera: Option, - region: ViewportRegion, -) -> Option<(&SolidViewData, SolidFrameView)> { - let view = solid_view?; - Some((view, SolidFrameView::new(camera?, region).ok()?)) -} - -fn solid_viewport_region(viewport: LayoutRect, surface: ViewportExtent) -> Option { - let (surface_w, surface_h) = (surface.width().value(), surface.height().value()); - let min_x = round_layout_px(viewport.min_x().value()).min(surface_w); - let min_y = round_layout_px(viewport.min_y().value()).min(surface_h); - let width = round_layout_px(viewport.size.width.value()).min(surface_w - min_x); - let height = round_layout_px(viewport.size.height.value()).min(surface_h - min_y); - (width > 0 && height > 0).then(|| { - ViewportRegion::new( - ViewportPx::new(min_x), - ViewportPx::new(min_y), - ViewportExtent::new(ViewportPx::new(width), ViewportPx::new(height)), - ) - }) -} - -#[allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "the saturating cast of a non-negative rounded px is clamped to the surface extent by the caller" -)] -fn round_layout_px(value: f32) -> u32 { - value.round().max(0.0) as u32 -} - -fn viewport_local_point( - cursor: PhysicalPosition, - region: ViewportRegion, -) -> Option { - let (min_x, min_y, _, _) = region.scissor(); - ViewportPoint::new(cursor.x - f64::from(min_x), cursor.y - f64::from(min_y)).ok() -} - -fn drag_gesture(modifiers: ModifiersState) -> NavGesture { - let with_ctrl = if modifiers.control_key() || modifiers.super_key() { - DragModifiers::NONE.with_ctrl() - } else { - DragModifiers::NONE - }; - let with_shift = if modifiers.shift_key() { - with_ctrl.with_shift() - } else { - with_ctrl - }; - let resolved = if modifiers.alt_key() { - with_shift.with_alt() - } else { - with_shift - }; - resolved.gesture() -} - -fn build_preview( - mode: &Mode, - document: &Document, - cursor_world: Option, - camera: &Camera2, -) -> SketchPreview { - let Mode::Sketch { sketch_id, session } = mode else { - return SketchPreview::empty(); - }; - if session.drag.is_some() || session.dim_flow.is_some() { - return SketchPreview::empty(); - } - let Some(tool) = session.tool else { - return SketchPreview::empty(); - }; - let pending = session.pending; - let Some(sketch) = document.sketch(*sketch_id) else { - return SketchPreview::empty(); - }; - let Some(cursor) = cursor_world else { - return tools::preview_anchors_only(sketch, pending); - }; - let snap = match tool { - SketchTool::Line => compute_snap(sketch, camera, cursor, latest_anchor(pending)), - _ => compute_endpoint_snap(sketch, camera, cursor), - }; - tools::preview(sketch, tool, cursor, pending, snap) -} - -fn snap_tolerance(camera: &Camera2) -> Option { - let mm_per_px = camera.world_mm_per_pixel(); - if !mm_per_px.is_finite() || mm_per_px <= 0.0 { - return None; - } - let tol_mm = (SNAP_TOLERANCE_PX * mm_per_px).min(SNAP_TOLERANCE_MAX_MM); - Some(Length::new::(tol_mm)) -} - -fn compute_snap( - sketch: &Sketch, - camera: &Camera2, - cursor_world: Point2, - click: Option, -) -> Option { - snap::detect( - cursor_world, - click.and_then(|c| resolve_anchor(Some(sketch), c)), - sketch, - snap_tolerance(camera)?, - ) -} - -fn compute_endpoint_snap( - sketch: &Sketch, - camera: &Camera2, - cursor_world: Point2, -) -> Option { - snap::detect_endpoint_only(cursor_world, sketch, snap_tolerance(camera)?) -} - -fn resolve_anchor(sketch: Option<&Sketch>, click: ClickAnchor) -> Option { - match click { - ClickAnchor::Position(pos) | ClickAnchor::MidpointOf { position: pos, .. } => { - Some(Anchor { pos, id: None }) - } - ClickAnchor::Endpoint(id) => match sketch?.entities().get(id)? { - SketchEntity::Point(p) => Some(Anchor { - pos: p.at(), - id: Some(id), - }), - _ => None, - }, - } -} - -fn pan_by_px(camera: Camera2, horizontal_px: f64, vertical_px: f64) -> Camera2 { - let mm_per_px = camera.world_mm_per_pixel(); - let (pan_x, pan_y) = camera.pan_mm().coords_mm(); - camera.with_pan(Vec2::from_mm( - pan_x - horizontal_px * mm_per_px, - pan_y + vertical_px * mm_per_px, - )) -} - -fn zoom_fit(camera: Camera2, scene: &SketchScene, viewport: LayoutRect) -> Camera2 { - let Some(aabb) = scene.aabb() else { - return camera; - }; - let (mnx, mny) = aabb.min().coords_mm(); - let (mxx, mxy) = aabb.max().coords_mm(); - let center_x = (mnx + mxx) * 0.5; - let center_y = (mny + mxy) * 0.5; - let world_w = mxx - mnx; - let world_h = mxy - mny; - let v_w = f64::from(viewport.size.width.value()); - let v_h = f64::from(viewport.size.height.value()); - if v_w <= 0.0 || v_h <= 0.0 { - return camera; - } - let axis_zoom = |pixels: f64, world: f64| { - if world > 0.0 { - pixels / world - } else { - f64::INFINITY - } - }; - let raw_zoom = axis_zoom(v_w, world_w).min(axis_zoom(v_h, world_h)) * ZOOM_FIT_MARGIN; - let zoom = raw_zoom.clamp(ZOOM_MIN, ZOOM_MAX); - let pan = pan_centering((center_x, center_y), camera.extent(), viewport, zoom); - camera.with_zoom(PixelsPerMm::new(zoom)).with_pan(pan) -} - -fn pan_centering( - target_world: (f64, f64), - window: ViewportExtent, - viewport: LayoutRect, - zoom: f64, -) -> Vec2 { - let v_w = f64::from(viewport.size.width.value()); - let v_h = f64::from(viewport.size.height.value()); - let viewport_center_x = f64::from(viewport.origin.x.value()) + v_w * 0.5; - let viewport_center_y = f64::from(viewport.origin.y.value()) + v_h * 0.5; - let window_center_x = f64::from(window.width().value()) * 0.5; - let window_center_y = f64::from(window.height().value()) * 0.5; - let pan_x = target_world.0 - (viewport_center_x - window_center_x) / zoom; - let pan_y = target_world.1 + (viewport_center_y - window_center_y) / zoom; - Vec2::from_mm(pan_x, pan_y) -} - -fn keyboard_camera(code: KeyCode, input: &InputState, state: &RenderState) -> Option { - if input.modifiers.control_key() || input.modifiers.super_key() { - return None; - } - let camera = state.camera; - let step = input.pan_step_px(); - let shift = input.modifiers.shift_key(); - match code { - KeyCode::ArrowLeft => Some(pan_by_px(camera, step, 0.0)), - KeyCode::ArrowRight => Some(pan_by_px(camera, -step, 0.0)), - KeyCode::ArrowUp => Some(pan_by_px(camera, 0.0, step)), - KeyCode::ArrowDown => Some(pan_by_px(camera, 0.0, -step)), - KeyCode::KeyZ => Some(zoom_about( - camera, - input.cursor_px, - if shift { - 1.0 / ZOOM_KEY_STEP - } else { - ZOOM_KEY_STEP - }, - )), - KeyCode::Equal => Some(zoom_about(camera, input.cursor_px, ZOOM_KEY_STEP)), - KeyCode::Minus => Some(zoom_about(camera, input.cursor_px, 1.0 / ZOOM_KEY_STEP)), - _ => None, - } -} - -fn zoom_key3( - camera: Camera3, - extent: ViewportExtent, - pixel: ViewportPoint, - factor: f64, -) -> Option { - ZoomFactor::new(factor) - .ok() - .and_then(|f| zoom_about_pixel(camera, extent, pixel, f).ok()) -} - -fn keyboard_camera3(code: KeyCode, input: &InputState, state: &RenderState) -> Option { - let camera = state.camera3?; - let region = solid_viewport_region(state.viewport_rect, state.surface.extent())?; - let extent = region.extent(); - let ctrl = input.modifiers.control_key() || input.modifiers.super_key(); - let shift = input.modifiers.shift_key(); - let alt = input.modifiers.alt_key(); - let cx = f64::from(extent.width().value()) * 0.5; - let cy = f64::from(extent.height().value()) * 0.5; - let center = ViewportPoint::new(cx, cy).ok()?; - let pan_to = |dx: f64, dy: f64| { - ViewportPoint::new(cx + dx, cy + dy) - .ok() - .and_then(|to| pan_pixels(camera, extent, center, to).ok()) - }; - let step = Angle::new::(if shift { - ORBIT_KEY_SNAP_DEG - } else { - ORBIT_KEY_STEP_DEG - }); - match code { - KeyCode::ArrowLeft if ctrl => pan_to(-PAN_STEP_PX, 0.0), - KeyCode::ArrowRight if ctrl => pan_to(PAN_STEP_PX, 0.0), - KeyCode::ArrowUp if ctrl => pan_to(0.0, -PAN_STEP_PX), - KeyCode::ArrowDown if ctrl => pan_to(0.0, PAN_STEP_PX), - KeyCode::ArrowLeft if alt => roll_by(camera, step).ok(), - KeyCode::ArrowRight if alt => roll_by(camera, -step).ok(), - KeyCode::ArrowLeft => orbit_yaw(camera, step).ok(), - KeyCode::ArrowRight => orbit_yaw(camera, -step).ok(), - KeyCode::ArrowUp => orbit_pitch(camera, step).ok(), - KeyCode::ArrowDown => orbit_pitch(camera, -step).ok(), - KeyCode::KeyZ if shift => zoom_key3(camera, extent, center, 1.0 / ZOOM_KEY_STEP), - KeyCode::KeyZ | KeyCode::Equal => zoom_key3(camera, extent, center, ZOOM_KEY_STEP), - KeyCode::Minus => zoom_key3(camera, extent, center, 1.0 / ZOOM_KEY_STEP), - _ => None, - } -} - -fn build_hotkey_table() -> HotkeyTable { - let Ok(table) = hotkeys::compose_table(&hotkeys::HotkeyOverrides::default()) else { - unreachable!("default hotkey bindings are conflict-free"); - }; - table -} - -fn scopes_for_mode(mode: &Mode) -> HotkeyScopes { - let mut scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]); - if mode.is_sketch() { - scopes.push(HotkeyScope::Sketch); - } - if mode.is_extrude() { - scopes.push(HotkeyScope::Extrude); - } - scopes -} - -fn next_mode( - mode: Mode, - frame: &shell::ShellFrame, - escape_requested: bool, - plane_sketches: &BTreeMap, -) -> Mode { - let after_pick = resolve_pick(mode, frame, plane_sketches); - let after_escape = if escape_requested { - cancel_pending_or_exit(after_pick) - } else { - after_pick - }; - let after_exit = if frame.exit_sketch { - Mode::Idle - } else { - after_escape - }; - match frame.activated_tool { - Some(t) => toggle_or_arm(after_exit, t), - None => after_exit, - } -} - -fn resolve_pick( - mode: Mode, - frame: &shell::ShellFrame, - plane_sketches: &BTreeMap, -) -> Mode { - if mode.is_extrude() { - return match frame.sketch_activated { - Some(id) => match &mode { - Mode::Extrude(ExtrudeArming::Profile { feature, .. }) if feature.sketch == id => { - mode - } - _ => Mode::Extrude(ExtrudeArming::profile(id)), - }, - None => mode, - }; - } - let plane_pick = frame - .plane_picked - .filter(|_| !mode.is_sketch()) - .and_then(|plane| plane_sketches.get(&plane).copied()); - let sketch_pick = frame.sketch_activated.filter(|_| !mode.is_sketch()); - sketch_pick.or(plane_pick).map_or(mode, Mode::enter_sketch) -} - -fn toggle_or_arm(mode: Mode, tool: SketchTool) -> Mode { - let already_active = matches!( - &mode, - Mode::Sketch { session, .. } if session.tool == Some(tool) - ); - if already_active { - mode.disarm_tool() - } else { - mode.arm_tool(tool) - } -} - -fn cancel_pending_or_exit(mode: Mode) -> Mode { - match &mode { - Mode::Sketch { session, .. } if session.pending.is_some() => mode.clear_pending(), - Mode::Sketch { session, .. } if session.tool.is_some() => mode.disarm_tool(), - Mode::Sketch { .. } | Mode::Idle | Mode::Extrude(_) => Mode::Idle, - } -} - -fn create_surface( - event_loop: &ActiveEventLoop, -) -> Option<(Arc, SurfaceContext, ViewportExtent)> { - let attrs = Window::default_attributes().with_title("bone"); - let window = match event_loop.create_window(attrs) { - Ok(w) => Arc::new(w), - Err(e) => { - tracing::error!(error = %e, "create_window failed"); - event_loop.exit(); - return None; - } - }; - let extent = viewport_extent(window.inner_size()); - let surface = match pollster::block_on(SurfaceContext::new(window.clone(), extent)) { - Ok(s) => s, - Err(e) => { - tracing::error!(error = %e, "SurfaceContext::new failed"); - event_loop.exit(); - return None; - } - }; - Some((window, surface, extent)) -} - -impl ApplicationHandler for App { - fn resumed(&mut self, event_loop: &ActiveEventLoop) { - if self.redraw.is_some() { - return; - } - let Some((window, surface, extent)) = create_surface(event_loop) else { - return; - }; - let renderer = SketchRenderer::new(surface.gpu(), surface.color_format()); - let solid_renderer = SolidRenderer::new(surface.gpu(), surface.color_format()); - let chrome_pipeline = ChromePipeline::new(surface.gpu(), surface.color_format()); - let convex_pipeline = ConvexPolyPipeline::new(surface.gpu(), surface.color_format()); - let stroke_pipeline = StrokePipeline::new(surface.gpu(), surface.color_format()); - let sdf_atlas = MaskAtlas::new(MaskAtlasParams::STANDARD); - let text_pipeline = - ChromeTextPipeline::new(surface.gpu(), surface.color_format(), sdf_atlas.extent()); - let chrome_shaper = Shaper::new(); - let sans_font = bone_text::load_font(bone_text::FontFace::Sans); - let mono_font = bone_text::load_font(bone_text::FontFace::Mono); - let sketch = default_sketch(); - let scene = match SketchScene::extract(&sketch) { - Ok(s) => s, - Err(e) => { - tracing::error!(error = %e, "scene extract failed"); - event_loop.exit(); - return; - } - }; - let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(INITIAL_ZOOM_PX_PER_MM)); - let style = Style::default(); - let theme = Arc::new(Theme::light()); - let shell = shell::Shell::new(); - let (document, sketch_id) = initial_document(sketch); - let last_saved_baseline = document.clone(); - let plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); - let strings = strings::make_strings(bone_ui::strings::Locale::EnUs); - let viewport_rect = empty_rect(); - let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { - unreachable!("UNDO_CAPACITY constant is non-zero"); - }; - let loaded_settings = settings::load(); - let initial_hotkeys = match hotkeys::compose_table(&loaded_settings.hotkey_overrides) { - Ok(table) => table, - Err(e) => { - tracing::warn!(error = %e, "stored hotkey overrides conflict, using defaults"); - build_hotkey_table() - } - }; - window.request_redraw(); - self.redraw = Some(redraw::Scheduler::new(window)); - self.render = Some(RenderState { - surface, - renderer, - chrome_pipeline, - convex_pipeline, - stroke_pipeline, - text_pipeline, - sdf_atlas, - chrome_shaper, - sans_font, - mono_font, - scene, - camera, - style, - theme, - shell, - document, - plane_sketches, - mode: Mode::Idle, - feature_cache: FeatureCache::new(), - extrude_preview: None, - solid_renderer, - solid_view: None, - camera3: None, - framed_extrude: None, - navigator: ViewportNavigator::new(), - view: view_cube::ViewUi::default(), - focus: FocusManager::new(), - hit_state: HitState::new(), - hotkeys: initial_hotkeys, - strings, - viewport_rect, - undo: UndoStack::with_capacity(undo_capacity), - selection: Selection::default(), - settings: loaded_settings, - dim_editor: DimensionEditorState::default(), - dim_editor_bounds: None, - pending_exit: false, - current_folder: None, - documents_root: file_menu::documents_root(), - file_picker: None, - native_picker: None, - step_job: None, - pending_overwrite: None, - last_saved: Some(last_saved_baseline), - pending_discard: None, - notification: None, - shortcut_bar: None, - }); - } - - fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { - match event::AppEvent::from_winit(event) { - event::AppEvent::Input(input) => { - let ack = self.dispatch_input(event_loop, input); - if let Some(scheduler) = self.redraw.as_mut() { - scheduler.schedule(ack); - } - } - event::AppEvent::Redraw => self.dispatch_redraw(event_loop), - event::AppEvent::Close => event_loop.exit(), - event::AppEvent::Ignored => {} - } - } - - fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: winit::event::StartCause) { - if matches!(cause, winit::event::StartCause::ResumeTimeReached { .. }) - && let Some(scheduler) = self.redraw.as_mut() - { - scheduler.kick(); - scheduler.window().request_redraw(); - } - } -} - -impl App { - fn dispatch_input( - &mut self, - event_loop: &ActiveEventLoop, - input: event::InputEvent, - ) -> redraw::InputDispatched { - let ack = redraw::InputDispatched::after_input(); - let Some(state) = self.render.as_mut() else { - return ack; - }; - match input { - event::InputEvent::Resize(size) => { - let extent = viewport_extent(size); - state.surface.resize(extent); - state.camera = state.camera.with_extent(extent); - state.viewport_rect = empty_rect(); - } - event::InputEvent::Focus(focused) => { - if !focused { - self.input.forget_pan_state(); - state.navigator.end_drag(); - } - } - event::InputEvent::Modifier(mods) => { - self.input.modifiers = mods.state(); - } - event::InputEvent::CursorMove(position) => { - let prev = self.input.cursor_px; - self.input.cursor_px = Some(position); - let modal = modal_active(state); - if !modal - && state.navigator.is_dragging() - && let Some(camera) = state.camera3 - && let Some(region) = - solid_viewport_region(state.viewport_rect, state.surface.extent()) - && let Some(cursor) = viewport_local_point(position, region) - && let Ok(next) = state.navigator.drag_to(cursor, camera, region.extent()) - { - apply_nav_camera(state, next); - } else if !modal - && self.input.panning() - && let Some(p) = prev - { - state.camera = pan_by_px(state.camera, position.x - p.x, position.y - p.y); - } else if !modal - && dragging_in_sketch(&state.mode) - && let Some(world) = cursor_to_world(state.camera, position) - { - try_drag_to(state, world); - } - } - event::InputEvent::CursorLeft => { - self.input.cursor_px = None; - } - event::InputEvent::CursorEntered => {} - event::InputEvent::Pointer { - button, - state: btn_state, - } => { - self.dispatch_pointer_button(button, btn_state); - } - event::InputEvent::Wheel(delta) => { - self.dispatch_wheel(delta); - } - event::InputEvent::KeyDown { - physical_key, - logical_key, - text, - repeat, - } => { - self.dispatch_keydown( - event_loop, - physical_key, - &logical_key, - text.as_ref(), - repeat, - ); - } - } - ack - } - - fn dispatch_wheel(&mut self, delta: MouseScrollDelta) { - let Some(state) = self.render.as_mut() else { - return; - }; - if modal_active(state) || !self.input.cursor_in(state.viewport_rect) { - return; - } - if state.solid_view.is_none() { - state.camera = zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); - return; - } - let (Some(camera), Some(region)) = ( - state.camera3, - solid_viewport_region(state.viewport_rect, state.surface.extent()), - ) else { - return; - }; - match delta { - MouseScrollDelta::PixelDelta(p) => { - if let Ok(next) = state - .navigator - .orbit_pixels(camera, region.extent(), p.x, p.y) - { - apply_nav_camera(state, next); - } - } - MouseScrollDelta::LineDelta(..) => { - if let Some(cursor) = self - .input - .cursor_px - .and_then(|p| viewport_local_point(p, region)) - && let Ok(factor) = ZoomFactor::new(zoom_factor(delta)) - && let Ok(next) = zoom_about_pixel(camera, region.extent(), cursor, factor) - { - apply_nav_camera(state, next); - } - } - } - } - - fn dispatch_pointer_button(&mut self, button: MouseButton, btn_state: ElementState) { - let Some(state) = self.render.as_mut() else { - return; - }; - let modal = modal_active(state); - match button { - MouseButton::Left => { - if btn_state == ElementState::Pressed { - let in_viewport = self.input.cursor_in(state.viewport_rect); - let over_dim_editor = state - .dim_editor_bounds - .is_some_and(|r| self.input.cursor_in(r)); - let dim_active = dim_flow_active(&state.mode); - self.input.left_pan = !modal && in_viewport && !over_dim_editor && !dim_active; - self.input.pending_pressed = - self.input.pending_pressed.with(PointerButton::Primary); - if !modal - && in_viewport - && !over_dim_editor - && !dim_active - && !self.input.modifiers.shift_key() - && let Some(cursor) = self.input.cursor_px - { - if state.mode.active_tool().is_some() { - if let Some(world) = cursor_to_world(state.camera, cursor) { - try_place(state, world); - } - } else { - let additive = self.input.modifiers.control_key() - || self.input.modifiers.super_key(); - handle_viewport_click(state, cursor, additive); - } - } - } else { - self.input.left_pan = false; - state.mode = core::mem::take(&mut state.mode).end_drag(); - self.input.pending_released = - self.input.pending_released.with(PointerButton::Primary); - } - } - MouseButton::Right => { - if btn_state == ElementState::Pressed { - self.input.pending_pressed = - self.input.pending_pressed.with(PointerButton::Secondary); - if !modal && self.input.cursor_in(state.viewport_rect) { - state.mode = core::mem::take(&mut state.mode).clear_pending(); - } - } else { - self.input.pending_released = - self.input.pending_released.with(PointerButton::Secondary); - } - } - MouseButton::Middle => { - if btn_state == ElementState::Pressed { - let in_viewport = !modal && self.input.cursor_in(state.viewport_rect); - if in_viewport - && state.solid_view.is_some() - && let Some(region) = - solid_viewport_region(state.viewport_rect, state.surface.extent()) - && let Some(cursor) = self - .input - .cursor_px - .and_then(|p| viewport_local_point(p, region)) - { - state - .navigator - .begin_drag(drag_gesture(self.input.modifiers), cursor); - } else { - self.input.middle_pan = in_viewport; - } - self.input.pending_pressed = - self.input.pending_pressed.with(PointerButton::Middle); - } else { - self.input.middle_pan = false; - state.navigator.end_drag(); - self.input.pending_released = - self.input.pending_released.with(PointerButton::Middle); - } - } - MouseButton::Back | MouseButton::Forward | MouseButton::Other(_) => {} - } - } - - fn dispatch_keydown( - &mut self, - event_loop: &ActiveEventLoop, - physical_key: PhysicalKey, - logical_key: &Key, - text: Option<&winit::keyboard::SmolStr>, - repeat: bool, - ) { - let Some(state) = self.render.as_mut() else { - return; - }; - let physical_code = match physical_key { - PhysicalKey::Code(c) => Some(c), - PhysicalKey::Unidentified(_) => None, - }; - let logical_named = match logical_key { - Key::Named(nk) => winit_named_to_ui(*nk), - _ => None, - }; - let physical_named = physical_code.and_then(keycode_to_named); - let named = logical_named.or(physical_named); - let mods = self.input.modifier_mask(); - let repeat_space = repeat && named == Some(NamedKey::Space); - if let Some(named) = named { - if !repeat_space { - self.input - .pending_keys - .push(UiKeyEvent::new(UiKeyCode::Named(named), mods)); - } - } else if let Some(c) = physical_code.and_then(keycode_to_char) { - self.input.pending_keys.push(UiKeyEvent::new( - UiKeyCode::Char(KeyChar::from_char(c)), - mods, - )); - } - if let Some(typed) = text { - let filtered: String = typed.chars().filter(|c| !c.is_control()).collect(); - if !filtered.is_empty() { - self.input.pending_text.push_str(&filtered); - } - } - let suppress_camera = dim_flow_active(&state.mode) || state.focus.focused().is_some(); - if matches!(named, Some(NamedKey::Escape)) && state.notification.is_some() { - state.notification = None; - } - if let Some(code) = physical_code - && !suppress_camera - { - if state.solid_view.is_some() { - if let Some(next) = keyboard_camera3(code, &self.input, state) { - apply_nav_camera(state, next); - } - } else if let Some(next) = keyboard_camera(code, &self.input, state) { - state.camera = next; - } - } - let _ = event_loop; - } - - fn dispatch_redraw(&mut self, event_loop: &ActiveEventLoop) { - let (Some(state), Some(scheduler)) = (self.render.as_mut(), self.redraw.as_mut()) else { - return; - }; - render_frame(state, scheduler, &mut self.input); - if state.pending_exit { - event_loop.exit(); - return; - } - if let Some(deadline) = next_wake_deadline(state, &self.input) { - event_loop.set_control_flow(ControlFlow::WaitUntil(deadline)); - } else { - event_loop.set_control_flow(ControlFlow::Wait); - } - } -} - -fn next_wake_deadline(state: &RenderState, input: &InputState) -> Option { - let now = std::time::Instant::now(); - let native_poll = state - .native_picker - .is_some() - .then(|| now + std::time::Duration::from_millis(40)); - let step_poll = state - .step_job - .is_some() - .then(|| now + std::time::Duration::from_millis(40)); - let rename_deadline = state - .shell - .state - .feature_tree - .pending_rename - .map(|pending| { - let window = bone_ui::input::DoubleClickWindow::DEFAULT.duration(); - let slack = std::time::Duration::from_millis(8); - input.start + pending.at.duration() + window + slack - }); - let tween_tick = state - .view - .tween - .is_some() - .then(|| now + std::time::Duration::from_millis(8)); - [native_poll, step_poll, rename_deadline, tween_tick] - .into_iter() - .flatten() - .min() -} - -#[allow( - clippy::cast_precision_loss, - reason = "viewport pixel counts at any realistic display size fit f32 mantissa" -)] -#[allow( - clippy::too_many_lines, - reason = "splitting hides the per-outcome dispatch table" -)] -fn render_frame( - state: &mut RenderState, - scheduler: &mut redraw::Scheduler, - input_state: &mut InputState, -) { - poll_native_picker(state); - poll_step_job(state); - let extent = state.surface.extent(); - let layout_size = layout_size_from_extent(extent); - let theme = Arc::clone(&state.theme); - let mut input = input_state.drain_snapshot(); - step_view_tween(state, input.frame); - let mut hits = HitFrame::new(); - let mut a11y = AccessTreeBuilder::new(); - let scopes = scopes_for_mode(&state.mode); - let chrome_cursor_world = input_state - .cursor_px - .filter(|c| state.viewport_rect.contains(physical_to_layout_pos(*c))) - .and_then(|c| cursor_to_world(state.camera, c)); - let FrameOutcomes { - mut frame, - hotkey_actions, - dim: dim_outcome, - dim_conflict: conflict_outcome, - picker: picker_outcome, - overwrite: overwrite_outcome, - discard: discard_outcome, - step_progress: step_progress_outcome, - notification: notification_outcome, - shortcut_bar: shortcut_bar_outcome, - } = run_frame_ui( - state, - theme, - &mut input, - &mut hits, - &mut a11y, - &scopes, - layout_size, - chrome_cursor_world, - ); - state.dim_editor_bounds = apply_popup_overlays( - &mut frame.overlay_paints, - dim_outcome.as_ref(), - conflict_outcome.as_ref(), - picker_outcome.as_ref(), - overwrite_outcome.as_ref(), - discard_outcome.as_ref(), - step_progress_outcome.as_ref(), - notification_outcome.as_ref(), - shortcut_bar_outcome.as_ref(), - ); - apply_shortcut_bar_outcome(state, shortcut_bar_outcome.as_ref()); - let picker_kind = state.file_picker.as_ref().map(|s| s.kind); - if let (Some(cmd), Some(kind)) = (picker_outcome.and_then(|o| o.command), picker_kind) { - apply_picker_command(state, kind, cmd); - } - apply_overwrite_outcome(state, overwrite_outcome); - apply_discard_outcome(state, discard_outcome); - apply_step_progress_outcome(state, step_progress_outcome); - apply_notification_outcome(state, notification_outcome); - let claimed_pointer = dim_outcome.as_ref().is_some_and(|o| o.claimed_pointer); - let frame = if claimed_pointer { - suppress_pointer_activations(frame) - } else { - frame - }; - state.viewport_rect = frame.viewport_rect; - apply_resolve_and_kick_redraws(state, scheduler, &hits, &input); - if let Some(plane) = frame.plane_picked { - match ( - state.mode.is_sketch(), - state.plane_sketches.contains_key(&plane), - ) { - (true, _) => { - tracing::debug!(?plane, "plane pick ignored: already in sketch mode"); - } - (false, false) => { - tracing::debug!(?plane, "plane pick ignored: no sketch on this plane"); - } - (false, true) => {} - } - } - let escape_requested = hotkey_actions.contains(&sketch_mode::ESCAPE_ACTION); - apply_extrude_edit(state, frame.extrude_edit); - apply_extrude_confirm(state, frame.confirm_action); - let prev_active_sketch = active_sketch_id(&state.mode, &state.plane_sketches); - state.mode = next_mode( - core::mem::take(&mut state.mode), - &frame, - escape_requested, - &state.plane_sketches, - ); - apply_feature_tool(state, frame.activated_feature_tool); - apply_extrude_activation(state, frame.extrude_activated); - if active_sketch_id(&state.mode, &state.plane_sketches) != prev_active_sketch { - refresh_active_scene(state); - } - apply_dimension_outcome(state, dim_outcome); - apply_dim_conflict_outcome(state, conflict_outcome); - apply_dimension_request(state, frame.activated_dimension); - let dimension_edit = match frame.confirm_action { - Some(shell::ConfirmAction::Cancel) => None, - _ => frame.dimension_edit, - }; - apply_dimension_edit(state, dimension_edit); - sync_solid_view(state); - let solid_region = solid_viewport_region(state.viewport_rect, extent); - sync_solid_camera(state, solid_region); - let cursor_layout = input_state.cursor_px.map(physical_to_layout_pos); - apply_hotkey_actions(state, &hotkey_actions, cursor_layout, input.frame); - apply_view_pick(state, frame.view_pick, input.frame); - apply_view_menu(state, frame.view_menu, input.frame); - apply_menu_action(state, frame.menu_action); - apply_settings_change(state, frame.settings_change); - apply_relation_action(state, frame.activated_relation); - apply_sketch_rename(state, frame.sketch_rename.clone()); - apply_extrude_rename(state, frame.extrude_rename.clone()); - let cursor_world = input_state - .cursor_px - .filter(|c| state.viewport_rect.contains(physical_to_layout_pos(*c))) - .and_then(|c| cursor_to_world(state.camera, c)); - let preview = build_preview(&state.mode, &state.document, cursor_world, &state.camera); - let main_layer = build_chrome_layer(state, &frame.paints); - let overlay_layer = build_chrome_layer(state, &frame.overlay_paints); - let atlas_pixels = state.sdf_atlas.pixels(); - let atlas_version = state.sdf_atlas.version(); - let viewport_px = [ - extent.width().value() as f32, - extent.height().value() as f32, - ]; - let surface = &mut state.surface; - let renderer = &mut state.renderer; - let mut chrome_stage = ChromeStage { - chrome: &mut state.chrome_pipeline, - convex: &mut state.convex_pipeline, - stroke: &mut state.stroke_pipeline, - text: &mut state.text_pipeline, - atlas_pixels, - atlas_version, - viewport_px, - }; - let scene = &state.scene; - let style = &state.style; - renderer.prepare(scene, style); - let viewport = ViewportEncode { - solid: solid_region.and_then(|region| { - preview_solid_frame(state.solid_view.as_ref(), state.camera3, region) - }), - solid_renderer: &state.solid_renderer, - sketch_renderer: renderer, - scene, - preview: &preview, - camera: state.camera, - style, - }; - let pre_present = || scheduler.window().pre_present_notify(); - surface.render( - |encoder, color, pick, depth| { - viewport.encode(encoder, color, pick, depth); - chrome_stage.encode_layered(encoder, color, &main_layer, &overlay_layer); - }, - pre_present, - ); - scheduler.consume_kick(); -} - -struct ViewportEncode<'a> { - solid: Option<(&'a SolidViewData, SolidFrameView)>, - solid_renderer: &'a SolidRenderer, - sketch_renderer: &'a SketchRenderer, - scene: &'a SketchScene, - preview: &'a SketchPreview, - camera: Camera2, - style: &'a Style, -} - -impl ViewportEncode<'_> { - fn encode( - &self, - encoder: &mut wgpu::CommandEncoder, - color: &wgpu::TextureView, - pick: &wgpu::TextureView, - depth: &wgpu::TextureView, - ) { - let targets = RenderTargets::new(color, pick); - match &self.solid { - Some((view, frame)) => self.solid_renderer.encode_passes( - encoder, - targets, - depth, - &view.faces, - &view.edges, - &bone_render::SolidDisplay { - view: frame, - style: self.style, - mode: DisplayMode::ShadedWithEdges, - }, - ), - None => self.sketch_renderer.encode_passes( - encoder, - targets, - self.scene, - self.preview, - self.camera, - self.style, - ), - } - } -} - -struct ChromeStage<'a> { - chrome: &'a mut ChromePipeline, - convex: &'a mut ConvexPolyPipeline, - stroke: &'a mut StrokePipeline, - text: &'a mut ChromeTextPipeline, - atlas_pixels: &'a [u8], - atlas_version: u64, - viewport_px: [f32; 2], -} - -fn merge_layers(main: &[T], overlay: &[T]) -> Vec { - main.iter().chain(overlay.iter()).copied().collect() -} - -fn count_u32(len: usize) -> u32 { - let Ok(count) = u32::try_from(len) else { - unreachable!("instance counts fit in u32"); - }; - count -} - -impl ChromeStage<'_> { - fn encode_layered( - &mut self, - encoder: &mut wgpu::CommandEncoder, - color: &wgpu::TextureView, - main: &ChromeLayer, - overlay: &ChromeLayer, - ) { - let chrome = merge_layers(&main.chrome, &overlay.chrome); - let convex = merge_layers(&main.convex, &overlay.convex); - let stroke = merge_layers(&main.stroke, &overlay.stroke); - let glyphs = merge_layers(&main.glyphs, &overlay.glyphs); - self.chrome.upload(self.viewport_px, &chrome); - self.convex.upload(self.viewport_px, &convex); - self.stroke.upload(self.viewport_px, &stroke); - self.text.upload( - self.viewport_px, - self.atlas_pixels, - self.atlas_version, - &glyphs, - ); - let (mc, tc) = (count_u32(main.chrome.len()), count_u32(chrome.len())); - let (mv, tv) = (count_u32(main.convex.len()), count_u32(convex.len())); - let (ms, ts) = (count_u32(main.stroke.len()), count_u32(stroke.len())); - let (mg, tg) = (count_u32(main.glyphs.len()), count_u32(glyphs.len())); - self.chrome.draw_range(encoder, color, 0..mc); - self.convex.draw_range(encoder, color, 0..mv); - self.stroke.draw_range(encoder, color, 0..ms); - self.text.draw_range(encoder, color, 0..mg); - self.chrome.draw_range(encoder, color, mc..tc); - self.convex.draw_range(encoder, color, mv..tv); - self.stroke.draw_range(encoder, color, ms..ts); - self.text.draw_range(encoder, color, mg..tg); - } -} - -fn apply_resolve_and_kick_redraws( - state: &mut RenderState, - scheduler: &mut redraw::Scheduler, - hits: &HitFrame, - input: &InputSnapshot, -) { - state.hit_state = resolve(&state.hit_state, hits, input, state.focus.focused()); - if any_actionable_interaction(&state.hit_state) { - scheduler.kick(); - } -} - -fn any_actionable_interaction(hit_state: &HitState) -> bool { - use bone_ui::hit_test::InteractionState; - hit_state.interactions.values().any(|i| { - i.state.contains(InteractionState::CLICK) - || i.state.contains(InteractionState::DOUBLE_CLICK) - || i.state.contains(InteractionState::DRAG_START) - || i.state.contains(InteractionState::DRAG_RELEASE) - }) -} - -struct ChromeLayer { - chrome: Vec, - convex: Vec, - stroke: Vec, - glyphs: Vec, -} - -fn build_chrome_layer( - state: &mut RenderState, - paints: &[bone_ui::widgets::WidgetPaint], -) -> ChromeLayer { - let chrome = chrome::paint_to_instances(&state.theme, paints); - let convex = chrome::paint_to_convex_instances(paints); - let stroke = chrome::paint_to_stroke_instances(paints); - let spans = chrome::paint_to_text_spans(paints, &state.strings); - let glyphs = chrome::build_glyph_instances( - &spans, - &mut state.sdf_atlas, - &mut state.chrome_shaper, - &state.sans_font, - &state.mono_font, - ); - ChromeLayer { - chrome, - convex, - stroke, - glyphs, - } -} - -fn apply_hotkey_actions( - state: &mut RenderState, - actions: &[ActionId], - cursor_layout: Option, - now: FrameInstant, -) { - actions - .iter() - .filter_map(|a| hotkeys::command_for_action(*a)) - .for_each(|cmd| dispatch_hotkey_command(state, cmd, cursor_layout, now)); -} - -fn dispatch_hotkey_command( - state: &mut RenderState, - cmd: hotkeys::HotkeyCommand, - cursor_layout: Option, - now: FrameInstant, -) { - use hotkeys::HotkeyCommand as C; - match cmd { - C::Undo => { - if state.undo.undo(&mut state.document) { - refresh_active_scene(state); - } - } - C::Redo => { - if state.undo.redo(&mut state.document) { - refresh_active_scene(state); - } - } - C::NewDocument => apply_menu_action(state, Some(shell::MenuAction::NewDocument)), - C::OpenDocument => apply_menu_action(state, Some(shell::MenuAction::OpenDocument)), - C::SaveDocument => apply_menu_action(state, Some(shell::MenuAction::SaveDocument)), - C::ImportStep => apply_menu_action(state, Some(shell::MenuAction::ImportStep)), - C::ExportStep => apply_menu_action(state, Some(shell::MenuAction::ExportStep)), - C::ZoomFit => apply_menu_action(state, Some(shell::MenuAction::ZoomFit)), - C::Quit => { - state.pending_exit = true; - } - C::OpenShortcutBar => { - if state.shortcut_bar.is_none() { - let anchor = cursor_layout.unwrap_or(LayoutPos::ORIGIN); - state.shortcut_bar = Some(shortcut_bar::ShortcutBarState { anchor }); - } - } - C::ToggleConstruction => apply_construction_toggle(state), - C::Mirror => apply_mirror(state), - C::StandardView(view) => { - if view_nav_enabled(state) { - apply_view_pick(state, Some(view_cube::ViewPick::Standard(view)), now); - } - } - C::ToggleViewSelector => { - if view_nav_enabled(state) { - state.view.toggle_selector(); - } - } - C::ToggleViewCube => { - if view_nav_enabled(state) { - state.view.toggle_cube(); - } - } - C::SelectAll - | C::DeleteSelection - | C::EnterSketch - | C::SmartDimension - | C::Trim - | C::Extend => notify_stub(state, hotkeys::label_for_command(cmd)), - } -} - -fn apply_construction_toggle(state: &mut RenderState) { - let Mode::Sketch { sketch_id, .. } = state.mode else { - return; - }; - let entity_ids: Vec = state.selection.entity_ids().to_vec(); - if entity_ids.is_empty() { - return; - } - let Some(sketch) = state.document.sketch(sketch_id) else { - return; - }; - let pivot = entity_ids - .iter() - .find_map(|id| match sketch.entities().get(*id)? { - SketchEntity::Line(l) => Some(l.for_construction()), - SketchEntity::Arc(a) => Some(a.for_construction()), - SketchEntity::Circle(c) => Some(c.for_construction()), - SketchEntity::Point(_) => None, - }); - let Some(current) = pivot else { - return; - }; - let target = !current; - let snapshot = state.document.clone(); - let result = - entity_ids - .iter() - .try_fold(sketch.clone(), |acc, id| match acc.entities().get(*id) { - Some(SketchEntity::Point(_)) | None => Ok(acc), - Some(_) => acc - .apply(bone_document::SketchEdit::SetConstruction { - id: *id, - for_construction: target, - }) - .map(|(s, _)| s), - }); - match result { - Ok(next) => { - state.undo.record(snapshot); - state.document.replace_sketch(sketch_id, next); - refresh_active_scene(state); - } - Err(e) => tracing::warn!(error = %e, "construction toggle failed"), - } -} - -fn apply_mirror(state: &mut RenderState) { - let Mode::Sketch { sketch_id, .. } = state.mode else { - return; - }; - let entity_ids: Vec = state.selection.entity_ids().to_vec(); - if entity_ids.is_empty() { - return; - } - let Some(sketch) = state.document.sketch(sketch_id) else { - return; - }; - let axis_lines: Vec<(bone_types::SketchEntityId, LineData)> = entity_ids - .iter() - .filter_map(|id| match sketch.entities().get(*id)? { - SketchEntity::Line(l) => Some((*id, *l)), - _ => None, - }) - .collect(); - let [(axis_id, axis_line)] = axis_lines.as_slice() else { - notify_mirror_hint(state); - return; - }; - let axis_id = *axis_id; - let Some(pa) = lookup_point(sketch, axis_line.a()) else { - return; - }; - let Some(pb) = lookup_point(sketch, axis_line.b()) else { - return; - }; - let axis = MirrorAxis::from_points(pa, pb); - if axis.is_degenerate() { - notify_mirror_hint(state); - return; - } - let source_ids: std::collections::BTreeSet = entity_ids - .iter() - .copied() - .filter(|id| *id != axis_id) - .collect(); - if source_ids.is_empty() { - notify_mirror_hint(state); - return; - } - let snapshot = state.document.clone(); - let result = mirror_targets(sketch.clone(), &source_ids, axis_id, &axis); - match result { - Ok(next) => { - state.undo.record(snapshot); - state.document.replace_sketch(sketch_id, next); - state.selection = Selection::default(); - refresh_active_scene(state); - } - Err(e) => tracing::warn!(error = %e, "mirror failed"), - } -} - -fn notify_mirror_hint(state: &mut RenderState) { - state.notification = Some(Notification { - kind: NotificationKind::Info, - headline: strings::HOTKEY_LABEL_MIRROR, - detail: Some( - state - .strings - .resolve(strings::NOTIFY_MIRROR_SELECTION_HINT) - .to_owned(), - ), - }); -} - -#[derive(Copy, Clone, Debug)] -struct MirrorAxis { - anchor_x: f64, - anchor_y: f64, - direction_x: f64, - direction_y: f64, - length_sq: f64, -} - -impl MirrorAxis { - fn from_points(a: Point2, b: Point2) -> Self { - let (ax, ay) = a.coords_mm(); - let (bx, by) = b.coords_mm(); - let dx = bx - ax; - let dy = by - ay; - Self { - anchor_x: ax, - anchor_y: ay, - direction_x: dx, - direction_y: dy, - length_sq: dx * dx + dy * dy, - } - } - - fn is_degenerate(self) -> bool { - !self.length_sq.is_finite() || self.length_sq <= f64::EPSILON - } - - fn reflect(self, p: Point2) -> Point2 { - let (px, py) = p.coords_mm(); - let vx = px - self.anchor_x; - let vy = py - self.anchor_y; - let t = (vx * self.direction_x + vy * self.direction_y) / self.length_sq; - let foot_x = self.anchor_x + t * self.direction_x; - let foot_y = self.anchor_y + t * self.direction_y; - Point2::from_mm(2.0 * foot_x - px, 2.0 * foot_y - py) - } - - fn is_on_axis(self, p: Point2) -> bool { - let (px, py) = p.coords_mm(); - let vx = px - self.anchor_x; - let vy = py - self.anchor_y; - let cross = vx * self.direction_y - vy * self.direction_x; - let perp_dist_sq = cross * cross / self.length_sq; - perp_dist_sq < ON_AXIS_TOLERANCE_MM_SQ - } -} - -const ON_AXIS_TOLERANCE_MM_SQ: f64 = 1e-12; - -struct MirrorBuilder { - sketch: Sketch, - point_map: std::collections::BTreeMap, - entity_map: std::collections::BTreeMap, -} - -impl MirrorBuilder { - fn new(sketch: Sketch) -> Self { - Self { - sketch, - point_map: std::collections::BTreeMap::new(), - entity_map: std::collections::BTreeMap::new(), - } - } - - fn mirror_point( - &mut self, - id: bone_types::SketchEntityId, - axis: &MirrorAxis, - ) -> Result { - if let Some(&existing) = self.point_map.get(&id) { - return Ok(existing); - } - let pos = require_point(&self.sketch, id)?; - if axis.is_on_axis(pos) { - self.point_map.insert(id, id); - self.entity_map.insert(id, id); - return Ok(id); - } - let (next, new_id) = add_point(self.sketch.clone(), axis.reflect(pos))?; - self.sketch = next; - self.point_map.insert(id, new_id); - self.entity_map.insert(id, new_id); - Ok(new_id) - } - - fn mirror_entity( - &mut self, - id: bone_types::SketchEntityId, - axis: &MirrorAxis, - ) -> Result<(), bone_document::SketchEditError> { - if self.entity_map.contains_key(&id) { - return Ok(()); - } - let entity = self - .sketch - .entities() - .get(id) - .copied() - .ok_or(bone_document::SketchEditError::EntityNotFound(id))?; - match entity { - SketchEntity::Point(_) => { - self.mirror_point(id, axis)?; - } - SketchEntity::Line(l) => { - let new_a = self.mirror_point(l.a(), axis)?; - let new_b = self.mirror_point(l.b(), axis)?; - let (next, outcome) = - self.sketch - .clone() - .apply(bone_document::SketchEdit::AddEntity(SketchEntity::line( - new_a, - new_b, - l.for_construction(), - )))?; - let EditOutcome::Entity(new_id) = outcome else { - unreachable!("AddEntity yields Entity outcome") - }; - self.sketch = next; - self.entity_map.insert(id, new_id); - } - SketchEntity::Arc(a) => { - let new_center = self.mirror_point(a.center(), axis)?; - let new_start = self.mirror_point(a.start(), axis)?; - let new_end = self.mirror_point(a.end(), axis)?; - let (next, outcome) = - self.sketch - .clone() - .apply(bone_document::SketchEdit::AddEntity(SketchEntity::arc( - new_center, - new_end, - new_start, - a.for_construction(), - )))?; - let EditOutcome::Entity(new_id) = outcome else { - unreachable!("AddEntity yields Entity outcome") - }; - self.sketch = next; - self.entity_map.insert(id, new_id); - } - SketchEntity::Circle(c) => { - let new_center = self.mirror_point(c.center(), axis)?; - let (next, outcome) = - self.sketch - .clone() - .apply(bone_document::SketchEdit::AddEntity(SketchEntity::circle( - new_center, - c.radius(), - c.for_construction(), - )))?; - let EditOutcome::Entity(new_id) = outcome else { - unreachable!("AddEntity yields Entity outcome") - }; - self.sketch = next; - self.entity_map.insert(id, new_id); - } - } - Ok(()) - } -} - -fn mirror_targets( - sketch: Sketch, - source_ids: &std::collections::BTreeSet, - axis_id: bone_types::SketchEntityId, - axis: &MirrorAxis, -) -> Result { - let mut builder = MirrorBuilder::new(sketch); - source_ids - .iter() - .try_for_each(|id| builder.mirror_entity(*id, axis))?; - builder.entity_map.insert(axis_id, axis_id); - builder.sketch = symmetric_relations_for_pairs(builder.sketch, &builder.point_map, axis_id)?; - builder.sketch = copy_relations(builder.sketch, source_ids, axis_id, &builder.entity_map)?; - builder.sketch = copy_dimensions(builder.sketch, source_ids, axis_id, &builder.entity_map)?; - Ok(builder.sketch) -} - -fn symmetric_relations_for_pairs( - sketch: Sketch, - point_map: &std::collections::BTreeMap, - axis_id: bone_types::SketchEntityId, -) -> Result { - point_map - .iter() - .filter(|(source, mirrored)| source != mirrored) - .try_fold(sketch, |acc, (&source, &mirrored)| { - let (next, _) = acc.apply(bone_document::SketchEdit::AddRelation( - SketchRelation::Symmetric { - a: source, - b: mirrored, - axis: axis_id, - }, - ))?; - Ok(next) - }) -} - -fn copy_relations( - sketch: Sketch, - source_ids: &std::collections::BTreeSet, - axis_id: bone_types::SketchEntityId, - entity_map: &std::collections::BTreeMap, -) -> Result { - let relations: Vec = sketch - .relations() - .iter() - .map(|(_, r)| *r) - .filter(|r| relation_is_mirrorable(r, source_ids, axis_id)) - .filter_map(|r| remap_relation(r, entity_map)) - .collect(); - relations.into_iter().try_fold(sketch, |acc, rel| { - match acc - .clone() - .apply(bone_document::SketchEdit::AddRelation(rel)) - { - Ok((next, _)) => Ok(next), - Err(e) => { - tracing::warn!(error = %e, relation = ?rel, "mirror: skipped relation"); - Ok(acc) - } - } - }) -} - -fn relation_is_mirrorable( - rel: &SketchRelation, - source_ids: &std::collections::BTreeSet, - axis_id: bone_types::SketchEntityId, -) -> bool { - let refs: Vec<_> = rel.references().into_iter().collect(); - let touches_source = refs.iter().any(|id| source_ids.contains(id)); - let all_known = refs - .iter() - .all(|id| source_ids.contains(id) || *id == axis_id); - touches_source && all_known -} - -fn remap_relation( - rel: SketchRelation, - entity_map: &std::collections::BTreeMap, -) -> Option { - let get = |id| entity_map.get(&id).copied(); - match rel { - SketchRelation::Coincident(a, b) => Some(SketchRelation::Coincident(get(a)?, get(b)?)), - SketchRelation::Horizontal(a) => Some(SketchRelation::Horizontal(get(a)?)), - SketchRelation::Vertical(a) => Some(SketchRelation::Vertical(get(a)?)), - SketchRelation::Parallel(a, b) => Some(SketchRelation::Parallel(get(a)?, get(b)?)), - SketchRelation::Perpendicular(a, b) => { - Some(SketchRelation::Perpendicular(get(a)?, get(b)?)) - } - SketchRelation::Tangent(a, b) => Some(SketchRelation::Tangent(get(a)?, get(b)?)), - SketchRelation::Equal(a, b) => Some(SketchRelation::Equal(get(a)?, get(b)?)), - SketchRelation::Concentric(a, b) => Some(SketchRelation::Concentric(get(a)?, get(b)?)), - SketchRelation::Midpoint { point, line } => Some(SketchRelation::Midpoint { - point: get(point)?, - line: get(line)?, - }), - SketchRelation::Symmetric { a, b, axis } => Some(SketchRelation::Symmetric { - a: get(a)?, - b: get(b)?, - axis: get(axis)?, - }), - SketchRelation::Fix(a) => Some(SketchRelation::Fix(get(a)?)), - } -} - -fn copy_dimensions( - sketch: Sketch, - source_ids: &std::collections::BTreeSet, - axis_id: bone_types::SketchEntityId, - entity_map: &std::collections::BTreeMap, -) -> Result { - let dims: Vec = sketch - .dimensions() - .iter() - .map(|(_, d)| *d) - .filter(|d| dimension_is_mirrorable(d, source_ids, axis_id)) - .filter_map(|d| remap_dimension(d, entity_map)) - .collect(); - dims.into_iter().try_fold(sketch, |acc, dim| { - match acc - .clone() - .apply(bone_document::SketchEdit::AddDimension(dim)) - { - Ok((next, _)) => Ok(next), - Err(e) => { - tracing::warn!(error = %e, dimension = ?dim, "mirror: skipped dimension"); - Ok(acc) - } - } - }) -} - -fn dimension_is_mirrorable( - dim: &SketchDimension, - source_ids: &std::collections::BTreeSet, - axis_id: bone_types::SketchEntityId, -) -> bool { - let refs: Vec<_> = dim.references().into_iter().collect(); - let touches_source = refs.iter().any(|id| source_ids.contains(id)); - let all_known = refs - .iter() - .all(|id| source_ids.contains(id) || *id == axis_id); - touches_source && all_known -} - -fn remap_dimension( - dim: SketchDimension, - entity_map: &std::collections::BTreeMap, -) -> Option { - let get = |id| entity_map.get(&id).copied(); - match dim { - SketchDimension::Linear { a, b, value, kind } => Some(SketchDimension::Linear { - a: get(a)?, - b: get(b)?, - value, - kind, - }), - SketchDimension::Radius { - target, - value, - kind, - } => Some(SketchDimension::Radius { - target: get(target)?, - value, - kind, - }), - SketchDimension::Diameter { - target, - value, - kind, - } => Some(SketchDimension::Diameter { - target: get(target)?, - value, - kind, - }), - SketchDimension::Angular { a, b, value, kind } => Some(SketchDimension::Angular { - a: get(a)?, - b: get(b)?, - value, - kind, - }), - } -} - -fn add_point( - sketch: Sketch, - at: Point2, -) -> Result<(Sketch, bone_types::SketchEntityId), bone_document::SketchEditError> { - let (next, outcome) = sketch.apply(bone_document::SketchEdit::AddEntity( - SketchEntity::point(at), - ))?; - let EditOutcome::Entity(id) = outcome else { - unreachable!("AddEntity must yield Entity outcome") - }; - Ok((next, id)) -} - -fn require_point( - sketch: &Sketch, - id: bone_types::SketchEntityId, -) -> Result { - match sketch - .entities() - .get(id) - .ok_or(bone_document::SketchEditError::EntityNotFound(id))? - { - SketchEntity::Point(p) => Ok(p.at()), - _ => Err(bone_document::SketchEditError::ExpectedPoint(id)), - } -} - -fn lookup_point(sketch: &Sketch, id: bone_types::SketchEntityId) -> Option { - match sketch.entities().get(id)? { - SketchEntity::Point(p) => Some(p.at()), - _ => None, - } -} - -fn suppress_pointer_activations(frame: shell::ShellFrame) -> shell::ShellFrame { - shell::ShellFrame { - paints: frame.paints, - overlay_paints: frame.overlay_paints, - viewport_rect: frame.viewport_rect, - activated_tool: None, - activated_feature_tool: None, - activated_relation: None, - activated_dimension: None, - dimension_edit: None, - extrude_edit: frame.extrude_edit, - plane_picked: None, - sketch_activated: None, - sketch_rename: None, - extrude_activated: None, - extrude_rename: None, - exit_sketch: false, - confirm_action: None, - menu_action: None, - settings_change: None, - view_pick: None, - view_menu: None, - } -} - -#[allow( - clippy::too_many_arguments, - reason = "popup overlay dispatch threads every transient surface" -)] -fn apply_popup_overlays( - overlay: &mut Vec, - dim_outcome: Option<&DimensionEditorOutcome>, - conflict_outcome: Option<&DimConflictOutcome>, - picker_outcome: Option<&file_menu::PickerModalOutcome>, - overwrite_outcome: Option<&OverwriteOutcome>, - discard_outcome: Option<&DiscardOutcome>, - step_progress_outcome: Option<&StepProgressOutcome>, - notification_outcome: Option<&NotificationOutcome>, - shortcut_bar_outcome: Option<&shortcut_bar::ShortcutBarOutcome>, -) -> Option { - let dim_closing = matches!( - dim_outcome.map(|o| &o.action), - Some(DimensionEditorAction::Commit(_) | DimensionEditorAction::Cancel), - ); - extend_when_open( - overlay, - dim_outcome.map(|o| o.paints.as_slice()), - dim_closing, - ); - let conflict_closing = matches!( - conflict_outcome.map(|o| o.action), - Some(DimConflictAction::MakeDriven | DimConflictAction::Cancel), - ); - extend_when_open( - overlay, - conflict_outcome.map(|o| o.paints.as_slice()), - conflict_closing, - ); - let picker_closing = picker_outcome.and_then(|o| o.command.as_ref()).is_some(); - extend_when_open( - overlay, - picker_outcome.map(|o| o.paints.as_slice()), - picker_closing, - ); - let overwrite_closing = matches!( - overwrite_outcome.map(|o| o.action), - Some(OverwriteAction::Replace | OverwriteAction::Cancel), - ); - extend_when_open( - overlay, - overwrite_outcome.map(|o| o.paints.as_slice()), - overwrite_closing, - ); - let discard_closing = matches!( - discard_outcome.map(|o| o.action), - Some(DiscardAction::Confirm | DiscardAction::Cancel), - ); - extend_when_open( - overlay, - discard_outcome.map(|o| o.paints.as_slice()), - discard_closing, - ); - if let Some(progress) = step_progress_outcome { - overlay.extend(progress.paints.iter().cloned()); - } - if let Some(notification) = notification_outcome { - overlay.extend(notification.paints.iter().cloned()); - } - let bar_closing = shortcut_bar_outcome.is_some_and(|o| o.dismissed || o.activated.is_some()); - extend_when_open( - overlay, - shortcut_bar_outcome.map(|o| o.paints.as_slice()), - bar_closing, - ); - if dim_closing { - None - } else { - dim_outcome.map(|o| o.bounds) - } -} - -fn extend_when_open( - overlay: &mut Vec, - paints: Option<&[bone_ui::widgets::WidgetPaint]>, - closing: bool, -) { - if let Some(p) = paints - && !closing - { - overlay.extend(p.iter().cloned()); - } -} - -struct FrameOutcomes { - frame: shell::ShellFrame, - hotkey_actions: Vec, - dim: Option, - dim_conflict: Option, - picker: Option, - overwrite: Option, - discard: Option, - step_progress: Option, - notification: Option, - shortcut_bar: Option, -} - -fn strip_plain_letter_chords(input: &mut InputSnapshot) { - input.keys_pressed.retain(|event| { - !matches!(event.code, bone_ui::input::KeyCode::Char(_)) - || event.modifiers != ModifierMask::NONE - }); -} - -#[allow( - clippy::too_many_arguments, - reason = "run_frame_ui threads every per-frame UI subsystem" -)] -fn run_frame_ui( - state: &mut RenderState, - theme: Arc, - input: &mut InputSnapshot, - hits: &mut HitFrame, - a11y: &mut AccessTreeBuilder, - scopes: &HotkeyScopes, - layout_size: LayoutSize, - cursor_world: Option, -) -> FrameOutcomes { - let mut ctx = FrameCtx::new( - theme, - input, - &mut state.focus, - &state.hotkeys, - &state.strings, - hits, - &state.hit_state, - a11y, - &mut state.chrome_shaper, - ); - let extrude_status = state.extrude_preview.as_ref().map(ExtrudePreview::status); - let frame = state.shell.render( - &mut ctx, - &state.document, - &state.mode, - &state.selection, - &state.settings, - layout_size, - cursor_world, - state.camera3.filter(|_| state.solid_view.is_some()), - extrude_status, - &mut state.view, - ); - let dim_outcome = pending_dim(&state.mode).map(|pending| { - let live_anchor = state - .mode - .sketch_id() - .and_then(|id| state.document.sketch(id)) - .and_then(|s| smart_dimension::live_anchor(s, pending.proto)) - .unwrap_or(pending.anchor); - dimension_editor::render( - &mut ctx, - pending, - live_anchor, - &state.camera, - frame.viewport_rect, - &mut state.dim_editor, - ) - }); - let conflict_outcome = - dim_conflict_pending(&state.mode).map(|_| render_dim_conflict_modal(&mut ctx, layout_size)); - let picker_outcome = state - .file_picker - .as_mut() - .map(|session| file_menu::render(&mut ctx, session, layout_size)); - let overwrite_outcome = state - .pending_overwrite - .as_ref() - .map(|pending| render_overwrite_modal(&mut ctx, layout_size, pending)); - let discard_outcome = state - .pending_discard - .as_ref() - .map(|pending| render_discard_modal(&mut ctx, layout_size, pending)); - let reduce_motion = state.settings.reduce_motion; - let step_progress_outcome = state - .step_job - .as_ref() - .filter(|job| job.meta().show_progress) - .map(|job| render_step_progress_dialog(&mut ctx, layout_size, job, reduce_motion)); - let notification_outcome = state - .notification - .as_ref() - .map(|notification| render_notification_toast(&mut ctx, layout_size, notification)); - let is_sketch = state.mode.is_sketch(); - let shortcut_bar_outcome = state - .shortcut_bar - .map(|bar_state| shortcut_bar::render(&mut ctx, bar_state, layout_size, is_sketch)); - let any_modal_open = state.shell.state.keyboard_dialog_open - || conflict_outcome.is_some() - || dim_outcome.is_some() - || picker_outcome.is_some() - || overwrite_outcome.is_some() - || discard_outcome.is_some() - || step_progress_outcome.is_some() - || shortcut_bar_outcome.is_some(); - if !any_modal_open && ctx.focus.is_text_input_focused() { - strip_plain_letter_chords(ctx.input); - } - let mut actions = if any_modal_open { - Vec::new() - } else { - ctx.dispatch_hotkeys(scopes) - }; - if let Some(activated) = shortcut_bar_outcome.as_ref().and_then(|o| o.activated) { - actions.push(activated); - } - FrameOutcomes { - frame, - hotkey_actions: actions, - dim: dim_outcome, - dim_conflict: conflict_outcome, - picker: picker_outcome, - overwrite: overwrite_outcome, - discard: discard_outcome, - step_progress: step_progress_outcome, - notification: notification_outcome, - shortcut_bar: shortcut_bar_outcome, - } -} - -fn dim_conflict_pending(mode: &Mode) -> Option { - match mode.dim_flow() { - Some(DimensionFlow::Conflict(p)) => Some(p), - Some(DimensionFlow::Editing(_)) | None => None, - } -} - -#[derive(Clone, Debug, PartialEq)] -struct DimConflictOutcome { - paints: Vec, - action: DimConflictAction, -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -enum DimConflictAction { - Idle, - MakeDriven, - Cancel, -} - -fn render_dim_conflict_modal( - ctx: &mut FrameCtx<'_>, - layout_size: LayoutSize, -) -> DimConflictOutcome { - use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; - use bone_ui::{WidgetId, WidgetKey}; - let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); - let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(180.0)); - let id = WidgetId::ROOT.child(WidgetKey::new("dim.conflict")); - let response = show_confirmation( - ctx, - ConfirmationDialog { - id, - viewport, - size: dialog_size, - title: strings::DIM_CONFLICT_TITLE, - message: strings::DIM_CONFLICT_MESSAGE, - confirm_label: strings::DIM_CONFLICT_MAKE_DRIVEN, - cancel_label: strings::DIM_CONFLICT_CANCEL, - destructive: false, - }, - ); - let action = match response.outcome { - Some(ConfirmationOutcome::Confirm) => DimConflictAction::MakeDriven, - Some(ConfirmationOutcome::Cancel) => DimConflictAction::Cancel, - None => DimConflictAction::Idle, - }; - DimConflictOutcome { - paints: response.paint, - action, - } -} - -#[derive(Clone, Debug, PartialEq)] -struct OverwriteOutcome { - paints: Vec, - action: OverwriteAction, -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -enum OverwriteAction { - Idle, - Replace, - Cancel, -} - -fn render_overwrite_modal( - ctx: &mut FrameCtx<'_>, - layout_size: LayoutSize, - pending: &PendingOverwrite, -) -> OverwriteOutcome { - use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; - use bone_ui::{WidgetId, WidgetKey}; - let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); - let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(180.0)); - let id = WidgetId::ROOT.child(WidgetKey::new("file.overwrite")); - let (title, message) = match pending { - PendingOverwrite::Document(_) => ( - strings::FILE_OVERWRITE_TITLE, - strings::FILE_OVERWRITE_MESSAGE, - ), - PendingOverwrite::StepExport(_) => ( - strings::FILE_OVERWRITE_TITLE_STEP, - strings::FILE_OVERWRITE_MESSAGE_STEP, - ), - }; - let response = show_confirmation( - ctx, - ConfirmationDialog { - id, - viewport, - size: dialog_size, - title, - message, - confirm_label: strings::FILE_OVERWRITE_REPLACE, - cancel_label: strings::FILE_OVERWRITE_CANCEL, - destructive: true, - }, - ); - let action = match response.outcome { - Some(ConfirmationOutcome::Confirm) => OverwriteAction::Replace, - Some(ConfirmationOutcome::Cancel) => OverwriteAction::Cancel, - None => OverwriteAction::Idle, - }; - OverwriteOutcome { - paints: response.paint, - action, - } -} - -#[derive(Clone, Debug, PartialEq)] -struct DiscardOutcome { - paints: Vec, - action: DiscardAction, -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -enum DiscardAction { - Idle, - Confirm, - Cancel, -} - -fn render_discard_modal( - ctx: &mut FrameCtx<'_>, - layout_size: LayoutSize, - pending: &PendingDiscard, -) -> DiscardOutcome { - use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; - use bone_ui::{WidgetId, WidgetKey}; - let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); - let dialog_size = LayoutSize::new(LayoutPx::new(460.0), LayoutPx::new(190.0)); - let id = WidgetId::ROOT.child(WidgetKey::new("file.discard")); - let (title, message, confirm_label, cancel_label) = match pending { - PendingDiscard::New | PendingDiscard::Open(_) | PendingDiscard::ImportStep(_) => ( - strings::FILE_DISCARD_TITLE, - strings::FILE_DISCARD_MESSAGE, - strings::FILE_DISCARD_CONFIRM, - strings::FILE_DISCARD_CANCEL, - ), - PendingDiscard::InstallImported { .. } => ( - strings::FILE_IMPORT_REPLACE_TITLE, - strings::FILE_IMPORT_REPLACE_MESSAGE, - strings::FILE_IMPORT_REPLACE_CONFIRM, - strings::FILE_IMPORT_REPLACE_CANCEL, - ), - }; - let response = show_confirmation( - ctx, - ConfirmationDialog { - id, - viewport, - size: dialog_size, - title, - message, - confirm_label, - cancel_label, - destructive: true, - }, - ); - let action = match response.outcome { - Some(ConfirmationOutcome::Confirm) => DiscardAction::Confirm, - Some(ConfirmationOutcome::Cancel) => DiscardAction::Cancel, - None => DiscardAction::Idle, - }; - DiscardOutcome { - paints: response.paint, - action, - } -} - -fn apply_discard_outcome(state: &mut RenderState, outcome: Option) { - let Some(outcome) = outcome else { return }; - match outcome.action { - DiscardAction::Idle => {} - DiscardAction::Cancel => { - state.pending_discard = None; - } - DiscardAction::Confirm => { - let Some(pending) = state.pending_discard.take() else { - return; - }; - match pending { - PendingDiscard::New => apply_new_document(state), - PendingDiscard::Open(path) => apply_open_folder(state, path), - PendingDiscard::ImportStep(path) => start_step_import(state, path), - PendingDiscard::InstallImported { - document, - file_name, - } => { - install_imported_document(state, *document); - notify_info(state, strings::NOTIFY_IMPORTED, Some(file_name)); - } - } - } - } -} - -#[derive(Clone, Debug, PartialEq)] -struct StepProgressOutcome { - paints: Vec, - cancel_requested: bool, -} - -fn render_step_progress_dialog( - ctx: &mut FrameCtx<'_>, - layout_size: LayoutSize, - job: &step_jobs::StepJob, - reduce_motion: bool, -) -> StepProgressOutcome { - use bone_ui::widgets::{Dialog, DialogButton, LabelText, WidgetPaint, show_dialog}; - use bone_ui::{WidgetId, WidgetKey}; - let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); - let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(170.0)); - let id = WidgetId::ROOT.child(WidgetKey::new("step.progress")); - let cancel_id = id.child(WidgetKey::new("cancel")); - let title = match job { - step_jobs::StepJob::Import { .. } => strings::STEP_PROGRESS_TITLE_IMPORT, - step_jobs::StepJob::Export { .. } => strings::STEP_PROGRESS_TITLE_EXPORT, - }; - let buttons = [DialogButton::secondary( - cancel_id, - strings::STEP_PROGRESS_CANCEL, - )]; - let sweep = sweep_phase(ctx.input.frame, reduce_motion); - let file_name = job.meta().file_name.clone(); - let (response, ()) = show_dialog( - ctx, - Dialog::new(id, viewport, dialog_size, title, &buttons), - |ctx, body_rect, paint| { - let label_rect = LayoutRect::new( - LayoutPos::new( - LayoutPx::new(body_rect.origin.x.value() + 16.0), - LayoutPx::new(body_rect.origin.y.value() + 12.0), - ), - LayoutSize::new( - LayoutPx::saturating_nonneg(body_rect.size.width.value() - 32.0), - LayoutPx::new(20.0), - ), - ); - paint.push(WidgetPaint::Label { - rect: label_rect, - text: LabelText::Owned(file_name), - color: ctx.theme().colors.text_secondary(), - role: ctx.theme().typography.body, - }); - push_progress_bar(ctx, body_rect, sweep, paint); - }, - ); - StepProgressOutcome { - paints: response.paint, - cancel_requested: response.dismissed || response.activated == Some(cancel_id), - } -} - -const SWEEP_PERIOD_SECS: f32 = 1.2; -const SWEEP_SPAN: f32 = 0.3; - -fn sweep_phase(now: bone_ui::input::FrameInstant, reduce_motion: bool) -> f32 { - if reduce_motion { - return 0.5; - } - (now.duration().as_secs_f32() / SWEEP_PERIOD_SECS).fract() -} - -fn push_progress_bar( - ctx: &FrameCtx<'_>, - body: LayoutRect, - sweep: f32, - paint: &mut Vec, -) { - use bone_ui::widgets::WidgetPaint; - let track = LayoutRect::new( - LayoutPos::new( - LayoutPx::new(body.origin.x.value() + 16.0), - LayoutPx::new(body.origin.y.value() + 48.0), - ), - LayoutSize::new( - LayoutPx::saturating_nonneg(body.size.width.value() - 32.0), - LayoutPx::new(8.0), - ), - ); - paint.push(WidgetPaint::Surface { - rect: track, - fill: ctx.theme().colors.surface(bone_ui::theme::SurfaceLevel::L0), - border: Some(bone_ui::theme::Border { - width: bone_ui::theme::StrokeWidth::HAIRLINE, - color: ctx - .theme() - .colors - .neutral - .step(bone_ui::theme::Step12::SUBTLE_BORDER), - }), - radius: ctx.theme().radius.sm, - elevation: None, - }); - let start = sweep * (1.0 + SWEEP_SPAN) - SWEEP_SPAN; - let left = start.max(0.0); - let right = (start + SWEEP_SPAN).min(1.0); - if right <= left { - return; - } - let width = track.size.width.value(); - let fill_rect = LayoutRect::new( - LayoutPos::new( - LayoutPx::new(track.origin.x.value() + width * left), - track.origin.y, - ), - LayoutSize::new(LayoutPx::new(width * (right - left)), track.size.height), - ); - paint.push(WidgetPaint::Surface { - rect: fill_rect, - fill: ctx.theme().colors.accent_solid(), - border: None, - radius: ctx.theme().radius.sm, - elevation: None, - }); -} - -fn apply_step_progress_outcome(state: &RenderState, outcome: Option) { - let cancel = outcome.is_some_and(|o| o.cancel_requested); - if !cancel { - return; - } - if let Some(job) = state.step_job.as_ref() { - job.meta().request_cancel(); - } -} - -#[derive(Clone, Debug, PartialEq)] -struct NotificationOutcome { - paints: Vec, - dismissed: bool, -} - -fn render_notification_toast( - ctx: &mut FrameCtx<'_>, - layout_size: LayoutSize, - notification: &Notification, -) -> NotificationOutcome { - use bone_ui::widgets::{Button, ButtonVariant, WidgetPaint, show_button}; - use bone_ui::{WidgetId, WidgetKey}; - let theme = ctx.theme(); - let bg = match notification.kind { - NotificationKind::Info => theme.colors.surface(bone_ui::theme::SurfaceLevel::L2), - NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::SUBTLE_BG), - }; - let fg = match notification.kind { - NotificationKind::Info => theme.colors.text_primary(), - NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::TEXT_HIGH), - }; - let toast_width = layout_size.width.value().clamp(280.0, 420.0); - let toast_height = if notification.detail.is_some() { - 72.0 - } else { - 44.0 - }; - let margin = 24.0; - let toast_rect = LayoutRect::new( - LayoutPos::new( - LayoutPx::new(margin), - LayoutPx::new(layout_size.height.value() - toast_height - margin), - ), - LayoutSize::new(LayoutPx::new(toast_width), LayoutPx::new(toast_height)), - ); - let id = WidgetId::ROOT.child(WidgetKey::new("notification.toast")); - let mut paints = vec![WidgetPaint::Surface { - rect: toast_rect, - fill: bg, - border: Some(bone_ui::theme::Border { - width: bone_ui::theme::StrokeWidth::HAIRLINE, - color: theme.colors.neutral.step(bone_ui::theme::Step12::BORDER), - }), - radius: theme.radius.sm, - elevation: Some(theme.elevation.level1), - }]; - let headline_rect = LayoutRect::new( - LayoutPos::new( - LayoutPx::new(toast_rect.origin.x.value() + 16.0), - LayoutPx::new(toast_rect.origin.y.value() + 12.0), - ), - LayoutSize::new(LayoutPx::new(toast_width - 120.0), LayoutPx::new(20.0)), - ); - paints.push(WidgetPaint::Label { - rect: headline_rect, - text: bone_ui::widgets::LabelText::Key(notification.headline), - color: fg, - role: theme.typography.label, - }); - if let Some(detail) = ¬ification.detail { - let detail_rect = LayoutRect::new( - LayoutPos::new( - LayoutPx::new(toast_rect.origin.x.value() + 16.0), - LayoutPx::new(toast_rect.origin.y.value() + 36.0), - ), - LayoutSize::new(LayoutPx::new(toast_width - 32.0), LayoutPx::new(24.0)), - ); - paints.push(WidgetPaint::Label { - rect: detail_rect, - text: bone_ui::widgets::LabelText::Owned(detail.clone()), - color: theme.colors.text_secondary(), - role: theme.typography.caption, - }); - } - let dismiss_rect = LayoutRect::new( - LayoutPos::new( - LayoutPx::new(toast_rect.origin.x.value() + toast_width - 96.0), - LayoutPx::new(toast_rect.origin.y.value() + 8.0), - ), - LayoutSize::new(LayoutPx::new(84.0), LayoutPx::new(28.0)), - ); - let dismiss_id = id.child(WidgetKey::new("dismiss")); - let response = show_button( - ctx, - Button::new( - dismiss_id, - dismiss_rect, - strings::NOTIFY_DISMISS, - ButtonVariant::Secondary, - ), - ); - paints.extend(response.paint); - NotificationOutcome { - paints, - dismissed: response.activated, - } -} - -fn apply_notification_outcome(state: &mut RenderState, outcome: Option) { - let Some(outcome) = outcome else { return }; - if outcome.dismissed { - state.notification = None; - } -} - -fn apply_overwrite_outcome(state: &mut RenderState, outcome: Option) { - let Some(outcome) = outcome else { return }; - match outcome.action { - OverwriteAction::Idle => {} - OverwriteAction::Cancel => { - state.pending_overwrite = None; - } - OverwriteAction::Replace => match state.pending_overwrite.take() { - Some(PendingOverwrite::Document(folder)) => perform_save_to(state, folder), - Some(PendingOverwrite::StepExport(path)) => start_step_export(state, path), - None => {} - }, - } -} - -fn pending_dim(mode: &Mode) -> Option { - match mode.dim_flow() { - Some(DimensionFlow::Editing(p)) => Some(p), - Some(DimensionFlow::Conflict(_)) | None => None, - } -} - -fn apply_dimension_request(state: &mut RenderState, request: Option) { - let Some(request) = request else { return }; - let Mode::Sketch { .. } = state.mode else { - return; - }; - state.mode = core::mem::take(&mut state.mode).start_dimension(request); -} - -fn apply_dimension_outcome(state: &mut RenderState, outcome: Option) { - let Some(outcome) = outcome else { return }; - let Some(pending) = pending_dim(&state.mode) else { - return; - }; - match outcome.action { - DimensionEditorAction::Idle => {} - DimensionEditorAction::Cancel => { - state.mode = core::mem::take(&mut state.mode).cancel_dimension(); - state.dim_editor.close(); - } - DimensionEditorAction::Swap(next_proto) => { - state.mode = core::mem::take(&mut state.mode).start_dimension(PendingDimension { - proto: next_proto, - anchor: pending.anchor, - }); - } - DimensionEditorAction::Commit(value) => { - commit_pending_dimension(state, pending, value); - } - } -} - -fn apply_dim_conflict_outcome(state: &mut RenderState, outcome: Option) { - let Some(outcome) = outcome else { return }; - let Some(pending) = dim_conflict_pending(&state.mode) else { - return; - }; - match outcome.action { - DimConflictAction::Idle => {} - DimConflictAction::Cancel => { - state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); - } - DimConflictAction::MakeDriven => { - confirm_dim_conflict_make_driven(state, pending); - } - } -} - -fn commit_pending_dimension( - state: &mut RenderState, - pending: PendingDimension, - value: DimensionValue, -) { - let Mode::Sketch { sketch_id, .. } = state.mode else { - return; - }; - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { - return; - }; - let proto = match pending.proto.with_value(value) { - Ok(p) => p, - Err(e) => { - tracing::warn!(error = %e, ?pending.proto, ?value, "dimension value type mismatch"); - return; - } - }; - let (after_add, dim_id) = match sketch.clone().apply(SketchEdit::AddDimension(proto)) { - Ok((next, EditOutcome::Dimension(id))) => (next, id), - Ok(_) => { - tracing::warn!(?proto, "add dimension produced unexpected outcome"); - return; - } - Err(e) => { - tracing::warn!(error = %e, ?proto, "add dimension failed"); - return; - } - }; - let solved = match after_add.solve() { - Ok(s) => s, - Err(SolverError::OverDefined { .. }) => { - state.mode = core::mem::take(&mut state.mode).start_dim_conflict(PendingDimension { - proto, - anchor: pending.anchor, - }); - state.dim_editor.close(); - return; - } - Err(e) => { - tracing::warn!(error = %e, "solve after add dim did not converge; rejecting"); - return; - } - }; - state.undo.record(state.document.clone()); - state.document.replace_sketch(sketch_id, solved); - state.selection = Selection::Dimension(dim_id); - state.mode = core::mem::take(&mut state.mode).cancel_dimension(); - state.dim_editor.close(); - refresh_active_scene(state); -} - -fn confirm_dim_conflict_make_driven(state: &mut RenderState, pending: PendingDimension) { - let Mode::Sketch { sketch_id, .. } = state.mode else { - return; - }; - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { - return; - }; - let Some(measured) = sketch.measure(pending.proto) else { - tracing::warn!(?pending.proto, "measure failed for driven conversion; aborting"); - state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); - return; - }; - let Some(driven_proto) = driven_with_value(pending.proto, measured) else { - tracing::warn!(?pending.proto, "driven conversion failed"); - state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); - return; - }; - let (after_add, dim_id) = match sketch.apply(SketchEdit::AddDimension(driven_proto)) { - Ok((next, EditOutcome::Dimension(id))) => (next, id), - Ok(_) => { - tracing::warn!( - ?driven_proto, - "add driven dimension produced unexpected outcome" - ); - state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); - return; - } - Err(e) => { - tracing::warn!(error = %e, ?driven_proto, "add driven dimension failed"); - state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); - return; - } - }; - let solved = after_add.clone().solve().unwrap_or(after_add); - state.undo.record(state.document.clone()); - state.document.replace_sketch(sketch_id, solved); - state.selection = Selection::Dimension(dim_id); - state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); - refresh_active_scene(state); -} - -fn driven_with_value(proto: SketchDimension, value: DimensionValue) -> Option { - proto - .with_kind(DimensionKind::Driven) - .with_value(value) - .ok() -} - -fn apply_dimension_edit(state: &mut RenderState, edit: Option) { - let Some(edit) = edit else { return }; - let Mode::Sketch { sketch_id, .. } = state.mode else { - return; - }; - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { - return; - }; - let after = match sketch.apply(SketchEdit::UpdateDimensionValue { - id: edit.id, - value: edit.value, - }) { - Ok((next, _)) => next, - Err(e) => { - tracing::warn!(error = %e, ?edit, "update dimension value failed"); - return; - } - }; - let solved = match after.solve() { - Ok(s) => s, - Err(e) => { - tracing::warn!(error = %e, ?edit, "solve after dimension edit failed"); - return; - } - }; - state.undo.record(state.document.clone()); - state.document.replace_sketch(sketch_id, solved); - refresh_active_scene(state); -} - -fn apply_relation_action(state: &mut RenderState, relation: Option) { - let Some(relation) = relation else { return }; - let Mode::Sketch { sketch_id, .. } = state.mode else { - return; - }; - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { - return; - }; - let next = match sketch.apply(SketchEdit::AddRelation(relation)) { - Ok((next, _)) => next, - Err(e) => { - tracing::warn!(error = %e, ?relation, "add relation failed"); - return; - } - }; - state.undo.record(state.document.clone()); - state.document.replace_sketch(sketch_id, next); - state.selection = Selection::default(); - refresh_active_scene(state); -} - -fn apply_menu_action(state: &mut RenderState, action: Option) { - match action { - Some(shell::MenuAction::Quit) => { - state.pending_exit = true; - } - Some(shell::MenuAction::Undo) if state.undo.undo(&mut state.document) => { - refresh_active_scene(state); - } - Some(shell::MenuAction::Redo) if state.undo.redo(&mut state.document) => { - refresh_active_scene(state); - } - Some(shell::MenuAction::ZoomFit) => { - let solid_fit = state - .solid_view - .as_ref() - .map(|view| view.aabb) - .and_then(|aabb| { - let region = - solid_viewport_region(state.viewport_rect, state.surface.extent())?; - frame_current(state.camera3?, aabb, region.extent()).ok() - }); - if let Some(next) = solid_fit { - apply_nav_camera(state, next); - } else { - state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); - } - } - Some(shell::MenuAction::OpenSettings) => { - state.shell.state.settings_dialog_open = true; - } - Some(shell::MenuAction::OpenKeyboardCustomize) => { - state.shell.state.keyboard_dialog_open = true; - } - Some(shell::MenuAction::NewDocument) => { - request_new_document(state); - } - Some(shell::MenuAction::OpenDocument) => { - open_picker( - state, - bone_ui::widgets::FilePickerMode::Open, - file_menu::FileKind::Document, - None, - ); - } - Some(shell::MenuAction::SaveDocument) => { - apply_save_in_place(state); - } - Some(shell::MenuAction::SaveDocumentAs) => { - let seed = state.document.name().to_owned(); - open_picker( - state, - bone_ui::widgets::FilePickerMode::Save, - file_menu::FileKind::Document, - Some(seed), - ); - } - Some(shell::MenuAction::ImportStep) => { - if state.step_job.is_none() { - open_picker( - state, - bone_ui::widgets::FilePickerMode::Open, - file_menu::FileKind::Step, - None, - ); - } - } - Some(shell::MenuAction::ExportStep) => { - if state.step_job.is_none() { - let seed = format!("{}.step", state.document.name()); - open_picker( - state, - bone_ui::widgets::FilePickerMode::Save, - file_menu::FileKind::Step, - Some(seed), - ); - } - } - Some(shell::MenuAction::Undo | shell::MenuAction::Redo | shell::MenuAction::ExitSketch) - | None => {} - } -} - -fn request_new_document(state: &mut RenderState) { - if is_dirty(state) { - state.pending_discard = Some(PendingDiscard::New); - } else { - apply_new_document(state); - } -} - -fn request_open_folder(state: &mut RenderState, path: PathBuf) { - if is_dirty(state) { - state.pending_discard = Some(PendingDiscard::Open(path)); - } else { - apply_open_folder(state, path); - } -} - -fn request_import_step(state: &mut RenderState, path: PathBuf) { - if is_dirty(state) { - state.pending_discard = Some(PendingDiscard::ImportStep(path)); - } else { - start_step_import(state, path); - } -} - -fn start_step_import(state: &mut RenderState, path: PathBuf) { - if state.step_job.is_some() { - return; - } - match step_jobs::spawn_import(path, state.document.clone()) { - Ok(job) => state.step_job = Some(job), - Err(e) => notify_error(state, strings::NOTIFY_IMPORT_FAILED, e.to_string()), - } -} - -fn apply_export_step_as(state: &mut RenderState, path: PathBuf, via: PickedVia) { - let extension_appended = !file_menu::is_step_file(&path); - let path = file_menu::with_step_extension(path); - let unconfirmed = matches!(via, PickedVia::CustomPicker) || extension_appended; - if unconfirmed && path.is_file() { - state.pending_overwrite = Some(PendingOverwrite::StepExport(path)); - return; - } - start_step_export(state, path); -} - -fn start_step_export(state: &mut RenderState, path: PathBuf) { - if state.step_job.is_some() { - return; - } - match step_jobs::spawn_export(state.document.clone(), path) { - Ok(job) => state.step_job = Some(job), - Err(e) => notify_error(state, strings::NOTIFY_EXPORT_FAILED, e.to_string()), - } -} - -fn poll_step_job(state: &mut RenderState) { - let Some(job) = state.step_job.take() else { - return; - }; - match job { - step_jobs::StepJob::Import { rx, baseline, meta } => match step_jobs::poll(&rx) { - std::task::Poll::Pending => { - state.step_job = Some(step_jobs::StepJob::Import { rx, baseline, meta }); - } - std::task::Poll::Ready(result) => finish_import(state, result, &baseline, &meta), - }, - step_jobs::StepJob::Export { rx, meta } => match step_jobs::poll(&rx) { - std::task::Poll::Pending => { - state.step_job = Some(step_jobs::StepJob::Export { rx, meta }); - } - std::task::Poll::Ready(result) => finish_export(state, result, &meta), - }, - } -} - -fn finish_import( - state: &mut RenderState, - result: step_jobs::JobResult>, - baseline: &Document, - meta: &step_jobs::JobMeta, -) { - match result { - step_jobs::JobResult::Finished(_) if meta.cancel_requested() => { - tracing::info!(file = %meta.file_name, "discarding import that finished after cancel"); - } - step_jobs::JobResult::Finished(document) if *baseline != state.document => { - state.pending_discard = Some(PendingDiscard::InstallImported { - document, - file_name: meta.file_name.clone(), - }); - } - step_jobs::JobResult::Finished(document) => { - install_imported_document(state, *document); - notify_info( - state, - strings::NOTIFY_IMPORTED, - Some(meta.file_name.clone()), - ); - } - step_jobs::JobResult::Failed(bone_interop::StepError::Canceled) => { - tracing::info!(file = %meta.file_name, "step import canceled"); - } - step_jobs::JobResult::Failed(e) => { - tracing::warn!(error = %e, file = %meta.file_name, "step import failed"); - notify_error(state, strings::NOTIFY_IMPORT_FAILED, e.to_string()); - } - step_jobs::JobResult::WorkerLost => { - tracing::error!(file = %meta.file_name, "step import worker stopped before reporting a result"); - notify_error( - state, - strings::NOTIFY_IMPORT_FAILED, - "worker stopped before reporting a result".to_owned(), - ); - } - } -} - -fn finish_export( - state: &mut RenderState, - result: step_jobs::JobResult<()>, - meta: &step_jobs::JobMeta, -) { - match result { - step_jobs::JobResult::Finished(()) => { - notify_info( - state, - strings::NOTIFY_EXPORTED, - Some(meta.file_name.clone()), - ); - } - step_jobs::JobResult::Failed(bone_interop::StepError::Canceled) => { - tracing::info!(file = %meta.file_name, "step export canceled"); - } - step_jobs::JobResult::Failed(e) => { - tracing::warn!(error = %e, file = %meta.file_name, "step export failed"); - notify_error(state, strings::NOTIFY_EXPORT_FAILED, e.to_string()); - } - step_jobs::JobResult::WorkerLost => { - tracing::error!(file = %meta.file_name, "step export worker stopped before reporting a result"); - notify_error( - state, - strings::NOTIFY_EXPORT_FAILED, - "worker stopped before reporting a result".to_owned(), - ); - } - } -} - -fn is_dirty(state: &RenderState) -> bool { - state.last_saved.as_ref() != Some(&state.document) -} - -fn apply_new_document(state: &mut RenderState) { - let sketch = default_sketch(); - let scene = match SketchScene::extract(&sketch) { - Ok(s) => s, - Err(e) => { - tracing::warn!(error = %e, "scene extract failed on new document"); - notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); - return; - } - }; - let (document, sketch_id) = initial_document(sketch); - state.last_saved = Some(document.clone()); - state.document = document; - state.plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); - state.scene = scene; - state.mode = Mode::Idle; - state.selection = Selection::default(); - state.framed_extrude = None; - state.current_folder = None; - state.pending_overwrite = None; - let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { - unreachable!("UNDO_CAPACITY constant is non-zero"); - }; - state.undo = UndoStack::with_capacity(undo_capacity); - state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); -} - -fn open_picker( - state: &mut RenderState, - mode: bone_ui::widgets::FilePickerMode, - kind: file_menu::FileKind, - seed_filename: Option, -) { - if state.file_picker.is_some() || state.native_picker.is_some() { - return; - } - let starting_folder = state - .current_folder - .as_ref() - .map(|f| f.path().to_owned()) - .or_else(|| { - state - .documents_root - .is_dir() - .then(|| state.documents_root.clone()) - }); - let title_key = file_menu::title_key(kind, mode); - let accept_key = file_menu::accept_key(kind, mode); - let title = state.strings.resolve(title_key).to_owned(); - let accept_label = state.strings.resolve(accept_key).to_owned(); - let native_req = native_picker::Request { - mode, - kind, - title: title.as_str(), - accept_label: accept_label.as_str(), - seed_filename: seed_filename.as_deref(), - current_folder: starting_folder.as_deref(), - }; - match native_picker::spawn(native_req) { - Ok(handle) => { - state.native_picker = Some(handle); - return; - } - Err(native_picker::SpawnError::Unsupported) => { - tracing::debug!("native picker unavailable, falling back to custom picker"); - } - } - open_custom_picker(state, mode, kind, seed_filename); -} - -fn open_custom_picker( - state: &mut RenderState, - mode: bone_ui::widgets::FilePickerMode, - kind: file_menu::FileKind, - seed_filename: Option, -) { - let scan = match kind { - file_menu::FileKind::Document => file_menu::scan_document_folders(&state.documents_root), - file_menu::FileKind::Step => file_menu::scan_step_files(&state.documents_root), - }; - let entries = match scan { - Ok(v) => v, - Err(e) => { - tracing::warn!(error = %e, path = %state.documents_root.display(), "scan documents root failed"); - notify_error(state, strings::NOTIFY_SCAN_FAILED, e.to_string()); - Vec::new() - } - }; - state.file_picker = Some(file_menu::FilePickerSession::open( - state.documents_root.clone(), - mode, - kind, - seed_filename, - entries, - )); -} - -fn poll_native_picker(state: &mut RenderState) { - let Some(handle) = state.native_picker.as_ref() else { - return; - }; - let outcome = match handle.poll() { - std::task::Poll::Pending => return, - std::task::Poll::Ready(o) => o, - }; - let mode = handle.mode; - let kind = handle.kind; - state.native_picker = None; - match outcome { - native_picker::NativeOutcome::Path(path) => { - route_picked_path(state, kind, mode, path, PickedVia::NativePortal); - } - native_picker::NativeOutcome::Cancelled => {} - native_picker::NativeOutcome::Error(message) => { - tracing::warn!(error = %message, "native picker errored, falling back to custom picker"); - let seed = matches!(mode, bone_ui::widgets::FilePickerMode::Save).then(|| match kind { - file_menu::FileKind::Document => state.document.name().to_owned(), - file_menu::FileKind::Step => format!("{}.step", state.document.name()), - }); - open_custom_picker(state, mode, kind, seed); - } - } -} - -fn route_picked_path( - state: &mut RenderState, - kind: file_menu::FileKind, - mode: bone_ui::widgets::FilePickerMode, - path: PathBuf, - via: PickedVia, -) { - match (kind, mode) { - (file_menu::FileKind::Document, bone_ui::widgets::FilePickerMode::Open) => { - request_open_folder(state, path); - } - (file_menu::FileKind::Document, bone_ui::widgets::FilePickerMode::Save) => { - apply_save_as(state, path); - } - (file_menu::FileKind::Step, bone_ui::widgets::FilePickerMode::Open) => { - request_import_step(state, path); - } - (file_menu::FileKind::Step, bone_ui::widgets::FilePickerMode::Save) => { - apply_export_step_as(state, path, via); - } - } -} - -fn apply_save_in_place(state: &mut RenderState) { - let Some(folder) = state.current_folder.clone() else { - let seed = state.document.name().to_owned(); - open_picker( - state, - bone_ui::widgets::FilePickerMode::Save, - file_menu::FileKind::Document, - Some(seed), - ); - return; - }; - if let Err(e) = bone_document::save(&state.document, &folder) { - tracing::warn!(error = %e, path = %folder.path().display(), "save failed"); - notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string()); - return; - } - state.last_saved = Some(state.document.clone()); - notify_info(state, strings::NOTIFY_SAVED, None); -} - -fn apply_picker_command( - state: &mut RenderState, - kind: file_menu::FileKind, - command: file_menu::PickerCommand, -) { - state.file_picker = None; - match command { - file_menu::PickerCommand::Cancel => {} - file_menu::PickerCommand::Open(path) => { - route_picked_path( - state, - kind, - bone_ui::widgets::FilePickerMode::Open, - path, - PickedVia::CustomPicker, - ); - } - file_menu::PickerCommand::SaveAs(path) => { - route_picked_path( - state, - kind, - bone_ui::widgets::FilePickerMode::Save, - path, - PickedVia::CustomPicker, - ); - } - } -} - -fn apply_open_folder(state: &mut RenderState, path: PathBuf) { - let folder = DocumentFolder::new(path); - let document = match bone_document::load(&folder) { - Ok(d) => d, - Err(e) => { - tracing::warn!(error = %e, path = %folder.path().display(), "load failed"); - notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); - return; - } - }; - install_loaded_document(state, document, Some(folder)); -} - -fn apply_save_as(state: &mut RenderState, path: PathBuf) { - let folder = DocumentFolder::new(path); - let in_place = state - .current_folder - .as_ref() - .is_some_and(|current| same_folder(current.path(), folder.path())); - if folder.document_file().is_file() && !in_place { - state.pending_overwrite = Some(PendingOverwrite::Document(folder)); - return; - } - perform_save_to(state, folder); -} - -fn perform_save_to(state: &mut RenderState, folder: DocumentFolder) { - let prior_name = state.document.name().to_owned(); - state - .document - .set_name(folder_display_name(&folder, &state.strings)); - if let Err(e) = bone_document::save(&state.document, &folder) { - tracing::warn!(error = %e, path = %folder.path().display(), "save as failed"); - state.document.set_name(prior_name); - notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string()); - return; - } - state.current_folder = Some(folder); - state.last_saved = Some(state.document.clone()); - notify_info(state, strings::NOTIFY_SAVED, None); -} - -fn folder_display_name(folder: &DocumentFolder, string_table: &StringTable) -> String { - folder.path().file_name().map_or_else( - || { - string_table - .resolve(strings::DEFAULT_DOCUMENT_NAME) - .to_owned() - }, - |s| s.to_string_lossy().into_owned(), - ) -} - -fn same_folder(a: &Path, b: &Path) -> bool { - match (resolve_path(a), resolve_path(b)) { - (Some(x), Some(y)) => x == y, - _ => false, - } -} - -fn resolve_path(path: &Path) -> Option { - if let Ok(canon) = std::fs::canonicalize(path) { - return Some(canon); - } - let parent = path.parent()?; - let file_name = path.file_name()?; - let parent_canon = std::fs::canonicalize(parent).ok()?; - Some(parent_canon.join(file_name)) -} - -fn notify_error(state: &mut RenderState, headline: bone_ui::strings::StringKey, detail: String) { - state.notification = Some(Notification { - kind: NotificationKind::Error, - headline, - detail: Some(detail), - }); -} - -fn notify_info( - state: &mut RenderState, - headline: bone_ui::strings::StringKey, - detail: Option, -) { - state.notification = Some(Notification { - kind: NotificationKind::Info, - headline, - detail, - }); -} - -fn notify_stub(state: &mut RenderState, label: bone_ui::strings::StringKey) { - let detail = state.strings.resolve(label).to_owned(); - tracing::info!(label = %detail, "stub action invoked"); - notify_info(state, strings::NOTIFY_COMING_SOON, Some(detail)); -} - -fn apply_shortcut_bar_outcome( - state: &mut RenderState, - outcome: Option<&shortcut_bar::ShortcutBarOutcome>, -) { - let Some(outcome) = outcome else { return }; - if outcome.dismissed || outcome.activated.is_some() { - state.shortcut_bar = None; - } -} - -fn install_imported_document(state: &mut RenderState, document: Document) { - install_loaded_document(state, document, None); - state.last_saved = None; -} - -fn install_loaded_document( - state: &mut RenderState, - document: Document, - folder: Option, -) { - let plane_sketches = plane_sketches_from(&document); - let active_sketch_id = plane_sketches.get(&Plane::Xy).copied(); - state.last_saved = Some(document.clone()); - state.document = document; - state.plane_sketches = plane_sketches; - state.mode = Mode::Idle; - state.selection = Selection::default(); - state.framed_extrude = None; - state.current_folder = folder; - state.pending_overwrite = None; - let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { - unreachable!("UNDO_CAPACITY constant is non-zero"); - }; - state.undo = UndoStack::with_capacity(undo_capacity); - let scene_attempt = active_sketch_id - .and_then(|id| state.document.sketch(id)) - .map(SketchScene::extract); - state.scene = match scene_attempt { - None => SketchScene::empty(), - Some(Ok(scene)) => scene, - Some(Err(e)) => { - tracing::warn!(error = %e, "scene extract on load failed"); - notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); - SketchScene::empty() - } - }; - state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); -} - -fn plane_sketches_from(document: &Document) -> BTreeMap { - document - .sketches() - .map(|(id, _)| (Plane::Xy, id)) - .take(1) - .collect() -} - -fn persist_settings(state: &RenderState) { - settings::save(&state.settings); -} - -fn apply_settings_change(state: &mut RenderState, change: Option) { - let Some(next) = change else { return }; - let overrides_changed = next.hotkey_overrides != state.settings.hotkey_overrides; - if !overrides_changed { - state.settings = next; - persist_settings(state); - return; - } - match hotkeys::compose_table(&next.hotkey_overrides) { - Ok(table) => { - state.hotkeys = table; - state.settings = next; - persist_settings(state); - } - Err(error) => { - tracing::warn!(?error, "hotkey override rejected, retaining prior settings"); - state.shell.state.hotkey_capture.clear(); - notify_error(state, strings::NOTIFY_HOTKEY_CONFLICT, format!("{error}")); - } - } -} - -fn apply_sketch_rename(state: &mut RenderState, request: Option) { - let Some(req) = request else { return }; - apply_sketch_rename_into(&mut state.document, &mut state.undo, req); -} - -fn apply_extrude_rename(state: &mut RenderState, request: Option) { - let Some(req) = request else { return }; - apply_extrude_rename_into(&mut state.document, &mut state.undo, req); -} - -fn apply_extrude_rename_into( - document: &mut Document, - undo: &mut UndoStack, - request: shell::ExtrudeRenameRequest, -) { - let shell::ExtrudeRenameRequest { id, label } = request; - let trimmed = label.trim(); - let Some(current) = document.extrude_label(id) else { - return; - }; - if trimmed.is_empty() || current == trimmed { - return; - } - let snapshot = document.clone(); - match document.rename_extrude(id, &label) { - Ok(()) => undo.record(snapshot), - Err(e) => tracing::warn!(error = %e, ?id, "extrude rename rejected"), - } -} - -fn apply_sketch_rename_into( - document: &mut Document, - undo: &mut UndoStack, - request: shell::SketchRenameRequest, -) { - let shell::SketchRenameRequest { id, label } = request; - let trimmed = label.trim(); - let Some(current) = document.sketch_label(id) else { - return; - }; - if trimmed.is_empty() || current == trimmed { - return; - } - let snapshot = document.clone(); - match document.rename_sketch(id, &label) { - Ok(()) => undo.record(snapshot), - Err(e) => tracing::warn!(error = %e, ?id, "sketch rename rejected"), - } -} - -enum RunMode { - Window, - Gallery(PathBuf), -} - -fn parse_mode(args: impl IntoIterator) -> Result { - let collected: Vec = args.into_iter().skip(1).collect(); - parse_args(&collected, RunMode::Window) -} - -fn parse_args(args: &[String], acc: RunMode) -> Result { - match args { - [] => Ok(acc), - [first, second, rest @ ..] if first == "--gallery" => { - parse_args(rest, RunMode::Gallery(PathBuf::from(second))) - } - [only] if only == "--gallery" => Err(AppError::MissingArg { - flag: "--gallery ", - }), - [unknown, ..] => Err(AppError::UnknownArg(unknown.clone())), - } -} - -fn emit_gallery(out_dir: &Path) -> Result<(), AppError> { - std::fs::create_dir_all(out_dir).map_err(|e| AppError::GalleryDir { - path: out_dir.to_path_buf(), - source: e, - })?; - [ - (ThemeMode::Light, Theme::light()), - (ThemeMode::Dark, Theme::dark()), - ] - .into_iter() - .try_for_each(|(mode, theme)| -> Result<(), AppError> { - let mut state = GalleryState::new(); - let paint = render(Arc::new(theme.clone()), &mut state); - let rgba = rasterize(&theme, &paint, GALLERY_CANVAS, StringTable::empty()); - let png = encode_png(&rgba, GALLERY_CANVAS) - .map_err(|source| AppError::GalleryEncode { mode, source })?; - let target = out_dir.join(format!("gallery_{mode}.png")); - std::fs::write(&target, &png).map_err(|source| AppError::GalleryWrite { - path: target.clone(), - source, - })?; - tracing::info!(path = %target.display(), "gallery png written"); - Ok(()) - }) -} - -fn main() -> Result<(), AppError> { - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_FILTER)), - ) - .init(); - match parse_mode(std::env::args())? { - RunMode::Gallery(dir) => emit_gallery(&dir), - RunMode::Window => run_window(), - } -} - -fn keycode_to_named(code: KeyCode) -> Option { - match code { - KeyCode::Tab => Some(NamedKey::Tab), - KeyCode::Enter | KeyCode::NumpadEnter => Some(NamedKey::Enter), - KeyCode::Escape => Some(NamedKey::Escape), - KeyCode::Backspace => Some(NamedKey::Backspace), - KeyCode::Delete => Some(NamedKey::Delete), - KeyCode::Space => Some(NamedKey::Space), - KeyCode::ArrowUp => Some(NamedKey::ArrowUp), - KeyCode::ArrowDown => Some(NamedKey::ArrowDown), - KeyCode::ArrowLeft => Some(NamedKey::ArrowLeft), - KeyCode::ArrowRight => Some(NamedKey::ArrowRight), - KeyCode::Home => Some(NamedKey::Home), - KeyCode::End => Some(NamedKey::End), - KeyCode::PageUp => Some(NamedKey::PageUp), - KeyCode::PageDown => Some(NamedKey::PageDown), - KeyCode::F2 => Some(NamedKey::F2), - _ => None, - } -} - -fn winit_named_to_ui(named: WinitNamed) -> Option { - match named { - WinitNamed::Tab => Some(NamedKey::Tab), - WinitNamed::Enter => Some(NamedKey::Enter), - WinitNamed::Escape => Some(NamedKey::Escape), - WinitNamed::Backspace => Some(NamedKey::Backspace), - WinitNamed::Delete => Some(NamedKey::Delete), - WinitNamed::Space => Some(NamedKey::Space), - WinitNamed::ArrowUp => Some(NamedKey::ArrowUp), - WinitNamed::ArrowDown => Some(NamedKey::ArrowDown), - WinitNamed::ArrowLeft => Some(NamedKey::ArrowLeft), - WinitNamed::ArrowRight => Some(NamedKey::ArrowRight), - WinitNamed::Home => Some(NamedKey::Home), - WinitNamed::End => Some(NamedKey::End), - WinitNamed::PageUp => Some(NamedKey::PageUp), - WinitNamed::PageDown => Some(NamedKey::PageDown), - WinitNamed::F2 => Some(NamedKey::F2), - _ => None, - } -} - -fn keycode_to_char(code: KeyCode) -> Option { - match code { - KeyCode::KeyA => Some('a'), - KeyCode::KeyB => Some('b'), - KeyCode::KeyC => Some('c'), - KeyCode::KeyD => Some('d'), - KeyCode::KeyE => Some('e'), - KeyCode::KeyF => Some('f'), - KeyCode::KeyG => Some('g'), - KeyCode::KeyH => Some('h'), - KeyCode::KeyI => Some('i'), - KeyCode::KeyJ => Some('j'), - KeyCode::KeyK => Some('k'), - KeyCode::KeyL => Some('l'), - KeyCode::KeyM => Some('m'), - KeyCode::KeyN => Some('n'), - KeyCode::KeyO => Some('o'), - KeyCode::KeyP => Some('p'), - KeyCode::KeyQ => Some('q'), - KeyCode::KeyR => Some('r'), - KeyCode::KeyS => Some('s'), - KeyCode::KeyT => Some('t'), - KeyCode::KeyU => Some('u'), - KeyCode::KeyV => Some('v'), - KeyCode::KeyW => Some('w'), - KeyCode::KeyX => Some('x'), - KeyCode::KeyY => Some('y'), - KeyCode::KeyZ => Some('z'), - KeyCode::Digit0 => Some('0'), - KeyCode::Digit1 => Some('1'), - KeyCode::Digit2 => Some('2'), - KeyCode::Digit3 => Some('3'), - KeyCode::Digit4 => Some('4'), - KeyCode::Digit5 => Some('5'), - KeyCode::Digit6 => Some('6'), - KeyCode::Digit7 => Some('7'), - KeyCode::Digit8 => Some('8'), - KeyCode::Digit9 => Some('9'), - _ => None, + .init(); + match parse_mode(std::env::args())? { + RunMode::Gallery(dir) => emit_gallery(&dir), + RunMode::Window => run_window(), } } fn run_window() -> Result<(), AppError> { let event_loop = EventLoop::new()?; event_loop.set_control_flow(ControlFlow::Wait); - let mut app = App { - redraw: None, - render: None, - input: InputState::default(), - }; + let mut app = App { running: None }; event_loop.run_app(&mut app)?; Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::sketch_mode::SketchSession; - use bone_ui::hotkey::KeyChord; - use bone_ui::input::{KeyChar, KeyCode, KeyEvent, NamedKey}; - - #[test] - fn strip_plain_letter_chords_removes_chars_with_no_modifiers() { - let mut input = InputSnapshot::idle(FrameInstant::ZERO); - let plain_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::NONE); - let ctrl_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL); - let esc = KeyEvent::new(KeyCode::Named(NamedKey::Escape), ModifierMask::NONE); - input.keys_pressed = vec![plain_s, ctrl_s, esc]; - strip_plain_letter_chords(&mut input); - assert_eq!( - input.keys_pressed, - vec![ctrl_s, esc], - "strip removes plain letters, keeps modified chords and named keys" - ); - } - - #[test] - fn strip_plain_letter_chords_is_idempotent_when_no_chars() { - let mut input = InputSnapshot::idle(FrameInstant::ZERO); - let enter = KeyEvent::new(KeyCode::Named(NamedKey::Enter), ModifierMask::NONE); - input.keys_pressed = vec![enter]; - strip_plain_letter_chords(&mut input); - assert_eq!(input.keys_pressed, vec![enter]); - } - - #[test] - fn cursor_to_world_at_window_center_equals_camera_pan() { - let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100)); - let camera = Camera2::new(extent) - .with_pan(Vec2::from_mm(7.0, -3.0)) - .with_zoom(PixelsPerMm::new(5.0)); - let Some(world) = cursor_to_world(camera, PhysicalPosition::new(100.0, 50.0)) else { - panic!("center maps"); - }; - let (x, y) = world.coords_mm(); - assert!((x - 7.0).abs() < 1e-9); - assert!((y - -3.0).abs() < 1e-9); - } - - #[test] - fn cursor_to_world_inverts_y_so_up_in_window_is_up_in_world() { - let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100)); - let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(10.0)); - let Some(above) = cursor_to_world(camera, PhysicalPosition::new(100.0, 0.0)) else { - panic!("top"); - }; - let Some(below) = cursor_to_world(camera, PhysicalPosition::new(100.0, 100.0)) else { - panic!("bottom"); - }; - let (_, ya) = above.coords_mm(); - let (_, yb) = below.coords_mm(); - assert!(ya > yb); - } - - #[test] - fn cursor_to_world_rejects_zero_extent() { - let extent = ViewportExtent::new(ViewportPx::new(0), ViewportPx::new(100)); - let camera = Camera2::new(extent); - assert!(cursor_to_world(camera, PhysicalPosition::new(0.0, 0.0)).is_none()); - } - - fn empty_frame() -> shell::ShellFrame { - shell::ShellFrame { - paints: Vec::new(), - overlay_paints: Vec::new(), - viewport_rect: empty_rect(), - activated_tool: None, - activated_feature_tool: None, - activated_relation: None, - activated_dimension: None, - dimension_edit: None, - extrude_edit: None, - plane_picked: None, - sketch_activated: None, - sketch_rename: None, - extrude_activated: None, - extrude_rename: None, - exit_sketch: false, - confirm_action: None, - menu_action: None, - settings_change: None, - view_pick: None, - view_menu: None, - } - } - - fn xy_only() -> BTreeMap { - BTreeMap::from([(Plane::Xy, SketchId::default())]) - } - - #[test] - fn classify_extrude_profile_separates_no_sketch_from_unique() { - let empty = Document::new(DocumentId::default(), "Untitled".to_owned()); - assert!(matches!( - super::classify_extrude_profile(&empty), - super::ProfileChoice::NoSketch - )); - - let (one, id) = super::initial_document(Sketch::new(Plane::Xy.basis())); - assert!(matches!( - super::classify_extrude_profile(&one), - super::ProfileChoice::Unique(found) if found == id - )); - } - - fn rectangle_sketch() -> Sketch { - let sketch = Sketch::new(Plane::Xy.basis()); - let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); - let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); - let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(10.0, 6.0)); - let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(0.0, 6.0)); - let (sketch, _) = tools::add_line(sketch, p0, p1, false); - let (sketch, _) = tools::add_line(sketch, p1, p2, false); - let (sketch, _) = tools::add_line(sketch, p2, p3, false); - let (sketch, _) = tools::add_line(sketch, p3, p0, false); - sketch - } - - fn tall_rectangle_sketch() -> Sketch { - let sketch = Sketch::new(Plane::Xy.basis()); - let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); - let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); - let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(10.0, 20.0)); - let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(0.0, 20.0)); - let (sketch, _) = tools::add_line(sketch, p0, p1, false); - let (sketch, _) = tools::add_line(sketch, p1, p2, false); - let (sketch, _) = tools::add_line(sketch, p2, p3, false); - let (sketch, _) = tools::add_line(sketch, p3, p0, false); - sketch - } - - #[test] - fn extrude_preview_cache_is_current_only_for_the_same_feature_and_sketch_version() { - let id = SketchId::default(); - let feature = sketch_mode::default_extrude_feature(id); - let base_version = Sketch::new(Plane::Xy.basis()).version(); - let edited_version = rectangle_sketch().version(); - assert_ne!( - base_version, edited_version, - "a sketch edit must bump the version this gate keys on" - ); - let cached = super::ExtrudePreview { - feature, - sketch_version: base_version, - generation: None, - failed: false, - error: None, - }; - assert!(super::extrude_preview_is_current( - Some(&cached), - &feature, - base_version - )); - assert!( - !super::extrude_preview_is_current(Some(&cached), &feature, edited_version), - "editing the sketch under the same feature must invalidate the cached preview", - ); - assert!(!super::extrude_preview_is_current( - None, - &feature, - base_version - )); - } - - #[test] - fn extrude_preview_refreshes_when_the_sketch_changes_under_one_cache() { - let (mut document, id) = super::initial_document(rectangle_sketch()); - let feature = sketch_mode::default_extrude_feature(id); - let mut cache = super::FeatureCache::new(); - let first = super::compute_extrude_preview(&mut cache, &document, feature) - .and_then(|preview| preview.generation()); - document.replace_sketch(id, tall_rectangle_sketch()); - let second = super::compute_extrude_preview(&mut cache, &document, feature) - .and_then(|preview| preview.generation()); - let (Some(first), Some(second)) = (first, second) else { - panic!("both rectangles extrude to a solid"); - }; - assert_ne!( - first, second, - "a sketch edit under the same feature must re-evaluate against the edited geometry", - ); - } - - #[test] - fn extrude_preview_evaluates_a_closed_rectangle() { - let (document, id) = super::initial_document(rectangle_sketch()); - let feature = sketch_mode::default_extrude_feature(id); - let mut cache = super::FeatureCache::new(); - let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { - panic!("a registered sketch yields an evaluated preview"); - }; - assert!( - preview.solid().is_some(), - "closed rectangle extrudes to a solid" - ); - } - - #[test] - #[cfg_attr( - debug_assertions, - ignore = "frame budget assertions are only meaningful in release builds" - )] - fn extrude_live_preview_under_frame_budget() { - use bone_document::ExtrudeEndCondition; - use bone_types::PositiveLength; - use std::time::{Duration, Instant}; - use uom::si::length::millimeter; - - const BASE_MM: f64 = 4.0; - const STEP_MM: f64 = 0.5; - const STEPS: u32 = 16; - - let (document, id) = super::initial_document(rectangle_sketch()); - let mut cache = super::FeatureCache::new(); - let blind = |depth_mm: f64| { - let Ok(depth) = PositiveLength::new(Length::new::(depth_mm)) else { - panic!("{depth_mm} mm is a positive depth"); - }; - ExtrudeFeature { - end_condition: ExtrudeEndCondition::Blind { depth }, - ..sketch_mode::default_extrude_feature(id) - } - }; - let edit = |cache: &mut super::FeatureCache, depth_mm: f64| { - let started = Instant::now(); - let Some(preview) = super::compute_extrude_preview(cache, &document, blind(depth_mm)) - else { - panic!("the rectangle extrudes at {depth_mm} mm"); - }; - let Some(solid) = preview.solid() else { - panic!("the rectangle yields a solid at {depth_mm} mm"); - }; - let Ok(_view) = super::build_solid_view(solid) else { - panic!("the slab tessellates and packs scenes at {depth_mm} mm"); - }; - started.elapsed() - }; - - let _warmup = edit(&mut cache, BASE_MM); - let durations: Vec = (0..STEPS) - .map(|i| edit(&mut cache, BASE_MM + STEP_MM * f64::from(i))) - .collect(); - let sorted = { - let mut v = durations.clone(); - v.sort(); - v - }; - let median = sorted[sorted.len() / 2]; - let Some(&worst) = sorted.last() else { - panic!("preview loop produced zero samples"); - }; - let budget = BudgetCeiling::FRAME_16MS.duration(); - assert!( - median <= budget, - "median evaluate+tessellate+scene step {median:?} exceeds {budget:?} frame budget; samples {durations:?}", - ); - assert!( - worst <= budget * 2, - "worst evaluate+tessellate+scene step {worst:?} exceeds the relaxed ceiling; samples {durations:?}", - ); - } - - #[test] - fn extrude_preview_absent_when_sketch_missing() { - let document = Document::new(DocumentId::default(), "Empty".to_owned()); - let feature = sketch_mode::default_extrude_feature(SketchId::default()); - let mut cache = super::FeatureCache::new(); - assert!(super::compute_extrude_preview(&mut cache, &document, feature).is_none()); - } - - #[test] - fn default_document_extrudes_to_a_solid() { - let (document, id) = super::initial_document(super::default_sketch()); - let feature = sketch_mode::default_extrude_feature(id); - let mut cache = super::FeatureCache::new(); - let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { - panic!("the default sketch is registered"); - }; - let Some(solid) = preview.solid() else { - panic!("the default sketch extrudes to a solid"); - }; - assert!(super::build_solid_view(solid).is_ok()); - } - - #[test] - fn preview_solid_view_tessellates_and_frames() { - let (document, id) = super::initial_document(rectangle_sketch()); - let feature = sketch_mode::default_extrude_feature(id); - let mut cache = super::FeatureCache::new(); - let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { - panic!("a registered sketch yields an evaluated preview"); - }; - let Some(solid) = preview.solid() else { - panic!("the rectangle extrudes to a solid"); - }; - let Ok(view) = super::build_solid_view(solid) else { - panic!("the solid tessellates into a renderable view"); - }; - let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256)); - let region = ViewportRegion::at_origin(extent); - let camera = frame_standard_view(view.aabb, extent, StandardView::Isometric, None).ok(); - assert!( - camera.is_some(), - "the solid aabb frames an isometric camera" - ); - assert!( - super::preview_solid_frame(Some(&view), camera, region).is_some(), - "a framed preview lowers to a solid frame view", - ); - assert!( - super::preview_solid_frame(Some(&view), None, region).is_none(), - "without a camera there is nothing to frame", - ); - } - - fn layout_rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { - LayoutRect::new( - LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), - LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)), - ) - } - - #[test] - fn solid_viewport_region_offsets_inside_the_surface() { - let surface = ViewportExtent::new(ViewportPx::new(1280), ViewportPx::new(800)); - let Some(region) = - super::solid_viewport_region(layout_rect(320.0, 96.0, 800.0, 600.0), surface) - else { - panic!("an inset viewport yields a region"); - }; - assert_eq!( - region.scissor(), - (320, 96, 800, 600), - "the region carries the viewport offset and size, not the whole window", - ); - } - - #[test] - fn solid_viewport_region_clamps_to_the_surface() { - let surface = ViewportExtent::new(ViewportPx::new(640), ViewportPx::new(480)); - let Some(region) = - super::solid_viewport_region(layout_rect(600.0, 400.0, 400.0, 400.0), surface) - else { - panic!("a partly off-surface viewport still yields a clamped region"); - }; - let (x, y, w, h) = region.scissor(); - assert!( - x + w <= 640 && y + h <= 480, - "the scissor never runs past the surface: {x}+{w}, {y}+{h}", - ); - } - - #[test] - fn solid_viewport_region_is_none_for_a_degenerate_viewport() { - let surface = ViewportExtent::new(ViewportPx::new(1280), ViewportPx::new(800)); - assert!(super::solid_viewport_region(layout_rect(0.0, 0.0, 0.0, 0.0), surface).is_none()); - } - - #[test] - fn viewport_local_point_centers_the_inset_viewport_not_the_window() { - let region = ViewportRegion::new( - ViewportPx::new(282), - ViewportPx::new(120), - ViewportExtent::new(ViewportPx::new(998), ViewportPx::new(636)), - ); - let center = PhysicalPosition::new(282.0 + 499.0, 120.0 + 318.0); - let Some(local) = super::viewport_local_point(center, region) else { - panic!("a cursor inside the surface yields a viewport-local point"); - }; - assert!( - (local.x() - 499.0).abs() < 1e-9 && (local.y() - 318.0).abs() < 1e-9, - "a cursor at the inset viewport center maps to the region-local center: ({}, {})", - local.x(), - local.y(), - ); - } - - #[test] - fn drag_gesture_maps_modifiers_to_orbit_pan_zoom_roll() { - use winit::keyboard::ModifiersState; - assert_eq!( - super::drag_gesture(ModifiersState::empty()), - NavGesture::Orbit - ); - assert_eq!( - super::drag_gesture(ModifiersState::CONTROL), - NavGesture::Pan - ); - assert_eq!(super::drag_gesture(ModifiersState::SHIFT), NavGesture::Zoom); - assert_eq!(super::drag_gesture(ModifiersState::ALT), NavGesture::Roll); - assert_eq!( - super::drag_gesture(ModifiersState::CONTROL | ModifiersState::SHIFT), - NavGesture::Pan, - "ctrl outranks shift so a held ctrl always pans", - ); - } - - #[test] - fn plane_pick_from_idle_enters_sketch_for_known_plane() { - let frame = shell::ShellFrame { - plane_picked: Some(Plane::Xy), - ..empty_frame() - }; - let next = next_mode(Mode::Idle, &frame, false, &xy_only()); - assert_eq!(next, Mode::enter_sketch(SketchId::default())); - } - - #[test] - fn plane_pick_from_idle_with_no_sketch_for_plane_stays_idle() { - let frame = shell::ShellFrame { - plane_picked: Some(Plane::Yz), - ..empty_frame() - }; - let next = next_mode(Mode::Idle, &frame, false, &xy_only()); - assert_eq!(next, Mode::Idle); - } - - #[test] - fn plane_pick_while_in_sketch_keeps_current_mode() { - let prev = Mode::enter_sketch(SketchId::default()); - let frame = shell::ShellFrame { - plane_picked: Some(Plane::Xy), - ..empty_frame() - }; - assert_eq!(next_mode(prev.clone(), &frame, false, &xy_only()), prev); - } - - #[test] - fn ribbon_exit_returns_idle() { - let prev = Mode::enter_sketch(SketchId::default()); - let frame = shell::ShellFrame { - exit_sketch: true, - ..empty_frame() - }; - assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); - } - - #[test] - fn exit_sketch_action_returns_idle() { - let prev = Mode::enter_sketch(SketchId::default()); - assert_eq!( - next_mode(prev, &empty_frame(), true, &xy_only()), - Mode::Idle - ); - } - - #[test] - fn escape_with_pending_clears_pending_keeps_sketch_and_tool() { - let prev = Mode::Sketch { - sketch_id: SketchId::default(), - session: Box::new(SketchSession { - tool: Some(SketchTool::Line), - pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm( - 1.0, 2.0, - )))), - ..SketchSession::default() - }), - }; - let next = next_mode(prev, &empty_frame(), true, &xy_only()); - let Mode::Sketch { session, .. } = next else { - panic!("escape with pending must keep sketch mode"); - }; - assert_eq!(session.tool, Some(SketchTool::Line)); - assert_eq!(session.pending, None); - } - - fn far_camera() -> Camera2 { - Camera2::new(ViewportExtent::new( - ViewportPx::new(800), - ViewportPx::new(600), - )) - .with_zoom(PixelsPerMm::new(1_000_000.0)) - } - - #[test] - fn build_preview_in_idle_is_empty() { - let document = Document::new(DocumentId::default(), "doc".to_owned()); - let preview = build_preview( - &Mode::Idle, - &document, - Some(Point2::from_mm(1.0, 1.0)), - &far_camera(), - ); - assert!(preview.is_empty()); - } - - #[test] - fn build_preview_without_armed_tool_is_empty() { - let document = Document::new(DocumentId::default(), "doc".to_owned()); - let mode = Mode::enter_sketch(SketchId::default()); - let preview = build_preview( - &mode, - &document, - Some(Point2::from_mm(0.0, 0.0)), - &far_camera(), - ); - assert!(preview.is_empty()); - } - - #[test] - fn build_preview_with_position_pending_emits_anchor_and_segment() { - let sketch = Sketch::new(Plane::Xy.basis()); - let (document, sketch_id) = initial_document(sketch); - let anchor = Point2::from_mm(2.0, 3.0); - let mode = Mode::Sketch { - sketch_id, - session: Box::new(SketchSession { - tool: Some(SketchTool::Line), - pending: Some(Pending::First(ClickAnchor::Position(anchor))), - ..SketchSession::default() - }), - }; - let cursor = Point2::from_mm(5.0, 7.0); - let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); - assert_eq!(preview.anchors, vec![anchor]); - assert_eq!(preview.segments, vec![(anchor, cursor)]); - } - - #[test] - fn build_preview_with_endpoint_pending_resolves_via_document() { - let sketch = Sketch::new(Plane::Xy.basis()); - let target = Point2::from_mm(-4.0, 6.0); - let (sketch, endpoint) = tools::add_point(sketch, target); - let (document, sketch_id) = initial_document(sketch); - let mode = Mode::Sketch { - sketch_id, - session: Box::new(SketchSession { - tool: Some(SketchTool::Line), - pending: Some(Pending::First(ClickAnchor::Endpoint(endpoint))), - ..SketchSession::default() - }), - }; - let cursor = Point2::from_mm(0.0, 0.0); - let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); - assert_eq!(preview.anchors, vec![target]); - assert_eq!(preview.segments, vec![(target, cursor)]); - } - - #[test] - fn build_preview_keeps_anchor_when_cursor_outside_viewport() { - let sketch = Sketch::new(Plane::Xy.basis()); - let (document, sketch_id) = initial_document(sketch); - let anchor = Point2::from_mm(1.0, 1.0); - let mode = Mode::Sketch { - sketch_id, - session: Box::new(SketchSession { - tool: Some(SketchTool::Line), - pending: Some(Pending::First(ClickAnchor::Position(anchor))), - ..SketchSession::default() - }), - }; - let preview = build_preview(&mode, &document, None, &far_camera()); - assert_eq!(preview.anchors, vec![anchor]); - assert!(preview.segments.is_empty()); - } - - #[test] - fn build_preview_during_drag_is_empty() { - let document = Document::new(DocumentId::default(), "doc".to_owned()); - let mode = Mode::Sketch { - sketch_id: SketchId::default(), - session: Box::new(SketchSession { - tool: Some(SketchTool::Line), - pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm( - 0.0, 0.0, - )))), - drag: Some(DragSession { - entity: bone_types::SketchEntityId::default(), - press: Point2::origin(), - pins: DragPins::from_array([ - Some((bone_types::SketchEntityId::default(), Point2::origin())), - None, - None, - ]), - }), - ..SketchSession::default() - }), - }; - let preview = build_preview( - &mode, - &document, - Some(Point2::from_mm(1.0, 1.0)), - &far_camera(), - ); - assert!(preview.is_empty()); - } - - #[test] - fn build_preview_circle_emits_ghost_circle() { - let sketch = Sketch::new(Plane::Xy.basis()); - let (document, sketch_id) = initial_document(sketch); - let center = Point2::from_mm(0.0, 0.0); - let mode = Mode::Sketch { - sketch_id, - session: Box::new(SketchSession { - tool: Some(SketchTool::Circle), - pending: Some(Pending::First(ClickAnchor::Position(center))), - ..SketchSession::default() - }), - }; - let cursor = Point2::from_mm(3.0, 4.0); - let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); - assert_eq!(preview.anchors, vec![center]); - assert_eq!(preview.circles.len(), 1); - let r = preview.circles[0].radius.get::(); - assert!((r - 5.0).abs() < 1e-9, "r={r}"); - } - - #[test] - fn build_preview_corner_rectangle_emits_four_segments() { - let sketch = Sketch::new(Plane::Xy.basis()); - let (document, sketch_id) = initial_document(sketch); - let corner = Point2::from_mm(0.0, 0.0); - let mode = Mode::Sketch { - sketch_id, - session: Box::new(SketchSession { - tool: Some(SketchTool::CornerRectangle), - pending: Some(Pending::First(ClickAnchor::Position(corner))), - ..SketchSession::default() - }), - }; - let cursor = Point2::from_mm(5.0, 3.0); - let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); - assert_eq!(preview.anchors, vec![corner]); - assert_eq!(preview.segments.len(), 4); - } - - #[test] - fn build_preview_tangent_arc_emits_ghost_arc_after_endpoint_click() { - let sketch = Sketch::new(Plane::Xy.basis()); - let (sketch, a) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); - let (sketch, b) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); - let (sketch, _) = tools::add_line(sketch, a, b, false); - let (document, sketch_id) = initial_document(sketch); - let mode = Mode::Sketch { - sketch_id, - session: Box::new(SketchSession { - tool: Some(SketchTool::TangentArc), - pending: Some(Pending::First(ClickAnchor::Endpoint(b))), - ..SketchSession::default() - }), - }; - let cursor = Point2::from_mm(10.0, 6.0); - let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); - assert_eq!(preview.anchors.len(), 1, "start anchor visible"); - assert_eq!(preview.arcs.len(), 1, "ghost arc emitted"); - } - - #[test] - fn build_preview_centerpoint_arc_emits_ghost_arc_after_two_clicks() { - let sketch = Sketch::new(Plane::Xy.basis()); - let (document, sketch_id) = initial_document(sketch); - let center = Point2::from_mm(0.0, 0.0); - let start = Point2::from_mm(5.0, 0.0); - let mode = Mode::Sketch { - sketch_id, - session: Box::new(SketchSession { - tool: Some(SketchTool::CenterpointArc), - pending: Some(Pending::Second( - ClickAnchor::Position(center), - ClickAnchor::Position(start), - )), - ..SketchSession::default() - }), - }; - let cursor = Point2::from_mm(0.0, 5.0); - let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); - assert_eq!(preview.arcs.len(), 1); - assert_eq!(preview.anchors.len(), 2); - } - - #[test] - fn escape_with_armed_tool_no_pending_disarms_tool() { - let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); - let next = next_mode(prev, &empty_frame(), true, &xy_only()); - let Mode::Sketch { session, .. } = next else { - panic!("escape with armed tool must keep sketch mode"); - }; - assert_eq!(session.tool, None); - assert_eq!(session.pending, None); - } - - #[test] - fn clicking_active_tool_disarms_it() { - let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); - let frame = shell::ShellFrame { - activated_tool: Some(SketchTool::Line), - ..empty_frame() - }; - let next = next_mode(prev, &frame, false, &xy_only()); - let Mode::Sketch { session, .. } = next else { - panic!("expected sketch mode"); - }; - assert_eq!(session.tool, None); - } - - #[test] - fn clicking_different_tool_swaps() { - let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); - let frame = shell::ShellFrame { - activated_tool: Some(SketchTool::Point), - ..empty_frame() - }; - let next = next_mode(prev, &frame, false, &xy_only()); - let Mode::Sketch { session, .. } = next else { - panic!("expected sketch mode"); - }; - assert_eq!(session.tool, Some(SketchTool::Point)); - } - - #[test] - fn ribbon_exit_overrides_pending_chain() { - let prev = Mode::Sketch { - sketch_id: SketchId::default(), - session: Box::new(SketchSession { - tool: Some(SketchTool::Line), - pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm( - 0.0, 0.0, - )))), - ..SketchSession::default() - }), - }; - let frame = shell::ShellFrame { - exit_sketch: true, - ..empty_frame() - }; - assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); - } - - #[test] - fn tool_in_idle_does_not_promote_to_sketch() { - let frame = shell::ShellFrame { - activated_tool: Some(SketchTool::Line), - ..empty_frame() - }; - assert_eq!(next_mode(Mode::Idle, &frame, false, &xy_only()), Mode::Idle); - } - - #[test] - fn tool_in_sketch_arms_session() { - let prev = Mode::enter_sketch(SketchId::default()); - let frame = shell::ShellFrame { - activated_tool: Some(SketchTool::Line), - ..empty_frame() - }; - let Mode::Sketch { session, .. } = next_mode(prev, &frame, false, &xy_only()) else { - panic!("expected sketch mode"); - }; - assert_eq!(session.tool, Some(SketchTool::Line)); - } - - #[test] - fn plane_pick_then_tool_enters_and_arms_in_one_frame() { - let frame = shell::ShellFrame { - plane_picked: Some(Plane::Xy), - activated_tool: Some(SketchTool::Line), - ..empty_frame() - }; - let Mode::Sketch { session, .. } = next_mode(Mode::Idle, &frame, false, &xy_only()) else { - panic!("expected sketch mode"); - }; - assert_eq!(session.tool, Some(SketchTool::Line)); - } - - fn doc_with_default_sketch() -> (Document, SketchId) { - let sketch = bone_document::Sketch::new(Plane::Xy.basis()); - let mut document = Document::new(DocumentId::default(), "Untitled".to_owned()); - let id = SketchId::default(); - document.insert_sketch(id, "Sketch1".to_owned(), sketch); - (document, id) - } - - #[test] - fn apply_sketch_rename_into_writes_label_and_records_undo_on_change() { - let (mut document, id) = doc_with_default_sketch(); - let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); - apply_sketch_rename_into( - &mut document, - &mut undo, - shell::SketchRenameRequest { - id, - label: "Profile".to_owned(), - }, - ); - assert_eq!(document.sketch_label(id), Some("Profile")); - assert_eq!( - undo.past_len(), - 1, - "successful rename records one undo snapshot" - ); - } - - #[test] - fn apply_sketch_rename_into_drops_empty_label_without_undo() { - let (mut document, id) = doc_with_default_sketch(); - let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); - apply_sketch_rename_into( - &mut document, - &mut undo, - shell::SketchRenameRequest { - id, - label: " ".to_owned(), - }, - ); - assert_eq!(document.sketch_label(id), Some("Sketch1")); - assert_eq!(undo.past_len(), 0); - } - - #[test] - fn apply_sketch_rename_into_skips_no_op_against_trimmed_match() { - let (mut document, id) = doc_with_default_sketch(); - let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); - apply_sketch_rename_into( - &mut document, - &mut undo, - shell::SketchRenameRequest { - id, - label: " Sketch1 ".to_owned(), - }, - ); - assert_eq!(document.sketch_label(id), Some("Sketch1")); - assert_eq!( - undo.past_len(), - 0, - "trimmed-equal rename must not record undo" - ); - } - - fn extrude_node_count(document: &Document) -> usize { - document - .feature_tree() - .iter() - .filter(|(_, node)| matches!(node, FeatureNode::Extrude(_))) - .count() - } - - #[test] - fn commit_armed_extrude_on_accept_adds_node_and_records_undo() { - let (mut document, sketch) = doc_with_default_sketch(); - let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); - let mode = Mode::Extrude(ExtrudeArming::profile(sketch)); - commit_armed_extrude( - &mut document, - &mut undo, - &mode, - Some(shell::ConfirmAction::Accept), - ); - assert_eq!(extrude_node_count(&document), 1); - assert_eq!(undo.past_len(), 1); - } - - #[test] - fn commit_armed_extrude_ignores_cancel_and_non_extrude_mode() { - let (mut document, sketch) = doc_with_default_sketch(); - let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); - commit_armed_extrude( - &mut document, - &mut undo, - &Mode::Extrude(ExtrudeArming::profile(sketch)), - Some(shell::ConfirmAction::Cancel), - ); - commit_armed_extrude( - &mut document, - &mut undo, - &Mode::Idle, - Some(shell::ConfirmAction::Accept), - ); - assert_eq!(extrude_node_count(&document), 0); - assert_eq!(undo.past_len(), 0); - } - - #[test] - fn commit_armed_extrude_edit_target_updates_in_place_keeping_label() { - let (mut document, sketch) = doc_with_default_sketch(); - let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); - let Ok(()) = document.rename_extrude(id, "Boss") else { - panic!("rename accepts"); - }; - let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); - let mode = Mode::Extrude(ExtrudeArming::edit( - id, - sketch_mode::default_extrude_feature(sketch), - )); - commit_armed_extrude( - &mut document, - &mut undo, - &mode, - Some(shell::ConfirmAction::Accept), - ); - assert_eq!(extrude_node_count(&document), 1, "editing reuses the node"); - assert_eq!(document.extrude_label(id), Some("Boss")); - } - - #[test] - fn extrude_edit_mode_arms_from_idle_but_not_from_sketch() { - let (mut document, sketch) = doc_with_default_sketch(); - let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); - let from_idle = extrude_edit_mode(&document, &Mode::Idle, Some(id)); - assert!(matches!( - from_idle, - Some(Mode::Extrude(ExtrudeArming::Profile { target: Some(t), .. })) if t == id - )); - assert_eq!( - extrude_edit_mode(&document, &Mode::enter_sketch(sketch), Some(id)), - None, - "double-click is inert while sketching", - ); - assert_eq!( - extrude_edit_mode(&document, &Mode::Idle, Some(ExtrudeId::default())), - None, - "unknown extrude id arms nothing", - ); - } - - #[test] - fn active_solid_feature_tracks_mode_then_falls_back_to_committed() { - let (mut document, sketch) = doc_with_default_sketch(); - let armed = sketch_mode::default_extrude_feature(sketch); - assert_eq!( - active_solid_feature( - &Mode::Extrude(ExtrudeArming::profile(sketch)), - &document, - None, - ), - Some(armed), - "an armed profile previews its own feature", - ); - assert_eq!( - active_solid_feature(&Mode::enter_sketch(sketch), &document, None), - None, - "sketching shows the 2D scene, not a solid", - ); - assert_eq!( - active_solid_feature(&Mode::Idle, &document, None), - None, - "idle with no committed extrude shows no solid", - ); - let _ = document.commit_extrude(armed); - assert_eq!( - active_solid_feature(&Mode::Idle, &document, None), - Some(armed), - "idle falls back to the committed extrude", - ); - } - - #[test] - fn framed_extrude_overrides_last_committed_in_idle() { - let (mut document, sketch) = doc_with_default_sketch(); - let first_feature = sketch_mode::default_extrude_feature(sketch); - let mut second_feature = first_feature; - second_feature.merge_result = bone_document::MergeResult::Separate; - let first = document.commit_extrude(first_feature); - let _second = document.commit_extrude(second_feature); - assert_eq!( - active_solid_feature(&Mode::Idle, &document, None), - Some(second_feature), - "with no framed id, idle frames the last-committed extrude", - ); - assert_eq!( - active_solid_feature(&Mode::Idle, &document, Some(first)), - Some(first_feature), - "a framed id wins over the tree tip, so editing a non-last extrude stays framed", - ); - assert_eq!( - active_solid_feature(&Mode::Idle, &document, Some(ExtrudeId::default())), - Some(second_feature), - "a stale framed id self-heals to the last-committed extrude", - ); - } - - #[test] - fn apply_extrude_rename_into_writes_label_and_records_undo() { - let (mut document, sketch) = doc_with_default_sketch(); - let id = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); - let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); - apply_extrude_rename_into( - &mut document, - &mut undo, - shell::ExtrudeRenameRequest { - id, - label: "Boss".to_owned(), - }, - ); - assert_eq!(document.extrude_label(id), Some("Boss")); - assert_eq!(undo.past_len(), 1); - } - - #[test] - fn sketch_activated_from_idle_enters_that_sketch_without_plane_map() { - let sketch_id = SketchId::default(); - let frame = shell::ShellFrame { - sketch_activated: Some(sketch_id), - ..empty_frame() - }; - let next = next_mode(Mode::Idle, &frame, false, &BTreeMap::new()); - assert_eq!(next, Mode::enter_sketch(sketch_id)); - } - - #[test] - fn sketch_pick_in_extrude_sets_profile_instead_of_editing() { - let sketch_id = SketchId::default(); - let frame = shell::ShellFrame { - sketch_activated: Some(sketch_id), - ..empty_frame() - }; - assert_eq!( - next_mode( - Mode::Extrude(ExtrudeArming::AwaitingSketch), - &frame, - false, - &xy_only(), - ), - Mode::Extrude(ExtrudeArming::profile(sketch_id)), - ); - assert_eq!( - next_mode( - Mode::Extrude(ExtrudeArming::profile(sketch_id)), - &frame, - false, - &xy_only(), - ), - Mode::Extrude(ExtrudeArming::profile(sketch_id)), - "a pick while armed re-targets the profile, never drops into sketch editing", - ); - } - - #[test] - fn sketch_activated_while_in_sketch_is_ignored() { - let prev = Mode::enter_sketch(SketchId::default()); - let frame = shell::ShellFrame { - sketch_activated: Some(SketchId::default()), - ..empty_frame() - }; - assert_eq!(next_mode(prev.clone(), &frame, false, &xy_only()), prev); - } - - #[test] - fn exit_action_wins_over_pending_tool() { - let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); - let frame = shell::ShellFrame { - exit_sketch: true, - ..empty_frame() - }; - assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); - } - - #[test] - fn idle_scopes_omit_sketch_scope() { - let scopes = scopes_for_mode(&Mode::Idle); - let collected: Vec<_> = scopes.innermost_first().copied().collect(); - assert!(!collected.contains(&HotkeyScope::Sketch)); - assert!(collected.contains(&HotkeyScope::Global)); - } - - #[test] - fn sketch_scopes_include_sketch_scope() { - let scopes = scopes_for_mode(&Mode::enter_sketch(SketchId::default())); - let collected: Vec<_> = scopes.innermost_first().copied().collect(); - assert!(collected.contains(&HotkeyScope::Sketch)); - assert!(collected.contains(&HotkeyScope::Global)); - } - - #[test] - fn hotkey_table_binds_escape_to_exit_under_sketch_scope() { - let table = build_hotkey_table(); - let chord = KeyChord::new(UiKeyCode::Named(NamedKey::Escape), ModifierMask::NONE); - let in_sketch = scopes_for_mode(&Mode::enter_sketch(SketchId::default())); - assert_eq!( - table.dispatch(chord, &in_sketch), - Some(sketch_mode::ESCAPE_ACTION) - ); - let in_idle = scopes_for_mode(&Mode::Idle); - assert_eq!(table.dispatch(chord, &in_idle), None); - } - - #[test] - fn extrude_scope_isolates_escape_from_sketch_tools() { - let extrude = scopes_for_mode(&Mode::Extrude(ExtrudeArming::AwaitingSketch)); - let in_extrude: Vec<_> = extrude.innermost_first().copied().collect(); - assert!(in_extrude.contains(&HotkeyScope::Extrude)); - assert!( - !in_extrude.contains(&HotkeyScope::Sketch), - "extrude must not activate the sketch-tool scope", - ); - let sketch = scopes_for_mode(&Mode::enter_sketch(SketchId::default())); - let in_sketch: Vec<_> = sketch.innermost_first().copied().collect(); - assert!(!in_sketch.contains(&HotkeyScope::Extrude)); - } - - #[test] - fn hotkey_table_binds_escape_under_extrude_scope() { - let table = build_hotkey_table(); - let chord = KeyChord::new(UiKeyCode::Named(NamedKey::Escape), ModifierMask::NONE); - let in_extrude = scopes_for_mode(&Mode::Extrude(ExtrudeArming::AwaitingSketch)); - assert_eq!( - table.dispatch(chord, &in_extrude), - Some(sketch_mode::ESCAPE_ACTION) - ); - } - - #[test] - fn escape_exits_extrude_to_idle() { - let frame = empty_frame(); - let awaiting = Mode::Extrude(ExtrudeArming::AwaitingSketch); - assert_eq!(next_mode(awaiting, &frame, true, &xy_only()), Mode::Idle); - let profiled = Mode::Extrude(ExtrudeArming::profile(SketchId::default())); - assert_eq!(next_mode(profiled, &frame, true, &xy_only()), Mode::Idle); - } - - #[test] - fn cancel_pending_or_exit_drops_extrude_arming() { - assert_eq!( - cancel_pending_or_exit(Mode::Extrude(ExtrudeArming::AwaitingSketch)), - Mode::Idle - ); - } - - #[test] - fn driven_with_value_promotes_kind_and_overwrites_value() { - let proto = SketchDimension::Linear { - a: bone_types::SketchEntityId::default(), - b: bone_types::SketchEntityId::default(), - value: Length::new::(8.0), - kind: DimensionKind::Driving, - }; - let measured = DimensionValue::Length(Length::new::(10.0)); - let Some(driven) = driven_with_value(proto, measured) else { - panic!("driven_with_value rejected matched-kind value"); - }; - assert_eq!(driven.kind(), DimensionKind::Driven); - let DimensionValue::Length(length) = driven.value() else { - panic!("expected Length"); - }; - assert!((length.get::() - 10.0).abs() < 1e-9); - } - - #[test] - fn driven_with_value_rejects_kind_mismatch() { - let proto = SketchDimension::Linear { - a: bone_types::SketchEntityId::default(), - b: bone_types::SketchEntityId::default(), - value: Length::new::(1.0), - kind: DimensionKind::Driving, - }; - let bad = DimensionValue::Angle(bone_types::Angle::new::(1.0)); - assert!(driven_with_value(proto, bad).is_none()); - } - - #[test] - fn dim_conflict_pending_returns_proto_when_set() { - let proto = SketchDimension::Linear { - a: bone_types::SketchEntityId::default(), - b: bone_types::SketchEntityId::default(), - value: Length::new::(2.0), - kind: DimensionKind::Driving, - }; - let pending = PendingDimension { - proto, - anchor: Point2::origin(), - }; - let mode = Mode::enter_sketch(SketchId::default()).start_dim_conflict(pending); - assert_eq!(dim_conflict_pending(&mode), Some(pending)); - } - - #[test] - fn dim_conflict_pending_returns_none_in_idle() { - assert_eq!(dim_conflict_pending(&Mode::Idle), None); - } - - fn horizontal_line_fixture() -> ( - Sketch, - bone_types::SketchEntityId, - bone_types::SketchEntityId, - bone_types::SketchEntityId, - ) { - let sketch = Sketch::new(Plane::Xy.basis()); - let (sketch, a) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); - let (sketch, b) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); - let (sketch, line) = tools::add_line(sketch, a, b, false); - let sketch = tools::add_relation(sketch, SketchRelation::Horizontal(line)); - let sketch = tools::add_relation(sketch, SketchRelation::Fix(a)); - (sketch, a, b, line) - } - - fn point_at(sketch: &Sketch, id: bone_types::SketchEntityId) -> Point2 { - let SketchEntity::Point(p) = sketch.entities()[id] else { - panic!("expected point entity"); - }; - p.at() - } - - #[test] - fn drag_resolved_translates_endpoint_and_preserves_horizontal() { - let (sketch, a, b, _) = horizontal_line_fixture(); - let drag = DragSession { - entity: b, - press: Point2::from_mm(10.0, 0.0), - pins: DragPins::from_array([Some((b, Point2::from_mm(10.0, 0.0))), None, None]), - }; - let cursor = Point2::from_mm(13.0, 0.0); - let Some(next) = drag_resolved(&sketch, drag, cursor) else { - panic!("solve_with_drag_pins must converge on horizontal line") - }; - let (bx, by) = point_at(&next, b).coords_mm(); - assert!((bx - 13.0).abs() < 1e-6, "b.x slides under cursor: {bx}"); - assert!(by.abs() < 1e-6, "horizontal preserved: by={by}"); - let (ax, ay) = point_at(&next, a).coords_mm(); - assert!(ax.abs() < 1e-9, "fixed a.x stays put: {ax}"); - assert!(ay.abs() < 1e-9, "fixed a.y stays put: {ay}"); - } - - #[test] - fn mirror_axis_reflects_across_horizontal_x_axis() { - let axis = MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); - let (rx, ry) = axis.reflect(Point2::from_mm(3.0, 4.0)).coords_mm(); - assert!((rx - 3.0).abs() < 1e-9, "x preserved across x-axis"); - assert!((ry - -4.0).abs() < 1e-9, "y inverted across x-axis"); - } - - #[test] - fn mirror_axis_reflects_across_diagonal() { - let axis = MirrorAxis::from_points(Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 1.0)); - let (rx, ry) = axis.reflect(Point2::from_mm(2.0, 0.0)).coords_mm(); - assert!((rx - 0.0).abs() < 1e-9, "x reflects to y on y=x diagonal"); - assert!((ry - 2.0).abs() < 1e-9, "y reflects to x on y=x diagonal"); - } - - #[test] - fn mirror_axis_detects_degenerate_zero_length() { - let axis = MirrorAxis::from_points(Point2::from_mm(1.0, 1.0), Point2::from_mm(1.0, 1.0)); - assert!( - axis.is_degenerate(), - "coincident endpoints must be degenerate" - ); - } - - #[test] - fn mirror_targets_creates_reflected_circle_with_symmetric_relations() { - let (sketch, _, _, axis_line) = horizontal_line_fixture(); - let (sketch, center) = tools::add_point(sketch, Point2::from_mm(0.0, 3.0)); - let (sketch, circle_id) = - tools::add_circle(sketch, center, Length::new::(1.0), false); - let axis_geom = - MirrorAxis::from_points(Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 0.0)); - let source_ids: std::collections::BTreeSet<_> = [center, circle_id].into_iter().collect(); - let Ok(mirrored) = mirror_targets(sketch.clone(), &source_ids, axis_line, &axis_geom) - else { - panic!("circle mirror must succeed"); - }; - let new_circles: Vec<_> = mirrored - .entities() - .iter() - .filter_map(|(_, e)| match *e { - SketchEntity::Circle(c) => Some(c), - _ => None, - }) - .collect(); - assert_eq!(new_circles.len(), 2, "original + mirrored circle"); - let new_center_pos = new_circles - .iter() - .map(|c| { - let SketchEntity::Point(p) = mirrored.entities()[c.center()] else { - panic!("circle center is a point"); - }; - p.at().coords_mm() - }) - .find(|(_, y)| *y < 0.0); - let Some((cx, cy)) = new_center_pos else { - panic!("mirrored circle must lie below x-axis"); - }; - assert!(cx.abs() < 1e-9 && (cy + 3.0).abs() < 1e-9, "({cx}, {cy})"); - let symmetric_count = mirrored - .relations() - .iter() - .filter( - |(_, r)| matches!(r, SketchRelation::Symmetric { axis, .. } if *axis == axis_line), - ) - .count(); - assert!( - symmetric_count >= 1, - "mirror must emit at least one Symmetric relation tied to the axis", - ); - } - - #[test] - fn mirror_copies_horizontal_relation_to_mirrored_line() { - use bone_document::SketchEdit; - let sketch = Sketch::new(Plane::Xy.basis()); - let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0)); - let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0)); - let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true); - let (sketch, source_p0) = tools::add_point(sketch, Point2::from_mm(-2.0, 3.0)); - let (sketch, source_p1) = tools::add_point(sketch, Point2::from_mm(2.0, 3.0)); - let (sketch, source_line) = tools::add_line(sketch, source_p0, source_p1, false); - let Ok((sketch, _)) = sketch.apply(SketchEdit::AddRelation(SketchRelation::Horizontal( - source_line, - ))) else { - panic!("seed Horizontal must apply"); - }; - let axis_geom = - MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); - let source_ids: std::collections::BTreeSet<_> = - [source_p0, source_p1, source_line].into_iter().collect(); - let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else { - panic!("mirror must succeed"); - }; - let horizontal_lines: Vec<_> = mirrored - .relations() - .iter() - .filter_map(|(_, r)| match r { - SketchRelation::Horizontal(id) => Some(*id), - _ => None, - }) - .collect(); - assert_eq!( - horizontal_lines.len(), - 2, - "original + mirrored horizontal must both exist" - ); - } - - #[test] - fn construction_toggle_flips_line_flag() { - let (sketch, _, _, line) = horizontal_line_fixture(); - let before = match sketch.entities()[line] { - SketchEntity::Line(l) => l.for_construction(), - _ => panic!("line"), - }; - let Ok((next, _)) = sketch.apply(SketchEdit::SetConstruction { - id: line, - for_construction: !before, - }) else { - panic!("set construction must succeed"); - }; - let after = match next.entities()[line] { - SketchEntity::Line(l) => l.for_construction(), - _ => panic!("line"), - }; - assert_ne!(before, after); - } - - #[test] - fn mirror_axis_detects_on_axis_point() { - let axis = MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); - assert!(axis.is_on_axis(Point2::from_mm(2.0, 0.0))); - assert!(axis.is_on_axis(Point2::from_mm(-5.0, 0.0))); - assert!(!axis.is_on_axis(Point2::from_mm(2.0, 0.5))); - } - - #[test] - fn mirror_on_axis_source_point_is_identity_no_self_symmetric() { - let sketch = Sketch::new(Plane::Xy.basis()); - let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0)); - let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0)); - let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true); - let (sketch, on_axis_point) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); - let axis_geom = - MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); - let source_ids: std::collections::BTreeSet<_> = [on_axis_point].into_iter().collect(); - let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else { - panic!("mirror must succeed"); - }; - let point_count = mirrored - .entities() - .iter() - .filter(|(_, e)| matches!(e, SketchEntity::Point(_))) - .count(); - assert_eq!( - point_count, 3, - "on-axis source must not produce a duplicate point: {point_count}" - ); - let symmetric_count = mirrored - .relations() - .iter() - .filter(|(_, r)| matches!(r, SketchRelation::Symmetric { .. })) - .count(); - assert_eq!( - symmetric_count, 0, - "on-axis source must not emit a self-pair Symmetric relation" - ); - } - - #[test] - fn mirror_copies_relation_referencing_axis_line() { - use bone_document::SketchEdit; - let sketch = Sketch::new(Plane::Xy.basis()); - let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0)); - let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0)); - let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true); - let (sketch, off_a) = tools::add_point(sketch, Point2::from_mm(-2.0, 3.0)); - let (sketch, off_b) = tools::add_point(sketch, Point2::from_mm(2.0, 3.0)); - let (sketch, off_line) = tools::add_line(sketch, off_a, off_b, false); - let Ok((sketch, _)) = sketch.apply(SketchEdit::AddRelation(SketchRelation::Parallel( - off_line, axis_line, - ))) else { - panic!("seed Parallel(off_line, axis_line) must apply"); - }; - let axis_geom = - MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); - let source_ids: std::collections::BTreeSet<_> = - [off_a, off_b, off_line].into_iter().collect(); - let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else { - panic!("mirror must succeed"); - }; - let parallel_count = mirrored - .relations() - .iter() - .filter(|(_, r)| matches!(r, SketchRelation::Parallel(_, b) if *b == axis_line)) - .count(); - assert_eq!( - parallel_count, 2, - "original + mirrored Parallel(line, axis_line) both expected: {parallel_count}", - ); - } -} diff --git a/crates/bone-app/src/redraw.rs b/crates/bone-app/src/redraw.rs index dbb6bcc..43de2c4 100644 --- a/crates/bone-app/src/redraw.rs +++ b/crates/bone-app/src/redraw.rs @@ -1,3 +1,4 @@ +use bone_app::InputDispatched; use std::sync::Arc; use winit::window::Window; @@ -8,15 +9,6 @@ pub struct Scheduler { kicks: u8, } -#[must_use = "input dispatch must be acknowledged via Scheduler::schedule"] -pub struct InputDispatched(()); - -impl InputDispatched { - pub(crate) fn after_input() -> Self { - Self(()) - } -} - impl Scheduler { pub fn new(window: Arc) -> Self { Self { window, kicks: 0 } diff --git a/crates/bone-jig/Cargo.toml b/crates/bone-jig/Cargo.toml new file mode 100644 index 0000000..25ff186 --- /dev/null +++ b/crates/bone-jig/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "bone-jig" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +bone-app = { workspace = true } +bone-render = { workspace = true } +bone-ui = { workspace = true } + +accesskit = { workspace = true } +pollster = { workspace = true } +ron = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +wgpu = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/bone-jig/scenarios/cold_shell.ron b/crates/bone-jig/scenarios/cold_shell.ron new file mode 100644 index 0000000..3261aee --- /dev/null +++ b/crates/bone-jig/scenarios/cold_shell.ron @@ -0,0 +1,7 @@ +Scenario( + steps: [ + Advance(2), + Snapshot("cold-shell"), + DumpTree("cold-shell"), + ], +) diff --git a/crates/bone-jig/scenarios/cold_shell_dark.ron b/crates/bone-jig/scenarios/cold_shell_dark.ron new file mode 100644 index 0000000..5346717 --- /dev/null +++ b/crates/bone-jig/scenarios/cold_shell_dark.ron @@ -0,0 +1,8 @@ +Scenario( + steps: [ + Theme(Dark), + Advance(2), + Snapshot("cold-shell-dark"), + DumpTree("cold-shell-dark"), + ], +) diff --git a/crates/bone-jig/scenarios/dialog_open.ron b/crates/bone-jig/scenarios/dialog_open.ron new file mode 100644 index 0000000..7f38973 --- /dev/null +++ b/crates/bone-jig/scenarios/dialog_open.ron @@ -0,0 +1,16 @@ +Scenario( + steps: [ + Advance(2), + Click(target: Label("Sketch1")), + Click(target: Label("Sketch1")), + Click(target: Label("Point")), + Click(target: At(x: 700, y: 400)), + Key(code: Named(Escape)), + Key(code: Named(Escape)), + Advance(2), + Key(code: Char('n'), modifiers: [Ctrl]), + Advance(2), + Snapshot("dialog-open"), + DumpTree("dialog-open"), + ], +) diff --git a/crates/bone-jig/scenarios/dialog_open_dark.ron b/crates/bone-jig/scenarios/dialog_open_dark.ron new file mode 100644 index 0000000..59e9c72 --- /dev/null +++ b/crates/bone-jig/scenarios/dialog_open_dark.ron @@ -0,0 +1,17 @@ +Scenario( + steps: [ + Theme(Dark), + Advance(2), + Click(target: Label("Sketch1")), + Click(target: Label("Sketch1")), + Click(target: Label("Point")), + Click(target: At(x: 700, y: 400)), + Key(code: Named(Escape)), + Key(code: Named(Escape)), + Advance(2), + Key(code: Char('n'), modifiers: [Ctrl]), + Advance(2), + Snapshot("dialog-open-dark"), + DumpTree("dialog-open-dark"), + ], +) diff --git a/crates/bone-jig/scenarios/sketch_mode.ron b/crates/bone-jig/scenarios/sketch_mode.ron new file mode 100644 index 0000000..9814670 --- /dev/null +++ b/crates/bone-jig/scenarios/sketch_mode.ron @@ -0,0 +1,10 @@ +Scenario( + steps: [ + Advance(2), + Click(target: Label("Sketch1")), + Click(target: Label("Sketch1")), + Advance(2), + Snapshot("sketch-mode"), + DumpTree("sketch-mode"), + ], +) diff --git a/crates/bone-jig/scenarios/sketch_mode_dark.ron b/crates/bone-jig/scenarios/sketch_mode_dark.ron new file mode 100644 index 0000000..ed1ee76 --- /dev/null +++ b/crates/bone-jig/scenarios/sketch_mode_dark.ron @@ -0,0 +1,11 @@ +Scenario( + steps: [ + Theme(Dark), + Advance(2), + Click(target: Label("Sketch1")), + Click(target: Label("Sketch1")), + Advance(2), + Snapshot("sketch-mode-dark"), + DumpTree("sketch-mode-dark"), + ], +) diff --git a/crates/bone-jig/scenarios/smoke.ron b/crates/bone-jig/scenarios/smoke.ron new file mode 100644 index 0000000..52893b3 --- /dev/null +++ b/crates/bone-jig/scenarios/smoke.ron @@ -0,0 +1,14 @@ +Scenario( + steps: [ + Advance(2), + PointerMove(Label("Point")), + Click(target: Label("File")), + Key(code: Named(Escape)), + Click(target: Label("Features")), + Click(target: Label("Extruded Boss/Base")), + Click(target: Label("Right")), + Advance(30), + Snapshot("final"), + DumpTree("final"), + ], +) diff --git a/crates/bone-jig/src/lib.rs b/crates/bone-jig/src/lib.rs new file mode 100644 index 0000000..f7abb10 --- /dev/null +++ b/crates/bone-jig/src/lib.rs @@ -0,0 +1,21 @@ +mod run; +mod scenario; +mod target; +mod tree; + +pub use run::{JigError, RunResult, RunStatus, StepOutcome, StepRecord, run_scenario}; +pub use scenario::{ + ArtifactName, ArtifactNameError, Modifier, NodeRef, NodeRefParseError, PointerTarget, Scenario, + ScenarioError, ScenarioSource, Step, modifier_mask, +}; +pub use target::{Candidate, ResolveError, resolve_target}; +pub use tree::{DumpNode, DumpStates, TreeDump, TreeDumpError}; + +use bone_render::{AdapterPolicy, OffscreenContext, Result, ViewportExtent, ViewportPx}; + +pub const DEFAULT_VIEWPORT: ViewportExtent = + ViewportExtent::new(ViewportPx::new(1280), ViewportPx::new(800)); + +pub fn offscreen_context(extent: ViewportExtent) -> Result { + pollster::block_on(OffscreenContext::new(extent, AdapterPolicy::Software)) +} diff --git a/crates/bone-jig/src/main.rs b/crates/bone-jig/src/main.rs new file mode 100644 index 0000000..8687cb8 --- /dev/null +++ b/crates/bone-jig/src/main.rs @@ -0,0 +1,433 @@ +use std::path::PathBuf; +use std::process::ExitCode; + +use bone_jig::{RunResult, RunStatus, Scenario, ScenarioSource, Step, StepOutcome, run_scenario}; +use bone_render::{PixelDiff, PixelDiffThreshold, decode_png, encode_png_rgba}; +use thiserror::Error; + +const USAGE: &str = "usage: + bone-jig run --out [--document ] + bone-jig diff [--out ] [--threshold <0-255>]"; + +const EXIT_FAILED: u8 = 1; +const EXIT_USAGE: u8 = 2; +const EXIT_ERROR: u8 = 3; + +#[derive(Debug, PartialEq, Eq, Error)] +enum CliError { + #[error("missing command")] + MissingCommand, + #[error("unknown command: {0}")] + UnknownCommand(String), + #[error("missing argument: {0}")] + MissingArg(&'static str), + #[error("unexpected argument: {0}")] + UnexpectedArg(String), + #[error("threshold {0:?} is not an integer in 0-255")] + BadThreshold(String), +} + +#[derive(Debug, PartialEq)] +enum Command { + Run { + scenario: ScenarioSource, + out: PathBuf, + document: Option, + }, + Diff { + left: PathBuf, + right: PathBuf, + out: Option, + threshold: PixelDiffThreshold, + }, +} + +fn parse(args: &[String]) -> Result { + match args { + [] => Err(CliError::MissingCommand), + [command, rest @ ..] if command == "run" => parse_run(rest, RunArgs::default()), + [command, rest @ ..] if command == "diff" => parse_diff(rest, DiffArgs::default()), + [command, ..] => Err(CliError::UnknownCommand(command.clone())), + } +} + +#[derive(Default)] +struct RunArgs { + scenario: Option, + out: Option, + document: Option, +} + +fn scenario_source(arg: &str) -> ScenarioSource { + if arg == "-" { + ScenarioSource::Stdin + } else { + ScenarioSource::Path(PathBuf::from(arg)) + } +} + +fn parse_run(args: &[String], acc: RunArgs) -> Result { + match args { + [] => Ok(Command::Run { + scenario: acc.scenario.ok_or(CliError::MissingArg(""))?, + out: acc.out.ok_or(CliError::MissingArg("--out "))?, + document: acc.document, + }), + [flag, value, rest @ ..] if flag == "--out" => parse_run( + rest, + RunArgs { + out: Some(PathBuf::from(value)), + ..acc + }, + ), + [flag, value, rest @ ..] if flag == "--document" => parse_run( + rest, + RunArgs { + document: Some(PathBuf::from(value)), + ..acc + }, + ), + [flag] if flag == "--out" => Err(CliError::MissingArg("--out ")), + [flag] if flag == "--document" => Err(CliError::MissingArg("--document ")), + [positional, rest @ ..] if acc.scenario.is_none() && !positional.starts_with("--") => { + parse_run( + rest, + RunArgs { + scenario: Some(scenario_source(positional)), + ..acc + }, + ) + } + [unexpected, ..] => Err(CliError::UnexpectedArg(unexpected.clone())), + } +} + +#[derive(Default)] +struct DiffArgs { + images: Vec, + out: Option, + threshold: Option, +} + +fn parse_threshold(value: &str) -> Result { + value + .parse::() + .map(|n| PixelDiffThreshold::new(f64::from(n) / 255.0)) + .map_err(|_| CliError::BadThreshold(value.to_owned())) +} + +fn parse_diff(args: &[String], acc: DiffArgs) -> Result { + match args { + [] => match <[PathBuf; 2]>::try_from(acc.images) { + Ok([left, right]) => Ok(Command::Diff { + left, + right, + out: acc.out, + threshold: acc.threshold.unwrap_or(PixelDiffThreshold::EXACT), + }), + Err(_) => Err(CliError::MissingArg(" ")), + }, + [flag, value, rest @ ..] if flag == "--out" => parse_diff( + rest, + DiffArgs { + out: Some(PathBuf::from(value)), + ..acc + }, + ), + [flag, value, rest @ ..] if flag == "--threshold" => parse_diff( + rest, + DiffArgs { + threshold: Some(parse_threshold(value)?), + ..acc + }, + ), + [flag] if flag == "--out" => Err(CliError::MissingArg("--out ")), + [flag] if flag == "--threshold" => Err(CliError::MissingArg("--threshold <0-255>")), + [positional, rest @ ..] if acc.images.len() < 2 && !positional.starts_with("--") => { + let mut images = acc.images; + images.push(PathBuf::from(positional)); + parse_diff(rest, DiffArgs { images, ..acc }) + } + [unexpected, ..] => Err(CliError::UnexpectedArg(unexpected.clone())), + } +} + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + match parse(&args) { + Ok(Command::Run { + scenario, + out, + document, + }) => execute_run(&scenario, &out, document), + Ok(Command::Diff { + left, + right, + out, + threshold, + }) => execute_diff(&left, &right, out.as_deref(), threshold), + Err(e) => { + eprintln!("{e}"); + eprintln!("{USAGE}"); + ExitCode::from(EXIT_USAGE) + } + } +} + +fn with_document(scenario: Scenario, document: Option) -> Scenario { + match document { + None => scenario, + Some(path) => Scenario { + steps: std::iter::once(Step::OpenDocument(path)) + .chain(scenario.steps) + .collect(), + }, + } +} + +fn report_failure(result: &RunResult) { + result + .steps + .iter() + .enumerate() + .filter_map(|(index, record)| match &record.outcome { + StepOutcome::Failed(message) => Some((index, message)), + StepOutcome::Ok => None, + }) + .for_each(|(index, message)| eprintln!("step {index} failed: {message}")); +} + +fn execute_run( + source: &ScenarioSource, + out: &std::path::Path, + document: Option, +) -> ExitCode { + let scenario = match Scenario::load(source) { + Ok(s) => with_document(s, document), + Err(e) => { + eprintln!("{e}"); + return ExitCode::from(EXIT_ERROR); + } + }; + match run_scenario(&scenario, out, &mut std::io::stdout().lock()) { + Ok(result) if result.status == RunStatus::Passed => ExitCode::SUCCESS, + Ok(result) => { + report_failure(&result); + ExitCode::from(EXIT_FAILED) + } + Err(e) => { + eprintln!("{e}"); + ExitCode::from(EXIT_ERROR) + } + } +} + +fn load_png(path: &std::path::Path) -> Result<(bone_render::ViewportExtent, Vec), String> { + let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?; + decode_png(&bytes).map_err(|e| format!("decode {}: {e}", path.display())) +} + +fn diff_image(left: &[u8], right: &[u8], limit: u8) -> Vec { + left.chunks_exact(4) + .zip(right.chunks_exact(4)) + .flat_map(|(a, b)| { + let over = a + .iter() + .zip(b.iter()) + .map(|(x, y)| x.abs_diff(*y)) + .max() + .unwrap_or(0) + > limit; + if over { + [255, 0, 0, 255] + } else { + [0, 0, 0, 255] + } + }) + .collect() +} + +fn execute_diff( + left: &std::path::Path, + right: &std::path::Path, + out: Option<&std::path::Path>, + threshold: PixelDiffThreshold, +) -> ExitCode { + let ((extent_left, rgba_left), (extent_right, rgba_right)) = + match (load_png(left), load_png(right)) { + (Ok(l), Ok(r)) => (l, r), + (Err(e), _) | (_, Err(e)) => { + eprintln!("{e}"); + return ExitCode::from(EXIT_ERROR); + } + }; + if extent_left != extent_right { + eprintln!("size mismatch: {extent_left} vs {extent_right}"); + return ExitCode::from(EXIT_FAILED); + } + let report = match PixelDiff::compare_bytes(extent_left, &rgba_left, &rgba_right, threshold) { + Ok(r) => r, + Err(e) => { + eprintln!("{e}"); + return ExitCode::from(EXIT_ERROR); + } + }; + println!( + "pixels over threshold: {} of {}", + report.over_threshold(), + extent_left.pixel_count(), + ); + if let Some(worst) = report.worst() { + println!( + "worst: ({},{}) delta={}", + worst.x(), + worst.y(), + worst.max_delta(), + ); + } + if let Some(path) = out { + let rgba = diff_image(&rgba_left, &rgba_right, threshold.as_u8()); + let written = encode_png_rgba(extent_left, &rgba) + .map_err(|e| e.to_string()) + .and_then(|png| { + std::fs::write(path, png).map_err(|e| format!("write {}: {e}", path.display())) + }); + if let Err(e) = written { + eprintln!("{e}"); + return ExitCode::from(EXIT_ERROR); + } + } + if report.is_clean() { + ExitCode::SUCCESS + } else { + ExitCode::from(EXIT_FAILED) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(parts: &[&str]) -> Vec { + parts.iter().map(|s| (*s).to_owned()).collect() + } + + #[test] + fn run_parses_scenario_out_and_document() { + let cmd = parse(&strings(&[ + "run", + "shell.ron", + "--out", + "artifacts", + "--document", + "clamp.step", + ])); + assert_eq!( + cmd, + Ok(Command::Run { + scenario: ScenarioSource::Path(PathBuf::from("shell.ron")), + out: PathBuf::from("artifacts"), + document: Some(PathBuf::from("clamp.step")), + }), + ); + } + + #[test] + fn run_dash_reads_stdin() { + let cmd = parse(&strings(&["run", "-", "--out", "artifacts"])); + assert_eq!( + cmd, + Ok(Command::Run { + scenario: ScenarioSource::Stdin, + out: PathBuf::from("artifacts"), + document: None, + }), + ); + } + + #[test] + fn run_requires_out() { + assert_eq!( + parse(&strings(&["run", "shell.ron"])), + Err(CliError::MissingArg("--out ")), + ); + } + + #[test] + fn diff_parses_threshold_and_out() { + let cmd = parse(&strings(&[ + "diff", + "a.png", + "b.png", + "--threshold", + "8", + "--out", + "d.png", + ])); + assert_eq!( + cmd, + Ok(Command::Diff { + left: PathBuf::from("a.png"), + right: PathBuf::from("b.png"), + out: Some(PathBuf::from("d.png")), + threshold: PixelDiffThreshold::new(8.0 / 255.0), + }), + ); + } + + #[test] + fn diff_defaults_to_exact_threshold() { + let cmd = parse(&strings(&["diff", "a.png", "b.png"])); + assert_eq!( + cmd, + Ok(Command::Diff { + left: PathBuf::from("a.png"), + right: PathBuf::from("b.png"), + out: None, + threshold: PixelDiffThreshold::EXACT, + }), + ); + } + + #[test] + fn diff_rejects_bad_threshold() { + assert_eq!( + parse(&strings(&["diff", "a.png", "b.png", "--threshold", "256"])), + Err(CliError::BadThreshold("256".to_owned())), + ); + assert_eq!( + parse(&strings(&["diff", "a.png"])), + Err(CliError::MissingArg(" ")), + ); + } + + #[test] + fn unknown_command_is_rejected() { + assert_eq!( + parse(&strings(&["serve"])), + Err(CliError::UnknownCommand("serve".to_owned())), + ); + assert_eq!(parse(&[]), Err(CliError::MissingCommand)); + } + + #[test] + fn with_document_prepends_an_open_step() { + let scenario = Scenario { + steps: vec![Step::Advance(bone_app::FrameCount::ONE)], + }; + let amended = with_document(scenario, Some(PathBuf::from("clamp.step"))); + assert_eq!( + amended.steps[0], + Step::OpenDocument(PathBuf::from("clamp.step")), + ); + assert_eq!(amended.steps.len(), 2); + } + + #[test] + fn diff_image_marks_only_over_threshold_pixels() { + let left = [0_u8, 0, 0, 255, 100, 0, 0, 255]; + let right = [0_u8, 0, 0, 255, 0, 0, 0, 255]; + let image = diff_image(&left, &right, 8); + assert_eq!(image, vec![0, 0, 0, 255, 255, 0, 0, 255]); + } +} diff --git a/crates/bone-jig/src/run.rs b/crates/bone-jig/src/run.rs new file mode 100644 index 0000000..18fe381 --- /dev/null +++ b/crates/bone-jig/src/run.rs @@ -0,0 +1,419 @@ +use core::ops::ControlFlow; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use bone_app::{AppCore, FrameCount, FrameTarget, InputEvent, KeyDown, NavKey, WindowPoint}; +use bone_render::{ + OffscreenContext, PickIdError, PickIndex, Picker, RenderError, ViewportExtent, encode_png, +}; +use bone_ui::input::{KeyCode, ModifierMask, NamedKey}; +use serde::Serialize; +use thiserror::Error; + +use crate::scenario::{PointerTarget, Scenario, Step, modifier_mask}; +use crate::target::resolve_target; +use crate::tree::TreeDump; +use crate::{DEFAULT_VIEWPORT, offscreen_context}; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] +pub enum RunStatus { + Passed, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub enum StepOutcome { + Ok, + Failed(String), +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct StepRecord { + pub step: Step, + pub outcome: StepOutcome, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct RunResult { + pub steps: Vec, + pub status: RunStatus, +} + +#[derive(Debug, Error)] +pub enum JigError { + #[error("render: {0}")] + Render(#[from] RenderError), + #[error("pick id space: {0}")] + PickId(#[from] PickIdError), + #[error("{path}: {error}")] + Io { + path: PathBuf, + error: std::io::Error, + }, + #[error("encode result: {0}")] + ResultEncode(#[from] ron::Error), +} + +struct OffscreenTarget(OffscreenContext); + +impl FrameTarget for OffscreenTarget { + fn picker(&self, index: PickIndex) -> Picker<'_> { + self.0.picker(index) + } + + fn render( + &mut self, + build_passes: impl FnOnce( + &mut wgpu::CommandEncoder, + &wgpu::TextureView, + &wgpu::TextureView, + &wgpu::TextureView, + ), + ) { + self.0.render_passes(build_passes); + } +} + +pub fn run_scenario( + scenario: &Scenario, + out_dir: &Path, + sink: &mut impl Write, +) -> Result { + std::fs::create_dir_all(out_dir).map_err(|error| JigError::Io { + path: out_dir.to_path_buf(), + error, + })?; + let context = offscreen_context(DEFAULT_VIEWPORT)?; + let core = AppCore::new(context.gpu(), context.color_format(), DEFAULT_VIEWPORT)?; + let mut session = Session { + core, + target: OffscreenTarget(context), + out_dir, + sink, + }; + session.frame(); + let outcome = + scenario + .steps + .iter() + .enumerate() + .try_fold(Vec::new(), |mut records, (index, step)| { + let (outcome, flow) = match session.exec(index, step) { + Ok(()) => (StepOutcome::Ok, ControlFlow::Continue(())), + Err(message) => (StepOutcome::Failed(message), ControlFlow::Break(())), + }; + records.push(StepRecord { + step: step.clone(), + outcome, + }); + match flow { + ControlFlow::Continue(()) => ControlFlow::Continue(records), + ControlFlow::Break(()) => ControlFlow::Break(records), + } + }); + let (steps, status) = match outcome { + ControlFlow::Continue(records) => (records, RunStatus::Passed), + ControlFlow::Break(records) => (records, RunStatus::Failed), + }; + let result = RunResult { steps, status }; + let text = ron::ser::to_string_pretty(&result, ron::ser::PrettyConfig::default())?; + let path = out_dir.join("result.ron"); + std::fs::write(&path, text).map_err(|error| JigError::Io { path, error })?; + Ok(result) +} + +struct Session<'a, W: Write> { + core: AppCore, + target: OffscreenTarget, + out_dir: &'a Path, + sink: &'a mut W, +} + +impl Session<'_, W> { + fn exec(&mut self, index: usize, step: &Step) -> Result<(), String> { + match step { + Step::Resize { width, height } => { + let extent = ViewportExtent::new(*width, *height); + self.target.0.resize(extent).map_err(|e| e.to_string())?; + self.input(InputEvent::Resize(extent)); + self.frame(); + Ok(()) + } + Step::Theme(mode) => { + self.core.set_theme(*mode); + self.frame(); + Ok(()) + } + Step::PointerMove(target) => { + let point = self.resolve(index, target)?; + self.input(InputEvent::CursorMove(point)); + self.frame(); + Ok(()) + } + Step::Click { target, button } => { + let point = self.resolve(index, target)?; + self.input(InputEvent::CursorMove(point)); + self.frame(); + self.press_release(*button); + Ok(()) + } + Step::Drag { from, to, button } => { + let start = self.resolve(index, from)?; + self.input(InputEvent::CursorMove(start)); + self.frame(); + self.input(InputEvent::Pointer { + button: *button, + pressed: true, + }); + self.frame(); + let end = self.resolve(index, to)?; + self.input(InputEvent::CursorMove(end)); + self.frame(); + self.input(InputEvent::Pointer { + button: *button, + pressed: false, + }); + self.frame(); + self.frame(); + self.frame(); + Ok(()) + } + Step::Wheel(delta) => { + self.input(InputEvent::Wheel(*delta)); + self.frame(); + Ok(()) + } + Step::Key { code, modifiers } => { + self.key_down(*code, modifiers); + Ok(()) + } + Step::Text(text) => { + self.input(InputEvent::KeyDown(KeyDown { + code: None, + nav: None, + text: Some(text.clone()), + repeat: false, + })); + self.frame(); + Ok(()) + } + Step::Advance(frames) => { + (0..frames.get()).for_each(|_| self.frame()); + Ok(()) + } + Step::OpenDocument(path) => { + self.core + .open_document(path.clone()) + .map_err(|e| e.to_string())?; + self.frame(); + Ok(()) + } + Step::Snapshot(name) => { + let frame = self.target.0.capture().map_err(|e| e.to_string())?; + let png = encode_png(&frame).map_err(|e| e.to_string())?; + self.write_file(&artifact_file(index, &name.to_string(), "png"), &png) + } + Step::DumpTree(name) => { + let dump = self.dump()?; + let file = artifact_file(index, &name.to_string(), "tree.json"); + self.write_tree(&file, &dump)?; + writeln!(self.sink, "# {file}").map_err(|e| e.to_string())?; + self.sink + .write_all(dump.to_text().as_bytes()) + .map_err(|e| e.to_string()) + } + } + } + + fn resolve(&mut self, index: usize, target: &PointerTarget) -> Result { + let update = self.core.access_tree(); + resolve_target(target, &update).map_err(|resolve_error| { + let file = artifact_file(index, "unresolved", "tree.json"); + let flushed = TreeDump::from_update(&update) + .map_err(|e| e.to_string()) + .and_then(|dump| self.write_tree(&file, &dump)); + match flushed { + Ok(()) => format!("{resolve_error}. searched tree written to {file}"), + Err(write_error) => format!("{resolve_error}. searched tree lost: {write_error}"), + } + }) + } + + fn dump(&self) -> Result { + TreeDump::from_update(&self.core.access_tree()).map_err(|e| e.to_string()) + } + + fn write_tree(&self, file: &str, dump: &TreeDump) -> Result<(), String> { + let json = serde_json::to_vec_pretty(dump).map_err(|e| e.to_string())?; + self.write_file(file, &json) + } + + fn write_file(&self, file: &str, bytes: &[u8]) -> Result<(), String> { + let path = self.out_dir.join(file); + std::fs::write(&path, bytes).map_err(|e| format!("write {}: {e}", path.display())) + } + + fn key_down(&mut self, code: KeyCode, modifiers: &[crate::scenario::Modifier]) { + let mask = modifier_mask(modifiers); + if mask != ModifierMask::NONE { + self.input(InputEvent::Modifiers(mask)); + } + self.input(InputEvent::KeyDown(KeyDown { + code: Some(code), + nav: nav_key(code), + text: None, + repeat: false, + })); + self.frame(); + if mask != ModifierMask::NONE { + self.input(InputEvent::Modifiers(ModifierMask::NONE)); + self.frame(); + } + } + + fn press_release(&mut self, button: bone_ui::input::PointerButton) { + self.input(InputEvent::Pointer { + button, + pressed: true, + }); + self.frame(); + self.input(InputEvent::Pointer { + button, + pressed: false, + }); + self.frame(); + self.frame(); + self.frame(); + } + + fn input(&mut self, event: InputEvent) { + let _ack = self.core.handle_input(&self.target, event); + } + + fn frame(&mut self) { + self.core.clock_mut().advance(FrameCount::ONE); + let _report = self.core.render_frame(&mut self.target); + } +} + +fn artifact_file(index: usize, name: &str, ext: &str) -> String { + format!("{index:03}_{name}.{ext}") +} + +fn nav_key(code: KeyCode) -> Option { + match code { + KeyCode::Named(NamedKey::ArrowLeft) => Some(NavKey::Left), + KeyCode::Named(NamedKey::ArrowRight) => Some(NavKey::Right), + KeyCode::Named(NamedKey::ArrowUp) => Some(NavKey::Up), + KeyCode::Named(NamedKey::ArrowDown) => Some(NavKey::Down), + KeyCode::Named(_) => None, + KeyCode::Char(c) => match c.get().to_ascii_lowercase() { + 'z' => Some(NavKey::Zoom), + '=' | '+' => Some(NavKey::ZoomIn), + '-' | '_' => Some(NavKey::ZoomOut), + _ => None, + }, + } +} + +#[cfg(test)] +mod tests { + use crate::scenario::Scenario; + + use super::*; + + fn run_in_tempdir(scenario_text: &str) -> (tempfile::TempDir, RunResult, String) { + let dir = match tempfile::tempdir() { + Ok(d) => d, + Err(e) => panic!("tempdir: {e}"), + }; + let scenario = match Scenario::parse(scenario_text) { + Ok(s) => s, + Err(e) => panic!("parse: {e}"), + }; + let mut sink = Vec::new(); + let result = match run_scenario(&scenario, dir.path(), &mut sink) { + Ok(r) => r, + Err(e) => panic!("run: {e}"), + }; + let text = String::from_utf8_lossy(&sink).into_owned(); + (dir, result, text) + } + + #[test] + fn snapshot_and_dump_write_numbered_artifacts() { + let (dir, result, text) = run_in_tempdir( + r#"Scenario( + steps: [ + Advance(2), + Snapshot("shell"), + DumpTree("shell"), + ], + )"#, + ); + assert_eq!(result.status, RunStatus::Passed); + assert_eq!(result.steps.len(), 3); + let png = match std::fs::read(dir.path().join("001_shell.png")) { + Ok(bytes) => bytes, + Err(e) => panic!("png: {e}"), + }; + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); + assert!(dir.path().join("002_shell.tree.json").is_file()); + assert!(dir.path().join("result.ron").is_file()); + assert!(text.starts_with("# 002_shell.tree.json\n")); + assert!(text.contains("window"), "tree text was: {text}"); + } + + #[test] + fn failed_resolution_flushes_the_searched_tree_and_halts() { + let (dir, result, _text) = run_in_tempdir( + r#"Scenario( + steps: [ + Snapshot("before"), + Click(target: Label("No Such Widget")), + Snapshot("after"), + ], + )"#, + ); + assert_eq!(result.status, RunStatus::Failed); + assert_eq!( + result.steps.len(), + 2, + "steps after the failure must not run" + ); + assert!(matches!(result.steps[1].outcome, StepOutcome::Failed(_))); + assert!(dir.path().join("000_before.png").is_file()); + assert!(dir.path().join("001_unresolved.tree.json").is_file()); + let result_text = match std::fs::read_to_string(dir.path().join("result.ron")) { + Ok(t) => t, + Err(e) => panic!("result.ron: {e}"), + }; + assert!(result_text.contains("Failed"), "result was: {result_text}"); + } + + #[test] + fn nav_key_zoom_is_case_insensitive() { + use bone_ui::input::KeyChar; + let zoom = |c| nav_key(KeyCode::Char(KeyChar::from_char(c))); + assert_eq!(zoom('z'), Some(NavKey::Zoom)); + assert_eq!(zoom('Z'), Some(NavKey::Zoom)); + assert_eq!(zoom('='), Some(NavKey::ZoomIn)); + assert_eq!(zoom('+'), Some(NavKey::ZoomIn)); + assert_eq!(zoom('-'), Some(NavKey::ZoomOut)); + assert_eq!(zoom('_'), Some(NavKey::ZoomOut)); + } + + #[test] + fn clicking_a_labeled_button_resolves_and_passes() { + let (_dir, result, _text) = run_in_tempdir( + r#"Scenario( + steps: [ + Advance(1), + Click(target: Label("File")), + DumpTree("after-click"), + ], + )"#, + ); + assert_eq!(result.status, RunStatus::Passed); + } +} diff --git a/crates/bone-jig/src/scenario.rs b/crates/bone-jig/src/scenario.rs new file mode 100644 index 0000000..370ae4a --- /dev/null +++ b/crates/bone-jig/src/scenario.rs @@ -0,0 +1,372 @@ +use core::fmt; +use core::num::NonZeroU64; +use core::str::FromStr; +use std::path::PathBuf; + +use accesskit::NodeId; +use bone_app::{FrameCount, ScrollDelta}; +use bone_render::ViewportPx; +use bone_ui::WidgetId; +use bone_ui::input::{KeyCode, ModifierMask, PointerButton}; +use bone_ui::theme::ThemeMode; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct NodeRef(NonZeroU64); + +impl NodeRef { + #[must_use] + pub const fn from_widget(id: WidgetId) -> Self { + Self(id.raw()) + } + + #[must_use] + pub const fn node_id(self) -> NodeId { + NodeId(self.0.get()) + } + + #[must_use] + pub const fn from_node_id(id: NodeId) -> Option { + match NonZeroU64::new(id.0) { + Some(raw) => Some(Self(raw)), + None => None, + } + } +} + +impl fmt::Display for NodeRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:016x}", self.0) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Error)] +#[error("node ref {text:?} is not a non-zero 64-bit hex value")] +pub struct NodeRefParseError { + text: String, +} + +impl FromStr for NodeRef { + type Err = NodeRefParseError; + + fn from_str(s: &str) -> Result { + u64::from_str_radix(s, 16) + .ok() + .and_then(NonZeroU64::new) + .map(Self) + .ok_or_else(|| NodeRefParseError { text: s.to_owned() }) + } +} + +impl TryFrom for NodeRef { + type Error = NodeRefParseError; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +impl From for String { + fn from(value: NodeRef) -> Self { + value.to_string() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ArtifactName(String); + +impl fmt::Display for ArtifactName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Error)] +#[error("artifact name {text:?} must be non-empty ascii alphanumeric with '-' or '_'")] +pub struct ArtifactNameError { + text: String, +} + +impl TryFrom for ArtifactName { + type Error = ArtifactNameError; + + fn try_from(value: String) -> Result { + let valid = !value.is_empty() + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + if valid { + Ok(Self(value)) + } else { + Err(ArtifactNameError { text: value }) + } + } +} + +impl From for String { + fn from(value: ArtifactName) -> Self { + value.0 + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Modifier { + Ctrl, + Shift, + Alt, + Meta, +} + +#[must_use] +pub fn modifier_mask(modifiers: &[Modifier]) -> ModifierMask { + modifiers.iter().fold(ModifierMask::NONE, |acc, m| { + acc.union(match m { + Modifier::Ctrl => ModifierMask::CTRL, + Modifier::Shift => ModifierMask::SHIFT, + Modifier::Alt => ModifierMask::ALT, + Modifier::Meta => ModifierMask::META, + }) + }) +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum PointerTarget { + At { x: f64, y: f64 }, + Node(NodeRef), + Label(String), +} + +fn primary_button() -> PointerButton { + PointerButton::Primary +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Step { + Resize { + width: ViewportPx, + height: ViewportPx, + }, + Theme(ThemeMode), + PointerMove(PointerTarget), + Click { + target: PointerTarget, + #[serde(default = "primary_button")] + button: PointerButton, + }, + Drag { + from: PointerTarget, + to: PointerTarget, + #[serde(default = "primary_button")] + button: PointerButton, + }, + Wheel(ScrollDelta), + Key { + code: KeyCode, + #[serde(default)] + modifiers: Vec, + }, + Text(String), + Advance(FrameCount), + OpenDocument(PathBuf), + Snapshot(ArtifactName), + DumpTree(ArtifactName), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Scenario { + pub steps: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ScenarioSource { + Path(PathBuf), + Stdin, +} + +impl fmt::Display for ScenarioSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Path(path) => write!(f, "{}", path.display()), + Self::Stdin => f.write_str("stdin"), + } + } +} + +#[derive(Debug, Error)] +pub enum ScenarioError { + #[error("read scenario from {from}: {error}")] + Read { + from: ScenarioSource, + error: std::io::Error, + }, + #[error("parse scenario: {0}")] + Parse(#[from] ron::error::SpannedError), +} + +impl Scenario { + pub fn parse(text: &str) -> Result { + ron::de::from_str(text) + } + + pub fn load(source: &ScenarioSource) -> Result { + let text = match source { + ScenarioSource::Path(path) => std::fs::read_to_string(path), + ScenarioSource::Stdin => std::io::read_to_string(std::io::stdin()), + } + .map_err(|error| ScenarioError::Read { + from: source.clone(), + error, + })?; + Ok(Self::parse(&text)?) + } +} + +#[cfg(test)] +mod tests { + use bone_ui::input::NamedKey; + + use super::*; + + fn parsed(text: &str) -> Scenario { + match Scenario::parse(text) { + Ok(scenario) => scenario, + Err(e) => panic!("parse failed: {e}"), + } + } + + fn frames(n: u32) -> FrameCount { + match FrameCount::try_from(n) { + Ok(count) => count, + Err(e) => panic!("frame count: {e}"), + } + } + + #[test] + fn full_step_vocabulary_parses() { + let scenario = parsed( + r#"Scenario( + steps: [ + Resize(width: 1280, height: 800), + OpenDocument("parts/bracket.step"), + Advance(3), + PointerMove(Label("Extrude")), + Click(target: At(x: 640.0, y: 400.0)), + Click(target: Node("00000000000000ff"), button: Secondary), + Drag(from: At(x: 100.0, y: 100.0), to: At(x: 220.0, y: 160.0)), + Wheel(Lines(x: 0.0, y: 1.0)), + Key(code: Named(Escape)), + Key(code: Char('z'), modifiers: [Ctrl, Shift]), + Text("12.5"), + Snapshot("after-extrude"), + DumpTree("final"), + ], + )"#, + ); + assert_eq!(scenario.steps.len(), 13); + assert_eq!( + scenario.steps[4], + Step::Click { + target: PointerTarget::At { x: 640.0, y: 400.0 }, + button: PointerButton::Primary, + }, + "click button defaults to primary", + ); + assert_eq!( + scenario.steps[8], + Step::Key { + code: KeyCode::Named(NamedKey::Escape), + modifiers: vec![], + }, + "key modifiers default to empty", + ); + assert_eq!(scenario.steps[2], Step::Advance(frames(3))); + } + + #[test] + fn advance_rejects_zero_frames() { + assert!( + Scenario::parse("Scenario(steps: [Advance(0)])").is_err(), + "a zero-frame advance is not representable", + ); + assert!(Scenario::parse("Scenario(steps: [Advance(1)])").is_ok()); + } + + #[test] + fn scenario_round_trips_through_ron() { + let scenario = parsed( + r#"Scenario( + steps: [ + Click(target: Label("OK")), + Wheel(Pixels(x: 0.0, y: -40.0)), + Snapshot("dialog"), + ], + )"#, + ); + let text = match ron::ser::to_string(&scenario) { + Ok(t) => t, + Err(e) => panic!("serialize failed: {e}"), + }; + assert_eq!(parsed(&text), scenario); + } + + #[test] + fn node_ref_round_trips_as_hex() { + let node = NodeRef::from_widget(WidgetId::ROOT); + assert_eq!(node.to_string(), "b0feb0feb0feb0fe"); + assert_eq!("b0feb0feb0feb0fe".parse(), Ok(node)); + } + + #[test] + fn node_ref_rejects_zero_and_garbage() { + assert!(NodeRef::from_str("0").is_err()); + assert!(NodeRef::from_str("not-hex").is_err()); + assert!(NodeRef::from_str("").is_err()); + assert!(NodeRef::from_node_id(NodeId(0)).is_none()); + } + + #[test] + fn artifact_name_rejects_path_traversal() { + assert!(ArtifactName::try_from("../escape".to_owned()).is_err()); + assert!(ArtifactName::try_from(String::new()).is_err()); + assert!(ArtifactName::try_from("a/b".to_owned()).is_err()); + assert!(ArtifactName::try_from("cold_shell-01".to_owned()).is_ok()); + } + + #[test] + fn modifier_mask_folds_all_flags() { + let mask = modifier_mask(&[Modifier::Ctrl, Modifier::Shift]); + assert!(mask.contains(ModifierMask::CTRL)); + assert!(mask.contains(ModifierMask::SHIFT)); + assert!(!mask.contains(ModifierMask::ALT)); + assert_eq!(modifier_mask(&[]), ModifierMask::NONE); + } + + #[test] + fn load_reads_a_scenario_file() { + let dir = match tempfile::tempdir() { + Ok(d) => d, + Err(e) => panic!("tempdir: {e}"), + }; + let path = dir.path().join("smoke.ron"); + if let Err(e) = std::fs::write(&path, "Scenario(steps: [Advance(1)])") { + panic!("write: {e}"); + } + let scenario = match Scenario::load(&ScenarioSource::Path(path)) { + Ok(s) => s, + Err(e) => panic!("load: {e}"), + }; + assert_eq!(scenario.steps, vec![Step::Advance(FrameCount::ONE)]); + } + + #[test] + fn load_reports_the_missing_path() { + let source = ScenarioSource::Path(PathBuf::from("/nonexistent/limpet.ron")); + match Scenario::load(&source) { + Err(ScenarioError::Read { from, .. }) => assert_eq!(from, source), + other => panic!("expected read error, got {other:?}"), + } + } +} diff --git a/crates/bone-jig/src/target.rs b/crates/bone-jig/src/target.rs new file mode 100644 index 0000000..d462ecf --- /dev/null +++ b/crates/bone-jig/src/target.rs @@ -0,0 +1,261 @@ +use core::fmt; + +use accesskit::{Node, TreeUpdate}; +use bone_app::WindowPoint; +use thiserror::Error; + +use crate::scenario::{NodeRef, PointerTarget}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Candidate { + pub node: NodeRef, + pub label: String, +} + +impl fmt::Display for Candidate { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {:?}", self.node, self.label) + } +} + +fn list(candidates: &[Candidate]) -> String { + if candidates.is_empty() { + return "none".to_owned(); + } + candidates + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") +} + +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum ResolveError { + #[error("node {node} not in tree; labeled nodes: {}", list(.candidates))] + NodeNotFound { + node: NodeRef, + candidates: Vec, + }, + #[error("node {node} has no bounds")] + NodeWithoutBounds { node: NodeRef }, + #[error("no node label contains {label:?}; labeled nodes: {}", list(.candidates))] + LabelNotFound { + label: String, + candidates: Vec, + }, + #[error("label {label:?} is ambiguous; matches: {}", list(.matches))] + LabelAmbiguous { + label: String, + matches: Vec, + }, +} + +pub fn resolve_target( + target: &PointerTarget, + tree: &TreeUpdate, +) -> Result { + match target { + PointerTarget::At { x, y } => Ok(WindowPoint::new(*x, *y)), + PointerTarget::Node(node) => resolve_node(*node, tree), + PointerTarget::Label(label) => resolve_label(label, tree), + } +} + +fn resolve_node(node: NodeRef, tree: &TreeUpdate) -> Result { + tree.nodes + .iter() + .find(|(id, _)| *id == node.node_id()) + .ok_or_else(|| ResolveError::NodeNotFound { + node, + candidates: labeled(tree), + }) + .and_then(|(_, n)| center(node, n)) +} + +fn resolve_label(needle: &str, tree: &TreeUpdate) -> Result { + let needle_fold = needle.to_lowercase(); + let matches: Vec<(NodeRef, &Node, String)> = tree + .nodes + .iter() + .filter_map(|(id, n)| { + NodeRef::from_node_id(*id) + .zip(n.label()) + .filter(|(_, label)| label.to_lowercase().contains(&needle_fold)) + .map(|(node, label)| (node, n, label.to_owned())) + }) + .collect(); + let exact: Vec<&(NodeRef, &Node, String)> = matches + .iter() + .filter(|(_, _, label)| label.to_lowercase() == needle_fold) + .collect(); + match (matches.as_slice(), exact.as_slice()) { + ([], _) => Err(ResolveError::LabelNotFound { + label: needle.to_owned(), + candidates: labeled(tree), + }), + ([(node, n, _)], _) | (_, [(node, n, _)]) => center(*node, n), + _ => Err(ResolveError::LabelAmbiguous { + label: needle.to_owned(), + matches: matches + .iter() + .map(|(node, _, label)| Candidate { + node: *node, + label: label.clone(), + }) + .collect(), + }), + } +} + +fn center(node: NodeRef, n: &Node) -> Result { + n.bounds() + .map(|r| WindowPoint::new((r.x0 + r.x1) * 0.5, (r.y0 + r.y1) * 0.5)) + .ok_or(ResolveError::NodeWithoutBounds { node }) +} + +fn labeled(tree: &TreeUpdate) -> Vec { + tree.nodes + .iter() + .filter_map(|(id, n)| { + NodeRef::from_node_id(*id) + .zip(n.label()) + .map(|(node, label)| Candidate { + node, + label: label.to_owned(), + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use accesskit::{NodeId, Rect, Role, Tree, TreeId}; + + use super::*; + + fn button(label: &str, bounds: Option) -> Node { + let mut node = Node::new(Role::Button); + node.set_label(label); + if let Some(rect) = bounds { + node.set_bounds(rect); + } + node + } + + fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Rect { + Rect { x0, y0, x1, y1 } + } + + fn tree(nodes: Vec<(NodeId, Node)>) -> TreeUpdate { + TreeUpdate { + nodes, + tree: Some(Tree::new(NodeId(1))), + tree_id: TreeId::ROOT, + focus: NodeId(1), + } + } + + fn node_ref(raw: u64) -> NodeRef { + match NodeRef::from_node_id(NodeId(raw)) { + Some(node) => node, + None => panic!("raw must be non-zero"), + } + } + + fn shell() -> TreeUpdate { + tree(vec![ + (NodeId(1), Node::new(Role::Window)), + ( + NodeId(2), + button("Extrude", Some(rect(10.0, 20.0, 30.0, 40.0))), + ), + ( + NodeId(3), + button("Extrude Cut", Some(rect(40.0, 20.0, 60.0, 40.0))), + ), + ( + NodeId(4), + button("Fillet", Some(rect(70.0, 20.0, 90.0, 40.0))), + ), + ]) + } + + fn resolved(target: &PointerTarget, tree: &TreeUpdate) -> WindowPoint { + match resolve_target(target, tree) { + Ok(point) => point, + Err(e) => panic!("resolve failed: {e}"), + } + } + + #[test] + fn coordinates_pass_through() { + let point = resolved(&PointerTarget::At { x: 12.5, y: 7.0 }, &shell()); + assert_eq!(point, WindowPoint::new(12.5, 7.0)); + } + + #[test] + fn unique_label_substring_hits_bounds_center() { + let point = resolved(&PointerTarget::Label("fill".to_owned()), &shell()); + assert_eq!(point, WindowPoint::new(80.0, 30.0)); + } + + #[test] + fn exact_label_wins_over_substring_ambiguity() { + let point = resolved(&PointerTarget::Label("Extrude".to_owned()), &shell()); + assert_eq!(point, WindowPoint::new(20.0, 30.0)); + } + + #[test] + fn ambiguous_label_names_the_matches() { + let needle = PointerTarget::Label("trude".to_owned()); + match resolve_target(&needle, &shell()) { + Err(ResolveError::LabelAmbiguous { matches, .. }) => { + let labels: Vec<&str> = matches.iter().map(|c| c.label.as_str()).collect(); + assert_eq!(labels, vec!["Extrude", "Extrude Cut"]); + } + other => panic!("expected ambiguity, got {other:?}"), + } + } + + #[test] + fn missing_label_names_the_candidates() { + let needle = PointerTarget::Label("Revolve".to_owned()); + match resolve_target(&needle, &shell()) { + Err(ref err @ ResolveError::LabelNotFound { ref candidates, .. }) => { + assert_eq!(candidates.len(), 3); + let message = err.to_string(); + assert!(message.contains("Extrude Cut"), "message was: {message}"); + assert!(message.contains("Fillet"), "message was: {message}"); + } + other => panic!("expected not-found, got {other:?}"), + } + } + + #[test] + fn node_ref_resolves_to_bounds_center() { + let point = resolved(&PointerTarget::Node(node_ref(3)), &shell()); + assert_eq!(point, WindowPoint::new(50.0, 30.0)); + } + + #[test] + fn missing_node_ref_names_the_candidates() { + match resolve_target(&PointerTarget::Node(node_ref(99)), &shell()) { + Err(ResolveError::NodeNotFound { candidates, .. }) => { + assert_eq!(candidates.len(), 3); + } + other => panic!("expected not-found, got {other:?}"), + } + } + + #[test] + fn node_without_bounds_is_an_error() { + let update = tree(vec![ + (NodeId(1), Node::new(Role::Window)), + (NodeId(2), button("Ghost", None)), + ]); + match resolve_target(&PointerTarget::Label("Ghost".to_owned()), &update) { + Err(ResolveError::NodeWithoutBounds { node }) => assert_eq!(node, node_ref(2)), + other => panic!("expected missing bounds, got {other:?}"), + } + } +} diff --git a/crates/bone-jig/src/tree.rs b/crates/bone-jig/src/tree.rs new file mode 100644 index 0000000..8b35b71 --- /dev/null +++ b/crates/bone-jig/src/tree.rs @@ -0,0 +1,298 @@ +use std::collections::BTreeMap; + +use accesskit::{Node, NodeId, Rect, Role, Toggled, TreeUpdate}; +use serde::Serialize; +use thiserror::Error; + +use crate::scenario::NodeRef; + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct TreeDump { + pub root: DumpNode, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct DumpNode { + #[serde(rename = "ref")] + pub node: NodeRef, + pub role: Role, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bounds: Option, + pub states: DumpStates, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub children: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct DumpStates { + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expanded: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub toggled: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum TreeDumpError { + #[error("tree update carries no root")] + MissingTree, + #[error("node {0:?} referenced but absent from update")] + MissingNode(NodeId), + #[error("node id zero cannot become a ref")] + ZeroNodeId, +} + +impl TreeDump { + pub fn from_update(update: &TreeUpdate) -> Result { + let nodes: BTreeMap = + update.nodes.iter().map(|(id, node)| (*id, node)).collect(); + let root = update.tree.as_ref().ok_or(TreeDumpError::MissingTree)?.root; + Ok(Self { + root: dump_node(root, &nodes)?, + }) + } + + #[must_use] + pub fn to_text(&self) -> String { + lines(&self.root, 0).into_iter().map(|l| l + "\n").collect() + } +} + +fn dump_node(id: NodeId, nodes: &BTreeMap) -> Result { + let node = nodes.get(&id).ok_or(TreeDumpError::MissingNode(id))?; + let node_ref = NodeRef::from_node_id(id).ok_or(TreeDumpError::ZeroNodeId)?; + let children = node + .children() + .iter() + .map(|child| dump_node(*child, nodes)) + .collect::, _>>()?; + Ok(DumpNode { + node: node_ref, + role: node.role(), + label: node.label().map(str::to_owned), + bounds: node.bounds(), + states: DumpStates { + enabled: !node.is_disabled(), + selected: node.is_selected(), + expanded: node.is_expanded(), + toggled: node.toggled(), + }, + children, + }) +} + +fn lines(node: &DumpNode, depth: usize) -> Vec { + std::iter::once(line(node, depth)) + .chain( + node.children + .iter() + .flat_map(|child| lines(child, depth + 1)), + ) + .collect() +} + +fn line(node: &DumpNode, depth: usize) -> String { + [ + Some(format!( + "{}{} {}", + " ".repeat(depth), + node.node, + role_token(node.role), + )), + node.label.as_ref().map(|label| format!("{label:?}")), + node.bounds.map(bounds_token), + (!node.states.enabled).then(|| "disabled".to_owned()), + node.states.selected.map(|v| format!("selected={v}")), + node.states.expanded.map(|v| format!("expanded={v}")), + node.states + .toggled + .map(|t| format!("toggled={}", toggled_token(t))), + ] + .into_iter() + .flatten() + .collect::>() + .join(" ") +} + +fn role_token(role: Role) -> String { + serde_json::to_string(&role).map_or_else( + |_| format!("{role:?}"), + |quoted| quoted.trim_matches('"').to_owned(), + ) +} + +fn bounds_token(rect: Rect) -> String { + format!("({},{})-({},{})", rect.x0, rect.y0, rect.x1, rect.y1) +} + +const fn toggled_token(toggled: Toggled) -> &'static str { + match toggled { + Toggled::False => "false", + Toggled::True => "true", + Toggled::Mixed => "mixed", + } +} + +#[cfg(test)] +mod tests { + use accesskit::Toggled; + use bone_ui::a11y::{AccessNode, AccessTreeBuilder}; + use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; + use bone_ui::strings::StringTable; + use bone_ui::widgets::LabelText; + use bone_ui::{Role, WidgetId, WidgetKey}; + + use super::*; + + const RIBBON: WidgetKey = WidgetKey::new("ribbon"); + const BUTTON: WidgetKey = WidgetKey::new("button"); + + fn rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { + LayoutRect::new( + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), + LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)), + ) + } + + fn labeled_button(label: &str) -> AccessNode { + AccessNode::new(Role::Button).with_label_text(LabelText::Owned(label.to_owned())) + } + + fn dumped(builder: &AccessTreeBuilder) -> TreeDump { + let update = builder.build(&StringTable::new(), None); + match TreeDump::from_update(&update) { + Ok(dump) => dump, + Err(e) => panic!("dump failed: {e}"), + } + } + + #[test] + fn dump_nests_children_under_the_window_root() { + let mut builder = AccessTreeBuilder::new(); + builder.push( + WidgetId::ROOT.child(RIBBON), + rect(10.0, 20.0, 20.0, 20.0), + labeled_button("Extrude").with_toggled(Toggled::True), + ); + builder.push( + WidgetId::ROOT.child(BUTTON), + rect(40.0, 20.0, 20.0, 20.0), + labeled_button("Fillet").with_disabled(true), + ); + let dump = dumped(&builder); + assert_eq!(dump.root.role, Role::Window); + assert_eq!(dump.root.node, NodeRef::from_widget(WidgetId::ROOT)); + let labels: Vec> = dump + .root + .children + .iter() + .map(|c| c.label.as_deref()) + .collect(); + assert_eq!(labels, vec![Some("Extrude"), Some("Fillet")]); + assert_eq!(dump.root.children[0].states.toggled, Some(Toggled::True)); + assert!(!dump.root.children[1].states.enabled); + } + + #[test] + fn refs_stay_stable_across_frames() { + let extrude = WidgetId::ROOT.child(RIBBON).child_named(BUTTON, "extrude"); + let mut builder = AccessTreeBuilder::new(); + builder.push( + extrude, + rect(10.0, 20.0, 20.0, 20.0), + labeled_button("Extrude"), + ); + let first = dumped(&builder); + builder.begin_frame(); + builder.push( + WidgetId::ROOT.child(BUTTON), + rect(0.0, 0.0, 5.0, 5.0), + labeled_button("New"), + ); + builder.push( + extrude, + rect(10.0, 60.0, 20.0, 20.0), + labeled_button("Extrude"), + ); + let second = dumped(&builder); + let ref_of = |dump: &TreeDump, label: &str| { + dump.root + .children + .iter() + .find(|c| c.label.as_deref() == Some(label)) + .map(|c| c.node) + }; + assert_eq!(ref_of(&first, "Extrude"), ref_of(&second, "Extrude")); + assert_eq!( + ref_of(&first, "Extrude"), + Some(NodeRef::from_widget(extrude)) + ); + } + + #[test] + fn text_form_is_one_indented_line_per_node() { + let mut builder = AccessTreeBuilder::new(); + builder.push( + WidgetId::ROOT.child(RIBBON), + rect(10.0, 20.0, 20.0, 20.0), + labeled_button("Extrude").with_selected(true), + ); + let dump = dumped(&builder); + let child = NodeRef::from_widget(WidgetId::ROOT.child(RIBBON)); + let expected = format!( + "b0feb0feb0feb0fe window\n {child} button \"Extrude\" (10,20)-(30,40) selected=true\n", + ); + assert_eq!(dump.to_text(), expected); + } + + #[test] + fn json_form_carries_refs_roles_bounds_and_states() { + let mut builder = AccessTreeBuilder::new(); + builder.push( + WidgetId::ROOT.child(RIBBON), + rect(10.0, 20.0, 20.0, 20.0), + labeled_button("Extrude").with_expanded(false), + ); + let dump = dumped(&builder); + let child = NodeRef::from_widget(WidgetId::ROOT.child(RIBBON)); + let value = match serde_json::to_value(&dump) { + Ok(v) => v, + Err(e) => panic!("serialize failed: {e}"), + }; + assert_eq!( + value, + serde_json::json!({ + "root": { + "ref": "b0feb0feb0feb0fe", + "role": "window", + "states": { "enabled": true }, + "children": [{ + "ref": child.to_string(), + "role": "button", + "label": "Extrude", + "bounds": { "x0": 10.0, "y0": 20.0, "x1": 30.0, "y1": 40.0 }, + "states": { "enabled": true, "expanded": false }, + }], + }, + }), + ); + } + + #[test] + fn missing_root_is_a_typed_error() { + let update = TreeUpdate { + nodes: vec![], + tree: None, + tree_id: accesskit::TreeId::ROOT, + focus: NodeId(1), + }; + assert_eq!( + TreeDump::from_update(&update), + Err(TreeDumpError::MissingTree), + ); + } +} diff --git a/crates/bone-jig/tests/goldens/smoke_final.png b/crates/bone-jig/tests/goldens/smoke_final.png new file mode 100644 index 0000000000000000000000000000000000000000..558f0939fc6833ffed4d6fdda82837aba8ed1f67 GIT binary patch literal 109825 zcmeAS@N?(olHy`uVBq!ia0y~yU}a!nU{>H@V_;yYnQ{I%1LK+#o-U3d6^w5WFfUMv zoObNv|MGXeC(bM{@=jitdwUxcW%kUQ=CyvFk+IUdpJm&#s+P4|XJ7Td|NHX()OEXF zty+zR_}_iu`@b*wKiA&>v2`bQC9D7C|9lz$FZat{`@hk3ug&Xnv6urk`+s!Zuj%#D zweS7^PrLuCH-0u)6btcx+Wdb{@4JCq_H=vQ2w|LK& zZu?p9|J{>+vNZl%>d*P_jP3shuh{x&=KQ~vF{M}Qr%kW>=>GZc{ol=dzMS5t^IyF7 z+46d>tzmYbH{19BUmf@BsQlUT`uF^wF6sZ?6jORNG&~lWsBb~jFE#(?%lS@gr=I`w z=KN#VkC*rVx&5NL{@3k*K^+d^MAhhFT8dt zz3$udkC*iSW$gQ1T|Z%Z-OcH&4^-v;7VLY^UOxrIeQJN}_WrZcA3m-Bz*X~Ud7T>n z7w`QaKG(c)ueGTE-m?6|C;h*V5SITB0bA!@`{wv(zV|=${)AU`y4%lr|NEf)Q*hYz zZ;4vFX}vR~E|s z+R*-4+5X?_x>NBe?GSVFa1wd|L>#zXS?@*XRmxYy*3g(CH~s~{F$`h=Vbez zoA-Ru-mmrjciZ~ES#^)E*H4}Q=Vttq_)kLpbr$!(H`|}`-v8!w(SFoO)c^RR{d2AT zkHz35@xA(`c)iK}zkBUZfE0gP|8eI0f0|dKex5o1&n8a9{_pAiz5Bi|uRopt_t^Tg z-H)!u|9tvmMgQN5dk&I@7)7KA&L!+-(0zc+acV`>YstiD9 zR4Y)@B2&S)H?=jN)N6G1f41I#^w}{n`A-Yw&#~9vaR1P3|6%W+Kj;7P*#A(rXFPCv z|CjEMLHd76_!`X}z6 zL8ja(d@j4^q4Yk(`2W85kL8##KLBUMb@~6xj+uQ5%m2Eq=AU@2X7|x1+~M`x_Sr-J zXNT<`E&lOP{#V_*z2D>F{+h;3p8tF1{AYFXe^TR5{;q$({(1gGLH?bc`Ty6wf4Vj9 zcPV4VugmjJ9j`y*{^@!B$Mib|e|c*zEw5qA|GO=p{lMk<-$LyECEHE1|KDx@xb1cR zgUNPJjVoSnueD}CEw~wKUW(V--T(Ub{<#}&_g<~NH|_lYMf#~5BR>rn?+fEFxS{nZF%E`+y<%hq5 zOFIVt`dh(2fAs(K`1eQuUyt;<{f+*=rksDU_WrlGKMuZKsQU1{=|C2NBk!t*;{~4%>^3Hqp`aj2iT+;tn^Y6#`|7^I^ zGgHHKlxmQnzw$@(&jHU@{|ES=p=PF= zo4>eGq>rl6S*}3PE_Fk^#e)FE~ zl3w?m3dO_iJtw92=z$Z*@|suaMc>}+-E-S|zis>n*Z5=7doD_UmVb*{89cuh^*A*C zV`|0h?bX$GZw}kfy8mzO{U?9#Kezp{q5n??xX}UkI=J>>K&_1aOWOTXwiC(O_v`h3 z&W88@*1dnQd;jC;pKtH~7iK_B^EaOP-g{75xpI1?q}|hC`^lTr-#@*3@8{c}P5Qr# zHV7WzYFIdng9Q${O34OsyhVA^go02 zkD?U()As*dz5h|{{g-cl?2P{v>NkI1+x&ko<)2TRo&Rua{P(Xv`@eFWvd|WqI+dCz{|o!vEKg{=~|XBiHL5Z~uVOOk#at$X{>JU-f12 z&$j%ZWnP+l?ndvGl>hWl{^agGud6?-TK~gn&)M61ZRNi`ls}vQ|62a1fA2rb)!cBe zGnxPM=6v_;-s=@pr$6Vt|HJp^jsD*%|Nfl+H{*NN`SPdYbuY~=%5vJT*PRW2xN`fw zqoCZ;w*Gh2mdr=*=l=+qSNQsJ<=pF4q4Pd{vF_yfw-|p*E9WzT_&S3Ua$*g~ZT=jL zDgRqqbN>5(gGm+V%d6Gz{n5QQtN-^Ae~kA1|JuBL4^8a9O}0O+z5mndqW$?lZmoZ2 zUiUZr=gIkhUBRU)&V+`RGtir9I9qt2w3q*hZT)HQy=P8;(47Bk<{aB<{rBI_<)0U? zxj6mfuJ!-f_Po;G$CLlLEdP9a?GN|QxA%XYz32b$J?!$|Cd!{p2Q|DG{Xp#u#fgB+ zUQlEG-S-#AZQAbtf4u(zYC(k(RMYnV-@X5_?)^W!m7gxZTQG&`KzYsY?VrNy|E1Ub zHLsV3*2$2*f_vSW;~zm?mOoGD|F?Yqr!W8T~&Ag>t<)K+d1v`-@o_&%I5!kmVaXM{4(~XA`JT;-`+oU|F6&X zk6-Wqd7A;;Trscty#3Rz_5a)MyhrN=T~?MW62AX0_WtSYz29Dc3a|etzUS`keU|<; zr*cbT*$=qaykN|?w>enR||_X z|NF%MIy(RS{r`9Gf81;TL3z)$)q9ohf8A?;Vy%C@)cwD)_YZ#mccA}6)cS8-H7DBZ zRo?&6dw&ikO+d0w&F%7f{q_G|t>-)-UUM(KxTNIB?Y(!cKb?F3!_4O2$?ZX2j1OY( zeax*qlN{dzZd|Va_ej5D_x9T2c|WGyezA+Mq5sbX{zpGfPOf?Pyj~F8*86M!CE4bw zrvJ0=b>FxD{JsBw^uCX?_s_Nez1jYuc-_17pN;bW3jZ1J0rk85EB-vL%;T>=qWlBg z+xqwZU*Gnc&*o1~`tNQI;$pb}@$LOn@qd5L|M>O(pIp>L!w8Oy>9tRye(XXwuR<~47_KTGfbxBAbM^Z%6a zBoy?{ZXIfqEbh@o{^!5zKDdKBi0{6`%imk2d!EkTujpTQOLj^S!;ilF|6%`Lo&VqS zz4kxFlI`w{98L}eg2sNh}H4-RPdqvXUt zpY;DRz5l28-a(V~_Kjm^>p#v~f7Ji)9sQ3OO@wdV_Wz4(ZceXZ-2b(@{>)#ymyhk+ z&aTxZqHPcv_it%@kN@8<=bt@|`}ef+>Gb-{f8Rd(KRYusn*p`w@agpaAKZI>@7}|_ zR3s-^`u3h<+55!ozZUO5!e0O2_opEJpOykqX#e!Q{u8LE ziJQa!x1;|N)?9sZ{$EzP&o{)GA87CU(!KNbI_sZZ>;G9X)IDjhZ+QQI-uuK^YH|O0 z?@i$U`=S3ydEM{r6`;)SrFr9+*|y5w>ovM{ub$U&g6ok#N6!CY>aX1>{kR#C9zFhf?fnlq)aDYypA-E58SekLy?@xmch5iRJ&N+b9?G9gN^bt2 zvH$n({bzsg|6cuJdHu)oJ-4*?>iqw(WckMZ+mCDSIph7|c>OU$!$7#Tn1F#+yC6W|3I?b?<9sAkTc5fma4D+{wx2)^xBi+AENhvKYr)^WV?UK zH}2ee1ZtfASQ`JMa^Hu~_OtK*UTpuoF8<@v_%p)ue^l)IYQ3+W|KEY{PnX7hT3UJQ z^1M^pdq1uI@V)+jxy=tn{-@{vKk2W0)4kV1{+FQq(fT&j*4w-9lkNW{gZo4Ef1cML z*8l%V|HHfYpZKN(ZRpL-`7E{mXVjl7=l=?!re{bO=dk^^U~uD&q5k{!`qR;Sk4k^s zl78nbW{1G$i{tgrw)g)Z-g7K_kJ$I$uh#!C`uBmqe!l$Q&iM~AionKZMu+6#sLf|DOta zD`$KCXY)O;t@r7I2J{pDlKKC=9d+x1e0nkVgb0>}O5 zJ;}ZI>}@6PY7Aw_57kBUKSs_!UBBgN(Mjk0eSQ8vx14{n{{JuipXc8HWMqhYZW=dp zd*$u&2cUM`&)diCC9!sQF^jIs?dA3QkQSEh&zDnz7%HZh*F=Mgx5wZtx_bW)?tAZY z@6S=Uduq089rFWa`!CHFb)Qt`|Na?&mQ%i z|B6D1O11;xH7~`Bug8{u5{>^`%CP53vi!;KHUIfPgGR%VG9NgndfR@R2yXU+d5C-~ zwDiNe_dmA%xhp)cNk#63{{KJcE5YIT{mJrRjJ6DpF>}P&)B;q`qh=(d$>x=ZuRj%y z`#2Rm2CLK*|L^Gh^qnyu|H*%F{NrBzHN4K!?p3n=yz+|UBsqk-?`@> zh{k_YwXFTcGylJ9{Hf@@XK$kqD$hJQd(XqypSafl@T$3Tyv|1Op#J|y=Qqc!tN73V zLt);pDe9lC)_+|UQ~XTo-=FpW8~^ULjr*l4f7Zm;=B;FTu-8LU@@w5s@%lz}+mD=k z9`D|x-TjkI{;Qz;nXl$|PJp^NpVaby?Amikd!G=f`Qq>L;q?9=pLM2d-}}#O#{sE% zpPc{41gb;|_g}Am8~*sO-GkE*TjDj(-RMH!*XT>J6W`aeZ`K7#tfpEu4w0ZM!y z=Dq)S?hm+g{Y^;!Ah_{=EdSTDnls1iloR~`g26T_I?H0-`hVB| zANY4qPXF(u{^#7Lt_Z#H@|GYn8=AM(%`}Ed-=vsdS zG!G*z2x>UmoPOH%{eJ`hBeaAF8Yo$}|0CPJ=fCSXJoh}`z27+hlUn{c^_ox1u{5;f zzh8|%32HZd%Dw-y*5*wj|Kt9DAHG+v|6UVb_vCo}tnk{a;m`5b%xFm;y-iuQ{%_Wv z`|LFW??0`3->8BV0idxFoL;NE|NZ&Y_PrCp6?)`_g_ddt|gpZ1e zANBwLDSz?%zc259ir)V*`}3j-)U4cy)=of~_hSSNaoIh-Y&Y}%zux-{4|?xE@%`Cz z{%?)nT$`iN@h#5&dK>;PkMutZ{rUF(KPN+f{T=%c6XpLMwE20`|IyUAm!TG=pG>ab zEwBZ52+%ri^Z(zR|1j9@mKYahw5B0mJa?Bp1 zbyOJ6&D-05{>K*oXWQ!^n=@2AHm?k}dt7WkKUm$L@dIey<$CpB^9Qf@y^Ln8*n7Rs z)NiiU+5Ue=&VQKy6g4q{hF(CuCUC}O`~YeM-zmL5x8`Vgz3l0ylh*(Fr2nve-~Znh zF>;?~t^awe=IQl1=H-6#9=*Bw`$v!e@07aV=5@>s=5>?JpBmeJ|7=UEcvf86%|Lmz|9S55zCkL{l+|LorXr#NrNKQ_CMlkIx9*S43lppBRm?R=uW|Bv^N zh425{@l9{v^DKLxS^Uqf_YbU`y!^-c50&#j¼UT3-gr*-`a?|omqnLk{%|0xU} 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 literal 0 HcmV?d00001 diff --git a/crates/bone-jig/tests/smoke.rs b/crates/bone-jig/tests/smoke.rs new file mode 100644 index 0000000..9af23e7 --- /dev/null +++ b/crates/bone-jig/tests/smoke.rs @@ -0,0 +1,218 @@ +use std::path::{Path, PathBuf}; + +use bone_jig::{RunStatus, Scenario, ScenarioSource, StepOutcome, run_scenario}; +use bone_render::{PixelDiff, PixelDiffThreshold, decode_png}; + +const GOLDEN_DIFF_TOLERANCE: f64 = 16.0 / 255.0; +const UPDATE_ENV: &str = "BONE_UPDATE_JIG_SMOKE"; + +fn manifest_path(rel: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel) +} + +fn run_library_scenario(name: &str, out_dir: &Path) -> bone_jig::RunResult { + let source = ScenarioSource::Path(manifest_path(&format!("scenarios/{name}"))); + let scenario = match Scenario::load(&source) { + Ok(s) => s, + Err(e) => panic!("load {name}: {e}"), + }; + let mut sink = Vec::new(); + match run_scenario(&scenario, out_dir, &mut sink) { + Ok(result) => result, + Err(e) => panic!("run {name}: {e}"), + } +} + +fn find_artifact(out_dir: &Path, suffix: &str) -> PathBuf { + let entries = match std::fs::read_dir(out_dir) { + Ok(it) => it, + Err(e) => panic!("read {}: {e}", out_dir.display()), + }; + entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| { + path.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.ends_with(suffix)) + }) + .unwrap_or_else(|| panic!("no artifact ending in {suffix} under {}", out_dir.display())) +} + +fn check_golden(actual_png: &Path, golden: &Path) { + let bytes = match std::fs::read(actual_png) { + Ok(b) => b, + Err(e) => panic!("read {}: {e}", actual_png.display()), + }; + if std::env::var(UPDATE_ENV).is_ok() { + if let Some(parent) = golden.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + panic!("create {}: {e}", parent.display()); + } + if let Err(e) = std::fs::write(golden, &bytes) { + panic!("write {}: {e}", golden.display()); + } + return; + } + let Ok(golden_bytes) = std::fs::read(golden) else { + panic!( + "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate", + golden.display(), + ); + }; + let ((actual_extent, actual_rgba), (golden_extent, golden_rgba)) = + match (decode_png(&bytes), decode_png(&golden_bytes)) { + (Ok(a), Ok(g)) => (a, g), + (Err(e), _) | (_, Err(e)) => panic!("decode png: {e}"), + }; + assert_eq!(actual_extent, golden_extent, "golden extent drift"); + let threshold = PixelDiffThreshold::new(GOLDEN_DIFF_TOLERANCE); + let report = + match PixelDiff::compare_bytes(actual_extent, &actual_rgba, &golden_rgba, threshold) { + Ok(r) => r, + Err(e) => panic!("PixelDiff rejected inputs: {e}"), + }; + assert!( + report.is_clean(), + "smoke frame drifted from golden: {} mismatches, worst {:?}", + report.over_threshold(), + report.worst(), + ); +} + +#[test] +fn shell_smoke_matches_golden() { + let dir = match tempfile::tempdir() { + Ok(d) => d, + Err(e) => panic!("tempdir: {e}"), + }; + let result = run_library_scenario("smoke.ron", dir.path()); + assert_eq!(result.status, RunStatus::Passed, "result: {result:?}"); + let snapshot = find_artifact(dir.path(), "_final.png"); + check_golden(&snapshot, &manifest_path("tests/goldens/smoke_final.png")); +} + +fn run_text(text: &str, out_dir: &Path) -> bone_jig::RunResult { + let scenario = match Scenario::parse(text) { + Ok(s) => s, + Err(e) => panic!("parse scenario: {e}"), + }; + let mut sink = Vec::new(); + match run_scenario(&scenario, out_dir, &mut sink) { + Ok(result) => result, + Err(e) => panic!("run scenario: {e}"), + } +} + +fn read_bytes(path: &Path) -> Vec { + match std::fs::read(path) { + Ok(bytes) => bytes, + Err(e) => panic!("read {}: {e}", path.display()), + } +} + +#[test] +fn step_import_lands_one_frame_after_open() { + let dir = match tempfile::tempdir() { + Ok(d) => d, + Err(e) => panic!("tempdir: {e}"), + }; + let fixture = manifest_path("../bone-interop/tests/goldens/cube.step"); + let Some(path) = fixture.to_str() else { + panic!("fixture path is not utf-8: {}", fixture.display()); + }; + let text = format!( + r#"Scenario(steps: [Advance(2), OpenDocument({path:?}), Advance(1), DumpTree("cube")])"# + ); + let result = run_text(&text, dir.path()); + assert_eq!(result.status, RunStatus::Passed, "result: {result:?}"); + let tree = match std::fs::read_to_string(dir.path().join("003_cube.tree.json")) { + Ok(t) => t, + Err(e) => panic!("read tree: {e}"), + }; + assert!( + tree.contains("cube") && !tree.contains("Untitled"), + "a synchronous open replaces the default document within one frame; tree: {tree}", + ); +} + +#[test] +fn missing_step_document_fails_the_step_and_halts() { + let dir = match tempfile::tempdir() { + Ok(d) => d, + Err(e) => panic!("tempdir: {e}"), + }; + let result = run_text( + r#"Scenario(steps: [OpenDocument("/nonexistent/clam.step"), Snapshot("after")])"#, + dir.path(), + ); + assert_eq!( + result.status, + RunStatus::Failed, + "a missing import must fail the run", + ); + assert_eq!( + result.steps.len(), + 1, + "the snapshot after a failed open must not run", + ); + assert!(matches!(result.steps[0].outcome, StepOutcome::Failed(_))); + assert!(!dir.path().join("001_after.png").exists()); +} + +#[test] +fn smoke_run_is_byte_identical_across_runs() { + let first = match tempfile::tempdir() { + Ok(d) => d, + Err(e) => panic!("tempdir: {e}"), + }; + let second = match tempfile::tempdir() { + Ok(d) => d, + Err(e) => panic!("tempdir: {e}"), + }; + let a = run_library_scenario("smoke.ron", first.path()); + let b = run_library_scenario("smoke.ron", second.path()); + assert_eq!(a.status, RunStatus::Passed, "first run: {a:?}"); + assert_eq!(b.status, RunStatus::Passed, "second run: {b:?}"); + assert_eq!( + read_bytes(&find_artifact(first.path(), "_final.png")), + read_bytes(&find_artifact(second.path(), "_final.png")), + "two runs of the same scenario must produce byte-identical pixels", + ); + assert_eq!( + read_bytes(&find_artifact(first.path(), "_final.tree.json")), + read_bytes(&find_artifact(second.path(), "_final.tree.json")), + "two runs must produce byte-identical trees", + ); +} + +#[test] +fn every_library_scenario_runs_clean() { + let dir = manifest_path("scenarios"); + let entries = match std::fs::read_dir(&dir) { + Ok(it) => it, + Err(e) => panic!("read {}: {e}", dir.display()), + }; + let ran: Vec = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "ron")) + .inspect(|path| { + let out = match tempfile::tempdir() { + Ok(d) => d, + Err(e) => panic!("tempdir: {e}"), + }; + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + panic!("scenario path is not utf-8: {}", path.display()); + }; + let result = run_library_scenario(name, out.path()); + assert_eq!( + result.status, + RunStatus::Passed, + "{name} did not run clean: {result:?}", + ); + }) + .collect(); + assert!(ran.len() >= 7, "library shrank: {ran:?}"); +} diff --git a/crates/bone-render/Cargo.toml b/crates/bone-render/Cargo.toml index f0e0a58..f45f846 100644 --- a/crates/bone-render/Cargo.toml +++ b/crates/bone-render/Cargo.toml @@ -14,6 +14,7 @@ bytemuck = { workspace = true } lyon_tessellation = { workspace = true } nalgebra = { workspace = true } png = { workspace = true } +serde = { workspace = true } slotmap = { workspace = true } swash = { workspace = true } thiserror = { workspace = true } diff --git a/crates/bone-render/src/camera.rs b/crates/bone-render/src/camera.rs index 7076253..17a0f20 100644 --- a/crates/bone-render/src/camera.rs +++ b/crates/bone-render/src/camera.rs @@ -1,7 +1,9 @@ use bone_types::{Length, Vec2}; +use serde::{Deserialize, Serialize}; use uom::si::length::millimeter; -#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] pub struct ViewportPx(u32); impl ViewportPx { diff --git a/crates/bone-render/src/gpu.rs b/crates/bone-render/src/gpu.rs index f116d29..db05139 100644 --- a/crates/bone-render/src/gpu.rs +++ b/crates/bone-render/src/gpu.rs @@ -9,6 +9,12 @@ pub(crate) const BYTES_PER_PIXEL: u32 = 4; pub(crate) const PICK_BYTES_PER_PIXEL: u32 = 4; const MSAA_SAMPLE_COUNT: u32 = 4; +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum AdapterPolicy { + Platform, + Software, +} + #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct BackendTag(wgpu::Backend); @@ -121,21 +127,53 @@ pub struct OffscreenContext { gpu: Gpu, color: wgpu::Texture, pick: wgpu::Texture, + depth: wgpu::Texture, pick_staging: wgpu::Buffer, extent: ViewportExtent, } +fn require_nonzero(extent: ViewportExtent) -> Result<()> { + if extent.width().value() == 0 || extent.height().value() == 0 { + return Err(RenderError::ZeroExtent); + } + Ok(()) +} + +fn offscreen_color_texture(device: &wgpu::Device, extent: ViewportExtent) -> wgpu::Texture { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("bone-render:offscreen-color"), + size: texture_size(extent), + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: COLOR_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }) +} + +fn offscreen_pick_texture(device: &wgpu::Device, extent: ViewportExtent) -> wgpu::Texture { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("bone-render:offscreen-pick"), + size: texture_size(extent), + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: PICK_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }) +} + impl OffscreenContext { - pub async fn new(extent: ViewportExtent) -> Result { - if extent.width().value() == 0 || extent.height().value() == 0 { - return Err(RenderError::ZeroExtent); - } + pub async fn new(extent: ViewportExtent, policy: AdapterPolicy) -> Result { + require_nonzero(extent)?; let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); let adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::LowPower, - force_fallback_adapter: false, + force_fallback_adapter: matches!(policy, AdapterPolicy::Software), compatible_surface: None, }) .await?; @@ -150,26 +188,9 @@ impl OffscreenContext { }) .await?; let capabilities = Capabilities::probe(&adapter); - let color = device.create_texture(&wgpu::TextureDescriptor { - label: Some("bone-render:offscreen-color"), - size: texture_size(extent), - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: COLOR_FORMAT, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, - view_formats: &[], - }); - let pick = device.create_texture(&wgpu::TextureDescriptor { - label: Some("bone-render:offscreen-pick"), - size: texture_size(extent), - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: PICK_FORMAT, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, - view_formats: &[], - }); + let color = offscreen_color_texture(&device, extent); + let pick = offscreen_pick_texture(&device, extent); + let depth = crate::depth_texture(&device, extent); let pick_staging = device.create_buffer(&wgpu::BufferDescriptor { label: Some("bone-render:pick-readback"), size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT), @@ -184,11 +205,49 @@ impl OffscreenContext { }, color, pick, + depth, pick_staging, extent, }) } + pub fn resize(&mut self, extent: ViewportExtent) -> Result<()> { + require_nonzero(extent)?; + self.color = offscreen_color_texture(&self.gpu.device, extent); + self.pick = offscreen_pick_texture(&self.gpu.device, extent); + self.depth = crate::depth_texture(&self.gpu.device, extent); + self.extent = extent; + Ok(()) + } + + pub fn render_passes(&self, build_passes: F) + where + F: FnOnce( + &mut wgpu::CommandEncoder, + &wgpu::TextureView, + &wgpu::TextureView, + &wgpu::TextureView, + ), + { + let mut encoder = self + .gpu + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("bone-render:offscreen-frame"), + }); + let color_view = self.color_view(); + let pick_view = self.pick_view(); + let depth_view = self + .depth + .create_view(&wgpu::TextureViewDescriptor::default()); + build_passes(&mut encoder, &color_view, &pick_view, &depth_view); + self.gpu.queue.submit(Some(encoder.finish())); + } + + pub fn capture(&self) -> Result { + self.render(|_, _, _| {}) + } + #[must_use] pub fn gpu(&self) -> &Gpu { &self.gpu diff --git a/crates/bone-render/src/lib.rs b/crates/bone-render/src/lib.rs index 6390714..f48ef48 100644 --- a/crates/bone-render/src/lib.rs +++ b/crates/bone-render/src/lib.rs @@ -19,7 +19,7 @@ pub use camera3::{ world_ray, zoom_about_pixel, }; pub use diff::{PixelDiff, PixelDiffError, PixelDiffReport, PixelDiffThreshold, PixelMismatch}; -pub use gpu::{BackendTag, Capabilities, Gpu, OffscreenContext}; +pub use gpu::{AdapterPolicy, BackendTag, Capabilities, Gpu, OffscreenContext}; pub use navigate::{DragModifiers, NavGesture, ViewportNavigator}; pub use pick::{ EntityKindTag, PickAperture, PickId, PickIdError, PickIndex, PickQuery, PickedItem, Picker, @@ -39,7 +39,7 @@ pub use scene::{ }; pub use snapshot::{ ClearColor, EdgeStyle, GlyphStyle, GridStyle, SnapshotFrame, StrokeStyle, Style, TextStyle, - decode_png, encode_png, + decode_png, encode_png, encode_png_rgba, }; pub use surface::{SurfaceContext, SurfaceError}; pub use tween::CameraTween; diff --git a/crates/bone-render/src/pipelines/vector.rs b/crates/bone-render/src/pipelines/vector.rs index 9b10671..2ac6721 100644 --- a/crates/bone-render/src/pipelines/vector.rs +++ b/crates/bone-render/src/pipelines/vector.rs @@ -355,7 +355,7 @@ impl core::fmt::Debug for StrokePipeline { #[cfg(test)] mod tests { use super::*; - use crate::{OffscreenContext, ViewportExtent, ViewportPx}; + use crate::{AdapterPolicy, OffscreenContext, ViewportExtent, ViewportPx}; fn clear_black(encoder: &mut wgpu::CommandEncoder, color: &wgpu::TextureView) { let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { @@ -415,7 +415,8 @@ mod tests { #[test] fn a_translucent_polyline_joint_blends_once_on_the_gpu() { let extent = ViewportExtent::square(ViewportPx::new(64)); - let Ok(ctx) = pollster::block_on(OffscreenContext::new(extent)) else { + let Ok(ctx) = pollster::block_on(OffscreenContext::new(extent, AdapterPolicy::Platform)) + else { return; }; let mut stroke = StrokePipeline::new(ctx.gpu(), ctx.color_format()); @@ -449,7 +450,8 @@ mod tests { #[test] fn convex_fill_and_stroke_paint_their_pixels_on_the_gpu() { let extent = ViewportExtent::square(ViewportPx::new(64)); - let Ok(ctx) = pollster::block_on(OffscreenContext::new(extent)) else { + let Ok(ctx) = pollster::block_on(OffscreenContext::new(extent, AdapterPolicy::Platform)) + else { return; }; let mut convex = ConvexPolyPipeline::new(ctx.gpu(), ctx.color_format()); diff --git a/crates/bone-render/src/snapshot.rs b/crates/bone-render/src/snapshot.rs index b28f05f..4ff71d4 100644 --- a/crates/bone-render/src/snapshot.rs +++ b/crates/bone-render/src/snapshot.rs @@ -400,17 +400,18 @@ impl SnapshotFrame { } pub fn encode_png(frame: &SnapshotFrame) -> Result> { + encode_png_rgba(frame.extent, frame.rgba()) +} + +pub fn encode_png_rgba(extent: ViewportExtent, rgba: &[u8]) -> Result> { let mut out: Vec = Vec::new(); { - let mut encoder = png::Encoder::new( - &mut out, - frame.extent.width().value(), - frame.extent.height().value(), - ); + let mut encoder = + png::Encoder::new(&mut out, extent.width().value(), extent.height().value()); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); let mut writer = encoder.write_header()?; - writer.write_image_data(frame.rgba())?; + writer.write_image_data(rgba)?; } Ok(out) } diff --git a/crates/bone-render/tests/common/mod.rs b/crates/bone-render/tests/common/mod.rs index 51b3830..71ad346 100644 --- a/crates/bone-render/tests/common/mod.rs +++ b/crates/bone-render/tests/common/mod.rs @@ -6,8 +6,8 @@ use std::path::PathBuf; use bone_render::{ - OffscreenContext, PixelDiff, PixelDiffThreshold, RenderError, SnapshotFrame, ViewportExtent, - ViewportPx, decode_png, encode_png, + AdapterPolicy, OffscreenContext, PixelDiff, PixelDiffThreshold, RenderError, SnapshotFrame, + ViewportExtent, ViewportPx, decode_png, encode_png, }; const ADAPTER_RETRIES: u32 = 3; @@ -23,7 +23,7 @@ pub fn extent_square(side: u32) -> ViewportExtent { pub fn make_context(extent: ViewportExtent) -> OffscreenContext { let mut last_err: Option = None; for attempt in 0..ADAPTER_RETRIES { - match pollster::block_on(OffscreenContext::new(extent)) { + match pollster::block_on(OffscreenContext::new(extent, AdapterPolicy::Platform)) { Ok(ctx) => return ctx, Err(e @ (RenderError::NoAdapter(_) | RenderError::Device(_))) => { last_err = Some(e); diff --git a/crates/bone-ui/src/input/pointer.rs b/crates/bone-ui/src/input/pointer.rs index db83383..2aafd50 100644 --- a/crates/bone-ui/src/input/pointer.rs +++ b/crates/bone-ui/src/input/pointer.rs @@ -80,6 +80,11 @@ impl FrameInstant { pub fn since(self, earlier: Self) -> Duration { self.0.saturating_sub(earlier.0) } + + #[must_use] + pub fn after(self, delay: Duration) -> Self { + Self(self.0.saturating_add(delay)) + } } #[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Serialize, Deserialize)]