diff --git a/crates/bone-app/src/app.rs b/crates/bone-app/src/app.rs index 3da88b5..49a5b85 100644 --- a/crates/bone-app/src/app.rs +++ b/crates/bone-app/src/app.rs @@ -1,27 +1,33 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::sync::Arc; use bone_document::{ - DimensionKind, DimensionValue, Document, DocumentFolder, EditOutcome, EvaluatedExtrude, - EvaluatedModel, ExtrudeError, ExtrudeFeature, FeatureNode, LineData, RebuildBudget, - RebuildCost, RebuildPass, RecomputeScope, Sketch, SketchDimension, SketchEdit, SketchEntity, - SketchRelation, SketchVersion, SolverError, UndoStack, evaluate_extrude, evaluate_sketch, + 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, + UndoStack, evaluate_extrude_reaching, evaluate_revolve_reaching, evaluate_sketch, }; use bone_render::{ - Camera2, CameraTween, ChromeInstance, ChromePipeline, ChromeTextPipeline, ConvexInstance, - ConvexPolyPipeline, DragModifiers, EdgeScene, Gpu, IconInstance, IconPipeline, NavGesture, - PickIdError, PickIndex, PickQuery, PickedItem, Picker, PixelsPerMm, RenderTargets, - SdfGlyphInstance, SketchPreview, SketchRenderer, SketchScene, SolidFrameView, SolidRenderer, - SolidScene, StrokeInstance, StrokePipeline, Style, ViewportExtent, ViewportNavigator, - ViewportPoint, ViewportPx, ViewportRegion, frame_current, frame_standard_view, - frame_view_direction, orbit_pitch, orbit_yaw, pan_pixels, roll_by, zoom_about_pixel, + BodyKey, Camera2, CameraTween, ChromeInstance, ChromePipeline, ChromeTextPipeline, + ConvexInstance, ConvexPolyPipeline, DragModifiers, EdgeScene, EdgeStroke, EntityKindTag, + GenuineEdge, Gpu, IconInstance, IconPipeline, NavGesture, PickId, PickIdError, PickIndex, + PickQuery, PickedItem, Picker, PixelsPerMm, PreviewArc, PreviewCircle, RenderTargets, + SdfGlyphInstance, SketchPreview, SketchRenderer, SketchScene, SolidEdgeRef, SolidFaceRef, + SolidFrameView, SolidPickTable, SolidRenderer, SolidScene, SolidVertexRef, StrokeInstance, + StrokePipeline, Style, ViewportExtent, ViewportNavigator, ViewportPoint, ViewportPx, + ViewportRegion, frame_current, frame_standard_view, frame_view_direction, orbit_pitch, + orbit_yaw, pan_pixels, roll_by, single_body_pick, zoom_about_pixel, }; use bone_types::{ - Aabb3, Angle, AngleTolerance, BrepFaceId, BudgetCeiling, Camera3, ChordHeightTolerance, - CubicEasing, DisplayMode, DocumentId, ExtrudeId, FeatureId, GeometryGeneration, Length, Plane3, - Point2, RebuildError, RebuildStatus, SketchId, SketchItemId, StandardView, Vec2, ZoomFactor, + 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, }; use bone_ui::a11y::AccessTreeBuilder; use bone_ui::focus::FocusManager; @@ -35,10 +41,10 @@ use bone_ui::input::{ use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; use bone_ui::strings::StringTable; use bone_ui::theme::Theme; -use bone_ui::widgets::TreeBadge; +use bone_ui::widgets::{LabelText, TreeBadge}; use bone_ui::{MaskAtlas, MaskAtlasParams, Shaper}; use swash::FontRef; -use uom::si::angle::degree; +use uom::si::angle::{degree, radian}; use uom::si::length::millimeter; use crate::clock::FrameClock; @@ -48,14 +54,16 @@ use crate::dimension_editor::{ use crate::input::{InputDispatched, InputEvent, KeyDown, NavKey, ScrollDelta, WindowPoint}; use crate::selection::Selection; use crate::sketch_mode::{ - ClickAnchor, DimensionFlow, DragPins, DragSession, ExtrudeArming, FeatureTool, Mode, Pending, - PendingDimension, Plane, SketchTool, + ClickAnchor, DatumArming, DimensionFlow, DragPins, DragSession, EndConditionKind, + ExtrudeArming, FeatureTool, Mode, ModeExit, Pending, PendingDimension, Plane, ReferenceKind, + RevolveArming, SketchTool, }; use crate::snap::{Anchor, SnapHit}; use crate::status_badge::ExtrudeStatus; use crate::{ chrome, dimension_editor, file_menu, hotkeys, native_picker, selection, settings, shell, - shortcut_bar, sketch_mode, smart_dimension, snap, step_jobs, strings, tools, view_cube, + shortcut_bar, sketch_mode, sketch3_mode, sketch3_project, smart_dimension, snap, step_jobs, + strings, tools, view_cube, }; const ZOOM_STEP_PER_LINE: f64 = 1.1; const ZOOM_STEP_PER_PIXEL: f64 = 1.0025; @@ -72,6 +80,13 @@ const UNDO_CAPACITY: usize = 256; const SNAP_TOLERANCE_PX: f64 = 8.0; const SNAP_TOLERANCE_MAX_MM: f64 = 5.0; +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum RevolveArm { + Idle, + Axis, + Contour, +} + struct AppState { extent: ViewportExtent, renderer: SketchRenderer, @@ -98,6 +113,9 @@ struct AppState { changed_features: BTreeSet, needs_rebuild: bool, pending_reattach: Option, + extrude_reference_arm: Option, + revolve_arm: RevolveArm, + brep_selection: Vec, solid_renderer: SolidRenderer, solid_view: Option, camera3: Option, @@ -314,24 +332,17 @@ pub struct AppCore { } fn default_sketch() -> Sketch { - let sketch = Sketch::new(Plane::Xy.basis()); - let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(-20.0, -12.5)); - let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(20.0, -12.5)); - let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(20.0, 12.5)); - let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(-20.0, 12.5)); - let (sketch, _) = tools::add_line(sketch, p0, p1, false); - let (sketch, _) = tools::add_line(sketch, p1, p2, false); - let (sketch, _) = tools::add_line(sketch, p2, p3, false); - let (sketch, _) = tools::add_line(sketch, p3, p0, false); - let (sketch, origin) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); - let (sketch, _) = tools::add_circle(sketch, origin, Length::new::(5.0), false); - sketch + Sketch::new(Plane::Xy.basis()) } -fn initial_document(sketch: Sketch) -> (Document, SketchId) { +fn initial_document(sketch: &Sketch) -> (Document, SketchId) { let mut document = Document::new(DocumentId::default(), "Untitled".to_owned()); let sketch_id = SketchId::default(); - document.insert_sketch(sketch_id, "Sketch1".to_owned(), sketch); + document.insert_sketch( + sketch_id, + "Sketch1".to_owned(), + sketch3_project::embed_sketch(sketch), + ); (document, sketch_id) } @@ -433,32 +444,61 @@ fn try_place(state: &mut AppState, world: Point2) { }; let prev_pending = session.pending; let sketch_id = *sketch_id; - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { + let Some(base) = state.document.sketch3(sketch_id) else { return; }; - let snap = match tool { - SketchTool::Line => { - compute_snap(&sketch, &state.camera, world, latest_anchor(prev_pending)) - } - _ => compute_endpoint_snap(&sketch, &state.camera, world), - }; - let (next_sketch, new_pending) = tools::place(sketch, tool, world, prev_pending, snap); - if let Some(next) = next_sketch { - state.undo.record(state.document.clone()); - state.document.replace_sketch(sketch_id, next); - let Some(stored) = state.document.sketch(sketch_id) else { - return; + let basis = base.plane(); + let view = sketch3_project::project(base, basis); + let snap = snap_tolerance(&state.camera) + .and_then(|tol| tool_snap(view.sketch(), tool, prev_pending, world, tol)); + if tool == SketchTool::Spline { + let existing = match snap { + Some(SnapHit { + kind: snap::SnapKind::Endpoint(id2), + .. + }) => view.to_3d(id2), + _ => None, }; - match SketchScene::extract(stored) { - Ok(scene) => state.scene = scene, - Err(e) => tracing::warn!(error = %e, "scene extract after place failed"), - } + let (next, draft_point) = if let Some(id) = existing { + (base.clone(), sketch_mode::SplineDraftPoint::reused(id)) + } else { + let (next, id) = sketch3_project::embed_point(base, basis, world); + (next, sketch_mode::SplineDraftPoint::fresh(id)) + }; + push_spline_draft_point(state, sketch_id, next, draft_point); + return; + } + let placed = sketch3_project::embed_place( + base, + &view, + tool, + world, + prev_pending, + snap, + sketch3_project::PlaneLock::Locked, + ); + let changed = placed.sketch != *base; + commit_placement(state, sketch_id, placed, changed); +} + +fn commit_placement( + state: &mut AppState, + sketch_id: SketchId, + placed: sketch3_project::Placed3, + changed: bool, +) { + if changed { + state.undo.record(state.document.clone()); + state + .document + .replace_sketch(sketch_id, sketch3_mode::solved(placed.sketch)); + refresh_active_scene(state); } if let Mode::Sketch { ref mut session, .. } = state.mode { - session.pending = new_pending; + session.pending = placed.pending; } } @@ -469,27 +509,118 @@ const fn latest_anchor(pending: Option) -> Option { } } -fn solid_pick_index(model: &EvaluatedModel) -> Option { - let faces: BTreeSet = model - .bodies() - .flat_map(|(_, solid)| solid.iter_faces().map(bone_document::BrepFace::id)) - .collect(); - if faces.is_empty() { - return None; +fn pending_anchor_world(base: &bone_document::Sketch3, pending: Option) -> Option { + match latest_anchor(pending)? { + ClickAnchor::Endpoint(id) => base.point_position(id), + ClickAnchor::Position(_) | ClickAnchor::MidpointOf { .. } | ClickAnchor::OnEdge { .. } => { + None + } + } +} + +fn reorigin_through(basis: SketchPlaneBasis, anchor: Point3) -> SketchPlaneBasis { + let (nx, ny, nz) = basis.normal().components(); + let (ox, oy, oz) = basis.origin().coords_mm(); + let (ax, ay, az) = anchor.coords_mm(); + let depth = (ax - ox).mul_add(nx, (ay - oy).mul_add(ny, (az - oz) * nz)); + let origin = Point3::from_mm( + nx.mul_add(depth, ox), + ny.mul_add(depth, oy), + nz.mul_add(depth, oz), + ); + SketchPlaneBasis::new( + origin, + basis.x_axis(), + basis.y_axis(), + bone_types::Tolerance::new(1.0e-9), + ) + .unwrap_or(basis) +} + +fn sketch3_aid_basis( + base: &bone_document::Sketch3, + plane: sketch_mode::Plane, + pending: Option, + session_anchor: Option, +) -> SketchPlaneBasis { + let principal = plane.basis(); + if pending.is_none() { + return principal; } - PickIndex::build_solid(faces, core::iter::empty(), core::iter::empty()).ok() + pending_anchor_world(base, pending) + .or(session_anchor) + .map_or(principal, |anchor| reorigin_through(principal, anchor)) +} + +fn body_key(feature: FeatureId, ordinal: BodyOrdinal) -> BodyKey { + BodyKey::new(feature, ordinal) +} + +fn solid_pick_table(model: &EvaluatedModel) -> Option { + let table = model + .bodies() + .try_fold( + SolidPickTable::new(), + |mut table, (feature, ordinal, solid)| { + table + .add_body( + body_key(feature, ordinal), + solid.iter_faces().map(bone_document::BrepFace::id), + solid.iter_edges().map(bone_document::BrepEdge::id), + solid.iter_vertices().map(bone_document::BrepVertex::id), + ) + .map(|()| table) + }, + ) + .ok()?; + (!table.is_empty()).then_some(table) +} + +fn solid_pick_index(model: &EvaluatedModel) -> Option { + solid_pick_table(model).map(|table| table.pick_index()) +} + +fn sketch3_pick_index(state: &AppState) -> Option { + let sketch3_id = state.mode.sketch3_id()?; + let sketch = state.document.sketch3(sketch3_id)?; + let entities = sketch.entity_order().iter().filter_map(|id| { + let tag = match sketch.entities().get(*id)? { + bone_document::Sketch3Entity::Point(_) => EntityKindTag::Point, + bone_document::Sketch3Entity::Line(_) => EntityKindTag::Line, + bone_document::Sketch3Entity::Circle(_) => EntityKindTag::Circle, + bone_document::Sketch3Entity::Arc(_) => EntityKindTag::Arc, + bone_document::Sketch3Entity::Ellipse(_) => EntityKindTag::Ellipse, + bone_document::Sketch3Entity::Spline(_) => EntityKindTag::Spline, + }; + Some((*id, tag)) + }); + let relations = sketch.relation_order().iter().copied(); + PickIndex::build(entities, relations, core::iter::empty()).ok() } fn active_pick_index(state: &AppState) -> Option { - if matches!(state.mode, Mode::Idle) && state.solid_view.is_some() { + if state.mode.is_sketch3() { + return sketch3_pick_index(state); + } + let solid_mode = state.extrude_reference_arm.is_some() + || state.revolve_arm == RevolveArm::Axis + || matches!(state.mode, Mode::Idle); + if solid_mode && state.solid_view.is_some() { return solid_pick_index(&state.model); } - match state.scene.pick_index() { - Ok(index) => Some(index), + let sketch_index = match state.scene.pick_index() { + Ok(index) => index, Err(error) => { tracing::warn!(error = %error, "build sketch pick index"); - None + return None; } + }; + if !(state.mode.is_sketch() && state.solid_view.is_some()) { + return Some(sketch_index); + } + match solid_pick_table(&state.model) { + Some(table) => Some(sketch_index.with_solid(&table)), + None => Some(sketch_index), } } @@ -531,20 +662,41 @@ fn handle_viewport_click( cursor: WindowPoint, additive: bool, ) { + if state.mode.is_sketch3() && handle_sketch3_click(state, cursor) { + return; + } let picked = pick_at(state, target, cursor); if let Some(sketch) = state.pending_reattach { if let Some(PickedItem::BrepFace(face)) = picked { - apply_reattach(state, sketch, face); + apply_reattach(state, sketch, face.face()); } return; } + if state.extrude_reference_arm.is_some() && capture_extrude_reference(state, picked) { + return; + } + if state.revolve_arm == RevolveArm::Axis && capture_revolve_axis(state, picked) { + return; + } + if state.revolve_arm == RevolveArm::Contour && capture_revolve_contour(state, picked) { + return; + } if !additive && matches!(state.mode, Mode::Idle) && let Some(PickedItem::BrepFace(face)) = picked - && begin_face_sketch(state, face) + && begin_face_sketch(state, face.face()) + { + return; + } + if state.mode.is_sketch() + && let Some(brep @ (PickedItem::BrepEdge(_) | PickedItem::BrepFace(_))) = picked { + toggle_brep_selection(state, brep, additive); return; } + if !additive { + state.brep_selection.clear(); + } let item = picked.and_then(selection::picked_to_item); state.selection = std::mem::take(&mut state.selection).picked(item, additive); if additive || !state.mode.is_sketch() { @@ -553,20 +705,26 @@ fn handle_viewport_click( let Some(SketchItemId::Entity(entity_id)) = item else { return; }; - let Mode::Sketch { sketch_id, .. } = state.mode else { + let Mode::Sketch { sketch_id, session } = &state.mode else { return; }; - let Some(sketch) = state.document.sketch(sketch_id) else { + let sketch_id = *sketch_id; + let spatial = session.spatial; + let Some(base) = state.document.sketch3(sketch_id) else { + return; + }; + let basis = spatial.map_or_else(|| base.plane(), |sp| sp.plane.basis()); + let Some(entity3) = resolve_entity_3d(base, spatial.is_some(), entity_id) else { return; }; - let Some(world) = cursor_to_world(state.camera, cursor) else { + let Some(pins) = DragPins::from_sketch3_entity(base, entity3) else { return; }; - let Some(pins) = DragPins::from_sketch_entity(sketch, entity_id) else { + let Some(world) = active_plane_cursor(state, spatial.is_some(), basis, cursor) else { return; }; let drag = DragSession { - entity: entity_id, + entity: entity3, press: world, pins, }; @@ -574,26 +732,70 @@ fn handle_viewport_click( state.mode = core::mem::take(&mut state.mode).start_drag(drag); } -fn drag_resolved(sketch: &Sketch, drag: DragSession, world: Point2) -> Option { - let pins = drag.pins.to_targets(drag.press, world); - sketch - .solve_with_drag_pins(&pins, BudgetCeiling::FRAME_16MS) - .map_err(|e| tracing::trace!(error = %e, "drag solve failed, keeping last-good sketch")) - .ok() +fn resolve_entity_3d( + base: &bone_document::Sketch3, + in_3d: bool, + entity: SketchEntityId, +) -> Option { + if in_3d { + base.entities().contains_key(entity).then_some(entity) + } else { + base.project().to_3d(entity) + } +} + +fn toggle_brep_selection(state: &mut AppState, item: PickedItem, additive: bool) { + if additive { + match state.brep_selection.iter().position(|held| *held == item) { + Some(index) => { + state.brep_selection.remove(index); + } + None => state.brep_selection.push(item), + } + } else { + state.brep_selection = vec![item]; + state.selection = Selection::default(); + } +} + +fn active_plane_cursor( + state: &AppState, + in_3d: bool, + basis: SketchPlaneBasis, + cursor: WindowPoint, +) -> Option { + if in_3d { + let camera = state.camera3?; + let region = solid_viewport_region(state.viewport_rect, state.extent)?; + let pixel = viewport_local_point(cursor, region)?; + let world = sketch3_mode::ray_plane_point(camera, region.extent(), pixel, basis)?; + Some(sketch3_project::flatten(basis, world)) + } else { + cursor_to_world(state.camera, cursor) + } } -fn try_drag_to(state: &mut AppState, world: Point2) { +fn try_drag_to(state: &mut AppState, cursor: WindowPoint) { let Mode::Sketch { sketch_id, session } = &state.mode else { return; }; - let Some(drag) = session.drag else { + let Some(drag) = &session.drag else { return; }; let sketch_id = *sketch_id; - let Some(sketch) = state.document.sketch(sketch_id) else { + let spatial = session.spatial; + let Some(base) = state.document.sketch3(sketch_id) else { return; }; - let Some(next) = drag_resolved(sketch, drag, world) else { + let basis = spatial.map_or_else(|| base.plane(), |sp| sp.plane.basis()); + let Some(world) = active_plane_cursor(state, spatial.is_some(), basis, cursor) else { + return; + }; + let targets = drag.pins.to_targets(basis, drag.press, world); + let Ok(next) = base + .solve_with_drag_pins(&targets, BudgetCeiling::FRAME_16MS) + .map_err(|e| tracing::trace!(error = %e, "drag solve failed, keeping last-good sketch")) + else { return; }; state.document.replace_sketch(sketch_id, next); @@ -604,22 +806,35 @@ fn refresh_active_scene(state: &mut AppState) { let Some(active_id) = active_sketch_id(&state.mode, &state.plane_sketches) else { return; }; - let Some(sketch) = state.document.sketch(active_id) else { + let Some(base) = state.document.sketch3(active_id) else { return; }; - match SketchScene::extract(sketch) { + let sketch = sketch3_project::project_sketch(base, base.plane()); + match SketchScene::extract(&sketch) { Ok(scene) => state.scene = scene, Err(e) => tracing::warn!(error = %e, "refresh active scene failed"), } } +fn projected_id_map( + base: &bone_document::Sketch3, + view: &bone_document::ProjectedSketch, +) -> HashMap { + base.entity_order() + .iter() + .filter_map(|id3| view.to_2d(*id3).map(|id2| (id2, *id3))) + .collect() +} + fn active_sketch_id(mode: &Mode, plane_sketches: &BTreeMap) -> Option { match mode { Mode::Sketch { sketch_id, .. } => Some(*sketch_id), Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => Some(feature.sketch), - Mode::Extrude(ExtrudeArming::AwaitingSketch) | Mode::Idle => { - plane_sketches.get(&Plane::Xy).copied() - } + Mode::Revolve(RevolveArming::Profile { feature, .. }) => Some(feature.sketch), + Mode::Extrude(ExtrudeArming::AwaitingSketch(_)) + | Mode::Revolve(RevolveArming::AwaitingSketch(_)) + | Mode::Idle + | Mode::Datum(_) => plane_sketches.get(&Plane::Xy).copied(), } } @@ -630,13 +845,17 @@ enum ProfileChoice { } fn classify_extrude_profile(document: &Document) -> ProfileChoice { - let mut sketches = document - .feature_tree() - .iter() - .filter_map(|(_, node)| match node { - FeatureNode::Sketch(id) => Some(id), - _ => None, - }); + let tree = document.feature_tree(); + let consumed = |sketch: FeatureId| { + tree.children(sketch) + .into_iter() + .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, + }); match (sketches.next(), sketches.next()) { (None, _) => ProfileChoice::NoSketch, (Some(id), None) => ProfileChoice::Unique(id), @@ -645,126 +864,884 @@ fn classify_extrude_profile(document: &Document) -> ProfileChoice { } fn apply_feature_tool(state: &mut AppState, tool: Option) { + let Some(tool) = tool else { + return; + }; + let lifecycle = state.mode.lifecycle(); + if lifecycle.exit == ModeExit::Toggle && lifecycle.button == Some(tool) { + state.mode = Mode::Idle; + return; + } match tool { - Some(FeatureTool::ExtrudedBossBase) => arm_extruded_boss_base(state), - Some(FeatureTool::ExtrudedCut) => notify_stub(state, strings::TOOL_EXTRUDED_CUT), - None => {} + FeatureTool::ExtrudedBossBase => arm_extruded_boss_base(state), + FeatureTool::ExtrudedCut => arm_extruded_cut(state), + FeatureTool::RevolvedBossBase => arm_revolve(state, ExtrudeOperation::default()), + FeatureTool::RevolvedCut => arm_revolve(state, ExtrudeOperation::Cut), + FeatureTool::Sketch3D => arm_sketch3(state), + datum if datum.is_datum() => arm_datum(state, datum), + _ => {} } } -fn arm_extruded_boss_base(state: &mut AppState) { - match classify_extrude_profile(&state.document) { - ProfileChoice::NoSketch => notify_info(state, strings::NOTIFY_EXTRUDE_NO_SKETCH, None), - ProfileChoice::Unique(id) => state.mode = Mode::Extrude(ExtrudeArming::profile(id)), - ProfileChoice::Ambiguous => state.mode = Mode::Extrude(ExtrudeArming::AwaitingSketch), - } +fn sketch3_tool_for_key(text: &str) -> Option { + SketchTool::ENTITIES + .iter() + .copied() + .find(|tool| sketch3_tool_hotkey(*tool).is_some_and(|key| key.eq_ignore_ascii_case(text))) } -fn apply_extrude_edit(state: &mut AppState, edit: Option) { - let Some(edit) = edit else { return }; - let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = &state.mode else { +fn sketch3_tool_hotkey(tool: SketchTool) -> Option<&'static str> { + match tool { + SketchTool::Point => Some("p"), + SketchTool::Line => Some("l"), + SketchTool::Centerline => Some("c"), + SketchTool::Circle => Some("o"), + SketchTool::CenterpointArc => Some("a"), + SketchTool::Spline => Some("s"), + SketchTool::TangentArc + | SketchTool::ThreePointArc + | SketchTool::PerimeterCircle + | SketchTool::Ellipse + | SketchTool::CornerRectangle + | SketchTool::CenterRectangle + | SketchTool::ThreePointCornerRectangle + | SketchTool::ThreePointCenterRectangle + | SketchTool::Parallelogram => None, + } +} + +const SKETCH3_SNAP_MM: f64 = 1.0; + +fn handle_sketch3_click(state: &mut AppState, cursor: WindowPoint) -> bool { + let Mode::Sketch { sketch_id, session } = &state.mode else { + return false; + }; + let Some(spatial) = session.spatial else { + return false; + }; + let Some(tool) = session.tool else { + return false; + }; + let sketch3_id = *sketch_id; + let plane = spatial.plane; + let pending = session.pending; + let aid_anchor = session.aid_anchor; + let Some(camera) = state.camera3 else { + return false; + }; + let Some(region) = solid_viewport_region(state.viewport_rect, state.extent) else { + return false; + }; + let Some(pixel) = viewport_local_point(cursor, region) else { + return false; + }; + let Some(base) = state.document.sketch3(sketch3_id) else { + return false; + }; + let basis = sketch3_aid_basis(base, plane, pending, aid_anchor); + let Some(probe) = sketch3_probe(base, basis, camera, region, pixel, tool, pending) else { + return false; + }; + let placed_world = sketch3_project::lift(probe.basis, probe.effective); + if tool == SketchTool::Spline { + let existing = match probe.hit { + Some(SnapHit { + kind: snap::SnapKind::Endpoint(id2), + .. + }) => probe.view.to_3d(id2), + _ => None, + }; + commit_sketch3_spline_point(state, sketch3_id, placed_world, existing); + state.mode = core::mem::take(&mut state.mode).with_aid_anchor(placed_world); + return true; + } + let placed = sketch3_project::embed_place( + base, + &probe.view, + tool, + probe.effective, + pending, + probe.hit, + sketch3_project::PlaneLock::Free, + ); + let changed = placed.sketch != *base; + commit_placement(state, sketch3_id, placed, changed); + state.mode = core::mem::take(&mut state.mode).with_aid_anchor(placed_world); + true +} + +fn commit_sketch3_spline_point( + state: &mut AppState, + sketch3_id: SketchId, + world: Point3, + existing: Option, +) { + let Some(base) = state.document.sketch3(sketch3_id) else { return; }; - let next = edit.apply(*feature); - let target = *target; - state.mode = Mode::Extrude(ExtrudeArming::Profile { - feature: next, - target, - }); + let (next, draft_point) = if let Some(id) = existing { + (base.clone(), sketch_mode::SplineDraftPoint::reused(id)) + } else { + let (next, id) = base.with_entity(bone_document::Sketch3Entity::point(world)); + (next, sketch_mode::SplineDraftPoint::fresh(id)) + }; + push_spline_draft_point(state, sketch3_id, next, draft_point); } -fn apply_extrude_activation(state: &mut AppState, activated: Option) { - if let Some(mode) = extrude_edit_mode(&state.document, &state.mode, activated) { - if let Mode::Extrude(ExtrudeArming::Profile { - target: Some(id), .. - }) = mode - { - state.framed_extrude = Some(id); - } - state.mode = mode; - } +fn push_spline_draft_point( + state: &mut AppState, + sketch3_id: SketchId, + next: bone_document::Sketch3, + draft_point: sketch_mode::SplineDraftPoint, +) { + state.undo.record(state.document.clone()); + state + .document + .replace_sketch(sketch3_id, sketch3_mode::solved(next)); + state.mode = core::mem::take(&mut state.mode).with_spline_point(draft_point); + refresh_active_scene(state); } -fn extrude_edit_mode( - document: &Document, - current: &Mode, - activated: Option, -) -> Option { - let id = activated?; - if current.is_sketch() { - return None; - } - let feature = document.extrude(id).copied()?; - Some(Mode::Extrude(ExtrudeArming::edit(id, feature))) +fn dedup_spline_control( + base: &bone_document::Sketch3, + draft: &[sketch_mode::SplineDraftPoint], +) -> Vec { + draft.iter().fold(Vec::new(), |mut acc, point| { + let coincident = acc.last().copied().is_some_and(|prev| { + prev == point.id + || match (base.point_position(prev), base.point_position(point.id)) { + (Some(a), Some(b)) => a == b, + _ => false, + } + }); + if !coincident { + acc.push(point.id); + } + acc + }) } -fn apply_extrude_confirm(state: &mut AppState, confirm: Option) { - if let Some(id) = - commit_armed_extrude(&mut state.document, &mut state.undo, &state.mode, confirm) - { - state.framed_extrude = Some(id); - } +fn is_loose_point(sketch: &bone_document::Sketch3, id: SketchEntityId) -> bool { + let is_point = matches!( + sketch.entities().get(id), + Some(bone_document::Sketch3Entity::Point(_)) + ); + is_point + && sketch.entity_order().iter().all(|eid| { + *eid == id + || match sketch.entities().get(*eid) { + Some(entity) => !entity.references().into_iter().any(|r| r == id), + None => true, + } + }) } -fn commit_armed_extrude( - document: &mut Document, - undo: &mut UndoStack, - mode: &Mode, - confirm: Option, -) -> Option { - let Some(shell::ConfirmAction::Accept) = confirm else { - return None; - }; - let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = mode else { - return None; - }; - let snapshot = document.clone(); - let committed = match target { - Some(id) => { - document.insert_extrude(*id, *feature); - *id - } - None => document.commit_extrude(*feature), +fn delete_loose_points(state: &mut AppState, sketch_id: SketchId, ids: &[SketchEntityId]) { + let Some(base) = state.document.sketch3(sketch_id) else { + return; }; - undo.record(snapshot); - Some(committed) + let next = ids + .iter() + .filter(|id| is_loose_point(base, **id)) + .fold(base.clone(), |acc, id| acc.without_entity(*id)); + let changed = next != *base; + if !changed { + return; + } + state.undo.record(state.document.clone()); + state + .document + .replace_sketch(sketch_id, sketch3_mode::solved(next)); + refresh_active_scene(state); } -struct SolidViewData { - faces: SolidScene, - edges: EdgeScene, - aabb: Aabb3, +fn abandon_spline_draft(state: &mut AppState) { + let Some(sketch_id) = state.mode.sketch_id() else { + return; + }; + let fresh = state.mode.spline_draft_fresh(); + delete_loose_points(state, sketch_id, &fresh); } -struct ExtrudePreview { - feature: ExtrudeFeature, - sketch_version: SketchVersion, - generation: Option, - failed: bool, - error: Option, +fn reconcile_spline_draft( + state: &mut AppState, + sketch_id: Option, + prev_fresh: &[SketchEntityId], +) { + if prev_fresh.is_empty() { + return; + } + let Some(sketch_id) = sketch_id else { + return; + }; + let still: HashSet = state.mode.spline_draft().iter().map(|p| p.id).collect(); + let orphans: Vec = prev_fresh + .iter() + .copied() + .filter(|id| !still.contains(id)) + .collect(); + delete_loose_points(state, sketch_id, &orphans); } -impl ExtrudePreview { - fn status(&self) -> ExtrudeStatus<'_> { - match &self.error { - Some(error) => ExtrudeStatus::Failed(error), - None => ExtrudeStatus::Valid, +fn finalize_sketch3_spline(state: &mut AppState) { + let Some(sketch_id) = state.mode.sketch_id() else { + return; + }; + let committed = state.document.sketch3(sketch_id).and_then(|base| { + let control = dedup_spline_control(base, state.mode.spline_draft()); + if control.len() < 2 { + return None; + } + let (next, _) = + base.with_entity(bone_document::Sketch3Entity::spline(control, false, false)); + next.validate().is_ok().then_some(next) + }); + match committed { + Some(next) => { + state.undo.record(state.document.clone()); + state + .document + .replace_sketch(sketch_id, sketch3_mode::solved(next)); } + None => abandon_spline_draft(state), } + state.mode = core::mem::take(&mut state.mode).clear_spline_draft(); + refresh_active_scene(state); } -const PREVIEW_CHORD_MM: f64 = 0.05; -const PREVIEW_ANGLE: AngleTolerance = AngleTolerance::from_radians(0.2); +struct Sketch3Probe { + basis: SketchPlaneBasis, + view: sketch3_project::PlaneView, + hit: Option, + effective: Point2, +} -fn active_solid_feature( - mode: &Mode, - document: &Document, - framed: Option, -) -> Option { +fn sketch3_snap_hit( + snap: sketch3_mode::Snap3, + view: &sketch3_project::PlaneView, + world: Point2, +) -> Option { + let kind = match snap { + 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)?), + }; + Some(SnapHit { kind, world }) +} + +fn sketch3_probe( + base: &bone_document::Sketch3, + basis: SketchPlaneBasis, + camera: Camera3, + region: ViewportRegion, + pixel: ViewportPoint, + tool: SketchTool, + pending: Option, +) -> Option { + let tolerance = sketch3_snap_tolerance(camera, region) + .unwrap_or_else(|| Length::new::(SKETCH3_SNAP_MM)); + let exclude = match latest_anchor(pending) { + Some(ClickAnchor::Endpoint(id)) => Some(id), + _ => None, + }; + if let Some(snap3) = + sketch3_mode::nearest_snap3(base, camera, region.extent(), pixel, tolerance, exclude) + { + let basis = reorigin_through(basis, snap3.at()); + let view = sketch3_project::project(base, basis); + let effective = sketch3_project::flatten(basis, snap3.at()); + let hit = sketch3_snap_hit(snap3, &view, effective); + return Some(Sketch3Probe { + basis, + view, + hit, + effective, + }); + } + let world = sketch3_mode::ray_plane_point(camera, region.extent(), pixel, basis)?; + let cursor2d = sketch3_project::flatten(basis, world); + let view = sketch3_project::project(base, basis); + let pending2 = sketch3_project::to_plane_pending(&view, pending); + let hit = tool_snap(view.sketch(), tool, pending2, cursor2d, tolerance); + let effective = hit.map_or(cursor2d, |h| h.world); + Some(Sketch3Probe { + basis, + view, + hit, + effective, + }) +} + +const SKETCH3_CURSOR_MARKER_MM: f64 = 1.5; + +fn preview_cursor_edges(at: Point3) -> [GenuineEdge; 3] { + let (x, y, z) = at.coords_mm(); + let h = SKETCH3_CURSOR_MARKER_MM; + let crease = CreaseAngle::from_dot(-1.0); + let seg = |from: Point3, to: Point3| GenuineEdge::new(from, to, PickId::NONE, crease); + [ + seg(Point3::from_mm(x - h, y, z), Point3::from_mm(x + h, y, z)), + seg(Point3::from_mm(x, y - h, z), Point3::from_mm(x, y + h, z)), + seg(Point3::from_mm(x, y, z - h), Point3::from_mm(x, y, z + h)), + ] +} + +fn sketch3_preview_edges( + state: &AppState, + region: ViewportRegion, + cursor: Option, +) -> Option { + let Mode::Sketch { sketch_id, session } = &state.mode else { + return None; + }; + let spatial = session.spatial?; + let tool = session.tool?; + 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 probe = sketch3_probe(base, basis, camera, region, pixel, tool, session.pending)?; + if tool == SketchTool::Spline { + let cursor3 = sketch3_project::lift(probe.basis, probe.effective); + let mut positions: Vec = session + .spline_draft + .iter() + .filter_map(|point| base.point_position(point.id)) + .collect(); + positions.push(cursor3); + let crease = CreaseAngle::from_dot(-1.0); + let mut edges = if positions.len() >= 3 { + polyline_edges( + &sketch3_mode::spline3_polyline(&positions, false), + PickId::NONE, + ) + } else { + positions + .windows(2) + .map(|w| GenuineEdge::new(w[0], w[1], PickId::NONE, crease)) + .collect() + }; + edges.extend(preview_cursor_edges(cursor3)); + return (!edges.is_empty()).then(|| EdgeScene::from_genuine(edges)); + } + let pending2 = sketch3_project::to_plane_pending(&probe.view, session.pending); + let preview = tools::preview( + probe.view.sketch(), + tool, + probe.effective, + pending2, + probe.hit, + ); + let mut edges = preview_edges_3d(&preview, probe.basis); + edges.extend(preview_cursor_edges(sketch3_project::lift( + probe.basis, + probe.effective, + ))); + (!edges.is_empty()).then(|| EdgeScene::from_genuine(edges)) +} + +fn preview_edges_3d(preview: &SketchPreview, basis: SketchPlaneBasis) -> Vec { + let crease = CreaseAngle::from_dot(-1.0); + let lift = |p: Point2| sketch3_project::lift(basis, p); + let (u, v) = (basis.x_axis(), basis.y_axis()); + let circle_points = |c: &PreviewCircle| -> Vec { + let center = lift(c.center); + let radius = c.radius.get::(); + (0..=sketch3_mode::SKETCH3_CURVE_SEGMENTS) + .map(|i| { + let angle = core::f64::consts::TAU * f64::from(i) + / f64::from(sketch3_mode::SKETCH3_CURVE_SEGMENTS); + sketch3_mode::around(center, u, v, radius, radius, angle) + }) + .collect() + }; + let arc_points = |arc: &PreviewArc| -> Vec { + let center = lift(arc.center); + let radius = arc.radius.get::(); + let start = arc.start_angle.get::(); + let sweep = arc.sweep_angle.get::(); + (0..=sketch3_mode::SKETCH3_CURVE_SEGMENTS) + .map(|i| { + let t = f64::from(i) / f64::from(sketch3_mode::SKETCH3_CURVE_SEGMENTS); + sketch3_mode::around(center, u, v, radius, radius, sweep.mul_add(t, start)) + }) + .collect() + }; + let segments = preview + .segments + .iter() + .map(|(a, b)| GenuineEdge::new(lift(*a), lift(*b), PickId::NONE, crease)); + let circles = preview + .circles + .iter() + .flat_map(|c| polyline_edges(&circle_points(c), PickId::NONE)); + let arcs = preview + .arcs + .iter() + .flat_map(|a| polyline_edges(&arc_points(a), PickId::NONE)); + let anchors = preview + .anchors + .iter() + .flat_map(|p| preview_cursor_edges(lift(*p))); + segments.chain(circles).chain(arcs).chain(anchors).collect() +} + +fn arm_sketch3(state: &mut AppState) { + let id = state.document.allocate_sketch(); + state.undo.record(state.document.clone()); + state + .document + .insert_sketch(id, "3D Sketch".to_owned(), bone_document::Sketch3::new()); + let plane = sketch_mode::Plane::Xy; + state.mode = Mode::enter_sketch3(id, plane); + notify_info( + state, + strings::NOTIFY_SKETCH3_ACTIVE, + Some(plane.label().to_owned()), + ); + recompute_model(state, RecomputeScope::Full); + rebuild_model_view(state); +} + +fn cycle_sketch3_plane(state: &mut AppState) { + state.mode = core::mem::take(&mut state.mode).cycle_sketch3_plane(); + let Some(plane) = state.mode.sketch3_plane() else { + return; + }; + notify_info( + state, + strings::NOTIFY_SKETCH3_PLANE, + Some(plane.label().to_owned()), + ); +} + +fn arm_datum(state: &mut AppState, tool: FeatureTool) { + 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::DatumAxis => xy + .zip(yz) + .map(|(a, b)| sketch_mode::default_datum_axis(a, b)), + FeatureTool::DatumPoint => origin.map(sketch_mode::default_datum_point), + FeatureTool::DatumCoordinateSystem => origin + .zip(yz.zip(zx)) + .map(|(o, (x, y))| sketch_mode::default_datum_coordinate_system(o, x, y)), + FeatureTool::ExtrudedBossBase + | FeatureTool::ExtrudedCut + | FeatureTool::RevolvedBossBase + | FeatureTool::RevolvedCut + | FeatureTool::Sketch3D => None, + }; + if let Some(feature) = feature { + state.mode = Mode::Datum(DatumArming::new(feature)); + } +} + +fn arm_extruded_boss_base(state: &mut AppState) { + arm_extrude(state, ExtrudeOperation::default()); +} + +fn arm_extruded_cut(state: &mut AppState) { + arm_extrude(state, ExtrudeOperation::Cut); +} + +fn arm_extrude(state: &mut AppState, operation: ExtrudeOperation) { + match classify_extrude_profile(&state.document) { + ProfileChoice::NoSketch => notify_info(state, strings::NOTIFY_EXTRUDE_NO_SKETCH, None), + ProfileChoice::Unique(id) => { + state.mode = Mode::Extrude(ExtrudeArming::armed(id, operation)); + } + ProfileChoice::Ambiguous => { + state.mode = Mode::Extrude(ExtrudeArming::AwaitingSketch(operation)); + } + } +} + +fn arm_revolve(state: &mut AppState, operation: ExtrudeOperation) { + match classify_extrude_profile(&state.document) { + ProfileChoice::NoSketch => notify_info(state, strings::NOTIFY_EXTRUDE_NO_SKETCH, None), + ProfileChoice::Unique(id) => { + let axis = RevolveAxis::SketchLine( + state + .document + .sketch3(id) + .and_then(bone_document::Sketch3::axis_candidate) + .unwrap_or_default(), + ); + state.mode = Mode::Revolve(RevolveArming::armed(id, axis, operation)); + } + ProfileChoice::Ambiguous => { + state.mode = Mode::Revolve(RevolveArming::AwaitingSketch(operation)); + } + } +} + +fn apply_revolve_edit(state: &mut AppState, edit: Option) { + let Some(edit) = edit else { return }; + let Mode::Revolve(RevolveArming::Profile { feature, target }) = &state.mode else { + return; + }; + let next = edit.apply(feature); + let target = *target; + state.mode = Mode::Revolve(RevolveArming::Profile { + feature: Box::new(next), + target, + }); +} + +fn apply_revolve_axis_arm(state: &mut AppState, arm: bool) { + if arm && matches!(state.mode, Mode::Revolve(RevolveArming::Profile { .. })) { + state.revolve_arm = RevolveArm::Axis; + } +} + +fn apply_revolve_contour_arm(state: &mut AppState, arm: bool) { + if arm && matches!(state.mode, Mode::Revolve(RevolveArming::Profile { .. })) { + state.revolve_arm = RevolveArm::Contour; + } +} + +fn apply_revolve_confirm(state: &mut AppState, confirm: Option) { + commit_armed_revolve(&mut state.document, &mut state.undo, &state.mode, confirm); +} + +fn commit_armed_revolve( + document: &mut Document, + undo: &mut UndoStack, + mode: &Mode, + confirm: Option, +) -> Option { + let Some(shell::ConfirmAction::Accept) = confirm else { + return None; + }; + let Mode::Revolve(RevolveArming::Profile { feature, target }) = mode else { + return None; + }; + let snapshot = document.clone(); + let committed = match target { + Some(id) => { + document.insert_revolve(*id, **feature); + *id + } + None => document.commit_revolve(**feature), + }; + undo.record(snapshot); + Some(committed) +} + +fn apply_extrude_edit(state: &mut AppState, edit: Option) { + let Some(edit) = edit else { return }; + let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = &state.mode else { + return; + }; + let next = edit.apply(**feature); + let target = *target; + let arm = unresolved_reference_arm(edit, &next); + state.mode = Mode::Extrude(ExtrudeArming::Profile { + feature: Box::new(next), + target, + }); + if matches!(edit, shell::ExtrudeEdit::EndCondition(_)) { + state.extrude_reference_arm = arm; + } +} + +fn unresolved_reference_arm( + edit: shell::ExtrudeEdit, + feature: &ExtrudeFeature, +) -> Option { + match edit { + shell::ExtrudeEdit::EndCondition(kind) + if kind.needs_reference() + && EndConditionKind::of(&feature.end_condition) != Some(kind) => + { + Some(shell::ReferenceSlot::Direction1(kind)) + } + _ => None, + } +} + +fn apply_extrude_arm(state: &mut AppState, arm: Option) { + let Some(slot) = arm else { return }; + if matches!(state.mode, Mode::Extrude(ExtrudeArming::Profile { .. })) { + state.extrude_reference_arm = Some(slot); + } +} + +fn capture_extrude_reference(state: &mut AppState, picked: Option) -> bool { + let Some(slot) = state.extrude_reference_arm else { + return false; + }; + let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = &state.mode else { + return false; + }; + let Some(kind) = slot.reference_kind() else { + return false; + }; + let Some(captured) = captured_reference(&state.model, kind, picked) else { + return false; + }; + let next = shell::apply_extrude_reference(**feature, slot, captured); + let target = *target; + state.mode = Mode::Extrude(ExtrudeArming::Profile { + feature: Box::new(next), + target, + }); + state.extrude_reference_arm = None; + true +} + +fn captured_reference( + model: &EvaluatedModel, + kind: ReferenceKind, + picked: Option, +) -> Option { + match (kind, picked?) { + (ReferenceKind::Face, PickedItem::BrepFace(id)) => { + model_face_label(model, id).map(shell::CapturedReference::Face) + } + (ReferenceKind::Body, PickedItem::BrepFace(id)) => { + model_face_label(model, id).map(|label| shell::CapturedReference::Body(label.feature)) + } + (ReferenceKind::Vertex, PickedItem::BrepVertex(id)) => { + model_vertex_label(model, id).map(shell::CapturedReference::Vertex) + } + _ => None, + } +} + +fn capture_revolve_axis(state: &mut AppState, picked: Option) -> bool { + let Mode::Revolve(RevolveArming::Profile { feature, target }) = &state.mode else { + return false; + }; + let Some(axis) = picked_revolve_axis(&state.model, picked) else { + return false; + }; + let next = shell::RevolveEdit::Axis(axis).apply(feature); + let target = *target; + state.mode = Mode::Revolve(RevolveArming::Profile { + feature: Box::new(next), + target, + }); + state.revolve_arm = RevolveArm::Idle; + true +} + +fn picked_revolve_axis(model: &EvaluatedModel, picked: Option) -> Option { + match picked? { + PickedItem::BrepEdge(id) => model_edge_label(model, id).map(RevolveAxis::Edge), + _ => None, + } +} + +fn capture_revolve_contour(state: &mut AppState, picked: Option) -> bool { + let Mode::Revolve(RevolveArming::Profile { feature, target }) = &state.mode else { + return false; + }; + let Some(SketchItemId::Entity(entity)) = picked.and_then(selection::picked_to_item) else { + return false; + }; + let next = shell::RevolveEdit::Contour(ContourSelection::Contour(entity)).apply(feature); + let target = *target; + state.mode = Mode::Revolve(RevolveArming::Profile { + feature: Box::new(next), + target, + }); + state.revolve_arm = RevolveArm::Idle; + true +} + +fn model_edge_label(model: &EvaluatedModel, reference: SolidEdgeRef) -> Option { + model + .body_at(reference.feature(), reference.ordinal())? + .iter_edges() + .find(|edge| edge.id() == reference.edge()) + .map(bone_document::BrepEdge::label) +} + +fn model_face_label(model: &EvaluatedModel, reference: SolidFaceRef) -> Option { + model + .body_at(reference.feature(), reference.ordinal())? + .iter_faces() + .find(|face| face.id() == reference.face()) + .map(bone_document::BrepFace::label) +} + +fn model_vertex_label(model: &EvaluatedModel, reference: SolidVertexRef) -> Option { + model + .body_at(reference.feature(), reference.ordinal())? + .iter_vertices() + .find(|vertex| vertex.id() == reference.vertex()) + .map(bone_document::BrepVertex::label) +} + +fn apply_extrude_activation(state: &mut AppState, activated: Option) { + if let Some(mode) = extrude_edit_mode(&state.document, &state.mode, activated) { + if let Mode::Extrude(ExtrudeArming::Profile { + target: Some(id), .. + }) = mode + { + state.framed_extrude = Some(id); + } + state.mode = mode; + } +} + +fn extrude_edit_mode( + document: &Document, + current: &Mode, + activated: Option, +) -> Option { + let id = activated?; + if current.is_sketch() { + return None; + } + let feature = document.extrude(id).copied()?; + Some(Mode::Extrude(ExtrudeArming::edit(id, &feature))) +} + +fn apply_extrude_confirm(state: &mut AppState, confirm: Option) { + if let Some(id) = + commit_armed_extrude(&mut state.document, &mut state.undo, &state.mode, confirm) + { + state.framed_extrude = Some(id); + } +} + +fn apply_datum_confirm(state: &mut AppState, confirm: Option) { + if !state.mode.is_datum() { + return; + } + match confirm { + Some(shell::ConfirmAction::Accept) => { + commit_armed_datum(&mut state.document, &mut state.undo, &state.mode); + state.mode = Mode::Idle; + } + Some(shell::ConfirmAction::Cancel) => state.mode = Mode::Idle, + None => {} + } +} + +fn commit_armed_datum( + document: &mut Document, + undo: &mut UndoStack, + mode: &Mode, +) -> Option { + let Mode::Datum(DatumArming { feature, target }) = mode else { + return None; + }; + let snapshot = document.clone(); + let committed = match target { + Some(id) => { + document.insert_datum(*id, feature.clone()).ok()?; + *id + } + None => document.commit_datum(feature.clone()), + }; + undo.record(snapshot); + Some(committed) +} + +fn commit_armed_extrude( + document: &mut Document, + undo: &mut UndoStack, + mode: &Mode, + confirm: Option, +) -> Option { + let Some(shell::ConfirmAction::Accept) = confirm else { + return None; + }; + let Mode::Extrude(ExtrudeArming::Profile { feature, target }) = mode else { + return None; + }; + let snapshot = document.clone(); + let committed = match target { + Some(id) => { + document.insert_extrude(*id, **feature); + *id + } + None => document.commit_extrude(**feature), + }; + undo.record(snapshot); + Some(committed) +} + +struct SolidViewData { + faces: SolidScene, + edges: EdgeScene, + aabb: Aabb3, +} + +#[derive(Copy, Clone, Debug, PartialEq)] +enum PreviewFeature { + Extrude(ExtrudeFeature), + Revolve(RevolveFeature), +} + +impl PreviewFeature { + fn sketch(&self) -> bone_types::SketchId { + 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) + } + } + } +} + +struct ExtrudePreview { + feature: PreviewFeature, + sketch_version: SketchVersion, + generation: Option, + failed: bool, + error: Option, +} + +impl ExtrudePreview { + fn status(&self) -> ExtrudeStatus<'_> { + match &self.error { + Some(error) => ExtrudeStatus::Failed(error), + None => ExtrudeStatus::Valid, + } + } +} + +const PREVIEW_CHORD_MM: f64 = 0.05; +const PREVIEW_ANGLE: AngleTolerance = AngleTolerance::from_radians(0.2); + +fn active_solid_feature( + mode: &Mode, + document: &Document, + framed: Option, +) -> Option { match mode { - Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => Some(*feature), - Mode::Sketch { .. } => None, - Mode::Idle | Mode::Extrude(ExtrudeArming::AwaitingSketch) => framed + Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => { + Some(PreviewFeature::Extrude(**feature)) + } + Mode::Revolve(RevolveArming::Profile { feature, .. }) => { + Some(PreviewFeature::Revolve(**feature)) + } + Mode::Sketch { .. } | Mode::Datum(_) => None, + Mode::Idle + | Mode::Extrude(ExtrudeArming::AwaitingSketch(_)) + | Mode::Revolve(RevolveArming::AwaitingSketch(_)) => framed .and_then(|id| document.extrude(id).copied()) .or_else(|| { document @@ -776,7 +1753,8 @@ fn active_solid_feature( }) .last() .and_then(|id| document.extrude(id).copied()) - }), + }) + .map(PreviewFeature::Extrude), } } @@ -790,14 +1768,15 @@ fn clear_solid(state: &mut AppState) { fn body_passes(model: &EvaluatedModel) -> Vec<(FeatureId, RebuildPass)> { model .bodies() - .filter_map(|(feature, _)| model.built_at(feature).map(|pass| (feature, pass))) + .filter_map(|(feature, _, _)| model.built_at(feature).map(|pass| (feature, pass))) .collect() } fn build_combined_view(model: &EvaluatedModel) -> Option { - let mut parts = model - .bodies() - .filter_map(|(_, solid)| build_solid_view(solid).ok()); + 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() + }); let first = parts.next()?; Some(parts.fold(first, |acc, part| SolidViewData { faces: acc.faces.merge(part.faces), @@ -817,12 +1796,378 @@ fn recompute_model(state: &mut AppState, scope: RecomputeScope) { fn rebuild_model_view(state: &mut AppState) { state.model_passes = body_passes(&state.model); - state.solid_view = build_combined_view(&state.model); + let model_view = build_combined_view(&state.model); + state.solid_view = match (model_view, sketch3_overlay_view(state)) { + (Some(model), Some(overlay)) => Some(SolidViewData { + faces: model.faces, + edges: model.edges.merge(overlay.edges), + aabb: model.aabb.union(overlay.aabb), + }), + (Some(view), None) | (None, Some(view)) => Some(view), + (None, None) => None, + }; if state.solid_view.is_none() { clear_solid(state); } } +fn sketch3_overlay_view(state: &AppState) -> Option { + match &state.mode { + Mode::Sketch { sketch_id, session } => { + let spatial = session.spatial?; + let sketch = state.document.sketch3(*sketch_id)?; + Some(sketch3_aabb_view(sketch, spatial.plane.basis())) + } + Mode::Idle => unconsumed_sketches(&state.document) + .into_iter() + .map(|(_, sketch)| sketch3_aabb_view(sketch, sketch.plane())) + .reduce(|a, b| SolidViewData { + faces: a.faces, + edges: a.edges, + aabb: a.aabb.union(b.aabb), + }), + Mode::Extrude(_) | Mode::Revolve(_) | Mode::Datum(_) => None, + } +} + +fn unconsumed_sketches(document: &Document) -> Vec<(SketchId, &bone_document::Sketch3)> { + let tree = document.feature_tree(); + let consumed = |feature: FeatureId| { + tree.children(feature) + .into_iter() + .filter_map(|child| tree.node(child)) + .any(|node| matches!(node, FeatureNode::Extrude(_) | FeatureNode::Revolve(_))) + }; + tree.iter() + .filter_map(|(feature, node)| match node { + FeatureNode::Sketch(id) if !consumed(feature) => { + document.sketch3(id).map(|sketch| (id, sketch)) + } + _ => None, + }) + .filter(|(_, sketch)| !sketch.entity_order().is_empty()) + .collect() +} + +const SKETCH3_DEFAULT_HALF_MM: f64 = 25.0; +const SKETCH3_POINT_RADIUS_PX: f64 = 4.0; +const SKETCH3_POINT_MIN_MM: f64 = 0.02; +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); + +struct Sketch3Overlay { + points: SolidScene, + normal_lines: EdgeScene, + construction_lines: EdgeScene, + selected_lines: EdgeScene, +} + +impl Sketch3Overlay { + fn merge(self, other: Self) -> Self { + Self { + points: self.points.merge(other.points), + normal_lines: self.normal_lines.merge(other.normal_lines), + construction_lines: self.construction_lines.merge(other.construction_lines), + selected_lines: self.selected_lines.merge(other.selected_lines), + } + } +} + +fn sketch3_aabb_view( + sketch: &bone_document::Sketch3, + plane: bone_types::SketchPlaneBasis, +) -> SolidViewData { + let curve_extents = sketch.entity_order().iter().flat_map(|id| { + sketch + .entities() + .get(*id) + .and_then(|entity| sketch3_entity_edges(sketch, *id, entity)) + .into_iter() + .flat_map(|(_, edges)| edges) + .flat_map(|edge| [edge.a(), edge.b()]) + }); + let points = sketch + .entity_order() + .iter() + .filter_map(|id| sketch.point_position(*id)) + .chain(curve_extents); + let aabb = Aabb3::from_points(points).unwrap_or_else(|| { + let (ox, oy, oz) = plane.origin().coords_mm(); + Aabb3::from_corners( + Point3::from_mm( + ox - SKETCH3_DEFAULT_HALF_MM, + oy - SKETCH3_DEFAULT_HALF_MM, + oz - SKETCH3_DEFAULT_HALF_MM, + ), + Point3::from_mm( + ox + SKETCH3_DEFAULT_HALF_MM, + oy + SKETCH3_DEFAULT_HALF_MM, + oz + SKETCH3_DEFAULT_HALF_MM, + ), + ) + }); + SolidViewData { + faces: SolidScene::empty(), + edges: EdgeScene::empty(), + aabb, + } +} + +fn sketch3_frame_layers( + state: &AppState, + region: Option, + cursor_px: Option, +) -> (Option, Option) { + let preview = region.and_then(|region| { + let cursor = cursor_px.filter(|c| state.viewport_rect.contains(window_to_layout_pos(*c))); + sketch3_preview_edges(state, region, cursor) + }); + let overlay = region.and_then(|region| { + sketch3_overlay(state, region).or_else(|| idle_sketch3_overlay(state, region)) + }); + (preview, overlay) +} + +fn sketch3_overlay(state: &AppState, region: ViewportRegion) -> Option { + let sketch3_id = state.mode.sketch3_id()?; + let sketch = state.document.sketch3(sketch3_id)?; + let camera = state.camera3?; + let normal_color = clear_to_linear(state.style.strokes().stroke()); + Some(build_sketch3_overlay( + sketch, + state.selection.entity_ids(), + camera, + region, + normal_color, + )) +} + +fn idle_sketch3_overlay(state: &AppState, region: ViewportRegion) -> Option { + if !matches!(state.mode, Mode::Idle) { + return None; + } + let camera = state.camera3?; + let normal_color = clear_to_linear(state.style.strokes().stroke()); + unconsumed_sketches(&state.document) + .into_iter() + .map(|(_, sketch)| build_sketch3_overlay(sketch, &[], camera, region, normal_color)) + .reduce(Sketch3Overlay::merge) +} + +fn build_sketch3_overlay( + sketch: &bone_document::Sketch3, + selected: &[SketchEntityId], + camera: Camera3, + region: ViewportRegion, + normal_color: LinearRgba, +) -> Sketch3Overlay { + let (normal, construction, selected_lines) = sketch + .entity_order() + .iter() + .filter_map(|id| { + let entity = sketch.entities().get(*id)?; + let (for_construction, edges) = sketch3_entity_edges(sketch, *id, entity)?; + Some((*id, for_construction, edges)) + }) + .fold( + (Vec::new(), Vec::new(), Vec::new()), + |(mut normal, mut construction, mut selected_lines), (id, for_construction, edges)| { + if selected.contains(&id) { + selected_lines.extend(edges); + } else if for_construction { + construction.extend(edges); + } else { + normal.extend(edges); + } + (normal, construction, selected_lines) + }, + ); + + let points = sketch + .entity_order() + .iter() + .filter_map(|id| { + let at = sketch.point_position(*id)?; + let pick = PickId::point(*id).ok()?; + let color = if selected.contains(id) { + SKETCH3_SELECTED + } else { + normal_color + }; + Some(point_ball( + at, + sketch3_point_radius(camera, region, at), + color, + pick, + )) + }) + .fold(SolidScene::empty(), SolidScene::merge); + + Sketch3Overlay { + points, + normal_lines: EdgeScene::from_genuine(normal), + construction_lines: EdgeScene::from_genuine(construction), + selected_lines: EdgeScene::from_genuine(selected_lines), + } +} + +fn polyline_edges(points: &[Point3], pick: PickId) -> Vec { + let crease = CreaseAngle::from_dot(-1.0); + points + .windows(2) + .map(|w| GenuineEdge::new(w[0], w[1], pick, crease)) + .collect() +} + +fn sketch3_entity_edges( + sketch: &bone_document::Sketch3, + id: SketchEntityId, + entity: &bone_document::Sketch3Entity, +) -> Option<(bool, Vec)> { + use bone_document::Sketch3Entity as E; + let pos = |eid| sketch.point_position(eid); + match entity { + E::Point(_) => None, + E::Line(line) => { + let edge = GenuineEdge::new( + pos(line.a())?, + pos(line.b())?, + PickId::line(id).ok()?, + CreaseAngle::from_dot(-1.0), + ); + Some((line.for_construction(), vec![edge])) + } + E::Circle(circle) => { + let points = sketch3_mode::circle3_polyline( + pos(circle.center())?, + circle.radius().get::(), + circle.normal(), + ); + Some(( + circle.for_construction(), + polyline_edges(&points, PickId::circle(id).ok()?), + )) + } + E::Arc(arc) => { + let points = sketch3_mode::arc3_polyline( + pos(arc.center())?, + pos(arc.start())?, + pos(arc.end())?, + arc.normal(), + ); + Some(( + arc.for_construction(), + polyline_edges(&points, PickId::arc(id).ok()?), + )) + } + E::Ellipse(ellipse) => { + let points = sketch3_mode::ellipse3_polyline( + pos(ellipse.center())?, + pos(ellipse.major())?, + pos(ellipse.minor())?, + ellipse.normal(), + ); + Some(( + ellipse.for_construction(), + polyline_edges(&points, PickId::ellipse(id).ok()?), + )) + } + E::Spline(spline) => { + let control = spline + .control() + .iter() + .map(|id| pos(*id)) + .collect::>>()?; + let points = sketch3_mode::spline3_polyline(&control, spline.closed()); + Some(( + spline.for_construction(), + polyline_edges(&points, PickId::spline(id).ok()?), + )) + } + } +} + +fn clear_to_linear(color: bone_render::ClearColor) -> LinearRgba { + let [r, g, b, a] = color.to_rgba_array(); + LinearRgba::new(r, g, b, a) +} + +fn with_theme_tokens(style: &Style, theme: &Theme) -> Style { + let [r, g, b] = theme.cad.sketch_driven.srgb_f32(); + let driven = bone_render::ClearColor::new(f64::from(r), f64::from(g), f64::from(b), 1.0); + style.with_text(style.text().with_driven_color(driven)) +} + +fn sketch3_point_radius(camera: Camera3, region: ViewportRegion, at: Point3) -> f64 { + let height_px = f64::from(region.extent().height().value()); + let (ex, ey, ez) = camera.eye().coords_mm(); + let (tx, ty, tz) = camera.target().coords_mm(); + let (vx, vy, vz) = (tx - ex, ty - ey, tz - ez); + let view_len = vx.mul_add(vx, vy.mul_add(vy, vz * vz)).sqrt(); + let (ax, ay, az) = at.coords_mm(); + let (rx, ry, rz) = (ax - ex, ay - ey, az - ez); + let depth = (rx.mul_add(vx, ry.mul_add(vy, rz * vz)) / view_len).max(SKETCH3_MIN_DEPTH_MM); + let world_per_px = match camera.projection().kind() { + ProjectionKind::Perspective { fov, .. } => { + 2.0 * depth * (0.5 * fov.get::()).tan() / height_px + } + ProjectionKind::Orthographic { half_height } => { + 2.0 * half_height.get::() / height_px + } + }; + (SKETCH3_POINT_RADIUS_PX * world_per_px).max(SKETCH3_POINT_MIN_MM) +} + +fn point_ball(center: Point3, radius: f64, color: LinearRgba, pick: PickId) -> SolidScene { + let (cx, cy, cz) = center.coords_mm(); + let direction = |i: u32, j: u32| -> (f64, f64, f64) { + let theta = core::f64::consts::PI * f64::from(i) / f64::from(SKETCH3_BALL_RINGS); + let phi = core::f64::consts::TAU * f64::from(j) / f64::from(SKETCH3_BALL_SEGMENTS); + ( + theta.sin() * phi.cos(), + theta.cos(), + theta.sin() * phi.sin(), + ) + }; + let grid: Vec<(u32, u32)> = (0..=SKETCH3_BALL_RINGS) + .flat_map(|i| (0..=SKETCH3_BALL_SEGMENTS).map(move |j| (i, j))) + .collect(); + let positions: Vec = grid + .iter() + .map(|&(i, j)| { + let (dx, dy, dz) = direction(i, j); + Point3::from_mm( + radius.mul_add(dx, cx), + radius.mul_add(dy, cy), + radius.mul_add(dz, cz), + ) + }) + .collect(); + let normals: Vec = grid + .iter() + .map(|&(i, j)| { + let (dx, dy, dz) = direction(i, j); + UnitVec3::new_unchecked(dx, dy, dz) + }) + .collect(); + let colors = vec![color; positions.len()]; + let picks = vec![pick; positions.len()]; + let stride = SKETCH3_BALL_SEGMENTS + 1; + let index = |i: u32, j: u32| -> u32 { i * stride + j }; + let triangles: Vec<[u32; 3]> = (0..SKETCH3_BALL_RINGS) + .flat_map(|i| (0..SKETCH3_BALL_SEGMENTS).map(move |j| (i, j))) + .flat_map(|(i, j)| { + [ + [index(i, j), index(i + 1, j), index(i + 1, j + 1)], + [index(i, j), index(i + 1, j + 1), index(i, j + 1)], + ] + }) + .collect(); + SolidScene::from_parts_picked(positions, normals, colors, picks, triangles) +} + fn defer_rebuild(first: bool, cost: RebuildCost, budget: RebuildBudget) -> bool { !first && !cost.within(budget) } @@ -880,20 +2225,70 @@ fn feature_badge(state: &AppState, feature: FeatureId) -> Option { [status, rebuild].into_iter().flatten().max() } -fn compute_feature_badges(state: &AppState) -> BTreeMap { - state +fn compute_feature_badges(state: &AppState) -> BTreeMap { + state + .document + .feature_tree() + .iter() + .filter_map(|(feature, _)| feature_badge(state, feature).map(|badge| (feature, badge))) + .collect() +} + +fn frame_badges( + state: &mut AppState, +) -> (BTreeMap, Vec, bool) { + recompute_model(state, RecomputeScope::Full); + ( + compute_feature_badges(state), + compute_body_rows(state), + state.needs_rebuild, + ) +} + +fn feature_node_label(document: &Document, node: FeatureNode) -> Option { + match node { + FeatureNode::Extrude(id) => Some(document.extrude_label(id).unwrap_or("").to_owned()), + FeatureNode::Revolve(id) => Some(document.revolve_label(id).unwrap_or("").to_owned()), + FeatureNode::Datum(_) + | FeatureNode::Sketch(_) + | FeatureNode::Origin + | FeatureNode::PrincipalPlane(_) + | FeatureNode::ImportedBody(_) => None, + } +} + +fn compute_body_rows(state: &AppState) -> Vec { + let labels: BTreeMap = state .document .feature_tree() .iter() - .filter_map(|(feature, _)| feature_badge(state, feature).map(|badge| (feature, badge))) + .filter_map(|(feature, node)| { + feature_node_label(&state.document, node).map(|label| (feature, label)) + }) + .collect(); + let counts: BTreeMap = + state + .model + .bodies() + .fold(BTreeMap::new(), |mut acc, (feature, _, _)| { + *acc.entry(feature).or_insert(0) += 1; + acc + }); + state + .model + .bodies() + .map(|(feature, ordinal, _)| { + let base = labels.get(&feature).cloned().unwrap_or_default(); + let label = if counts.get(&feature).copied().unwrap_or(0) > 1 { + format!("{base}[{}]", ordinal.value() + 1) + } else { + base + }; + shell::BodyRow { label } + }) .collect() } -fn frame_badges(state: &mut AppState) -> (BTreeMap, bool) { - recompute_model(state, RecomputeScope::Full); - (compute_feature_badges(state), state.needs_rebuild) -} - fn feature_label_text(document: &Document, feature: FeatureId) -> String { match document.feature_tree().node(feature) { Some(FeatureNode::Sketch(sketch_id)) => { @@ -955,32 +2350,46 @@ fn whats_wrong_entry( model: &EvaluatedModel, document: &Document, feature: FeatureId, + strings_table: &StringTable, ) -> Option { - let kind = whats_wrong_kind(model.status(feature)?)?; + let status = model.status(feature)?; + let kind = whats_wrong_kind(status)?; let reattach = kind .offers_reattach .then(|| sketch_of_feature(document, feature)) .flatten(); + let message = match status { + RebuildStatus::Error(RebuildError::Build(_)) => model + .extrude_error(feature) + .map_or(LabelText::Key(kind.message), |error| { + crate::status_badge::extrude_panel_line(&error, strings_table) + }), + _ => LabelText::Key(kind.message), + }; Some(shell::WhatsWrong { label: feature_label_text(document, feature), - message: kind.message, + message, is_error: kind.is_error, reattach, }) } -fn compute_whats_wrong(model: &EvaluatedModel, document: &Document) -> Vec { +fn compute_whats_wrong( + model: &EvaluatedModel, + document: &Document, + strings_table: &StringTable, +) -> Vec { document .feature_tree() .iter() - .filter_map(|(feature, _)| whats_wrong_entry(model, document, feature)) + .filter_map(|(feature, _)| whats_wrong_entry(model, document, feature, strings_table)) .collect() } fn live_dim_anchor(mode: &Mode, document: &Document, pending: &PendingDimension) -> Point2 { mode.sketch_id() .and_then(|id| document.sketch(id)) - .and_then(|sketch| smart_dimension::live_anchor(sketch, pending.proto)) + .and_then(|sketch| smart_dimension::live_anchor(&sketch, pending.proto)) .unwrap_or(pending.anchor) } @@ -1008,6 +2417,14 @@ fn sync_solid_view(state: &mut AppState) { sync_model_view(state); return; } + if matches!(state.mode, Mode::Sketch { .. }) { + 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 { @@ -1018,7 +2435,12 @@ fn sync_solid_view(state: &mut AppState) { state.view.tween = None; return; }; - let Some(sketch_version) = state.document.sketch(feature.sketch).map(Sketch::version) else { + let Some(sketch_version) = state + .document + .sketch(feature.sketch()) + .as_ref() + .map(Sketch::version) + else { state.extrude_preview = None; state.solid_view = None; state.camera3 = None; @@ -1038,7 +2460,16 @@ 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 = compute_extrude_preview(&state.document, feature); + 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 generation = preview.as_ref().and_then(EvaluatedExtrude::generation); let error = preview .as_ref() @@ -1046,7 +2477,7 @@ fn sync_solid_view(state: &mut AppState) { let mut failure = error.as_ref().map(ToString::to_string); if generation != previous_generation { state.solid_view = match preview.as_ref().and_then(EvaluatedExtrude::solid) { - Some(solid) => match build_solid_view(solid) { + Some(solid) => match preview_solid_view(solid) { Ok(view) => Some(view), Err(error) => { failure = Some(error); @@ -1071,9 +2502,120 @@ fn sync_solid_view(state: &mut AppState) { } } +fn sync_sketch_overlay_view(state: &mut AppState) { + state.extrude_preview = None; + let passes = body_passes(&state.model); + if passes.is_empty() { + if state.solid_view.is_some() || !state.model_passes.is_empty() { + state.model_passes.clear(); + clear_solid(state); + } + return; + } + if passes == state.model_passes && state.solid_view.is_some() { + 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, + aabb: Aabb3, + region: ViewportRegion, +) -> Option { + let zoom = camera.zoom().value(); + let (region_x, region_y, region_w, region_h) = region.scissor(); + let half_height = f64::from(region_h) / (2.0 * zoom); + if !(half_height.is_finite() && half_height > 0.0) { + return None; + } + let surface = camera.extent(); + let region_center_x = f64::from(region_x) + f64::from(region_w) / 2.0; + let region_center_y = f64::from(region_y) + f64::from(region_h) / 2.0; + let surface_center_x = f64::from(surface.width().value()) / 2.0; + let surface_center_y = f64::from(surface.height().value()) / 2.0; + let (pan_x, pan_y) = camera.pan_mm().coords_mm(); + let center_u = pan_x + (region_center_x - surface_center_x) / zoom; + let center_v = pan_y + (surface_center_y - region_center_y) / zoom; + let (x_axis, y_axis, normal) = (basis.x_axis(), basis.y_axis(), basis.normal()); + let target = basis.origin() + + x_axis.into_vec(Length::new::(center_u)) + + y_axis.into_vec(Length::new::(center_v)); + let standoff = (1.5 * aabb.extent().norm_mm()).max(half_height); + let eye = target + normal.into_vec(Length::new::(standoff)); + let projection = Projection::orthographic(Length::new::(half_height)).ok()?; + Camera3::new(eye, target, y_axis, projection).ok() +} + +fn face_plane_bounds(basis: SketchPlaneBasis, aabb: Aabb3) -> Option<(f64, f64, f64, f64)> { + const CORNERS: [(f64, f64, f64); 8] = [ + (-1.0, -1.0, -1.0), + (-1.0, -1.0, 1.0), + (-1.0, 1.0, -1.0), + (-1.0, 1.0, 1.0), + (1.0, -1.0, -1.0), + (1.0, -1.0, 1.0), + (1.0, 1.0, -1.0), + (1.0, 1.0, 1.0), + ]; + let (ox, oy, oz) = basis.origin().coords_mm(); + let (ux, uy, uz) = basis.x_axis().components(); + let (vx, vy, vz) = basis.y_axis().components(); + let (cx, cy, cz) = aabb.center().coords_mm(); + let (sx, sy, sz) = aabb.extent().coords_mm(); + let (hx, hy, hz) = (0.5 * sx, 0.5 * sy, 0.5 * sz); + CORNERS + .iter() + .map(|&(dx, dy, dz)| { + let (px, py, pz) = (cx + dx * hx - ox, cy + dy * hy - oy, cz + dz * hz - oz); + (px * ux + py * uy + pz * uz, px * vx + py * vy + pz * vz) + }) + .fold(None, |acc, (su, sv)| { + Some(acc.map_or((su, sv, su, sv), |(mnu, mnv, mxu, mxv)| { + (mnu.min(su), mnv.min(sv), mxu.max(su), mxv.max(sv)) + })) + }) +} + +fn frame_face_sketch_camera(state: &AppState, basis: SketchPlaneBasis) -> Camera2 { + let base = + Camera2::new(state.camera.extent()).with_zoom(PixelsPerMm::new(INITIAL_ZOOM_PX_PER_MM)); + match state + .solid_view + .as_ref() + .and_then(|view| face_plane_bounds(basis, view.aabb)) + { + Some(bounds) => zoom_fit_to_bounds(base, bounds, state.viewport_rect), + None => zoom_fit(base, &state.scene, state.viewport_rect), + } +} + +fn sketch_overlay_frame( + state: &AppState, + region: Option, +) -> Option { + if state.mode.is_sketch3() { + return None; + } + let region = region?; + let view = state.solid_view.as_ref()?; + let sketch = state.document.sketch(state.mode.sketch_id()?)?; + let camera = sketch_overlay_camera(sketch.plane(), state.camera, view.aabb, region)?; + SolidFrameView::new(camera, region).ok() +} + fn extrude_preview_is_current( cached: Option<&ExtrudePreview>, - feature: &ExtrudeFeature, + feature: &PreviewFeature, sketch_version: SketchVersion, ) -> bool { cached @@ -1082,18 +2624,24 @@ fn extrude_preview_is_current( fn compute_extrude_preview( document: &Document, - feature: ExtrudeFeature, + obstacles: &[&BrepSolid], + feature: &PreviewFeature, ) -> Option { - let sketch = document.sketch(feature.sketch)?; + let sketch = document.sketch3(feature.sketch())?; let evaluated_sketch = evaluate_sketch(sketch); - Some(evaluate_extrude( - FeatureId::default(), - &evaluated_sketch, - &feature, - )) + Some(feature.evaluate(&evaluated_sketch, obstacles)) +} + +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) } -fn build_solid_view(solid: &bone_document::BrepSolid) -> Result { +fn build_solid_view( + solid: &bone_document::BrepSolid, + body: BodyKey, + table: &SolidPickTable, +) -> Result { let chord = ChordHeightTolerance::from_mm(PREVIEW_CHORD_MM); let aabb = solid .bounding_box() @@ -1101,8 +2649,9 @@ fn build_solid_view(solid: &bone_document::BrepSolid) -> Result, now: fn view_nav_enabled(state: &AppState) -> bool { state.solid_view.is_some() + && !state.mode.is_sketch() && !modal_active(state) && state.focus.focused().is_none() && !dim_flow_active(&state.mode) @@ -1298,55 +2848,68 @@ fn build_preview( let Mode::Sketch { sketch_id, session } = mode else { return SketchPreview::empty(); }; - if session.drag.is_some() || session.dim_flow.is_some() { + if session.spatial.is_some() || session.drag.is_some() || session.dim_flow.is_some() { return SketchPreview::empty(); } let Some(tool) = session.tool else { return SketchPreview::empty(); }; - let pending = session.pending; - let Some(sketch) = document.sketch(*sketch_id) else { + let Some(base) = document.sketch3(*sketch_id) else { return SketchPreview::empty(); }; + let view = sketch3_project::project(base, base.plane()); + let sketch = view.sketch(); + let pending = sketch3_project::to_plane_pending(&view, session.pending); let Some(cursor) = cursor_world else { return tools::preview_anchors_only(sketch, pending); }; - let snap = match tool { - SketchTool::Line => compute_snap(sketch, camera, cursor, latest_anchor(pending)), - _ => compute_endpoint_snap(sketch, camera, cursor), - }; + let snap = snap_tolerance(camera).and_then(|tol| tool_snap(sketch, tool, pending, cursor, tol)); tools::preview(sketch, tool, cursor, pending, snap) } +fn snap_tolerance_for_mm_per_px(mm_per_px: f64) -> Option { + (mm_per_px.is_finite() && mm_per_px > 0.0).then(|| { + Length::new::((SNAP_TOLERANCE_PX * mm_per_px).min(SNAP_TOLERANCE_MAX_MM)) + }) +} + fn snap_tolerance(camera: &Camera2) -> Option { - let mm_per_px = camera.world_mm_per_pixel(); - if !mm_per_px.is_finite() || mm_per_px <= 0.0 { + snap_tolerance_for_mm_per_px(camera.world_mm_per_pixel()) +} + +fn sketch3_snap_tolerance(camera: Camera3, region: ViewportRegion) -> Option { + let bone_types::ProjectionKind::Orthographic { half_height } = camera.projection().kind() + else { return None; - } - let tol_mm = (SNAP_TOLERANCE_PX * mm_per_px).min(SNAP_TOLERANCE_MAX_MM); - Some(Length::new::(tol_mm)) + }; + let height_px = f64::from(region.extent().height().value()); + snap_tolerance_for_mm_per_px(2.0 * half_height.get::() / height_px) } -fn compute_snap( +fn snap_for_tool( sketch: &Sketch, - camera: &Camera2, - cursor_world: Point2, - click: Option, + tool: SketchTool, + anchor: Option, + cursor: Point2, + tolerance: Length, ) -> Option { - snap::detect( - cursor_world, - click.and_then(|c| resolve_anchor(Some(sketch), c)), - sketch, - snap_tolerance(camera)?, - ) + match tool { + SketchTool::Line | SketchTool::Centerline => { + snap::detect(cursor, anchor, sketch, tolerance) + } + _ => snap::detect_endpoint_only(cursor, sketch, tolerance), + } } -fn compute_endpoint_snap( +fn tool_snap( sketch: &Sketch, - camera: &Camera2, - cursor_world: Point2, + tool: SketchTool, + pending: Option, + cursor: Point2, + tolerance: Length, ) -> Option { - snap::detect_endpoint_only(cursor_world, sketch, snap_tolerance(camera)?) + let anchor = latest_anchor(pending).and_then(|c| resolve_anchor(Some(sketch), c)); + snap_for_tool(sketch, tool, anchor, cursor, tolerance) } fn resolve_anchor(sketch: Option<&Sketch>, click: ClickAnchor) -> Option { @@ -1354,6 +2917,10 @@ fn resolve_anchor(sketch: Option<&Sketch>, click: ClickAnchor) -> Option ClickAnchor::Position(pos) | ClickAnchor::MidpointOf { position: pos, .. } => { Some(Anchor { pos, id: None }) } + ClickAnchor::OnEdge { entity, position } => Some(Anchor { + pos: position, + id: Some(entity), + }), ClickAnchor::Endpoint(id) => match sketch?.entities().get(id)? { SketchEntity::Point(p) => Some(Anchor { pos: p.at(), @@ -1379,6 +2946,14 @@ fn zoom_fit(camera: Camera2, scene: &SketchScene, viewport: LayoutRect) -> Camer }; let (mnx, mny) = aabb.min().coords_mm(); let (mxx, mxy) = aabb.max().coords_mm(); + zoom_fit_to_bounds(camera, (mnx, mny, mxx, mxy), viewport) +} + +fn zoom_fit_to_bounds( + camera: Camera2, + (mnx, mny, mxx, mxy): (f64, f64, f64, f64), + viewport: LayoutRect, +) -> Camera2 { let center_x = (mnx + mxx) * 0.5; let center_y = (mny + mxy) * 0.5; let world_w = mxx - mnx; @@ -1507,6 +3082,12 @@ fn scopes_for_mode(mode: &Mode) -> HotkeyScopes { if mode.is_extrude() { scopes.push(HotkeyScope::Extrude); } + if mode.is_revolve() { + scopes.push(HotkeyScope::Revolve); + } + if mode.is_datum() { + scopes.push(HotkeyScope::Datum); + } scopes } @@ -1544,6 +3125,12 @@ fn resolve_pick( Mode::Extrude(ExtrudeArming::Profile { feature, .. }) if feature.sketch == id => { mode } + Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => { + Mode::Extrude(ExtrudeArming::armed(id, feature.operation)) + } + Mode::Extrude(ExtrudeArming::AwaitingSketch(operation)) => { + Mode::Extrude(ExtrudeArming::armed(id, *operation)) + } _ => Mode::Extrude(ExtrudeArming::profile(id)), }, None => mode, @@ -1571,9 +3158,14 @@ fn toggle_or_arm(mode: Mode, tool: SketchTool) -> Mode { fn cancel_pending_or_exit(mode: Mode) -> Mode { match &mode { + Mode::Sketch { session, .. } if !session.spline_draft.is_empty() => { + mode.clear_spline_draft() + } Mode::Sketch { session, .. } if session.pending.is_some() => mode.clear_pending(), Mode::Sketch { session, .. } if session.tool.is_some() => mode.disarm_tool(), - Mode::Sketch { .. } | Mode::Idle | Mode::Extrude(_) => Mode::Idle, + Mode::Sketch { .. } | Mode::Idle | Mode::Extrude(_) | Mode::Revolve(_) | Mode::Datum(_) => { + Mode::Idle + } } } @@ -1597,10 +3189,10 @@ impl AppCore { let sketch = default_sketch(); let scene = SketchScene::extract(&sketch)?; let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(INITIAL_ZOOM_PX_PER_MM)); - let style = Style::light(); let theme = Arc::new(Theme::light()); + let style = with_theme_tokens(&Style::light(), &theme); let shell = shell::Shell::new(); - let (document, sketch_id) = initial_document(sketch); + let (document, sketch_id) = initial_document(&sketch); let last_saved_baseline = document.clone(); let plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); let strings = strings::make_strings(bone_ui::strings::Locale::EnUs); @@ -1642,6 +3234,9 @@ impl AppCore { changed_features: BTreeSet::new(), needs_rebuild: false, pending_reattach: None, + extrude_reference_arm: None, + revolve_arm: RevolveArm::Idle, + brep_selection: Vec::new(), solid_renderer, solid_view: None, camera3: None, @@ -1716,11 +3311,8 @@ impl AppCore { && let Some(p) = prev { state.camera = pan_by_px(state.camera, position.x - p.x, position.y - p.y); - } else if !modal - && dragging_in_sketch(&state.mode) - && let Some(world) = cursor_to_world(state.camera, position) - { - try_drag_to(state, world); + } else if !modal && dragging_in_sketch(&state.mode) { + try_drag_to(state, position); } } InputEvent::CursorLeft => { @@ -1828,6 +3420,7 @@ impl AppCore { self.input.pending_pressed = self.input.pending_pressed.with(PointerButton::Secondary); if !modal && self.input.cursor_in(state.viewport_rect) { + abandon_spline_draft(state); state.mode = core::mem::take(&mut state.mode).clear_pending(); } } else { @@ -1872,6 +3465,24 @@ impl AppCore { Some(UiKeyCode::Named(named)) => Some(named), Some(UiKeyCode::Char(_)) | None => None, }; + let typing_active = dim_flow_active(&state.mode) || state.focus.focused().is_some(); + if state.mode.is_sketch() + && !typing_active + && let Some(tool) = key.text.as_deref().and_then(sketch3_tool_for_key) + { + abandon_spline_draft(state); + state.mode = core::mem::take(&mut state.mode).arm_tool(tool); + } + if state.mode.is_sketch3() && named == Some(NamedKey::Tab) && !key.repeat { + cycle_sketch3_plane(state); + } + if state.mode.is_sketch() + && named == Some(NamedKey::Enter) + && !key.repeat + && !state.mode.spline_draft().is_empty() + { + finalize_sketch3_spline(state); + } let repeat_space = key.repeat && named == Some(NamedKey::Space); if let Some(code) = key.code && !repeat_space @@ -1884,7 +3495,7 @@ impl AppCore { self.input.pending_text.push_str(&filtered); } } - let suppress_camera = dim_flow_active(&state.mode) || state.focus.focused().is_some(); + let suppress_camera = typing_active; if matches!(named, Some(NamedKey::Escape)) && state.notification.is_some() { state.notification = None; } @@ -1941,10 +3552,11 @@ impl AppCore { bone_ui::theme::ThemeMode::Light => Theme::light(), bone_ui::theme::ThemeMode::Dark => Theme::dark(), }); - self.state.style = match mode { + let base = match mode { bone_ui::theme::ThemeMode::Light => Style::light(), bone_ui::theme::ThemeMode::Dark => Style::default(), }; + self.state.style = with_theme_tokens(&base, &self.state.theme); } pub fn clock_mut(&mut self) -> &mut FrameClock { @@ -2083,9 +3695,19 @@ fn render_frame( let escape_requested = hotkey_actions.contains(&sketch_mode::ESCAPE_ACTION); if escape_requested { state.pending_reattach = None; + state.extrude_reference_arm = None; + state.revolve_arm = RevolveArm::Idle; } apply_extrude_edit(state, frame.extrude_edit); + apply_extrude_arm(state, frame.extrude_arm_reference); apply_extrude_confirm(state, frame.confirm_action); + apply_revolve_edit(state, frame.revolve_edit); + apply_revolve_axis_arm(state, frame.revolve_arm_axis); + apply_revolve_contour_arm(state, frame.revolve_arm_contour); + apply_revolve_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), @@ -2093,6 +3715,7 @@ fn render_frame( escape_requested, &state.plane_sketches, ); + 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 { @@ -2126,7 +3749,8 @@ fn render_frame( .cursor_px .filter(|c| state.viewport_rect.contains(window_to_layout_pos(*c))) .and_then(|c| cursor_to_world(state.camera, c)); - let preview = build_preview(&state.mode, &state.document, cursor_world, &state.camera); + let 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 atlas_pixels = state.sdf_atlas.pixels(); @@ -2135,6 +3759,9 @@ fn render_frame( extent.width().value() as f32, extent.height().value() as f32, ]; + let overlay_frame = sketch_overlay_frame(state, solid_region); + let (sketch3_preview, sketch3_overlay) = + sketch3_frame_layers(state, solid_region, input_state.cursor_px); let renderer = &mut state.renderer; let mut chrome_stage = ChromeStage { chrome: &mut state.chrome_pipeline, @@ -2149,14 +3776,22 @@ fn render_frame( let scene = &state.scene; let style = &state.style; renderer.prepare(scene, style); - let viewport = ViewportEncode { - solid: solid_region.and_then(|region| { + let solid = match overlay_frame { + Some(frame) => state.solid_view.as_ref().map(|view| (view, frame)), + None => solid_region.and_then(|region| { preview_solid_frame(state.solid_view.as_ref(), state.camera3, region) }), + }; + let overlay = overlay_frame.is_some() && solid.is_some(); + let viewport = ViewportEncode { + solid, + overlay, solid_renderer: &state.solid_renderer, sketch_renderer: renderer, scene, preview: &preview, + sketch3_preview, + sketch3_overlay, camera: state.camera, style, }; @@ -2172,10 +3807,13 @@ fn render_frame( struct ViewportEncode<'a> { solid: Option<(&'a SolidViewData, SolidFrameView)>, + overlay: bool, solid_renderer: &'a SolidRenderer, sketch_renderer: &'a SketchRenderer, scene: &'a SketchScene, preview: &'a SketchPreview, + sketch3_preview: Option, + sketch3_overlay: Option, camera: Camera2, style: &'a Style, } @@ -2188,28 +3826,87 @@ impl ViewportEncode<'_> { pick: &wgpu::TextureView, depth: &wgpu::TextureView, ) { - let targets = RenderTargets::new(color, pick); - match &self.solid { - Some((view, frame)) => self.solid_renderer.encode_passes( + let Some((view, frame)) = &self.solid else { + self.sketch_renderer.encode_passes( + encoder, + RenderTargets::new(color, pick), + self.scene, + self.preview, + self.camera, + self.style, + ); + return; + }; + self.solid_renderer.encode_passes( + encoder, + RenderTargets::new(color, pick), + depth, + &view.faces, + &view.edges, + &bone_render::SolidDisplay { + view: frame, + style: self.style, + mode: DisplayMode::ShadedWithEdges, + }, + ); + if let Some(preview) = &self.sketch3_preview { + self.solid_renderer.encode_preview_edges( encoder, - targets, + RenderTargets::new(color, pick), depth, - &view.faces, - &view.edges, - &bone_render::SolidDisplay { - view: frame, - style: self.style, - mode: DisplayMode::ShadedWithEdges, + preview, + frame, + self.style, + ); + } + if let Some(overlay) = &self.sketch3_overlay { + let strokes = self.style.strokes(); + self.solid_renderer.encode_overlay_solid( + encoder, + RenderTargets::new(color, pick), + depth, + &overlay.points, + frame, + self.style, + ); + self.solid_renderer.encode_styled_edges( + encoder, + RenderTargets::new(color, pick), + depth, + &overlay.normal_lines, + frame, + EdgeStroke::solid(strokes.stroke().to_rgba_array()), + ); + self.solid_renderer.encode_styled_edges( + encoder, + RenderTargets::new(color, pick), + depth, + &overlay.construction_lines, + frame, + EdgeStroke { + color: strokes.construction().to_rgba_array(), + dash_period_px: strokes.construction_dash_period_px(), + dash_on_ratio: strokes.construction_dash_on_ratio(), }, - ), - None => self.sketch_renderer.encode_passes( + ); + self.solid_renderer.encode_styled_edges( + encoder, + RenderTargets::new(color, pick), + depth, + &overlay.selected_lines, + frame, + EdgeStroke::solid(SKETCH3_SELECTED.to_array()), + ); + } + if self.overlay { + self.sketch_renderer.encode_overlay_passes( encoder, - targets, + RenderTargets::new(color, pick), self.scene, self.preview, self.camera, self.style, - ), + ); } } } @@ -2368,6 +4065,7 @@ fn dispatch_hotkey_command( } C::ToggleConstruction => apply_construction_toggle(state), C::Mirror => apply_mirror(state), + C::ConvertEntities => apply_convert_entities(state), C::StandardView(view) => { if view_nav_enabled(state) { apply_view_pick(state, Some(view_cube::ViewPick::Standard(view)), now); @@ -2383,59 +4081,116 @@ fn dispatch_hotkey_command( state.view.toggle_cube(); } } - C::SelectAll - | C::DeleteSelection - | C::EnterSketch - | C::SmartDimension - | C::Trim - | C::Extend => notify_stub(state, hotkeys::label_for_command(cmd)), + C::DeleteSelection => apply_delete_selection(state), + C::SmartDimension => apply_smart_dimension(state), + C::SelectAll | C::EnterSketch | C::Trim | C::Extend => { + notify_stub(state, hotkeys::label_for_command(cmd)); + } } } -fn apply_construction_toggle(state: &mut AppState) { - let Mode::Sketch { sketch_id, .. } = state.mode else { +fn selected_entities3(state: &AppState, base: &bone_document::Sketch3) -> Vec { + let ids = state.selection.entity_ids(); + if state.mode.is_sketch3() { + ids.to_vec() + } else { + let view = base.project(); + ids.iter().filter_map(|id2| view.to_3d(*id2)).collect() + } +} + +fn apply_delete_selection(state: &mut AppState) { + let Some(sketch_id) = state.mode.sketch_id() else { return; }; - let entity_ids: Vec = state.selection.entity_ids().to_vec(); - if entity_ids.is_empty() { + let Some(base) = state.document.sketch3(sketch_id) else { return; - } - let Some(sketch) = state.document.sketch(sketch_id) else { + }; + let pruned = match &state.selection { + Selection::Entities(_) => { + let ids3 = selected_entities3(state, base); + if ids3.is_empty() { + return; + } + ids3.iter() + .fold(base.clone(), |acc, id| acc.without_entity(*id)) + } + selection => match relation_to_delete(base, selection) { + Some(rid) => base.without_relation(rid), + None => return, + }, + }; + state.undo.record(state.document.clone()); + state + .document + .replace_sketch(sketch_id, sketch3_mode::solved(pruned)); + state.selection = Selection::default(); + refresh_active_scene(state); +} + +fn relation_to_delete( + base: &bone_document::Sketch3, + selection: &Selection, +) -> Option { + let item = match selection { + Selection::Relation(id) => { + if base.relations().contains_key(*id) { + return Some(*id); + } + SketchItemId::Relation(*id) + } + Selection::Dimension(id) => SketchItemId::Dimension(*id), + Selection::Entities(_) => return None, + }; + sketch3_project::relation_for_item(base, base.plane(), item) +} + +fn apply_construction_toggle(state: &mut AppState) { + let Some(sketch_id) = state.mode.sketch_id() else { return; }; - let pivot = entity_ids - .iter() - .find_map(|id| match sketch.entities().get(*id)? { - SketchEntity::Line(l) => Some(l.for_construction()), - SketchEntity::Arc(a) => Some(a.for_construction()), - SketchEntity::Circle(c) => Some(c.for_construction()), - SketchEntity::Point(_) => None, - }); + let Some(base) = state.document.sketch3(sketch_id) else { + return; + }; + let ids3 = selected_entities3(state, base); + let pivot = ids3.iter().find_map(|id| { + let entity = base.entities().get(*id)?; + (!entity.is_point()).then(|| entity.for_construction()) + }); let Some(current) = pivot else { return; }; let target = !current; - let snapshot = state.document.clone(); - let result = - entity_ids - .iter() - .try_fold(sketch.clone(), |acc, id| match acc.entities().get(*id) { - Some(SketchEntity::Point(_)) | None => Ok(acc), - Some(_) => acc - .apply(bone_document::SketchEdit::SetConstruction { - id: *id, - for_construction: target, - }) - .map(|(s, _)| s), - }); - match result { - Ok(next) => { - state.undo.record(snapshot); - state.document.replace_sketch(sketch_id, next); - refresh_active_scene(state); - } - Err(e) => tracing::warn!(error = %e, "construction toggle failed"), + let next = ids3 + .iter() + .fold(base.clone(), |acc, id| acc.with_construction(*id, target)); + state.undo.record(state.document.clone()); + state.document.replace_sketch(sketch_id, next); + refresh_active_scene(state); +} + +fn apply_driven_toggle(state: &mut AppState) { + let Some(sketch_id) = state.mode.sketch_id() else { + return; + }; + let Some(base) = state.document.sketch3(sketch_id) else { + return; + }; + let Some(rid) = relation_to_delete(base, &state.selection) else { + return; + }; + let Some(relation) = base.relations().get(rid).copied() else { + return; + }; + if relation.as_reference() == relation.as_driving() { + return; } + let next = base.with_reference(rid, !relation.is_reference()); + state.undo.record(state.document.clone()); + state + .document + .replace_sketch(sketch_id, sketch3_mode::solved(next)); + refresh_active_scene(state); } fn apply_mirror(state: &mut AppState) { @@ -2461,39 +4216,205 @@ fn apply_mirror(state: &mut AppState) { return; }; let axis_id = *axis_id; - let Some(pa) = lookup_point(sketch, axis_line.a()) else { + let Some(pa) = lookup_point(&sketch, axis_line.a()) else { + return; + }; + let Some(pb) = lookup_point(&sketch, axis_line.b()) else { + return; + }; + let axis = MirrorAxis::from_points(pa, pb); + if axis.is_degenerate() { + notify_mirror_hint(state); + return; + } + let source_ids: std::collections::BTreeSet = entity_ids + .iter() + .copied() + .filter(|id| *id != axis_id) + .collect(); + if source_ids.is_empty() { + notify_mirror_hint(state); + return; + } + let mirrored = match mirror_targets(sketch.clone(), &source_ids, axis_id, &axis) { + Ok(next) => next, + Err(e) => { + tracing::warn!(error = %e, "mirror failed"); + return; + } + }; + let Some(base) = state.document.sketch3(sketch_id) else { + return; + }; + let basis = base.plane(); + let view = sketch3_project::project(base, basis); + let (next3, _) = + sketch3_project::embed_delta(base, &view, &mirrored, sketch3_project::PlaneLock::Locked); + state.undo.record(state.document.clone()); + state + .document + .replace_sketch(sketch_id, sketch3_mode::solved(next3)); + state.selection = Selection::default(); + refresh_active_scene(state); +} + +fn apply_convert_entities(state: &mut AppState) { + let Mode::Sketch { sketch_id, .. } = state.mode else { + return; + }; + let Some(sketch) = state.document.sketch(sketch_id) else { + return; + }; + let basis = sketch.plane(); + let curves: Vec = state + .brep_selection + .iter() + .flat_map(|item| convert_targets(&state.model, *item, basis)) + .collect(); + if curves.is_empty() { return; + } + let converted = match curves + .into_iter() + .try_fold(sketch.clone(), convert_one_curve) + { + Ok(next) => next, + Err(e) => { + tracing::warn!(error = %e, "convert entities failed"); + return; + } }; - let Some(pb) = lookup_point(sketch, axis_line.b()) else { + let Some(base) = state.document.sketch3(sketch_id) else { return; }; - let axis = MirrorAxis::from_points(pa, pb); - if axis.is_degenerate() { - notify_mirror_hint(state); + 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()); + state + .document + .replace_sketch(sketch_id, sketch3_mode::solved(next3)); + state.brep_selection.clear(); + state.selection = Selection::default(); + refresh_active_scene(state); +} + +fn convert_targets( + model: &EvaluatedModel, + item: PickedItem, + basis: SketchPlaneBasis, +) -> Vec { + match item { + PickedItem::BrepEdge(edge) => model + .body_at(edge.feature(), edge.ordinal()) + .and_then(|solid| solid.project_edge(edge.edge(), basis)) + .into_iter() + .collect(), + PickedItem::BrepFace(face) => model + .body_at(face.feature(), face.ordinal()) + .map(|solid| { + solid + .face_boundary_edges(face.face()) + .iter() + .filter_map(|edge| solid.project_edge(*edge, basis)) + .collect::>() + }) + .unwrap_or_default(), + _ => Vec::new(), + } +} + +fn extend_preview_with_brep_selection(preview: &mut SketchPreview, state: &AppState) { + if state.brep_selection.is_empty() { return; } - let source_ids: std::collections::BTreeSet = entity_ids - .iter() - .copied() - .filter(|id| *id != axis_id) - .collect(); - if source_ids.is_empty() { - notify_mirror_hint(state); + let Some(sketch) = state + .mode + .sketch_id() + .and_then(|id| state.document.sketch(id)) + else { return; + }; + let basis = sketch.plane(); + state + .brep_selection + .iter() + .flat_map(|item| convert_targets(&state.model, *item, basis)) + .for_each(|curve| match curve { + ProjectedCurve::Line { a, b } => preview.segments.push((a, b)), + ProjectedCurve::Circle { center, radius } => { + preview.circles.push(PreviewCircle { center, radius }); + } + ProjectedCurve::Arc { center, start, end } => { + if let Some(arc) = highlight_arc(center, start, end) { + preview.arcs.push(arc); + } + } + }); +} + +fn highlight_arc(center: Point2, start: Point2, end: Point2) -> Option { + use core::f64::consts::{PI, TAU}; + let (cx, cy) = center.coords_mm(); + let (sx, sy) = start.coords_mm(); + let (ex, ey) = end.coords_mm(); + let radius = (sx - cx).hypot(sy - cy); + if radius <= 0.0 { + return None; } - let snapshot = state.document.clone(); - let result = mirror_targets(sketch.clone(), &source_ids, axis_id, &axis); - match result { - Ok(next) => { - state.undo.record(snapshot); - state.document.replace_sketch(sketch_id, next); - state.selection = Selection::default(); - refresh_active_scene(state); + let start_angle = (sy - cy).atan2(sx - cx); + let mut sweep = (ey - cy).atan2(ex - cx) - start_angle; + while sweep <= -PI { + sweep += TAU; + } + while sweep > PI { + sweep -= TAU; + } + Some(PreviewArc { + center, + radius: Length::new::(radius), + start_angle: Angle::new::(start_angle), + sweep_angle: Angle::new::(sweep), + }) +} + +fn convert_one_curve(sketch: Sketch, curve: ProjectedCurve) -> Result { + match curve { + ProjectedCurve::Line { a, b } => { + let (sketch, start) = add_sketch_point(sketch, a)?; + let (sketch, end) = add_sketch_point(sketch, b)?; + add_sketch_entity(sketch, SketchEntity::line(start, end, false)) + } + ProjectedCurve::Arc { center, start, end } => { + let (sketch, center) = add_sketch_point(sketch, center)?; + let (sketch, start) = add_sketch_point(sketch, start)?; + let (sketch, end) = add_sketch_point(sketch, end)?; + add_sketch_entity(sketch, SketchEntity::arc(center, start, end, false)) + } + ProjectedCurve::Circle { center, radius } => { + let (sketch, center) = add_sketch_point(sketch, center)?; + add_sketch_entity(sketch, SketchEntity::circle(center, radius, false)) } - Err(e) => tracing::warn!(error = %e, "mirror failed"), } } +fn add_sketch_point( + sketch: Sketch, + at: Point2, +) -> Result<(Sketch, bone_types::SketchEntityId), SketchEditError> { + let (next, outcome) = sketch.apply(SketchEdit::AddEntity(SketchEntity::point(at)))?; + let EditOutcome::Entity(id) = outcome else { + unreachable!("adding a point yields an entity outcome"); + }; + Ok((next, id)) +} + +fn add_sketch_entity(sketch: Sketch, entity: SketchEntity) -> Result { + sketch + .apply(SketchEdit::AddEntity(entity)) + .map(|(next, _)| next) +} + fn notify_mirror_hint(state: &mut AppState) { state.notification = Some(Notification { kind: NotificationKind::Info, @@ -2605,7 +4526,7 @@ impl MirrorBuilder { .sketch .entities() .get(id) - .copied() + .cloned() .ok_or(bone_document::SketchEditError::EntityNotFound(id))?; match entity { SketchEntity::Point(_) => { @@ -2614,58 +4535,64 @@ impl MirrorBuilder { SketchEntity::Line(l) => { let new_a = self.mirror_point(l.a(), axis)?; let new_b = self.mirror_point(l.b(), axis)?; - let (next, outcome) = - self.sketch - .clone() - .apply(bone_document::SketchEdit::AddEntity(SketchEntity::line( - new_a, - new_b, - l.for_construction(), - )))?; - let EditOutcome::Entity(new_id) = outcome else { - unreachable!("AddEntity yields Entity outcome") - }; - self.sketch = next; - self.entity_map.insert(id, new_id); + self.add_mirrored(id, SketchEntity::line(new_a, new_b, l.for_construction()))?; } SketchEntity::Arc(a) => { let new_center = self.mirror_point(a.center(), axis)?; let new_start = self.mirror_point(a.start(), axis)?; let new_end = self.mirror_point(a.end(), axis)?; - let (next, outcome) = - self.sketch - .clone() - .apply(bone_document::SketchEdit::AddEntity(SketchEntity::arc( - new_center, - new_end, - new_start, - a.for_construction(), - )))?; - let EditOutcome::Entity(new_id) = outcome else { - unreachable!("AddEntity yields Entity outcome") - }; - self.sketch = next; - self.entity_map.insert(id, new_id); + self.add_mirrored( + id, + SketchEntity::arc(new_center, new_end, new_start, a.for_construction()), + )?; } SketchEntity::Circle(c) => { let new_center = self.mirror_point(c.center(), axis)?; - let (next, outcome) = - self.sketch - .clone() - .apply(bone_document::SketchEdit::AddEntity(SketchEntity::circle( - new_center, - c.radius(), - c.for_construction(), - )))?; - let EditOutcome::Entity(new_id) = outcome else { - unreachable!("AddEntity yields Entity outcome") - }; - self.sketch = next; - self.entity_map.insert(id, new_id); + self.add_mirrored( + id, + SketchEntity::circle(new_center, c.radius(), c.for_construction()), + )?; + } + SketchEntity::Ellipse(e) => { + let new_center = self.mirror_point(e.center(), axis)?; + let new_major = self.mirror_point(e.major(), axis)?; + let new_minor = self.mirror_point(e.minor(), axis)?; + self.add_mirrored( + id, + SketchEntity::ellipse(new_center, new_major, new_minor, e.for_construction()), + )?; + } + SketchEntity::Spline(s) => { + let new_control = s + .control() + .iter() + .map(|point| self.mirror_point(*point, axis)) + .collect::, _>>()?; + self.add_mirrored( + id, + SketchEntity::spline(new_control, s.closed(), s.for_construction()), + )?; } } Ok(()) } + + fn add_mirrored( + &mut self, + original: bone_types::SketchEntityId, + entity: SketchEntity, + ) -> Result<(), bone_document::SketchEditError> { + let (next, outcome) = self + .sketch + .clone() + .apply(bone_document::SketchEdit::AddEntity(entity))?; + let EditOutcome::Entity(new_id) = outcome else { + unreachable!("AddEntity yields Entity outcome") + }; + self.sketch = next; + self.entity_map.insert(original, new_id); + Ok(()) + } } fn mirror_targets( @@ -2898,6 +4825,10 @@ fn suppress_pointer_activations(frame: shell::ShellFrame) -> shell::ShellFrame { activated_dimension: None, dimension_edit: None, extrude_edit: frame.extrude_edit, + extrude_arm_reference: frame.extrude_arm_reference, + revolve_edit: frame.revolve_edit, + revolve_arm_axis: frame.revolve_arm_axis, + revolve_arm_contour: frame.revolve_arm_contour, plane_picked: None, sketch_activated: None, sketch_rename: None, @@ -3024,6 +4955,20 @@ fn strip_plain_letter_chords(input: &mut InputSnapshot) { }); } +fn dim_render_view( + camera2: Camera2, + camera3: Option, + extent: ViewportExtent, + viewport_rect: LayoutRect, +) -> DimRenderView { + DimRenderView { + camera2, + camera3, + region: solid_viewport_region(viewport_rect, extent), + viewport_rect, + } +} + #[allow( clippy::too_many_arguments, reason = "run_frame_ui threads every per-frame UI subsystem" @@ -3038,8 +4983,8 @@ fn run_frame_ui( layout_size: LayoutSize, cursor_world: Option, ) -> FrameOutcomes { - let (feature_badges, needs_rebuild) = frame_badges(state); - let whats_wrong = compute_whats_wrong(&state.model, &state.document); + let (feature_badges, body_rows, needs_rebuild) = frame_badges(state); + let whats_wrong = compute_whats_wrong(&state.model, &state.document, &state.strings); let mut ctx = FrameCtx::new( theme, input, @@ -3064,20 +5009,23 @@ fn run_frame_ui( extrude_status, &mut state.view, &feature_badges, + &body_rows, needs_rebuild, &whats_wrong, ); - let dim_outcome = pending_dim(&state.mode).map(|pending| { - let live_anchor = live_dim_anchor(&state.mode, &state.document, &pending); - dimension_editor::render( - &mut ctx, - pending, - live_anchor, - &state.camera, - frame.viewport_rect, - &mut state.dim_editor, - ) - }); + let dim_view = dim_render_view( + state.camera, + state.camera3, + state.extent, + frame.viewport_rect, + ); + let dim_outcome = render_active_dimension( + &mut ctx, + &state.mode, + &state.document, + &dim_view, + &mut state.dim_editor, + ); let conflict_outcome = dim_conflict_pending(&state.mode).map(|_| render_dim_conflict_modal(&mut ctx, layout_size)); let picker_outcome = state @@ -3114,17 +5062,8 @@ fn run_frame_ui( || discard_outcome.is_some() || step_progress_outcome.is_some() || shortcut_bar_outcome.is_some(); - if !any_modal_open && ctx.focus.is_text_input_focused() { - strip_plain_letter_chords(ctx.input); - } - let mut actions = if any_modal_open { - Vec::new() - } else { - ctx.dispatch_hotkeys(scopes) - }; - if let Some(activated) = shortcut_bar_outcome.as_ref().and_then(|o| o.activated) { - actions.push(activated); - } + let activated = shortcut_bar_outcome.as_ref().and_then(|o| o.activated); + let actions = frame_actions(&mut ctx, scopes, any_modal_open, activated); FrameOutcomes { frame, hotkey_actions: actions, @@ -3139,9 +5078,29 @@ fn run_frame_ui( } } -fn dim_conflict_pending(mode: &Mode) -> Option { +fn frame_actions( + ctx: &mut FrameCtx<'_>, + scopes: &HotkeyScopes, + any_modal_open: bool, + activated: Option, +) -> Vec { + if !any_modal_open && ctx.focus.is_text_input_focused() { + strip_plain_letter_chords(ctx.input); + } + let mut actions = if any_modal_open { + Vec::new() + } else { + ctx.dispatch_hotkeys(scopes) + }; + if let Some(activated) = activated { + actions.push(activated); + } + actions +} + +fn dim_conflict_pending(mode: &Mode) -> Option { match mode.dim_flow() { - Some(DimensionFlow::Conflict(p)) => Some(p), + Some(DimensionFlow::Conflict(relation)) => Some(relation), Some(DimensionFlow::Editing(_)) | None => None, } } @@ -3596,6 +5555,31 @@ fn pending_dim(mode: &Mode) -> Option { } } +fn apply_smart_dimension(state: &mut AppState) { + let Some(sketch_id) = state.mode.sketch_id() else { + return; + }; + let Some(base) = state.document.sketch3(sketch_id) else { + return; + }; + let view = sketch3_project::project(base, base.plane()); + let ids2: Vec = if state.mode.is_sketch3() { + state + .selection + .entity_ids() + .iter() + .filter_map(|id3| view.to_2d(*id3)) + .collect() + } else { + state.selection.entity_ids().to_vec() + }; + let request = match smart_dimension::eligibility(view.sketch(), &ids2) { + smart_dimension::Eligibility::Eligible(req) => Some(req), + smart_dimension::Eligibility::Disabled(_) => None, + }; + apply_dimension_request(state, request); +} + fn apply_dimension_request(state: &mut AppState, request: Option) { let Some(request) = request else { return }; let Mode::Sketch { .. } = state.mode else { @@ -3629,7 +5613,7 @@ fn apply_dimension_outcome(state: &mut AppState, outcome: Option) { let Some(outcome) = outcome else { return }; - let Some(pending) = dim_conflict_pending(&state.mode) else { + let Some(relation) = dim_conflict_pending(&state.mode) else { return; }; match outcome.action { @@ -3638,7 +5622,7 @@ fn apply_dim_conflict_outcome(state: &mut AppState, outcome: Option { - confirm_dim_conflict_make_driven(state, pending); + confirm_dim_conflict_make_driven(state, relation); } } } @@ -3651,9 +5635,6 @@ fn commit_pending_dimension( let Mode::Sketch { sketch_id, .. } = state.mode else { return; }; - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { - return; - }; let proto = match pending.proto.with_value(value) { Ok(p) => p, Err(e) => { @@ -3661,24 +5642,20 @@ fn commit_pending_dimension( return; } }; - let (after_add, dim_id) = match sketch.clone().apply(SketchEdit::AddDimension(proto)) { - Ok((next, EditOutcome::Dimension(id))) => (next, id), - Ok(_) => { - tracing::warn!(?proto, "add dimension produced unexpected outcome"); - return; - } - Err(e) => { - tracing::warn!(error = %e, ?proto, "add dimension failed"); - return; - } + let Some(base) = state.document.sketch3(sketch_id) else { + return; + }; + let view = base.project(); + let map = projected_id_map(base, &view); + let Some(relation) = sketch3_project::map_dimension(proto, &map) else { + tracing::warn!(?proto, "dimension references do not map into the sketch"); + return; }; - let solved = match after_add.solve() { + let (next, relation_id) = base.with_relation(relation); + let solved = match next.solve() { Ok(s) => s, Err(SolverError::OverDefined { .. }) => { - state.mode = core::mem::take(&mut state.mode).start_dim_conflict(PendingDimension { - proto, - anchor: pending.anchor, - }); + state.mode = core::mem::take(&mut state.mode).start_dim_conflict(relation); state.dim_editor.close(); return; } @@ -3689,58 +5666,107 @@ fn commit_pending_dimension( }; state.undo.record(state.document.clone()); state.document.replace_sketch(sketch_id, solved); - state.selection = Selection::Dimension(dim_id); + state.selection = Selection::Relation(relation_id); state.mode = core::mem::take(&mut state.mode).cancel_dimension(); state.dim_editor.close(); refresh_active_scene(state); } -fn confirm_dim_conflict_make_driven(state: &mut AppState, pending: PendingDimension) { +fn confirm_dim_conflict_make_driven(state: &mut AppState, relation: Sketch3Relation) { let Mode::Sketch { sketch_id, .. } = state.mode else { return; }; - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { - return; - }; - let Some(measured) = sketch.measure(pending.proto) else { - tracing::warn!(?pending.proto, "measure failed for driven conversion; aborting"); - state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); - return; - }; - let Some(driven_proto) = driven_with_value(pending.proto, measured) else { - tracing::warn!(?pending.proto, "driven conversion failed"); - state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); + let Some(base) = state.document.sketch3(sketch_id).cloned() else { return; }; - let (after_add, dim_id) = match sketch.apply(SketchEdit::AddDimension(driven_proto)) { - Ok((next, EditOutcome::Dimension(id))) => (next, id), - Ok(_) => { - tracing::warn!( - ?driven_proto, - "add driven dimension produced unexpected outcome" - ); - state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); - return; - } - Err(e) => { - tracing::warn!(error = %e, ?driven_proto, "add driven dimension failed"); + let (next, relation_id) = base.with_relation(relation.as_reference()); + let solved = match next.solve() { + Ok(solved) => solved, + Err(error) => { + tracing::warn!(error = %error, "reference dimension fails to converge; rejecting"); state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); + state.dim_editor.close(); return; } }; - let solved = after_add.clone().solve().unwrap_or(after_add); state.undo.record(state.document.clone()); state.document.replace_sketch(sketch_id, solved); - state.selection = Selection::Dimension(dim_id); + state.selection = Selection::Relation(relation_id); state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); + state.dim_editor.close(); refresh_active_scene(state); + if state.mode.is_sketch3() { + rebuild_model_view(state); + } } -fn driven_with_value(proto: SketchDimension, value: DimensionValue) -> Option { - proto - .with_kind(DimensionKind::Driven) - .with_value(value) - .ok() +fn project_point3_to_screen( + camera: Camera3, + region: ViewportRegion, + point: Point3, +) -> Option { + let clip = bone_render::clip_from_world(camera, region.extent()).ok()?; + let (wx, wy, wz) = point.coords_mm(); + let homogeneous = [wx, wy, wz, 1.0]; + let row = |r: usize| { + (0..4) + .map(|c| f64::from(clip[c * 4 + r]) * homogeneous[c]) + .sum::() + }; + let clip_w = row(3); + if clip_w.abs() < 1.0e-9 { + return None; + } + let ndc_x = row(0) / clip_w; + let ndc_y = row(1) / clip_w; + let (origin_x, origin_y, width, height) = region.scissor(); + let px = f64::from(origin_x) + ndc_x.mul_add(0.5, 0.5) * f64::from(width); + let py = f64::from(origin_y) + (0.5 - ndc_y * 0.5) * f64::from(height); + Some(LayoutPos::new( + LayoutPx::saturating(bone_render::lower_f32(px)), + LayoutPx::saturating(bone_render::lower_f32(py)), + )) +} + +struct DimRenderView { + camera2: Camera2, + camera3: Option, + region: Option, + viewport_rect: LayoutRect, +} + +fn render_active_dimension( + ctx: &mut FrameCtx<'_>, + mode: &Mode, + document: &Document, + view: &DimRenderView, + dim_editor: &mut DimensionEditorState, +) -> Option { + let pending = pending_dim(mode)?; + let anchor = live_dim_anchor(mode, document, &pending); + if mode.is_sketch3() { + let basis = document.sketch3(mode.sketch3_id()?)?.plane(); + let at = sketch3_project::lift(basis, anchor); + let anchor_screen = view + .camera3 + .zip(view.region) + .and_then(|(camera, region)| project_point3_to_screen(camera, region, at))?; + return Some(dimension_editor::render_at( + ctx, + pending, + anchor_screen, + view.viewport_rect, + dim_editor, + )); + } + Some(dimension_editor::render( + ctx, + pending, + anchor, + &view.camera2, + view.viewport_rect, + dim_editor, + )) } fn apply_dimension_edit(state: &mut AppState, edit: Option) { @@ -3748,20 +5774,19 @@ fn apply_dimension_edit(state: &mut AppState, edit: Option let Mode::Sketch { sketch_id, .. } = state.mode else { return; }; - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { + let Some(base) = state.document.sketch3(sketch_id) else { return; }; - let after = match sketch.apply(SketchEdit::UpdateDimensionValue { - id: edit.id, - value: edit.value, - }) { - Ok((next, _)) => next, - Err(e) => { - tracing::warn!(error = %e, ?edit, "update dimension value failed"); - return; - } + let basis = base.plane(); + let Some(relation_id) = sketch3_project::dimension_relation_map(base, basis) + .get(&edit.id) + .copied() + else { + tracing::warn!(?edit, "dimension edit does not map to a sketch relation"); + return; }; - let solved = match after.solve() { + let updated = base.with_relation_value(relation_id, edit.value); + let solved = match updated.solve() { Ok(s) => s, Err(e) => { tracing::warn!(error = %e, ?edit, "solve after dimension edit failed"); @@ -3778,18 +5803,24 @@ fn apply_relation_action(state: &mut AppState, relation: Option) let Mode::Sketch { sketch_id, .. } = state.mode else { return; }; - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { + let Some(base) = state.document.sketch3(sketch_id) else { return; }; - let next = match sketch.apply(SketchEdit::AddRelation(relation)) { - Ok((next, _)) => next, - Err(e) => { - tracing::warn!(error = %e, ?relation, "add relation failed"); - return; - } + let view = base.project(); + let map = projected_id_map(base, &view); + let Some(relation3) = sketch3_project::map_relation(relation, &map, base) else { + tracing::warn!(?relation, "relation references do not map into the sketch"); + return; }; + if let Err(err) = base.validate_relation(relation3) { + tracing::warn!(?relation3, %err, "relation rejected for incompatible operands"); + return; + } + let next = base.with_relation(relation3).0; state.undo.record(state.document.clone()); - state.document.replace_sketch(sketch_id, next); + state + .document + .replace_sketch(sketch_id, sketch3_mode::solved(next)); state.selection = Selection::default(); refresh_active_scene(state); } @@ -3870,6 +5901,9 @@ 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::Undo | shell::MenuAction::Redo | shell::MenuAction::ExitSketch) | None => {} } @@ -4037,7 +6071,7 @@ fn apply_new_document(state: &mut AppState) { return; } }; - let (document, sketch_id) = initial_document(sketch); + let (document, sketch_id) = initial_document(&sketch); state.last_saved = Some(document.clone()); state.document = document; state.plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); @@ -4355,7 +6389,7 @@ fn install_loaded_document( state.undo = UndoStack::with_capacity(undo_capacity); let scene_attempt = active_sketch_id .and_then(|id| state.document.sketch(id)) - .map(SketchScene::extract); + .map(|sketch| SketchScene::extract(&sketch)); state.scene = match scene_attempt { None => SketchScene::empty(), Some(Ok(scene)) => scene, @@ -4495,12 +6529,13 @@ fn begin_face_sketch(state: &mut AppState, face: BrepFaceId) -> bool { let Some((face_ref, basis)) = state.model.face_for_sketch(face) else { return false; }; + state.brep_selection.clear(); let snapshot = state.document.clone(); let sketch_id = state.document.allocate_sketch(); let label = next_sketch_label(&state.document); state .document - .insert_sketch(sketch_id, label, Sketch::new(basis)); + .insert_sketch(sketch_id, label, bone_document::Sketch3::on_plane(basis)); if state .document .bind_sketch_to_face(sketch_id, face_ref) @@ -4513,6 +6548,7 @@ fn begin_face_sketch(state: &mut AppState, face: BrepFaceId) -> bool { state.mode = Mode::enter_sketch(sketch_id); refresh_active_scene(state); sync_solid_view(state); + state.camera = frame_face_sketch_camera(state, basis); true } @@ -4574,10 +6610,62 @@ fn apply_sketch_rename_into( #[cfg(test)] mod tests { use super::*; - use crate::sketch_mode::SketchSession; + use crate::sketch_mode::{SketchSession, SplineDraftPoint}; + use bone_document::BrepError; use bone_ui::hotkey::KeyChord; use bone_ui::input::{KeyChar, KeyCode, KeyEvent, NamedKey}; + #[test] + fn dedup_spline_control_drops_geometrically_coincident_points() { + 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), + 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)); + 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", + ); + } + + #[test] + fn theme_tokens_drive_the_driven_dimension_color() { + let theme = Theme::light(); + let styled = with_theme_tokens(&Style::light(), &theme); + let [r, g, b] = theme.cad.sketch_driven.srgb_f32(); + let driven = styled.text().driven_color().to_rgba_array(); + assert!( + (driven[0] - r).abs() < 1.0e-6 + && (driven[1] - g).abs() < 1.0e-6 + && (driven[2] - b).abs() < 1.0e-6, + "the driven dimension color is the sketch_driven design token, not a baked render constant", + ); + } + #[test] fn strip_plain_letter_chords_removes_chars_with_no_modifiers() { let mut input = InputSnapshot::idle(FrameInstant::ZERO); @@ -4602,6 +6690,61 @@ mod tests { assert_eq!(input.keys_pressed, vec![enter]); } + #[test] + fn sketch_overlay_camera_matches_the_sketch_camera_mapping() { + use bone_render::clip_from_world; + use bone_types::{Point3, Tolerance, UnitVec3}; + + let extent = ViewportExtent::new(ViewportPx::new(800), ViewportPx::new(600)); + let tol = Tolerance::new(1.0e-9); + let (Ok(x_axis), Ok(y_axis)) = ( + UnitVec3::try_from_components(1.0, 1.0, 0.0, tol), + UnitVec3::try_from_components(0.0, 0.0, 1.0, tol), + ) 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 camera = Camera2::new(extent) + .with_zoom(PixelsPerMm::new(7.5)) + .with_pan(Vec2::from_mm(1.5, -0.5)); + let aabb = Aabb3::from_corners( + Point3::from_mm(0.0, 0.0, 0.0), + 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 clip2 = camera.clip_from_world_mm(); + let ndc = |m: &[f32; 16], x: f64, y: f64, z: f64| { + let v = [x, y, z, 1.0]; + let row = |r: usize| (0..4).map(|c| f64::from(m[c * 4 + r]) * v[c]).sum::(); + let w = row(3); + (row(0) / w, row(1) / w) + }; + [(0.0, 0.0), (2.0, 1.0), (-1.5, 3.0)] + .into_iter() + .for_each(|(sx, sy)| { + let world = basis.origin() + + x_axis.into_vec(Length::new::(sx)) + + y_axis.into_vec(Length::new::(sy)); + let (wx, wy, wz) = world.coords_mm(); + let (nx3, ny3) = ndc(&clip3, wx, wy, wz); + let (nx2, ny2) = ndc(&clip2, sx, sy, 0.0); + assert!( + (nx3 - nx2).abs() < 1.0e-4 && (ny3 - ny2).abs() < 1.0e-4, + "sketch point ({sx}, {sy}) lands at the same pixel under both cameras: \ + overlay ({nx3}, {ny3}) vs sketch ({nx2}, {ny2})", + ); + }); + } + #[test] fn cursor_to_world_at_window_center_equals_camera_pan() { let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100)); @@ -4649,6 +6792,10 @@ mod tests { activated_dimension: None, dimension_edit: None, extrude_edit: None, + extrude_arm_reference: None, + revolve_edit: None, + revolve_arm_axis: false, + revolve_arm_contour: false, plane_picked: None, sketch_activated: None, sketch_rename: None, @@ -4679,7 +6826,7 @@ mod tests { super::ProfileChoice::NoSketch )); - let (one, id) = super::initial_document(Sketch::new(Plane::Xy.basis())); + let (one, id) = super::initial_document(&Sketch::new(Plane::Xy.basis())); assert!(matches!( super::classify_extrude_profile(&one), super::ProfileChoice::Unique(found) if found == id @@ -4722,8 +6869,9 @@ mod tests { base_version, edited_version, "a sketch edit must bump the version this gate keys on" ); + let preview_feature = super::PreviewFeature::Extrude(feature); let cached = super::ExtrudePreview { - feature, + feature: preview_feature, sketch_version: base_version, generation: None, failed: false, @@ -4731,29 +6879,40 @@ mod tests { }; assert!(super::extrude_preview_is_current( Some(&cached), - &feature, + &preview_feature, base_version )); assert!( - !super::extrude_preview_is_current(Some(&cached), &feature, edited_version), + !super::extrude_preview_is_current(Some(&cached), &preview_feature, edited_version), "editing the sketch under the same feature must invalidate the cached preview", ); assert!(!super::extrude_preview_is_current( None, - &feature, + &preview_feature, base_version )); } #[test] fn extrude_preview_refreshes_when_the_sketch_changes() { - let (mut document, id) = super::initial_document(rectangle_sketch()); + let (mut document, id) = super::initial_document(&rectangle_sketch()); let feature = sketch_mode::default_extrude_feature(id); - let first = super::compute_extrude_preview(&document, feature) - .and_then(|preview| preview.generation()); - document.replace_sketch(id, tall_rectangle_sketch()); - let second = super::compute_extrude_preview(&document, feature) - .and_then(|preview| preview.generation()); + let first = super::compute_extrude_preview( + &document, + &[], + &super::PreviewFeature::Extrude(feature), + ) + .and_then(|preview| preview.generation()); + document.replace_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 (Some(first), Some(second)) = (first, second) else { panic!("both rectangles extrude to a solid"); }; @@ -4765,9 +6924,13 @@ mod tests { #[test] fn extrude_preview_evaluates_a_closed_rectangle() { - let (document, id) = super::initial_document(rectangle_sketch()); + let (document, id) = super::initial_document(&rectangle_sketch()); let feature = sketch_mode::default_extrude_feature(id); - let Some(preview) = super::compute_extrude_preview(&document, feature) else { + let Some(preview) = super::compute_extrude_preview( + &document, + &[], + &super::PreviewFeature::Extrude(feature), + ) else { panic!("a registered sketch yields an evaluated preview"); }; assert!( @@ -4776,6 +6939,41 @@ mod tests { ); } + #[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", + ); + } + #[test] #[cfg_attr( debug_assertions, @@ -4791,7 +6989,7 @@ mod tests { const STEP_MM: f64 = 0.5; const STEPS: u32 = 16; - let (document, id) = super::initial_document(rectangle_sketch()); + let (document, id) = super::initial_document(&rectangle_sketch()); let blind = |depth_mm: f64| { let Ok(depth) = PositiveLength::new(Length::new::(depth_mm)) else { panic!("{depth_mm} mm is a positive depth"); @@ -4803,13 +7001,17 @@ mod tests { }; let edit = |depth_mm: f64| { let started = Instant::now(); - let Some(preview) = super::compute_extrude_preview(&document, blind(depth_mm)) else { + let Some(preview) = super::compute_extrude_preview( + &document, + &[], + &super::PreviewFeature::Extrude(blind(depth_mm)), + ) else { panic!("the rectangle extrudes at {depth_mm} mm"); }; let Some(solid) = preview.solid() else { panic!("the rectangle yields a solid at {depth_mm} mm"); }; - let Ok(_view) = super::build_solid_view(solid) else { + let Ok(_view) = super::preview_solid_view(solid) else { panic!("the slab tessellates and packs scenes at {depth_mm} mm"); }; started.elapsed() @@ -4843,33 +7045,57 @@ mod tests { fn extrude_preview_absent_when_sketch_missing() { let document = Document::new(DocumentId::default(), "Empty".to_owned()); let feature = sketch_mode::default_extrude_feature(SketchId::default()); - assert!(super::compute_extrude_preview(&document, feature).is_none()); + assert!( + super::compute_extrude_preview( + &document, + &[], + &super::PreviewFeature::Extrude(feature) + ) + .is_none() + ); } #[test] - fn default_document_extrudes_to_a_solid() { - let (document, id) = super::initial_document(super::default_sketch()); + 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, feature) else { - panic!("the default sketch is registered"); + 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 default sketch extrudes to a solid"); + panic!("the profile sketch extrudes to a solid"); }; - assert!(super::build_solid_view(solid).is_ok()); + assert!(super::preview_solid_view(solid).is_ok()); + } + + #[test] + fn the_startup_sketch_is_empty() { + let sketch = super::default_sketch(); + assert!( + sketch.entities().is_empty(), + "a fresh document opens with an empty sketch to draw into", + ); } #[test] fn preview_solid_view_tessellates_and_frames() { - let (document, id) = super::initial_document(rectangle_sketch()); + let (document, id) = super::initial_document(&rectangle_sketch()); let feature = sketch_mode::default_extrude_feature(id); - let Some(preview) = super::compute_extrude_preview(&document, feature) else { + 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::build_solid_view(solid) else { + 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)); @@ -4889,12 +7115,39 @@ mod tests { ); } + #[test] + fn unconsumed_sketches_lists_only_standalone_geometry() { + let (mut document, consumed) = super::initial_document(&rectangle_sketch()); + let _extrude = document.commit_extrude(sketch_mode::default_extrude_feature(consumed)); + let standalone = document.allocate_sketch(); + document.insert_sketch( + standalone, + "Standalone".to_owned(), + super::sketch3_project::embed_sketch(&rectangle_sketch()), + ); + let empty = document.allocate_sketch(); + document.insert_sketch(empty, "Empty".to_owned(), bone_document::Sketch3::new()); + let listed: Vec<_> = super::unconsumed_sketches(&document) + .into_iter() + .map(|(id, _)| id) + .collect(); + assert_eq!( + listed, + vec![standalone], + "an extrude-consumed sketch and an empty sketch both stay hidden in Idle", + ); + } + #[test] fn build_combined_view_merges_every_evaluated_body() { - let (mut document, s1) = super::initial_document(rectangle_sketch()); + let (mut document, s1) = super::initial_document(&rectangle_sketch()); let e1 = document.commit_extrude(sketch_mode::default_extrude_feature(s1)); let s2 = document.allocate_sketch(); - document.insert_sketch(s2, "Sketch2".to_owned(), rectangle_sketch()); + document.insert_sketch( + s2, + "Sketch2".to_owned(), + super::sketch3_project::embed_sketch(&rectangle_sketch()), + ); let _e2 = document.commit_extrude(sketch_mode::default_extrude_feature(s2)); let mut model = EvaluatedModel::new(); model.recompute( @@ -4914,7 +7167,7 @@ mod tests { let Some(solid) = model.body(f1) else { panic!("the first body is evaluated"); }; - let Ok(single) = super::build_solid_view(solid) else { + let Ok(single) = super::preview_solid_view(solid) else { panic!("a single body tessellates"); }; let Some(combined) = super::build_combined_view(&model) else { @@ -4939,7 +7192,7 @@ mod tests { #[test] fn face_bound_sketch_creation_then_break_offers_reattach() { use bone_types::FaceRole; - let (mut document, base_sketch) = super::initial_document(rectangle_sketch()); + let (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); @@ -4960,7 +7213,11 @@ mod tests { }; let face_sketch = document.allocate_sketch(); - document.insert_sketch(face_sketch, "Sketch2".to_owned(), rectangle_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 a fresh sketch to a face is acyclic"); }; @@ -4969,8 +7226,8 @@ mod tests { full_recompute(&mut model, &document); assert_eq!( super::body_passes(&model).len(), - 2, - "the face-bound extrude is a separate second body", + 1, + "the merging face-bound boss unions into the base body", ); document.remove_extrude(base); @@ -4994,7 +7251,8 @@ mod tests { Some(RebuildStatus::Error(RebuildError::UpstreamUnresolved)), "the downstream extrude is blamed as upstream-unresolved, not a second dangling owner", ); - let wrong = super::compute_whats_wrong(&model, &document); + let strings = strings::make_strings(bone_ui::strings::Locale::EnUs); + let wrong = super::compute_whats_wrong(&model, &document, &strings); assert_eq!( wrong .iter() @@ -5010,10 +7268,9 @@ mod tests { "the dangling face-bound sketch offers reattach", ); assert!( - wrong - .iter() - .any(|entry| entry.message == strings::WHATS_WRONG_UPSTREAM - && entry.reattach.is_none()), + wrong.iter().any(|entry| entry.message + == LabelText::Key(strings::WHATS_WRONG_UPSTREAM) + && entry.reattach.is_none()), "the downstream body reports an upstream failure with no reattach of its own", ); } @@ -5030,133 +7287,751 @@ 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; - } - let Ok(bytes) = std::fs::read(&path) else { - panic!( - "golden {} missing; rerun with BONE_UPDATE_REBUILD_GOLDENS=1 to bless", - path.display(), - ); + fn check_solid_golden(frame: &bone_render::SnapshotFrame, golden_rel: &str) { + use bone_render::{PixelDiff, PixelDiffThreshold, decode_png, encode_png}; + let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(golden_rel); + if std::env::var_os("BONE_UPDATE_REBUILD_GOLDENS").is_some() { + let Ok(bytes) = encode_png(frame) else { + panic!("encode_png failed"); + }; + if let Some(parent) = path.parent() { + let Ok(()) = std::fs::create_dir_all(parent) else { + panic!("create goldens dir failed"); + }; + } + let Ok(()) = std::fs::write(&path, &bytes) else { + panic!("write golden {} failed", path.display()); + }; + return; + } + let Ok(bytes) = std::fs::read(&path) else { + panic!( + "golden {} missing; rerun with BONE_UPDATE_REBUILD_GOLDENS=1 to bless", + path.display(), + ); + }; + let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { + panic!("decode golden failed"); + }; + assert_eq!(golden_extent, frame.extent(), "golden extent drift"); + let threshold = PixelDiffThreshold::new(16.0 / 255.0); + let Ok(report) = PixelDiff::compare(frame, &golden_rgba, threshold) else { + panic!("PixelDiff rejected inputs"); + }; + assert!( + report.is_clean(), + "rebuild-downstream golden drifted: {} mismatches, worst {:?}", + report.over_threshold(), + report.worst(), + ); + } + + #[test] + fn rebuild_downstream_scene_matches_golden() { + use bone_types::FaceRole; + let (mut document, base_sketch) = super::initial_document(&rectangle_sketch()); + let base = document.commit_extrude(sketch_mode::default_extrude_feature(base_sketch)); + let mut model = EvaluatedModel::new(); + full_recompute(&mut model, &document); + + let Some(body) = document.feature_tree().feature_of_extrude(base) else { + panic!("the base extrude resolves to a feature id"); + }; + let Some(solid) = model.body(body) else { + panic!("the base body is evaluated"); + }; + let Some(cap) = solid.iter_faces().find_map(|candidate| { + matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id()) + }) else { + panic!("the box has a planar top cap"); + }; + let Some((face_ref, _basis)) = model.face_for_sketch(cap) else { + panic!("a planar cap yields a face reference and basis"); + }; + + let face_sketch = document.allocate_sketch(); + document.insert_sketch( + face_sketch, + "Sketch2".to_owned(), + 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); + + 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 Some(combined) = super::build_combined_view(&model) else { + panic!("the rebuilt model lowers to a combined view"); + }; + let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256)); + let Ok(camera) = frame_standard_view(combined.aabb, extent, StandardView::Isometric, None) + else { + panic!("the combined aabb frames an isometric camera"); + }; + + let ctx = offscreen_context(extent, 3); + let mut renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format()); + let Ok(frame) = renderer.render_display( + &ctx, + &combined.faces, + &combined.edges, + camera, + &Style::default(), + DisplayMode::ShadedWithEdges, + ) else { + panic!("the rebuilt two-body scene renders"); + }; + check_solid_golden(&frame, "tests/goldens/rebuild_downstream_iso_256.png"); + } + + #[test] + fn 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", + ); + }); + } + + #[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", + ); + }); + } + + #[test] + fn solid_pick_index_builds_for_an_evaluated_body() { + let (mut document, sketch) = super::initial_document(&rectangle_sketch()); + let _extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); + let mut model = EvaluatedModel::new(); + model.recompute( + &document, + document.suppressed(), + document.rollback(), + RecomputeScope::Full, + ); + assert!( + super::solid_pick_index(&model).is_some(), + "an evaluated body yields a solid pick index", + ); + assert!( + super::solid_pick_index(&EvaluatedModel::new()).is_none(), + "an empty model has nothing to pick", + ); + } + + #[test] + fn 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)); + let mut model = EvaluatedModel::new(); + model.recompute( + &document, + document.suppressed(), + document.rollback(), + RecomputeScope::Full, + ); + let Some((body, ordinal, solid)) = model.bodies().next() else { + panic!("the model owns a body"); + }; + let Some(face) = solid.iter_faces().next() else { + panic!("the body owns a face"); + }; + let label = face.label(); + let pick = Some(PickedItem::BrepFace(super::SolidFaceRef::new( + super::BodyKey::new(body, ordinal), + face.id(), + ))); + assert_eq!( + super::captured_reference(&model, ReferenceKind::Face, pick), + Some(shell::CapturedReference::Face(label)), + ); + assert_eq!( + super::captured_reference(&model, ReferenceKind::Body, pick), + Some(shell::CapturedReference::Body(body)), + ); + assert_eq!( + super::captured_reference(&model, ReferenceKind::Vertex, pick), + None, + "a face pick cannot satisfy a vertex reference", + ); + } + + #[test] + fn picked_revolve_axis_maps_an_edge_pick_to_an_edge_axis() { + 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", + ); + } + + #[test] + fn sketch3_tool_keys_select_point_and_line() { + assert_eq!(super::sketch3_tool_for_key("l"), Some(SketchTool::Line)); + assert_eq!(super::sketch3_tool_for_key("P"), Some(SketchTool::Point)); + assert_eq!(super::sketch3_tool_for_key("x"), None); + } + + fn sketch3_overlay_camera(aabb: bone_types::Aabb3) -> (Camera3, bone_render::ViewportRegion) { + 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"); + }; + (camera, bone_render::ViewportRegion::at_origin(extent)) + } + + #[test] + fn sketch3_overlay_splits_lines_and_builds_ball_points() { + let plane = sketch_mode::Plane::Xy.basis(); + let (sketch, start) = sketch3_project::embed_point( + &bone_document::Sketch3::new(), + plane, + Point2::from_mm(0.0, 0.0), + ); + let (sketch, end) = sketch3_project::embed_point(&sketch, plane, Point2::from_mm(5.0, 0.0)); + let (sketch, _solid_line) = sketch3_project::embed_line(&sketch, start, end, false); + 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), + ); + assert_eq!( + overlay.normal_lines.genuine().len(), + 1, + "the plain segment is a normal line", + ); + assert_eq!( + overlay.construction_lines.genuine().len(), + 1, + "the construction segment routes to the dashed layer", + ); + assert!( + overlay.selected_lines.genuine().is_empty(), + "no whole line is selected", + ); + assert!( + !overlay.points.triangles().is_empty(), + "each endpoint renders a shaded ball, not a star", + ); + assert!( + overlay + .normal_lines + .genuine() + .iter() + .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( + &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), + ); + assert!( + overlay.normal_lines.genuine().len() >= 8, + "the circle renders as a tessellated polyline, got {}", + overlay.normal_lines.genuine().len(), + ); + } + + #[test] + fn embed_place_line_with_horizontal_snap_locks_to_plane_x() { + let plane = sketch_mode::Plane::Xy.basis(); + let base = bone_document::Sketch3::new(); + let view0 = sketch3_project::project(&base, plane); + let first = sketch3_project::embed_place( + &base, + &view0, + SketchTool::Line, + Point2::from_mm(0.0, 0.0), + None, + None, + sketch3_project::PlaneLock::Locked, + ); + let view1 = sketch3_project::project(&first.sketch, plane); + let horizontal = SnapHit { + kind: snap::SnapKind::Horizontal, + world: Point2::from_mm(5.0, 0.0), + }; + let second = sketch3_project::embed_place( + &first.sketch, + &view1, + SketchTool::Line, + Point2::from_mm(5.0, 0.0), + first.pending, + Some(horizontal), + sketch3_project::PlaneLock::Locked, + ); + let locked = second + .sketch + .relations() + .values() + .any(|relation| matches!(relation, Sketch3Relation::Horizontal(_))); + assert!( + locked, + "a horizontal snap lands a first-class horizontal relation" + ); + let lines = second + .sketch + .entities() + .values() + .filter(|e| matches!(e, bone_document::Sketch3Entity::Line(_))) + .count(); + assert_eq!(lines, 1, "the second click embeds exactly one line"); + } + + fn place_two_point_line(lock: sketch3_project::PlaneLock) -> bone_document::Sketch3 { + let plane = sketch_mode::Plane::Xy.basis(); + let base = bone_document::Sketch3::new(); + let view0 = sketch3_project::project(&base, plane); + let first = sketch3_project::embed_place( + &base, + &view0, + SketchTool::Line, + Point2::from_mm(0.0, 0.0), + None, + None, + lock, + ); + let view1 = sketch3_project::project(&first.sketch, plane); + let second = sketch3_project::embed_place( + &first.sketch, + &view1, + SketchTool::Line, + Point2::from_mm(5.0, 0.0), + first.pending, + None, + lock, + ); + second.sketch + } + + fn on_plane_count(sketch: &bone_document::Sketch3) -> usize { + sketch + .relations() + .values() + .filter(|relation| matches!(relation, Sketch3Relation::OnPlane { .. })) + .count() + } + + #[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", + ); + } + + #[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", + ); + } + + #[test] + fn nearest_point_snap3_grabs_the_point_under_the_cursor_ray() { + 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), + ); + let Ok(camera) = frame_standard_view(aabb, extent, StandardView::Isometric, None) else { + panic!("the box aabb frames a camera"); }; - let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { - panic!("decode golden failed"); + let Ok(pixel) = ViewportPoint::new(128.0, 128.0) else { + panic!("the center pixel is valid"); }; - 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"); + let Ok((origin, direction)) = bone_render::world_ray(camera, extent, pixel) else { + panic!("the center pixel casts a ray"); }; - assert!( - report.is_clean(), - "rebuild-downstream golden drifted: {} mismatches, worst {:?}", - report.over_threshold(), - report.worst(), + let (ox, oy, oz) = origin.coords_mm(); + let (dx, dy, dz) = direction.components(); + let on_ray = Point3::from_mm( + dx.mul_add(40.0, ox), + dy.mul_add(40.0, oy), + dz.mul_add(40.0, oz), + ); + let off_ray = Point3::from_mm( + dx.mul_add(40.0, ox) + 14.0, + dy.mul_add(40.0, oy), + dz.mul_add(40.0, oz) - 14.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), + Some(sketch3_mode::Snap3::Endpoint { + point: on_id, + 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)), + None, + "excluding the on-ray point leaves nothing within tolerance", ); } #[test] - fn rebuild_downstream_scene_matches_golden() { - use bone_types::FaceRole; - let (mut document, base_sketch) = super::initial_document(rectangle_sketch()); - let base = document.commit_extrude(sketch_mode::default_extrude_feature(base_sketch)); - let mut model = EvaluatedModel::new(); - full_recompute(&mut model, &document); - - let Some(body) = document.feature_tree().feature_of_extrude(base) else { - panic!("the base extrude resolves to a feature id"); - }; - let Some(solid) = model.body(body) else { - panic!("the base body is evaluated"); + 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 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 Ok(pixel) = ViewportPoint::new(128.0, 128.0) else { + panic!("the center pixel is valid"); }; - let Some((face_ref, _basis)) = model.face_for_sketch(cap) else { - panic!("a planar cap yields a face reference and basis"); + 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 m = ( + dx.mul_add(40.0, ox), + dy.mul_add(40.0, oy), + dz.mul_add(40.0, oz), + ); + let plen = dx.hypot(dy); + let (qx, qy, qz) = (dy / plen, -dx / plen, 0.0_f64); + let along = + |k: f64| Point3::from_mm(qx.mul_add(k, m.0), qy.mul_add(k, m.1), qz.mul_add(k, m.2)); + let tol = Length::new::(5.0); + + let (mid_sketch, ma) = bone_document::Sketch3::new() + .with_entity(bone_document::Sketch3Entity::point(along(10.0))); + let (mid_sketch, mb) = + 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) { + Some(sketch3_mode::Snap3::Midpoint { line, .. }) => assert_eq!(line, mid_line), + other => panic!("expected a midpoint snap, got {other:?}"), + } - let face_sketch = document.allocate_sketch(); - document.insert_sketch(face_sketch, "Sketch2".to_owned(), rectangle_sketch()); - let Ok(()) = document.bind_sketch_to_face(face_sketch, face_ref) else { - panic!("binding the second sketch to the cap is acyclic"); - }; - let _ = document.commit_extrude(sketch_mode::default_extrude_feature(face_sketch)); - full_recompute(&mut model, &document); + let (edge_sketch, ea) = bone_document::Sketch3::new() + .with_entity(bone_document::Sketch3Entity::point(along(30.0))); + let (edge_sketch, eb) = + 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) { + Some(sketch3_mode::Snap3::OnEdge { entity, .. }) => assert_eq!(entity, edge_line), + other => panic!("expected an on-edge snap, got {other:?}"), + } - document.replace_sketch(base_sketch, tall_rectangle_sketch()); - full_recompute(&mut model, &document); - assert_eq!( - super::body_passes(&model).len(), - 2, - "editing the base sketch keeps both bodies", + let (rim_sketch, center) = bone_document::Sketch3::new() + .with_entity(bone_document::Sketch3Entity::point(along(-50.0))); + let (rim_sketch, circle) = rim_sketch.with_entity(bone_document::Sketch3Entity::circle( + center, + Length::new::(50.0), + bone_types::UnitVec3::z_axis(), + false, + )); + match sketch3_mode::nearest_snap3(&rim_sketch, camera, extent, pixel, tol, None) { + Some(sketch3_mode::Snap3::OnEdge { entity, .. }) => assert_eq!(entity, circle), + other => panic!("expected a circle-rim on-edge snap, got {other:?}"), + } + } + + #[test] + fn sketch3_overlay_draws_visible_edge_pixels() { + let plane = sketch_mode::Plane::Xy.basis(); + let (sketch, a) = sketch3_project::embed_point( + &bone_document::Sketch3::new(), + plane, + Point2::from_mm(-10.0, -10.0), ); + let (sketch, b) = + sketch3_project::embed_point(&sketch, plane, Point2::from_mm(10.0, -10.0)); + let (sketch, _l1) = sketch3_project::embed_line(&sketch, a, b, false); + let (sketch, c) = sketch3_project::embed_point(&sketch, plane, Point2::from_mm(10.0, 10.0)); + let (sketch, _l2) = sketch3_project::embed_line(&sketch, b, c, false); + let aabb = super::sketch3_aabb_view(&sketch, plane).aabb; - let Some(combined) = super::build_combined_view(&model) else { - panic!("the rebuilt model lowers to a combined view"); - }; let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256)); - let Ok(camera) = frame_standard_view(combined.aabb, extent, StandardView::Isometric, None) - else { - panic!("the combined aabb frames an isometric camera"); + let Ok(camera) = frame_standard_view(aabb, extent, StandardView::Isometric, None) else { + panic!("the overlay aabb frames a camera"); }; - + let overlay = super::build_sketch3_overlay( + &sketch, + &[], + camera, + bone_render::ViewportRegion::at_origin(extent), + LinearRgba::new(0.14, 0.24, 0.62, 1.0), + ); 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, + &overlay.points, + &overlay.normal_lines, camera, &Style::default(), DisplayMode::ShadedWithEdges, ) else { - panic!("the rebuilt two-body scene renders"); + panic!("the overlay renders"); }; - check_solid_golden(&frame, "tests/goldens/rebuild_downstream_iso_256.png"); + 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(); + assert!( + drawn > 50, + "the sketch3 overlay must draw visible edge pixels, drew {drawn}", + ); } #[test] - fn solid_pick_index_builds_for_an_evaluated_body() { - let (mut document, sketch) = super::initial_document(rectangle_sketch()); - let _extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); - let mut model = EvaluatedModel::new(); - model.recompute( - &document, - document.suppressed(), - document.rollback(), - RecomputeScope::Full, + fn sketch3_overlay_renders_through_an_offset_region() { + let plane = sketch_mode::Plane::Xy.basis(); + let (sketch, a) = sketch3_project::embed_point( + &bone_document::Sketch3::new(), + plane, + Point2::from_mm(-32.0, -32.0), + ); + let (sketch, b) = + sketch3_project::embed_point(&sketch, plane, Point2::from_mm(32.0, -32.0)); + let (sketch, _l1) = sketch3_project::embed_line(&sketch, a, b, false); + let (sketch, c) = sketch3_project::embed_point(&sketch, plane, Point2::from_mm(32.0, 32.0)); + let (sketch, _l2) = sketch3_project::embed_line(&sketch, b, c, false); + let (sketch, d) = + sketch3_project::embed_point(&sketch, plane, Point2::from_mm(-32.0, 32.0)); + let (sketch, _l3) = sketch3_project::embed_line(&sketch, c, d, false); + + let surface = ViewportExtent::new(ViewportPx::new(1024), ViewportPx::new(640)); + let region = bone_render::ViewportRegion::new( + ViewportPx::new(320), + ViewportPx::new(96), + ViewportExtent::new(ViewportPx::new(640), ViewportPx::new(480)), + ); + let fallback = bone_types::Aabb3::from_corners( + 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 overlay = super::build_sketch3_overlay( + &sketch, + &[], + camera, + region, + LinearRgba::new(0.14, 0.24, 0.62, 1.0), ); + 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"); + }; + ctx.render_passes(|encoder, color, pick, depth| { + renderer.encode_passes( + encoder, + bone_render::RenderTargets::new(color, pick), + depth, + &overlay.points, + &overlay.normal_lines, + &bone_render::SolidDisplay { + view: &frame_view, + style: &Style::light(), + mode: DisplayMode::ShadedWithEdges, + }, + ); + }); + 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(); assert!( - super::solid_pick_index(&model).is_some(), - "an evaluated body yields a solid pick index", + drawn > 50, + "3d edges must survive an inset viewport offset, drew {drawn}", + ); + } + + #[test] + fn a_center_click_in_sketch3_lands_on_the_plane() { + 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), + ); + let region = bone_render::ViewportRegion::new( + bone_render::ViewportPx::new(320), + bone_render::ViewportPx::new(96), + bone_render::ViewportExtent::new( + bone_render::ViewportPx::new(800), + bone_render::ViewportPx::new(600), + ), ); + let Ok(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 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 (x, y, z) = world.coords_mm(); + assert!(z.abs() < 1e-6, "the hit lies on the XY plane, got z={z}"); assert!( - super::solid_pick_index(&EvaluatedModel::new()).is_none(), - "an empty model has nothing to pick", + x.abs() < 5.0 && y.abs() < 5.0, + "a center click lands near the plane origin, got ({x}, {y})", ); } #[test] fn reattach_binds_a_sketch_to_a_picked_cap_face() { use bone_types::FaceRole; - let (mut document, base_sketch) = super::initial_document(rectangle_sketch()); + let (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( @@ -5180,7 +8055,11 @@ mod tests { panic!("a picked cap yields a face reference"); }; let new_sketch = document.allocate_sketch(); - document.insert_sketch(new_sketch, "Sketch2".to_owned(), rectangle_sketch()); + document.insert_sketch( + new_sketch, + "Sketch2".to_owned(), + super::sketch3_project::embed_sketch(&rectangle_sketch()), + ); assert!( document.bind_sketch_to_face(new_sketch, face_ref).is_ok(), "reattaching a sketch to a planar cap binds cleanly", @@ -5189,7 +8068,7 @@ mod tests { #[test] fn compute_whats_wrong_is_empty_for_a_healthy_document() { - let (mut document, sketch) = super::initial_document(rectangle_sketch()); + 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( @@ -5198,8 +8077,9 @@ mod tests { document.rollback(), RecomputeScope::Full, ); + let strings = strings::make_strings(bone_ui::strings::Locale::EnUs); assert!( - super::compute_whats_wrong(&model, &document).is_empty(), + 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 { @@ -5298,6 +8178,37 @@ mod tests { 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!( + line(ExtrudeError::CutWithoutBody), + LabelText::Key(strings::EXTRUDE_PANEL_CUT_WITHOUT_BODY), + "a cut with no body to remove material from says so, not a generic build failure", + ); + assert_eq!( + line(ExtrudeError::Kernel(BrepError::BooleanUnsupported { + detail: bone_document::BooleanGap::NonConvexFace, + })), + LabelText::Owned("Cut blocked by a non-convex face".to_owned()), + "an unsupported boolean gate names the geometry it choked on", + ); + assert_eq!( + line(ExtrudeError::Kernel(BrepError::BooleanDisjoint)), + LabelText::Key(strings::EXTRUDE_PANEL_CUT_NO_INTERSECT), + ); + assert_eq!( + line(ExtrudeError::Kernel(BrepError::RevolveAxisUnresolved)), + LabelText::Key(strings::EXTRUDE_PANEL_REVOLVE_AXIS), + "a revolve with no centerline names the missing axis, not a generic build failure", + ); + assert_eq!( + line(ExtrudeError::Kernel(BrepError::RevolveProfileCrossesAxis)), + LabelText::Key(strings::EXTRUDE_PANEL_REVOLVE_CROSSES), + ); + } + #[test] fn viewport_local_point_centers_the_inset_viewport_not_the_window() { let region = ViewportRegion::new( @@ -5435,7 +8346,7 @@ mod tests { #[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 (document, sketch_id) = initial_document(&sketch); let anchor = Point2::from_mm(2.0, 3.0); let mode = Mode::Sketch { sketch_id, @@ -5456,7 +8367,7 @@ mod tests { 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 (document, sketch_id) = initial_document(&sketch); let mode = Mode::Sketch { sketch_id, session: Box::new(SketchSession { @@ -5474,7 +8385,7 @@ mod tests { #[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); let anchor = Point2::from_mm(1.0, 1.0); let mode = Mode::Sketch { sketch_id, @@ -5503,7 +8414,7 @@ mod tests { entity: bone_types::SketchEntityId::default(), press: Point2::origin(), pins: DragPins::from_array([ - Some((bone_types::SketchEntityId::default(), Point2::origin())), + Some((bone_types::SketchEntityId::default(), Point3::origin())), None, None, ]), @@ -5523,7 +8434,7 @@ mod tests { #[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); let center = Point2::from_mm(0.0, 0.0); let mode = Mode::Sketch { sketch_id, @@ -5544,7 +8455,7 @@ mod tests { #[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); let corner = Point2::from_mm(0.0, 0.0); let mode = Mode::Sketch { sketch_id, @@ -5566,7 +8477,7 @@ mod tests { 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 (document, sketch_id) = initial_document(&sketch); let mode = Mode::Sketch { sketch_id, session: Box::new(SketchSession { @@ -5584,7 +8495,7 @@ mod tests { #[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); let center = Point2::from_mm(0.0, 0.0); let start = Point2::from_mm(5.0, 0.0); let mode = Mode::Sketch { @@ -5629,6 +8540,102 @@ mod tests { 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", + ); + } + + #[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", + ); + } + + fn armed_3d_line_with_pending() -> Mode { + Mode::Sketch { + sketch_id: SketchId::default(), + session: Box::new(sketch_mode::SketchSession { + tool: Some(SketchTool::Line), + pending: Some(sketch_mode::Pending::First( + sketch_mode::ClickAnchor::Position(Point2::origin()), + )), + spatial: Some(sketch_mode::Spatial { + plane: 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); @@ -5701,7 +8708,11 @@ mod tests { let sketch = bone_document::Sketch::new(Plane::Xy.basis()); let mut document = Document::new(DocumentId::default(), "Untitled".to_owned()); let id = SketchId::default(); - document.insert_sketch(id, "Sketch1".to_owned(), sketch); + document.insert_sketch( + id, + "Sketch1".to_owned(), + super::sketch3_project::embed_sketch(&sketch), + ); (document, id) } @@ -5814,7 +8825,7 @@ mod tests { let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); let mode = Mode::Extrude(ExtrudeArming::edit( id, - sketch_mode::default_extrude_feature(sketch), + &sketch_mode::default_extrude_feature(sketch), )); commit_armed_extrude( &mut document, @@ -5910,7 +8921,7 @@ mod tests { &document, None, ), - Some(armed), + Some(PreviewFeature::Extrude(armed)), "an armed profile previews its own feature", ); assert_eq!( @@ -5926,32 +8937,85 @@ mod tests { let _ = document.commit_extrude(armed); assert_eq!( active_solid_feature(&Mode::Idle, &document, None), - Some(armed), + 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( + sketch, + RevolveAxis::SketchLine(bone_types::SketchEntityId::default()), + bone_document::ExtrudeOperation::default(), + )); + assert!( + matches!( + active_solid_feature(&mode, &document, None), + Some(PreviewFeature::Revolve(_)), + ), + "an armed revolve drives a live solid preview, not a blank viewport", + ); + } + #[test] fn framed_extrude_overrides_last_committed_in_idle() { let (mut document, sketch) = doc_with_default_sketch(); let first_feature = sketch_mode::default_extrude_feature(sketch); let mut second_feature = first_feature; - second_feature.merge_result = bone_document::MergeResult::Separate; + second_feature.operation = + bone_document::ExtrudeOperation::Boss(bone_document::MergeResult::Separate); let first = document.commit_extrude(first_feature); let _second = document.commit_extrude(second_feature); assert_eq!( active_solid_feature(&Mode::Idle, &document, None), - Some(second_feature), + Some(PreviewFeature::Extrude(second_feature)), "with no framed id, idle frames the last-committed extrude", ); assert_eq!( active_solid_feature(&Mode::Idle, &document, Some(first)), - Some(first_feature), + Some(PreviewFeature::Extrude(first_feature)), "a framed id wins over the tree tip, so editing a non-last extrude stays framed", ); assert_eq!( active_solid_feature(&Mode::Idle, &document, Some(ExtrudeId::default())), - Some(second_feature), + Some(PreviewFeature::Extrude(second_feature)), "a stale framed id self-heals to the last-committed extrude", ); } @@ -5993,7 +9057,7 @@ mod tests { }; assert_eq!( next_mode( - Mode::Extrude(ExtrudeArming::AwaitingSketch), + Mode::Extrude(ExtrudeArming::AwaitingSketch(ExtrudeOperation::default())), &frame, false, &xy_only(), @@ -6063,7 +9127,9 @@ mod tests { #[test] fn extrude_scope_isolates_escape_from_sketch_tools() { - let extrude = scopes_for_mode(&Mode::Extrude(ExtrudeArming::AwaitingSketch)); + let extrude = scopes_for_mode(&Mode::Extrude(ExtrudeArming::AwaitingSketch( + ExtrudeOperation::default(), + ))); let in_extrude: Vec<_> = extrude.innermost_first().copied().collect(); assert!(in_extrude.contains(&HotkeyScope::Extrude)); assert!( @@ -6079,7 +9145,9 @@ mod tests { 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)); + let in_extrude = scopes_for_mode(&Mode::Extrude(ExtrudeArming::AwaitingSketch( + ExtrudeOperation::default(), + ))); assert_eq!( table.dispatch(chord, &in_extrude), Some(sketch_mode::ESCAPE_ACTION) @@ -6089,7 +9157,7 @@ mod tests { #[test] fn escape_exits_extrude_to_idle() { let frame = empty_frame(); - let awaiting = Mode::Extrude(ExtrudeArming::AwaitingSketch); + 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); @@ -6098,56 +9166,23 @@ mod tests { #[test] fn cancel_pending_or_exit_drops_extrude_arming() { assert_eq!( - cancel_pending_or_exit(Mode::Extrude(ExtrudeArming::AwaitingSketch)), + cancel_pending_or_exit(Mode::Extrude(ExtrudeArming::AwaitingSketch( + ExtrudeOperation::default() + ))), Mode::Idle ); } #[test] - fn driven_with_value_promotes_kind_and_overwrites_value() { - let proto = SketchDimension::Linear { - a: bone_types::SketchEntityId::default(), - b: bone_types::SketchEntityId::default(), - value: Length::new::(8.0), - kind: DimensionKind::Driving, - }; - let measured = DimensionValue::Length(Length::new::(10.0)); - let Some(driven) = driven_with_value(proto, measured) else { - panic!("driven_with_value rejected matched-kind value"); - }; - assert_eq!(driven.kind(), DimensionKind::Driven); - let DimensionValue::Length(length) = driven.value() else { - panic!("expected Length"); - }; - assert!((length.get::() - 10.0).abs() < 1e-9); - } - - #[test] - fn driven_with_value_rejects_kind_mismatch() { - let proto = SketchDimension::Linear { - a: bone_types::SketchEntityId::default(), - b: bone_types::SketchEntityId::default(), - value: Length::new::(1.0), - kind: DimensionKind::Driving, - }; - let bad = DimensionValue::Angle(bone_types::Angle::new::(1.0)); - assert!(driven_with_value(proto, bad).is_none()); - } - - #[test] - fn dim_conflict_pending_returns_proto_when_set() { - let proto = SketchDimension::Linear { + fn dim_conflict_pending_returns_relation_when_set() { + let relation = Sketch3Relation::Distance { a: bone_types::SketchEntityId::default(), b: bone_types::SketchEntityId::default(), value: Length::new::(2.0), - kind: DimensionKind::Driving, - }; - let pending = PendingDimension { - proto, - anchor: Point2::origin(), + reference: false, }; - let mode = Mode::enter_sketch(SketchId::default()).start_dim_conflict(pending); - assert_eq!(dim_conflict_pending(&mode), Some(pending)); + let mode = Mode::enter_sketch(SketchId::default()).start_dim_conflict(relation); + assert_eq!(dim_conflict_pending(&mode), Some(relation)); } #[test] @@ -6170,31 +9205,41 @@ mod tests { (sketch, a, b, line) } - fn point_at(sketch: &Sketch, id: bone_types::SketchEntityId) -> Point2 { - let SketchEntity::Point(p) = sketch.entities()[id] else { - panic!("expected point entity"); - }; - p.at() - } - #[test] - fn drag_resolved_translates_endpoint_and_preserves_horizontal() { - let (sketch, a, b, _) = horizontal_line_fixture(); - let drag = DragSession { - entity: b, - press: Point2::from_mm(10.0, 0.0), - pins: DragPins::from_array([Some((b, Point2::from_mm(10.0, 0.0))), None, None]), - }; - let cursor = Point2::from_mm(13.0, 0.0); - let Some(next) = drag_resolved(&sketch, drag, cursor) else { - panic!("solve_with_drag_pins must converge on horizontal line") - }; - let (bx, by) = point_at(&next, b).coords_mm(); + fn drag_translates_endpoint_and_preserves_horizontal() { + use bone_document::{Sketch3, Sketch3Relation}; + let plane = SketchPlaneBasis::xy(); + let (s, a) = + sketch3_project::embed_point(&Sketch3::new(), plane, Point2::from_mm(0.0, 0.0)); + let (s, b) = sketch3_project::embed_point(&s, plane, Point2::from_mm(10.0, 0.0)); + let (s, line) = sketch3_project::embed_line(&s, a, b, false); + let (s, _) = s.with_relation(Sketch3Relation::AlongAxis { + line, + 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 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 (Some(b_at), Some(a_at)) = (next.point_position(b), next.point_position(a)) else { + panic!("a and b stay points"); + }; + let (bx, by, bz) = b_at.coords_mm(); assert!((bx - 13.0).abs() < 1e-6, "b.x slides under cursor: {bx}"); - assert!(by.abs() < 1e-6, "horizontal preserved: by={by}"); - let (ax, ay) = point_at(&next, a).coords_mm(); - assert!(ax.abs() < 1e-9, "fixed a.x stays put: {ax}"); - assert!(ay.abs() < 1e-9, "fixed a.y stays put: {ay}"); + assert!(by.abs() < 1e-6 && bz.abs() < 1e-6, "horizontal preserved"); + let (ax, ay, az) = a_at.coords_mm(); + assert!( + ax.abs() < 1e-9 && ay.abs() < 1e-9 && az.abs() < 1e-9, + "fixed a stays put" + ); } #[test] diff --git a/crates/bone-app/src/dimension_editor.rs b/crates/bone-app/src/dimension_editor.rs index c6353af..59756d2 100644 --- a/crates/bone-app/src/dimension_editor.rs +++ b/crates/bone-app/src/dimension_editor.rs @@ -67,11 +67,27 @@ pub fn render( camera: &Camera2, viewport_rect: LayoutRect, state: &mut DimensionEditorState, +) -> DimensionEditorOutcome { + render_at( + ctx, + pending, + world_to_screen(camera, anchor), + viewport_rect, + state, + ) +} + +pub fn render_at( + ctx: &mut FrameCtx<'_>, + pending: PendingDimension, + anchor_screen: LayoutPos, + viewport_rect: LayoutRect, + state: &mut DimensionEditorState, ) -> DimensionEditorOutcome { let editor_id = WidgetId::ROOT.child(WidgetKey::new("dim.editor")); let radius_id = editor_id.child(WidgetKey::new("swap.radius")); let diameter_id = editor_id.child(WidgetKey::new("swap.diameter")); - let editor_rect = editor_rect(viewport_rect, camera, anchor); + let editor_rect = editor_rect_at(viewport_rect, anchor_screen); let mut paints = Vec::new(); if state.last_seeded_for != Some(pending.proto) { state.reset_for(pending.proto); @@ -255,8 +271,8 @@ fn render_swap( } } -fn editor_rect(viewport_rect: LayoutRect, camera: &Camera2, anchor: Point2) -> LayoutRect { - let LayoutPos { x: ax, y: ay } = world_to_screen(camera, anchor); +fn editor_rect_at(viewport_rect: LayoutRect, anchor_screen: LayoutPos) -> LayoutRect { + let LayoutPos { x: ax, y: ay } = anchor_screen; let raw_x = ax.value() + EDITOR_OFFSET_X_PX; let raw_y = ay.value() + EDITOR_OFFSET_Y_PX; let total_width = EDITOR_WIDTH_PX + 2.0 * (SWAP_BUTTON_WIDTH_PX + SWAP_BUTTON_GAP_PX); @@ -296,7 +312,7 @@ fn swap_button_rect(editor_rect: LayoutRect, index: u32) -> LayoutRect { clippy::cast_possible_truncation, reason = "world-to-screen px collapses f64 to LayoutPx (f32) at the sub-pixel limit" )] -fn world_to_screen(camera: &Camera2, world: Point2) -> LayoutPos { +pub(crate) fn world_to_screen(camera: &Camera2, world: Point2) -> LayoutPos { let extent = camera.extent(); let w = f64::from(extent.width().value()); let h = f64::from(extent.height().value()); @@ -526,7 +542,7 @@ mod tests { LayoutSize::new(LayoutPx::new(120.0), LayoutPx::new(60.0)), ); let camera = sample_camera(); - let rect = editor_rect(viewport, &camera, Point2::origin()); + let rect = editor_rect_at(viewport, world_to_screen(&camera, Point2::origin())); assert!(rect.min_x().value() >= viewport.min_x().value()); assert!(rect.min_y().value() >= viewport.min_y().value()); } @@ -539,7 +555,7 @@ mod tests { ); let camera = sample_camera(); let anchor = Point2::from_mm(100.0, 0.0); - let rect = editor_rect(viewport, &camera, anchor); + let rect = editor_rect_at(viewport, world_to_screen(&camera, anchor)); let total_width = EDITOR_WIDTH_PX + 2.0 * (SWAP_BUTTON_WIDTH_PX + SWAP_BUTTON_GAP_PX); assert!(rect.max_x().value() <= viewport.max_x().value()); assert!(rect.min_x().value() + total_width <= viewport.max_x().value() + 1e-3); @@ -644,7 +660,7 @@ mod tests { LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(600.0)), ); - let bounds_probe = editor_rect(viewport, &camera, pending.anchor); + let bounds_probe = editor_rect_at(viewport, world_to_screen(&camera, pending.anchor)); let inside_x = bounds_probe.min_x().value() + 4.0; let inside_y = bounds_probe.min_y().value() + 4.0; diff --git a/crates/bone-app/src/hotkeys.rs b/crates/bone-app/src/hotkeys.rs index 7c9b3d8..bc0fc22 100644 --- a/crates/bone-app/src/hotkeys.rs +++ b/crates/bone-app/src/hotkeys.rs @@ -47,6 +47,7 @@ pub const VIEW_SELECTOR_ACTION: ActionId = action_id(28); pub const VIEW_CUBE_ACTION: ActionId = action_id(29); pub const REBUILD_ACTION: ActionId = action_id(30); pub const FORCE_REBUILD_ACTION: ActionId = action_id(31); +pub const CONVERT_ENTITIES_ACTION: ActionId = action_id(32); const fn ch(c: char) -> KeyCode { KeyCode::Char(KeyChar::from_ascii(c)) @@ -67,6 +68,10 @@ const CTRL_S: KeyChord = KeyChord::new(ch('s'), ModifierMask::CTRL); const CTRL_A: KeyChord = KeyChord::new(ch('a'), ModifierMask::CTRL); const CTRL_Q: KeyChord = KeyChord::new(ch('q'), ModifierMask::CTRL); const CTRL_B: KeyChord = KeyChord::new(ch('b'), ModifierMask::CTRL); +const CTRL_SHIFT_B: KeyChord = + KeyChord::new(ch('b'), ModifierMask::CTRL.union(ModifierMask::SHIFT)); +const CTRL_SHIFT_C: KeyChord = + KeyChord::new(ch('c'), ModifierMask::CTRL.union(ModifierMask::SHIFT)); const CTRL_I: KeyChord = KeyChord::new(ch('i'), ModifierMask::CTRL); const CTRL_E: KeyChord = KeyChord::new(ch('e'), ModifierMask::CTRL); const CTRL_1: KeyChord = KeyChord::new(ch('1'), ModifierMask::CTRL); @@ -104,6 +109,7 @@ pub enum HotkeyCommand { Trim, Extend, Mirror, + ConvertEntities, ToggleConstruction, StandardView(StandardView), ToggleViewSelector, @@ -149,6 +155,20 @@ pub const COMMANDS: &[Command] = &[ label: s::HOTKEY_LABEL_ESCAPE, defaults: &[ESC], }, + Command { + action: ESCAPE_ACTION, + kind: None, + scope: HotkeyScope::Revolve, + label: s::HOTKEY_LABEL_ESCAPE, + defaults: &[ESC], + }, + Command { + action: ESCAPE_ACTION, + kind: None, + scope: HotkeyScope::Datum, + label: s::HOTKEY_LABEL_ESCAPE, + defaults: &[ESC], + }, Command { action: UNDO_ACTION, kind: Some(HotkeyCommand::Undo), @@ -231,7 +251,7 @@ pub const COMMANDS: &[Command] = &[ kind: Some(HotkeyCommand::Quit), scope: HotkeyScope::Global, label: s::HOTKEY_LABEL_QUIT, - defaults: &[], + defaults: &[CTRL_Q], }, Command { action: REBUILD_ACTION, @@ -245,7 +265,7 @@ pub const COMMANDS: &[Command] = &[ kind: Some(HotkeyCommand::ForceRebuild), scope: HotkeyScope::Global, label: s::HOTKEY_LABEL_FORCE_REBUILD, - defaults: &[CTRL_Q], + defaults: &[CTRL_SHIFT_B], }, Command { action: ENTER_SKETCH_ACTION, @@ -282,6 +302,13 @@ pub const COMMANDS: &[Command] = &[ label: s::HOTKEY_LABEL_MIRROR, defaults: &[], }, + Command { + action: CONVERT_ENTITIES_ACTION, + kind: Some(HotkeyCommand::ConvertEntities), + scope: HotkeyScope::Sketch, + label: s::HOTKEY_LABEL_CONVERT_ENTITIES, + defaults: &[CTRL_SHIFT_C], + }, Command { action: TOGGLE_CONSTRUCTION_ACTION, kind: Some(HotkeyCommand::ToggleConstruction), diff --git a/crates/bone-app/src/lib.rs b/crates/bone-app/src/lib.rs index f39bf78..7fcbc5d 100644 --- a/crates/bone-app/src/lib.rs +++ b/crates/bone-app/src/lib.rs @@ -12,6 +12,8 @@ mod selection; mod settings; mod shell; mod shortcut_bar; +mod sketch3_mode; +mod sketch3_project; mod sketch_mode; mod smart_dimension; mod snap; @@ -24,3 +26,5 @@ mod view_cube; pub use app::{AppCore, FrameReport, FrameTarget, OpenError}; pub use clock::{FrameClock, FrameCount, ZeroFrameCount}; pub use input::{InputDispatched, InputEvent, KeyDown, NavKey, ScrollDelta, WindowPoint}; +pub use shell::{CapturedReference, ExtrudeEdit, ReferenceSlot, apply_extrude_reference}; +pub use sketch_mode::ReferenceKind; diff --git a/crates/bone-app/src/selection.rs b/crates/bone-app/src/selection.rs index d8014dc..0557d69 100644 --- a/crates/bone-app/src/selection.rs +++ b/crates/bone-app/src/selection.rs @@ -49,7 +49,9 @@ pub fn picked_to_item(picked: PickedItem) -> Option { PickedItem::Point(id) | PickedItem::Line(id) | PickedItem::Arc(id) - | PickedItem::Circle(id) => Some(SketchItemId::Entity(id)), + | PickedItem::Circle(id) + | PickedItem::Ellipse(id) + | PickedItem::Spline(id) => Some(SketchItemId::Entity(id)), PickedItem::Relation(id) => Some(SketchItemId::Relation(id)), PickedItem::Dimension(id) => Some(SketchItemId::Dimension(id)), PickedItem::BrepFace(_) | PickedItem::BrepEdge(_) | PickedItem::BrepVertex(_) => None, diff --git a/crates/bone-app/src/shell.rs b/crates/bone-app/src/shell.rs index 01ac689..1f99ea8 100644 --- a/crates/bone-app/src/shell.rs +++ b/crates/bone-app/src/shell.rs @@ -3,13 +3,17 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use bone_document::{ - DimensionKind, DimensionValue, Document, ExtrudeEndCondition, ExtrudeFeature, FeatureEdge, - FeatureNode, MergeResult, Sketch, SketchDimension, SketchEntity, SketchRelation, - SketchStatusReport, SketchVersion, + ContourSelection, DatumFeature, DimensionKind, DimensionValue, Direction2, Document, + DraftAngle, DraftDirection, DraftMagnitude, ExtrudeEndCondition, ExtrudeFeature, + ExtrudeOperation, ExtrudeSense, FeatureEdge, FeatureNode, FeatureScope, FromCondition, + MergeResult, RevolveAngle, RevolveAxis, RevolveDirection2, RevolveEndCondition, RevolveFeature, + Sketch, Sketch3Version, SketchDimension, SketchEntity, SketchRelation, SketchStatusReport, + ThinWall, ThinWallDirection, }; use bone_types::{ - Angle, Camera3, ExtrudeId, FeatureId, IconId, Length, Point2, PositiveLength, RollbackMarker, - SketchDimensionId, SketchEntityId, SketchId, + Angle, Camera3, DatumId, ExtrudeId, FaceLabel, FeatureId, IconId, Length, Point2, + PositiveLength, RevolveId, RollbackMarker, SketchDimensionId, SketchEntityId, SketchId, + VertexLabel, }; use bone_ui::a11y::{AccessNode, Role}; use bone_ui::frame::{FrameCtx, InteractDeclaration}; @@ -47,8 +51,8 @@ use crate::selection::Selection; use crate::settings::Settings; use crate::sketch_mode::PendingDimension; use crate::sketch_mode::{ - EndConditionKind, ExtrudeArming, FeatureTool, Mode, Plane, SketchTool, default_extrude_depth, - end_condition_depth, + EndConditionKind, ExtrudeArming, FeatureTool, Mode, ModeExit, Plane, ReferenceKind, + RevolveArming, SketchTool, default_extrude_depth, end_condition_depth, }; use crate::smart_dimension; use crate::status_badge::{ @@ -135,6 +139,9 @@ struct ShellIds { menu_tools_options: WidgetId, menu_tools_keyboard: WidgetId, menu_sketch_exit: WidgetId, + menu_sketch_convert_entities: WidgetId, + menu_sketch_toggle_construction: WidgetId, + menu_sketch_toggle_driven: WidgetId, settings_dialog: WidgetId, settings_aperture_slider: WidgetId, settings_reduce_motion: WidgetId, @@ -212,6 +219,10 @@ impl ShellIds { menu_tools_options: menu_tools.child(WidgetKey::new("options")), menu_tools_keyboard: menu_tools.child(WidgetKey::new("keyboard")), menu_sketch_exit: menu_sketch.child(WidgetKey::new("exit")), + menu_sketch_convert_entities: menu_sketch.child(WidgetKey::new("convert_entities")), + menu_sketch_toggle_construction: menu_sketch + .child(WidgetKey::new("toggle_construction")), + menu_sketch_toggle_driven: menu_sketch.child(WidgetKey::new("toggle_driven")), settings_dialog, settings_aperture_slider: settings_dialog.child(WidgetKey::new("aperture.slider")), settings_reduce_motion: settings_dialog.child(WidgetKey::new("reduce_motion.checkbox")), @@ -249,6 +260,15 @@ impl ShellIds { (self.menu_tools_options, MenuAction::OpenSettings), (self.menu_tools_keyboard, MenuAction::OpenKeyboardCustomize), (self.menu_sketch_exit, MenuAction::ExitSketch), + ( + self.menu_sketch_convert_entities, + MenuAction::ConvertEntities, + ), + ( + self.menu_sketch_toggle_construction, + MenuAction::ToggleConstruction, + ), + (self.menu_sketch_toggle_driven, MenuAction::ToggleDriven), ] .iter() .copied() @@ -271,6 +291,9 @@ pub enum MenuAction { OpenSettings, OpenKeyboardCustomize, ExitSketch, + ConvertEntities, + ToggleConstruction, + ToggleDriven, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -303,7 +326,7 @@ pub enum RollbackChange { #[derive(Clone, Debug, PartialEq)] pub struct WhatsWrong { pub label: String, - pub message: StringKey, + pub message: LabelText, pub is_error: bool, pub reattach: Option, } @@ -345,6 +368,7 @@ pub struct ShellState { pub menu_bar: MenuBarState, pub dim_property: Option, pub extrude_property: Option, + pub revolve_property: Option, pub settings_dialog_open: bool, pub keyboard_dialog_open: bool, pub hotkey_capture: BTreeMap, @@ -356,7 +380,7 @@ pub struct ShellState { pub extrude_panel_open: bool, pub extrude_panel: PanelState, pub property_groups: BTreeMap, - status_cache: Option<(SketchVersion, SketchStatusReport)>, + status_cache: Option<(Sketch3Version, SketchStatusReport)>, pub ribbon_overflow_open: BTreeMap, pub ribbon_active_tab: Option, } @@ -381,79 +405,704 @@ pub enum DimPropertyEditor { }, } +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum FromKind { + SketchPlane, + Offset, + Surface, + Vertex, +} + +impl FromKind { + pub const SUPPORTED: &'static [Self] = + &[Self::SketchPlane, Self::Offset, Self::Surface, Self::Vertex]; + + #[must_use] + fn of(from: &FromCondition) -> Self { + match from { + FromCondition::SketchPlane => Self::SketchPlane, + FromCondition::Offset { .. } => Self::Offset, + FromCondition::Surface { .. } => Self::Surface, + FromCondition::Vertex { .. } => Self::Vertex, + } + } + + #[must_use] + fn reference_kind(self) -> Option { + match self { + Self::Surface => Some(ReferenceKind::Face), + Self::Vertex => Some(ReferenceKind::Vertex), + Self::SketchPlane | Self::Offset => None, + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ReferenceSlot { + Direction1(EndConditionKind), + Direction2(EndConditionKind), + From(FromKind), +} + +impl ReferenceSlot { + #[must_use] + pub fn reference_kind(self) -> Option { + match self { + Self::Direction1(kind) | Self::Direction2(kind) => kind.reference_kind(), + Self::From(kind) => kind.reference_kind(), + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum CapturedReference { + Face(FaceLabel), + Vertex(VertexLabel), + Body(FeatureId), +} + +const DEFAULT_OFFSET_FROM_SURFACE_MM: f64 = 10.0; + +#[must_use] +fn reference_end_condition( + kind: EndConditionKind, + captured: CapturedReference, +) -> Option { + match (kind, captured) { + (EndConditionKind::UpToVertex, CapturedReference::Vertex(vertex)) => { + Some(ExtrudeEndCondition::UpToVertex { vertex }) + } + (EndConditionKind::UpToSurface, CapturedReference::Face(surface)) => { + Some(ExtrudeEndCondition::UpToSurface { surface }) + } + (EndConditionKind::OffsetFromSurface, CapturedReference::Face(surface)) => { + Some(ExtrudeEndCondition::OffsetFromSurface { + surface, + offset: Length::new::(DEFAULT_OFFSET_FROM_SURFACE_MM), + reverse_offset: false, + translate_surface: false, + }) + } + (EndConditionKind::UpToBody, CapturedReference::Body(body)) => { + Some(ExtrudeEndCondition::UpToBody { body }) + } + _ => None, + } +} + +#[must_use] +fn reference_from_condition(kind: FromKind, captured: CapturedReference) -> Option { + match (kind, captured) { + (FromKind::Surface, CapturedReference::Face(surface)) => { + Some(FromCondition::Surface { surface }) + } + (FromKind::Vertex, CapturedReference::Vertex(vertex)) => { + Some(FromCondition::Vertex { vertex }) + } + _ => None, + } +} + +#[must_use] +pub fn apply_extrude_reference( + feature: ExtrudeFeature, + slot: ReferenceSlot, + captured: CapturedReference, +) -> ExtrudeFeature { + match slot { + ReferenceSlot::Direction1(kind) => match reference_end_condition(kind, captured) { + Some(end_condition) => ExtrudeFeature { + end_condition, + ..feature + }, + None => feature, + }, + ReferenceSlot::Direction2(kind) => match reference_end_condition(kind, captured) { + Some(end_condition) => ExtrudeFeature { + direction2: Some(Direction2 { + end_condition, + draft: feature.direction2.and_then(|d| d.draft), + }), + ..feature + }, + None => feature, + }, + ReferenceSlot::From(kind) => match reference_from_condition(kind, captured) { + Some(from) => ExtrudeFeature { from, ..feature }, + None => feature, + }, + } +} + #[derive(Copy, Clone, Debug, PartialEq)] pub enum ExtrudeEdit { EndCondition(EndConditionKind), Depth(PositiveLength), + Offset(Length), + ReverseOffset(bool), + Draft(Option), + Direction2(Option), + Thin(Option), + From(FromCondition), + FlipSide(bool), + Scope(FeatureScope), Merge(MergeResult), + ArmReference(ReferenceSlot), +} + +#[must_use] +fn rebuild_end_condition( + kind: EndConditionKind, + current: &ExtrudeEndCondition, +) -> ExtrudeEndCondition { + match kind { + EndConditionKind::Blind | EndConditionKind::MidPlane => { + let depth = end_condition_depth(current).unwrap_or_else(default_extrude_depth); + kind.with_depth(depth) + } + EndConditionKind::ThroughAll => ExtrudeEndCondition::ThroughAll, + EndConditionKind::ThroughAllBoth => ExtrudeEndCondition::ThroughAllBoth, + EndConditionKind::UpToSurface => match current { + ExtrudeEndCondition::OffsetFromSurface { surface, .. } + | ExtrudeEndCondition::UpToSurface { surface } => { + ExtrudeEndCondition::UpToSurface { surface: *surface } + } + _ => *current, + }, + EndConditionKind::OffsetFromSurface => match current { + ExtrudeEndCondition::UpToSurface { surface } + | ExtrudeEndCondition::OffsetFromSurface { surface, .. } => { + ExtrudeEndCondition::OffsetFromSurface { + surface: *surface, + offset: offset_of(current).unwrap_or_else(|| { + Length::new::(DEFAULT_OFFSET_FROM_SURFACE_MM) + }), + reverse_offset: reverse_offset_of(current), + translate_surface: false, + } + } + _ => *current, + }, + EndConditionKind::UpToVertex | EndConditionKind::UpToBody => *current, + } +} + +#[must_use] +fn offset_of(condition: &ExtrudeEndCondition) -> Option { + match condition { + ExtrudeEndCondition::OffsetFromSurface { offset, .. } => Some(*offset), + _ => None, + } +} + +#[must_use] +fn reverse_offset_of(condition: &ExtrudeEndCondition) -> bool { + matches!( + condition, + ExtrudeEndCondition::OffsetFromSurface { + reverse_offset: true, + .. + } + ) } impl ExtrudeEdit { #[must_use] pub fn apply(self, feature: ExtrudeFeature) -> ExtrudeFeature { match self { - Self::EndCondition(kind) => match end_condition_depth(&feature.end_condition) { - Some(depth) => ExtrudeFeature { + Self::EndCondition(kind) => ExtrudeFeature { + end_condition: rebuild_end_condition(kind, &feature.end_condition), + ..feature + }, + Self::Depth(depth) => match EndConditionKind::of(&feature.end_condition) { + Some(kind) if kind.needs_depth() => ExtrudeFeature { end_condition: kind.with_depth(depth), ..feature }, - None => feature, + _ => feature, }, - Self::Depth(depth) => match EndConditionKind::of(&feature.end_condition) { - Some(kind) => ExtrudeFeature { - end_condition: kind.with_depth(depth), + Self::Offset(offset) => match feature.end_condition { + ExtrudeEndCondition::OffsetFromSurface { + surface, + reverse_offset, + translate_surface, + .. + } => ExtrudeFeature { + end_condition: ExtrudeEndCondition::OffsetFromSurface { + surface, + offset, + reverse_offset, + translate_surface, + }, + ..feature + }, + _ => feature, + }, + Self::ReverseOffset(reverse_offset) => match feature.end_condition { + ExtrudeEndCondition::OffsetFromSurface { + surface, + offset, + translate_surface, + .. + } => ExtrudeFeature { + end_condition: ExtrudeEndCondition::OffsetFromSurface { + surface, + offset, + reverse_offset, + translate_surface, + }, ..feature }, - None => feature, + _ => feature, + }, + Self::Draft(draft) => ExtrudeFeature { draft, ..feature }, + Self::Direction2(direction2) => ExtrudeFeature { + direction2, + ..feature + }, + Self::Thin(thin_wall) => ExtrudeFeature { + thin_wall, + ..feature }, - Self::Merge(merge_result) => ExtrudeFeature { - merge_result, + Self::From(from) => ExtrudeFeature { from, ..feature }, + Self::FlipSide(flip_side) => ExtrudeFeature { + flip_side, ..feature }, + Self::Scope(scope) => ExtrudeFeature { scope, ..feature }, + Self::Merge(merge) => match feature.operation { + ExtrudeOperation::Boss(_) => ExtrudeFeature { + operation: ExtrudeOperation::Boss(merge), + ..feature + }, + ExtrudeOperation::Cut => feature, + }, + Self::ArmReference(_) => feature, + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum RevolveKind { + Blind, + MidPlane, +} + +impl RevolveKind { + const SUPPORTED: &'static [Self] = &[Self::Blind, Self::MidPlane]; + + #[must_use] + fn of(condition: &RevolveEndCondition) -> Self { + match condition { + RevolveEndCondition::MidPlane { .. } => Self::MidPlane, + _ => Self::Blind, + } + } + + #[must_use] + fn label(self) -> StringKey { + match self { + Self::Blind => strings::EXTRUDE_END_BLIND, + Self::MidPlane => strings::EXTRUDE_END_MIDPLANE, + } + } +} + +fn revolve_kind_options() -> Vec { + RevolveKind::SUPPORTED + .iter() + .map(|kind| PropertyOption { + label: kind.label(), + }) + .collect() +} + +fn revolve_kind_index(condition: &RevolveEndCondition) -> usize { + let kind = RevolveKind::of(condition); + RevolveKind::SUPPORTED + .iter() + .position(|candidate| *candidate == kind) + .unwrap_or(0) +} + +fn revolve_kind_from_index(index: Option) -> Option { + index.and_then(|i| RevolveKind::SUPPORTED.get(i).copied()) +} + +fn revolve_angle_value(condition: &RevolveEndCondition) -> Angle { + match condition { + RevolveEndCondition::Blind { angle } | RevolveEndCondition::MidPlane { angle } => { + angle.get() + } + _ => RevolveAngle::full().get(), + } +} + +fn revolve_dir2_angle(direction2: Option) -> Angle { + direction2.map_or_else( + || RevolveAngle::full().get(), + |second| revolve_angle_value(&second.end_condition), + ) +} + +fn revolve_merge_on(feature: &RevolveFeature) -> bool { + matches!( + feature.operation, + ExtrudeOperation::Boss(MergeResult::Merge) + ) +} + +fn with_revolve_angle(kind: RevolveKind, angle: RevolveAngle) -> RevolveEndCondition { + match kind { + RevolveKind::Blind => RevolveEndCondition::Blind { angle }, + RevolveKind::MidPlane => RevolveEndCondition::MidPlane { angle }, + } +} + +fn rebuild_revolve_condition( + kind: RevolveKind, + current: &RevolveEndCondition, +) -> RevolveEndCondition { + let angle = + RevolveAngle::new(revolve_angle_value(current)).unwrap_or_else(|_| RevolveAngle::full()); + with_revolve_angle(kind, angle) +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum RevolveEdit { + Kind(RevolveKind), + Angle(Angle), + Reverse(bool), + DirectionTwo(Option), + Thin(Option), + Scope(FeatureScope), + Merge(MergeResult), + Axis(RevolveAxis), + Contour(ContourSelection), +} + +impl RevolveEdit { + #[must_use] + pub fn apply(self, feature: &RevolveFeature) -> RevolveFeature { + match self { + Self::Kind(kind) => RevolveFeature { + end_condition: rebuild_revolve_condition(kind, &feature.end_condition), + direction2: if matches!(kind, RevolveKind::MidPlane) { + None + } else { + feature.direction2 + }, + ..*feature + }, + Self::Angle(angle) => match RevolveAngle::new(angle) { + Ok(angle) => { + let kind = RevolveKind::of(&feature.end_condition); + RevolveFeature { + end_condition: with_revolve_angle(kind, angle), + ..*feature + } + } + Err(_) => *feature, + }, + Self::Reverse(reverse) => RevolveFeature { + sense: if reverse { + ExtrudeSense::Reverse + } else { + ExtrudeSense::Forward + }, + ..*feature + }, + Self::DirectionTwo(angle) => RevolveFeature { + direction2: angle.map(RevolveDirection2::blind), + ..*feature + }, + Self::Thin(thin_wall) => RevolveFeature { + thin_wall, + ..*feature + }, + Self::Scope(scope) => RevolveFeature { scope, ..*feature }, + Self::Axis(axis) => RevolveFeature { axis, ..*feature }, + Self::Contour(contours) => RevolveFeature { + contours, + ..*feature + }, + Self::Merge(merge) => match feature.operation { + ExtrudeOperation::Boss(_) => RevolveFeature { + operation: ExtrudeOperation::Boss(merge), + ..*feature + }, + ExtrudeOperation::Cut => *feature, + }, + } + } +} + +pub struct RevolvePropertyEditor { + sketch: SketchId, + kind: SelectionEditor, + angle: AngleEditor, + reverse: BoolEditor, + axis: SelectionEditor, + direction_two: BoolEditor, + dir2_angle: AngleEditor, + thin: BoolEditor, + thin_direction: SelectionEditor, + thickness: LengthEditor, + thickness2: LengthEditor, + scope: SelectionEditor, + contours: SelectionEditor, + merge: BoolEditor, + is_cut: bool, +} + +impl RevolvePropertyEditor { + fn new(feature: &RevolveFeature) -> Self { + Self { + sketch: feature.sketch, + kind: SelectionEditor::new( + revolve_kind_options(), + Some(revolve_kind_index(&feature.end_condition)), + ), + angle: AngleEditor::new(revolve_angle_value(&feature.end_condition)), + reverse: BoolEditor::new(matches!(feature.sense, ExtrudeSense::Reverse)), + axis: revolve_axis_editor(feature.axis), + direction_two: BoolEditor::new(feature.direction2.is_some()), + dir2_angle: AngleEditor::new(revolve_dir2_angle(feature.direction2)), + thin: BoolEditor::new(feature.thin_wall.is_some()), + thin_direction: thin_direction_editor(feature.thin_wall), + thickness: LengthEditor::new(thin_thickness_value(feature.thin_wall)), + thickness2: LengthEditor::new(thin_thickness2_value(feature.thin_wall)), + scope: SelectionEditor::new(scope_options(), Some(scope_index(feature.scope))), + contours: revolve_contour_editor(feature.contours), + merge: BoolEditor::new(revolve_merge_on(feature)), + is_cut: feature.operation.is_cut(), } } + + fn synced(mut self, feature: &RevolveFeature) -> Self { + self.kind.current = Some(revolve_kind_index(&feature.end_condition)); + self.angle.value = revolve_angle_value(&feature.end_condition); + self.reverse.value = matches!(feature.sense, ExtrudeSense::Reverse); + self.axis = revolve_axis_editor(feature.axis); + self.direction_two.value = feature.direction2.is_some(); + self.dir2_angle.value = revolve_dir2_angle(feature.direction2); + self.thin.value = feature.thin_wall.is_some(); + self.thin_direction.current = Some(thin_direction_index(feature.thin_wall)); + self.thickness.value = thin_thickness_value(feature.thin_wall); + self.thickness2.value = thin_thickness2_value(feature.thin_wall); + self.scope.current = Some(scope_index(feature.scope)); + self.contours = revolve_contour_editor(feature.contours); + self.merge.value = revolve_merge_on(feature); + self.is_cut = feature.operation.is_cut(); + self + } +} + +const REVOLVE_PICK_INDEX: usize = 1; + +fn revolve_axis_label(axis: RevolveAxis) -> StringKey { + match axis { + RevolveAxis::SketchLine(_) => strings::REVOLVE_AXIS_SKETCH_LINE, + RevolveAxis::Edge(_) => strings::REVOLVE_AXIS_EDGE, + RevolveAxis::Datum(_) => strings::REVOLVE_AXIS_DATUM, + RevolveAxis::Sketch3Line { .. } => strings::REVOLVE_AXIS_3D_SKETCH_LINE, + } +} + +fn revolve_axis_editor(axis: RevolveAxis) -> SelectionEditor { + SelectionEditor::new( + vec![ + PropertyOption { + label: revolve_axis_label(axis), + }, + PropertyOption { + label: strings::REVOLVE_AXIS_SELECT, + }, + ], + Some(0), + ) +} + +fn revolve_contour_label(contours: ContourSelection) -> StringKey { + match contours { + ContourSelection::All => strings::REVOLVE_CONTOUR_ALL, + ContourSelection::Contour(_) => strings::REVOLVE_CONTOUR_ONE, + } +} + +fn revolve_contour_editor(contours: ContourSelection) -> SelectionEditor { + SelectionEditor::new( + vec![ + PropertyOption { + label: revolve_contour_label(contours), + }, + PropertyOption { + label: strings::REVOLVE_CONTOUR_SELECT, + }, + ], + Some(0), + ) +} + +fn resolve_revolve_arm(editor: &RevolvePropertyEditor, changed: &[WidgetId]) -> (bool, bool) { + let arm_axis = changed.contains(&revolve_row_id("axis")) + && editor.axis.current == Some(REVOLVE_PICK_INDEX); + let arm_contour = changed.contains(&revolve_row_id("contours")) + && editor.contours.current == Some(REVOLVE_PICK_INDEX); + (arm_axis, arm_contour) } pub struct ExtrudePropertyEditor { sketch: SketchId, end_condition: SelectionEditor, depth: LengthEditor, + direction1_reference: SelectionEditor, + offset: LengthEditor, + reverse_offset: BoolEditor, draft_enabled: BoolEditor, draft_angle: AngleEditor, + draft_direction: SelectionEditor, direction_two: BoolEditor, + dir2_end_condition: SelectionEditor, + dir2_depth: LengthEditor, + dir2_reference: SelectionEditor, + from_kind: SelectionEditor, + from_distance: LengthEditor, + from_reference: SelectionEditor, + scope: SelectionEditor, + flip_side: BoolEditor, thin: BoolEditor, + thin_direction: SelectionEditor, + thickness: LengthEditor, + thickness2: LengthEditor, + cap_ends: BoolEditor, merge: BoolEditor, + is_cut: bool, +} + +fn merge_on(feature: &ExtrudeFeature) -> bool { + matches!( + feature.operation, + ExtrudeOperation::Boss(MergeResult::Merge) + ) +} + +const DEFAULT_THIN_THICKNESS_MM: f64 = 1.0; + +#[must_use] +fn default_thin_thickness() -> PositiveLength { + let Ok(thickness) = PositiveLength::new(Length::new::(DEFAULT_THIN_THICKNESS_MM)) + else { + unreachable!("constant default thin thickness is positive and finite"); + }; + thickness } impl ExtrudePropertyEditor { - fn new(feature: ExtrudeFeature) -> Self { + fn new(feature: &ExtrudeFeature) -> Self { Self { sketch: feature.sketch, end_condition: SelectionEditor::new( end_condition_options(), Some(kind_index(&feature.end_condition)), ), - depth: LengthEditor::new(current_depth(&feature)), + depth: LengthEditor::new(current_depth(&feature.end_condition)), + direction1_reference: reference_editor(reference_label_of(&feature.end_condition)), + offset: LengthEditor::new(current_offset(&feature.end_condition)), + reverse_offset: BoolEditor::new(reverse_offset_of(&feature.end_condition)), draft_enabled: BoolEditor::new(feature.draft.is_some()), - draft_angle: AngleEditor::new(current_draft_angle(&feature)), - direction_two: BoolEditor::new(false), + draft_angle: AngleEditor::new(current_draft_angle(feature.draft)), + draft_direction: draft_direction_editor(feature.draft), + direction_two: BoolEditor::new(feature.direction2.is_some()), + dir2_end_condition: SelectionEditor::new( + end_condition_options(), + Some(dir2_kind_index(feature.direction2)), + ), + dir2_depth: LengthEditor::new(dir2_depth_value(feature.direction2)), + dir2_reference: reference_editor(dir2_reference_label(feature.direction2)), + from_kind: SelectionEditor::new(from_options(), Some(from_index(&feature.from))), + from_distance: LengthEditor::new(from_distance_value(&feature.from)), + from_reference: reference_editor(from_reference_label(&feature.from)), + scope: SelectionEditor::new(scope_options(), Some(scope_index(feature.scope))), + flip_side: BoolEditor::new(feature.flip_side), thin: BoolEditor::new(feature.thin_wall.is_some()), - merge: BoolEditor::new(matches!(feature.merge_result, MergeResult::Merge)), + thin_direction: thin_direction_editor(feature.thin_wall), + thickness: LengthEditor::new(thin_thickness_value(feature.thin_wall)), + thickness2: LengthEditor::new(thin_thickness2_value(feature.thin_wall)), + cap_ends: BoolEditor::new(thin_cap_ends(feature.thin_wall)), + merge: BoolEditor::new(merge_on(feature)), + is_cut: feature.operation.is_cut(), } } - fn synced(mut self, feature: ExtrudeFeature) -> Self { + fn synced(mut self, feature: &ExtrudeFeature) -> Self { self.end_condition.current = Some(kind_index(&feature.end_condition)); - self.depth.value = current_depth(&feature); + self.depth.value = current_depth(&feature.end_condition); + self.direction1_reference = reference_editor(reference_label_of(&feature.end_condition)); + self.offset.value = current_offset(&feature.end_condition); + self.reverse_offset.value = reverse_offset_of(&feature.end_condition); self.draft_enabled.value = feature.draft.is_some(); - self.draft_angle.value = current_draft_angle(&feature); - self.direction_two.value = false; + self.draft_angle.value = current_draft_angle(feature.draft); + self.draft_direction.current = Some(draft_direction_index(feature.draft)); + self.direction_two.value = feature.direction2.is_some(); + self.dir2_end_condition.current = Some(dir2_kind_index(feature.direction2)); + self.dir2_depth.value = dir2_depth_value(feature.direction2); + self.dir2_reference = reference_editor(dir2_reference_label(feature.direction2)); + self.from_kind.current = Some(from_index(&feature.from)); + self.from_distance.value = from_distance_value(&feature.from); + self.from_reference = reference_editor(from_reference_label(&feature.from)); + self.scope.current = Some(scope_index(feature.scope)); + self.flip_side.value = feature.flip_side; self.thin.value = feature.thin_wall.is_some(); - self.merge.value = matches!(feature.merge_result, MergeResult::Merge); + self.thin_direction.current = Some(thin_direction_index(feature.thin_wall)); + self.thickness.value = thin_thickness_value(feature.thin_wall); + self.thickness2.value = thin_thickness2_value(feature.thin_wall); + self.cap_ends.value = thin_cap_ends(feature.thin_wall); + self.merge.value = merge_on(feature); + self.is_cut = feature.operation.is_cut(); self } } +const REFERENCE_PICK_INDEX: usize = 1; + +fn reference_editor(label: StringKey) -> SelectionEditor { + SelectionEditor::new( + vec![ + PropertyOption { label }, + PropertyOption { + label: strings::EXTRUDE_REFERENCE_SELECT, + }, + ], + Some(0), + ) +} + +fn reference_label_of(condition: &ExtrudeEndCondition) -> StringKey { + match condition { + ExtrudeEndCondition::UpToVertex { .. } + | ExtrudeEndCondition::UpToSurface { .. } + | ExtrudeEndCondition::OffsetFromSurface { .. } + | ExtrudeEndCondition::UpToBody { .. } => strings::EXTRUDE_REFERENCE_SET, + _ => strings::EXTRUDE_REFERENCE_NONE, + } +} + +fn dir2_reference_label(direction2: Option) -> StringKey { + direction2.map_or(strings::EXTRUDE_REFERENCE_NONE, |d| { + reference_label_of(&d.end_condition) + }) +} + +fn from_reference_label(from: &FromCondition) -> StringKey { + match from { + FromCondition::Surface { .. } | FromCondition::Vertex { .. } => { + strings::EXTRUDE_REFERENCE_SET + } + _ => strings::EXTRUDE_REFERENCE_NONE, + } +} + fn end_condition_options() -> Vec { EndConditionKind::SUPPORTED .iter() @@ -466,7 +1115,13 @@ fn end_condition_options() -> Vec { fn end_condition_label(kind: EndConditionKind) -> StringKey { match kind { EndConditionKind::Blind => strings::EXTRUDE_END_BLIND, + EndConditionKind::ThroughAll => strings::EXTRUDE_END_THROUGH_ALL, + EndConditionKind::ThroughAllBoth => strings::EXTRUDE_END_THROUGH_ALL_BOTH, EndConditionKind::MidPlane => strings::EXTRUDE_END_MIDPLANE, + EndConditionKind::UpToVertex => strings::EXTRUDE_END_UP_TO_VERTEX, + EndConditionKind::UpToSurface => strings::EXTRUDE_END_UP_TO_SURFACE, + EndConditionKind::OffsetFromSurface => strings::EXTRUDE_END_OFFSET_FROM_SURFACE, + EndConditionKind::UpToBody => strings::EXTRUDE_END_UP_TO_BODY, } } @@ -482,54 +1137,205 @@ fn kind_from_index(index: Option) -> Option { index.and_then(|i| EndConditionKind::SUPPORTED.get(i).copied()) } -fn current_depth(feature: &ExtrudeFeature) -> Length { - end_condition_depth(&feature.end_condition).map_or_else( - || { - debug_assert!( - false, - "an armed extrude always carries a Blind or MidPlane depth" - ); - default_extrude_depth().get() - }, - PositiveLength::get, +fn dir2_kind_index(direction2: Option) -> usize { + direction2.map_or(0, |d| kind_index(&d.end_condition)) +} + +fn dir2_depth_value(direction2: Option) -> Length { + direction2.map_or_else( + || default_extrude_depth().get(), + |d| current_depth(&d.end_condition), ) } -fn current_draft_angle(feature: &ExtrudeFeature) -> Angle { - feature - .draft - .map_or_else(|| Angle::new::(0.0), |draft| draft.angle().get()) +fn current_depth(condition: &ExtrudeEndCondition) -> Length { + end_condition_depth(condition) + .map_or_else(|| default_extrude_depth().get(), PositiveLength::get) } -#[derive(Clone, Debug, PartialEq)] -pub struct ShellFrame { - pub paints: Vec, - pub overlay_paints: Vec, - pub viewport_rect: LayoutRect, - pub activated_tool: Option, - pub activated_feature_tool: Option, - pub activated_relation: Option, - pub activated_dimension: Option, - pub dimension_edit: Option, - pub extrude_edit: Option, - pub plane_picked: Option, - pub sketch_activated: Option, - pub sketch_rename: Option, - pub extrude_activated: Option, - pub extrude_rename: Option, - pub feature_command: Option, - pub feature_reorder: Option, - pub rollback_change: Option, - pub reattach_request: Option, - pub exit_sketch: bool, - pub confirm_action: Option, - pub menu_action: Option, - pub settings_change: Option, - pub view_pick: Option, - pub view_menu: Option, +fn current_offset(condition: &ExtrudeEndCondition) -> Length { + offset_of(condition) + .unwrap_or_else(|| Length::new::(DEFAULT_OFFSET_FROM_SURFACE_MM)) } -#[derive(Copy, Clone, Debug, PartialEq)] +fn current_draft_angle(draft: Option) -> Angle { + draft.map_or_else(|| Angle::new::(0.0), |d| d.angle().get()) +} + +fn draft_direction_options() -> Vec { + vec![ + PropertyOption { + label: strings::EXTRUDE_DRAFT_OUTWARD, + }, + PropertyOption { + label: strings::EXTRUDE_DRAFT_INWARD, + }, + ] +} + +fn draft_direction_index(draft: Option) -> usize { + match draft.map(DraftAngle::direction) { + Some(DraftDirection::Inward) => 1, + _ => 0, + } +} + +fn draft_direction_editor(draft: Option) -> SelectionEditor { + SelectionEditor::new( + draft_direction_options(), + Some(draft_direction_index(draft)), + ) +} + +fn from_options() -> Vec { + FromKind::SUPPORTED + .iter() + .map(|kind| PropertyOption { + label: from_kind_label(*kind), + }) + .collect() +} + +fn from_kind_label(kind: FromKind) -> StringKey { + match kind { + FromKind::SketchPlane => strings::EXTRUDE_FROM_SKETCH_PLANE, + FromKind::Offset => strings::EXTRUDE_FROM_OFFSET, + FromKind::Surface => strings::EXTRUDE_FROM_SURFACE, + FromKind::Vertex => strings::EXTRUDE_FROM_VERTEX, + } +} + +fn from_index(from: &FromCondition) -> usize { + let kind = FromKind::of(from); + FromKind::SUPPORTED + .iter() + .position(|candidate| *candidate == kind) + .unwrap_or(0) +} + +fn from_kind_from_index(index: Option) -> Option { + index.and_then(|i| FromKind::SUPPORTED.get(i).copied()) +} + +const DEFAULT_FROM_OFFSET_MM: f64 = 10.0; + +fn from_distance_value(from: &FromCondition) -> Length { + match from { + FromCondition::Offset { distance } => *distance, + _ => Length::new::(DEFAULT_FROM_OFFSET_MM), + } +} + +fn scope_options() -> Vec { + vec![ + PropertyOption { + label: strings::EXTRUDE_SCOPE_AUTO, + }, + PropertyOption { + label: strings::EXTRUDE_SCOPE_ALL, + }, + ] +} + +fn scope_index(scope: FeatureScope) -> usize { + match scope { + FeatureScope::AutoSelect => 0, + FeatureScope::AllBodies => 1, + } +} + +fn scope_from_index(index: Option) -> FeatureScope { + match index { + Some(1) => FeatureScope::AllBodies, + _ => FeatureScope::AutoSelect, + } +} + +fn thin_direction_options() -> Vec { + vec![ + PropertyOption { + label: strings::EXTRUDE_THIN_INWARD, + }, + PropertyOption { + label: strings::EXTRUDE_THIN_OUTWARD, + }, + PropertyOption { + label: strings::EXTRUDE_THIN_MIDPLANE, + }, + PropertyOption { + label: strings::EXTRUDE_THIN_TWO_DIRECTION, + }, + ] +} + +fn thin_direction_index(thin: Option) -> usize { + match thin.map(|t| t.direction) { + Some(ThinWallDirection::Outward) => 1, + Some(ThinWallDirection::MidPlane) => 2, + Some(ThinWallDirection::TwoDirection) => 3, + Some(ThinWallDirection::Inward) | None => 0, + } +} + +fn thin_direction_from_index(index: Option) -> ThinWallDirection { + match index { + Some(1) => ThinWallDirection::Outward, + Some(2) => ThinWallDirection::MidPlane, + Some(3) => ThinWallDirection::TwoDirection, + _ => ThinWallDirection::Inward, + } +} + +fn thin_direction_editor(thin: Option) -> SelectionEditor { + SelectionEditor::new(thin_direction_options(), Some(thin_direction_index(thin))) +} + +fn thin_thickness_value(thin: Option) -> Length { + thin.map_or_else(|| default_thin_thickness().get(), |t| t.thickness.get()) +} + +fn thin_thickness2_value(thin: Option) -> Length { + thin.and_then(|t| t.thickness2) + .map_or_else(|| default_thin_thickness().get(), PositiveLength::get) +} + +fn thin_cap_ends(thin: Option) -> bool { + thin.is_some_and(|t| t.cap_ends) +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ShellFrame { + pub paints: Vec, + pub overlay_paints: Vec, + pub viewport_rect: LayoutRect, + pub activated_tool: Option, + pub activated_feature_tool: Option, + pub activated_relation: Option, + pub activated_dimension: Option, + pub dimension_edit: Option, + pub extrude_edit: Option, + pub extrude_arm_reference: Option, + pub revolve_edit: Option, + pub revolve_arm_axis: bool, + pub revolve_arm_contour: bool, + pub plane_picked: Option, + pub sketch_activated: Option, + pub sketch_rename: Option, + pub extrude_activated: Option, + pub extrude_rename: Option, + pub feature_command: Option, + pub feature_reorder: Option, + pub rollback_change: Option, + pub reattach_request: Option, + pub exit_sketch: bool, + pub confirm_action: Option, + pub menu_action: Option, + pub settings_change: Option, + pub view_pick: Option, + pub view_menu: Option, +} + +#[derive(Copy, Clone, Debug, PartialEq)] pub struct DimensionEdit { pub id: SketchDimensionId, pub value: DimensionValue, @@ -547,6 +1353,10 @@ impl ShellFrame { activated_dimension: None, dimension_edit: None, extrude_edit: None, + extrude_arm_reference: None, + revolve_edit: None, + revolve_arm_axis: false, + revolve_arm_contour: false, plane_picked: None, sketch_activated: None, sketch_rename: None, @@ -632,6 +1442,7 @@ impl Shell { extrude_status: Option>, view: &mut ViewUi, feature_badges: &BTreeMap, + body_rows: &[BodyRow], needs_rebuild: bool, whats_wrong: &[WhatsWrong], ) -> ShellFrame { @@ -676,7 +1487,7 @@ impl Shell { ribbon: self.ids.ribbon, ribbon_smart_dimension: self.ids.ribbon_smart_dimension, mode, - sketch: active_sketch, + sketch: active_sketch.as_ref(), selection: entity_ids, }, &mut paints, @@ -688,7 +1499,7 @@ impl Shell { &mut self.state, selection, mode.active_tool(), - mode.is_extrude(), + mode.is_extrude() || mode.is_revolve(), ); let tab_clicked = render_left_pane_tabs( ctx, @@ -714,6 +1525,7 @@ impl Shell { part_id: self.ids.feature_part, document, badges: feature_badges, + body_rows, needs_rebuild, }, &mut paints, @@ -738,27 +1550,35 @@ impl Shell { &mut PaneEditors { dim: &mut self.state.dim_property, extrude: &mut self.state.extrude_property, + revolve: &mut self.state.revolve_property, groups: &mut self.state.property_groups, }, PropertyState { mode, - sketch: active_sketch, + sketch: active_sketch.as_ref(), selection, }, &mut paints, ); let dimension_edit = pane.dimension_edit; let extrude_edit = pane.extrude_edit; + let extrude_arm_reference = pane.extrude_arm_reference; + let revolve_edit = pane.revolve_edit; + let revolve_arm_axis = pane.revolve_arm_axis; + let revolve_arm_contour = pane.revolve_arm_contour; render_doc_tabs(ctx, doc_tabs_rect, &self.ids, &mut paints); - let status_report: Option<&SketchStatusReport> = if let Some(s) = active_sketch { - let v = s.version(); + let status_report: Option<&SketchStatusReport> = if let Some(s3) = + mode.sketch_id().and_then(|id| document.sketch3(id)) + { + let v = s3.version(); if self .state .status_cache .as_ref() .is_none_or(|(cv, _)| *cv != v) { - self.state.status_cache = Some((v, s.status())); + let report = crate::sketch3_project::project_status(s3, s3.plane(), &s3.status()); + self.state.status_cache = Some((v, report)); } self.state.status_cache.as_ref().map(|(_, r)| r) } else { @@ -783,11 +1603,11 @@ impl Shell { if badge_activated == Some(status_badge_id) { self.state.status_panel_open = !self.state.status_panel_open; } - if status_report.is_none_or(|r| r.offending().is_empty()) { + if status_report.is_none_or(|r| r.offending().is_empty() && r.redundant().is_empty()) { self.state.status_panel_open = false; } if self.state.status_panel_open { - if let (Some(report), Some(sketch)) = (status_report, active_sketch) { + if let (Some(report), Some(sketch)) = (status_report, active_sketch.as_ref()) { render_status_panel( ctx, status_panel_widget_id(self.ids.status_bar), @@ -818,8 +1638,14 @@ impl Shell { None => self.state.extrude_panel_open = false, } } - let confirm_visible = - mode.is_sketch() || matches!(mode, Mode::Extrude(ExtrudeArming::Profile { .. })); + let confirm_visible = match mode.lifecycle().exit { + ModeExit::Toggle => true, + ModeExit::Dialog => { + matches!(mode, Mode::Extrude(ExtrudeArming::Profile { .. })) + || matches!(mode, Mode::Revolve(RevolveArming::Profile { .. })) + } + ModeExit::None => false, + }; let confirm = render_confirm_corner(ctx, viewport_rect, &self.ids, confirm_visible, &mut paints); let confirm_action = confirm.or(pane.confirm); @@ -851,13 +1677,13 @@ impl Shell { let activated_relation = resolve_activated_relation( activated_widget, &self.relation_index, - active_sketch, + active_sketch.as_ref(), entity_ids, ); let activated_dimension = resolve_activated_dimension( activated_widget, self.ids.ribbon_smart_dimension, - active_sketch, + active_sketch.as_ref(), entity_ids, ); let plane_picked = feature_tree @@ -949,6 +1775,10 @@ impl Shell { activated_dimension, dimension_edit, extrude_edit, + extrude_arm_reference, + revolve_edit, + revolve_arm_axis, + revolve_arm_contour, plane_picked, sketch_activated, sketch_rename, @@ -1473,8 +2303,10 @@ fn partition_overlay( ) } -fn active_sketch<'a>(document: &'a Document, mode: &Mode) -> Option<&'a Sketch> { - mode.sketch_id().and_then(|id| document.sketch(id)) +fn active_sketch(document: &Document, mode: &Mode) -> Option { + mode.sketch_id() + .and_then(|id| document.sketch3(id)) + .map(|sketch| crate::sketch3_project::project_sketch(sketch, sketch.plane())) } fn resolve_activated_relation( @@ -1511,13 +2343,9 @@ fn smart_dimension_tool_item( id: WidgetId, sketch: Option<&Sketch>, selection: &[SketchEntityId], - sketch_disabled: bool, ) -> ToolbarItem { let item = ToolbarItem::new(id, strings::TOOL_SMART_DIMENSION) .with_icon(RibbonIconSize::Large.slot(IconId::SmartDimension)); - if sketch_disabled { - return item.disabled(true); - } let Some(sketch) = sketch else { return item.disabled(true); }; @@ -1671,11 +2499,28 @@ fn build_menu_entries( entries.push(MenuBarEntry { id: ids.menu_sketch, label: strings::MENU_SKETCH, - items: vec![action_with_accel( - ids.menu_sketch_exit, - strings::MENU_SKETCH_EXIT, - Some(crate::sketch_mode::ESCAPE_ACTION), - )], + items: vec![ + action_with_accel( + ids.menu_sketch_convert_entities, + strings::MENU_SKETCH_CONVERT_ENTITIES, + Some(crate::hotkeys::CONVERT_ENTITIES_ACTION), + ), + action_with_accel( + ids.menu_sketch_toggle_construction, + strings::MENU_SKETCH_TOGGLE_CONSTRUCTION, + Some(crate::hotkeys::TOGGLE_CONSTRUCTION_ACTION), + ), + action_with_accel( + ids.menu_sketch_toggle_driven, + strings::MENU_SKETCH_TOGGLE_DRIVEN, + None, + ), + action_with_accel( + ids.menu_sketch_exit, + strings::MENU_SKETCH_EXIT, + Some(crate::sketch_mode::ESCAPE_ACTION), + ), + ], }); } entries.extend([ @@ -1712,7 +2557,7 @@ fn features_tab_id(ribbon: WidgetId) -> WidgetId { } fn sync_ribbon_tab_to_mode(state: &mut ShellState, ribbon: WidgetId, mode: &Mode) { - let in_sketch = mode.is_sketch(); + let in_sketch = mode.is_sketch() || mode.is_sketch3(); if in_sketch && !state.last_mode_was_sketch { state.ribbon_active_tab = Some(sketch_tab_id(ribbon)); } @@ -1739,6 +2584,7 @@ fn render_ribbon( return None; } let active_tool = mode.active_tool(); + let sketch3_tool = mode.sketch3_tool(); let tools_disabled = !mode.is_sketch(); let label_font_size_px = ctx.theme().typography.caption.size.as_px_f32(); let size_item = |item: ToolbarItem, min_width: LayoutPx| -> ToolbarItem { @@ -1752,17 +2598,18 @@ fn render_ribbon( .iter() .copied() .map(|t| { + let state = entity_tool_state(mode, t, active_tool, sketch3_tool, tools_disabled); size_item( ToolbarItem::new(tool_widget_id(ribbon, t), tool_label(t)) .with_icon(RibbonIconSize::Large.slot(tool_icon(t))) - .active(active_tool == Some(t)) - .disabled(tools_disabled), + .active(state.active) + .disabled(state.disabled), large_min, ) }) .collect(); let dimension_items = vec![size_item( - smart_dimension_tool_item(ribbon_smart_dimension, sketch, selection, tools_disabled), + smart_dimension_tool_item(ribbon_smart_dimension, sketch, selection), large_min, )]; let relation_items: Vec = @@ -1770,7 +2617,10 @@ fn render_ribbon( .into_iter() .map(|item| size_item(item, small_min)) .collect(); - let feature_items = feature_tool_items(ctx, ribbon, mode, large_min); + let extrude_items = feature_tool_items(ctx, ribbon, mode, FeatureTool::EXTRUDE, large_min); + let revolve_items = feature_tool_items(ctx, ribbon, mode, FeatureTool::REVOLVE, large_min); + let datum_items = feature_tool_items(ctx, ribbon, mode, FeatureTool::DATUM, large_min); + let sketch3_items = feature_tool_items(ctx, ribbon, mode, FeatureTool::SKETCH3, large_min); let sketch_tab_id = sketch_tab_id(ribbon); let features_tab_id = features_tab_id(ribbon); let groups = build_sketch_groups( @@ -1782,7 +2632,15 @@ fn render_ribbon( small_min, overflow_open, ); - let feature_groups = build_feature_groups(ribbon, feature_items, large_min, overflow_open); + let feature_groups = build_feature_groups( + ribbon, + extrude_items, + revolve_items, + datum_items, + sketch3_items, + large_min, + overflow_open, + ); let placeholder_tab = |key: &'static str, label: StringKey| { RibbonTab::new(ribbon.child(WidgetKey::new(key)), label, Vec::new()).disabled(true) }; @@ -1867,21 +2725,44 @@ fn build_sketch_groups( fn build_feature_groups( ribbon: WidgetId, - feature_items: Vec, + extrude_items: Vec, + revolve_items: Vec, + datum_items: Vec, + sketch3_items: Vec, large_min: LayoutPx, overflow_open: &BTreeMap, ) -> Vec { - let extrude_id = ribbon.child(WidgetKey::new("group.extrude")); - vec![RibbonGroup { - id: extrude_id, - label: strings::RIBBON_GROUP_EXTRUDE, - min_width: group_min_width(large_min, feature_items.len()), - width: group_width_for(&feature_items, large_min, RibbonIconSize::Large.rows()), - items: feature_items, - icon_size: RibbonIconSize::Large, - overflow_open: overflow_open.get(&extrude_id).copied().unwrap_or(false), - overflow_label: Some(strings::TOOLBAR_OVERFLOW), - }] + let feature_group = |key: &'static str, label: StringKey, items: Vec| { + let id = ribbon.child(WidgetKey::new(key)); + RibbonGroup { + id, + label, + min_width: group_min_width(large_min, items.len()), + width: group_width_for(&items, large_min, RibbonIconSize::Large.rows()), + items, + icon_size: RibbonIconSize::Large, + overflow_open: overflow_open.get(&id).copied().unwrap_or(false), + overflow_label: Some(strings::TOOLBAR_OVERFLOW), + } + }; + vec![ + feature_group( + "group.extrude", + strings::RIBBON_GROUP_EXTRUDE, + extrude_items, + ), + feature_group( + "group.revolve", + strings::RIBBON_GROUP_REVOLVE, + revolve_items, + ), + feature_group("group.datum", strings::RIBBON_GROUP_DATUM, datum_items), + feature_group( + "group.sketch3", + strings::RIBBON_GROUP_SKETCH3, + sketch3_items, + ), + ] } fn process_ribbon_response( @@ -2009,7 +2890,9 @@ fn consumed_sketches(document: &Document) -> BTreeSet { Some(FeatureNode::Sketch(sketch_id)) => Some(sketch_id), _ => None, }, - FeatureEdge::FaceToSketch { .. } => None, + FeatureEdge::FaceToSketch { .. } + | FeatureEdge::ReferenceToDatum { .. } + | FeatureEdge::AxisToRevolve { .. } => None, }) .collect() } @@ -2018,6 +2901,7 @@ fn feature_tree_children( document: &Document, part_id: WidgetId, badges: &BTreeMap, + body_rows: &[BodyRow], ) -> Vec { let leaf = |key: &'static str, label: StringKey| { TreeNode::leaf(part_id.child(WidgetKey::new(key)), label) @@ -2031,7 +2915,7 @@ fn feature_tree_children( placeholder("history", strings::FEATURE_HISTORY), placeholder("sensors", strings::FEATURE_SENSORS), placeholder("annotations", strings::FEATURE_ANNOTATIONS), - placeholder("solid_bodies", strings::FEATURE_SOLID_BODIES), + solid_bodies_node(part_id, body_rows), placeholder("material", strings::FEATURE_MATERIAL), plane_leaf("plane.xy", strings::FEATURE_PLANE_XY), plane_leaf("plane.yz", strings::FEATURE_PLANE_YZ), @@ -2043,6 +2927,28 @@ fn feature_tree_children( .collect() } +fn solid_bodies_node(part_id: WidgetId, body_rows: &[BodyRow]) -> TreeNode { + let folder_id = part_id.child(WidgetKey::new("solid_bodies")); + if body_rows.is_empty() { + return TreeNode::leaf(folder_id, strings::FEATURE_SOLID_BODIES) + .with_icon(IconId::TreeFeature) + .disabled(true); + } + let children: Vec = body_rows + .iter() + .enumerate() + .map(|(index, row)| { + let id = folder_id.child_indexed( + WidgetKey::new("body"), + u64::try_from(index).unwrap_or(u64::MAX), + ); + TreeNode::leaf_owned(id, row.label.clone()).with_icon(IconId::TreeFeature) + }) + .collect(); + TreeNode::parent(folder_id, strings::FEATURE_SOLID_BODIES, children) + .with_icon(IconId::TreeFeature) +} + fn tree_illegal_drop( document: &Document, state: &TreeViewState, @@ -2070,6 +2976,12 @@ fn feature_rows( FeatureNode::Extrude(extrude_id) => { Some(extrude_feature_node(document, part_id, extrude_id, badges)) } + FeatureNode::Revolve(revolve_id) => { + Some(revolve_feature_node(document, part_id, revolve_id, badges)) + } + FeatureNode::Datum(datum_id) => { + Some(datum_feature_node(document, part_id, datum_id, badges)) + } FeatureNode::Sketch(sketch_id) if !consumed.contains(&sketch_id) => { Some(sketch_leaf(document, part_id, sketch_id, badges)) } @@ -2078,6 +2990,64 @@ fn feature_rows( .collect() } +fn datum_widget_id(part_id: WidgetId, datum_id: DatumId) -> WidgetId { + part_id.child_indexed(WidgetKey::new("datum"), datum_id.as_u64()) +} + +fn revolve_widget_id(part_id: WidgetId, revolve_id: RevolveId) -> WidgetId { + part_id.child_indexed(WidgetKey::new("revolve"), revolve_id.as_u64()) +} + +fn revolve_icon(feature: Option<&RevolveFeature>) -> IconId { + match feature.map(|feature| feature.operation) { + Some(ExtrudeOperation::Cut) => IconId::RevolvedCut, + _ => IconId::RevolvedBossBase, + } +} + +fn revolve_feature_node( + document: &Document, + part_id: WidgetId, + revolve_id: RevolveId, + badges: &BTreeMap, +) -> TreeNode { + let widget_id = revolve_widget_id(part_id, revolve_id); + let label = document.revolve_label(revolve_id).unwrap_or("").to_owned(); + let feature = document.feature_tree().feature_of_revolve(revolve_id); + let badge = feature.and_then(|feature| badges.get(&feature).copied()); + let rolled_back = feature.is_some_and(|feature| document.is_rolled_back(feature)); + TreeNode::leaf_owned(widget_id, label) + .with_icon(revolve_icon(document.revolve(revolve_id))) + .with_badge(badge) + .disabled(rolled_back) +} + +fn datum_icon(feature: Option<&DatumFeature>) -> IconId { + match feature { + Some(DatumFeature::Plane(_)) => IconId::TreePlane, + Some(DatumFeature::Axis(_)) => IconId::Line, + Some(DatumFeature::Point(_)) => IconId::Point, + Some(DatumFeature::CoordinateSystem(_)) | None => IconId::TreeOrigin, + } +} + +fn datum_feature_node( + document: &Document, + part_id: WidgetId, + datum_id: DatumId, + badges: &BTreeMap, +) -> TreeNode { + let widget_id = datum_widget_id(part_id, datum_id); + let label = document.datum_label(datum_id).unwrap_or("").to_owned(); + let feature = document.feature_tree().feature_of_datum(datum_id); + let badge = feature.and_then(|feature| badges.get(&feature).copied()); + let rolled_back = feature.is_some_and(|feature| document.is_rolled_back(feature)); + TreeNode::leaf_owned(widget_id, label) + .with_icon(datum_icon(document.datum(datum_id))) + .with_badge(badge) + .disabled(rolled_back) +} + fn sketch_widget_ids(document: &Document, part_id: WidgetId) -> Vec<(SketchId, WidgetId)> { document .sketches() @@ -2097,6 +3067,11 @@ fn extrude_widget_ids(document: &Document, part_id: WidgetId) -> Vec<(ExtrudeId, .collect() } +#[derive(Clone, Debug, PartialEq)] +pub struct BodyRow { + pub label: String, +} + #[derive(Copy, Clone)] struct FeatureTreeInputs<'a> { rect: LayoutRect, @@ -2104,6 +3079,7 @@ struct FeatureTreeInputs<'a> { part_id: WidgetId, document: &'a Document, badges: &'a BTreeMap, + body_rows: &'a [BodyRow], needs_rebuild: bool, } @@ -2161,6 +3137,7 @@ fn render_feature_tree( part_id, document, badges, + body_rows, needs_rebuild, } = inputs; if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { @@ -2177,7 +3154,7 @@ fn render_feature_tree( sketch_ids.iter().map(|(s, w)| (*w, *s)).collect(); let widget_to_extrude: BTreeMap = extrude_ids.iter().map(|(e, w)| (*w, *e)).collect(); - let children = feature_tree_children(document, part_id, badges); + let children = feature_tree_children(document, part_id, badges, body_rows); let part_badge = [ badges.values().copied().max(), needs_rebuild.then_some(TreeBadge::RebuildNeeded), @@ -2500,7 +3477,7 @@ fn render_feature_context_menu( } const REL_ROW_PX: f32 = 22.0; -const REL_PANEL_WIDTH_PX: f32 = 280.0; +const REL_PANEL_WIDTH_PX: f32 = 360.0; const REL_PAD_PX: f32 = 10.0; #[derive(Copy, Clone)] @@ -2525,6 +3502,9 @@ fn feature_label(document: &Document, feature: FeatureId) -> Option { FeatureNode::Extrude(extrude_id) => { Some(document.extrude_label(extrude_id).unwrap_or("").to_owned()) } + FeatureNode::Datum(datum_id) => { + Some(document.datum_label(datum_id).unwrap_or("").to_owned()) + } _ => None, } } @@ -2724,24 +3704,24 @@ fn whats_wrong_row_paint( LayoutPx::new(top), ), LayoutSize::new( - LayoutPx::saturating_nonneg(body.size.width.value() * 0.55 - REL_PAD_PX), + LayoutPx::saturating_nonneg(body.size.width.value() * 0.78 - REL_PAD_PX), LayoutPx::new(REL_ROW_PX), ), ); paints.push(WidgetPaint::AlignedLabel { rect: message_rect, - text: LabelText::Key(entry.message), + text: entry.message.clone(), color: message_color, role: ctx.theme().typography.body, align: HorizontalAlign::Start, }); let right_rect = LayoutRect::new( LayoutPos::new( - LayoutPx::new(body.origin.x.value() + body.size.width.value() * 0.55), + LayoutPx::new(body.origin.x.value() + body.size.width.value() * 0.78), LayoutPx::new(top), ), LayoutSize::new( - LayoutPx::saturating_nonneg(body.size.width.value() * 0.45 - REL_PAD_PX), + LayoutPx::saturating_nonneg(body.size.width.value() * 0.22 - REL_PAD_PX), LayoutPx::new(REL_ROW_PX), ), ); @@ -2882,12 +3862,17 @@ struct PropertyState<'a> { struct PropertyPaneOutcome { dimension_edit: Option, extrude_edit: Option, + extrude_arm_reference: Option, + revolve_edit: Option, + revolve_arm_axis: bool, + revolve_arm_contour: bool, confirm: Option, } struct PaneEditors<'a> { dim: &'a mut Option, extrude: &'a mut Option, + revolve: &'a mut Option, groups: &'a mut BTreeMap, } @@ -2912,21 +3897,46 @@ fn render_property_pane( if !matches!(state.mode, Mode::Extrude(ExtrudeArming::Profile { .. })) { *editors.extrude = None; } + if !matches!(state.mode, Mode::Revolve(RevolveArming::Profile { .. })) { + *editors.revolve = None; + } if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { return PropertyPaneOutcome::default(); } + if let Mode::Datum(arming) = state.mode { + let confirm = render_datum_rows(ctx, rect, id, clipboard, &arming.feature, paints); + return PropertyPaneOutcome { + confirm, + ..PropertyPaneOutcome::default() + }; + } if let Mode::Extrude(arming) = state.mode { return match arming { ExtrudeArming::Profile { feature, .. } => { let outcome = - render_extrude_rows(ctx, rect, id, clipboard, editors, *feature, paints); + render_extrude_rows(ctx, rect, id, clipboard, editors, feature, paints); PropertyPaneOutcome { dimension_edit: None, extrude_edit: outcome.edit, + extrude_arm_reference: outcome.arm_reference, + revolve_edit: None, confirm: outcome.confirm, + ..PropertyPaneOutcome::default() } } - ExtrudeArming::AwaitingSketch => { + ExtrudeArming::AwaitingSketch(_) => { + let mut editors = vec![row_editor(strings::EXTRUDE_PROMPT_SELECT_SKETCH, "")]; + render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); + PropertyPaneOutcome::default() + } + }; + } + if let Mode::Revolve(arming) = state.mode { + return match arming { + RevolveArming::Profile { feature, .. } => { + render_revolve_rows(ctx, rect, id, clipboard, editors, feature, paints).into_pane() + } + RevolveArming::AwaitingSketch(_) => { let mut editors = vec![row_editor(strings::EXTRUDE_PROMPT_SELECT_SKETCH, "")]; render_static_rows(ctx, rect, id, clipboard, &mut editors, paints); PropertyPaneOutcome::default() @@ -2961,8 +3971,7 @@ fn render_property_pane( sketch, paints, ), - extrude_edit: None, - confirm: None, + ..PropertyPaneOutcome::default() } } None => { @@ -2973,10 +3982,10 @@ fn render_property_pane( } } -fn sync_extrude_editor( - slot: &mut Option, - feature: ExtrudeFeature, -) -> &mut ExtrudePropertyEditor { +fn sync_extrude_editor<'slot>( + slot: &'slot mut Option, + feature: &ExtrudeFeature, +) -> &'slot mut ExtrudePropertyEditor { let editor = match slot.take() { Some(editor) if editor.sketch == feature.sketch => editor.synced(feature), _ => ExtrudePropertyEditor::new(feature), @@ -2991,6 +4000,7 @@ const PM_GROUP_GAP: f32 = 6.0; struct ExtrudeRowsOutcome { edit: Option, + arm_reference: Option, confirm: Option, } @@ -3008,56 +4018,236 @@ fn extrude_row_id(key: &'static str) -> WidgetId { .child(WidgetKey::new(key)) } -fn direction_group_rows(editor: &mut ExtrudePropertyEditor) -> Vec> { - vec![ - PropertyRow { - id: extrude_row_id("end"), - label: strings::PROPERTY_ROW_EXTRUDE_END, - editor: &mut editor.end_condition, - read_only: false, - }, - PropertyRow { +fn direction_group_rows( + editor: &mut ExtrudePropertyEditor, + end_kind: EndConditionKind, +) -> Vec> { + let draft_on = editor.draft_enabled.value; + let ExtrudePropertyEditor { + end_condition, + depth, + direction1_reference, + offset, + reverse_offset, + draft_enabled, + draft_angle, + draft_direction, + .. + } = editor; + let mut rows = vec![PropertyRow { + id: extrude_row_id("end"), + label: strings::PROPERTY_ROW_EXTRUDE_END, + editor: end_condition, + read_only: false, + }]; + if end_kind.needs_depth() { + rows.push(PropertyRow { id: extrude_row_id("depth"), label: strings::PROPERTY_ROW_EXTRUDE_DEPTH, - editor: &mut editor.depth, + editor: depth, read_only: false, - }, - PropertyRow { - id: extrude_row_id("draft"), - label: strings::PROPERTY_ROW_EXTRUDE_DRAFT, - editor: &mut editor.draft_enabled, - read_only: true, - }, - PropertyRow { + }); + } + if end_kind.needs_reference() { + rows.push(PropertyRow { + id: extrude_row_id("d1_reference"), + label: strings::PROPERTY_ROW_EXTRUDE_REFERENCE, + editor: direction1_reference, + read_only: false, + }); + } + if matches!(end_kind, EndConditionKind::OffsetFromSurface) { + rows.push(PropertyRow { + id: extrude_row_id("offset"), + label: strings::PROPERTY_ROW_EXTRUDE_OFFSET, + editor: offset, + read_only: false, + }); + rows.push(PropertyRow { + id: extrude_row_id("reverse_offset"), + label: strings::PROPERTY_ROW_EXTRUDE_REVERSE_OFFSET, + editor: reverse_offset, + read_only: false, + }); + } + rows.push(PropertyRow { + id: extrude_row_id("draft"), + label: strings::PROPERTY_ROW_EXTRUDE_DRAFT, + editor: draft_enabled, + read_only: false, + }); + if draft_on { + rows.push(PropertyRow { id: extrude_row_id("draft_angle"), label: strings::PROPERTY_ROW_EXTRUDE_DRAFT_ANGLE, - editor: &mut editor.draft_angle, - read_only: true, - }, - PropertyRow { - id: extrude_row_id("direction_two"), - label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION_TWO, - editor: &mut editor.direction_two, - read_only: true, - }, - ] + editor: draft_angle, + read_only: false, + }); + rows.push(PropertyRow { + id: extrude_row_id("draft_direction"), + label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION, + editor: draft_direction, + read_only: false, + }); + } + rows +} + +fn direction_two_group_rows( + editor: &mut ExtrudePropertyEditor, + dir2_kind: EndConditionKind, +) -> Vec> { + let direction_two_on = editor.direction_two.value; + let ExtrudePropertyEditor { + direction_two, + dir2_end_condition, + dir2_depth, + dir2_reference, + .. + } = editor; + let mut rows = vec![PropertyRow { + id: extrude_row_id("direction_two"), + label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION_TWO, + editor: direction_two, + read_only: false, + }]; + if direction_two_on { + rows.push(PropertyRow { + id: extrude_row_id("d2_end"), + label: strings::PROPERTY_ROW_EXTRUDE_END, + editor: dir2_end_condition, + read_only: false, + }); + if dir2_kind.needs_depth() { + rows.push(PropertyRow { + id: extrude_row_id("d2_depth"), + label: strings::PROPERTY_ROW_EXTRUDE_DEPTH, + editor: dir2_depth, + read_only: false, + }); + } + if dir2_kind.needs_reference() { + rows.push(PropertyRow { + id: extrude_row_id("d2_reference"), + label: strings::PROPERTY_ROW_EXTRUDE_REFERENCE, + editor: dir2_reference, + read_only: false, + }); + } + } + rows +} + +fn from_group_rows( + editor: &mut ExtrudePropertyEditor, + from_kind: FromKind, +) -> Vec> { + let ExtrudePropertyEditor { + from_kind: from_kind_editor, + from_distance, + from_reference, + .. + } = editor; + let mut rows = vec![PropertyRow { + id: extrude_row_id("from_kind"), + label: strings::PROPERTY_ROW_EXTRUDE_FROM, + editor: from_kind_editor, + read_only: false, + }]; + if matches!(from_kind, FromKind::Offset) { + rows.push(PropertyRow { + id: extrude_row_id("from_distance"), + label: strings::PROPERTY_ROW_EXTRUDE_FROM_DISTANCE, + editor: from_distance, + read_only: false, + }); + } + if from_kind.reference_kind().is_some() { + rows.push(PropertyRow { + id: extrude_row_id("from_reference"), + label: strings::PROPERTY_ROW_EXTRUDE_REFERENCE, + editor: from_reference, + read_only: false, + }); + } + rows } fn scope_group_rows(editor: &mut ExtrudePropertyEditor) -> Vec> { - vec![ - PropertyRow { - id: extrude_row_id("thin"), - label: strings::PROPERTY_ROW_EXTRUDE_THIN, - editor: &mut editor.thin, - read_only: true, - }, - PropertyRow { + let is_cut = editor.is_cut; + let thin_on = editor.thin.value; + let two_direction = matches!( + thin_direction_from_index(editor.thin_direction.current), + ThinWallDirection::TwoDirection + ); + let ExtrudePropertyEditor { + scope, + flip_side, + thin, + thin_direction, + thickness, + thickness2, + cap_ends, + merge, + .. + } = editor; + let mut rows = vec![PropertyRow { + id: extrude_row_id("scope"), + label: strings::PROPERTY_ROW_EXTRUDE_SCOPE, + editor: scope, + read_only: false, + }]; + if is_cut { + rows.push(PropertyRow { + id: extrude_row_id("flip_side"), + label: strings::PROPERTY_ROW_EXTRUDE_FLIP_SIDE, + editor: flip_side, + read_only: false, + }); + } + rows.push(PropertyRow { + id: extrude_row_id("thin"), + label: strings::PROPERTY_ROW_EXTRUDE_THIN, + editor: thin, + read_only: false, + }); + if thin_on { + rows.push(PropertyRow { + id: extrude_row_id("thin_direction"), + label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION, + editor: thin_direction, + read_only: false, + }); + rows.push(PropertyRow { + id: extrude_row_id("thickness"), + label: strings::PROPERTY_ROW_EXTRUDE_THICKNESS, + editor: thickness, + read_only: false, + }); + if two_direction { + rows.push(PropertyRow { + id: extrude_row_id("thickness2"), + label: strings::PROPERTY_ROW_EXTRUDE_THICKNESS_TWO, + editor: thickness2, + read_only: false, + }); + } + rows.push(PropertyRow { + id: extrude_row_id("cap_ends"), + label: strings::PROPERTY_ROW_EXTRUDE_CAP_ENDS, + editor: cap_ends, + read_only: false, + }); + } + if !is_cut { + rows.push(PropertyRow { id: extrude_row_id("merge"), label: strings::PROPERTY_ROW_EXTRUDE_MERGE, - editor: &mut editor.merge, + editor: merge, read_only: false, - }, - ] + }); + } + rows } fn render_property_group( @@ -3146,78 +4336,677 @@ fn extrude_pane_header( (confirm, header.paint) } -fn render_extrude_rows( +fn sync_revolve_editor<'slot>( + slot: &'slot mut Option, + feature: &RevolveFeature, +) -> &'slot mut RevolvePropertyEditor { + let editor = match slot.take() { + Some(editor) if editor.sketch == feature.sketch => editor.synced(feature), + _ => RevolvePropertyEditor::new(feature), + }; + slot.insert(editor) +} + +fn revolve_row_id(key: &'static str) -> WidgetId { + WidgetId::ROOT + .child(WidgetKey::new("props.revolve")) + .child(WidgetKey::new(key)) +} + +struct RevolveRowsOutcome { + edit: Option, + arm_axis: bool, + arm_contour: bool, + confirm: Option, +} + +impl RevolveRowsOutcome { + fn into_pane(self) -> PropertyPaneOutcome { + PropertyPaneOutcome { + revolve_edit: self.edit, + revolve_arm_axis: self.arm_axis, + revolve_arm_contour: self.arm_contour, + confirm: self.confirm, + ..PropertyPaneOutcome::default() + } + } +} + +fn revolve_pane_header( + ctx: &mut FrameCtx<'_>, + id: WidgetId, + rect: LayoutRect, +) -> (Option, Vec) { + let header_id = id.child(WidgetKey::new("header")); + let header = show_property_pane_header( + ctx, + PropertyPaneHeader { + id: header_id, + rect: LayoutRect::new( + rect.origin, + LayoutSize::new(rect.size.width, LayoutPx::new(PM_HEADER_HEIGHT)), + ), + title: strings::PROPERTY_PANE_REVOLVE_TITLE, + accept_id: header_id.child(WidgetKey::new("accept")), + cancel_id: header_id.child(WidgetKey::new("cancel")), + }, + ); + let confirm = match header.action { + Some(PropertyPaneAction::Accept) => Some(ConfirmAction::Accept), + Some(PropertyPaneAction::Cancel) => Some(ConfirmAction::Cancel), + None => None, + }; + (confirm, header.paint) +} + +fn revolve_direction_group_rows(editor: &mut RevolvePropertyEditor) -> Vec> { + let RevolvePropertyEditor { + kind, + angle, + reverse, + axis, + .. + } = editor; + vec![ + PropertyRow { + id: revolve_row_id("axis"), + label: strings::PROPERTY_ROW_REVOLVE_AXIS, + editor: axis, + read_only: false, + }, + PropertyRow { + id: revolve_row_id("kind"), + label: strings::PROPERTY_ROW_REVOLVE_TYPE, + editor: kind, + read_only: false, + }, + PropertyRow { + id: revolve_row_id("angle"), + label: strings::PROPERTY_ROW_REVOLVE_ANGLE, + editor: angle, + read_only: false, + }, + PropertyRow { + id: revolve_row_id("reverse"), + label: strings::PROPERTY_ROW_REVOLVE_REVERSE, + editor: reverse, + read_only: false, + }, + ] +} + +fn revolve_direction_two_group_rows(editor: &mut RevolvePropertyEditor) -> Vec> { + let direction_two_on = editor.direction_two.value; + let RevolvePropertyEditor { + direction_two, + dir2_angle, + .. + } = editor; + let mut rows = vec![PropertyRow { + id: revolve_row_id("direction_two"), + label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION_TWO, + editor: direction_two, + read_only: false, + }]; + if direction_two_on { + rows.push(PropertyRow { + id: revolve_row_id("dir2_angle"), + label: strings::PROPERTY_ROW_REVOLVE_ANGLE, + editor: dir2_angle, + read_only: false, + }); + } + rows +} + +fn revolve_scope_group_rows(editor: &mut RevolvePropertyEditor) -> Vec> { + let is_cut = editor.is_cut; + let thin_on = editor.thin.value; + let two_direction = matches!( + thin_direction_from_index(editor.thin_direction.current), + ThinWallDirection::TwoDirection + ); + let RevolvePropertyEditor { + scope, + contours, + thin, + thin_direction, + thickness, + thickness2, + merge, + .. + } = editor; + let mut rows = vec![ + PropertyRow { + id: revolve_row_id("contours"), + label: strings::PROPERTY_ROW_REVOLVE_CONTOURS, + editor: contours, + read_only: false, + }, + PropertyRow { + id: revolve_row_id("scope"), + label: strings::PROPERTY_ROW_EXTRUDE_SCOPE, + editor: scope, + read_only: false, + }, + PropertyRow { + id: revolve_row_id("thin"), + label: strings::PROPERTY_ROW_EXTRUDE_THIN, + editor: thin, + read_only: false, + }, + ]; + if thin_on { + rows.push(PropertyRow { + id: revolve_row_id("thin_direction"), + label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION, + editor: thin_direction, + read_only: false, + }); + rows.push(PropertyRow { + id: revolve_row_id("thickness"), + label: strings::PROPERTY_ROW_EXTRUDE_THICKNESS, + editor: thickness, + read_only: false, + }); + if two_direction { + rows.push(PropertyRow { + id: revolve_row_id("thickness2"), + label: strings::PROPERTY_ROW_EXTRUDE_THICKNESS_TWO, + editor: thickness2, + read_only: false, + }); + } + } + if !is_cut { + rows.push(PropertyRow { + id: revolve_row_id("merge"), + label: strings::PROPERTY_ROW_EXTRUDE_MERGE, + editor: merge, + read_only: false, + }); + } + rows +} + +fn current_thin_from_revolve_editor(editor: &RevolvePropertyEditor) -> Option { + if !editor.thin.value { + return None; + } + let thickness = PositiveLength::new(editor.thickness.value).ok()?; + let direction = thin_direction_from_index(editor.thin_direction.current); + let thickness2 = match direction { + ThinWallDirection::TwoDirection => PositiveLength::new(editor.thickness2.value).ok(), + _ => None, + }; + Some(ThinWall { + thickness, + direction, + thickness2, + cap_ends: false, + auto_fillet: None, + }) +} + +fn resolve_revolve_edit( + editor: &RevolvePropertyEditor, + changed: &[WidgetId], +) -> Option { + let on = |key: &'static str| changed.contains(&revolve_row_id(key)); + if on("kind") { + return revolve_kind_from_index(editor.kind.current).map(RevolveEdit::Kind); + } + if on("angle") { + return Some(RevolveEdit::Angle(editor.angle.value)); + } + if on("reverse") { + return Some(RevolveEdit::Reverse(editor.reverse.value)); + } + if on("direction_two") || on("dir2_angle") { + let angle = editor + .direction_two + .value + .then(|| RevolveAngle::new(editor.dir2_angle.value).ok()) + .flatten(); + if editor.direction_two.value && angle.is_none() { + return None; + } + return Some(RevolveEdit::DirectionTwo(angle)); + } + if on("thin") || on("thin_direction") || on("thickness") || on("thickness2") { + return Some(RevolveEdit::Thin(current_thin_from_revolve_editor(editor))); + } + if on("scope") { + return Some(RevolveEdit::Scope(scope_from_index(editor.scope.current))); + } + if on("merge") { + return Some(RevolveEdit::Merge(if editor.merge.value { + MergeResult::Merge + } else { + MergeResult::Separate + })); + } + None +} + +fn render_revolve_rows( ctx: &mut FrameCtx<'_>, rect: LayoutRect, id: WidgetId, clipboard: &mut MemoryClipboard, editors: &mut PaneEditors<'_>, - feature: ExtrudeFeature, + feature: &RevolveFeature, paints: &mut Vec, -) -> ExtrudeRowsOutcome { +) -> RevolveRowsOutcome { ctx.a11y.push( id, rect, AccessNode::new(Role::Form).with_label(strings::PROPERTY_PANE_LABEL), ); - let (confirm, header_paint) = extrude_pane_header(ctx, id, rect); + let (confirm, header_paint) = revolve_pane_header(ctx, id, rect); paints.extend(header_paint); - let editor = sync_extrude_editor(editors.extrude, feature); + let groups = &mut *editors.groups; + let editor = sync_revolve_editor(editors.revolve, feature); let groups_top = LayoutPx::new(rect.origin.y.value() + PM_HEADER_HEIGHT + PM_GROUP_GAP); let mut changed: Vec = Vec::new(); - let scope_top = { - let mut rows = direction_group_rows(editor); + let mut render_group = |ctx: &mut FrameCtx<'_>, + rows: &mut Vec>, + key: &'static str, + title: StringKey, + top: LayoutPx| { let (next_y, ch) = render_property_group( ctx, clipboard, - editors.groups, + groups, PropertyGroupSpec { - id: id.child(WidgetKey::new("group.direction1")), - title: strings::PROPERTY_GROUP_DIRECTION_1, - top_left: LayoutPos::new(rect.origin.x, groups_top), + id: id.child(WidgetKey::new(key)), + title, + top_left: LayoutPos::new(rect.origin.x, top), width: rect.size.width, }, - &mut rows, + rows, paints, ); changed.extend(ch); next_y }; + let dir2_top = { + let mut rows = revolve_direction_group_rows(editor); + render_group( + ctx, + &mut rows, + "group.direction1", + strings::PROPERTY_GROUP_DIRECTION_1, + groups_top, + ) + }; + let scope_top = { + let mut rows = revolve_direction_two_group_rows(editor); + render_group( + ctx, + &mut rows, + "group.direction2", + strings::PROPERTY_GROUP_DIRECTION_2, + dir2_top, + ) + }; { - let mut rows = scope_group_rows(editor); - let (_next_y, ch) = render_property_group( + let mut rows = revolve_scope_group_rows(editor); + let _ = render_group( + ctx, + &mut rows, + "group.feature_scope", + strings::PROPERTY_GROUP_FEATURE_SCOPE, + scope_top, + ); + } + + let edit = resolve_revolve_edit(editor, &changed); + let (arm_axis, arm_contour) = resolve_revolve_arm(editor, &changed); + RevolveRowsOutcome { + edit, + arm_axis, + arm_contour, + confirm, + } +} + +fn render_extrude_rows( + ctx: &mut FrameCtx<'_>, + rect: LayoutRect, + id: WidgetId, + clipboard: &mut MemoryClipboard, + editors: &mut PaneEditors<'_>, + feature: &ExtrudeFeature, + paints: &mut Vec, +) -> ExtrudeRowsOutcome { + ctx.a11y.push( + id, + rect, + AccessNode::new(Role::Form).with_label(strings::PROPERTY_PANE_LABEL), + ); + let (confirm, header_paint) = extrude_pane_header(ctx, id, rect); + paints.extend(header_paint); + + let groups = &mut *editors.groups; + let editor = sync_extrude_editor(editors.extrude, feature); + let end_kind = kind_from_index(editor.end_condition.current).unwrap_or(EndConditionKind::Blind); + let dir2_kind = + kind_from_index(editor.dir2_end_condition.current).unwrap_or(EndConditionKind::Blind); + let from_kind = from_kind_from_index(editor.from_kind.current).unwrap_or(FromKind::SketchPlane); + let groups_top = LayoutPx::new(rect.origin.y.value() + PM_HEADER_HEIGHT + PM_GROUP_GAP); + let mut changed: Vec = Vec::new(); + let mut render_group = |ctx: &mut FrameCtx<'_>, + rows: &mut Vec>, + key: &'static str, + title: StringKey, + top: LayoutPx| { + let (next_y, ch) = render_property_group( ctx, clipboard, - editors.groups, + groups, PropertyGroupSpec { - id: id.child(WidgetKey::new("group.feature_scope")), - title: strings::PROPERTY_GROUP_FEATURE_SCOPE, - top_left: LayoutPos::new(rect.origin.x, scope_top), + id: id.child(WidgetKey::new(key)), + title, + top_left: LayoutPos::new(rect.origin.x, top), width: rect.size.width, }, - &mut rows, + rows, paints, ); changed.extend(ch); + next_y + }; + let dir2_top = { + let mut rows = direction_group_rows(editor, end_kind); + render_group( + ctx, + &mut rows, + "group.direction1", + strings::PROPERTY_GROUP_DIRECTION_1, + groups_top, + ) + }; + let from_top = { + let mut rows = direction_two_group_rows(editor, dir2_kind); + render_group( + ctx, + &mut rows, + "group.direction2", + strings::PROPERTY_GROUP_DIRECTION_2, + dir2_top, + ) + }; + let scope_top = { + let mut rows = from_group_rows(editor, from_kind); + render_group( + ctx, + &mut rows, + "group.from", + strings::PROPERTY_GROUP_FROM, + from_top, + ) + }; + { + let mut rows = scope_group_rows(editor); + let _ = render_group( + ctx, + &mut rows, + "group.feature_scope", + strings::PROPERTY_GROUP_FEATURE_SCOPE, + scope_top, + ); + } + + let edit = resolve_extrude_edit(editor, &changed, from_kind); + let arm_reference = resolve_arm_reference(editor, &changed, end_kind, dir2_kind, from_kind); + ExtrudeRowsOutcome { + edit, + arm_reference, + confirm, + } +} + +fn resolve_extrude_edit( + editor: &ExtrudePropertyEditor, + changed: &[WidgetId], + from_kind: FromKind, +) -> Option { + let on = |key: &'static str| changed.contains(&extrude_row_id(key)); + if on("end") { + return kind_from_index(editor.end_condition.current).map(ExtrudeEdit::EndCondition); + } + if on("depth") { + return PositiveLength::new(editor.depth.value) + .ok() + .map(ExtrudeEdit::Depth); + } + if on("offset") { + return Some(ExtrudeEdit::Offset(editor.offset.value)); + } + if on("reverse_offset") { + return Some(ExtrudeEdit::ReverseOffset(editor.reverse_offset.value)); + } + if on("draft") || on("draft_angle") || on("draft_direction") { + return Some(ExtrudeEdit::Draft(current_draft_from_editor(editor))); + } + if on("direction_two") || on("d2_end") || on("d2_depth") { + return Some(ExtrudeEdit::Direction2(current_direction2_from_editor( + editor, + ))); + } + if on("from_distance") { + return Some(ExtrudeEdit::From(FromCondition::Offset { + distance: editor.from_distance.value, + })); + } + if on("from_kind") { + return from_condition_for_kind(editor, from_kind).map(ExtrudeEdit::From); + } + if on("scope") { + return Some(ExtrudeEdit::Scope(scope_from_index(editor.scope.current))); + } + if on("flip_side") { + return Some(ExtrudeEdit::FlipSide(editor.flip_side.value)); + } + if on("thin") || on("thin_direction") || on("thickness") || on("thickness2") || on("cap_ends") { + return Some(ExtrudeEdit::Thin(current_thin_from_editor(editor))); + } + if on("merge") { + return Some(ExtrudeEdit::Merge(if editor.merge.value { + MergeResult::Merge + } else { + MergeResult::Separate + })); + } + None +} + +fn resolve_arm_reference( + editor: &ExtrudePropertyEditor, + changed: &[WidgetId], + end_kind: EndConditionKind, + dir2_kind: EndConditionKind, + from_kind: FromKind, +) -> Option { + let picked = |key: &'static str, current: Option| { + changed.contains(&extrude_row_id(key)) && current == Some(REFERENCE_PICK_INDEX) + }; + if picked("d1_reference", editor.direction1_reference.current) { + return Some(ReferenceSlot::Direction1(end_kind)); + } + if picked("d2_reference", editor.dir2_reference.current) { + return Some(ReferenceSlot::Direction2(dir2_kind)); + } + if picked("from_reference", editor.from_reference.current) { + return Some(ReferenceSlot::From(from_kind)); + } + None +} + +fn current_draft_from_editor(editor: &ExtrudePropertyEditor) -> Option { + if !editor.draft_enabled.value { + return None; + } + let direction = match editor.draft_direction.current { + Some(1) => DraftDirection::Inward, + _ => DraftDirection::Outward, + }; + DraftMagnitude::new(editor.draft_angle.value) + .ok() + .map(|magnitude| DraftAngle::new(magnitude, direction)) +} + +fn current_direction2_from_editor(editor: &ExtrudePropertyEditor) -> Option { + if !editor.direction_two.value { + return None; + } + let kind = kind_from_index(editor.dir2_end_condition.current)?; + let end_condition = match kind { + EndConditionKind::Blind | EndConditionKind::MidPlane => { + let depth = PositiveLength::new(editor.dir2_depth.value).ok()?; + kind.with_depth(depth) + } + EndConditionKind::ThroughAll => ExtrudeEndCondition::ThroughAll, + EndConditionKind::ThroughAllBoth => ExtrudeEndCondition::ThroughAllBoth, + EndConditionKind::UpToVertex + | EndConditionKind::UpToSurface + | EndConditionKind::OffsetFromSurface + | EndConditionKind::UpToBody => return None, + }; + Some(Direction2 { + end_condition, + draft: None, + }) +} + +fn from_condition_for_kind( + editor: &ExtrudePropertyEditor, + from_kind: FromKind, +) -> Option { + match from_kind { + FromKind::SketchPlane => Some(FromCondition::SketchPlane), + FromKind::Offset => Some(FromCondition::Offset { + distance: editor.from_distance.value, + }), + FromKind::Surface | FromKind::Vertex => None, + } +} + +fn current_thin_from_editor(editor: &ExtrudePropertyEditor) -> Option { + if !editor.thin.value { + return None; + } + let thickness = PositiveLength::new(editor.thickness.value).ok()?; + let direction = thin_direction_from_index(editor.thin_direction.current); + let thickness2 = match direction { + ThinWallDirection::TwoDirection => PositiveLength::new(editor.thickness2.value).ok(), + _ => None, + }; + Some(ThinWall { + thickness, + direction, + thickness2, + cap_ends: editor.cap_ends.value, + auto_fillet: None, + }) +} + +fn datum_pane_title(feature: &DatumFeature) -> StringKey { + match feature { + DatumFeature::Plane(_) => strings::PROPERTY_PANE_DATUM_PLANE_TITLE, + DatumFeature::Axis(_) => strings::PROPERTY_PANE_DATUM_AXIS_TITLE, + DatumFeature::Point(_) => strings::PROPERTY_PANE_DATUM_POINT_TITLE, + DatumFeature::CoordinateSystem(_) => strings::PROPERTY_PANE_DATUM_CSYS_TITLE, + } +} + +fn datum_definition_summary(feature: &DatumFeature) -> &'static str { + use bone_document::{AxisRecipe, PlaneRecipe, PointRecipe}; + match feature { + DatumFeature::Plane(recipe) => match recipe.as_ref() { + PlaneRecipe::Coincident { .. } => "Coincident", + PlaneRecipe::Offset { .. } => "Offset distance", + PlaneRecipe::AtAngle { .. } => "At angle", + PlaneRecipe::ThreePoints { .. } => "Through three points", + PlaneRecipe::NormalToCurve { .. } => "Normal to curve", + PlaneRecipe::Parallel { .. } => "Parallel", + PlaneRecipe::MidPlane { .. } => "Mid plane", + PlaneRecipe::TangentToSurface { .. } => "Tangent", + PlaneRecipe::ParallelToScreen => "Parallel to screen", + }, + DatumFeature::Axis(recipe) => match recipe.as_ref() { + AxisRecipe::OneLine { .. } => "One line / edge / axis", + AxisRecipe::TwoPlanes { .. } => "Two planes", + AxisRecipe::TwoPoints { .. } => "Two points", + AxisRecipe::CylindricalFace { .. } => "Cylindrical / conical face", + AxisRecipe::PointAndFace { .. } => "Point and face / plane", + }, + DatumFeature::Point(recipe) => match recipe.as_ref() { + PointRecipe::OnPoint { .. } => "On point", + PointRecipe::Intersection { .. } => "Intersection", + PointRecipe::Projection { .. } => "Projection", + PointRecipe::ArcCenter { .. } => "Arc center", + PointRecipe::CenterOfFace { .. } => "Center of face", + PointRecipe::AlongCurve { .. } => "Along curve", + }, + DatumFeature::CoordinateSystem(_) => "Origin and axes", } +} - let edit = if changed.contains(&extrude_row_id("end")) { - kind_from_index(editor.end_condition.current).map(ExtrudeEdit::EndCondition) - } else if changed.contains(&extrude_row_id("depth")) { - PositiveLength::new(editor.depth.value) - .ok() - .map(ExtrudeEdit::Depth) - } else if changed.contains(&extrude_row_id("merge")) { - Some(ExtrudeEdit::Merge(if editor.merge.value { - MergeResult::Merge - } else { - MergeResult::Separate - })) - } else { - None +fn render_datum_rows( + ctx: &mut FrameCtx<'_>, + rect: LayoutRect, + id: WidgetId, + clipboard: &mut MemoryClipboard, + feature: &DatumFeature, + paints: &mut Vec, +) -> Option { + ctx.a11y.push( + id, + rect, + AccessNode::new(Role::Form).with_label(strings::PROPERTY_PANE_LABEL), + ); + let header_id = id.child(WidgetKey::new("header")); + let header = show_property_pane_header( + ctx, + PropertyPaneHeader { + id: header_id, + rect: LayoutRect::new( + rect.origin, + LayoutSize::new(rect.size.width, LayoutPx::new(PM_HEADER_HEIGHT)), + ), + title: datum_pane_title(feature), + accept_id: header_id.child(WidgetKey::new("accept")), + cancel_id: header_id.child(WidgetKey::new("cancel")), + }, + ); + paints.extend(header.paint); + let confirm = match header.action { + Some(PropertyPaneAction::Accept) => Some(ConfirmAction::Accept), + Some(PropertyPaneAction::Cancel) => Some(ConfirmAction::Cancel), + None => None, }; - ExtrudeRowsOutcome { edit, confirm } + let body_top = LayoutPx::new(rect.origin.y.value() + PM_HEADER_HEIGHT + PM_GROUP_GAP); + let body_height = (rect.size.height.value() - PM_HEADER_HEIGHT - PM_GROUP_GAP).max(0.0); + let body_rect = LayoutRect::new( + LayoutPos::new(rect.origin.x, body_top), + LayoutSize::new(rect.size.width, LayoutPx::new(body_height)), + ); + let mut specs = vec![row_editor( + strings::PROPERTY_ROW_DATUM_DEFINITION, + datum_definition_summary(feature), + )]; + render_static_rows( + ctx, + body_rect, + id.child(WidgetKey::new("body")), + clipboard, + &mut specs, + paints, + ); + confirm } enum SelectionTarget { @@ -3232,7 +5021,7 @@ fn resolve_selection_target(sketch: &Sketch, selection: &Selection) -> Option sketch .entities() .get(*id) - .copied() + .cloned() .map(SelectionTarget::Entity), _ => None, }, @@ -3513,6 +5302,12 @@ fn endpoint_or_entity_label( Some(SketchEntity::Circle(_)) => strings_table .resolve(strings::PROPERTY_KIND_CIRCLE) .to_owned(), + Some(SketchEntity::Ellipse(_)) => strings_table + .resolve(strings::PROPERTY_KIND_ELLIPSE) + .to_owned(), + Some(SketchEntity::Spline(_)) => strings_table + .resolve(strings::PROPERTY_KIND_SPLINE) + .to_owned(), None => "?".to_owned(), } } @@ -3527,6 +5322,13 @@ fn row_editor(label: StringKey, value: impl Into) -> PropertyRowSpec { (row_id, label, editor) } +fn kind_row(strings_table: &StringTable, kind: StringKey) -> PropertyRowSpec { + row_editor( + strings::PROPERTY_ROW_KIND, + strings_table.resolve(kind).to_owned(), + ) +} + fn entity_editors( strings_table: &StringTable, entity: SketchEntity, @@ -3545,12 +5347,7 @@ fn entity_editors( SketchEntity::Point(p) => { let (x, y) = p.at().coords_mm(); vec![ - row_editor( - strings::PROPERTY_ROW_KIND, - strings_table - .resolve(strings::PROPERTY_KIND_POINT) - .to_owned(), - ), + kind_row(strings_table, strings::PROPERTY_KIND_POINT), row_editor(strings::PROPERTY_ROW_X, format_mm(x)), row_editor(strings::PROPERTY_ROW_Y, format_mm(y)), ] @@ -3559,12 +5356,7 @@ fn entity_editors( let from = endpoint_or_entity_label(strings_table, sketch, l.a()); let to = endpoint_or_entity_label(strings_table, sketch, l.b()); vec![ - row_editor( - strings::PROPERTY_ROW_KIND, - strings_table - .resolve(strings::PROPERTY_KIND_LINE) - .to_owned(), - ), + kind_row(strings_table, strings::PROPERTY_KIND_LINE), row_editor(strings::PROPERTY_ROW_FROM, from), row_editor(strings::PROPERTY_ROW_TO, to), row_editor( @@ -3578,10 +5370,7 @@ fn entity_editors( let start = endpoint_or_entity_label(strings_table, sketch, a.start()); let end = endpoint_or_entity_label(strings_table, sketch, a.end()); vec![ - row_editor( - strings::PROPERTY_ROW_KIND, - strings_table.resolve(strings::PROPERTY_KIND_ARC).to_owned(), - ), + kind_row(strings_table, strings::PROPERTY_KIND_ARC), row_editor(strings::PROPERTY_ROW_CENTER, center), row_editor(strings::PROPERTY_ROW_START, start), row_editor(strings::PROPERTY_ROW_END, end), @@ -3594,12 +5383,7 @@ fn entity_editors( SketchEntity::Circle(c) => { let center = endpoint_or_entity_label(strings_table, sketch, c.center()); vec![ - row_editor( - strings::PROPERTY_ROW_KIND, - strings_table - .resolve(strings::PROPERTY_KIND_CIRCLE) - .to_owned(), - ), + kind_row(strings_table, strings::PROPERTY_KIND_CIRCLE), row_editor(strings::PROPERTY_ROW_CENTER, center), row_editor(strings::PROPERTY_ROW_RADIUS, format_length(c.radius())), row_editor( @@ -3608,6 +5392,26 @@ fn entity_editors( ), ] } + SketchEntity::Ellipse(e) => { + let center = endpoint_or_entity_label(strings_table, sketch, e.center()); + vec![ + kind_row(strings_table, strings::PROPERTY_KIND_ELLIPSE), + row_editor(strings::PROPERTY_ROW_CENTER, center), + row_editor( + strings::PROPERTY_ROW_CONSTRUCTION, + yes_no(e.for_construction()), + ), + ] + } + SketchEntity::Spline(s) => { + vec![ + kind_row(strings_table, strings::PROPERTY_KIND_SPLINE), + row_editor( + strings::PROPERTY_ROW_CONSTRUCTION, + yes_no(s.for_construction()), + ), + ] + } } .into_iter() .enumerate() @@ -3796,6 +5600,11 @@ fn mode_status_label(strings_table: &StringTable, mode: &Mode, document: &Docume match mode { Mode::Idle => LabelText::Key(strings::STATUS_READY), Mode::Extrude(_) => LabelText::Key(strings::STATUS_EXTRUDE_ACTIVE), + Mode::Revolve(_) => LabelText::Key(strings::STATUS_REVOLVE_ACTIVE), + Mode::Datum(_) => LabelText::Key(strings::STATUS_DATUM_ACTIVE), + Mode::Sketch { session, .. } if session.spatial.is_some() => { + LabelText::Key(strings::STATUS_SKETCH3_ACTIVE) + } Mode::Sketch { sketch_id, .. } => { let Some(label) = document.sketch_label(*sketch_id) else { tracing::warn!(?sketch_id, "active sketch missing from document"); @@ -3887,6 +5696,30 @@ fn relation_widget_id(ribbon: WidgetId, kind: RelationKind) -> WidgetId { ribbon.child(WidgetKey::new(kind.key())) } +struct ToolButtonState { + active: bool, + disabled: bool, +} + +fn entity_tool_state( + mode: &Mode, + tool: SketchTool, + active_tool: Option, + sketch3_tool: Option, + tools_disabled: bool, +) -> ToolButtonState { + if mode.is_sketch3() { + return ToolButtonState { + active: sketch3_tool == Some(tool), + disabled: false, + }; + } + ToolButtonState { + active: active_tool == Some(tool), + disabled: tools_disabled, + } +} + fn build_tool_index(ribbon: WidgetId) -> BTreeMap { SketchTool::ENTITIES .iter() @@ -3903,16 +5736,19 @@ fn tool_key(tool: SketchTool) -> &'static str { match tool { SketchTool::Point => "tool.point", SketchTool::Line => "tool.line", + SketchTool::Centerline => "tool.centerline", SketchTool::CenterpointArc => "tool.centerpoint_arc", SketchTool::TangentArc => "tool.tangent_arc", SketchTool::ThreePointArc => "tool.three_point_arc", SketchTool::Circle => "tool.circle", SketchTool::PerimeterCircle => "tool.perimeter_circle", + SketchTool::Ellipse => "tool.ellipse", SketchTool::CornerRectangle => "tool.corner_rectangle", SketchTool::CenterRectangle => "tool.center_rectangle", SketchTool::ThreePointCornerRectangle => "tool.three_point_corner_rectangle", SketchTool::ThreePointCenterRectangle => "tool.three_point_center_rectangle", SketchTool::Parallelogram => "tool.parallelogram", + SketchTool::Spline => "tool.spline", } } @@ -3920,16 +5756,19 @@ fn tool_label(tool: SketchTool) -> StringKey { match tool { SketchTool::Point => strings::TOOL_POINT, SketchTool::Line => strings::TOOL_LINE, + SketchTool::Centerline => strings::TOOL_CENTERLINE, SketchTool::CenterpointArc => strings::TOOL_CENTERPOINT_ARC, SketchTool::TangentArc => strings::TOOL_TANGENT_ARC, SketchTool::ThreePointArc => strings::TOOL_THREE_POINT_ARC, SketchTool::Circle => strings::TOOL_CIRCLE, SketchTool::PerimeterCircle => strings::TOOL_PERIMETER_CIRCLE, + SketchTool::Ellipse => strings::TOOL_ELLIPSE, SketchTool::CornerRectangle => strings::TOOL_CORNER_RECTANGLE, SketchTool::CenterRectangle => strings::TOOL_CENTER_RECTANGLE, SketchTool::ThreePointCornerRectangle => strings::TOOL_THREE_POINT_CORNER_RECTANGLE, SketchTool::ThreePointCenterRectangle => strings::TOOL_THREE_POINT_CENTER_RECTANGLE, SketchTool::Parallelogram => strings::TOOL_PARALLELOGRAM, + SketchTool::Spline => strings::TOOL_SPLINE, } } @@ -3937,16 +5776,19 @@ const fn tool_icon(tool: SketchTool) -> IconId { match tool { SketchTool::Point => IconId::Point, SketchTool::Line => IconId::Line, + SketchTool::Centerline => IconId::Centerline, SketchTool::CenterpointArc => IconId::CenterpointArc, SketchTool::TangentArc => IconId::TangentArc, SketchTool::ThreePointArc => IconId::ThreePointArc, SketchTool::Circle => IconId::Circle, SketchTool::PerimeterCircle => IconId::PerimeterCircle, + SketchTool::Ellipse => IconId::Ellipse, SketchTool::CornerRectangle => IconId::CornerRectangle, SketchTool::CenterRectangle => IconId::CenterRectangle, SketchTool::ThreePointCornerRectangle => IconId::ThreePointCornerRectangle, SketchTool::ThreePointCenterRectangle => IconId::ThreePointCenterRectangle, SketchTool::Parallelogram => IconId::Parallelogram, + SketchTool::Spline => IconId::Spline, } } @@ -3966,6 +5808,13 @@ fn feature_tool_key(tool: FeatureTool) -> &'static str { match tool { FeatureTool::ExtrudedBossBase => "tool.extruded_boss_base", FeatureTool::ExtrudedCut => "tool.extruded_cut", + FeatureTool::RevolvedBossBase => "tool.revolved_boss_base", + FeatureTool::RevolvedCut => "tool.revolved_cut", + FeatureTool::DatumPlane => "tool.datum_plane", + FeatureTool::DatumAxis => "tool.datum_axis", + FeatureTool::DatumPoint => "tool.datum_point", + FeatureTool::DatumCoordinateSystem => "tool.datum_coordinate_system", + FeatureTool::Sketch3D => "tool.sketch3", } } @@ -3973,6 +5822,13 @@ fn feature_tool_label(tool: FeatureTool) -> StringKey { match tool { FeatureTool::ExtrudedBossBase => strings::TOOL_EXTRUDED_BOSS_BASE, FeatureTool::ExtrudedCut => strings::TOOL_EXTRUDED_CUT, + FeatureTool::RevolvedBossBase => strings::TOOL_REVOLVED_BOSS_BASE, + FeatureTool::RevolvedCut => strings::TOOL_REVOLVED_CUT, + FeatureTool::DatumPlane => strings::TOOL_DATUM_PLANE, + FeatureTool::DatumAxis => strings::TOOL_DATUM_AXIS, + FeatureTool::DatumPoint => strings::TOOL_DATUM_POINT, + FeatureTool::DatumCoordinateSystem => strings::TOOL_DATUM_COORDINATE_SYSTEM, + FeatureTool::Sketch3D => strings::TOOL_SKETCH3, } } @@ -3980,6 +5836,13 @@ const fn feature_tool_icon(tool: FeatureTool) -> IconId { match tool { FeatureTool::ExtrudedBossBase => IconId::ExtrudedBossBase, FeatureTool::ExtrudedCut => IconId::ExtrudedCut, + FeatureTool::RevolvedBossBase => IconId::RevolvedBossBase, + FeatureTool::RevolvedCut => IconId::RevolvedCut, + FeatureTool::DatumPlane => IconId::TreePlane, + FeatureTool::DatumAxis => IconId::Line, + FeatureTool::DatumPoint => IconId::Point, + FeatureTool::DatumCoordinateSystem => IconId::TreeOrigin, + FeatureTool::Sketch3D => IconId::TreeSketch, } } @@ -3987,14 +5850,12 @@ fn feature_tool_items( ctx: &FrameCtx<'_>, ribbon: WidgetId, mode: &Mode, + tools: &[FeatureTool], large_min: LayoutPx, ) -> Vec { - let active = match mode { - Mode::Extrude(_) => Some(FeatureTool::ExtrudedBossBase), - Mode::Idle | Mode::Sketch { .. } => None, - }; + let active = mode.lifecycle().button; let font = ctx.theme().typography.caption.size.as_px_f32(); - FeatureTool::ALL + tools .iter() .copied() .map(|t| { @@ -4226,9 +6087,9 @@ fn update_left_pane_auto( state: &mut ShellState, selection: &Selection, active_tool: Option, - armed_extrude: bool, + armed_feature: bool, ) { - let interesting = !selection.is_empty() || active_tool.is_some() || armed_extrude; + let interesting = !selection.is_empty() || active_tool.is_some() || armed_feature; if interesting && !state.last_left_pane_interesting { state.left_pane = LeftPane::Properties; } else if !interesting && state.last_left_pane_interesting { @@ -4539,6 +6400,7 @@ mod tests { None, &mut ViewUi::default(), &BTreeMap::new(), + &[], false, &[], ) @@ -4580,25 +6442,22 @@ mod tests { } fn blind_extrude(sketch: SketchId) -> ExtrudeFeature { - use bone_document::{ExtrudeDirection, ExtrudeSense}; let Ok(depth) = PositiveLength::new(Length::new::(10.0)) else { panic!("ten millimeters is a positive depth"); }; ExtrudeFeature { - sketch, - direction: ExtrudeDirection::Normal { - sense: ExtrudeSense::Forward, - }, - end_condition: ExtrudeEndCondition::Blind { depth }, - draft: None, - thin_wall: None, - merge_result: MergeResult::Separate, + operation: ExtrudeOperation::Boss(MergeResult::Separate), + ..ExtrudeFeature::blind(sketch, depth) } } fn push_plain_chain(document: &mut Document, label: &str) -> (SketchId, ExtrudeId) { let sketch_id = document.allocate_sketch(); - document.insert_sketch(sketch_id, label.to_owned(), Sketch::new(Plane::Xy.basis())); + document.insert_sketch( + sketch_id, + label.to_owned(), + bone_document::Sketch3::on_plane(Plane::Xy.basis()), + ); let extrude_id = document.commit_extrude(blind_extrude(sketch_id)); (sketch_id, extrude_id) } @@ -4636,7 +6495,7 @@ mod tests { document.insert_sketch( sketch_id, "Sketch1".to_owned(), - Sketch::new(Plane::Xy.basis()), + bone_document::Sketch3::on_plane(Plane::Xy.basis()), ); let part_id = test_part_id(); let rows = feature_rows(&document, part_id, &BTreeMap::new()); @@ -4648,6 +6507,35 @@ mod tests { ); } + #[test] + fn solid_bodies_folder_lists_each_live_body() { + let part_id = test_part_id(); + let empty = solid_bodies_node(part_id, &[]); + assert!( + empty.children.is_empty(), + "no bodies leaves the folder empty" + ); + assert!( + empty.disabled, + "an empty solid bodies folder stays disabled" + ); + let rows = [ + BodyRow { + label: "Revolve1[1]".to_owned(), + }, + BodyRow { + label: "Revolve1[2]".to_owned(), + }, + ]; + let folder = solid_bodies_node(part_id, &rows); + assert!(!folder.disabled, "live bodies enable the folder"); + assert_eq!( + folder.children.len(), + 2, + "a two-body feature shows two body rows" + ); + } + fn menu_labels(items: &[MenuItem]) -> Vec { items .iter() @@ -5159,7 +7047,7 @@ mod tests { Theme::light(), layout_size(1280.0, 800.0), &sample_document(), - &Mode::Extrude(ExtrudeArming::AwaitingSketch), + &Mode::Extrude(ExtrudeArming::AwaitingSketch(ExtrudeOperation::default())), ); assert!( label_rect(&frame.paints, strings::EXTRUDE_PROMPT_SELECT_SKETCH).is_some(), @@ -5172,7 +7060,7 @@ mod tests { else { unreachable!("profile arming holds a feature"); }; - feature + *feature } #[test] @@ -5202,7 +7090,267 @@ mod tests { #[test] fn extrude_edit_toggles_merge_result() { let next = ExtrudeEdit::Merge(MergeResult::Separate).apply(profile_feature()); - assert_eq!(next.merge_result, MergeResult::Separate); + assert_eq!( + next.operation, + ExtrudeOperation::Boss(MergeResult::Separate) + ); + } + + fn cut_feature() -> ExtrudeFeature { + ExtrudeFeature { + operation: ExtrudeOperation::Cut, + ..profile_feature() + } + } + + #[test] + fn extrude_edit_switches_to_through_all() { + let next = ExtrudeEdit::EndCondition(EndConditionKind::ThroughAll).apply(profile_feature()); + assert_eq!(next.end_condition, ExtrudeEndCondition::ThroughAll); + } + + #[test] + fn extrude_edit_switches_to_through_all_both() { + let next = + ExtrudeEdit::EndCondition(EndConditionKind::ThroughAllBoth).apply(profile_feature()); + assert_eq!(next.end_condition, ExtrudeEndCondition::ThroughAllBoth); + } + + #[test] + fn extrude_edit_sets_and_clears_draft() { + let Ok(magnitude) = DraftMagnitude::new(Angle::new::(5.0)) else { + unreachable!("5 deg is a valid draft"); + }; + let draft = DraftAngle::new(magnitude, DraftDirection::Inward); + let with_draft = ExtrudeEdit::Draft(Some(draft)).apply(profile_feature()); + assert_eq!(with_draft.draft, Some(draft)); + let cleared = ExtrudeEdit::Draft(None).apply(with_draft); + assert_eq!(cleared.draft, None); + } + + #[test] + fn extrude_edit_sets_thin_wall() { + let Ok(thickness) = PositiveLength::new(Length::new::(2.0)) else { + unreachable!("2 mm is positive"); + }; + let thin = ThinWall { + thickness, + direction: ThinWallDirection::MidPlane, + thickness2: None, + cap_ends: true, + auto_fillet: None, + }; + let next = ExtrudeEdit::Thin(Some(thin)).apply(profile_feature()); + assert_eq!(next.thin_wall, Some(thin)); + } + + #[test] + fn revolve_edit_toggles_thin_wall() { + use bone_document::RevolveAxis; + let feature = RevolveFeature::blind( + SketchId::default(), + RevolveAxis::SketchLine(SketchEntityId::default()), + RevolveAngle::full(), + ); + let Ok(thickness) = PositiveLength::new(Length::new::(2.0)) else { + unreachable!("2 mm is positive"); + }; + let thin = ThinWall { + thickness, + direction: ThinWallDirection::MidPlane, + thickness2: None, + cap_ends: false, + auto_fillet: None, + }; + let next = RevolveEdit::Thin(Some(thin)).apply(&feature); + assert_eq!(next.thin_wall, Some(thin)); + let cleared = RevolveEdit::Thin(None).apply(&next); + assert!(cleared.thin_wall.is_none()); + } + + #[test] + fn revolve_edit_sets_scope() { + use bone_document::RevolveAxis; + let feature = RevolveFeature::blind( + SketchId::default(), + RevolveAxis::SketchLine(SketchEntityId::default()), + RevolveAngle::full(), + ); + assert_eq!(feature.scope, FeatureScope::AutoSelect); + let scoped = RevolveEdit::Scope(FeatureScope::AllBodies).apply(&feature); + assert_eq!(scoped.scope, FeatureScope::AllBodies); + } + + #[test] + fn revolve_edit_sets_the_axis() { + let feature = RevolveFeature::blind( + SketchId::default(), + RevolveAxis::SketchLine(SketchEntityId::default()), + RevolveAngle::full(), + ); + let datum = FeatureId::default(); + let next = RevolveEdit::Axis(RevolveAxis::Datum(datum)).apply(&feature); + assert_eq!(next.axis, RevolveAxis::Datum(datum)); + } + + #[test] + fn revolve_edit_selects_a_contour() { + let feature = RevolveFeature::blind( + SketchId::default(), + RevolveAxis::SketchLine(SketchEntityId::default()), + RevolveAngle::full(), + ); + assert_eq!(feature.contours, ContourSelection::All); + let seed = SketchEntityId::default(); + let next = RevolveEdit::Contour(ContourSelection::Contour(seed)).apply(&feature); + assert_eq!(next.contours, ContourSelection::Contour(seed)); + } + + #[test] + fn extrude_edit_sets_scope_and_flip_side() { + let scoped = ExtrudeEdit::Scope(FeatureScope::AllBodies).apply(profile_feature()); + assert_eq!(scoped.scope, FeatureScope::AllBodies); + let flipped = ExtrudeEdit::FlipSide(true).apply(cut_feature()); + assert!(flipped.flip_side); + } + + #[test] + fn extrude_edit_sets_from_offset() { + let distance = Length::new::(4.0); + let next = ExtrudeEdit::From(FromCondition::Offset { distance }).apply(profile_feature()); + assert!(matches!( + next.from, + FromCondition::Offset { distance: d } if d == distance + )); + } + + #[test] + fn extrude_edit_sets_direction_two() { + let Ok(depth) = PositiveLength::new(Length::new::(6.0)) else { + unreachable!("6 mm is positive"); + }; + let direction2 = Direction2 { + end_condition: ExtrudeEndCondition::Blind { depth }, + draft: None, + }; + let next = ExtrudeEdit::Direction2(Some(direction2)).apply(profile_feature()); + assert_eq!(next.direction2, Some(direction2)); + } + + #[test] + fn offset_from_surface_edits_preserve_the_surface() { + let surface = FaceLabel { + feature: FeatureId::default(), + role: bone_types::FaceRole::StartCap, + }; + let base = ExtrudeFeature { + end_condition: ExtrudeEndCondition::OffsetFromSurface { + surface, + offset: Length::new::(3.0), + reverse_offset: false, + translate_surface: false, + }, + ..profile_feature() + }; + let moved = ExtrudeEdit::Offset(Length::new::(8.0)).apply(base); + assert!(matches!( + moved.end_condition, + ExtrudeEndCondition::OffsetFromSurface { surface: s, offset, .. } + if s == surface && offset == Length::new::(8.0) + )); + let reversed = ExtrudeEdit::ReverseOffset(true).apply(moved); + assert!(matches!( + reversed.end_condition, + ExtrudeEndCondition::OffsetFromSurface { + surface: s, + reverse_offset: true, + .. + } if s == surface + )); + } + + #[test] + fn switching_up_to_surface_and_offset_keeps_the_face() { + let surface = FaceLabel { + feature: FeatureId::default(), + role: bone_types::FaceRole::StartCap, + }; + let up_to = ExtrudeFeature { + end_condition: ExtrudeEndCondition::UpToSurface { surface }, + ..profile_feature() + }; + let to_offset = ExtrudeEdit::EndCondition(EndConditionKind::OffsetFromSurface).apply(up_to); + assert!(matches!( + to_offset.end_condition, + ExtrudeEndCondition::OffsetFromSurface { surface: s, .. } if s == surface + )); + let back = ExtrudeEdit::EndCondition(EndConditionKind::UpToSurface).apply(to_offset); + assert!(matches!( + back.end_condition, + ExtrudeEndCondition::UpToSurface { surface: s } if s == surface + )); + } + + #[test] + fn arm_reference_leaves_the_feature_untouched() { + let feature = profile_feature(); + let armed = + ExtrudeEdit::ArmReference(ReferenceSlot::Direction1(EndConditionKind::UpToSurface)) + .apply(feature); + assert_eq!(armed, feature); + } + + #[test] + fn apply_reference_builds_up_to_surface_in_direction_one() { + let surface = FaceLabel { + feature: FeatureId::default(), + role: bone_types::FaceRole::StartCap, + }; + let next = apply_extrude_reference( + profile_feature(), + ReferenceSlot::Direction1(EndConditionKind::UpToSurface), + CapturedReference::Face(surface), + ); + assert!(matches!( + next.end_condition, + ExtrudeEndCondition::UpToSurface { surface: s } if s == surface + )); + } + + #[test] + fn apply_reference_builds_from_vertex() { + let vertex = VertexLabel { + feature: FeatureId::default(), + role: bone_types::VertexRole::Imported { + ordinal: bone_types::ImportOrdinal::new(0), + }, + }; + let next = apply_extrude_reference( + profile_feature(), + ReferenceSlot::From(FromKind::Vertex), + CapturedReference::Vertex(vertex), + ); + assert!(matches!( + next.from, + FromCondition::Vertex { vertex: v } if v == vertex + )); + } + + #[test] + fn apply_reference_rejects_mismatched_kind() { + let vertex = VertexLabel { + feature: FeatureId::default(), + role: bone_types::VertexRole::Imported { + ordinal: bone_types::ImportOrdinal::new(0), + }, + }; + let feature = profile_feature(); + let next = apply_extrude_reference( + feature, + ReferenceSlot::Direction1(EndConditionKind::UpToSurface), + CapturedReference::Vertex(vertex), + ); + assert_eq!(next, feature); } #[test] @@ -5217,6 +7365,10 @@ mod tests { strings::PROPERTY_ROW_EXTRUDE_END, strings::PROPERTY_ROW_EXTRUDE_DEPTH, strings::PROPERTY_ROW_EXTRUDE_MERGE, + strings::PROPERTY_ROW_EXTRUDE_DIRECTION_TWO, + strings::PROPERTY_ROW_EXTRUDE_FROM, + strings::PROPERTY_ROW_EXTRUDE_SCOPE, + strings::PROPERTY_ROW_EXTRUDE_THIN, ] .into_iter() .for_each(|key| { @@ -5227,6 +7379,71 @@ mod tests { }); } + #[test] + fn draft_and_thin_rows_appear_when_enabled() { + let Ok(magnitude) = DraftMagnitude::new(Angle::new::(3.0)) else { + unreachable!("3 deg is a valid draft"); + }; + let Ok(thickness) = PositiveLength::new(Length::new::(2.0)) else { + unreachable!("2 mm is positive"); + }; + let feature = ExtrudeFeature { + draft: Some(DraftAngle::new(magnitude, DraftDirection::Outward)), + thin_wall: Some(ThinWall { + thickness, + direction: ThinWallDirection::TwoDirection, + thickness2: Some(thickness), + cap_ends: false, + auto_fillet: None, + }), + ..profile_feature() + }; + let frame = render_with( + Theme::light(), + layout_size(1280.0, 800.0), + &sample_document(), + &Mode::Extrude(ExtrudeArming::Profile { + feature: Box::new(feature), + target: None, + }), + ); + [ + strings::PROPERTY_ROW_EXTRUDE_DRAFT_ANGLE, + strings::PROPERTY_ROW_EXTRUDE_DIRECTION, + strings::PROPERTY_ROW_EXTRUDE_THICKNESS, + strings::PROPERTY_ROW_EXTRUDE_THICKNESS_TWO, + strings::PROPERTY_ROW_EXTRUDE_CAP_ENDS, + ] + .into_iter() + .for_each(|key| { + assert!( + label_rect(&frame.paints, key).is_some(), + "draft and thin pane shows {key:?}", + ); + }); + } + + #[test] + fn cut_extrude_shows_flip_side_and_hides_merge() { + let frame = render_with( + Theme::light(), + layout_size(1280.0, 800.0), + &sample_document(), + &Mode::Extrude(ExtrudeArming::Profile { + feature: Box::new(cut_feature()), + target: None, + }), + ); + assert!( + label_rect(&frame.paints, strings::PROPERTY_ROW_EXTRUDE_FLIP_SIDE).is_some(), + "a cut extrude offers Flip Side To Cut", + ); + assert!( + label_rect(&frame.paints, strings::PROPERTY_ROW_EXTRUDE_MERGE).is_none(), + "a cut extrude hides Merge Result", + ); + } + #[test] fn feature_tools_disabled_while_sketching() { let theme = Arc::new(Theme::light()); @@ -5255,6 +7472,7 @@ mod tests { &ctx, ribbon, &Mode::enter_sketch(SketchId::default()), + FeatureTool::ALL, large, ); assert!( @@ -5268,7 +7486,7 @@ mod tests { "disabled feature tools explain why through a tooltip", ); - let idle = feature_tool_items(&ctx, ribbon, &Mode::Idle, large); + let idle = feature_tool_items(&ctx, ribbon, &Mode::Idle, FeatureTool::ALL, large); assert!( idle.iter().all(|item| !item.disabled), "feature tools stay live from idle", @@ -5540,6 +7758,7 @@ mod tests { None, &mut ViewUi::default(), &BTreeMap::new(), + &[], false, &[], ); @@ -5591,15 +7810,7 @@ mod tests { #[test] fn smart_dimension_item_disabled_without_sketch() { let ids = ShellIds::standard(); - let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, None, &[], false); - assert!(item.disabled); - } - - #[test] - fn smart_dimension_item_disabled_when_sketch_disabled_flag_set() { - let ids = ShellIds::standard(); - let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); - let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[], true); + let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, None, &[]); assert!(item.disabled); } @@ -5607,7 +7818,7 @@ mod tests { fn smart_dimension_item_carries_reason_tooltip_when_no_selection() { let ids = ShellIds::standard(); let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); - let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[], false); + let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[]); assert!(item.disabled); assert_eq!(item.tooltip, Some(strings::DIM_HINT_GENERIC)); } @@ -5616,8 +7827,7 @@ mod tests { fn smart_dimension_item_enabled_for_eligible_line() { let ids = ShellIds::standard(); let (sketch, line) = sample_sketch_with_line(); - let item = - smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[line], false); + let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[line]); assert!(!item.disabled); assert!(item.tooltip.is_none()); } @@ -5795,17 +8005,21 @@ mod tests { (s, id, line) } - fn document_with_sketch(sketch: bone_document::Sketch) -> (Document, SketchId) { + fn document_with_sketch(sketch: &bone_document::Sketch) -> (Document, SketchId) { let mut doc = sample_document(); let id = SketchId::default(); - doc.insert_sketch(id, "Sketch1".to_owned(), sketch); + doc.insert_sketch( + id, + "Sketch1".to_owned(), + crate::sketch3_project::embed_sketch(sketch), + ); (doc, id) } #[test] fn property_pane_for_driving_dim_populates_editor_with_value() { let (sketch, dim_id) = sketch_with_dim(DimensionKind::Driving); - let (doc, sketch_id) = document_with_sketch(sketch); + let (doc, sketch_id) = document_with_sketch(&sketch); let mut shell = Shell::new(); let frame = render_into_shell( &mut shell, @@ -5830,7 +8044,7 @@ mod tests { #[test] fn property_pane_keeps_driven_editor_but_marks_read_only() { let (sketch, dim_id) = sketch_with_dim(DimensionKind::Driven); - let (doc, sketch_id) = document_with_sketch(sketch); + let (doc, sketch_id) = document_with_sketch(&sketch); let mut shell = Shell::new(); let _ = render_into_shell( &mut shell, @@ -5849,7 +8063,7 @@ mod tests { #[test] fn property_pane_drops_dim_editor_when_selection_changes_off_dim() { let (sketch, dim_id) = sketch_with_dim(DimensionKind::Driving); - let (doc, sketch_id) = document_with_sketch(sketch); + let (doc, sketch_id) = document_with_sketch(&sketch); let mut shell = Shell::new(); let _ = render_into_shell( &mut shell, @@ -5901,7 +8115,7 @@ mod tests { panic!("expected second Dimension outcome"); }; assert_ne!(dim_a, dim_b); - let (doc, sketch_id) = document_with_sketch(s); + let (doc, sketch_id) = document_with_sketch(&s); let mut shell = Shell::new(); let _ = render_into_shell( &mut shell, @@ -5928,11 +8142,12 @@ mod tests { #[test] fn property_pane_renders_relation_kind_label() { let (sketch, _rel_id, _line) = sketch_with_relation(); - let (doc, sketch_id) = document_with_sketch(sketch); - let Some(sketch_ref) = doc.sketch(sketch_id) else { + let (doc, sketch_id) = document_with_sketch(&sketch); + let Some(base) = doc.sketch3(sketch_id) else { panic!("expected inserted sketch"); }; - let Some(rel_id) = sketch_ref.relation_order().first().copied() else { + let projected = crate::sketch3_project::project_sketch(base, base.plane()); + let Some(rel_id) = projected.relation_order().first().copied() else { panic!("expected relation present"); }; let mut shell = Shell::new(); @@ -5996,6 +8211,7 @@ mod tests { None, &mut ViewUi::default(), &BTreeMap::new(), + &[], false, &[], ) @@ -6016,7 +8232,7 @@ mod tests { fn document_with_extrude() -> (Document, ExtrudeId) { let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); - let (mut document, sketch_id) = document_with_sketch(sketch); + let (mut document, sketch_id) = document_with_sketch(&sketch); let extrude_id = document.commit_extrude(crate::sketch_mode::default_extrude_feature(sketch_id)); (document, extrude_id) @@ -6025,7 +8241,7 @@ mod tests { #[test] fn f2_with_focused_sketch_row_starts_rename_in_full_shell() { let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); - let (document, sketch_id) = document_with_sketch(sketch); + let (document, sketch_id) = document_with_sketch(&sketch); let mut shell = Shell::new(); let widget = sketch_widget(&shell.ids, sketch_id); let mut focus = FocusManager::new(); @@ -6095,7 +8311,7 @@ mod tests { #[test] fn status_bar_uses_current_sketch_label_when_in_sketch_mode() { let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); - let (mut document, sketch_id) = document_with_sketch(sketch); + let (mut document, sketch_id) = document_with_sketch(&sketch); let Ok(()) = document.rename_sketch(sketch_id, "Profile") else { panic!("rename must accept non-empty label"); }; @@ -6120,7 +8336,7 @@ mod tests { #[test] fn sketch_row_hit_rect_lies_within_left_pane_bounds() { let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); - let (document, sketch_id) = document_with_sketch(sketch); + let (document, sketch_id) = document_with_sketch(&sketch); let mut shell = Shell::new(); let widget = sketch_widget(&shell.ids, sketch_id); let mut focus = FocusManager::new(); @@ -6166,7 +8382,7 @@ mod tests { fn property_header_accept_emits_confirm_via_full_shell() { use bone_ui::input::{PointerButton, PointerButtonMask, PointerSample}; let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); - let (document, sketch_id) = document_with_sketch(sketch); + let (document, sketch_id) = document_with_sketch(&sketch); let mode = Mode::Extrude(ExtrudeArming::profile(sketch_id)); let mut shell = Shell::new(); let accept_id = shell @@ -6258,7 +8474,7 @@ mod tests { PointerSample, }; let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); - let (document, sketch_id) = document_with_sketch(sketch); + let (document, sketch_id) = document_with_sketch(&sketch); let mut shell = Shell::new(); let widget = sketch_widget(&shell.ids, sketch_id); let mut focus = FocusManager::new(); @@ -6357,7 +8573,7 @@ mod tests { fn double_click_sketch_row_emits_sketch_activated() { use bone_ui::input::{PointerButton, PointerButtonMask, PointerSample}; let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); - let (document, sketch_id) = document_with_sketch(sketch); + let (document, sketch_id) = document_with_sketch(&sketch); let mut shell = Shell::new(); let widget = sketch_widget(&shell.ids, sketch_id); let mut focus = FocusManager::new(); @@ -6702,6 +8918,7 @@ mod tests { None, &mut ViewUi::default(), &BTreeMap::new(), + &[], false, &[], ); @@ -6878,7 +9095,13 @@ mod tests { ), ( ids.menu_sketch, - vec![(ids.menu_sketch_exit, "menu.sketch.exit")], + vec![ + ( + ids.menu_sketch_convert_entities, + "menu.sketch.convert_entities", + ), + (ids.menu_sketch_exit, "menu.sketch.exit"), + ], ), ( ids.menu_window, @@ -7013,7 +9236,7 @@ mod tests { fn selection_updates(canvas: LayoutSize, table: &StringTable) -> [accesskit::TreeUpdate; 3] { let (shared_sketch, rel_id, line_id) = sketch_with_relation(); - let (doc_shared, sketch_id_shared) = document_with_sketch(shared_sketch); + let (doc_shared, sketch_id_shared) = document_with_sketch(&shared_sketch); let entity_update = build_update( canvas, table, @@ -7031,7 +9254,7 @@ mod tests { |s| s.state.left_pane = LeftPane::Properties, ); let (dim_sketch, dim_id) = sketch_with_dim(DimensionKind::Driving); - let (doc_dim, sketch_id_dim) = document_with_sketch(dim_sketch); + let (doc_dim, sketch_id_dim) = document_with_sketch(&dim_sketch); let dim_update = build_update( canvas, table, @@ -7063,7 +9286,7 @@ mod tests { let table = crate::strings::make_strings(Locale::EnUs); let canvas = layout_size(3600.0, 900.0); - let (doc_empty, sketch_id_empty) = document_with_sketch(bone_document::Sketch::new( + let (doc_empty, sketch_id_empty) = document_with_sketch(&bone_document::Sketch::new( crate::sketch_mode::Plane::Xy.basis(), )); let empty_sel = Selection::default(); @@ -7178,6 +9401,7 @@ mod tests { None, &mut view, &BTreeMap::new(), + &[], false, &[], ) @@ -7316,8 +9540,9 @@ mod tests { "end", "depth", "draft", - "draft_angle", "direction_two", + "from_kind", + "scope", "thin", "merge", ] diff --git a/crates/bone-app/src/sketch3_mode.rs b/crates/bone-app/src/sketch3_mode.rs new file mode 100644 index 0000000..c2a2a4d --- /dev/null +++ b/crates/bone-app/src/sketch3_mode.rs @@ -0,0 +1,522 @@ +use bone_document::{Sketch3, Sketch3Entity}; +use bone_render::{ViewportExtent, ViewportPoint, world_ray}; +use bone_types::{Camera3, Length, Point3, SketchEntityId, SketchPlaneBasis, UnitVec3}; +use uom::si::length::millimeter; + +const RAY_DENOM_FLOOR: f64 = 1.0e-9; + +#[must_use] +pub fn ray_plane_point( + camera: Camera3, + extent: ViewportExtent, + pixel: ViewportPoint, + plane: SketchPlaneBasis, +) -> Option { + let (origin, direction) = world_ray(camera, extent, pixel).ok()?; + let (nx, ny, nz) = plane.normal().components(); + let (ox, oy, oz) = origin.coords_mm(); + let (dx, dy, dz) = direction.components(); + let denom = dx * nx + dy * ny + dz * nz; + if denom.abs() < RAY_DENOM_FLOOR { + return None; + } + let (px, py, pz) = plane.origin().coords_mm(); + let t = ((px - ox) * nx + (py - oy) * ny + (pz - oz) * nz) / denom; + if t <= 0.0 { + return None; + } + Some(Point3::from_mm(ox + dx * t, oy + dy * t, oz + dz * t)) +} + +type Ray = ((f64, f64, f64), (f64, f64, f64)); + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum Snap3 { + Endpoint { point: SketchEntityId, at: Point3 }, + Midpoint { line: SketchEntityId, at: Point3 }, + OnEdge { entity: SketchEntityId, at: Point3 }, +} + +impl Snap3 { + #[must_use] + pub fn at(self) -> Point3 { + match self { + Self::Endpoint { at, .. } | Self::Midpoint { at, .. } | Self::OnEdge { at, .. } => at, + } + } +} + +fn ray_perp_dist2(ray: Ray, p: Point3) -> f64 { + let ((ox, oy, oz), (dx, dy, dz)) = ray; + let (px, py, pz) = p.coords_mm(); + let (vx, vy, vz) = (px - ox, py - oy, pz - oz); + let t = vx.mul_add(dx, vy.mul_add(dy, vz * dz)); + if t < 0.0 { + return f64::INFINITY; + } + let (ex, ey, ez) = (dx.mul_add(-t, vx), dy.mul_add(-t, vy), dz.mul_add(-t, vz)); + ex.mul_add(ex, ey.mul_add(ey, ez * ez)) +} + +fn closest_on_segment(ray: Ray, start: Point3, end: Point3) -> Point3 { + let ((ox, oy, oz), (dx, dy, dz)) = ray; + let (ax, ay, az) = start.coords_mm(); + let (bx, by, bz) = end.coords_mm(); + let (ux, uy, uz) = (bx - ax, by - ay, bz - az); + let (rx, ry, rz) = (ox - ax, oy - ay, oz - az); + let ray_dot_seg = dx.mul_add(ux, dy.mul_add(uy, dz * uz)); + let seg_len2 = ux.mul_add(ux, uy.mul_add(uy, uz * uz)); + let ray_dot_rel = dx.mul_add(rx, dy.mul_add(ry, dz * rz)); + let seg_dot_rel = ux.mul_add(rx, uy.mul_add(ry, uz * rz)); + let denom = ray_dot_seg.mul_add(-ray_dot_seg, seg_len2); + let t = if denom.abs() < RAY_DENOM_FLOOR { + 0.0 + } else { + (seg_dot_rel - ray_dot_rel * ray_dot_seg) / denom + } + .clamp(0.0, 1.0); + Point3::from_mm(ux.mul_add(t, ax), uy.mul_add(t, ay), uz.mul_add(t, az)) +} + +fn nearest( + candidates: impl Iterator, + tol2: f64, +) -> Option<(T, Point3)> { + candidates + .filter(|(_, _, dist2)| *dist2 <= tol2) + .min_by(|a, b| a.2.total_cmp(&b.2)) + .map(|(item, at, _)| (item, at)) +} + +#[must_use] +pub fn nearest_snap3( + sketch: &Sketch3, + camera: Camera3, + extent: ViewportExtent, + pixel: ViewportPoint, + tolerance: Length, + exclude: Option, +) -> Option { + let Ok((origin, direction)) = world_ray(camera, extent, pixel) else { + return None; + }; + let ray: Ray = (origin.coords_mm(), direction.components()); + let tol = tolerance.get::(); + let tol2 = tol * tol; + + let points = sketch + .entity_order() + .iter() + .filter(|id| Some(**id) != exclude) + .filter_map(|id| { + sketch + .point_position(*id) + .map(|p| (*id, p, ray_perp_dist2(ray, p))) + }); + if let Some((point, at)) = nearest(points, tol2) { + return Some(Snap3::Endpoint { point, at }); + } + + let lines: Vec<(SketchEntityId, Point3, Point3)> = sketch + .entity_order() + .iter() + .filter_map(|id| match sketch.entities().get(*id)? { + Sketch3Entity::Line(line) => Some(( + *id, + sketch.point_position(line.a())?, + sketch.point_position(line.b())?, + )), + _ => None, + }) + .collect(); + + let midpoints = lines.iter().map(|(id, a, b)| { + let (ax, ay, az) = a.coords_mm(); + let (bx, by, bz) = b.coords_mm(); + let mid = Point3::from_mm( + f64::midpoint(ax, bx), + f64::midpoint(ay, by), + f64::midpoint(az, bz), + ); + (*id, mid, ray_perp_dist2(ray, mid)) + }); + if let Some((line, at)) = nearest(midpoints, tol2) { + return Some(Snap3::Midpoint { line, at }); + } + + let on_edges = sketch.entity_order().iter().filter_map(|id| { + let poly = entity_polyline(sketch, *id)?; + let chord = closest_on_polyline(ray, &poly)?; + let at = refine_on_edge(sketch, *id, chord); + Some((*id, at, ray_perp_dist2(ray, at))) + }); + nearest(on_edges, tol2).map(|(entity, at)| Snap3::OnEdge { entity, at }) +} + +fn project_radial(center: Point3, radius: f64, normal: UnitVec3, at: Point3) -> Point3 { + let (u, v) = normal.orthonormal_basis(); + let (a, b) = local_angle(center, u, v, at); + if a.hypot(b) < RAY_DENOM_FLOOR { + return at; + } + around(center, u, v, radius, radius, b.atan2(a)) +} + +fn refine_on_edge(sketch: &Sketch3, id: SketchEntityId, at: Point3) -> Point3 { + match sketch.entities().get(id) { + Some(Sketch3Entity::Circle(circle)) => { + sketch.point_position(circle.center()).map_or(at, |center| { + project_radial( + center, + circle.radius().get::(), + circle.normal(), + at, + ) + }) + } + Some(Sketch3Entity::Arc(arc)) => { + match ( + sketch.point_position(arc.center()), + sketch.point_position(arc.start()), + ) { + (Some(center), Some(start)) => { + let (sx, sy, sz) = (start - center).coords_mm(); + let radius = sx.mul_add(sx, sy.mul_add(sy, sz * sz)).sqrt(); + project_radial(center, radius, arc.normal(), at) + } + _ => at, + } + } + _ => at, + } +} + +fn entity_polyline(sketch: &Sketch3, id: SketchEntityId) -> Option> { + match sketch.entities().get(id)? { + Sketch3Entity::Line(line) => Some(vec![ + sketch.point_position(line.a())?, + sketch.point_position(line.b())?, + ]), + Sketch3Entity::Circle(circle) => Some(circle3_polyline( + sketch.point_position(circle.center())?, + circle.radius().get::(), + circle.normal(), + )), + Sketch3Entity::Arc(arc) => Some(arc3_polyline( + sketch.point_position(arc.center())?, + sketch.point_position(arc.start())?, + sketch.point_position(arc.end())?, + arc.normal(), + )), + Sketch3Entity::Ellipse(ellipse) => Some(ellipse3_polyline( + sketch.point_position(ellipse.center())?, + sketch.point_position(ellipse.major())?, + sketch.point_position(ellipse.minor())?, + ellipse.normal(), + )), + Sketch3Entity::Point(_) | Sketch3Entity::Spline(_) => None, + } +} + +fn closest_on_polyline(ray: Ray, polyline: &[Point3]) -> Option { + polyline + .windows(2) + .map(|w| { + let q = closest_on_segment(ray, w[0], w[1]); + (q, ray_perp_dist2(ray, q)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(q, _)| q) +} + +pub(crate) const SKETCH3_CURVE_SEGMENTS: u32 = 48; + +pub(crate) fn around( + center: Point3, + u: UnitVec3, + v: UnitVec3, + ru: f64, + rv: f64, + angle: f64, +) -> Point3 { + let (cx, cy, cz) = center.coords_mm(); + let (ux, uy, uz) = u.components(); + let (vx, vy, vz) = v.components(); + let (ca, sa) = (angle.cos(), angle.sin()); + Point3::from_mm( + cx + ru * ca * ux + rv * sa * vx, + cy + ru * ca * uy + rv * sa * vy, + cz + ru * ca * uz + rv * sa * vz, + ) +} + +pub(crate) fn circle3_polyline(center: Point3, radius: f64, normal: UnitVec3) -> Vec { + let (u, v) = normal.orthonormal_basis(); + (0..=SKETCH3_CURVE_SEGMENTS) + .map(|i| { + let angle = core::f64::consts::TAU * f64::from(i) / f64::from(SKETCH3_CURVE_SEGMENTS); + around(center, u, v, radius, radius, angle) + }) + .collect() +} + +fn local_angle(center: Point3, u: UnitVec3, v: UnitVec3, p: Point3) -> (f64, f64) { + let (px, py, pz) = (p - center).coords_mm(); + let (ux, uy, uz) = u.components(); + let (vx, vy, vz) = v.components(); + (px * ux + py * uy + pz * uz, px * vx + py * vy + pz * vz) +} + +pub(crate) fn arc3_polyline( + center: Point3, + start: Point3, + end: Point3, + normal: UnitVec3, +) -> Vec { + let (u, v) = normal.orthonormal_basis(); + let (sx, sy) = local_angle(center, u, v, start); + let radius = sx.hypot(sy); + let start_angle = sy.atan2(sx); + let (ex, ey) = local_angle(center, u, v, end); + let mut sweep = ey.atan2(ex) - start_angle; + while sweep <= 0.0 { + sweep += core::f64::consts::TAU; + } + (0..=SKETCH3_CURVE_SEGMENTS) + .map(|i| { + let t = f64::from(i) / f64::from(SKETCH3_CURVE_SEGMENTS); + around(center, u, v, radius, radius, start_angle + sweep * t) + }) + .collect() +} + +pub(crate) fn ellipse3_polyline( + center: Point3, + major: Point3, + minor: Point3, + normal: UnitVec3, +) -> Vec { + let (u, v) = normal.orthonormal_basis(); + let (mu, _) = local_angle(center, u, v, major); + let (_, mv) = local_angle(center, u, v, minor); + (0..=SKETCH3_CURVE_SEGMENTS) + .map(|i| { + let angle = core::f64::consts::TAU * f64::from(i) / f64::from(SKETCH3_CURVE_SEGMENTS); + around(center, u, v, mu, mv, angle) + }) + .collect() +} + +pub(crate) fn spline3_polyline(control: &[Point3], closed: bool) -> Vec { + let count = control.len(); + if count < 3 { + return control.to_vec(); + } + let lerp = |p: Point3, q: Point3, t: f64| -> Point3 { + let (px, py, pz) = p.coords_mm(); + let (qx, qy, qz) = q.coords_mm(); + Point3::from_mm(px + (qx - px) * t, py + (qy - py) * t, pz + (qz - pz) * t) + }; + let blend = |a: Point3, wa: f64, b: Point3, wb: f64, c: Point3, wc: f64| -> Point3 { + let (ax, ay, az) = a.coords_mm(); + let (bx, by, bz) = b.coords_mm(); + let (cx, cy, cz) = c.coords_mm(); + let total = wa + wb + wc; + Point3::from_mm( + ax.mul_add(wa, bx.mul_add(wb, cx * wc)) / total, + ay.mul_add(wa, by.mul_add(wb, cy * wc)) / total, + az.mul_add(wa, bz.mul_add(wb, cz * wc)) / total, + ) + }; + let cubic = |b0: Point3, b1: Point3, b2: Point3, b3: Point3, t: f64| -> Point3 { + lerp( + lerp(lerp(b0, b1, t), lerp(b1, b2, t), t), + lerp(lerp(b1, b2, t), lerp(b2, b3, t), t), + t, + ) + }; + let spans = if closed { count } else { count - 1 }; + let prev = |i: usize| { + if closed { + control[(i + count - 1) % count] + } else { + control[i.saturating_sub(1)] + } + }; + let next1 = |i: usize| { + if closed { + control[(i + 1) % count] + } else { + control[i + 1] + } + }; + let next2 = |i: usize| { + if closed { + control[(i + 2) % count] + } else { + control[(i + 2).min(count - 1)] + } + }; + let segment = |i: usize| { + let b0 = control[i]; + let b1 = blend(prev(i), -1.0 / 6.0, control[i], 1.0, next1(i), 1.0 / 6.0); + let b2 = blend(control[i], 1.0 / 6.0, next1(i), 1.0, next2(i), -1.0 / 6.0); + let b3 = next1(i); + (0..SKETCH3_CURVE_SEGMENTS).map(move |j| { + cubic( + b0, + b1, + b2, + b3, + f64::from(j) / f64::from(SKETCH3_CURVE_SEGMENTS), + ) + }) + }; + (0..spans) + .flat_map(segment) + .chain(std::iter::once(next1(spans - 1))) + .collect() +} + +#[must_use] +pub fn solved(sketch: Sketch3) -> Sketch3 { + sketch.solve().unwrap_or(sketch) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sketch3_project::{embed_line, embed_point}; + use bone_types::{Point2, Tolerance}; + + fn xy_plane() -> SketchPlaneBasis { + let Ok(basis) = SketchPlaneBasis::new( + Point3::origin(), + UnitVec3::x_axis(), + UnitVec3::y_axis(), + Tolerance::new(1.0e-9), + ) else { + panic!("xy plane is orthonormal"); + }; + basis + } + + #[test] + fn a_solved_line_yields_an_axis_along_its_direction() { + let plane = xy_plane(); + let (sketch, start) = embed_point(&Sketch3::new(), plane, Point2::from_mm(0.0, 0.0)); + let (sketch, end) = embed_point(&sketch, plane, Point2::from_mm(5.0, 0.0)); + let (sketch, line) = embed_line(&sketch, start, end, false); + let solved = solved(sketch); + let Some(axis) = solved.line_axis(line) else { + panic!("the placed line yields an axis"); + }; + let (dx, dy, dz) = axis.direction().components(); + assert!(dx.abs() > 1.0 - 1e-6 && dy.abs() < 1e-6 && dz.abs() < 1e-6); + assert_ne!(start, end); + } + + #[test] + fn ray_perp_dist2_rejects_points_behind_the_origin() { + let ray: Ray = ((0.0, 0.0, 0.0), (0.0, 0.0, 1.0)); + let ahead = ray_perp_dist2(ray, Point3::from_mm(1.0, 0.0, 5.0)); + let behind = ray_perp_dist2(ray, Point3::from_mm(1.0, 0.0, -5.0)); + assert!( + (ahead - 1.0).abs() < 1e-9, + "a point in front keeps its true perpendicular distance: {ahead}" + ); + assert!( + behind.is_infinite(), + "a point behind the camera never snaps: {behind}" + ); + } + + #[test] + fn circle3_polyline_lies_on_the_radius_in_its_plane() { + let center = Point3::from_mm(1.0, 2.0, 3.0); + let points = circle3_polyline(center, 5.0, UnitVec3::z_axis()); + assert!(points.len() >= 8, "the ring is tessellated"); + points.iter().for_each(|p| { + let (px, py, pz) = (*p - center).coords_mm(); + let radius = px.hypot(py); + assert!( + (radius - 5.0).abs() < 1e-6, + "point sits on the radius: {radius}" + ); + assert!(pz.abs() < 1e-9, "point stays in the z plane: {pz}"); + }); + } + + #[test] + fn arc3_polyline_runs_from_start_to_end_on_the_radius() { + let center = Point3::from_mm(0.0, 0.0, 0.0); + let start = Point3::from_mm(4.0, 0.0, 0.0); + let end = Point3::from_mm(0.0, 4.0, 0.0); + let points = arc3_polyline(center, start, end, UnitVec3::z_axis()); + let (Some(first), Some(last)) = (points.first(), points.last()) else { + panic!("an arc has points"); + }; + assert!( + (*first - start).coords_mm().0.abs() < 1e-6 + && (*first - start).coords_mm().1.abs() < 1e-6 + ); + let (lx, ly, _) = (*last - center).coords_mm(); + assert!( + (lx).abs() < 1e-6 && (ly - 4.0).abs() < 1e-6, + "arc ends at the end point" + ); + points.iter().for_each(|p| { + let (px, py, _) = (*p - center).coords_mm(); + assert!( + (px.hypot(py) - 4.0).abs() < 1e-6, + "every sample is on the radius" + ); + }); + } + + #[test] + fn an_open_spline_does_not_return_to_its_first_control_point() { + let control = [ + Point3::from_mm(0.0, 0.0, 0.0), + Point3::from_mm(10.0, 5.0, 0.0), + Point3::from_mm(20.0, -5.0, 0.0), + Point3::from_mm(30.0, 0.0, 0.0), + ]; + let open = spline3_polyline(&control, false); + let closed = spline3_polyline(&control, true); + let (Some(open_first), Some(open_last)) = (open.first(), open.last()) else { + panic!("an open spline tessellates"); + }; + let span = (*open_last - *open_first).coords_mm(); + let separation = span.0.hypot(span.1).hypot(span.2); + assert!( + separation > 1.0, + "the open spline keeps its ends apart: {separation}" + ); + assert!( + closed.len() > open.len(), + "the closed spline adds the wrap-around span" + ); + let near = |a: Point3, b: Point3| { + let (dx, dy, dz) = (a - b).coords_mm(); + dx.hypot(dy).hypot(dz) < 1e-9 + }; + assert!( + near(*open_first, control[0]), + "an open spline interpolates its first control point" + ); + assert!( + near(*open_last, control[3]), + "an open spline interpolates its last control point" + ); + let (Some(closed_first), Some(closed_last)) = + (closed.first().copied(), closed.last().copied()) + else { + panic!("a closed spline tessellates"); + }; + assert!( + near(closed_last, closed_first), + "a closed spline returns to its start" + ); + } +} diff --git a/crates/bone-app/src/sketch3_project.rs b/crates/bone-app/src/sketch3_project.rs new file mode 100644 index 0000000..33771ad --- /dev/null +++ b/crates/bone-app/src/sketch3_project.rs @@ -0,0 +1,835 @@ +use std::collections::{BTreeSet, HashMap}; + +use bone_document::{ + DimensionKind, EditOutcome, Sketch, Sketch3, Sketch3Entity, Sketch3Relation, SketchDimension, + SketchEdit, SketchEntity, SketchRelation, SketchStatusReport, +}; +use bone_types::{ + Length, Plane3, Point2, Point3, SketchDimensionId, SketchEntityId, SketchItemId, + SketchPlaneBasis, SketchRelationId, UnitVec3, +}; +use uom::si::length::millimeter; + +use crate::sketch_mode::{ClickAnchor, Pending, SketchTool}; +use crate::snap::SnapHit; +use crate::tools; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum PlaneLock { + Locked, + Free, +} + +pub struct PlaneView { + plane: SketchPlaneBasis, + sketch: Sketch, + to_3d: HashMap, + to_2d: HashMap, +} + +impl PlaneView { + #[must_use] + pub fn plane(&self) -> SketchPlaneBasis { + self.plane + } + + #[must_use] + pub fn sketch(&self) -> &Sketch { + &self.sketch + } + + #[must_use] + pub fn to_3d(&self, projected: SketchEntityId) -> Option { + self.to_3d.get(&projected).copied() + } + + #[must_use] + pub fn to_2d(&self, three: SketchEntityId) -> Option { + self.to_2d.get(&three).copied() + } +} + +#[must_use] +pub fn flatten(plane: SketchPlaneBasis, world: Point3) -> Point2 { + Plane3::from(plane).project(world) +} + +#[must_use] +pub fn project(sketch3: &Sketch3, plane: SketchPlaneBasis) -> PlaneView { + let projected = sketch3.project_onto(plane); + let to_3d: HashMap = projected + .sketch() + .entity_order() + .iter() + .filter_map(|id2| projected.to_3d(*id2).map(|id3| (*id2, id3))) + .collect(); + let to_2d: HashMap = + to_3d.iter().map(|(id2, id3)| (*id3, *id2)).collect(); + let sketch = sketch3 + .relation_order() + .iter() + .filter_map(|rid| sketch3.relations().get(*rid).copied()) + .filter_map(|relation| unproject_relation(relation, plane, &to_2d)) + .fold(projected.into_sketch(), |sketch, edit| { + match sketch.clone().apply(edit) { + Ok((next, _)) => next, + Err(_) => sketch, + } + }); + PlaneView { + plane, + sketch, + to_3d, + to_2d, + } +} + +#[must_use] +pub fn project_sketch(sketch3: &Sketch3, plane: SketchPlaneBasis) -> Sketch { + project(sketch3, plane).sketch +} + +#[must_use] +pub fn relation_item_map( + sketch3: &Sketch3, + plane: SketchPlaneBasis, +) -> HashMap { + let projected = sketch3.project_onto(plane); + let to_2d: HashMap = sketch3 + .entity_order() + .iter() + .filter_map(|id3| projected.to_2d(*id3).map(|id2| (*id3, id2))) + .collect(); + sketch3 + .relation_order() + .iter() + .filter_map(|rid| { + sketch3 + .relations() + .get(*rid) + .copied() + .map(|rel| (*rid, rel)) + }) + .filter_map(|(rid, rel)| unproject_relation(rel, plane, &to_2d).map(|edit| (rid, edit))) + .fold( + (projected.into_sketch(), HashMap::new()), + |(sketch, mut map), (rid, edit)| match sketch.clone().apply(edit) { + Ok((next, EditOutcome::Dimension(dim))) => { + map.insert(rid, SketchItemId::Dimension(dim)); + (next, map) + } + Ok((next, EditOutcome::Relation(rel))) => { + map.insert(rid, SketchItemId::Relation(rel)); + (next, map) + } + Ok((next, _)) => (next, map), + Err(_) => (sketch, map), + }, + ) + .1 +} + +#[must_use] +pub fn dimension_relation_map( + sketch3: &Sketch3, + plane: SketchPlaneBasis, +) -> HashMap { + relation_item_map(sketch3, plane) + .into_iter() + .filter_map(|(rid, item)| match item { + SketchItemId::Dimension(dim) => Some((dim, rid)), + _ => None, + }) + .collect() +} + +#[must_use] +pub fn relation_for_item( + sketch3: &Sketch3, + plane: SketchPlaneBasis, + item: SketchItemId, +) -> Option { + relation_item_map(sketch3, plane) + .into_iter() + .find_map(|(rid, mapped)| (mapped == item).then_some(rid)) +} + +#[must_use] +pub fn project_status( + sketch3: &Sketch3, + plane: SketchPlaneBasis, + report: &SketchStatusReport, +) -> SketchStatusReport { + let projected = sketch3.project_onto(plane); + let relations = relation_item_map(sketch3, plane); + let remap = |item: &SketchItemId| match *item { + SketchItemId::Entity(id3) => { + let mapped = projected.to_2d(id3).map(SketchItemId::Entity); + debug_assert!( + mapped.is_some(), + "offending sketch entity {id3:?} has no projected 2D image" + ); + mapped + } + SketchItemId::Relation(rid) => relations.get(&rid).copied().or_else(|| { + sketch3.relations().get(rid).and_then(|rel| { + rel.references() + .into_iter() + .find_map(|eid| projected.to_2d(eid).map(SketchItemId::Entity)) + }) + }), + SketchItemId::Dimension(dim) => Some(SketchItemId::Dimension(dim)), + }; + let offending = report.offending().iter().filter_map(remap).collect(); + let redundant = report.redundant().iter().filter_map(remap).collect(); + report + .clone() + .with_offending(offending) + .with_redundant(redundant) +} + +fn aligned(a: UnitVec3, b: UnitVec3) -> bool { + let (ax, ay, az) = a.components(); + let (bx, by, bz) = b.components(); + ax.mul_add(bx, ay.mul_add(by, az * bz)).abs() > 0.999 +} + +const fn dimension_kind(reference: bool) -> DimensionKind { + if reference { + DimensionKind::Driven + } else { + DimensionKind::Driving + } +} + +fn unproject_relation( + relation: Sketch3Relation, + plane: SketchPlaneBasis, + to_2d: &HashMap, +) -> Option { + let m = |id| to_2d.get(&id).copied(); + let mapped = match relation { + Sketch3Relation::Coincident(a, b) => SketchRelation::Coincident(m(a)?, m(b)?), + Sketch3Relation::PointOnLine { point, line } => { + SketchRelation::Coincident(m(point)?, m(line)?) + } + Sketch3Relation::Perpendicular(a, b) => SketchRelation::Perpendicular(m(a)?, m(b)?), + Sketch3Relation::Parallel(a, b) => SketchRelation::Parallel(m(a)?, m(b)?), + Sketch3Relation::Tangent(a, b) => SketchRelation::Tangent(m(a)?, m(b)?), + Sketch3Relation::Equal(a, b) => SketchRelation::Equal(m(a)?, m(b)?), + Sketch3Relation::Concentric(a, b) => SketchRelation::Concentric(m(a)?, m(b)?), + Sketch3Relation::Midpoint { point, line } => SketchRelation::Midpoint { + point: m(point)?, + line: m(line)?, + }, + Sketch3Relation::Symmetric { a, b, axis } => SketchRelation::Symmetric { + a: m(a)?, + b: m(b)?, + axis: m(axis)?, + }, + Sketch3Relation::Fix(a) => SketchRelation::Fix(m(a)?), + Sketch3Relation::Horizontal(line) => SketchRelation::Horizontal(m(line)?), + Sketch3Relation::Vertical(line) => SketchRelation::Vertical(m(line)?), + Sketch3Relation::AlongAxis { line, axis } => { + if aligned(axis, plane.x_axis()) { + SketchRelation::Horizontal(m(line)?) + } else if aligned(axis, plane.y_axis()) { + SketchRelation::Vertical(m(line)?) + } else { + return None; + } + } + Sketch3Relation::Distance { + a, + b, + value, + reference, + } => { + return Some(SketchEdit::AddDimension(SketchDimension::Linear { + a: m(a)?, + b: m(b)?, + value, + kind: dimension_kind(reference), + })); + } + Sketch3Relation::Radius { + target, + value, + reference, + } => { + return Some(SketchEdit::AddDimension(SketchDimension::Radius { + target: m(target)?, + value, + kind: dimension_kind(reference), + })); + } + Sketch3Relation::Diameter { + target, + value, + reference, + } => { + return Some(SketchEdit::AddDimension(SketchDimension::Diameter { + target: m(target)?, + value, + kind: dimension_kind(reference), + })); + } + Sketch3Relation::Angle { + a, + b, + value, + reference, + } => { + return Some(SketchEdit::AddDimension(SketchDimension::Angular { + a: m(a)?, + b: m(b)?, + value, + kind: dimension_kind(reference), + })); + } + Sketch3Relation::OnPlane { .. } => return None, + }; + Some(SketchEdit::AddRelation(mapped)) +} + +#[must_use] +pub fn lift(plane: SketchPlaneBasis, point: Point2) -> Point3 { + let (u, v) = point.coords_mm(); + Plane3::from(plane).point_at_local(u, v, 0.0) +} + +fn plane_offset(plane: SketchPlaneBasis) -> Length { + let (nx, ny, nz) = plane.normal().components(); + let (ox, oy, oz) = plane.origin().coords_mm(); + Length::new::(ox.mul_add(nx, oy.mul_add(ny, oz * nz))) +} + +#[must_use] +pub fn embed_point( + sketch3: &Sketch3, + plane: SketchPlaneBasis, + point: Point2, +) -> (Sketch3, SketchEntityId) { + let (next, id) = sketch3.with_entity(Sketch3Entity::point(lift(plane, point))); + let (next, _) = next.with_relation(Sketch3Relation::OnPlane { + point: id, + normal: plane.normal(), + offset: plane_offset(plane), + }); + (next, id) +} + +#[must_use] +pub fn embed_line( + sketch3: &Sketch3, + from: SketchEntityId, + to: SketchEntityId, + for_construction: bool, +) -> (Sketch3, SketchEntityId) { + sketch3.with_entity(Sketch3Entity::line(from, to, for_construction)) +} + +pub struct Placed3 { + pub sketch: Sketch3, + pub pending: Option, +} + +#[must_use] +pub fn embed_place( + base: &Sketch3, + view: &PlaneView, + tool: SketchTool, + effective: Point2, + pending3: Option, + snap: Option, + lock: PlaneLock, +) -> Placed3 { + let pending2 = pending3.and_then(|p| translate_pending(p, |id| view.to_2d(id))); + let (placed, next2) = tools::place(view.sketch().clone(), tool, effective, pending2, snap); + let Some(new2) = placed else { + return Placed3 { + sketch: base.clone(), + pending: next2.and_then(|p| translate_pending(p, |id| view.to_3d(id))), + }; + }; + let (sketch, map) = embed_delta(base, view, &new2, lock); + Placed3 { + sketch, + pending: next2.and_then(|p| translate_pending(p, |id| map.get(&id).copied())), + } +} + +pub(crate) fn embed_delta( + base: &Sketch3, + view: &PlaneView, + edited: &Sketch, + lock: PlaneLock, +) -> (Sketch3, HashMap) { + let plane = view.plane(); + let old_entities: BTreeSet = + view.sketch().entity_order().iter().copied().collect(); + let old_relations: BTreeSet<_> = view.sketch().relation_order().iter().copied().collect(); + let mut map: HashMap = view + .sketch() + .entity_order() + .iter() + .filter_map(|id2| view.to_3d(*id2).map(|id3| (*id2, id3))) + .collect(); + let fresh: Vec = edited + .entity_order() + .iter() + .copied() + .filter(|id| !old_entities.contains(id)) + .collect(); + let points = fresh + .iter() + .filter(|id| matches!(edited.entities().get(**id), Some(SketchEntity::Point(_)))); + let curves = fresh + .iter() + .filter(|id| !matches!(edited.entities().get(**id), Some(SketchEntity::Point(_)))); + let sketch = points.chain(curves).fold(base.clone(), |sketch, id2| { + match embed_entity(&sketch, plane, edited.entities().get(*id2), &map, lock) { + Some((next, id3)) => { + map.insert(*id2, id3); + next + } + None => sketch, + } + }); + let relations: Vec = edited + .relation_order() + .iter() + .filter(|rid| !old_relations.contains(*rid)) + .filter_map(|rid| edited.relations().get(*rid).copied()) + .filter_map(|rel| map_relation(rel, &map, &sketch)) + .collect(); + let sketch = relations + .into_iter() + .fold(sketch, |sketch, rel| sketch.with_relation(rel).0); + (sketch, map) +} + +#[must_use] +pub(crate) fn to_plane_pending(view: &PlaneView, pending3: Option) -> Option { + pending3.and_then(|p| translate_pending(p, |id| view.to_2d(id))) +} + +fn embed_entity( + sketch3: &Sketch3, + plane: SketchPlaneBasis, + entity: Option<&SketchEntity>, + map: &HashMap, + lock: PlaneLock, +) -> Option<(Sketch3, SketchEntityId)> { + let to3d = |id| map.get(&id).copied(); + match entity? { + SketchEntity::Point(point) => Some(match lock { + PlaneLock::Locked => embed_point(sketch3, plane, point.at()), + PlaneLock::Free => sketch3.with_entity(Sketch3Entity::point(lift(plane, point.at()))), + }), + SketchEntity::Line(line) => Some(embed_line( + sketch3, + to3d(line.a())?, + to3d(line.b())?, + line.for_construction(), + )), + SketchEntity::Circle(circle) => Some(sketch3.with_entity(Sketch3Entity::circle( + to3d(circle.center())?, + circle.radius(), + plane.normal(), + circle.for_construction(), + ))), + SketchEntity::Arc(arc) => Some(sketch3.with_entity(Sketch3Entity::arc( + to3d(arc.center())?, + to3d(arc.start())?, + to3d(arc.end())?, + plane.normal(), + arc.for_construction(), + ))), + SketchEntity::Ellipse(ellipse) => Some(sketch3.with_entity(Sketch3Entity::ellipse( + to3d(ellipse.center())?, + to3d(ellipse.major())?, + to3d(ellipse.minor())?, + plane.normal(), + ellipse.for_construction(), + ))), + SketchEntity::Spline(spline) => { + let control = spline + .control() + .iter() + .map(|id| to3d(*id)) + .collect::>>()?; + Some(sketch3.with_entity(Sketch3Entity::spline( + control, + spline.closed(), + spline.for_construction(), + ))) + } + } +} + +#[must_use] +pub fn embed_sketch(sketch: &Sketch) -> Sketch3 { + let basis = sketch.plane(); + let points = sketch + .entity_order() + .iter() + .filter(|id| matches!(sketch.entities().get(**id), Some(SketchEntity::Point(_)))); + let others = sketch + .entity_order() + .iter() + .filter(|id| !matches!(sketch.entities().get(**id), Some(SketchEntity::Point(_)))); + let (base, map) = points.chain(others).fold( + (Sketch3::on_plane(basis), HashMap::new()), + |(s3, mut map), id2| match embed_entity( + &s3, + basis, + sketch.entities().get(*id2), + &map, + PlaneLock::Locked, + ) { + Some((next, id3)) => { + map.insert(*id2, id3); + (next, map) + } + None => (s3, map), + }, + ); + let relations: Vec = sketch + .relation_order() + .iter() + .filter_map(|rid| sketch.relations().get(*rid).copied()) + .filter_map(|relation| map_relation(relation, &map, &base)) + .collect(); + let with_relations = relations + .into_iter() + .fold(base, |s3, relation| s3.with_relation(relation).0); + sketch + .dimension_order() + .iter() + .filter_map(|did| sketch.dimensions().get(*did).copied()) + .filter_map(|dimension| map_dimension(dimension, &map)) + .fold(with_relations, |s3, relation| s3.with_relation(relation).0) +} + +pub(crate) fn map_dimension( + dimension: SketchDimension, + map: &HashMap, +) -> Option { + let m = |id| map.get(&id).copied(); + let reference = matches!(dimension.kind(), DimensionKind::Driven); + match dimension { + SketchDimension::Linear { a, b, value, .. } => Some(Sketch3Relation::Distance { + a: m(a)?, + b: m(b)?, + value, + reference, + }), + SketchDimension::Radius { target, value, .. } => Some(Sketch3Relation::Radius { + target: m(target)?, + value, + reference, + }), + SketchDimension::Diameter { target, value, .. } => Some(Sketch3Relation::Diameter { + target: m(target)?, + value, + reference, + }), + SketchDimension::Angular { a, b, value, .. } => Some(Sketch3Relation::Angle { + a: m(a)?, + b: m(b)?, + value, + reference, + }), + } +} + +pub(crate) fn map_relation( + relation: SketchRelation, + map: &HashMap, + sketch3: &Sketch3, +) -> Option { + let m = |id| map.get(&id).copied(); + let is_point = |id| matches!(sketch3.entities().get(id), Some(Sketch3Entity::Point(_))); + let is_line = |id| matches!(sketch3.entities().get(id), Some(Sketch3Entity::Line(_))); + let is_curve = |id| { + matches!( + sketch3.entities().get(id), + Some(Sketch3Entity::Circle(_) | Sketch3Entity::Arc(_) | Sketch3Entity::Ellipse(_)) + ) + }; + match relation { + SketchRelation::Coincident(a, b) => { + let (a3, b3) = (m(a)?, m(b)?); + match ( + is_point(a3), + is_point(b3), + is_line(a3), + is_line(b3), + is_curve(a3), + is_curve(b3), + ) { + (true, true, ..) => Some(Sketch3Relation::Coincident(a3, b3)), + (true, _, _, true, _, _) => Some(Sketch3Relation::PointOnLine { + point: a3, + line: b3, + }), + (_, true, true, _, _, _) => Some(Sketch3Relation::PointOnLine { + point: b3, + line: a3, + }), + (true, _, _, _, _, true) => Some(Sketch3Relation::Coincident(a3, b3)), + (_, true, _, _, true, _) => Some(Sketch3Relation::Coincident(b3, a3)), + _ => None, + } + } + SketchRelation::Horizontal(line) => Some(Sketch3Relation::Horizontal(m(line)?)), + SketchRelation::Vertical(line) => Some(Sketch3Relation::Vertical(m(line)?)), + SketchRelation::Parallel(a, b) => Some(Sketch3Relation::Parallel(m(a)?, m(b)?)), + SketchRelation::Perpendicular(a, b) => Some(Sketch3Relation::Perpendicular(m(a)?, m(b)?)), + SketchRelation::Tangent(a, b) => Some(Sketch3Relation::Tangent(m(a)?, m(b)?)), + SketchRelation::Equal(a, b) => Some(Sketch3Relation::Equal(m(a)?, m(b)?)), + SketchRelation::Concentric(a, b) => Some(Sketch3Relation::Concentric(m(a)?, m(b)?)), + SketchRelation::Midpoint { point, line } => Some(Sketch3Relation::Midpoint { + point: m(point)?, + line: m(line)?, + }), + SketchRelation::Symmetric { a, b, axis } => Some(Sketch3Relation::Symmetric { + a: m(a)?, + b: m(b)?, + axis: m(axis)?, + }), + SketchRelation::Fix(id) => Some(Sketch3Relation::Fix(m(id)?)), + } +} + +fn translate_pending( + pending: Pending, + map: impl Fn(SketchEntityId) -> Option, +) -> Option { + match pending { + Pending::First(a) => translate_anchor(a, &map).map(Pending::First), + Pending::Second(a, b) => Some(Pending::Second( + translate_anchor(a, &map)?, + translate_anchor(b, &map)?, + )), + } +} + +fn translate_anchor( + anchor: ClickAnchor, + map: impl Fn(SketchEntityId) -> Option, +) -> Option { + match anchor { + ClickAnchor::Position(p) => Some(ClickAnchor::Position(p)), + ClickAnchor::Endpoint(id) => map(id).map(ClickAnchor::Endpoint), + ClickAnchor::MidpointOf { line, position } => { + map(line).map(|line| ClickAnchor::MidpointOf { line, position }) + } + ClickAnchor::OnEdge { entity, position } => { + map(entity).map(|entity| ClickAnchor::OnEdge { entity, position }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bone_types::{Tolerance, UnitVec3}; + + const TOL: Tolerance = Tolerance::new(1.0e-9); + + fn plane() -> SketchPlaneBasis { + let Ok(basis) = SketchPlaneBasis::new( + Point3::from_mm(0.0, 0.0, 5.0), + UnitVec3::x_axis(), + UnitVec3::y_axis(), + TOL, + ) else { + panic!("xy plane offset by 5mm is orthonormal"); + }; + basis + } + + #[test] + fn project_round_trips_points_into_plane_coordinates() { + let basis = plane(); + let (s3, a) = embed_point(&Sketch3::new(), basis, Point2::from_mm(3.0, -4.0)); + let (s3, b) = embed_point(&s3, basis, Point2::from_mm(7.0, 1.0)); + let (s3, _line) = embed_line(&s3, a, b, false); + + let view = project(&s3, basis); + assert_eq!( + view.sketch().entity_order().len(), + 3, + "two points and one line" + ); + let Some((sx, sy)) = + view.sketch() + .entities() + .iter() + .find_map(|(id, entity)| match entity { + SketchEntity::Point(p) if view.to_3d(id) == Some(a) => Some(p.at().coords_mm()), + _ => None, + }) + else { + panic!("the first projected point maps back to its 3d id"); + }; + assert!( + (sx - 3.0).abs() < 1e-9 && (sy + 4.0).abs() < 1e-9, + "({sx}, {sy})" + ); + } + + #[test] + fn project_status_remaps_offending_into_the_projection() { + let basis = plane(); + let (s3, a) = embed_point(&Sketch3::new(), basis, Point2::from_mm(0.0, 0.0)); + let (s3, b) = embed_point(&s3, basis, Point2::from_mm(5.0, 0.0)); + let (s3, _) = s3.with_relation(Sketch3Relation::Fix(a)); + let (s3, _) = s3.with_relation(Sketch3Relation::Distance { + a, + b, + value: Length::new::(5.0), + reference: false, + }); + let (s3, _) = s3.with_relation(Sketch3Relation::Distance { + a, + b, + value: Length::new::(7.0), + reference: false, + }); + let report = s3.status(); + assert_eq!(report.status(), bone_types::SketchStatus::OverDefined); + let remapped = project_status(&s3, basis, &report); + assert_eq!(remapped.status(), report.status(), "status is preserved"); + assert!( + !remapped.offending().is_empty(), + "the conflict survives the id remap" + ); + let view = project_sketch(&s3, basis); + remapped.offending().iter().for_each(|item| { + let present = match *item { + SketchItemId::Entity(id) => view.entities().contains_key(id), + SketchItemId::Relation(id) => view.relations().contains_key(id), + SketchItemId::Dimension(id) => view.dimensions().contains_key(id), + }; + assert!( + present, + "offending item resolves in the projection: {item:?}" + ); + }); + } + + #[test] + fn map_relation_routes_point_and_circle_to_coincident() { + let basis = plane(); + let (s3, center) = embed_point(&Sketch3::new(), basis, Point2::from_mm(0.0, 0.0)); + let (s3, circle) = s3.with_entity(Sketch3Entity::circle( + center, + Length::new::(4.0), + basis.normal(), + false, + )); + let (s3, free) = embed_point(&s3, basis, Point2::from_mm(8.0, 0.0)); + let view = project(&s3, basis); + let map: HashMap = view + .sketch() + .entity_order() + .iter() + .filter_map(|id2| view.to_3d(*id2).map(|id3| (*id2, id3))) + .collect(); + let (Some(free2), Some(circle2)) = (view.to_2d(free), view.to_2d(circle)) else { + panic!("the free point and the circle both project"); + }; + let mapped = map_relation(SketchRelation::Coincident(free2, circle2), &map, &s3); + assert_eq!( + mapped, + Some(Sketch3Relation::Coincident(free, circle)), + "a point-and-circle coincidence lands a point-on-curve relation", + ); + } + + #[test] + fn project_emits_a_circle_for_a_3d_circle() { + let basis = plane(); + let (s3, center) = embed_point(&Sketch3::new(), basis, Point2::from_mm(1.0, 2.0)); + let (s3, circle3) = s3.with_entity(Sketch3Entity::circle( + center, + Length::new::(7.0), + basis.normal(), + false, + )); + let view = project(&s3, basis); + let Some(id2) = view.to_2d(circle3) else { + panic!("the circle maps into the plane view"); + }; + let Some(SketchEntity::Circle(c)) = view.sketch().entities().get(id2) else { + panic!("the projected entity is a circle"); + }; + assert!((c.radius().get::() - 7.0).abs() < 1e-9); + assert_eq!(view.to_3d(id2), Some(circle3), "the id map round-trips"); + } + + #[test] + fn embed_point_lands_on_the_plane() { + let basis = plane(); + let (s3, id) = embed_point(&Sketch3::new(), basis, Point2::from_mm(2.0, 6.0)); + let Some(position) = s3.point_position(id) else { + panic!("placed point resolves"); + }; + let (x, y, z) = position.coords_mm(); + assert!((x - 2.0).abs() < 1e-9 && (y - 6.0).abs() < 1e-9 && (z - 5.0).abs() < 1e-9); + } + + #[test] + fn reference_dimension_projects_to_a_driven_overlay_dimension() { + let basis = plane(); + let (s3, a) = embed_point(&Sketch3::new(), basis, Point2::from_mm(0.0, 0.0)); + let (s3, b) = embed_point(&s3, basis, Point2::from_mm(10.0, 0.0)); + let (s3, _line) = embed_line(&s3, a, b, false); + let distance = |reference| Sketch3Relation::Distance { + a, + b, + value: Length::new::(10.0), + reference, + }; + let overlay_kind = |sketch: &Sketch3| { + project(sketch, basis) + .sketch() + .dimensions() + .iter() + .map(|(_, dim)| dim.kind()) + .next() + }; + + let driving = s3.with_relation(distance(false)).0; + let reference = s3.with_relation(distance(true)).0; + assert_eq!(overlay_kind(&driving), Some(DimensionKind::Driving)); + assert_eq!( + overlay_kind(&reference), + Some(DimensionKind::Driven), + "a reference 3d dimension renders as a driven overlay dimension", + ); + } + + #[test] + fn driven_overlay_dimension_maps_back_to_a_reference_relation() { + let a = SketchEntityId::default(); + let b = SketchEntityId::default(); + let map = HashMap::from([(a, a), (b, b)]); + let driven = SketchDimension::Linear { + a, + b, + value: Length::new::(4.0), + kind: DimensionKind::Driven, + }; + let Some(relation) = map_dimension(driven, &map) else { + panic!("a linear dimension maps to a distance relation"); + }; + assert!( + relation.is_reference(), + "a driven overlay dimension carries the reference flag into the 3d relation", + ); + } +} diff --git a/crates/bone-app/src/sketch_mode.rs b/crates/bone-app/src/sketch_mode.rs index f0b2320..d3bdfa6 100644 --- a/crates/bone-app/src/sketch_mode.rs +++ b/crates/bone-app/src/sketch_mode.rs @@ -1,12 +1,13 @@ use core::num::NonZeroU32; use bone_document::{ - ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, Sketch, - SketchDimension, SketchEntity, + AxisRecipe, CsysAxisRef, CsysRecipe, DatumFeature, ExtrudeEndCondition, ExtrudeFeature, + ExtrudeOperation, PlaneRecipe, PointRecipe, RevolveAngle, RevolveAxis, RevolveFeature, Sketch3, + Sketch3Entity, Sketch3Relation, SketchDimension, }; use bone_types::{ - ExtrudeId, Length, Point2, Point3, PositiveLength, SketchEntityId, SketchId, SketchPlaneBasis, - Tolerance, UnitVec3, + DatumId, DatumTarget, ExtrudeId, FeatureId, Length, Point2, Point3, PositiveLength, RevolveId, + SketchEntityId, SketchId, SketchPlaneBasis, Tolerance, UnitVec3, }; use bone_ui::hotkey::ActionId; use uom::si::length::millimeter; @@ -15,32 +16,38 @@ use uom::si::length::millimeter; pub enum SketchTool { Point, Line, + Centerline, CenterpointArc, TangentArc, ThreePointArc, Circle, PerimeterCircle, + Ellipse, CornerRectangle, CenterRectangle, ThreePointCornerRectangle, ThreePointCenterRectangle, Parallelogram, + Spline, } impl SketchTool { pub const ENTITIES: &'static [Self] = &[ Self::Point, Self::Line, + Self::Centerline, Self::CenterpointArc, Self::TangentArc, Self::ThreePointArc, Self::Circle, Self::PerimeterCircle, + Self::Ellipse, Self::CornerRectangle, Self::CenterRectangle, Self::ThreePointCornerRectangle, Self::ThreePointCenterRectangle, Self::Parallelogram, + Self::Spline, ]; } @@ -48,37 +55,107 @@ impl SketchTool { pub enum FeatureTool { ExtrudedBossBase, ExtrudedCut, + RevolvedBossBase, + RevolvedCut, + DatumPlane, + DatumAxis, + DatumPoint, + DatumCoordinateSystem, + Sketch3D, } impl FeatureTool { - pub const ALL: &'static [Self] = &[Self::ExtrudedBossBase, Self::ExtrudedCut]; + pub const ALL: &'static [Self] = &[ + Self::ExtrudedBossBase, + Self::ExtrudedCut, + Self::RevolvedBossBase, + Self::RevolvedCut, + Self::DatumPlane, + Self::DatumAxis, + Self::DatumPoint, + Self::DatumCoordinateSystem, + Self::Sketch3D, + ]; + + pub const EXTRUDE: &'static [Self] = &[Self::ExtrudedBossBase, Self::ExtrudedCut]; + + pub const REVOLVE: &'static [Self] = &[Self::RevolvedBossBase, Self::RevolvedCut]; + + pub const DATUM: &'static [Self] = &[ + Self::DatumPlane, + Self::DatumAxis, + Self::DatumPoint, + Self::DatumCoordinateSystem, + ]; + + pub const SKETCH3: &'static [Self] = &[Self::Sketch3D]; + + #[must_use] + pub const fn is_datum(self) -> bool { + matches!( + self, + Self::DatumPlane | Self::DatumAxis | Self::DatumPoint | Self::DatumCoordinateSystem + ) + } } -#[derive(Copy, Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] +pub struct DatumArming { + pub feature: DatumFeature, + pub target: Option, +} + +impl DatumArming { + #[must_use] + pub fn new(feature: DatumFeature) -> Self { + Self { + feature, + target: None, + } + } +} + +#[derive(Clone, Debug, PartialEq)] pub enum ExtrudeArming { Profile { - feature: ExtrudeFeature, + feature: Box, target: Option, }, - AwaitingSketch, + AwaitingSketch(ExtrudeOperation), } impl ExtrudeArming { #[must_use] pub fn profile(sketch: SketchId) -> Self { Self::Profile { - feature: default_extrude_feature(sketch), + feature: Box::new(default_extrude_feature(sketch)), target: None, } } #[must_use] - pub fn edit(target: ExtrudeId, feature: ExtrudeFeature) -> Self { + pub fn armed(sketch: SketchId, operation: ExtrudeOperation) -> Self { Self::Profile { - feature, + feature: Box::new(extrude_feature(sketch, operation)), + target: None, + } + } + + #[must_use] + pub fn edit(target: ExtrudeId, feature: &ExtrudeFeature) -> Self { + Self::Profile { + feature: Box::new(*feature), target: Some(target), } } + + #[must_use] + pub fn operation(&self) -> ExtrudeOperation { + match self { + Self::Profile { feature, .. } => feature.operation, + Self::AwaitingSketch(operation) => *operation, + } + } } const DEFAULT_EXTRUDE_DEPTH_MM: f64 = 10.0; @@ -93,43 +170,184 @@ pub fn default_extrude_depth() -> PositiveLength { #[must_use] pub fn default_extrude_feature(sketch: SketchId) -> ExtrudeFeature { + extrude_feature(sketch, ExtrudeOperation::default()) +} + +#[must_use] +pub fn extrude_feature(sketch: SketchId, operation: ExtrudeOperation) -> ExtrudeFeature { ExtrudeFeature { - sketch, - direction: ExtrudeDirection::Normal { - sense: ExtrudeSense::Forward, - }, - end_condition: ExtrudeEndCondition::Blind { - depth: default_extrude_depth(), - }, - draft: None, - thin_wall: None, - merge_result: MergeResult::Merge, + operation, + ..ExtrudeFeature::blind(sketch, default_extrude_depth()) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum RevolveArming { + Profile { + feature: Box, + target: Option, + }, + AwaitingSketch(ExtrudeOperation), +} + +impl RevolveArming { + #[must_use] + pub fn armed(sketch: SketchId, axis: RevolveAxis, operation: ExtrudeOperation) -> Self { + Self::Profile { + feature: Box::new(revolve_feature(sketch, axis, operation)), + target: None, + } + } + + #[must_use] + pub fn operation(&self) -> ExtrudeOperation { + match self { + Self::Profile { feature, .. } => feature.operation, + Self::AwaitingSketch(operation) => *operation, + } } } +#[must_use] +pub fn revolve_feature( + sketch: SketchId, + axis: RevolveAxis, + operation: ExtrudeOperation, +) -> RevolveFeature { + RevolveFeature { + operation, + ..RevolveFeature::blind(sketch, axis, RevolveAngle::full()) + } +} + +const DEFAULT_DATUM_OFFSET_MM: f64 = 10.0; + +#[must_use] +pub fn default_datum_plane(reference: FeatureId) -> DatumFeature { + DatumFeature::plane(PlaneRecipe::Offset { + from: DatumTarget::Feature(reference), + distance: Length::new::(DEFAULT_DATUM_OFFSET_MM), + flip: false, + }) +} + +#[must_use] +pub fn default_datum_axis(first_plane: FeatureId, second_plane: FeatureId) -> DatumFeature { + DatumFeature::axis(AxisRecipe::TwoPlanes { + a: DatumTarget::Feature(first_plane), + b: DatumTarget::Feature(second_plane), + }) +} + +#[must_use] +pub fn default_datum_point(origin: FeatureId) -> DatumFeature { + DatumFeature::point(PointRecipe::OnPoint { + reference: DatumTarget::Feature(origin), + }) +} + +#[must_use] +pub fn default_datum_coordinate_system( + origin: FeatureId, + x_axis_plane: FeatureId, + y_axis_plane: FeatureId, +) -> DatumFeature { + DatumFeature::coordinate_system(CsysRecipe { + origin: DatumTarget::Feature(origin), + x_axis: Some(CsysAxisRef { + target: DatumTarget::Feature(x_axis_plane), + reverse: false, + }), + y_axis: Some(CsysAxisRef { + target: DatumTarget::Feature(y_axis_plane), + reverse: false, + }), + z_axis: None, + position: None, + rotation: None, + }) +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ReferenceKind { + Face, + Vertex, + Body, +} + #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum EndConditionKind { Blind, + ThroughAll, + ThroughAllBoth, MidPlane, + UpToVertex, + UpToSurface, + OffsetFromSurface, + UpToBody, } impl EndConditionKind { - pub const SUPPORTED: &'static [Self] = &[Self::Blind, Self::MidPlane]; + pub const SUPPORTED: &'static [Self] = &[ + Self::Blind, + Self::ThroughAll, + Self::ThroughAllBoth, + Self::MidPlane, + Self::UpToVertex, + Self::UpToSurface, + Self::OffsetFromSurface, + Self::UpToBody, + ]; #[must_use] pub fn of(condition: &ExtrudeEndCondition) -> Option { match condition { ExtrudeEndCondition::Blind { .. } => Some(Self::Blind), + ExtrudeEndCondition::ThroughAll => Some(Self::ThroughAll), + ExtrudeEndCondition::ThroughAllBoth => Some(Self::ThroughAllBoth), ExtrudeEndCondition::MidPlane { .. } => Some(Self::MidPlane), - _ => None, + ExtrudeEndCondition::UpToVertex { .. } => Some(Self::UpToVertex), + ExtrudeEndCondition::UpToSurface { .. } => Some(Self::UpToSurface), + ExtrudeEndCondition::OffsetFromSurface { .. } => Some(Self::OffsetFromSurface), + ExtrudeEndCondition::UpToBody { .. } => Some(Self::UpToBody), + ExtrudeEndCondition::UpToNext => None, + } + } + + #[must_use] + pub fn needs_depth(self) -> bool { + matches!(self, Self::Blind | Self::MidPlane) + } + + #[must_use] + pub fn needs_reference(self) -> bool { + matches!( + self, + Self::UpToVertex | Self::UpToSurface | Self::OffsetFromSurface | Self::UpToBody + ) + } + + #[must_use] + pub fn reference_kind(self) -> Option { + match self { + Self::UpToSurface | Self::OffsetFromSurface => Some(ReferenceKind::Face), + Self::UpToVertex => Some(ReferenceKind::Vertex), + Self::UpToBody => Some(ReferenceKind::Body), + Self::Blind | Self::ThroughAll | Self::ThroughAllBoth | Self::MidPlane => None, } } #[must_use] pub fn with_depth(self, depth: PositiveLength) -> ExtrudeEndCondition { match self { - Self::Blind => ExtrudeEndCondition::Blind { depth }, Self::MidPlane => ExtrudeEndCondition::MidPlane { depth }, + Self::Blind + | Self::ThroughAll + | Self::ThroughAllBoth + | Self::UpToVertex + | Self::UpToSurface + | Self::OffsetFromSurface + | Self::UpToBody => ExtrudeEndCondition::Blind { depth }, } } } @@ -152,6 +370,24 @@ pub enum Plane { } impl Plane { + #[must_use] + pub const fn next(self) -> Self { + match self { + Self::Xy => Self::Yz, + Self::Yz => Self::Zx, + Self::Zx => Self::Xy, + } + } + + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Xy => "XY", + Self::Yz => "YZ", + Self::Zx => "ZX", + } + } + #[must_use] pub fn basis(self) -> SketchPlaneBasis { let (x, y) = match self { @@ -185,6 +421,10 @@ pub enum ClickAnchor { line: SketchEntityId, position: Point2, }, + OnEdge { + entity: SketchEntityId, + position: Point2, + }, } #[derive(Copy, Clone, Debug, PartialEq)] @@ -199,56 +439,68 @@ pub struct PendingDimension { pub anchor: Point2, } -#[derive(Copy, Clone, Debug, Default, PartialEq)] -pub struct DragPins([Option<(SketchEntityId, Point2)>; 3]); +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DragPins(Vec<(SketchEntityId, Point3)>); impl DragPins { #[cfg(test)] #[must_use] - pub const fn from_array(arr: [Option<(SketchEntityId, Point2)>; 3]) -> Self { - Self(arr) + pub fn from_array(arr: [Option<(SketchEntityId, Point3)>; 3]) -> Self { + Self(arr.into_iter().flatten().collect()) } #[must_use] - pub fn from_sketch_entity(sketch: &Sketch, id: SketchEntityId) -> Option { - let entity = sketch.entities().get(id).copied()?; - let pin = |pid: SketchEntityId| -> Option<(SketchEntityId, Point2)> { - match sketch.entities().get(pid)? { - SketchEntity::Point(p) => Some((pid, p.at())), - _ => None, - } + pub fn from_sketch3_entity(sketch: &Sketch3, id: SketchEntityId) -> Option { + let entity = sketch.entities().get(id)?; + let pin = |pid: SketchEntityId| -> Option<(SketchEntityId, Point3)> { + sketch.point_position(pid).map(|at| (pid, at)) }; - match entity { - SketchEntity::Point(p) => Some(Self([Some((id, p.at())), None, None])), - SketchEntity::Line(l) => Some(Self([Some(pin(l.a())?), Some(pin(l.b())?), None])), - SketchEntity::Arc(a) => Some(Self([ - Some(pin(a.center())?), - Some(pin(a.start())?), - Some(pin(a.end())?), - ])), - SketchEntity::Circle(c) => Some(Self([Some(pin(c.center())?), None, None])), - } + let pins = match entity { + Sketch3Entity::Point(p) => vec![(id, p.at())], + Sketch3Entity::Line(l) => vec![pin(l.a())?, pin(l.b())?], + Sketch3Entity::Arc(a) => vec![pin(a.center())?, pin(a.start())?, pin(a.end())?], + Sketch3Entity::Circle(c) => vec![pin(c.center())?], + Sketch3Entity::Ellipse(e) => vec![pin(e.center())?, pin(e.major())?, pin(e.minor())?], + Sketch3Entity::Spline(s) => s + .control() + .iter() + .map(|cp| pin(*cp)) + .collect::>>()?, + }; + Some(Self(pins)) } - pub fn iter(&self) -> impl Iterator + '_ { - self.0.iter().filter_map(|p| *p) + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().copied() } #[must_use] - pub fn to_targets(self, press: Point2, cursor: Point2) -> Vec<(SketchEntityId, Point2)> { + pub fn to_targets( + &self, + basis: SketchPlaneBasis, + press: Point2, + cursor: Point2, + ) -> Vec<(SketchEntityId, Point3)> { let (px, py) = press.coords_mm(); let (cx, cy) = cursor.coords_mm(); let (dx, dy) = (cx - px, cy - py); + let (ax, ay, az) = basis.x_axis().components(); + let (bx, by, bz) = basis.y_axis().components(); + let (wx, wy, wz) = ( + dx.mul_add(ax, dy * bx), + dx.mul_add(ay, dy * by), + dx.mul_add(az, dy * bz), + ); self.iter() .map(|(id, original)| { - let (ox, oy) = original.coords_mm(); - (id, Point2::from_mm(ox + dx, oy + dy)) + let (ox, oy, oz) = original.coords_mm(); + (id, Point3::from_mm(ox + wx, oy + wy, oz + wz)) }) .collect() } } -#[derive(Copy, Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct DragSession { pub entity: SketchEntityId, pub press: Point2, @@ -258,15 +510,41 @@ pub struct DragSession { #[derive(Copy, Clone, Debug, PartialEq)] pub enum DimensionFlow { Editing(PendingDimension), - Conflict(PendingDimension), + Conflict(Sketch3Relation), +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct SplineDraftPoint { + pub id: SketchEntityId, + pub fresh: bool, +} + +impl SplineDraftPoint { + #[must_use] + pub const fn fresh(id: SketchEntityId) -> Self { + Self { id, fresh: true } + } + + #[must_use] + pub const fn reused(id: SketchEntityId) -> Self { + Self { id, fresh: false } + } } -#[derive(Copy, Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct SketchSession { pub tool: Option, pub pending: Option, pub drag: Option, pub dim_flow: Option, + pub spatial: Option, + pub spline_draft: Vec, + pub aid_anchor: Option, +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct Spatial { + pub plane: Plane, } #[derive(Clone, Debug, Default, PartialEq)] @@ -278,9 +556,68 @@ pub enum Mode { session: Box, }, Extrude(ExtrudeArming), + Revolve(RevolveArming), + Datum(DatumArming), +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ModeExit { + None, + Toggle, + Dialog, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct ModeLifecycle { + pub button: Option, + pub exit: ModeExit, +} + +#[must_use] +pub fn datum_button(feature: &DatumFeature) -> FeatureTool { + match feature { + DatumFeature::Plane(_) => FeatureTool::DatumPlane, + DatumFeature::Axis(_) => FeatureTool::DatumAxis, + DatumFeature::Point(_) => FeatureTool::DatumPoint, + DatumFeature::CoordinateSystem(_) => FeatureTool::DatumCoordinateSystem, + } } impl Mode { + #[must_use] + pub fn lifecycle(&self) -> ModeLifecycle { + match self { + Self::Idle => ModeLifecycle { + button: None, + exit: ModeExit::None, + }, + Self::Sketch { session, .. } => ModeLifecycle { + button: session.spatial.map(|_| FeatureTool::Sketch3D), + exit: ModeExit::Toggle, + }, + Self::Extrude(arming) => ModeLifecycle { + button: Some(if arming.operation().is_cut() { + FeatureTool::ExtrudedCut + } else { + FeatureTool::ExtrudedBossBase + }), + exit: ModeExit::Dialog, + }, + Self::Revolve(arming) => ModeLifecycle { + button: Some(if arming.operation().is_cut() { + FeatureTool::RevolvedCut + } else { + FeatureTool::RevolvedBossBase + }), + exit: ModeExit::Dialog, + }, + Self::Datum(arming) => ModeLifecycle { + button: Some(datum_button(&arming.feature)), + exit: ModeExit::Dialog, + }, + } + } + #[must_use] pub fn enter_sketch(sketch_id: SketchId) -> Self { Self::Sketch { @@ -289,6 +626,32 @@ impl Mode { } } + #[must_use] + pub fn enter_sketch3(sketch_id: SketchId, plane: Plane) -> Self { + Self::Sketch { + sketch_id, + session: Box::new(SketchSession { + spatial: Some(Spatial { plane }), + ..SketchSession::default() + }), + } + } + + #[must_use] + pub fn cycle_sketch3_plane(self) -> Self { + self.map_session_spatial(|s, sp| SketchSession { + spatial: Some(Spatial { + plane: sp.plane.next(), + }), + ..s + }) + } + + #[must_use] + pub fn is_sketch3(&self) -> bool { + matches!(self, Self::Sketch { session, .. } if session.spatial.is_some()) + } + #[must_use] fn map_session(self, f: impl FnOnce(SketchSession) -> SketchSession) -> Self { match self { @@ -296,26 +659,84 @@ impl Mode { sketch_id, session: Box::new(f(*session)), }, - Self::Idle | Self::Extrude(_) => self, + Self::Idle | Self::Extrude(_) | Self::Revolve(_) | Self::Datum(_) => self, } } + #[must_use] + fn map_session_spatial(self, f: impl FnOnce(SketchSession, Spatial) -> SketchSession) -> Self { + self.map_session(|s| match s.spatial { + Some(spatial) => f(s, spatial), + None => s, + }) + } + #[must_use] pub fn arm_tool(self, kind: SketchTool) -> Self { - self.map_session(|_| SketchSession { + self.map_session(|s| SketchSession { tool: Some(kind), + spatial: s.spatial, ..SketchSession::default() }) } #[must_use] pub fn disarm_tool(self) -> Self { - self.map_session(|_| SketchSession::default()) + self.map_session(|s| SketchSession { + spatial: s.spatial, + ..SketchSession::default() + }) } #[must_use] pub fn clear_pending(self) -> Self { - self.map_session(|s| SketchSession { pending: None, ..s }) + self.map_session(|mut s| { + s.pending = None; + s.spline_draft.clear(); + s.aid_anchor = None; + s + }) + } + + #[must_use] + pub fn with_aid_anchor(self, anchor: Point3) -> Self { + self.map_session(|mut s| { + s.aid_anchor = Some(anchor); + s + }) + } + + #[must_use] + pub fn spline_draft(&self) -> &[SplineDraftPoint] { + match self { + Self::Sketch { session, .. } => &session.spline_draft, + Self::Idle | Self::Extrude(_) | Self::Revolve(_) | Self::Datum(_) => &[], + } + } + + #[must_use] + pub fn spline_draft_fresh(&self) -> Vec { + self.spline_draft() + .iter() + .filter(|point| point.fresh) + .map(|point| point.id) + .collect() + } + + #[must_use] + pub fn with_spline_point(self, point: SplineDraftPoint) -> Self { + self.map_session(|mut s| { + s.spline_draft.push(point); + s + }) + } + + #[must_use] + pub fn clear_spline_draft(self) -> Self { + self.map_session(|mut s| { + s.spline_draft.clear(); + s + }) } #[must_use] @@ -353,9 +774,9 @@ impl Mode { } #[must_use] - pub fn start_dim_conflict(self, pending: PendingDimension) -> Self { + pub fn start_dim_conflict(self, relation: Sketch3Relation) -> Self { self.map_session(|s| SketchSession { - dim_flow: Some(DimensionFlow::Conflict(pending)), + dim_flow: Some(DimensionFlow::Conflict(relation)), ..s }) } @@ -381,19 +802,37 @@ impl Mode { matches!(self, Self::Extrude(_)) } + #[must_use] + pub const fn is_revolve(&self) -> bool { + matches!(self, Self::Revolve(_)) + } + + #[must_use] + pub const fn is_datum(&self) -> bool { + matches!(self, Self::Datum(_)) + } + #[must_use] pub fn sketch_id(&self) -> Option { match self { Self::Sketch { sketch_id, .. } => Some(*sketch_id), - Self::Idle | Self::Extrude(_) => None, + Self::Idle | Self::Extrude(_) | Self::Revolve(_) | Self::Datum(_) => None, } } #[must_use] pub fn active_tool(&self) -> Option { match self { - Self::Sketch { session, .. } => session.tool, - Self::Idle | Self::Extrude(_) => None, + Self::Sketch { session, .. } if session.spatial.is_none() => session.tool, + _ => None, + } + } + + #[must_use] + pub fn sketch3_tool(&self) -> Option { + match self { + Self::Sketch { session, .. } if session.spatial.is_some() => session.tool, + _ => None, } } @@ -401,7 +840,23 @@ impl Mode { pub fn dim_flow(&self) -> Option { match self { Self::Sketch { session, .. } => session.dim_flow, - Self::Idle | Self::Extrude(_) => None, + Self::Idle | Self::Extrude(_) | Self::Revolve(_) | Self::Datum(_) => None, + } + } + + #[must_use] + pub fn sketch3_id(&self) -> Option { + match self { + Self::Sketch { sketch_id, session } if session.spatial.is_some() => Some(*sketch_id), + _ => None, + } + } + + #[must_use] + pub fn sketch3_plane(&self) -> Option { + match self { + Self::Sketch { session, .. } => session.spatial.map(|sp| sp.plane), + _ => None, } } } @@ -410,13 +865,58 @@ impl Mode { mod tests { use super::{ ClickAnchor, DEFAULT_EXTRUDE_DEPTH_MM, DimensionFlow, DragPins, DragSession, - EndConditionKind, ExtrudeArming, ExtrudeDirection, ExtrudeEndCondition, ExtrudeSense, - FeatureTool, MergeResult, Mode, Pending, Plane, SketchSession, SketchTool, + EndConditionKind, ExtrudeArming, ExtrudeEndCondition, ExtrudeOperation, FeatureTool, Mode, + ModeExit, ModeLifecycle, Pending, Plane, SketchSession, SketchTool, SplineDraftPoint, default_extrude_feature, end_condition_depth, }; - use bone_types::{Length, Point2, PositiveLength, SketchEntityId, SketchId}; + use bone_document::{ExtrudeDirection, ExtrudeSense, MergeResult}; + use bone_types::{Length, Point2, Point3, PositiveLength, SketchEntityId, SketchId}; use uom::si::length::millimeter; + #[test] + fn lifecycle_pins_each_mode_button_and_exit() { + let sketch = SketchId::default(); + let sketch3 = SketchId::default(); + assert_eq!( + Mode::Idle.lifecycle(), + ModeLifecycle { + button: None, + exit: ModeExit::None, + }, + ); + assert_eq!( + Mode::enter_sketch(sketch).lifecycle(), + ModeLifecycle { + button: None, + exit: ModeExit::Toggle, + }, + ); + assert_eq!( + Mode::enter_sketch3(sketch3, Plane::Xy).lifecycle(), + ModeLifecycle { + button: Some(FeatureTool::Sketch3D), + exit: ModeExit::Toggle, + }, + ); + let boss = Mode::Extrude(ExtrudeArming::profile(sketch)).lifecycle(); + assert_eq!(boss.button, Some(FeatureTool::ExtrudedBossBase)); + assert_eq!(boss.exit, ModeExit::Dialog); + assert_eq!( + Mode::Extrude(ExtrudeArming::armed(sketch, ExtrudeOperation::Cut)) + .lifecycle() + .button, + Some(FeatureTool::ExtrudedCut), + ); + } + + #[test] + fn a_toggle_mode_button_round_trips_to_its_owner() { + let mode = Mode::enter_sketch3(SketchId::default(), Plane::Xy); + let lifecycle = mode.lifecycle(); + assert_eq!(lifecycle.exit, ModeExit::Toggle); + assert_eq!(lifecycle.button, Some(FeatureTool::Sketch3D)); + } + #[test] fn sketch_mode_projections_read_session() { let id = SketchId::default(); @@ -428,29 +928,55 @@ mod tests { #[test] fn off_sketch_projections_are_empty() { - [Mode::Idle, Mode::Extrude(ExtrudeArming::AwaitingSketch)] - .into_iter() - .for_each(|mode| { - assert_eq!(mode.sketch_id(), None); - assert_eq!(mode.active_tool(), None); - assert_eq!(mode.dim_flow(), None); - }); + [ + Mode::Idle, + Mode::Extrude(ExtrudeArming::AwaitingSketch(ExtrudeOperation::default())), + ] + .into_iter() + .for_each(|mode| { + assert_eq!(mode.sketch_id(), None); + assert_eq!(mode.active_tool(), None); + assert_eq!(mode.dim_flow(), None); + }); } #[test] fn session_transforms_leave_extrude_untouched() { - let mode = Mode::Extrude(ExtrudeArming::AwaitingSketch); + let mode = Mode::Extrude(ExtrudeArming::AwaitingSketch(ExtrudeOperation::default())); assert_eq!(mode.clone().arm_tool(SketchTool::Line), mode); assert_eq!(mode.clone().disarm_tool(), mode); assert_eq!(mode.clone().clear_pending(), mode); } #[test] - fn feature_tool_all_lists_both_extrude_buttons() { + fn feature_tool_all_lists_extrude_then_datum_buttons() { assert_eq!( FeatureTool::ALL, + &[ + FeatureTool::ExtrudedBossBase, + FeatureTool::ExtrudedCut, + FeatureTool::RevolvedBossBase, + FeatureTool::RevolvedCut, + FeatureTool::DatumPlane, + FeatureTool::DatumAxis, + FeatureTool::DatumPoint, + FeatureTool::DatumCoordinateSystem, + FeatureTool::Sketch3D, + ], + ); + assert_eq!( + FeatureTool::EXTRUDE, &[FeatureTool::ExtrudedBossBase, FeatureTool::ExtrudedCut], ); + assert_eq!( + FeatureTool::REVOLVE, + &[FeatureTool::RevolvedBossBase, FeatureTool::RevolvedCut], + ); + assert!(FeatureTool::DATUM.iter().all(|tool| tool.is_datum())); + assert!( + FeatureTool::EXTRUDE.iter().all(|tool| !tool.is_datum()), + "the extrude group carries no datum tools", + ); } #[test] @@ -519,10 +1045,41 @@ mod tests { DragSession { entity, press: Point2::origin(), - pins: DragPins::from_array([Some((entity, Point2::origin())), None, None]), + pins: DragPins::from_array([Some((entity, Point3::origin())), None, None]), } } + #[test] + fn spline_draft_accumulates_then_clears() { + let id = SketchEntityId::default(); + let mode = Mode::enter_sketch3(SketchId::default(), Plane::Xy) + .with_spline_point(SplineDraftPoint::fresh(id)) + .with_spline_point(SplineDraftPoint::fresh(id)) + .with_spline_point(SplineDraftPoint::fresh(id)); + assert_eq!( + mode.spline_draft().len(), + 3, + "every click adds a control point" + ); + assert!( + mode.clear_spline_draft().spline_draft().is_empty(), + "finalize or cancel empties the draft", + ); + } + + #[test] + fn escape_path_clears_spline_draft_first() { + let id = SketchEntityId::default(); + let mode = Mode::enter_sketch3(SketchId::default(), Plane::Xy) + .arm_tool(SketchTool::Spline) + .with_spline_point(SplineDraftPoint::fresh(id)); + let cleared = mode.clear_pending(); + assert!( + cleared.spline_draft().is_empty(), + "clearing the pending chain also drops the spline draft", + ); + } + #[test] fn start_drag_records_entity_keeps_tool_and_pending() { let drag = sample_drag_session(); @@ -536,7 +1093,7 @@ mod tests { ..SketchSession::default() }), } - .start_drag(drag); + .start_drag(drag.clone()); let Mode::Sketch { session, .. } = mode else { panic!("expected sketch mode"); }; @@ -575,7 +1132,11 @@ mod tests { #[test] fn entities_table_holds_day_one_set() { - assert_eq!(SketchTool::ENTITIES.len(), 12, "day-1 entity tools"); + assert_eq!( + SketchTool::ENTITIES.len(), + 15, + "twelve day-1 entity tools plus the centerline, the spline, and the ellipse", + ); } #[test] @@ -642,26 +1203,22 @@ mod tests { #[test] fn cancel_dimension_leaves_conflict_intact() { - use bone_document::{DimensionKind, SketchDimension}; + use bone_document::Sketch3Relation; use bone_types::Length; use uom::si::length::millimeter; - let proto = SketchDimension::Linear { + let relation = Sketch3Relation::Distance { a: SketchEntityId::default(), b: SketchEntityId::default(), value: Length::new::(5.0), - kind: DimensionKind::Driving, - }; - let pending = super::PendingDimension { - proto, - anchor: Point2::origin(), + reference: false, }; let mode = Mode::enter_sketch(SketchId::default()) - .start_dim_conflict(pending) + .start_dim_conflict(relation) .cancel_dimension(); let Mode::Sketch { session, .. } = mode else { panic!("expected sketch mode"); }; - assert_eq!(session.dim_flow, Some(DimensionFlow::Conflict(pending))); + assert_eq!(session.dim_flow, Some(DimensionFlow::Conflict(relation))); } #[test] @@ -714,6 +1271,42 @@ mod tests { }); } + #[test] + fn next_cycles_the_three_principal_planes() { + assert_eq!(Plane::Xy.next(), Plane::Yz); + assert_eq!(Plane::Yz.next(), Plane::Zx); + assert_eq!(Plane::Zx.next(), Plane::Xy); + } + + #[test] + fn cycle_sketch3_plane_advances_only_in_sketch3() { + let sketch3 = Mode::enter_sketch3(SketchId::default(), Plane::Xy); + let Mode::Sketch { session, .. } = sketch3.cycle_sketch3_plane() else { + panic!("cycling a 3D sketch stays in sketch mode"); + }; + let Some(spatial) = session.spatial else { + panic!("cycling a 3D sketch stays spatial"); + }; + assert_eq!(spatial.plane, Plane::Yz); + assert_eq!(Mode::Idle.cycle_sketch3_plane(), Mode::Idle); + } + + #[test] + fn every_entity_tool_arms_in_the_3d_sketch() { + SketchTool::ENTITIES.iter().copied().for_each(|tool| { + let armed = Mode::enter_sketch3(SketchId::default(), Plane::Xy).arm_tool(tool); + assert_eq!(armed.sketch3_tool(), Some(tool)); + }); + } + + #[test] + fn sketch3_tool_reads_the_armed_3d_tool_only_in_sketch3() { + let armed = Mode::enter_sketch3(SketchId::default(), Plane::Xy).arm_tool(SketchTool::Line); + assert_eq!(armed.sketch3_tool(), Some(SketchTool::Line)); + assert_eq!(armed.active_tool(), None); + assert_eq!(Mode::Idle.sketch3_tool(), None); + } + #[test] fn default_extrude_feature_is_blind_forward_merge() { let feature = default_extrude_feature(SketchId::default()); @@ -727,7 +1320,10 @@ mod tests { feature.end_condition, ExtrudeEndCondition::Blind { .. } )); - assert_eq!(feature.merge_result, MergeResult::Merge); + assert_eq!( + feature.operation, + ExtrudeOperation::Boss(MergeResult::Merge) + ); assert!(feature.draft.is_none()); assert!(feature.thin_wall.is_none()); let Some(depth) = end_condition_depth(&feature.end_condition) else { @@ -737,25 +1333,104 @@ mod tests { } #[test] - fn supported_end_conditions_are_blind_and_midplane() { + fn supported_end_conditions_list_eight_in_canonical_order() { assert_eq!( EndConditionKind::SUPPORTED, - &[EndConditionKind::Blind, EndConditionKind::MidPlane], + &[ + EndConditionKind::Blind, + EndConditionKind::ThroughAll, + EndConditionKind::ThroughAllBoth, + EndConditionKind::MidPlane, + EndConditionKind::UpToVertex, + EndConditionKind::UpToSurface, + EndConditionKind::OffsetFromSurface, + EndConditionKind::UpToBody, + ], ); } #[test] - fn end_condition_kind_round_trips_through_depth() { + fn up_to_next_maps_to_no_supported_kind() { + assert_eq!(EndConditionKind::of(&ExtrudeEndCondition::UpToNext), None); + } + + #[test] + fn end_condition_kind_of_round_trips_every_supported_variant() { + use bone_types::{FaceLabel, FaceRole, FeatureId, ImportOrdinal, VertexLabel, VertexRole}; let Ok(depth) = PositiveLength::new(Length::new::(7.0)) else { unreachable!("7 mm is positive and finite"); }; - EndConditionKind::SUPPORTED.iter().for_each(|kind| { - let condition = kind.with_depth(depth); - assert_eq!(EndConditionKind::of(&condition), Some(*kind)); - assert_eq!(end_condition_depth(&condition), Some(depth)); + let body = FeatureId::default(); + let face = FaceLabel { + feature: body, + role: FaceRole::StartCap, + }; + let vertex = VertexLabel { + feature: body, + role: VertexRole::Imported { + ordinal: ImportOrdinal::new(0), + }, + }; + [ + ExtrudeEndCondition::Blind { depth }, + ExtrudeEndCondition::ThroughAll, + ExtrudeEndCondition::ThroughAllBoth, + ExtrudeEndCondition::MidPlane { depth }, + ExtrudeEndCondition::UpToVertex { vertex }, + ExtrudeEndCondition::UpToSurface { surface: face }, + ExtrudeEndCondition::OffsetFromSurface { + surface: face, + offset: Length::new::(2.0), + reverse_offset: false, + translate_surface: false, + }, + ExtrudeEndCondition::UpToBody { body }, + ] + .into_iter() + .for_each(|condition| { + let Some(kind) = EndConditionKind::of(&condition) else { + panic!("every supported variant maps to a kind"); + }; + assert!(EndConditionKind::SUPPORTED.contains(&kind)); }); } + #[test] + fn depth_kinds_round_trip_through_with_depth() { + let Ok(depth) = PositiveLength::new(Length::new::(7.0)) else { + unreachable!("7 mm is positive and finite"); + }; + [EndConditionKind::Blind, EndConditionKind::MidPlane] + .into_iter() + .for_each(|kind| { + let condition = kind.with_depth(depth); + assert_eq!(EndConditionKind::of(&condition), Some(kind)); + assert_eq!(end_condition_depth(&condition), Some(depth)); + }); + } + + #[test] + fn reference_kinds_classify_by_end_condition() { + use super::ReferenceKind; + assert_eq!( + EndConditionKind::UpToSurface.reference_kind(), + Some(ReferenceKind::Face) + ); + assert_eq!( + EndConditionKind::OffsetFromSurface.reference_kind(), + Some(ReferenceKind::Face) + ); + assert_eq!( + EndConditionKind::UpToVertex.reference_kind(), + Some(ReferenceKind::Vertex) + ); + assert_eq!( + EndConditionKind::UpToBody.reference_kind(), + Some(ReferenceKind::Body) + ); + assert_eq!(EndConditionKind::Blind.reference_kind(), None); + } + #[test] fn extrude_arming_profile_carries_the_sketch() { let id = SketchId::default(); diff --git a/crates/bone-app/src/smart_dimension.rs b/crates/bone-app/src/smart_dimension.rs index 28868fe..fcf44fa 100644 --- a/crates/bone-app/src/smart_dimension.rs +++ b/crates/bone-app/src/smart_dimension.rs @@ -95,7 +95,7 @@ pub const fn placeholder_for(value: DimensionValue) -> StringKey { fn single(sketch: &Sketch, id: SketchEntityId) -> Option { let entity = sketch.entities().get(id)?; match entity { - SketchEntity::Point(_) => None, + SketchEntity::Point(_) | SketchEntity::Ellipse(_) | SketchEntity::Spline(_) => None, SketchEntity::Line(l) => { let a = point_position(sketch, l.a())?; let b = point_position(sketch, l.b())?; diff --git a/crates/bone-app/src/snap.rs b/crates/bone-app/src/snap.rs index 1221bae..e2a2664 100644 --- a/crates/bone-app/src/snap.rs +++ b/crates/bone-app/src/snap.rs @@ -6,6 +6,7 @@ use uom::si::length::millimeter; pub enum SnapKind { Endpoint(SketchEntityId), Midpoint(SketchEntityId), + OnEdge(SketchEntityId), Tangent(SketchEntityId), Horizontal, Vertical, @@ -34,6 +35,7 @@ pub fn detect( endpoint_snap(cursor, anchor, sketch, tol) .or_else(|| midpoint_snap(cursor, anchor, sketch, tol)) .or_else(|| anchor.and_then(|a| tangent_snap(cursor, a.pos, sketch, tol))) + .or_else(|| on_edge_snap(cursor, anchor, sketch, tol)) .or_else(|| anchor.and_then(|a| axis_snap(cursor, a.pos, tol))) } @@ -95,6 +97,82 @@ fn midpoint_snap( }) } +fn on_edge_snap( + cursor: Point2, + anchor: Option, + sketch: &Sketch, + tol: f64, +) -> Option { + let anchor_id = anchor.and_then(|a| a.id); + sketch + .entity_order() + .iter() + .filter_map(|id| edge_projection(sketch, *id, cursor, anchor_id).map(|world| (*id, world))) + .filter_map(|(id, world)| { + let d = distance(world, cursor); + (d <= tol).then_some((d, id, world)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, id, world)| SnapHit { + kind: SnapKind::OnEdge(id), + world, + }) +} + +fn edge_projection( + sketch: &Sketch, + id: SketchEntityId, + cursor: Point2, + exclude_anchor: Option, +) -> Option { + match sketch.entities().get(id)? { + SketchEntity::Line(line) => { + let excluded = + exclude_anchor.is_some_and(|aid| aid == id || line.a() == aid || line.b() == aid); + if excluded { + return None; + } + let a = endpoint_position(sketch, line.a())?; + let b = endpoint_position(sketch, line.b())?; + project_to_segment(cursor, a, b) + } + SketchEntity::Circle(_) | SketchEntity::Arc(_) => { + project_to_curve(cursor, curve_target(sketch, id)?) + } + SketchEntity::Point(_) | SketchEntity::Ellipse(_) | SketchEntity::Spline(_) => None, + } +} + +fn project_to_segment(cursor: Point2, a: Point2, b: Point2) -> Option { + let (cx, cy) = cursor.coords_mm(); + let (ax, ay) = a.coords_mm(); + let (bx, by) = b.coords_mm(); + let (dx, dy) = (bx - ax, by - ay); + let len2 = dx.mul_add(dx, dy * dy); + if len2 <= f64::EPSILON { + return None; + } + let t = (cx - ax).mul_add(dx, (cy - ay) * dy) / len2; + (0.0..=1.0) + .contains(&t) + .then(|| Point2::from_mm(t.mul_add(dx, ax), t.mul_add(dy, ay))) +} + +fn project_to_curve(cursor: Point2, target: CurveTarget) -> Option { + let (cursor_x, cursor_y) = cursor.coords_mm(); + let (origin_x, origin_y) = target.center.coords_mm(); + let (radial_x, radial_y) = (cursor_x - origin_x, cursor_y - origin_y); + let reach = radial_x.hypot(radial_y); + if reach <= f64::EPSILON { + return None; + } + let world = Point2::from_mm( + (radial_x / reach).mul_add(target.radius, origin_x), + (radial_y / reach).mul_add(target.radius, origin_y), + ); + in_arc_sweep(world, target).then_some(world) +} + fn tangent_snap(cursor: Point2, anchor: Point2, sketch: &Sketch, tol: f64) -> Option { sketch .entity_order() @@ -366,6 +444,78 @@ mod tests { assert!(matches!(hit.kind, SnapKind::Endpoint(_))); } + #[test] + fn on_edge_snaps_to_the_nearest_point_along_a_line() { + let sketch = Sketch::new(xy_basis()); + let (sketch, a) = add_point(sketch, 0.0, 0.0); + let (sketch, b) = add_point(sketch, 10.0, 0.0); + let (sketch, line) = add_line(sketch, a, b); + let cursor = Point2::from_mm(3.0, 0.1); + let Some(hit) = detect(cursor, None, &sketch, tol()) else { + panic!("expected an on-edge snap onto the line body"); + }; + assert_eq!(hit.kind, SnapKind::OnEdge(line)); + let (x, y) = hit.world.coords_mm(); + assert!((x - 3.0).abs() < 1e-9, "x={x}"); + assert!(y.abs() < 1e-9, "y={y}"); + } + + #[test] + fn endpoint_and_midpoint_beat_on_edge() { + let sketch = Sketch::new(xy_basis()); + let (sketch, a) = add_point(sketch, 0.0, 0.0); + let (sketch, b) = add_point(sketch, 10.0, 0.0); + let (sketch, _) = add_line(sketch, a, b); + assert!( + matches!( + detect(Point2::from_mm(0.05, 0.05), None, &sketch, tol()).map(|h| h.kind), + Some(SnapKind::Endpoint(_)), + ), + "a corner outranks the line body", + ); + assert!( + matches!( + detect(Point2::from_mm(5.0, 0.1), None, &sketch, tol()).map(|h| h.kind), + Some(SnapKind::Midpoint(_)), + ), + "a midpoint outranks the line body", + ); + } + + #[test] + fn on_edge_snaps_onto_a_circle_rim() { + let sketch = Sketch::new(xy_basis()); + let (sketch, c) = add_point(sketch, 0.0, 0.0); + let (sketch, circle) = add_circle(sketch, c, 5.0); + let cursor = Point2::from_mm(5.2, 0.0); + let Some(hit) = detect(cursor, None, &sketch, tol()) else { + panic!("expected an on-edge snap onto the circle rim"); + }; + assert_eq!(hit.kind, SnapKind::OnEdge(circle)); + let (x, y) = hit.world.coords_mm(); + assert!((x - 5.0).abs() < 1e-9, "x={x}"); + assert!(y.abs() < 1e-9, "y={y}"); + } + + #[test] + fn on_edge_snaps_to_the_far_rim_of_the_anchored_circle_for_a_chord() { + let sketch = Sketch::new(xy_basis()); + let (sketch, c) = add_point(sketch, 0.0, 0.0); + let (sketch, circle) = add_circle(sketch, c, 5.0); + let anchor = Anchor { + pos: Point2::from_mm(-5.0, 0.0), + id: Some(circle), + }; + let cursor = Point2::from_mm(5.2, 0.0); + let Some(hit) = detect(cursor, Some(anchor), &sketch, tol()) else { + panic!("a chord must snap onto the far rim of the same circle the anchor sits on"); + }; + assert_eq!(hit.kind, SnapKind::OnEdge(circle)); + let (x, y) = hit.world.coords_mm(); + assert!((x - 5.0).abs() < 1e-9, "x={x}"); + assert!(y.abs() < 1e-9, "y={y}"); + } + #[test] fn horizontal_axis_with_anchor() { let sketch = Sketch::new(xy_basis()); diff --git a/crates/bone-app/src/snapshots/bone_app__hotkeys__tests__default_hotkey_table.snap b/crates/bone-app/src/snapshots/bone_app__hotkeys__tests__default_hotkey_table.snap index c96f239..866a6b3 100644 --- a/crates/bone-app/src/snapshots/bone_app__hotkeys__tests__default_hotkey_table.snap +++ b/crates/bone-app/src/snapshots/bone_app__hotkeys__tests__default_hotkey_table.snap @@ -2,7 +2,9 @@ source: crates/bone-app/src/hotkeys.rs expression: "rendered.join(\"\\n\")" --- +action=1 chord=Esc scope=Datum action=1 chord=Esc scope=Extrude +action=1 chord=Esc scope=Revolve action=1 chord=Esc scope=Sketch action=10 chord=Ctrl+N scope=Global action=11 chord=Ctrl+O scope=Global @@ -11,6 +13,7 @@ action=13 chord=Ctrl+A scope=Global action=14 chord=Delete scope=Global action=15 chord=F scope=Global action=16 chord=S scope=Global +action=17 chord=Ctrl+Q scope=Global action=18 chord=Ctrl+I scope=Global action=19 chord=Ctrl+E scope=Global action=2 chord=Ctrl+Z scope=Global @@ -27,4 +30,5 @@ action=29 chord=Ctrl+Space scope=Global action=3 chord=Ctrl+Shift+Z scope=Global action=3 chord=Ctrl+Y scope=Global action=30 chord=Ctrl+B scope=Global -action=31 chord=Ctrl+Q scope=Global +action=31 chord=Ctrl+Shift+B scope=Global +action=32 chord=Ctrl+Shift+C scope=Sketch diff --git a/crates/bone-app/src/status_badge.rs b/crates/bone-app/src/status_badge.rs index 026bbe7..3a7162a 100644 --- a/crates/bone-app/src/status_badge.rs +++ b/crates/bone-app/src/status_badge.rs @@ -66,21 +66,40 @@ impl<'a> ExtrudeStatus<'a> { #[must_use] pub fn extrude_badge_style(status: ExtrudeStatus<'_>, cad: &CadColors) -> (StringKey, Color) { - match status { - ExtrudeStatus::Valid => (strings::STATUS_EXTRUDE_VALID, cad.sketch_fully_defined), - ExtrudeStatus::Failed( - ExtrudeError::Kernel(BrepError::DanglingEdge { .. } | BrepError::DanglingVertex { .. }) - | ExtrudeError::PlaneUnresolved(RebuildError::DanglingReference(_)), - ) => (strings::STATUS_EXTRUDE_DANGLING, cad.sketch_dangling), - ExtrudeStatus::Failed(_) => (strings::STATUS_EXTRUDE_INVALID, cad.sketch_invalid), + match status.error() { + None => (strings::STATUS_EXTRUDE_VALID, cad.sketch_fully_defined), + Some(error) if extrude_error_is_dangling(error) => { + (strings::STATUS_EXTRUDE_DANGLING, cad.sketch_dangling) + } + Some(_) => (strings::STATUS_EXTRUDE_INVALID, cad.sketch_invalid), + } +} + +fn extrude_error_is_dangling(error: &ExtrudeError) -> bool { + match error { + ExtrudeError::Kernel(kernel) => matches!( + kernel, + BrepError::DanglingEdge { .. } | BrepError::DanglingVertex { .. } + ), + ExtrudeError::PlaneUnresolved(rebuild) => { + matches!(**rebuild, RebuildError::DanglingReference(_)) + } + ExtrudeError::UnsolvedSketch(_) + | ExtrudeError::CutWithoutBody + | ExtrudeError::FlipSideUnsupported => false, } } -fn extrude_panel_line(error: &ExtrudeError, strings_table: &StringTable) -> LabelText { +#[must_use] +pub fn extrude_panel_line(error: &ExtrudeError, strings_table: &StringTable) -> LabelText { match error { ExtrudeError::UnsolvedSketch(_) => LabelText::Key(strings::EXTRUDE_PANEL_UNSOLVED_SKETCH), ExtrudeError::Kernel(kernel) => kernel_panel_line(kernel, strings_table), - ExtrudeError::PlaneUnresolved(rebuild) => LabelText::Key(rebuild_error_key(*rebuild)), + ExtrudeError::PlaneUnresolved(rebuild) => LabelText::Key(rebuild_error_key(**rebuild)), + ExtrudeError::CutWithoutBody => LabelText::Key(strings::EXTRUDE_PANEL_CUT_WITHOUT_BODY), + ExtrudeError::FlipSideUnsupported => { + LabelText::Key(strings::EXTRUDE_PANEL_UNSUPPORTED_FLIP) + } } } @@ -96,7 +115,9 @@ fn rebuild_error_key(error: RebuildError) -> StringKey { fn kernel_panel_line(error: &BrepError, strings_table: &StringTable) -> LabelText { match error { BrepError::InvalidProfile { reason } => LabelText::Key(profile_defect_key(*reason)), - BrepError::EmptyExtrudeDepth => LabelText::Key(strings::EXTRUDE_PANEL_EMPTY_DEPTH), + BrepError::EmptyExtrudeDepth | BrepError::EndConditionBehindProfile => { + LabelText::Key(strings::EXTRUDE_PANEL_EMPTY_DEPTH) + } BrepError::ShellNotClosed => LabelText::Key(strings::EXTRUDE_PANEL_SHELL_OPEN), BrepError::DegenerateEdge { edge } => brep_entity_line( strings::EXTRUDE_PANEL_DEGENERATE_EDGE, @@ -114,7 +135,27 @@ fn kernel_panel_line(error: &BrepError, strings_table: &StringTable) -> LabelTex vertex.slot(), ), BrepError::TruckUnsupported { detail } => LabelText::Key(truck_gap_key(*detail)), - BrepError::MissingLabel { .. } + BrepError::EndConditionUnresolved => { + LabelText::Key(strings::EXTRUDE_PANEL_DANGLING_REFERENCE) + } + BrepError::RevolveAxisUnresolved => LabelText::Key(strings::EXTRUDE_PANEL_REVOLVE_AXIS), + BrepError::RevolveAxisNotInPlane => { + LabelText::Key(strings::EXTRUDE_PANEL_REVOLVE_AXIS_PLANE) + } + BrepError::RevolveProfileCrossesAxis => { + LabelText::Key(strings::EXTRUDE_PANEL_REVOLVE_CROSSES) + } + BrepError::RevolveProfileTouchesAxis => { + LabelText::Key(strings::EXTRUDE_PANEL_REVOLVE_TOUCHES) + } + BrepError::BooleanDisjoint => LabelText::Key(strings::EXTRUDE_PANEL_CUT_NO_INTERSECT), + BrepError::BooleanUnsupported { detail } => LabelText::Owned( + strings_table + .resolve(strings::EXTRUDE_PANEL_CUT_BLOCKED) + .replace("{detail}", &detail.to_string()), + ), + BrepError::RevolveAngleOutOfRange + | BrepError::MissingLabel { .. } | BrepError::ReattachMismatch { .. } | BrepError::ReattachOrder | BrepError::BlobSerialize @@ -125,6 +166,8 @@ fn kernel_panel_line(error: &BrepError, strings_table: &StringTable) -> LabelTex | BrepError::StepEmpty | BrepError::StepMultipleSolids { .. } | BrepError::StepUnsupported { .. } + | BrepError::BooleanEmpty + | BrepError::BooleanIndeterminate | BrepError::Canceled => LabelText::Key(strings::EXTRUDE_PANEL_INTERNAL), } } @@ -137,6 +180,8 @@ fn profile_defect_key(defect: ProfileDefect) -> StringKey { ProfileDefect::ZeroArea => strings::EXTRUDE_PANEL_PROFILE_ZERO_AREA, ProfileDefect::UncontainedLoop => strings::EXTRUDE_PANEL_PROFILE_UNCONTAINED, ProfileDefect::OverlappingLoops => strings::EXTRUDE_PANEL_PROFILE_OVERLAPPING, + ProfileDefect::UnknownContour => strings::EXTRUDE_PANEL_PROFILE_UNKNOWN_CONTOUR, + ProfileDefect::NonPlanar => strings::EXTRUDE_PANEL_PROFILE_NON_PLANAR, } } @@ -153,6 +198,10 @@ fn truck_gap_key(gap: TruckGap) -> StringKey { | TruckGap::UpToBody => strings::EXTRUDE_PANEL_UNSUPPORTED_END, TruckGap::Draft => strings::EXTRUDE_PANEL_UNSUPPORTED_DRAFT, TruckGap::ThinWall => strings::EXTRUDE_PANEL_UNSUPPORTED_THIN, + TruckGap::MidPlaneDirection2 => strings::EXTRUDE_PANEL_REVOLVE_MIDPLANE, + TruckGap::RevolvePolePartial | TruckGap::RevolvePoleThin => { + strings::REVOLVE_PANEL_UNSUPPORTED_POLE + } } } @@ -277,27 +326,34 @@ fn compose_panel_lines( sketch: &Sketch, strings_table: &StringTable, ) -> Vec { - let total = report.offending().len(); - if total == 0 { + let mut lines: Vec = report + .offending() + .iter() + .map(|item| offending_label(*item, sketch, strings_table)) + .collect(); + if !report.redundant().is_empty() { + lines.push(LabelText::Key(strings::STATUS_PANEL_REDUNDANT)); + lines.extend( + report + .redundant() + .iter() + .map(|item| offending_label(*item, sketch, strings_table)), + ); + } + if lines.is_empty() { return vec![LabelText::Key(strings::STATUS_PANEL_EMPTY)]; } - if total <= STATUS_PANEL_MAX_ROWS { - return report - .offending() - .iter() - .map(|item| offending_label(*item, sketch, strings_table)) - .collect(); + if lines.len() <= STATUS_PANEL_MAX_ROWS { + return lines; } let visible = STATUS_PANEL_MAX_ROWS - 1; - let rest = total - visible; - let head = report - .offending() - .iter() - .take(visible) - .map(|item| offending_label(*item, sketch, strings_table)); + let rest = lines.len() - visible; let more_template = strings_table.resolve(strings::STATUS_PANEL_MORE); - let more_line = LabelText::Owned(more_template.replace("{n}", &rest.to_string())); - head.chain(core::iter::once(more_line)).collect() + lines.truncate(visible); + lines.push(LabelText::Owned( + more_template.replace("{n}", &rest.to_string()), + )); + lines } fn offending_label(item: SketchItemId, sketch: &Sketch, strings_table: &StringTable) -> LabelText { @@ -375,6 +431,8 @@ fn entity_label_key(kind: SketchEntityKind) -> StringKey { SketchEntityKind::Line => strings::STATUS_PANEL_KIND_LINE, SketchEntityKind::Arc => strings::STATUS_PANEL_KIND_ARC, SketchEntityKind::Circle => strings::STATUS_PANEL_KIND_CIRCLE, + SketchEntityKind::Ellipse => strings::STATUS_PANEL_KIND_ELLIPSE, + SketchEntityKind::Spline => strings::STATUS_PANEL_KIND_SPLINE, } } diff --git a/crates/bone-app/src/strings.rs b/crates/bone-app/src/strings.rs index bfeaaeb..8f580e9 100644 --- a/crates/bone-app/src/strings.rs +++ b/crates/bone-app/src/strings.rs @@ -11,6 +11,7 @@ pub const RIBBON_GROUP_ENTITIES: StringKey = StringKey::new("shell.ribbon.group. pub const RIBBON_GROUP_RELATIONS: StringKey = StringKey::new("shell.ribbon.group.relations"); pub const RIBBON_GROUP_DIMENSIONS: StringKey = StringKey::new("shell.ribbon.group.dimensions"); pub const RIBBON_GROUP_EXTRUDE: StringKey = StringKey::new("shell.ribbon.group.extrude"); +pub const RIBBON_GROUP_REVOLVE: StringKey = StringKey::new("shell.ribbon.group.revolve"); pub const CONFIRM_ACCEPT: StringKey = StringKey::new("confirm.accept"); pub const CONFIRM_CANCEL: StringKey = StringKey::new("confirm.cancel"); @@ -43,11 +44,13 @@ pub const HEADS_UP_VIEW_SETTINGS: StringKey = StringKey::new("heads_up.view_sett pub const TOOL_POINT: StringKey = StringKey::new("tool.point"); pub const TOOL_LINE: StringKey = StringKey::new("tool.line"); +pub const TOOL_CENTERLINE: StringKey = StringKey::new("tool.centerline"); pub const TOOL_CENTERPOINT_ARC: StringKey = StringKey::new("tool.centerpoint_arc"); pub const TOOL_TANGENT_ARC: StringKey = StringKey::new("tool.tangent_arc"); pub const TOOL_THREE_POINT_ARC: StringKey = StringKey::new("tool.three_point_arc"); pub const TOOL_CIRCLE: StringKey = StringKey::new("tool.circle"); pub const TOOL_PERIMETER_CIRCLE: StringKey = StringKey::new("tool.perimeter_circle"); +pub const TOOL_ELLIPSE: StringKey = StringKey::new("tool.ellipse"); pub const TOOL_CORNER_RECTANGLE: StringKey = StringKey::new("tool.corner_rectangle"); pub const TOOL_CENTER_RECTANGLE: StringKey = StringKey::new("tool.center_rectangle"); pub const TOOL_THREE_POINT_CORNER_RECTANGLE: StringKey = @@ -55,6 +58,7 @@ pub const TOOL_THREE_POINT_CORNER_RECTANGLE: StringKey = pub const TOOL_THREE_POINT_CENTER_RECTANGLE: StringKey = StringKey::new("tool.three_point_center_rectangle"); pub const TOOL_PARALLELOGRAM: StringKey = StringKey::new("tool.parallelogram"); +pub const TOOL_SPLINE: StringKey = StringKey::new("tool.spline"); pub const TOOL_EXTRUDED_BOSS_BASE: StringKey = StringKey::new("tool.extruded_boss_base"); pub const TOOL_EXTRUDED_CUT: StringKey = StringKey::new("tool.extruded_cut"); @@ -153,12 +157,15 @@ pub const STATUS_PANEL_KIND_POINT: StringKey = StringKey::new("status.panel.kind pub const STATUS_PANEL_KIND_LINE: StringKey = StringKey::new("status.panel.kind.line"); pub const STATUS_PANEL_KIND_ARC: StringKey = StringKey::new("status.panel.kind.arc"); pub const STATUS_PANEL_KIND_CIRCLE: StringKey = StringKey::new("status.panel.kind.circle"); +pub const STATUS_PANEL_KIND_ELLIPSE: StringKey = StringKey::new("status.panel.kind.ellipse"); +pub const STATUS_PANEL_KIND_SPLINE: StringKey = StringKey::new("status.panel.kind.spline"); pub const STATUS_PANEL_KIND_LINEAR: StringKey = StringKey::new("status.panel.kind.linear"); pub const STATUS_PANEL_KIND_ANGULAR: StringKey = StringKey::new("status.panel.kind.angular"); pub const STATUS_PANEL_KIND_RADIUS: StringKey = StringKey::new("status.panel.kind.radius"); pub const STATUS_PANEL_KIND_DIAMETER: StringKey = StringKey::new("status.panel.kind.diameter"); pub const STATUS_PANEL_KIND_UNKNOWN: StringKey = StringKey::new("status.panel.kind.unknown"); pub const STATUS_PANEL_MORE: StringKey = StringKey::new("status.panel.more"); +pub const STATUS_PANEL_REDUNDANT: StringKey = StringKey::new("status.panel.redundant"); pub const TOOLBAR_OVERFLOW: StringKey = StringKey::new("toolbar.overflow"); pub const MENU_BAR_LABEL: StringKey = StringKey::new("shell.menu_bar"); @@ -169,6 +176,10 @@ pub const MENU_INSERT: StringKey = StringKey::new("menu.insert"); pub const MENU_TOOLS: StringKey = StringKey::new("menu.tools"); pub const MENU_SKETCH: StringKey = StringKey::new("menu.sketch"); pub const MENU_SKETCH_EXIT: StringKey = StringKey::new("menu.sketch.exit"); +pub const MENU_SKETCH_CONVERT_ENTITIES: StringKey = StringKey::new("menu.sketch.convert_entities"); +pub const MENU_SKETCH_TOGGLE_CONSTRUCTION: StringKey = + StringKey::new("menu.sketch.toggle_construction"); +pub const MENU_SKETCH_TOGGLE_DRIVEN: StringKey = StringKey::new("menu.sketch.toggle_driven"); pub const MENU_WINDOW: StringKey = StringKey::new("menu.window"); pub const MENU_HELP: StringKey = StringKey::new("menu.help"); pub const MENU_FILE_NEW: StringKey = StringKey::new("menu.file.new"); @@ -246,6 +257,8 @@ pub const HOTKEY_LABEL_SMART_DIMENSION: StringKey = StringKey::new("hotkey.label pub const HOTKEY_LABEL_TRIM: StringKey = StringKey::new("hotkey.label.trim"); pub const HOTKEY_LABEL_EXTEND: StringKey = StringKey::new("hotkey.label.extend"); pub const HOTKEY_LABEL_MIRROR: StringKey = StringKey::new("hotkey.label.mirror"); +pub const HOTKEY_LABEL_CONVERT_ENTITIES: StringKey = + StringKey::new("hotkey.label.convert_entities"); pub const HOTKEY_LABEL_CONSTRUCTION_TOGGLE: StringKey = StringKey::new("hotkey.label.construction_toggle"); pub const HOTKEY_LABEL_UNDO: StringKey = StringKey::new("hotkey.label.undo"); @@ -287,10 +300,92 @@ pub const PROPERTY_ROW_EXTRUDE_DIRECTION_TWO: StringKey = pub const PROPERTY_ROW_EXTRUDE_THIN: StringKey = StringKey::new("property.row.extrude.thin"); pub const PROPERTY_ROW_EXTRUDE_MERGE: StringKey = StringKey::new("property.row.extrude.merge"); pub const PROPERTY_PANE_EXTRUDE_TITLE: StringKey = StringKey::new("property.pane.extrude.title"); +pub const TOOL_REVOLVED_BOSS_BASE: StringKey = StringKey::new("tool.revolved_boss_base"); +pub const TOOL_REVOLVED_CUT: StringKey = StringKey::new("tool.revolved_cut"); +pub const STATUS_REVOLVE_ACTIVE: StringKey = StringKey::new("status.revolve_active"); +pub const PROPERTY_PANE_REVOLVE_TITLE: StringKey = StringKey::new("property.pane.revolve.title"); +pub const PROPERTY_ROW_REVOLVE_TYPE: StringKey = StringKey::new("property.row.revolve.type"); +pub const PROPERTY_ROW_REVOLVE_ANGLE: StringKey = StringKey::new("property.row.revolve.angle"); +pub const PROPERTY_ROW_REVOLVE_REVERSE: StringKey = StringKey::new("property.row.revolve.reverse"); +pub const PROPERTY_ROW_REVOLVE_AXIS: StringKey = StringKey::new("property.row.revolve.axis"); +pub const PROPERTY_ROW_REVOLVE_CONTOURS: StringKey = + StringKey::new("property.row.revolve.contours"); +pub const REVOLVE_AXIS_SELECT: StringKey = StringKey::new("revolve.axis.select"); +pub const REVOLVE_AXIS_SKETCH_LINE: StringKey = StringKey::new("revolve.axis.sketch_line"); +pub const REVOLVE_AXIS_EDGE: StringKey = StringKey::new("revolve.axis.edge"); +pub const REVOLVE_AXIS_DATUM: StringKey = StringKey::new("revolve.axis.datum"); +pub const REVOLVE_AXIS_3D_SKETCH_LINE: StringKey = StringKey::new("revolve.axis.sketch3_line"); +pub const REVOLVE_CONTOUR_SELECT: StringKey = StringKey::new("revolve.contour.select"); +pub const REVOLVE_CONTOUR_ALL: StringKey = StringKey::new("revolve.contour.all"); +pub const REVOLVE_CONTOUR_ONE: StringKey = StringKey::new("revolve.contour.one"); +pub const RIBBON_GROUP_DATUM: StringKey = StringKey::new("shell.ribbon.group.datum"); +pub const RIBBON_GROUP_SKETCH3: StringKey = StringKey::new("shell.ribbon.group.sketch3"); +pub const TOOL_SKETCH3: StringKey = StringKey::new("tool.sketch3"); +pub const TOOL_DATUM_PLANE: StringKey = StringKey::new("tool.datum_plane"); +pub const TOOL_DATUM_AXIS: StringKey = StringKey::new("tool.datum_axis"); +pub const TOOL_DATUM_POINT: StringKey = StringKey::new("tool.datum_point"); +pub const TOOL_DATUM_COORDINATE_SYSTEM: StringKey = StringKey::new("tool.datum_coordinate_system"); +pub const STATUS_DATUM_ACTIVE: StringKey = StringKey::new("status.datum_active"); +pub const STATUS_SKETCH3_ACTIVE: StringKey = StringKey::new("status.sketch3_active"); +pub const NOTIFY_SKETCH3_PLANE: StringKey = StringKey::new("notify.sketch3_plane"); +pub const NOTIFY_SKETCH3_ACTIVE: StringKey = StringKey::new("notify.sketch3_active"); +pub const PROPERTY_PANE_DATUM_PLANE_TITLE: StringKey = + StringKey::new("property.pane.datum.plane.title"); +pub const PROPERTY_PANE_DATUM_AXIS_TITLE: StringKey = + StringKey::new("property.pane.datum.axis.title"); +pub const PROPERTY_PANE_DATUM_POINT_TITLE: StringKey = + StringKey::new("property.pane.datum.point.title"); +pub const PROPERTY_PANE_DATUM_CSYS_TITLE: StringKey = + StringKey::new("property.pane.datum.csys.title"); +pub const PROPERTY_ROW_DATUM_DEFINITION: StringKey = + StringKey::new("property.row.datum.definition"); pub const PROPERTY_GROUP_DIRECTION_1: StringKey = StringKey::new("property.group.direction_1"); pub const PROPERTY_GROUP_FEATURE_SCOPE: StringKey = StringKey::new("property.group.feature_scope"); pub const EXTRUDE_END_BLIND: StringKey = StringKey::new("extrude.end.blind"); pub const EXTRUDE_END_MIDPLANE: StringKey = StringKey::new("extrude.end.midplane"); +pub const EXTRUDE_END_THROUGH_ALL: StringKey = StringKey::new("extrude.end.through_all"); +pub const EXTRUDE_END_THROUGH_ALL_BOTH: StringKey = StringKey::new("extrude.end.through_all_both"); +pub const EXTRUDE_END_UP_TO_VERTEX: StringKey = StringKey::new("extrude.end.up_to_vertex"); +pub const EXTRUDE_END_UP_TO_SURFACE: StringKey = StringKey::new("extrude.end.up_to_surface"); +pub const EXTRUDE_END_OFFSET_FROM_SURFACE: StringKey = + StringKey::new("extrude.end.offset_from_surface"); +pub const EXTRUDE_END_UP_TO_BODY: StringKey = StringKey::new("extrude.end.up_to_body"); +pub const PROPERTY_ROW_EXTRUDE_OFFSET: StringKey = StringKey::new("property.row.extrude.offset"); +pub const PROPERTY_ROW_EXTRUDE_REVERSE_OFFSET: StringKey = + StringKey::new("property.row.extrude.reverse_offset"); +pub const PROPERTY_ROW_EXTRUDE_DIRECTION: StringKey = + StringKey::new("property.row.extrude.direction"); +pub const PROPERTY_ROW_EXTRUDE_REFERENCE: StringKey = + StringKey::new("property.row.extrude.reference"); +pub const PROPERTY_ROW_EXTRUDE_FROM: StringKey = StringKey::new("property.row.extrude.from"); +pub const PROPERTY_ROW_EXTRUDE_FROM_DISTANCE: StringKey = + StringKey::new("property.row.extrude.from_distance"); +pub const PROPERTY_ROW_EXTRUDE_THICKNESS: StringKey = + StringKey::new("property.row.extrude.thickness"); +pub const PROPERTY_ROW_EXTRUDE_THICKNESS_TWO: StringKey = + StringKey::new("property.row.extrude.thickness_two"); +pub const PROPERTY_ROW_EXTRUDE_CAP_ENDS: StringKey = + StringKey::new("property.row.extrude.cap_ends"); +pub const PROPERTY_ROW_EXTRUDE_FLIP_SIDE: StringKey = + StringKey::new("property.row.extrude.flip_side"); +pub const PROPERTY_ROW_EXTRUDE_SCOPE: StringKey = StringKey::new("property.row.extrude.scope"); +pub const PROPERTY_GROUP_DIRECTION_2: StringKey = StringKey::new("property.group.direction_2"); +pub const PROPERTY_GROUP_FROM: StringKey = StringKey::new("property.group.from"); +pub const EXTRUDE_FROM_SKETCH_PLANE: StringKey = StringKey::new("extrude.from.sketch_plane"); +pub const EXTRUDE_FROM_OFFSET: StringKey = StringKey::new("extrude.from.offset"); +pub const EXTRUDE_FROM_SURFACE: StringKey = StringKey::new("extrude.from.surface"); +pub const EXTRUDE_FROM_VERTEX: StringKey = StringKey::new("extrude.from.vertex"); +pub const EXTRUDE_SCOPE_AUTO: StringKey = StringKey::new("extrude.scope.auto"); +pub const EXTRUDE_SCOPE_ALL: StringKey = StringKey::new("extrude.scope.all"); +pub const EXTRUDE_DRAFT_OUTWARD: StringKey = StringKey::new("extrude.draft.outward"); +pub const EXTRUDE_DRAFT_INWARD: StringKey = StringKey::new("extrude.draft.inward"); +pub const EXTRUDE_THIN_INWARD: StringKey = StringKey::new("extrude.thin.inward"); +pub const EXTRUDE_THIN_OUTWARD: StringKey = StringKey::new("extrude.thin.outward"); +pub const EXTRUDE_THIN_MIDPLANE: StringKey = StringKey::new("extrude.thin.midplane"); +pub const EXTRUDE_THIN_TWO_DIRECTION: StringKey = StringKey::new("extrude.thin.two_direction"); +pub const EXTRUDE_REFERENCE_SELECT: StringKey = StringKey::new("extrude.reference.select"); +pub const EXTRUDE_REFERENCE_SET: StringKey = StringKey::new("extrude.reference.set"); +pub const EXTRUDE_REFERENCE_NONE: StringKey = StringKey::new("extrude.reference.none"); pub const STATUS_EXTRUDE_VALID: StringKey = StringKey::new("status.extrude.valid"); pub const STATUS_EXTRUDE_INVALID: StringKey = StringKey::new("status.extrude.invalid"); pub const STATUS_EXTRUDE_DANGLING: StringKey = StringKey::new("status.extrude.dangling"); @@ -309,6 +404,10 @@ pub const EXTRUDE_PANEL_PROFILE_UNCONTAINED: StringKey = StringKey::new("extrude.panel.profile.uncontained"); pub const EXTRUDE_PANEL_PROFILE_OVERLAPPING: StringKey = StringKey::new("extrude.panel.profile.overlapping"); +pub const EXTRUDE_PANEL_PROFILE_UNKNOWN_CONTOUR: StringKey = + StringKey::new("extrude.panel.profile.unknown_contour"); +pub const EXTRUDE_PANEL_PROFILE_NON_PLANAR: StringKey = + StringKey::new("extrude.panel.profile.non_planar"); pub const EXTRUDE_PANEL_EMPTY_DEPTH: StringKey = StringKey::new("extrude.panel.empty_depth"); pub const EXTRUDE_PANEL_SHELL_OPEN: StringKey = StringKey::new("extrude.panel.shell_open"); pub const EXTRUDE_PANEL_DEGENERATE_EDGE: StringKey = @@ -324,13 +423,31 @@ pub const EXTRUDE_PANEL_UNSUPPORTED_DRAFT: StringKey = StringKey::new("extrude.panel.unsupported.draft"); pub const EXTRUDE_PANEL_UNSUPPORTED_THIN: StringKey = StringKey::new("extrude.panel.unsupported.thin"); +pub const EXTRUDE_PANEL_UNSUPPORTED_FLIP: StringKey = + StringKey::new("extrude.panel.unsupported.flip"); pub const EXTRUDE_PANEL_INTERNAL: StringKey = StringKey::new("extrude.panel.internal"); +pub const EXTRUDE_PANEL_REVOLVE_AXIS: StringKey = StringKey::new("extrude.panel.revolve_axis"); +pub const EXTRUDE_PANEL_REVOLVE_AXIS_PLANE: StringKey = + StringKey::new("extrude.panel.revolve_axis_plane"); +pub const EXTRUDE_PANEL_REVOLVE_CROSSES: StringKey = + StringKey::new("extrude.panel.revolve_crosses"); +pub const EXTRUDE_PANEL_REVOLVE_TOUCHES: StringKey = + StringKey::new("extrude.panel.revolve_touches"); +pub const EXTRUDE_PANEL_REVOLVE_MIDPLANE: StringKey = + StringKey::new("extrude.panel.revolve_midplane"); +pub const REVOLVE_PANEL_UNSUPPORTED_POLE: StringKey = + StringKey::new("revolve.panel.unsupported_pole"); pub const EXTRUDE_PANEL_DANGLING_REFERENCE: StringKey = StringKey::new("extrude.panel.dangling_reference"); pub const EXTRUDE_PANEL_NON_PLANAR_TARGET: StringKey = StringKey::new("extrude.panel.non_planar_target"); pub const EXTRUDE_PANEL_UPSTREAM_UNRESOLVED: StringKey = StringKey::new("extrude.panel.upstream_unresolved"); +pub const EXTRUDE_PANEL_CUT_WITHOUT_BODY: StringKey = + StringKey::new("extrude.panel.cut_without_body"); +pub const EXTRUDE_PANEL_CUT_NO_INTERSECT: StringKey = + StringKey::new("extrude.panel.cut_no_intersect"); +pub const EXTRUDE_PANEL_CUT_BLOCKED: StringKey = StringKey::new("extrude.panel.cut_blocked"); pub const PROPERTY_ROW_KIND: StringKey = StringKey::new("property.row.kind"); pub const PROPERTY_ROW_X: StringKey = StringKey::new("property.row.x"); pub const PROPERTY_ROW_Y: StringKey = StringKey::new("property.row.y"); @@ -345,6 +462,8 @@ pub const PROPERTY_KIND_POINT: StringKey = StringKey::new("property.kind.point") pub const PROPERTY_KIND_LINE: StringKey = StringKey::new("property.kind.line"); pub const PROPERTY_KIND_ARC: StringKey = StringKey::new("property.kind.arc"); pub const PROPERTY_KIND_CIRCLE: StringKey = StringKey::new("property.kind.circle"); +pub const PROPERTY_KIND_ELLIPSE: StringKey = StringKey::new("property.kind.ellipse"); +pub const PROPERTY_KIND_SPLINE: StringKey = StringKey::new("property.kind.spline"); pub const PROPERTY_VALUE_YES: StringKey = StringKey::new("property.value.yes"); pub const PROPERTY_VALUE_NO: StringKey = StringKey::new("property.value.no"); pub const PROPERTY_ROW_FIRST: StringKey = StringKey::new("property.row.first"); @@ -388,6 +507,7 @@ const EN_US: &[(StringKey, &str)] = &[ (RIBBON_GROUP_RELATIONS, "Relations"), (RIBBON_GROUP_DIMENSIONS, "Dimensions"), (RIBBON_GROUP_EXTRUDE, "Extrude"), + (RIBBON_GROUP_REVOLVE, "Revolve"), (CONFIRM_ACCEPT, "Accept"), (CONFIRM_CANCEL, "Cancel"), (VIEW_FRONT, "Front"), @@ -418,11 +538,13 @@ const EN_US: &[(StringKey, &str)] = &[ (HEADS_UP_VIEW_SETTINGS, "View Settings"), (TOOL_POINT, "Point"), (TOOL_LINE, "Line"), + (TOOL_CENTERLINE, "Centerline"), (TOOL_CENTERPOINT_ARC, "Centerpoint Arc"), (TOOL_TANGENT_ARC, "Tangent Arc"), (TOOL_THREE_POINT_ARC, "3 Point Arc"), (TOOL_CIRCLE, "Circle"), (TOOL_PERIMETER_CIRCLE, "Perimeter Circle"), + (TOOL_ELLIPSE, "Ellipse"), (TOOL_CORNER_RECTANGLE, "Corner Rectangle"), (TOOL_CENTER_RECTANGLE, "Center Rectangle"), ( @@ -434,6 +556,7 @@ const EN_US: &[(StringKey, &str)] = &[ "3 Point Center Rectangle", ), (TOOL_PARALLELOGRAM, "Parallelogram"), + (TOOL_SPLINE, "Spline"), (TOOL_EXTRUDED_BOSS_BASE, "Extruded Boss/Base"), (TOOL_EXTRUDED_CUT, "Extruded Cut"), (TOOL_COINCIDENT, "Coincident"), @@ -529,12 +652,15 @@ const EN_US: &[(StringKey, &str)] = &[ (STATUS_PANEL_KIND_LINE, "Line"), (STATUS_PANEL_KIND_ARC, "Arc"), (STATUS_PANEL_KIND_CIRCLE, "Circle"), + (STATUS_PANEL_KIND_ELLIPSE, "Ellipse"), + (STATUS_PANEL_KIND_SPLINE, "Spline"), (STATUS_PANEL_KIND_LINEAR, "Linear Dimension"), (STATUS_PANEL_KIND_ANGULAR, "Angular Dimension"), (STATUS_PANEL_KIND_RADIUS, "Radius Dimension"), (STATUS_PANEL_KIND_DIAMETER, "Diameter Dimension"), (STATUS_PANEL_KIND_UNKNOWN, "Unknown"), (STATUS_PANEL_MORE, "+ {n} more"), + (STATUS_PANEL_REDUNDANT, "Redundant relations:"), (TOOLBAR_OVERFLOW, "More tools"), (MENU_BAR_LABEL, "Menu Bar"), (MENU_FILE, "File"), @@ -543,6 +669,9 @@ const EN_US: &[(StringKey, &str)] = &[ (MENU_INSERT, "Insert"), (MENU_TOOLS, "Tools"), (MENU_SKETCH, "Sketch"), + (MENU_SKETCH_CONVERT_ENTITIES, "Convert Entities"), + (MENU_SKETCH_TOGGLE_CONSTRUCTION, "Toggle Construction"), + (MENU_SKETCH_TOGGLE_DRIVEN, "Toggle Driven"), (MENU_SKETCH_EXIT, "Exit Sketch"), (MENU_WINDOW, "Window"), (MENU_HELP, "Help"), @@ -634,6 +763,7 @@ const EN_US: &[(StringKey, &str)] = &[ (HOTKEY_LABEL_TRIM, "Trim Entities"), (HOTKEY_LABEL_EXTEND, "Extend Entities"), (HOTKEY_LABEL_MIRROR, "Mirror Entities"), + (HOTKEY_LABEL_CONVERT_ENTITIES, "Convert Entities"), (HOTKEY_LABEL_CONSTRUCTION_TOGGLE, "Toggle Construction"), (HOTKEY_LABEL_UNDO, "Undo"), (HOTKEY_LABEL_REDO, "Redo"), @@ -678,10 +808,77 @@ const EN_US: &[(StringKey, &str)] = &[ (PROPERTY_ROW_EXTRUDE_THIN, "Thin Feature"), (PROPERTY_ROW_EXTRUDE_MERGE, "Merge Result"), (PROPERTY_PANE_EXTRUDE_TITLE, "Extrude"), + (TOOL_REVOLVED_BOSS_BASE, "Revolved Boss/Base"), + (TOOL_REVOLVED_CUT, "Revolved Cut"), + (STATUS_REVOLVE_ACTIVE, "Revolve"), + (PROPERTY_PANE_REVOLVE_TITLE, "Revolve"), + (PROPERTY_ROW_REVOLVE_TYPE, "Revolve Type"), + (PROPERTY_ROW_REVOLVE_ANGLE, "Angle"), + (PROPERTY_ROW_REVOLVE_REVERSE, "Reverse Direction"), + (PROPERTY_ROW_REVOLVE_AXIS, "Axis of Revolution"), + (PROPERTY_ROW_REVOLVE_CONTOURS, "Selected Contours"), + (REVOLVE_AXIS_SELECT, "Select an axis"), + (REVOLVE_AXIS_SKETCH_LINE, "Sketch line"), + (REVOLVE_AXIS_EDGE, "Model edge"), + (REVOLVE_AXIS_DATUM, "Datum axis"), + (REVOLVE_AXIS_3D_SKETCH_LINE, "3D sketch line"), + (REVOLVE_CONTOUR_SELECT, "Select a contour"), + (REVOLVE_CONTOUR_ALL, "All contours"), + (REVOLVE_CONTOUR_ONE, "Selected contour"), + (RIBBON_GROUP_DATUM, "Reference Geometry"), + (RIBBON_GROUP_SKETCH3, "3D Sketch"), + (TOOL_SKETCH3, "3D Sketch"), + (TOOL_DATUM_PLANE, "Plane"), + (TOOL_DATUM_AXIS, "Axis"), + (TOOL_DATUM_POINT, "Point"), + (TOOL_DATUM_COORDINATE_SYSTEM, "Coordinate System"), + (STATUS_DATUM_ACTIVE, "Reference Geometry"), + (STATUS_SKETCH3_ACTIVE, "3D Sketch"), + (NOTIFY_SKETCH3_PLANE, "3D sketch plane"), + (NOTIFY_SKETCH3_ACTIVE, "3D sketch started"), + (PROPERTY_PANE_DATUM_PLANE_TITLE, "Plane"), + (PROPERTY_PANE_DATUM_AXIS_TITLE, "Axis"), + (PROPERTY_PANE_DATUM_POINT_TITLE, "Point"), + (PROPERTY_PANE_DATUM_CSYS_TITLE, "Coordinate System"), + (PROPERTY_ROW_DATUM_DEFINITION, "Definition"), (PROPERTY_GROUP_DIRECTION_1, "Direction 1"), (PROPERTY_GROUP_FEATURE_SCOPE, "Feature Scope"), (EXTRUDE_END_BLIND, "Blind"), (EXTRUDE_END_MIDPLANE, "Mid Plane"), + (EXTRUDE_END_THROUGH_ALL, "Through All"), + (EXTRUDE_END_THROUGH_ALL_BOTH, "Through All - Both"), + (EXTRUDE_END_UP_TO_VERTEX, "Up To Vertex"), + (EXTRUDE_END_UP_TO_SURFACE, "Up To Surface"), + (EXTRUDE_END_OFFSET_FROM_SURFACE, "Offset From Surface"), + (EXTRUDE_END_UP_TO_BODY, "Up To Body"), + (PROPERTY_ROW_EXTRUDE_OFFSET, "Offset"), + (PROPERTY_ROW_EXTRUDE_REVERSE_OFFSET, "Reverse Offset"), + (PROPERTY_ROW_EXTRUDE_DIRECTION, "Direction"), + (PROPERTY_ROW_EXTRUDE_REFERENCE, "Reference"), + (PROPERTY_ROW_EXTRUDE_FROM, "From"), + (PROPERTY_ROW_EXTRUDE_FROM_DISTANCE, "From Distance"), + (PROPERTY_ROW_EXTRUDE_THICKNESS, "Thickness"), + (PROPERTY_ROW_EXTRUDE_THICKNESS_TWO, "Thickness 2"), + (PROPERTY_ROW_EXTRUDE_CAP_ENDS, "Cap Ends"), + (PROPERTY_ROW_EXTRUDE_FLIP_SIDE, "Flip Side To Cut"), + (PROPERTY_ROW_EXTRUDE_SCOPE, "Feature Scope"), + (PROPERTY_GROUP_DIRECTION_2, "Direction 2"), + (PROPERTY_GROUP_FROM, "From"), + (EXTRUDE_FROM_SKETCH_PLANE, "Sketch Plane"), + (EXTRUDE_FROM_OFFSET, "Offset"), + (EXTRUDE_FROM_SURFACE, "Surface"), + (EXTRUDE_FROM_VERTEX, "Vertex"), + (EXTRUDE_SCOPE_AUTO, "Auto Select"), + (EXTRUDE_SCOPE_ALL, "All Bodies"), + (EXTRUDE_DRAFT_OUTWARD, "Outward"), + (EXTRUDE_DRAFT_INWARD, "Inward"), + (EXTRUDE_THIN_INWARD, "Inward"), + (EXTRUDE_THIN_OUTWARD, "Outward"), + (EXTRUDE_THIN_MIDPLANE, "Mid Plane"), + (EXTRUDE_THIN_TWO_DIRECTION, "Two-Direction"), + (EXTRUDE_REFERENCE_SELECT, "Select an entity"), + (EXTRUDE_REFERENCE_SET, "Reference set"), + (EXTRUDE_REFERENCE_NONE, "Select an entity"), (STATUS_EXTRUDE_VALID, "Valid Solid"), (STATUS_EXTRUDE_INVALID, "Invalid Solid"), (STATUS_EXTRUDE_DANGLING, "Dangling"), @@ -701,10 +898,18 @@ const EN_US: &[(StringKey, &str)] = &[ EXTRUDE_PANEL_PROFILE_UNCONTAINED, "Profile loop lies outside the outer boundary", ), + ( + EXTRUDE_PANEL_PROFILE_UNKNOWN_CONTOUR, + "Selected contour is not in the sketch", + ), ( EXTRUDE_PANEL_PROFILE_OVERLAPPING, "Profile inner loops overlap", ), + ( + EXTRUDE_PANEL_PROFILE_NON_PLANAR, + "Profile is not planar; use sweep or loft", + ), (EXTRUDE_PANEL_EMPTY_DEPTH, "Extrude depth is zero"), (EXTRUDE_PANEL_SHELL_OPEN, "Solid shell is not closed"), ( @@ -732,7 +937,35 @@ const EN_US: &[(StringKey, &str)] = &[ EXTRUDE_PANEL_UNSUPPORTED_THIN, "Thin feature is not supported yet", ), + ( + EXTRUDE_PANEL_UNSUPPORTED_FLIP, + "Flip side to cut is not supported yet", + ), (EXTRUDE_PANEL_INTERNAL, "Internal kernel error"), + ( + EXTRUDE_PANEL_REVOLVE_AXIS, + "Revolve needs a centerline in the sketch to use as its axis", + ), + ( + EXTRUDE_PANEL_REVOLVE_AXIS_PLANE, + "Revolve axis must lie in the sketch plane", + ), + ( + EXTRUDE_PANEL_REVOLVE_CROSSES, + "Revolve profile cannot cross the axis", + ), + ( + EXTRUDE_PANEL_REVOLVE_TOUCHES, + "Revolve profile cannot touch the axis", + ), + ( + EXTRUDE_PANEL_REVOLVE_MIDPLANE, + "Mid-plane revolve cannot also have a second direction", + ), + ( + REVOLVE_PANEL_UNSUPPORTED_POLE, + "An axis-touching profile revolves only as a full solid turn", + ), ( EXTRUDE_PANEL_DANGLING_REFERENCE, "Sketch plane references a face that no longer exists", @@ -745,6 +978,15 @@ const EN_US: &[(StringKey, &str)] = &[ EXTRUDE_PANEL_UPSTREAM_UNRESOLVED, "Depends on a feature that failed to build", ), + ( + EXTRUDE_PANEL_CUT_WITHOUT_BODY, + "Cut has no body to remove material from", + ), + ( + EXTRUDE_PANEL_CUT_NO_INTERSECT, + "Cut does not intersect the model", + ), + (EXTRUDE_PANEL_CUT_BLOCKED, "Cut blocked by {detail}"), (PROPERTY_ROW_KIND, "Type"), (PROPERTY_ROW_X, "X"), (PROPERTY_ROW_Y, "Y"), @@ -759,6 +1001,8 @@ const EN_US: &[(StringKey, &str)] = &[ (PROPERTY_KIND_LINE, "Line"), (PROPERTY_KIND_ARC, "Arc"), (PROPERTY_KIND_CIRCLE, "Circle"), + (PROPERTY_KIND_ELLIPSE, "Ellipse"), + (PROPERTY_KIND_SPLINE, "Spline"), (PROPERTY_VALUE_YES, "Yes"), (PROPERTY_VALUE_NO, "No"), (PROPERTY_ROW_FIRST, "First"), @@ -790,6 +1034,7 @@ const AR_XB: &[(StringKey, &str)] = &[ (RIBBON_GROUP_RELATIONS, "[!! Relâtions !!]"), (RIBBON_GROUP_DIMENSIONS, "[!! Dîmensions !!]"), (RIBBON_GROUP_EXTRUDE, "[!! Êxtrude !!]"), + (RIBBON_GROUP_REVOLVE, "[!! ʁevolve !!]"), (CONFIRM_ACCEPT, "[!! Accêpt !!]"), (CONFIRM_CANCEL, "[!! Cancêl !!]"), (VIEW_FRONT, "[!! Frônt !!]"), @@ -820,11 +1065,13 @@ const AR_XB: &[(StringKey, &str)] = &[ (HEADS_UP_VIEW_SETTINGS, "[!! Vîew Sêttings !!]"), (TOOL_POINT, "[!! Pôint !!]"), (TOOL_LINE, "[!! Lîne !!]"), + (TOOL_CENTERLINE, "[!! Cênterlîne !!]"), (TOOL_CENTERPOINT_ARC, "[!! Cêntrepoint Arc !!]"), (TOOL_TANGENT_ARC, "[!! Tângent Arc !!]"), (TOOL_THREE_POINT_ARC, "[!! 3 Pôint Arc !!]"), (TOOL_CIRCLE, "[!! Cîrcle !!]"), (TOOL_PERIMETER_CIRCLE, "[!! Perîmeter Cîrcle !!]"), + (TOOL_ELLIPSE, "[!! Ellîpse !!]"), (TOOL_CORNER_RECTANGLE, "[!! Côrner Rectàngle !!]"), (TOOL_CENTER_RECTANGLE, "[!! Cênter Rectàngle !!]"), ( @@ -836,6 +1083,7 @@ const AR_XB: &[(StringKey, &str)] = &[ "[!! 3 Pôint Cênter Rectàngle !!]", ), (TOOL_PARALLELOGRAM, "[!! Parâllelôgram !!]"), + (TOOL_SPLINE, "[!! Splîne !!]"), (TOOL_EXTRUDED_BOSS_BASE, "[!! Êxtruded Bôss/Base !!]"), (TOOL_EXTRUDED_CUT, "[!! Êxtruded Cût !!]"), (TOOL_COINCIDENT, "[!! Coincîdent !!]"), @@ -934,12 +1182,15 @@ const AR_XB: &[(StringKey, &str)] = &[ (STATUS_PANEL_KIND_LINE, "[!! Lîne !!]"), (STATUS_PANEL_KIND_ARC, "[!! Ârc !!]"), (STATUS_PANEL_KIND_CIRCLE, "[!! Cîrcle !!]"), + (STATUS_PANEL_KIND_ELLIPSE, "[!! Éllîpse !!]"), + (STATUS_PANEL_KIND_SPLINE, "[!! Splîne !!]"), (STATUS_PANEL_KIND_LINEAR, "[!! Lîneâr Dimênsion !!]"), (STATUS_PANEL_KIND_ANGULAR, "[!! Ângulâr Dimênsion !!]"), (STATUS_PANEL_KIND_RADIUS, "[!! Râdius Dimênsion !!]"), (STATUS_PANEL_KIND_DIAMETER, "[!! Diâmeter Dimênsion !!]"), (STATUS_PANEL_KIND_UNKNOWN, "[!! Ûnknôwn !!]"), (STATUS_PANEL_MORE, "[!! + {n} môre !!]"), + (STATUS_PANEL_REDUNDANT, "[!! Redûndant relâtions !!]"), (TOOLBAR_OVERFLOW, "[!! Môre tôols !!]"), (MENU_BAR_LABEL, "[!! Mênu Bâr !!]"), (MENU_FILE, "[!! Fîle !!]"), @@ -948,6 +1199,12 @@ const AR_XB: &[(StringKey, &str)] = &[ (MENU_INSERT, "[!! Insêrt !!]"), (MENU_TOOLS, "[!! Tôols !!]"), (MENU_SKETCH, "[!! Skêtch !!]"), + (MENU_SKETCH_CONVERT_ENTITIES, "[!! Cônvert Êntities !!]"), + ( + MENU_SKETCH_TOGGLE_CONSTRUCTION, + "[!! Tôggle Cônstruction !!]", + ), + (MENU_SKETCH_TOGGLE_DRIVEN, "[!! Tôggle Drîven !!]"), (MENU_SKETCH_EXIT, "[!! Êxit Skêtch !!]"), (MENU_WINDOW, "[!! Wîndow !!]"), (MENU_HELP, "[!! Hêlp !!]"), @@ -1051,6 +1308,7 @@ const AR_XB: &[(StringKey, &str)] = &[ (HOTKEY_LABEL_TRIM, "[!! Trîm Êntities !!]"), (HOTKEY_LABEL_EXTEND, "[!! Extênd Êntities !!]"), (HOTKEY_LABEL_MIRROR, "[!! Mîrror Êntities !!]"), + (HOTKEY_LABEL_CONVERT_ENTITIES, "[!! Cônvert Êntities !!]"), ( HOTKEY_LABEL_CONSTRUCTION_TOGGLE, "[!! Tôggle Constrûction !!]", @@ -1101,10 +1359,83 @@ const AR_XB: &[(StringKey, &str)] = &[ (PROPERTY_ROW_EXTRUDE_THIN, "[!! Thîn Fêature !!]"), (PROPERTY_ROW_EXTRUDE_MERGE, "[!! Mêrge Rêsult !!]"), (PROPERTY_PANE_EXTRUDE_TITLE, "[!! Éxtrude !!]"), + (TOOL_REVOLVED_BOSS_BASE, "[!! ʁevolved Bôss/Base !!]"), + (TOOL_REVOLVED_CUT, "[!! ʁevolved Cût !!]"), + (STATUS_REVOLVE_ACTIVE, "[!! ʁevolve !!]"), + (PROPERTY_PANE_REVOLVE_TITLE, "[!! ʁevolve !!]"), + (PROPERTY_ROW_REVOLVE_TYPE, "[!! ʁevolve Týpe !!]"), + (PROPERTY_ROW_REVOLVE_ANGLE, "[!! Ângle !!]"), + (PROPERTY_ROW_REVOLVE_REVERSE, "[!! ʁeverse Dîrection !!]"), + (PROPERTY_ROW_REVOLVE_AXIS, "[!! Âxis of ʁevolution !!]"), + (PROPERTY_ROW_REVOLVE_CONTOURS, "[!! Sêlected Contôurs !!]"), + (REVOLVE_AXIS_SELECT, "[!! Sêlect an âxis !!]"), + (REVOLVE_AXIS_SKETCH_LINE, "[!! Skêtch lîne !!]"), + (REVOLVE_AXIS_EDGE, "[!! Môdel êdge !!]"), + (REVOLVE_AXIS_DATUM, "[!! Dâtum âxis !!]"), + (REVOLVE_AXIS_3D_SKETCH_LINE, "[!! 3D skêtch lîne !!]"), + (REVOLVE_CONTOUR_SELECT, "[!! Sêlect a contôur !!]"), + (REVOLVE_CONTOUR_ALL, "[!! All contôurs !!]"), + (REVOLVE_CONTOUR_ONE, "[!! Sêlected contôur !!]"), + (RIBBON_GROUP_DATUM, "[!! ʁeference Gêometry !!]"), + (RIBBON_GROUP_SKETCH3, "[!! 3D Skêtch !!]"), + (TOOL_SKETCH3, "[!! 3D Skêtch !!]"), + (TOOL_DATUM_PLANE, "[!! Plâne !!]"), + (TOOL_DATUM_AXIS, "[!! Áxis !!]"), + (TOOL_DATUM_POINT, "[!! Pôint !!]"), + (TOOL_DATUM_COORDINATE_SYSTEM, "[!! Côordinate Sýstem !!]"), + (STATUS_DATUM_ACTIVE, "[!! ʁeference Gêometry !!]"), + (STATUS_SKETCH3_ACTIVE, "[!! 3D Skêtch !!]"), + (NOTIFY_SKETCH3_PLANE, "[!! 3D skêtch plâne !!]"), + (NOTIFY_SKETCH3_ACTIVE, "[!! 3D skêtch stârted !!]"), + (PROPERTY_PANE_DATUM_PLANE_TITLE, "[!! Plâne !!]"), + (PROPERTY_PANE_DATUM_AXIS_TITLE, "[!! Áxis !!]"), + (PROPERTY_PANE_DATUM_POINT_TITLE, "[!! Pôint !!]"), + (PROPERTY_PANE_DATUM_CSYS_TITLE, "[!! Côordinate Sýstem !!]"), + (PROPERTY_ROW_DATUM_DEFINITION, "[!! Defînition !!]"), (PROPERTY_GROUP_DIRECTION_1, "[!! Dîrection 1 !!]"), (PROPERTY_GROUP_FEATURE_SCOPE, "[!! Fêature Scôpe !!]"), (EXTRUDE_END_BLIND, "[!! Blînd !!]"), (EXTRUDE_END_MIDPLANE, "[!! Mîd Plâne !!]"), + (EXTRUDE_END_THROUGH_ALL, "[!! Thrôugh Áll !!]"), + (EXTRUDE_END_THROUGH_ALL_BOTH, "[!! Thrôugh Áll - Bôth !!]"), + (EXTRUDE_END_UP_TO_VERTEX, "[!! Úp Tô Vêrtex !!]"), + (EXTRUDE_END_UP_TO_SURFACE, "[!! Úp Tô Sûrface !!]"), + ( + EXTRUDE_END_OFFSET_FROM_SURFACE, + "[!! Ôffset Frôm Sûrface !!]", + ), + (EXTRUDE_END_UP_TO_BODY, "[!! Úp Tô Bôdy !!]"), + (PROPERTY_ROW_EXTRUDE_OFFSET, "[!! Ôffset !!]"), + ( + PROPERTY_ROW_EXTRUDE_REVERSE_OFFSET, + "[!! ʁeverse Ôffset !!]", + ), + (PROPERTY_ROW_EXTRUDE_DIRECTION, "[!! Dîrection !!]"), + (PROPERTY_ROW_EXTRUDE_REFERENCE, "[!! ʁeference !!]"), + (PROPERTY_ROW_EXTRUDE_FROM, "[!! Frôm !!]"), + (PROPERTY_ROW_EXTRUDE_FROM_DISTANCE, "[!! Frôm Dîstance !!]"), + (PROPERTY_ROW_EXTRUDE_THICKNESS, "[!! Thîckness !!]"), + (PROPERTY_ROW_EXTRUDE_THICKNESS_TWO, "[!! Thîckness 2 !!]"), + (PROPERTY_ROW_EXTRUDE_CAP_ENDS, "[!! Câp Énds !!]"), + (PROPERTY_ROW_EXTRUDE_FLIP_SIDE, "[!! Flîp Sîde Tô Cût !!]"), + (PROPERTY_ROW_EXTRUDE_SCOPE, "[!! Fêature Scôpe !!]"), + (PROPERTY_GROUP_DIRECTION_2, "[!! Dîrection 2 !!]"), + (PROPERTY_GROUP_FROM, "[!! Frôm !!]"), + (EXTRUDE_FROM_SKETCH_PLANE, "[!! Skêtch Plâne !!]"), + (EXTRUDE_FROM_OFFSET, "[!! Ôffset !!]"), + (EXTRUDE_FROM_SURFACE, "[!! Sûrface !!]"), + (EXTRUDE_FROM_VERTEX, "[!! Vêrtex !!]"), + (EXTRUDE_SCOPE_AUTO, "[!! Áuto Sêlect !!]"), + (EXTRUDE_SCOPE_ALL, "[!! Áll Bôdies !!]"), + (EXTRUDE_DRAFT_OUTWARD, "[!! Ôutward !!]"), + (EXTRUDE_DRAFT_INWARD, "[!! Înward !!]"), + (EXTRUDE_THIN_INWARD, "[!! Înward !!]"), + (EXTRUDE_THIN_OUTWARD, "[!! Ôutward !!]"), + (EXTRUDE_THIN_MIDPLANE, "[!! Mîd Plâne !!]"), + (EXTRUDE_THIN_TWO_DIRECTION, "[!! Twô-Dîrection !!]"), + (EXTRUDE_REFERENCE_SELECT, "[!! Sêlect an êntity !!]"), + (EXTRUDE_REFERENCE_SET, "[!! ʁeference sêt !!]"), + (EXTRUDE_REFERENCE_NONE, "[!! Sêlect an êntity !!]"), (STATUS_EXTRUDE_VALID, "[!! Vâlid Sôlid !!]"), (STATUS_EXTRUDE_INVALID, "[!! Invâlid Sôlid !!]"), (STATUS_EXTRUDE_DANGLING, "[!! Dângling !!]"), @@ -1133,10 +1464,18 @@ const AR_XB: &[(StringKey, &str)] = &[ EXTRUDE_PANEL_PROFILE_UNCONTAINED, "[!! Prôfile lôop lîes outsîde the ôuter bôundary !!]", ), + ( + EXTRUDE_PANEL_PROFILE_UNKNOWN_CONTOUR, + "[!! Selêcted contôur îs nôt în the skêtch !!]", + ), ( EXTRUDE_PANEL_PROFILE_OVERLAPPING, "[!! Prôfile înner lôops overlâp !!]", ), + ( + EXTRUDE_PANEL_PROFILE_NON_PLANAR, + "[!! Prôfile is nôt planâr; use swêep or lôft !!]", + ), (EXTRUDE_PANEL_EMPTY_DEPTH, "[!! Êxtrude dêpth is zêro !!]"), ( EXTRUDE_PANEL_SHELL_OPEN, @@ -1170,7 +1509,35 @@ const AR_XB: &[(StringKey, &str)] = &[ EXTRUDE_PANEL_UNSUPPORTED_THIN, "[!! Thîn fêature is nôt suppôrted yêt !!]", ), + ( + EXTRUDE_PANEL_UNSUPPORTED_FLIP, + "[!! Flîp sîde to cût is nôt suppôrted yêt !!]", + ), (EXTRUDE_PANEL_INTERNAL, "[!! Intêrnal kêrnel êrror !!]"), + ( + EXTRUDE_PANEL_REVOLVE_AXIS, + "[!! Revôlve nêeds a cênterline in the skêtch to ûse as its âxis !!]", + ), + ( + EXTRUDE_PANEL_REVOLVE_AXIS_PLANE, + "[!! Revôlve âxis mûst lîe in the skêtch plâne !!]", + ), + ( + EXTRUDE_PANEL_REVOLVE_CROSSES, + "[!! Revôlve prôfile cânnot crôss the âxis !!]", + ), + ( + EXTRUDE_PANEL_REVOLVE_TOUCHES, + "[!! Revôlve prôfile cânnot tôuch the âxis !!]", + ), + ( + EXTRUDE_PANEL_REVOLVE_MIDPLANE, + "[!! Mîd-plane revôlve cânnot âlso have a sêcond dirêction !!]", + ), + ( + REVOLVE_PANEL_UNSUPPORTED_POLE, + "[!! An âxis-touching prôfile revôlves ônly as a fûll sôlid tûrn !!]", + ), ( EXTRUDE_PANEL_DANGLING_REFERENCE, "[!! Skêtch plâne refêrences a fâce that nô longer exîsts !!]", @@ -1183,6 +1550,15 @@ const AR_XB: &[(StringKey, &str)] = &[ EXTRUDE_PANEL_UPSTREAM_UNRESOLVED, "[!! Dêpends ôn a fâiled featûre !!]", ), + ( + EXTRUDE_PANEL_CUT_WITHOUT_BODY, + "[!! Cût has nô bôdy to remôve matêrial frôm !!]", + ), + ( + EXTRUDE_PANEL_CUT_NO_INTERSECT, + "[!! Cût does nôt intêrsect the môdel !!]", + ), + (EXTRUDE_PANEL_CUT_BLOCKED, "[!! Cût blôcked by {detail} !!]"), (PROPERTY_ROW_KIND, "[!! Týpe !!]"), (PROPERTY_ROW_X, "X"), (PROPERTY_ROW_Y, "Y"), @@ -1197,6 +1573,8 @@ const AR_XB: &[(StringKey, &str)] = &[ (PROPERTY_KIND_LINE, "[!! Lîne !!]"), (PROPERTY_KIND_ARC, "[!! Ârc !!]"), (PROPERTY_KIND_CIRCLE, "[!! Cîrcle !!]"), + (PROPERTY_KIND_ELLIPSE, "[!! Éllîpse !!]"), + (PROPERTY_KIND_SPLINE, "[!! Splîne !!]"), (PROPERTY_VALUE_YES, "[!! Yés !!]"), (PROPERTY_VALUE_NO, "[!! Nô !!]"), (PROPERTY_ROW_FIRST, "[!! Fîrst !!]"), diff --git a/crates/bone-app/src/tools/geometry.rs b/crates/bone-app/src/tools/geometry.rs index c4a4ef8..deda830 100644 --- a/crates/bone-app/src/tools/geometry.rs +++ b/crates/bone-app/src/tools/geometry.rs @@ -229,6 +229,43 @@ pub(super) fn three_point_center_corners( Some((p1, p2, p3, p4)) } +pub(super) fn ellipse_minor_tip(center: Point2, major: Point2, click3: Point2) -> Option { + let (cx, cy) = center.coords_mm(); + let (mx, my) = major.coords_mm(); + let (kx, ky) = click3.coords_mm(); + let axis = (mx - cx, my - cy); + let len = (axis.0 * axis.0 + axis.1 * axis.1).sqrt(); + if !(len.is_finite() && len > 0.0) { + return None; + } + let n = (-axis.1 / len, axis.0 / len); + let from_center = (kx - cx, ky - cy); + let extent = from_center.0 * n.0 + from_center.1 * n.1; + if !extent.is_finite() || extent.abs() < 1e-9 { + return None; + } + Some(Point2::from_mm(cx + n.0 * extent, cy + n.1 * extent)) +} + +pub(super) fn ellipse_outline_segments( + center: Point2, + major: Point2, + minor: Point2, +) -> Vec<(Point2, Point2)> { + let (cx, cy) = center.coords_mm(); + let (mx, my) = major.coords_mm(); + let (nx, ny) = minor.coords_mm(); + let a = (mx - cx, my - cy); + let b = (nx - cx, ny - cy); + let n = 48u16; + let sample = |i: u16| -> Point2 { + let t = std::f64::consts::TAU * f64::from(i) / f64::from(n); + let (c, s) = (t.cos(), t.sin()); + Point2::from_mm(cx + a.0 * c + b.0 * s, cy + a.1 * c + b.1 * s) + }; + (0..n).map(|i| (sample(i), sample((i + 1) % n))).collect() +} + pub(super) fn degenerate_triangle(p1: Point2, p2: Point2, p3: Point2) -> bool { let (ax, ay) = p1.coords_mm(); let (bx, by) = p2.coords_mm(); diff --git a/crates/bone-app/src/tools/mod.rs b/crates/bone-app/src/tools/mod.rs index a578a34..25ef641 100644 --- a/crates/bone-app/src/tools/mod.rs +++ b/crates/bone-app/src/tools/mod.rs @@ -59,6 +59,19 @@ pub fn add_arc( ) } +pub fn add_ellipse( + sketch: Sketch, + center: SketchEntityId, + major: SketchEntityId, + minor: SketchEntityId, + for_construction: bool, +) -> (Sketch, SketchEntityId) { + add_entity( + sketch, + SketchEntity::ellipse(center, major, minor, for_construction), + ) +} + pub fn add_relation(sketch: Sketch, relation: SketchRelation) -> Sketch { match sketch.clone().apply(SketchEdit::AddRelation(relation)) { Ok((next, _)) => next, @@ -71,7 +84,9 @@ pub fn add_relation(sketch: Sketch, relation: SketchRelation) -> Sketch { pub fn click_position(sketch: &Sketch, click: ClickAnchor) -> Option { match click { - ClickAnchor::Position(p) | ClickAnchor::MidpointOf { position: p, .. } => Some(p), + ClickAnchor::Position(p) + | ClickAnchor::MidpointOf { position: p, .. } + | ClickAnchor::OnEdge { position: p, .. } => Some(p), ClickAnchor::Endpoint(id) => geometry::endpoint_position(sketch, id), } } @@ -85,6 +100,11 @@ pub(super) fn materialize(sketch: Sketch, click: ClickAnchor) -> (Sketch, Sketch let sketch = add_relation(sketch, SketchRelation::Midpoint { point: id, line }); (sketch, id) } + ClickAnchor::OnEdge { entity, position } => { + let (sketch, id) = add_point(sketch, position); + let sketch = add_relation(sketch, SketchRelation::Coincident(id, entity)); + (sketch, id) + } } } @@ -95,6 +115,10 @@ pub(super) fn anchor_at(pos: Point2, snap: Option) -> ClickAnchor { line, position: pos, }, + Some(SnapKind::OnEdge(entity)) => ClickAnchor::OnEdge { + entity, + position: pos, + }, _ => ClickAnchor::Position(pos), } } @@ -120,9 +144,11 @@ pub fn place( ) -> PlaceResult { match tool { SketchTool::Point => place::place_point(sketch, world, snap), - SketchTool::Line => place::place_line(sketch, world, pending, snap), + SketchTool::Line => place::place_line(sketch, world, pending, snap, false), + SketchTool::Centerline => place::place_line(sketch, world, pending, snap, true), SketchTool::Circle => place::place_circle(sketch, world, pending, snap), SketchTool::PerimeterCircle => place::place_perimeter_circle(sketch, world, pending, snap), + SketchTool::Ellipse => place::place_ellipse(sketch, world, pending, snap), SketchTool::CenterpointArc => place::place_centerpoint_arc(sketch, world, pending, snap), SketchTool::TangentArc => place::place_tangent_arc(sketch, world, pending, snap), SketchTool::ThreePointArc => place::place_three_point_arc(sketch, world, pending, snap), @@ -135,6 +161,7 @@ pub fn place( place::place_three_point_center_rectangle(sketch, world, pending, snap) } SketchTool::Parallelogram => place::place_parallelogram(sketch, world, pending, snap), + SketchTool::Spline => (None, pending), } } diff --git a/crates/bone-app/src/tools/place.rs b/crates/bone-app/src/tools/place.rs index f6b2e74..0e812c6 100644 --- a/crates/bone-app/src/tools/place.rs +++ b/crates/bone-app/src/tools/place.rs @@ -6,13 +6,13 @@ use crate::sketch_mode::{ClickAnchor, Pending}; use crate::snap::{SnapHit, SnapKind}; use super::geometry::{ - axes_distinct, ccw_through, circumcircle, degenerate_triangle, distance, minor_arc_ccw, - on_circle_endpoint, project_to_circle, tangent_arc_ccw_natural, tangent_arc_center, - tangent_at_point, three_point_center_corners, three_point_corner_extents, + axes_distinct, ccw_through, circumcircle, degenerate_triangle, distance, ellipse_minor_tip, + minor_arc_ccw, on_circle_endpoint, project_to_circle, tangent_arc_ccw_natural, + tangent_arc_center, tangent_at_point, three_point_center_corners, three_point_corner_extents, }; use super::{ - PlaceResult, add_arc, add_circle, add_line, add_point, add_relation, anchor_at, anchor_from, - anchors_collide, click_position, materialize, snap_world, + PlaceResult, add_arc, add_circle, add_ellipse, add_line, add_point, add_relation, anchor_at, + anchor_from, anchors_collide, click_position, materialize, snap_world, }; pub(super) fn dispatch_two( @@ -60,9 +60,10 @@ pub(super) fn place_line( world: Point2, pending: Option, snap: Option, + for_construction: bool, ) -> PlaceResult { dispatch_two(pending, world, snap, move |prev| { - commit_line(sketch, world, prev, snap) + commit_line(sketch, world, prev, snap, for_construction) }) } @@ -71,6 +72,7 @@ fn commit_line( world: Point2, prev: ClickAnchor, snap: Option, + for_construction: bool, ) -> PlaceResult { if click_position(&sketch, prev).is_none() { return (None, None); @@ -81,14 +83,14 @@ fn commit_line( } let (sketch, prev_id) = materialize(sketch, prev); let (sketch, end_id) = materialize(sketch, end_anchor); - let (sketch, line_id) = add_line(sketch, prev_id, end_id, false); + let (sketch, line_id) = add_line(sketch, prev_id, end_id, for_construction); let sketch = match snap.map(|s| s.kind) { Some(SnapKind::Tangent(curve)) => { add_relation(sketch, SketchRelation::Tangent(line_id, curve)) } Some(SnapKind::Horizontal) => add_relation(sketch, SketchRelation::Horizontal(line_id)), Some(SnapKind::Vertical) => add_relation(sketch, SketchRelation::Vertical(line_id)), - Some(SnapKind::Midpoint(_) | SnapKind::Endpoint(_)) | None => sketch, + Some(SnapKind::Midpoint(_) | SnapKind::Endpoint(_) | SnapKind::OnEdge(_)) | None => sketch, }; ( Some(sketch), @@ -148,6 +150,34 @@ pub(super) fn place_perimeter_circle( }) } +pub(super) fn place_ellipse( + sketch: Sketch, + world: Point2, + pending: Option, + snap: Option, +) -> PlaceResult { + dispatch_three(pending, world, snap, move |center, major| { + let Some(center_pos) = click_position(&sketch, center) else { + return (None, None); + }; + let Some(major_pos) = click_position(&sketch, major) else { + return (None, None); + }; + let Some(minor_pos) = ellipse_minor_tip(center_pos, major_pos, snap_world(world, snap)) + else { + return (None, Some(Pending::Second(center, major))); + }; + if anchors_collide(center, major) { + return (None, Some(Pending::Second(center, major))); + } + let (sketch, center_id) = materialize(sketch, center); + let (sketch, major_id) = materialize(sketch, major); + let (sketch, minor_id) = add_point(sketch, minor_pos); + let (sketch, _) = add_ellipse(sketch, center_id, major_id, minor_id, false); + (Some(sketch), None) + }) +} + pub(super) fn place_centerpoint_arc( sketch: Sketch, world: Point2, diff --git a/crates/bone-app/src/tools/preview.rs b/crates/bone-app/src/tools/preview.rs index 7b256cb..fe78bff 100644 --- a/crates/bone-app/src/tools/preview.rs +++ b/crates/bone-app/src/tools/preview.rs @@ -9,9 +9,9 @@ use crate::snap::SnapHit; use super::click_position; use super::geometry::{ - ccw_through, circumcircle, degenerate_triangle, distance, minor_arc_ccw, project_to_circle, - tangent_arc_ccw_natural, tangent_arc_center, tangent_at_point, three_point_center_corners, - three_point_corner_extents, + ccw_through, circumcircle, degenerate_triangle, distance, ellipse_minor_tip, + ellipse_outline_segments, minor_arc_ccw, project_to_circle, tangent_arc_ccw_natural, + tangent_arc_center, tangent_at_point, three_point_center_corners, three_point_corner_extents, }; pub(super) fn preview( @@ -24,12 +24,15 @@ pub(super) fn preview( let snap_dot = snap.map(|s| s.world); let effective = snap_dot.unwrap_or(cursor); match tool { - SketchTool::Point => snap_only(snap_dot), - SketchTool::Line => preview_line(sketch, effective, pending, snap_dot), + SketchTool::Point | SketchTool::Spline => snap_only(snap_dot), + SketchTool::Line | SketchTool::Centerline => { + preview_line(sketch, effective, pending, snap_dot) + } SketchTool::Circle => preview_circle(sketch, effective, pending, snap_dot), SketchTool::PerimeterCircle => { preview_perimeter_circle(sketch, effective, pending, snap_dot) } + SketchTool::Ellipse => preview_ellipse(sketch, effective, pending, snap_dot), SketchTool::CenterpointArc => preview_centerpoint_arc(sketch, effective, pending, snap_dot), SketchTool::TangentArc => preview_tangent_arc(sketch, effective, pending, snap_dot), SketchTool::ThreePointArc => preview_three_point_arc(sketch, effective, pending, snap_dot), @@ -171,6 +174,30 @@ fn preview_perimeter_circle( ) } +fn preview_ellipse( + sketch: &Sketch, + effective: Point2, + pending: Option, + snap_dot: Option, +) -> SketchPreview { + preview_dispatch_three( + sketch, + pending, + effective, + snap_dot, + |center_pos, major_pos, eff| match ellipse_minor_tip(center_pos, major_pos, eff) { + Some(minor_pos) => SketchPreview { + segments: ellipse_outline_segments(center_pos, major_pos, minor_pos), + ..SketchPreview::empty() + }, + None => SketchPreview { + segments: vec![(center_pos, major_pos)], + ..SketchPreview::empty() + }, + }, + ) +} + fn preview_centerpoint_arc( sketch: &Sketch, effective: Point2, diff --git a/crates/bone-app/src/tools/tests/basic.rs b/crates/bone-app/src/tools/tests/basic.rs index a270253..36c6e13 100644 --- a/crates/bone-app/src/tools/tests/basic.rs +++ b/crates/bone-app/src/tools/tests/basic.rs @@ -151,6 +151,51 @@ fn perimeter_circle_collinear_holds_state() { assert!(matches!(kept, Some(Pending::Second(_, _)))); } +#[test] +fn ellipse_emits_after_three_clicks_with_perpendicular_minor() { + let center = Point2::from_mm(0.0, 0.0); + let major = Point2::from_mm(5.0, 0.0); + let minor_extent = Point2::from_mm(2.0, 3.0); + let (s, pending) = place(fresh(), SketchTool::Ellipse, center, None, None); + assert!(s.is_none()); + assert_eq!(pending, Some(Pending::First(ClickAnchor::Position(center)))); + let (s, pending) = place(fresh(), SketchTool::Ellipse, major, pending, None); + assert!(s.is_none()); + let Some(Pending::Second(_, _)) = pending else { + panic!("two anchors after the major-axis click"); + }; + let (next, pending) = place(fresh(), SketchTool::Ellipse, minor_extent, pending, None); + let Some(next) = next else { + panic!("ellipse commits on the third click") + }; + assert_eq!(count_kind(&next, SketchEntityKind::Ellipse), 1); + assert_eq!(count_kind(&next, SketchEntityKind::Point), 3); + assert_eq!(pending, None); + let Some((_, SketchEntity::Ellipse(data))) = next + .entities() + .iter() + .find(|(_, e)| matches!(e, SketchEntity::Ellipse(_))) + else { + panic!("ellipse entity"); + }; + let resolve = |id| match next.entities().get(id) { + Some(SketchEntity::Point(p)) => p.at().coords_mm(), + _ => panic!("point id {id:?} did not resolve"), + }; + let (cx, cy) = resolve(data.center()); + let (mx, my) = resolve(data.major()); + let (nx, ny) = resolve(data.minor()); + let major_axis = (mx - cx, my - cy); + let minor_axis = (nx - cx, ny - cy); + let dot = major_axis.0 * minor_axis.0 + major_axis.1 * minor_axis.1; + assert!(dot.abs() < 1e-9, "axes must be orthogonal, dot={dot}"); + let minor_len = (minor_axis.0 * minor_axis.0 + minor_axis.1 * minor_axis.1).sqrt(); + assert!( + (minor_len - 3.0).abs() < 1e-9, + "minor radius is the perpendicular extent of the third click: {minor_len}", + ); +} + #[test] fn centerpoint_arc_emits_three_clicks() { let center = Point2::from_mm(0.0, 0.0); diff --git a/crates/bone-app/src/tools/tests/infer.rs b/crates/bone-app/src/tools/tests/infer.rs index dd8ef99..d0c7292 100644 --- a/crates/bone-app/src/tools/tests/infer.rs +++ b/crates/bone-app/src/tools/tests/infer.rs @@ -35,6 +35,61 @@ fn end_point(sketch: &Sketch, line: LineData) -> Point2 { p.at() } +#[test] +fn line_tool_draws_solid_geometry_and_centerline_tool_draws_construction() { + let from = Some(Pending::First(ClickAnchor::Position(Point2::from_mm( + 0.0, 0.0, + )))); + let end = Point2::from_mm(0.0, 10.0); + + let (Some(line), _) = place(fresh(), SketchTool::Line, end, from, None) else { + panic!("line tool commits on the second click"); + }; + assert!( + !only_line(&line).1.for_construction(), + "the line tool draws solid profile geometry", + ); + + let (Some(centerline), _) = place(fresh(), SketchTool::Centerline, end, from, None) else { + panic!("centerline tool commits on the second click"); + }; + assert!( + only_line(¢erline).1.for_construction(), + "the centerline tool draws a construction line the revolve axis picker can use", + ); +} + +#[test] +fn line_end_click_on_edge_snap_pins_the_point_onto_the_host_line() { + let (sketch, p0) = add_point(fresh(), Point2::from_mm(0.0, 0.0)); + let (sketch, p1) = add_point(sketch, Point2::from_mm(10.0, 0.0)); + let (sketch, host) = add_line(sketch, p0, p1, false); + let (sketch, anchor) = add_point(sketch, Point2::from_mm(3.0, 5.0)); + let snap = SnapHit { + kind: SnapKind::OnEdge(host), + world: Point2::from_mm(3.0, 0.0), + }; + let (next, _) = place( + sketch, + SketchTool::Line, + Point2::from_mm(3.0, 0.0), + Some(Pending::First(ClickAnchor::Endpoint(anchor))), + Some(snap), + ); + let Some(next) = next else { + panic!("line commits onto the host edge"); + }; + let coincident = next + .relation_order() + .iter() + .filter_map(|rid| next.relations().get(*rid).copied()) + .any(|r| matches!(r, SketchRelation::Coincident(_, target) if target == host)); + assert!( + coincident, + "an on-edge end snap adds a coincident relation pinning the new point onto the host line", + ); +} + #[test] fn line_first_click_endpoint_snap_reuses_existing_point() { let (sketch, existing) = add_point(fresh(), Point2::from_mm(0.0, 0.0));