diff --git a/crates/bone-app/src/app.rs b/crates/bone-app/src/app.rs index 4f6b05d..3da88b5 100644 --- a/crates/bone-app/src/app.rs +++ b/crates/bone-app/src/app.rs @@ -1,12 +1,13 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::sync::Arc; use bone_document::{ DimensionKind, DimensionValue, Document, DocumentFolder, EditOutcome, EvaluatedExtrude, - ExtrudeError, ExtrudeFeature, FeatureCache, FeatureNode, LineData, Sketch, SketchDimension, - SketchEdit, SketchEntity, SketchRelation, SketchVersion, SolverError, UndoStack, + EvaluatedModel, ExtrudeError, ExtrudeFeature, FeatureNode, LineData, RebuildBudget, + RebuildCost, RebuildPass, RecomputeScope, Sketch, SketchDimension, SketchEdit, SketchEntity, + SketchRelation, SketchVersion, SolverError, UndoStack, evaluate_extrude, evaluate_sketch, }; use bone_render::{ Camera2, CameraTween, ChromeInstance, ChromePipeline, ChromeTextPipeline, ConvexInstance, @@ -18,9 +19,9 @@ use bone_render::{ frame_view_direction, orbit_pitch, orbit_yaw, pan_pixels, roll_by, zoom_about_pixel, }; use bone_types::{ - Aabb3, Angle, AngleTolerance, BudgetCeiling, Camera3, ChordHeightTolerance, CubicEasing, - DisplayMode, DocumentId, ExtrudeId, FeatureId, GeometryGeneration, Length, Plane3, Point2, - SketchId, SketchItemId, StandardView, Vec2, ZoomFactor, + Aabb3, Angle, AngleTolerance, BrepFaceId, BudgetCeiling, Camera3, ChordHeightTolerance, + CubicEasing, DisplayMode, DocumentId, ExtrudeId, FeatureId, GeometryGeneration, Length, Plane3, + Point2, RebuildError, RebuildStatus, SketchId, SketchItemId, StandardView, Vec2, ZoomFactor, }; use bone_ui::a11y::AccessTreeBuilder; use bone_ui::focus::FocusManager; @@ -34,6 +35,7 @@ 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::{MaskAtlas, MaskAtlasParams, Shaper}; use swash::FontRef; use uom::si::angle::degree; @@ -90,8 +92,12 @@ struct AppState { document: Document, plane_sketches: BTreeMap, mode: Mode, - feature_cache: FeatureCache, extrude_preview: Option, + model: EvaluatedModel, + model_passes: Vec<(FeatureId, RebuildPass)>, + changed_features: BTreeSet, + needs_rebuild: bool, + pending_reattach: Option, solid_renderer: SolidRenderer, solid_view: Option, camera3: Option, @@ -463,6 +469,30 @@ 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; + } + PickIndex::build_solid(faces, core::iter::empty(), core::iter::empty()).ok() +} + +fn active_pick_index(state: &AppState) -> Option { + if matches!(state.mode, Mode::Idle) && state.solid_view.is_some() { + return solid_pick_index(&state.model); + } + match state.scene.pick_index() { + Ok(index) => Some(index), + Err(error) => { + tracing::warn!(error = %error, "build sketch pick index"); + None + } + } +} + fn pick_at(state: &AppState, target: &impl FrameTarget, cursor: WindowPoint) -> Option { if !cursor.x.is_finite() || !cursor.y.is_finite() || cursor.x < 0.0 || cursor.y < 0.0 { return None; @@ -483,13 +513,7 @@ fn pick_at(state: &AppState, target: &impl FrameTarget, cursor: WindowPoint) -> if qx >= extent.width().value() || qy >= extent.height().value() { return None; } - let index = match state.scene.pick_index() { - Ok(i) => i, - Err(e) => { - tracing::warn!(error = %e, "build pick index"); - return None; - } - }; + let index = active_pick_index(state)?; let query = PickQuery::new(ViewportPx::new(qx), ViewportPx::new(qy)) .with_aperture(state.settings.pick_aperture); match target.picker(index).at(query) { @@ -507,7 +531,21 @@ fn handle_viewport_click( cursor: WindowPoint, additive: bool, ) { - let item = pick_at(state, target, cursor).and_then(selection::picked_to_item); + let picked = pick_at(state, target, cursor); + if let Some(sketch) = state.pending_reattach { + if let Some(PickedItem::BrepFace(face)) = picked { + apply_reattach(state, sketch, face); + } + return; + } + if !additive + && matches!(state.mode, Mode::Idle) + && let Some(PickedItem::BrepFace(face)) = picked + && begin_face_sketch(state, face) + { + return; + } + let item = picked.and_then(selection::picked_to_item); state.selection = std::mem::take(&mut state.selection).picked(item, additive); if additive || !state.mode.is_sketch() { return; @@ -742,7 +780,235 @@ fn active_solid_feature( } } +fn clear_solid(state: &mut AppState) { + state.solid_view = None; + state.camera3 = None; + state.view.home = None; + state.view.tween = None; +} + +fn body_passes(model: &EvaluatedModel) -> Vec<(FeatureId, RebuildPass)> { + model + .bodies() + .filter_map(|(feature, _)| model.built_at(feature).map(|pass| (feature, pass))) + .collect() +} + +fn build_combined_view(model: &EvaluatedModel) -> Option { + let mut parts = model + .bodies() + .filter_map(|(_, solid)| build_solid_view(solid).ok()); + let first = parts.next()?; + Some(parts.fold(first, |acc, part| SolidViewData { + faces: acc.faces.merge(part.faces), + edges: acc.edges.merge(part.edges), + aabb: acc.aabb.union(part.aabb), + })) +} + +fn recompute_model(state: &mut AppState, scope: RecomputeScope) { + state.model.recompute( + &state.document, + state.document.suppressed(), + state.document.rollback(), + scope, + ); +} + +fn rebuild_model_view(state: &mut AppState) { + state.model_passes = body_passes(&state.model); + state.solid_view = build_combined_view(&state.model); + if state.solid_view.is_none() { + clear_solid(state); + } +} + +fn defer_rebuild(first: bool, cost: RebuildCost, budget: RebuildBudget) -> bool { + !first && !cost.within(budget) +} + +fn sync_model_view(state: &mut AppState) { + state.extrude_preview = None; + recompute_model(state, RecomputeScope::Full); + let passes = body_passes(&state.model); + if passes.is_empty() { + if state.solid_view.is_some() || !state.model_passes.is_empty() { + state.model_passes.clear(); + clear_solid(state); + } + state.changed_features.clear(); + state.needs_rebuild = false; + return; + } + if passes == state.model_passes && state.solid_view.is_some() { + return; + } + let first = state.model_passes.is_empty() || state.solid_view.is_none(); + if defer_rebuild( + first, + state.model.rebuild_cost(), + RebuildBudget::INTERACTIVE, + ) { + let changed = passes + .iter() + .filter(|entry| !state.model_passes.contains(entry)) + .map(|(feature, _)| *feature); + state.changed_features.extend(changed); + state.needs_rebuild = true; + return; + } + rebuild_model_view(state); + state.changed_features.clear(); + state.needs_rebuild = false; +} + +fn status_to_badge(status: RebuildStatus) -> Option { + match status { + RebuildStatus::Error(_) => Some(TreeBadge::Error), + RebuildStatus::Warning(_) => Some(TreeBadge::Warning), + RebuildStatus::NeedsRebuild => Some(TreeBadge::RebuildNeeded), + RebuildStatus::UpToDate => None, + } +} + +fn feature_badge(state: &AppState, feature: FeatureId) -> Option { + let status = state.model.status(feature).and_then(status_to_badge); + let rebuild = state + .changed_features + .contains(&feature) + .then_some(TreeBadge::RebuildNeeded); + [status, rebuild].into_iter().flatten().max() +} + +fn compute_feature_badges(state: &AppState) -> BTreeMap { + state + .document + .feature_tree() + .iter() + .filter_map(|(feature, _)| feature_badge(state, feature).map(|badge| (feature, badge))) + .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)) => { + document.sketch_label(sketch_id).unwrap_or("").to_owned() + } + Some(FeatureNode::Extrude(extrude_id)) => { + document.extrude_label(extrude_id).unwrap_or("").to_owned() + } + _ => String::new(), + } +} + +fn sketch_of_feature(document: &Document, feature: FeatureId) -> Option { + match document.feature_tree().node(feature)? { + FeatureNode::Sketch(sketch_id) => Some(sketch_id), + _ => None, + } +} + +struct WhatsWrongKind { + message: bone_ui::strings::StringKey, + is_error: bool, + offers_reattach: bool, +} + +fn whats_wrong_kind(status: RebuildStatus) -> Option { + let kind = match status { + RebuildStatus::Error(RebuildError::DanglingReference(_)) => WhatsWrongKind { + message: strings::WHATS_WRONG_DANGLING, + is_error: true, + offers_reattach: true, + }, + RebuildStatus::Error(RebuildError::NonPlanarSketchTarget) => WhatsWrongKind { + message: strings::WHATS_WRONG_NON_PLANAR, + is_error: true, + offers_reattach: false, + }, + RebuildStatus::Error(RebuildError::UpstreamUnresolved) => WhatsWrongKind { + message: strings::WHATS_WRONG_UPSTREAM, + is_error: true, + offers_reattach: false, + }, + RebuildStatus::Error(RebuildError::Build(_)) => WhatsWrongKind { + message: strings::WHATS_WRONG_BUILD_FAILED, + is_error: true, + offers_reattach: false, + }, + RebuildStatus::Warning(_) => WhatsWrongKind { + message: strings::WHATS_WRONG_REPAIRED, + is_error: false, + offers_reattach: false, + }, + RebuildStatus::UpToDate | RebuildStatus::NeedsRebuild => return None, + }; + Some(kind) +} + +fn whats_wrong_entry( + model: &EvaluatedModel, + document: &Document, + feature: FeatureId, +) -> Option { + let kind = whats_wrong_kind(model.status(feature)?)?; + let reattach = kind + .offers_reattach + .then(|| sketch_of_feature(document, feature)) + .flatten(); + Some(shell::WhatsWrong { + label: feature_label_text(document, feature), + message: kind.message, + is_error: kind.is_error, + reattach, + }) +} + +fn compute_whats_wrong(model: &EvaluatedModel, document: &Document) -> Vec { + document + .feature_tree() + .iter() + .filter_map(|(feature, _)| whats_wrong_entry(model, document, feature)) + .collect() +} + +fn live_dim_anchor(mode: &Mode, document: &Document, pending: &PendingDimension) -> Point2 { + mode.sketch_id() + .and_then(|id| document.sketch(id)) + .and_then(|sketch| smart_dimension::live_anchor(sketch, pending.proto)) + .unwrap_or(pending.anchor) +} + +fn rebuild_changed(state: &mut AppState) { + recompute_model(state, RecomputeScope::Full); + if matches!(state.mode, Mode::Idle) { + rebuild_model_view(state); + } + state.needs_rebuild = false; + state.changed_features.clear(); +} + +fn force_rebuild_all(state: &mut AppState) { + state.model = EvaluatedModel::new(); + recompute_model(state, RecomputeScope::Full); + if matches!(state.mode, Mode::Idle) { + rebuild_model_view(state); + } + state.needs_rebuild = false; + state.changed_features.clear(); +} + fn sync_solid_view(state: &mut AppState) { + if matches!(state.mode, Mode::Idle) { + sync_model_view(state); + return; + } + state.model_passes.clear(); let Some(feature) = active_solid_feature(&state.mode, &state.document, state.framed_extrude) else { state.extrude_preview = None; @@ -772,7 +1038,7 @@ 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(&mut state.feature_cache, &state.document, feature); + let preview = compute_extrude_preview(&state.document, feature); let generation = preview.as_ref().and_then(EvaluatedExtrude::generation); let error = preview .as_ref() @@ -815,18 +1081,16 @@ fn extrude_preview_is_current( } fn compute_extrude_preview( - cache: &mut FeatureCache, document: &Document, feature: ExtrudeFeature, ) -> Option { let sketch = document.sketch(feature.sketch)?; - let fid = FeatureId::default(); - let evaluated_sketch = cache.evaluate(fid, sketch).clone(); - Some( - cache - .evaluate_extrude(fid, &evaluated_sketch, &feature) - .clone(), - ) + let evaluated_sketch = evaluate_sketch(sketch); + Some(evaluate_extrude( + FeatureId::default(), + &evaluated_sketch, + &feature, + )) } fn build_solid_view(solid: &bone_document::BrepSolid) -> Result { @@ -1372,8 +1636,12 @@ impl AppCore { document, plane_sketches, mode: Mode::Idle, - feature_cache: FeatureCache::new(), extrude_preview: None, + model: EvaluatedModel::new(), + model_passes: Vec::new(), + changed_features: BTreeSet::new(), + needs_rebuild: false, + pending_reattach: None, solid_renderer, solid_view: None, camera3: None, @@ -1813,6 +2081,9 @@ fn render_frame( } } let escape_requested = hotkey_actions.contains(&sketch_mode::ESCAPE_ACTION); + if escape_requested { + state.pending_reattach = None; + } apply_extrude_edit(state, frame.extrude_edit); apply_extrude_confirm(state, frame.confirm_action); let prev_active_sketch = active_sketch_id(&state.mode, &state.plane_sketches); @@ -1847,6 +2118,10 @@ fn render_frame( apply_relation_action(state, frame.activated_relation); apply_sketch_rename(state, frame.sketch_rename.clone()); apply_extrude_rename(state, frame.extrude_rename.clone()); + apply_feature_command(state, frame.feature_command); + apply_feature_reorder(state, frame.feature_reorder); + apply_rollback_change(state, frame.rollback_change); + begin_reattach(state, frame.reattach_request); let cursor_world = input_state .cursor_px .filter(|c| state.viewport_rect.contains(window_to_layout_pos(*c))) @@ -2083,6 +2358,8 @@ fn dispatch_hotkey_command( C::Quit => { state.pending_exit = true; } + C::RebuildChanged => rebuild_changed(state), + C::ForceRebuild => force_rebuild_all(state), C::OpenShortcutBar => { if state.shortcut_bar.is_none() { let anchor = cursor_layout.unwrap_or(LayoutPos::ORIGIN); @@ -2626,6 +2903,10 @@ fn suppress_pointer_activations(frame: shell::ShellFrame) -> shell::ShellFrame { sketch_rename: None, extrude_activated: None, extrude_rename: None, + feature_command: None, + feature_reorder: None, + rollback_change: None, + reattach_request: None, exit_sketch: false, confirm_action: None, menu_action: None, @@ -2757,6 +3038,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 mut ctx = FrameCtx::new( theme, input, @@ -2780,14 +3063,12 @@ fn run_frame_ui( state.camera3.filter(|_| state.solid_view.is_some()), extrude_status, &mut state.view, + &feature_badges, + needs_rebuild, + &whats_wrong, ); let dim_outcome = pending_dim(&state.mode).map(|pending| { - let live_anchor = state - .mode - .sketch_id() - .and_then(|id| state.document.sketch(id)) - .and_then(|s| smart_dimension::live_anchor(s, pending.proto)) - .unwrap_or(pending.anchor); + let live_anchor = live_dim_anchor(&state.mode, &state.document, &pending); dimension_editor::render( &mut ctx, pending, @@ -4131,6 +4412,125 @@ fn apply_extrude_rename(state: &mut AppState, request: Option) { + let Some(command) = command else { return }; + if apply_feature_command_into(&mut state.document, &mut state.undo, command) { + refresh_active_scene(state); + sync_solid_view(state); + } +} + +fn apply_feature_command_into( + document: &mut Document, + undo: &mut UndoStack, + command: shell::FeatureCommand, +) -> bool { + let snapshot = document.clone(); + let changed = match command { + shell::FeatureCommand::Suppress(feature) => { + document.suppress(feature); + true + } + shell::FeatureCommand::Unsuppress(feature) => { + document.unsuppress(feature); + true + } + shell::FeatureCommand::RollbackToHere(feature) => { + document.roll_to_here(feature); + true + } + shell::FeatureCommand::Delete(target) => apply_feature_delete(document, target), + }; + if changed { + undo.record(snapshot); + } + changed +} + +fn apply_feature_delete(document: &mut Document, target: shell::FeatureTarget) -> bool { + match target { + shell::FeatureTarget::Sketch(sketch_id) => document.remove_sketch(sketch_id).is_some(), + shell::FeatureTarget::Extrude(extrude_id) => document.remove_extrude(extrude_id).is_some(), + } +} + +fn apply_feature_reorder(state: &mut AppState, reorder: Option) { + let Some(reorder) = reorder else { return }; + let snapshot = state.document.clone(); + if state + .document + .reorder_feature(reorder.moved, reorder.anchor, reorder.before) + { + state.undo.record(snapshot); + refresh_active_scene(state); + sync_solid_view(state); + } +} + +fn begin_reattach(state: &mut AppState, request: Option) { + let Some(sketch) = request else { return }; + state.pending_reattach = Some(sketch); + notify_info(state, strings::NOTIFY_REATTACH_PICK, None); +} + +fn apply_reattach(state: &mut AppState, sketch: SketchId, face: BrepFaceId) { + let Some((face_ref, _)) = state.model.face_for_sketch(face) else { + notify_info(state, strings::NOTIFY_REATTACH_NON_PLANAR, None); + return; + }; + let snapshot = state.document.clone(); + if state.document.bind_sketch_to_face(sketch, face_ref).is_ok() { + state.pending_reattach = None; + state.undo.record(snapshot); + refresh_active_scene(state); + sync_solid_view(state); + } +} + +fn next_sketch_label(document: &Document) -> String { + format!("Sketch{}", document.sketches().count() + 1) +} + +fn begin_face_sketch(state: &mut AppState, face: BrepFaceId) -> bool { + let Some((face_ref, basis)) = state.model.face_for_sketch(face) else { + return false; + }; + let snapshot = state.document.clone(); + let sketch_id = state.document.allocate_sketch(); + let label = next_sketch_label(&state.document); + state + .document + .insert_sketch(sketch_id, label, Sketch::new(basis)); + if state + .document + .bind_sketch_to_face(sketch_id, face_ref) + .is_err() + { + state.document = snapshot; + return false; + } + state.undo.record(snapshot); + state.mode = Mode::enter_sketch(sketch_id); + refresh_active_scene(state); + sync_solid_view(state); + true +} + +fn apply_rollback_change(state: &mut AppState, change: Option) { + let Some(change) = change else { return }; + let before = state.document.rollback(); + let snapshot = state.document.clone(); + match change { + shell::RollbackChange::ToEnd => state.document.roll_to_end(), + shell::RollbackChange::ToFeature(feature) => state.document.roll_to_here(feature), + } + if state.document.rollback() != before { + state.undo.record(snapshot); + refresh_active_scene(state); + sync_solid_view(state); + } +} + fn apply_extrude_rename_into( document: &mut Document, undo: &mut UndoStack, @@ -4254,6 +4654,10 @@ mod tests { sketch_rename: None, extrude_activated: None, extrude_rename: None, + feature_command: None, + feature_reorder: None, + rollback_change: None, + reattach_request: None, exit_sketch: false, confirm_action: None, menu_action: None, @@ -4342,14 +4746,13 @@ mod tests { } #[test] - fn extrude_preview_refreshes_when_the_sketch_changes_under_one_cache() { + fn extrude_preview_refreshes_when_the_sketch_changes() { let (mut document, id) = super::initial_document(rectangle_sketch()); let feature = sketch_mode::default_extrude_feature(id); - let mut cache = super::FeatureCache::new(); - let first = super::compute_extrude_preview(&mut cache, &document, feature) + 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(&mut cache, &document, feature) + let second = super::compute_extrude_preview(&document, feature) .and_then(|preview| preview.generation()); let (Some(first), Some(second)) = (first, second) else { panic!("both rectangles extrude to a solid"); @@ -4364,8 +4767,7 @@ mod tests { fn extrude_preview_evaluates_a_closed_rectangle() { let (document, id) = super::initial_document(rectangle_sketch()); let feature = sketch_mode::default_extrude_feature(id); - let mut cache = super::FeatureCache::new(); - let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { + let Some(preview) = super::compute_extrude_preview(&document, feature) else { panic!("a registered sketch yields an evaluated preview"); }; assert!( @@ -4390,7 +4792,6 @@ mod tests { const STEPS: u32 = 16; let (document, id) = super::initial_document(rectangle_sketch()); - let mut cache = super::FeatureCache::new(); let blind = |depth_mm: f64| { let Ok(depth) = PositiveLength::new(Length::new::(depth_mm)) else { panic!("{depth_mm} mm is a positive depth"); @@ -4400,10 +4801,9 @@ mod tests { ..sketch_mode::default_extrude_feature(id) } }; - let edit = |cache: &mut super::FeatureCache, depth_mm: f64| { + let edit = |depth_mm: f64| { let started = Instant::now(); - let Some(preview) = super::compute_extrude_preview(cache, &document, blind(depth_mm)) - else { + let Some(preview) = super::compute_extrude_preview(&document, blind(depth_mm)) else { panic!("the rectangle extrudes at {depth_mm} mm"); }; let Some(solid) = preview.solid() else { @@ -4415,9 +4815,9 @@ mod tests { started.elapsed() }; - let _warmup = edit(&mut cache, BASE_MM); + let _warmup = edit(BASE_MM); let durations: Vec = (0..STEPS) - .map(|i| edit(&mut cache, BASE_MM + STEP_MM * f64::from(i))) + .map(|i| edit(BASE_MM + STEP_MM * f64::from(i))) .collect(); let sorted = { let mut v = durations.clone(); @@ -4443,16 +4843,14 @@ 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()); - let mut cache = super::FeatureCache::new(); - assert!(super::compute_extrude_preview(&mut cache, &document, feature).is_none()); + assert!(super::compute_extrude_preview(&document, feature).is_none()); } #[test] fn default_document_extrudes_to_a_solid() { let (document, id) = super::initial_document(super::default_sketch()); let feature = sketch_mode::default_extrude_feature(id); - let mut cache = super::FeatureCache::new(); - let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { + let Some(preview) = super::compute_extrude_preview(&document, feature) else { panic!("the default sketch is registered"); }; let Some(solid) = preview.solid() else { @@ -4465,8 +4863,7 @@ mod tests { fn preview_solid_view_tessellates_and_frames() { let (document, id) = super::initial_document(rectangle_sketch()); let feature = sketch_mode::default_extrude_feature(id); - let mut cache = super::FeatureCache::new(); - let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { + let Some(preview) = super::compute_extrude_preview(&document, feature) else { panic!("a registered sketch yields an evaluated preview"); }; let Some(solid) = preview.solid() else { @@ -4492,6 +4889,328 @@ mod tests { ); } + #[test] + fn build_combined_view_merges_every_evaluated_body() { + let (mut document, s1) = super::initial_document(rectangle_sketch()); + let e1 = document.commit_extrude(sketch_mode::default_extrude_feature(s1)); + let s2 = document.allocate_sketch(); + document.insert_sketch(s2, "Sketch2".to_owned(), rectangle_sketch()); + let _e2 = document.commit_extrude(sketch_mode::default_extrude_feature(s2)); + let mut model = EvaluatedModel::new(); + model.recompute( + &document, + document.suppressed(), + document.rollback(), + RecomputeScope::Full, + ); + assert_eq!( + super::body_passes(&model).len(), + 2, + "two separate bodies build" + ); + let Some(f1) = document.feature_tree().feature_of_extrude(e1) else { + panic!("the first extrude resolves to a feature id"); + }; + let Some(solid) = model.body(f1) else { + panic!("the first body is evaluated"); + }; + let Ok(single) = super::build_solid_view(solid) else { + panic!("a single body tessellates"); + }; + let Some(combined) = super::build_combined_view(&model) else { + panic!("the combined view is built from both bodies"); + }; + assert_eq!( + combined.faces.triangles().len(), + 2 * single.faces.triangles().len(), + "the combined mesh holds both bodies", + ); + } + + fn full_recompute(model: &mut EvaluatedModel, document: &Document) { + model.recompute( + document, + document.suppressed(), + document.rollback(), + RecomputeScope::Full, + ); + } + + #[test] + fn face_bound_sketch_creation_then_break_offers_reattach() { + use bone_types::FaceRole; + let (mut document, base_sketch) = super::initial_document(rectangle_sketch()); + let base = document.commit_extrude(sketch_mode::default_extrude_feature(base_sketch)); + let mut model = EvaluatedModel::new(); + full_recompute(&mut model, &document); + + let Some(body) = document.feature_tree().feature_of_extrude(base) else { + panic!("the base extrude resolves to a feature id"); + }; + let Some(solid) = model.body(body) else { + panic!("the base body is evaluated"); + }; + let Some(face) = solid.iter_faces().find_map(|candidate| { + matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id()) + }) else { + panic!("the box has a planar cap"); + }; + let Some((face_ref, _basis)) = model.face_for_sketch(face) else { + panic!("a planar cap yields a face reference and basis"); + }; + + let face_sketch = document.allocate_sketch(); + document.insert_sketch(face_sketch, "Sketch2".to_owned(), rectangle_sketch()); + let Ok(()) = document.bind_sketch_to_face(face_sketch, face_ref) else { + panic!("binding a fresh sketch to a face is acyclic"); + }; + let face_extrude = + document.commit_extrude(sketch_mode::default_extrude_feature(face_sketch)); + full_recompute(&mut model, &document); + assert_eq!( + super::body_passes(&model).len(), + 2, + "the face-bound extrude is a separate second body", + ); + + document.remove_extrude(base); + full_recompute(&mut model, &document); + let Some(face_feature) = document.feature_tree().feature_of_sketch(face_sketch) else { + panic!("the face-bound sketch keeps its feature id"); + }; + assert!( + matches!( + model.status(face_feature), + Some(RebuildStatus::Error(RebuildError::DanglingReference(_))) + ), + "deleting the base dangles the face-bound sketch", + ); + let Some(face_extrude_feature) = document.feature_tree().feature_of_extrude(face_extrude) + else { + panic!("the face-bound extrude keeps its feature id"); + }; + assert_eq!( + model.status(face_extrude_feature), + Some(RebuildStatus::Error(RebuildError::UpstreamUnresolved)), + "the downstream extrude is blamed as upstream-unresolved, not a second dangling owner", + ); + let wrong = super::compute_whats_wrong(&model, &document); + assert_eq!( + wrong + .iter() + .filter(|entry| entry.reattach.is_some()) + .count(), + 1, + "only the sketch that owns the dangling reference offers reattach", + ); + assert!( + wrong + .iter() + .any(|entry| entry.reattach == Some(face_sketch)), + "the dangling face-bound sketch offers reattach", + ); + assert!( + wrong + .iter() + .any(|entry| entry.message == strings::WHATS_WRONG_UPSTREAM + && entry.reattach.is_none()), + "the downstream body reports an upstream failure with no reattach of its own", + ); + } + + fn offscreen_context(extent: ViewportExtent, remaining: u32) -> bone_render::OffscreenContext { + use bone_render::{AdapterPolicy, OffscreenContext, RenderError}; + match pollster::block_on(OffscreenContext::new(extent, AdapterPolicy::Platform)) { + Ok(ctx) => ctx, + Err(RenderError::NoAdapter(_) | RenderError::Device(_)) if remaining > 0 => { + std::thread::sleep(std::time::Duration::from_millis(100)); + offscreen_context(extent, remaining - 1) + } + Err(e) => panic!("offscreen context init failed: {e}"), + } + } + + fn check_solid_golden(frame: &bone_render::SnapshotFrame, golden_rel: &str) { + use bone_render::{PixelDiff, PixelDiffThreshold, decode_png, encode_png}; + let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(golden_rel); + if std::env::var_os("BONE_UPDATE_REBUILD_GOLDENS").is_some() { + let Ok(bytes) = encode_png(frame) else { + panic!("encode_png failed"); + }; + if let Some(parent) = path.parent() { + let Ok(()) = std::fs::create_dir_all(parent) else { + panic!("create goldens dir failed"); + }; + } + let Ok(()) = std::fs::write(&path, &bytes) else { + panic!("write golden {} failed", path.display()); + }; + return; + } + let Ok(bytes) = std::fs::read(&path) else { + panic!( + "golden {} missing; rerun with BONE_UPDATE_REBUILD_GOLDENS=1 to bless", + path.display(), + ); + }; + let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { + panic!("decode golden failed"); + }; + assert_eq!(golden_extent, frame.extent(), "golden extent drift"); + let threshold = PixelDiffThreshold::new(16.0 / 255.0); + let Ok(report) = PixelDiff::compare(frame, &golden_rgba, threshold) else { + panic!("PixelDiff rejected inputs"); + }; + assert!( + report.is_clean(), + "rebuild-downstream golden drifted: {} mismatches, worst {:?}", + report.over_threshold(), + report.worst(), + ); + } + + #[test] + fn rebuild_downstream_scene_matches_golden() { + use bone_types::FaceRole; + let (mut document, base_sketch) = super::initial_document(rectangle_sketch()); + let base = document.commit_extrude(sketch_mode::default_extrude_feature(base_sketch)); + let mut model = EvaluatedModel::new(); + full_recompute(&mut model, &document); + + let Some(body) = document.feature_tree().feature_of_extrude(base) else { + panic!("the base extrude resolves to a feature id"); + }; + let Some(solid) = model.body(body) else { + panic!("the base body is evaluated"); + }; + let Some(cap) = solid.iter_faces().find_map(|candidate| { + matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id()) + }) else { + panic!("the box has a planar top cap"); + }; + let Some((face_ref, _basis)) = model.face_for_sketch(cap) else { + panic!("a planar cap yields a face reference and basis"); + }; + + let face_sketch = document.allocate_sketch(); + document.insert_sketch(face_sketch, "Sketch2".to_owned(), rectangle_sketch()); + let Ok(()) = document.bind_sketch_to_face(face_sketch, face_ref) else { + panic!("binding the second sketch to the cap is acyclic"); + }; + let _ = document.commit_extrude(sketch_mode::default_extrude_feature(face_sketch)); + full_recompute(&mut model, &document); + + document.replace_sketch(base_sketch, tall_rectangle_sketch()); + full_recompute(&mut model, &document); + assert_eq!( + super::body_passes(&model).len(), + 2, + "editing the base sketch keeps both bodies", + ); + + let Some(combined) = super::build_combined_view(&model) else { + panic!("the rebuilt model lowers to a combined view"); + }; + let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256)); + let Ok(camera) = frame_standard_view(combined.aabb, extent, StandardView::Isometric, None) + else { + panic!("the combined aabb frames an isometric camera"); + }; + + let ctx = offscreen_context(extent, 3); + let mut renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format()); + let Ok(frame) = renderer.render_display( + &ctx, + &combined.faces, + &combined.edges, + camera, + &Style::default(), + DisplayMode::ShadedWithEdges, + ) else { + panic!("the rebuilt two-body scene renders"); + }; + check_solid_golden(&frame, "tests/goldens/rebuild_downstream_iso_256.png"); + } + + #[test] + fn solid_pick_index_builds_for_an_evaluated_body() { + let (mut document, sketch) = super::initial_document(rectangle_sketch()); + let _extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); + let mut model = EvaluatedModel::new(); + model.recompute( + &document, + document.suppressed(), + document.rollback(), + RecomputeScope::Full, + ); + assert!( + super::solid_pick_index(&model).is_some(), + "an evaluated body yields a solid pick index", + ); + assert!( + super::solid_pick_index(&EvaluatedModel::new()).is_none(), + "an empty model has nothing to pick", + ); + } + + #[test] + fn reattach_binds_a_sketch_to_a_picked_cap_face() { + use bone_types::FaceRole; + let (mut document, base_sketch) = super::initial_document(rectangle_sketch()); + let base = document.commit_extrude(sketch_mode::default_extrude_feature(base_sketch)); + let mut model = EvaluatedModel::new(); + model.recompute( + &document, + document.suppressed(), + document.rollback(), + RecomputeScope::Full, + ); + let Some(body) = document.feature_tree().feature_of_extrude(base) else { + panic!("the base extrude resolves to a feature id"); + }; + let Some(solid) = model.body(body) else { + panic!("the base body is evaluated"); + }; + let Some(face) = solid.iter_faces().find_map(|candidate| { + matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id()) + }) else { + panic!("the box has a planar cap"); + }; + let Some(face_ref) = model.face_ref_any(face) else { + panic!("a picked cap yields a face reference"); + }; + let new_sketch = document.allocate_sketch(); + document.insert_sketch(new_sketch, "Sketch2".to_owned(), rectangle_sketch()); + assert!( + document.bind_sketch_to_face(new_sketch, face_ref).is_ok(), + "reattaching a sketch to a planar cap binds cleanly", + ); + } + + #[test] + fn compute_whats_wrong_is_empty_for_a_healthy_document() { + let (mut document, sketch) = super::initial_document(rectangle_sketch()); + let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); + let mut model = EvaluatedModel::new(); + model.recompute( + &document, + document.suppressed(), + document.rollback(), + RecomputeScope::Full, + ); + assert!( + super::compute_whats_wrong(&model, &document).is_empty(), + "a clean rebuild has nothing wrong", + ); + let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the extrude resolves to a feature id"); + }; + let Ok(()) = document.rename_extrude(extrude, "Boss") else { + panic!("rename accepts"); + }; + assert_eq!(super::feature_label_text(&document, feature), "Boss"); + } + fn layout_rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { LayoutRect::new( LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), @@ -4535,6 +5254,50 @@ mod tests { assert!(super::solid_viewport_region(layout_rect(0.0, 0.0, 0.0, 0.0), surface).is_none()); } + #[test] + fn defer_rebuild_holds_only_for_a_costly_later_build() { + let cheap = RebuildCost::new(10); + let costly = RebuildCost::new(10_000); + let budget = RebuildBudget::new(100); + assert!( + !super::defer_rebuild(true, costly, budget), + "the first build always renders, however heavy", + ); + assert!( + super::defer_rebuild(false, costly, budget), + "a heavy later rebuild defers behind the rebuild-needed badge", + ); + assert!( + !super::defer_rebuild(false, cheap, budget), + "a cheap later rebuild stays eager and never raises the badge", + ); + } + + #[test] + fn whats_wrong_classifies_failures_and_withholds_reattach_from_downstream() { + let Some(upstream) = + super::whats_wrong_kind(RebuildStatus::Error(RebuildError::UpstreamUnresolved)) + else { + panic!("an upstream failure is reported"); + }; + assert_eq!(upstream.message, strings::WHATS_WRONG_UPSTREAM); + assert!(upstream.is_error); + assert!( + !upstream.offers_reattach, + "a downstream feature blames the upstream, it never offers its own reattach", + ); + + let Some(non_planar) = + super::whats_wrong_kind(RebuildStatus::Error(RebuildError::NonPlanarSketchTarget)) + else { + panic!("a non-planar target is reported"); + }; + assert!(non_planar.is_error && !non_planar.offers_reattach); + + assert!(super::whats_wrong_kind(RebuildStatus::UpToDate).is_none()); + assert!(super::whats_wrong_kind(RebuildStatus::NeedsRebuild).is_none()); + } + #[test] fn viewport_local_point_centers_the_inset_viewport_not_the_window() { let region = ViewportRegion::new( @@ -5063,6 +5826,59 @@ mod tests { assert_eq!(document.extrude_label(id), Some("Boss")); } + #[test] + fn apply_feature_command_suppresses_and_records_undo() { + let (mut document, sketch) = doc_with_default_sketch(); + let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); + let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the committed extrude has a feature id"); + }; + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); + let changed = apply_feature_command_into( + &mut document, + &mut undo, + shell::FeatureCommand::Suppress(feature), + ); + assert!(changed); + assert!(document.suppression_state(feature).is_suppressed()); + assert_eq!( + undo.past_len(), + 1, + "a suppression records one undo snapshot" + ); + } + + #[test] + fn apply_feature_command_deletes_extrude_and_records_undo() { + let (mut document, sketch) = doc_with_default_sketch(); + let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); + let changed = apply_feature_command_into( + &mut document, + &mut undo, + shell::FeatureCommand::Delete(shell::FeatureTarget::Extrude(extrude)), + ); + assert!(changed); + assert_eq!(document.extrude(extrude), None); + assert_eq!(undo.past_len(), 1); + } + + #[test] + fn apply_feature_command_rolls_back_to_feature() { + let (mut document, sketch) = doc_with_default_sketch(); + let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); + let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the committed extrude has a feature id"); + }; + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); + apply_feature_command_into( + &mut document, + &mut undo, + shell::FeatureCommand::RollbackToHere(feature), + ); + assert!(document.is_rolled_back(feature)); + } + #[test] fn extrude_edit_mode_arms_from_idle_but_not_from_sketch() { let (mut document, sketch) = doc_with_default_sketch(); diff --git a/crates/bone-app/src/hotkeys.rs b/crates/bone-app/src/hotkeys.rs index 3e2423f..7c9b3d8 100644 --- a/crates/bone-app/src/hotkeys.rs +++ b/crates/bone-app/src/hotkeys.rs @@ -45,6 +45,8 @@ pub const VIEW_ISOMETRIC_ACTION: ActionId = action_id(26); pub const VIEW_NORMAL_TO_ACTION: ActionId = action_id(27); 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); const fn ch(c: char) -> KeyCode { KeyCode::Char(KeyChar::from_ascii(c)) @@ -64,6 +66,7 @@ const CTRL_O: KeyChord = KeyChord::new(ch('o'), ModifierMask::CTRL); 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_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); @@ -94,6 +97,8 @@ pub enum HotkeyCommand { ZoomFit, OpenShortcutBar, Quit, + RebuildChanged, + ForceRebuild, EnterSketch, SmartDimension, Trim, @@ -226,6 +231,20 @@ pub const COMMANDS: &[Command] = &[ kind: Some(HotkeyCommand::Quit), scope: HotkeyScope::Global, label: s::HOTKEY_LABEL_QUIT, + defaults: &[], + }, + Command { + action: REBUILD_ACTION, + kind: Some(HotkeyCommand::RebuildChanged), + scope: HotkeyScope::Global, + label: s::HOTKEY_LABEL_REBUILD, + defaults: &[CTRL_B], + }, + Command { + action: FORCE_REBUILD_ACTION, + kind: Some(HotkeyCommand::ForceRebuild), + scope: HotkeyScope::Global, + label: s::HOTKEY_LABEL_FORCE_REBUILD, defaults: &[CTRL_Q], }, Command { diff --git a/crates/bone-app/src/shell.rs b/crates/bone-app/src/shell.rs index 8938951..01ac689 100644 --- a/crates/bone-app/src/shell.rs +++ b/crates/bone-app/src/shell.rs @@ -1,19 +1,20 @@ use core::num::NonZeroU32; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use bone_document::{ - DimensionKind, DimensionValue, Document, ExtrudeEndCondition, ExtrudeFeature, FeatureNode, - MergeResult, Sketch, SketchDimension, SketchEntity, SketchRelation, SketchStatusReport, - SketchVersion, + DimensionKind, DimensionValue, Document, ExtrudeEndCondition, ExtrudeFeature, FeatureEdge, + FeatureNode, MergeResult, Sketch, SketchDimension, SketchEntity, SketchRelation, + SketchStatusReport, SketchVersion, }; use bone_types::{ - Angle, Camera3, ExtrudeId, IconId, Length, Point2, PositiveLength, SketchDimensionId, - SketchEntityId, SketchId, + Angle, Camera3, ExtrudeId, FeatureId, IconId, Length, Point2, PositiveLength, RollbackMarker, + SketchDimensionId, SketchEntityId, SketchId, }; use bone_ui::a11y::{AccessNode, Role}; use bone_ui::frame::{FrameCtx, InteractDeclaration}; use bone_ui::hit_test::Sense; +use bone_ui::input::PointerButton; use bone_ui::layout::{ Axis, DockNode, DockPanel, DockState, GridChild, GridLine, GridSpan, GridTrack, Layout, LayoutPos, LayoutPx, LayoutRect, LayoutSize, NodeKind, PanelId, RetainedLayout, SolvedLayout, @@ -23,16 +24,17 @@ use bone_ui::strings::{StringKey, StringTable}; use bone_ui::theme::{Border, ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme}; use bone_ui::widgets::IconTint; use bone_ui::widgets::{ - AngleEditor, BoolEditor, Checkbox, CheckboxState, Clipboard, Dialog, DialogButton, - HotkeyCapture, HotkeyCaptureState, LabelText, LengthEditor, MemoryClipboard, MenuBar, - MenuBarEntry, MenuBarState, MenuItem, Panel, PanelState, PanelTitlebar, PanelVariant, + AngleEditor, BoolEditor, Checkbox, CheckboxState, Clipboard, ContextMenu, ContextMenuRequest, + Dialog, DialogButton, DropPlacement, DropTarget, HorizontalAlign, HotkeyCapture, + HotkeyCaptureState, LabelText, LengthEditor, MemoryClipboard, MenuBar, MenuBarEntry, + MenuBarState, MenuItem, MenuState, Panel, PanelState, PanelTitlebar, PanelVariant, PropertyCell, PropertyEditor, PropertyGrid, PropertyOption, PropertyPaneAction, PropertyPaneHeader, PropertyRow, RenameCommit, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, - SelectionEditor, Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, - Tabs, TabsOrientation, ToolbarItem, TreeNode, TreeView, TreeViewState, WidgetPaint, - show_checkbox, show_dialog, show_hotkey_capture, show_menu_bar, show_panel, show_property_grid, - show_property_pane_header, show_ribbon, show_slider, show_status_bar, show_tabs, - show_tree_view, + RollbackBar, RollbackTarget, SelectionEditor, Slider, SliderRange, SliderStep, StatusAlign, + StatusBar, StatusItem, Tab, Tabs, TabsOrientation, ToolbarItem, TreeBadge, TreeNode, TreeView, + TreeViewState, WidgetPaint, show_checkbox, show_context_menu, show_dialog, show_hotkey_capture, + show_menu_bar, show_panel, show_property_grid, show_property_pane_header, show_ribbon, + show_slider, show_status_bar, show_tabs, show_tree_view, }; use bone_ui::{WidgetId, WidgetKey}; use uom::si::angle::degree; @@ -271,6 +273,53 @@ pub enum MenuAction { ExitSketch, } +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum FeatureTarget { + Sketch(SketchId), + Extrude(ExtrudeId), +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum FeatureCommand { + Suppress(FeatureId), + Unsuppress(FeatureId), + RollbackToHere(FeatureId), + Delete(FeatureTarget), +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct FeatureReorder { + pub moved: FeatureId, + pub anchor: FeatureId, + pub before: bool, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum RollbackChange { + ToEnd, + ToFeature(FeatureId), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct WhatsWrong { + pub label: String, + pub message: StringKey, + pub is_error: bool, + pub reattach: Option, +} + +#[derive(Default)] +pub struct FeatureMenu { + open: Option, + menu: MenuState, +} + +#[derive(Copy, Clone)] +struct FeatureMenuAnchor { + target: FeatureTarget, + anchor: LayoutPos, +} + pub struct Shell { panels: ShellPanels, ids: ShellIds, @@ -289,6 +338,9 @@ pub struct Shell { )] pub struct ShellState { pub feature_tree: TreeViewState, + pub feature_menu: FeatureMenu, + pub relationships: Option, + pub whats_wrong_open: bool, pub clipboard: MemoryClipboard, pub menu_bar: MenuBarState, pub dim_property: Option, @@ -465,6 +517,10 @@ pub struct ShellFrame { 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, @@ -496,6 +552,10 @@ impl ShellFrame { sketch_rename: None, extrude_activated: None, extrude_rename: None, + feature_command: None, + feature_reorder: None, + rollback_change: None, + reattach_request: None, exit_sketch: false, confirm_action: None, menu_action: None, @@ -571,6 +631,9 @@ impl Shell { camera3: Option, extrude_status: Option>, view: &mut ViewUi, + feature_badges: &BTreeMap, + needs_rebuild: bool, + whats_wrong: &[WhatsWrong], ) -> ShellFrame { let theme = ctx.theme(); let direction = ctx.direction(); @@ -644,13 +707,29 @@ impl Shell { }; let feature_tree = render_feature_tree( ctx, - tree_rect, - self.ids.feature_tree, - self.ids.feature_part, &mut self.state.feature_tree, - document, + FeatureTreeInputs { + rect: tree_rect, + tree_id: self.ids.feature_tree, + part_id: self.ids.feature_part, + document, + badges: feature_badges, + needs_rebuild, + }, &mut paints, ); + if let Some((target, anchor)) = feature_tree.context_menu { + self.state.feature_menu.open = Some(FeatureMenuAnchor { target, anchor }); + self.state.feature_menu.menu = MenuState::default(); + } + let feature_menu_base = self.ids.feature_tree; + let feature_menu_outcome = render_feature_context_menu( + ctx, + feature_menu_base, + &mut self.state, + document, + &mut popover_paints, + ); let pane = render_property_pane( ctx, property_rect, @@ -784,10 +863,61 @@ impl Shell { let plane_picked = feature_tree .double_activated .and_then(|id| self.ids.plane_for(id)); - let sketch_activated = feature_tree.sketch_activated; + let mut sketch_activated = feature_tree.sketch_activated; let sketch_rename = feature_tree.sketch_rename; - let extrude_activated = feature_tree.extrude_activated; + let mut extrude_activated = feature_tree.extrude_activated; let extrude_rename = feature_tree.extrude_rename; + let feature_reorder = feature_tree.reorder; + let rollback_change = feature_tree.rollback; + let mut feature_command = None; + match feature_menu_outcome { + Some(FeatureMenuOutcome::EditSketch(sketch_id)) => { + sketch_activated = sketch_activated.or(Some(sketch_id)); + } + Some(FeatureMenuOutcome::EditExtrude(extrude_id)) => { + extrude_activated = extrude_activated.or(Some(extrude_id)); + } + Some(FeatureMenuOutcome::ShowRelationships(target)) => { + self.state.relationships = Some(target); + } + Some(FeatureMenuOutcome::Command(command)) => feature_command = Some(command), + None => {} + } + if let Some(target) = self.state.relationships { + let relationships_base = self.ids.feature_tree; + let close = render_relationships_panel( + ctx, + relationships_base, + viewport_rect, + target, + document, + &mut popover_paints, + ); + if close { + self.state.relationships = None; + } + } + if feature_tree.part_activated && !whats_wrong.is_empty() { + self.state.whats_wrong_open = !self.state.whats_wrong_open; + } + if whats_wrong.is_empty() { + self.state.whats_wrong_open = false; + } + let mut reattach_request = None; + if self.state.whats_wrong_open { + let whats_wrong_base = self.ids.feature_tree; + let outcome = render_whats_wrong_panel( + ctx, + whats_wrong_base, + viewport_rect, + whats_wrong, + &mut popover_paints, + ); + reattach_request = outcome.reattach; + if outcome.close || reattach_request.is_some() { + self.state.whats_wrong_open = false; + } + } let mut dialog_paints: Vec = Vec::new(); let settings_change = render_settings_dialog( ctx, @@ -824,6 +954,10 @@ impl Shell { sketch_rename, extrude_activated, extrude_rename, + feature_command, + feature_reorder, + rollback_change, + reattach_request, exit_sketch, confirm_action, menu_action, @@ -1801,6 +1935,10 @@ struct FeatureTreeOutcome { sketch_rename: Option, extrude_activated: Option, extrude_rename: Option, + context_menu: Option<(FeatureTarget, LayoutPos)>, + reorder: Option, + rollback: Option, + part_activated: bool, } fn sketch_widget_id(part_id: WidgetId, sketch_id: SketchId) -> WidgetId { @@ -1811,138 +1949,928 @@ fn extrude_widget_id(part_id: WidgetId, extrude_id: ExtrudeId) -> WidgetId { part_id.child_indexed(WidgetKey::new("extrude"), extrude_id.as_u64()) } -fn sketch_tree_rows(document: &Document, part_id: WidgetId) -> Vec<(SketchId, WidgetId, TreeNode)> { - document - .sketches() - .map(|(sketch_id, _)| { - let widget_id = sketch_widget_id(part_id, sketch_id); - let label = document.sketch_label(sketch_id).unwrap_or("").to_owned(); - let node = TreeNode::leaf_owned(widget_id, label).with_icon(IconId::TreeSketch); - (sketch_id, widget_id, node) +fn sketch_leaf( + document: &Document, + part_id: WidgetId, + sketch_id: SketchId, + badges: &BTreeMap, +) -> TreeNode { + let widget_id = sketch_widget_id(part_id, sketch_id); + let label = document.sketch_label(sketch_id).unwrap_or("").to_owned(); + let feature = document.feature_tree().feature_of_sketch(sketch_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(IconId::TreeSketch) + .with_badge(badge) + .disabled(rolled_back) +} + +fn source_sketch_of_extrude(document: &Document, extrude_id: ExtrudeId) -> Option { + let tree = document.feature_tree(); + let extrude_feature = tree.feature_of_extrude(extrude_id)?; + tree.parents(extrude_feature) + .into_iter() + .find_map(|parent| match tree.node(parent) { + Some(FeatureNode::Sketch(sketch_id)) => Some(sketch_id), + _ => None, + }) +} + +fn extrude_feature_node( + document: &Document, + part_id: WidgetId, + extrude_id: ExtrudeId, + badges: &BTreeMap, +) -> TreeNode { + let widget_id = extrude_widget_id(part_id, extrude_id); + let label = document.extrude_label(extrude_id).unwrap_or("").to_owned(); + let nested: Vec = source_sketch_of_extrude(document, extrude_id) + .map(|sketch_id| sketch_leaf(document, part_id, sketch_id, badges)) + .into_iter() + .collect(); + let feature = document.feature_tree().feature_of_extrude(extrude_id); + let own = feature.and_then(|feature| badges.get(&feature).copied()); + let child = nested.iter().filter_map(|node| node.badge).max(); + let badge = [own, child].into_iter().flatten().max(); + let rolled_back = feature.is_some_and(|feature| document.is_rolled_back(feature)); + TreeNode::parent_owned(widget_id, label, nested) + .with_icon(IconId::TreeFeature) + .with_badge(badge) + .disabled(rolled_back) +} + +fn consumed_sketches(document: &Document) -> BTreeSet { + let tree = document.feature_tree(); + tree.edges() + .iter() + .filter_map(|edge| match edge { + FeatureEdge::SketchToExtrude { sketch, .. } => match tree.node(*sketch) { + Some(FeatureNode::Sketch(sketch_id)) => Some(sketch_id), + _ => None, + }, + FeatureEdge::FaceToSketch { .. } => None, }) .collect() } -fn extrude_tree_rows( +fn feature_tree_children( document: &Document, part_id: WidgetId, -) -> Vec<(ExtrudeId, WidgetId, TreeNode)> { + badges: &BTreeMap, +) -> Vec { + let leaf = |key: &'static str, label: StringKey| { + TreeNode::leaf(part_id.child(WidgetKey::new(key)), label) + }; + let feature_leaf = + |key: &'static str, label: StringKey| leaf(key, label).with_icon(IconId::TreeFeature); + let placeholder = |key: &'static str, label: StringKey| feature_leaf(key, label).disabled(true); + let plane_leaf = + |key: &'static str, label: StringKey| leaf(key, label).with_icon(IconId::TreePlane); + [ + placeholder("history", strings::FEATURE_HISTORY), + placeholder("sensors", strings::FEATURE_SENSORS), + placeholder("annotations", strings::FEATURE_ANNOTATIONS), + placeholder("solid_bodies", strings::FEATURE_SOLID_BODIES), + placeholder("material", strings::FEATURE_MATERIAL), + plane_leaf("plane.xy", strings::FEATURE_PLANE_XY), + plane_leaf("plane.yz", strings::FEATURE_PLANE_YZ), + plane_leaf("plane.zx", strings::FEATURE_PLANE_ZX), + leaf("origin", strings::FEATURE_ORIGIN).with_icon(IconId::TreeOrigin), + ] + .into_iter() + .chain(feature_rows(document, part_id, badges)) + .collect() +} + +fn tree_illegal_drop( + document: &Document, + state: &TreeViewState, + widget_to_sketch: &BTreeMap, + widget_to_extrude: &BTreeMap, +) -> bool { + state + .drag_source + .zip(state.drop_target) + .is_some_and(|(src, target)| { + drop_to_reorder(document, widget_to_sketch, widget_to_extrude, src, target).is_none() + }) +} + +fn feature_rows( + document: &Document, + part_id: WidgetId, + badges: &BTreeMap, +) -> Vec { + let consumed = consumed_sketches(document); document .feature_tree() .iter() .filter_map(|(_, node)| match node { - FeatureNode::Extrude(extrude_id) => Some(extrude_id), + FeatureNode::Extrude(extrude_id) => { + Some(extrude_feature_node(document, part_id, extrude_id, badges)) + } + FeatureNode::Sketch(sketch_id) if !consumed.contains(&sketch_id) => { + Some(sketch_leaf(document, part_id, sketch_id, badges)) + } _ => None, }) - .map(|extrude_id| { - let widget_id = extrude_widget_id(part_id, extrude_id); - let label = document.extrude_label(extrude_id).unwrap_or("").to_owned(); - let node = TreeNode::leaf_owned(widget_id, label).with_icon(IconId::TreeFeature); - (extrude_id, widget_id, node) + .collect() +} + +fn sketch_widget_ids(document: &Document, part_id: WidgetId) -> Vec<(SketchId, WidgetId)> { + document + .sketches() + .map(|(sketch_id, _)| (sketch_id, sketch_widget_id(part_id, sketch_id))) + .collect() +} + +fn extrude_widget_ids(document: &Document, part_id: WidgetId) -> Vec<(ExtrudeId, WidgetId)> { + document + .feature_tree() + .iter() + .filter_map(|(_, node)| match node { + FeatureNode::Extrude(extrude_id) => Some(extrude_id), + _ => None, }) + .map(|extrude_id| (extrude_id, extrude_widget_id(part_id, extrude_id))) .collect() } -fn render_feature_tree( - ctx: &mut FrameCtx<'_>, +#[derive(Copy, Clone)] +struct FeatureTreeInputs<'a> { rect: LayoutRect, tree_id: WidgetId, part_id: WidgetId, - state: &mut TreeViewState, - document: &Document, - paints: &mut Vec, -) -> FeatureTreeOutcome { - if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { - return FeatureTreeOutcome { + document: &'a Document, + badges: &'a BTreeMap, + needs_rebuild: bool, +} + +impl FeatureTreeOutcome { + fn empty() -> Self { + Self { double_activated: None, sketch_activated: None, sketch_rename: None, extrude_activated: None, extrude_rename: None, - }; + context_menu: None, + reorder: None, + rollback: None, + part_activated: false, + } } - let leaf = |key: &'static str, label: StringKey| { - TreeNode::leaf(part_id.child(WidgetKey::new(key)), label) - }; - let feature_leaf = - |key: &'static str, label: StringKey| leaf(key, label).with_icon(IconId::TreeFeature); - let placeholder = |key: &'static str, label: StringKey| feature_leaf(key, label).disabled(true); - let plane_leaf = - |key: &'static str, label: StringKey| leaf(key, label).with_icon(IconId::TreePlane); - let sketch_rows = sketch_tree_rows(document, part_id); - let extrude_rows = extrude_tree_rows(document, part_id); - let renamable: Vec = sketch_rows +} + +fn resolve_renames( + commit: Option<&RenameCommit>, + widget_to_sketch: &BTreeMap, + widget_to_extrude: &BTreeMap, +) -> (Option, Option) { + let sketch = commit.and_then(|RenameCommit { id, text }| { + widget_to_sketch + .get(id) + .copied() + .map(|sketch_id| SketchRenameRequest { + id: sketch_id, + label: text.clone(), + }) + }); + let extrude = commit.and_then(|RenameCommit { id, text }| { + widget_to_extrude + .get(id) + .copied() + .map(|extrude_id| ExtrudeRenameRequest { + id: extrude_id, + label: text.clone(), + }) + }); + (sketch, extrude) +} + +fn render_feature_tree( + ctx: &mut FrameCtx<'_>, + state: &mut TreeViewState, + inputs: FeatureTreeInputs<'_>, + paints: &mut Vec, +) -> FeatureTreeOutcome { + let FeatureTreeInputs { + rect, + tree_id, + part_id, + document, + badges, + needs_rebuild, + } = inputs; + if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { + return FeatureTreeOutcome::empty(); + } + let sketch_ids = sketch_widget_ids(document, part_id); + let extrude_ids = extrude_widget_ids(document, part_id); + let renamable: Vec = sketch_ids .iter() - .map(|(_, w, _)| *w) - .chain(extrude_rows.iter().map(|(_, w, _)| *w)) + .map(|(_, w)| *w) + .chain(extrude_ids.iter().map(|(_, w)| *w)) .collect(); let widget_to_sketch: BTreeMap = - sketch_rows.iter().map(|(s, w, _)| (*w, *s)).collect(); + sketch_ids.iter().map(|(s, w)| (*w, *s)).collect(); let widget_to_extrude: BTreeMap = - extrude_rows.iter().map(|(e, w, _)| (*w, *e)).collect(); - let children: Vec = [ - placeholder("history", strings::FEATURE_HISTORY), - placeholder("sensors", strings::FEATURE_SENSORS), - placeholder("annotations", strings::FEATURE_ANNOTATIONS), - placeholder("solid_bodies", strings::FEATURE_SOLID_BODIES), - placeholder("material", strings::FEATURE_MATERIAL), - plane_leaf("plane.xy", strings::FEATURE_PLANE_XY), - plane_leaf("plane.yz", strings::FEATURE_PLANE_YZ), - plane_leaf("plane.zx", strings::FEATURE_PLANE_ZX), - leaf("origin", strings::FEATURE_ORIGIN).with_icon(IconId::TreeOrigin), + extrude_ids.iter().map(|(e, w)| (*w, *e)).collect(); + let children = feature_tree_children(document, part_id, badges); + let part_badge = [ + badges.values().copied().max(), + needs_rebuild.then_some(TreeBadge::RebuildNeeded), ] .into_iter() - .chain(sketch_rows.into_iter().map(|(_, _, node)| node)) - .chain(extrude_rows.into_iter().map(|(_, _, node)| node)) - .collect(); - let part = TreeNode::parent_owned(part_id, document.name().to_owned(), children); + .flatten() + .max(); + let part = TreeNode::parent_owned(part_id, document.name().to_owned(), children) + .with_badge(part_badge); let roots = [part]; + let illegal_drop = tree_illegal_drop(document, state, &widget_to_sketch, &widget_to_extrude); + let stops = rollback_stops(document, part_id); + let rollback_bar = (!stops.is_empty()).then(|| RollbackBar { + stops: &stops, + marker: current_rollback_target(document, part_id), + }); let response = show_tree_view( ctx, TreeView::new(tree_id, rect, strings::FEATURE_TREE_LABEL, &roots, state) - .renamable(&renamable), + .renamable(&renamable) + .illegal_drop(illegal_drop) + .rollback(rollback_bar), ); paints.extend(response.paint); + let reorder = response.drop_committed.and_then(|(src, target)| { + drop_to_reorder(document, &widget_to_sketch, &widget_to_extrude, src, target) + }); + let rollback = response.rollback_moved.and_then(|target| { + resolve_rollback_change(document, &widget_to_sketch, &widget_to_extrude, target) + }); let sketch_activated = response .double_activated .and_then(|id| widget_to_sketch.get(&id).copied()); let extrude_activated = response .double_activated .and_then(|id| widget_to_extrude.get(&id).copied()); - let sketch_rename = response - .rename_committed - .as_ref() - .and_then(|RenameCommit { id, text }| { - widget_to_sketch - .get(id) - .copied() - .map(|sketch_id| SketchRenameRequest { - id: sketch_id, - label: text.clone(), - }) - }); - let extrude_rename = - response - .rename_committed - .as_ref() - .and_then(|RenameCommit { id, text }| { - widget_to_extrude - .get(id) - .copied() - .map(|extrude_id| ExtrudeRenameRequest { - id: extrude_id, - label: text.clone(), - }) - }); + let (sketch_rename, extrude_rename) = resolve_renames( + response.rename_committed.as_ref(), + &widget_to_sketch, + &widget_to_extrude, + ); + let context_menu = + resolve_context_menu_target(response.context_menu, &widget_to_sketch, &widget_to_extrude); FeatureTreeOutcome { double_activated: response.double_activated, sketch_activated, sketch_rename, extrude_activated, extrude_rename, + context_menu, + reorder, + rollback, + part_activated: response.activated == Some(part_id), + } +} + +fn widget_feature( + document: &Document, + widget: WidgetId, + widget_to_sketch: &BTreeMap, + widget_to_extrude: &BTreeMap, +) -> Option { + let tree = document.feature_tree(); + widget_to_sketch + .get(&widget) + .and_then(|sketch_id| tree.feature_of_sketch(*sketch_id)) + .or_else(|| { + widget_to_extrude + .get(&widget) + .and_then(|extrude_id| tree.feature_of_extrude(*extrude_id)) + }) +} + +fn drop_to_reorder( + document: &Document, + widget_to_sketch: &BTreeMap, + widget_to_extrude: &BTreeMap, + source: WidgetId, + target: DropTarget, +) -> Option { + let moved = widget_feature(document, source, widget_to_sketch, widget_to_extrude)?; + let anchor = widget_feature(document, target.anchor, widget_to_sketch, widget_to_extrude)?; + let before = match target.placement { + DropPlacement::Before => true, + DropPlacement::After => false, + }; + document + .reorder_is_legal(moved, anchor, before) + .then_some(FeatureReorder { + moved, + anchor, + before, + }) +} + +fn feature_widget_id( + document: &Document, + part_id: WidgetId, + feature: FeatureId, +) -> Option { + match document.feature_tree().node(feature)? { + FeatureNode::Sketch(sketch_id) => Some(sketch_widget_id(part_id, sketch_id)), + FeatureNode::Extrude(extrude_id) => Some(extrude_widget_id(part_id, extrude_id)), + _ => None, + } +} + +fn rollback_stops(document: &Document, part_id: WidgetId) -> Vec { + let consumed = consumed_sketches(document); + document + .feature_tree() + .iter() + .filter_map(|(_, node)| match node { + FeatureNode::Extrude(extrude_id) => Some(extrude_widget_id(part_id, extrude_id)), + FeatureNode::Sketch(sketch_id) if !consumed.contains(&sketch_id) => { + Some(sketch_widget_id(part_id, sketch_id)) + } + _ => None, + }) + .collect() +} + +fn current_rollback_target(document: &Document, part_id: WidgetId) -> RollbackTarget { + match document.rollback() { + RollbackMarker::AtEnd => RollbackTarget::AtEnd, + RollbackMarker::Above(feature) => feature_widget_id(document, part_id, feature) + .map_or(RollbackTarget::AtEnd, RollbackTarget::Above), + } +} + +fn resolve_rollback_change( + document: &Document, + widget_to_sketch: &BTreeMap, + widget_to_extrude: &BTreeMap, + target: RollbackTarget, +) -> Option { + match target { + RollbackTarget::AtEnd => Some(RollbackChange::ToEnd), + RollbackTarget::Above(widget) => { + widget_feature(document, widget, widget_to_sketch, widget_to_extrude) + .map(RollbackChange::ToFeature) + } + } +} + +fn resolve_context_menu_target( + request: Option, + widget_to_sketch: &BTreeMap, + widget_to_extrude: &BTreeMap, +) -> Option<(FeatureTarget, LayoutPos)> { + let ContextMenuRequest { target, at } = request?; + widget_to_sketch + .get(&target) + .map(|sketch_id| FeatureTarget::Sketch(*sketch_id)) + .or_else(|| { + widget_to_extrude + .get(&target) + .map(|extrude_id| FeatureTarget::Extrude(*extrude_id)) + }) + .map(|feature_target| (feature_target, at)) +} + +struct FeatureMenuIds { + root: WidgetId, + edit_feature: WidgetId, + edit_sketch: WidgetId, + suppress: WidgetId, + unsuppress: WidgetId, + rollback: WidgetId, + delete: WidgetId, + relationships: WidgetId, +} + +impl FeatureMenuIds { + fn new(base: WidgetId) -> Self { + let root = base.child(WidgetKey::new("ctxmenu")); + Self { + edit_feature: root.child(WidgetKey::new("edit_feature")), + edit_sketch: root.child(WidgetKey::new("edit_sketch")), + suppress: root.child(WidgetKey::new("suppress")), + unsuppress: root.child(WidgetKey::new("unsuppress")), + rollback: root.child(WidgetKey::new("rollback")), + delete: root.child(WidgetKey::new("delete")), + relationships: root.child(WidgetKey::new("relationships")), + root, + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum FeatureMenuOutcome { + EditSketch(SketchId), + EditExtrude(ExtrudeId), + ShowRelationships(FeatureTarget), + Command(FeatureCommand), +} + +fn feature_id_of(document: &Document, target: FeatureTarget) -> Option { + let tree = document.feature_tree(); + match target { + FeatureTarget::Sketch(sketch_id) => tree.feature_of_sketch(sketch_id), + FeatureTarget::Extrude(extrude_id) => tree.feature_of_extrude(extrude_id), + } +} + +fn feature_target_sketch(document: &Document, target: FeatureTarget) -> Option { + match target { + FeatureTarget::Sketch(sketch_id) => Some(sketch_id), + FeatureTarget::Extrude(extrude_id) => source_sketch_of_extrude(document, extrude_id), } } +fn feature_menu_items( + ids: &FeatureMenuIds, + target: FeatureTarget, + suppressed: bool, + has_sketch: bool, +) -> Vec { + let action = |id, label| MenuItem::Action { + id, + label, + shortcut: None, + disabled: false, + }; + let edit_feature = matches!(target, FeatureTarget::Extrude(_)) + .then(|| action(ids.edit_feature, strings::FEATURE_CTX_EDIT_FEATURE)); + let edit_sketch = has_sketch.then(|| action(ids.edit_sketch, strings::FEATURE_CTX_EDIT_SKETCH)); + let suppression = if suppressed { + action(ids.unsuppress, strings::FEATURE_CTX_UNSUPPRESS) + } else { + action(ids.suppress, strings::FEATURE_CTX_SUPPRESS) + }; + edit_feature + .into_iter() + .chain(edit_sketch) + .chain([ + suppression, + action(ids.rollback, strings::FEATURE_CTX_ROLLBACK), + MenuItem::Separator, + action(ids.delete, strings::FEATURE_CTX_DELETE), + MenuItem::Separator, + action(ids.relationships, strings::FEATURE_CTX_RELATIONSHIPS), + ]) + .collect() +} + +fn feature_menu_outcome_for( + ids: &FeatureMenuIds, + activated: WidgetId, + target: FeatureTarget, + document: &Document, +) -> Option { + let feature = feature_id_of(document, target)?; + if activated == ids.edit_feature { + match target { + FeatureTarget::Extrude(extrude_id) => Some(FeatureMenuOutcome::EditExtrude(extrude_id)), + FeatureTarget::Sketch(_) => None, + } + } else if activated == ids.edit_sketch { + feature_target_sketch(document, target).map(FeatureMenuOutcome::EditSketch) + } else if activated == ids.suppress { + Some(FeatureMenuOutcome::Command(FeatureCommand::Suppress( + feature, + ))) + } else if activated == ids.unsuppress { + Some(FeatureMenuOutcome::Command(FeatureCommand::Unsuppress( + feature, + ))) + } else if activated == ids.rollback { + Some(FeatureMenuOutcome::Command(FeatureCommand::RollbackToHere( + feature, + ))) + } else if activated == ids.delete { + Some(FeatureMenuOutcome::Command(FeatureCommand::Delete(target))) + } else if activated == ids.relationships { + Some(FeatureMenuOutcome::ShowRelationships(target)) + } else { + None + } +} + +fn render_feature_context_menu( + ctx: &mut FrameCtx<'_>, + base_id: WidgetId, + state: &mut ShellState, + document: &Document, + paints: &mut Vec, +) -> Option { + let open = state.feature_menu.open?; + let target = open.target; + let ids = FeatureMenuIds::new(base_id); + let suppressed = feature_id_of(document, target) + .is_some_and(|feature| document.suppression_state(feature).is_suppressed()); + let has_sketch = feature_target_sketch(document, target).is_some(); + let items = feature_menu_items(&ids, target, suppressed, has_sketch); + let response = show_context_menu( + ctx, + ContextMenu::at_cursor( + ids.root, + open.anchor, + strings::FEATURE_TREE_LABEL, + &items, + &mut state.feature_menu.menu, + ), + ); + paints.extend(response.paint); + let outcome = response + .activated + .and_then(|id| feature_menu_outcome_for(&ids, id, target, document)); + let pressed_outside = ctx.input.buttons_pressed.contains(PointerButton::Primary) + && ctx + .input + .pointer + .is_some_and(|sample| !response.rect.contains(sample.position)); + if outcome.is_some() || response.close || pressed_outside { + state.feature_menu.open = None; + state.feature_menu.menu = MenuState::default(); + } + outcome +} + +const REL_ROW_PX: f32 = 22.0; +const REL_PANEL_WIDTH_PX: f32 = 280.0; +const REL_PAD_PX: f32 = 10.0; + +#[derive(Copy, Clone)] +enum RelTone { + Heading, + Item, + Empty, +} + +#[derive(Clone)] +struct RelRow { + text: LabelText, + indent: f32, + tone: RelTone, +} + +fn feature_label(document: &Document, feature: FeatureId) -> Option { + match document.feature_tree().node(feature)? { + FeatureNode::Sketch(sketch_id) => { + Some(document.sketch_label(sketch_id).unwrap_or("").to_owned()) + } + FeatureNode::Extrude(extrude_id) => { + Some(document.extrude_label(extrude_id).unwrap_or("").to_owned()) + } + _ => None, + } +} + +fn relationship_section( + document: &Document, + header: StringKey, + features: &[FeatureId], +) -> Vec { + let head = RelRow { + text: LabelText::Key(header), + indent: REL_PAD_PX, + tone: RelTone::Heading, + }; + let items: Vec = features + .iter() + .filter_map(|feature| feature_label(document, *feature)) + .map(|label| RelRow { + text: LabelText::Owned(label), + indent: REL_PAD_PX + 12.0, + tone: RelTone::Item, + }) + .collect(); + let body = if items.is_empty() { + vec![RelRow { + text: LabelText::Key(strings::FEATURE_REL_NONE), + indent: REL_PAD_PX + 12.0, + tone: RelTone::Empty, + }] + } else { + items + }; + std::iter::once(head).chain(body).collect() +} + +fn relationship_rows(document: &Document, feature: FeatureId) -> Vec { + let tree = document.feature_tree(); + let parents = tree.parents(feature); + let children = tree.children(feature); + relationship_section(document, strings::FEATURE_REL_PARENTS, &parents) + .into_iter() + .chain(relationship_section( + document, + strings::FEATURE_REL_CHILDREN, + &children, + )) + .collect() +} + +fn relationships_panel_rect(viewport: LayoutRect, content_height: f32) -> LayoutRect { + let height = REL_PAD_PX * 2.0 + REL_ROW_PX + content_height; + let x = viewport.origin.x.value() + (viewport.size.width.value() - REL_PANEL_WIDTH_PX) / 2.0; + let y = viewport.origin.y.value() + (viewport.size.height.value() - height) / 2.0; + LayoutRect::new( + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), + LayoutSize::new(LayoutPx::new(REL_PANEL_WIDTH_PX), LayoutPx::new(height)), + ) +} + +fn relationships_close_rect(panel: LayoutRect) -> LayoutRect { + let width = 56.0; + LayoutRect::new( + LayoutPos::new( + LayoutPx::new(panel.origin.x.value() + panel.size.width.value() - width - REL_PAD_PX), + LayoutPx::new(panel.origin.y.value() + REL_PAD_PX), + ), + LayoutSize::new(LayoutPx::new(width), LayoutPx::new(REL_ROW_PX)), + ) +} + +fn rel_row_paint(ctx: &FrameCtx<'_>, body: LayoutRect, row: &RelRow, top: f32) -> WidgetPaint { + let rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(body.origin.x.value() + row.indent), + LayoutPx::new(top), + ), + LayoutSize::new( + LayoutPx::saturating_nonneg(body.size.width.value() - row.indent - REL_PAD_PX), + LayoutPx::new(REL_ROW_PX), + ), + ); + let (color, role) = match row.tone { + RelTone::Heading => ( + ctx.theme().colors.text_secondary(), + ctx.theme().typography.label, + ), + RelTone::Item => ( + ctx.theme().colors.text_primary(), + ctx.theme().typography.body, + ), + RelTone::Empty => ( + ctx.theme().colors.text_disabled(), + ctx.theme().typography.body, + ), + }; + WidgetPaint::AlignedLabel { + rect, + text: row.text.clone(), + color, + role, + align: HorizontalAlign::Start, + } +} + +fn render_relationships_panel( + ctx: &mut FrameCtx<'_>, + base_id: WidgetId, + viewport: LayoutRect, + target: FeatureTarget, + document: &Document, + paints: &mut Vec, +) -> bool { + let Some(feature) = feature_id_of(document, target) else { + return true; + }; + let rows = relationship_rows(document, feature); + let content_height = rows.iter().fold(0.0_f32, |acc, _| acc + REL_ROW_PX); + let rect = relationships_panel_rect(viewport, content_height); + let panel_id = base_id.child(WidgetKey::new("relationships")); + let mut panel_state = PanelState::open(); + let response = show_panel( + ctx, + Panel::new(panel_id, rect, &mut panel_state).variant(PanelVariant::Card), + ); + paints.extend(response.paint); + let Some(body) = response.body_rect else { + return false; + }; + paints.push(WidgetPaint::AlignedLabel { + rect: LayoutRect::new( + LayoutPos::new( + LayoutPx::new(body.origin.x.value() + REL_PAD_PX), + LayoutPx::new(body.origin.y.value() + REL_PAD_PX), + ), + LayoutSize::new( + LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * REL_PAD_PX), + LayoutPx::new(REL_ROW_PX), + ), + ), + text: LabelText::Key(strings::FEATURE_CTX_RELATIONSHIPS), + color: ctx.theme().colors.text_primary(), + role: ctx.theme().typography.title, + align: HorizontalAlign::Start, + }); + let start_y = body.origin.y.value() + REL_PAD_PX + REL_ROW_PX; + paints.extend(rows.iter().scan(start_y, |top, row| { + let current = *top; + *top += REL_ROW_PX; + Some(rel_row_paint(ctx, body, row, current)) + })); + let close_id = panel_id.child(WidgetKey::new("close")); + let close_rect = relationships_close_rect(rect); + let close = ctx.interact( + InteractDeclaration::new(close_id, close_rect, Sense::INTERACTIVE) + .a11y(AccessNode::new(Role::Button).with_label(strings::FEATURE_REL_CLOSE)), + ); + paints.push(WidgetPaint::AlignedLabel { + rect: close_rect, + text: LabelText::Key(strings::FEATURE_REL_CLOSE), + color: if close.hover() { + ctx.theme().colors.text_primary() + } else { + ctx.theme().colors.text_secondary() + }, + role: ctx.theme().typography.label, + align: HorizontalAlign::End, + }); + let pressed_outside = ctx.input.buttons_pressed.contains(PointerButton::Primary) + && ctx + .input + .pointer + .is_some_and(|sample| !rect.contains(sample.position)); + close.click() || pressed_outside +} + +struct WhatsWrongOutcome { + close: bool, + reattach: Option, +} + +fn whats_wrong_row_paint( + ctx: &mut FrameCtx<'_>, + panel_id: WidgetId, + body: LayoutRect, + entry: &WhatsWrong, + top: f32, + paints: &mut Vec, +) -> Option { + let message_color = if entry.is_error { + ctx.theme().colors.danger.step(Step12::SOLID) + } else { + ctx.theme().colors.warning.step(Step12::SOLID) + }; + let message_rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(body.origin.x.value() + REL_PAD_PX), + LayoutPx::new(top), + ), + LayoutSize::new( + LayoutPx::saturating_nonneg(body.size.width.value() * 0.55 - REL_PAD_PX), + LayoutPx::new(REL_ROW_PX), + ), + ); + paints.push(WidgetPaint::AlignedLabel { + rect: message_rect, + text: LabelText::Key(entry.message), + 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(top), + ), + LayoutSize::new( + LayoutPx::saturating_nonneg(body.size.width.value() * 0.45 - REL_PAD_PX), + LayoutPx::new(REL_ROW_PX), + ), + ); + let Some(sketch_id) = entry.reattach else { + paints.push(WidgetPaint::AlignedLabel { + rect: right_rect, + text: LabelText::Owned(entry.label.clone()), + color: ctx.theme().colors.text_secondary(), + role: ctx.theme().typography.caption, + align: HorizontalAlign::End, + }); + return None; + }; + let reattach_id = panel_id.child_indexed(WidgetKey::new("reattach"), sketch_id.as_u64()); + let interaction = ctx.interact( + InteractDeclaration::new(reattach_id, right_rect, Sense::INTERACTIVE) + .a11y(AccessNode::new(Role::Button).with_label(strings::WHATS_WRONG_REATTACH)), + ); + paints.push(WidgetPaint::AlignedLabel { + rect: right_rect, + text: LabelText::Key(strings::WHATS_WRONG_REATTACH), + color: ctx.theme().colors.accent.step(Step12::SOLID), + role: ctx.theme().typography.label, + align: HorizontalAlign::End, + }); + interaction.click().then_some(sketch_id) +} + +fn render_whats_wrong_panel( + ctx: &mut FrameCtx<'_>, + base_id: WidgetId, + viewport: LayoutRect, + entries: &[WhatsWrong], + paints: &mut Vec, +) -> WhatsWrongOutcome { + let line_count = entries.len().max(1); + let content_height = (0..line_count).fold(0.0_f32, |height, _| height + REL_ROW_PX); + let rect = relationships_panel_rect(viewport, content_height); + let panel_id = base_id.child(WidgetKey::new("whats_wrong")); + let mut panel_state = PanelState::open(); + let response = show_panel( + ctx, + Panel::new(panel_id, rect, &mut panel_state).variant(PanelVariant::Card), + ); + paints.extend(response.paint); + let Some(body) = response.body_rect else { + return WhatsWrongOutcome { + close: false, + reattach: None, + }; + }; + paints.push(WidgetPaint::AlignedLabel { + rect: LayoutRect::new( + LayoutPos::new( + LayoutPx::new(body.origin.x.value() + REL_PAD_PX), + LayoutPx::new(body.origin.y.value() + REL_PAD_PX), + ), + LayoutSize::new( + LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * REL_PAD_PX), + LayoutPx::new(REL_ROW_PX), + ), + ), + text: LabelText::Key(strings::WHATS_WRONG_TITLE), + color: ctx.theme().colors.text_primary(), + role: ctx.theme().typography.title, + align: HorizontalAlign::Start, + }); + let start_y = body.origin.y.value() + REL_PAD_PX + REL_ROW_PX; + let reattach = if entries.is_empty() { + paints.push(WidgetPaint::AlignedLabel { + rect: LayoutRect::new( + LayoutPos::new( + LayoutPx::new(body.origin.x.value() + REL_PAD_PX), + LayoutPx::new(start_y), + ), + LayoutSize::new( + LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * REL_PAD_PX), + LayoutPx::new(REL_ROW_PX), + ), + ), + text: LabelText::Key(strings::WHATS_WRONG_NONE), + color: ctx.theme().colors.text_disabled(), + role: ctx.theme().typography.body, + align: HorizontalAlign::Start, + }); + None + } else { + entries + .iter() + .enumerate() + .fold(None, |acc, (index, entry)| { + let top = start_y + index_offset(index); + acc.or(whats_wrong_row_paint( + ctx, panel_id, body, entry, top, paints, + )) + }) + }; + let close_id = panel_id.child(WidgetKey::new("close")); + let close_rect = relationships_close_rect(rect); + let close = ctx.interact( + InteractDeclaration::new(close_id, close_rect, Sense::INTERACTIVE) + .a11y(AccessNode::new(Role::Button).with_label(strings::FEATURE_REL_CLOSE)), + ); + paints.push(WidgetPaint::AlignedLabel { + rect: close_rect, + text: LabelText::Key(strings::FEATURE_REL_CLOSE), + color: if close.hover() { + ctx.theme().colors.text_primary() + } else { + ctx.theme().colors.text_secondary() + }, + role: ctx.theme().typography.label, + align: HorizontalAlign::End, + }); + let pressed_outside = ctx.input.buttons_pressed.contains(PointerButton::Primary) + && ctx + .input + .pointer + .is_some_and(|sample| !rect.contains(sample.position)); + WhatsWrongOutcome { + close: close.click() || pressed_outside, + reattach, + } +} + +fn index_offset(index: usize) -> f32 { + f32::from(u16::try_from(index).unwrap_or(u16::MAX)) * REL_ROW_PX +} + #[derive(Copy, Clone)] struct PropertyState<'a> { mode: &'a Mode, @@ -3610,6 +4538,9 @@ mod tests { None, None, &mut ViewUi::default(), + &BTreeMap::new(), + false, + &[], ) } @@ -3648,6 +4579,311 @@ 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, + } + } + + 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())); + let extrude_id = document.commit_extrude(blind_extrude(sketch_id)); + (sketch_id, extrude_id) + } + + fn test_part_id() -> WidgetId { + use std::num::NonZeroU64; + WidgetId::from_raw(NonZeroU64::MIN).child(WidgetKey::new("part")) + } + + #[test] + fn feature_rows_nest_sketch_under_consuming_extrude() { + let mut document = sample_document(); + let (sketch_a, extrude_a) = push_plain_chain(&mut document, "Sketch1"); + let (sketch_b, extrude_b) = push_plain_chain(&mut document, "Sketch2"); + let part_id = test_part_id(); + let rows = feature_rows(&document, part_id, &BTreeMap::new()); + assert_eq!( + rows.len(), + 2, + "two extrudes are the only top-level features" + ); + let assert_chain = |node: &TreeNode, extrude_id: ExtrudeId, sketch_id: SketchId| { + assert_eq!(node.id, extrude_widget_id(part_id, extrude_id)); + assert_eq!(node.children.len(), 1, "the extrude absorbs its sketch"); + assert_eq!(node.children[0].id, sketch_widget_id(part_id, sketch_id)); + }; + assert_chain(&rows[0], extrude_a, sketch_a); + assert_chain(&rows[1], extrude_b, sketch_b); + } + + #[test] + fn feature_rows_keep_unconsumed_sketch_at_top_level() { + let mut document = sample_document(); + let sketch_id = document.allocate_sketch(); + document.insert_sketch( + sketch_id, + "Sketch1".to_owned(), + Sketch::new(Plane::Xy.basis()), + ); + let part_id = test_part_id(); + let rows = feature_rows(&document, part_id, &BTreeMap::new()); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, sketch_widget_id(part_id, sketch_id)); + assert!( + rows[0].children.is_empty(), + "an unconsumed sketch has no nested feature" + ); + } + + fn menu_labels(items: &[MenuItem]) -> Vec { + items + .iter() + .filter_map(|item| match item { + MenuItem::Action { label, .. } => Some(*label), + _ => None, + }) + .collect() + } + + #[test] + fn feature_menu_items_for_extrude_include_edit_feature_and_suppress() { + let ids = FeatureMenuIds::new(test_part_id()); + let items = feature_menu_items( + &ids, + FeatureTarget::Extrude(ExtrudeId::default()), + false, + true, + ); + let labels = menu_labels(&items); + assert!(labels.contains(&strings::FEATURE_CTX_EDIT_FEATURE)); + assert!(labels.contains(&strings::FEATURE_CTX_EDIT_SKETCH)); + assert!(labels.contains(&strings::FEATURE_CTX_SUPPRESS)); + assert!(!labels.contains(&strings::FEATURE_CTX_UNSUPPRESS)); + assert!(labels.contains(&strings::FEATURE_CTX_DELETE)); + assert!(labels.contains(&strings::FEATURE_CTX_RELATIONSHIPS)); + } + + #[test] + fn feature_menu_items_for_suppressed_sketch_omit_edit_feature_and_offer_unsuppress() { + let ids = FeatureMenuIds::new(test_part_id()); + let items = + feature_menu_items(&ids, FeatureTarget::Sketch(SketchId::default()), true, true); + let labels = menu_labels(&items); + assert!(!labels.contains(&strings::FEATURE_CTX_EDIT_FEATURE)); + assert!(labels.contains(&strings::FEATURE_CTX_UNSUPPRESS)); + assert!(!labels.contains(&strings::FEATURE_CTX_SUPPRESS)); + } + + #[test] + fn feature_menu_outcome_maps_ids_to_commands() { + let mut document = sample_document(); + let (sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); + let target = FeatureTarget::Extrude(extrude); + let ids = FeatureMenuIds::new(test_part_id()); + let Some(feature) = feature_id_of(&document, target) else { + panic!("the extrude has a feature id"); + }; + assert_eq!( + feature_menu_outcome_for(&ids, ids.suppress, target, &document), + Some(FeatureMenuOutcome::Command(FeatureCommand::Suppress( + feature + ))), + ); + assert_eq!( + feature_menu_outcome_for(&ids, ids.delete, target, &document), + Some(FeatureMenuOutcome::Command(FeatureCommand::Delete(target))), + ); + assert_eq!( + feature_menu_outcome_for(&ids, ids.edit_sketch, target, &document), + Some(FeatureMenuOutcome::EditSketch(sketch)), + "edit sketch on an extrude resolves to its source sketch", + ); + assert_eq!( + feature_menu_outcome_for(&ids, ids.relationships, target, &document), + Some(FeatureMenuOutcome::ShowRelationships(target)), + ); + } + + #[test] + fn rollback_target_and_change_round_trip_through_the_marker() { + let mut document = sample_document(); + let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); + let part_id = test_part_id(); + assert_eq!( + current_rollback_target(&document, part_id), + RollbackTarget::AtEnd, + "a fresh document is rolled to the end", + ); + let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the extrude resolves to a feature id"); + }; + document.roll_to_here(feature); + let widget = extrude_widget_id(part_id, extrude); + assert_eq!( + current_rollback_target(&document, part_id), + RollbackTarget::Above(widget), + ); + let w2s: BTreeMap = sketch_widget_ids(&document, part_id) + .into_iter() + .map(|(s, w)| (w, s)) + .collect(); + let w2e: BTreeMap = extrude_widget_ids(&document, part_id) + .into_iter() + .map(|(e, w)| (w, e)) + .collect(); + assert_eq!( + resolve_rollback_change(&document, &w2s, &w2e, RollbackTarget::AtEnd), + Some(RollbackChange::ToEnd), + ); + assert_eq!( + resolve_rollback_change(&document, &w2s, &w2e, RollbackTarget::Above(widget)), + Some(RollbackChange::ToFeature(feature)), + ); + } + + #[test] + fn rolled_back_feature_node_is_disabled() { + let mut document = sample_document(); + let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); + let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the extrude resolves to a feature id"); + }; + document.roll_to_here(feature); + let part_id = test_part_id(); + let rows = feature_rows(&document, part_id, &BTreeMap::new()); + assert!( + rows[0].disabled, + "a rolled-back feature is greyed and inert" + ); + } + + #[test] + fn extrude_node_shows_its_own_error_badge() { + let mut document = sample_document(); + let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); + let part_id = test_part_id(); + let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the extrude resolves to a feature id"); + }; + let badges = BTreeMap::from([(feature, TreeBadge::Error)]); + let rows = feature_rows(&document, part_id, &badges); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].badge, Some(TreeBadge::Error)); + } + + #[test] + fn extrude_node_rolls_up_a_warning_from_its_nested_sketch() { + let mut document = sample_document(); + let (sketch, _extrude) = push_plain_chain(&mut document, "Sketch1"); + let part_id = test_part_id(); + let Some(sketch_feature) = document.feature_tree().feature_of_sketch(sketch) else { + panic!("the sketch resolves to a feature id"); + }; + let badges = BTreeMap::from([(sketch_feature, TreeBadge::Warning)]); + let rows = feature_rows(&document, part_id, &badges); + assert_eq!( + rows[0].badge, + Some(TreeBadge::Warning), + "the extrude rolls up its nested sketch's warning", + ); + assert_eq!(rows[0].children[0].badge, Some(TreeBadge::Warning)); + } + + #[test] + fn extrude_node_error_outranks_a_nested_warning() { + let mut document = sample_document(); + let (sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); + let part_id = test_part_id(); + let Some(sketch_feature) = document.feature_tree().feature_of_sketch(sketch) else { + panic!("the sketch resolves to a feature id"); + }; + let Some(extrude_feature) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the extrude resolves to a feature id"); + }; + let badges = BTreeMap::from([ + (extrude_feature, TreeBadge::Warning), + (sketch_feature, TreeBadge::Error), + ]); + let rows = feature_rows(&document, part_id, &badges); + assert_eq!( + rows[0].badge, + Some(TreeBadge::Error), + "an error on the nested sketch outranks the extrude's own warning", + ); + } + + #[test] + fn drop_to_reorder_accepts_legal_move_and_rejects_illegal() { + let mut document = sample_document(); + let (sketch_a, extrude_a) = push_plain_chain(&mut document, "Sketch1"); + let (_sketch_b, extrude_b) = push_plain_chain(&mut document, "Sketch2"); + let part_id = test_part_id(); + let w2s: BTreeMap = sketch_widget_ids(&document, part_id) + .into_iter() + .map(|(s, w)| (w, s)) + .collect(); + let w2e: BTreeMap = extrude_widget_ids(&document, part_id) + .into_iter() + .map(|(e, w)| (w, e)) + .collect(); + let src = extrude_widget_id(part_id, extrude_a); + let after_b = DropTarget { + anchor: extrude_widget_id(part_id, extrude_b), + placement: DropPlacement::After, + }; + let Some(reorder) = drop_to_reorder(&document, &w2s, &w2e, src, after_b) else { + panic!("moving the first extrude after the second is legal"); + }; + assert!(!reorder.before); + let before_own_sketch = DropTarget { + anchor: sketch_widget_id(part_id, sketch_a), + placement: DropPlacement::Before, + }; + assert!( + drop_to_reorder(&document, &w2s, &w2e, src, before_own_sketch).is_none(), + "an extrude cannot move before its own sketch", + ); + } + + #[test] + fn relationship_rows_list_the_extrude_parent_sketch_and_no_children() { + let mut document = sample_document(); + let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); + let Some(feature) = feature_id_of(&document, FeatureTarget::Extrude(extrude)) else { + panic!("the extrude has a feature id"); + }; + let rows = relationship_rows(&document, feature); + let owned: Vec<&str> = rows + .iter() + .filter_map(|row| match &row.text { + LabelText::Owned(label) => Some(label.as_str()), + LabelText::Key(_) => None, + }) + .collect(); + assert_eq!( + owned, + vec!["Sketch1"], + "the only parent is the source sketch" + ); + let has_none = rows.iter().any( + |row| matches!(&row.text, LabelText::Key(key) if *key == strings::FEATURE_REL_NONE), + ); + assert!(has_none, "a childless extrude shows None under children"); + } + #[test] fn tools_options_menu_id_maps_to_open_settings_action() { let shell = Shell::new(); @@ -4303,6 +5539,9 @@ mod tests { None, None, &mut ViewUi::default(), + &BTreeMap::new(), + false, + &[], ); let any_smart_dim_label = frame.paints.iter().any(|p| { matches!( @@ -4756,6 +5995,9 @@ mod tests { None, None, &mut ViewUi::default(), + &BTreeMap::new(), + false, + &[], ) }; *prev = bone_ui::hit_test::resolve(prev, &hits, snap, focus.focused()); @@ -5459,6 +6701,9 @@ mod tests { None, None, &mut ViewUi::default(), + &BTreeMap::new(), + false, + &[], ); } (shell, a11y, focus) @@ -5932,6 +7177,9 @@ mod tests { camera3, None, &mut view, + &BTreeMap::new(), + false, + &[], ) }; let update = a11y.build(strings, focus.focused()); 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 d2ff782..c96f239 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 @@ -11,7 +11,6 @@ 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,3 +26,5 @@ action=28 chord=Space scope=Global 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 diff --git a/crates/bone-app/src/status_badge.rs b/crates/bone-app/src/status_badge.rs index c9da327..026bbe7 100644 --- a/crates/bone-app/src/status_badge.rs +++ b/crates/bone-app/src/status_badge.rs @@ -3,7 +3,8 @@ use bone_document::{ SketchStatusReport, TruckGap, }; use bone_types::{ - BrepSlot, SketchDimensionId, SketchEntityId, SketchItemId, SketchRelationId, SketchStatus, + BrepSlot, RebuildError, SketchDimensionId, SketchEntityId, SketchItemId, SketchRelationId, + SketchStatus, }; use bone_ui::frame::FrameCtx; use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; @@ -67,9 +68,10 @@ impl<'a> ExtrudeStatus<'a> { 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 { .. }, - )) => (strings::STATUS_EXTRUDE_DANGLING, cad.sketch_dangling), + 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), } } @@ -78,6 +80,16 @@ fn extrude_panel_line(error: &ExtrudeError, strings_table: &StringTable) -> Labe 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)), + } +} + +fn rebuild_error_key(error: RebuildError) -> StringKey { + match error { + RebuildError::DanglingReference(_) => strings::EXTRUDE_PANEL_DANGLING_REFERENCE, + RebuildError::NonPlanarSketchTarget => strings::EXTRUDE_PANEL_NON_PLANAR_TARGET, + RebuildError::UpstreamUnresolved => strings::EXTRUDE_PANEL_UPSTREAM_UNRESOLVED, + RebuildError::Build(_) => strings::EXTRUDE_PANEL_INTERNAL, } } diff --git a/crates/bone-app/src/strings.rs b/crates/bone-app/src/strings.rs index 94469a3..bfeaaeb 100644 --- a/crates/bone-app/src/strings.rs +++ b/crates/bone-app/src/strings.rs @@ -105,6 +105,26 @@ pub const FEATURE_PLANE_XY: StringKey = StringKey::new("feature.plane.xy"); pub const FEATURE_PLANE_YZ: StringKey = StringKey::new("feature.plane.yz"); pub const FEATURE_PLANE_ZX: StringKey = StringKey::new("feature.plane.zx"); +pub const FEATURE_CTX_EDIT_FEATURE: StringKey = StringKey::new("feature.ctx.edit_feature"); +pub const FEATURE_CTX_EDIT_SKETCH: StringKey = StringKey::new("feature.ctx.edit_sketch"); +pub const FEATURE_CTX_SUPPRESS: StringKey = StringKey::new("feature.ctx.suppress"); +pub const FEATURE_CTX_UNSUPPRESS: StringKey = StringKey::new("feature.ctx.unsuppress"); +pub const FEATURE_CTX_ROLLBACK: StringKey = StringKey::new("feature.ctx.rollback"); +pub const FEATURE_CTX_DELETE: StringKey = StringKey::new("feature.ctx.delete"); +pub const FEATURE_CTX_RELATIONSHIPS: StringKey = StringKey::new("feature.ctx.relationships"); +pub const FEATURE_REL_PARENTS: StringKey = StringKey::new("feature.rel.parents"); +pub const FEATURE_REL_CHILDREN: StringKey = StringKey::new("feature.rel.children"); +pub const FEATURE_REL_NONE: StringKey = StringKey::new("feature.rel.none"); +pub const FEATURE_REL_CLOSE: StringKey = StringKey::new("feature.rel.close"); +pub const WHATS_WRONG_TITLE: StringKey = StringKey::new("whats_wrong.title"); +pub const WHATS_WRONG_NONE: StringKey = StringKey::new("whats_wrong.none"); +pub const WHATS_WRONG_DANGLING: StringKey = StringKey::new("whats_wrong.dangling"); +pub const WHATS_WRONG_NON_PLANAR: StringKey = StringKey::new("whats_wrong.non_planar"); +pub const WHATS_WRONG_UPSTREAM: StringKey = StringKey::new("whats_wrong.upstream"); +pub const WHATS_WRONG_BUILD_FAILED: StringKey = StringKey::new("whats_wrong.build_failed"); +pub const WHATS_WRONG_REPAIRED: StringKey = StringKey::new("whats_wrong.repaired"); +pub const WHATS_WRONG_REATTACH: StringKey = StringKey::new("whats_wrong.reattach"); + pub const PROPERTY_PANE_LABEL: StringKey = StringKey::new("shell.property_pane"); pub const LEFT_PANE_LABEL: StringKey = StringKey::new("shell.left_pane"); pub const LEFT_PANE_TAB_CONFIGURATION: StringKey = @@ -241,6 +261,10 @@ pub const HOTKEY_LABEL_DELETE_SELECTION: StringKey = pub const HOTKEY_LABEL_ZOOM_FIT: StringKey = StringKey::new("hotkey.label.zoom_fit"); pub const HOTKEY_LABEL_SHORTCUT_BAR: StringKey = StringKey::new("hotkey.label.shortcut_bar"); pub const HOTKEY_LABEL_QUIT: StringKey = StringKey::new("hotkey.label.quit"); +pub const HOTKEY_LABEL_REBUILD: StringKey = StringKey::new("hotkey.label.rebuild"); +pub const HOTKEY_LABEL_FORCE_REBUILD: StringKey = StringKey::new("hotkey.label.force_rebuild"); +pub const NOTIFY_REATTACH_PICK: StringKey = StringKey::new("notify.reattach_pick"); +pub const NOTIFY_REATTACH_NON_PLANAR: StringKey = StringKey::new("notify.reattach_non_planar"); pub const NOTIFY_COMING_SOON: StringKey = StringKey::new("notify.coming_soon"); pub const NOTIFY_MIRROR_SELECTION_HINT: StringKey = StringKey::new("notify.mirror.selection_hint"); pub const NOTIFY_HOTKEY_CONFLICT: StringKey = StringKey::new("notify.hotkey.conflict"); @@ -301,6 +325,12 @@ pub const EXTRUDE_PANEL_UNSUPPORTED_DRAFT: StringKey = pub const EXTRUDE_PANEL_UNSUPPORTED_THIN: StringKey = StringKey::new("extrude.panel.unsupported.thin"); pub const EXTRUDE_PANEL_INTERNAL: StringKey = StringKey::new("extrude.panel.internal"); +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 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"); @@ -453,6 +483,28 @@ const EN_US: &[(StringKey, &str)] = &[ (FEATURE_PLANE_XY, "Front Plane"), (FEATURE_PLANE_YZ, "Right Plane"), (FEATURE_PLANE_ZX, "Top Plane"), + (FEATURE_CTX_EDIT_FEATURE, "Edit Feature"), + (FEATURE_CTX_EDIT_SKETCH, "Edit Sketch"), + (FEATURE_CTX_SUPPRESS, "Suppress"), + (FEATURE_CTX_UNSUPPRESS, "Unsuppress"), + (FEATURE_CTX_ROLLBACK, "Rollback to Here"), + (FEATURE_CTX_DELETE, "Delete"), + (FEATURE_CTX_RELATIONSHIPS, "Parent/Child Relationships"), + (FEATURE_REL_PARENTS, "Parent Features"), + (FEATURE_REL_CHILDREN, "Child Features"), + (FEATURE_REL_NONE, "None"), + (FEATURE_REL_CLOSE, "Close"), + (WHATS_WRONG_TITLE, "What's Wrong"), + (WHATS_WRONG_NONE, "No rebuild errors"), + (WHATS_WRONG_DANGLING, "Dangling reference"), + (WHATS_WRONG_NON_PLANAR, "Sketch plane is not planar"), + ( + WHATS_WRONG_UPSTREAM, + "Depends on a feature that failed to build", + ), + (WHATS_WRONG_BUILD_FAILED, "Geometry failed to build"), + (WHATS_WRONG_REPAIRED, "Reference repaired"), + (WHATS_WRONG_REATTACH, "Reattach"), (PROPERTY_PANE_LABEL, "Property Manager"), (LEFT_PANE_LABEL, "Left Pane"), (LEFT_PANE_TAB_CONFIGURATION, "Configuration Manager"), @@ -595,6 +647,13 @@ const EN_US: &[(StringKey, &str)] = &[ (HOTKEY_LABEL_ZOOM_FIT, "Zoom to Fit"), (HOTKEY_LABEL_SHORTCUT_BAR, "Shortcut Bar"), (HOTKEY_LABEL_QUIT, "Quit"), + (HOTKEY_LABEL_REBUILD, "Rebuild"), + (HOTKEY_LABEL_FORCE_REBUILD, "Force Rebuild"), + ( + NOTIFY_REATTACH_PICK, + "Pick a face to reattach the dangling reference", + ), + (NOTIFY_REATTACH_NON_PLANAR, "Pick a planar face to reattach"), (NOTIFY_COMING_SOON, "Coming soon"), ( NOTIFY_MIRROR_SELECTION_HINT, @@ -674,6 +733,18 @@ const EN_US: &[(StringKey, &str)] = &[ "Thin feature is not supported yet", ), (EXTRUDE_PANEL_INTERNAL, "Internal kernel error"), + ( + EXTRUDE_PANEL_DANGLING_REFERENCE, + "Sketch plane references a face that no longer exists", + ), + ( + EXTRUDE_PANEL_NON_PLANAR_TARGET, + "Sketch plane target face is not planar", + ), + ( + EXTRUDE_PANEL_UPSTREAM_UNRESOLVED, + "Depends on a feature that failed to build", + ), (PROPERTY_ROW_KIND, "Type"), (PROPERTY_ROW_X, "X"), (PROPERTY_ROW_Y, "Y"), @@ -820,6 +891,25 @@ const AR_XB: &[(StringKey, &str)] = &[ (FEATURE_PLANE_XY, "[!! Front Plàne !!]"), (FEATURE_PLANE_YZ, "[!! Rîght Plàne !!]"), (FEATURE_PLANE_ZX, "[!! Tôp Plàne !!]"), + (FEATURE_CTX_EDIT_FEATURE, "[!! Édit Featûre !!]"), + (FEATURE_CTX_EDIT_SKETCH, "[!! Édit Skétch !!]"), + (FEATURE_CTX_SUPPRESS, "[!! Suppréss !!]"), + (FEATURE_CTX_UNSUPPRESS, "[!! Unsuppréss !!]"), + (FEATURE_CTX_ROLLBACK, "[!! Rôllback to Hére !!]"), + (FEATURE_CTX_DELETE, "[!! Delête !!]"), + (FEATURE_CTX_RELATIONSHIPS, "[!! Parént/Chîld Relâtions !!]"), + (FEATURE_REL_PARENTS, "[!! Parént Featûres !!]"), + (FEATURE_REL_CHILDREN, "[!! Chîld Featûres !!]"), + (FEATURE_REL_NONE, "[!! Nône !!]"), + (FEATURE_REL_CLOSE, "[!! Clôse !!]"), + (WHATS_WRONG_TITLE, "[!! Whât's Wrông !!]"), + (WHATS_WRONG_NONE, "[!! Nô rebûild errôrs !!]"), + (WHATS_WRONG_DANGLING, "[!! Dânglîng reférence !!]"), + (WHATS_WRONG_NON_PLANAR, "[!! Plâne nôt plânar !!]"), + (WHATS_WRONG_UPSTREAM, "[!! Dêpends ôn fâiled featûre !!]"), + (WHATS_WRONG_BUILD_FAILED, "[!! Geômetry fâiled !!]"), + (WHATS_WRONG_REPAIRED, "[!! Reférence repâired !!]"), + (WHATS_WRONG_REATTACH, "[!! Reattâch !!]"), (PROPERTY_PANE_LABEL, "[!! Propérty Mânager !!]"), (LEFT_PANE_LABEL, "[!! Léft Pâne !!]"), (LEFT_PANE_TAB_CONFIGURATION, "[!! Cônfig Mânager !!]"), @@ -977,6 +1067,10 @@ const AR_XB: &[(StringKey, &str)] = &[ (HOTKEY_LABEL_ZOOM_FIT, "[!! Zôom to Fît !!]"), (HOTKEY_LABEL_SHORTCUT_BAR, "[!! Shortcût Bâr !!]"), (HOTKEY_LABEL_QUIT, "[!! Quît !!]"), + (HOTKEY_LABEL_REBUILD, "[!! Rebûild !!]"), + (HOTKEY_LABEL_FORCE_REBUILD, "[!! Fôrce Rebûild !!]"), + (NOTIFY_REATTACH_PICK, "[!! Pîck a fâce to reattâch !!]"), + (NOTIFY_REATTACH_NON_PLANAR, "[!! Pîck a plânar fâce !!]"), (NOTIFY_COMING_SOON, "[!! Côming sôon !!]"), ( NOTIFY_MIRROR_SELECTION_HINT, @@ -1077,6 +1171,18 @@ const AR_XB: &[(StringKey, &str)] = &[ "[!! Thîn fêature is nôt suppôrted yêt !!]", ), (EXTRUDE_PANEL_INTERNAL, "[!! Intêrnal kêrnel êrror !!]"), + ( + EXTRUDE_PANEL_DANGLING_REFERENCE, + "[!! Skêtch plâne refêrences a fâce that nô longer exîsts !!]", + ), + ( + EXTRUDE_PANEL_NON_PLANAR_TARGET, + "[!! Skêtch plâne târget fâce is nôt planâr !!]", + ), + ( + EXTRUDE_PANEL_UPSTREAM_UNRESOLVED, + "[!! Dêpends ôn a fâiled featûre !!]", + ), (PROPERTY_ROW_KIND, "[!! Týpe !!]"), (PROPERTY_ROW_X, "X"), (PROPERTY_ROW_Y, "Y"), diff --git a/crates/bone-app/tests/goldens/rebuild_downstream_iso_256.png b/crates/bone-app/tests/goldens/rebuild_downstream_iso_256.png new file mode 100644 index 0000000000000000000000000000000000000000..e9dd3cdc89f4f237ad2e19dbf56f578d6ae666ed GIT binary patch literal 18554 zcmeAS@N?(olHy`uVBq!ia0y~yU}OMc4mJh`hM1xiX$%Y=j-D=#Ar*{o4=^uKiJW%q zK& zKko^%-2>*z>*clOB9G4Pe)mj~ze-@c-Q%#l9sk&Jems=_$GGon^!}NjpX(P~?Ph_b z=&$x6Yo~6n|6Tr|+5U&~&TqG}cYfV=yW-O1`G<}jV_W}m)%rv7e?H1@tS>mSSngwi zT+QjTcgpWa?t2=&uT%c_M)_13_jOeZq50@P^WImh_esfr4U|9Yz4wZD@wYpca^G9j ziweud8-msSKS{0srS<2a{C~mp^RWyMj&we@c^26JWY*>MAG`Pej^6hyd*7`4|Nq{9 zyhX;Y0pvjms9FZ0~=zt$AKvXI))g9rINO zS{nR!6FeBb=V!gd#CPrtj$AD|3@EQbcjE>a&q#XW2^UZ)qgl%f8=Jd zc!i^xxmm>(W%+};=68?GEx&i}di~$)pN;K5UrtdAZ-kUZzw$p7>3{!JbH%;ZWc`n> z^+$eki&q?)JnzWO;^#m6%YI$al>5BGz39~w&wp>u|C_S@@2&L@&;NVl|FfPC8n^Lp z+NATI9JYINxN`RPO6joIjq0{PHEkY$yS?#jz1+tIv-2MX_Sdj{zS%SX=gj%fr1w31 zz4LM3?Vt5rFauIHMtqQa|1oaQ{oi}-qi;vse9iQKe$v0z>aoR#_@%F{acv)zkk%gRqG{`JRWQ-aN%xu~?jPCmKeWAh z|Niq2b=%I}@AkF-f4gq~J1bCn*tB`?+4cW_t^Zsa|NW}X$4>tz|2d$cu>a!6$LbaT zzyD{r_t^Fxvu4Go;Q7Zc=k1+(P3nL66d{`@j{MIi^4Cjj``IY}qi)~Z+xw<=i|c>b zF9WVHf5|`o^73-c`R_IM@t;)VkG%i)?)?Y*P|c~^t9NgI>}>x9RC-A7c-waylt>R1 z?R@foRqxR`?sktJ+PrucedGT9$Je6spUU3<`1WT!4>*DS-QLY{@73CSv-*GU@PBY- z=H)flmGA#Kd;dUr{rB?Xl9ChuRn~vfT7SsB?uq+nBmK{t=2+MF9k+ex8MC8e;(vaz zZdU!TkMxg~UXSBGpuO+W=}p_WpZ^~O$&&l8p5FgQ`$M4o*Nb}&Y41_mJzcLbC%wIU z_wG41g-y}YW4QSt#O}Q z)NMPv#q~c{t^Ygg&(HY3qK6GW%y75u5Z(X}q_uli` zd+p!r-hWRuoBespa@)Df?fz!=JU;TD-8`n;7F?v=y!lLC?mPdVWBLEs*8kvIf8em( zuY$PuU*l#cO8kh2WZA#dqjbzF)_66rWUaL3jIy7w&d*qO|P)J+?n)UjNy==)CRs5A%JwKmpOj|Nle(14mFja=%jD zzv9wl3mcn9|C^JK_kEn0cxCo1Ohmesy;D_3F3RAD#dIYWA8J<<*_jD?A}4)_=X8@coji-2WE! zqW3p9+iZWkRyXeb*7$Dw|Eu>u>a%|TXs7J$n-4Gc*Qi{NFW2?2c_jSf*80Cvb&t*K z=Kj~X9$(Ka|7)TAsoVSB-Zt)228CMXvA5fkXa9e#@^J#YeTTd4pTsM-KBm|IOaF83 z{U5&S-_AS`E#%L-pe-TVgBz8X&L`7*c^G@djIF$pBm+V9GqiQ z*|gm5ucXb-mHw%F<37u)Dpj0lmOryt?w>){l~WaA8p_&p}*pY z^3LD$s=-yly?b{5<#Y0LKhJQtZIaI4(fIq_?*98f&c;7~|No)vw5?S z|M}Or_gf{~8viea)HC%fu2;^z{#e=mlXB&mWVzF}-|rm%5>#u(#yz}|J-#`8@cx@&3 z^F#Z`3+*;D3MGErz5m&dc*ep=Rq!cx9_>!ztwXr3Y(7Gd=iW)e#TS(y}bT(c>UM#kL>jy ze%oxnyxjk@qx_eHdoEk=<<9*4WVuc2a{I?-Mw9=)Rw+E~YyJ_GQ-9W+i~svI{^RWZ zU!!C8R7|Yz#hG$q{pMJlWUo2E{$btwUve%T>b6f;+WeK=ektnH{fEn>^B-L7uVIOv zJ^Rli{eMc}((v-UZzj9eJ(s=zQTFEp{(k{}^X<-Fo?j^KUv+9SDC5-^{JP>P_xpu= z@uwr4^Xgt-{`vL(57WH8UnT!U;|!1dpzNn_@4b8ba{~WQgLQkqMa?Vx{nqB=N&m#1 zF`w?Qy>{rZ+@}D!e;e8jdX=}|`&II*?$}|uZw2qHtL4G9{Eq6*|NLg=<`q{C%O5;z ze)q(?cdm7xx7T&=|MhzRA+!HsICJ%CP;*oZ)NVKoZ;t*yqF(gyk>3ByIXOBNE5+*# z*X{lml~eJ{c;3PL|Nh!1%>Mt{Yo*PjME-|~{IweAj>-SKDF3l~|NrVWVcP%p-g|WR z{+xE(FAw+Jd%f4}u)&A@BH+rgYNG9rgZ$5w?H(ytUN)~S=IieR)q+Q4%kLfipAvih z^RoOOzvkH2_g!9ozTn>>R&cBM-yzn2>vz3uldkxpEPwLZGq(Eg-|LU(|9O^Q@a~S~ zzr{F1RR7k!d(ZaX|6N--|9hqUuexKC=l?R9_v_1YV_)I_mrG;MOYcAU`qN|kZ^|)u z_8z&t_wDWCXJ;hycK_v@_hE^8;g*uHEuPCvlqgINB@X*ECE>n+Uh*KlvYUn%Zib7``rh0T-t z?scy#ADdUi*1d7BpE3XE&H0JN#ogO0K5u`}d;f=Thlb75O#i1V{j1r6XaBgLWjO!y z3U`}1?01W}+1acAT+9E@SO4vKy<6I+`44dwH_u-GBG(~jG` z3Vf534=$VI|DTP2KCk-S!~KVFh5q!j&$;h?w!J_3d-eYB>Dk%c%WeNk+PqxppDe@v z&;0eSdFFM^*PkA*`_le%=KQ~s^X5l!um8Pk{rTwqS8o?RI#M|AUr+jnBkH!TSF6tc zH#uHb25vN$UXMF%`~A+@_5Y9MfB2zpGw;6_uIvPA#vg;1*^p+u@cc9X4%+<}FM+1H zWcy!-|C~Ahk4OK^xpRj1eyzPf%e>;R`I4oTpMvKd0u?1|P5Gf^mN1NsUB;0%W_I~H|+MClKU%IC%_y34`;rYGaKi+rblB+rG zYx7F5f5H3ee}B&ZpL74$-1|xS`ThU(aQOZ||6I%BHvWGP_#evN|2X%AiGR(h;2mEj zw@;k;C;oD&ZT=s&^{4y)zTp3CZ2#N%PxhWar}rzp|H1eE#98zEhwgsAcUXAd4;8() zZ$HOBodxZ%bUr>_c`A7Rq4j@%tq149`)_e1{r?m9y{}weUX^b5qS>w`c)8!FFX?xV zO`i8jBy6?m|8Q82hll?EsM`CV-SU4N+jBH}kE*nEG^p7Bv}C!>)I^CN=Qr)$dUUb; zSC71153A12vpo$eaXysS|NnmDS$z|Z)c;>S?)BEV+5Nv(^gr9!Uu*bo?|U6kJ$Z9) zN$3C97{On^I(WI?N3;CDWqZ!O-otm;;6sbLee3PK{d0Xj%P)OzCHL(@`=<@dXJpmVe~lKRZy#v*Yu->L33Xy{@|VgEwxv z{O^nM&(iC@rT_R|bwpY2r1ieT)*m0~{}YP2bN6YXe}z)|`MHnpI}4fLFH!EVJE9z^ z^X$*p`+rRLf1JJl;L&dJpYvbfihvc5kN1BDwVU?*-o0OasmQ!9SC)U+vfRG;n$-XK z7^(lipvuQp>wjhK`FndW^Yz$r+wyy*;-JdR!oucB{OcO(S5YZ zymGSnW}i@)|nbH$Ova&5Wb2G#xFd+#Uj zjQX_yD30RqKjZd0Mckl9)xp{Oe?}K=iTT)~ZadTZ-5%lFf9kJeWWe;QjpEgY_a4gL zo9S-*DR7Q;{k-G$A3f*nFKGOIx8me)7^VMw zU8Rb(*Q;{xz018nZ?d|7;qABO1^0Juwt4Yv_ReQ#B;W1%><4OL&sDem>G>!BD~?ic z|CMXk#OnV&ukRGOQ+&Vn&t3aJ%GBt=6Bm=mWL=o?W~B z?ttIACM-6r-uG(t=ZEs&1Z&P-uj5TWKlkZ>oTbvzU;Cb$*FE0;^Ys2-#ruBm-rwDB z|3+}nW9xm&`X7b#j~=}n3oiEW-`tEXm4sJc4X-V=`*YZ?Y5mVz>mR<^xtR#x)c*{x zKL;A3U{vS~uY4N*^zFTWvO7MuN$>c3Zg(YUgzn%$#{cn{rSjFduc7gC`TuX|e~`WJ zTQ+Dc;lDVJR^A`|tKV(+f0W*THvZq$`JeCJ{~K#_p~mjH@4X+sm7mS)xqsE23!eY4 zWZtJM%MJaM|KG{K4xrZ?6}8yA%0OzXDf=TJ^pf)M1{z|Icds=ezg+-d&L>_j|^1@c5j~tHAz; ze_B7=yyNtL^rw~kPyGdq3{GLxebn#di2H0B-@Uzd_V&kTW*Xlq`#sm@;Yt5zpux-2 zn7J!!{jXK)&$ZXzaQ|Q$|J!tqZTYnS+&H=e|E9nCJ@5XH-uut=|Gzr_^WFP@dT}Aq z(b0Qu&fd$K|LIu1m&=DI%k6rX+x~s&v-V>>X5%sbZD{=0t3N;R|K`~DEPH?3=I7@= z)gQ!{fu;9y5n3rxta)PV4vkX8+Ho zy666_jeE5;ZdQ3sd-)@9TX(MQ>BsXvxRmL~KKkE9^( zdw=BgzB8vcZr=_b$#}Q#d)zQ2xA(|` z=0^@!*?u^(+y>eZwU4BJQkm**^JpTd{t?#ym!yBNwObrqA=IXy|7agded|;`_kd>H z^8c5;KTvu-_PlKQy_3T8{-p5n@%@~?45L}R{@beczh2e+Xs>07|GYK+@Xg}%pZg!+ zXz|sr-1q)x@BJUS|30n%HwQG_|7ic=q>T@J?|(U4x!Jt7c-OkeEB))O{Oev_p0TX+ zzb5s|73ixA>3uSE!!R?Ok0y+*<$DYR}o*dt{gU&3(4bI`28Be-*e*&lii) zq~01{do}#|Vf#1AKW44}D|K$3_38htxcaN!Uu)0j|2dZbd3ycV=QZEU>&5?H&dJUF zI4%EU*q(>d`#1$3dHUCK`B(ok&RF{~ekJt-bJgtpT{E}Wx0gQ=jej`xhW(A_pyB@V zd$s)kL#dxsmRE!31V9b`iS0H&7T&48UkjQONcw(B_1|WyH^u(s2V_6}8vmcS;`R0_ z>9Vr2ieGEbKk)Rg)mnXZ)_)!1;?8XT@0;^a&;NIG{-^H!U%FG&7)XfB*)sNZP21~M zZ-4kT?)}%y+((`Mm0H{Fz{ABE#M!UbU-9E`<@NGP^I!YYciKD>um3sy=jr`_NNCpl zGp~B#EO*L!&tdCNm-PQ`in+D>$m4k*RNn1x`(p&|GnM$uuYmV88lMdjhN%WQV7=XzL|4Odhf;Apl;7J9Wm~C-#}Arm**Wb z;Q3QcTr+&O?IXed2eS9x$=9$_R|)Apy3%c_&7FJ ziz}}v%OADg_t^ULjpO?yp6kav;PkH&NPd$ z@T%7E2fX(l^hWAD<2ig_{r_j{KeN|=U}qq{2lgj^TSU%>XZhcr?KwGnk7VrikKks! z-QSl!myhgULR4tXe>ZJ*{=?k+&))8MHp_U+wI8$h|CO%$w7hQ8nJ2g=D6xf!d`$h{ zub=@YWzZb?sf(XFj@y3(EsUAF$AGx}A0jO+4Qlt<_SYO)%&_C}IqM%6<$nvFo4@}+ zaW&EXwjckW9F6~@TCu#mCSC5|2loQdq94#u>%&K`^X3y5H;c?GADcg$8vinM#nw&x z_a8t1@6P#8JC57W!#VeeC13uZ9mEB07Sjw_vGx2J9i$m;QRCL{a?NPKcn{_-H&Tz5}Qj_zkMrv@00HRN#-@p=4=m6`qygt z@BSJV<;{Rm@%^@aHL?E@XiWL%p7Z}@{uzVjtk8PV>O21Z%C7vqz1khL%ERdFWBLCU zb#K(`=HQu!#4>*Hr@s8O?)DFS`TzUC!_(Ejf7|@y^ndiO`u#%mqzfr+w&fqs-uo=O z_~Rp1yFbcyQ~vv4=>_|J{@Js=_VxCs8~bYwRd#&8SN-#%{O`hdJHO}sGbO%OGuS%h_r52Je7@#^NTb&q-D z{_|F@-d>fxZQHgE4H=m?o40N~3#zq0JhuO~_)gvNw|~MbFvpR?tG_IkI~=|DX>`$> z8-~j=AMyYH(f{LV{6E!ywj_i~Rh+utoCmh|AJ|?$^?IDNT#=cQW=LgY6II zZohl1ehRKx1*)G_uhxH3um7h0$DR0MY{x6t&6|to@c;k7|5!Edk1Auur^)lr zT(A9mz3|(cy?@fN#Bh4Wjl*)sZtuBzyWrCk&Zz7^N8|sg+W#)L_r^1eiKVz$RX^9= z_SMCj5A3xZpw%O5P3xYQ*SEj_b1ok=T(69!L{NVFCg(F>{`WqcFAMn@kF4EpH&_05 zr+jkxccK3?Nl0D4wpT7TuSvf5aqhjDy+_&H-QEA(=>Kc5|Bv?mGxbw2`y2Hw9S_dl z|KeNub9%k2muAeaEvNnee(?vbRhapoi}>bi_}|-8h4$S4U8}JE>#X&@E(P!RmVeg0 z|LZJxUF-eVm^u3WS1$ekpY~jo-e=f-v?)>I$JzV;eCytSuj|J-V~M5K4FCHZJdgK( z1OM}h{WUxZ|NhDUYs~-AwmuOQbYYmqTm9UQP zPXsL!J=p*6M}P6VJCXl(6W?fw`6~Z@?Y&pI6|=Y3MW=0+{Qdj)p9lPZ8TS9Zz5nF? zW0(!a_{Y8X9`u3|d}!9%H}Btn{Jj6e=Rbe#|1kf*OG?^0Z#e&a<^S*h6XKpuji1@^ zpwIf<1L^%QUswLRJpWktBg{BnQ~_Frb?=Yw{hqaJb&nN3`TqX_{}0d-lYh%eDYfmd z`NUyLJyNfqWBq>5?Ejslg!THb=iYx=x98>TeVVb?H}Bqk`g_g$ z@=wR>{~Wj3j+u>%tKVL)%8mQIHE#C0*PtbR)9b%Nx_o*r=ZE34Yd}u9w%dJqSo5O2M#B7F zg>czp&`QQ1*Yba>wIx2lNO?t%esJFVD0_b*Xg$g{(4_pa{y#7Ji%Lok{1+ptR0BEp< z%X9d^`G0TDf6(6lOPlBLtkwUjU0B|Ko%jCi>%I402YaQ~*Ux|ZHuqTJlGj$(UYmmE zdr!&#`zZf;|5cpx3f9)Q|Gb?4Pc`A+BmI9(?|;2}|KQKh&oN)^4{uCX|Ixw!pCj(g z(Ksdp22c(=2U>MAVdkHFTuV6i-{}AMqW|*)cKaD?*Xq{2-d;C({;!wwpFoT}-d=md z{ZkfbeDeSIdVT+jOTu`@CU**}`+Z`R|IoPS_U^sbhc`S+um6_*^J@I>)V9P2tNzzv z7UZ~RYx*m$ES5V8T37w~*ZTjz?v&lm{qv*$zXoVQ(y7q@dohzP?pZte&sOU{SJgam zuT$x-Jd+F>PARVYa=dOzy3OOTb-&(&mIV+y0Q-Nz%{9;G-v4>`#{vF73VpJ-ABNYz z4gYy_{@+Z!*j-0L|8FJ9Wo2)-UjO7M|3wirJgxluH~XI}{{L0>e~sRM(8M?9Yy4x3 zY=LLWiCO=nkp4-~YWdGu>%U3ad|m1P@Oa%DcW`fZ-LLcCF$!7Sk@=s~6V#pN1g)4q z>MZ|D;NOq_|1;!&f0Tc^Zuh$jL-jGwf_4dGyDJC_Qyem`lhJ0J6_#? zMnb{}J^y$if2{^6Do@Vd_i}d8_1N;ykL^D${_|1(zu>l=ui~GRVw(O1(9|lO(n7JZ=Z)2r3{(E3QYro(r>ihpX@ zU&#YX8)skdIrqBgcJBEH?)7ine{}EvQM|6|9|<`<=4<{ZP&rlu+Huogb!G9-&-;HE z*S$GjH|2I-HCwptauO14_}|%QpBvx%8hd{(|BnX#l4_l+N|Jg}0Odq@+08~Gj=zlNLKmS<%KmVT-{r@Qy z=IGDa`+sVKngT7$ZQo}8iT{9EL*CD_oB#RM`fsoH+*-X)Yin4Pcig+DakG%N_w-*V zwFPYhIl*54p}pwQ5zo5E=Jmax-Fu*geE-a=F~_0u%kFNy{&89UuQZ!KiTp>+^gkQv zA1&JX1h!ql{?yCM%Rwb#!o8Ps?}1j0&Hp!Z{-^t~B@(4`^YT8-TK_R> z&nNA@M!$bo*WBJ-s}0&gbnf0g)qmoUU334HZNF{of4bQ2)#A#v*Q;dn_I`Z{8eQN3 zt)Dvi-waE-^;K$`UcYnt^U}EATW{XD@kG7u(eWS4@_)I#+xcAXA3tOV=l{ifA4>02 z1g!-;sx0@>1HAhY)LMA>?RNh2`feZxfBVhnpRfF0UhjYJ|KI!bdwYHBo`%=Wod0*{{D%(`=Kp_*B_J-(wJUAA zUVZoaL)ExPs+Gr#=N-IWd-wXM?)^VL-+1<40Gjh<+XbJ0yFLHl z|3b*#xq4O23eXZWyGP1)O|$ds1nNJq??3MU=ShDdXmgnTNz5^W`bq4zFAjp%$H?(l zesBi2d%^p8L2Z(U|2d%{aqHSOG0+y#)1dvg0)an{#{a6U`zT&NZ}DQ)|H4>C71b^3 z{#dO4ZMFU|Xe~_9_q*kvd+-0?{d2|tugY4}fAWx>(*Ma<`f1K$5lK~Fu`ieXEKQFfbrEK%k)Bnl-)0p9~zgY#;CF;3efA{+1Gc&V)OpX7U zy6@fVeQn@@So=1}rmpjvOFxz6e=n#sSY{Ik9OM>RNrL8;=Q7GxLH|H$M0^B*wVJz%c*US7|Ctnf*C{k!xZSL6St zUVGj6Ukgi?)4Cmfd(XG*{bK7sd|H2?m0SGhv;2Q*pw>c5qQs6@Mv#cBzo#t!(*u+q zy38w@&7bsH=RH4O_s1R7XAj0c96U9sVrh7dYTRqn`0jo0e@>MDnF!uBUi|!A;niYj zR?Y`m%?H|{b2eG-a|Ea`odnue{_v%Lz19DjSfXvF+}{=MAAha?{wt>bFE42SoLKy4 z)A+NG<-S|2tJ((*2kqMXpU=JjqgV5Qz0Tlxzx;DharX1&{GXY6vA3SuAI6M0`@^Q! zV@|)`fA96jr2bmNd4Ha$e=LpvzV*+m_5ZlS_Xa?6#{Z=#8;ND)7R42 zz2g2^YW>G3o4+spAA?(fulN7c{PZ!g&8w0@kvFN`_RYgTC+Gh$1dT78k}bb;aQ}78u(A$`s#{i-22kF{^?aKr$5?u8&Y0=}8swH$_2S@k zTzZxJ18h${4`@&Q3E}xaSiqI?aoczDKdxixf@Sad;=R`dR0N$qIa&Q@Yy6K$P=4-9 zw|OiX^HrV+ypuWhtCI^${?BRoCqbha1($uzKmXqUzxvM)|34Dn@0RQ9cVX5_-H)b4 z=RN!y|NbjzZ{;!22E32pP`)VtS1|l%-ss z|7&Z0Z?BgY*N=O$>c0sjldP(525Ef^uK8C^ugd)Q@BRO2@;@)iKh*TEG&=tlGhy6` zd$Uph7%2A@o;AJx3A7%f=I-@cUjM2~lXtwbmjxHcyI$R2aqZOY{eP`LfO-&fEQ{Oj z|9X4>$@02a>N^V_+I+`s{oHxq_B#IoIE+DKJdfqR^Vht+UdIa>`#u`_KNXTiLjU`z zBOjm9miQpO?%VR8TjPI=VjC{pv~}y*?ETlWKXtAD;03DAqxU_X{qv#xzr?o0v!Spp zsiFTJ<^H~K|12o~F%Yy}&~E$PZ#;E>j@M6FH~%AeefwSW8e9I#56zWtuh->1+iYq7 zE!qAMXfs0L{o3z^SL?ZXEjk5nPpmyQu<#n%?gO0_RCnG0UboIXkRCc%=c5}p@>3f=!_eQpRpBNYz O7(8A5T-G@yGywqF9K>S) literal 0 HcmV?d00001 diff --git a/crates/bone-document/src/document/feature_tree.rs b/crates/bone-document/src/document/feature_tree.rs index 721d724..18fa0e5 100644 --- a/crates/bone-document/src/document/feature_tree.rs +++ b/crates/bone-document/src/document/feature_tree.rs @@ -1,8 +1,10 @@ +use std::collections::{BTreeMap, BTreeSet}; + use bone_kernel::ExtrudeFeature; -use bone_types::{BodyId, ExtrudeId, FeatureId, SketchId}; +use bone_types::{BodyId, ExtrudeId, FeatureGeneration, FeatureId, RollbackMarker, SketchId}; use serde::{Deserialize, Serialize}; -use super::{key_from_index, next_key}; +use super::{gen_key, key_from_index, max_gen}; #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum PrincipalPlane { @@ -26,6 +28,23 @@ pub enum FeatureEdge { sketch: FeatureId, extrude: FeatureId, }, + FaceToSketch { + face_feature: FeatureId, + sketch: FeatureId, + }, +} + +impl FeatureEdge { + #[must_use] + pub fn endpoints(self) -> (FeatureId, FeatureId) { + match self { + Self::SketchToExtrude { sketch, extrude } => (sketch, extrude), + Self::FaceToSketch { + face_feature, + sketch, + } => (face_feature, sketch), + } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -39,6 +58,8 @@ struct FeatureEntry { #[serde(deny_unknown_fields)] pub struct FeatureTree { entries: Vec, + #[serde(default)] + id_high_water: FeatureGeneration, #[serde(skip)] edges: Vec, } @@ -52,7 +73,7 @@ impl FeatureTree { FeatureNode::PrincipalPlane(PrincipalPlane::Yz), FeatureNode::PrincipalPlane(PrincipalPlane::Zx), ]; - let entries = seeds + let entries: Vec = seeds .into_iter() .zip(1u32..) .map(|(node, idx)| FeatureEntry { @@ -60,8 +81,10 @@ impl FeatureTree { node, }) .collect(); + let id_high_water = max_gen(entries.iter().map(|e| e.id)); Self { entries, + id_high_water, edges: Vec::new(), } } @@ -114,7 +137,7 @@ impl FeatureTree { id }); let edge = self.sketch_edge(id, feature); - self.drop_edges_incident(id); + self.drop_sketch_to_extrude(id); self.edges.extend(edge); self.edges.sort_unstable(); id @@ -134,6 +157,103 @@ impl FeatureTree { }); } + fn position_of(&self, id: FeatureId) -> Option { + self.entries.iter().position(|entry| entry.id == id) + } + + pub(crate) fn move_before(&mut self, id: FeatureId, anchor: FeatureId) { + if id == anchor { + return; + } + let Some(from) = self.position_of(id) else { + return; + }; + let entry = self.entries.remove(from); + match self.position_of(anchor) { + Some(to) => self.entries.insert(to, entry), + None => self.entries.push(entry), + } + } + + fn place(&mut self, moved: FeatureId, anchor: FeatureId, before: bool) { + if before { + self.move_before(moved, anchor); + } else { + self.move_after(moved, anchor); + } + } + + #[must_use] + pub(crate) fn reorder_is_legal( + &self, + moved: FeatureId, + anchor: FeatureId, + before: bool, + ) -> bool { + if self.is_datum(moved) || self.is_datum(anchor) || moved == anchor { + return false; + } + let mut trial = self.clone(); + trial.place(moved, anchor, before); + trial.order_violation().is_none() + } + + pub(crate) fn try_reorder( + &mut self, + moved: FeatureId, + anchor: FeatureId, + before: bool, + ) -> bool { + if !self.reorder_is_legal(moved, anchor, before) { + return false; + } + self.place(moved, anchor, before); + true + } + + pub(crate) fn move_before_keeping_order(&mut self, id: FeatureId, anchor: FeatureId) { + let last_parent = self + .parents(id) + .into_iter() + .filter_map(|parent| self.position_of(parent).map(|pos| (pos, parent))) + .max(); + match (self.position_of(anchor), last_parent) { + (Some(anchor_at), Some((parent_at, parent))) if parent_at >= anchor_at => { + self.move_after(id, parent); + } + (Some(_), _) => self.move_before(id, anchor), + (None, _) => {} + } + } + + fn move_after(&mut self, id: FeatureId, anchor: FeatureId) { + if id == anchor { + return; + } + let Some(from) = self.position_of(id) else { + return; + }; + let entry = self.entries.remove(from); + match self.position_of(anchor) { + Some(to) => self.entries.insert(to + 1, entry), + None => self.entries.push(entry), + } + } + + fn reorder_after(&mut self, feature: FeatureId, anchor: FeatureId) { + let already_ordered = matches!( + (self.position_of(feature), self.position_of(anchor)), + (Some(child), Some(parent)) if child > parent + ); + if already_ordered { + return; + } + self.move_after(feature, anchor); + self.children(feature) + .into_iter() + .for_each(|child| self.reorder_after(child, feature)); + } + #[must_use] pub fn edges(&self) -> &[FeatureEdge] { &self.edges @@ -142,6 +262,7 @@ impl FeatureTree { pub(crate) fn rebuild_edges<'a>( &mut self, extrudes: impl Iterator, + face_bindings: impl Iterator, ) { let mut rebuilt: Vec = extrudes .filter_map(|(extrude, feature)| { @@ -149,20 +270,43 @@ impl FeatureTree { self.sketch_edge(extrude, feature) }) .collect(); + rebuilt.extend( + face_bindings.map(|(face_feature, sketch)| FeatureEdge::FaceToSketch { + face_feature, + sketch, + }), + ); rebuilt.sort_unstable(); self.edges = rebuilt; } + pub(crate) fn bind_face_to_sketch(&mut self, face_feature: FeatureId, sketch: FeatureId) { + self.edges.retain(|edge| { + !matches!(edge, FeatureEdge::FaceToSketch { sketch: target, .. } if *target == sketch) + }); + self.edges.push(FeatureEdge::FaceToSketch { + face_feature, + sketch, + }); + self.edges.sort_unstable(); + self.reorder_after(sketch, face_feature); + } + fn sketch_edge(&self, extrude: FeatureId, feature: &ExtrudeFeature) -> Option { let sketch = self.feature_of_sketch(feature.sketch)?; Some(FeatureEdge::SketchToExtrude { sketch, extrude }) } fn drop_edges_incident(&mut self, feature: FeatureId) { - self.edges.retain(|edge| match edge { - FeatureEdge::SketchToExtrude { sketch, extrude } => { - *sketch != feature && *extrude != feature - } + self.edges.retain(|edge| { + let (parent, child) = edge.endpoints(); + parent != feature && child != feature + }); + } + + fn drop_sketch_to_extrude(&mut self, extrude: FeatureId) { + self.edges.retain(|edge| { + !matches!(edge, FeatureEdge::SketchToExtrude { extrude: target, .. } if *target == extrude) }); } @@ -173,11 +317,217 @@ impl FeatureTree { .map(|e| e.id) } - pub(crate) fn allocate(&self) -> FeatureId { - next_key(self.entries.iter().map(|e| e.id)) + pub(crate) fn allocate(&mut self) -> FeatureId { + let next = self + .id_high_water + .raised_to(max_gen(self.entries.iter().map(|e| e.id))) + .succ(); + self.id_high_water = next; + gen_key(next) + } + + pub(crate) fn raise_high_water(&mut self) { + self.id_high_water = self + .id_high_water + .raised_to(max_gen(self.entries.iter().map(|e| e.id))); + } + + #[must_use] + pub fn children(&self, id: FeatureId) -> Vec { + self.edges + .iter() + .map(|edge| edge.endpoints()) + .filter(|(parent, _)| *parent == id) + .map(|(_, child)| child) + .collect::>() + .into_iter() + .collect() + } + + #[must_use] + pub fn parents(&self, id: FeatureId) -> Vec { + self.edges + .iter() + .map(|edge| edge.endpoints()) + .filter(|(_, child)| *child == id) + .map(|(parent, _)| parent) + .collect::>() + .into_iter() + .collect() + } + + #[must_use] + pub fn descendants(&self, id: FeatureId) -> Vec { + reachable(id, &self.adjacency(Direction::Forward), BTreeSet::new()) + .into_iter() + .collect() + } + + #[must_use] + pub fn ancestors(&self, id: FeatureId) -> Vec { + reachable(id, &self.adjacency(Direction::Backward), BTreeSet::new()) + .into_iter() + .collect() + } + + #[must_use] + pub fn rolled_back(&self, marker: RollbackMarker) -> BTreeSet { + let Some(target) = marker.feature() else { + return BTreeSet::new(); + }; + self.position_of(target) + .map(|start| self.entries[start..].iter().map(|entry| entry.id).collect()) + .unwrap_or_default() + } + + #[must_use] + pub(crate) fn rollable_order(&self) -> Vec { + self.entries + .iter() + .filter(|entry| { + !matches!( + entry.node, + FeatureNode::Origin | FeatureNode::PrincipalPlane(_) + ) + }) + .map(|entry| entry.id) + .collect() + } + + #[must_use] + pub fn is_datum(&self, id: FeatureId) -> bool { + matches!( + self.node(id), + Some(FeatureNode::Origin | FeatureNode::PrincipalPlane(_)) + ) + } + + #[must_use] + pub(crate) fn order_violation(&self) -> Option { + self.edges.iter().copied().find(|edge| { + let (parent, child) = edge.endpoints(); + matches!( + (self.position_of(parent), self.position_of(child)), + (Some(parent_at), Some(child_at)) if parent_at >= child_at + ) + }) + } + + #[must_use] + pub(crate) fn find_cycle(&self) -> Option { + let adjacency = self.adjacency(Direction::Forward); + self.entries + .iter() + .fold((BTreeSet::new(), None), |(done, found), entry| { + if found.is_some() || done.contains(&entry.id) { + (done, found) + } else { + detect_cycle(entry.id, &adjacency, done, BTreeSet::new()) + } + }) + .1 + } + + #[must_use] + pub fn topo_order(&self) -> Vec { + let adjacency = self.adjacency(Direction::Forward); + let (_, postorder) = self + .entries + .iter() + .fold((BTreeSet::new(), Vec::new()), |(visited, order), entry| { + visit_post(entry.id, &adjacency, visited, order) + }); + postorder.into_iter().rev().collect() + } + + fn adjacency(&self, direction: Direction) -> BTreeMap> { + self.edges.iter().map(|edge| edge.endpoints()).fold( + BTreeMap::new(), + |mut acc, (parent, child)| { + let (from, to) = match direction { + Direction::Forward => (parent, child), + Direction::Backward => (child, parent), + }; + acc.entry(from).or_default().insert(to); + acc + }, + ) } } +#[derive(Copy, Clone)] +enum Direction { + Forward, + Backward, +} + +fn reachable( + start: FeatureId, + adjacency: &BTreeMap>, + reached: BTreeSet, +) -> BTreeSet { + adjacency + .get(&start) + .into_iter() + .flatten() + .fold(reached, |reached, &next| { + if reached.contains(&next) { + reached + } else { + reachable(next, adjacency, insert(reached, next)) + } + }) +} + +fn detect_cycle( + node: FeatureId, + adjacency: &BTreeMap>, + done: BTreeSet, + on_path: BTreeSet, +) -> (BTreeSet, Option) { + let on_path = insert(on_path, node); + let (done, found) = + adjacency + .get(&node) + .into_iter() + .flatten() + .fold((done, None), |(done, found), &next| { + if found.is_some() { + (done, found) + } else if on_path.contains(&next) { + (done, Some(next)) + } else if done.contains(&next) { + (done, None) + } else { + detect_cycle(next, adjacency, done, on_path.clone()) + } + }); + (insert(done, node), found) +} + +fn visit_post( + node: FeatureId, + adjacency: &BTreeMap>, + visited: BTreeSet, + order: Vec, +) -> (BTreeSet, Vec) { + if visited.contains(&node) { + return (visited, order); + } + let (visited, mut order) = adjacency.get(&node).into_iter().flatten().fold( + (insert(visited, node), order), + |(visited, order), &child| visit_post(child, adjacency, visited, order), + ); + order.push(node); + (visited, order) +} + +fn insert(set: BTreeSet, value: FeatureId) -> BTreeSet { + let mut next = set; + next.insert(value); + next +} + #[cfg(test)] pub(crate) fn sample_blind_extrude(sketch: SketchId) -> ExtrudeFeature { use bone_kernel::{ExtrudeDirection, ExtrudeEndCondition, ExtrudeSense, MergeResult}; @@ -199,7 +549,11 @@ pub(crate) fn sample_blind_extrude(sketch: SketchId) -> ExtrudeFeature { #[cfg(test)] mod tests { - use super::{FeatureEdge, FeatureNode, FeatureTree, sample_blind_extrude}; + use std::collections::BTreeSet; + + use proptest::prelude::*; + + use super::{FeatureEdge, FeatureId, FeatureNode, FeatureTree, sample_blind_extrude}; use bone_types::{ExtrudeId, SketchId}; #[test] @@ -278,6 +632,37 @@ mod tests { ); } + #[test] + fn push_extrude_preserves_face_bound_child_edge() { + let mut tree = FeatureTree::seeded(); + let sketch = SketchId::default(); + tree.push_sketch(sketch); + let extrude = ExtrudeId::default(); + let ext_feat = tree.push_extrude(extrude, &sample_blind_extrude(sketch)); + let downstream = tree.push_sketch(sketch_key(2)); + let face_edge = FeatureEdge::FaceToSketch { + face_feature: ext_feat, + sketch: downstream, + }; + tree.edges.push(face_edge); + tree.edges.sort_unstable(); + + tree.push_extrude(extrude, &sample_blind_extrude(sketch)); + + assert!( + tree.edges().contains(&face_edge), + "re-pushing an extrude must not erase a downstream face-bound dependency" + ); + assert_eq!( + tree.edges() + .iter() + .filter(|e| matches!(e, FeatureEdge::SketchToExtrude { extrude, .. } if *extrude == ext_feat)) + .count(), + 1, + "the sketch-to-extrude edge stays single after a re-push" + ); + } + #[test] fn remove_extrude_drops_node_and_edge() { let mut tree = FeatureTree::seeded(); @@ -332,7 +717,10 @@ mod tests { (low, sample_blind_extrude(sketch)), (high, sample_blind_extrude(sketch)), ]); - rebuilt.rebuild_edges(extrudes.iter().map(|(id, feature)| (*id, feature))); + rebuilt.rebuild_edges( + extrudes.iter().map(|(id, feature)| (*id, feature)), + std::iter::empty(), + ); assert_eq!( pushed.edges(), @@ -341,4 +729,212 @@ mod tests { ); assert_eq!(pushed.edges().len(), 2); } + + #[test] + fn allocate_does_not_reuse_removed_top_id() { + let mut tree = FeatureTree::seeded(); + let first = tree.push_sketch(sketch_key(1)); + tree.remove_sketch(sketch_key(1)); + let second = tree.push_sketch(sketch_key(2)); + assert_ne!( + first, second, + "a removed top id must never be handed out again" + ); + } + + #[test] + fn endpoints_orient_parent_before_child() { + let parent = super::super::key_from_index::(7); + let child = super::super::key_from_index::(9); + assert_eq!( + FeatureEdge::SketchToExtrude { + sketch: parent, + extrude: child, + } + .endpoints(), + (parent, child) + ); + assert_eq!( + FeatureEdge::FaceToSketch { + face_feature: parent, + sketch: child, + } + .endpoints(), + (parent, child) + ); + } + + fn sketch_extrude_pair( + tree: &mut FeatureTree, + sketch: SketchId, + extrude: ExtrudeId, + ) -> (FeatureId, FeatureId) { + let sketch_feature = tree.push_sketch(sketch); + let extrude_feature = tree.push_extrude(extrude, &sample_blind_extrude(sketch)); + (sketch_feature, extrude_feature) + } + + #[test] + fn children_and_parents_read_the_edges() { + let mut tree = FeatureTree::seeded(); + let (sketch_feature, extrude_feature) = + sketch_extrude_pair(&mut tree, sketch_key(1), extrude_key(1)); + assert_eq!(tree.children(sketch_feature), vec![extrude_feature]); + assert_eq!(tree.parents(extrude_feature), vec![sketch_feature]); + assert!(tree.children(extrude_feature).is_empty()); + assert!(tree.parents(sketch_feature).is_empty()); + } + + #[test] + fn descendants_and_ancestors_follow_the_chain() { + let mut tree = FeatureTree::seeded(); + let (sketch_feature, extrude_feature) = + sketch_extrude_pair(&mut tree, sketch_key(1), extrude_key(1)); + assert_eq!(tree.descendants(sketch_feature), vec![extrude_feature]); + assert_eq!(tree.ancestors(extrude_feature), vec![sketch_feature]); + assert!(tree.descendants(extrude_feature).is_empty()); + assert!(tree.ancestors(sketch_feature).is_empty()); + } + + #[test] + fn topo_order_lists_every_node_parents_first() { + let mut tree = FeatureTree::seeded(); + let (sketch_feature, extrude_feature) = + sketch_extrude_pair(&mut tree, sketch_key(1), extrude_key(1)); + let order = tree.topo_order(); + let all: BTreeSet = tree.iter().map(|(id, _)| id).collect(); + assert_eq!( + order.iter().copied().collect::>(), + all, + "topo order must visit every node exactly once" + ); + let pos = |id| order.iter().position(|other| *other == id); + assert!( + pos(sketch_feature) < pos(extrude_feature), + "a parent must precede its child" + ); + } + + #[test] + fn order_violation_flags_a_child_before_its_parent() { + let mut tree = FeatureTree::seeded(); + let sketch = SketchId::default(); + tree.push_sketch(sketch); + let extrude = ExtrudeId::default(); + tree.push_extrude(extrude, &sample_blind_extrude(sketch)); + assert!( + tree.order_violation().is_none(), + "a freshly built sketch-then-extrude chain is in dependency order", + ); + + let Some(sketch_feature) = tree.feature_of_sketch(sketch) else { + panic!("the sketch resolves to a feature node"); + }; + let Some(extrude_feature) = tree.feature_of_extrude(extrude) else { + panic!("the extrude resolves to a feature node"); + }; + tree.move_before(extrude_feature, sketch_feature); + + assert_eq!( + tree.order_violation().map(FeatureEdge::endpoints), + Some((sketch_feature, extrude_feature)), + "moving the extrude above its sketch is reported as an order violation", + ); + } + + #[test] + fn binding_a_face_reorders_the_sketch_subtree_after_its_target() { + let mut tree = FeatureTree::seeded(); + let early_sketch = tree.push_sketch(sketch_key(1)); + let early_extrude = tree.push_extrude(extrude_key(1), &sample_blind_extrude(sketch_key(1))); + tree.push_sketch(sketch_key(2)); + let body = tree.push_extrude(extrude_key(2), &sample_blind_extrude(sketch_key(2))); + + tree.bind_face_to_sketch(body, early_sketch); + + assert!( + tree.order_violation().is_none(), + "binding an earlier sketch onto a later body drags its subtree past the target", + ); + let order = tree.rollable_order(); + let position = |needle: FeatureId| order.iter().position(|f| *f == needle); + assert!( + position(body) < position(early_sketch), + "the bound sketch now follows the body it sits on", + ); + assert!( + position(early_sketch) < position(early_extrude), + "the bound sketch still precedes its own extrude", + ); + } + + #[test] + fn find_cycle_flags_a_back_edge() { + let mut tree = FeatureTree::seeded(); + let a = tree.push_sketch(sketch_key(1)); + let b = tree.push_sketch(sketch_key(2)); + assert_eq!(tree.find_cycle(), None); + + tree.edges = vec![ + FeatureEdge::SketchToExtrude { + sketch: a, + extrude: b, + }, + FeatureEdge::SketchToExtrude { + sketch: b, + extrude: a, + }, + ]; + tree.edges.sort_unstable(); + + assert!( + tree.find_cycle().is_some(), + "a back edge in the dependency graph must be reported" + ); + } + + #[derive(Clone, Debug)] + enum Op { + Add, + Remove(usize), + } + + fn op_strategy() -> impl Strategy { + prop_oneof![ + 2 => Just(Op::Add), + 1 => (0usize..1024).prop_map(Op::Remove), + ] + } + + proptest! { + #[test] + fn feature_ids_never_reused(ops in proptest::collection::vec(op_strategy(), 0..64)) { + let tree = FeatureTree::seeded(); + let seeds: Vec = tree.iter().map(|(id, _)| id).collect(); + let (_, _, _, handed_out) = ops.into_iter().fold( + (tree, Vec::::new(), 0u64, seeds), + |(mut tree, mut live, counter, mut handed_out), op| match op { + Op::Add => { + let sketch = sketch_key(counter + 1); + handed_out.push(tree.push_sketch(sketch)); + live.push(sketch); + (tree, live, counter + 1, handed_out) + } + Op::Remove(index) => { + if !live.is_empty() { + let removed = live.remove(index % live.len()); + tree.remove_sketch(removed); + } + (tree, live, counter, handed_out) + } + }, + ); + let unique: BTreeSet = handed_out.iter().copied().collect(); + prop_assert_eq!( + unique.len(), + handed_out.len(), + "a feature id held by a reference or snapshot was reused after a delete" + ); + } + } } diff --git a/crates/bone-document/src/document/mod.rs b/crates/bone-document/src/document/mod.rs index 8b2760e..aaffacc 100644 --- a/crates/bone-document/src/document/mod.rs +++ b/crates/bone-document/src/document/mod.rs @@ -1,7 +1,11 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; use bone_kernel::{BrepEdge, BrepFace, BrepSolid, BrepVertex, ExtrudeFeature}; -use bone_types::{BodyId, DocumentId, ExtrudeId, FeatureId, SchemaHeader, SketchId}; +use bone_types::{ + BodyId, DocumentId, ExtrudeId, FaceRef, FeatureGeneration, FeatureId, RollbackMarker, + SchemaHeader, SketchId, SuppressionState, +}; use serde::{Deserialize, Serialize}; use slotmap::KeyData; @@ -13,18 +17,20 @@ pub use feature_tree::{FeatureEdge, FeatureNode, FeatureTree, PrincipalPlane}; #[derive(Clone)] pub struct ImportedSolid { - solid: BrepSolid, + solid: Arc, } impl ImportedSolid { #[must_use] pub fn new(solid: BrepSolid) -> Self { - Self { solid } + Self { + solid: Arc::new(solid), + } } #[must_use] pub fn solid(&self) -> &BrepSolid { - &self.solid + self.solid.as_ref() } } @@ -140,6 +146,14 @@ pub enum RenameExtrudeError { EmptyLabel, } +#[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum BindSketchToFaceError { + #[error("sketch {0:?} not found")] + UnknownSketch(SketchId), + #[error("binding sketch onto feature {0:?} would make the feature graph cyclic")] + WouldCycle(FeatureId), +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct DocumentParameters { @@ -186,6 +200,18 @@ pub struct DocumentHeader { pub parameters: DocumentParameters, pub feature_tree: FeatureTree, pub sketches: SketchRegistry, + #[serde(default)] + pub sketch_planes: BTreeMap, + #[serde(default)] + pub sketch_high_water: FeatureGeneration, + #[serde(default)] + pub extrude_high_water: FeatureGeneration, + #[serde(default)] + pub body_high_water: FeatureGeneration, + #[serde(default)] + pub rollback: RollbackMarker, + #[serde(default)] + pub suppressed: BTreeSet, #[serde(skip)] pub extrudes: BTreeMap, #[serde(skip)] @@ -203,14 +229,71 @@ impl DocumentHeader { parameters: DocumentParameters::new(), feature_tree: FeatureTree::seeded(), sketches: SketchRegistry::new(), + sketch_planes: BTreeMap::new(), + sketch_high_water: FeatureGeneration::START, + extrude_high_water: FeatureGeneration::START, + body_high_water: FeatureGeneration::START, + rollback: RollbackMarker::AtEnd, + suppressed: BTreeSet::new(), extrudes: BTreeMap::new(), extrude_labels: BTreeMap::new(), } } pub(crate) fn rebuild_edges(&mut self) { - self.feature_tree - .rebuild_edges(self.extrudes.iter().map(|(id, feature)| (*id, feature))); + let bindings: Vec<(FeatureId, FeatureId)> = self + .sketch_planes + .iter() + .filter_map(|(sketch_id, face)| { + let sketch = self.feature_tree.feature_of_sketch(*sketch_id)?; + Some((face.label.feature, sketch)) + }) + .collect(); + self.feature_tree.rebuild_edges( + self.extrudes.iter().map(|(id, feature)| (*id, feature)), + bindings.into_iter(), + ); + } + + pub(crate) fn retire_feature(&mut self, feature: FeatureId) { + self.suppressed.remove(&feature); + if self.rollback.feature() == Some(feature) { + let order = self.feature_tree.rollable_order(); + let successor = order + .iter() + .position(|candidate| *candidate == feature) + .and_then(|at| order.get(at + 1)) + .copied(); + self.rollback = successor.map_or(RollbackMarker::AtEnd, RollbackMarker::Above); + } + } + + pub(crate) fn normalize_high_water(&mut self) { + self.feature_tree.raise_high_water(); + self.sketch_high_water = self + .sketch_high_water + .raised_to(max_gen(self.sketches.order().iter().copied())); + self.extrude_high_water = self + .extrude_high_water + .raised_to(max_gen(self.extrudes.keys().copied())); + } + + fn allocate_sketch(&mut self) -> SketchId { + let next = self + .sketch_high_water + .raised_to(max_gen(self.sketches.order().iter().copied())) + .succ(); + self.sketch_high_water = next; + gen_key(next) + } + + fn allocate_extrude(&mut self) -> ExtrudeId { + let next = self + .extrude_high_water + .raised_to(max_gen(self.extrudes.keys().copied())) + .succ(); + self.extrude_high_water = next; + gen_key(next) } } @@ -272,6 +355,10 @@ impl Document { sketches: BTreeMap, bodies: BTreeMap, ) -> Self { + header.normalize_high_water(); + header.body_high_water = header + .body_high_water + .raised_to(max_gen(bodies.keys().copied())); header.rebuild_edges(); Self { header, @@ -280,6 +367,16 @@ impl Document { } } + fn allocate_body(&mut self) -> BodyId { + let next = self + .header + .body_high_water + .raised_to(max_gen(self.bodies.keys().copied())) + .succ(); + self.header.body_high_water = next; + gen_key(next) + } + #[must_use] pub fn header(&self) -> &DocumentHeader { &self.header @@ -360,12 +457,22 @@ impl Document { self.header.units = units; } + #[must_use] + pub fn allocate_sketch(&mut self) -> SketchId { + self.header.allocate_sketch() + } + pub fn insert_sketch(&mut self, id: SketchId, label: String, sketch: Sketch) { let filename = sketch_filename(id); self.header .sketches .insert(id, SketchRegistryEntry { label, filename }); - self.header.feature_tree.push_sketch(id); + self.header.sketch_high_water = self.header.sketch_high_water.raised_to(key_gen(id)); + let existed = self.header.feature_tree.feature_of_sketch(id).is_some(); + let placed = self.header.feature_tree.push_sketch(id); + if !existed { + self.place_at_rollback_bar(placed); + } self.sketches.insert(id, sketch); let dependents: Vec = self.extrudes_of_sketch(id).collect(); dependents.iter().for_each(|extrude| { @@ -379,8 +486,145 @@ impl Document { self.sketches.insert(id, sketch) } + pub fn bind_sketch_to_face( + &mut self, + sketch: SketchId, + face: FaceRef, + ) -> Result<(), BindSketchToFaceError> { + let sketch_feature = self + .header + .feature_tree + .feature_of_sketch(sketch) + .ok_or(BindSketchToFaceError::UnknownSketch(sketch))?; + let target = face.label.feature; + let cyclic = target == sketch_feature + || self + .header + .feature_tree + .descendants(sketch_feature) + .contains(&target); + if cyclic { + return Err(BindSketchToFaceError::WouldCycle(target)); + } + self.header.sketch_planes.insert(sketch, face); + self.header + .feature_tree + .bind_face_to_sketch(target, sketch_feature); + Ok(()) + } + + #[must_use] + pub fn sketch_plane_binding(&self, sketch: SketchId) -> Option { + self.header.sketch_planes.get(&sketch).copied() + } + + #[must_use] + pub fn reorder_is_legal(&self, moved: FeatureId, anchor: FeatureId, before: bool) -> bool { + self.header + .feature_tree + .reorder_is_legal(moved, anchor, before) + } + + pub fn reorder_feature(&mut self, moved: FeatureId, anchor: FeatureId, before: bool) -> bool { + self.header.feature_tree.try_reorder(moved, anchor, before) + } + + #[must_use] + pub fn rollback(&self) -> RollbackMarker { + self.header.rollback + } + + pub fn roll_to_here(&mut self, feature: FeatureId) { + if self.header.feature_tree.rollable_order().contains(&feature) { + self.header.rollback = RollbackMarker::Above(feature); + } + } + + pub fn roll_to_end(&mut self) { + self.header.rollback = RollbackMarker::AtEnd; + } + + pub fn roll_to_previous(&mut self) { + let order = self.header.feature_tree.rollable_order(); + if order.is_empty() { + return; + } + let position = match self.header.rollback { + RollbackMarker::AtEnd => order.len(), + RollbackMarker::Above(feature) => order + .iter() + .position(|candidate| *candidate == feature) + .unwrap_or(order.len()), + }; + self.header.rollback = RollbackMarker::Above(order[position.saturating_sub(1)]); + } + + #[must_use] + pub fn is_rolled_back(&self, feature: FeatureId) -> bool { + self.header + .feature_tree + .rolled_back(self.header.rollback) + .contains(&feature) + } + + fn place_at_rollback_bar(&mut self, feature: FeatureId) { + if let Some(anchor) = self.header.rollback.feature() { + self.header + .feature_tree + .move_before_keeping_order(feature, anchor); + } + } + + #[must_use] + pub fn suppressed(&self) -> &BTreeSet { + &self.header.suppressed + } + + #[must_use] + pub fn suppression_state(&self, feature: FeatureId) -> SuppressionState { + let effective = self.header.suppressed.contains(&feature) + || self + .header + .feature_tree + .ancestors(feature) + .iter() + .any(|ancestor| self.header.suppressed.contains(ancestor)); + if effective { + SuppressionState::Suppressed + } else { + SuppressionState::Active + } + } + + pub fn suppress(&mut self, feature: FeatureId) { + if self.header.feature_tree.is_datum(feature) { + return; + } + let cascade = self.header.feature_tree.descendants(feature); + self.header.suppressed.insert(feature); + self.header.suppressed.extend(cascade); + } + + pub fn unsuppress(&mut self, feature: FeatureId) { + let cleared = self + .header + .feature_tree + .ancestors(feature) + .into_iter() + .chain(self.header.feature_tree.descendants(feature)) + .chain([feature]); + cleared.for_each(|target| { + self.header.suppressed.remove(&target); + }); + } + pub fn insert_extrude(&mut self, id: ExtrudeId, feature: ExtrudeFeature) { - self.header.feature_tree.push_extrude(id, &feature); + self.header.extrude_high_water = self.header.extrude_high_water.raised_to(key_gen(id)); + let existed = self.header.feature_tree.feature_of_extrude(id).is_some(); + let placed = self.header.feature_tree.push_extrude(id, &feature); + if !existed { + self.place_at_rollback_bar(placed); + } self.header.extrudes.insert(id, feature); self.header .extrude_labels @@ -390,13 +634,16 @@ impl Document { #[must_use] pub fn commit_extrude(&mut self, feature: ExtrudeFeature) -> ExtrudeId { - let id = next_key(self.header.extrudes.keys().copied()); + let id = self.header.allocate_extrude(); self.insert_extrude(id, feature); id } pub fn remove_extrude(&mut self, id: ExtrudeId) -> Option { - self.header.feature_tree.remove_extrude(id); + if let Some(feature) = self.header.feature_tree.feature_of_extrude(id) { + self.header.retire_feature(feature); + self.header.feature_tree.remove_extrude(id); + } self.header.extrude_labels.remove(&id); self.header.extrudes.remove(&id) } @@ -439,7 +686,11 @@ impl Document { self.remove_extrude(*extrude); }); self.header.sketches.remove(id); - self.header.feature_tree.remove_sketch(id); + self.header.sketch_planes.remove(&id); + if let Some(feature) = self.header.feature_tree.feature_of_sketch(id) { + self.header.retire_feature(feature); + self.header.feature_tree.remove_sketch(id); + } self.sketches.remove(&id) } @@ -474,9 +725,10 @@ impl Document { build: impl FnOnce(FeatureId) -> Result, ) -> Result<(FeatureId, BodyId), E> { let feature = self.header.feature_tree.allocate(); - let body = next_key(self.bodies.keys().copied()); + let body = self.allocate_body(); let solid = build(feature)?; self.header.feature_tree.push_imported_body(feature, body); + self.place_at_rollback_bar(feature); self.bodies.insert(body, ImportedSolid::new(solid)); Ok((feature, body)) } @@ -513,15 +765,26 @@ pub(crate) fn key_from_index(idx: u32) -> K { K::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) } -pub(crate) fn next_key(existing: impl Iterator) -> K { - let highest = existing.map(key_index).max().unwrap_or(0); - let Some(next) = highest.checked_add(1) else { +pub(crate) fn key_gen(id: K) -> FeatureGeneration { + if id.is_null() { + FeatureGeneration::START + } else { + FeatureGeneration::new(u64::from(key_index(id))) + } +} + +pub(crate) fn max_gen(ids: impl Iterator) -> FeatureGeneration { + ids.map(key_gen).max().unwrap_or(FeatureGeneration::START) +} + +pub(crate) fn gen_key(mark: FeatureGeneration) -> K { + let Ok(idx) = u32::try_from(mark.value()) else { panic!( "exhausted 32-bit key space for {}", core::any::type_name::() ); }; - key_from_index(next) + key_from_index(idx) } fn id_stem(id: K) -> String { @@ -551,8 +814,14 @@ pub fn body_labels_filename(id: BodyId) -> String { #[cfg(test)] mod tests { use super::feature_tree::sample_blind_extrude; - use super::{Document, RenameExtrudeError, RenameSketchError, Sketch, SketchId}; - use bone_types::{DocumentId, ExtrudeId, Point3, SketchPlaneBasis, Tolerance, UnitVec3}; + use super::{ + BindSketchToFaceError, Document, DocumentHeader, FeatureEdge, RenameExtrudeError, + RenameSketchError, Sketch, SketchId, + }; + use bone_types::{ + DocumentId, ExtrudeId, FaceFingerprint, FaceLabel, FaceRef, FaceRole, FeatureId, Plane3, + Point3, RollbackMarker, SketchPlaneBasis, SuppressionState, Tolerance, UnitVec3, + }; fn xy_basis() -> SketchPlaneBasis { let Ok(basis) = SketchPlaneBasis::new( @@ -573,6 +842,49 @@ mod tests { (document, id) } + #[test] + fn reorder_rejects_moving_extrude_before_its_source_sketch() { + let (mut document, sketch) = doc_with_sketch(); + let extrude = document.commit_extrude(sample_blind_extrude(sketch)); + let tree = document.feature_tree(); + let (Some(sketch_feature), Some(extrude_feature)) = ( + tree.feature_of_sketch(sketch), + tree.feature_of_extrude(extrude), + ) else { + panic!("the chain resolves to feature ids"); + }; + assert!( + !document.reorder_is_legal(extrude_feature, sketch_feature, true), + "an extrude may not precede its source sketch", + ); + assert!(!document.reorder_feature(extrude_feature, sketch_feature, true)); + } + + #[test] + fn reorder_moves_an_independent_feature_and_keeps_the_dag_valid() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let s1 = SketchId::default(); + document.insert_sketch(s1, "Sketch1".to_owned(), Sketch::new(xy_basis())); + let e1 = document.commit_extrude(sample_blind_extrude(s1)); + let s2 = document.allocate_sketch(); + document.insert_sketch(s2, "Sketch2".to_owned(), Sketch::new(xy_basis())); + let e2 = document.commit_extrude(sample_blind_extrude(s2)); + let tree = document.feature_tree(); + let (Some(f1), Some(f2)) = (tree.feature_of_extrude(e1), tree.feature_of_extrude(e2)) + else { + panic!("both extrudes resolve to feature ids"); + }; + assert!( + document.reorder_is_legal(f1, f2, false), + "the first extrude may move after the second", + ); + assert!(document.reorder_feature(f1, f2, false)); + assert!( + document.feature_tree().order_violation().is_none(), + "the reorder leaves every parent before its child", + ); + } + #[test] fn rename_sketch_updates_label() { let (mut document, id) = doc_with_sketch(); @@ -739,6 +1051,287 @@ mod tests { ); } + #[test] + fn commit_after_removing_top_extrude_does_not_reuse_id() { + let (mut document, sketch) = doc_with_sketch(); + let first = document.commit_extrude(sample_blind_extrude(sketch)); + let second = document.commit_extrude(sample_blind_extrude(sketch)); + document.remove_extrude(second); + let third = document.commit_extrude(sample_blind_extrude(sketch)); + assert_ne!( + third, second, + "removing the top extrude must not free its id for reuse" + ); + assert_ne!(third, first); + } + + #[test] + fn allocate_sketch_walks_above_removed_ids() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let first = document.allocate_sketch(); + document.insert_sketch(first, "Sketch1".to_owned(), Sketch::new(xy_basis())); + let second = document.allocate_sketch(); + document.insert_sketch(second, "Sketch2".to_owned(), Sketch::new(xy_basis())); + document.remove_sketch(second); + let third = document.allocate_sketch(); + assert_ne!(first, second); + assert_ne!( + third, second, + "a removed sketch id must not be handed out again" + ); + assert_ne!(third, first); + } + + fn sample_face_ref(body: super::FeatureId) -> FaceRef { + FaceRef::new( + FaceLabel { + feature: body, + role: FaceRole::EndCap, + }, + FaceFingerprint { + plane: Plane3::new_unchecked( + Point3::from_mm(0.0, 0.0, 4.0), + UnitVec3::x_axis(), + UnitVec3::y_axis(), + ), + centroid: Point3::from_mm(0.0, 0.0, 4.0), + }, + ) + } + + #[test] + fn sketch_plane_binding_records_a_face_to_sketch_edge() { + let (mut document, base_sketch) = doc_with_sketch(); + let extrude = document.commit_extrude(sample_blind_extrude(base_sketch)); + let Some(body) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the extrude has a feature node"); + }; + let downstream = document.allocate_sketch(); + document.insert_sketch(downstream, "Sketch2".to_owned(), Sketch::new(xy_basis())); + let face = sample_face_ref(body); + let Ok(()) = document.bind_sketch_to_face(downstream, face) else { + panic!("the face binding is acyclic"); + }; + + assert_eq!(document.sketch_plane_binding(downstream), Some(face)); + let Some(sketch_feature) = document.feature_tree().feature_of_sketch(downstream) else { + panic!("the downstream sketch has a feature node"); + }; + assert!( + document + .feature_tree() + .edges() + .contains(&FeatureEdge::FaceToSketch { + face_feature: body, + sketch: sketch_feature, + }), + "binding a sketch to a face records the dependency edge", + ); + } + + #[test] + fn sketch_plane_binding_round_trips_through_the_header() { + let (mut document, base_sketch) = doc_with_sketch(); + let extrude = document.commit_extrude(sample_blind_extrude(base_sketch)); + let Some(body) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the extrude has a feature node"); + }; + let downstream = document.allocate_sketch(); + document.insert_sketch(downstream, "Sketch2".to_owned(), Sketch::new(xy_basis())); + let face = sample_face_ref(body); + let Ok(()) = document.bind_sketch_to_face(downstream, face) else { + panic!("the face binding is acyclic"); + }; + + let Ok(ron) = crate::io::ron_io::to_string(document.header()) else { + panic!("the header serializes"); + }; + let Ok(back) = crate::io::ron_io::from_str::(&ron) else { + panic!("the header deserializes"); + }; + assert_eq!(back.sketch_planes.get(&downstream).copied(), Some(face)); + } + + #[test] + fn removing_a_sketch_drops_its_plane_binding() { + let (mut document, base_sketch) = doc_with_sketch(); + let extrude = document.commit_extrude(sample_blind_extrude(base_sketch)); + let Some(body) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the extrude has a feature node"); + }; + let downstream = document.allocate_sketch(); + document.insert_sketch(downstream, "Sketch2".to_owned(), Sketch::new(xy_basis())); + let Ok(()) = document.bind_sketch_to_face(downstream, sample_face_ref(body)) else { + panic!("the face binding is acyclic"); + }; + + document.remove_sketch(downstream); + + assert_eq!(document.sketch_plane_binding(downstream), None); + } + + #[test] + fn binding_a_sketch_onto_its_own_descendant_is_rejected_as_cyclic() { + let (mut document, _base_sketch) = doc_with_sketch(); + let downstream = document.allocate_sketch(); + document.insert_sketch(downstream, "Sketch2".to_owned(), Sketch::new(xy_basis())); + let extrude = document.commit_extrude(sample_blind_extrude(downstream)); + let Some(body) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the downstream extrude has a feature node"); + }; + + assert_eq!( + document.bind_sketch_to_face(downstream, sample_face_ref(body)), + Err(BindSketchToFaceError::WouldCycle(body)), + "binding a sketch onto a face of its own descendant must not form a cycle", + ); + assert_eq!( + document.sketch_plane_binding(downstream), + None, + "a rejected bind records no plane", + ); + assert!( + !document + .feature_tree() + .edges() + .iter() + .any(|edge| matches!(edge, FeatureEdge::FaceToSketch { .. })), + "a rejected bind records no edge", + ); + } + + #[test] + fn binding_a_sketch_absent_from_the_tree_is_rejected() { + let (mut document, base_sketch) = doc_with_sketch(); + let extrude = document.commit_extrude(sample_blind_extrude(base_sketch)); + let Some(body) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the extrude has a feature node"); + }; + let stranger = document.allocate_sketch(); + + assert_eq!( + document.bind_sketch_to_face(stranger, sample_face_ref(body)), + Err(BindSketchToFaceError::UnknownSketch(stranger)), + "a sketch with no feature node cannot carry a plane binding", + ); + } + + #[test] + fn removing_a_feature_prunes_its_history_state() { + let (mut document, sketch) = doc_with_sketch(); + let extrude = document.commit_extrude(sample_blind_extrude(sketch)); + let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the extrude has a feature node"); + }; + document.suppress(feature); + document.roll_to_here(feature); + assert!(document.suppressed().contains(&feature)); + + document.remove_extrude(extrude); + + assert!( + !document.suppressed().contains(&feature), + "deleting a feature drops it from the suppressed set", + ); + assert_eq!( + document.rollback(), + RollbackMarker::AtEnd, + "deleting the feature the bar sits on resets the rollback marker", + ); + } + + #[test] + fn removing_a_sketch_prunes_history_state_of_cascaded_features() { + let (mut document, sketch) = doc_with_sketch(); + let extrude = document.commit_extrude(sample_blind_extrude(sketch)); + let Some(sketch_feature) = document.feature_tree().feature_of_sketch(sketch) else { + panic!("the sketch has a feature node"); + }; + let Some(extrude_feature) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the extrude has a feature node"); + }; + document.suppress(extrude_feature); + document.roll_to_here(sketch_feature); + + document.remove_sketch(sketch); + + assert!( + document.suppressed().is_empty(), + "removing a sketch prunes the suppressed set of its cascaded extrude", + ); + assert_eq!( + document.rollback(), + RollbackMarker::AtEnd, + "removing the sketch the bar sits on resets the rollback marker", + ); + } + + #[test] + fn deleting_the_bar_feature_moves_the_bar_to_its_successor() { + let (mut document, first) = doc_with_sketch(); + let second = document.allocate_sketch(); + document.insert_sketch(second, "Sketch2".to_owned(), Sketch::new(xy_basis())); + let (Some(first_feature), Some(second_feature)) = ( + document.feature_tree().feature_of_sketch(first), + document.feature_tree().feature_of_sketch(second), + ) else { + panic!("both sketches resolve to feature nodes"); + }; + document.roll_to_here(first_feature); + + document.remove_sketch(first); + + assert_eq!( + document.rollback(), + RollbackMarker::Above(second_feature), + "deleting the bar feature keeps the remaining tail rolled back", + ); + } + + #[test] + fn suppressing_a_datum_is_ignored() { + let (mut document, _sketch) = doc_with_sketch(); + let Some(datum) = document + .feature_tree() + .iter() + .find_map(|(id, _)| document.feature_tree().is_datum(id).then_some(id)) + else { + panic!("the seeded tree carries datum features"); + }; + + document.suppress(datum); + + assert!( + document.suppressed().is_empty(), + "a datum never enters the suppressed set", + ); + assert_eq!(document.suppression_state(datum), SuppressionState::Active); + } + + #[test] + fn a_dependent_added_after_suppression_reads_suppressed() { + let (mut document, sketch) = doc_with_sketch(); + let Some(sketch_feature) = document.feature_tree().feature_of_sketch(sketch) else { + panic!("the sketch resolves to a feature node"); + }; + document.suppress(sketch_feature); + + let extrude = document.commit_extrude(sample_blind_extrude(sketch)); + let Some(extrude_feature) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the extrude resolves to a feature node"); + }; + + assert_eq!( + document.suppression_state(extrude_feature), + SuppressionState::Suppressed, + "a dependent of a suppressed feature reads suppressed even when added later", + ); + assert!( + !document.suppressed().contains(&extrude_feature), + "the late dependent need not enter the persisted set to read suppressed", + ); + } + #[test] fn reinsert_existing_extrude_preserves_renamed_label() { let (mut document, sketch) = doc_with_sketch(); @@ -749,4 +1342,246 @@ mod tests { document.insert_extrude(id, sample_blind_extrude(sketch)); assert_eq!(document.extrude_label(id), Some("Boss")); } + + fn sketch_extrude_doc() -> (Document, FeatureId, FeatureId) { + let (mut document, sketch) = doc_with_sketch(); + let extrude = document.commit_extrude(sample_blind_extrude(sketch)); + let (Some(sketch_feature), Some(extrude_feature)) = ( + document.feature_tree().feature_of_sketch(sketch), + document.feature_tree().feature_of_extrude(extrude), + ) else { + panic!("the sketch and extrude resolve to feature nodes"); + }; + (document, sketch_feature, extrude_feature) + } + + #[test] + fn roll_to_here_marks_the_contiguous_tail_rolled_back() { + let (mut document, sketch_feature, extrude_feature) = sketch_extrude_doc(); + let second = document.allocate_sketch(); + document.insert_sketch(second, "Sketch2".to_owned(), Sketch::new(xy_basis())); + let Some(second_feature) = document.feature_tree().feature_of_sketch(second) else { + panic!("the second sketch resolves to a feature node"); + }; + + document.roll_to_here(extrude_feature); + + assert_eq!(document.rollback(), RollbackMarker::Above(extrude_feature)); + assert!( + !document.is_rolled_back(sketch_feature), + "a feature above the bar stays live and selectable", + ); + assert!( + document.is_rolled_back(extrude_feature), + "the feature the bar sits on is rolled back", + ); + assert!( + document.is_rolled_back(second_feature), + "every feature after the bar is rolled back, the contiguous-tail rule", + ); + } + + #[test] + fn roll_to_previous_walks_up_one_feature_then_clamps() { + let (mut document, sketch_feature, extrude_feature) = sketch_extrude_doc(); + + assert_eq!(document.rollback(), RollbackMarker::AtEnd); + document.roll_to_previous(); + assert_eq!( + document.rollback(), + RollbackMarker::Above(extrude_feature), + "from the end the bar steps above the last feature", + ); + document.roll_to_previous(); + assert_eq!( + document.rollback(), + RollbackMarker::Above(sketch_feature), + "another step rolls the prior feature back too", + ); + document.roll_to_previous(); + assert_eq!( + document.rollback(), + RollbackMarker::Above(sketch_feature), + "the bar never climbs above the first feature", + ); + + document.roll_to_end(); + assert_eq!( + document.rollback(), + RollbackMarker::AtEnd, + "roll to end clears the marker", + ); + } + + #[test] + fn roll_to_here_ignores_a_datum_feature() { + let (mut document, _sketch_feature, _extrude_feature) = sketch_extrude_doc(); + let Some((origin, _)) = document.feature_tree().iter().next() else { + panic!("the seeded tree opens with the origin datum"); + }; + + document.roll_to_here(origin); + + assert_eq!( + document.rollback(), + RollbackMarker::AtEnd, + "a datum can never carry the rollback bar", + ); + } + + #[test] + fn a_feature_inserted_while_rolled_back_lands_at_the_bar() { + let (mut document, _sketch_feature, extrude_feature) = sketch_extrude_doc(); + document.roll_to_here(extrude_feature); + + let inserted = document.allocate_sketch(); + document.insert_sketch(inserted, "Inserted".to_owned(), Sketch::new(xy_basis())); + let Some(inserted_feature) = document.feature_tree().feature_of_sketch(inserted) else { + panic!("the inserted sketch resolves to a feature node"); + }; + + assert!( + !document.is_rolled_back(inserted_feature), + "a feature inserted while rolled back lands above the bar, active", + ); + assert_eq!( + document.rollback(), + RollbackMarker::Above(extrude_feature), + "inserting at the bar leaves the marker where it was", + ); + let order: Vec = document.feature_tree().iter().map(|(id, _)| id).collect(); + let position = |target: FeatureId| order.iter().position(|other| *other == target); + assert!( + position(inserted_feature) < position(extrude_feature), + "the inserted feature sits immediately above the bar feature, not at the tree end", + ); + } + + #[test] + fn inserting_an_extrude_on_a_rolled_back_sketch_keeps_dependency_order() { + let (mut document, sketch) = doc_with_sketch(); + let Some(sketch_feature) = document.feature_tree().feature_of_sketch(sketch) else { + panic!("the sketch resolves to a feature node"); + }; + document.roll_to_here(sketch_feature); + + let extrude = document.commit_extrude(sample_blind_extrude(sketch)); + let Some(extrude_feature) = document.feature_tree().feature_of_extrude(extrude) else { + panic!("the committed extrude resolves to a feature node"); + }; + + assert!( + document.feature_tree().order_violation().is_none(), + "an extrude committed onto a rolled-back sketch must not precede its parent", + ); + let order: Vec = document.feature_tree().iter().map(|(id, _)| id).collect(); + let position = |target: FeatureId| order.iter().position(|other| *other == target); + assert!( + position(sketch_feature) < position(extrude_feature), + "placing at the bar still lands the child after the parent it depends on", + ); + } + + #[test] + fn editing_an_above_bar_feature_does_not_reorder_history() { + let (mut document, sketch) = doc_with_sketch(); + let first = document.commit_extrude(sample_blind_extrude(sketch)); + let _middle = document.commit_extrude(sample_blind_extrude(sketch)); + let last = document.commit_extrude(sample_blind_extrude(sketch)); + let Some(bar) = document.feature_tree().feature_of_extrude(last) else { + panic!("the last extrude resolves to a feature node"); + }; + document.roll_to_here(bar); + let before: Vec = document.feature_tree().iter().map(|(id, _)| id).collect(); + + document.insert_extrude(first, sample_blind_extrude(sketch)); + + let after: Vec = document.feature_tree().iter().map(|(id, _)| id).collect(); + assert_eq!( + before, after, + "re-inserting an existing feature while rolled back must not relocate it to the bar", + ); + } + + #[test] + fn unsuppressing_a_parent_restores_its_children() { + let (mut document, sketch_feature, extrude_feature) = sketch_extrude_doc(); + + document.suppress(sketch_feature); + assert_eq!( + document.suppression_state(extrude_feature), + SuppressionState::Suppressed, + ); + + document.unsuppress(sketch_feature); + + assert_eq!( + document.suppression_state(sketch_feature), + SuppressionState::Active, + ); + assert_eq!( + document.suppression_state(extrude_feature), + SuppressionState::Active, + "unsuppressing a feature restores the feature and its children", + ); + } + + #[test] + fn suppressing_a_feature_cascades_to_its_dependents() { + let (mut document, sketch_feature, extrude_feature) = sketch_extrude_doc(); + + document.suppress(sketch_feature); + + assert_eq!( + document.suppression_state(sketch_feature), + SuppressionState::Suppressed, + ); + assert_eq!( + document.suppression_state(extrude_feature), + SuppressionState::Suppressed, + "suppressing a feature suppresses its children", + ); + } + + #[test] + fn unsuppressing_a_child_restores_its_parent_but_not_its_sibling() { + let (mut document, sketch) = doc_with_sketch(); + let left = document.commit_extrude(sample_blind_extrude(sketch)); + let right = document.commit_extrude(sample_blind_extrude(sketch)); + let (Some(sketch_feature), Some(left_feature), Some(right_feature)) = ( + document.feature_tree().feature_of_sketch(sketch), + document.feature_tree().feature_of_extrude(left), + document.feature_tree().feature_of_extrude(right), + ) else { + panic!("the sketch and both extrudes resolve to feature nodes"); + }; + + document.suppress(sketch_feature); + assert_eq!( + document.suppression_state(left_feature), + SuppressionState::Suppressed, + ); + assert_eq!( + document.suppression_state(right_feature), + SuppressionState::Suppressed, + ); + + document.unsuppress(left_feature); + + assert_eq!( + document.suppression_state(left_feature), + SuppressionState::Active, + "the unsuppressed child comes back", + ); + assert_eq!( + document.suppression_state(sketch_feature), + SuppressionState::Active, + "unsuppressing a child restores its parent", + ); + assert_eq!( + document.suppression_state(right_feature), + SuppressionState::Suppressed, + "the parent's other child stays suppressed", + ); + } } diff --git a/crates/bone-document/src/evaluator.rs b/crates/bone-document/src/evaluator.rs index 0fcf31f..51fe101 100644 --- a/crates/bone-document/src/evaluator.rs +++ b/crates/bone-document/src/evaluator.rs @@ -1,8 +1,6 @@ -use std::collections::{BTreeMap, BTreeSet, btree_map::Entry}; - use bone_kernel::{BrepError, BrepSolid, ExtrudeFeature}; use bone_solver::SolverError; -use bone_types::{FeatureId, GeometryGeneration}; +use bone_types::{FeatureId, GeometryGeneration, RebuildError}; use crate::Sketch; use crate::profile::build_profile; @@ -11,6 +9,7 @@ use crate::profile::build_profile; pub enum EvaluatedSketch { Solved(Sketch), Failed(SolverError), + PlaneUnresolved(RebuildError), } #[must_use] @@ -27,6 +26,8 @@ pub enum ExtrudeError { UnsolvedSketch(SolverError), #[error(transparent)] Kernel(BrepError), + #[error("{0}")] + PlaneUnresolved(RebuildError), } #[derive(Clone)] @@ -74,6 +75,7 @@ pub fn evaluate_extrude( .and_then(|profile| bone_kernel::evaluate_extrude(extrude, &profile, feature)) .map_err(ExtrudeError::Kernel), EvaluatedSketch::Failed(error) => Err(ExtrudeError::UnsolvedSketch(error.clone())), + EvaluatedSketch::PlaneUnresolved(error) => Err(ExtrudeError::PlaneUnresolved(*error)), }; let generation = result .as_ref() @@ -81,99 +83,3 @@ pub fn evaluate_extrude( .map(|solid| GeometryGeneration::from_solid_key(solid.content_key())); EvaluatedExtrude { result, generation } } - -#[derive(Clone, Debug, Default)] -pub struct FeatureCache { - sketches: BTreeMap>, - extrudes: BTreeMap>, -} - -#[derive(Clone, Debug)] -struct Cached { - input: I, - output: O, -} - -fn memoize( - cache: &mut BTreeMap>, - feature: FeatureId, - matches: impl FnOnce(&I) -> bool, - build_input: impl FnOnce() -> I, - eval: impl FnOnce(&I) -> O, -) -> &O { - let cached = match cache.entry(feature) { - Entry::Occupied(mut slot) => { - if !matches(&slot.get().input) { - let input = build_input(); - let output = eval(&input); - *slot.get_mut() = Cached { input, output }; - } - slot.into_mut() - } - Entry::Vacant(slot) => { - let input = build_input(); - let output = eval(&input); - slot.insert(Cached { input, output }) - } - }; - &cached.output -} - -impl FeatureCache { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - pub fn evaluate(&mut self, feature: FeatureId, input: &Sketch) -> &EvaluatedSketch { - memoize( - &mut self.sketches, - feature, - |cached| cached == input, - || input.clone(), - evaluate_sketch, - ) - } - - pub fn evaluate_extrude( - &mut self, - feature: FeatureId, - sketch: &EvaluatedSketch, - extrude: &ExtrudeFeature, - ) -> &EvaluatedExtrude { - memoize( - &mut self.extrudes, - feature, - |(cached_sketch, cached_extrude)| cached_sketch == sketch && cached_extrude == extrude, - || (sketch.clone(), *extrude), - |(sketch, extrude)| evaluate_extrude(feature, sketch, extrude), - ) - } - - #[must_use] - pub fn contains(&self, feature: FeatureId) -> bool { - self.sketches.contains_key(&feature) || self.extrudes.contains_key(&feature) - } - - pub fn invalidate(&mut self, feature: FeatureId) -> bool { - let sketch = self.sketches.remove(&feature).is_some(); - let extrude = self.extrudes.remove(&feature).is_some(); - sketch || extrude - } - - pub fn retain(&mut self, live: impl IntoIterator) { - let keep: BTreeSet = live.into_iter().collect(); - self.sketches.retain(|id, _| keep.contains(id)); - self.extrudes.retain(|id, _| keep.contains(id)); - } - - #[must_use] - pub fn len(&self) -> usize { - self.sketches.len() + self.extrudes.len() - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.sketches.is_empty() && self.extrudes.is_empty() - } -} diff --git a/crates/bone-document/src/io/folder.rs b/crates/bone-document/src/io/folder.rs index 02d1109..e0f7c91 100644 --- a/crates/bone-document/src/io/folder.rs +++ b/crates/bone-document/src/io/folder.rs @@ -6,11 +6,12 @@ use std::{fs, io}; use bone_kernel::{BrepSolid, ExtrudeFeature}; use bone_types::{ - AngleTolerance, BodyId, ChordHeightTolerance, ExtrudeId, SchemaHeader, SchemaVersion, SketchId, + AngleTolerance, BodyId, ChordHeightTolerance, ExtrudeId, FeatureId, SchemaHeader, + SchemaVersion, SketchId, }; use crate::document::{ - Document, DocumentHeader, ExtrudeFile, FeatureNode, ImportedSolid, SketchFile, + Document, DocumentHeader, ExtrudeFile, FeatureNode, FeatureTree, ImportedSolid, SketchFile, body_brep_filename, body_labels_filename, extrude_filename, sketch_filename, }; use crate::io::blob::{BlobHash, BlobKind}; @@ -96,6 +97,20 @@ pub enum FolderErrorKind { #[source] source: SketchEditError, }, + #[error("feature tree lists feature {id:?} more than once")] + DuplicateFeatureId { id: FeatureId }, + #[error("feature tree has a dependency cycle through feature {id:?}")] + FeatureCycle { id: FeatureId }, + #[error("feature {child:?} precedes its parent {parent:?} in feature order")] + FeatureOrderViolation { parent: FeatureId, child: FeatureId }, + #[error("rollback marker references feature {id:?} absent from the feature tree")] + DanglingRollback { id: FeatureId }, + #[error("rollback marker sits on datum feature {id:?}, which is never rollable")] + RollbackOnDatum { id: FeatureId }, + #[error("suppressed set references feature {id:?} absent from the feature tree")] + DanglingSuppressed { id: FeatureId }, + #[error("suppressed set lists datum feature {id:?}, which is never suppressible")] + SuppressedDatum { id: FeatureId }, #[error("feature tree references sketch {id:?} not in registry")] DanglingTreeSketch { id: SketchId }, #[error("registry has sketch {id:?} absent from feature tree")] @@ -334,8 +349,8 @@ fn tree_body_ids(header: &DocumentHeader) -> BTreeSet { pub fn load(folder: &DocumentFolder) -> Result { let header_path = folder.document_file(); let header_text = read_to_string(&header_path)?; + check_schema(&peek_schema(&header_path, &header_text)?)?; let mut header: DocumentHeader = from_ron(&header_path, &header_text)?; - check_schema(&header.schema)?; let (extrudes, extrude_labels) = read_extrudes(folder, &header)?; header.extrudes = extrudes; header.extrude_labels = extrude_labels; @@ -372,7 +387,27 @@ pub fn load(folder: &DocumentFolder) -> Result { let bodies = read_bodies(folder, &header)?; - Ok(Document::from_parts(header, sketches, bodies)) + let document = Document::from_parts(header, sketches, bodies); + ensure_acyclic(document.feature_tree())?; + ensure_ordered(document.feature_tree())?; + Ok(document) +} + +fn ensure_acyclic(tree: &FeatureTree) -> Result<(), FolderError> { + match tree.find_cycle() { + Some(id) => Err(FolderErrorKind::FeatureCycle { id }.wrap()), + None => Ok(()), + } +} + +fn ensure_ordered(tree: &FeatureTree) -> Result<(), FolderError> { + match tree.order_violation() { + Some(edge) => { + let (parent, child) = edge.endpoints(); + Err(FolderErrorKind::FeatureOrderViolation { parent, child }.wrap()) + } + None => Ok(()), + } } fn read_bodies( @@ -453,6 +488,15 @@ fn read_extrudes( fn validate_header(header: &DocumentHeader) -> Result<(), FolderError> { let tree = &header.feature_tree; + let duplicate = tree + .iter() + .map(|(id, _)| id) + .scan(BTreeSet::new(), |seen, id| Some((id, !seen.insert(id)))) + .find_map(|(id, repeated)| repeated.then_some(id)); + if let Some(id) = duplicate { + return Err(FolderErrorKind::DuplicateFeatureId { id }.wrap()); + } + let registered: BTreeSet = header.sketches.order().iter().copied().collect(); let tree_sketches: BTreeSet = tree .iter() @@ -483,9 +527,35 @@ fn validate_header(header: &DocumentHeader) -> Result<(), FolderError> { .wrap()); } + let live: BTreeSet = tree.iter().map(|(id, _)| id).collect(); + if let Some(id) = header.rollback.feature() { + if !live.contains(&id) { + return Err(FolderErrorKind::DanglingRollback { id }.wrap()); + } + if tree.is_datum(id) { + return Err(FolderErrorKind::RollbackOnDatum { id }.wrap()); + } + } + if let Some(&id) = header.suppressed.difference(&live).next() { + return Err(FolderErrorKind::DanglingSuppressed { id }.wrap()); + } + if let Some(&id) = header.suppressed.iter().find(|&&id| tree.is_datum(id)) { + return Err(FolderErrorKind::SuppressedDatum { id }.wrap()); + } + Ok(()) } +#[derive(serde::Deserialize)] +#[serde(rename = "DocumentHeader")] +struct SchemaProbe { + schema: SchemaHeader, +} + +fn peek_schema(path: &Path, text: &str) -> Result { + from_ron::(path, text).map(|probe| probe.schema) +} + fn check_schema(schema: &SchemaHeader) -> Result<(), FolderError> { if !schema.is_bone_document() { return Err(FolderErrorKind::UnknownSchema { @@ -825,4 +895,71 @@ mod tree_sourced_save { }; assert!(!loaded.header().extrudes.contains_key(&orphan)); } + + #[test] + fn validate_header_rejects_duplicate_feature_id() { + use super::{FolderErrorKind, validate_header}; + use bone_types::BodyId; + + let mut header = DocumentHeader::new(document_id(1), "dup".to_owned()); + let Some((existing, _)) = header.feature_tree.iter().next() else { + panic!("the seeded tree has feature nodes"); + }; + let body = BodyId::from(KeyData::from_ffi((1u64 << 32) | 1)); + header.feature_tree.push_imported_body(existing, body); + + let Err(err) = validate_header(&header) else { + panic!("a feature tree with a repeated id must be rejected"); + }; + assert!(matches!( + err.into_kind(), + FolderErrorKind::DuplicateFeatureId { id } if id == existing + )); + } + + #[test] + fn validate_header_rejects_rollback_on_a_datum() { + use super::{FolderErrorKind, validate_header}; + use bone_types::RollbackMarker; + + let mut header = DocumentHeader::new(document_id(1), "datum".to_owned()); + let Some((datum, _)) = header.feature_tree.iter().next() else { + panic!("the seeded tree opens with a datum"); + }; + header.rollback = RollbackMarker::Above(datum); + + let Err(err) = validate_header(&header) else { + panic!("a rollback marker sitting on a datum must be rejected"); + }; + assert!(matches!( + err.into_kind(), + FolderErrorKind::RollbackOnDatum { id } if id == datum + )); + } + + #[test] + fn ensure_ordered_rejects_a_child_before_its_parent() { + use super::{FolderErrorKind, ensure_ordered}; + use crate::document::Document; + + let mut header = DocumentHeader::new(document_id(1), "order".to_owned()); + let sketch = SketchId::from(KeyData::from_ffi((1u64 << 32) | 1)); + let sketch_feature = header.feature_tree.push_sketch(sketch); + let extrude = extrude_id(1); + let extrude_feature = header.feature_tree.push_extrude(extrude, &blind(sketch)); + header.extrudes.insert(extrude, blind(sketch)); + header + .feature_tree + .move_before(extrude_feature, sketch_feature); + + let doc = Document::from_parts(header, BTreeMap::new(), BTreeMap::new()); + let Err(err) = ensure_ordered(doc.feature_tree()) else { + panic!("a child ordered before its parent must be rejected"); + }; + assert!(matches!( + err.into_kind(), + FolderErrorKind::FeatureOrderViolation { parent, child } + if parent == sketch_feature && child == extrude_feature + )); + } } diff --git a/crates/bone-document/src/lib.rs b/crates/bone-document/src/lib.rs index fb226b2..d6d77c1 100644 --- a/crates/bone-document/src/lib.rs +++ b/crates/bone-document/src/lib.rs @@ -1,29 +1,35 @@ pub mod document; pub mod evaluator; pub mod io; +pub mod matcher; mod profile; +pub mod recompute; pub mod sketch; +#[cfg(test)] +mod test_support; pub mod undo; pub use bone_kernel::{ - BrepError, BrepSolid, DraftAngle, DraftDirection, DraftMagnitude, ExtrudeDirection, + BrepError, BrepFace, BrepSolid, DraftAngle, DraftDirection, DraftMagnitude, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, ProfileDefect, ThinWall, ThinWallDirection, TruckGap, }; pub use document::{ - Document, DocumentHeader, DocumentParameters, ExtrudeFile, FeatureEdge, FeatureNode, - FeatureTree, ImportedSolid, PrincipalPlane, RenameExtrudeError, RenameSketchError, SketchFile, - SketchRegistry, SketchRegistryEntry, UnitsPreference, extrude_filename, sketch_filename, + BindSketchToFaceError, Document, DocumentHeader, DocumentParameters, ExtrudeFile, FeatureEdge, + FeatureNode, FeatureTree, ImportedSolid, PrincipalPlane, RenameExtrudeError, RenameSketchError, + SketchFile, SketchRegistry, SketchRegistryEntry, UnitsPreference, extrude_filename, + sketch_filename, }; pub use evaluator::{ - EvaluatedExtrude, EvaluatedSketch, ExtrudeError, FeatureCache, evaluate_extrude, - evaluate_sketch, + EvaluatedExtrude, EvaluatedSketch, ExtrudeError, evaluate_extrude, evaluate_sketch, }; pub use io::{ BlobHash, BlobKind, DocumentFolder, FolderError, FolderErrorKind, LabelSidecar, RonError, from_str, load, read_solid, read_tessellation, save, to_string, write_solid, write_tessellation, }; +pub use matcher::{ResolvedEntity, ResolvedFace, resolve, resolve_face}; +pub use recompute::{EvaluatedModel, RebuildBudget, RebuildCost, RebuildPass, RecomputeScope}; pub use sketch::{ ArcData, CircleData, DimensionKind, DimensionRefs, DimensionValue, DimensionValueMismatch, EditOutcome, EntityRefs, LineData, PointData, RelationRefs, Sketch, SketchDimension, diff --git a/crates/bone-document/src/matcher.rs b/crates/bone-document/src/matcher.rs new file mode 100644 index 0000000..89ca7e2 --- /dev/null +++ b/crates/bone-document/src/matcher.rs @@ -0,0 +1,423 @@ +use bone_kernel::{BrepFace, BrepSolid}; +use bone_types::{ + BrepFaceId, EntityRef, FaceFingerprint, FaceLabel, FaceRef, FeatureId, Resolution, + SketchPlaneBasis, +}; + +use crate::recompute::{EvaluatedModel, RebuildPass}; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct ResolvedFace { + body: FeatureId, + face: BrepFaceId, + built_at: RebuildPass, +} + +impl ResolvedFace { + #[must_use] + pub const fn body(self) -> FeatureId { + self.body + } + + #[must_use] + pub const fn face(self) -> BrepFaceId { + self.face + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ResolvedEntity { + Face(ResolvedFace), +} + +#[must_use] +pub fn resolve(model: &EvaluatedModel, reference: EntityRef) -> Resolution { + match reference { + EntityRef::Face(label, fingerprint) => { + resolve_face(model, label, fingerprint).map(ResolvedEntity::Face) + } + EntityRef::Edge(..) | EntityRef::Vertex(..) => Resolution::Dangling { + last_known: reference, + }, + } +} + +#[must_use] +pub fn resolve_face( + model: &EvaluatedModel, + label: FaceLabel, + fingerprint: FaceFingerprint, +) -> Resolution { + let dangling = Resolution::Dangling { + last_known: EntityRef::Face(label, fingerprint), + }; + let (Some(solid), Some(built_at)) = (model.body(label.feature), model.built_at(label.feature)) + else { + return dangling; + }; + match unique_face(solid, label) { + Some(face) => Resolution::Resolved(ResolvedFace { + body: label.feature, + face: face.id(), + built_at, + }), + None => dangling, + } +} + +#[must_use] +pub(crate) fn unique_face(solid: &BrepSolid, label: FaceLabel) -> Option<&BrepFace> { + let mut matches = solid.iter_faces().filter(|face| face.label() == label); + let first = matches.next()?; + if matches.next().is_some() { + debug_assert!(false, "face label {label} matched multiple faces"); + return None; + } + Some(first) +} + +impl EvaluatedModel { + #[must_use] + pub fn face(&self, resolved: ResolvedFace) -> Option<&BrepFace> { + (self.built_at(resolved.body) == Some(resolved.built_at)) + .then(|| self.body(resolved.body)) + .flatten() + .and_then(|solid| solid.iter_faces().find(|face| face.id() == resolved.face)) + } + + #[must_use] + pub fn face_ref(&self, resolved: ResolvedFace) -> Option { + let label = self.face(resolved)?.label(); + let fingerprint = self.body(resolved.body)?.face_fingerprint(resolved.face)?; + Some(FaceRef::new(label, fingerprint)) + } + + #[must_use] + pub fn face_ref_at(&self, body: FeatureId, face: BrepFaceId) -> Option { + let solid = self.body(body)?; + let label = solid + .iter_faces() + .find(|candidate| candidate.id() == face)? + .label(); + let fingerprint = solid.face_fingerprint(face)?; + Some(FaceRef::new(label, fingerprint)) + } + + #[must_use] + pub fn face_ref_any(&self, face: BrepFaceId) -> Option { + self.bodies() + .find_map(|(body, _)| self.face_ref_at(body, face)) + } + + #[must_use] + pub fn face_for_sketch(&self, face: BrepFaceId) -> Option<(FaceRef, SketchPlaneBasis)> { + self.bodies().find_map(|(body, solid)| { + let basis = solid.face_plane_basis(face)?; + let face_ref = self.face_ref_at(body, face)?; + Some((face_ref, basis)) + }) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use bone_kernel::BrepFace; + use bone_types::{ + DocumentId, EdgeFingerprint, EdgeLabel, EdgeRole, EntityRef, FaceFingerprint, FaceLabel, + FaceRole, FeatureId, Length, LoopIndex, Plane3, Point3, Resolution, RollbackMarker, + UnitVec3, millimeter, + }; + use proptest::prelude::*; + + use super::{ResolvedEntity, ResolvedFace, resolve, resolve_face}; + use crate::document::Document; + use crate::recompute::{EvaluatedModel, RecomputeScope}; + use crate::sketch::{DimensionValue, SketchEdit}; + use crate::test_support::{ + blind_extrude, chain_handles, circle_with_radius_dim, full_model, push_chain, + }; + + fn placeholder_fingerprint() -> FaceFingerprint { + FaceFingerprint { + plane: Plane3::new_unchecked(Point3::origin(), UnitVec3::x_axis(), UnitVec3::y_axis()), + centroid: Point3::origin(), + } + } + + fn face_labels(model: &EvaluatedModel, body: FeatureId) -> Vec { + let Some(solid) = model.body(body) else { + panic!("body is built"); + }; + solid.iter_faces().map(BrepFace::label).collect() + } + + fn resolved_label(model: &EvaluatedModel, resolved: ResolvedFace) -> FaceLabel { + let Some(face) = model.face(resolved) else { + panic!("a resolved face is present in its model"); + }; + face.label() + } + + #[test] + fn face_ref_at_round_trips_a_picked_face() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let chain = push_chain(&mut document, "Body", 6.0, 4.0); + let model = full_model(&document); + let Some(label) = face_labels(&model, chain.extrude).into_iter().next() else { + panic!("the body has faces"); + }; + let Resolution::Resolved(resolved) = resolve_face(&model, label, placeholder_fingerprint()) + else { + panic!("the label resolves on its own model"); + }; + let Some(face_ref) = model.face_ref_at(resolved.body(), resolved.face()) else { + panic!("a picked face yields a face reference"); + }; + let Resolution::Resolved(ResolvedEntity::Face(again)) = + resolve(&model, face_ref.entity_ref()) + else { + panic!("the reattach reference resolves back to a face"); + }; + assert_eq!( + again.face(), + resolved.face(), + "reattach targets the same face" + ); + assert_eq!( + model + .face_ref_any(resolved.face()) + .map(|reference| reference.label), + Some(face_ref.label), + ); + } + + #[test] + fn face_for_sketch_yields_a_basis_for_a_planar_cap() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let chain = push_chain(&mut document, "Body", 6.0, 4.0); + let model = full_model(&document); + let Some(cap) = face_labels(&model, chain.extrude) + .into_iter() + .find(|label| matches!(label.role, FaceRole::EndCap)) + else { + panic!("the cylinder has a planar cap"); + }; + let Resolution::Resolved(resolved) = resolve_face(&model, cap, placeholder_fingerprint()) + else { + panic!("the cap resolves on its own model"); + }; + assert!( + model.face_for_sketch(resolved.face()).is_some(), + "a planar cap yields a face reference and a sketch basis", + ); + } + + proptest! { + #[test] + fn a_surviving_ref_resolves_and_a_deleted_target_dangles( + kept_radius in 2.0f64..18.0, + doomed_radius in 2.0f64..18.0, + ) { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let kept = push_chain(&mut document, "Keep", kept_radius, 4.0); + let doomed = push_chain(&mut document, "Drop", doomed_radius, 4.0); + let mut model = full_model(&document); + + let Some(survivor) = face_labels(&model, kept.extrude).into_iter().next() else { + return Err(TestCaseError::fail("the kept body has faces")); + }; + let Some(lost) = face_labels(&model, doomed.extrude).into_iter().next() else { + return Err(TestCaseError::fail("the doomed body has faces")); + }; + + document.remove_extrude(doomed.extrude_id); + model.recompute( + &document, + &BTreeSet::new(), + RollbackMarker::AtEnd, + RecomputeScope::Full, + ); + + let Resolution::Resolved(resolved) = + resolve_face(&model, survivor, placeholder_fingerprint()) + else { + return Err(TestCaseError::fail(format!( + "{survivor} must survive a sibling deletion" + ))); + }; + prop_assert_eq!(resolved.body(), kept.extrude); + prop_assert_eq!(resolved_label(&model, resolved), survivor); + + prop_assert_eq!( + resolve_face(&model, lost, placeholder_fingerprint()), + Resolution::Dangling { + last_known: EntityRef::Face(lost, placeholder_fingerprint()), + }, + "a deleted target dangles with its last-known reference", + ); + } + } + + #[test] + fn a_no_match_label_never_binds_to_a_nearest_face() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let sketch_id = document.allocate_sketch(); + let (sketch, circle, _dim) = circle_with_radius_dim(5.0); + document.insert_sketch(sketch_id, "Sketch1".to_owned(), sketch); + let extrude_id = document.commit_extrude(blind_extrude(sketch_id, 4.0)); + let chain = chain_handles(&document, sketch_id, extrude_id); + let model = full_model(&document); + + let absent = FaceLabel { + feature: chain.extrude, + role: FaceRole::Side { + loop_index: LoopIndex::new(u16::MAX), + from: circle, + }, + }; + let resolution = resolve_face(&model, absent, placeholder_fingerprint()); + assert!(resolution.is_dangling()); + assert_eq!(resolution.id(), None, "no nearest-guess id is returned"); + } + + #[test] + fn resolve_routes_a_face_ref_and_dangles_unimplemented_kinds() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let sketch_id = document.allocate_sketch(); + let (sketch, circle, _dim) = circle_with_radius_dim(5.0); + document.insert_sketch(sketch_id, "Sketch1".to_owned(), sketch); + let extrude_id = document.commit_extrude(blind_extrude(sketch_id, 4.0)); + let chain = chain_handles(&document, sketch_id, extrude_id); + let model = full_model(&document); + + let Some(label) = face_labels(&model, chain.extrude).into_iter().next() else { + panic!("the body has faces"); + }; + let Resolution::Resolved(ResolvedEntity::Face(resolved)) = + resolve(&model, EntityRef::Face(label, placeholder_fingerprint())) + else { + panic!("a face reference resolves through the dispatcher"); + }; + assert_eq!(resolved.body(), chain.extrude); + + let edge_ref = EntityRef::Edge( + EdgeLabel { + feature: chain.extrude, + role: EdgeRole::StartCapEdge { from: circle }, + }, + EdgeFingerprint { + sample: Point3::origin(), + direction: UnitVec3::x_axis(), + }, + ); + assert!( + resolve(&model, edge_ref).is_dangling(), + "edge resolution is unimplemented, so it dangles rather than guessing", + ); + } + + #[test] + fn a_resolved_face_goes_stale_when_its_body_rebuilds() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let sketch_id = document.allocate_sketch(); + let (sketch, _circle, radius_dim) = circle_with_radius_dim(5.0); + document.insert_sketch(sketch_id, "Sketch1".to_owned(), sketch); + let extrude_id = document.commit_extrude(blind_extrude(sketch_id, 4.0)); + let chain = chain_handles(&document, sketch_id, extrude_id); + let mut model = full_model(&document); + + let Some(label) = face_labels(&model, chain.extrude).into_iter().next() else { + panic!("the body has faces"); + }; + let Resolution::Resolved(stale) = resolve_face(&model, label, placeholder_fingerprint()) + else { + panic!("the face resolves before the edit"); + }; + assert!( + model.face(stale).is_some(), + "a fresh handle reads back on the model that produced it", + ); + + let Some(original) = document.sketch(sketch_id).cloned() else { + panic!("the sketch is present"); + }; + let Ok((edited, _)) = original.apply(SketchEdit::UpdateDimensionValue { + id: radius_dim, + value: DimensionValue::Length(Length::new::(9.0)), + }) else { + panic!("the driving radius dimension accepts a new value"); + }; + document.replace_sketch(sketch_id, edited); + model.recompute( + &document, + &BTreeSet::new(), + RollbackMarker::AtEnd, + RecomputeScope::Edited(chain.sketch), + ); + + assert!( + model.face(stale).is_none(), + "a handle from before the rebuild must not read a face out of the new arena", + ); + assert!( + resolve_face(&model, label, placeholder_fingerprint()) + .id() + .is_some(), + "re-resolving against the rebuilt model yields a usable handle", + ); + } + + proptest! { + #[test] + fn a_dimension_edit_keeps_every_face_ref_resolved( + radius in 2.0f64..18.0, + edited in 2.0f64..18.0, + ) { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let sketch_id = document.allocate_sketch(); + let (sketch, _circle, radius_dim) = circle_with_radius_dim(radius); + document.insert_sketch(sketch_id, "Sketch1".to_owned(), sketch); + let extrude_id = document.commit_extrude(blind_extrude(sketch_id, 4.0)); + let chain = chain_handles(&document, sketch_id, extrude_id); + let mut model = full_model(&document); + + let labels = face_labels(&model, chain.extrude); + + let Some(original) = document.sketch(sketch_id).cloned() else { + prop_assert!(false, "the sketch is present"); + return Ok(()); + }; + let Ok((edited_sketch, _)) = original.apply(SketchEdit::UpdateDimensionValue { + id: radius_dim, + value: DimensionValue::Length(Length::new::(edited)), + }) else { + prop_assert!(false, "a driving radius dimension accepts a new value"); + return Ok(()); + }; + document.replace_sketch(sketch_id, edited_sketch); + model.recompute( + &document, + &BTreeSet::new(), + RollbackMarker::AtEnd, + RecomputeScope::Edited(chain.sketch), + ); + + let unresolved: Vec = labels + .iter() + .copied() + .filter(|&label| { + match resolve_face(&model, label, placeholder_fingerprint()) { + Resolution::Resolved(resolved) => resolved_label(&model, resolved) != label, + Resolution::Repaired { .. } | Resolution::Dangling { .. } => true, + } + }) + .collect(); + prop_assert!( + unresolved.is_empty(), + "every face ref must resolve to its identical label across a dimension edit, failed: {unresolved:?}", + ); + } + } +} diff --git a/crates/bone-document/src/recompute.rs b/crates/bone-document/src/recompute.rs new file mode 100644 index 0000000..d37f72a --- /dev/null +++ b/crates/bone-document/src/recompute.rs @@ -0,0 +1,950 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use bone_kernel::{BrepSolid, ExtrudeFeature}; +use bone_types::{ + BodyId, BuildFailure, FaceRef, FeatureId, GeometryGeneration, RebuildError, RebuildStatus, + RollbackMarker, +}; + +use crate::Sketch; +use crate::document::{Document, FeatureNode, FeatureTree}; +use crate::evaluator::{ + EvaluatedExtrude, EvaluatedSketch, ExtrudeError, evaluate_extrude, evaluate_sketch, +}; +use crate::matcher::unique_face; + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RebuildPass(u64); + +impl RebuildPass { + #[must_use] + const fn succ(self) -> Self { + Self(self.0.saturating_add(1)) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum RecomputeScope { + Full, + Edited(FeatureId), +} + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RebuildCost(usize); + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RebuildBudget(usize); + +impl RebuildBudget { + pub const INTERACTIVE: Self = Self(2048); + + #[must_use] + pub const fn new(limit: usize) -> Self { + Self(limit) + } +} + +impl RebuildCost { + #[must_use] + pub const fn new(weight: usize) -> Self { + Self(weight) + } + + #[must_use] + pub const fn within(self, budget: RebuildBudget) -> bool { + self.0 <= budget.0 + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum SkipState { + Active, + Suppressed, + RolledBack, +} + +#[derive(Clone)] +enum NodeOutput { + Datum, + Sketch(EvaluatedSketch), + Extrude(EvaluatedExtrude), + Imported(BrepSolid), + Unbuilt, +} + +#[derive(Clone, PartialEq)] +enum ParentDigest { + Datum, + Sketch(EvaluatedSketch), + Geometry(GeometryGeneration), + Absent, +} + +#[derive(Clone, PartialEq)] +enum PayloadKey { + Datum, + Sketch(Sketch, Option), + Extrude(Option), + Imported(BodyId), +} + +#[derive(Clone, PartialEq)] +struct NodeFingerprint { + payload: PayloadKey, + parents: Vec<(FeatureId, ParentDigest)>, +} + +struct NodeMemo { + fingerprint: NodeFingerprint, + output: NodeOutput, + digest: ParentDigest, + skip: SkipState, + status: RebuildStatus, + built_at: RebuildPass, +} + +type Memo = BTreeMap; + +#[derive(Default)] +pub struct EvaluatedModel { + memo: Memo, + order: Vec, + pass: RebuildPass, +} + +impl EvaluatedModel { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn recompute( + &mut self, + document: &Document, + suppressed: &BTreeSet, + rollback: RollbackMarker, + scope: RecomputeScope, + ) { + let tree = document.feature_tree(); + debug_assert!( + tree.find_cycle().is_none(), + "recompute requires an acyclic feature graph", + ); + debug_assert!( + tree.order_violation().is_none(), + "recompute requires every parent to precede its child in feature order", + ); + let live: BTreeSet = tree.iter().map(|(id, _)| id).collect(); + let rolled = tree.rolled_back(rollback); + let suppressed = closed_over_descendants(tree, suppressed); + let pass = self.pass.succ(); + + let retained: Memo = core::mem::take(&mut self.memo) + .into_iter() + .filter(|(id, _)| live.contains(id)) + .collect(); + let dirty = dirty_set(tree, &live, &retained, &suppressed, &rolled, scope); + let order = tree.topo_order(); + + self.memo = order.iter().fold(retained, |mut memo, &id| { + if !dirty.contains(&id) { + return memo; + } + let Some(node) = tree.node(id) else { + return memo; + }; + let fingerprint = NodeFingerprint { + payload: payload_key(document, node), + parents: parent_digests(tree, id, &memo), + }; + let skip = skip_state(id, &suppressed, &rolled); + let unchanged = memo + .get(&id) + .is_some_and(|entry| entry.fingerprint == fingerprint && entry.skip == skip); + if !unchanged { + let entry = build_entry(document, id, node, &memo, fingerprint, skip, pass); + memo.insert(id, entry); + } + memo + }); + self.order = order; + self.pass = pass; + } + + #[must_use] + pub fn status(&self, id: FeatureId) -> Option { + self.memo.get(&id).map(|entry| entry.status) + } + + #[must_use] + pub fn body(&self, id: FeatureId) -> Option<&BrepSolid> { + self.memo + .get(&id) + .and_then(|entry| output_solid(&entry.output)) + } + + pub fn bodies(&self) -> impl Iterator + '_ { + self.order + .iter() + .filter_map(move |&id| self.body(id).map(|solid| (id, solid))) + } + + #[must_use] + pub fn built_at(&self, id: FeatureId) -> Option { + self.memo.get(&id).map(|entry| entry.built_at) + } + + #[must_use] + pub fn rebuild_cost(&self) -> RebuildCost { + RebuildCost( + self.bodies() + .map(|(_, solid)| solid.iter_faces().count() + solid.iter_edges().count()) + .sum(), + ) + } +} + +fn closed_over_descendants(tree: &FeatureTree, seed: &BTreeSet) -> BTreeSet { + seed.iter() + .flat_map(|&feature| tree.descendants(feature)) + .chain(seed.iter().copied()) + .collect() +} + +fn dirty_set( + tree: &FeatureTree, + live: &BTreeSet, + retained: &Memo, + suppressed: &BTreeSet, + rolled: &BTreeSet, + scope: RecomputeScope, +) -> BTreeSet { + let scoped = match scope { + RecomputeScope::Full => live.clone(), + RecomputeScope::Edited(root) => subtree(tree, root), + }; + let appeared = live.iter().copied().filter(|id| !retained.contains_key(id)); + let reskinned = retained + .iter() + .filter(|(id, entry)| entry.skip != skip_state(**id, suppressed, rolled)) + .flat_map(|(id, _)| subtree(tree, *id)); + appeared + .chain(reskinned) + .filter(|id| live.contains(id)) + .fold(scoped, |mut acc, id| { + acc.insert(id); + acc + }) +} + +fn subtree(tree: &FeatureTree, root: FeatureId) -> BTreeSet { + tree.descendants(root).into_iter().chain([root]).collect() +} + +fn skip_state( + id: FeatureId, + suppressed: &BTreeSet, + rolled: &BTreeSet, +) -> SkipState { + match (rolled.contains(&id), suppressed.contains(&id)) { + (true, _) => SkipState::RolledBack, + (false, true) => SkipState::Suppressed, + (false, false) => SkipState::Active, + } +} + +fn payload_key(document: &Document, node: FeatureNode) -> PayloadKey { + match node { + FeatureNode::Origin | FeatureNode::PrincipalPlane(_) => PayloadKey::Datum, + FeatureNode::Sketch(sketch) => document.sketch(sketch).map_or(PayloadKey::Datum, |value| { + PayloadKey::Sketch(value.clone(), document.sketch_plane_binding(sketch)) + }), + FeatureNode::Extrude(extrude) => PayloadKey::Extrude(document.extrude(extrude).copied()), + FeatureNode::ImportedBody(body) => PayloadKey::Imported(body), + } +} + +fn parent_digests( + tree: &FeatureTree, + id: FeatureId, + memo: &Memo, +) -> Vec<(FeatureId, ParentDigest)> { + tree.parents(id) + .into_iter() + .map(|parent| { + let digest = memo + .get(&parent) + .map_or(ParentDigest::Absent, |entry| entry.digest.clone()); + (parent, digest) + }) + .collect() +} + +fn build_entry( + document: &Document, + id: FeatureId, + node: FeatureNode, + memo: &Memo, + fingerprint: NodeFingerprint, + skip: SkipState, + pass: RebuildPass, +) -> NodeMemo { + let output = match skip { + SkipState::Active => node_output(document, id, node, memo), + SkipState::Suppressed | SkipState::RolledBack => NodeOutput::Unbuilt, + }; + let digest = digest_of(&output, skip); + let status = status_of(&output, skip); + NodeMemo { + fingerprint, + output, + digest, + skip, + status, + built_at: pass, + } +} + +fn node_output(document: &Document, id: FeatureId, node: FeatureNode, memo: &Memo) -> NodeOutput { + match node { + FeatureNode::Origin | FeatureNode::PrincipalPlane(_) => NodeOutput::Datum, + FeatureNode::Sketch(sketch) => { + document + .sketch(sketch) + .map_or(NodeOutput::Unbuilt, |value| { + let evaluated = match document.sketch_plane_binding(sketch) { + None => evaluate_sketch(value), + Some(face) => face_bound_sketch(memo, value, face), + }; + NodeOutput::Sketch(evaluated) + }) + } + FeatureNode::Extrude(extrude) => { + let feature = document.extrude(extrude); + let profile = parent_sketch(document.feature_tree(), id, memo); + match (feature, profile) { + (Some(feature), Some(profile)) => { + NodeOutput::Extrude(evaluate_extrude(id, profile, feature)) + } + _ => NodeOutput::Unbuilt, + } + } + FeatureNode::ImportedBody(body) => document + .imported_body(body) + .map_or(NodeOutput::Unbuilt, |solid| { + NodeOutput::Imported(solid.clone()) + }), + } +} + +fn face_bound_sketch(memo: &Memo, value: &Sketch, face: FaceRef) -> EvaluatedSketch { + let Some(entry) = memo.get(&face.label.feature) else { + return EvaluatedSketch::PlaneUnresolved(RebuildError::DanglingReference( + face.entity_ref(), + )); + }; + let Some(solid) = output_solid(&entry.output) else { + return EvaluatedSketch::PlaneUnresolved(RebuildError::UpstreamUnresolved); + }; + let Some(matched) = unique_face(solid, face.label) else { + return EvaluatedSketch::PlaneUnresolved(RebuildError::DanglingReference( + face.entity_ref(), + )); + }; + match solid.face_plane_basis(matched.id()) { + Some(basis) => evaluate_sketch(&value.with_plane(basis)), + None => EvaluatedSketch::PlaneUnresolved(RebuildError::NonPlanarSketchTarget), + } +} + +fn output_solid(output: &NodeOutput) -> Option<&BrepSolid> { + match output { + NodeOutput::Extrude(extrude) => extrude.solid(), + NodeOutput::Imported(solid) => Some(solid), + NodeOutput::Datum | NodeOutput::Sketch(_) | NodeOutput::Unbuilt => None, + } +} + +fn parent_sketch<'memo>( + tree: &FeatureTree, + id: FeatureId, + memo: &'memo Memo, +) -> Option<&'memo EvaluatedSketch> { + tree.parents(id).into_iter().find_map(|parent| { + memo.get(&parent).and_then(|entry| match &entry.output { + NodeOutput::Sketch(sketch) => Some(sketch), + NodeOutput::Datum + | NodeOutput::Extrude(_) + | NodeOutput::Imported(_) + | NodeOutput::Unbuilt => None, + }) + }) +} + +fn digest_of(output: &NodeOutput, skip: SkipState) -> ParentDigest { + match skip { + SkipState::Suppressed | SkipState::RolledBack => ParentDigest::Absent, + SkipState::Active => match output { + NodeOutput::Datum => ParentDigest::Datum, + NodeOutput::Sketch(sketch) => ParentDigest::Sketch(sketch.clone()), + NodeOutput::Extrude(extrude) => extrude + .generation() + .map_or(ParentDigest::Absent, ParentDigest::Geometry), + NodeOutput::Imported(solid) => { + ParentDigest::Geometry(GeometryGeneration::from_solid_key(solid.content_key())) + } + NodeOutput::Unbuilt => ParentDigest::Absent, + }, + } +} + +fn status_of(output: &NodeOutput, skip: SkipState) -> RebuildStatus { + match skip { + SkipState::Suppressed | SkipState::RolledBack => RebuildStatus::UpToDate, + SkipState::Active => match output { + NodeOutput::Datum | NodeOutput::Imported(_) => RebuildStatus::UpToDate, + NodeOutput::Unbuilt => RebuildStatus::NeedsRebuild, + NodeOutput::Sketch(sketch) => match sketch { + EvaluatedSketch::Solved(_) => RebuildStatus::UpToDate, + EvaluatedSketch::Failed(_) => { + RebuildStatus::Error(RebuildError::Build(BuildFailure::UnsolvedSketch)) + } + EvaluatedSketch::PlaneUnresolved(error) => RebuildStatus::Error(*error), + }, + NodeOutput::Extrude(extrude) => match extrude.result() { + Ok(_) => RebuildStatus::UpToDate, + Err(ExtrudeError::UnsolvedSketch(_)) => { + RebuildStatus::Error(RebuildError::Build(BuildFailure::UnsolvedSketch)) + } + Err(ExtrudeError::Kernel(_)) => { + RebuildStatus::Error(RebuildError::Build(BuildFailure::Kernel)) + } + Err(ExtrudeError::PlaneUnresolved(_)) => { + RebuildStatus::Error(RebuildError::UpstreamUnresolved) + } + }, + }, + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use bone_types::{ + BuildFailure, DocumentId, FaceRef, FeatureId, RebuildError, RebuildStatus, RollbackMarker, + }; + use proptest::prelude::*; + + use super::{EvaluatedModel, RebuildBudget, RebuildCost, RecomputeScope}; + use crate::document::Document; + use crate::test_support::{ + blind_extrude, chain_handles, circle_sketch, end_cap_ref, full_model, open_chain_sketch, + placeholder_fingerprint, push_chain, push_face_bound_chain, side_face_label, + }; + + fn body_max_z(model: &EvaluatedModel, body: FeatureId) -> f64 { + let Some(solid) = model.body(body) else { + panic!("the body is built"); + }; + let Some(aabb) = solid.bounding_box() else { + panic!("a built body has a bounding box"); + }; + aabb.max().coords_mm().2 + } + + fn body_min_z(model: &EvaluatedModel, body: FeatureId) -> f64 { + let Some(solid) = model.body(body) else { + panic!("the body is built"); + }; + let Some(aabb) = solid.bounding_box() else { + panic!("a built body has a bounding box"); + }; + aabb.min().coords_mm().2 + } + + fn body_keys(model: &EvaluatedModel) -> Vec<(FeatureId, [u8; 16])> { + model + .bodies() + .map(|(id, solid)| (id, solid.content_key().bytes())) + .collect() + } + + #[test] + fn an_extrude_chain_builds_one_body() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0); + let model = full_model(&document); + + assert!( + model.body(chain.extrude).is_some(), + "the extrude yields a body" + ); + assert_eq!(model.status(chain.extrude), Some(RebuildStatus::UpToDate)); + assert_eq!(model.status(chain.sketch), Some(RebuildStatus::UpToDate)); + assert_eq!(model.bodies().count(), 1, "one extrude, one body"); + } + + #[test] + fn recompute_is_idempotent_without_an_edit() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0); + let mut model = full_model(&document); + let first = model.built_at(chain.extrude); + + model.recompute( + &document, + &BTreeSet::new(), + RollbackMarker::AtEnd, + RecomputeScope::Edited(chain.sketch), + ); + + assert_eq!( + model.built_at(chain.extrude), + first, + "an edit scope with no real input change must not re-evaluate the subtree", + ); + } + + #[test] + fn recompute_cascade_closes_a_raw_suppressed_sketch() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0); + let mut model = EvaluatedModel::new(); + let suppressed = BTreeSet::from([chain.sketch]); + model.recompute( + &document, + &suppressed, + RollbackMarker::AtEnd, + RecomputeScope::Full, + ); + + assert!(model.body(chain.extrude).is_none(), "no profile, no body"); + assert_eq!( + model.status(chain.sketch), + Some(RebuildStatus::UpToDate), + "a suppressed feature is not an error", + ); + assert_eq!( + model.status(chain.extrude), + Some(RebuildStatus::UpToDate), + "recompute closes the suppressed set over descendants, so the child is suppressed not stranded", + ); + } + + #[test] + fn a_cascaded_suppression_marks_the_extrude_suppressed_not_stranded() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0); + document.suppress(chain.sketch); + let mut model = EvaluatedModel::new(); + model.recompute( + &document, + document.suppressed(), + document.rollback(), + RecomputeScope::Full, + ); + + assert!( + model.body(chain.extrude).is_none(), + "a suppressed chain builds no body", + ); + assert_eq!( + model.status(chain.sketch), + Some(RebuildStatus::UpToDate), + "a suppressed feature is not an error", + ); + assert_eq!( + model.status(chain.extrude), + Some(RebuildStatus::UpToDate), + "the cascaded child is suppressed, not a stranded needs-rebuild", + ); + } + + #[test] + fn rolling_back_above_an_extrude_drops_its_body() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0); + let mut model = EvaluatedModel::new(); + model.recompute( + &document, + &BTreeSet::new(), + RollbackMarker::Above(chain.extrude), + RecomputeScope::Full, + ); + + assert!( + model.body(chain.extrude).is_none(), + "a rolled-back extrude is not evaluated", + ); + assert_eq!(model.bodies().count(), 0); + assert_eq!(model.status(chain.sketch), Some(RebuildStatus::UpToDate)); + } + + #[test] + fn a_feature_both_rolled_back_and_suppressed_stays_up_to_date() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0); + let mut model = EvaluatedModel::new(); + let suppressed = BTreeSet::from([chain.extrude]); + model.recompute( + &document, + &suppressed, + RollbackMarker::Above(chain.extrude), + RecomputeScope::Full, + ); + + assert!( + model.body(chain.extrude).is_none(), + "a feature that is both rolled back and suppressed builds nothing", + ); + assert_eq!( + model.status(chain.extrude), + Some(RebuildStatus::UpToDate), + "overlapping skip states collapse to a single up-to-date skip, never an error", + ); + } + + #[test] + fn a_suppression_outside_the_edit_scope_still_takes_effect() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let a = push_chain(&mut document, "Sketch1", 5.0, 4.0); + let b = push_chain(&mut document, "Sketch2", 6.0, 4.0); + let mut model = full_model(&document); + assert!( + model.body(b.extrude).is_some(), + "b builds before suppression" + ); + + document.replace_sketch(a.sketch_id, circle_sketch(7.0)); + let suppressed = BTreeSet::from([b.sketch]); + model.recompute( + &document, + &suppressed, + RollbackMarker::AtEnd, + RecomputeScope::Edited(a.sketch), + ); + + assert!( + model.body(b.extrude).is_none(), + "suppressing a feature outside the edit scope must still strand its body", + ); + assert_eq!( + model.status(b.sketch), + Some(RebuildStatus::UpToDate), + "a suppressed feature is not an error", + ); + assert!( + model.body(a.extrude).is_some(), + "the edited chain still builds" + ); + } + + #[test] + fn a_rollback_outside_the_edit_scope_still_takes_effect() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let a = push_chain(&mut document, "Sketch1", 5.0, 4.0); + let b = push_chain(&mut document, "Sketch2", 6.0, 4.0); + let mut model = full_model(&document); + assert!(model.body(b.extrude).is_some(), "b builds before rollback"); + + document.replace_sketch(a.sketch_id, circle_sketch(7.0)); + model.recompute( + &document, + &BTreeSet::new(), + RollbackMarker::Above(b.extrude), + RecomputeScope::Edited(a.sketch), + ); + + assert!( + model.body(b.extrude).is_none(), + "rolling back a feature outside the edit scope must still drop its body", + ); + } + + #[test] + fn an_unbuildable_profile_reports_a_build_error() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let sketch_id = document.allocate_sketch(); + document.insert_sketch(sketch_id, "Sketch1".to_owned(), open_chain_sketch()); + let extrude_id = document.commit_extrude(blind_extrude(sketch_id, 4.0)); + let chain = chain_handles(&document, sketch_id, extrude_id); + let model = full_model(&document); + + assert_eq!( + model.status(chain.sketch), + Some(RebuildStatus::UpToDate), + "the open chain solves, only the profile is unbuildable", + ); + assert_eq!( + model.status(chain.extrude), + Some(RebuildStatus::Error(RebuildError::Build( + BuildFailure::Kernel + ))), + "an open profile is a hard build error, not a stale mark", + ); + assert!(model.body(chain.extrude).is_none()); + } + + proptest! { + #[test] + fn rebuilding_twice_is_bit_identical(radius in 1.0f64..20.0, depth in 1.0f64..20.0) { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + push_chain(&mut document, "Sketch1", radius, depth); + let first = body_keys(&full_model(&document)); + let second = body_keys(&full_model(&document)); + prop_assert_eq!(first, second, "two rebuilds of one document must agree byte for byte"); + } + + #[test] + fn editing_one_chain_leaves_the_other_untouched( + radius_a in 1.0f64..10.0, + radius_b in 1.0f64..10.0, + edited in 11.0f64..20.0, + ) { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let a = push_chain(&mut document, "Sketch1", radius_a, 4.0); + let b = push_chain(&mut document, "Sketch2", radius_b, 4.0); + let mut model = full_model(&document); + + let b_sketch_before = model.built_at(b.sketch); + let b_extrude_before = model.built_at(b.extrude); + let b_body_before = model.body(b.extrude).map(bone_kernel::BrepSolid::content_key); + let a_extrude_before = model.built_at(a.extrude); + + document.replace_sketch(a.sketch_id, circle_sketch(edited)); + model.recompute( + &document, + &BTreeSet::new(), + RollbackMarker::AtEnd, + RecomputeScope::Edited(a.sketch), + ); + + prop_assert_eq!(model.built_at(b.sketch), b_sketch_before, "sibling sketch must not re-evaluate"); + prop_assert_eq!(model.built_at(b.extrude), b_extrude_before, "sibling extrude must not re-evaluate"); + prop_assert_eq!( + model.body(b.extrude).map(bone_kernel::BrepSolid::content_key), + b_body_before, + "the untouched body must stay byte-identical", + ); + prop_assert!( + model.built_at(a.extrude) > a_extrude_before, + "the edited chain's extrude must rebuild", + ); + } + } + + #[test] + #[cfg_attr( + debug_assertions, + ignore = "frame budget assertions are only meaningful in release builds" + )] + fn single_edit_rebuild_under_frame_budget() { + use std::time::{Duration, Instant}; + + const BASE_MM: f64 = 4.0; + const STEP_MM: f64 = 0.5; + const STEPS: u32 = 16; + + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let chain = push_chain(&mut document, "Sketch1", 5.0, BASE_MM); + let mut model = full_model(&document); + + let step = |document: &mut Document, model: &mut EvaluatedModel, radius_mm: f64| { + document.replace_sketch(chain.sketch_id, circle_sketch(radius_mm)); + let started = Instant::now(); + model.recompute( + document, + &BTreeSet::new(), + RollbackMarker::AtEnd, + RecomputeScope::Edited(chain.sketch), + ); + let elapsed = started.elapsed(); + assert!( + model.body(chain.extrude).is_some(), + "the edit rebuilds a body" + ); + elapsed + }; + + let _warmup = step(&mut document, &mut model, 5.0); + let durations: Vec = (0..STEPS) + .map(|i| step(&mut document, &mut model, 5.0 + STEP_MM * f64::from(i))) + .collect(); + let sorted = { + let mut v = durations.clone(); + v.sort_unstable(); + v + }; + let median = sorted[sorted.len() / 2]; + let budget = Duration::from_millis(16); + assert!( + median <= budget, + "median single-edit recompute {median:?} exceeds {budget:?} frame budget; samples {durations:?}", + ); + } + + #[test] + fn a_face_bound_sketch_builds_a_disjoint_body_on_the_cap() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let base = push_chain(&mut document, "Base", 6.0, 4.0); + let probe = full_model(&document); + let cap = end_cap_ref(&probe, base.extrude); + let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0); + let model = full_model(&document); + + let descendants = document.feature_tree().descendants(base.extrude); + assert!( + descendants.contains(&top.sketch) && descendants.contains(&top.extrude), + "the face binding records a dependency from the cap's body onto the new chain", + ); + assert_eq!(model.status(top.sketch), Some(RebuildStatus::UpToDate)); + assert!(model.body(top.extrude).is_some()); + assert_eq!(model.bodies().count(), 2, "two separate bodies, no boolean"); + + let cap_z = body_max_z(&model, base.extrude); + assert!( + (body_min_z(&model, top.extrude) - cap_z).abs() < 1.0e-6, + "the derived plane sits on the cap so the second body rises from it", + ); + } + + #[test] + fn an_upstream_edit_moves_the_face_bound_body() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let base = push_chain(&mut document, "Base", 6.0, 4.0); + let probe = full_model(&document); + let cap = end_cap_ref(&probe, base.extrude); + let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0); + let mut model = full_model(&document); + let before = model.built_at(top.extrude); + + document.insert_extrude(base.extrude_id, blind_extrude(base.sketch_id, 7.0)); + model.recompute( + &document, + &BTreeSet::new(), + RollbackMarker::AtEnd, + RecomputeScope::Edited(base.extrude), + ); + + assert!( + model.built_at(top.extrude) > before, + "deepening the base re-resolves the face and rebuilds the downstream body", + ); + assert!((body_max_z(&model, base.extrude) - 7.0).abs() < 1.0e-6); + assert!( + (body_min_z(&model, top.extrude) - 7.0).abs() < 1.0e-6, + "the face-bound body follows the cap to its new height", + ); + } + + #[test] + fn a_deleted_cap_dangles_the_face_bound_chain() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let base = push_chain(&mut document, "Base", 6.0, 4.0); + let probe = full_model(&document); + let cap = end_cap_ref(&probe, base.extrude); + let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0); + let mut model = full_model(&document); + assert!(model.body(top.extrude).is_some(), "the chain builds first"); + + document.remove_extrude(base.extrude_id); + model.recompute( + &document, + &BTreeSet::new(), + RollbackMarker::AtEnd, + RecomputeScope::Full, + ); + + assert!( + matches!( + model.status(top.sketch), + Some(RebuildStatus::Error(RebuildError::DanglingReference(_))) + ), + "a removed cap leaves the face-bound sketch dangling, never a silent wrong plane", + ); + assert_eq!( + model.status(top.extrude), + Some(RebuildStatus::Error(RebuildError::UpstreamUnresolved)), + "the downstream extrude fails as upstream-unresolved, not as a dangling owner", + ); + assert!(model.body(top.extrude).is_none()); + } + + #[test] + fn a_non_planar_target_is_a_typed_rebuild_error() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let base = push_chain(&mut document, "Base", 6.0, 4.0); + let probe = full_model(&document); + let side = side_face_label(&probe, base.extrude); + let top = push_face_bound_chain( + &mut document, + "Top", + FaceRef::new(side, placeholder_fingerprint()), + 1.0, + 2.0, + ); + let model = full_model(&document); + + assert_eq!( + model.status(top.sketch), + Some(RebuildStatus::Error(RebuildError::NonPlanarSketchTarget)), + "a sketch bound to a cylindrical side fails with the non-planar contract", + ); + assert!(model.body(top.extrude).is_none()); + } + + #[test] + fn rebuild_cost_sums_topology_and_gates_on_the_budget() { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + push_chain(&mut document, "Sketch1", 5.0, 4.0); + let model = full_model(&document); + let cost = model.rebuild_cost(); + + assert!( + cost > RebuildCost::default(), + "a built body contributes faces and edges to the cost", + ); + assert!( + cost.within(RebuildBudget::INTERACTIVE), + "a single extrude stays under the interactive budget, so the rebuild is eager", + ); + assert!( + !cost.within(RebuildBudget::new(0)), + "a zero budget forces the deferred path for any non-empty model", + ); + assert_eq!( + EvaluatedModel::new().rebuild_cost(), + RebuildCost::default(), + "an empty model costs nothing to rebuild", + ); + } + + proptest! { + #[test] + fn the_face_bound_body_tracks_the_cap_across_depth_edits( + base_depth in 2.0f64..12.0, + edited_depth in 2.0f64..12.0, + ) { + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); + let base = push_chain(&mut document, "Base", 6.0, base_depth); + let probe = full_model(&document); + let cap = end_cap_ref(&probe, base.extrude); + let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0); + let mut model = full_model(&document); + + document.insert_extrude(base.extrude_id, blind_extrude(base.sketch_id, edited_depth)); + model.recompute( + &document, + &BTreeSet::new(), + RollbackMarker::AtEnd, + RecomputeScope::Edited(base.extrude), + ); + + prop_assert!( + (body_min_z(&model, top.extrude) - edited_depth).abs() < 1.0e-6, + "the face-bound body always rises from the current cap height", + ); + } + } +} diff --git a/crates/bone-document/src/sketch/mod.rs b/crates/bone-document/src/sketch/mod.rs index 0eef878..874cba0 100644 --- a/crates/bone-document/src/sketch/mod.rs +++ b/crates/bone-document/src/sketch/mod.rs @@ -85,6 +85,14 @@ impl Sketch { self.plane } + #[must_use] + pub fn with_plane(&self, plane: SketchPlaneBasis) -> Self { + Self { + plane, + ..self.clone() + } + } + #[must_use] pub fn entities(&self) -> &EntityMap { &self.entities diff --git a/crates/bone-document/src/test_support.rs b/crates/bone-document/src/test_support.rs new file mode 100644 index 0000000..428d2a7 --- /dev/null +++ b/crates/bone-document/src/test_support.rs @@ -0,0 +1,205 @@ +use std::collections::BTreeSet; + +use bone_kernel::{ + BrepFace, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, +}; +use bone_types::{ + ExtrudeId, FaceFingerprint, FaceLabel, FaceRef, FaceRole, FeatureId, Length, Plane3, Point2, + Point3, PositiveLength, Resolution, RollbackMarker, SketchDimensionId, SketchEntityId, + SketchId, SketchPlaneBasis, Tolerance, UnitVec3, millimeter, +}; + +use crate::Sketch; +use crate::document::Document; +use crate::matcher::resolve_face; +use crate::recompute::{EvaluatedModel, RecomputeScope}; +use crate::sketch::{DimensionKind, EditOutcome, SketchDimension, SketchEdit, SketchEntity}; + +pub(crate) fn xy_basis() -> SketchPlaneBasis { + let Ok(basis) = SketchPlaneBasis::new( + Point3::origin(), + UnitVec3::x_axis(), + UnitVec3::y_axis(), + Tolerance::new(1e-9), + ) else { + panic!("xy plane basis is orthogonal"); + }; + basis +} + +pub(crate) fn add_point(sketch: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { + let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( + SketchEntity::point(Point2::from_mm(x, y)), + )) else { + panic!("add point"); + }; + (next, id) +} + +fn circle_entities(radius_mm: f64) -> (Sketch, SketchEntityId) { + let (sketch, center) = add_point(Sketch::new(xy_basis()), 0.0, 0.0); + let Ok((next, EditOutcome::Entity(circle))) = sketch.apply(SketchEdit::AddEntity( + SketchEntity::circle(center, Length::new::(radius_mm), false), + )) else { + panic!("add circle"); + }; + (next, circle) +} + +pub(crate) fn circle_sketch(radius_mm: f64) -> Sketch { + circle_entities(radius_mm).0 +} + +pub(crate) fn circle_with_radius_dim( + radius_mm: f64, +) -> (Sketch, SketchEntityId, SketchDimensionId) { + let (sketch, circle) = circle_entities(radius_mm); + let Ok((next, EditOutcome::Dimension(dim))) = + sketch.apply(SketchEdit::AddDimension(SketchDimension::Radius { + target: circle, + value: Length::new::(radius_mm), + kind: DimensionKind::Driving, + })) + else { + panic!("add radius dimension"); + }; + (next, circle, dim) +} + +pub(crate) fn open_chain_sketch() -> Sketch { + let add_line = |sketch: Sketch, from: SketchEntityId, to: SketchEntityId| { + let Ok((next, _)) = + sketch.apply(SketchEdit::AddEntity(SketchEntity::line(from, to, false))) + else { + panic!("add line"); + }; + next + }; + let (sketch, a) = add_point(Sketch::new(xy_basis()), 0.0, 0.0); + let (sketch, b) = add_point(sketch, 10.0, 0.0); + let (sketch, c) = add_point(sketch, 10.0, 5.0); + add_line(add_line(sketch, a, b), b, c) +} + +pub(crate) fn blind_extrude(sketch: SketchId, depth_mm: f64) -> ExtrudeFeature { + let Ok(depth) = PositiveLength::new(Length::new::(depth_mm)) else { + panic!("{depth_mm} mm 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, + } +} + +pub(crate) struct Chain { + pub(crate) sketch: FeatureId, + pub(crate) extrude: FeatureId, + pub(crate) sketch_id: SketchId, + pub(crate) extrude_id: ExtrudeId, +} + +pub(crate) fn chain_handles( + document: &Document, + sketch_id: SketchId, + extrude_id: ExtrudeId, +) -> Chain { + let tree = document.feature_tree(); + let (Some(sketch), Some(extrude)) = ( + tree.feature_of_sketch(sketch_id), + tree.feature_of_extrude(extrude_id), + ) else { + panic!("inserted chain resolves to feature ids"); + }; + Chain { + sketch, + extrude, + sketch_id, + extrude_id, + } +} + +pub(crate) fn push_chain( + document: &mut Document, + label: &str, + radius_mm: f64, + depth_mm: f64, +) -> Chain { + let sketch_id = document.allocate_sketch(); + document.insert_sketch(sketch_id, label.to_owned(), circle_sketch(radius_mm)); + let extrude_id = document.commit_extrude(blind_extrude(sketch_id, depth_mm)); + chain_handles(document, sketch_id, extrude_id) +} + +pub(crate) fn placeholder_fingerprint() -> FaceFingerprint { + FaceFingerprint { + plane: Plane3::new_unchecked(Point3::origin(), UnitVec3::x_axis(), UnitVec3::y_axis()), + centroid: Point3::origin(), + } +} + +fn face_label_of( + model: &EvaluatedModel, + body: FeatureId, + matches_role: impl Fn(FaceRole) -> bool, +) -> FaceLabel { + let Some(solid) = model.body(body) else { + panic!("the body is built"); + }; + let Some(label) = solid + .iter_faces() + .map(BrepFace::label) + .find(|label| matches_role(label.role)) + else { + panic!("the body carries the requested face role"); + }; + label +} + +pub(crate) fn end_cap_ref(model: &EvaluatedModel, body: FeatureId) -> FaceRef { + let label = face_label_of(model, body, |role| matches!(role, FaceRole::EndCap)); + let Resolution::Resolved(resolved) = resolve_face(model, label, placeholder_fingerprint()) + else { + panic!("the end cap resolves on the model that built it"); + }; + let Some(face_ref) = model.face_ref(resolved) else { + panic!("a planar cap yields a face reference"); + }; + face_ref +} + +pub(crate) fn side_face_label(model: &EvaluatedModel, body: FeatureId) -> FaceLabel { + face_label_of(model, body, |role| matches!(role, FaceRole::Side { .. })) +} + +pub(crate) fn push_face_bound_chain( + document: &mut Document, + label: &str, + face: FaceRef, + radius_mm: f64, + depth_mm: f64, +) -> Chain { + let sketch_id = document.allocate_sketch(); + document.insert_sketch(sketch_id, label.to_owned(), circle_sketch(radius_mm)); + let Ok(()) = document.bind_sketch_to_face(sketch_id, face) else { + panic!("the face binding is acyclic"); + }; + let extrude_id = document.commit_extrude(blind_extrude(sketch_id, depth_mm)); + chain_handles(document, sketch_id, extrude_id) +} + +pub(crate) fn full_model(document: &Document) -> EvaluatedModel { + let mut model = EvaluatedModel::new(); + model.recompute( + document, + &BTreeSet::new(), + RollbackMarker::AtEnd, + RecomputeScope::Full, + ); + model +} diff --git a/crates/bone-document/src/undo.rs b/crates/bone-document/src/undo.rs index 6193fae..9761cd1 100644 --- a/crates/bone-document/src/undo.rs +++ b/crates/bone-document/src/undo.rs @@ -23,7 +23,7 @@ impl UndoStack { pub fn record(&mut self, previous: Document) { self.future.clear(); - if self.past.len() == self.capacity.get() { + while self.past.len() >= self.capacity.get() { self.past.pop_front(); } self.past.push_back(previous); diff --git a/crates/bone-document/tests/evaluator.rs b/crates/bone-document/tests/evaluator.rs index 085fd78..8a5d483 100644 --- a/crates/bone-document/tests/evaluator.rs +++ b/crates/bone-document/tests/evaluator.rs @@ -1,7 +1,6 @@ use bone_document::{ - DimensionKind, DimensionValue, Document, EditOutcome, EvaluatedSketch, FeatureCache, - FeatureNode, Sketch, SketchDimension, SketchEdit, SketchEntity, SketchRelation, - evaluate_extrude, evaluate_sketch, + DimensionKind, DimensionValue, Document, EditOutcome, EvaluatedSketch, FeatureNode, Sketch, + SketchDimension, SketchEdit, SketchEntity, SketchRelation, evaluate_extrude, evaluate_sketch, }; use bone_kernel::{ BrepEdge, BrepFace, BrepSolid, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, @@ -136,69 +135,6 @@ fn evaluate_sketch_returns_failed_variant_on_conflict() { ); } -#[test] -fn feature_cache_reuses_solution_for_identical_input() { - let sketch = horizontal_line_sketch(10.0); - let mut cache = FeatureCache::new(); - let fid = feature_id(7); - let first = cache.evaluate(fid, &sketch).clone(); - let second = cache.evaluate(fid, &sketch).clone(); - assert_eq!(first, second); - assert_eq!(cache.len(), 1); - assert!(cache.contains(fid)); -} - -#[test] -fn feature_cache_refreshes_on_input_change() { - let mut cache = FeatureCache::new(); - let fid = feature_id(7); - let first = cache.evaluate(fid, &horizontal_line_sketch(10.0)).clone(); - let second = cache.evaluate(fid, &horizontal_line_sketch(20.0)).clone(); - let EvaluatedSketch::Solved(first_solved) = first else { - panic!("first Solved"); - }; - let EvaluatedSketch::Solved(second_solved) = second else { - panic!("second Solved"); - }; - assert_ne!(first_solved, second_solved); - assert_eq!(cache.len(), 1); -} - -#[test] -fn feature_cache_keeps_entries_per_feature() { - let mut cache = FeatureCache::new(); - let a = feature_id(1); - let b = feature_id(2); - cache.evaluate(a, &horizontal_line_sketch(10.0)); - cache.evaluate(b, &horizontal_line_sketch(20.0)); - assert_eq!(cache.len(), 2); - assert!(cache.contains(a)); - assert!(cache.contains(b)); -} - -#[test] -fn feature_cache_invalidate_drops_entry() { - let mut cache = FeatureCache::new(); - let fid = feature_id(7); - cache.evaluate(fid, &horizontal_line_sketch(10.0)); - assert!(cache.invalidate(fid)); - assert!(!cache.contains(fid)); - assert!(!cache.invalidate(fid)); -} - -#[test] -fn feature_cache_retain_drops_absent_features() { - let mut cache = FeatureCache::new(); - let keep = feature_id(1); - let drop = feature_id(2); - cache.evaluate(keep, &horizontal_line_sketch(10.0)); - cache.evaluate(drop, &horizontal_line_sketch(20.0)); - cache.retain([keep]); - assert!(cache.contains(keep)); - assert!(!cache.contains(drop)); - assert_eq!(cache.len(), 1); -} - #[test] fn document_resolves_feature_id_back_to_sketch() { let mut doc = Document::new(document_id(1), "d".to_owned()); @@ -220,7 +156,7 @@ fn document_resolves_feature_id_back_to_sketch() { } #[test] -fn feature_cache_refreshes_when_feature_id_is_reused() { +fn removed_feature_id_is_never_reused() { let mut doc = Document::new(document_id(1), "d".to_owned()); let sid_a = sketch_id(10); let sid_b = sketch_id(11); @@ -231,11 +167,10 @@ fn feature_cache_refreshes_when_feature_id_is_reused() { panic!("A feature present"); }; - let mut cache = FeatureCache::new(); let Some(sketch_a) = doc.sketch(sid_a) else { panic!("A in map"); }; - let EvaluatedSketch::Solved(solved_a) = cache.evaluate(fid_a, sketch_a).clone() else { + let EvaluatedSketch::Solved(solved_a) = evaluate_sketch(sketch_a) else { panic!("A solves"); }; @@ -246,18 +181,21 @@ fn feature_cache_refreshes_when_feature_id_is_reused() { }) else { panic!("B feature present"); }; - assert_eq!( + assert_ne!( fid_a, fid_b, - "precondition: FeatureTree reuses the id of a removed feature" + "a removed feature id is never handed to a later feature" ); let Some(sketch_b) = doc.sketch(sid_b) else { panic!("B in map"); }; - let EvaluatedSketch::Solved(solved_b) = cache.evaluate(fid_b, sketch_b).clone() else { + let EvaluatedSketch::Solved(solved_b) = evaluate_sketch(sketch_b) else { panic!("B solves"); }; - assert_ne!(solved_a, solved_b, "cache must refresh on reused FeatureId"); + assert_ne!( + solved_a, solved_b, + "distinct feature ids cache distinct geometry" + ); } fn dimensioned_rectangle(width_mm: f64, height_mm: f64) -> (Sketch, SketchDimensionId) { @@ -352,36 +290,6 @@ fn evaluate_extrude_reports_failed_sketch() { assert!(extrude.generation().is_none()); } -#[test] -fn feature_cache_extrude_refreshes_when_sketch_changes() { - let mut cache = FeatureCache::new(); - let sketch_feature = feature_id(20); - let extrude_feature = feature_id(21); - let feature = blind(8.0); - - let (narrow, _) = dimensioned_rectangle(10.0, 5.0); - let solved_narrow = cache.evaluate(sketch_feature, &narrow).clone(); - let first = cache - .evaluate_extrude(extrude_feature, &solved_narrow, &feature) - .generation(); - let cached = cache - .evaluate_extrude(extrude_feature, &solved_narrow, &feature) - .generation(); - assert_eq!(first, cached, "identical input hits the cache"); - - let (wide, _) = dimensioned_rectangle(25.0, 5.0); - let solved_wide = cache.evaluate(sketch_feature, &wide).clone(); - let refreshed = cache - .evaluate_extrude(extrude_feature, &solved_wide, &feature) - .generation(); - assert_ne!( - first, refreshed, - "a changed upstream sketch refreshes the extrude" - ); - assert!(cache.contains(extrude_feature)); - assert_eq!(cache.len(), 2); -} - proptest::proptest! { #[test] fn extrude_re_evaluates_under_edited_width(width_mm in 2.0f64..40.0) { diff --git a/crates/bone-document/tests/folder_jj.rs b/crates/bone-document/tests/folder_jj.rs index 01c4b20..d84bf2a 100644 --- a/crates/bone-document/tests/folder_jj.rs +++ b/crates/bone-document/tests/folder_jj.rs @@ -171,6 +171,10 @@ fn jj_diff_on_dimension_edit_is_text_only() { diff.contains("sketches/"), "expected sketch file in diff:\n{diff}" ); + assert!( + !diff.contains("document.ron"), + "a dimension edit must leave the recipe stable, only the sketch file changes:\n{diff}" + ); assert!( diff.contains('+') && diff.contains('-'), "expected text diff markers:\n{diff}" diff --git a/crates/bone-document/tests/folder_roundtrip.rs b/crates/bone-document/tests/folder_roundtrip.rs index ba6ec2d..bd1d5dd 100644 --- a/crates/bone-document/tests/folder_roundtrip.rs +++ b/crates/bone-document/tests/folder_roundtrip.rs @@ -7,8 +7,9 @@ use bone_kernel::{ BrepFace, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, }; use bone_types::{ - Angle, DocumentId, ExtrudeId, Length, Point2, Point3, PositiveLength, SketchEntityId, SketchId, - SketchPlaneBasis, Tolerance, UnitVec3, degree, millimeter, + Angle, DocumentId, ExtrudeId, FaceFingerprint, FaceLabel, FaceRef, FaceRole, FeatureId, Length, + Plane3, Point2, Point3, PositiveLength, RollbackMarker, SketchEntityId, SketchId, + SketchPlaneBasis, SuppressionState, Tolerance, UnitVec3, degree, millimeter, }; use slotmap::{Key, KeyData}; use tempfile::{TempDir, tempdir}; @@ -52,6 +53,10 @@ fn extrude_id(idx: u32) -> ExtrudeId { ExtrudeId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) } +fn feature_id(idx: u32) -> FeatureId { + FeatureId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) +} + fn blind_extrude(sketch: SketchId) -> ExtrudeFeature { let Ok(depth) = PositiveLength::new(mm(10.0)) else { panic!("positive depth"); @@ -68,6 +73,30 @@ fn blind_extrude(sketch: SketchId) -> ExtrudeFeature { } } +fn separate_extrude(sketch: SketchId) -> ExtrudeFeature { + ExtrudeFeature { + merge_result: MergeResult::Separate, + ..blind_extrude(sketch) + } +} + +fn sample_face_ref(feature: FeatureId) -> FaceRef { + FaceRef::new( + FaceLabel { + feature, + role: FaceRole::EndCap, + }, + FaceFingerprint { + plane: Plane3::new_unchecked( + Point3::from_mm(0.0, 0.0, 10.0), + UnitVec3::x_axis(), + UnitVec3::y_axis(), + ), + centroid: Point3::from_mm(0.0, 0.0, 10.0), + }, + ) +} + fn rectangle() -> Sketch { let script = [ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), @@ -107,6 +136,30 @@ fn write_file(path: &std::path::Path, contents: &str) { }; } +fn folder_bytes(root: &std::path::Path) -> std::collections::BTreeMap> { + let Ok(entries) = std::fs::read_dir(root) else { + panic!("read dir {}", root.display()); + }; + entries + .map(|entry| { + let Ok(entry) = entry else { + panic!("dir entry under {}", root.display()); + }; + entry.path() + }) + .flat_map(|path| { + if path.is_dir() { + folder_bytes(&path).into_iter().collect::>() + } else { + let Ok(bytes) = std::fs::read(&path) else { + panic!("read {}", path.display()); + }; + vec![(path, bytes)] + } + }) + .collect() +} + #[test] fn rectangle_roundtrips_through_folder() { let dir = ok_dir(); @@ -229,7 +282,10 @@ fn load_refuses_unknown_schema_major() { else { panic!("expected UnsupportedMajor"); }; - assert_eq!(found, SchemaVersion::new(9999, 2)); + assert_eq!( + found, + SchemaVersion::new(9999, SchemaHeader::BONE_DOCUMENT_MINOR) + ); assert_eq!( supported, SchemaVersion::new( @@ -239,6 +295,32 @@ fn load_refuses_unknown_schema_major() { ); } +#[test] +fn load_reports_unsupported_major_ahead_of_unknown_fields() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("future_major.bone")); + let mut doc = Document::new(document_id(1), "future".to_owned()); + doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle()); + assert_save(&doc, &folder); + + let header_path = folder.document_file(); + let text = read_file(&header_path); + let bumped = text.replace("major: 1,", "major: 9999,"); + assert_ne!(bumped, text, "schema major pattern missing"); + let injected = bumped.replace( + "DocumentHeader(\n schema:", + "DocumentHeader(\n mystery_field: 7,\n schema:", + ); + assert_ne!(injected, bumped, "document header anchor missing"); + write_file(&header_path, &injected); + + let result = load(&folder).map_err(bone_document::FolderError::into_kind); + let Err(bone_document::FolderErrorKind::UnsupportedMajor { found, .. }) = result else { + panic!("a newer major must refuse outright, not surface a raw field error: {result:?}"); + }; + assert_eq!(found.major, 9999); +} + #[test] fn load_refuses_unknown_schema_name() { let dir = ok_dir(); @@ -347,27 +429,41 @@ fn loaded_document_preserves_slotmap_keys() { } #[test] -fn feature_tree_bytes_survive_insert_remove_insert_cycle() { +fn folder_reserves_removed_ids_across_roundtrip() { let dir = ok_dir(); - let with_history = DocumentFolder::new(dir.path().join("hist.bone")); - let fresh = DocumentFolder::new(dir.path().join("fresh.bone")); + let folder = DocumentFolder::new(dir.path().join("hist.bone")); - let rect = rectangle(); - let mut doc_a = Document::new(document_id(1), "doc".to_owned()); - doc_a.insert_sketch(sketch_id(1), "S".to_owned(), rect.clone()); - doc_a.insert_sketch(sketch_id(2), "T".to_owned(), rect.clone()); - doc_a.remove_sketch(sketch_id(2)); - assert_save(&doc_a, &with_history); - - let mut doc_b = Document::new(document_id(1), "doc".to_owned()); - doc_b.insert_sketch(sketch_id(1), "S".to_owned(), rect); - assert_save(&doc_b, &fresh); - - let bytes_a = read_file(&with_history.document_file()); - let bytes_b = read_file(&fresh.document_file()); + let mut doc = Document::new(document_id(1), "doc".to_owned()); + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); + doc.insert_sketch(sketch_id(2), "T".to_owned(), rectangle()); + let Some(removed_feature) = doc.feature_tree().feature_of_sketch(sketch_id(2)) else { + panic!("feature node for sketch 2"); + }; + doc.remove_sketch(sketch_id(2)); + assert_save(&doc, &folder); + + let Ok(mut reloaded) = load(&folder) else { + panic!("reload"); + }; assert_eq!( - bytes_a, bytes_b, - "remove then re-insert leaks tombstones into document.ron" + reloaded.registry().order().len(), + 1, + "the recipe lists only the live sketch, not a tombstone vector" + ); + + let reborn_sketch = reloaded.allocate_sketch(); + assert_ne!( + reborn_sketch, + sketch_id(2), + "a removed sketch id stays reserved across the folder round-trip" + ); + reloaded.insert_sketch(reborn_sketch, "U".to_owned(), rectangle()); + let Some(reborn_feature) = reloaded.feature_tree().feature_of_sketch(reborn_sketch) else { + panic!("feature node for the reborn sketch"); + }; + assert_ne!( + reborn_feature, removed_feature, + "a removed feature id stays reserved across the folder round-trip" ); } @@ -561,6 +657,90 @@ fn load_refuses_dangling_feature_tree_sketch() { assert_eq!(id, sketch_id(99)); } +#[test] +fn load_refuses_dangling_rollback_marker() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("dangle_rollback.bone")); + let mut doc = Document::new(document_id(1), "dangle".to_owned()); + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); + assert_save(&doc, &folder); + + let bogus = feature_id(9999); + patch_header(&folder.document_file(), |h| { + h.rollback = RollbackMarker::Above(bogus); + }); + + let result = load(&folder).map_err(bone_document::FolderError::into_kind); + let Err(bone_document::FolderErrorKind::DanglingRollback { id }) = result else { + panic!("expected DanglingRollback, got {result:?}"); + }; + assert_eq!(id, bogus); +} + +#[test] +fn load_refuses_rollback_marker_on_a_datum() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("rollback_datum.bone")); + let mut doc = Document::new(document_id(1), "datum".to_owned()); + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); + let Some((datum, _)) = doc.feature_tree().iter().next() else { + panic!("the seeded tree opens with a datum"); + }; + assert_save(&doc, &folder); + + patch_header(&folder.document_file(), |h| { + h.rollback = RollbackMarker::Above(datum); + }); + + let result = load(&folder).map_err(bone_document::FolderError::into_kind); + let Err(bone_document::FolderErrorKind::RollbackOnDatum { id }) = result else { + panic!("expected RollbackOnDatum, got {result:?}"); + }; + assert_eq!(id, datum); +} + +#[test] +fn load_refuses_dangling_suppressed_feature() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("dangle_suppressed.bone")); + let mut doc = Document::new(document_id(1), "dangle".to_owned()); + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); + assert_save(&doc, &folder); + + let bogus = feature_id(9999); + patch_header(&folder.document_file(), |h| { + h.suppressed.insert(bogus); + }); + + let result = load(&folder).map_err(bone_document::FolderError::into_kind); + let Err(bone_document::FolderErrorKind::DanglingSuppressed { id }) = result else { + panic!("expected DanglingSuppressed, got {result:?}"); + }; + assert_eq!(id, bogus); +} + +#[test] +fn load_refuses_suppressed_datum_feature() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("suppressed_datum.bone")); + let mut doc = Document::new(document_id(1), "datum".to_owned()); + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); + let Some((datum, _)) = doc.feature_tree().iter().next() else { + panic!("the seeded tree opens with a datum"); + }; + assert_save(&doc, &folder); + + patch_header(&folder.document_file(), |h| { + h.suppressed.insert(datum); + }); + + let result = load(&folder).map_err(bone_document::FolderError::into_kind); + let Err(bone_document::FolderErrorKind::SuppressedDatum { id }) = result else { + panic!("expected SuppressedDatum, got {result:?}"); + }; + assert_eq!(id, datum); +} + #[test] fn extrude_roundtrips_through_folder() { let dir = ok_dir(); @@ -809,6 +989,36 @@ fn width_change_yields_text_diff_and_new_blob() { ); } +fn sample_solid(feature: FeatureId) -> bone_kernel::BrepSolid { + let evaluated = evaluate_sketch(&closed_rectangle(10.0)); + let extruded = evaluate_extrude(feature, &evaluated, &blind_extrude(SketchId::null())); + let Some(solid) = extruded.solid() else { + panic!("rectangle extrudes to a solid"); + }; + solid.clone() +} + +#[test] +fn imported_body_id_stays_reserved_across_roundtrip() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("body.bone")); + + let mut doc = Document::new(document_id(1), "body".to_owned()); + let Ok((_, first)) = doc.import_body(|feature| Ok::<_, ()>(sample_solid(feature))) else { + panic!("import the first body"); + }; + assert_save(&doc, &folder); + + let mut reloaded = assert_load(&folder); + let Ok((_, second)) = reloaded.import_body(|feature| Ok::<_, ()>(sample_solid(feature))) else { + panic!("import a body after reload"); + }; + assert_ne!( + first, second, + "a body id allocated before the save is not handed out again after the load" + ); +} + #[test] fn blob_path_matches_locked_shape() { let folder = DocumentFolder::new("/tmp/part.bone"); @@ -835,3 +1045,105 @@ fn blob_path_matches_locked_shape() { assert_eq!(components[0], hex[..2]); assert_eq!(stem, &hex[2..]); } + +#[test] +fn phase_three_history_state_round_trips_through_the_folder() { + let dir = ok_dir(); + let folder = DocumentFolder::new(dir.path().join("phase3.bone")); + + let base_sketch = sketch_id(1); + let base_extrude = extrude_id(1); + let cap_sketch = sketch_id(2); + let cap_extrude = extrude_id(2); + + let mut doc = Document::new(document_id(1), "phase3".to_owned()); + doc.insert_sketch(base_sketch, "Base".to_owned(), rectangle()); + doc.insert_extrude(base_extrude, blind_extrude(base_sketch)); + let Some(base_body) = doc.feature_tree().feature_of_extrude(base_extrude) else { + panic!("base extrude feature present"); + }; + + doc.insert_sketch(cap_sketch, "Top".to_owned(), rectangle()); + let face = sample_face_ref(base_body); + let Ok(()) = doc.bind_sketch_to_face(cap_sketch, face) else { + panic!("the face binding is acyclic"); + }; + doc.insert_extrude(cap_extrude, separate_extrude(cap_sketch)); + + let Some(cap_sketch_feature) = doc.feature_tree().feature_of_sketch(cap_sketch) else { + panic!("cap sketch feature present"); + }; + let Some(cap_extrude_feature) = doc.feature_tree().feature_of_extrude(cap_extrude) else { + panic!("cap extrude feature present"); + }; + doc.roll_to_here(cap_sketch_feature); + doc.suppress(cap_extrude_feature); + + assert_save(&doc, &folder); + let loaded = assert_load(&folder); + + assert_eq!(loaded, doc, "every phase-3 state survives the round-trip"); + assert_eq!( + loaded.sketch_plane_binding(cap_sketch), + Some(face), + "the face ref held by the bound sketch persists", + ); + assert_eq!( + loaded.rollback(), + RollbackMarker::Above(cap_sketch_feature), + "the rollback marker persists, keyed by a stable feature id", + ); + assert_eq!( + loaded.suppression_state(cap_extrude_feature), + SuppressionState::Suppressed, + "the suppressed set persists", + ); + assert_eq!( + loaded.suppression_state(base_body), + SuppressionState::Active, + "a feature absent from the suppressed set loads active", + ); + + let before = folder_bytes(folder.path()); + assert_save(&loaded, &folder); + let after = folder_bytes(folder.path()); + assert_eq!( + before, after, + "saving the reloaded document reproduces every file byte for byte", + ); +} + +#[test] +fn phase_three_header_ron_surface() { + let base_sketch = sketch_id(1); + let base_extrude = extrude_id(1); + let cap_sketch = sketch_id(2); + let cap_extrude = extrude_id(2); + + let mut doc = Document::new(document_id(1), "phase3".to_owned()); + doc.insert_sketch(base_sketch, "Base".to_owned(), rectangle()); + doc.insert_extrude(base_extrude, blind_extrude(base_sketch)); + let Some(base_body) = doc.feature_tree().feature_of_extrude(base_extrude) else { + panic!("base extrude feature present"); + }; + + doc.insert_sketch(cap_sketch, "Top".to_owned(), rectangle()); + let Ok(()) = doc.bind_sketch_to_face(cap_sketch, sample_face_ref(base_body)) else { + panic!("the face binding is acyclic"); + }; + doc.insert_extrude(cap_extrude, separate_extrude(cap_sketch)); + + let Some(cap_sketch_feature) = doc.feature_tree().feature_of_sketch(cap_sketch) else { + panic!("cap sketch feature present"); + }; + let Some(cap_extrude_feature) = doc.feature_tree().feature_of_extrude(cap_extrude) else { + panic!("cap extrude feature present"); + }; + doc.roll_to_here(cap_sketch_feature); + doc.suppress(cap_extrude_feature); + + let Ok(ron) = to_string(doc.header()) else { + panic!("ron"); + }; + insta::assert_snapshot!("phase_three_header", ron); +} diff --git a/crates/bone-document/tests/snapshots/folder_roundtrip__phase_three_header.snap b/crates/bone-document/tests/snapshots/folder_roundtrip__phase_three_header.snap new file mode 100644 index 0000000..8445073 --- /dev/null +++ b/crates/bone-document/tests/snapshots/folder_roundtrip__phase_three_header.snap @@ -0,0 +1,164 @@ +--- +source: crates/bone-document/tests/folder_roundtrip.rs +expression: ron +--- +#![enable(unwrap_newtypes)] +#![enable(implicit_some)] +#![enable(explicit_struct_names)] +DocumentHeader( + schema: SchemaHeader( + name: "bone-document", + version: SchemaVersion( + major: 1, + minor: 4, + ), + ), + id: SerKey( + idx: 1, + version: 1, + ), + name: "phase3", + units: Millimetre, + parameters: DocumentParameters( + order: [], + entries: {}, + ), + feature_tree: FeatureTree( + entries: [FeatureEntry( + id: SerKey( + idx: 1, + version: 1, + ), + node: Origin, + ), FeatureEntry( + id: SerKey( + idx: 2, + version: 1, + ), + node: PrincipalPlane(Xy), + ), FeatureEntry( + id: SerKey( + idx: 3, + version: 1, + ), + node: PrincipalPlane(Yz), + ), FeatureEntry( + id: SerKey( + idx: 4, + version: 1, + ), + node: PrincipalPlane(Zx), + ), FeatureEntry( + id: SerKey( + idx: 5, + version: 1, + ), + node: Sketch(SerKey( + idx: 1, + version: 1, + )), + ), FeatureEntry( + id: SerKey( + idx: 6, + version: 1, + ), + node: Extrude(SerKey( + idx: 1, + version: 1, + )), + ), FeatureEntry( + id: SerKey( + idx: 7, + version: 1, + ), + node: Sketch(SerKey( + idx: 2, + version: 1, + )), + ), FeatureEntry( + id: SerKey( + idx: 8, + version: 1, + ), + node: Extrude(SerKey( + idx: 2, + version: 1, + )), + )], + id_high_water: 8, + ), + sketches: SketchRegistry( + order: [SerKey( + idx: 1, + version: 1, + ), SerKey( + idx: 2, + version: 1, + )], + entries: { + SerKey( + idx: 1, + version: 1, + ): SketchRegistryEntry( + label: "Base", + filename: "0000000100000001.ron", + ), + SerKey( + idx: 2, + version: 1, + ): SketchRegistryEntry( + label: "Top", + filename: "0000000100000002.ron", + ), + }, + ), + sketch_planes: { + SerKey( + idx: 2, + version: 1, + ): FaceRef( + label: FaceLabel( + feature: SerKey( + idx: 6, + version: 1, + ), + role: EndCap, + ), + fingerprint: FaceFingerprint( + plane: Plane3( + origin: Point3( + x: 0.0, + y: 0.0, + z: 10.0, + ), + x_axis: UnitVec3( + x: 1.0, + y: 0.0, + z: 0.0, + ), + y_axis: UnitVec3( + x: 0.0, + y: 1.0, + z: 0.0, + ), + ), + centroid: Point3( + x: 0.0, + y: 0.0, + z: 10.0, + ), + ), + ), + }, + sketch_high_water: 2, + extrude_high_water: 2, + body_high_water: 0, + rollback: Above(SerKey( + idx: 7, + version: 1, + )), + suppressed: [SerKey( + idx: 8, + version: 1, + )], +) diff --git a/crates/bone-document/tests/snapshots/folder_snapshots__document_header.snap b/crates/bone-document/tests/snapshots/folder_snapshots__document_header.snap index d07b466..c618306 100644 --- a/crates/bone-document/tests/snapshots/folder_snapshots__document_header.snap +++ b/crates/bone-document/tests/snapshots/folder_snapshots__document_header.snap @@ -10,7 +10,7 @@ DocumentHeader( name: "bone-document", version: SchemaVersion( major: 1, - minor: 2, + minor: 4, ), ), id: SerKey( @@ -60,6 +60,7 @@ DocumentHeader( version: 1, )), )], + id_high_water: 5, ), sketches: SketchRegistry( order: [SerKey( @@ -76,4 +77,10 @@ DocumentHeader( ), }, ), + sketch_planes: {}, + sketch_high_water: 7, + extrude_high_water: 0, + body_high_water: 0, + rollback: AtEnd, + suppressed: [], ) diff --git a/crates/bone-document/tests/snapshots/folder_snapshots__extrude_file.snap b/crates/bone-document/tests/snapshots/folder_snapshots__extrude_file.snap index 31bd7aa..98ec1a1 100644 --- a/crates/bone-document/tests/snapshots/folder_snapshots__extrude_file.snap +++ b/crates/bone-document/tests/snapshots/folder_snapshots__extrude_file.snap @@ -10,7 +10,7 @@ ExtrudeFile( name: "bone-document", version: SchemaVersion( major: 1, - minor: 2, + minor: 4, ), ), feature: ExtrudeFeature( diff --git a/crates/bone-document/tests/snapshots/folder_snapshots__sketch_file.snap b/crates/bone-document/tests/snapshots/folder_snapshots__sketch_file.snap index bf30426..ce4079d 100644 --- a/crates/bone-document/tests/snapshots/folder_snapshots__sketch_file.snap +++ b/crates/bone-document/tests/snapshots/folder_snapshots__sketch_file.snap @@ -10,7 +10,7 @@ SketchFile( name: "bone-document", version: SchemaVersion( major: 1, - minor: 2, + minor: 4, ), ), sketch: Sketch( diff --git a/crates/bone-document/tests/undo.rs b/crates/bone-document/tests/undo.rs index b39ac86..b077162 100644 --- a/crates/bone-document/tests/undo.rs +++ b/crates/bone-document/tests/undo.rs @@ -1,16 +1,18 @@ +use std::collections::BTreeSet; use std::num::NonZeroUsize; use bone_document::{ - Document, EditOutcome, FeatureEdge, Sketch, SketchEdit, SketchEntity, UndoStack, + Document, EditOutcome, EvaluatedModel, FeatureEdge, RecomputeScope, Sketch, SketchEdit, + SketchEntity, UndoStack, evaluate_extrude, evaluate_sketch, }; use bone_kernel::{ - ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, + BrepSolid, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, }; use bone_types::{ - DocumentId, ExtrudeId, Length, Point2, Point3, PositiveLength, SketchEntityId, SketchId, - SketchPlaneBasis, Tolerance, UnitVec3, millimeter, + DocumentId, ExtrudeId, FeatureId, Length, Point2, Point3, PositiveLength, RollbackMarker, + SketchEntityId, SketchId, SketchPlaneBasis, Tolerance, UnitVec3, millimeter, }; -use slotmap::KeyData; +use slotmap::{Key, KeyData}; fn plane() -> SketchPlaneBasis { let Ok(basis) = SketchPlaneBasis::new( @@ -60,10 +62,14 @@ fn entity_of(outcome: EditOutcome) -> SketchEntityId { } fn rectangle() -> Sketch { + rectangle_width(10.0) +} + +fn rectangle_width(width_mm: f64) -> Sketch { let Ok((with_points, outcomes)) = Sketch::new(plane()).apply_all(vec![ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), - SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 0.0))), - SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 5.0))), + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(width_mm, 0.0))), + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(width_mm, 5.0))), SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 5.0))), ]) else { panic!("rectangle corners"); @@ -80,6 +86,15 @@ fn rectangle() -> Sketch { closed } +fn sample_solid(feature: FeatureId) -> BrepSolid { + let evaluated = evaluate_sketch(&rectangle()); + let extruded = evaluate_extrude(feature, &evaluated, &blind_extrude(SketchId::null())); + let Some(solid) = extruded.solid() else { + panic!("rectangle extrudes to a solid"); + }; + solid.clone() +} + fn blind_extrude(sketch: SketchId) -> ExtrudeFeature { let Ok(depth) = PositiveLength::new(Length::new::(10.0)) else { panic!("10 mm is positive"); @@ -298,3 +313,215 @@ fn undo_extrude_restores_sketch_only_document() { "redo restores the derived edge even though it is never serialized" ); } + +#[test] +fn undo_and_redo_cross_a_recompute() { + let sid = sketch_id(1); + let xid = extrude_id(1); + let mut live = base_doc(); + live.insert_sketch(sid, "Sketch1".to_owned(), rectangle_width(10.0)); + live.insert_extrude(xid, blind_extrude(sid)); + + let Some(extrude_feature) = live.feature_tree().feature_of_extrude(xid) else { + panic!("extrude feature present"); + }; + let Some(sketch_feature) = live.feature_tree().feature_of_sketch(sid) else { + panic!("sketch feature present"); + }; + + let mut model = EvaluatedModel::new(); + let active = BTreeSet::new(); + model.recompute(&live, &active, RollbackMarker::AtEnd, RecomputeScope::Full); + let Some(narrow) = model.body(extrude_feature).map(BrepSolid::content_key) else { + panic!("the base extrude builds a body"); + }; + + let mut stack = UndoStack::with_capacity(cap(5)); + stack.record(live.clone()); + live.replace_sketch(sid, rectangle_width(14.0)); + model.recompute( + &live, + &active, + RollbackMarker::AtEnd, + RecomputeScope::Edited(sketch_feature), + ); + let Some(wide) = model.body(extrude_feature).map(BrepSolid::content_key) else { + panic!("the widened extrude builds a body"); + }; + assert_ne!(narrow, wide, "widening the profile must change the body"); + + assert!(stack.undo(&mut live)); + model.recompute(&live, &active, RollbackMarker::AtEnd, RecomputeScope::Full); + assert_eq!( + model.body(extrude_feature).map(BrepSolid::content_key), + Some(narrow), + "undo restores the recipe and the rebuild reproduces the pre-edit body", + ); + + assert!(stack.redo(&mut live)); + model.recompute(&live, &active, RollbackMarker::AtEnd, RecomputeScope::Full); + assert_eq!( + model.body(extrude_feature).map(BrepSolid::content_key), + Some(wide), + "redo restores the edited recipe and the body widens again", + ); +} + +#[test] +fn rollback_and_suppression_survive_undo_and_redo() { + let sid = sketch_id(1); + let xid = extrude_id(1); + let mut live = base_doc(); + live.insert_sketch(sid, "Sketch1".to_owned(), rectangle()); + live.insert_extrude(xid, blind_extrude(sid)); + let Some(feature) = live.feature_tree().feature_of_extrude(xid) else { + panic!("extrude feature present"); + }; + let before = live.clone(); + + let mut stack = UndoStack::with_capacity(cap(5)); + stack.record(live.clone()); + live.roll_to_here(feature); + live.suppress(feature); + assert_eq!(live.rollback(), RollbackMarker::Above(feature)); + assert!(live.suppressed().contains(&feature)); + + assert!(stack.undo(&mut live)); + assert_eq!(live, before, "undo restores the pre-history-edit document"); + assert_eq!( + live.rollback(), + RollbackMarker::AtEnd, + "undo clears the rollback marker set by the undone step", + ); + assert!( + live.suppressed().is_empty(), + "undo clears the suppression set by the undone step", + ); + + assert!(stack.redo(&mut live)); + assert_eq!( + live.rollback(), + RollbackMarker::Above(feature), + "redo restores the rollback marker", + ); + assert!( + live.suppressed().contains(&feature), + "redo restores the suppression", + ); +} + +#[test] +fn cloning_a_document_shares_imported_body_storage() { + let mut live = base_doc(); + let Ok((_, body)) = live.import_body(|feature| Ok::<_, ()>(sample_solid(feature))) else { + panic!("import a body"); + }; + + let snapshot = live.clone(); + let Some(live_solid) = live.imported_body(body) else { + panic!("live body present"); + }; + let Some(snapshot_solid) = snapshot.imported_body(body) else { + panic!("snapshot body present"); + }; + assert!( + std::ptr::eq(live_solid, snapshot_solid), + "a clone shares the imported body payload, the snapshot is not a deep brep copy", + ); +} + +#[test] +fn an_undo_needs_a_full_recompute_to_restore_an_out_of_scope_body() { + let sid_a = sketch_id(1); + let xid_a = extrude_id(1); + let sid_b = sketch_id(2); + let xid_b = extrude_id(2); + + let mut live = base_doc(); + live.insert_sketch(sid_a, "A".to_owned(), rectangle_width(10.0)); + live.insert_extrude(xid_a, blind_extrude(sid_a)); + live.insert_sketch(sid_b, "B".to_owned(), rectangle_width(10.0)); + live.insert_extrude(xid_b, blind_extrude(sid_b)); + + let Some(a_sketch_feature) = live.feature_tree().feature_of_sketch(sid_a) else { + panic!("sketch a feature present"); + }; + let Some(b_sketch_feature) = live.feature_tree().feature_of_sketch(sid_b) else { + panic!("sketch b feature present"); + }; + let Some(b_extrude_feature) = live.feature_tree().feature_of_extrude(xid_b) else { + panic!("extrude b feature present"); + }; + + let mut model = EvaluatedModel::new(); + let active = BTreeSet::new(); + model.recompute(&live, &active, RollbackMarker::AtEnd, RecomputeScope::Full); + let Some(b_original) = model.body(b_extrude_feature).map(BrepSolid::content_key) else { + panic!("b builds a body"); + }; + + let mut stack = UndoStack::with_capacity(cap(5)); + stack.record(live.clone()); + live.replace_sketch(sid_b, rectangle_width(18.0)); + model.recompute( + &live, + &active, + RollbackMarker::AtEnd, + RecomputeScope::Edited(b_sketch_feature), + ); + let Some(b_wide) = model.body(b_extrude_feature).map(BrepSolid::content_key) else { + panic!("b rebuilds wide"); + }; + assert_ne!(b_original, b_wide, "widening b must change its body"); + + assert!(stack.undo(&mut live)); + model.recompute( + &live, + &active, + RollbackMarker::AtEnd, + RecomputeScope::Edited(a_sketch_feature), + ); + assert_eq!( + model.body(b_extrude_feature).map(BrepSolid::content_key), + Some(b_wide), + "an edit-scoped recompute after undo cannot see b reverted outside the scope", + ); + + model.recompute(&live, &active, RollbackMarker::AtEnd, RecomputeScope::Full); + assert_eq!( + model.body(b_extrude_feature).map(BrepSolid::content_key), + Some(b_original), + "a full recompute after undo restores every reverted body, the contract the app must use", + ); +} + +#[test] +fn cloning_a_document_shares_sketch_storage() { + let sid = sketch_id(1); + let mut live = base_doc(); + live.insert_sketch(sid, "Sketch1".to_owned(), rectangle_width(10.0)); + + let snapshot = live.clone(); + let Some(live_sketch) = live.sketch(sid) else { + panic!("live sketch present"); + }; + let Some(snapshot_sketch) = snapshot.sketch(sid) else { + panic!("snapshot sketch present"); + }; + assert!( + std::ptr::eq(live_sketch.entities(), snapshot_sketch.entities()), + "a clone shares the sketch entity payload, the snapshot is not a deep copy", + ); + + live.replace_sketch(sid, rectangle_width(14.0)); + let Some(edited_sketch) = live.sketch(sid) else { + panic!("edited sketch present"); + }; + let Some(snapshot_after) = snapshot.sketch(sid) else { + panic!("snapshot survives the edit"); + }; + assert!( + !std::ptr::eq(edited_sketch.entities(), snapshot_after.entities()), + "editing the live document leaves the snapshot's payload untouched", + ); +} diff --git a/crates/bone-interop/src/step.rs b/crates/bone-interop/src/step.rs index 94d85b6..2845d6a 100644 --- a/crates/bone-interop/src/step.rs +++ b/crates/bone-interop/src/step.rs @@ -55,7 +55,7 @@ pub enum StepError { Evaluation { feature: FeatureId, #[source] - source: ExtrudeError, + source: Box, }, #[error("step geometry uses {kind}, which the reader does not yet bridge")] UnsupportedEntity { kind: StepEntityKind }, @@ -152,7 +152,10 @@ pub fn body_of(document: &Document) -> Result { evaluate_extrude(feature, &evaluate_sketch(sketch), extrude) .result() .clone() - .map_err(|source| StepError::Evaluation { feature, source }) + .map_err(|source| StepError::Evaluation { + feature, + source: Box::new(source), + }) } pub fn read(path: &Path, cancel: CancelFlag) -> Result { diff --git a/crates/bone-kernel/src/brep/build.rs b/crates/bone-kernel/src/brep/build.rs index 1ce0029..bbda834 100644 --- a/crates/bone-kernel/src/brep/build.rs +++ b/crates/bone-kernel/src/brep/build.rs @@ -3,13 +3,13 @@ use std::collections::HashSet; use bone_types::{ BrepEdgeId, BrepFaceId, BrepLoopId, BrepShellId, BrepVertexId, CreaseAngle, EdgeLabel, - EdgeRole, FaceLabel, Parameter, Point3, SideKind, SketchEntityId, Tolerance, UnitVec3, - VertexLabel, VertexRole, + EdgeRole, FaceFingerprint, FaceLabel, Parameter, Plane3, Point3, SideKind, SketchEntityId, + SketchPlaneBasis, Tolerance, UnitVec3, VertexLabel, VertexRole, }; use slotmap::{Key, SlotMap}; use truck_modeling::{ BoundedCurve, Edge, EdgeID, FaceID, ParameterDivision1D, ParametricCurve, ParametricSurface3D, - SPHint2D, SearchNearestParameter, Shell, Solid, VertexID, + SPHint2D, SearchNearestParameter, Shell, Solid, Surface, VertexID, }; use super::convert::{point_from_truck, try_unit_from_truck}; @@ -46,6 +46,40 @@ impl Arena { pub(crate) fn face_index(&self) -> &HashMap { &self.face_index } + + pub(crate) fn truck_face(&self, target: BrepFaceId) -> Option<&truck_modeling::Face> { + let face_id = self + .face_index + .iter() + .find_map(|(id, brep)| (*brep == target).then_some(*id))?; + self.solid.face_iter().find(|face| face.id() == face_id) + } +} + +const FACE_PLANE_TOLERANCE: Tolerance = Tolerance::new(1.0e-9); + +pub(crate) fn face_plane_basis(face: &truck_modeling::Face) -> Option { + let Surface::Plane(plane) = face.surface() else { + return None; + }; + let origin = point_from_truck(plane.origin()); + let x = try_unit_from_truck(plane.u_axis(), FACE_PLANE_TOLERANCE)?; + let normal = try_unit_from_truck(plane.normal(), FACE_PLANE_TOLERANCE)?; + let outward = if face.orientation() { + normal + } else { + normal.reversed() + }; + let y = outward.cross(x, FACE_PLANE_TOLERANCE).ok()?; + SketchPlaneBasis::new(origin, x, y, FACE_PLANE_TOLERANCE).ok() +} + +pub(crate) fn face_fingerprint(face: &truck_modeling::Face) -> Option { + let basis = face_plane_basis(face)?; + Some(FaceFingerprint { + plane: Plane3::from(basis), + centroid: point_from_truck(super::persist::face_centroid(face)), + }) } const LENGTH_DIVISION_TOLERANCE: Tolerance = Tolerance::new(1.0e-4); diff --git a/crates/bone-kernel/src/brep/mod.rs b/crates/bone-kernel/src/brep/mod.rs index 7e08813..3835983 100644 --- a/crates/bone-kernel/src/brep/mod.rs +++ b/crates/bone-kernel/src/brep/mod.rs @@ -2,8 +2,8 @@ use std::collections::HashSet; use bone_types::{ Aabb3, AngleTolerance, BrepEdgeId, BrepFaceId, BrepLoopId, BrepShellId, BrepVertexId, - ChordHeightTolerance, CreaseAngle, EdgeLabel, EdgeRole, FaceLabel, Point3, SideKind, - StepEntityKind, Tolerance, VertexLabel, + ChordHeightTolerance, CreaseAngle, EdgeLabel, EdgeRole, FaceFingerprint, FaceLabel, Point3, + SideKind, SketchPlaneBasis, StepEntityKind, Tolerance, VertexLabel, }; use slotmap::SlotMap; use truck_modeling::ShellCondition; @@ -289,6 +289,20 @@ impl BrepSolid { self.face_order.iter().map(|id| &self.faces[*id]) } + #[must_use] + pub fn face_plane_basis(&self, face: BrepFaceId) -> Option { + self.arena + .truck_face(face) + .and_then(build::face_plane_basis) + } + + #[must_use] + pub fn face_fingerprint(&self, face: BrepFaceId) -> Option { + self.arena + .truck_face(face) + .and_then(build::face_fingerprint) + } + pub fn iter_loops(&self) -> impl Iterator { self.loop_order.iter().map(|id| &self.loops[*id]) } diff --git a/crates/bone-kernel/src/brep/persist.rs b/crates/bone-kernel/src/brep/persist.rs index 7d92b2c..2c6287c 100644 --- a/crates/bone-kernel/src/brep/persist.rs +++ b/crates/bone-kernel/src/brep/persist.rs @@ -216,7 +216,7 @@ fn digest_to_key(canonical: &str) -> SolidKey { SolidKey::from_bytes(key) } -fn face_centroid(face: &Face) -> truck_modeling::Point3 { +pub(super) fn face_centroid(face: &Face) -> truck_modeling::Point3 { let (x, y, z, count) = face .boundaries() .iter() diff --git a/crates/bone-render/src/scene.rs b/crates/bone-render/src/scene.rs index 9191610..74d40a4 100644 --- a/crates/bone-render/src/scene.rs +++ b/crates/bone-render/src/scene.rs @@ -700,6 +700,20 @@ impl SolidScene { } } + #[must_use] + pub fn merge(mut self, other: Self) -> Self { + let Ok(base) = u32::try_from(self.positions.len()) else { + panic!("merged solid scene vertex count fits a u32 index"); + }; + self.positions.extend(other.positions); + self.normals.extend(other.normals); + self.colors.extend(other.colors); + self.pick_ids.extend(other.pick_ids); + self.triangles + .extend(other.triangles.into_iter().map(|tri| tri.map(|i| i + base))); + self + } + #[must_use] pub fn positions(&self) -> &[Point3] { &self.positions @@ -854,6 +868,13 @@ impl EdgeScene { }) } + #[must_use] + pub fn merge(mut self, other: Self) -> Self { + self.genuine.extend(other.genuine); + self.silhouettes.extend(other.silhouettes); + self + } + #[must_use] pub fn genuine(&self) -> &[GenuineEdge] { &self.genuine diff --git a/crates/bone-types/src/history.rs b/crates/bone-types/src/history.rs new file mode 100644 index 0000000..d740621 --- /dev/null +++ b/crates/bone-types/src/history.rs @@ -0,0 +1,197 @@ +use serde::{Deserialize, Serialize}; + +use crate::{EntityRef, FeatureId, MatchScore}; + +#[derive( + Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize, +)] +#[serde(transparent)] +pub struct FeatureGeneration(u64); + +impl FeatureGeneration { + pub const START: Self = Self(0); + + #[must_use] + pub const fn new(value: u64) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } + + #[must_use] + pub const fn succ(self) -> Self { + Self(self.0.saturating_add(1)) + } + + #[must_use] + pub const fn raised_to(self, other: Self) -> Self { + if other.0 > self.0 { other } else { self } + } +} + +impl core::fmt::Display for FeatureGeneration { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "feat_gen={}", self.0) + } +} + +#[derive( + Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize, +)] +pub enum RollbackMarker { + #[default] + AtEnd, + Above(FeatureId), +} + +impl RollbackMarker { + #[must_use] + pub const fn feature(self) -> Option { + match self { + Self::AtEnd => None, + Self::Above(id) => Some(id), + } + } +} + +impl core::fmt::Display for RollbackMarker { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AtEnd => f.write_str("rollback=end"), + Self::Above(id) => write!(f, "rollback=above({id:?})"), + } + } +} + +#[derive( + Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize, +)] +pub enum SuppressionState { + #[default] + Active, + Suppressed, +} + +impl SuppressionState { + #[must_use] + pub const fn is_suppressed(self) -> bool { + matches!(self, Self::Suppressed) + } + + #[must_use] + pub const fn toggled(self) -> Self { + match self { + Self::Active => Self::Suppressed, + Self::Suppressed => Self::Active, + } + } +} + +impl core::fmt::Display for SuppressionState { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Active => f.write_str("active"), + Self::Suppressed => f.write_str("suppressed"), + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum BuildFailure { + UnsolvedSketch, + Kernel, +} + +impl core::fmt::Display for BuildFailure { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::UnsolvedSketch => f.write_str("sketch did not solve"), + Self::Kernel => f.write_str("solid build failed"), + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum RebuildError { + DanglingReference(EntityRef), + NonPlanarSketchTarget, + UpstreamUnresolved, + Build(BuildFailure), +} + +impl core::fmt::Display for RebuildError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::DanglingReference(reference) => write!(f, "dangling reference {reference}"), + Self::NonPlanarSketchTarget => f.write_str("sketch plane target is not planar"), + Self::UpstreamUnresolved => f.write_str("depends on a feature that failed to build"), + Self::Build(failure) => failure.fmt(f), + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum RebuildWarning { + RepairedReference { + reference: EntityRef, + score: MatchScore, + }, +} + +impl core::fmt::Display for RebuildWarning { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::RepairedReference { reference, score } => { + write!(f, "reference {reference} repaired at {score}") + } + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum RebuildStatus { + UpToDate, + NeedsRebuild, + Warning(RebuildWarning), + Error(RebuildError), +} + +impl RebuildStatus { + #[must_use] + pub const fn is_error(self) -> bool { + matches!(self, Self::Error(_)) + } + + #[must_use] + pub const fn builds_geometry(self) -> bool { + !self.is_error() + } + + #[must_use] + pub const fn worse_of(self, other: Self) -> Self { + match (self, other) { + (own @ Self::Error(_), _) => own, + (_, down @ Self::Error(_)) => down, + (own @ Self::Warning(_), _) => own, + (_, down @ Self::Warning(_)) => down, + (own @ Self::NeedsRebuild, _) => own, + (_, down @ Self::NeedsRebuild) => down, + (own, _) => own, + } + } +} + +impl core::fmt::Display for RebuildStatus { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::UpToDate => f.write_str("up to date"), + Self::NeedsRebuild => f.write_str("needs rebuild"), + Self::Warning(warning) => warning.fmt(f), + Self::Error(error) => error.fmt(f), + } + } +} diff --git a/crates/bone-types/src/lib.rs b/crates/bone-types/src/lib.rs index c166fd8..9180514 100644 --- a/crates/bone-types/src/lib.rs +++ b/crates/bone-types/src/lib.rs @@ -9,8 +9,10 @@ pub mod color; pub mod content; pub mod dimensioned_serde; pub mod display; +pub mod history; pub mod icon; pub mod label; +pub mod reference; pub mod schema; pub mod solver; pub mod space; @@ -23,11 +25,19 @@ pub use cancel::{Cancel, CancelFlag}; pub use color::LinearRgba; pub use content::SolidKey; pub use display::{DisplayMode, ShadingModel}; +pub use history::{ + BuildFailure, FeatureGeneration, RebuildError, RebuildStatus, RebuildWarning, RollbackMarker, + SuppressionState, +}; pub use icon::{IconId, IconTile}; pub use label::{ EdgeLabel, EdgeRole, FaceLabel, FaceRole, ImportOrdinal, LoopIndex, SideKind, VertexLabel, VertexRole, }; +pub use reference::{ + EdgeFingerprint, EntityFingerprint, EntityRef, FaceFingerprint, FaceRef, MatchScore, + Resolution, VertexFingerprint, +}; pub use schema::{SchemaHeader, SchemaVersion}; pub use solver::{ BudgetCeiling, DegreesOfFreedom, NewtonDamping, NewtonStepTolerance, Parameter, ParameterIndex, @@ -89,6 +99,8 @@ pub enum TypesError { NormalToRequiresPlane, #[error("cubic easing needs finite controls with x1,x2 in 0..=1: ({x1},{y1}) ({x2},{y2})")] InvalidEasingControl { x1: f64, y1: f64, x2: f64, y2: f64 }, + #[error("match score must be finite and within 0..=1: {0}")] + MatchScoreOutOfRange(f64), } pub type Result = core::result::Result; @@ -373,15 +385,18 @@ mod tests { use super::BrepSlot; use super::{ Aabb3, Angle, AngleTolerance, AxisAngle, BodyId, BrepEdgeId, BrepFaceId, BrepLoopId, - BrepShellId, BrepVertexId, Camera3, ChordHeightTolerance, CreaseAngle, CubicEasing, - DegreesOfFreedom, DisplayMode, DocumentId, EdgeId, EdgeLabel, EdgeRole, ExtrudeId, FaceId, - FaceLabel, FaceRole, FeatureId, ImportOrdinal, Length, LoopId, LoopIndex, MeshGeneration, - NodeId, OrbitState, OrientedBox3, Parameter, Plane3, Point2, Point3, PositiveLength, - Projection, ProjectionKind, ShadingModel, ShellId, SideKind, SketchDimensionId, - SketchEntityId, SketchId, SketchParameterId, SketchPlaneBasis, SketchRelationId, SolidId, - SolverResidual, StandardView, StepEntityId, StepEntityKind, StepFileHeader, StepFileName, - StepOrganization, StepOriginatingSystem, StepSchema, Tolerance, UnitVec2, UnitVec3, Vec2, - Vec3, VertexId, VertexLabel, VertexRole, WireId, ZoomFactor, degree, millimeter, radian, + BrepShellId, BrepVertexId, BuildFailure, Camera3, ChordHeightTolerance, CreaseAngle, + CubicEasing, DegreesOfFreedom, DisplayMode, DocumentId, EdgeFingerprint, EdgeId, EdgeLabel, + EdgeRole, EntityFingerprint, EntityRef, ExtrudeId, FaceFingerprint, FaceId, FaceLabel, + FaceRef, FaceRole, FeatureGeneration, FeatureId, ImportOrdinal, Length, LoopId, LoopIndex, + MatchScore, MeshGeneration, NodeId, OrbitState, OrientedBox3, Parameter, Plane3, Point2, + Point3, PositiveLength, Projection, ProjectionKind, RebuildError, RebuildStatus, + RebuildWarning, Resolution, RollbackMarker, ShadingModel, ShellId, SideKind, + SketchDimensionId, SketchEntityId, SketchId, SketchParameterId, SketchPlaneBasis, + SketchRelationId, SolidId, SolverResidual, StandardView, StepEntityId, StepEntityKind, + StepFileHeader, StepFileName, StepOrganization, StepOriginatingSystem, StepSchema, + SuppressionState, Tolerance, UnitVec2, UnitVec3, Vec2, Vec3, VertexFingerprint, VertexId, + VertexLabel, VertexRole, WireId, ZoomFactor, degree, millimeter, radian, }; use slotmap::{Key, SlotMap}; use uom::si::length::meter; @@ -1418,4 +1433,262 @@ mod tests { }; assert_ron_round_trip(&entity); } + + fn sample_face_fingerprint() -> FaceFingerprint { + let plane = Plane3::new_unchecked( + Point3::from_mm(1.0, 2.0, 3.0), + UnitVec3::x_axis(), + UnitVec3::y_axis(), + ); + FaceFingerprint { + plane, + centroid: Point3::from_mm(4.0, 5.0, 6.0), + } + } + + fn sample_face_ref() -> EntityRef { + EntityRef::Face( + FaceLabel { + feature: FeatureId::default(), + role: FaceRole::EndCap, + }, + sample_face_fingerprint(), + ) + } + + #[test] + fn entity_ref_round_trips_all_kinds() { + assert_ron_round_trip(&sample_face_ref()); + assert_ron_round_trip(&EntityRef::Edge( + EdgeLabel { + feature: FeatureId::default(), + role: EdgeRole::EndCapEdge { + from: SketchEntityId::default(), + }, + }, + EdgeFingerprint { + sample: Point3::from_mm(1.0, 0.0, 0.0), + direction: UnitVec3::z_axis(), + }, + )); + assert_ron_round_trip(&EntityRef::Vertex( + VertexLabel { + feature: FeatureId::default(), + role: VertexRole::EndCapVertex { + from: SketchEntityId::default(), + side: SideKind::Corner, + }, + }, + VertexFingerprint { + point: Point3::origin(), + }, + )); + } + + #[test] + fn face_ref_round_trips_and_projects_to_an_entity_ref() { + let face = FaceRef::new( + FaceLabel { + feature: FeatureId::default(), + role: FaceRole::EndCap, + }, + sample_face_fingerprint(), + ); + assert_ron_round_trip(&face); + assert_eq!(face.entity_ref(), sample_face_ref()); + } + + #[test] + fn entity_ref_fingerprint_projects_its_kind() { + assert_eq!( + sample_face_ref().fingerprint(), + EntityFingerprint::Face(sample_face_fingerprint()) + ); + } + + #[test] + fn entity_ref_display_shows_the_label() { + let expected = format!("face[{:?}]:{}", FeatureId::default(), FaceRole::EndCap); + assert_eq!(format!("{}", sample_face_ref()), expected); + } + + #[test] + fn fingerprint_rejects_unknown_field() { + let Ok(text) = ron::to_string(&sample_face_fingerprint()) else { + panic!("serialize face fingerprint"); + }; + assert!(ron::from_str::(&text).is_ok()); + let mut munged = text.clone(); + munged.insert_str(1, "bogus:42,"); + assert!(ron::from_str::(&munged).is_err()); + } + + #[test] + fn match_score_rejects_out_of_range_and_non_finite() { + assert!(MatchScore::new(0.0).is_ok()); + assert!(MatchScore::new(1.0).is_ok()); + assert!(MatchScore::new(0.75).is_ok()); + assert!(MatchScore::new(-0.1).is_err()); + assert!(MatchScore::new(1.5).is_err()); + assert!(MatchScore::new(f64::NAN).is_err()); + assert!(MatchScore::new(f64::INFINITY).is_err()); + assert!(ron::from_str::("0.5").is_ok()); + assert!(ron::from_str::("1.5").is_err()); + assert!(ron::from_str::("-0.1").is_err()); + assert!(ron::from_str::("inf").is_err()); + assert!(ron::from_str::("NaN").is_err()); + let Ok(score) = MatchScore::new(0.5) else { + panic!("0.5 is in range"); + }; + assert!((score.value() - 0.5).abs() < f64::EPSILON); + } + + #[test] + fn resolution_id_and_dangling() { + let resolved: Resolution = Resolution::Resolved(FeatureId::default()); + assert_eq!(resolved.id(), Some(FeatureId::default())); + assert!(!resolved.is_dangling()); + assert_ron_round_trip(&resolved); + + let Ok(score) = MatchScore::new(0.9) else { + panic!("0.9 is in range"); + }; + let repaired: Resolution = Resolution::Repaired { + id: FeatureId::default(), + score, + }; + assert_eq!(repaired.id(), Some(FeatureId::default())); + + let dangling: Resolution = Resolution::Dangling { + last_known: sample_face_ref(), + }; + assert_eq!(dangling.id(), None); + assert!(dangling.is_dangling()); + assert_ron_round_trip(&dangling); + assert_ron_round_trip(&repaired); + } + + #[test] + fn resolution_repaired_rejects_unknown_field() { + let Ok(score) = MatchScore::new(0.9) else { + panic!("0.9 is in range"); + }; + let repaired: Resolution = Resolution::Repaired { + id: FeatureId::default(), + score, + }; + let Ok(text) = ron::to_string(&repaired) else { + panic!("serialize repaired resolution"); + }; + assert!(ron::from_str::>(&text).is_ok()); + let Some(idx) = text.find("Repaired(") else { + panic!("expected Repaired variant in {text}"); + }; + let mut munged = text.clone(); + munged.insert_str(idx + "Repaired(".len(), "bogus:42,"); + assert!(ron::from_str::>(&munged).is_err()); + } + + #[test] + fn feature_generation_is_a_monotone_mark() { + assert_eq!(FeatureGeneration::START, FeatureGeneration::default()); + assert_eq!(FeatureGeneration::START.value(), 0); + let mark = FeatureGeneration::new(7); + assert_eq!(mark.succ(), FeatureGeneration::new(8)); + assert!(mark < mark.succ()); + assert_eq!(mark.raised_to(FeatureGeneration::new(3)), mark); + assert_eq!( + mark.raised_to(FeatureGeneration::new(12)), + FeatureGeneration::new(12) + ); + let Ok(text) = ron::to_string(&mark) else { + panic!("serialize feature generation"); + }; + assert_eq!(text, "7"); + assert_ron_round_trip(&mark); + assert_eq!( + FeatureGeneration::new(u64::MAX).succ(), + FeatureGeneration::new(u64::MAX) + ); + } + + #[test] + fn rollback_marker_defaults_to_end() { + assert_eq!(RollbackMarker::default(), RollbackMarker::AtEnd); + assert_eq!(RollbackMarker::AtEnd.feature(), None); + assert_eq!( + RollbackMarker::Above(FeatureId::default()).feature(), + Some(FeatureId::default()) + ); + assert!(RollbackMarker::AtEnd < RollbackMarker::Above(FeatureId::default())); + assert_ron_round_trip(&RollbackMarker::AtEnd); + assert_ron_round_trip(&RollbackMarker::Above(FeatureId::default())); + } + + #[test] + fn suppression_state_toggles() { + assert_eq!(SuppressionState::default(), SuppressionState::Active); + assert!(!SuppressionState::Active.is_suppressed()); + assert!(SuppressionState::Suppressed.is_suppressed()); + assert_eq!( + SuppressionState::Active.toggled(), + SuppressionState::Suppressed + ); + assert_eq!( + SuppressionState::Suppressed.toggled(), + SuppressionState::Active + ); + assert!(SuppressionState::Active < SuppressionState::Suppressed); + assert_ron_round_trip(&SuppressionState::Active); + assert_ron_round_trip(&SuppressionState::Suppressed); + } + + #[test] + fn rebuild_status_honors_warning_versus_error() { + let dangling = RebuildStatus::Error(RebuildError::DanglingReference(sample_face_ref())); + assert!(dangling.is_error()); + assert!(!dangling.builds_geometry()); + + let non_planar = RebuildStatus::Error(RebuildError::NonPlanarSketchTarget); + assert!(!non_planar.builds_geometry()); + + let build_failed = RebuildStatus::Error(RebuildError::Build(BuildFailure::Kernel)); + assert!(build_failed.is_error()); + assert!(!build_failed.builds_geometry()); + + let Ok(score) = MatchScore::new(0.8) else { + panic!("0.8 is in range"); + }; + let repaired = RebuildStatus::Warning(RebuildWarning::RepairedReference { + reference: sample_face_ref(), + score, + }); + assert!(!repaired.is_error()); + assert!(repaired.builds_geometry()); + + assert!(RebuildStatus::UpToDate.builds_geometry()); + assert!(RebuildStatus::NeedsRebuild.builds_geometry()); + + assert_eq!(dangling.worse_of(repaired), dangling); + assert_eq!(repaired.worse_of(dangling), dangling); + assert_eq!(repaired.worse_of(RebuildStatus::NeedsRebuild), repaired); + assert_eq!( + RebuildStatus::NeedsRebuild.worse_of(RebuildStatus::UpToDate), + RebuildStatus::NeedsRebuild + ); + assert_eq!( + RebuildStatus::UpToDate.worse_of(RebuildStatus::UpToDate), + RebuildStatus::UpToDate + ); + + assert_ron_round_trip(&dangling); + assert_ron_round_trip(&non_planar); + assert_ron_round_trip(&build_failed); + assert_ron_round_trip(&RebuildStatus::Error(RebuildError::Build( + BuildFailure::UnsolvedSketch, + ))); + assert_ron_round_trip(&repaired); + assert_ron_round_trip(&RebuildStatus::UpToDate); + assert_ron_round_trip(&RebuildStatus::NeedsRebuild); + } } diff --git a/crates/bone-types/src/reference.rs b/crates/bone-types/src/reference.rs new file mode 100644 index 0000000..3c25101 --- /dev/null +++ b/crates/bone-types/src/reference.rs @@ -0,0 +1,181 @@ +use serde::{Deserialize, Serialize}; + +use crate::{EdgeLabel, FaceLabel, Plane3, Point3, Result, TypesError, UnitVec3, VertexLabel}; + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FaceFingerprint { + pub plane: Plane3, + pub centroid: Point3, +} + +impl core::fmt::Display for FaceFingerprint { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "face@{}", self.centroid) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EdgeFingerprint { + pub sample: Point3, + pub direction: UnitVec3, +} + +impl core::fmt::Display for EdgeFingerprint { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "edge@{}", self.sample) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VertexFingerprint { + pub point: Point3, +} + +impl core::fmt::Display for VertexFingerprint { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "vertex@{}", self.point) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum EntityFingerprint { + Face(FaceFingerprint), + Edge(EdgeFingerprint), + Vertex(VertexFingerprint), +} + +impl core::fmt::Display for EntityFingerprint { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Face(g) => g.fmt(f), + Self::Edge(g) => g.fmt(f), + Self::Vertex(g) => g.fmt(f), + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum EntityRef { + Face(FaceLabel, FaceFingerprint), + Edge(EdgeLabel, EdgeFingerprint), + Vertex(VertexLabel, VertexFingerprint), +} + +impl EntityRef { + #[must_use] + pub fn fingerprint(self) -> EntityFingerprint { + match self { + Self::Face(_, g) => EntityFingerprint::Face(g), + Self::Edge(_, g) => EntityFingerprint::Edge(g), + Self::Vertex(_, g) => EntityFingerprint::Vertex(g), + } + } +} + +impl core::fmt::Display for EntityRef { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Face(label, _) => label.fmt(f), + Self::Edge(label, _) => label.fmt(f), + Self::Vertex(label, _) => label.fmt(f), + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FaceRef { + pub label: FaceLabel, + pub fingerprint: FaceFingerprint, +} + +impl FaceRef { + #[must_use] + pub const fn new(label: FaceLabel, fingerprint: FaceFingerprint) -> Self { + Self { label, fingerprint } + } + + #[must_use] + pub const fn entity_ref(self) -> EntityRef { + EntityRef::Face(self.label, self.fingerprint) + } +} + +impl core::fmt::Display for FaceRef { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + self.label.fmt(f) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(try_from = "f64", into = "f64")] +pub struct MatchScore(f64); + +impl MatchScore { + pub fn new(value: f64) -> Result { + if value.is_finite() && (0.0..=1.0).contains(&value) { + Ok(Self(value)) + } else { + Err(TypesError::MatchScoreOutOfRange(value)) + } + } + + #[must_use] + pub const fn value(self) -> f64 { + self.0 + } +} + +impl TryFrom for MatchScore { + type Error = TypesError; + fn try_from(value: f64) -> Result { + Self::new(value) + } +} + +impl From for f64 { + fn from(score: MatchScore) -> Self { + score.0 + } +} + +impl core::fmt::Display for MatchScore { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "score={}", self.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum Resolution { + Resolved(Id), + Repaired { id: Id, score: MatchScore }, + Dangling { last_known: EntityRef }, +} + +impl Resolution { + #[must_use] + pub fn id(self) -> Option { + match self { + Self::Resolved(id) | Self::Repaired { id, .. } => Some(id), + Self::Dangling { .. } => None, + } + } + + #[must_use] + pub fn map(self, f: impl FnOnce(Id) -> U) -> Resolution { + match self { + Self::Resolved(id) => Resolution::Resolved(f(id)), + Self::Repaired { id, score } => Resolution::Repaired { id: f(id), score }, + Self::Dangling { last_known } => Resolution::Dangling { last_known }, + } + } + + #[must_use] + pub const fn is_dangling(&self) -> bool { + matches!(self, Self::Dangling { .. }) + } +} diff --git a/crates/bone-types/src/schema.rs b/crates/bone-types/src/schema.rs index 87edc4e..02fe86e 100644 --- a/crates/bone-types/src/schema.rs +++ b/crates/bone-types/src/schema.rs @@ -30,7 +30,7 @@ pub struct SchemaHeader { impl SchemaHeader { pub const BONE_DOCUMENT_NAME: &'static str = "bone-document"; pub const BONE_DOCUMENT_MAJOR: u32 = 1; - pub const BONE_DOCUMENT_MINOR: u32 = 2; + pub const BONE_DOCUMENT_MINOR: u32 = 4; #[must_use] pub fn bone_document() -> Self { diff --git a/crates/bone-types/src/space.rs b/crates/bone-types/src/space.rs index dbfecf2..cad1309 100644 --- a/crates/bone-types/src/space.rs +++ b/crates/bone-types/src/space.rs @@ -532,6 +532,15 @@ impl UnitVec3 { let r = (by.to_unit_quaternion() * NVec3::new(x, y, z)).normalize(); Self::new_unchecked(r.x, r.y, r.z) } + + pub fn cross(self, other: Self, tolerance: Tolerance) -> Result { + Unit::try_new( + self.0.into_inner().cross(&other.0.into_inner()), + tolerance.value(), + ) + .map(Self) + .ok_or(TypesError::ZeroLengthAxis) + } } impl core::fmt::Display for UnitVec3 { diff --git a/crates/bone-types/tests/snapshots/surface__history_surface.snap b/crates/bone-types/tests/snapshots/surface__history_surface.snap new file mode 100644 index 0000000..988cb90 --- /dev/null +++ b/crates/bone-types/tests/snapshots/surface__history_surface.snap @@ -0,0 +1,17 @@ +--- +source: crates/bone-types/tests/surface.rs +expression: surface +--- +feature_gen_disp = feat_gen=7 +feature_gen_succ = feat_gen=8 +rollback_end_disp = rollback=end +rollback_above_disp = rollback=above(FeatureId(null)) +suppress_active = active +suppress_suppressed = suppressed +status_uptodate = up to date +status_needs = needs rebuild +status_dangling = dangling reference face[FeatureId(null)]:end_cap +status_non_planar = sketch plane target is not planar +status_warned = reference face[FeatureId(null)]:end_cap repaired at score=0.5 +status_error_flag = true +status_builds_geo = true diff --git a/crates/bone-types/tests/snapshots/surface__reference_surface.snap b/crates/bone-types/tests/snapshots/surface__reference_surface.snap new file mode 100644 index 0000000..41122d0 --- /dev/null +++ b/crates/bone-types/tests/snapshots/surface__reference_surface.snap @@ -0,0 +1,15 @@ +--- +source: crates/bone-types/tests/surface.rs +expression: surface +--- +face_fp_display = face@(4 mm, 5 mm, 6 mm) +edge_fp_display = edge@(1 mm, 0 mm, 0 mm) +vertex_fp_display = vertex@(0 mm, 0 mm, 0 mm) +entity_fp_display = face@(4 mm, 5 mm, 6 mm) +face_ref_display = face[FeatureId(null)]:end_cap +face_ref_debug = Face(FaceLabel { feature: FeatureId(null), role: EndCap }, FaceFingerprint { plane: Plane3 { origin: Point3(1 mm, 2 mm, 3 mm), x: UnitVec3([[1.0, 0.0, 0.0]]), y: UnitVec3([[0.0, 1.0, 0.0]]) }, centroid: Point3(4 mm, 5 mm, 6 mm) }) +match_score_disp = score=0.875 +match_score_value = 0.875 +resolved_debug = Resolved(FeatureId(null)) +repaired_debug = Repaired { id: FeatureId(null), score: MatchScore(0.875) } +dangling_debug = Dangling { last_known: Face(FaceLabel { feature: FeatureId(null), role: EndCap }, FaceFingerprint { plane: Plane3 { origin: Point3(1 mm, 2 mm, 3 mm), x: UnitVec3([[1.0, 0.0, 0.0]]), y: UnitVec3([[0.0, 1.0, 0.0]]) }, centroid: Point3(4 mm, 5 mm, 6 mm) }) } diff --git a/crates/bone-types/tests/surface.rs b/crates/bone-types/tests/surface.rs index 016d8b3..d7c06b4 100644 --- a/crates/bone-types/tests/surface.rs +++ b/crates/bone-types/tests/surface.rs @@ -1,14 +1,16 @@ use bone_types::{ Aabb3, Angle, AngleTolerance, AxisAngle, BodyId, BrepEdgeId, BrepFaceId, BrepLoopId, BrepShellId, BrepVertexId, BudgetCeiling, Camera3, ChordHeightTolerance, DegreesOfFreedom, - DisplayMode, DocumentId, EdgeId, EdgeLabel, EdgeRole, ExtrudeId, FaceId, FaceLabel, FaceRole, - FeatureId, ImportOrdinal, Length, LoopId, LoopIndex, NewtonDamping, NewtonStepTolerance, - NodeId, OrbitState, OrientedBox3, Parameter, ParentIndex, Plane3, Point2, Point3, Projection, - ShadingModel, ShellId, SideKind, SketchDimensionId, SketchEntityId, SketchId, SketchItemId, - SketchParameterId, SketchPlaneBasis, SketchRelationId, SolidId, SolverResidual, StandardView, - StepEntityId, StepFileHeader, StepFileName, StepOrganization, StepOriginatingSystem, - StepSchema, Tolerance, UnitVec2, UnitVec3, Vec2, Vec3, VertexId, VertexLabel, VertexRole, - WireId, ZoomFactor, degree, millimeter, radian, + DisplayMode, DocumentId, EdgeFingerprint, EdgeId, EdgeLabel, EdgeRole, EntityRef, ExtrudeId, + FaceFingerprint, FaceId, FaceLabel, FaceRole, FeatureGeneration, FeatureId, ImportOrdinal, + Length, LoopId, LoopIndex, MatchScore, NewtonDamping, NewtonStepTolerance, NodeId, OrbitState, + OrientedBox3, Parameter, ParentIndex, Plane3, Point2, Point3, Projection, RebuildError, + RebuildStatus, RebuildWarning, Resolution, RollbackMarker, ShadingModel, ShellId, SideKind, + SketchDimensionId, SketchEntityId, SketchId, SketchItemId, SketchParameterId, SketchPlaneBasis, + SketchRelationId, SolidId, SolverResidual, StandardView, StepEntityId, StepFileHeader, + StepFileName, StepOrganization, StepOriginatingSystem, StepSchema, SuppressionState, Tolerance, + UnitVec2, UnitVec3, Vec2, Vec3, VertexFingerprint, VertexId, VertexLabel, VertexRole, WireId, + ZoomFactor, degree, millimeter, radian, }; use slotmap::Key; @@ -420,6 +422,109 @@ fn step_surface() { insta::assert_snapshot!(surface); } +#[test] +fn reference_surface() { + let plane = Plane3::new_unchecked( + Point3::from_mm(1.0, 2.0, 3.0), + UnitVec3::x_axis(), + UnitVec3::y_axis(), + ); + let face_fp = FaceFingerprint { + plane, + centroid: Point3::from_mm(4.0, 5.0, 6.0), + }; + let edge_fp = EdgeFingerprint { + sample: Point3::from_mm(1.0, 0.0, 0.0), + direction: UnitVec3::z_axis(), + }; + let vertex_fp = VertexFingerprint { + point: Point3::origin(), + }; + let face_ref = EntityRef::Face( + FaceLabel { + feature: FeatureId::default(), + role: FaceRole::EndCap, + }, + face_fp, + ); + let Ok(score) = MatchScore::new(0.875) else { + panic!("0.875 is in range"); + }; + let resolved: Resolution = Resolution::Resolved(FeatureId::default()); + let repaired: Resolution = Resolution::Repaired { + id: FeatureId::default(), + score, + }; + let dangling: Resolution = Resolution::Dangling { + last_known: face_ref, + }; + let surface = format!( + "face_fp_display = {face_fp}\n\ + edge_fp_display = {edge_fp}\n\ + vertex_fp_display = {vertex_fp}\n\ + entity_fp_display = {efp}\n\ + face_ref_display = {face_ref}\n\ + face_ref_debug = {face_ref:?}\n\ + match_score_disp = {score}\n\ + match_score_value = {sv}\n\ + resolved_debug = {resolved:?}\n\ + repaired_debug = {repaired:?}\n\ + dangling_debug = {dangling:?}", + efp = face_ref.fingerprint(), + sv = score.value(), + ); + insta::assert_snapshot!(surface); +} + +#[test] +fn history_surface() { + let face_ref = EntityRef::Face( + FaceLabel { + feature: FeatureId::default(), + role: FaceRole::EndCap, + }, + FaceFingerprint { + plane: Plane3::new_unchecked(Point3::origin(), UnitVec3::x_axis(), UnitVec3::y_axis()), + centroid: Point3::from_mm(1.0, 2.0, 3.0), + }, + ); + let Ok(score) = MatchScore::new(0.5) else { + panic!("0.5 is in range"); + }; + let mark = FeatureGeneration::new(7); + let dangling = RebuildStatus::Error(RebuildError::DanglingReference(face_ref)); + let non_planar = RebuildStatus::Error(RebuildError::NonPlanarSketchTarget); + let warned = RebuildStatus::Warning(RebuildWarning::RepairedReference { + reference: face_ref, + score, + }); + let surface = format!( + "feature_gen_disp = {mark}\n\ + feature_gen_succ = {succ}\n\ + rollback_end_disp = {rb_end}\n\ + rollback_above_disp = {rb_above}\n\ + suppress_active = {sa}\n\ + suppress_suppressed = {ss}\n\ + status_uptodate = {uptodate}\n\ + status_needs = {needs}\n\ + status_dangling = {dangling}\n\ + status_non_planar = {non_planar}\n\ + status_warned = {warned}\n\ + status_error_flag = {ef}\n\ + status_builds_geo = {bg}", + succ = mark.succ(), + rb_end = RollbackMarker::AtEnd, + rb_above = RollbackMarker::Above(FeatureId::default()), + sa = SuppressionState::Active, + ss = SuppressionState::Suppressed, + uptodate = RebuildStatus::UpToDate, + needs = RebuildStatus::NeedsRebuild, + ef = dangling.is_error(), + bg = warned.builds_geometry(), + ); + insta::assert_snapshot!(surface); +} + #[test] fn display_surface() { let surface = format!( diff --git a/crates/bone-ui/src/widgets/mod.rs b/crates/bone-ui/src/widgets/mod.rs index dd0f626..b436251 100644 --- a/crates/bone-ui/src/widgets/mod.rs +++ b/crates/bone-ui/src/widgets/mod.rs @@ -91,8 +91,9 @@ pub use toggle_button::{ToggleButton, ToggleButtonResponse, show_toggle_button}; pub use toolbar::{Toolbar, ToolbarItem, ToolbarOrientation, ToolbarResponse, show_toolbar}; pub use tooltip::{Tooltip, TooltipPlacement, TooltipState, show_tooltip}; pub use tree_view::{ - DropPlacement, DropTarget, RenameCommit, TreeNode, TreeSelectionMode, TreeView, - TreeViewResponse, TreeViewState, show_tree_view, + ContextMenuRequest, DropPlacement, DropTarget, RenameCommit, RollbackBar, RollbackTarget, + TreeBadge, TreeNode, TreeSelectionMode, TreeView, TreeViewResponse, TreeViewState, + show_tree_view, }; pub use vector::{ConvexPoly, MAX_CONVEX_VERTS, MAX_PATH_POINTS, PolyPath}; pub use visuals::{ diff --git a/crates/bone-ui/src/widgets/tree_view.rs b/crates/bone-ui/src/widgets/tree_view.rs index c1025bf..daa3334 100644 --- a/crates/bone-ui/src/widgets/tree_view.rs +++ b/crates/bone-ui/src/widgets/tree_view.rs @@ -17,6 +17,13 @@ use super::scrollbar::{row_window, wheel_scroll, window_scrollbar}; use super::text_input::{AlwaysValid, MemoryClipboard, TextInput, TextInputState, show_text_input}; use super::visuals::push_focus_ring; +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum TreeBadge { + RebuildNeeded, + Warning, + Error, +} + #[derive(Clone, Debug, PartialEq)] pub struct TreeNode { pub id: WidgetId, @@ -24,6 +31,7 @@ pub struct TreeNode { pub children: Vec, pub disabled: bool, pub icon: Option, + pub badge: Option, } impl TreeNode { @@ -55,6 +63,7 @@ impl TreeNode { children, disabled: false, icon: None, + badge: None, } } @@ -70,6 +79,12 @@ impl TreeNode { self } + #[must_use] + pub fn with_badge(mut self, badge: Option) -> Self { + self.badge = badge; + self + } + #[must_use] pub fn has_children(&self) -> bool { !self.children.is_empty() @@ -86,7 +101,6 @@ pub enum TreeSelectionMode { pub enum DropPlacement { Before, After, - Into, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -95,6 +109,24 @@ pub struct DropTarget { pub placement: DropPlacement, } +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct ContextMenuRequest { + pub target: WidgetId, + pub at: LayoutPos, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum RollbackTarget { + AtEnd, + Above(WidgetId), +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct RollbackBar<'a> { + pub stops: &'a [WidgetId], + pub marker: RollbackTarget, +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct TreeViewState { pub expanded: BTreeSet, @@ -126,6 +158,8 @@ pub struct TreeView<'a, 'state> { pub row_height: LayoutPx, pub indent_step: LayoutPx, pub renamable: &'a [WidgetId], + pub illegal_drop: bool, + pub rollback: Option>, } impl<'a, 'state> TreeView<'a, 'state> { @@ -147,6 +181,8 @@ impl<'a, 'state> TreeView<'a, 'state> { row_height: LayoutPx::new(20.0), indent_step: LayoutPx::new(16.0), renamable: &[], + illegal_drop: false, + rollback: None, } } @@ -159,6 +195,19 @@ impl<'a, 'state> TreeView<'a, 'state> { pub const fn renamable(self, renamable: &'a [WidgetId]) -> Self { Self { renamable, ..self } } + + #[must_use] + pub const fn illegal_drop(self, illegal_drop: bool) -> Self { + Self { + illegal_drop, + ..self + } + } + + #[must_use] + pub const fn rollback(self, rollback: Option>) -> Self { + Self { rollback, ..self } + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -174,6 +223,8 @@ pub struct TreeViewResponse { pub rename_committed: Option, pub rename_cancelled: Option, pub drop_committed: Option<(WidgetId, DropTarget)>, + pub context_menu: Option, + pub rollback_moved: Option, pub paint: Vec, } @@ -189,6 +240,8 @@ pub fn show_tree_view(ctx: &mut FrameCtx<'_>, view: TreeView<'_, '_>) -> TreeVie row_height, indent_step, renamable, + illegal_drop, + rollback, } = view; ctx.a11y .push(id, rect, AccessNode::new(Role::Tree).with_label(label)); @@ -211,36 +264,42 @@ pub fn show_tree_view(ctx: &mut FrameCtx<'_>, view: TreeView<'_, '_>) -> TreeVie if let Some(id) = entry_id { ctx.focus.register_tab_stop(id); } - let mut activated: Option = None; - let mut double_activated: Option = None; - let mut drop_committed: Option<(WidgetId, DropTarget)> = None; let row_rect_of = |idx: usize| row_rect_at(content_rect, idx - window.first_row, row_height); - let row_paint = visible - .iter() - .enumerate() - .skip(window.first_row) - .take(window.last_row - window.first_row) - .map(|(idx, row)| { - draw_row( - ctx, - RowDrawArgs { - row, - row_rect: row_rect_of(idx), - indent_step, - state, - mode, - renamable, - }, - &mut activated, - &mut double_activated, - &mut drop_committed, - ) - }) - .fold(Vec::new(), |mut acc, mut p| { - acc.append(&mut p); - acc - }); + let (row_paint, interactions) = paint_tree_rows( + ctx, + &visible, + state, + renamable, + RowFrame { + content_rect, + row_height, + indent_step, + first_row: window.first_row, + last_row: window.last_row, + mode, + illegal_drop, + }, + ); + let RowInteractions { + mut activated, + double_activated, + drop_committed, + context_menu, + } = interactions; paint.extend(row_paint); + let rollback_moved = rollback.and_then(|bar| { + let (bar_paint, moved) = render_rollback_bar( + ctx, + id, + bar, + &visible, + content_rect, + window.first_row, + row_height, + ); + paint.extend(bar_paint); + moved + }); let (bar_paint, next_offset) = window_scrollbar(ctx, id, label, &window, state.scroll_offset); state.scroll_offset = next_offset; paint.extend(bar_paint); @@ -262,10 +321,180 @@ pub fn show_tree_view(ctx: &mut FrameCtx<'_>, view: TreeView<'_, '_>) -> TreeVie rename_committed, rename_cancelled, drop_committed, + context_menu, + rollback_moved, paint, } } +#[derive(Copy, Clone)] +struct RowFrame { + content_rect: LayoutRect, + row_height: LayoutPx, + indent_step: LayoutPx, + first_row: usize, + last_row: usize, + mode: TreeSelectionMode, + illegal_drop: bool, +} + +struct RowInteractions { + activated: Option, + double_activated: Option, + drop_committed: Option<(WidgetId, DropTarget)>, + context_menu: Option, +} + +fn paint_tree_rows( + ctx: &mut FrameCtx<'_>, + visible: &[VisibleRow], + state: &mut TreeViewState, + renamable: &[WidgetId], + frame: RowFrame, +) -> (Vec, RowInteractions) { + let mut out = RowInteractions { + activated: None, + double_activated: None, + drop_committed: None, + context_menu: None, + }; + let paint = visible + .iter() + .enumerate() + .skip(frame.first_row) + .take(frame.last_row - frame.first_row) + .flat_map(|(idx, row)| { + let row_rect = row_rect_at(frame.content_rect, idx - frame.first_row, frame.row_height); + draw_row( + ctx, + RowDrawArgs { + row, + row_rect, + indent_step: frame.indent_step, + state, + mode: frame.mode, + renamable, + illegal_drop: frame.illegal_drop, + }, + &mut RowOutcomes { + activated: &mut out.activated, + double_activated: &mut out.double_activated, + drop_committed: &mut out.drop_committed, + context_menu: &mut out.context_menu, + }, + ) + }) + .collect(); + (paint, out) +} + +fn rollback_boundaries( + bar: RollbackBar<'_>, + visible: &[VisibleRow], + content_rect: LayoutRect, + first_row: usize, + row_height: LayoutPx, +) -> Vec<(f32, RollbackTarget)> { + let row_top = |index: usize| { + row_rect_at(content_rect, index.saturating_sub(first_row), row_height) + .origin + .y + .value() + }; + let stops: Vec<(usize, WidgetId)> = bar + .stops + .iter() + .filter_map(|stop| { + visible + .iter() + .position(|row| row.id == *stop) + .map(|index| (index, *stop)) + }) + .collect(); + let above = stops + .iter() + .map(|(index, id)| (row_top(*index), RollbackTarget::Above(*id))); + let at_end = stops + .last() + .map(|(index, _)| (row_top(*index) + row_height.value(), RollbackTarget::AtEnd)); + above.chain(at_end).collect() +} + +fn nearest_boundary( + boundaries: &[(f32, RollbackTarget)], + pointer_y: f32, +) -> Option { + boundaries + .iter() + .min_by(|a, b| (a.0 - pointer_y).abs().total_cmp(&(b.0 - pointer_y).abs())) + .map(|(_, target)| *target) +} + +fn render_rollback_bar( + ctx: &mut FrameCtx<'_>, + tree_id: WidgetId, + bar: RollbackBar<'_>, + visible: &[VisibleRow], + content_rect: LayoutRect, + first_row: usize, + row_height: LayoutPx, +) -> (Vec, Option) { + let boundaries = rollback_boundaries(bar, visible, content_rect, first_row, row_height); + let Some(&(current_y, _)) = boundaries.iter().find(|(_, target)| *target == bar.marker) else { + return (Vec::new(), None); + }; + let bar_id = tree_id.child(WidgetKey::new("rollback_bar")); + let hit_rect = LayoutRect::new( + LayoutPos::new(content_rect.origin.x, LayoutPx::new(current_y - 3.0)), + LayoutSize::new(content_rect.size.width, LayoutPx::new(6.0)), + ); + let interaction = + ctx.interact(InteractDeclaration::new(bar_id, hit_rect, Sense::DRAGGABLE).focusable(false)); + let dragging = interaction.drag_button == Some(PointerButton::Primary); + let pointer_target = ctx + .input + .pointer + .and_then(|sample| nearest_boundary(&boundaries, sample.position.y.value())); + let moved = interaction + .drag_release() + .then_some(pointer_target) + .flatten() + .filter(|target| *target != bar.marker); + let line_rect = |y: f32| { + LayoutRect::new( + LayoutPos::new(content_rect.origin.x, LayoutPx::new(y - 1.0)), + LayoutSize::new(content_rect.size.width, LayoutPx::new(2.0)), + ) + }; + let mut paint = vec![WidgetPaint::Surface { + rect: line_rect(current_y), + fill: ctx.theme().colors.accent.step(Step12::SOLID), + border: None, + radius: ctx.theme().radius.none, + elevation: None, + }]; + let preview_y = dragging + .then_some(pointer_target) + .flatten() + .and_then(|target| { + boundaries + .iter() + .find(|(_, b)| *b == target) + .map(|(y, _)| *y) + }) + .filter(|y| (*y - current_y).abs() > f32::EPSILON); + if let Some(y) = preview_y { + paint.push(WidgetPaint::Surface { + rect: line_rect(y), + fill: ctx.theme().colors.accent.step(Step12::HOVER_BORDER), + border: None, + radius: ctx.theme().radius.none, + elevation: None, + }); + } + (paint, moved) +} + #[derive(Clone, Debug, PartialEq)] struct VisibleRow { id: WidgetId, @@ -274,6 +503,7 @@ struct VisibleRow { has_children: bool, disabled: bool, icon: Option, + badge: Option, } fn flatten(roots: &[TreeNode], expanded: &BTreeSet, depth: usize) -> Vec { @@ -287,6 +517,7 @@ fn flatten(roots: &[TreeNode], expanded: &BTreeSet, depth: usize) -> V has_children: node.has_children(), disabled: node.disabled, icon: node.icon, + badge: node.badge, }; let children = if expanded.contains(&node.id) { flatten(&node.children, expanded, depth + 1) @@ -346,14 +577,13 @@ struct RowDrawArgs<'a> { state: &'a mut TreeViewState, mode: TreeSelectionMode, renamable: &'a [WidgetId], + illegal_drop: bool, } fn draw_row( ctx: &mut FrameCtx<'_>, args: RowDrawArgs<'_>, - activated: &mut Option, - double_activated: &mut Option, - drop_committed: &mut Option<(WidgetId, DropTarget)>, + outcomes: &mut RowOutcomes<'_>, ) -> Vec { let RowDrawArgs { row, @@ -362,6 +592,7 @@ fn draw_row( state, mode, renamable, + illegal_drop, } = args; #[allow(clippy::cast_precision_loss, reason = "tree depth fits f32 mantissa")] let indent = LayoutPx::new(row.depth as f32 * indent_step.value()); @@ -395,12 +626,8 @@ fn draw_row( state, mode, renamable, - outcomes: RowOutcomes { - activated, - double_activated, - drop_committed, - }, }, + outcomes, ); let mut paint = vec![WidgetPaint::Surface { rect: row_rect, @@ -419,12 +646,20 @@ fn draw_row( row.disabled, )); } + let content_rect = badge_content_rect(label_rect, row.badge.is_some()); if state.renaming == Some(row.id) { paint.extend(draw_rename_editor( - ctx, row.id, &row.label, label_rect, state, + ctx, + row.id, + &row.label, + content_rect, + state, )); } else { - paint.extend(label_with_glyph_paint(ctx, row, label_rect)); + paint.extend(label_with_glyph_paint(ctx, row, content_rect)); + } + if let Some(badge) = row.badge { + paint.push(badge_dot_paint(ctx, badge, label_rect)); } push_focus_ring( ctx, @@ -436,9 +671,14 @@ fn draw_row( if let Some(target) = state.drop_target && target.anchor == row.id { + let fill = if illegal_drop { + ctx.theme().colors.danger.step(Step12::SOLID) + } else { + ctx.theme().colors.accent.step(Step12::SOLID) + }; paint.push(WidgetPaint::Surface { rect: drop_indicator_rect(row_rect, target.placement), - fill: ctx.theme().colors.accent.step(Step12::SOLID), + fill, border: None, radius: ctx.theme().radius.none, elevation: None, @@ -447,10 +687,54 @@ fn draw_row( paint } +const BADGE_GUTTER_PX: f32 = 11.0; + +fn badge_content_rect(label_rect: LayoutRect, has_badge: bool) -> LayoutRect { + if !has_badge { + return label_rect; + } + LayoutRect::new( + LayoutPos::new( + LayoutPx::new(label_rect.origin.x.value() + BADGE_GUTTER_PX), + label_rect.origin.y, + ), + LayoutSize::new( + LayoutPx::saturating_nonneg(label_rect.size.width.value() - BADGE_GUTTER_PX), + label_rect.size.height, + ), + ) +} + +fn badge_dot_paint(ctx: &FrameCtx<'_>, badge: TreeBadge, label_rect: LayoutRect) -> WidgetPaint { + const DOT_PX: f32 = 7.0; + let rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(label_rect.origin.x.value() + (BADGE_GUTTER_PX - DOT_PX) / 2.0), + LayoutPx::new( + label_rect.origin.y.value() + (label_rect.size.height.value() - DOT_PX) / 2.0, + ), + ), + LayoutSize::new(LayoutPx::new(DOT_PX), LayoutPx::new(DOT_PX)), + ); + let fill = match badge { + TreeBadge::Error => ctx.theme().colors.danger.step(Step12::SOLID), + TreeBadge::Warning => ctx.theme().colors.warning.step(Step12::SOLID), + TreeBadge::RebuildNeeded => ctx.theme().colors.accent.step(Step12::SOLID), + }; + WidgetPaint::Surface { + rect, + fill, + border: None, + radius: ctx.theme().radius.sm, + elevation: None, + } +} + struct RowOutcomes<'a> { activated: &'a mut Option, double_activated: &'a mut Option, drop_committed: &'a mut Option<(WidgetId, DropTarget)>, + context_menu: &'a mut Option, } struct RowInteractionArgs<'a> { @@ -460,7 +744,6 @@ struct RowInteractionArgs<'a> { state: &'a mut TreeViewState, mode: TreeSelectionMode, renamable: &'a [WidgetId], - outcomes: RowOutcomes<'a>, } fn commit_pending_rename( @@ -502,7 +785,11 @@ fn commit_pending_rename( state.pending_rename = None; } -fn apply_row_interaction(ctx: &mut FrameCtx<'_>, args: RowInteractionArgs<'_>) { +fn apply_row_interaction( + ctx: &mut FrameCtx<'_>, + args: RowInteractionArgs<'_>, + outcomes: &mut RowOutcomes<'_>, +) { let RowInteractionArgs { row, row_rect, @@ -510,22 +797,37 @@ fn apply_row_interaction(ctx: &mut FrameCtx<'_>, args: RowInteractionArgs<'_>) { state, mode, renamable, - outcomes, } = args; let RowOutcomes { activated, double_activated, drop_committed, + context_menu, } = outcomes; if row.disabled { return; } + let secondary = interaction.click_button == Some(PointerButton::Secondary); + if secondary && interaction.click() { + if !state.selection.contains(&row.id) { + update_selection(state, row.id, ModifierMask::NONE, mode); + } + state.focused = Some(row.id); + ctx.focus.request_focus(row.id); + state.pending_rename = None; + if context_menu.is_none() + && let Some(at) = ctx.input.pointer.map(|sample| sample.position) + { + **context_menu = Some(ContextMenuRequest { target: row.id, at }); + } + return; + } if interaction.click() { let was_selected = state.selection.contains(&row.id); let is_double = interaction.double_click(); update_selection(state, row.id, ctx.input.modifiers, mode); if activated.is_none() { - *activated = Some(row.id); + **activated = Some(row.id); } state.focused = Some(row.id); ctx.focus.request_focus(row.id); @@ -546,7 +848,7 @@ fn apply_row_interaction(ctx: &mut FrameCtx<'_>, args: RowInteractionArgs<'_>) { state.pending_rename = None; } if double_activated.is_none() { - *double_activated = Some(row.id); + **double_activated = Some(row.id); } } if interaction.drag_start() { @@ -557,18 +859,18 @@ fn apply_row_interaction(ctx: &mut FrameCtx<'_>, args: RowInteractionArgs<'_>) { && let Some(target) = state.drop_target { if drop_committed.is_none() && src != target.anchor { - *drop_committed = Some((src, target)); + **drop_committed = Some((src, target)); } state.drag_source = None; state.drop_target = None; } - if state.drag_source.is_some() && interaction.hover() { - let placement = ctx.input.pointer.map_or(DropPlacement::Into, |p| { - placement_from_pointer(p.position, row_rect) - }); + if state.drag_source.is_some() + && interaction.hover() + && let Some(pointer) = ctx.input.pointer + { state.drop_target = Some(DropTarget { anchor: row.id, - placement, + placement: placement_from_pointer(pointer.position, row_rect), }); } } @@ -700,14 +1002,11 @@ fn draw_rename_editor( } fn placement_from_pointer(pointer: LayoutPos, row: LayoutRect) -> DropPlacement { - let third = row.size.height.value() / 3.0; let local = pointer.y.value() - row.origin.y.value(); - if local < third { + if local * 2.0 < row.size.height.value() { DropPlacement::Before - } else if local > 2.0 * third { - DropPlacement::After } else { - DropPlacement::Into + DropPlacement::After } } @@ -724,7 +1023,6 @@ fn drop_indicator_rect(row: LayoutRect, placement: DropPlacement) -> LayoutRect ), LayoutSize::new(row.size.width, stripe), ), - DropPlacement::Into => row, } } @@ -1041,6 +1339,58 @@ mod tests { s } + fn secondary_press(pos: LayoutPos) -> InputSnapshot { + let mut s = InputSnapshot::idle(FrameInstant::ZERO); + s.pointer = Some(PointerSample::new(pos)); + s.buttons_pressed = PointerButtonMask::just(PointerButton::Secondary); + s + } + + fn secondary_release(pos: LayoutPos) -> InputSnapshot { + let mut s = InputSnapshot::idle(FrameInstant::ZERO); + s.pointer = Some(PointerSample::new(pos)); + s.buttons_released = PointerButtonMask::just(PointerButton::Secondary); + s + } + + #[test] + fn secondary_click_requests_context_menu_and_selects_row() { + let roots = sample_tree(); + let mut state = TreeViewState::default(); + let mut focus = FocusManager::new(); + let mut prev = HitState::new(); + let pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(30.0)); + let mut last: Option = None; + [secondary_press(pos), secondary_release(pos), idle(pos)] + .into_iter() + .for_each(|mut snap| { + let (response, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); + last = Some(response); + prev = next; + }); + let Some(request) = last.and_then(|r| r.context_menu) else { + panic!("secondary click requests a context menu"); + }; + assert_eq!(request.target, root_id("sketch")); + assert!(state.selection.contains(&root_id("sketch"))); + } + + #[test] + fn secondary_click_does_not_activate_row() { + let roots = sample_tree(); + let mut state = TreeViewState::default(); + let mut focus = FocusManager::new(); + let mut prev = HitState::new(); + let pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(30.0)); + [secondary_press(pos), secondary_release(pos), idle(pos)] + .into_iter() + .for_each(|mut snap| { + let (response, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); + assert!(response.activated.is_none()); + prev = next; + }); + } + #[test] fn click_disclosure_toggles_expansion() { let roots = sample_tree();