diff --git a/crates/bone-app/src/app.rs b/crates/bone-app/src/app.rs index f7cd9ac..adbc4aa 100644 --- a/crates/bone-app/src/app.rs +++ b/crates/bone-app/src/app.rs @@ -4,12 +4,14 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use bone_document::{ - BrepSolid, ContourSelection, DimensionValue, Document, DocumentFolder, EditOutcome, - EvaluatedExtrude, EvaluatedModel, ExtrudeError, ExtrudeFeature, ExtrudeOperation, FeatureNode, - LineData, PrincipalPlane, ProjectedCurve, RebuildBudget, RebuildCost, RebuildPass, - RecomputeScope, RevolveAxis, RevolveFeature, Sketch, Sketch3Relation, SketchDimension, - SketchEdit, SketchEditError, SketchEntity, SketchRelation, SketchVersion, SolverError, - SweepPath, UndoStack, evaluate_extrude_reaching, evaluate_revolve_reaching, evaluate_sketch, + BrepSolid, ContourSelection, DatumFeature, DatumGeometry, DimensionValue, Document, + DocumentFolder, EditOutcome, EvaluatedExtrude, EvaluatedModel, ExtrudeError, ExtrudeFeature, + ExtrudeOperation, FeatureNode, LineData, PlaneRecipe, PrincipalPlane, ProjectedCurve, + RebuildBudget, RebuildCost, RebuildPass, RecomputeScope, RevolveAxis, RevolveFeature, Sketch, + Sketch3, Sketch3Entity, Sketch3Relation, SketchDimension, SketchEdit, SketchEditError, + SketchEntity, SketchPlaneRef, SketchRelation, SketchVersion, SolverError, SweepAlignment, + SweepFeature, SweepPath, SweepProfileKind, UndoStack, evaluate_extrude_reaching, + evaluate_revolve_reaching, evaluate_sketch, }; use bone_render::{ BodyKey, Camera2, CameraTween, ChromeInstance, ChromePipeline, ChromeTextPipeline, @@ -23,11 +25,12 @@ use bone_render::{ orbit_yaw, pan_pixels, roll_by, single_body_pick, zoom_about_pixel, }; use bone_types::{ - Aabb3, Angle, AngleTolerance, BodyOrdinal, BrepFaceId, BudgetCeiling, Camera3, - ChordHeightTolerance, CreaseAngle, CubicEasing, DatumId, DisplayMode, DocumentId, EdgeLabel, - ExtrudeId, FaceLabel, FeatureId, GeometryGeneration, Length, LinearRgba, Plane3, Point2, - Point3, Projection, ProjectionKind, RebuildError, RebuildStatus, SketchEntityId, SketchId, - SketchItemId, SketchPlaneBasis, StandardView, UnitVec3, Vec2, VertexLabel, ZoomFactor, + Aabb3, Angle, AngleTolerance, BodyOrdinal, BodyRef, BrepFaceId, BudgetCeiling, Camera3, + ChordHeightTolerance, CreaseAngle, CubicEasing, DatumId, DatumTarget, DisplayMode, DocumentId, + EdgeLabel, ExtrudeId, FaceLabel, FeatureId, GeometryGeneration, Length, LinearRgba, Plane3, + Point2, Point3, Projection, ProjectionKind, RebuildError, RebuildStatus, Sketch3EntityRef, + SketchEntityId, SketchId, SketchItemId, SketchPlaneBasis, StandardView, UnitVec3, Vec2, + VertexLabel, ZoomFactor, }; use bone_ui::a11y::AccessTreeBuilder; use bone_ui::focus::FocusManager; @@ -52,11 +55,12 @@ use crate::dimension_editor::{ DimensionEditorAction, DimensionEditorOutcome, DimensionEditorState, }; use crate::input::{InputDispatched, InputEvent, KeyDown, NavKey, ScrollDelta, WindowPoint}; +use crate::reference_planes::{self, DatumPlaneInput, PlaneLabel, ReferencePlaneScene}; use crate::selection::Selection; use crate::sketch_mode::{ ClickAnchor, DatumArming, DimensionFlow, DragPins, DragSession, EndConditionKind, ExtrudeArming, FeatureTool, Mode, ModeExit, Pending, PendingDimension, Plane, ReferenceKind, - RevolveArming, SketchTool, SweepArming, + RevolveArming, SketchPlaneSource, SketchTool, Spatial, SweepArming, }; use crate::snap::{Anchor, SnapHit}; use crate::status_badge::ExtrudeStatus; @@ -91,6 +95,14 @@ enum RevolveArm { enum SweepArm { Idle, Path, + Guide(shell::SweepGuideSlot), + AlignmentAxis, + ToolBody, +} + +struct SketchDraft { + sketch: SketchId, + before: Document, } struct AppState { @@ -111,18 +123,21 @@ struct AppState { theme: Arc, shell: shell::Shell, document: Document, - plane_sketches: BTreeMap, mode: Mode, extrude_preview: Option, model: EvaluatedModel, model_passes: Vec<(FeatureId, RebuildPass)>, + mesh_quality: MeshQuality, changed_features: BTreeSet, needs_rebuild: bool, pending_reattach: Option, + pending_sketch_draft: Option, extrude_reference_arm: Option, revolve_arm: RevolveArm, sweep_arm: SweepArm, brep_selection: Vec, + hidden_planes: BTreeSet, + planes_visible: bool, solid_renderer: SolidRenderer, solid_view: Option, camera3: Option, @@ -342,6 +357,7 @@ fn default_sketch() -> Sketch { Sketch::new(Plane::Xy.basis()) } +#[cfg(test)] fn initial_document(sketch: &Sketch) -> (Document, SketchId) { let mut document = Document::new(DocumentId::default(), "Untitled".to_owned()); let sketch_id = SketchId::default(); @@ -353,6 +369,10 @@ fn initial_document(sketch: &Sketch) -> (Document, SketchId) { (document, sketch_id) } +fn empty_document() -> Document { + Document::new(DocumentId::default(), "Untitled".to_owned()) +} + #[allow( clippy::cast_precision_loss, reason = "viewport pixel counts at any realistic display size fit f32 mantissa" @@ -495,7 +515,7 @@ fn commit_placement( changed: bool, ) { if changed { - state.undo.record(state.document.clone()); + record_sketch_edit(state, sketch_id); state .document .replace_sketch(sketch_id, sketch3_mode::solved(placed.sketch)); @@ -546,17 +566,16 @@ fn reorigin_through(basis: SketchPlaneBasis, anchor: Point3) -> SketchPlaneBasis fn sketch3_aid_basis( base: &bone_document::Sketch3, - plane: sketch_mode::Plane, + basis: SketchPlaneBasis, pending: Option, session_anchor: Option, ) -> SketchPlaneBasis { - let principal = plane.basis(); if pending.is_none() { - return principal; + return basis; } pending_anchor_world(base, pending) .or(session_anchor) - .map_or(principal, |anchor| reorigin_through(principal, anchor)) + .map_or(basis, |anchor| reorigin_through(basis, anchor)) } fn body_key(feature: FeatureId, ordinal: BodyOrdinal) -> BodyKey { @@ -611,7 +630,10 @@ fn active_pick_index(state: &AppState) -> Option { } let solid_mode = state.extrude_reference_arm.is_some() || state.revolve_arm == RevolveArm::Axis - || state.sweep_arm == SweepArm::Path + || matches!( + state.sweep_arm, + SweepArm::Path | SweepArm::Guide(_) | SweepArm::AlignmentAxis | SweepArm::ToolBody + ) || matches!(state.mode, Mode::Idle); if solid_mode && state.solid_view.is_some() { return solid_pick_index(&state.model); @@ -689,7 +711,7 @@ fn handle_viewport_click( if state.revolve_arm == RevolveArm::Contour && capture_revolve_contour(state, picked) { return; } - if state.sweep_arm == SweepArm::Path && capture_sweep_path(state, picked) { + if state.sweep_arm != SweepArm::Idle && capture_sweep_rail(state, picked) { return; } if !additive @@ -724,7 +746,7 @@ fn handle_viewport_click( let Some(base) = state.document.sketch3(sketch_id) else { return; }; - let basis = spatial.map_or_else(|| base.plane(), |sp| sp.plane.basis()); + let basis = spatial.map_or_else(|| base.plane(), |sp| sp.basis); let Some(entity3) = resolve_entity_3d(base, spatial.is_some(), entity_id) else { return; }; @@ -798,7 +820,7 @@ fn try_drag_to(state: &mut AppState, cursor: WindowPoint) { let Some(base) = state.document.sketch3(sketch_id) else { return; }; - let basis = spatial.map_or_else(|| base.plane(), |sp| sp.plane.basis()); + let basis = spatial.map_or_else(|| base.plane(), |sp| sp.basis); let Some(world) = active_plane_cursor(state, spatial.is_some(), basis, cursor) else { return; }; @@ -814,7 +836,7 @@ fn try_drag_to(state: &mut AppState, cursor: WindowPoint) { } fn refresh_active_scene(state: &mut AppState) { - let Some(active_id) = active_sketch_id(&state.mode, &state.plane_sketches) else { + let Some(active_id) = active_sketch_id(&state.mode) else { return; }; let Some(base) = state.document.sketch3(active_id) else { @@ -837,7 +859,7 @@ fn projected_id_map( .collect() } -fn active_sketch_id(mode: &Mode, plane_sketches: &BTreeMap) -> Option { +fn active_sketch_id(mode: &Mode) -> Option { match mode { Mode::Sketch { sketch_id, .. } => Some(*sketch_id), Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => Some(feature.sketch), @@ -847,7 +869,7 @@ fn active_sketch_id(mode: &Mode, plane_sketches: &BTreeMap) -> | Mode::Revolve(RevolveArming::AwaitingSketch(_)) | Mode::Sweep(SweepArming::AwaitingSketch(_)) | Mode::Idle - | Mode::Datum(_) => plane_sketches.get(&Plane::Xy).copied(), + | Mode::Datum(_) => None, } } @@ -857,7 +879,7 @@ enum ProfileChoice { Ambiguous, } -fn classify_extrude_profile(document: &Document) -> ProfileChoice { +fn unconsumed_profile_sketches(document: &Document) -> Vec { let tree = document.feature_tree(); let consumed = |sketch: FeatureId| { tree.children(sketch) @@ -865,10 +887,21 @@ fn classify_extrude_profile(document: &Document) -> ProfileChoice { .filter_map(|child| tree.node(child)) .any(|node| matches!(node, FeatureNode::Extrude(_))) }; - let mut sketches = tree.iter().filter_map(|(feature, node)| match node { - FeatureNode::Sketch(id) if !consumed(feature) => Some(id), - _ => None, - }); + let has_geometry = |id: SketchId| { + document + .sketch3(id) + .is_some_and(|sketch| !sketch.entity_order().is_empty()) + }; + tree.iter() + .filter_map(|(feature, node)| match node { + FeatureNode::Sketch(id) if !consumed(feature) && has_geometry(id) => Some(id), + _ => None, + }) + .collect() +} + +fn classify_extrude_profile(document: &Document) -> ProfileChoice { + let mut sketches = unconsumed_profile_sketches(document).into_iter(); match (sketches.next(), sketches.next()) { (None, _) => ProfileChoice::NoSketch, (Some(id), None) => ProfileChoice::Unique(id), @@ -876,6 +909,16 @@ fn classify_extrude_profile(document: &Document) -> ProfileChoice { } } +fn sole_other_profile_sketch(document: &Document, exclude: SketchId) -> Option { + let mut others = unconsumed_profile_sketches(document) + .into_iter() + .filter(|&id| id != exclude); + match (others.next(), others.next()) { + (Some(only), None) => Some(only), + _ => None, + } +} + fn apply_feature_tool(state: &mut AppState, tool: Option) { let Some(tool) = tool else { return; @@ -938,7 +981,7 @@ fn handle_sketch3_click(state: &mut AppState, cursor: WindowPoint) -> bool { return false; }; let sketch3_id = *sketch_id; - let plane = spatial.plane; + let spatial_basis = spatial.basis; let pending = session.pending; let aid_anchor = session.aid_anchor; let Some(camera) = state.camera3 else { @@ -953,7 +996,7 @@ fn handle_sketch3_click(state: &mut AppState, cursor: WindowPoint) -> bool { let Some(base) = state.document.sketch3(sketch3_id) else { return false; }; - let basis = sketch3_aid_basis(base, plane, pending, aid_anchor); + let basis = sketch3_aid_basis(base, spatial_basis, pending, aid_anchor); let Some(probe) = sketch3_probe(base, basis, camera, region, pixel, tool, pending) else { return false; }; @@ -1009,7 +1052,7 @@ fn push_spline_draft_point( next: bone_document::Sketch3, draft_point: sketch_mode::SplineDraftPoint, ) { - state.undo.record(state.document.clone()); + record_sketch_edit(state, sketch3_id); state .document .replace_sketch(sketch3_id, sketch3_mode::solved(next)); @@ -1137,6 +1180,7 @@ fn sketch3_snap_hit( world: Point2, ) -> Option { let kind = match snap { + sketch3_mode::Snap3::Origin { .. } => return None, sketch3_mode::Snap3::Endpoint { point, .. } => snap::SnapKind::Endpoint(view.to_2d(point)?), sketch3_mode::Snap3::Midpoint { line, .. } => snap::SnapKind::Midpoint(view.to_2d(line)?), sketch3_mode::Snap3::OnEdge { entity, .. } => snap::SnapKind::OnEdge(view.to_2d(entity)?), @@ -1159,9 +1203,16 @@ fn sketch3_probe( Some(ClickAnchor::Endpoint(id)) => Some(id), _ => None, }; - if let Some(snap3) = - sketch3_mode::nearest_snap3(base, camera, region.extent(), pixel, tolerance, exclude) - { + let reference = Some(base.plane().origin()); + if let Some(snap3) = sketch3_mode::nearest_snap3( + base, + camera, + region.extent(), + pixel, + tolerance, + exclude, + reference, + ) { let basis = reorigin_through(basis, snap3.at()); let view = sketch3_project::project(base, basis); let effective = sketch3_project::flatten(basis, snap3.at()); @@ -1214,7 +1265,7 @@ fn sketch3_preview_edges( let camera = state.camera3?; let pixel = viewport_local_point(cursor?, region)?; let base = state.document.sketch3(*sketch_id)?; - let basis = sketch3_aid_basis(base, spatial.plane, session.pending, session.aid_anchor); + let basis = sketch3_aid_basis(base, spatial.basis, session.pending, session.aid_anchor); let probe = sketch3_probe(base, basis, camera, region, pixel, tool, session.pending)?; if tool == SketchTool::Spline { let cursor3 = sketch3_project::lift(probe.basis, probe.effective); @@ -1302,18 +1353,13 @@ fn preview_edges_3d(preview: &SketchPreview, basis: SketchPlaneBasis) -> Vec bool { + let Some(DatumGeometry::Plane(plane)) = state.model.datum(feature) else { + return false; + }; + let basis = SketchPlaneBasis::from(plane); + let before = state.document.clone(); + let sketch_id = state.document.allocate_sketch(); + state.document.insert_sketch( + sketch_id, + next_sketch_name(&state.document), + Sketch3::on_plane(basis), + ); + if let Err(error) = state + .document + .bind_sketch_to_datum_plane(sketch_id, feature) + { + tracing::warn!(?error, "failed to bind sketch to its datum plane"); + } + state.pending_sketch_draft = Some(SketchDraft { + sketch: sketch_id, + before, + }); + enter_plane_sketch_on(state, sketch_id, Spatial::datum(feature, basis), now); + true +} + +fn edit_existing_sketch(state: &mut AppState, sketch_id: SketchId, now: FrameInstant) -> bool { + let Some(base) = state.document.sketch3(sketch_id) else { + return false; + }; + let spatial = match state.document.sketch_plane_binding(sketch_id) { + Some(SketchPlaneRef::Datum(datum)) => { + let source = principal_plane_of_datum(&state.document, datum) + .map_or(SketchPlaneSource::Datum(datum), |principal| { + SketchPlaneSource::Principal(Plane::from(principal)) + }); + Spatial { + basis: base.plane(), + source, + } + } + None => Spatial { + basis: base.plane(), + source: SketchPlaneSource::Principal(Plane::Xy), + }, + Some(SketchPlaneRef::Face(_)) => return false, + }; + enter_plane_sketch_on(state, sketch_id, spatial, now); + true +} + +fn enter_plane_sketch_on( + state: &mut AppState, + sketch_id: SketchId, + spatial: Spatial, + now: FrameInstant, +) { + state.mode = Mode::enter_sketch3(sketch_id, spatial); + aim_camera_normal_to(state, Plane3::from(spatial.basis), now); +} + +fn principal_plane_of_datum(document: &Document, datum: FeatureId) -> Option { + let tree = document.feature_tree(); + [PrincipalPlane::Xy, PrincipalPlane::Yz, PrincipalPlane::Zx] + .into_iter() + .find(|kind| tree.principal_plane_feature(*kind) == Some(datum)) +} + +fn draft_sketch(state: &AppState) -> Option { + state + .pending_sketch_draft + .as_ref() + .map(|draft| draft.sketch) +} + +fn record_sketch_edit(state: &mut AppState, sketch_id: SketchId) { + match state.pending_sketch_draft.take() { + Some(draft) if draft.sketch == sketch_id => state.undo.record(draft.before), + restored => { + state.pending_sketch_draft = restored; + state.undo.record(state.document.clone()); + } + } +} + +fn commit_pending_sketch_draft(state: &mut AppState) { + let Some(sketch) = draft_sketch(state) else { + return; + }; + let has_geometry = state + .document + .sketch3(sketch) + .is_some_and(|sketch| !sketch.entity_order().is_empty()); + if has_geometry && let Some(draft) = state.pending_sketch_draft.take() { + state.undo.record(draft.before); + } +} + +fn gc_abandoned_sketch(state: &mut AppState, entered_from: Option) { + let Some(id) = entered_from else { + return; + }; + if state.mode.sketch3_id() == Some(id) { + return; + } + let empty = state + .document + .sketch3(id) + .is_some_and(|sketch| sketch.entity_order().is_empty()); + if empty { + state.document.remove_sketch(id); + } + if draft_sketch(state) == Some(id) { + state.pending_sketch_draft = None; + } +} + +fn next_sketch_name(document: &Document) -> String { + let next = document + .sketches() + .filter_map(|(id, _)| document.sketch_label(id)) + .filter_map(|label| label.strip_prefix("Sketch")) + .filter_map(|suffix| suffix.parse::().ok()) + .max() + .map_or(1, |highest| highest + 1); + format!("Sketch{next}") +} + +fn aim_camera_normal_to(state: &mut AppState, plane3: Plane3, now: FrameInstant) { + let Some(region) = solid_viewport_region(state.viewport_rect, state.extent) else { + return; + }; + let half = reference_planes::plane_half_extent(state.solid_view.as_ref().map(|view| view.aabb)); + let Some(scene) = + reference_planes::build(&state.document, &[], Some(plane3), None, half, |_| true) + else { + return; + }; + if let Ok(camera) = frame_standard_view( + scene.aabb, + region.extent(), + StandardView::NormalTo, + Some(plane3), + ) { + start_view_tween(state, camera, now); + } +} + +fn line_endpoint_pair( + sketch: &Sketch3, + selected: &[SketchEntityId], +) -> Option<(SketchEntityId, SketchEntityId)> { + let line_ends = |id: SketchEntityId| match sketch.entities().get(id) { + Some(Sketch3Entity::Line(line)) => Some((line.a(), line.b())), + _ => None, + }; + let explicit = selected.iter().copied().find_map(|line| { + let (a, b) = line_ends(line)?; + selected + .iter() + .copied() + .find(|&other| other == a || other == b) + .map(|point| (line, point)) + }); + if let Some(pair) = explicit { + return Some(pair); + } + let point = selected + .iter() + .copied() + .find(|&id| matches!(sketch.entities().get(id), Some(Sketch3Entity::Point(_))))?; + let mut incident = sketch + .entity_order() + .iter() + .copied() + .filter(|&id| line_ends(id).is_some_and(|(a, b)| a == point || b == point)); + let line = incident.next()?; + incident.next().is_none().then_some((line, point)) +} + +pub(crate) fn normal_to_curve_datum( + document: &Document, + mode: &Mode, + selection: &[SketchEntityId], +) -> Option { + let sketch_id = mode.sketch3_id()?; + let sketch_feature = document.feature_tree().feature_of_sketch(sketch_id)?; + let sketch = document.sketch3(sketch_id)?; + let (line, point) = line_endpoint_pair(sketch, selection)?; + Some(DatumFeature::plane(PlaneRecipe::NormalToCurve { + curve: DatumTarget::Sketch3Entity(Sketch3EntityRef::new(sketch_feature, line)), + point: DatumTarget::Sketch3Entity(Sketch3EntityRef::new(sketch_feature, point)), + set_origin_on_curve: true, + })) +} + +fn normal_to_curve_from_selection(state: &AppState) -> Option { + normal_to_curve_datum(&state.document, &state.mode, state.selection.entity_ids()) +} + fn arm_datum(state: &mut AppState, tool: FeatureTool) { + let normal_to_curve = normal_to_curve_from_selection(state); let tree = state.document.feature_tree(); let origin = tree.origin_feature(); let xy = tree.principal_plane_feature(PrincipalPlane::Xy); let yz = tree.principal_plane_feature(PrincipalPlane::Yz); let zx = tree.principal_plane_feature(PrincipalPlane::Zx); let feature = match tool { - FeatureTool::DatumPlane => xy.map(sketch_mode::default_datum_plane), + FeatureTool::DatumPlane => { + normal_to_curve.or_else(|| xy.map(sketch_mode::default_datum_plane)) + } FeatureTool::DatumAxis => xy .zip(yz) .map(|(a, b)| sketch_mode::default_datum_axis(a, b)), @@ -1427,6 +1702,21 @@ fn apply_feature_arm(state: &mut AppState, arm: Option) { { state.sweep_arm = SweepArm::Path; } + Some(shell::FeatureArm::SweepGuide(slot)) + if matches!(state.mode, Mode::Sweep(SweepArming::Profile { .. })) => + { + state.sweep_arm = SweepArm::Guide(slot); + } + Some(shell::FeatureArm::SweepAlignmentAxis) + if matches!(state.mode, Mode::Sweep(SweepArming::Profile { .. })) => + { + state.sweep_arm = SweepArm::AlignmentAxis; + } + Some(shell::FeatureArm::SweepToolBody) + if matches!(state.mode, Mode::Sweep(SweepArming::Profile { .. })) => + { + state.sweep_arm = SweepArm::ToolBody; + } _ => {} } } @@ -1483,6 +1773,69 @@ fn apply_sweep_edit(state: &mut AppState, edit: Option) { feature: Box::new(next), target, }); + state.sweep_arm = SweepArm::Idle; +} + +fn autoselect_sweep_path(state: &mut AppState) { + let Mode::Sweep(SweepArming::Profile { feature, .. }) = &state.mode else { + return; + }; + let path_unset = matches!(feature.path, SweepPath::Sketch(id) if id == SketchId::default()); + if !path_unset { + return; + } + let profile = feature.profile; + let Some(path) = sole_other_profile_sketch(&state.document, profile) else { + return; + }; + apply_sweep_edit(state, Some(shell::SweepEdit::Path(SweepPath::Sketch(path)))); + if state.sweep_arm == SweepArm::Path { + state.sweep_arm = SweepArm::Idle; + } +} + +fn apply_sweep_sketch_pick(state: &mut AppState, sketch: SketchId) -> bool { + match &state.mode { + Mode::Sweep(SweepArming::AwaitingSketch(operation)) => { + let operation = *operation; + state.mode = Mode::Sweep(SweepArming::armed(sketch, operation)); + state.sweep_arm = SweepArm::Path; + autoselect_sweep_path(state); + true + } + Mode::Sweep(SweepArming::Profile { .. }) => { + match state.sweep_arm { + SweepArm::Path => { + apply_sweep_edit( + state, + Some(shell::SweepEdit::Path(SweepPath::Sketch(sketch))), + ); + state.sweep_arm = SweepArm::Idle; + } + SweepArm::Guide(slot) => { + apply_sweep_edit( + state, + Some(shell::SweepEdit::Guide { + slot, + guide: Some(SweepPath::Sketch(sketch)), + }), + ); + state.sweep_arm = SweepArm::Idle; + } + SweepArm::Idle | SweepArm::AlignmentAxis | SweepArm::ToolBody => { + let operation = match &state.mode { + Mode::Sweep(SweepArming::Profile { feature, .. }) => feature.operation, + _ => return true, + }; + state.mode = Mode::Sweep(SweepArming::armed(sketch, operation)); + state.sweep_arm = SweepArm::Path; + autoselect_sweep_path(state); + } + } + true + } + _ => false, + } } fn apply_sweep_confirm(state: &mut AppState, confirm: Option) { @@ -1504,23 +1857,50 @@ fn commit_armed_sweep( let snapshot = document.clone(); let committed = match target { Some(id) => { - document.insert_sweep(*id, **feature); + document.insert_sweep(*id, feature.as_ref().clone()); *id } - None => document.commit_sweep(**feature), + None => document.commit_sweep(feature.as_ref().clone()), }; undo.record(snapshot); Some(committed) } -fn capture_sweep_path(state: &mut AppState, picked: Option) -> bool { +fn capture_sweep_rail(state: &mut AppState, picked: Option) -> bool { let Mode::Sweep(SweepArming::Profile { feature, target }) = &state.mode else { return false; }; - let Some(path) = picked_sweep_path(&state.model, picked) else { - return false; + let next = match state.sweep_arm { + SweepArm::Path => { + let Some(rail) = picked_sweep_path(&state.model, picked) else { + return false; + }; + shell::SweepEdit::Path(rail).apply(feature) + } + SweepArm::Guide(slot) => { + let Some(rail) = picked_sweep_path(&state.model, picked) else { + return false; + }; + shell::SweepEdit::Guide { + slot, + guide: Some(rail), + } + .apply(feature) + } + SweepArm::AlignmentAxis => { + let Some(axis) = picked_revolve_axis(&state.model, picked) else { + return false; + }; + shell::SweepEdit::Alignment(SweepAlignment::DirectionVector { axis }).apply(feature) + } + SweepArm::ToolBody => { + let Some(kind) = picked_sweep_tool(picked) else { + return false; + }; + shell::SweepEdit::ProfileKind(kind).apply(feature) + } + SweepArm::Idle => return false, }; - let next = shell::SweepEdit::Path(path).apply(feature); let target = *target; state.mode = Mode::Sweep(SweepArming::Profile { feature: Box::new(next), @@ -1642,6 +2022,14 @@ fn picked_revolve_axis(model: &EvaluatedModel, picked: Option) -> Op } } +fn picked_sweep_tool(picked: Option) -> Option { + let PickedItem::BrepFace(face) = picked? else { + return None; + }; + let tool = BodyRef::new(face.feature(), face.ordinal()); + Some(SweepProfileKind::Solid { tool }) +} + fn capture_revolve_contour(state: &mut AppState, picked: Option) -> bool { let Mode::Revolve(RevolveArming::Profile { feature, target }) = &state.mode else { return false; @@ -1780,10 +2168,11 @@ struct SolidViewData { aabb: Aabb3, } -#[derive(Copy, Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] enum PreviewFeature { Extrude(ExtrudeFeature), Revolve(RevolveFeature), + Sweep(SweepFeature), } impl PreviewFeature { @@ -1791,28 +2180,14 @@ impl PreviewFeature { match self { Self::Extrude(feature) => feature.sketch, Self::Revolve(feature) => feature.sketch, - } - } - - fn evaluate( - &self, - sketch: &bone_document::EvaluatedSketch, - obstacles: &[&BrepSolid], - ) -> EvaluatedExtrude { - match self { - Self::Extrude(feature) => { - evaluate_extrude_reaching(FeatureId::default(), sketch, feature, obstacles) - } - Self::Revolve(feature) => { - evaluate_revolve_reaching(FeatureId::default(), sketch, feature) - } + Self::Sweep(feature) => feature.profile, } } } struct ExtrudePreview { feature: PreviewFeature, - sketch_version: SketchVersion, + inputs: Vec<(SketchId, SketchVersion)>, generation: Option, failed: bool, error: Option, @@ -1829,6 +2204,35 @@ impl ExtrudePreview { const PREVIEW_CHORD_MM: f64 = 0.05; const PREVIEW_ANGLE: AngleTolerance = AngleTolerance::from_radians(0.2); +const DRAG_CHORD_FRACTION: f64 = 0.006; +const DRAG_ANGLE: AngleTolerance = AngleTolerance::from_radians(0.5); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MeshQuality { + Fine, + Dragging, +} + +fn desired_mesh_quality(mode: &Mode) -> MeshQuality { + if dragging_in_sketch(mode) { + MeshQuality::Dragging + } else { + MeshQuality::Fine + } +} + +fn mesh_tolerances(quality: MeshQuality, aabb: &Aabb3) -> (ChordHeightTolerance, AngleTolerance) { + match quality { + MeshQuality::Fine => ( + ChordHeightTolerance::from_mm(PREVIEW_CHORD_MM), + PREVIEW_ANGLE, + ), + MeshQuality::Dragging => { + let coarse_mm = (aabb.extent().norm_mm() * DRAG_CHORD_FRACTION).max(PREVIEW_CHORD_MM); + (ChordHeightTolerance::from_mm(coarse_mm), DRAG_ANGLE) + } + } +} fn active_solid_feature( mode: &Mode, @@ -1842,7 +2246,10 @@ fn active_solid_feature( Mode::Revolve(RevolveArming::Profile { feature, .. }) => { Some(PreviewFeature::Revolve(**feature)) } - Mode::Sketch { .. } | Mode::Datum(_) | Mode::Sweep(SweepArming::Profile { .. }) => None, + Mode::Sweep(SweepArming::Profile { feature, .. }) => { + Some(PreviewFeature::Sweep((**feature).clone())) + } + Mode::Sketch { .. } | Mode::Datum(_) => None, Mode::Idle | Mode::Extrude(ExtrudeArming::AwaitingSketch(_)) | Mode::Revolve(RevolveArming::AwaitingSketch(_)) @@ -1877,10 +2284,10 @@ fn body_passes(model: &EvaluatedModel) -> Vec<(FeatureId, RebuildPass)> { .collect() } -fn build_combined_view(model: &EvaluatedModel) -> Option { +fn build_combined_view(model: &EvaluatedModel, quality: MeshQuality) -> Option { let table = solid_pick_table(model)?; let mut parts = model.bodies().filter_map(|(feature, ordinal, solid)| { - build_solid_view(solid, body_key(feature, ordinal), &table).ok() + build_solid_view(solid, body_key(feature, ordinal), &table, quality).ok() }); let first = parts.next()?; Some(parts.fold(first, |acc, part| SolidViewData { @@ -1901,7 +2308,8 @@ fn recompute_model(state: &mut AppState, scope: RecomputeScope) { fn rebuild_model_view(state: &mut AppState) { state.model_passes = body_passes(&state.model); - let model_view = build_combined_view(&state.model); + state.mesh_quality = desired_mesh_quality(&state.mode); + let model_view = build_combined_view(&state.model, state.mesh_quality); state.solid_view = match (model_view, sketch3_overlay_view(state)) { (Some(model), Some(overlay)) => Some(SolidViewData { faces: model.faces, @@ -1921,9 +2329,9 @@ fn sketch3_overlay_view(state: &AppState) -> Option { Mode::Sketch { sketch_id, session } => { let spatial = session.spatial?; let sketch = state.document.sketch3(*sketch_id)?; - Some(sketch3_aabb_view(sketch, spatial.plane.basis())) + Some(sketch3_aabb_view(sketch, spatial.basis)) } - Mode::Idle => unconsumed_sketches(&state.document) + Mode::Idle | Mode::Sweep(_) | Mode::Datum(_) => unconsumed_sketches(&state.document) .into_iter() .map(|(_, sketch)| sketch3_aabb_view(sketch, sketch.plane())) .reduce(|a, b| SolidViewData { @@ -1931,7 +2339,7 @@ fn sketch3_overlay_view(state: &AppState) -> Option { edges: a.edges, aabb: a.aabb.union(b.aabb), }), - Mode::Extrude(_) | Mode::Revolve(_) | Mode::Sweep(_) | Mode::Datum(_) => None, + Mode::Extrude(_) | Mode::Revolve(_) => None, } } @@ -1961,6 +2369,12 @@ const SKETCH3_BALL_RINGS: u32 = 6; const SKETCH3_BALL_SEGMENTS: u32 = 10; const SKETCH3_MIN_DEPTH_MM: f64 = 1.0; const SKETCH3_SELECTED: LinearRgba = LinearRgba::new(0.10, 0.62, 0.30, 1.0); +const SKETCH3_ORIGIN: LinearRgba = LinearRgba::new(0.95, 0.55, 0.16, 1.0); +const SKETCH3_ORIGIN_SCALE: f64 = 1.7; +const PLANE_BORDER: LinearRgba = LinearRgba::new(0.34, 0.42, 0.60, 1.0); +const PLANE_BORDER_HIGHLIGHT: LinearRgba = LinearRgba::new(0.95, 0.55, 0.16, 1.0); +const PLANE_FILL: LinearRgba = LinearRgba::new(0.34, 0.42, 0.60, 0.07); +const PLANE_FILL_HIGHLIGHT: LinearRgba = LinearRgba::new(0.95, 0.55, 0.16, 0.20); struct Sketch3Overlay { points: SolidScene, @@ -2030,7 +2444,7 @@ fn sketch3_frame_layers( sketch3_preview_edges(state, region, cursor) }); let overlay = region.and_then(|region| { - sketch3_overlay(state, region).or_else(|| idle_sketch3_overlay(state, region)) + sketch3_overlay(state, region).or_else(|| workspace_sketch3_overlay(state, region)) }); (preview, overlay) } @@ -2040,17 +2454,23 @@ fn sketch3_overlay(state: &AppState, region: ViewportRegion) -> Option Option { - if !matches!(state.mode, Mode::Idle) { +fn workspace_sketch3_overlay(state: &AppState, region: ViewportRegion) -> Option { + if !shows_workspace(&state.mode) { return None; } let camera = state.camera3?; @@ -2290,15 +2710,18 @@ fn sync_model_view(state: &mut AppState) { state.needs_rebuild = false; return; } - if passes == state.model_passes && state.solid_view.is_some() { + let refine_only = passes == state.model_passes && state.solid_view.is_some(); + if refine_only && state.mesh_quality == desired_mesh_quality(&state.mode) { return; } let first = state.model_passes.is_empty() || state.solid_view.is_none(); - if defer_rebuild( - first, - state.model.rebuild_cost(), - RebuildBudget::INTERACTIVE, - ) { + if !refine_only + && defer_rebuild( + first, + state.model.rebuild_cost(), + RebuildBudget::INTERACTIVE, + ) + { let changed = passes .iter() .filter(|entry| !state.model_passes.contains(entry)) @@ -2519,7 +2942,7 @@ fn force_rebuild_all(state: &mut AppState) { } fn sync_solid_view(state: &mut AppState) { - if matches!(state.mode, Mode::Idle) { + if shows_workspace(&state.mode) { sync_model_view(state); return; } @@ -2527,10 +2950,6 @@ fn sync_solid_view(state: &mut AppState) { sync_sketch_overlay_view(state); return; } - if state.mode.is_sketch3() { - sync_sketch3_overlay_view(state); - return; - } state.model_passes.clear(); let Some(feature) = active_solid_feature(&state.mode, &state.document, state.framed_extrude) else { @@ -2541,20 +2960,16 @@ fn sync_solid_view(state: &mut AppState) { state.view.tween = None; return; }; - let Some(sketch_version) = state - .document - .sketch(feature.sketch()) - .as_ref() - .map(Sketch::version) - else { + if state.document.sketch(feature.sketch()).is_none() { 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) { + } + let inputs = preview_inputs(&state.document, &feature); + if extrude_preview_is_current(state.extrude_preview.as_ref(), &feature, &inputs) { return; } let previous_generation = state @@ -2566,15 +2981,18 @@ fn sync_solid_view(state: &mut AppState) { .as_ref() .is_some_and(|cached| cached.failed); let first_preview = state.extrude_preview.is_none(); - let preview = { - let obstacles: Vec<&BrepSolid> = match state.mode { - Mode::Extrude(ExtrudeArming::Profile { .. }) - | Mode::Revolve(RevolveArming::Profile { .. }) => { - state.model.bodies().map(|(_, _, solid)| solid).collect() - } - _ => Vec::new(), - }; - compute_extrude_preview(&state.document, &obstacles, &feature) + let preview = match &feature { + PreviewFeature::Sweep(sweep) => state.model.sweep_preview(&state.document, sweep), + PreviewFeature::Extrude(_) | PreviewFeature::Revolve(_) => { + let obstacles: Vec<&BrepSolid> = match state.mode { + Mode::Extrude(ExtrudeArming::Profile { .. }) + | Mode::Revolve(RevolveArming::Profile { .. }) => { + state.model.bodies().map(|(_, _, solid)| solid).collect() + } + _ => Vec::new(), + }; + compute_extrude_preview(&state.document, &obstacles, &feature) + } }; let generation = preview.as_ref().and_then(EvaluatedExtrude::generation); let error = preview @@ -2594,17 +3012,44 @@ fn sync_solid_view(state: &mut AppState) { }; } let now_failed = failure.is_some(); + let is_sweep = matches!(feature, PreviewFeature::Sweep(_)); state.extrude_preview = Some(ExtrudePreview { feature, - sketch_version, + inputs, 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); + tracing::warn!(error = %detail, "solid preview evaluation failed"); + if !is_sweep { + notify_error(state, strings::NOTIFY_EXTRUDE_FAILED, detail); + } + } + if is_sweep && state.solid_view.is_none() { + rebuild_model_view(state); + } +} + +fn preview_inputs(document: &Document, feature: &PreviewFeature) -> Vec<(SketchId, SketchVersion)> { + let ids: Vec = match feature { + PreviewFeature::Extrude(f) => vec![f.sketch], + PreviewFeature::Revolve(f) => vec![f.sketch], + PreviewFeature::Sweep(f) => core::iter::once(f.profile) + .chain(sweep_path_sketch(f.path)) + .chain(f.guides.iter().copied().filter_map(sweep_path_sketch)) + .collect(), + }; + ids.into_iter() + .filter_map(|id| document.sketch(id).map(|sketch| (id, sketch.version()))) + .collect() +} + +fn sweep_path_sketch(path: SweepPath) -> Option { + match path { + SweepPath::Sketch(id) => Some(id), + SweepPath::Edge(_) => None, } } @@ -2618,20 +3063,15 @@ fn sync_sketch_overlay_view(state: &mut AppState) { } return; } - if passes == state.model_passes && state.solid_view.is_some() { + if passes == state.model_passes + && state.solid_view.is_some() + && state.mesh_quality == desired_mesh_quality(&state.mode) + { return; } rebuild_model_view(state); } -fn sync_sketch3_overlay_view(state: &mut AppState) { - state.extrude_preview = None; - let passes = body_passes(&state.model); - if passes != state.model_passes || state.solid_view.is_none() { - rebuild_model_view(state); - } -} - fn sketch_overlay_camera( basis: SketchPlaneBasis, camera: Camera2, @@ -2722,10 +3162,9 @@ fn sketch_overlay_frame( fn extrude_preview_is_current( cached: Option<&ExtrudePreview>, feature: &PreviewFeature, - sketch_version: SketchVersion, + inputs: &[(SketchId, SketchVersion)], ) -> bool { - cached - .is_some_and(|cached| cached.feature == *feature && cached.sketch_version == sketch_version) + cached.is_some_and(|cached| cached.feature == *feature && cached.inputs == inputs) } fn compute_extrude_preview( @@ -2735,25 +3174,39 @@ fn compute_extrude_preview( ) -> Option { let sketch = document.sketch3(feature.sketch())?; let evaluated_sketch = evaluate_sketch(sketch); - Some(feature.evaluate(&evaluated_sketch, obstacles)) + match feature { + PreviewFeature::Extrude(extrude) => Some(evaluate_extrude_reaching( + FeatureId::default(), + &evaluated_sketch, + extrude, + obstacles, + )), + PreviewFeature::Revolve(revolve) => Some(evaluate_revolve_reaching( + FeatureId::default(), + &evaluated_sketch, + revolve, + )), + PreviewFeature::Sweep(_) => None, + } } fn preview_solid_view(solid: &bone_document::BrepSolid) -> Result { let (body, table) = single_body_pick(solid).map_err(|error| error.to_string())?; - build_solid_view(solid, body, &table) + build_solid_view(solid, body, &table, MeshQuality::Fine) } fn build_solid_view( solid: &bone_document::BrepSolid, body: BodyKey, table: &SolidPickTable, + quality: MeshQuality, ) -> Result { - let chord = ChordHeightTolerance::from_mm(PREVIEW_CHORD_MM); let aabb = solid .bounding_box() .ok_or_else(|| "degenerate solid has no bounding box".to_owned())?; + let (chord, angle) = mesh_tolerances(quality, &aabb); let mesh = solid - .tessellate(chord, PREVIEW_ANGLE) + .tessellate(chord, angle) .map_err(|error| error.to_string())?; let faces = SolidScene::from_mesh(&mesh, body, table).map_err(|error| error.to_string())?; let edges = EdgeScene::from_solid(solid, &mesh, chord, body, table) @@ -2761,19 +3214,183 @@ fn build_solid_view( Ok(SolidViewData { faces, edges, aabb }) } -fn sync_solid_camera(state: &mut AppState, region: Option) { - if let Some(region) = region - && let Some(view) = state.solid_view.as_ref() - && state.camera3.is_none() - { - let framed = - frame_standard_view(view.aabb, region.extent(), StandardView::Isometric, None).ok(); - state.camera3 = framed; - if state.view.home.is_none() { - state.view.home = framed; - } +fn sync_solid_camera( + state: &mut AppState, + region: Option, + planes: Option<&ReferencePlaneScene>, +) { + if state.camera3.is_some() { + return; } -} + let Some(region) = region else { + return; + }; + let Some(aabb) = state + .solid_view + .as_ref() + .map(|view| view.aabb) + .or_else(|| planes.map(|planes| planes.aabb)) + else { + return; + }; + let framed = frame_standard_view(aabb, region.extent(), StandardView::Isometric, None).ok(); + state.camera3 = framed; + if state.view.home.is_none() { + state.view.home = framed; + } +} + +fn shows_workspace(mode: &Mode) -> bool { + matches!( + mode, + Mode::Idle | Mode::Datum(_) | Mode::Sweep(SweepArming::AwaitingSketch(_)) + ) +} + +fn show_reference_planes(mode: &Mode) -> bool { + shows_workspace(mode) || mode.is_sketch3() +} + +fn viewport_is_spatial(state: &AppState) -> bool { + state.solid_view.is_some() || show_reference_planes(&state.mode) +} + +fn reference_plane_scene(state: &AppState) -> Option { + if !show_reference_planes(&state.mode) { + return None; + } + let model_aabb = state.solid_view.as_ref().map(|view| view.aabb); + let half = reference_planes::plane_half_extent(model_aabb); + let datums = datum_plane_inputs(state); + let preview = armed_datum_plane(state); + let active = active_plane_feature(state); + let sketching = state.mode.is_sketch3(); + let visible = |feature: FeatureId| { + if sketching { + Some(feature) == active + } else { + state.planes_visible && !state.hidden_planes.contains(&feature) + } + }; + reference_planes::build(&state.document, &datums, preview, active, half, visible) +} + +fn datum_plane_inputs(state: &AppState) -> Vec> { + state + .document + .feature_tree() + .iter() + .filter_map(|(feature, node)| { + let FeatureNode::Datum(datum) = node else { + return None; + }; + let DatumGeometry::Plane(plane) = state.model.datum(feature)? else { + return None; + }; + let name = state.document.datum_label(datum)?; + Some(DatumPlaneInput { + feature, + plane, + name, + }) + }) + .collect() +} + +fn armed_datum_plane(state: &AppState) -> Option { + let Mode::Datum(arming) = &state.mode else { + return None; + }; + match state.model.datum_preview(&arming.feature)? { + DatumGeometry::Plane(plane) => Some(plane), + DatumGeometry::Point(_) | DatumGeometry::Axis(_) | DatumGeometry::CoordinateSystem(_) => { + None + } + } +} + +fn active_plane_feature(state: &AppState) -> Option { + match state.mode.sketch3_source()? { + SketchPlaneSource::Principal(plane) => state + .document + .feature_tree() + .principal_plane_feature(PrincipalPlane::from(plane)), + SketchPlaneSource::Datum(feature) => Some(feature), + } +} + +fn plane_label_key(kind: PrincipalPlane) -> bone_ui::strings::StringKey { + match kind { + PrincipalPlane::Xy => strings::FEATURE_PLANE_XY, + PrincipalPlane::Yz => strings::FEATURE_PLANE_YZ, + PrincipalPlane::Zx => strings::FEATURE_PLANE_ZX, + } +} + +fn project_to_viewport( + clip_from_world: [f32; 16], + point: Point3, + region: ViewportRegion, +) -> Option<(f64, f64)> { + let (x, y, z) = point.coords_mm(); + let m = clip_from_world.map(f64::from); + let cx = m[0] * x + m[4] * y + m[8] * z + m[12]; + let cy = m[1] * x + m[5] * y + m[9] * z + m[13]; + let cw = m[3] * x + m[7] * y + m[11] * z + m[15]; + if cw <= f64::EPSILON { + return None; + } + let ndc_x = cx / cw; + let ndc_y = cy / cw; + let (rx, ry, rw, rh) = region.scissor(); + let px = f64::from(rx) + (ndc_x * 0.5 + 0.5) * f64::from(rw); + let py = f64::from(ry) + (0.5 - ndc_y * 0.5) * f64::from(rh); + Some((px, py)) +} + +fn plane_label_paints( + state: &AppState, + scene: &ReferencePlaneScene, + region: Option, +) -> Vec { + let (Some(region), Some(camera)) = (region, state.camera3) else { + return Vec::new(); + }; + let Ok(clip) = bone_render::clip_from_world(camera, region.extent()) else { + return Vec::new(); + }; + let color = state.theme.colors.text_secondary(); + let role = state.theme.typography.caption; + scene + .planes + .iter() + .filter_map(|plane| { + let text = plane_label_text(&plane.label)?; + let (px, py) = project_to_viewport(clip, plane.label_anchor(), region)?; + let rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(bone_render::lower_f32(px)), + LayoutPx::new(bone_render::lower_f32(py)), + ), + LayoutSize::new(LayoutPx::new(96.0), LayoutPx::new(16.0)), + ); + Some(bone_ui::widgets::WidgetPaint::Label { + rect, + text, + color, + role, + }) + }) + .collect() +} + +fn plane_label_text(label: &PlaneLabel) -> Option { + match label { + PlaneLabel::Principal(kind) => Some(plane_label_key(*kind).into()), + PlaneLabel::Named(name) => Some(bone_ui::widgets::LabelText::Owned(name.clone())), + PlaneLabel::Preview => None, + } +} const VIEW_TWEEN_MS: u64 = 220; @@ -2820,12 +3437,7 @@ fn solid_aabb_and_extent(state: &AppState) -> Option<(Aabb3, ViewportExtent)> { } fn normal_to_plane(state: &AppState) -> Option { - let sketch_id = active_sketch_id(&state.mode, &state.plane_sketches)?; - let plane = state - .plane_sketches - .iter() - .find_map(|(plane, id)| (*id == sketch_id).then_some(*plane))?; - Some(Plane3::from(plane.basis())) + Some(Plane3::from(state.mode.sketch3_basis()?)) } fn frame_target_camera(state: &AppState, pick: view_cube::ViewPick) -> Option { @@ -3200,13 +3812,8 @@ fn scopes_for_mode(mode: &Mode) -> HotkeyScopes { scopes } -fn next_mode( - mode: Mode, - frame: &shell::ShellFrame, - escape_requested: bool, - plane_sketches: &BTreeMap, -) -> Mode { - let after_pick = resolve_pick(mode, frame, plane_sketches); +fn next_mode(mode: Mode, frame: &shell::ShellFrame, escape_requested: bool) -> Mode { + let after_pick = resolve_pick(mode, frame); let after_escape = if escape_requested { cancel_pending_or_exit(after_pick) } else { @@ -3223,11 +3830,7 @@ fn next_mode( } } -fn resolve_pick( - mode: Mode, - frame: &shell::ShellFrame, - plane_sketches: &BTreeMap, -) -> Mode { +fn resolve_pick(mode: Mode, frame: &shell::ShellFrame) -> Mode { if mode.is_extrude() { return match frame.sketch_activated { Some(id) => match &mode { @@ -3245,12 +3848,8 @@ fn resolve_pick( 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) + sketch_pick.map_or(mode, Mode::enter_sketch) } fn toggle_or_arm(mode: Mode, tool: SketchTool) -> Mode { @@ -3298,15 +3897,13 @@ impl AppCore { 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 scene = SketchScene::extract(&default_sketch())?; let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(INITIAL_ZOOM_PX_PER_MM)); let theme = Arc::new(Theme::light()); let style = with_theme_tokens(&Style::light(), &theme); let shell = shell::Shell::new(); - let (document, sketch_id) = initial_document(&sketch); + let document = empty_document(); 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 { @@ -3338,18 +3935,21 @@ impl AppCore { theme, shell, document, - plane_sketches, mode: Mode::Idle, extrude_preview: None, model: EvaluatedModel::new(), model_passes: Vec::new(), + mesh_quality: MeshQuality::Fine, changed_features: BTreeSet::new(), needs_rebuild: false, pending_reattach: None, + pending_sketch_draft: None, extrude_reference_arm: None, revolve_arm: RevolveArm::Idle, sweep_arm: SweepArm::Idle, brep_selection: Vec::new(), + hidden_planes: BTreeSet::new(), + planes_visible: true, solid_renderer, solid_view: None, camera3: None, @@ -3456,7 +4056,7 @@ impl AppCore { fn dispatch_viewport_wheel(&mut self, delta: ScrollDelta) { let state = &mut self.state; - if state.solid_view.is_none() { + if !viewport_is_spatial(state) { state.camera = zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); return; } @@ -3545,7 +4145,7 @@ impl AppCore { if pressed { let in_viewport = !modal && self.input.cursor_in(state.viewport_rect); if in_viewport - && state.solid_view.is_some() + && viewport_is_spatial(state) && let Some(region) = solid_viewport_region(state.viewport_rect, state.extent) && let Some(cursor) = self @@ -3615,7 +4215,7 @@ impl AppCore { if let Some(nav) = key.nav && !suppress_camera { - if state.solid_view.is_some() { + if viewport_is_spatial(state) { if let Some(next) = keyboard_camera3(nav, &self.input, state) { apply_nav_camera(state, next); } @@ -3783,7 +4383,7 @@ fn render_frame( 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 { + let mut frame = if claimed_pointer { suppress_pointer_activations(frame) } else { frame @@ -3791,19 +4391,26 @@ fn render_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 entry_sketch3 = state.mode.sketch3_id(); + if let Some(plane) = frame.plane_picked + && !state.mode.is_sketch() + { + begin_plane_sketch(state, plane, input.frame); + } + if let Some(feature) = frame.datum_sketch_target + && !state.mode.is_sketch() + { + begin_datum_sketch(state, feature, input.frame); + } + if let Some(sketch_id) = frame.sketch_activated + && apply_sweep_sketch_pick(state, sketch_id) + { + frame.sketch_activated = None; + } + if let Some(sketch_id) = frame.sketch_activated + && matches!(state.mode, Mode::Idle) + { + edit_existing_sketch(state, sketch_id, input.frame); } let escape_requested = hotkey_actions.contains(&sketch_mode::ESCAPE_ACTION); if escape_requested { @@ -3819,21 +4426,22 @@ fn render_frame( apply_revolve_confirm(state, frame.confirm_action); apply_sweep_edit(state, frame.sweep_edit); apply_feature_arm(state, frame.feature_arm); + autoselect_sweep_path(state); apply_sweep_confirm(state, frame.confirm_action); apply_datum_confirm(state, frame.confirm_action); let prev_spline_sketch = state.mode.sketch_id(); let prev_spline_fresh = state.mode.spline_draft_fresh(); - 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, - ); + let prev_active_sketch = active_sketch_id(&state.mode); + state.mode = next_mode(core::mem::take(&mut state.mode), &frame, escape_requested); + if !matches!(state.mode, Mode::Sweep(_)) { + state.sweep_arm = SweepArm::Idle; + } reconcile_spline_draft(state, prev_spline_sketch, &prev_spline_fresh); 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 { + commit_pending_sketch_draft(state); + gc_abandoned_sketch(state, entry_sketch3); + if active_sketch_id(&state.mode) != prev_active_sketch { refresh_active_scene(state); } apply_dimension_outcome(state, dim_outcome); @@ -3846,7 +4454,8 @@ fn render_frame( 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 reference_scene = reference_plane_scene(state); + sync_solid_camera(state, solid_region, reference_scene.as_ref()); 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); @@ -3867,7 +4476,17 @@ fn render_frame( let mut preview = build_preview(&state.mode, &state.document, cursor_world, &state.camera); extend_preview_with_brep_selection(&mut preview, state); let main_layer = build_chrome_layer(state, &frame.paints); - let overlay_layer = build_chrome_layer(state, &frame.overlay_paints); + let label_paints = reference_scene + .as_ref() + .map(|scene| plane_label_paints(state, scene, solid_region)) + .unwrap_or_default(); + let mut overlay_layer = build_chrome_layer(state, &frame.overlay_paints); + let plane_labels = build_chrome_layer(state, &label_paints); + overlay_layer.chrome.extend(plane_labels.chrome); + overlay_layer.convex.extend(plane_labels.convex); + overlay_layer.stroke.extend(plane_labels.stroke); + overlay_layer.icons.extend(plane_labels.icons); + overlay_layer.glyphs.extend(plane_labels.glyphs); let atlas_pixels = state.sdf_atlas.pixels(); let atlas_version = state.sdf_atlas.version(); let viewport_px = [ @@ -3891,15 +4510,19 @@ fn render_frame( let scene = &state.scene; let style = &state.style; renderer.prepare(scene, style); - let solid = match overlay_frame { - Some(frame) => state.solid_view.as_ref().map(|view| (view, frame)), + let solid_frame = match overlay_frame { + Some(frame) => Some(frame), None => solid_region.and_then(|region| { preview_solid_frame(state.solid_view.as_ref(), state.camera3, region) + .map(|(_, frame)| frame) + .or_else(|| SolidFrameView::new(state.camera3?, region).ok()) }), }; - let overlay = overlay_frame.is_some() && solid.is_some(); + let overlay = overlay_frame.is_some() && state.solid_view.is_some(); let viewport = ViewportEncode { - solid, + frame: solid_frame, + solid: state.solid_view.as_ref(), + planes: reference_scene.as_ref(), overlay, solid_renderer: &state.solid_renderer, sketch_renderer: renderer, @@ -3921,7 +4544,9 @@ fn render_frame( } struct ViewportEncode<'a> { - solid: Option<(&'a SolidViewData, SolidFrameView)>, + frame: Option, + solid: Option<&'a SolidViewData>, + planes: Option<&'a ReferencePlaneScene>, overlay: bool, solid_renderer: &'a SolidRenderer, sketch_renderer: &'a SketchRenderer, @@ -3934,6 +4559,46 @@ struct ViewportEncode<'a> { } impl ViewportEncode<'_> { + fn draw_reference_planes( + &self, + encoder: &mut wgpu::CommandEncoder, + color: &wgpu::TextureView, + pick: &wgpu::TextureView, + depth: &wgpu::TextureView, + frame: &SolidFrameView, + planes: &ReferencePlaneScene, + ) { + let fill = reference_planes::fill_scene(planes, |plane| { + if plane.highlighted { + PLANE_FILL_HIGHLIGHT + } else { + PLANE_FILL + } + }); + self.solid_renderer.encode_planes( + encoder, + RenderTargets::new(color, pick), + depth, + &fill, + frame, + ); + planes.planes.iter().for_each(|plane| { + let color_rgba = if plane.highlighted { + PLANE_BORDER_HIGHLIGHT + } else { + PLANE_BORDER + }; + self.solid_renderer.encode_styled_edges( + encoder, + RenderTargets::new(color, pick), + depth, + &plane.border(), + frame, + EdgeStroke::solid(color_rgba.to_array()), + ); + }); + } + fn encode( &self, encoder: &mut wgpu::CommandEncoder, @@ -3941,7 +4606,7 @@ impl ViewportEncode<'_> { pick: &wgpu::TextureView, depth: &wgpu::TextureView, ) { - let Some((view, frame)) = &self.solid else { + let Some(frame) = self.frame else { self.sketch_renderer.encode_passes( encoder, RenderTargets::new(color, pick), @@ -3952,25 +4617,34 @@ impl ViewportEncode<'_> { ); return; }; + let empty_faces = SolidScene::empty(); + let empty_edges = EdgeScene::empty(); + let (faces, edges) = match self.solid { + Some(view) => (&view.faces, &view.edges), + None => (&empty_faces, &empty_edges), + }; self.solid_renderer.encode_passes( encoder, RenderTargets::new(color, pick), depth, - &view.faces, - &view.edges, + faces, + edges, &bone_render::SolidDisplay { - view: frame, + view: &frame, style: self.style, mode: DisplayMode::ShadedWithEdges, }, ); + if let Some(planes) = self.planes { + self.draw_reference_planes(encoder, color, pick, depth, &frame, planes); + } if let Some(preview) = &self.sketch3_preview { self.solid_renderer.encode_preview_edges( encoder, RenderTargets::new(color, pick), depth, preview, - frame, + &frame, self.style, ); } @@ -3981,7 +4655,7 @@ impl ViewportEncode<'_> { RenderTargets::new(color, pick), depth, &overlay.points, - frame, + &frame, self.style, ); self.solid_renderer.encode_styled_edges( @@ -3989,7 +4663,7 @@ impl ViewportEncode<'_> { RenderTargets::new(color, pick), depth, &overlay.normal_lines, - frame, + &frame, EdgeStroke::solid(strokes.stroke().to_rgba_array()), ); self.solid_renderer.encode_styled_edges( @@ -3997,7 +4671,7 @@ impl ViewportEncode<'_> { RenderTargets::new(color, pick), depth, &overlay.construction_lines, - frame, + &frame, EdgeStroke { color: strokes.construction().to_rgba_array(), dash_period_px: strokes.construction_dash_period_px(), @@ -4009,7 +4683,7 @@ impl ViewportEncode<'_> { RenderTargets::new(color, pick), depth, &overlay.selected_lines, - frame, + &frame, EdgeStroke::solid(SKETCH3_SELECTED.to_array()), ); } @@ -4405,7 +5079,7 @@ fn apply_convert_entities(state: &mut AppState) { let view = sketch3_project::project(base, basis); let (next3, _) = sketch3_project::embed_delta(base, &view, &converted, sketch3_project::PlaneLock::Locked); - state.undo.record(state.document.clone()); + record_sketch_edit(state, sketch_id); state .document .replace_sketch(sketch_id, sketch3_mode::solved(next3)); @@ -4945,6 +5619,7 @@ fn suppress_pointer_activations(frame: shell::ShellFrame) -> shell::ShellFrame { sweep_edit: frame.sweep_edit, feature_arm: frame.feature_arm, plane_picked: None, + datum_sketch_target: None, sketch_activated: None, sketch_rename: None, extrude_activated: None, @@ -5127,6 +5802,7 @@ fn run_frame_ui( &body_rows, needs_rebuild, &whats_wrong, + &state.hidden_planes, ); let dim_view = dim_render_view( state.camera, @@ -6019,6 +6695,7 @@ fn apply_menu_action(state: &mut AppState, action: Option) { Some(shell::MenuAction::ConvertEntities) => apply_convert_entities(state), Some(shell::MenuAction::ToggleConstruction) => apply_construction_toggle(state), Some(shell::MenuAction::ToggleDriven) => apply_driven_toggle(state), + Some(shell::MenuAction::TogglePlanes) => state.planes_visible = !state.planes_visible, Some(shell::MenuAction::Undo | shell::MenuAction::Redo | shell::MenuAction::ExitSketch) | None => {} } @@ -6186,14 +6863,14 @@ fn apply_new_document(state: &mut AppState) { return; } }; - let (document, sketch_id) = initial_document(&sketch); + let document = empty_document(); 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.pending_sketch_draft = None; state.current_folder = None; state.pending_overwrite = None; let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { @@ -6488,43 +7165,22 @@ fn install_loaded_document( document: Document, folder: Option, ) { - let plane_sketches = plane_sketches_from(&document); - let active_sketch_id = plane_sketches.get(&Plane::Xy).copied(); state.last_saved = Some(document.clone()); state.document = document; - state.plane_sketches = plane_sketches; state.mode = Mode::Idle; state.selection = Selection::default(); state.framed_extrude = None; + state.pending_sketch_draft = 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(|sketch| SketchScene::extract(&sketch)); - 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.scene = SketchScene::empty(); state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); } -fn plane_sketches_from(document: &Document) -> BTreeMap { - document - .sketches() - .map(|(id, _)| (Plane::Xy, id)) - .take(1) - .collect() -} - fn persist_settings(state: &AppState) { settings::save(&state.settings); } @@ -6563,7 +7219,17 @@ fn apply_extrude_rename(state: &mut AppState, request: Option) { let Some(command) = command else { return }; + if let shell::FeatureCommand::TogglePlaneHidden(feature) = command { + if !state.hidden_planes.remove(&feature) { + state.hidden_planes.insert(feature); + } + return; + } if apply_feature_command_into(&mut state.document, &mut state.undo, command) { + let tree = state.document.feature_tree(); + state + .hidden_planes + .retain(|feature| tree.node(*feature).is_some()); refresh_active_scene(state); sync_solid_view(state); } @@ -6589,6 +7255,7 @@ fn apply_feature_command_into( true } shell::FeatureCommand::Delete(target) => apply_feature_delete(document, target), + shell::FeatureCommand::TogglePlaneHidden(_) => false, }; if changed { undo.record(snapshot); @@ -6600,6 +7267,8 @@ fn apply_feature_delete(document: &mut Document, target: shell::FeatureTarget) - 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(), + shell::FeatureTarget::Datum(datum_id) => document.remove_datum(datum_id).is_some(), + shell::FeatureTarget::Plane(_) => false, } } @@ -6730,40 +7399,39 @@ mod tests { use bone_ui::hotkey::KeyChord; use bone_ui::input::{KeyChar, KeyCode, KeyEvent, NamedKey}; + fn now16() -> FrameInstant { + FrameInstant::from_duration(std::time::Duration::from_millis(16)) + } + #[test] - fn dedup_spline_control_drops_geometrically_coincident_points() { + fn sketch3_point_helpers_dedup_coincident_and_flag_loose() { use bone_document::{Sketch3, Sketch3Entity}; let (s, a) = Sketch3::new().with_entity(Sketch3Entity::point(Point3::from_mm(0.0, 0.0, 0.0))); let (s, b) = s.with_entity(Sketch3Entity::point(Point3::from_mm(0.0, 0.0, 0.0))); let (s, c) = s.with_entity(Sketch3Entity::point(Point3::from_mm(5.0, 0.0, 0.0))); - let draft = [ - SplineDraftPoint::fresh(a), - SplineDraftPoint::fresh(b), - SplineDraftPoint::fresh(c), - ]; assert_eq!( - dedup_spline_control(&s, &draft), + dedup_spline_control( + &s, + &[ + SplineDraftPoint::fresh(a), + SplineDraftPoint::fresh(b), + SplineDraftPoint::fresh(c), + ], + ), vec![a, c], "two control points at the same position collapse to one", ); - } - #[test] - fn is_loose_point_distinguishes_referenced_from_standalone() { - use bone_document::{Sketch3, Sketch3Entity}; - let (s, a) = - Sketch3::new().with_entity(Sketch3Entity::point(Point3::from_mm(0.0, 0.0, 0.0))); - let (s, b) = s.with_entity(Sketch3Entity::point(Point3::from_mm(5.0, 0.0, 0.0))); let (s, standalone) = s.with_entity(Sketch3Entity::point(Point3::from_mm(0.0, 5.0, 0.0))); - let (s, _line) = s.with_entity(Sketch3Entity::line(a, b, false)); + let (s, _line) = s.with_entity(Sketch3Entity::line(a, c, false)); assert!( is_loose_point(&s, standalone), "a point no entity references is loose and safe to drop on abandon", ); assert!( !is_loose_point(&s, a), - "a point owned by a line is not loose", + "a point owned by a line is not loose" ); } @@ -6782,11 +7450,11 @@ mod tests { } #[test] - fn strip_plain_letter_chords_removes_chars_with_no_modifiers() { - let mut input = InputSnapshot::idle(FrameInstant::ZERO); + fn strip_plain_letter_chords_keeps_modified_and_named_keys() { 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); + let mut input = InputSnapshot::idle(FrameInstant::ZERO); input.keys_pressed = vec![plain_s, ctrl_s, esc]; strip_plain_letter_chords(&mut input); assert_eq!( @@ -6794,19 +7462,20 @@ mod tests { 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]); + assert_eq!( + input.keys_pressed, + vec![enter], + "with no plain letters strip is a no-op" + ); } #[test] - fn sketch_overlay_camera_matches_the_sketch_camera_mapping() { + fn sketch_overlay_camera_matches_the_sketch_camera_mapping() + -> Result<(), Box> { use bone_render::clip_from_world; use bone_types::{Point3, Tolerance, UnitVec3}; @@ -6818,10 +7487,7 @@ mod tests { ) else { panic!("the axes normalize"); }; - let Ok(basis) = SketchPlaneBasis::new(Point3::from_mm(3.0, -2.0, 5.0), x_axis, y_axis, tol) - else { - panic!("the axes are orthogonal"); - }; + let basis = SketchPlaneBasis::new(Point3::from_mm(3.0, -2.0, 5.0), x_axis, y_axis, tol)?; let camera = Camera2::new(extent) .with_zoom(PixelsPerMm::new(7.5)) .with_pan(Vec2::from_mm(1.5, -0.5)); @@ -6830,12 +7496,9 @@ mod tests { Point3::from_mm(6.0, 4.0, 8.0), ); let region = ViewportRegion::at_origin(extent); - let Some(camera3) = sketch_overlay_camera(basis, camera, aabb, region) else { - panic!("a positive zoom yields an overlay camera"); - }; - let Ok(clip3) = clip_from_world(camera3, extent) else { - panic!("the overlay camera lowers to a clip matrix"); - }; + let camera3 = sketch_overlay_camera(basis, camera, aabb, region) + .ok_or("a positive zoom yields an overlay camera")?; + let clip3 = clip_from_world(camera3, extent)?; let clip2 = camera.clip_from_world_mm(); let ndc = |m: &[f32; 16], x: f64, y: f64, z: f64| { let v = [x, y, z, 1.0]; @@ -6858,42 +7521,41 @@ mod tests { overlay ({nx3}, {ny3}) vs sketch ({nx2}, {ny2})", ); }); + Ok(()) } #[test] - fn cursor_to_world_at_window_center_equals_camera_pan() { + fn cursor_to_world_maps_center_inverts_y_and_rejects_zero_extent() + -> Result<(), Box> { 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 world = cursor_to_world(camera, WindowPoint::new(100.0, 50.0)).ok_or("center maps")?; let (x, y) = world.coords_mm(); - assert!((x - 7.0).abs() < 1e-9); - assert!((y - -3.0).abs() < 1e-9); - } + assert!( + (x - 7.0).abs() < 1e-9 && (y - -3.0).abs() < 1e-9, + "center maps to the camera pan", + ); - #[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 unpanned = Camera2::new(extent).with_zoom(PixelsPerMm::new(10.0)); + let (Some(above), Some(below)) = ( + cursor_to_world(unpanned, WindowPoint::new(100.0, 0.0)), + cursor_to_world(unpanned, WindowPoint::new(100.0, 100.0)), + ) else { + panic!("top and bottom map"); }; - let (_, ya) = above.coords_mm(); - let (_, yb) = below.coords_mm(); - assert!(ya > yb); - } + assert!( + above.coords_mm().1 > below.coords_mm().1, + "up in the window is up in the world", + ); - #[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()); + let zero = ViewportExtent::new(ViewportPx::new(0), ViewportPx::new(100)); + assert!( + cursor_to_world(Camera2::new(zero), WindowPoint::new(0.0, 0.0)).is_none(), + "a zero-width viewport has no world mapping", + ); + Ok(()) } fn empty_frame() -> shell::ShellFrame { @@ -6912,6 +7574,7 @@ mod tests { sweep_edit: None, feature_arm: None, plane_picked: None, + datum_sketch_target: None, sketch_activated: None, sketch_rename: None, extrude_activated: None, @@ -6929,10 +7592,6 @@ mod tests { } } - fn xy_only() -> BTreeMap { - BTreeMap::from([(Plane::Xy, SketchId::default())]) - } - #[test] fn classify_extrude_profile_separates_no_sketch_from_unique() { let empty = Document::new(DocumentId::default(), "Untitled".to_owned()); @@ -6941,7 +7600,16 @@ mod tests { super::ProfileChoice::NoSketch )); - let (one, id) = super::initial_document(&Sketch::new(Plane::Xy.basis())); + let (blank, _) = super::initial_document(&Sketch::new(Plane::Xy.basis())); + assert!( + matches!( + super::classify_extrude_profile(&blank), + super::ProfileChoice::NoSketch + ), + "an entity-less sketch is not an extrude profile", + ); + + let (one, id) = super::initial_document(&rectangle_sketch()); assert!(matches!( super::classify_extrude_profile(&one), super::ProfileChoice::Unique(found) if found == id @@ -6974,60 +7642,138 @@ mod tests { sketch } + fn circle_profile_zx() -> bone_document::Sketch3 { + use bone_document::{Sketch3, Sketch3Entity}; + let sketch = Sketch3::on_plane(PrincipalPlane::Zx.basis()); + let (sketch, center) = + sketch.with_entity(Sketch3Entity::point(Point3::from_mm(0.0, 0.0, 0.0))); + let (sketch, _) = sketch.with_entity(Sketch3Entity::circle( + center, + Length::new::(10.0), + UnitVec3::y_axis(), + false, + )); + sketch + } + + fn line_path_xy(length_mm: f64) -> bone_document::Sketch3 { + use bone_document::{Sketch3, Sketch3Entity}; + let sketch = Sketch3::on_plane(PrincipalPlane::Xy.basis()); + let (sketch, a) = sketch.with_entity(Sketch3Entity::point(Point3::from_mm(0.0, 0.0, 0.0))); + let (sketch, b) = + sketch.with_entity(Sketch3Entity::point(Point3::from_mm(0.0, length_mm, 0.0))); + let (sketch, _) = sketch.with_entity(Sketch3Entity::line(a, b, false)); + sketch + } + #[test] - fn extrude_preview_cache_is_current_only_for_the_same_feature_and_sketch_version() { + fn extrude_preview_evaluates_edits_reaches_and_frames() -> Result<(), Box> + { + use bone_document::ExtrudeEndCondition; + let id = SketchId::default(); - let feature = sketch_mode::default_extrude_feature(id); + let cache_feature = + super::PreviewFeature::Extrude(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 preview_feature = super::PreviewFeature::Extrude(feature); + let base_inputs = vec![(id, base_version)]; let cached = super::ExtrudePreview { - feature: preview_feature, - sketch_version: base_version, + feature: cache_feature.clone(), + inputs: base_inputs.clone(), generation: None, failed: false, error: None, }; assert!(super::extrude_preview_is_current( Some(&cached), - &preview_feature, - base_version + &cache_feature, + &base_inputs )); assert!( - !super::extrude_preview_is_current(Some(&cached), &preview_feature, edited_version), + !super::extrude_preview_is_current( + Some(&cached), + &cache_feature, + &[(id, edited_version)], + ), "editing the sketch under the same feature must invalidate the cached preview", ); assert!(!super::extrude_preview_is_current( None, - &preview_feature, - base_version + &cache_feature, + &base_inputs )); - } - #[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, - &[], - &super::PreviewFeature::Extrude(feature), - ) - .and_then(|preview| preview.generation()); + let empty = Document::new(DocumentId::default(), "Empty".to_owned()); + assert!( + super::compute_extrude_preview( + &empty, + &[], + &super::PreviewFeature::Extrude(sketch_mode::default_extrude_feature( + SketchId::default(), + )), + ) + .is_none(), + "a missing sketch previews nothing", + ); + + let (mut document, sketch_id) = super::initial_document(&rectangle_sketch()); + let feature = sketch_mode::default_extrude_feature(sketch_id); + let preview_feature = super::PreviewFeature::Extrude(feature); + let preview = super::compute_extrude_preview(&document, &[], &preview_feature) + .ok_or("a registered profile sketch yields an evaluated preview")?; + let solid = preview + .solid() + .ok_or("the closed rectangle extrudes to a solid")?; + let view = super::preview_solid_view(solid)?; + 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", + ); + let first = preview.generation(); + + let base_sketch = document.sketch3(sketch_id).ok_or("sketch is registered")?; + let evaluated = evaluate_sketch(base_sketch); + let base_body = evaluate_extrude_reaching(FeatureId::default(), &evaluated, &feature, &[]) + .solid() + .cloned() + .ok_or("base block builds")?; + let up_to_next = super::PreviewFeature::Extrude(ExtrudeFeature { + end_condition: ExtrudeEndCondition::UpToNext, + ..feature + }); + assert!( + super::compute_extrude_preview(&document, &[&base_body], &up_to_next) + .and_then(|preview| preview.solid().cloned()) + .is_some(), + "up to next reaches the obstacle body and previews a solid", + ); + assert!( + super::compute_extrude_preview(&document, &[], &up_to_next) + .is_some_and(|preview| preview.result().is_err()), + "with no obstacle body there is no next face to stop at", + ); + document.replace_sketch( - id, + sketch_id, super::sketch3_project::embed_sketch(&tall_rectangle_sketch()), ); - let second = super::compute_extrude_preview( - &document, - &[], - &super::PreviewFeature::Extrude(feature), - ) - .and_then(|preview| preview.generation()); + let second = super::compute_extrude_preview(&document, &[], &preview_feature) + .and_then(|preview| preview.generation()); let (Some(first), Some(second)) = (first, second) else { panic!("both rectangles extrude to a solid"); }; @@ -7035,58 +7781,7 @@ mod tests { 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, - &[], - &super::PreviewFeature::Extrude(feature), - ) else { - panic!("a registered sketch yields an evaluated preview"); - }; - assert!( - preview.solid().is_some(), - "closed rectangle extrudes to a solid" - ); - } - - #[test] - fn extrude_preview_resolves_up_to_next_against_obstacle_bodies() { - use bone_document::ExtrudeEndCondition; - let (document, id) = super::initial_document(&rectangle_sketch()); - let Some(sketch) = document.sketch3(id) else { - panic!("sketch is registered"); - }; - let evaluated = evaluate_sketch(sketch); - let Some(base) = evaluate_extrude_reaching( - FeatureId::default(), - &evaluated, - &sketch_mode::default_extrude_feature(id), - &[], - ) - .solid() - .cloned() else { - panic!("base block builds"); - }; - let feature = super::PreviewFeature::Extrude(ExtrudeFeature { - end_condition: ExtrudeEndCondition::UpToNext, - ..sketch_mode::default_extrude_feature(id) - }); - assert!( - super::compute_extrude_preview(&document, &[&base], &feature) - .and_then(|preview| preview.solid().cloned()) - .is_some(), - "up to next reaches the obstacle body and previews a solid", - ); - assert!( - super::compute_extrude_preview(&document, &[], &feature) - .is_some_and(|preview| preview.result().is_err()), - "with no obstacle body there is no next face to stop at", - ); + Ok(()) } #[test] @@ -7094,7 +7789,7 @@ mod tests { debug_assertions, ignore = "frame budget assertions are only meaningful in release builds" )] - fn extrude_live_preview_under_frame_budget() { + fn extrude_live_preview_under_frame_budget() -> Result<(), Box> { use bone_document::ExtrudeEndCondition; use bone_types::PositiveLength; use std::time::{Duration, Instant}; @@ -7142,9 +7837,7 @@ mod tests { v }; let median = sorted[sorted.len() / 2]; - let Some(&worst) = sorted.last() else { - panic!("preview loop produced zero samples"); - }; + let &worst = sorted.last().ok_or("preview loop produced zero samples")?; let budget = BudgetCeiling::FRAME_16MS.duration(); assert!( median <= budget, @@ -7154,37 +7847,7 @@ mod tests { 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, - &[], - &super::PreviewFeature::Extrude(feature) - ) - .is_none() - ); - } - - #[test] - fn a_profile_sketch_extrudes_to_a_solid() { - let (document, id) = super::initial_document(&rectangle_sketch()); - let feature = sketch_mode::default_extrude_feature(id); - let Some(preview) = super::compute_extrude_preview( - &document, - &[], - &super::PreviewFeature::Extrude(feature), - ) else { - panic!("the profile sketch is registered"); - }; - let Some(solid) = preview.solid() else { - panic!("the profile sketch extrudes to a solid"); - }; - assert!(super::preview_solid_view(solid).is_ok()); + Ok(()) } #[test] @@ -7196,40 +7859,6 @@ mod tests { ); } - #[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, - &[], - &super::PreviewFeature::Extrude(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::preview_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 unconsumed_sketches_lists_only_standalone_geometry() { let (mut document, consumed) = super::initial_document(&rectangle_sketch()); @@ -7254,7 +7883,7 @@ mod tests { } #[test] - fn build_combined_view_merges_every_evaluated_body() { + fn build_combined_view_merges_every_evaluated_body() -> Result<(), Box> { 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(); @@ -7276,23 +7905,73 @@ mod tests { 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::preview_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"); - }; + let f1 = document + .feature_tree() + .feature_of_extrude(e1) + .ok_or("the first extrude resolves to a feature id")?; + let solid = model.body(f1).ok_or("the first body is evaluated")?; + let single = super::preview_solid_view(solid)?; + let combined = super::build_combined_view(&model, super::MeshQuality::Fine) + .ok_or("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", ); + Ok(()) + } + + #[test] + fn mesh_quality_and_tolerances_track_the_drag() { + assert_eq!( + super::desired_mesh_quality(&Mode::Idle), + super::MeshQuality::Fine + ); + assert_eq!( + super::desired_mesh_quality(&Mode::enter_sketch(SketchId::default())), + super::MeshQuality::Fine, + "a sketch with no active drag stays fine", + ); + let dragging = Mode::Sketch { + sketch_id: SketchId::default(), + session: Box::new(SketchSession { + drag: Some(DragSession { + entity: bone_types::SketchEntityId::default(), + press: Point2::origin(), + pins: DragPins::from_array([None, None, None]), + }), + ..SketchSession::default() + }), + }; + assert_eq!( + super::desired_mesh_quality(&dragging), + super::MeshQuality::Dragging, + "dragging a sketch entity switches to the coarse mesh", + ); + + let close = |a: f64, b: f64| (a - b).abs() < 1e-9; + let big = Aabb3::from_corners( + Point3::from_mm(0.0, 0.0, 0.0), + Point3::from_mm(100.0, 100.0, 100.0), + ); + let (fine_chord, fine_angle) = super::mesh_tolerances(super::MeshQuality::Fine, &big); + assert!(close(fine_chord.millimeters(), super::PREVIEW_CHORD_MM)); + assert!(close(fine_angle.radians(), super::PREVIEW_ANGLE.radians())); + let (drag_chord, drag_angle) = super::mesh_tolerances(super::MeshQuality::Dragging, &big); + assert!( + drag_chord.millimeters() > fine_chord.millimeters(), + "a large body tessellates coarser while dragging", + ); + assert!(close(drag_angle.radians(), super::DRAG_ANGLE.radians())); + let tiny = Aabb3::from_corners( + Point3::from_mm(0.0, 0.0, 0.0), + Point3::from_mm(1.0, 1.0, 1.0), + ); + let (tiny_chord, _) = super::mesh_tolerances(super::MeshQuality::Dragging, &tiny); + assert!( + close(tiny_chord.millimeters(), super::PREVIEW_CHORD_MM), + "a tiny body never drops below the fine chord", + ); } fn full_recompute(model: &mut EvaluatedModel, document: &Document) { @@ -7305,27 +7984,28 @@ mod tests { } #[test] - fn face_bound_sketch_creation_then_break_offers_reattach() { + fn face_bound_sketch_creation_then_break_offers_reattach() + -> Result<(), Box> { 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 body = document + .feature_tree() + .feature_of_extrude(base) + .ok_or("the base extrude resolves to a feature id")?; + let solid = model.body(body).ok_or("the base body is evaluated")?; + let face = solid + .iter_faces() + .find_map(|candidate| { + matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id()) + }) + .ok_or("the box has a planar cap")?; + let (face_ref, _basis) = model + .face_for_sketch(face) + .ok_or("a planar cap yields a face reference and basis")?; let face_sketch = document.allocate_sketch(); document.insert_sketch( @@ -7333,9 +8013,7 @@ mod tests { "Sketch2".to_owned(), super::sketch3_project::embed_sketch(&rectangle_sketch()), ); - let Ok(()) = document.bind_sketch_to_face(face_sketch, face_ref) else { - panic!("binding a fresh sketch to a face is acyclic"); - }; + document.bind_sketch_to_face(face_sketch, face_ref)?; let face_extrude = document.commit_extrude(sketch_mode::default_extrude_feature(face_sketch)); full_recompute(&mut model, &document); @@ -7347,9 +8025,10 @@ mod tests { 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"); - }; + let face_feature = document + .feature_tree() + .feature_of_sketch(face_sketch) + .ok_or("the face-bound sketch keeps its feature id")?; assert!( matches!( model.status(face_feature), @@ -7357,10 +8036,10 @@ mod tests { ), "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"); - }; + let face_extrude_feature = document + .feature_tree() + .feature_of_extrude(face_extrude) + .ok_or("the face-bound extrude keeps its feature id")?; assert_eq!( model.status(face_extrude_feature), Some(RebuildStatus::Error(RebuildError::UpstreamUnresolved)), @@ -7388,6 +8067,7 @@ mod tests { && entry.reattach.is_none()), "the downstream body reports an upstream failure with no reattach of its own", ); + Ok(()) } fn offscreen_context(extent: ViewportExtent, remaining: u32) -> bone_render::OffscreenContext { @@ -7402,263 +8082,1041 @@ mod tests { } } - 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; + struct OffscreenFrameTarget { + ctx: bone_render::OffscreenContext, + } + + impl FrameTarget for OffscreenFrameTarget { + fn picker(&self, index: PickIndex) -> Picker<'_> { + self.ctx.picker(index) } - 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"); + + fn render( + &mut self, + build_passes: impl FnOnce( + &mut wgpu::CommandEncoder, + &wgpu::TextureView, + &wgpu::TextureView, + &wgpu::TextureView, + ), + ) { + self.ctx.render_passes(build_passes); + } + } + + fn fresh_core() -> (AppCore, OffscreenFrameTarget) { + let extent = ViewportExtent::new(ViewportPx::new(960), ViewportPx::new(640)); + let ctx = offscreen_context(extent, 3); + let Ok(mut core) = AppCore::new(ctx.gpu(), ctx.color_format(), extent) else { + panic!("app core init"); }; - assert!( - report.is_clean(), - "rebuild-downstream golden drifted: {} mismatches, worst {:?}", - report.over_threshold(), - report.worst(), - ); + let mut target = OffscreenFrameTarget { ctx }; + let _ = core.render_frame(&mut target); + (core, target) } - #[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)); + fn extruded_box_model() -> (Document, EvaluatedModel) { + 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(); full_recompute(&mut model, &document); + (document, model) + } - 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"); - }; + #[test] + fn plane_and_free_sketch_lifecycle_enter_edit_and_gc() -> Result<(), Box> + { + use bone_document::{Sketch3, Sketch3Entity}; - let face_sketch = document.allocate_sketch(); - document.insert_sketch( - face_sketch, - "Sketch2".to_owned(), - super::sketch3_project::embed_sketch(&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 face_boss = bone_document::ExtrudeFeature { - operation: bone_document::ExtrudeOperation::Boss(bone_document::MergeResult::Separate), - ..sketch_mode::default_extrude_feature(face_sketch) - }; - let _ = document.commit_extrude(face_boss); - full_recompute(&mut model, &document); + let (mut core, mut target) = fresh_core(); + let now = now16(); - document.replace_sketch( - base_sketch, - super::sketch3_project::embed_sketch(&tall_rectangle_sketch()), + assert!( + core.state.solid_view.is_none(), + "a fresh part has no solid body" ); - full_recompute(&mut model, &document); + assert!( + core.state.camera3.is_some(), + "the reference planes still seed a 3D camera to orbit", + ); + assert!( + super::viewport_is_spatial(&core.state), + "an empty part middle-drag orbits the reference planes rather than panning a flat sketch", + ); + + super::begin_plane_sketch(&mut core.state, Plane::Zx, now); + assert!( + core.state.mode.is_sketch3(), + "picking the top plane enters a native 3D sketch", + ); + assert_eq!(core.state.mode.sketch3_plane(), Some(Plane::Zx)); + let plane_id = core + .state + .mode + .sketch3_id() + .ok_or("the new sketch has an id")?; + let datum = core + .state + .document + .feature_tree() + .principal_plane_feature(PrincipalPlane::Zx); assert_eq!( - super::body_passes(&model).len(), - 2, - "editing the base sketch keeps both bodies", + core.state.document.sketch_plane_binding(plane_id), + datum.map(SketchPlaneRef::Datum), + "the sketch is bound to the top-plane datum", ); - 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"); - }; + core.state + .document + .replace_sketch(plane_id, sketch3_project::embed_sketch(&rectangle_sketch())); + core.state.mode = Mode::Idle; + assert!( + super::edit_existing_sketch(&mut core.state, plane_id, now), + "a datum-plane sketch re-opens as a native 3D sketch", + ); + assert!( + core.state.mode.is_sketch3(), + "re-edit stays in 3D, not flat 2D" + ); + assert_eq!(core.state.mode.sketch3_id(), Some(plane_id)); + assert_eq!(core.state.mode.sketch3_plane(), Some(Plane::Zx)); + core.state.mode = Mode::Idle; + + super::arm_sketch3(&mut core.state); + let free_id = core + .state + .mode + .sketch3_id() + .ok_or("the 3D sketch tool enters a native 3D sketch")?; + let line = { + let sketch = Sketch3::new(); + let (sketch, a) = + sketch.with_entity(Sketch3Entity::point(Point3::from_mm(0.0, 0.0, 0.0))); + let (sketch, b) = + sketch.with_entity(Sketch3Entity::point(Point3::from_mm(0.0, 0.0, 20.0))); + let (sketch, _) = sketch.with_entity(Sketch3Entity::line(a, b, false)); + sketch + }; + core.state.document.replace_sketch(free_id, line); + core.state.mode = Mode::Idle; + assert!( + super::edit_existing_sketch(&mut core.state, free_id, now), + "a 3D-sketch line re-opens for editing even though it is not bound to a plane", + ); + assert!(core.state.mode.is_sketch3(), "it re-opens as a 3D sketch"); + assert_eq!(core.state.mode.sketch3_id(), Some(free_id)); + core.state.mode = Mode::Idle; - 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"); - } + super::begin_plane_sketch(&mut core.state, Plane::Xy, now); + let empty_id = core.state.mode.sketch3_id().ok_or("empty sketch id")?; + core.state.mode = Mode::Idle; + super::gc_abandoned_sketch(&mut core.state, Some(empty_id)); + assert!( + core.state.document.sketch3(empty_id).is_none(), + "leaving a plane sketch you never drew on removes it, SolidWorks-style", + ); - #[test] - fn selecting_a_reference_end_condition_arms_its_picker() { - let blind = sketch_mode::default_extrude_feature(SketchId::default()); - [ - EndConditionKind::UpToSurface, - EndConditionKind::UpToVertex, - EndConditionKind::UpToBody, - EndConditionKind::OffsetFromSurface, - ] - .into_iter() - .for_each(|kind| { - let edit = shell::ExtrudeEdit::EndCondition(kind); - assert_eq!( - super::unresolved_reference_arm(edit, &edit.apply(blind)), - Some(shell::ReferenceSlot::Direction1(kind)), - "a fresh blind extrude that picks {kind:?} arms its reference slot", - ); - }); + super::begin_plane_sketch(&mut core.state, Plane::Xy, now); + let drawn_id = core.state.mode.sketch3_id().ok_or("drawn sketch id")?; + core.state + .document + .replace_sketch(drawn_id, sketch3_project::embed_sketch(&rectangle_sketch())); + core.state.mode = Mode::Idle; + super::gc_abandoned_sketch(&mut core.state, Some(drawn_id)); + assert!( + core.state.document.sketch3(drawn_id).is_some(), + "a sketch with geometry is never garbage collected on exit", + ); + + super::begin_plane_sketch(&mut core.state, Plane::Yz, now); + let active_id = core.state.mode.sketch3_id().ok_or("sketch id")?; + super::gc_abandoned_sketch(&mut core.state, Some(active_id)); + assert!( + core.state.document.sketch3(active_id).is_some(), + "while still in the sketch, it is not collected even though it is empty", + ); + + let _ = core.render_frame(&mut target); + Ok(()) } #[test] - fn selecting_a_self_contained_end_condition_arms_nothing() { - let blind = sketch_mode::default_extrude_feature(SketchId::default()); - [ - EndConditionKind::Blind, - EndConditionKind::ThroughAll, - EndConditionKind::MidPlane, - ] - .into_iter() - .for_each(|kind| { - let edit = shell::ExtrudeEdit::EndCondition(kind); - assert_eq!( - super::unresolved_reference_arm(edit, &edit.apply(blind)), - None, - "{kind:?} needs no reference, so nothing is armed", + fn extrude_arms_on_the_drawn_profile_ignoring_empty_plane_sketches() + -> Result<(), Box> { + let now = now16(); + [false, true].into_iter().try_for_each(|empty_distractor| -> Result<(), Box> { + let (mut core, _target) = fresh_core(); + + super::begin_plane_sketch(&mut core.state, Plane::Xy, now); + let front_sketch = core.state.mode.sketch3_id().ok_or("front sketch id")?; + let rect = sketch3_project::embed_sketch(&rectangle_sketch()); + core.state.document.replace_sketch(front_sketch, rect); + core.state.mode = Mode::Idle; + + if empty_distractor { + super::begin_plane_sketch(&mut core.state, Plane::Zx, now); + core.state.mode = Mode::Idle; + } + + super::arm_extrude(&mut core.state, ExtrudeOperation::default()); + assert!( + matches!( + core.state.mode, + Mode::Extrude(super::ExtrudeArming::Profile { .. }) + ), + "saving a plane profile arms extrude even past an empty second plane sketch; got {:?}", + core.state.mode, ); - }); + Ok(()) +})?; + Ok(()) } #[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)); + fn re_editing_a_sweeps_path_line_on_a_plane_rebuilds_the_sweep() + -> Result<(), Box> { + use bone_document::SweepPath; + + let (mut core, _target) = fresh_core(); + let now = now16(); + + super::begin_plane_sketch(&mut core.state, Plane::Zx, now); + let profile_id = core + .state + .mode + .sketch3_id() + .ok_or("profile plane sketch id")?; + core.state + .document + .replace_sketch(profile_id, circle_profile_zx()); + core.state.mode = Mode::Idle; + + super::begin_plane_sketch(&mut core.state, Plane::Xy, now); + let path_id = core.state.mode.sketch3_id().ok_or("path plane sketch id")?; + core.state + .document + .replace_sketch(path_id, line_path_xy(40.0)); + core.state.mode = Mode::Idle; + + super::apply_feature_tool(&mut core.state, Some(FeatureTool::SweptBossBase)); + if matches!(core.state.mode, Mode::Sweep(SweepArming::AwaitingSketch(_))) { + assert!(super::apply_sweep_sketch_pick(&mut core.state, profile_id)); + } + let Mode::Sweep(SweepArming::Profile { feature, .. }) = &core.state.mode else { + panic!("the sweep arms on its profile; got {:?}", core.state.mode); + }; + assert_eq!(feature.path, SweepPath::Sketch(path_id)); + super::apply_sweep_confirm(&mut core.state, Some(shell::ConfirmAction::Accept)); + super::recompute_model(&mut core.state, RecomputeScope::Full); + + let sweep = core + .state + .document + .feature_tree() + .iter() + .find_map(|(feature, node)| matches!(node, FeatureNode::Sweep(_)).then_some(feature)) + .ok_or("the sweep committed")?; + let built_before = core.state.model.built_at(sweep); + let span_before = core + .state + .model + .body(sweep) + .and_then(BrepSolid::bounding_box) + .map(|aabb| aabb.extent().norm_mm()) + .ok_or("the swept body has a bounding box")?; + + assert!( + super::edit_existing_sketch(&mut core.state, path_id, now), + "the sweep's path line, drawn on a plane, re-opens for editing after the fact", + ); + assert!(core.state.mode.is_sketch3(), "it re-opens as a 3D sketch"); + assert_eq!(core.state.mode.sketch3_id(), Some(path_id)); + + core.state + .document + .replace_sketch(path_id, line_path_xy(70.0)); + core.state.mode = Mode::Idle; + super::recompute_model(&mut core.state, RecomputeScope::Full); + + assert_ne!( + core.state.model.built_at(sweep), + built_before, + "stretching the path line re-dirties and rebuilds the sweep", + ); + let span_after = core + .state + .model + .body(sweep) + .and_then(BrepSolid::bounding_box) + .map(|aabb| aabb.extent().norm_mm()) + .ok_or("the rebuilt swept body has a bounding box")?; + assert!( + span_after > span_before + 1.0, + "the rebuilt sweep follows the longer path: {span_before} -> {span_after}", + ); + Ok(()) + } + + #[test] + fn document_level_sweep_builds_or_errors_by_path_resolution() + -> Result<(), Box> { + use bone_document::{SweepFeature, SweepPath}; + + let mut document = super::empty_document(); + let tree = document.feature_tree(); + let xy = tree + .principal_plane_feature(PrincipalPlane::Xy) + .ok_or("front plane seeded")?; + let zx = tree + .principal_plane_feature(PrincipalPlane::Zx) + .ok_or("top plane seeded")?; + let path_id = document.allocate_sketch(); + document.insert_sketch(path_id, "Path".to_owned(), line_path_xy(40.0)); + document.bind_sketch_to_datum_plane(path_id, xy)?; + let profile_id = document.allocate_sketch(); + document.insert_sketch(profile_id, "Profile".to_owned(), circle_profile_zx()); + document.bind_sketch_to_datum_plane(profile_id, zx)?; + let _sweep = + document.commit_sweep(SweepFeature::boss(profile_id, SweepPath::Sketch(path_id))); let mut model = EvaluatedModel::new(); - model.recompute( - &document, - document.suppressed(), - document.rollback(), - RecomputeScope::Full, + full_recompute(&mut model, &document); + assert!( + model.bodies().next().is_some(), + "a circle profile swept along a line on a perpendicular plane builds a solid", ); + + let mut document = super::empty_document(); + let profile_id = document.allocate_sketch(); + document.insert_sketch(profile_id, "Profile".to_owned(), circle_profile_zx()); + let sweep = document.commit_sweep(SweepFeature::boss( + profile_id, + SweepPath::Sketch(SketchId::default()), + )); + let mut model = EvaluatedModel::new(); + full_recompute(&mut model, &document); + let feature = document + .feature_tree() + .feature_of_sweep(sweep) + .ok_or("sweep has a feature id")?; assert!( - super::solid_pick_index(&model).is_some(), - "an evaluated body yields a solid pick index", + matches!(model.status(feature), Some(RebuildStatus::Error(_))), + "an unresolved sweep path is a red error, not a silent rebuild-pending; got {:?}", + model.status(feature), ); assert!( - super::solid_pick_index(&EvaluatedModel::new()).is_none(), - "an empty model has nothing to pick", + model.bodies().next().is_none(), + "the unresolved sweep produces no phantom body", ); + Ok(()) } #[test] - fn captured_reference_maps_a_face_pick_to_a_label() { - use super::PickedItem; - use crate::sketch_mode::ReferenceKind; - let (mut document, sketch) = super::initial_document(&rectangle_sketch()); - let _extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); + fn a_plane_sketch_records_an_undo_step_only_when_drawn() + -> Result<(), Box> { + let now = now16(); + [false, true].into_iter().try_for_each( + |drawn| -> Result<(), Box> { + let (mut core, _target) = fresh_core(); + assert!( + !core.state.undo.can_undo(), + "a fresh part has nothing to undo", + ); + + super::begin_plane_sketch(&mut core.state, Plane::Yz, now); + let id = core.state.mode.sketch3_id().ok_or("plane sketch id")?; + if drawn { + core.state + .document + .replace_sketch(id, sketch3_project::embed_sketch(&rectangle_sketch())); + } + core.state.mode = Mode::Idle; + super::commit_pending_sketch_draft(&mut core.state); + super::gc_abandoned_sketch(&mut core.state, Some(id)); + + assert_eq!( + core.state.undo.can_undo(), + drawn, + "a plane sketch becomes a distinct undo step only once you draw in it", + ); + assert_eq!( + core.state.document.sketch3(id).is_some(), + drawn, + "an untouched plane sketch is garbage collected while a drawn one survives", + ); + Ok(()) + }, + )?; + Ok(()) + } + + #[test] + fn backing_out_of_an_untouched_plane_preserves_a_pending_redo() + -> Result<(), Box> { + let (mut core, _target) = fresh_core(); + let now = now16(); + + super::begin_plane_sketch(&mut core.state, Plane::Xy, now); + let front = core + .state + .mode + .sketch3_id() + .ok_or("front plane sketch id")?; + core.state + .document + .replace_sketch(front, sketch3_project::embed_sketch(&rectangle_sketch())); + super::commit_pending_sketch_draft(&mut core.state); + core.state.mode = Mode::Idle; + super::gc_abandoned_sketch(&mut core.state, Some(front)); + + assert!(core.state.undo.undo(&mut core.state.document)); + assert!( + core.state.undo.can_redo(), + "an undone edit is redoable before we go plane-hopping", + ); + + super::begin_plane_sketch(&mut core.state, Plane::Yz, now); + let scratch = core + .state + .mode + .sketch3_id() + .ok_or("scratch plane sketch id")?; + core.state.mode = Mode::Idle; + super::commit_pending_sketch_draft(&mut core.state); + super::gc_abandoned_sketch(&mut core.state, Some(scratch)); + + assert!( + core.state.undo.can_redo(), + "double-clicking a plane and drawing nothing must not shred a pending redo", + ); + Ok(()) + } + + #[test] + fn two_candidate_sketches_arm_reassign_preview_and_commit_a_sweep() + -> Result<(), Box> { + use bone_document::SweepPath; + + let (mut core, mut target) = fresh_core(); + + let circle_id = core.state.document.allocate_sketch(); + core.state + .document + .insert_sketch(circle_id, "Circle".to_owned(), circle_profile_zx()); + let line_id = core.state.document.allocate_sketch(); + core.state + .document + .insert_sketch(line_id, "Line".to_owned(), line_path_xy(40.0)); + + super::apply_feature_tool(&mut core.state, Some(FeatureTool::SweptBossBase)); + assert!( + matches!(core.state.mode, Mode::Sweep(SweepArming::AwaitingSketch(_))), + "two candidate sketches leave the profile ambiguous", + ); + assert!( + super::apply_sweep_sketch_pick(&mut core.state, circle_id), + "clicking the profile sketch arms the sweep", + ); + let Mode::Sweep(SweepArming::Profile { feature, .. }) = &core.state.mode else { + panic!("the first pick arms the profile"); + }; + assert_eq!(feature.profile, circle_id); + assert_eq!( + feature.path, + SweepPath::Sketch(line_id), + "the lone other sketch auto-fills the path", + ); + + assert!( + super::apply_sweep_sketch_pick(&mut core.state, line_id), + "a second pick is not swallowed", + ); + let Mode::Sweep(SweepArming::Profile { feature, .. }) = &core.state.mode else { + panic!("the sweep is still armed after re-picking"); + }; + assert_eq!( + feature.profile, line_id, + "double-clicking a different sketch re-assigns the profile, no dead-end", + ); + assert_eq!( + feature.path, + SweepPath::Sketch(circle_id), + "the path re-auto-fills from the remaining sketch", + ); + + assert!( + super::apply_sweep_sketch_pick(&mut core.state, circle_id), + "re-picking the circle restores it as the profile", + ); + let Mode::Sweep(SweepArming::Profile { feature, .. }) = &core.state.mode else { + panic!("the sweep arms on its profile; got {:?}", core.state.mode); + }; + assert_eq!( + feature.path, + SweepPath::Sketch(line_id), + "the path auto-fills so no broken dropdown is needed", + ); + + super::recompute_model(&mut core.state, RecomputeScope::Full); + super::sync_solid_view(&mut core.state); + assert!( + core.state.solid_view.is_some(), + "the armed sweep renders a tentative preview before it is committed", + ); + + super::apply_sweep_confirm(&mut core.state, Some(shell::ConfirmAction::Accept)); + super::recompute_model(&mut core.state, RecomputeScope::Full); + + let sweep = core + .state + .document + .feature_tree() + .iter() + .find_map(|(feature, node)| matches!(node, FeatureNode::Sweep(_)).then_some(feature)) + .ok_or("the sweep was committed to the document")?; + assert_eq!( + core.state.model.status(sweep), + Some(RebuildStatus::UpToDate), + "the committed sweep rebuilds clean, no red error", + ); + assert!( + core.state.model.bodies().next().is_some(), + "drawing a profile + a path and hitting Sweep then confirm yields a real solid", + ); + + core.state.mode = Mode::Idle; + let _ = core.render_frame(&mut target); + assert!( + core.state + .solid_view + .as_ref() + .is_some_and(|view| !view.faces.triangles().is_empty()), + "the swept solid actually reaches the viewport, not just the model", + ); + Ok(()) + } + + #[test] + fn a_normal_to_curve_datum_evaluates_its_plane_and_renders_it() + -> Result<(), Box> { + use bone_document::{DatumGeometry, Sketch3, Sketch3Entity}; + + let (mut core, _target) = fresh_core(); + + let sketch = Sketch3::on_plane(PrincipalPlane::Xy.basis()); + let (sketch, a) = sketch.with_entity(Sketch3Entity::point(Point3::from_mm(0.0, 0.0, 0.0))); + let (sketch, end) = + sketch.with_entity(Sketch3Entity::point(Point3::from_mm(0.0, 0.0, 40.0))); + let (sketch, _line) = sketch.with_entity(Sketch3Entity::line(a, end, false)); + let path_id = core.state.document.allocate_sketch(); + core.state + .document + .insert_sketch(path_id, "Path".to_owned(), sketch); + super::recompute_model(&mut core.state, RecomputeScope::Full); + + core.state.mode = Mode::enter_sketch3(path_id, Spatial::principal(Plane::Xy)); + core.state.selection = crate::selection::Selection::Entities(vec![end]); + super::apply_feature_tool(&mut core.state, Some(FeatureTool::DatumPlane)); + assert!( + core.state.mode.is_datum(), + "picking the plane tool with an endpoint selected arms a datum; got {:?}", + core.state.mode, + ); + + let armed = super::reference_plane_scene(&core.state) + .ok_or("arming a datum keeps the reference planes on screen")?; + let previews: Vec<_> = armed + .planes + .iter() + .filter(|plane| matches!(plane.label, PlaneLabel::Preview)) + .collect(); + assert_eq!( + previews.len(), + 1, + "the armed datum previews as a plane before the user accepts", + ); + assert!( + previews[0] + .corners + .iter() + .all(|corner| (corner.coords_mm().2 - 40.0).abs() < 1.0e-6), + "the preview plane sits at the endpoint depth", + ); + + super::apply_datum_confirm(&mut core.state, Some(shell::ConfirmAction::Accept)); + super::recompute_model(&mut core.state, RecomputeScope::Full); + + let datum = core + .state + .document + .feature_tree() + .iter() + .find_map(|(feature, node)| matches!(node, FeatureNode::Datum(_)).then_some(feature)) + .ok_or("the datum was committed")?; + assert_eq!( + core.state.model.status(datum), + Some(RebuildStatus::UpToDate), + "the normal-to-curve datum rebuilds clean", + ); + let Some(DatumGeometry::Plane(datum_plane)) = core.state.model.datum(datum) else { + panic!("the datum evaluates to a plane"); + }; + let (x, y, z) = datum_plane.origin().coords_mm(); + assert!( + x.abs() < 1.0e-9 && y.abs() < 1.0e-9 && (z - 40.0).abs() < 1.0e-9, + "the plane sits on the picked path endpoint", + ); + + let scene = super::reference_plane_scene(&core.state) + .ok_or("the committed part keeps its reference planes")?; + let committed: Vec<_> = scene + .planes + .iter() + .filter(|plane| matches!(plane.label, PlaneLabel::Named(_))) + .collect(); + assert_eq!( + committed.len(), + 1, + "the accepted datum plane is handed to the renderer, not dropped", + ); + let plane = committed[0]; + assert!( + plane + .corners + .iter() + .all(|corner| (corner.coords_mm().2 - 40.0).abs() < 1.0e-6), + "the committed plane sits on the path endpoint", + ); + let (nx, ny, nz) = plane.normal.components(); + assert!( + nx.abs() < 1.0e-9 && ny.abs() < 1.0e-9 && (nz.abs() - 1.0).abs() < 1.0e-9, + "the plane faces along the path line it was built normal to", + ); + Ok(()) + } + + fn commit_normal_to_curve_datum( + core: &mut AppCore, + tip: Point3, + select_tip: bool, + ) -> (SketchId, FeatureId, bone_types::SketchEntityId) { + use bone_document::{Sketch3, Sketch3Entity}; + let path = Sketch3::on_plane(PrincipalPlane::Xy.basis()); + let (path, start) = path.with_entity(Sketch3Entity::point(Point3::from_mm(0.0, 0.0, 0.0))); + let (path, tip_entity) = path.with_entity(Sketch3Entity::point(tip)); + let (path, line) = path.with_entity(Sketch3Entity::line(start, tip_entity, false)); + let path_id = core.state.document.allocate_sketch(); + core.state + .document + .insert_sketch(path_id, "Path".to_owned(), path); + super::recompute_model(&mut core.state, RecomputeScope::Full); + + core.state.mode = Mode::enter_sketch3(path_id, Spatial::principal(Plane::Xy)); + let selected = if select_tip { tip_entity } else { start }; + core.state.selection = crate::selection::Selection::Entities(vec![selected]); + super::apply_feature_tool(&mut core.state, Some(FeatureTool::DatumPlane)); + super::apply_datum_confirm(&mut core.state, Some(shell::ConfirmAction::Accept)); + super::recompute_model(&mut core.state, RecomputeScope::Full); + + let Some(datum) = core + .state + .document + .feature_tree() + .iter() + .find_map(|(feature, node)| matches!(node, FeatureNode::Datum(_)).then_some(feature)) + else { + panic!("the normal-to-curve datum was committed"); + }; + (path_id, datum, line) + } + + #[test] + fn sketching_on_a_tilted_datum_plane_draws_in_its_oblique_basis() + -> Result<(), Box> { + use bone_document::DatumGeometry; + + let (mut core, mut target) = fresh_core(); + let (_path_id, datum, _path_line) = + commit_normal_to_curve_datum(&mut core, Point3::from_mm(0.0, 40.0, 40.0), true); + + let Some(DatumGeometry::Plane(datum_plane)) = core.state.model.datum(datum) else { + panic!("the datum evaluates to a plane"); + }; + let (nx, ny, nz) = datum_plane.normal().components(); + let root_half = core::f64::consts::FRAC_1_SQRT_2; + assert!( + nx.abs() < 1.0e-9 + && (ny.abs() - root_half).abs() < 1.0e-9 + && (nz.abs() - root_half).abs() < 1.0e-9, + "the datum is genuinely oblique, not parallel to a principal plane", + ); + + let now = now16(); + core.state.mode = Mode::Idle; + assert!( + super::begin_datum_sketch(&mut core.state, datum, now), + "double-activating the datum plane starts a sketch on it", + ); + assert!( + core.state.mode.is_sketch3(), + "the datum host enters sketch3" + ); + assert_eq!( + core.state.mode.sketch3_plane(), + None, + "a datum host is not one of the three principal planes", + ); + assert_eq!( + core.state.mode.sketch3_source(), + Some(SketchPlaneSource::Datum(datum)), + "the session remembers it is sketching on the datum", + ); + let sketch_basis = core + .state + .mode + .sketch3_basis() + .ok_or("the datum sketch carries a basis")?; + assert_eq!( + sketch_basis.normal().components(), + datum_plane.normal().components(), + "the sketch basis is the datum plane, not a principal fallback", + ); + + let sketch_id = core + .state + .mode + .sketch3_id() + .ok_or("the datum sketch has an id")?; + assert_eq!( + core.state.document.sketch_plane_binding(sketch_id), + Some(SketchPlaneRef::Datum(datum)), + "the new sketch is bound to the datum plane", + ); + let base = core + .state + .document + .sketch3(sketch_id) + .ok_or("the bound sketch exists")?; + let (drawn, dot) = + sketch3_project::embed_point(base, sketch_basis, Point2::from_mm(12.0, 7.0)); + let world = drawn + .point_position(dot) + .ok_or("the placed point has a position")?; + let (ox, oy, oz) = datum_plane.origin().coords_mm(); + let (px, py, pz) = world.coords_mm(); + let normal_offset = (px - ox) * nx + (py - oy) * ny + (pz - oz) * nz; + assert!( + normal_offset.abs() < 1.0e-6, + "a point drawn on the datum sketch lies in the oblique plane, off every principal plane", + ); + let _ = core.render_frame(&mut target); + Ok(()) + } + + #[test] + fn a_profile_pierced_to_a_normal_to_curve_datum_sweeps_along_the_path() + -> Result<(), Box> { + use bone_document::{Sketch3, Sketch3Edit, Sketch3Entity, Sketch3Relation, SweepFeature}; + use bone_types::{Length, Sketch3EntityRef}; + use uom::si::length::millimeter; + + let (mut core, mut target) = fresh_core(); + let (path_id, datum, path_line) = + commit_normal_to_curve_datum(&mut core, Point3::from_mm(0.0, 40.0, 0.0), false); + let path_feature = core + .state + .document + .feature_tree() + .feature_of_sketch(path_id) + .ok_or("path feature")?; + + let profile = Sketch3::new(); + let (profile, center) = + profile.with_entity(Sketch3Entity::point(Point3::from_mm(12.0, 0.0, 0.0))); + let (profile, _circle) = profile.with_entity(Sketch3Entity::circle( + center, + Length::new::(8.0), + UnitVec3::z_axis(), + false, + )); + let (profile, _pierce) = { + let (next, _) = profile.apply(Sketch3Edit::AddRelation(Sketch3Relation::Pierce { + point: center, + curve: Sketch3EntityRef::new(path_feature, path_line), + })); + (next, ()) + }; + let profile_id = core.state.document.allocate_sketch(); + core.state + .document + .insert_sketch(profile_id, "Profile".to_owned(), profile); + core.state + .document + .bind_sketch_to_datum_plane(profile_id, datum)?; + + let _sweep = core + .state + .document + .commit_sweep(SweepFeature::boss(profile_id, SweepPath::Sketch(path_id))); + core.state.mode = Mode::Idle; + super::recompute_model(&mut core.state, RecomputeScope::Full); + + let sweep = core + .state + .document + .feature_tree() + .iter() + .find_map(|(feature, node)| matches!(node, FeatureNode::Sweep(_)).then_some(feature)) + .ok_or("the sweep is committed")?; + assert_eq!( + core.state.model.status(sweep), + Some(RebuildStatus::UpToDate), + "profile on a normal-to-curve datum, pierced to the path, sweeps clean", + ); + assert!( + core.state.model.bodies().next().is_some(), + "the arbitrary-path sweep yields a real solid", + ); + + let _ = core.render_frame(&mut target); + assert!( + core.state + .solid_view + .as_ref() + .is_some_and(|view| !view.faces.triangles().is_empty()), + "the swept solid reaches the viewport", + ); + Ok(()) + } + + #[test] + fn feature_arming_keeps_the_3d_workspace_not_the_flat_grid() { + assert!( + super::show_reference_planes(&Mode::Sweep(SweepArming::AwaitingSketch( + ExtrudeOperation::default() + ))), + "a sweep shows planes + 3D sketches so the profile is not smooshed to 2D", + ); + assert!(super::show_reference_planes(&Mode::Idle)); + assert!(super::show_reference_planes(&Mode::enter_sketch3( + SketchId::default(), + Spatial::principal(Plane::Xy) + ))); + assert!( + !super::show_reference_planes(&Mode::enter_sketch(SketchId::default())), + "a flat 2D face sketch stays 2D, no reference planes", + ); + } + + fn check_solid_golden( + frame: &bone_render::SnapshotFrame, + golden_rel: &str, + ) -> Result<(), Box> { + 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 bytes = encode_png(frame)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, &bytes)?; + return Ok(()); + } + let bytes = std::fs::read(&path)?; + let (golden_extent, golden_rgba) = decode_png(&bytes)?; + assert_eq!(golden_extent, frame.extent(), "golden extent drift"); + let threshold = PixelDiffThreshold::new(16.0 / 255.0); + let report = PixelDiff::compare(frame, &golden_rgba, threshold)?; + assert!( + report.is_clean(), + "rebuild-downstream golden drifted: {} mismatches, worst {:?}", + report.over_threshold(), + report.worst(), + ); + Ok(()) + } + + #[test] + fn rebuild_downstream_scene_matches_golden() -> Result<(), Box> { + 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, + full_recompute(&mut model, &document); + + let body = document + .feature_tree() + .feature_of_extrude(base) + .ok_or("the base extrude resolves to a feature id")?; + let solid = model.body(body).ok_or("the base body is evaluated")?; + let cap = solid + .iter_faces() + .find_map(|candidate| { + matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id()) + }) + .ok_or("the box has a planar top cap")?; + let (face_ref, _basis) = model + .face_for_sketch(cap) + .ok_or("a planar cap yields a face reference and basis")?; + + let face_sketch = document.allocate_sketch(); + document.insert_sketch( + face_sketch, + "Sketch2".to_owned(), + super::sketch3_project::embed_sketch(&rectangle_sketch()), ); - let Some((body, ordinal, solid)) = model.bodies().next() else { - panic!("the model owns a body"); + document.bind_sketch_to_face(face_sketch, face_ref)?; + let face_boss = bone_document::ExtrudeFeature { + operation: bone_document::ExtrudeOperation::Boss(bone_document::MergeResult::Separate), + ..sketch_mode::default_extrude_feature(face_sketch) }; - let Some(face) = solid.iter_faces().next() else { - panic!("the body owns a face"); + let _ = document.commit_extrude(face_boss); + full_recompute(&mut model, &document); + + document.replace_sketch( + base_sketch, + super::sketch3_project::embed_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 combined = super::build_combined_view(&model, super::MeshQuality::Fine) + .ok_or("the rebuilt model lowers to a combined view")?; + let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256)); + let camera = frame_standard_view(combined.aabb, extent, StandardView::Isometric, None)?; + + let ctx = offscreen_context(extent, 3); + let mut renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format()); + let frame = renderer.render_display( + &ctx, + &combined.faces, + &combined.edges, + camera, + &Style::default(), + DisplayMode::ShadedWithEdges, + )?; + check_solid_golden(&frame, "tests/goldens/rebuild_downstream_iso_256.png")?; + Ok(()) + } + + #[test] + fn unresolved_reference_arm_arms_reference_pickers_only() { + let blind = sketch_mode::default_extrude_feature(SketchId::default()); + let check = |kind: EndConditionKind, expected: Option| { + let edit = shell::ExtrudeEdit::EndCondition(kind); + assert_eq!( + super::unresolved_reference_arm(edit, &edit.apply(blind)), + expected, + "{kind:?} arming", + ); }; + [ + EndConditionKind::UpToSurface, + EndConditionKind::UpToVertex, + EndConditionKind::UpToBody, + EndConditionKind::OffsetFromSurface, + ] + .into_iter() + .for_each(|kind| check(kind, Some(shell::ReferenceSlot::Direction1(kind)))); + [ + EndConditionKind::Blind, + EndConditionKind::ThroughAll, + EndConditionKind::MidPlane, + ] + .into_iter() + .for_each(|kind| check(kind, None)); + } + + #[test] + fn body_picks_map_to_index_labels_axes_and_tools() -> Result<(), Box> { + use super::PickedItem; + use crate::sketch_mode::ReferenceKind; + use bone_document::RevolveAxis; + + let (_document, model) = extruded_box_model(); + 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", + ); + + let (body, ordinal, solid) = model.bodies().next().ok_or("the model owns a body")?; + let key = super::BodyKey::new(body, ordinal); + let face = solid.iter_faces().next().ok_or("the body owns a face")?; let label = face.label(); - let pick = Some(PickedItem::BrepFace(super::SolidFaceRef::new( - super::BodyKey::new(body, ordinal), + let face_pick = Some(PickedItem::BrepFace(super::SolidFaceRef::new( + key, face.id(), ))); assert_eq!( - super::captured_reference(&model, ReferenceKind::Face, pick), - Some(shell::CapturedReference::Face(label)), + super::captured_reference(&model, ReferenceKind::Face, face_pick), + Some(shell::CapturedReference::Face(label)), + ); + assert_eq!( + super::captured_reference(&model, ReferenceKind::Body, face_pick), + Some(shell::CapturedReference::Body(body)), + ); + assert_eq!( + super::captured_reference(&model, ReferenceKind::Vertex, face_pick), + None, + "a face pick cannot satisfy a vertex reference", + ); + assert_eq!( + super::picked_sweep_tool(face_pick), + Some(SweepProfileKind::Solid { + tool: BodyRef::new(body, ordinal), + }), + "picking a body's face designates that body as the tool", + ); + assert_eq!( + super::picked_revolve_axis(&model, face_pick), + None, + "a face pick cannot set the revolve axis", ); + + let edge = solid.iter_edges().next().ok_or("the body owns an edge")?; + let edge_label = edge.label(); + let edge_pick = Some(PickedItem::BrepEdge(super::SolidEdgeRef::new( + key, + edge.id(), + ))); assert_eq!( - super::captured_reference(&model, ReferenceKind::Body, pick), - Some(shell::CapturedReference::Body(body)), + super::picked_revolve_axis(&model, edge_pick), + Some(RevolveAxis::Edge(edge_label)), ); assert_eq!( - super::captured_reference(&model, ReferenceKind::Vertex, pick), + super::picked_sweep_tool(edge_pick), None, - "a face pick cannot satisfy a vertex reference", + "an edge pick cannot designate a sweep tool body", ); + Ok(()) } #[test] - fn picked_revolve_axis_maps_an_edge_pick_to_an_edge_axis() { + fn selecting_a_body_face_in_sketch_mode_highlights_its_boundary() + -> Result<(), Box> { use super::PickedItem; - use bone_document::RevolveAxis; - 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, - ); - let Some((feature, ordinal, solid)) = model.bodies().next() else { - panic!("the model owns a body"); - }; - let key = super::BodyKey::new(feature, ordinal); - let Some(edge) = solid.iter_edges().next() else { - panic!("the body owns an edge"); - }; - let label = edge.label(); - assert_eq!( - super::picked_revolve_axis( - &model, - Some(PickedItem::BrepEdge(super::SolidEdgeRef::new( - key, - edge.id() - ))) - ), - Some(RevolveAxis::Edge(label)), - ); - let Some(face) = solid.iter_faces().next() else { - panic!("the body owns a face"); - }; - assert_eq!( - super::picked_revolve_axis( - &model, - Some(PickedItem::BrepFace(super::SolidFaceRef::new( - key, - face.id() - ))) - ), - None, - "a face pick cannot set the revolve axis", + + let (_document, model) = extruded_box_model(); + let basis = Plane::Xy.basis(); + + let (body, ordinal, solid) = model.bodies().next().ok_or("the model owns a body")?; + let key = super::BodyKey::new(body, ordinal); + let face = solid.iter_faces().next().ok_or("the body owns a face")?; + let pick = PickedItem::BrepFace(super::SolidFaceRef::new(key, face.id())); + + let boundary = super::convert_targets(&model, pick, basis); + assert!( + !boundary.is_empty(), + "selecting a body face projects its boundary edges into the sketch highlight", ); + Ok(()) } #[test] @@ -7676,8 +9134,23 @@ mod tests { (camera, bone_render::ViewportRegion::at_origin(extent)) } + fn overlay_of( + sketch: &bone_document::Sketch3, + plane: SketchPlaneBasis, + selection: &[bone_types::SketchEntityId], + ) -> super::Sketch3Overlay { + let (camera, region) = sketch3_overlay_camera(super::sketch3_aabb_view(sketch, plane).aabb); + super::build_sketch3_overlay( + sketch, + selection, + camera, + region, + LinearRgba::new(0.14, 0.24, 0.62, 1.0), + ) + } + #[test] - fn sketch3_overlay_splits_lines_and_builds_ball_points() { + fn sketch3_overlay_splits_lines_tessellates_circles_and_builds_ball_points() { let plane = sketch_mode::Plane::Xy.basis(); let (sketch, start) = sketch3_project::embed_point( &bone_document::Sketch3::new(), @@ -7689,16 +9162,8 @@ mod tests { let (sketch, ctor_end) = sketch3_project::embed_point(&sketch, plane, Point2::from_mm(5.0, 5.0)); let (sketch, _ctor_line) = sketch3_project::embed_line(&sketch, end, ctor_end, true); - let (camera, region) = - sketch3_overlay_camera(super::sketch3_aabb_view(&sketch, plane).aabb); - let overlay = super::build_sketch3_overlay( - &sketch, - &[start], - camera, - region, - LinearRgba::new(0.14, 0.24, 0.62, 1.0), - ); + let overlay = overlay_of(&sketch, plane, &[start]); assert_eq!( overlay.normal_lines.genuine().len(), 1, @@ -7725,35 +9190,24 @@ mod tests { .all(|edge| edge.pick() != PickId::NONE), "lines stay pickable", ); - } - #[test] - fn sketch3_overlay_tessellates_a_circle_into_many_edges() { - let plane = sketch_mode::Plane::Xy.basis(); - let (sketch, center) = sketch3_project::embed_point( + let (circle_sketch, center) = sketch3_project::embed_point( &bone_document::Sketch3::new(), plane, Point2::from_mm(0.0, 0.0), ); - let (sketch, _circle) = sketch.with_entity(bone_document::Sketch3Entity::circle( - center, - Length::new::(5.0), - plane.normal(), - false, - )); - let (camera, region) = - sketch3_overlay_camera(super::sketch3_aabb_view(&sketch, plane).aabb); - let overlay = super::build_sketch3_overlay( - &sketch, - &[], - camera, - region, - LinearRgba::new(0.14, 0.24, 0.62, 1.0), - ); + let (circle_sketch, _circle) = + circle_sketch.with_entity(bone_document::Sketch3Entity::circle( + center, + Length::new::(5.0), + plane.normal(), + false, + )); + let circle_overlay = overlay_of(&circle_sketch, plane, &[]); assert!( - overlay.normal_lines.genuine().len() >= 8, + circle_overlay.normal_lines.genuine().len() >= 8, "the circle renders as a tessellated polyline, got {}", - overlay.normal_lines.genuine().len(), + circle_overlay.normal_lines.genuine().len(), ); } @@ -7838,31 +9292,42 @@ mod tests { } #[test] - fn free_placement_leaves_points_unconstrained_in_space() { - let free = place_two_point_line(sketch3_project::PlaneLock::Free); - assert_eq!( - on_plane_count(&free), - 0, - "a native 3D click pins no point to the active plane", - ); + fn plane_lock_controls_whether_clicks_pin_to_the_plane() { + [ + ( + sketch3_project::PlaneLock::Free, + 0, + "a native 3D click pins no point to the active plane", + ), + ( + sketch3_project::PlaneLock::Locked, + 2, + "a planar click pins both endpoints onto the sketch plane", + ), + ] + .into_iter() + .for_each(|(lock, expected, why)| { + assert_eq!( + on_plane_count(&place_two_point_line(lock)), + expected, + "{why}" + ); + }); } - #[test] - fn locked_placement_pins_each_point_to_the_plane() { - let locked = place_two_point_line(sketch3_project::PlaneLock::Locked); - assert_eq!( - on_plane_count(&locked), - 2, - "a planar click pins both endpoints onto the sketch plane", - ); - } + type CenterRay = ( + Camera3, + ViewportExtent, + ViewportPoint, + (f64, f64, f64), + (f64, f64, f64), + ); - #[test] - fn nearest_point_snap3_grabs_the_point_under_the_cursor_ray() { + fn center_ray(half_mm: f64) -> CenterRay { let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256)); let aabb = Aabb3::from_corners( - Point3::from_mm(-20.0, -20.0, -20.0), - Point3::from_mm(20.0, 20.0, 20.0), + Point3::from_mm(-half_mm, -half_mm, -half_mm), + Point3::from_mm(half_mm, half_mm, half_mm), ); let Ok(camera) = frame_standard_view(aabb, extent, StandardView::Isometric, None) else { panic!("the box aabb frames a camera"); @@ -7873,8 +9338,18 @@ mod tests { let Ok((origin, direction)) = bone_render::world_ray(camera, extent, pixel) else { panic!("the center pixel casts a ray"); }; - let (ox, oy, oz) = origin.coords_mm(); - let (dx, dy, dz) = direction.components(); + ( + camera, + extent, + pixel, + origin.coords_mm(), + direction.components(), + ) + } + + #[test] + fn nearest_snap3_grabs_ray_points_and_the_pierce_reference() { + let (camera, extent, pixel, (ox, oy, oz), (dx, dy, dz)) = center_ray(20.0); let on_ray = Point3::from_mm( dx.mul_add(40.0, ox), dy.mul_add(40.0, oy), @@ -7885,43 +9360,57 @@ mod tests { dy.mul_add(40.0, oy), dz.mul_add(40.0, oz) - 14.0, ); + let tol = Length::new::(5.0); + let (sketch, _) = bone_document::Sketch3::new().with_entity(bone_document::Sketch3Entity::point(off_ray)); let (sketch, on_id) = sketch.with_entity(bone_document::Sketch3Entity::point(on_ray)); - let tol = Length::new::(5.0); assert_eq!( - sketch3_mode::nearest_snap3(&sketch, camera, extent, pixel, tol, None), + sketch3_mode::nearest_snap3(&sketch, camera, extent, pixel, tol, None, None), Some(sketch3_mode::Snap3::Endpoint { point: on_id, - at: on_ray + at: on_ray, }), "the point under the cursor ray snaps, the offset point stays clear", ); assert_eq!( - sketch3_mode::nearest_snap3(&sketch, camera, extent, pixel, tol, Some(on_id)), + sketch3_mode::nearest_snap3(&sketch, camera, extent, pixel, tol, Some(on_id), None), None, "excluding the on-ray point leaves nothing within tolerance", ); + + let empty = bone_document::Sketch3::new(); + assert_eq!( + sketch3_mode::nearest_snap3(&empty, camera, extent, pixel, tol, None, Some(on_ray)), + Some(sketch3_mode::Snap3::Origin { at: on_ray }), + "the plane's pierce point magnetizes even before any sketch geometry exists", + ); + assert_eq!( + sketch3_mode::nearest_snap3(&empty, camera, extent, pixel, tol, None, Some(off_ray)), + None, + "an off-ray pierce point does not snap", + ); + let (with_point, _pt) = empty.with_entity(bone_document::Sketch3Entity::point(on_ray)); + assert!( + matches!( + sketch3_mode::nearest_snap3( + &with_point, + camera, + extent, + pixel, + tol, + None, + Some(on_ray), + ), + Some(sketch3_mode::Snap3::Origin { .. }), + ), + "the pierce reference takes priority over an entity point at the same spot", + ); } #[test] fn nearest_snap3_grabs_line_midpoint_and_edge() { - let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256)); - let aabb = Aabb3::from_corners( - Point3::from_mm(-80.0, -80.0, -80.0), - Point3::from_mm(80.0, 80.0, 80.0), - ); - let Ok(camera) = frame_standard_view(aabb, extent, StandardView::Isometric, None) else { - panic!("the box aabb frames a camera"); - }; - let Ok(pixel) = ViewportPoint::new(128.0, 128.0) else { - panic!("the center pixel is valid"); - }; - let Ok((origin, direction)) = bone_render::world_ray(camera, extent, pixel) else { - panic!("the center pixel casts a ray"); - }; - let (ox, oy, oz) = origin.coords_mm(); - let (dx, dy, dz) = direction.components(); + let (camera, extent, pixel, (ox, oy, oz), (dx, dy, dz)) = center_ray(80.0); let m = ( dx.mul_add(40.0, ox), dy.mul_add(40.0, oy), @@ -7939,7 +9428,7 @@ mod tests { mid_sketch.with_entity(bone_document::Sketch3Entity::point(along(-10.0))); let (mid_sketch, mid_line) = mid_sketch.with_entity(bone_document::Sketch3Entity::line(ma, mb, false)); - match sketch3_mode::nearest_snap3(&mid_sketch, camera, extent, pixel, tol, None) { + match sketch3_mode::nearest_snap3(&mid_sketch, camera, extent, pixel, tol, None, None) { Some(sketch3_mode::Snap3::Midpoint { line, .. }) => assert_eq!(line, mid_line), other => panic!("expected a midpoint snap, got {other:?}"), } @@ -7950,7 +9439,7 @@ mod tests { edge_sketch.with_entity(bone_document::Sketch3Entity::point(along(-70.0))); let (edge_sketch, edge_line) = edge_sketch.with_entity(bone_document::Sketch3Entity::line(ea, eb, false)); - match sketch3_mode::nearest_snap3(&edge_sketch, camera, extent, pixel, tol, None) { + match sketch3_mode::nearest_snap3(&edge_sketch, camera, extent, pixel, tol, None, None) { Some(sketch3_mode::Snap3::OnEdge { entity, .. }) => assert_eq!(entity, edge_line), other => panic!("expected an on-edge snap, got {other:?}"), } @@ -7963,14 +9452,27 @@ mod tests { bone_types::UnitVec3::z_axis(), false, )); - match sketch3_mode::nearest_snap3(&rim_sketch, camera, extent, pixel, tol, None) { + match sketch3_mode::nearest_snap3(&rim_sketch, camera, extent, pixel, tol, None, None) { Some(sketch3_mode::Snap3::OnEdge { entity, .. }) => assert_eq!(entity, circle), other => panic!("expected a circle-rim on-edge snap, got {other:?}"), } } + fn foreground_pixel_count(rgba: &[u8]) -> usize { + let background = [rgba[0], rgba[1], rgba[2]]; + let channel_delta = |a: u8, b: u8| i16::from(a) - i16::from(b); + rgba.chunks_exact(4) + .filter(|pixel| { + channel_delta(pixel[0], background[0]).abs() + + channel_delta(pixel[1], background[1]).abs() + + channel_delta(pixel[2], background[2]).abs() + > 24 + }) + .count() + } + #[test] - fn sketch3_overlay_draws_visible_edge_pixels() { + fn sketch3_overlay_draws_visible_edge_pixels() -> Result<(), Box> { let plane = sketch_mode::Plane::Xy.basis(); let (sketch, a) = sketch3_project::embed_point( &bone_document::Sketch3::new(), @@ -7985,9 +9487,7 @@ mod tests { let aabb = super::sketch3_aabb_view(&sketch, plane).aabb; let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256)); - let Ok(camera) = frame_standard_view(aabb, extent, StandardView::Isometric, None) else { - panic!("the overlay aabb frames a camera"); - }; + let camera = frame_standard_view(aabb, extent, StandardView::Isometric, None)?; let overlay = super::build_sketch3_overlay( &sketch, &[], @@ -7997,36 +9497,25 @@ mod tests { ); let ctx = offscreen_context(extent, 3); let mut renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format()); - let Ok(frame) = renderer.render_display( + let frame = renderer.render_display( &ctx, &overlay.points, &overlay.normal_lines, camera, &Style::default(), DisplayMode::ShadedWithEdges, - ) else { - panic!("the overlay renders"); - }; - let rgba = frame.rgba(); - let background = [rgba[0], rgba[1], rgba[2]]; - let channel_delta = |a: u8, b: u8| i16::from(a) - i16::from(b); - let drawn = rgba - .chunks_exact(4) - .filter(|pixel| { - channel_delta(pixel[0], background[0]).abs() - + channel_delta(pixel[1], background[1]).abs() - + channel_delta(pixel[2], background[2]).abs() - > 24 - }) - .count(); + )?; + let drawn = foreground_pixel_count(frame.rgba()); assert!( drawn > 50, "the sketch3 overlay must draw visible edge pixels, drew {drawn}", ); + Ok(()) } #[test] - fn sketch3_overlay_renders_through_an_offset_region() { + fn sketch3_overlay_renders_through_an_offset_region() -> Result<(), Box> + { let plane = sketch_mode::Plane::Xy.basis(); let (sketch, a) = sketch3_project::embed_point( &bone_document::Sketch3::new(), @@ -8052,11 +9541,7 @@ mod tests { Point3::from_mm(-25.0, -25.0, -25.0), Point3::from_mm(25.0, 25.0, 25.0), ); - let Ok(camera) = - frame_standard_view(fallback, region.extent(), StandardView::Isometric, None) - else { - panic!("the fallback box frames a camera"); - }; + let camera = frame_standard_view(fallback, region.extent(), StandardView::Isometric, None)?; let overlay = super::build_sketch3_overlay( &sketch, &[], @@ -8066,9 +9551,7 @@ mod tests { ); let ctx = offscreen_context(surface, 3); let renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format()); - let Ok(frame_view) = bone_render::SolidFrameView::new(camera, region) else { - panic!("the offset region yields a frame view"); - }; + let frame_view = bone_render::SolidFrameView::new(camera, region)?; ctx.render_passes(|encoder, color, pick, depth| { renderer.encode_passes( encoder, @@ -8083,29 +9566,17 @@ mod tests { }, ); }); - let Ok(frame) = ctx.capture() else { - panic!("the offscreen color reads back"); - }; - let rgba = frame.rgba(); - let background = [rgba[0], rgba[1], rgba[2]]; - let channel_delta = |a: u8, b: u8| i16::from(a) - i16::from(b); - let drawn = rgba - .chunks_exact(4) - .filter(|pixel| { - channel_delta(pixel[0], background[0]).abs() - + channel_delta(pixel[1], background[1]).abs() - + channel_delta(pixel[2], background[2]).abs() - > 24 - }) - .count(); + let frame = ctx.capture()?; + let drawn = foreground_pixel_count(frame.rgba()); assert!( drawn > 50, "3d edges must survive an inset viewport offset, drew {drawn}", ); + Ok(()) } #[test] - fn a_center_click_in_sketch3_lands_on_the_plane() { + fn a_center_click_in_sketch3_lands_on_the_plane() -> Result<(), Box> { let aabb = bone_types::Aabb3::from_corners( Point3::from_mm(-25.0, -25.0, -25.0), Point3::from_mm(25.0, 25.0, 25.0), @@ -8118,33 +9589,29 @@ mod tests { bone_render::ViewportPx::new(600), ), ); - let Ok(camera) = bone_render::frame_standard_view( + let camera = bone_render::frame_standard_view( aabb, region.extent(), bone_types::StandardView::Isometric, None, - ) else { - panic!("isometric framing of a non-degenerate box"); - }; + )?; let cursor = super::WindowPoint::new(320.0 + 400.0, 96.0 + 300.0); - let Some(pixel) = super::viewport_local_point(cursor, region) else { - panic!("a center cursor sits in the region"); - }; + let pixel = super::viewport_local_point(cursor, region) + .ok_or("a center cursor sits in the region")?; let plane = sketch_mode::Plane::Xy.basis(); - let Some(world) = sketch3_mode::ray_plane_point(camera, region.extent(), pixel, plane) - else { - panic!("a center click must intersect the sketch plane"); - }; + let world = sketch3_mode::ray_plane_point(camera, region.extent(), pixel, plane) + .ok_or("a center click must intersect the sketch plane")?; let (x, y, z) = world.coords_mm(); assert!(z.abs() < 1e-6, "the hit lies on the XY plane, got z={z}"); assert!( x.abs() < 5.0 && y.abs() < 5.0, "a center click lands near the plane origin, got ({x}, {y})", ); + Ok(()) } #[test] - fn reattach_binds_a_sketch_to_a_picked_cap_face() { + fn reattach_binds_a_sketch_to_a_picked_cap_face() -> Result<(), Box> { 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)); @@ -8155,20 +9622,20 @@ mod tests { 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 body = document + .feature_tree() + .feature_of_extrude(base) + .ok_or("the base extrude resolves to a feature id")?; + let solid = model.body(body).ok_or("the base body is evaluated")?; + let face = solid + .iter_faces() + .find_map(|candidate| { + matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id()) + }) + .ok_or("the box has a planar cap")?; + let face_ref = model + .face_ref_any(face) + .ok_or("a picked cap yields a face reference")?; let new_sketch = document.allocate_sketch(); document.insert_sketch( new_sketch, @@ -8179,10 +9646,12 @@ mod tests { document.bind_sketch_to_face(new_sketch, face_ref).is_ok(), "reattaching a sketch to a planar cap binds cleanly", ); + Ok(()) } #[test] - fn compute_whats_wrong_is_empty_for_a_healthy_document() { + fn compute_whats_wrong_is_empty_for_a_healthy_document() + -> Result<(), Box> { 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(); @@ -8197,13 +9666,13 @@ mod tests { super::compute_whats_wrong(&model, &document, &strings).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"); - }; + let feature = document + .feature_tree() + .feature_of_extrude(extrude) + .ok_or("the extrude resolves to a feature id")?; + document.rename_extrude(extrude, "Boss")?; assert_eq!(super::feature_label_text(&document, feature), "Boss"); + Ok(()) } fn layout_rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { @@ -8214,39 +9683,31 @@ mod tests { } #[test] - fn solid_viewport_region_offsets_inside_the_surface() { + fn solid_viewport_region_offsets_clamps_and_rejects_degenerate() + -> Result<(), Box> { 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"); - }; + let inset = super::solid_viewport_region(layout_rect(320.0, 96.0, 800.0, 600.0), surface) + .ok_or("an inset viewport yields a region")?; assert_eq!( - region.scissor(), + inset.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(); + let small = ViewportExtent::new(ViewportPx::new(640), ViewportPx::new(480)); + let clamped = super::solid_viewport_region(layout_rect(600.0, 400.0, 400.0, 400.0), small) + .ok_or("a partly off-surface viewport still yields a clamped region")?; + let (x, y, w, h) = clamped.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()); + assert!( + super::solid_viewport_region(layout_rect(0.0, 0.0, 0.0, 0.0), surface).is_none(), + "a degenerate viewport yields no region", + ); + Ok(()) } #[test] @@ -8269,12 +9730,11 @@ mod tests { } #[test] - fn whats_wrong_classifies_failures_and_withholds_reattach_from_downstream() { - let Some(upstream) = + fn whats_wrong_classifies_status_and_names_extrude_errors() + -> Result<(), Box> { + let upstream = super::whats_wrong_kind(RebuildStatus::Error(RebuildError::UpstreamUnresolved)) - else { - panic!("an upstream failure is reported"); - }; + .ok_or("an upstream failure is reported")?; assert_eq!(upstream.message, strings::WHATS_WRONG_UPSTREAM); assert!(upstream.is_error); assert!( @@ -8282,19 +9742,14 @@ mod tests { "a downstream feature blames the upstream, it never offers its own reattach", ); - let Some(non_planar) = + let non_planar = super::whats_wrong_kind(RebuildStatus::Error(RebuildError::NonPlanarSketchTarget)) - else { - panic!("a non-planar target is reported"); - }; + .ok_or("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 whats_wrong_names_the_failure_reason_not_a_generic_build_error() { let strings = strings::make_strings(bone_ui::strings::Locale::EnUs); let line = |error: ExtrudeError| crate::status_badge::extrude_panel_line(&error, &strings); assert_eq!( @@ -8322,25 +9777,27 @@ mod tests { line(ExtrudeError::Kernel(BrepError::RevolveProfileCrossesAxis)), LabelText::Key(strings::EXTRUDE_PANEL_REVOLVE_CROSSES), ); + Ok(()) } #[test] - fn viewport_local_point_centers_the_inset_viewport_not_the_window() { + fn viewport_local_point_centers_the_inset_viewport_not_the_window() + -> Result<(), Box> { 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"); - }; + let local = super::viewport_local_point(center, region) + .ok_or("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(), ); + Ok(()) } #[test] @@ -8356,58 +9813,58 @@ mod tests { ); } - #[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())); + fn sketch_session_of(mode: Mode) -> SketchSession { + match mode { + Mode::Sketch { session, .. } => *session, + other => panic!("expected sketch mode, got {other:?}"), + } } - #[test] - fn plane_pick_from_idle_with_no_sketch_for_plane_stays_idle() { - let frame = shell::ShellFrame { - plane_picked: Some(Plane::Yz), + fn tool_frame(tool: SketchTool) -> shell::ShellFrame { + shell::ShellFrame { + activated_tool: Some(tool), ..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), + fn exit_frame() -> shell::ShellFrame { + shell::ShellFrame { + exit_sketch: true, ..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, + fn next_mode_2d_transitions_cover_exit_escape_and_tool_arming() { + let plane_frame = shell::ShellFrame { + plane_picked: Some(Plane::Xy), ..empty_frame() }; - assert_eq!(next_mode(prev, &frame, false, &xy_only()), Mode::Idle); - } + assert_eq!( + next_mode(Mode::Idle, &plane_frame, false), + Mode::Idle, + "plane entry is owned by begin_plane_sketch at the app layer, not next_mode", + ); + let sketching = Mode::enter_sketch(SketchId::default()); + assert_eq!(next_mode(sketching.clone(), &plane_frame, false), sketching); - #[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 + next_mode(sketching.clone(), &exit_frame(), false), + Mode::Idle, + "ribbon exit returns idle", + ); + assert_eq!( + next_mode(sketching, &empty_frame(), true), + Mode::Idle, + "the exit action returns idle", + ); + assert_eq!( + next_mode(Mode::Idle, &tool_frame(SketchTool::Line), false), + Mode::Idle, + "a tool in idle does not promote to sketch", ); - } - #[test] - fn escape_with_pending_clears_pending_keeps_sketch_and_tool() { - let prev = Mode::Sketch { + let pending = Mode::Sketch { sketch_id: SketchId::default(), session: Box::new(SketchSession { tool: Some(SketchTool::Line), @@ -8417,12 +9874,111 @@ mod tests { ..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); + let after_escape = sketch_session_of(next_mode(pending.clone(), &empty_frame(), true)); + assert_eq!( + after_escape.tool, + Some(SketchTool::Line), + "escape keeps the tool" + ); + assert_eq!( + after_escape.pending, None, + "escape clears the pending chain" + ); + assert_eq!( + next_mode(pending, &exit_frame(), false), + Mode::Idle, + "ribbon exit overrides a pending chain", + ); + + let armed = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line); + let disarmed = sketch_session_of(next_mode(armed.clone(), &empty_frame(), true)); + assert_eq!( + disarmed.tool, None, + "escape with no pending disarms the armed tool" + ); + assert_eq!(disarmed.pending, None); + assert_eq!( + next_mode(armed.clone(), &exit_frame(), false), + Mode::Idle, + "the exit action wins over an armed tool", + ); + assert_eq!( + sketch_session_of(next_mode( + armed.clone(), + &tool_frame(SketchTool::Line), + false + )) + .tool, + None, + "clicking the active tool disarms it", + ); + assert_eq!( + sketch_session_of(next_mode(armed, &tool_frame(SketchTool::Point), false)).tool, + Some(SketchTool::Point), + "clicking a different tool swaps", + ); + assert_eq!( + sketch_session_of(next_mode( + Mode::enter_sketch(SketchId::default()), + &tool_frame(SketchTool::Line), + false, + )) + .tool, + Some(SketchTool::Line), + "a tool click in a bare sketch arms the session", + ); + } + + #[test] + fn next_mode_3d_transitions_cover_tool_arming_and_escape() { + let base = Mode::enter_sketch3( + SketchId::default(), + Spatial::principal(sketch_mode::Plane::Xy), + ); + let armed = next_mode(base, &tool_frame(SketchTool::Line), false); + assert_eq!(armed.sketch3_tool(), Some(SketchTool::Line)); + assert_eq!( + next_mode(armed, &tool_frame(SketchTool::Line), false).sketch3_tool(), + None, + "clicking the active 3D tool disarms it", + ); + + let multi = Mode::enter_sketch3( + SketchId::default(), + Spatial::principal(sketch_mode::Plane::Xy), + ) + .arm_tool(SketchTool::Line); + assert_eq!( + next_mode(multi, &tool_frame(SketchTool::Parallelogram), false).sketch3_tool(), + Some(SketchTool::Parallelogram), + "every 2D tool now arms in the 3D sketch", + ); + + let after_first_mode = next_mode(armed_3d_line_with_pending(), &empty_frame(), true); + let after_first = sketch_session_of(after_first_mode.clone()); + assert!(after_first.spatial.is_some(), "escape keeps the 3D sketch"); + assert_eq!(after_first.tool, Some(SketchTool::Line)); + assert_eq!( + after_first.pending, None, + "escape ends the chain, keeps the tool" + ); + let after_second = sketch_session_of(next_mode(after_first_mode, &empty_frame(), true)); + assert!( + after_second.spatial.is_some(), + "disarming keeps the 3D sketch" + ); + assert_eq!( + after_second.tool, None, + "the second escape disarms the tool" + ); + + let cleared = sketch_session_of(armed_3d_line_with_pending().clear_pending()); + assert!(cleared.spatial.is_some(), "clearing keeps the 3D sketch"); + assert_eq!(cleared.tool, Some(SketchTool::Line)); + assert_eq!( + cleared.pending, None, + "a right-click clears the pending, keeps the tool" + ); } fn far_camera() -> Camera2 { @@ -8434,91 +9990,97 @@ mod tests { } #[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 { + fn build_preview_covers_idle_pending_ghosts_and_drag() { + let camera = far_camera(); + let empty_doc = Document::new(DocumentId::default(), "doc".to_owned()); + let mk_mode = |sketch_id, tool, pending| Mode::Sketch { sketch_id, session: Box::new(SketchSession { - tool: Some(SketchTool::Line), - pending: Some(Pending::First(ClickAnchor::Position(anchor))), + tool: Some(tool), + pending: Some(pending), ..SketchSession::default() }), }; + + assert!( + build_preview( + &Mode::Idle, + &empty_doc, + Some(Point2::from_mm(1.0, 1.0)), + &camera + ) + .is_empty(), + "idle previews nothing", + ); + assert!( + build_preview( + &Mode::enter_sketch(SketchId::default()), + &empty_doc, + Some(Point2::from_mm(0.0, 0.0)), + &camera, + ) + .is_empty(), + "a sketch with no armed tool previews nothing", + ); + + let (document, sketch_id) = initial_document(&Sketch::new(Plane::Xy.basis())); + let anchor = Point2::from_mm(2.0, 3.0); let cursor = Point2::from_mm(5.0, 7.0); - let preview = build_preview(&mode, &document, Some(cursor), &far_camera()); + let preview = build_preview( + &mk_mode( + sketch_id, + SketchTool::Line, + Pending::First(ClickAnchor::Position(anchor)), + ), + &document, + Some(cursor), + &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]); + let preview = build_preview( + &mk_mode( + sketch_id, + SketchTool::Line, + Pending::First(ClickAnchor::Endpoint(endpoint)), + ), + &document, + Some(cursor), + &camera, + ); + assert_eq!( + preview.anchors, + vec![target], + "endpoint pending resolves via document" + ); 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 (document, sketch_id) = initial_document(&Sketch::new(Plane::Xy.basis())); 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]); + let preview = build_preview( + &mk_mode( + sketch_id, + SketchTool::Line, + Pending::First(ClickAnchor::Position(anchor)), + ), + &document, + None, + &camera, + ); + assert_eq!( + preview.anchors, + vec![anchor], + "anchor kept when cursor outside viewport" + ); 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 { + let drag_mode = Mode::Sketch { sketch_id: SketchId::default(), session: Box::new(SketchSession { tool: Some(SketchTool::Line), @@ -8537,156 +10099,94 @@ mod tests { ..SketchSession::default() }), }; - let preview = build_preview( - &mode, - &document, - Some(Point2::from_mm(1.0, 1.0)), - &far_camera(), + assert!( + build_preview( + &drag_mode, + &empty_doc, + Some(Point2::from_mm(1.0, 1.0)), + &camera + ) + .is_empty(), + "an in-flight drag previews nothing", ); - 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 (document, sketch_id) = initial_document(&Sketch::new(Plane::Xy.basis())); 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()); + let preview = build_preview( + &mk_mode( + sketch_id, + SketchTool::Circle, + Pending::First(ClickAnchor::Position(center)), + ), + &document, + Some(Point2::from_mm(3.0, 4.0)), + &camera, + ); assert_eq!(preview.anchors, vec![center]); - assert_eq!(preview.circles.len(), 1); + assert_eq!(preview.circles.len(), 1, "circle tool emits a ghost circle"); let r = preview.circles[0].radius.get::(); assert!((r - 5.0).abs() < 1e-9, "r={r}"); - } - #[test] - fn build_preview_corner_rectangle_emits_four_segments() { - let sketch = Sketch::new(Plane::Xy.basis()); - let (document, sketch_id) = initial_document(&sketch); + let (document, sketch_id) = initial_document(&Sketch::new(Plane::Xy.basis())); 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()); + let preview = build_preview( + &mk_mode( + sketch_id, + SketchTool::CornerRectangle, + Pending::First(ClickAnchor::Position(corner)), + ), + &document, + Some(Point2::from_mm(5.0, 3.0)), + &camera, + ); assert_eq!(preview.anchors, vec![corner]); - assert_eq!(preview.segments.len(), 4); - } + assert_eq!( + preview.segments.len(), + 4, + "corner rectangle emits four segments" + ); - #[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()); + let preview = build_preview( + &mk_mode( + sketch_id, + SketchTool::TangentArc, + Pending::First(ClickAnchor::Endpoint(b)), + ), + &document, + Some(Point2::from_mm(10.0, 6.0)), + &camera, + ); assert_eq!(preview.anchors.len(), 1, "start anchor visible"); - assert_eq!(preview.arcs.len(), 1, "ghost arc emitted"); - } + assert_eq!( + preview.arcs.len(), + 1, + "tangent arc ghost emitted after endpoint click" + ); - #[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 (document, sketch_id) = initial_document(&Sketch::new(Plane::Xy.basis())); 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_a_3d_tool_arms_then_disarms_it() { - let prev = Mode::enter_sketch3(SketchId::default(), sketch_mode::Plane::Xy); - let frame = shell::ShellFrame { - activated_tool: Some(SketchTool::Line), - ..empty_frame() - }; - let armed = next_mode(prev, &frame, false, &xy_only()); - assert_eq!(armed.sketch3_tool(), Some(SketchTool::Line)); - - let toggled = next_mode(armed, &frame, false, &xy_only()); - assert_eq!( - toggled.sketch3_tool(), - None, - "clicking the active 3D tool disarms it", + let preview = build_preview( + &mk_mode( + sketch_id, + SketchTool::CenterpointArc, + Pending::Second(ClickAnchor::Position(center), ClickAnchor::Position(start)), + ), + &document, + Some(Point2::from_mm(0.0, 5.0)), + &camera, ); - } - - #[test] - fn clicking_a_multi_click_tool_in_3d_arms_it() { - let prev = Mode::enter_sketch3(SketchId::default(), sketch_mode::Plane::Xy) - .arm_tool(SketchTool::Line); - let frame = shell::ShellFrame { - activated_tool: Some(SketchTool::Parallelogram), - ..empty_frame() - }; - let next = next_mode(prev, &frame, false, &xy_only()); assert_eq!( - next.sketch3_tool(), - Some(SketchTool::Parallelogram), - "every 2D tool now arms in the 3D sketch", + preview.arcs.len(), + 1, + "centerpoint arc ghost after two clicks" ); + assert_eq!(preview.anchors.len(), 2); } fn armed_3d_line_with_pending() -> Mode { @@ -8697,128 +10197,12 @@ mod tests { pending: Some(sketch_mode::Pending::First( sketch_mode::ClickAnchor::Position(Point2::origin()), )), - spatial: Some(sketch_mode::Spatial { - plane: sketch_mode::Plane::Xy, - }), + spatial: Some(sketch_mode::Spatial::principal(sketch_mode::Plane::Xy)), ..sketch_mode::SketchSession::default() }), } } - #[test] - fn escape_with_pending_3d_clears_chain_keeps_tool() { - let next = next_mode( - armed_3d_line_with_pending(), - &empty_frame(), - true, - &xy_only(), - ); - let Mode::Sketch { session, .. } = next else { - panic!("escape with a 3D pending must keep the 3D sketch"); - }; - assert!(session.spatial.is_some(), "escape keeps the 3D sketch"); - assert_eq!(session.tool, Some(SketchTool::Line)); - assert_eq!( - session.pending, None, - "escape ends the chain, keeps the tool" - ); - } - - #[test] - fn second_escape_in_3d_disarms_the_tool() { - let after_first = next_mode( - armed_3d_line_with_pending(), - &empty_frame(), - true, - &xy_only(), - ); - let after_second = next_mode(after_first, &empty_frame(), true, &xy_only()); - let Mode::Sketch { session, .. } = after_second else { - panic!("disarming a 3D tool keeps the 3D sketch"); - }; - assert!(session.spatial.is_some(), "disarming keeps the 3D sketch"); - assert_eq!(session.tool, None, "the second escape disarms the tool"); - } - - #[test] - fn right_click_in_3d_clears_pending_keeps_tool() { - let cleared = armed_3d_line_with_pending().clear_pending(); - let Mode::Sketch { session, .. } = cleared else { - panic!("clearing a 3D pending must keep the 3D sketch"); - }; - assert!(session.spatial.is_some(), "clearing keeps the 3D sketch"); - assert_eq!(session.tool, Some(SketchTool::Line)); - assert_eq!(session.pending, 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()); @@ -8831,62 +10215,45 @@ mod tests { (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" - ); - } - + #[test] + fn apply_sketch_rename_into_writes_records_or_skips_by_label() { + [ + ( + "Profile", + "Profile", + 1usize, + "successful rename records one undo snapshot", + ), + ( + " ", + "Sketch1", + 0, + "an all-whitespace label is dropped without undo", + ), + ( + " Sketch1 ", + "Sketch1", + 0, + "a trimmed-equal rename must not record undo", + ), + ] + .into_iter() + .for_each(|(label, expected, undo_len, why)| { + 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: label.to_owned(), + }, + ); + assert_eq!(document.sketch_label(id), Some(expected), "{why}"); + assert_eq!(undo.past_len(), undo_len, "{why}"); + }); + } + fn extrude_node_count(document: &Document) -> usize { document .feature_tree() @@ -8896,22 +10263,19 @@ mod tests { } #[test] - fn commit_armed_extrude_on_accept_adds_node_and_records_undo() { + fn commit_armed_extrude_accepts_ignores_and_edits_in_place() + -> Result<(), Box> { 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, + &Mode::Extrude(ExtrudeArming::profile(sketch)), Some(shell::ConfirmAction::Accept), ); - assert_eq!(extrude_node_count(&document), 1); + assert_eq!(extrude_node_count(&document), 1, "accept adds a node"); 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( @@ -8926,83 +10290,85 @@ mod tests { &Mode::Idle, Some(shell::ConfirmAction::Accept), ); - assert_eq!(extrude_node_count(&document), 0); + assert_eq!( + extrude_node_count(&document), + 0, + "cancel and a non-extrude mode commit nothing" + ); 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"); - }; + document.rename_extrude(id, "Boss")?; 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, + &Mode::Extrude(ExtrudeArming::edit( + id, + &sketch_mode::default_extrude_feature(sketch), + )), Some(shell::ConfirmAction::Accept), ); assert_eq!(extrude_node_count(&document), 1, "editing reuses the node"); assert_eq!(document.extrude_label(id), Some("Boss")); + Ok(()) } #[test] - fn apply_feature_command_suppresses_and_records_undo() { + fn apply_feature_command_suppresses_deletes_and_rolls_back() + -> Result<(), Box> { 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 feature = document + .feature_tree() + .feature_of_extrude(extrude) + .ok_or("the committed extrude has a feature id")?; let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); - let changed = apply_feature_command_into( + assert!(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( + assert!(apply_feature_command_into( &mut document, &mut undo, shell::FeatureCommand::Delete(shell::FeatureTarget::Extrude(extrude)), + )); + assert_eq!( + document.extrude(extrude), + None, + "delete removes the 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 feature = document + .feature_tree() + .feature_of_extrude(extrude) + .ok_or("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)); + assert!( + document.is_rolled_back(feature), + "rollback marks the feature" + ); + Ok(()) } #[test] @@ -9027,14 +10393,14 @@ mod tests { } #[test] - fn active_solid_feature_tracks_mode_then_falls_back_to_committed() { + fn active_solid_feature_tracks_arming_committed_and_framed() { 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, + None ), Some(PreviewFeature::Extrude(armed)), "an armed profile previews its own feature", @@ -9049,68 +10415,25 @@ mod tests { None, "idle with no committed extrude shows no solid", ); - let _ = document.commit_extrude(armed); - assert_eq!( - active_solid_feature(&Mode::Idle, &document, None), - Some(PreviewFeature::Extrude(armed)), - "idle falls back to the committed extrude", - ); - } - - #[test] - fn revolve_live_preview_evaluates_to_a_solid() { - let sketch = Sketch::new(Plane::Xy.basis()); - let (sketch, a0) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); - let (sketch, a1) = tools::add_point(sketch, Point2::from_mm(0.0, 10.0)); - let (sketch, _axis) = tools::add_line(sketch, a0, a1, true); - let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(2.0, 0.0)); - let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(6.0, 0.0)); - let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(6.0, 8.0)); - let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(2.0, 8.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); - let (document, id) = super::initial_document(&sketch); - let Some(axis) = document - .sketch3(id) - .and_then(bone_document::Sketch3::axis_candidate) - else { - panic!("the construction line is the revolve axis candidate"); - }; - let feature = super::PreviewFeature::Revolve(sketch_mode::revolve_feature( - id, - RevolveAxis::SketchLine(axis), - ExtrudeOperation::default(), - )); - let Some(preview) = super::compute_extrude_preview(&document, &[], &feature) else { - panic!("an armed revolve previews an evaluated solid"); - }; - assert!( - preview.solid().is_some(), - "an offset rectangle about a centerline previews a revolved tube live, before commit", - ); - } - - #[test] - fn revolve_arming_previews_its_own_feature_live() { - let (document, sketch) = doc_with_default_sketch(); - let mode = Mode::Revolve(sketch_mode::RevolveArming::armed( + let revolve_mode = Mode::Revolve(sketch_mode::RevolveArming::armed( sketch, RevolveAxis::SketchLine(bone_types::SketchEntityId::default()), bone_document::ExtrudeOperation::default(), )); assert!( matches!( - active_solid_feature(&mode, &document, None), + active_solid_feature(&revolve_mode, &document, None), Some(PreviewFeature::Revolve(_)), ), "an armed revolve drives a live solid preview, not a blank viewport", ); - } + let _ = document.commit_extrude(armed); + assert_eq!( + active_solid_feature(&Mode::Idle, &document, None), + Some(PreviewFeature::Extrude(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; @@ -9135,6 +10458,39 @@ mod tests { ); } + #[test] + fn revolve_live_preview_evaluates_to_a_solid() -> Result<(), Box> { + let sketch = Sketch::new(Plane::Xy.basis()); + let (sketch, a0) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); + let (sketch, a1) = tools::add_point(sketch, Point2::from_mm(0.0, 10.0)); + let (sketch, _axis) = tools::add_line(sketch, a0, a1, true); + let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(2.0, 0.0)); + let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(6.0, 0.0)); + let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(6.0, 8.0)); + let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(2.0, 8.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); + let (document, id) = super::initial_document(&sketch); + let axis = document + .sketch3(id) + .and_then(bone_document::Sketch3::axis_candidate) + .ok_or("the construction line is the revolve axis candidate")?; + let feature = super::PreviewFeature::Revolve(sketch_mode::revolve_feature( + id, + RevolveAxis::SketchLine(axis), + ExtrudeOperation::default(), + )); + let preview = super::compute_extrude_preview(&document, &[], &feature) + .ok_or("an armed revolve previews an evaluated solid")?; + assert!( + preview.solid().is_some(), + "an offset rectangle about a centerline previews a revolved tube live, before commit", + ); + Ok(()) + } + #[test] fn apply_extrude_rename_into_writes_label_and_records_undo() { let (mut document, sketch) = doc_with_default_sketch(); @@ -9153,143 +10509,114 @@ mod tests { } #[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() { + fn next_mode_sketch_activation_and_extrude_exit() { let sketch_id = SketchId::default(); - let frame = shell::ShellFrame { + let activate = shell::ShellFrame { sketch_activated: Some(sketch_id), ..empty_frame() }; + assert_eq!( + next_mode(Mode::Idle, &activate, false), + Mode::enter_sketch(sketch_id), + "activating a sketch from idle enters it", + ); assert_eq!( next_mode( Mode::Extrude(ExtrudeArming::AwaitingSketch(ExtrudeOperation::default())), - &frame, + &activate, false, - &xy_only(), ), Mode::Extrude(ExtrudeArming::profile(sketch_id)), + "a sketch pick while arming sets the extrude profile", ); assert_eq!( next_mode( Mode::Extrude(ExtrudeArming::profile(sketch_id)), - &frame, - false, - &xy_only(), + &activate, + false ), Mode::Extrude(ExtrudeArming::profile(sketch_id)), "a pick while armed re-targets the profile, never drops into sketch editing", ); - } + let in_sketch = Mode::enter_sketch(SketchId::default()); + assert_eq!( + next_mode(in_sketch.clone(), &activate, false), + in_sketch, + "activating a sketch while already sketching is ignored", + ); - #[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); + let awaiting = Mode::Extrude(ExtrudeArming::AwaitingSketch(ExtrudeOperation::default())); + assert_eq!( + next_mode(awaiting.clone(), &empty_frame(), true), + Mode::Idle, + "escape exits an awaiting extrude", + ); + assert_eq!( + next_mode( + Mode::Extrude(ExtrudeArming::profile(SketchId::default())), + &empty_frame(), + true, + ), + Mode::Idle, + "escape exits a profiled extrude", + ); + assert_eq!( + cancel_pending_or_exit(awaiting), + Mode::Idle, + "cancel drops extrude arming", + ); } #[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() + fn mode_scopes_and_escape_hotkey_dispatch() { + let collect = |mode: &Mode| -> Vec { + scopes_for_mode(mode).innermost_first().copied().collect() }; - 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 idle = collect(&Mode::Idle); + assert!( + !idle.contains(&HotkeyScope::Sketch), + "idle omits the sketch scope" ); - let in_idle = scopes_for_mode(&Mode::Idle); - assert_eq!(table.dispatch(chord, &in_idle), None); - } + assert!(idle.contains(&HotkeyScope::Global)); - #[test] - fn extrude_scope_isolates_escape_from_sketch_tools() { - let extrude = scopes_for_mode(&Mode::Extrude(ExtrudeArming::AwaitingSketch( - ExtrudeOperation::default(), - ))); - let in_extrude: Vec<_> = extrude.innermost_first().copied().collect(); + let in_sketch = collect(&Mode::enter_sketch(SketchId::default())); + assert!(in_sketch.contains(&HotkeyScope::Sketch)); + assert!(in_sketch.contains(&HotkeyScope::Global)); + assert!(!in_sketch.contains(&HotkeyScope::Extrude)); + + let awaiting = Mode::Extrude(ExtrudeArming::AwaitingSketch(ExtrudeOperation::default())); + let in_extrude = collect(&awaiting); 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( - ExtrudeOperation::default(), - ))); assert_eq!( - table.dispatch(chord, &in_extrude), - Some(sketch_mode::ESCAPE_ACTION) + table.dispatch( + chord, + &scopes_for_mode(&Mode::enter_sketch(SketchId::default())) + ), + Some(sketch_mode::ESCAPE_ACTION), + "escape exits under the sketch scope", ); - } - - #[test] - fn escape_exits_extrude_to_idle() { - let frame = empty_frame(); - let awaiting = Mode::Extrude(ExtrudeArming::AwaitingSketch(ExtrudeOperation::default())); - 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( - ExtrudeOperation::default() - ))), - Mode::Idle + table.dispatch(chord, &scopes_for_mode(&Mode::Idle)), + None, + "escape is unbound in idle", + ); + assert_eq!( + table.dispatch(chord, &scopes_for_mode(&awaiting)), + Some(sketch_mode::ESCAPE_ACTION), + "escape exits under the extrude scope", ); } #[test] - fn dim_conflict_pending_returns_relation_when_set() { + fn dim_conflict_pending_tracks_the_conflicting_relation() { let relation = Sketch3Relation::Distance { a: bone_types::SketchEntityId::default(), b: bone_types::SketchEntityId::default(), @@ -9298,11 +10625,11 @@ mod tests { }; let mode = Mode::enter_sketch(SketchId::default()).start_dim_conflict(relation); assert_eq!(dim_conflict_pending(&mode), Some(relation)); - } - - #[test] - fn dim_conflict_pending_returns_none_in_idle() { - assert_eq!(dim_conflict_pending(&Mode::Idle), None); + assert_eq!( + dim_conflict_pending(&Mode::Idle), + None, + "idle has no pending conflict" + ); } fn horizontal_line_fixture() -> ( @@ -9321,7 +10648,8 @@ mod tests { } #[test] - fn drag_translates_endpoint_and_preserves_horizontal() { + fn drag_translates_endpoint_and_preserves_horizontal() -> Result<(), Box> + { use bone_document::{Sketch3, Sketch3Relation}; let plane = SketchPlaneBasis::xy(); let (s, a) = @@ -9333,17 +10661,13 @@ mod tests { axis: plane.x_axis(), }); let (s, _) = s.with_relation(Sketch3Relation::Fix(a)); - let Some(pins) = DragPins::from_sketch3_entity(&s, b) else { - panic!("b is a point"); - }; + let pins = DragPins::from_sketch3_entity(&s, b).ok_or("b is a point")?; let targets = pins.to_targets( plane, Point2::from_mm(10.0, 0.0), Point2::from_mm(13.0, 0.0), ); - let Ok(next) = s.solve_with_drag_pins(&targets, BudgetCeiling::FRAME_16MS) else { - panic!("solve_with_drag_pins converges on a horizontal line"); - }; + let next = s.solve_with_drag_pins(&targets, BudgetCeiling::FRAME_16MS)?; let (Some(b_at), Some(a_at)) = (next.point_position(b), next.point_position(a)) else { panic!("a and b stay points"); }; @@ -9355,35 +10679,40 @@ mod tests { ax.abs() < 1e-9 && ay.abs() < 1e-9 && az.abs() < 1e-9, "fixed a stays put" ); + Ok(()) } #[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(); + fn mirror_axis_reflects_and_classifies_points() { + let x_axis = MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0)); + let (rx, ry) = x_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"); - } + let diagonal = + MirrorAxis::from_points(Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 1.0)); + let (dx, dy) = diagonal.reflect(Point2::from_mm(2.0, 0.0)).coords_mm(); + assert!((dx - 0.0).abs() < 1e-9, "x reflects to y on y=x diagonal"); + assert!((dy - 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)); + let degenerate = + MirrorAxis::from_points(Point2::from_mm(1.0, 1.0), Point2::from_mm(1.0, 1.0)); assert!( - axis.is_degenerate(), + degenerate.is_degenerate(), "coincident endpoints must be degenerate" ); + + assert!(x_axis.is_on_axis(Point2::from_mm(2.0, 0.0))); + assert!(x_axis.is_on_axis(Point2::from_mm(-5.0, 0.0))); + assert!( + !x_axis.is_on_axis(Point2::from_mm(2.0, 0.5)), + "off-axis point is not on axis" + ); } #[test] - fn mirror_targets_creates_reflected_circle_with_symmetric_relations() { + fn mirror_targets_creates_reflected_circle_with_symmetric_relations() + -> Result<(), Box> { 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) = @@ -9391,10 +10720,7 @@ mod tests { 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 mirrored = mirror_targets(sketch.clone(), &source_ids, axis_line, &axis_geom)?; let new_circles: Vec<_> = mirrored .entities() .iter() @@ -9413,9 +10739,7 @@ mod tests { 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"); - }; + let (cx, cy) = new_center_pos.ok_or("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() @@ -9428,75 +10752,81 @@ mod tests { symmetric_count >= 1, "mirror must emit at least one Symmetric relation tied to the axis", ); + Ok(()) } #[test] - fn mirror_copies_horizontal_relation_to_mirrored_line() { + fn mirror_copies_relations_referencing_source_and_axis_lines() + -> Result<(), Box> { 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_and_line = || { + 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, p0) = tools::add_point(sketch, Point2::from_mm(-2.0, 3.0)); + let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(2.0, 3.0)); + let (sketch, line) = tools::add_line(sketch, p0, p1, false); + (sketch, axis_line, line, p0, p1) }; 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 + + let (sketch, axis_line, line, p0, p1) = axis_and_line(); + let (sketch, _) = + sketch.apply(SketchEdit::AddRelation(SketchRelation::Horizontal(line)))?; + let source_ids: std::collections::BTreeSet<_> = [p0, p1, line].into_iter().collect(); + let mirrored = mirror_targets(sketch, &source_ids, axis_line, &axis_geom)?; + let horizontal_count = mirrored .relations() .iter() - .filter_map(|(_, r)| match r { - SketchRelation::Horizontal(id) => Some(*id), - _ => None, - }) - .collect(); + .filter(|(_, r)| matches!(r, SketchRelation::Horizontal(_))) + .count(); assert_eq!( - horizontal_lines.len(), - 2, + horizontal_count, 2, "original + mirrored horizontal must both exist" ); + + let (sketch, axis_line, line, p0, p1) = axis_and_line(); + let (sketch, _) = sketch.apply(SketchEdit::AddRelation(SketchRelation::Parallel( + line, axis_line, + )))?; + let source_ids: std::collections::BTreeSet<_> = [p0, p1, line].into_iter().collect(); + let mirrored = mirror_targets(sketch, &source_ids, axis_line, &axis_geom)?; + 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}", + ); + Ok(()) } #[test] - fn construction_toggle_flips_line_flag() { + fn construction_toggle_flips_line_flag() -> Result<(), Box> { 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 { + let (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); + Ok(()) } #[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() { + fn mirror_on_axis_source_point_is_identity_no_self_symmetric() + -> Result<(), Box> { 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)); @@ -9505,9 +10835,7 @@ mod tests { 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 mirrored = mirror_targets(sketch, &source_ids, axis_line, &axis_geom)?; let point_count = mirrored .entities() .iter() @@ -9526,38 +10854,6 @@ mod tests { 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}", - ); + Ok(()) } }