use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use bone_document::{
    DimensionKind, DimensionValue, Document, DocumentFolder, EditOutcome, EvaluatedExtrude,
    EvaluatedModel, ExtrudeError, ExtrudeFeature, FeatureNode, LineData, RebuildBudget,
    RebuildCost, RebuildPass, RecomputeScope, Sketch, SketchDimension, SketchEdit, SketchEntity,
    SketchRelation, SketchVersion, SolverError, UndoStack, evaluate_extrude, evaluate_sketch,
};
use bone_render::{
    Camera2, CameraTween, ChromeInstance, ChromePipeline, ChromeTextPipeline, ConvexInstance,
    ConvexPolyPipeline, DragModifiers, EdgeScene, Gpu, IconInstance, IconPipeline, 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, BrepFaceId, BudgetCeiling, Camera3, ChordHeightTolerance,
    CubicEasing, DisplayMode, DocumentId, ExtrudeId, FeatureId, GeometryGeneration, Length, Plane3,
    Point2, RebuildError, RebuildStatus, 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::widgets::TreeBadge;
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,
    icon_pipeline: IconPipeline,
    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<Theme>,
    shell: shell::Shell,
    document: Document,
    plane_sketches: BTreeMap<Plane, SketchId>,
    mode: Mode,
    extrude_preview: Option<ExtrudePreview>,
    model: EvaluatedModel,
    model_passes: Vec<(FeatureId, RebuildPass)>,
    changed_features: BTreeSet<FeatureId>,
    needs_rebuild: bool,
    pending_reattach: Option<SketchId>,
    solid_renderer: SolidRenderer,
    solid_view: Option<SolidViewData>,
    camera3: Option<Camera3>,
    framed_extrude: Option<ExtrudeId>,
    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<LayoutRect>,
    pending_exit: bool,
    current_folder: Option<DocumentFolder>,
    documents_root: PathBuf,
    file_picker: Option<file_menu::FilePickerSession>,
    native_picker: Option<native_picker::PendingHandle>,
    step_job: Option<step_jobs::StepJob>,
    pending_overwrite: Option<PendingOverwrite>,
    last_saved: Option<Document>,
    pending_discard: Option<PendingDiscard>,
    notification: Option<Notification>,
    shortcut_bar: Option<shortcut_bar::ShortcutBarState>,
}

#[derive(Clone, Debug, PartialEq)]
enum PendingDiscard {
    New,
    Open(PathBuf),
    ImportStep(PathBuf),
    InstallImported {
        document: Box<Document>,
        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<String>,
}

struct InputState {
    cursor_px: Option<WindowPoint>,
    left_pan: bool,
    middle_pan: bool,
    modifiers: ModifierMask,
    pending_pressed: PointerButtonMask,
    pending_released: PointerButtonMask,
    pending_keys: Vec<UiKeyEvent>,
    pending_text: String,
    pending_scroll_y: f32,
}

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(),
            pending_scroll_y: 0.0,
        }
    }
}

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<PointerSample> {
        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.scroll_y = core::mem::replace(&mut self.pending_scroll_y, 0.0);
        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::<millimeter>(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),
    }
}

const WHEEL_LINE_PX: f32 = 48.0;

fn wheel_offset_px(delta: ScrollDelta) -> f32 {
    match delta {
        ScrollDelta::Lines { y, .. } => -y * WHEEL_LINE_PX,
        #[allow(
            clippy::cast_possible_truncation,
            reason = "trackpad pixel delta collapses to f32 at the sub-pixel limit"
        )]
        ScrollDelta::Pixels { y, .. } => -(y as f32),
    }
}

fn zoom_about(camera: Camera2, cursor: Option<WindowPoint>, 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<Point2> {
    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<Pending>) -> Option<ClickAnchor> {
    match pending {
        None => None,
        Some(Pending::First(a) | Pending::Second(_, a)) => Some(a),
    }
}

fn solid_pick_index(model: &EvaluatedModel) -> Option<PickIndex> {
    let faces: BTreeSet<BrepFaceId> = model
        .bodies()
        .flat_map(|(_, solid)| solid.iter_faces().map(bone_document::BrepFace::id))
        .collect();
    if faces.is_empty() {
        return None;
    }
    PickIndex::build_solid(faces, core::iter::empty(), core::iter::empty()).ok()
}

fn active_pick_index(state: &AppState) -> Option<PickIndex> {
    if matches!(state.mode, Mode::Idle) && state.solid_view.is_some() {
        return solid_pick_index(&state.model);
    }
    match state.scene.pick_index() {
        Ok(index) => Some(index),
        Err(error) => {
            tracing::warn!(error = %error, "build sketch pick index");
            None
        }
    }
}

fn pick_at(state: &AppState, target: &impl FrameTarget, cursor: WindowPoint) -> Option<PickedItem> {
    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 = active_pick_index(state)?;
    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 picked = pick_at(state, target, cursor);
    if let Some(sketch) = state.pending_reattach {
        if let Some(PickedItem::BrepFace(face)) = picked {
            apply_reattach(state, sketch, face);
        }
        return;
    }
    if !additive
        && matches!(state.mode, Mode::Idle)
        && let Some(PickedItem::BrepFace(face)) = picked
        && begin_face_sketch(state, face)
    {
        return;
    }
    let item = picked.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<Sketch> {
    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<Plane, SketchId>) -> Option<SketchId> {
    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<FeatureTool>) {
    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<shell::ExtrudeEdit>) {
    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<ExtrudeId>) {
    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<ExtrudeId>,
) -> Option<Mode> {
    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<shell::ConfirmAction>) {
    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<shell::ConfirmAction>,
) -> Option<ExtrudeId> {
    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<GeometryGeneration>,
    failed: bool,
    error: Option<ExtrudeError>,
}

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<ExtrudeId>,
) -> Option<ExtrudeFeature> {
    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 clear_solid(state: &mut AppState) {
    state.solid_view = None;
    state.camera3 = None;
    state.view.home = None;
    state.view.tween = None;
}

fn body_passes(model: &EvaluatedModel) -> Vec<(FeatureId, RebuildPass)> {
    model
        .bodies()
        .filter_map(|(feature, _)| model.built_at(feature).map(|pass| (feature, pass)))
        .collect()
}

fn build_combined_view(model: &EvaluatedModel) -> Option<SolidViewData> {
    let mut parts = model
        .bodies()
        .filter_map(|(_, solid)| build_solid_view(solid).ok());
    let first = parts.next()?;
    Some(parts.fold(first, |acc, part| SolidViewData {
        faces: acc.faces.merge(part.faces),
        edges: acc.edges.merge(part.edges),
        aabb: acc.aabb.union(part.aabb),
    }))
}

fn recompute_model(state: &mut AppState, scope: RecomputeScope) {
    state.model.recompute(
        &state.document,
        state.document.suppressed(),
        state.document.rollback(),
        scope,
    );
}

fn rebuild_model_view(state: &mut AppState) {
    state.model_passes = body_passes(&state.model);
    state.solid_view = build_combined_view(&state.model);
    if state.solid_view.is_none() {
        clear_solid(state);
    }
}

fn defer_rebuild(first: bool, cost: RebuildCost, budget: RebuildBudget) -> bool {
    !first && !cost.within(budget)
}

fn sync_model_view(state: &mut AppState) {
    state.extrude_preview = None;
    recompute_model(state, RecomputeScope::Full);
    let passes = body_passes(&state.model);
    if passes.is_empty() {
        if state.solid_view.is_some() || !state.model_passes.is_empty() {
            state.model_passes.clear();
            clear_solid(state);
        }
        state.changed_features.clear();
        state.needs_rebuild = false;
        return;
    }
    if passes == state.model_passes && state.solid_view.is_some() {
        return;
    }
    let first = state.model_passes.is_empty() || state.solid_view.is_none();
    if defer_rebuild(
        first,
        state.model.rebuild_cost(),
        RebuildBudget::INTERACTIVE,
    ) {
        let changed = passes
            .iter()
            .filter(|entry| !state.model_passes.contains(entry))
            .map(|(feature, _)| *feature);
        state.changed_features.extend(changed);
        state.needs_rebuild = true;
        return;
    }
    rebuild_model_view(state);
    state.changed_features.clear();
    state.needs_rebuild = false;
}

fn status_to_badge(status: RebuildStatus) -> Option<TreeBadge> {
    match status {
        RebuildStatus::Error(_) => Some(TreeBadge::Error),
        RebuildStatus::Warning(_) => Some(TreeBadge::Warning),
        RebuildStatus::NeedsRebuild => Some(TreeBadge::RebuildNeeded),
        RebuildStatus::UpToDate => None,
    }
}

fn feature_badge(state: &AppState, feature: FeatureId) -> Option<TreeBadge> {
    let status = state.model.status(feature).and_then(status_to_badge);
    let rebuild = state
        .changed_features
        .contains(&feature)
        .then_some(TreeBadge::RebuildNeeded);
    [status, rebuild].into_iter().flatten().max()
}

fn compute_feature_badges(state: &AppState) -> BTreeMap<FeatureId, TreeBadge> {
    state
        .document
        .feature_tree()
        .iter()
        .filter_map(|(feature, _)| feature_badge(state, feature).map(|badge| (feature, badge)))
        .collect()
}

fn frame_badges(state: &mut AppState) -> (BTreeMap<FeatureId, TreeBadge>, bool) {
    recompute_model(state, RecomputeScope::Full);
    (compute_feature_badges(state), state.needs_rebuild)
}

fn feature_label_text(document: &Document, feature: FeatureId) -> String {
    match document.feature_tree().node(feature) {
        Some(FeatureNode::Sketch(sketch_id)) => {
            document.sketch_label(sketch_id).unwrap_or("").to_owned()
        }
        Some(FeatureNode::Extrude(extrude_id)) => {
            document.extrude_label(extrude_id).unwrap_or("").to_owned()
        }
        _ => String::new(),
    }
}

fn sketch_of_feature(document: &Document, feature: FeatureId) -> Option<SketchId> {
    match document.feature_tree().node(feature)? {
        FeatureNode::Sketch(sketch_id) => Some(sketch_id),
        _ => None,
    }
}

struct WhatsWrongKind {
    message: bone_ui::strings::StringKey,
    is_error: bool,
    offers_reattach: bool,
}

fn whats_wrong_kind(status: RebuildStatus) -> Option<WhatsWrongKind> {
    let kind = match status {
        RebuildStatus::Error(RebuildError::DanglingReference(_)) => WhatsWrongKind {
            message: strings::WHATS_WRONG_DANGLING,
            is_error: true,
            offers_reattach: true,
        },
        RebuildStatus::Error(RebuildError::NonPlanarSketchTarget) => WhatsWrongKind {
            message: strings::WHATS_WRONG_NON_PLANAR,
            is_error: true,
            offers_reattach: false,
        },
        RebuildStatus::Error(RebuildError::UpstreamUnresolved) => WhatsWrongKind {
            message: strings::WHATS_WRONG_UPSTREAM,
            is_error: true,
            offers_reattach: false,
        },
        RebuildStatus::Error(RebuildError::Build(_)) => WhatsWrongKind {
            message: strings::WHATS_WRONG_BUILD_FAILED,
            is_error: true,
            offers_reattach: false,
        },
        RebuildStatus::Warning(_) => WhatsWrongKind {
            message: strings::WHATS_WRONG_REPAIRED,
            is_error: false,
            offers_reattach: false,
        },
        RebuildStatus::UpToDate | RebuildStatus::NeedsRebuild => return None,
    };
    Some(kind)
}

fn whats_wrong_entry(
    model: &EvaluatedModel,
    document: &Document,
    feature: FeatureId,
) -> Option<shell::WhatsWrong> {
    let kind = whats_wrong_kind(model.status(feature)?)?;
    let reattach = kind
        .offers_reattach
        .then(|| sketch_of_feature(document, feature))
        .flatten();
    Some(shell::WhatsWrong {
        label: feature_label_text(document, feature),
        message: kind.message,
        is_error: kind.is_error,
        reattach,
    })
}

fn compute_whats_wrong(model: &EvaluatedModel, document: &Document) -> Vec<shell::WhatsWrong> {
    document
        .feature_tree()
        .iter()
        .filter_map(|(feature, _)| whats_wrong_entry(model, document, feature))
        .collect()
}

fn live_dim_anchor(mode: &Mode, document: &Document, pending: &PendingDimension) -> Point2 {
    mode.sketch_id()
        .and_then(|id| document.sketch(id))
        .and_then(|sketch| smart_dimension::live_anchor(sketch, pending.proto))
        .unwrap_or(pending.anchor)
}

fn rebuild_changed(state: &mut AppState) {
    recompute_model(state, RecomputeScope::Full);
    if matches!(state.mode, Mode::Idle) {
        rebuild_model_view(state);
    }
    state.needs_rebuild = false;
    state.changed_features.clear();
}

fn force_rebuild_all(state: &mut AppState) {
    state.model = EvaluatedModel::new();
    recompute_model(state, RecomputeScope::Full);
    if matches!(state.mode, Mode::Idle) {
        rebuild_model_view(state);
    }
    state.needs_rebuild = false;
    state.changed_features.clear();
}

fn sync_solid_view(state: &mut AppState) {
    if matches!(state.mode, Mode::Idle) {
        sync_model_view(state);
        return;
    }
    state.model_passes.clear();
    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(&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(
    document: &Document,
    feature: ExtrudeFeature,
) -> Option<EvaluatedExtrude> {
    let sketch = document.sketch(feature.sketch)?;
    let evaluated_sketch = evaluate_sketch(sketch);
    Some(evaluate_extrude(
        FeatureId::default(),
        &evaluated_sketch,
        &feature,
    ))
}

fn build_solid_view(solid: &bone_document::BrepSolid) -> Result<SolidViewData, String> {
    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<ViewportRegion>) {
    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<Plane3> {
    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<Camera3> {
    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<view_cube::ViewPick>, 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<view_cube::ViewCubeMenuAction>,
    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<Camera3>,
    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<ViewportRegion> {
    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<ViewportPoint> {
    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<Point2>,
    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<Length> {
    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::<millimeter>(tol_mm))
}

fn compute_snap(
    sketch: &Sketch,
    camera: &Camera2,
    cursor_world: Point2,
    click: Option<ClickAnchor>,
) -> Option<SnapHit> {
    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<SnapHit> {
    snap::detect_endpoint_only(cursor_world, sketch, snap_tolerance(camera)?)
}

fn resolve_anchor(sketch: Option<&Sketch>, click: ClickAnchor) -> Option<Anchor> {
    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<Camera2> {
    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<Camera3> {
    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<Camera3> {
    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::<degree>(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<Plane, SketchId>,
) -> 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<Plane, SketchId>,
) -> 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<Self, PickIdError> {
        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 icon_pipeline = IconPipeline::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::light();
        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,
            icon_pipeline,
            text_pipeline,
            sdf_atlas,
            chrome_shaper,
            sans_font,
            mono_font,
            scene,
            camera,
            style,
            theme,
            shell,
            document,
            plane_sketches,
            mode: Mode::Idle,
            extrude_preview: None,
            model: EvaluatedModel::new(),
            model_passes: Vec::new(),
            changed_features: BTreeSet::new(),
            needs_rebuild: false,
            pending_reattach: 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 over_viewport = self.input.cursor_in(self.state.viewport_rect);
        if !over_viewport || modal_active(&self.state) {
            self.input.pending_scroll_y += wheel_offset_px(delta);
            return;
        }
        self.dispatch_viewport_wheel(delta);
    }

    fn dispatch_viewport_wheel(&mut self, delta: ScrollDelta) {
        let state = &mut self.state;
        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(),
        });
        self.state.style = match mode {
            bone_ui::theme::ThemeMode::Light => Style::light(),
            bone_ui::theme::ThemeMode::Dark => Style::default(),
        };
    }

    pub fn clock_mut(&mut self) -> &mut FrameClock {
        &mut self.clock
    }

    #[must_use]
    pub fn next_wake_deadline(&self) -> Option<FrameInstant> {
        next_wake_deadline(&self.state, self.clock.now())
    }
}

fn next_wake_deadline(state: &AppState, now: FrameInstant) -> Option<FrameInstant> {
    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);
    if escape_requested {
        state.pending_reattach = None;
    }
    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());
    apply_feature_command(state, frame.feature_command);
    apply_feature_reorder(state, frame.feature_reorder);
    apply_rollback_change(state, frame.rollback_change);
    begin_reattach(state, frame.reattach_request);
    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,
        icon: &mut state.icon_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,
    icon: &'a mut IconPipeline,
    text: &'a mut ChromeTextPipeline,
    atlas_pixels: &'a [u8],
    atlas_version: u64,
    viewport_px: [f32; 2],
}

fn merge_layers<T: Copy>(main: &[T], overlay: &[T]) -> Vec<T> {
    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 icons = merge_layers(&main.icons, &overlay.icons);
        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.icon.upload(self.viewport_px, &icons);
        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 (mi, ti) = (count_u32(main.icons.len()), count_u32(icons.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.icon.draw_range(encoder, color, 0..mi);
        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.icon.draw_range(encoder, color, mi..ti);
        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<ChromeInstance>,
    convex: Vec<ConvexInstance>,
    stroke: Vec<StrokeInstance>,
    icons: Vec<IconInstance>,
    glyphs: Vec<SdfGlyphInstance>,
}

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,
        icons: chrome::paint_to_icon_instances(paints),
        glyphs,
    }
}

fn apply_hotkey_actions(
    state: &mut AppState,
    actions: &[ActionId],
    cursor_layout: Option<LayoutPos>,
    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<LayoutPos>,
    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::RebuildChanged => rebuild_changed(state),
        C::ForceRebuild => force_rebuild_all(state),
        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<bone_types::SketchEntityId> = 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<bone_types::SketchEntityId> = 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<bone_types::SketchEntityId> = 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<bone_types::SketchEntityId, bone_types::SketchEntityId>,
    entity_map: std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
}

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<bone_types::SketchEntityId, bone_document::SketchEditError> {
        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<bone_types::SketchEntityId>,
    axis_id: bone_types::SketchEntityId,
    axis: &MirrorAxis,
) -> Result<Sketch, bone_document::SketchEditError> {
    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<bone_types::SketchEntityId, bone_types::SketchEntityId>,
    axis_id: bone_types::SketchEntityId,
) -> Result<Sketch, bone_document::SketchEditError> {
    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<bone_types::SketchEntityId>,
    axis_id: bone_types::SketchEntityId,
    entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
) -> Result<Sketch, bone_document::SketchEditError> {
    let relations: Vec<SketchRelation> = 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<bone_types::SketchEntityId>,
    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<bone_types::SketchEntityId, bone_types::SketchEntityId>,
) -> Option<SketchRelation> {
    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<bone_types::SketchEntityId>,
    axis_id: bone_types::SketchEntityId,
    entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
) -> Result<Sketch, bone_document::SketchEditError> {
    let dims: Vec<SketchDimension> = 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<bone_types::SketchEntityId>,
    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<bone_types::SketchEntityId, bone_types::SketchEntityId>,
) -> Option<SketchDimension> {
    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<Point2, bone_document::SketchEditError> {
    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<Point2> {
    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,
        feature_command: None,
        feature_reorder: None,
        rollback_change: None,
        reattach_request: 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<bone_ui::widgets::WidgetPaint>,
    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<LayoutRect> {
    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<bone_ui::widgets::WidgetPaint>,
    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<ActionId>,
    dim: Option<DimensionEditorOutcome>,
    dim_conflict: Option<DimConflictOutcome>,
    picker: Option<file_menu::PickerModalOutcome>,
    overwrite: Option<OverwriteOutcome>,
    discard: Option<DiscardOutcome>,
    step_progress: Option<StepProgressOutcome>,
    notification: Option<NotificationOutcome>,
    shortcut_bar: Option<shortcut_bar::ShortcutBarOutcome>,
}

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<Theme>,
    input: &mut InputSnapshot,
    hits: &mut HitFrame,
    a11y: &mut AccessTreeBuilder,
    scopes: &HotkeyScopes,
    layout_size: LayoutSize,
    cursor_world: Option<Point2>,
) -> FrameOutcomes {
    let (feature_badges, needs_rebuild) = frame_badges(state);
    let whats_wrong = compute_whats_wrong(&state.model, &state.document);
    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,
        &feature_badges,
        needs_rebuild,
        &whats_wrong,
    );
    let dim_outcome = pending_dim(&state.mode).map(|pending| {
        let live_anchor = live_dim_anchor(&state.mode, &state.document, &pending);
        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<PendingDimension> {
    match mode.dim_flow() {
        Some(DimensionFlow::Conflict(p)) => Some(p),
        Some(DimensionFlow::Editing(_)) | None => None,
    }
}

#[derive(Clone, Debug, PartialEq)]
struct DimConflictOutcome {
    paints: Vec<bone_ui::widgets::WidgetPaint>,
    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<bone_ui::widgets::WidgetPaint>,
    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<bone_ui::widgets::WidgetPaint>,
    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<DiscardOutcome>) {
    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<bone_ui::widgets::WidgetPaint>,
    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<bone_ui::widgets::WidgetPaint>,
) {
    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<StepProgressOutcome>) {
    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<bone_ui::widgets::WidgetPaint>,
    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) = &notification.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<NotificationOutcome>) {
    let Some(outcome) = outcome else { return };
    if outcome.dismissed {
        state.notification = None;
    }
}

fn apply_overwrite_outcome(state: &mut AppState, outcome: Option<OverwriteOutcome>) {
    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<PendingDimension> {
    match mode.dim_flow() {
        Some(DimensionFlow::Editing(p)) => Some(p),
        Some(DimensionFlow::Conflict(_)) | None => None,
    }
}

fn apply_dimension_request(state: &mut AppState, request: Option<PendingDimension>) {
    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<DimensionEditorOutcome>) {
    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<DimConflictOutcome>) {
    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<SketchDimension> {
    proto
        .with_kind(DimensionKind::Driven)
        .with_value(value)
        .ok()
}

fn apply_dimension_edit(state: &mut AppState, edit: Option<shell::DimensionEdit>) {
    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<SketchRelation>) {
    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<shell::MenuAction>) {
    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<Box<Document>>,
    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<String>,
) {
    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<String>,
) {
    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<PathBuf> {
    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<String>,
) {
    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<DocumentFolder>,
) {
    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<Plane, SketchId> {
    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<settings::Settings>) {
    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<shell::SketchRenameRequest>) {
    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<shell::ExtrudeRenameRequest>) {
    let Some(req) = request else { return };
    apply_extrude_rename_into(&mut state.document, &mut state.undo, req);
}

fn apply_feature_command(state: &mut AppState, command: Option<shell::FeatureCommand>) {
    let Some(command) = command else { return };
    if apply_feature_command_into(&mut state.document, &mut state.undo, command) {
        refresh_active_scene(state);
        sync_solid_view(state);
    }
}

fn apply_feature_command_into(
    document: &mut Document,
    undo: &mut UndoStack,
    command: shell::FeatureCommand,
) -> bool {
    let snapshot = document.clone();
    let changed = match command {
        shell::FeatureCommand::Suppress(feature) => {
            document.suppress(feature);
            true
        }
        shell::FeatureCommand::Unsuppress(feature) => {
            document.unsuppress(feature);
            true
        }
        shell::FeatureCommand::RollbackToHere(feature) => {
            document.roll_to_here(feature);
            true
        }
        shell::FeatureCommand::Delete(target) => apply_feature_delete(document, target),
    };
    if changed {
        undo.record(snapshot);
    }
    changed
}

fn apply_feature_delete(document: &mut Document, target: shell::FeatureTarget) -> bool {
    match target {
        shell::FeatureTarget::Sketch(sketch_id) => document.remove_sketch(sketch_id).is_some(),
        shell::FeatureTarget::Extrude(extrude_id) => document.remove_extrude(extrude_id).is_some(),
    }
}

fn apply_feature_reorder(state: &mut AppState, reorder: Option<shell::FeatureReorder>) {
    let Some(reorder) = reorder else { return };
    let snapshot = state.document.clone();
    if state
        .document
        .reorder_feature(reorder.moved, reorder.anchor, reorder.before)
    {
        state.undo.record(snapshot);
        refresh_active_scene(state);
        sync_solid_view(state);
    }
}

fn begin_reattach(state: &mut AppState, request: Option<SketchId>) {
    let Some(sketch) = request else { return };
    state.pending_reattach = Some(sketch);
    notify_info(state, strings::NOTIFY_REATTACH_PICK, None);
}

fn apply_reattach(state: &mut AppState, sketch: SketchId, face: BrepFaceId) {
    let Some((face_ref, _)) = state.model.face_for_sketch(face) else {
        notify_info(state, strings::NOTIFY_REATTACH_NON_PLANAR, None);
        return;
    };
    let snapshot = state.document.clone();
    if state.document.bind_sketch_to_face(sketch, face_ref).is_ok() {
        state.pending_reattach = None;
        state.undo.record(snapshot);
        refresh_active_scene(state);
        sync_solid_view(state);
    }
}

fn next_sketch_label(document: &Document) -> String {
    format!("Sketch{}", document.sketches().count() + 1)
}

fn begin_face_sketch(state: &mut AppState, face: BrepFaceId) -> bool {
    let Some((face_ref, basis)) = state.model.face_for_sketch(face) else {
        return false;
    };
    let snapshot = state.document.clone();
    let sketch_id = state.document.allocate_sketch();
    let label = next_sketch_label(&state.document);
    state
        .document
        .insert_sketch(sketch_id, label, Sketch::new(basis));
    if state
        .document
        .bind_sketch_to_face(sketch_id, face_ref)
        .is_err()
    {
        state.document = snapshot;
        return false;
    }
    state.undo.record(snapshot);
    state.mode = Mode::enter_sketch(sketch_id);
    refresh_active_scene(state);
    sync_solid_view(state);
    true
}

fn apply_rollback_change(state: &mut AppState, change: Option<shell::RollbackChange>) {
    let Some(change) = change else { return };
    let before = state.document.rollback();
    let snapshot = state.document.clone();
    match change {
        shell::RollbackChange::ToEnd => state.document.roll_to_end(),
        shell::RollbackChange::ToFeature(feature) => state.document.roll_to_here(feature),
    }
    if state.document.rollback() != before {
        state.undo.record(snapshot);
        refresh_active_scene(state);
        sync_solid_view(state);
    }
}

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,
            feature_command: None,
            feature_reorder: None,
            rollback_change: None,
            reattach_request: None,
            exit_sketch: false,
            confirm_action: None,
            menu_action: None,
            settings_change: None,
            view_pick: None,
            view_menu: None,
        }
    }

    fn xy_only() -> BTreeMap<Plane, SketchId> {
        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() {
        let (mut document, id) = super::initial_document(rectangle_sketch());
        let feature = sketch_mode::default_extrude_feature(id);
        let first = super::compute_extrude_preview(&document, feature)
            .and_then(|preview| preview.generation());
        document.replace_sketch(id, tall_rectangle_sketch());
        let second = super::compute_extrude_preview(&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 Some(preview) = super::compute_extrude_preview(&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 blind = |depth_mm: f64| {
            let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(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 = |depth_mm: f64| {
            let started = Instant::now();
            let Some(preview) = super::compute_extrude_preview(&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(BASE_MM);
        let durations: Vec<Duration> = (0..STEPS)
            .map(|i| edit(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());
        assert!(super::compute_extrude_preview(&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 Some(preview) = super::compute_extrude_preview(&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 Some(preview) = super::compute_extrude_preview(&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",
        );
    }

    #[test]
    fn build_combined_view_merges_every_evaluated_body() {
        let (mut document, s1) = super::initial_document(rectangle_sketch());
        let e1 = document.commit_extrude(sketch_mode::default_extrude_feature(s1));
        let s2 = document.allocate_sketch();
        document.insert_sketch(s2, "Sketch2".to_owned(), rectangle_sketch());
        let _e2 = document.commit_extrude(sketch_mode::default_extrude_feature(s2));
        let mut model = EvaluatedModel::new();
        model.recompute(
            &document,
            document.suppressed(),
            document.rollback(),
            RecomputeScope::Full,
        );
        assert_eq!(
            super::body_passes(&model).len(),
            2,
            "two separate bodies build"
        );
        let Some(f1) = document.feature_tree().feature_of_extrude(e1) else {
            panic!("the first extrude resolves to a feature id");
        };
        let Some(solid) = model.body(f1) else {
            panic!("the first body is evaluated");
        };
        let Ok(single) = super::build_solid_view(solid) else {
            panic!("a single body tessellates");
        };
        let Some(combined) = super::build_combined_view(&model) else {
            panic!("the combined view is built from both bodies");
        };
        assert_eq!(
            combined.faces.triangles().len(),
            2 * single.faces.triangles().len(),
            "the combined mesh holds both bodies",
        );
    }

    fn full_recompute(model: &mut EvaluatedModel, document: &Document) {
        model.recompute(
            document,
            document.suppressed(),
            document.rollback(),
            RecomputeScope::Full,
        );
    }

    #[test]
    fn face_bound_sketch_creation_then_break_offers_reattach() {
        use bone_types::FaceRole;
        let (mut document, base_sketch) = super::initial_document(rectangle_sketch());
        let base = document.commit_extrude(sketch_mode::default_extrude_feature(base_sketch));
        let mut model = EvaluatedModel::new();
        full_recompute(&mut model, &document);

        let Some(body) = document.feature_tree().feature_of_extrude(base) else {
            panic!("the base extrude resolves to a feature id");
        };
        let Some(solid) = model.body(body) else {
            panic!("the base body is evaluated");
        };
        let Some(face) = solid.iter_faces().find_map(|candidate| {
            matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id())
        }) else {
            panic!("the box has a planar cap");
        };
        let Some((face_ref, _basis)) = model.face_for_sketch(face) else {
            panic!("a planar cap yields a face reference and basis");
        };

        let face_sketch = document.allocate_sketch();
        document.insert_sketch(face_sketch, "Sketch2".to_owned(), rectangle_sketch());
        let Ok(()) = document.bind_sketch_to_face(face_sketch, face_ref) else {
            panic!("binding a fresh sketch to a face is acyclic");
        };
        let face_extrude =
            document.commit_extrude(sketch_mode::default_extrude_feature(face_sketch));
        full_recompute(&mut model, &document);
        assert_eq!(
            super::body_passes(&model).len(),
            2,
            "the face-bound extrude is a separate second body",
        );

        document.remove_extrude(base);
        full_recompute(&mut model, &document);
        let Some(face_feature) = document.feature_tree().feature_of_sketch(face_sketch) else {
            panic!("the face-bound sketch keeps its feature id");
        };
        assert!(
            matches!(
                model.status(face_feature),
                Some(RebuildStatus::Error(RebuildError::DanglingReference(_)))
            ),
            "deleting the base dangles the face-bound sketch",
        );
        let Some(face_extrude_feature) = document.feature_tree().feature_of_extrude(face_extrude)
        else {
            panic!("the face-bound extrude keeps its feature id");
        };
        assert_eq!(
            model.status(face_extrude_feature),
            Some(RebuildStatus::Error(RebuildError::UpstreamUnresolved)),
            "the downstream extrude is blamed as upstream-unresolved, not a second dangling owner",
        );
        let wrong = super::compute_whats_wrong(&model, &document);
        assert_eq!(
            wrong
                .iter()
                .filter(|entry| entry.reattach.is_some())
                .count(),
            1,
            "only the sketch that owns the dangling reference offers reattach",
        );
        assert!(
            wrong
                .iter()
                .any(|entry| entry.reattach == Some(face_sketch)),
            "the dangling face-bound sketch offers reattach",
        );
        assert!(
            wrong
                .iter()
                .any(|entry| entry.message == strings::WHATS_WRONG_UPSTREAM
                    && entry.reattach.is_none()),
            "the downstream body reports an upstream failure with no reattach of its own",
        );
    }

    fn offscreen_context(extent: ViewportExtent, remaining: u32) -> bone_render::OffscreenContext {
        use bone_render::{AdapterPolicy, OffscreenContext, RenderError};
        match pollster::block_on(OffscreenContext::new(extent, AdapterPolicy::Platform)) {
            Ok(ctx) => ctx,
            Err(RenderError::NoAdapter(_) | RenderError::Device(_)) if remaining > 0 => {
                std::thread::sleep(std::time::Duration::from_millis(100));
                offscreen_context(extent, remaining - 1)
            }
            Err(e) => panic!("offscreen context init failed: {e}"),
        }
    }

    fn check_solid_golden(frame: &bone_render::SnapshotFrame, golden_rel: &str) {
        use bone_render::{PixelDiff, PixelDiffThreshold, decode_png, encode_png};
        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(golden_rel);
        if std::env::var_os("BONE_UPDATE_REBUILD_GOLDENS").is_some() {
            let Ok(bytes) = encode_png(frame) else {
                panic!("encode_png failed");
            };
            if let Some(parent) = path.parent() {
                let Ok(()) = std::fs::create_dir_all(parent) else {
                    panic!("create goldens dir failed");
                };
            }
            let Ok(()) = std::fs::write(&path, &bytes) else {
                panic!("write golden {} failed", path.display());
            };
            return;
        }
        let Ok(bytes) = std::fs::read(&path) else {
            panic!(
                "golden {} missing; rerun with BONE_UPDATE_REBUILD_GOLDENS=1 to bless",
                path.display(),
            );
        };
        let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else {
            panic!("decode golden failed");
        };
        assert_eq!(golden_extent, frame.extent(), "golden extent drift");
        let threshold = PixelDiffThreshold::new(16.0 / 255.0);
        let Ok(report) = PixelDiff::compare(frame, &golden_rgba, threshold) else {
            panic!("PixelDiff rejected inputs");
        };
        assert!(
            report.is_clean(),
            "rebuild-downstream golden drifted: {} mismatches, worst {:?}",
            report.over_threshold(),
            report.worst(),
        );
    }

    #[test]
    fn rebuild_downstream_scene_matches_golden() {
        use bone_types::FaceRole;
        let (mut document, base_sketch) = super::initial_document(rectangle_sketch());
        let base = document.commit_extrude(sketch_mode::default_extrude_feature(base_sketch));
        let mut model = EvaluatedModel::new();
        full_recompute(&mut model, &document);

        let Some(body) = document.feature_tree().feature_of_extrude(base) else {
            panic!("the base extrude resolves to a feature id");
        };
        let Some(solid) = model.body(body) else {
            panic!("the base body is evaluated");
        };
        let Some(cap) = solid.iter_faces().find_map(|candidate| {
            matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id())
        }) else {
            panic!("the box has a planar top cap");
        };
        let Some((face_ref, _basis)) = model.face_for_sketch(cap) else {
            panic!("a planar cap yields a face reference and basis");
        };

        let face_sketch = document.allocate_sketch();
        document.insert_sketch(face_sketch, "Sketch2".to_owned(), rectangle_sketch());
        let Ok(()) = document.bind_sketch_to_face(face_sketch, face_ref) else {
            panic!("binding the second sketch to the cap is acyclic");
        };
        let _ = document.commit_extrude(sketch_mode::default_extrude_feature(face_sketch));
        full_recompute(&mut model, &document);

        document.replace_sketch(base_sketch, tall_rectangle_sketch());
        full_recompute(&mut model, &document);
        assert_eq!(
            super::body_passes(&model).len(),
            2,
            "editing the base sketch keeps both bodies",
        );

        let Some(combined) = super::build_combined_view(&model) else {
            panic!("the rebuilt model lowers to a combined view");
        };
        let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256));
        let Ok(camera) = frame_standard_view(combined.aabb, extent, StandardView::Isometric, None)
        else {
            panic!("the combined aabb frames an isometric camera");
        };

        let ctx = offscreen_context(extent, 3);
        let mut renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format());
        let Ok(frame) = renderer.render_display(
            &ctx,
            &combined.faces,
            &combined.edges,
            camera,
            &Style::default(),
            DisplayMode::ShadedWithEdges,
        ) else {
            panic!("the rebuilt two-body scene renders");
        };
        check_solid_golden(&frame, "tests/goldens/rebuild_downstream_iso_256.png");
    }

    #[test]
    fn solid_pick_index_builds_for_an_evaluated_body() {
        let (mut document, sketch) = super::initial_document(rectangle_sketch());
        let _extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch));
        let mut model = EvaluatedModel::new();
        model.recompute(
            &document,
            document.suppressed(),
            document.rollback(),
            RecomputeScope::Full,
        );
        assert!(
            super::solid_pick_index(&model).is_some(),
            "an evaluated body yields a solid pick index",
        );
        assert!(
            super::solid_pick_index(&EvaluatedModel::new()).is_none(),
            "an empty model has nothing to pick",
        );
    }

    #[test]
    fn reattach_binds_a_sketch_to_a_picked_cap_face() {
        use bone_types::FaceRole;
        let (mut document, base_sketch) = super::initial_document(rectangle_sketch());
        let base = document.commit_extrude(sketch_mode::default_extrude_feature(base_sketch));
        let mut model = EvaluatedModel::new();
        model.recompute(
            &document,
            document.suppressed(),
            document.rollback(),
            RecomputeScope::Full,
        );
        let Some(body) = document.feature_tree().feature_of_extrude(base) else {
            panic!("the base extrude resolves to a feature id");
        };
        let Some(solid) = model.body(body) else {
            panic!("the base body is evaluated");
        };
        let Some(face) = solid.iter_faces().find_map(|candidate| {
            matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id())
        }) else {
            panic!("the box has a planar cap");
        };
        let Some(face_ref) = model.face_ref_any(face) else {
            panic!("a picked cap yields a face reference");
        };
        let new_sketch = document.allocate_sketch();
        document.insert_sketch(new_sketch, "Sketch2".to_owned(), rectangle_sketch());
        assert!(
            document.bind_sketch_to_face(new_sketch, face_ref).is_ok(),
            "reattaching a sketch to a planar cap binds cleanly",
        );
    }

    #[test]
    fn compute_whats_wrong_is_empty_for_a_healthy_document() {
        let (mut document, sketch) = super::initial_document(rectangle_sketch());
        let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch));
        let mut model = EvaluatedModel::new();
        model.recompute(
            &document,
            document.suppressed(),
            document.rollback(),
            RecomputeScope::Full,
        );
        assert!(
            super::compute_whats_wrong(&model, &document).is_empty(),
            "a clean rebuild has nothing wrong",
        );
        let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else {
            panic!("the extrude resolves to a feature id");
        };
        let Ok(()) = document.rename_extrude(extrude, "Boss") else {
            panic!("rename accepts");
        };
        assert_eq!(super::feature_label_text(&document, feature), "Boss");
    }

    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 defer_rebuild_holds_only_for_a_costly_later_build() {
        let cheap = RebuildCost::new(10);
        let costly = RebuildCost::new(10_000);
        let budget = RebuildBudget::new(100);
        assert!(
            !super::defer_rebuild(true, costly, budget),
            "the first build always renders, however heavy",
        );
        assert!(
            super::defer_rebuild(false, costly, budget),
            "a heavy later rebuild defers behind the rebuild-needed badge",
        );
        assert!(
            !super::defer_rebuild(false, cheap, budget),
            "a cheap later rebuild stays eager and never raises the badge",
        );
    }

    #[test]
    fn whats_wrong_classifies_failures_and_withholds_reattach_from_downstream() {
        let Some(upstream) =
            super::whats_wrong_kind(RebuildStatus::Error(RebuildError::UpstreamUnresolved))
        else {
            panic!("an upstream failure is reported");
        };
        assert_eq!(upstream.message, strings::WHATS_WRONG_UPSTREAM);
        assert!(upstream.is_error);
        assert!(
            !upstream.offers_reattach,
            "a downstream feature blames the upstream, it never offers its own reattach",
        );

        let Some(non_planar) =
            super::whats_wrong_kind(RebuildStatus::Error(RebuildError::NonPlanarSketchTarget))
        else {
            panic!("a non-planar target is reported");
        };
        assert!(non_planar.is_error && !non_planar.offers_reattach);

        assert!(super::whats_wrong_kind(RebuildStatus::UpToDate).is_none());
        assert!(super::whats_wrong_kind(RebuildStatus::NeedsRebuild).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::<millimeter>();
        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 apply_feature_command_suppresses_and_records_undo() {
        let (mut document, sketch) = doc_with_default_sketch();
        let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch));
        let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else {
            panic!("the committed extrude has a feature id");
        };
        let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
        let changed = apply_feature_command_into(
            &mut document,
            &mut undo,
            shell::FeatureCommand::Suppress(feature),
        );
        assert!(changed);
        assert!(document.suppression_state(feature).is_suppressed());
        assert_eq!(
            undo.past_len(),
            1,
            "a suppression records one undo snapshot"
        );
    }

    #[test]
    fn apply_feature_command_deletes_extrude_and_records_undo() {
        let (mut document, sketch) = doc_with_default_sketch();
        let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch));
        let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
        let changed = apply_feature_command_into(
            &mut document,
            &mut undo,
            shell::FeatureCommand::Delete(shell::FeatureTarget::Extrude(extrude)),
        );
        assert!(changed);
        assert_eq!(document.extrude(extrude), None);
        assert_eq!(undo.past_len(), 1);
    }

    #[test]
    fn apply_feature_command_rolls_back_to_feature() {
        let (mut document, sketch) = doc_with_default_sketch();
        let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch));
        let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else {
            panic!("the committed extrude has a feature id");
        };
        let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
        apply_feature_command_into(
            &mut document,
            &mut undo,
            shell::FeatureCommand::RollbackToHere(feature),
        );
        assert!(document.is_rolled_back(feature));
    }

    #[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::<millimeter>(8.0),
            kind: DimensionKind::Driving,
        };
        let measured = DimensionValue::Length(Length::new::<millimeter>(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::<millimeter>() - 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::<millimeter>(1.0),
            kind: DimensionKind::Driving,
        };
        let bad = DimensionValue::Angle(bone_types::Angle::new::<uom::si::angle::radian>(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::<millimeter>(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::<millimeter>(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}",
        );
    }
}
