Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

Big checkpoint for the boy

Multiple features in seq, edit-propagate-recompute,
persistent ids, better undo mechanics, in-mem snapshot history,
document-folder on disk.

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (Jun 22, 2026, 11:21 AM +0300) commit a8986660 parent e0bfe3b3 change-id mmvwvqrl
+7623 -513
+865 -49
crates/bone-app/src/app.rs
··· 1 - use std::collections::BTreeMap; 1 + use std::collections::{BTreeMap, BTreeSet}; 2 2 use std::num::NonZeroUsize; 3 3 use std::path::{Path, PathBuf}; 4 4 use std::sync::Arc; 5 5 6 6 use bone_document::{ 7 7 DimensionKind, DimensionValue, Document, DocumentFolder, EditOutcome, EvaluatedExtrude, 8 - ExtrudeError, ExtrudeFeature, FeatureCache, FeatureNode, LineData, Sketch, SketchDimension, 9 - SketchEdit, SketchEntity, SketchRelation, SketchVersion, SolverError, UndoStack, 8 + EvaluatedModel, ExtrudeError, ExtrudeFeature, FeatureNode, LineData, RebuildBudget, 9 + RebuildCost, RebuildPass, RecomputeScope, Sketch, SketchDimension, SketchEdit, SketchEntity, 10 + SketchRelation, SketchVersion, SolverError, UndoStack, evaluate_extrude, evaluate_sketch, 10 11 }; 11 12 use bone_render::{ 12 13 Camera2, CameraTween, ChromeInstance, ChromePipeline, ChromeTextPipeline, ConvexInstance, ··· 18 19 frame_view_direction, orbit_pitch, orbit_yaw, pan_pixels, roll_by, zoom_about_pixel, 19 20 }; 20 21 use bone_types::{ 21 - Aabb3, Angle, AngleTolerance, BudgetCeiling, Camera3, ChordHeightTolerance, CubicEasing, 22 - DisplayMode, DocumentId, ExtrudeId, FeatureId, GeometryGeneration, Length, Plane3, Point2, 23 - SketchId, SketchItemId, StandardView, Vec2, ZoomFactor, 22 + Aabb3, Angle, AngleTolerance, BrepFaceId, BudgetCeiling, Camera3, ChordHeightTolerance, 23 + CubicEasing, DisplayMode, DocumentId, ExtrudeId, FeatureId, GeometryGeneration, Length, Plane3, 24 + Point2, RebuildError, RebuildStatus, SketchId, SketchItemId, StandardView, Vec2, ZoomFactor, 24 25 }; 25 26 use bone_ui::a11y::AccessTreeBuilder; 26 27 use bone_ui::focus::FocusManager; ··· 34 35 use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 35 36 use bone_ui::strings::StringTable; 36 37 use bone_ui::theme::Theme; 38 + use bone_ui::widgets::TreeBadge; 37 39 use bone_ui::{MaskAtlas, MaskAtlasParams, Shaper}; 38 40 use swash::FontRef; 39 41 use uom::si::angle::degree; ··· 90 92 document: Document, 91 93 plane_sketches: BTreeMap<Plane, SketchId>, 92 94 mode: Mode, 93 - feature_cache: FeatureCache, 94 95 extrude_preview: Option<ExtrudePreview>, 96 + model: EvaluatedModel, 97 + model_passes: Vec<(FeatureId, RebuildPass)>, 98 + changed_features: BTreeSet<FeatureId>, 99 + needs_rebuild: bool, 100 + pending_reattach: Option<SketchId>, 95 101 solid_renderer: SolidRenderer, 96 102 solid_view: Option<SolidViewData>, 97 103 camera3: Option<Camera3>, ··· 463 469 } 464 470 } 465 471 472 + fn solid_pick_index(model: &EvaluatedModel) -> Option<PickIndex> { 473 + let faces: BTreeSet<BrepFaceId> = model 474 + .bodies() 475 + .flat_map(|(_, solid)| solid.iter_faces().map(bone_document::BrepFace::id)) 476 + .collect(); 477 + if faces.is_empty() { 478 + return None; 479 + } 480 + PickIndex::build_solid(faces, core::iter::empty(), core::iter::empty()).ok() 481 + } 482 + 483 + fn active_pick_index(state: &AppState) -> Option<PickIndex> { 484 + if matches!(state.mode, Mode::Idle) && state.solid_view.is_some() { 485 + return solid_pick_index(&state.model); 486 + } 487 + match state.scene.pick_index() { 488 + Ok(index) => Some(index), 489 + Err(error) => { 490 + tracing::warn!(error = %error, "build sketch pick index"); 491 + None 492 + } 493 + } 494 + } 495 + 466 496 fn pick_at(state: &AppState, target: &impl FrameTarget, cursor: WindowPoint) -> Option<PickedItem> { 467 497 if !cursor.x.is_finite() || !cursor.y.is_finite() || cursor.x < 0.0 || cursor.y < 0.0 { 468 498 return None; ··· 483 513 if qx >= extent.width().value() || qy >= extent.height().value() { 484 514 return None; 485 515 } 486 - let index = match state.scene.pick_index() { 487 - Ok(i) => i, 488 - Err(e) => { 489 - tracing::warn!(error = %e, "build pick index"); 490 - return None; 491 - } 492 - }; 516 + let index = active_pick_index(state)?; 493 517 let query = PickQuery::new(ViewportPx::new(qx), ViewportPx::new(qy)) 494 518 .with_aperture(state.settings.pick_aperture); 495 519 match target.picker(index).at(query) { ··· 507 531 cursor: WindowPoint, 508 532 additive: bool, 509 533 ) { 510 - let item = pick_at(state, target, cursor).and_then(selection::picked_to_item); 534 + let picked = pick_at(state, target, cursor); 535 + if let Some(sketch) = state.pending_reattach { 536 + if let Some(PickedItem::BrepFace(face)) = picked { 537 + apply_reattach(state, sketch, face); 538 + } 539 + return; 540 + } 541 + if !additive 542 + && matches!(state.mode, Mode::Idle) 543 + && let Some(PickedItem::BrepFace(face)) = picked 544 + && begin_face_sketch(state, face) 545 + { 546 + return; 547 + } 548 + let item = picked.and_then(selection::picked_to_item); 511 549 state.selection = std::mem::take(&mut state.selection).picked(item, additive); 512 550 if additive || !state.mode.is_sketch() { 513 551 return; ··· 742 780 } 743 781 } 744 782 783 + fn clear_solid(state: &mut AppState) { 784 + state.solid_view = None; 785 + state.camera3 = None; 786 + state.view.home = None; 787 + state.view.tween = None; 788 + } 789 + 790 + fn body_passes(model: &EvaluatedModel) -> Vec<(FeatureId, RebuildPass)> { 791 + model 792 + .bodies() 793 + .filter_map(|(feature, _)| model.built_at(feature).map(|pass| (feature, pass))) 794 + .collect() 795 + } 796 + 797 + fn build_combined_view(model: &EvaluatedModel) -> Option<SolidViewData> { 798 + let mut parts = model 799 + .bodies() 800 + .filter_map(|(_, solid)| build_solid_view(solid).ok()); 801 + let first = parts.next()?; 802 + Some(parts.fold(first, |acc, part| SolidViewData { 803 + faces: acc.faces.merge(part.faces), 804 + edges: acc.edges.merge(part.edges), 805 + aabb: acc.aabb.union(part.aabb), 806 + })) 807 + } 808 + 809 + fn recompute_model(state: &mut AppState, scope: RecomputeScope) { 810 + state.model.recompute( 811 + &state.document, 812 + state.document.suppressed(), 813 + state.document.rollback(), 814 + scope, 815 + ); 816 + } 817 + 818 + fn rebuild_model_view(state: &mut AppState) { 819 + state.model_passes = body_passes(&state.model); 820 + state.solid_view = build_combined_view(&state.model); 821 + if state.solid_view.is_none() { 822 + clear_solid(state); 823 + } 824 + } 825 + 826 + fn defer_rebuild(first: bool, cost: RebuildCost, budget: RebuildBudget) -> bool { 827 + !first && !cost.within(budget) 828 + } 829 + 830 + fn sync_model_view(state: &mut AppState) { 831 + state.extrude_preview = None; 832 + recompute_model(state, RecomputeScope::Full); 833 + let passes = body_passes(&state.model); 834 + if passes.is_empty() { 835 + if state.solid_view.is_some() || !state.model_passes.is_empty() { 836 + state.model_passes.clear(); 837 + clear_solid(state); 838 + } 839 + state.changed_features.clear(); 840 + state.needs_rebuild = false; 841 + return; 842 + } 843 + if passes == state.model_passes && state.solid_view.is_some() { 844 + return; 845 + } 846 + let first = state.model_passes.is_empty() || state.solid_view.is_none(); 847 + if defer_rebuild( 848 + first, 849 + state.model.rebuild_cost(), 850 + RebuildBudget::INTERACTIVE, 851 + ) { 852 + let changed = passes 853 + .iter() 854 + .filter(|entry| !state.model_passes.contains(entry)) 855 + .map(|(feature, _)| *feature); 856 + state.changed_features.extend(changed); 857 + state.needs_rebuild = true; 858 + return; 859 + } 860 + rebuild_model_view(state); 861 + state.changed_features.clear(); 862 + state.needs_rebuild = false; 863 + } 864 + 865 + fn status_to_badge(status: RebuildStatus) -> Option<TreeBadge> { 866 + match status { 867 + RebuildStatus::Error(_) => Some(TreeBadge::Error), 868 + RebuildStatus::Warning(_) => Some(TreeBadge::Warning), 869 + RebuildStatus::NeedsRebuild => Some(TreeBadge::RebuildNeeded), 870 + RebuildStatus::UpToDate => None, 871 + } 872 + } 873 + 874 + fn feature_badge(state: &AppState, feature: FeatureId) -> Option<TreeBadge> { 875 + let status = state.model.status(feature).and_then(status_to_badge); 876 + let rebuild = state 877 + .changed_features 878 + .contains(&feature) 879 + .then_some(TreeBadge::RebuildNeeded); 880 + [status, rebuild].into_iter().flatten().max() 881 + } 882 + 883 + fn compute_feature_badges(state: &AppState) -> BTreeMap<FeatureId, TreeBadge> { 884 + state 885 + .document 886 + .feature_tree() 887 + .iter() 888 + .filter_map(|(feature, _)| feature_badge(state, feature).map(|badge| (feature, badge))) 889 + .collect() 890 + } 891 + 892 + fn frame_badges(state: &mut AppState) -> (BTreeMap<FeatureId, TreeBadge>, bool) { 893 + recompute_model(state, RecomputeScope::Full); 894 + (compute_feature_badges(state), state.needs_rebuild) 895 + } 896 + 897 + fn feature_label_text(document: &Document, feature: FeatureId) -> String { 898 + match document.feature_tree().node(feature) { 899 + Some(FeatureNode::Sketch(sketch_id)) => { 900 + document.sketch_label(sketch_id).unwrap_or("").to_owned() 901 + } 902 + Some(FeatureNode::Extrude(extrude_id)) => { 903 + document.extrude_label(extrude_id).unwrap_or("").to_owned() 904 + } 905 + _ => String::new(), 906 + } 907 + } 908 + 909 + fn sketch_of_feature(document: &Document, feature: FeatureId) -> Option<SketchId> { 910 + match document.feature_tree().node(feature)? { 911 + FeatureNode::Sketch(sketch_id) => Some(sketch_id), 912 + _ => None, 913 + } 914 + } 915 + 916 + struct WhatsWrongKind { 917 + message: bone_ui::strings::StringKey, 918 + is_error: bool, 919 + offers_reattach: bool, 920 + } 921 + 922 + fn whats_wrong_kind(status: RebuildStatus) -> Option<WhatsWrongKind> { 923 + let kind = match status { 924 + RebuildStatus::Error(RebuildError::DanglingReference(_)) => WhatsWrongKind { 925 + message: strings::WHATS_WRONG_DANGLING, 926 + is_error: true, 927 + offers_reattach: true, 928 + }, 929 + RebuildStatus::Error(RebuildError::NonPlanarSketchTarget) => WhatsWrongKind { 930 + message: strings::WHATS_WRONG_NON_PLANAR, 931 + is_error: true, 932 + offers_reattach: false, 933 + }, 934 + RebuildStatus::Error(RebuildError::UpstreamUnresolved) => WhatsWrongKind { 935 + message: strings::WHATS_WRONG_UPSTREAM, 936 + is_error: true, 937 + offers_reattach: false, 938 + }, 939 + RebuildStatus::Error(RebuildError::Build(_)) => WhatsWrongKind { 940 + message: strings::WHATS_WRONG_BUILD_FAILED, 941 + is_error: true, 942 + offers_reattach: false, 943 + }, 944 + RebuildStatus::Warning(_) => WhatsWrongKind { 945 + message: strings::WHATS_WRONG_REPAIRED, 946 + is_error: false, 947 + offers_reattach: false, 948 + }, 949 + RebuildStatus::UpToDate | RebuildStatus::NeedsRebuild => return None, 950 + }; 951 + Some(kind) 952 + } 953 + 954 + fn whats_wrong_entry( 955 + model: &EvaluatedModel, 956 + document: &Document, 957 + feature: FeatureId, 958 + ) -> Option<shell::WhatsWrong> { 959 + let kind = whats_wrong_kind(model.status(feature)?)?; 960 + let reattach = kind 961 + .offers_reattach 962 + .then(|| sketch_of_feature(document, feature)) 963 + .flatten(); 964 + Some(shell::WhatsWrong { 965 + label: feature_label_text(document, feature), 966 + message: kind.message, 967 + is_error: kind.is_error, 968 + reattach, 969 + }) 970 + } 971 + 972 + fn compute_whats_wrong(model: &EvaluatedModel, document: &Document) -> Vec<shell::WhatsWrong> { 973 + document 974 + .feature_tree() 975 + .iter() 976 + .filter_map(|(feature, _)| whats_wrong_entry(model, document, feature)) 977 + .collect() 978 + } 979 + 980 + fn live_dim_anchor(mode: &Mode, document: &Document, pending: &PendingDimension) -> Point2 { 981 + mode.sketch_id() 982 + .and_then(|id| document.sketch(id)) 983 + .and_then(|sketch| smart_dimension::live_anchor(sketch, pending.proto)) 984 + .unwrap_or(pending.anchor) 985 + } 986 + 987 + fn rebuild_changed(state: &mut AppState) { 988 + recompute_model(state, RecomputeScope::Full); 989 + if matches!(state.mode, Mode::Idle) { 990 + rebuild_model_view(state); 991 + } 992 + state.needs_rebuild = false; 993 + state.changed_features.clear(); 994 + } 995 + 996 + fn force_rebuild_all(state: &mut AppState) { 997 + state.model = EvaluatedModel::new(); 998 + recompute_model(state, RecomputeScope::Full); 999 + if matches!(state.mode, Mode::Idle) { 1000 + rebuild_model_view(state); 1001 + } 1002 + state.needs_rebuild = false; 1003 + state.changed_features.clear(); 1004 + } 1005 + 745 1006 fn sync_solid_view(state: &mut AppState) { 1007 + if matches!(state.mode, Mode::Idle) { 1008 + sync_model_view(state); 1009 + return; 1010 + } 1011 + state.model_passes.clear(); 746 1012 let Some(feature) = active_solid_feature(&state.mode, &state.document, state.framed_extrude) 747 1013 else { 748 1014 state.extrude_preview = None; ··· 772 1038 .as_ref() 773 1039 .is_some_and(|cached| cached.failed); 774 1040 let first_preview = state.extrude_preview.is_none(); 775 - let preview = compute_extrude_preview(&mut state.feature_cache, &state.document, feature); 1041 + let preview = compute_extrude_preview(&state.document, feature); 776 1042 let generation = preview.as_ref().and_then(EvaluatedExtrude::generation); 777 1043 let error = preview 778 1044 .as_ref() ··· 815 1081 } 816 1082 817 1083 fn compute_extrude_preview( 818 - cache: &mut FeatureCache, 819 1084 document: &Document, 820 1085 feature: ExtrudeFeature, 821 1086 ) -> Option<EvaluatedExtrude> { 822 1087 let sketch = document.sketch(feature.sketch)?; 823 - let fid = FeatureId::default(); 824 - let evaluated_sketch = cache.evaluate(fid, sketch).clone(); 825 - Some( 826 - cache 827 - .evaluate_extrude(fid, &evaluated_sketch, &feature) 828 - .clone(), 829 - ) 1088 + let evaluated_sketch = evaluate_sketch(sketch); 1089 + Some(evaluate_extrude( 1090 + FeatureId::default(), 1091 + &evaluated_sketch, 1092 + &feature, 1093 + )) 830 1094 } 831 1095 832 1096 fn build_solid_view(solid: &bone_document::BrepSolid) -> Result<SolidViewData, String> { ··· 1372 1636 document, 1373 1637 plane_sketches, 1374 1638 mode: Mode::Idle, 1375 - feature_cache: FeatureCache::new(), 1376 1639 extrude_preview: None, 1640 + model: EvaluatedModel::new(), 1641 + model_passes: Vec::new(), 1642 + changed_features: BTreeSet::new(), 1643 + needs_rebuild: false, 1644 + pending_reattach: None, 1377 1645 solid_renderer, 1378 1646 solid_view: None, 1379 1647 camera3: None, ··· 1813 2081 } 1814 2082 } 1815 2083 let escape_requested = hotkey_actions.contains(&sketch_mode::ESCAPE_ACTION); 2084 + if escape_requested { 2085 + state.pending_reattach = None; 2086 + } 1816 2087 apply_extrude_edit(state, frame.extrude_edit); 1817 2088 apply_extrude_confirm(state, frame.confirm_action); 1818 2089 let prev_active_sketch = active_sketch_id(&state.mode, &state.plane_sketches); ··· 1847 2118 apply_relation_action(state, frame.activated_relation); 1848 2119 apply_sketch_rename(state, frame.sketch_rename.clone()); 1849 2120 apply_extrude_rename(state, frame.extrude_rename.clone()); 2121 + apply_feature_command(state, frame.feature_command); 2122 + apply_feature_reorder(state, frame.feature_reorder); 2123 + apply_rollback_change(state, frame.rollback_change); 2124 + begin_reattach(state, frame.reattach_request); 1850 2125 let cursor_world = input_state 1851 2126 .cursor_px 1852 2127 .filter(|c| state.viewport_rect.contains(window_to_layout_pos(*c))) ··· 2083 2358 C::Quit => { 2084 2359 state.pending_exit = true; 2085 2360 } 2361 + C::RebuildChanged => rebuild_changed(state), 2362 + C::ForceRebuild => force_rebuild_all(state), 2086 2363 C::OpenShortcutBar => { 2087 2364 if state.shortcut_bar.is_none() { 2088 2365 let anchor = cursor_layout.unwrap_or(LayoutPos::ORIGIN); ··· 2626 2903 sketch_rename: None, 2627 2904 extrude_activated: None, 2628 2905 extrude_rename: None, 2906 + feature_command: None, 2907 + feature_reorder: None, 2908 + rollback_change: None, 2909 + reattach_request: None, 2629 2910 exit_sketch: false, 2630 2911 confirm_action: None, 2631 2912 menu_action: None, ··· 2757 3038 layout_size: LayoutSize, 2758 3039 cursor_world: Option<Point2>, 2759 3040 ) -> FrameOutcomes { 3041 + let (feature_badges, needs_rebuild) = frame_badges(state); 3042 + let whats_wrong = compute_whats_wrong(&state.model, &state.document); 2760 3043 let mut ctx = FrameCtx::new( 2761 3044 theme, 2762 3045 input, ··· 2780 3063 state.camera3.filter(|_| state.solid_view.is_some()), 2781 3064 extrude_status, 2782 3065 &mut state.view, 3066 + &feature_badges, 3067 + needs_rebuild, 3068 + &whats_wrong, 2783 3069 ); 2784 3070 let dim_outcome = pending_dim(&state.mode).map(|pending| { 2785 - let live_anchor = state 2786 - .mode 2787 - .sketch_id() 2788 - .and_then(|id| state.document.sketch(id)) 2789 - .and_then(|s| smart_dimension::live_anchor(s, pending.proto)) 2790 - .unwrap_or(pending.anchor); 3071 + let live_anchor = live_dim_anchor(&state.mode, &state.document, &pending); 2791 3072 dimension_editor::render( 2792 3073 &mut ctx, 2793 3074 pending, ··· 4131 4412 apply_extrude_rename_into(&mut state.document, &mut state.undo, req); 4132 4413 } 4133 4414 4415 + fn apply_feature_command(state: &mut AppState, command: Option<shell::FeatureCommand>) { 4416 + let Some(command) = command else { return }; 4417 + if apply_feature_command_into(&mut state.document, &mut state.undo, command) { 4418 + refresh_active_scene(state); 4419 + sync_solid_view(state); 4420 + } 4421 + } 4422 + 4423 + fn apply_feature_command_into( 4424 + document: &mut Document, 4425 + undo: &mut UndoStack, 4426 + command: shell::FeatureCommand, 4427 + ) -> bool { 4428 + let snapshot = document.clone(); 4429 + let changed = match command { 4430 + shell::FeatureCommand::Suppress(feature) => { 4431 + document.suppress(feature); 4432 + true 4433 + } 4434 + shell::FeatureCommand::Unsuppress(feature) => { 4435 + document.unsuppress(feature); 4436 + true 4437 + } 4438 + shell::FeatureCommand::RollbackToHere(feature) => { 4439 + document.roll_to_here(feature); 4440 + true 4441 + } 4442 + shell::FeatureCommand::Delete(target) => apply_feature_delete(document, target), 4443 + }; 4444 + if changed { 4445 + undo.record(snapshot); 4446 + } 4447 + changed 4448 + } 4449 + 4450 + fn apply_feature_delete(document: &mut Document, target: shell::FeatureTarget) -> bool { 4451 + match target { 4452 + shell::FeatureTarget::Sketch(sketch_id) => document.remove_sketch(sketch_id).is_some(), 4453 + shell::FeatureTarget::Extrude(extrude_id) => document.remove_extrude(extrude_id).is_some(), 4454 + } 4455 + } 4456 + 4457 + fn apply_feature_reorder(state: &mut AppState, reorder: Option<shell::FeatureReorder>) { 4458 + let Some(reorder) = reorder else { return }; 4459 + let snapshot = state.document.clone(); 4460 + if state 4461 + .document 4462 + .reorder_feature(reorder.moved, reorder.anchor, reorder.before) 4463 + { 4464 + state.undo.record(snapshot); 4465 + refresh_active_scene(state); 4466 + sync_solid_view(state); 4467 + } 4468 + } 4469 + 4470 + fn begin_reattach(state: &mut AppState, request: Option<SketchId>) { 4471 + let Some(sketch) = request else { return }; 4472 + state.pending_reattach = Some(sketch); 4473 + notify_info(state, strings::NOTIFY_REATTACH_PICK, None); 4474 + } 4475 + 4476 + fn apply_reattach(state: &mut AppState, sketch: SketchId, face: BrepFaceId) { 4477 + let Some((face_ref, _)) = state.model.face_for_sketch(face) else { 4478 + notify_info(state, strings::NOTIFY_REATTACH_NON_PLANAR, None); 4479 + return; 4480 + }; 4481 + let snapshot = state.document.clone(); 4482 + if state.document.bind_sketch_to_face(sketch, face_ref).is_ok() { 4483 + state.pending_reattach = None; 4484 + state.undo.record(snapshot); 4485 + refresh_active_scene(state); 4486 + sync_solid_view(state); 4487 + } 4488 + } 4489 + 4490 + fn next_sketch_label(document: &Document) -> String { 4491 + format!("Sketch{}", document.sketches().count() + 1) 4492 + } 4493 + 4494 + fn begin_face_sketch(state: &mut AppState, face: BrepFaceId) -> bool { 4495 + let Some((face_ref, basis)) = state.model.face_for_sketch(face) else { 4496 + return false; 4497 + }; 4498 + let snapshot = state.document.clone(); 4499 + let sketch_id = state.document.allocate_sketch(); 4500 + let label = next_sketch_label(&state.document); 4501 + state 4502 + .document 4503 + .insert_sketch(sketch_id, label, Sketch::new(basis)); 4504 + if state 4505 + .document 4506 + .bind_sketch_to_face(sketch_id, face_ref) 4507 + .is_err() 4508 + { 4509 + state.document = snapshot; 4510 + return false; 4511 + } 4512 + state.undo.record(snapshot); 4513 + state.mode = Mode::enter_sketch(sketch_id); 4514 + refresh_active_scene(state); 4515 + sync_solid_view(state); 4516 + true 4517 + } 4518 + 4519 + fn apply_rollback_change(state: &mut AppState, change: Option<shell::RollbackChange>) { 4520 + let Some(change) = change else { return }; 4521 + let before = state.document.rollback(); 4522 + let snapshot = state.document.clone(); 4523 + match change { 4524 + shell::RollbackChange::ToEnd => state.document.roll_to_end(), 4525 + shell::RollbackChange::ToFeature(feature) => state.document.roll_to_here(feature), 4526 + } 4527 + if state.document.rollback() != before { 4528 + state.undo.record(snapshot); 4529 + refresh_active_scene(state); 4530 + sync_solid_view(state); 4531 + } 4532 + } 4533 + 4134 4534 fn apply_extrude_rename_into( 4135 4535 document: &mut Document, 4136 4536 undo: &mut UndoStack, ··· 4254 4654 sketch_rename: None, 4255 4655 extrude_activated: None, 4256 4656 extrude_rename: None, 4657 + feature_command: None, 4658 + feature_reorder: None, 4659 + rollback_change: None, 4660 + reattach_request: None, 4257 4661 exit_sketch: false, 4258 4662 confirm_action: None, 4259 4663 menu_action: None, ··· 4342 4746 } 4343 4747 4344 4748 #[test] 4345 - fn extrude_preview_refreshes_when_the_sketch_changes_under_one_cache() { 4749 + fn extrude_preview_refreshes_when_the_sketch_changes() { 4346 4750 let (mut document, id) = super::initial_document(rectangle_sketch()); 4347 4751 let feature = sketch_mode::default_extrude_feature(id); 4348 - let mut cache = super::FeatureCache::new(); 4349 - let first = super::compute_extrude_preview(&mut cache, &document, feature) 4752 + let first = super::compute_extrude_preview(&document, feature) 4350 4753 .and_then(|preview| preview.generation()); 4351 4754 document.replace_sketch(id, tall_rectangle_sketch()); 4352 - let second = super::compute_extrude_preview(&mut cache, &document, feature) 4755 + let second = super::compute_extrude_preview(&document, feature) 4353 4756 .and_then(|preview| preview.generation()); 4354 4757 let (Some(first), Some(second)) = (first, second) else { 4355 4758 panic!("both rectangles extrude to a solid"); ··· 4364 4767 fn extrude_preview_evaluates_a_closed_rectangle() { 4365 4768 let (document, id) = super::initial_document(rectangle_sketch()); 4366 4769 let feature = sketch_mode::default_extrude_feature(id); 4367 - let mut cache = super::FeatureCache::new(); 4368 - let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { 4770 + let Some(preview) = super::compute_extrude_preview(&document, feature) else { 4369 4771 panic!("a registered sketch yields an evaluated preview"); 4370 4772 }; 4371 4773 assert!( ··· 4390 4792 const STEPS: u32 = 16; 4391 4793 4392 4794 let (document, id) = super::initial_document(rectangle_sketch()); 4393 - let mut cache = super::FeatureCache::new(); 4394 4795 let blind = |depth_mm: f64| { 4395 4796 let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(depth_mm)) else { 4396 4797 panic!("{depth_mm} mm is a positive depth"); ··· 4400 4801 ..sketch_mode::default_extrude_feature(id) 4401 4802 } 4402 4803 }; 4403 - let edit = |cache: &mut super::FeatureCache, depth_mm: f64| { 4804 + let edit = |depth_mm: f64| { 4404 4805 let started = Instant::now(); 4405 - let Some(preview) = super::compute_extrude_preview(cache, &document, blind(depth_mm)) 4406 - else { 4806 + let Some(preview) = super::compute_extrude_preview(&document, blind(depth_mm)) else { 4407 4807 panic!("the rectangle extrudes at {depth_mm} mm"); 4408 4808 }; 4409 4809 let Some(solid) = preview.solid() else { ··· 4415 4815 started.elapsed() 4416 4816 }; 4417 4817 4418 - let _warmup = edit(&mut cache, BASE_MM); 4818 + let _warmup = edit(BASE_MM); 4419 4819 let durations: Vec<Duration> = (0..STEPS) 4420 - .map(|i| edit(&mut cache, BASE_MM + STEP_MM * f64::from(i))) 4820 + .map(|i| edit(BASE_MM + STEP_MM * f64::from(i))) 4421 4821 .collect(); 4422 4822 let sorted = { 4423 4823 let mut v = durations.clone(); ··· 4443 4843 fn extrude_preview_absent_when_sketch_missing() { 4444 4844 let document = Document::new(DocumentId::default(), "Empty".to_owned()); 4445 4845 let feature = sketch_mode::default_extrude_feature(SketchId::default()); 4446 - let mut cache = super::FeatureCache::new(); 4447 - assert!(super::compute_extrude_preview(&mut cache, &document, feature).is_none()); 4846 + assert!(super::compute_extrude_preview(&document, feature).is_none()); 4448 4847 } 4449 4848 4450 4849 #[test] 4451 4850 fn default_document_extrudes_to_a_solid() { 4452 4851 let (document, id) = super::initial_document(super::default_sketch()); 4453 4852 let feature = sketch_mode::default_extrude_feature(id); 4454 - let mut cache = super::FeatureCache::new(); 4455 - let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { 4853 + let Some(preview) = super::compute_extrude_preview(&document, feature) else { 4456 4854 panic!("the default sketch is registered"); 4457 4855 }; 4458 4856 let Some(solid) = preview.solid() else { ··· 4465 4863 fn preview_solid_view_tessellates_and_frames() { 4466 4864 let (document, id) = super::initial_document(rectangle_sketch()); 4467 4865 let feature = sketch_mode::default_extrude_feature(id); 4468 - let mut cache = super::FeatureCache::new(); 4469 - let Some(preview) = super::compute_extrude_preview(&mut cache, &document, feature) else { 4866 + let Some(preview) = super::compute_extrude_preview(&document, feature) else { 4470 4867 panic!("a registered sketch yields an evaluated preview"); 4471 4868 }; 4472 4869 let Some(solid) = preview.solid() else { ··· 4492 4889 ); 4493 4890 } 4494 4891 4892 + #[test] 4893 + fn build_combined_view_merges_every_evaluated_body() { 4894 + let (mut document, s1) = super::initial_document(rectangle_sketch()); 4895 + let e1 = document.commit_extrude(sketch_mode::default_extrude_feature(s1)); 4896 + let s2 = document.allocate_sketch(); 4897 + document.insert_sketch(s2, "Sketch2".to_owned(), rectangle_sketch()); 4898 + let _e2 = document.commit_extrude(sketch_mode::default_extrude_feature(s2)); 4899 + let mut model = EvaluatedModel::new(); 4900 + model.recompute( 4901 + &document, 4902 + document.suppressed(), 4903 + document.rollback(), 4904 + RecomputeScope::Full, 4905 + ); 4906 + assert_eq!( 4907 + super::body_passes(&model).len(), 4908 + 2, 4909 + "two separate bodies build" 4910 + ); 4911 + let Some(f1) = document.feature_tree().feature_of_extrude(e1) else { 4912 + panic!("the first extrude resolves to a feature id"); 4913 + }; 4914 + let Some(solid) = model.body(f1) else { 4915 + panic!("the first body is evaluated"); 4916 + }; 4917 + let Ok(single) = super::build_solid_view(solid) else { 4918 + panic!("a single body tessellates"); 4919 + }; 4920 + let Some(combined) = super::build_combined_view(&model) else { 4921 + panic!("the combined view is built from both bodies"); 4922 + }; 4923 + assert_eq!( 4924 + combined.faces.triangles().len(), 4925 + 2 * single.faces.triangles().len(), 4926 + "the combined mesh holds both bodies", 4927 + ); 4928 + } 4929 + 4930 + fn full_recompute(model: &mut EvaluatedModel, document: &Document) { 4931 + model.recompute( 4932 + document, 4933 + document.suppressed(), 4934 + document.rollback(), 4935 + RecomputeScope::Full, 4936 + ); 4937 + } 4938 + 4939 + #[test] 4940 + fn face_bound_sketch_creation_then_break_offers_reattach() { 4941 + use bone_types::FaceRole; 4942 + let (mut document, base_sketch) = super::initial_document(rectangle_sketch()); 4943 + let base = document.commit_extrude(sketch_mode::default_extrude_feature(base_sketch)); 4944 + let mut model = EvaluatedModel::new(); 4945 + full_recompute(&mut model, &document); 4946 + 4947 + let Some(body) = document.feature_tree().feature_of_extrude(base) else { 4948 + panic!("the base extrude resolves to a feature id"); 4949 + }; 4950 + let Some(solid) = model.body(body) else { 4951 + panic!("the base body is evaluated"); 4952 + }; 4953 + let Some(face) = solid.iter_faces().find_map(|candidate| { 4954 + matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id()) 4955 + }) else { 4956 + panic!("the box has a planar cap"); 4957 + }; 4958 + let Some((face_ref, _basis)) = model.face_for_sketch(face) else { 4959 + panic!("a planar cap yields a face reference and basis"); 4960 + }; 4961 + 4962 + let face_sketch = document.allocate_sketch(); 4963 + document.insert_sketch(face_sketch, "Sketch2".to_owned(), rectangle_sketch()); 4964 + let Ok(()) = document.bind_sketch_to_face(face_sketch, face_ref) else { 4965 + panic!("binding a fresh sketch to a face is acyclic"); 4966 + }; 4967 + let face_extrude = 4968 + document.commit_extrude(sketch_mode::default_extrude_feature(face_sketch)); 4969 + full_recompute(&mut model, &document); 4970 + assert_eq!( 4971 + super::body_passes(&model).len(), 4972 + 2, 4973 + "the face-bound extrude is a separate second body", 4974 + ); 4975 + 4976 + document.remove_extrude(base); 4977 + full_recompute(&mut model, &document); 4978 + let Some(face_feature) = document.feature_tree().feature_of_sketch(face_sketch) else { 4979 + panic!("the face-bound sketch keeps its feature id"); 4980 + }; 4981 + assert!( 4982 + matches!( 4983 + model.status(face_feature), 4984 + Some(RebuildStatus::Error(RebuildError::DanglingReference(_))) 4985 + ), 4986 + "deleting the base dangles the face-bound sketch", 4987 + ); 4988 + let Some(face_extrude_feature) = document.feature_tree().feature_of_extrude(face_extrude) 4989 + else { 4990 + panic!("the face-bound extrude keeps its feature id"); 4991 + }; 4992 + assert_eq!( 4993 + model.status(face_extrude_feature), 4994 + Some(RebuildStatus::Error(RebuildError::UpstreamUnresolved)), 4995 + "the downstream extrude is blamed as upstream-unresolved, not a second dangling owner", 4996 + ); 4997 + let wrong = super::compute_whats_wrong(&model, &document); 4998 + assert_eq!( 4999 + wrong 5000 + .iter() 5001 + .filter(|entry| entry.reattach.is_some()) 5002 + .count(), 5003 + 1, 5004 + "only the sketch that owns the dangling reference offers reattach", 5005 + ); 5006 + assert!( 5007 + wrong 5008 + .iter() 5009 + .any(|entry| entry.reattach == Some(face_sketch)), 5010 + "the dangling face-bound sketch offers reattach", 5011 + ); 5012 + assert!( 5013 + wrong 5014 + .iter() 5015 + .any(|entry| entry.message == strings::WHATS_WRONG_UPSTREAM 5016 + && entry.reattach.is_none()), 5017 + "the downstream body reports an upstream failure with no reattach of its own", 5018 + ); 5019 + } 5020 + 5021 + fn offscreen_context(extent: ViewportExtent, remaining: u32) -> bone_render::OffscreenContext { 5022 + use bone_render::{AdapterPolicy, OffscreenContext, RenderError}; 5023 + match pollster::block_on(OffscreenContext::new(extent, AdapterPolicy::Platform)) { 5024 + Ok(ctx) => ctx, 5025 + Err(RenderError::NoAdapter(_) | RenderError::Device(_)) if remaining > 0 => { 5026 + std::thread::sleep(std::time::Duration::from_millis(100)); 5027 + offscreen_context(extent, remaining - 1) 5028 + } 5029 + Err(e) => panic!("offscreen context init failed: {e}"), 5030 + } 5031 + } 5032 + 5033 + fn check_solid_golden(frame: &bone_render::SnapshotFrame, golden_rel: &str) { 5034 + use bone_render::{PixelDiff, PixelDiffThreshold, decode_png, encode_png}; 5035 + let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(golden_rel); 5036 + if std::env::var_os("BONE_UPDATE_REBUILD_GOLDENS").is_some() { 5037 + let Ok(bytes) = encode_png(frame) else { 5038 + panic!("encode_png failed"); 5039 + }; 5040 + if let Some(parent) = path.parent() { 5041 + let Ok(()) = std::fs::create_dir_all(parent) else { 5042 + panic!("create goldens dir failed"); 5043 + }; 5044 + } 5045 + let Ok(()) = std::fs::write(&path, &bytes) else { 5046 + panic!("write golden {} failed", path.display()); 5047 + }; 5048 + return; 5049 + } 5050 + let Ok(bytes) = std::fs::read(&path) else { 5051 + panic!( 5052 + "golden {} missing; rerun with BONE_UPDATE_REBUILD_GOLDENS=1 to bless", 5053 + path.display(), 5054 + ); 5055 + }; 5056 + let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { 5057 + panic!("decode golden failed"); 5058 + }; 5059 + assert_eq!(golden_extent, frame.extent(), "golden extent drift"); 5060 + let threshold = PixelDiffThreshold::new(16.0 / 255.0); 5061 + let Ok(report) = PixelDiff::compare(frame, &golden_rgba, threshold) else { 5062 + panic!("PixelDiff rejected inputs"); 5063 + }; 5064 + assert!( 5065 + report.is_clean(), 5066 + "rebuild-downstream golden drifted: {} mismatches, worst {:?}", 5067 + report.over_threshold(), 5068 + report.worst(), 5069 + ); 5070 + } 5071 + 5072 + #[test] 5073 + fn rebuild_downstream_scene_matches_golden() { 5074 + use bone_types::FaceRole; 5075 + let (mut document, base_sketch) = super::initial_document(rectangle_sketch()); 5076 + let base = document.commit_extrude(sketch_mode::default_extrude_feature(base_sketch)); 5077 + let mut model = EvaluatedModel::new(); 5078 + full_recompute(&mut model, &document); 5079 + 5080 + let Some(body) = document.feature_tree().feature_of_extrude(base) else { 5081 + panic!("the base extrude resolves to a feature id"); 5082 + }; 5083 + let Some(solid) = model.body(body) else { 5084 + panic!("the base body is evaluated"); 5085 + }; 5086 + let Some(cap) = solid.iter_faces().find_map(|candidate| { 5087 + matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id()) 5088 + }) else { 5089 + panic!("the box has a planar top cap"); 5090 + }; 5091 + let Some((face_ref, _basis)) = model.face_for_sketch(cap) else { 5092 + panic!("a planar cap yields a face reference and basis"); 5093 + }; 5094 + 5095 + let face_sketch = document.allocate_sketch(); 5096 + document.insert_sketch(face_sketch, "Sketch2".to_owned(), rectangle_sketch()); 5097 + let Ok(()) = document.bind_sketch_to_face(face_sketch, face_ref) else { 5098 + panic!("binding the second sketch to the cap is acyclic"); 5099 + }; 5100 + let _ = document.commit_extrude(sketch_mode::default_extrude_feature(face_sketch)); 5101 + full_recompute(&mut model, &document); 5102 + 5103 + document.replace_sketch(base_sketch, tall_rectangle_sketch()); 5104 + full_recompute(&mut model, &document); 5105 + assert_eq!( 5106 + super::body_passes(&model).len(), 5107 + 2, 5108 + "editing the base sketch keeps both bodies", 5109 + ); 5110 + 5111 + let Some(combined) = super::build_combined_view(&model) else { 5112 + panic!("the rebuilt model lowers to a combined view"); 5113 + }; 5114 + let extent = ViewportExtent::new(ViewportPx::new(256), ViewportPx::new(256)); 5115 + let Ok(camera) = frame_standard_view(combined.aabb, extent, StandardView::Isometric, None) 5116 + else { 5117 + panic!("the combined aabb frames an isometric camera"); 5118 + }; 5119 + 5120 + let ctx = offscreen_context(extent, 3); 5121 + let mut renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format()); 5122 + let Ok(frame) = renderer.render_display( 5123 + &ctx, 5124 + &combined.faces, 5125 + &combined.edges, 5126 + camera, 5127 + &Style::default(), 5128 + DisplayMode::ShadedWithEdges, 5129 + ) else { 5130 + panic!("the rebuilt two-body scene renders"); 5131 + }; 5132 + check_solid_golden(&frame, "tests/goldens/rebuild_downstream_iso_256.png"); 5133 + } 5134 + 5135 + #[test] 5136 + fn solid_pick_index_builds_for_an_evaluated_body() { 5137 + let (mut document, sketch) = super::initial_document(rectangle_sketch()); 5138 + let _extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); 5139 + let mut model = EvaluatedModel::new(); 5140 + model.recompute( 5141 + &document, 5142 + document.suppressed(), 5143 + document.rollback(), 5144 + RecomputeScope::Full, 5145 + ); 5146 + assert!( 5147 + super::solid_pick_index(&model).is_some(), 5148 + "an evaluated body yields a solid pick index", 5149 + ); 5150 + assert!( 5151 + super::solid_pick_index(&EvaluatedModel::new()).is_none(), 5152 + "an empty model has nothing to pick", 5153 + ); 5154 + } 5155 + 5156 + #[test] 5157 + fn reattach_binds_a_sketch_to_a_picked_cap_face() { 5158 + use bone_types::FaceRole; 5159 + let (mut document, base_sketch) = super::initial_document(rectangle_sketch()); 5160 + let base = document.commit_extrude(sketch_mode::default_extrude_feature(base_sketch)); 5161 + let mut model = EvaluatedModel::new(); 5162 + model.recompute( 5163 + &document, 5164 + document.suppressed(), 5165 + document.rollback(), 5166 + RecomputeScope::Full, 5167 + ); 5168 + let Some(body) = document.feature_tree().feature_of_extrude(base) else { 5169 + panic!("the base extrude resolves to a feature id"); 5170 + }; 5171 + let Some(solid) = model.body(body) else { 5172 + panic!("the base body is evaluated"); 5173 + }; 5174 + let Some(face) = solid.iter_faces().find_map(|candidate| { 5175 + matches!(candidate.label().role, FaceRole::EndCap).then(|| candidate.id()) 5176 + }) else { 5177 + panic!("the box has a planar cap"); 5178 + }; 5179 + let Some(face_ref) = model.face_ref_any(face) else { 5180 + panic!("a picked cap yields a face reference"); 5181 + }; 5182 + let new_sketch = document.allocate_sketch(); 5183 + document.insert_sketch(new_sketch, "Sketch2".to_owned(), rectangle_sketch()); 5184 + assert!( 5185 + document.bind_sketch_to_face(new_sketch, face_ref).is_ok(), 5186 + "reattaching a sketch to a planar cap binds cleanly", 5187 + ); 5188 + } 5189 + 5190 + #[test] 5191 + fn compute_whats_wrong_is_empty_for_a_healthy_document() { 5192 + let (mut document, sketch) = super::initial_document(rectangle_sketch()); 5193 + let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); 5194 + let mut model = EvaluatedModel::new(); 5195 + model.recompute( 5196 + &document, 5197 + document.suppressed(), 5198 + document.rollback(), 5199 + RecomputeScope::Full, 5200 + ); 5201 + assert!( 5202 + super::compute_whats_wrong(&model, &document).is_empty(), 5203 + "a clean rebuild has nothing wrong", 5204 + ); 5205 + let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { 5206 + panic!("the extrude resolves to a feature id"); 5207 + }; 5208 + let Ok(()) = document.rename_extrude(extrude, "Boss") else { 5209 + panic!("rename accepts"); 5210 + }; 5211 + assert_eq!(super::feature_label_text(&document, feature), "Boss"); 5212 + } 5213 + 4495 5214 fn layout_rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { 4496 5215 LayoutRect::new( 4497 5216 LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), ··· 4533 5252 fn solid_viewport_region_is_none_for_a_degenerate_viewport() { 4534 5253 let surface = ViewportExtent::new(ViewportPx::new(1280), ViewportPx::new(800)); 4535 5254 assert!(super::solid_viewport_region(layout_rect(0.0, 0.0, 0.0, 0.0), surface).is_none()); 5255 + } 5256 + 5257 + #[test] 5258 + fn defer_rebuild_holds_only_for_a_costly_later_build() { 5259 + let cheap = RebuildCost::new(10); 5260 + let costly = RebuildCost::new(10_000); 5261 + let budget = RebuildBudget::new(100); 5262 + assert!( 5263 + !super::defer_rebuild(true, costly, budget), 5264 + "the first build always renders, however heavy", 5265 + ); 5266 + assert!( 5267 + super::defer_rebuild(false, costly, budget), 5268 + "a heavy later rebuild defers behind the rebuild-needed badge", 5269 + ); 5270 + assert!( 5271 + !super::defer_rebuild(false, cheap, budget), 5272 + "a cheap later rebuild stays eager and never raises the badge", 5273 + ); 5274 + } 5275 + 5276 + #[test] 5277 + fn whats_wrong_classifies_failures_and_withholds_reattach_from_downstream() { 5278 + let Some(upstream) = 5279 + super::whats_wrong_kind(RebuildStatus::Error(RebuildError::UpstreamUnresolved)) 5280 + else { 5281 + panic!("an upstream failure is reported"); 5282 + }; 5283 + assert_eq!(upstream.message, strings::WHATS_WRONG_UPSTREAM); 5284 + assert!(upstream.is_error); 5285 + assert!( 5286 + !upstream.offers_reattach, 5287 + "a downstream feature blames the upstream, it never offers its own reattach", 5288 + ); 5289 + 5290 + let Some(non_planar) = 5291 + super::whats_wrong_kind(RebuildStatus::Error(RebuildError::NonPlanarSketchTarget)) 5292 + else { 5293 + panic!("a non-planar target is reported"); 5294 + }; 5295 + assert!(non_planar.is_error && !non_planar.offers_reattach); 5296 + 5297 + assert!(super::whats_wrong_kind(RebuildStatus::UpToDate).is_none()); 5298 + assert!(super::whats_wrong_kind(RebuildStatus::NeedsRebuild).is_none()); 4536 5299 } 4537 5300 4538 5301 #[test] ··· 5061 5824 ); 5062 5825 assert_eq!(extrude_node_count(&document), 1, "editing reuses the node"); 5063 5826 assert_eq!(document.extrude_label(id), Some("Boss")); 5827 + } 5828 + 5829 + #[test] 5830 + fn apply_feature_command_suppresses_and_records_undo() { 5831 + let (mut document, sketch) = doc_with_default_sketch(); 5832 + let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); 5833 + let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { 5834 + panic!("the committed extrude has a feature id"); 5835 + }; 5836 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 5837 + let changed = apply_feature_command_into( 5838 + &mut document, 5839 + &mut undo, 5840 + shell::FeatureCommand::Suppress(feature), 5841 + ); 5842 + assert!(changed); 5843 + assert!(document.suppression_state(feature).is_suppressed()); 5844 + assert_eq!( 5845 + undo.past_len(), 5846 + 1, 5847 + "a suppression records one undo snapshot" 5848 + ); 5849 + } 5850 + 5851 + #[test] 5852 + fn apply_feature_command_deletes_extrude_and_records_undo() { 5853 + let (mut document, sketch) = doc_with_default_sketch(); 5854 + let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); 5855 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 5856 + let changed = apply_feature_command_into( 5857 + &mut document, 5858 + &mut undo, 5859 + shell::FeatureCommand::Delete(shell::FeatureTarget::Extrude(extrude)), 5860 + ); 5861 + assert!(changed); 5862 + assert_eq!(document.extrude(extrude), None); 5863 + assert_eq!(undo.past_len(), 1); 5864 + } 5865 + 5866 + #[test] 5867 + fn apply_feature_command_rolls_back_to_feature() { 5868 + let (mut document, sketch) = doc_with_default_sketch(); 5869 + let extrude = document.commit_extrude(sketch_mode::default_extrude_feature(sketch)); 5870 + let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { 5871 + panic!("the committed extrude has a feature id"); 5872 + }; 5873 + let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN); 5874 + apply_feature_command_into( 5875 + &mut document, 5876 + &mut undo, 5877 + shell::FeatureCommand::RollbackToHere(feature), 5878 + ); 5879 + assert!(document.is_rolled_back(feature)); 5064 5880 } 5065 5881 5066 5882 #[test]
+19
crates/bone-app/src/hotkeys.rs
··· 45 45 pub const VIEW_NORMAL_TO_ACTION: ActionId = action_id(27); 46 46 pub const VIEW_SELECTOR_ACTION: ActionId = action_id(28); 47 47 pub const VIEW_CUBE_ACTION: ActionId = action_id(29); 48 + pub const REBUILD_ACTION: ActionId = action_id(30); 49 + pub const FORCE_REBUILD_ACTION: ActionId = action_id(31); 48 50 49 51 const fn ch(c: char) -> KeyCode { 50 52 KeyCode::Char(KeyChar::from_ascii(c)) ··· 64 66 const CTRL_S: KeyChord = KeyChord::new(ch('s'), ModifierMask::CTRL); 65 67 const CTRL_A: KeyChord = KeyChord::new(ch('a'), ModifierMask::CTRL); 66 68 const CTRL_Q: KeyChord = KeyChord::new(ch('q'), ModifierMask::CTRL); 69 + const CTRL_B: KeyChord = KeyChord::new(ch('b'), ModifierMask::CTRL); 67 70 const CTRL_I: KeyChord = KeyChord::new(ch('i'), ModifierMask::CTRL); 68 71 const CTRL_E: KeyChord = KeyChord::new(ch('e'), ModifierMask::CTRL); 69 72 const CTRL_1: KeyChord = KeyChord::new(ch('1'), ModifierMask::CTRL); ··· 94 97 ZoomFit, 95 98 OpenShortcutBar, 96 99 Quit, 100 + RebuildChanged, 101 + ForceRebuild, 97 102 EnterSketch, 98 103 SmartDimension, 99 104 Trim, ··· 226 231 kind: Some(HotkeyCommand::Quit), 227 232 scope: HotkeyScope::Global, 228 233 label: s::HOTKEY_LABEL_QUIT, 234 + defaults: &[], 235 + }, 236 + Command { 237 + action: REBUILD_ACTION, 238 + kind: Some(HotkeyCommand::RebuildChanged), 239 + scope: HotkeyScope::Global, 240 + label: s::HOTKEY_LABEL_REBUILD, 241 + defaults: &[CTRL_B], 242 + }, 243 + Command { 244 + action: FORCE_REBUILD_ACTION, 245 + kind: Some(HotkeyCommand::ForceRebuild), 246 + scope: HotkeyScope::Global, 247 + label: s::HOTKEY_LABEL_FORCE_REBUILD, 229 248 defaults: &[CTRL_Q], 230 249 }, 231 250 Command {
+1348 -100
crates/bone-app/src/shell.rs
··· 1 1 use core::num::NonZeroU32; 2 - use std::collections::BTreeMap; 2 + use std::collections::{BTreeMap, BTreeSet}; 3 3 use std::sync::Arc; 4 4 5 5 use bone_document::{ 6 - DimensionKind, DimensionValue, Document, ExtrudeEndCondition, ExtrudeFeature, FeatureNode, 7 - MergeResult, Sketch, SketchDimension, SketchEntity, SketchRelation, SketchStatusReport, 8 - SketchVersion, 6 + DimensionKind, DimensionValue, Document, ExtrudeEndCondition, ExtrudeFeature, FeatureEdge, 7 + FeatureNode, MergeResult, Sketch, SketchDimension, SketchEntity, SketchRelation, 8 + SketchStatusReport, SketchVersion, 9 9 }; 10 10 use bone_types::{ 11 - Angle, Camera3, ExtrudeId, IconId, Length, Point2, PositiveLength, SketchDimensionId, 12 - SketchEntityId, SketchId, 11 + Angle, Camera3, ExtrudeId, FeatureId, IconId, Length, Point2, PositiveLength, RollbackMarker, 12 + SketchDimensionId, SketchEntityId, SketchId, 13 13 }; 14 14 use bone_ui::a11y::{AccessNode, Role}; 15 15 use bone_ui::frame::{FrameCtx, InteractDeclaration}; 16 16 use bone_ui::hit_test::Sense; 17 + use bone_ui::input::PointerButton; 17 18 use bone_ui::layout::{ 18 19 Axis, DockNode, DockPanel, DockState, GridChild, GridLine, GridSpan, GridTrack, Layout, 19 20 LayoutPos, LayoutPx, LayoutRect, LayoutSize, NodeKind, PanelId, RetainedLayout, SolvedLayout, ··· 23 24 use bone_ui::theme::{Border, ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme}; 24 25 use bone_ui::widgets::IconTint; 25 26 use bone_ui::widgets::{ 26 - AngleEditor, BoolEditor, Checkbox, CheckboxState, Clipboard, Dialog, DialogButton, 27 - HotkeyCapture, HotkeyCaptureState, LabelText, LengthEditor, MemoryClipboard, MenuBar, 28 - MenuBarEntry, MenuBarState, MenuItem, Panel, PanelState, PanelTitlebar, PanelVariant, 27 + AngleEditor, BoolEditor, Checkbox, CheckboxState, Clipboard, ContextMenu, ContextMenuRequest, 28 + Dialog, DialogButton, DropPlacement, DropTarget, HorizontalAlign, HotkeyCapture, 29 + HotkeyCaptureState, LabelText, LengthEditor, MemoryClipboard, MenuBar, MenuBarEntry, 30 + MenuBarState, MenuItem, MenuState, Panel, PanelState, PanelTitlebar, PanelVariant, 29 31 PropertyCell, PropertyEditor, PropertyGrid, PropertyOption, PropertyPaneAction, 30 32 PropertyPaneHeader, PropertyRow, RenameCommit, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, 31 - SelectionEditor, Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, 32 - Tabs, TabsOrientation, ToolbarItem, TreeNode, TreeView, TreeViewState, WidgetPaint, 33 - show_checkbox, show_dialog, show_hotkey_capture, show_menu_bar, show_panel, show_property_grid, 34 - show_property_pane_header, show_ribbon, show_slider, show_status_bar, show_tabs, 35 - show_tree_view, 33 + RollbackBar, RollbackTarget, SelectionEditor, Slider, SliderRange, SliderStep, StatusAlign, 34 + StatusBar, StatusItem, Tab, Tabs, TabsOrientation, ToolbarItem, TreeBadge, TreeNode, TreeView, 35 + TreeViewState, WidgetPaint, show_checkbox, show_context_menu, show_dialog, show_hotkey_capture, 36 + show_menu_bar, show_panel, show_property_grid, show_property_pane_header, show_ribbon, 37 + show_slider, show_status_bar, show_tabs, show_tree_view, 36 38 }; 37 39 use bone_ui::{WidgetId, WidgetKey}; 38 40 use uom::si::angle::degree; ··· 271 273 ExitSketch, 272 274 } 273 275 276 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 277 + pub enum FeatureTarget { 278 + Sketch(SketchId), 279 + Extrude(ExtrudeId), 280 + } 281 + 282 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 283 + pub enum FeatureCommand { 284 + Suppress(FeatureId), 285 + Unsuppress(FeatureId), 286 + RollbackToHere(FeatureId), 287 + Delete(FeatureTarget), 288 + } 289 + 290 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 291 + pub struct FeatureReorder { 292 + pub moved: FeatureId, 293 + pub anchor: FeatureId, 294 + pub before: bool, 295 + } 296 + 297 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 298 + pub enum RollbackChange { 299 + ToEnd, 300 + ToFeature(FeatureId), 301 + } 302 + 303 + #[derive(Clone, Debug, PartialEq)] 304 + pub struct WhatsWrong { 305 + pub label: String, 306 + pub message: StringKey, 307 + pub is_error: bool, 308 + pub reattach: Option<SketchId>, 309 + } 310 + 311 + #[derive(Default)] 312 + pub struct FeatureMenu { 313 + open: Option<FeatureMenuAnchor>, 314 + menu: MenuState, 315 + } 316 + 317 + #[derive(Copy, Clone)] 318 + struct FeatureMenuAnchor { 319 + target: FeatureTarget, 320 + anchor: LayoutPos, 321 + } 322 + 274 323 pub struct Shell { 275 324 panels: ShellPanels, 276 325 ids: ShellIds, ··· 289 338 )] 290 339 pub struct ShellState { 291 340 pub feature_tree: TreeViewState, 341 + pub feature_menu: FeatureMenu, 342 + pub relationships: Option<FeatureTarget>, 343 + pub whats_wrong_open: bool, 292 344 pub clipboard: MemoryClipboard, 293 345 pub menu_bar: MenuBarState, 294 346 pub dim_property: Option<DimPropertyEditor>, ··· 465 517 pub sketch_rename: Option<SketchRenameRequest>, 466 518 pub extrude_activated: Option<ExtrudeId>, 467 519 pub extrude_rename: Option<ExtrudeRenameRequest>, 520 + pub feature_command: Option<FeatureCommand>, 521 + pub feature_reorder: Option<FeatureReorder>, 522 + pub rollback_change: Option<RollbackChange>, 523 + pub reattach_request: Option<SketchId>, 468 524 pub exit_sketch: bool, 469 525 pub confirm_action: Option<ConfirmAction>, 470 526 pub menu_action: Option<MenuAction>, ··· 496 552 sketch_rename: None, 497 553 extrude_activated: None, 498 554 extrude_rename: None, 555 + feature_command: None, 556 + feature_reorder: None, 557 + rollback_change: None, 558 + reattach_request: None, 499 559 exit_sketch: false, 500 560 confirm_action: None, 501 561 menu_action: None, ··· 571 631 camera3: Option<Camera3>, 572 632 extrude_status: Option<ExtrudeStatus<'_>>, 573 633 view: &mut ViewUi, 634 + feature_badges: &BTreeMap<FeatureId, TreeBadge>, 635 + needs_rebuild: bool, 636 + whats_wrong: &[WhatsWrong], 574 637 ) -> ShellFrame { 575 638 let theme = ctx.theme(); 576 639 let direction = ctx.direction(); ··· 644 707 }; 645 708 let feature_tree = render_feature_tree( 646 709 ctx, 647 - tree_rect, 648 - self.ids.feature_tree, 649 - self.ids.feature_part, 650 710 &mut self.state.feature_tree, 651 - document, 711 + FeatureTreeInputs { 712 + rect: tree_rect, 713 + tree_id: self.ids.feature_tree, 714 + part_id: self.ids.feature_part, 715 + document, 716 + badges: feature_badges, 717 + needs_rebuild, 718 + }, 652 719 &mut paints, 653 720 ); 721 + if let Some((target, anchor)) = feature_tree.context_menu { 722 + self.state.feature_menu.open = Some(FeatureMenuAnchor { target, anchor }); 723 + self.state.feature_menu.menu = MenuState::default(); 724 + } 725 + let feature_menu_base = self.ids.feature_tree; 726 + let feature_menu_outcome = render_feature_context_menu( 727 + ctx, 728 + feature_menu_base, 729 + &mut self.state, 730 + document, 731 + &mut popover_paints, 732 + ); 654 733 let pane = render_property_pane( 655 734 ctx, 656 735 property_rect, ··· 784 863 let plane_picked = feature_tree 785 864 .double_activated 786 865 .and_then(|id| self.ids.plane_for(id)); 787 - let sketch_activated = feature_tree.sketch_activated; 866 + let mut sketch_activated = feature_tree.sketch_activated; 788 867 let sketch_rename = feature_tree.sketch_rename; 789 - let extrude_activated = feature_tree.extrude_activated; 868 + let mut extrude_activated = feature_tree.extrude_activated; 790 869 let extrude_rename = feature_tree.extrude_rename; 870 + let feature_reorder = feature_tree.reorder; 871 + let rollback_change = feature_tree.rollback; 872 + let mut feature_command = None; 873 + match feature_menu_outcome { 874 + Some(FeatureMenuOutcome::EditSketch(sketch_id)) => { 875 + sketch_activated = sketch_activated.or(Some(sketch_id)); 876 + } 877 + Some(FeatureMenuOutcome::EditExtrude(extrude_id)) => { 878 + extrude_activated = extrude_activated.or(Some(extrude_id)); 879 + } 880 + Some(FeatureMenuOutcome::ShowRelationships(target)) => { 881 + self.state.relationships = Some(target); 882 + } 883 + Some(FeatureMenuOutcome::Command(command)) => feature_command = Some(command), 884 + None => {} 885 + } 886 + if let Some(target) = self.state.relationships { 887 + let relationships_base = self.ids.feature_tree; 888 + let close = render_relationships_panel( 889 + ctx, 890 + relationships_base, 891 + viewport_rect, 892 + target, 893 + document, 894 + &mut popover_paints, 895 + ); 896 + if close { 897 + self.state.relationships = None; 898 + } 899 + } 900 + if feature_tree.part_activated && !whats_wrong.is_empty() { 901 + self.state.whats_wrong_open = !self.state.whats_wrong_open; 902 + } 903 + if whats_wrong.is_empty() { 904 + self.state.whats_wrong_open = false; 905 + } 906 + let mut reattach_request = None; 907 + if self.state.whats_wrong_open { 908 + let whats_wrong_base = self.ids.feature_tree; 909 + let outcome = render_whats_wrong_panel( 910 + ctx, 911 + whats_wrong_base, 912 + viewport_rect, 913 + whats_wrong, 914 + &mut popover_paints, 915 + ); 916 + reattach_request = outcome.reattach; 917 + if outcome.close || reattach_request.is_some() { 918 + self.state.whats_wrong_open = false; 919 + } 920 + } 791 921 let mut dialog_paints: Vec<WidgetPaint> = Vec::new(); 792 922 let settings_change = render_settings_dialog( 793 923 ctx, ··· 824 954 sketch_rename, 825 955 extrude_activated, 826 956 extrude_rename, 957 + feature_command, 958 + feature_reorder, 959 + rollback_change, 960 + reattach_request, 827 961 exit_sketch, 828 962 confirm_action, 829 963 menu_action, ··· 1801 1935 sketch_rename: Option<SketchRenameRequest>, 1802 1936 extrude_activated: Option<ExtrudeId>, 1803 1937 extrude_rename: Option<ExtrudeRenameRequest>, 1938 + context_menu: Option<(FeatureTarget, LayoutPos)>, 1939 + reorder: Option<FeatureReorder>, 1940 + rollback: Option<RollbackChange>, 1941 + part_activated: bool, 1804 1942 } 1805 1943 1806 1944 fn sketch_widget_id(part_id: WidgetId, sketch_id: SketchId) -> WidgetId { ··· 1811 1949 part_id.child_indexed(WidgetKey::new("extrude"), extrude_id.as_u64()) 1812 1950 } 1813 1951 1814 - fn sketch_tree_rows(document: &Document, part_id: WidgetId) -> Vec<(SketchId, WidgetId, TreeNode)> { 1815 - document 1816 - .sketches() 1817 - .map(|(sketch_id, _)| { 1818 - let widget_id = sketch_widget_id(part_id, sketch_id); 1819 - let label = document.sketch_label(sketch_id).unwrap_or("").to_owned(); 1820 - let node = TreeNode::leaf_owned(widget_id, label).with_icon(IconId::TreeSketch); 1821 - (sketch_id, widget_id, node) 1952 + fn sketch_leaf( 1953 + document: &Document, 1954 + part_id: WidgetId, 1955 + sketch_id: SketchId, 1956 + badges: &BTreeMap<FeatureId, TreeBadge>, 1957 + ) -> TreeNode { 1958 + let widget_id = sketch_widget_id(part_id, sketch_id); 1959 + let label = document.sketch_label(sketch_id).unwrap_or("").to_owned(); 1960 + let feature = document.feature_tree().feature_of_sketch(sketch_id); 1961 + let badge = feature.and_then(|feature| badges.get(&feature).copied()); 1962 + let rolled_back = feature.is_some_and(|feature| document.is_rolled_back(feature)); 1963 + TreeNode::leaf_owned(widget_id, label) 1964 + .with_icon(IconId::TreeSketch) 1965 + .with_badge(badge) 1966 + .disabled(rolled_back) 1967 + } 1968 + 1969 + fn source_sketch_of_extrude(document: &Document, extrude_id: ExtrudeId) -> Option<SketchId> { 1970 + let tree = document.feature_tree(); 1971 + let extrude_feature = tree.feature_of_extrude(extrude_id)?; 1972 + tree.parents(extrude_feature) 1973 + .into_iter() 1974 + .find_map(|parent| match tree.node(parent) { 1975 + Some(FeatureNode::Sketch(sketch_id)) => Some(sketch_id), 1976 + _ => None, 1977 + }) 1978 + } 1979 + 1980 + fn extrude_feature_node( 1981 + document: &Document, 1982 + part_id: WidgetId, 1983 + extrude_id: ExtrudeId, 1984 + badges: &BTreeMap<FeatureId, TreeBadge>, 1985 + ) -> TreeNode { 1986 + let widget_id = extrude_widget_id(part_id, extrude_id); 1987 + let label = document.extrude_label(extrude_id).unwrap_or("").to_owned(); 1988 + let nested: Vec<TreeNode> = source_sketch_of_extrude(document, extrude_id) 1989 + .map(|sketch_id| sketch_leaf(document, part_id, sketch_id, badges)) 1990 + .into_iter() 1991 + .collect(); 1992 + let feature = document.feature_tree().feature_of_extrude(extrude_id); 1993 + let own = feature.and_then(|feature| badges.get(&feature).copied()); 1994 + let child = nested.iter().filter_map(|node| node.badge).max(); 1995 + let badge = [own, child].into_iter().flatten().max(); 1996 + let rolled_back = feature.is_some_and(|feature| document.is_rolled_back(feature)); 1997 + TreeNode::parent_owned(widget_id, label, nested) 1998 + .with_icon(IconId::TreeFeature) 1999 + .with_badge(badge) 2000 + .disabled(rolled_back) 2001 + } 2002 + 2003 + fn consumed_sketches(document: &Document) -> BTreeSet<SketchId> { 2004 + let tree = document.feature_tree(); 2005 + tree.edges() 2006 + .iter() 2007 + .filter_map(|edge| match edge { 2008 + FeatureEdge::SketchToExtrude { sketch, .. } => match tree.node(*sketch) { 2009 + Some(FeatureNode::Sketch(sketch_id)) => Some(sketch_id), 2010 + _ => None, 2011 + }, 2012 + FeatureEdge::FaceToSketch { .. } => None, 1822 2013 }) 1823 2014 .collect() 1824 2015 } 1825 2016 1826 - fn extrude_tree_rows( 2017 + fn feature_tree_children( 2018 + document: &Document, 2019 + part_id: WidgetId, 2020 + badges: &BTreeMap<FeatureId, TreeBadge>, 2021 + ) -> Vec<TreeNode> { 2022 + let leaf = |key: &'static str, label: StringKey| { 2023 + TreeNode::leaf(part_id.child(WidgetKey::new(key)), label) 2024 + }; 2025 + let feature_leaf = 2026 + |key: &'static str, label: StringKey| leaf(key, label).with_icon(IconId::TreeFeature); 2027 + let placeholder = |key: &'static str, label: StringKey| feature_leaf(key, label).disabled(true); 2028 + let plane_leaf = 2029 + |key: &'static str, label: StringKey| leaf(key, label).with_icon(IconId::TreePlane); 2030 + [ 2031 + placeholder("history", strings::FEATURE_HISTORY), 2032 + placeholder("sensors", strings::FEATURE_SENSORS), 2033 + placeholder("annotations", strings::FEATURE_ANNOTATIONS), 2034 + placeholder("solid_bodies", strings::FEATURE_SOLID_BODIES), 2035 + placeholder("material", strings::FEATURE_MATERIAL), 2036 + plane_leaf("plane.xy", strings::FEATURE_PLANE_XY), 2037 + plane_leaf("plane.yz", strings::FEATURE_PLANE_YZ), 2038 + plane_leaf("plane.zx", strings::FEATURE_PLANE_ZX), 2039 + leaf("origin", strings::FEATURE_ORIGIN).with_icon(IconId::TreeOrigin), 2040 + ] 2041 + .into_iter() 2042 + .chain(feature_rows(document, part_id, badges)) 2043 + .collect() 2044 + } 2045 + 2046 + fn tree_illegal_drop( 2047 + document: &Document, 2048 + state: &TreeViewState, 2049 + widget_to_sketch: &BTreeMap<WidgetId, SketchId>, 2050 + widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>, 2051 + ) -> bool { 2052 + state 2053 + .drag_source 2054 + .zip(state.drop_target) 2055 + .is_some_and(|(src, target)| { 2056 + drop_to_reorder(document, widget_to_sketch, widget_to_extrude, src, target).is_none() 2057 + }) 2058 + } 2059 + 2060 + fn feature_rows( 1827 2061 document: &Document, 1828 2062 part_id: WidgetId, 1829 - ) -> Vec<(ExtrudeId, WidgetId, TreeNode)> { 2063 + badges: &BTreeMap<FeatureId, TreeBadge>, 2064 + ) -> Vec<TreeNode> { 2065 + let consumed = consumed_sketches(document); 1830 2066 document 1831 2067 .feature_tree() 1832 2068 .iter() 1833 2069 .filter_map(|(_, node)| match node { 1834 - FeatureNode::Extrude(extrude_id) => Some(extrude_id), 2070 + FeatureNode::Extrude(extrude_id) => { 2071 + Some(extrude_feature_node(document, part_id, extrude_id, badges)) 2072 + } 2073 + FeatureNode::Sketch(sketch_id) if !consumed.contains(&sketch_id) => { 2074 + Some(sketch_leaf(document, part_id, sketch_id, badges)) 2075 + } 1835 2076 _ => None, 1836 2077 }) 1837 - .map(|extrude_id| { 1838 - let widget_id = extrude_widget_id(part_id, extrude_id); 1839 - let label = document.extrude_label(extrude_id).unwrap_or("").to_owned(); 1840 - let node = TreeNode::leaf_owned(widget_id, label).with_icon(IconId::TreeFeature); 1841 - (extrude_id, widget_id, node) 2078 + .collect() 2079 + } 2080 + 2081 + fn sketch_widget_ids(document: &Document, part_id: WidgetId) -> Vec<(SketchId, WidgetId)> { 2082 + document 2083 + .sketches() 2084 + .map(|(sketch_id, _)| (sketch_id, sketch_widget_id(part_id, sketch_id))) 2085 + .collect() 2086 + } 2087 + 2088 + fn extrude_widget_ids(document: &Document, part_id: WidgetId) -> Vec<(ExtrudeId, WidgetId)> { 2089 + document 2090 + .feature_tree() 2091 + .iter() 2092 + .filter_map(|(_, node)| match node { 2093 + FeatureNode::Extrude(extrude_id) => Some(extrude_id), 2094 + _ => None, 1842 2095 }) 2096 + .map(|extrude_id| (extrude_id, extrude_widget_id(part_id, extrude_id))) 1843 2097 .collect() 1844 2098 } 1845 2099 1846 - fn render_feature_tree( 1847 - ctx: &mut FrameCtx<'_>, 2100 + #[derive(Copy, Clone)] 2101 + struct FeatureTreeInputs<'a> { 1848 2102 rect: LayoutRect, 1849 2103 tree_id: WidgetId, 1850 2104 part_id: WidgetId, 1851 - state: &mut TreeViewState, 1852 - document: &Document, 1853 - paints: &mut Vec<WidgetPaint>, 1854 - ) -> FeatureTreeOutcome { 1855 - if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 1856 - return FeatureTreeOutcome { 2105 + document: &'a Document, 2106 + badges: &'a BTreeMap<FeatureId, TreeBadge>, 2107 + needs_rebuild: bool, 2108 + } 2109 + 2110 + impl FeatureTreeOutcome { 2111 + fn empty() -> Self { 2112 + Self { 1857 2113 double_activated: None, 1858 2114 sketch_activated: None, 1859 2115 sketch_rename: None, 1860 2116 extrude_activated: None, 1861 2117 extrude_rename: None, 1862 - }; 2118 + context_menu: None, 2119 + reorder: None, 2120 + rollback: None, 2121 + part_activated: false, 2122 + } 1863 2123 } 1864 - let leaf = |key: &'static str, label: StringKey| { 1865 - TreeNode::leaf(part_id.child(WidgetKey::new(key)), label) 1866 - }; 1867 - let feature_leaf = 1868 - |key: &'static str, label: StringKey| leaf(key, label).with_icon(IconId::TreeFeature); 1869 - let placeholder = |key: &'static str, label: StringKey| feature_leaf(key, label).disabled(true); 1870 - let plane_leaf = 1871 - |key: &'static str, label: StringKey| leaf(key, label).with_icon(IconId::TreePlane); 1872 - let sketch_rows = sketch_tree_rows(document, part_id); 1873 - let extrude_rows = extrude_tree_rows(document, part_id); 1874 - let renamable: Vec<WidgetId> = sketch_rows 2124 + } 2125 + 2126 + fn resolve_renames( 2127 + commit: Option<&RenameCommit>, 2128 + widget_to_sketch: &BTreeMap<WidgetId, SketchId>, 2129 + widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>, 2130 + ) -> (Option<SketchRenameRequest>, Option<ExtrudeRenameRequest>) { 2131 + let sketch = commit.and_then(|RenameCommit { id, text }| { 2132 + widget_to_sketch 2133 + .get(id) 2134 + .copied() 2135 + .map(|sketch_id| SketchRenameRequest { 2136 + id: sketch_id, 2137 + label: text.clone(), 2138 + }) 2139 + }); 2140 + let extrude = commit.and_then(|RenameCommit { id, text }| { 2141 + widget_to_extrude 2142 + .get(id) 2143 + .copied() 2144 + .map(|extrude_id| ExtrudeRenameRequest { 2145 + id: extrude_id, 2146 + label: text.clone(), 2147 + }) 2148 + }); 2149 + (sketch, extrude) 2150 + } 2151 + 2152 + fn render_feature_tree( 2153 + ctx: &mut FrameCtx<'_>, 2154 + state: &mut TreeViewState, 2155 + inputs: FeatureTreeInputs<'_>, 2156 + paints: &mut Vec<WidgetPaint>, 2157 + ) -> FeatureTreeOutcome { 2158 + let FeatureTreeInputs { 2159 + rect, 2160 + tree_id, 2161 + part_id, 2162 + document, 2163 + badges, 2164 + needs_rebuild, 2165 + } = inputs; 2166 + if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 { 2167 + return FeatureTreeOutcome::empty(); 2168 + } 2169 + let sketch_ids = sketch_widget_ids(document, part_id); 2170 + let extrude_ids = extrude_widget_ids(document, part_id); 2171 + let renamable: Vec<WidgetId> = sketch_ids 1875 2172 .iter() 1876 - .map(|(_, w, _)| *w) 1877 - .chain(extrude_rows.iter().map(|(_, w, _)| *w)) 2173 + .map(|(_, w)| *w) 2174 + .chain(extrude_ids.iter().map(|(_, w)| *w)) 1878 2175 .collect(); 1879 2176 let widget_to_sketch: BTreeMap<WidgetId, SketchId> = 1880 - sketch_rows.iter().map(|(s, w, _)| (*w, *s)).collect(); 2177 + sketch_ids.iter().map(|(s, w)| (*w, *s)).collect(); 1881 2178 let widget_to_extrude: BTreeMap<WidgetId, ExtrudeId> = 1882 - extrude_rows.iter().map(|(e, w, _)| (*w, *e)).collect(); 1883 - let children: Vec<TreeNode> = [ 1884 - placeholder("history", strings::FEATURE_HISTORY), 1885 - placeholder("sensors", strings::FEATURE_SENSORS), 1886 - placeholder("annotations", strings::FEATURE_ANNOTATIONS), 1887 - placeholder("solid_bodies", strings::FEATURE_SOLID_BODIES), 1888 - placeholder("material", strings::FEATURE_MATERIAL), 1889 - plane_leaf("plane.xy", strings::FEATURE_PLANE_XY), 1890 - plane_leaf("plane.yz", strings::FEATURE_PLANE_YZ), 1891 - plane_leaf("plane.zx", strings::FEATURE_PLANE_ZX), 1892 - leaf("origin", strings::FEATURE_ORIGIN).with_icon(IconId::TreeOrigin), 2179 + extrude_ids.iter().map(|(e, w)| (*w, *e)).collect(); 2180 + let children = feature_tree_children(document, part_id, badges); 2181 + let part_badge = [ 2182 + badges.values().copied().max(), 2183 + needs_rebuild.then_some(TreeBadge::RebuildNeeded), 1893 2184 ] 1894 2185 .into_iter() 1895 - .chain(sketch_rows.into_iter().map(|(_, _, node)| node)) 1896 - .chain(extrude_rows.into_iter().map(|(_, _, node)| node)) 1897 - .collect(); 1898 - let part = TreeNode::parent_owned(part_id, document.name().to_owned(), children); 2186 + .flatten() 2187 + .max(); 2188 + let part = TreeNode::parent_owned(part_id, document.name().to_owned(), children) 2189 + .with_badge(part_badge); 1899 2190 let roots = [part]; 2191 + let illegal_drop = tree_illegal_drop(document, state, &widget_to_sketch, &widget_to_extrude); 2192 + let stops = rollback_stops(document, part_id); 2193 + let rollback_bar = (!stops.is_empty()).then(|| RollbackBar { 2194 + stops: &stops, 2195 + marker: current_rollback_target(document, part_id), 2196 + }); 1900 2197 let response = show_tree_view( 1901 2198 ctx, 1902 2199 TreeView::new(tree_id, rect, strings::FEATURE_TREE_LABEL, &roots, state) 1903 - .renamable(&renamable), 2200 + .renamable(&renamable) 2201 + .illegal_drop(illegal_drop) 2202 + .rollback(rollback_bar), 1904 2203 ); 1905 2204 paints.extend(response.paint); 2205 + let reorder = response.drop_committed.and_then(|(src, target)| { 2206 + drop_to_reorder(document, &widget_to_sketch, &widget_to_extrude, src, target) 2207 + }); 2208 + let rollback = response.rollback_moved.and_then(|target| { 2209 + resolve_rollback_change(document, &widget_to_sketch, &widget_to_extrude, target) 2210 + }); 1906 2211 let sketch_activated = response 1907 2212 .double_activated 1908 2213 .and_then(|id| widget_to_sketch.get(&id).copied()); 1909 2214 let extrude_activated = response 1910 2215 .double_activated 1911 2216 .and_then(|id| widget_to_extrude.get(&id).copied()); 1912 - let sketch_rename = response 1913 - .rename_committed 1914 - .as_ref() 1915 - .and_then(|RenameCommit { id, text }| { 1916 - widget_to_sketch 1917 - .get(id) 1918 - .copied() 1919 - .map(|sketch_id| SketchRenameRequest { 1920 - id: sketch_id, 1921 - label: text.clone(), 1922 - }) 1923 - }); 1924 - let extrude_rename = 1925 - response 1926 - .rename_committed 1927 - .as_ref() 1928 - .and_then(|RenameCommit { id, text }| { 1929 - widget_to_extrude 1930 - .get(id) 1931 - .copied() 1932 - .map(|extrude_id| ExtrudeRenameRequest { 1933 - id: extrude_id, 1934 - label: text.clone(), 1935 - }) 1936 - }); 2217 + let (sketch_rename, extrude_rename) = resolve_renames( 2218 + response.rename_committed.as_ref(), 2219 + &widget_to_sketch, 2220 + &widget_to_extrude, 2221 + ); 2222 + let context_menu = 2223 + resolve_context_menu_target(response.context_menu, &widget_to_sketch, &widget_to_extrude); 1937 2224 FeatureTreeOutcome { 1938 2225 double_activated: response.double_activated, 1939 2226 sketch_activated, 1940 2227 sketch_rename, 1941 2228 extrude_activated, 1942 2229 extrude_rename, 2230 + context_menu, 2231 + reorder, 2232 + rollback, 2233 + part_activated: response.activated == Some(part_id), 1943 2234 } 1944 2235 } 1945 2236 2237 + fn widget_feature( 2238 + document: &Document, 2239 + widget: WidgetId, 2240 + widget_to_sketch: &BTreeMap<WidgetId, SketchId>, 2241 + widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>, 2242 + ) -> Option<FeatureId> { 2243 + let tree = document.feature_tree(); 2244 + widget_to_sketch 2245 + .get(&widget) 2246 + .and_then(|sketch_id| tree.feature_of_sketch(*sketch_id)) 2247 + .or_else(|| { 2248 + widget_to_extrude 2249 + .get(&widget) 2250 + .and_then(|extrude_id| tree.feature_of_extrude(*extrude_id)) 2251 + }) 2252 + } 2253 + 2254 + fn drop_to_reorder( 2255 + document: &Document, 2256 + widget_to_sketch: &BTreeMap<WidgetId, SketchId>, 2257 + widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>, 2258 + source: WidgetId, 2259 + target: DropTarget, 2260 + ) -> Option<FeatureReorder> { 2261 + let moved = widget_feature(document, source, widget_to_sketch, widget_to_extrude)?; 2262 + let anchor = widget_feature(document, target.anchor, widget_to_sketch, widget_to_extrude)?; 2263 + let before = match target.placement { 2264 + DropPlacement::Before => true, 2265 + DropPlacement::After => false, 2266 + }; 2267 + document 2268 + .reorder_is_legal(moved, anchor, before) 2269 + .then_some(FeatureReorder { 2270 + moved, 2271 + anchor, 2272 + before, 2273 + }) 2274 + } 2275 + 2276 + fn feature_widget_id( 2277 + document: &Document, 2278 + part_id: WidgetId, 2279 + feature: FeatureId, 2280 + ) -> Option<WidgetId> { 2281 + match document.feature_tree().node(feature)? { 2282 + FeatureNode::Sketch(sketch_id) => Some(sketch_widget_id(part_id, sketch_id)), 2283 + FeatureNode::Extrude(extrude_id) => Some(extrude_widget_id(part_id, extrude_id)), 2284 + _ => None, 2285 + } 2286 + } 2287 + 2288 + fn rollback_stops(document: &Document, part_id: WidgetId) -> Vec<WidgetId> { 2289 + let consumed = consumed_sketches(document); 2290 + document 2291 + .feature_tree() 2292 + .iter() 2293 + .filter_map(|(_, node)| match node { 2294 + FeatureNode::Extrude(extrude_id) => Some(extrude_widget_id(part_id, extrude_id)), 2295 + FeatureNode::Sketch(sketch_id) if !consumed.contains(&sketch_id) => { 2296 + Some(sketch_widget_id(part_id, sketch_id)) 2297 + } 2298 + _ => None, 2299 + }) 2300 + .collect() 2301 + } 2302 + 2303 + fn current_rollback_target(document: &Document, part_id: WidgetId) -> RollbackTarget { 2304 + match document.rollback() { 2305 + RollbackMarker::AtEnd => RollbackTarget::AtEnd, 2306 + RollbackMarker::Above(feature) => feature_widget_id(document, part_id, feature) 2307 + .map_or(RollbackTarget::AtEnd, RollbackTarget::Above), 2308 + } 2309 + } 2310 + 2311 + fn resolve_rollback_change( 2312 + document: &Document, 2313 + widget_to_sketch: &BTreeMap<WidgetId, SketchId>, 2314 + widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>, 2315 + target: RollbackTarget, 2316 + ) -> Option<RollbackChange> { 2317 + match target { 2318 + RollbackTarget::AtEnd => Some(RollbackChange::ToEnd), 2319 + RollbackTarget::Above(widget) => { 2320 + widget_feature(document, widget, widget_to_sketch, widget_to_extrude) 2321 + .map(RollbackChange::ToFeature) 2322 + } 2323 + } 2324 + } 2325 + 2326 + fn resolve_context_menu_target( 2327 + request: Option<ContextMenuRequest>, 2328 + widget_to_sketch: &BTreeMap<WidgetId, SketchId>, 2329 + widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>, 2330 + ) -> Option<(FeatureTarget, LayoutPos)> { 2331 + let ContextMenuRequest { target, at } = request?; 2332 + widget_to_sketch 2333 + .get(&target) 2334 + .map(|sketch_id| FeatureTarget::Sketch(*sketch_id)) 2335 + .or_else(|| { 2336 + widget_to_extrude 2337 + .get(&target) 2338 + .map(|extrude_id| FeatureTarget::Extrude(*extrude_id)) 2339 + }) 2340 + .map(|feature_target| (feature_target, at)) 2341 + } 2342 + 2343 + struct FeatureMenuIds { 2344 + root: WidgetId, 2345 + edit_feature: WidgetId, 2346 + edit_sketch: WidgetId, 2347 + suppress: WidgetId, 2348 + unsuppress: WidgetId, 2349 + rollback: WidgetId, 2350 + delete: WidgetId, 2351 + relationships: WidgetId, 2352 + } 2353 + 2354 + impl FeatureMenuIds { 2355 + fn new(base: WidgetId) -> Self { 2356 + let root = base.child(WidgetKey::new("ctxmenu")); 2357 + Self { 2358 + edit_feature: root.child(WidgetKey::new("edit_feature")), 2359 + edit_sketch: root.child(WidgetKey::new("edit_sketch")), 2360 + suppress: root.child(WidgetKey::new("suppress")), 2361 + unsuppress: root.child(WidgetKey::new("unsuppress")), 2362 + rollback: root.child(WidgetKey::new("rollback")), 2363 + delete: root.child(WidgetKey::new("delete")), 2364 + relationships: root.child(WidgetKey::new("relationships")), 2365 + root, 2366 + } 2367 + } 2368 + } 2369 + 2370 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 2371 + enum FeatureMenuOutcome { 2372 + EditSketch(SketchId), 2373 + EditExtrude(ExtrudeId), 2374 + ShowRelationships(FeatureTarget), 2375 + Command(FeatureCommand), 2376 + } 2377 + 2378 + fn feature_id_of(document: &Document, target: FeatureTarget) -> Option<FeatureId> { 2379 + let tree = document.feature_tree(); 2380 + match target { 2381 + FeatureTarget::Sketch(sketch_id) => tree.feature_of_sketch(sketch_id), 2382 + FeatureTarget::Extrude(extrude_id) => tree.feature_of_extrude(extrude_id), 2383 + } 2384 + } 2385 + 2386 + fn feature_target_sketch(document: &Document, target: FeatureTarget) -> Option<SketchId> { 2387 + match target { 2388 + FeatureTarget::Sketch(sketch_id) => Some(sketch_id), 2389 + FeatureTarget::Extrude(extrude_id) => source_sketch_of_extrude(document, extrude_id), 2390 + } 2391 + } 2392 + 2393 + fn feature_menu_items( 2394 + ids: &FeatureMenuIds, 2395 + target: FeatureTarget, 2396 + suppressed: bool, 2397 + has_sketch: bool, 2398 + ) -> Vec<MenuItem> { 2399 + let action = |id, label| MenuItem::Action { 2400 + id, 2401 + label, 2402 + shortcut: None, 2403 + disabled: false, 2404 + }; 2405 + let edit_feature = matches!(target, FeatureTarget::Extrude(_)) 2406 + .then(|| action(ids.edit_feature, strings::FEATURE_CTX_EDIT_FEATURE)); 2407 + let edit_sketch = has_sketch.then(|| action(ids.edit_sketch, strings::FEATURE_CTX_EDIT_SKETCH)); 2408 + let suppression = if suppressed { 2409 + action(ids.unsuppress, strings::FEATURE_CTX_UNSUPPRESS) 2410 + } else { 2411 + action(ids.suppress, strings::FEATURE_CTX_SUPPRESS) 2412 + }; 2413 + edit_feature 2414 + .into_iter() 2415 + .chain(edit_sketch) 2416 + .chain([ 2417 + suppression, 2418 + action(ids.rollback, strings::FEATURE_CTX_ROLLBACK), 2419 + MenuItem::Separator, 2420 + action(ids.delete, strings::FEATURE_CTX_DELETE), 2421 + MenuItem::Separator, 2422 + action(ids.relationships, strings::FEATURE_CTX_RELATIONSHIPS), 2423 + ]) 2424 + .collect() 2425 + } 2426 + 2427 + fn feature_menu_outcome_for( 2428 + ids: &FeatureMenuIds, 2429 + activated: WidgetId, 2430 + target: FeatureTarget, 2431 + document: &Document, 2432 + ) -> Option<FeatureMenuOutcome> { 2433 + let feature = feature_id_of(document, target)?; 2434 + if activated == ids.edit_feature { 2435 + match target { 2436 + FeatureTarget::Extrude(extrude_id) => Some(FeatureMenuOutcome::EditExtrude(extrude_id)), 2437 + FeatureTarget::Sketch(_) => None, 2438 + } 2439 + } else if activated == ids.edit_sketch { 2440 + feature_target_sketch(document, target).map(FeatureMenuOutcome::EditSketch) 2441 + } else if activated == ids.suppress { 2442 + Some(FeatureMenuOutcome::Command(FeatureCommand::Suppress( 2443 + feature, 2444 + ))) 2445 + } else if activated == ids.unsuppress { 2446 + Some(FeatureMenuOutcome::Command(FeatureCommand::Unsuppress( 2447 + feature, 2448 + ))) 2449 + } else if activated == ids.rollback { 2450 + Some(FeatureMenuOutcome::Command(FeatureCommand::RollbackToHere( 2451 + feature, 2452 + ))) 2453 + } else if activated == ids.delete { 2454 + Some(FeatureMenuOutcome::Command(FeatureCommand::Delete(target))) 2455 + } else if activated == ids.relationships { 2456 + Some(FeatureMenuOutcome::ShowRelationships(target)) 2457 + } else { 2458 + None 2459 + } 2460 + } 2461 + 2462 + fn render_feature_context_menu( 2463 + ctx: &mut FrameCtx<'_>, 2464 + base_id: WidgetId, 2465 + state: &mut ShellState, 2466 + document: &Document, 2467 + paints: &mut Vec<WidgetPaint>, 2468 + ) -> Option<FeatureMenuOutcome> { 2469 + let open = state.feature_menu.open?; 2470 + let target = open.target; 2471 + let ids = FeatureMenuIds::new(base_id); 2472 + let suppressed = feature_id_of(document, target) 2473 + .is_some_and(|feature| document.suppression_state(feature).is_suppressed()); 2474 + let has_sketch = feature_target_sketch(document, target).is_some(); 2475 + let items = feature_menu_items(&ids, target, suppressed, has_sketch); 2476 + let response = show_context_menu( 2477 + ctx, 2478 + ContextMenu::at_cursor( 2479 + ids.root, 2480 + open.anchor, 2481 + strings::FEATURE_TREE_LABEL, 2482 + &items, 2483 + &mut state.feature_menu.menu, 2484 + ), 2485 + ); 2486 + paints.extend(response.paint); 2487 + let outcome = response 2488 + .activated 2489 + .and_then(|id| feature_menu_outcome_for(&ids, id, target, document)); 2490 + let pressed_outside = ctx.input.buttons_pressed.contains(PointerButton::Primary) 2491 + && ctx 2492 + .input 2493 + .pointer 2494 + .is_some_and(|sample| !response.rect.contains(sample.position)); 2495 + if outcome.is_some() || response.close || pressed_outside { 2496 + state.feature_menu.open = None; 2497 + state.feature_menu.menu = MenuState::default(); 2498 + } 2499 + outcome 2500 + } 2501 + 2502 + const REL_ROW_PX: f32 = 22.0; 2503 + const REL_PANEL_WIDTH_PX: f32 = 280.0; 2504 + const REL_PAD_PX: f32 = 10.0; 2505 + 2506 + #[derive(Copy, Clone)] 2507 + enum RelTone { 2508 + Heading, 2509 + Item, 2510 + Empty, 2511 + } 2512 + 2513 + #[derive(Clone)] 2514 + struct RelRow { 2515 + text: LabelText, 2516 + indent: f32, 2517 + tone: RelTone, 2518 + } 2519 + 2520 + fn feature_label(document: &Document, feature: FeatureId) -> Option<String> { 2521 + match document.feature_tree().node(feature)? { 2522 + FeatureNode::Sketch(sketch_id) => { 2523 + Some(document.sketch_label(sketch_id).unwrap_or("").to_owned()) 2524 + } 2525 + FeatureNode::Extrude(extrude_id) => { 2526 + Some(document.extrude_label(extrude_id).unwrap_or("").to_owned()) 2527 + } 2528 + _ => None, 2529 + } 2530 + } 2531 + 2532 + fn relationship_section( 2533 + document: &Document, 2534 + header: StringKey, 2535 + features: &[FeatureId], 2536 + ) -> Vec<RelRow> { 2537 + let head = RelRow { 2538 + text: LabelText::Key(header), 2539 + indent: REL_PAD_PX, 2540 + tone: RelTone::Heading, 2541 + }; 2542 + let items: Vec<RelRow> = features 2543 + .iter() 2544 + .filter_map(|feature| feature_label(document, *feature)) 2545 + .map(|label| RelRow { 2546 + text: LabelText::Owned(label), 2547 + indent: REL_PAD_PX + 12.0, 2548 + tone: RelTone::Item, 2549 + }) 2550 + .collect(); 2551 + let body = if items.is_empty() { 2552 + vec![RelRow { 2553 + text: LabelText::Key(strings::FEATURE_REL_NONE), 2554 + indent: REL_PAD_PX + 12.0, 2555 + tone: RelTone::Empty, 2556 + }] 2557 + } else { 2558 + items 2559 + }; 2560 + std::iter::once(head).chain(body).collect() 2561 + } 2562 + 2563 + fn relationship_rows(document: &Document, feature: FeatureId) -> Vec<RelRow> { 2564 + let tree = document.feature_tree(); 2565 + let parents = tree.parents(feature); 2566 + let children = tree.children(feature); 2567 + relationship_section(document, strings::FEATURE_REL_PARENTS, &parents) 2568 + .into_iter() 2569 + .chain(relationship_section( 2570 + document, 2571 + strings::FEATURE_REL_CHILDREN, 2572 + &children, 2573 + )) 2574 + .collect() 2575 + } 2576 + 2577 + fn relationships_panel_rect(viewport: LayoutRect, content_height: f32) -> LayoutRect { 2578 + let height = REL_PAD_PX * 2.0 + REL_ROW_PX + content_height; 2579 + let x = viewport.origin.x.value() + (viewport.size.width.value() - REL_PANEL_WIDTH_PX) / 2.0; 2580 + let y = viewport.origin.y.value() + (viewport.size.height.value() - height) / 2.0; 2581 + LayoutRect::new( 2582 + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 2583 + LayoutSize::new(LayoutPx::new(REL_PANEL_WIDTH_PX), LayoutPx::new(height)), 2584 + ) 2585 + } 2586 + 2587 + fn relationships_close_rect(panel: LayoutRect) -> LayoutRect { 2588 + let width = 56.0; 2589 + LayoutRect::new( 2590 + LayoutPos::new( 2591 + LayoutPx::new(panel.origin.x.value() + panel.size.width.value() - width - REL_PAD_PX), 2592 + LayoutPx::new(panel.origin.y.value() + REL_PAD_PX), 2593 + ), 2594 + LayoutSize::new(LayoutPx::new(width), LayoutPx::new(REL_ROW_PX)), 2595 + ) 2596 + } 2597 + 2598 + fn rel_row_paint(ctx: &FrameCtx<'_>, body: LayoutRect, row: &RelRow, top: f32) -> WidgetPaint { 2599 + let rect = LayoutRect::new( 2600 + LayoutPos::new( 2601 + LayoutPx::new(body.origin.x.value() + row.indent), 2602 + LayoutPx::new(top), 2603 + ), 2604 + LayoutSize::new( 2605 + LayoutPx::saturating_nonneg(body.size.width.value() - row.indent - REL_PAD_PX), 2606 + LayoutPx::new(REL_ROW_PX), 2607 + ), 2608 + ); 2609 + let (color, role) = match row.tone { 2610 + RelTone::Heading => ( 2611 + ctx.theme().colors.text_secondary(), 2612 + ctx.theme().typography.label, 2613 + ), 2614 + RelTone::Item => ( 2615 + ctx.theme().colors.text_primary(), 2616 + ctx.theme().typography.body, 2617 + ), 2618 + RelTone::Empty => ( 2619 + ctx.theme().colors.text_disabled(), 2620 + ctx.theme().typography.body, 2621 + ), 2622 + }; 2623 + WidgetPaint::AlignedLabel { 2624 + rect, 2625 + text: row.text.clone(), 2626 + color, 2627 + role, 2628 + align: HorizontalAlign::Start, 2629 + } 2630 + } 2631 + 2632 + fn render_relationships_panel( 2633 + ctx: &mut FrameCtx<'_>, 2634 + base_id: WidgetId, 2635 + viewport: LayoutRect, 2636 + target: FeatureTarget, 2637 + document: &Document, 2638 + paints: &mut Vec<WidgetPaint>, 2639 + ) -> bool { 2640 + let Some(feature) = feature_id_of(document, target) else { 2641 + return true; 2642 + }; 2643 + let rows = relationship_rows(document, feature); 2644 + let content_height = rows.iter().fold(0.0_f32, |acc, _| acc + REL_ROW_PX); 2645 + let rect = relationships_panel_rect(viewport, content_height); 2646 + let panel_id = base_id.child(WidgetKey::new("relationships")); 2647 + let mut panel_state = PanelState::open(); 2648 + let response = show_panel( 2649 + ctx, 2650 + Panel::new(panel_id, rect, &mut panel_state).variant(PanelVariant::Card), 2651 + ); 2652 + paints.extend(response.paint); 2653 + let Some(body) = response.body_rect else { 2654 + return false; 2655 + }; 2656 + paints.push(WidgetPaint::AlignedLabel { 2657 + rect: LayoutRect::new( 2658 + LayoutPos::new( 2659 + LayoutPx::new(body.origin.x.value() + REL_PAD_PX), 2660 + LayoutPx::new(body.origin.y.value() + REL_PAD_PX), 2661 + ), 2662 + LayoutSize::new( 2663 + LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * REL_PAD_PX), 2664 + LayoutPx::new(REL_ROW_PX), 2665 + ), 2666 + ), 2667 + text: LabelText::Key(strings::FEATURE_CTX_RELATIONSHIPS), 2668 + color: ctx.theme().colors.text_primary(), 2669 + role: ctx.theme().typography.title, 2670 + align: HorizontalAlign::Start, 2671 + }); 2672 + let start_y = body.origin.y.value() + REL_PAD_PX + REL_ROW_PX; 2673 + paints.extend(rows.iter().scan(start_y, |top, row| { 2674 + let current = *top; 2675 + *top += REL_ROW_PX; 2676 + Some(rel_row_paint(ctx, body, row, current)) 2677 + })); 2678 + let close_id = panel_id.child(WidgetKey::new("close")); 2679 + let close_rect = relationships_close_rect(rect); 2680 + let close = ctx.interact( 2681 + InteractDeclaration::new(close_id, close_rect, Sense::INTERACTIVE) 2682 + .a11y(AccessNode::new(Role::Button).with_label(strings::FEATURE_REL_CLOSE)), 2683 + ); 2684 + paints.push(WidgetPaint::AlignedLabel { 2685 + rect: close_rect, 2686 + text: LabelText::Key(strings::FEATURE_REL_CLOSE), 2687 + color: if close.hover() { 2688 + ctx.theme().colors.text_primary() 2689 + } else { 2690 + ctx.theme().colors.text_secondary() 2691 + }, 2692 + role: ctx.theme().typography.label, 2693 + align: HorizontalAlign::End, 2694 + }); 2695 + let pressed_outside = ctx.input.buttons_pressed.contains(PointerButton::Primary) 2696 + && ctx 2697 + .input 2698 + .pointer 2699 + .is_some_and(|sample| !rect.contains(sample.position)); 2700 + close.click() || pressed_outside 2701 + } 2702 + 2703 + struct WhatsWrongOutcome { 2704 + close: bool, 2705 + reattach: Option<SketchId>, 2706 + } 2707 + 2708 + fn whats_wrong_row_paint( 2709 + ctx: &mut FrameCtx<'_>, 2710 + panel_id: WidgetId, 2711 + body: LayoutRect, 2712 + entry: &WhatsWrong, 2713 + top: f32, 2714 + paints: &mut Vec<WidgetPaint>, 2715 + ) -> Option<SketchId> { 2716 + let message_color = if entry.is_error { 2717 + ctx.theme().colors.danger.step(Step12::SOLID) 2718 + } else { 2719 + ctx.theme().colors.warning.step(Step12::SOLID) 2720 + }; 2721 + let message_rect = LayoutRect::new( 2722 + LayoutPos::new( 2723 + LayoutPx::new(body.origin.x.value() + REL_PAD_PX), 2724 + LayoutPx::new(top), 2725 + ), 2726 + LayoutSize::new( 2727 + LayoutPx::saturating_nonneg(body.size.width.value() * 0.55 - REL_PAD_PX), 2728 + LayoutPx::new(REL_ROW_PX), 2729 + ), 2730 + ); 2731 + paints.push(WidgetPaint::AlignedLabel { 2732 + rect: message_rect, 2733 + text: LabelText::Key(entry.message), 2734 + color: message_color, 2735 + role: ctx.theme().typography.body, 2736 + align: HorizontalAlign::Start, 2737 + }); 2738 + let right_rect = LayoutRect::new( 2739 + LayoutPos::new( 2740 + LayoutPx::new(body.origin.x.value() + body.size.width.value() * 0.55), 2741 + LayoutPx::new(top), 2742 + ), 2743 + LayoutSize::new( 2744 + LayoutPx::saturating_nonneg(body.size.width.value() * 0.45 - REL_PAD_PX), 2745 + LayoutPx::new(REL_ROW_PX), 2746 + ), 2747 + ); 2748 + let Some(sketch_id) = entry.reattach else { 2749 + paints.push(WidgetPaint::AlignedLabel { 2750 + rect: right_rect, 2751 + text: LabelText::Owned(entry.label.clone()), 2752 + color: ctx.theme().colors.text_secondary(), 2753 + role: ctx.theme().typography.caption, 2754 + align: HorizontalAlign::End, 2755 + }); 2756 + return None; 2757 + }; 2758 + let reattach_id = panel_id.child_indexed(WidgetKey::new("reattach"), sketch_id.as_u64()); 2759 + let interaction = ctx.interact( 2760 + InteractDeclaration::new(reattach_id, right_rect, Sense::INTERACTIVE) 2761 + .a11y(AccessNode::new(Role::Button).with_label(strings::WHATS_WRONG_REATTACH)), 2762 + ); 2763 + paints.push(WidgetPaint::AlignedLabel { 2764 + rect: right_rect, 2765 + text: LabelText::Key(strings::WHATS_WRONG_REATTACH), 2766 + color: ctx.theme().colors.accent.step(Step12::SOLID), 2767 + role: ctx.theme().typography.label, 2768 + align: HorizontalAlign::End, 2769 + }); 2770 + interaction.click().then_some(sketch_id) 2771 + } 2772 + 2773 + fn render_whats_wrong_panel( 2774 + ctx: &mut FrameCtx<'_>, 2775 + base_id: WidgetId, 2776 + viewport: LayoutRect, 2777 + entries: &[WhatsWrong], 2778 + paints: &mut Vec<WidgetPaint>, 2779 + ) -> WhatsWrongOutcome { 2780 + let line_count = entries.len().max(1); 2781 + let content_height = (0..line_count).fold(0.0_f32, |height, _| height + REL_ROW_PX); 2782 + let rect = relationships_panel_rect(viewport, content_height); 2783 + let panel_id = base_id.child(WidgetKey::new("whats_wrong")); 2784 + let mut panel_state = PanelState::open(); 2785 + let response = show_panel( 2786 + ctx, 2787 + Panel::new(panel_id, rect, &mut panel_state).variant(PanelVariant::Card), 2788 + ); 2789 + paints.extend(response.paint); 2790 + let Some(body) = response.body_rect else { 2791 + return WhatsWrongOutcome { 2792 + close: false, 2793 + reattach: None, 2794 + }; 2795 + }; 2796 + paints.push(WidgetPaint::AlignedLabel { 2797 + rect: LayoutRect::new( 2798 + LayoutPos::new( 2799 + LayoutPx::new(body.origin.x.value() + REL_PAD_PX), 2800 + LayoutPx::new(body.origin.y.value() + REL_PAD_PX), 2801 + ), 2802 + LayoutSize::new( 2803 + LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * REL_PAD_PX), 2804 + LayoutPx::new(REL_ROW_PX), 2805 + ), 2806 + ), 2807 + text: LabelText::Key(strings::WHATS_WRONG_TITLE), 2808 + color: ctx.theme().colors.text_primary(), 2809 + role: ctx.theme().typography.title, 2810 + align: HorizontalAlign::Start, 2811 + }); 2812 + let start_y = body.origin.y.value() + REL_PAD_PX + REL_ROW_PX; 2813 + let reattach = if entries.is_empty() { 2814 + paints.push(WidgetPaint::AlignedLabel { 2815 + rect: LayoutRect::new( 2816 + LayoutPos::new( 2817 + LayoutPx::new(body.origin.x.value() + REL_PAD_PX), 2818 + LayoutPx::new(start_y), 2819 + ), 2820 + LayoutSize::new( 2821 + LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * REL_PAD_PX), 2822 + LayoutPx::new(REL_ROW_PX), 2823 + ), 2824 + ), 2825 + text: LabelText::Key(strings::WHATS_WRONG_NONE), 2826 + color: ctx.theme().colors.text_disabled(), 2827 + role: ctx.theme().typography.body, 2828 + align: HorizontalAlign::Start, 2829 + }); 2830 + None 2831 + } else { 2832 + entries 2833 + .iter() 2834 + .enumerate() 2835 + .fold(None, |acc, (index, entry)| { 2836 + let top = start_y + index_offset(index); 2837 + acc.or(whats_wrong_row_paint( 2838 + ctx, panel_id, body, entry, top, paints, 2839 + )) 2840 + }) 2841 + }; 2842 + let close_id = panel_id.child(WidgetKey::new("close")); 2843 + let close_rect = relationships_close_rect(rect); 2844 + let close = ctx.interact( 2845 + InteractDeclaration::new(close_id, close_rect, Sense::INTERACTIVE) 2846 + .a11y(AccessNode::new(Role::Button).with_label(strings::FEATURE_REL_CLOSE)), 2847 + ); 2848 + paints.push(WidgetPaint::AlignedLabel { 2849 + rect: close_rect, 2850 + text: LabelText::Key(strings::FEATURE_REL_CLOSE), 2851 + color: if close.hover() { 2852 + ctx.theme().colors.text_primary() 2853 + } else { 2854 + ctx.theme().colors.text_secondary() 2855 + }, 2856 + role: ctx.theme().typography.label, 2857 + align: HorizontalAlign::End, 2858 + }); 2859 + let pressed_outside = ctx.input.buttons_pressed.contains(PointerButton::Primary) 2860 + && ctx 2861 + .input 2862 + .pointer 2863 + .is_some_and(|sample| !rect.contains(sample.position)); 2864 + WhatsWrongOutcome { 2865 + close: close.click() || pressed_outside, 2866 + reattach, 2867 + } 2868 + } 2869 + 2870 + fn index_offset(index: usize) -> f32 { 2871 + f32::from(u16::try_from(index).unwrap_or(u16::MAX)) * REL_ROW_PX 2872 + } 2873 + 1946 2874 #[derive(Copy, Clone)] 1947 2875 struct PropertyState<'a> { 1948 2876 mode: &'a Mode, ··· 3610 4538 None, 3611 4539 None, 3612 4540 &mut ViewUi::default(), 4541 + &BTreeMap::new(), 4542 + false, 4543 + &[], 3613 4544 ) 3614 4545 } 3615 4546 ··· 3648 4579 }) 3649 4580 } 3650 4581 4582 + fn blind_extrude(sketch: SketchId) -> ExtrudeFeature { 4583 + use bone_document::{ExtrudeDirection, ExtrudeSense}; 4584 + let Ok(depth) = PositiveLength::new(Length::new::<bone_types::millimeter>(10.0)) else { 4585 + panic!("ten millimeters is a positive depth"); 4586 + }; 4587 + ExtrudeFeature { 4588 + sketch, 4589 + direction: ExtrudeDirection::Normal { 4590 + sense: ExtrudeSense::Forward, 4591 + }, 4592 + end_condition: ExtrudeEndCondition::Blind { depth }, 4593 + draft: None, 4594 + thin_wall: None, 4595 + merge_result: MergeResult::Separate, 4596 + } 4597 + } 4598 + 4599 + fn push_plain_chain(document: &mut Document, label: &str) -> (SketchId, ExtrudeId) { 4600 + let sketch_id = document.allocate_sketch(); 4601 + document.insert_sketch(sketch_id, label.to_owned(), Sketch::new(Plane::Xy.basis())); 4602 + let extrude_id = document.commit_extrude(blind_extrude(sketch_id)); 4603 + (sketch_id, extrude_id) 4604 + } 4605 + 4606 + fn test_part_id() -> WidgetId { 4607 + use std::num::NonZeroU64; 4608 + WidgetId::from_raw(NonZeroU64::MIN).child(WidgetKey::new("part")) 4609 + } 4610 + 4611 + #[test] 4612 + fn feature_rows_nest_sketch_under_consuming_extrude() { 4613 + let mut document = sample_document(); 4614 + let (sketch_a, extrude_a) = push_plain_chain(&mut document, "Sketch1"); 4615 + let (sketch_b, extrude_b) = push_plain_chain(&mut document, "Sketch2"); 4616 + let part_id = test_part_id(); 4617 + let rows = feature_rows(&document, part_id, &BTreeMap::new()); 4618 + assert_eq!( 4619 + rows.len(), 4620 + 2, 4621 + "two extrudes are the only top-level features" 4622 + ); 4623 + let assert_chain = |node: &TreeNode, extrude_id: ExtrudeId, sketch_id: SketchId| { 4624 + assert_eq!(node.id, extrude_widget_id(part_id, extrude_id)); 4625 + assert_eq!(node.children.len(), 1, "the extrude absorbs its sketch"); 4626 + assert_eq!(node.children[0].id, sketch_widget_id(part_id, sketch_id)); 4627 + }; 4628 + assert_chain(&rows[0], extrude_a, sketch_a); 4629 + assert_chain(&rows[1], extrude_b, sketch_b); 4630 + } 4631 + 4632 + #[test] 4633 + fn feature_rows_keep_unconsumed_sketch_at_top_level() { 4634 + let mut document = sample_document(); 4635 + let sketch_id = document.allocate_sketch(); 4636 + document.insert_sketch( 4637 + sketch_id, 4638 + "Sketch1".to_owned(), 4639 + Sketch::new(Plane::Xy.basis()), 4640 + ); 4641 + let part_id = test_part_id(); 4642 + let rows = feature_rows(&document, part_id, &BTreeMap::new()); 4643 + assert_eq!(rows.len(), 1); 4644 + assert_eq!(rows[0].id, sketch_widget_id(part_id, sketch_id)); 4645 + assert!( 4646 + rows[0].children.is_empty(), 4647 + "an unconsumed sketch has no nested feature" 4648 + ); 4649 + } 4650 + 4651 + fn menu_labels(items: &[MenuItem]) -> Vec<StringKey> { 4652 + items 4653 + .iter() 4654 + .filter_map(|item| match item { 4655 + MenuItem::Action { label, .. } => Some(*label), 4656 + _ => None, 4657 + }) 4658 + .collect() 4659 + } 4660 + 4661 + #[test] 4662 + fn feature_menu_items_for_extrude_include_edit_feature_and_suppress() { 4663 + let ids = FeatureMenuIds::new(test_part_id()); 4664 + let items = feature_menu_items( 4665 + &ids, 4666 + FeatureTarget::Extrude(ExtrudeId::default()), 4667 + false, 4668 + true, 4669 + ); 4670 + let labels = menu_labels(&items); 4671 + assert!(labels.contains(&strings::FEATURE_CTX_EDIT_FEATURE)); 4672 + assert!(labels.contains(&strings::FEATURE_CTX_EDIT_SKETCH)); 4673 + assert!(labels.contains(&strings::FEATURE_CTX_SUPPRESS)); 4674 + assert!(!labels.contains(&strings::FEATURE_CTX_UNSUPPRESS)); 4675 + assert!(labels.contains(&strings::FEATURE_CTX_DELETE)); 4676 + assert!(labels.contains(&strings::FEATURE_CTX_RELATIONSHIPS)); 4677 + } 4678 + 4679 + #[test] 4680 + fn feature_menu_items_for_suppressed_sketch_omit_edit_feature_and_offer_unsuppress() { 4681 + let ids = FeatureMenuIds::new(test_part_id()); 4682 + let items = 4683 + feature_menu_items(&ids, FeatureTarget::Sketch(SketchId::default()), true, true); 4684 + let labels = menu_labels(&items); 4685 + assert!(!labels.contains(&strings::FEATURE_CTX_EDIT_FEATURE)); 4686 + assert!(labels.contains(&strings::FEATURE_CTX_UNSUPPRESS)); 4687 + assert!(!labels.contains(&strings::FEATURE_CTX_SUPPRESS)); 4688 + } 4689 + 4690 + #[test] 4691 + fn feature_menu_outcome_maps_ids_to_commands() { 4692 + let mut document = sample_document(); 4693 + let (sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); 4694 + let target = FeatureTarget::Extrude(extrude); 4695 + let ids = FeatureMenuIds::new(test_part_id()); 4696 + let Some(feature) = feature_id_of(&document, target) else { 4697 + panic!("the extrude has a feature id"); 4698 + }; 4699 + assert_eq!( 4700 + feature_menu_outcome_for(&ids, ids.suppress, target, &document), 4701 + Some(FeatureMenuOutcome::Command(FeatureCommand::Suppress( 4702 + feature 4703 + ))), 4704 + ); 4705 + assert_eq!( 4706 + feature_menu_outcome_for(&ids, ids.delete, target, &document), 4707 + Some(FeatureMenuOutcome::Command(FeatureCommand::Delete(target))), 4708 + ); 4709 + assert_eq!( 4710 + feature_menu_outcome_for(&ids, ids.edit_sketch, target, &document), 4711 + Some(FeatureMenuOutcome::EditSketch(sketch)), 4712 + "edit sketch on an extrude resolves to its source sketch", 4713 + ); 4714 + assert_eq!( 4715 + feature_menu_outcome_for(&ids, ids.relationships, target, &document), 4716 + Some(FeatureMenuOutcome::ShowRelationships(target)), 4717 + ); 4718 + } 4719 + 4720 + #[test] 4721 + fn rollback_target_and_change_round_trip_through_the_marker() { 4722 + let mut document = sample_document(); 4723 + let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); 4724 + let part_id = test_part_id(); 4725 + assert_eq!( 4726 + current_rollback_target(&document, part_id), 4727 + RollbackTarget::AtEnd, 4728 + "a fresh document is rolled to the end", 4729 + ); 4730 + let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { 4731 + panic!("the extrude resolves to a feature id"); 4732 + }; 4733 + document.roll_to_here(feature); 4734 + let widget = extrude_widget_id(part_id, extrude); 4735 + assert_eq!( 4736 + current_rollback_target(&document, part_id), 4737 + RollbackTarget::Above(widget), 4738 + ); 4739 + let w2s: BTreeMap<WidgetId, SketchId> = sketch_widget_ids(&document, part_id) 4740 + .into_iter() 4741 + .map(|(s, w)| (w, s)) 4742 + .collect(); 4743 + let w2e: BTreeMap<WidgetId, ExtrudeId> = extrude_widget_ids(&document, part_id) 4744 + .into_iter() 4745 + .map(|(e, w)| (w, e)) 4746 + .collect(); 4747 + assert_eq!( 4748 + resolve_rollback_change(&document, &w2s, &w2e, RollbackTarget::AtEnd), 4749 + Some(RollbackChange::ToEnd), 4750 + ); 4751 + assert_eq!( 4752 + resolve_rollback_change(&document, &w2s, &w2e, RollbackTarget::Above(widget)), 4753 + Some(RollbackChange::ToFeature(feature)), 4754 + ); 4755 + } 4756 + 4757 + #[test] 4758 + fn rolled_back_feature_node_is_disabled() { 4759 + let mut document = sample_document(); 4760 + let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); 4761 + let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { 4762 + panic!("the extrude resolves to a feature id"); 4763 + }; 4764 + document.roll_to_here(feature); 4765 + let part_id = test_part_id(); 4766 + let rows = feature_rows(&document, part_id, &BTreeMap::new()); 4767 + assert!( 4768 + rows[0].disabled, 4769 + "a rolled-back feature is greyed and inert" 4770 + ); 4771 + } 4772 + 4773 + #[test] 4774 + fn extrude_node_shows_its_own_error_badge() { 4775 + let mut document = sample_document(); 4776 + let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); 4777 + let part_id = test_part_id(); 4778 + let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { 4779 + panic!("the extrude resolves to a feature id"); 4780 + }; 4781 + let badges = BTreeMap::from([(feature, TreeBadge::Error)]); 4782 + let rows = feature_rows(&document, part_id, &badges); 4783 + assert_eq!(rows.len(), 1); 4784 + assert_eq!(rows[0].badge, Some(TreeBadge::Error)); 4785 + } 4786 + 4787 + #[test] 4788 + fn extrude_node_rolls_up_a_warning_from_its_nested_sketch() { 4789 + let mut document = sample_document(); 4790 + let (sketch, _extrude) = push_plain_chain(&mut document, "Sketch1"); 4791 + let part_id = test_part_id(); 4792 + let Some(sketch_feature) = document.feature_tree().feature_of_sketch(sketch) else { 4793 + panic!("the sketch resolves to a feature id"); 4794 + }; 4795 + let badges = BTreeMap::from([(sketch_feature, TreeBadge::Warning)]); 4796 + let rows = feature_rows(&document, part_id, &badges); 4797 + assert_eq!( 4798 + rows[0].badge, 4799 + Some(TreeBadge::Warning), 4800 + "the extrude rolls up its nested sketch's warning", 4801 + ); 4802 + assert_eq!(rows[0].children[0].badge, Some(TreeBadge::Warning)); 4803 + } 4804 + 4805 + #[test] 4806 + fn extrude_node_error_outranks_a_nested_warning() { 4807 + let mut document = sample_document(); 4808 + let (sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); 4809 + let part_id = test_part_id(); 4810 + let Some(sketch_feature) = document.feature_tree().feature_of_sketch(sketch) else { 4811 + panic!("the sketch resolves to a feature id"); 4812 + }; 4813 + let Some(extrude_feature) = document.feature_tree().feature_of_extrude(extrude) else { 4814 + panic!("the extrude resolves to a feature id"); 4815 + }; 4816 + let badges = BTreeMap::from([ 4817 + (extrude_feature, TreeBadge::Warning), 4818 + (sketch_feature, TreeBadge::Error), 4819 + ]); 4820 + let rows = feature_rows(&document, part_id, &badges); 4821 + assert_eq!( 4822 + rows[0].badge, 4823 + Some(TreeBadge::Error), 4824 + "an error on the nested sketch outranks the extrude's own warning", 4825 + ); 4826 + } 4827 + 4828 + #[test] 4829 + fn drop_to_reorder_accepts_legal_move_and_rejects_illegal() { 4830 + let mut document = sample_document(); 4831 + let (sketch_a, extrude_a) = push_plain_chain(&mut document, "Sketch1"); 4832 + let (_sketch_b, extrude_b) = push_plain_chain(&mut document, "Sketch2"); 4833 + let part_id = test_part_id(); 4834 + let w2s: BTreeMap<WidgetId, SketchId> = sketch_widget_ids(&document, part_id) 4835 + .into_iter() 4836 + .map(|(s, w)| (w, s)) 4837 + .collect(); 4838 + let w2e: BTreeMap<WidgetId, ExtrudeId> = extrude_widget_ids(&document, part_id) 4839 + .into_iter() 4840 + .map(|(e, w)| (w, e)) 4841 + .collect(); 4842 + let src = extrude_widget_id(part_id, extrude_a); 4843 + let after_b = DropTarget { 4844 + anchor: extrude_widget_id(part_id, extrude_b), 4845 + placement: DropPlacement::After, 4846 + }; 4847 + let Some(reorder) = drop_to_reorder(&document, &w2s, &w2e, src, after_b) else { 4848 + panic!("moving the first extrude after the second is legal"); 4849 + }; 4850 + assert!(!reorder.before); 4851 + let before_own_sketch = DropTarget { 4852 + anchor: sketch_widget_id(part_id, sketch_a), 4853 + placement: DropPlacement::Before, 4854 + }; 4855 + assert!( 4856 + drop_to_reorder(&document, &w2s, &w2e, src, before_own_sketch).is_none(), 4857 + "an extrude cannot move before its own sketch", 4858 + ); 4859 + } 4860 + 4861 + #[test] 4862 + fn relationship_rows_list_the_extrude_parent_sketch_and_no_children() { 4863 + let mut document = sample_document(); 4864 + let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1"); 4865 + let Some(feature) = feature_id_of(&document, FeatureTarget::Extrude(extrude)) else { 4866 + panic!("the extrude has a feature id"); 4867 + }; 4868 + let rows = relationship_rows(&document, feature); 4869 + let owned: Vec<&str> = rows 4870 + .iter() 4871 + .filter_map(|row| match &row.text { 4872 + LabelText::Owned(label) => Some(label.as_str()), 4873 + LabelText::Key(_) => None, 4874 + }) 4875 + .collect(); 4876 + assert_eq!( 4877 + owned, 4878 + vec!["Sketch1"], 4879 + "the only parent is the source sketch" 4880 + ); 4881 + let has_none = rows.iter().any( 4882 + |row| matches!(&row.text, LabelText::Key(key) if *key == strings::FEATURE_REL_NONE), 4883 + ); 4884 + assert!(has_none, "a childless extrude shows None under children"); 4885 + } 4886 + 3651 4887 #[test] 3652 4888 fn tools_options_menu_id_maps_to_open_settings_action() { 3653 4889 let shell = Shell::new(); ··· 4303 5539 None, 4304 5540 None, 4305 5541 &mut ViewUi::default(), 5542 + &BTreeMap::new(), 5543 + false, 5544 + &[], 4306 5545 ); 4307 5546 let any_smart_dim_label = frame.paints.iter().any(|p| { 4308 5547 matches!( ··· 4756 5995 None, 4757 5996 None, 4758 5997 &mut ViewUi::default(), 5998 + &BTreeMap::new(), 5999 + false, 6000 + &[], 4759 6001 ) 4760 6002 }; 4761 6003 *prev = bone_ui::hit_test::resolve(prev, &hits, snap, focus.focused()); ··· 5459 6701 None, 5460 6702 None, 5461 6703 &mut ViewUi::default(), 6704 + &BTreeMap::new(), 6705 + false, 6706 + &[], 5462 6707 ); 5463 6708 } 5464 6709 (shell, a11y, focus) ··· 5932 7177 camera3, 5933 7178 None, 5934 7179 &mut view, 7180 + &BTreeMap::new(), 7181 + false, 7182 + &[], 5935 7183 ) 5936 7184 }; 5937 7185 let update = a11y.build(strings, focus.focused());
+2 -1
crates/bone-app/src/snapshots/bone_app__hotkeys__tests__default_hotkey_table.snap
··· 11 11 action=14 chord=Delete scope=Global 12 12 action=15 chord=F scope=Global 13 13 action=16 chord=S scope=Global 14 - action=17 chord=Ctrl+Q scope=Global 15 14 action=18 chord=Ctrl+I scope=Global 16 15 action=19 chord=Ctrl+E scope=Global 17 16 action=2 chord=Ctrl+Z scope=Global ··· 27 26 action=29 chord=Ctrl+Space scope=Global 28 27 action=3 chord=Ctrl+Shift+Z scope=Global 29 28 action=3 chord=Ctrl+Y scope=Global 29 + action=30 chord=Ctrl+B scope=Global 30 + action=31 chord=Ctrl+Q scope=Global
+16 -4
crates/bone-app/src/status_badge.rs
··· 3 3 SketchStatusReport, TruckGap, 4 4 }; 5 5 use bone_types::{ 6 - BrepSlot, SketchDimensionId, SketchEntityId, SketchItemId, SketchRelationId, SketchStatus, 6 + BrepSlot, RebuildError, SketchDimensionId, SketchEntityId, SketchItemId, SketchRelationId, 7 + SketchStatus, 7 8 }; 8 9 use bone_ui::frame::FrameCtx; 9 10 use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; ··· 67 68 pub fn extrude_badge_style(status: ExtrudeStatus<'_>, cad: &CadColors) -> (StringKey, Color) { 68 69 match status { 69 70 ExtrudeStatus::Valid => (strings::STATUS_EXTRUDE_VALID, cad.sketch_fully_defined), 70 - ExtrudeStatus::Failed(ExtrudeError::Kernel( 71 - BrepError::DanglingEdge { .. } | BrepError::DanglingVertex { .. }, 72 - )) => (strings::STATUS_EXTRUDE_DANGLING, cad.sketch_dangling), 71 + ExtrudeStatus::Failed( 72 + ExtrudeError::Kernel(BrepError::DanglingEdge { .. } | BrepError::DanglingVertex { .. }) 73 + | ExtrudeError::PlaneUnresolved(RebuildError::DanglingReference(_)), 74 + ) => (strings::STATUS_EXTRUDE_DANGLING, cad.sketch_dangling), 73 75 ExtrudeStatus::Failed(_) => (strings::STATUS_EXTRUDE_INVALID, cad.sketch_invalid), 74 76 } 75 77 } ··· 78 80 match error { 79 81 ExtrudeError::UnsolvedSketch(_) => LabelText::Key(strings::EXTRUDE_PANEL_UNSOLVED_SKETCH), 80 82 ExtrudeError::Kernel(kernel) => kernel_panel_line(kernel, strings_table), 83 + ExtrudeError::PlaneUnresolved(rebuild) => LabelText::Key(rebuild_error_key(*rebuild)), 84 + } 85 + } 86 + 87 + fn rebuild_error_key(error: RebuildError) -> StringKey { 88 + match error { 89 + RebuildError::DanglingReference(_) => strings::EXTRUDE_PANEL_DANGLING_REFERENCE, 90 + RebuildError::NonPlanarSketchTarget => strings::EXTRUDE_PANEL_NON_PLANAR_TARGET, 91 + RebuildError::UpstreamUnresolved => strings::EXTRUDE_PANEL_UPSTREAM_UNRESOLVED, 92 + RebuildError::Build(_) => strings::EXTRUDE_PANEL_INTERNAL, 81 93 } 82 94 } 83 95
+106
crates/bone-app/src/strings.rs
··· 105 105 pub const FEATURE_PLANE_YZ: StringKey = StringKey::new("feature.plane.yz"); 106 106 pub const FEATURE_PLANE_ZX: StringKey = StringKey::new("feature.plane.zx"); 107 107 108 + pub const FEATURE_CTX_EDIT_FEATURE: StringKey = StringKey::new("feature.ctx.edit_feature"); 109 + pub const FEATURE_CTX_EDIT_SKETCH: StringKey = StringKey::new("feature.ctx.edit_sketch"); 110 + pub const FEATURE_CTX_SUPPRESS: StringKey = StringKey::new("feature.ctx.suppress"); 111 + pub const FEATURE_CTX_UNSUPPRESS: StringKey = StringKey::new("feature.ctx.unsuppress"); 112 + pub const FEATURE_CTX_ROLLBACK: StringKey = StringKey::new("feature.ctx.rollback"); 113 + pub const FEATURE_CTX_DELETE: StringKey = StringKey::new("feature.ctx.delete"); 114 + pub const FEATURE_CTX_RELATIONSHIPS: StringKey = StringKey::new("feature.ctx.relationships"); 115 + pub const FEATURE_REL_PARENTS: StringKey = StringKey::new("feature.rel.parents"); 116 + pub const FEATURE_REL_CHILDREN: StringKey = StringKey::new("feature.rel.children"); 117 + pub const FEATURE_REL_NONE: StringKey = StringKey::new("feature.rel.none"); 118 + pub const FEATURE_REL_CLOSE: StringKey = StringKey::new("feature.rel.close"); 119 + pub const WHATS_WRONG_TITLE: StringKey = StringKey::new("whats_wrong.title"); 120 + pub const WHATS_WRONG_NONE: StringKey = StringKey::new("whats_wrong.none"); 121 + pub const WHATS_WRONG_DANGLING: StringKey = StringKey::new("whats_wrong.dangling"); 122 + pub const WHATS_WRONG_NON_PLANAR: StringKey = StringKey::new("whats_wrong.non_planar"); 123 + pub const WHATS_WRONG_UPSTREAM: StringKey = StringKey::new("whats_wrong.upstream"); 124 + pub const WHATS_WRONG_BUILD_FAILED: StringKey = StringKey::new("whats_wrong.build_failed"); 125 + pub const WHATS_WRONG_REPAIRED: StringKey = StringKey::new("whats_wrong.repaired"); 126 + pub const WHATS_WRONG_REATTACH: StringKey = StringKey::new("whats_wrong.reattach"); 127 + 108 128 pub const PROPERTY_PANE_LABEL: StringKey = StringKey::new("shell.property_pane"); 109 129 pub const LEFT_PANE_LABEL: StringKey = StringKey::new("shell.left_pane"); 110 130 pub const LEFT_PANE_TAB_CONFIGURATION: StringKey = ··· 241 261 pub const HOTKEY_LABEL_ZOOM_FIT: StringKey = StringKey::new("hotkey.label.zoom_fit"); 242 262 pub const HOTKEY_LABEL_SHORTCUT_BAR: StringKey = StringKey::new("hotkey.label.shortcut_bar"); 243 263 pub const HOTKEY_LABEL_QUIT: StringKey = StringKey::new("hotkey.label.quit"); 264 + pub const HOTKEY_LABEL_REBUILD: StringKey = StringKey::new("hotkey.label.rebuild"); 265 + pub const HOTKEY_LABEL_FORCE_REBUILD: StringKey = StringKey::new("hotkey.label.force_rebuild"); 266 + pub const NOTIFY_REATTACH_PICK: StringKey = StringKey::new("notify.reattach_pick"); 267 + pub const NOTIFY_REATTACH_NON_PLANAR: StringKey = StringKey::new("notify.reattach_non_planar"); 244 268 pub const NOTIFY_COMING_SOON: StringKey = StringKey::new("notify.coming_soon"); 245 269 pub const NOTIFY_MIRROR_SELECTION_HINT: StringKey = StringKey::new("notify.mirror.selection_hint"); 246 270 pub const NOTIFY_HOTKEY_CONFLICT: StringKey = StringKey::new("notify.hotkey.conflict"); ··· 301 325 pub const EXTRUDE_PANEL_UNSUPPORTED_THIN: StringKey = 302 326 StringKey::new("extrude.panel.unsupported.thin"); 303 327 pub const EXTRUDE_PANEL_INTERNAL: StringKey = StringKey::new("extrude.panel.internal"); 328 + pub const EXTRUDE_PANEL_DANGLING_REFERENCE: StringKey = 329 + StringKey::new("extrude.panel.dangling_reference"); 330 + pub const EXTRUDE_PANEL_NON_PLANAR_TARGET: StringKey = 331 + StringKey::new("extrude.panel.non_planar_target"); 332 + pub const EXTRUDE_PANEL_UPSTREAM_UNRESOLVED: StringKey = 333 + StringKey::new("extrude.panel.upstream_unresolved"); 304 334 pub const PROPERTY_ROW_KIND: StringKey = StringKey::new("property.row.kind"); 305 335 pub const PROPERTY_ROW_X: StringKey = StringKey::new("property.row.x"); 306 336 pub const PROPERTY_ROW_Y: StringKey = StringKey::new("property.row.y"); ··· 453 483 (FEATURE_PLANE_XY, "Front Plane"), 454 484 (FEATURE_PLANE_YZ, "Right Plane"), 455 485 (FEATURE_PLANE_ZX, "Top Plane"), 486 + (FEATURE_CTX_EDIT_FEATURE, "Edit Feature"), 487 + (FEATURE_CTX_EDIT_SKETCH, "Edit Sketch"), 488 + (FEATURE_CTX_SUPPRESS, "Suppress"), 489 + (FEATURE_CTX_UNSUPPRESS, "Unsuppress"), 490 + (FEATURE_CTX_ROLLBACK, "Rollback to Here"), 491 + (FEATURE_CTX_DELETE, "Delete"), 492 + (FEATURE_CTX_RELATIONSHIPS, "Parent/Child Relationships"), 493 + (FEATURE_REL_PARENTS, "Parent Features"), 494 + (FEATURE_REL_CHILDREN, "Child Features"), 495 + (FEATURE_REL_NONE, "None"), 496 + (FEATURE_REL_CLOSE, "Close"), 497 + (WHATS_WRONG_TITLE, "What's Wrong"), 498 + (WHATS_WRONG_NONE, "No rebuild errors"), 499 + (WHATS_WRONG_DANGLING, "Dangling reference"), 500 + (WHATS_WRONG_NON_PLANAR, "Sketch plane is not planar"), 501 + ( 502 + WHATS_WRONG_UPSTREAM, 503 + "Depends on a feature that failed to build", 504 + ), 505 + (WHATS_WRONG_BUILD_FAILED, "Geometry failed to build"), 506 + (WHATS_WRONG_REPAIRED, "Reference repaired"), 507 + (WHATS_WRONG_REATTACH, "Reattach"), 456 508 (PROPERTY_PANE_LABEL, "Property Manager"), 457 509 (LEFT_PANE_LABEL, "Left Pane"), 458 510 (LEFT_PANE_TAB_CONFIGURATION, "Configuration Manager"), ··· 595 647 (HOTKEY_LABEL_ZOOM_FIT, "Zoom to Fit"), 596 648 (HOTKEY_LABEL_SHORTCUT_BAR, "Shortcut Bar"), 597 649 (HOTKEY_LABEL_QUIT, "Quit"), 650 + (HOTKEY_LABEL_REBUILD, "Rebuild"), 651 + (HOTKEY_LABEL_FORCE_REBUILD, "Force Rebuild"), 652 + ( 653 + NOTIFY_REATTACH_PICK, 654 + "Pick a face to reattach the dangling reference", 655 + ), 656 + (NOTIFY_REATTACH_NON_PLANAR, "Pick a planar face to reattach"), 598 657 (NOTIFY_COMING_SOON, "Coming soon"), 599 658 ( 600 659 NOTIFY_MIRROR_SELECTION_HINT, ··· 674 733 "Thin feature is not supported yet", 675 734 ), 676 735 (EXTRUDE_PANEL_INTERNAL, "Internal kernel error"), 736 + ( 737 + EXTRUDE_PANEL_DANGLING_REFERENCE, 738 + "Sketch plane references a face that no longer exists", 739 + ), 740 + ( 741 + EXTRUDE_PANEL_NON_PLANAR_TARGET, 742 + "Sketch plane target face is not planar", 743 + ), 744 + ( 745 + EXTRUDE_PANEL_UPSTREAM_UNRESOLVED, 746 + "Depends on a feature that failed to build", 747 + ), 677 748 (PROPERTY_ROW_KIND, "Type"), 678 749 (PROPERTY_ROW_X, "X"), 679 750 (PROPERTY_ROW_Y, "Y"), ··· 820 891 (FEATURE_PLANE_XY, "[!! Front Plàne !!]"), 821 892 (FEATURE_PLANE_YZ, "[!! Rîght Plàne !!]"), 822 893 (FEATURE_PLANE_ZX, "[!! Tôp Plàne !!]"), 894 + (FEATURE_CTX_EDIT_FEATURE, "[!! Édit Featûre !!]"), 895 + (FEATURE_CTX_EDIT_SKETCH, "[!! Édit Skétch !!]"), 896 + (FEATURE_CTX_SUPPRESS, "[!! Suppréss !!]"), 897 + (FEATURE_CTX_UNSUPPRESS, "[!! Unsuppréss !!]"), 898 + (FEATURE_CTX_ROLLBACK, "[!! Rôllback to Hére !!]"), 899 + (FEATURE_CTX_DELETE, "[!! Delête !!]"), 900 + (FEATURE_CTX_RELATIONSHIPS, "[!! Parént/Chîld Relâtions !!]"), 901 + (FEATURE_REL_PARENTS, "[!! Parént Featûres !!]"), 902 + (FEATURE_REL_CHILDREN, "[!! Chîld Featûres !!]"), 903 + (FEATURE_REL_NONE, "[!! Nône !!]"), 904 + (FEATURE_REL_CLOSE, "[!! Clôse !!]"), 905 + (WHATS_WRONG_TITLE, "[!! Whât's Wrông !!]"), 906 + (WHATS_WRONG_NONE, "[!! Nô rebûild errôrs !!]"), 907 + (WHATS_WRONG_DANGLING, "[!! Dânglîng reférence !!]"), 908 + (WHATS_WRONG_NON_PLANAR, "[!! Plâne nôt plânar !!]"), 909 + (WHATS_WRONG_UPSTREAM, "[!! Dêpends ôn fâiled featûre !!]"), 910 + (WHATS_WRONG_BUILD_FAILED, "[!! Geômetry fâiled !!]"), 911 + (WHATS_WRONG_REPAIRED, "[!! Reférence repâired !!]"), 912 + (WHATS_WRONG_REATTACH, "[!! Reattâch !!]"), 823 913 (PROPERTY_PANE_LABEL, "[!! Propérty Mânager !!]"), 824 914 (LEFT_PANE_LABEL, "[!! Léft Pâne !!]"), 825 915 (LEFT_PANE_TAB_CONFIGURATION, "[!! Cônfig Mânager !!]"), ··· 977 1067 (HOTKEY_LABEL_ZOOM_FIT, "[!! Zôom to Fît !!]"), 978 1068 (HOTKEY_LABEL_SHORTCUT_BAR, "[!! Shortcût Bâr !!]"), 979 1069 (HOTKEY_LABEL_QUIT, "[!! Quît !!]"), 1070 + (HOTKEY_LABEL_REBUILD, "[!! Rebûild !!]"), 1071 + (HOTKEY_LABEL_FORCE_REBUILD, "[!! Fôrce Rebûild !!]"), 1072 + (NOTIFY_REATTACH_PICK, "[!! Pîck a fâce to reattâch !!]"), 1073 + (NOTIFY_REATTACH_NON_PLANAR, "[!! Pîck a plânar fâce !!]"), 980 1074 (NOTIFY_COMING_SOON, "[!! Côming sôon !!]"), 981 1075 ( 982 1076 NOTIFY_MIRROR_SELECTION_HINT, ··· 1077 1171 "[!! Thîn fêature is nôt suppôrted yêt !!]", 1078 1172 ), 1079 1173 (EXTRUDE_PANEL_INTERNAL, "[!! Intêrnal kêrnel êrror !!]"), 1174 + ( 1175 + EXTRUDE_PANEL_DANGLING_REFERENCE, 1176 + "[!! Skêtch plâne refêrences a fâce that nô longer exîsts !!]", 1177 + ), 1178 + ( 1179 + EXTRUDE_PANEL_NON_PLANAR_TARGET, 1180 + "[!! Skêtch plâne târget fâce is nôt planâr !!]", 1181 + ), 1182 + ( 1183 + EXTRUDE_PANEL_UPSTREAM_UNRESOLVED, 1184 + "[!! Dêpends ôn a fâiled featûre !!]", 1185 + ), 1080 1186 (PROPERTY_ROW_KIND, "[!! Týpe !!]"), 1081 1187 (PROPERTY_ROW_X, "X"), 1082 1188 (PROPERTY_ROW_Y, "Y"),
crates/bone-app/tests/goldens/rebuild_downstream_iso_256.png

This is a binary file and will not be displayed.

+608 -12
crates/bone-document/src/document/feature_tree.rs
··· 1 + use std::collections::{BTreeMap, BTreeSet}; 2 + 1 3 use bone_kernel::ExtrudeFeature; 2 - use bone_types::{BodyId, ExtrudeId, FeatureId, SketchId}; 4 + use bone_types::{BodyId, ExtrudeId, FeatureGeneration, FeatureId, RollbackMarker, SketchId}; 3 5 use serde::{Deserialize, Serialize}; 4 6 5 - use super::{key_from_index, next_key}; 7 + use super::{gen_key, key_from_index, max_gen}; 6 8 7 9 #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 8 10 pub enum PrincipalPlane { ··· 26 28 sketch: FeatureId, 27 29 extrude: FeatureId, 28 30 }, 31 + FaceToSketch { 32 + face_feature: FeatureId, 33 + sketch: FeatureId, 34 + }, 35 + } 36 + 37 + impl FeatureEdge { 38 + #[must_use] 39 + pub fn endpoints(self) -> (FeatureId, FeatureId) { 40 + match self { 41 + Self::SketchToExtrude { sketch, extrude } => (sketch, extrude), 42 + Self::FaceToSketch { 43 + face_feature, 44 + sketch, 45 + } => (face_feature, sketch), 46 + } 47 + } 29 48 } 30 49 31 50 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] ··· 39 58 #[serde(deny_unknown_fields)] 40 59 pub struct FeatureTree { 41 60 entries: Vec<FeatureEntry>, 61 + #[serde(default)] 62 + id_high_water: FeatureGeneration, 42 63 #[serde(skip)] 43 64 edges: Vec<FeatureEdge>, 44 65 } ··· 52 73 FeatureNode::PrincipalPlane(PrincipalPlane::Yz), 53 74 FeatureNode::PrincipalPlane(PrincipalPlane::Zx), 54 75 ]; 55 - let entries = seeds 76 + let entries: Vec<FeatureEntry> = seeds 56 77 .into_iter() 57 78 .zip(1u32..) 58 79 .map(|(node, idx)| FeatureEntry { ··· 60 81 node, 61 82 }) 62 83 .collect(); 84 + let id_high_water = max_gen(entries.iter().map(|e| e.id)); 63 85 Self { 64 86 entries, 87 + id_high_water, 65 88 edges: Vec::new(), 66 89 } 67 90 } ··· 114 137 id 115 138 }); 116 139 let edge = self.sketch_edge(id, feature); 117 - self.drop_edges_incident(id); 140 + self.drop_sketch_to_extrude(id); 118 141 self.edges.extend(edge); 119 142 self.edges.sort_unstable(); 120 143 id ··· 134 157 }); 135 158 } 136 159 160 + fn position_of(&self, id: FeatureId) -> Option<usize> { 161 + self.entries.iter().position(|entry| entry.id == id) 162 + } 163 + 164 + pub(crate) fn move_before(&mut self, id: FeatureId, anchor: FeatureId) { 165 + if id == anchor { 166 + return; 167 + } 168 + let Some(from) = self.position_of(id) else { 169 + return; 170 + }; 171 + let entry = self.entries.remove(from); 172 + match self.position_of(anchor) { 173 + Some(to) => self.entries.insert(to, entry), 174 + None => self.entries.push(entry), 175 + } 176 + } 177 + 178 + fn place(&mut self, moved: FeatureId, anchor: FeatureId, before: bool) { 179 + if before { 180 + self.move_before(moved, anchor); 181 + } else { 182 + self.move_after(moved, anchor); 183 + } 184 + } 185 + 186 + #[must_use] 187 + pub(crate) fn reorder_is_legal( 188 + &self, 189 + moved: FeatureId, 190 + anchor: FeatureId, 191 + before: bool, 192 + ) -> bool { 193 + if self.is_datum(moved) || self.is_datum(anchor) || moved == anchor { 194 + return false; 195 + } 196 + let mut trial = self.clone(); 197 + trial.place(moved, anchor, before); 198 + trial.order_violation().is_none() 199 + } 200 + 201 + pub(crate) fn try_reorder( 202 + &mut self, 203 + moved: FeatureId, 204 + anchor: FeatureId, 205 + before: bool, 206 + ) -> bool { 207 + if !self.reorder_is_legal(moved, anchor, before) { 208 + return false; 209 + } 210 + self.place(moved, anchor, before); 211 + true 212 + } 213 + 214 + pub(crate) fn move_before_keeping_order(&mut self, id: FeatureId, anchor: FeatureId) { 215 + let last_parent = self 216 + .parents(id) 217 + .into_iter() 218 + .filter_map(|parent| self.position_of(parent).map(|pos| (pos, parent))) 219 + .max(); 220 + match (self.position_of(anchor), last_parent) { 221 + (Some(anchor_at), Some((parent_at, parent))) if parent_at >= anchor_at => { 222 + self.move_after(id, parent); 223 + } 224 + (Some(_), _) => self.move_before(id, anchor), 225 + (None, _) => {} 226 + } 227 + } 228 + 229 + fn move_after(&mut self, id: FeatureId, anchor: FeatureId) { 230 + if id == anchor { 231 + return; 232 + } 233 + let Some(from) = self.position_of(id) else { 234 + return; 235 + }; 236 + let entry = self.entries.remove(from); 237 + match self.position_of(anchor) { 238 + Some(to) => self.entries.insert(to + 1, entry), 239 + None => self.entries.push(entry), 240 + } 241 + } 242 + 243 + fn reorder_after(&mut self, feature: FeatureId, anchor: FeatureId) { 244 + let already_ordered = matches!( 245 + (self.position_of(feature), self.position_of(anchor)), 246 + (Some(child), Some(parent)) if child > parent 247 + ); 248 + if already_ordered { 249 + return; 250 + } 251 + self.move_after(feature, anchor); 252 + self.children(feature) 253 + .into_iter() 254 + .for_each(|child| self.reorder_after(child, feature)); 255 + } 256 + 137 257 #[must_use] 138 258 pub fn edges(&self) -> &[FeatureEdge] { 139 259 &self.edges ··· 142 262 pub(crate) fn rebuild_edges<'a>( 143 263 &mut self, 144 264 extrudes: impl Iterator<Item = (ExtrudeId, &'a ExtrudeFeature)>, 265 + face_bindings: impl Iterator<Item = (FeatureId, FeatureId)>, 145 266 ) { 146 267 let mut rebuilt: Vec<FeatureEdge> = extrudes 147 268 .filter_map(|(extrude, feature)| { ··· 149 270 self.sketch_edge(extrude, feature) 150 271 }) 151 272 .collect(); 273 + rebuilt.extend( 274 + face_bindings.map(|(face_feature, sketch)| FeatureEdge::FaceToSketch { 275 + face_feature, 276 + sketch, 277 + }), 278 + ); 152 279 rebuilt.sort_unstable(); 153 280 self.edges = rebuilt; 154 281 } 155 282 283 + pub(crate) fn bind_face_to_sketch(&mut self, face_feature: FeatureId, sketch: FeatureId) { 284 + self.edges.retain(|edge| { 285 + !matches!(edge, FeatureEdge::FaceToSketch { sketch: target, .. } if *target == sketch) 286 + }); 287 + self.edges.push(FeatureEdge::FaceToSketch { 288 + face_feature, 289 + sketch, 290 + }); 291 + self.edges.sort_unstable(); 292 + self.reorder_after(sketch, face_feature); 293 + } 294 + 156 295 fn sketch_edge(&self, extrude: FeatureId, feature: &ExtrudeFeature) -> Option<FeatureEdge> { 157 296 let sketch = self.feature_of_sketch(feature.sketch)?; 158 297 Some(FeatureEdge::SketchToExtrude { sketch, extrude }) 159 298 } 160 299 161 300 fn drop_edges_incident(&mut self, feature: FeatureId) { 162 - self.edges.retain(|edge| match edge { 163 - FeatureEdge::SketchToExtrude { sketch, extrude } => { 164 - *sketch != feature && *extrude != feature 165 - } 301 + self.edges.retain(|edge| { 302 + let (parent, child) = edge.endpoints(); 303 + parent != feature && child != feature 304 + }); 305 + } 306 + 307 + fn drop_sketch_to_extrude(&mut self, extrude: FeatureId) { 308 + self.edges.retain(|edge| { 309 + !matches!(edge, FeatureEdge::SketchToExtrude { extrude: target, .. } if *target == extrude) 166 310 }); 167 311 } 168 312 ··· 173 317 .map(|e| e.id) 174 318 } 175 319 176 - pub(crate) fn allocate(&self) -> FeatureId { 177 - next_key(self.entries.iter().map(|e| e.id)) 320 + pub(crate) fn allocate(&mut self) -> FeatureId { 321 + let next = self 322 + .id_high_water 323 + .raised_to(max_gen(self.entries.iter().map(|e| e.id))) 324 + .succ(); 325 + self.id_high_water = next; 326 + gen_key(next) 327 + } 328 + 329 + pub(crate) fn raise_high_water(&mut self) { 330 + self.id_high_water = self 331 + .id_high_water 332 + .raised_to(max_gen(self.entries.iter().map(|e| e.id))); 333 + } 334 + 335 + #[must_use] 336 + pub fn children(&self, id: FeatureId) -> Vec<FeatureId> { 337 + self.edges 338 + .iter() 339 + .map(|edge| edge.endpoints()) 340 + .filter(|(parent, _)| *parent == id) 341 + .map(|(_, child)| child) 342 + .collect::<BTreeSet<_>>() 343 + .into_iter() 344 + .collect() 345 + } 346 + 347 + #[must_use] 348 + pub fn parents(&self, id: FeatureId) -> Vec<FeatureId> { 349 + self.edges 350 + .iter() 351 + .map(|edge| edge.endpoints()) 352 + .filter(|(_, child)| *child == id) 353 + .map(|(parent, _)| parent) 354 + .collect::<BTreeSet<_>>() 355 + .into_iter() 356 + .collect() 357 + } 358 + 359 + #[must_use] 360 + pub fn descendants(&self, id: FeatureId) -> Vec<FeatureId> { 361 + reachable(id, &self.adjacency(Direction::Forward), BTreeSet::new()) 362 + .into_iter() 363 + .collect() 364 + } 365 + 366 + #[must_use] 367 + pub fn ancestors(&self, id: FeatureId) -> Vec<FeatureId> { 368 + reachable(id, &self.adjacency(Direction::Backward), BTreeSet::new()) 369 + .into_iter() 370 + .collect() 371 + } 372 + 373 + #[must_use] 374 + pub fn rolled_back(&self, marker: RollbackMarker) -> BTreeSet<FeatureId> { 375 + let Some(target) = marker.feature() else { 376 + return BTreeSet::new(); 377 + }; 378 + self.position_of(target) 379 + .map(|start| self.entries[start..].iter().map(|entry| entry.id).collect()) 380 + .unwrap_or_default() 381 + } 382 + 383 + #[must_use] 384 + pub(crate) fn rollable_order(&self) -> Vec<FeatureId> { 385 + self.entries 386 + .iter() 387 + .filter(|entry| { 388 + !matches!( 389 + entry.node, 390 + FeatureNode::Origin | FeatureNode::PrincipalPlane(_) 391 + ) 392 + }) 393 + .map(|entry| entry.id) 394 + .collect() 395 + } 396 + 397 + #[must_use] 398 + pub fn is_datum(&self, id: FeatureId) -> bool { 399 + matches!( 400 + self.node(id), 401 + Some(FeatureNode::Origin | FeatureNode::PrincipalPlane(_)) 402 + ) 403 + } 404 + 405 + #[must_use] 406 + pub(crate) fn order_violation(&self) -> Option<FeatureEdge> { 407 + self.edges.iter().copied().find(|edge| { 408 + let (parent, child) = edge.endpoints(); 409 + matches!( 410 + (self.position_of(parent), self.position_of(child)), 411 + (Some(parent_at), Some(child_at)) if parent_at >= child_at 412 + ) 413 + }) 414 + } 415 + 416 + #[must_use] 417 + pub(crate) fn find_cycle(&self) -> Option<FeatureId> { 418 + let adjacency = self.adjacency(Direction::Forward); 419 + self.entries 420 + .iter() 421 + .fold((BTreeSet::new(), None), |(done, found), entry| { 422 + if found.is_some() || done.contains(&entry.id) { 423 + (done, found) 424 + } else { 425 + detect_cycle(entry.id, &adjacency, done, BTreeSet::new()) 426 + } 427 + }) 428 + .1 429 + } 430 + 431 + #[must_use] 432 + pub fn topo_order(&self) -> Vec<FeatureId> { 433 + let adjacency = self.adjacency(Direction::Forward); 434 + let (_, postorder) = self 435 + .entries 436 + .iter() 437 + .fold((BTreeSet::new(), Vec::new()), |(visited, order), entry| { 438 + visit_post(entry.id, &adjacency, visited, order) 439 + }); 440 + postorder.into_iter().rev().collect() 441 + } 442 + 443 + fn adjacency(&self, direction: Direction) -> BTreeMap<FeatureId, BTreeSet<FeatureId>> { 444 + self.edges.iter().map(|edge| edge.endpoints()).fold( 445 + BTreeMap::new(), 446 + |mut acc, (parent, child)| { 447 + let (from, to) = match direction { 448 + Direction::Forward => (parent, child), 449 + Direction::Backward => (child, parent), 450 + }; 451 + acc.entry(from).or_default().insert(to); 452 + acc 453 + }, 454 + ) 178 455 } 179 456 } 180 457 458 + #[derive(Copy, Clone)] 459 + enum Direction { 460 + Forward, 461 + Backward, 462 + } 463 + 464 + fn reachable( 465 + start: FeatureId, 466 + adjacency: &BTreeMap<FeatureId, BTreeSet<FeatureId>>, 467 + reached: BTreeSet<FeatureId>, 468 + ) -> BTreeSet<FeatureId> { 469 + adjacency 470 + .get(&start) 471 + .into_iter() 472 + .flatten() 473 + .fold(reached, |reached, &next| { 474 + if reached.contains(&next) { 475 + reached 476 + } else { 477 + reachable(next, adjacency, insert(reached, next)) 478 + } 479 + }) 480 + } 481 + 482 + fn detect_cycle( 483 + node: FeatureId, 484 + adjacency: &BTreeMap<FeatureId, BTreeSet<FeatureId>>, 485 + done: BTreeSet<FeatureId>, 486 + on_path: BTreeSet<FeatureId>, 487 + ) -> (BTreeSet<FeatureId>, Option<FeatureId>) { 488 + let on_path = insert(on_path, node); 489 + let (done, found) = 490 + adjacency 491 + .get(&node) 492 + .into_iter() 493 + .flatten() 494 + .fold((done, None), |(done, found), &next| { 495 + if found.is_some() { 496 + (done, found) 497 + } else if on_path.contains(&next) { 498 + (done, Some(next)) 499 + } else if done.contains(&next) { 500 + (done, None) 501 + } else { 502 + detect_cycle(next, adjacency, done, on_path.clone()) 503 + } 504 + }); 505 + (insert(done, node), found) 506 + } 507 + 508 + fn visit_post( 509 + node: FeatureId, 510 + adjacency: &BTreeMap<FeatureId, BTreeSet<FeatureId>>, 511 + visited: BTreeSet<FeatureId>, 512 + order: Vec<FeatureId>, 513 + ) -> (BTreeSet<FeatureId>, Vec<FeatureId>) { 514 + if visited.contains(&node) { 515 + return (visited, order); 516 + } 517 + let (visited, mut order) = adjacency.get(&node).into_iter().flatten().fold( 518 + (insert(visited, node), order), 519 + |(visited, order), &child| visit_post(child, adjacency, visited, order), 520 + ); 521 + order.push(node); 522 + (visited, order) 523 + } 524 + 525 + fn insert(set: BTreeSet<FeatureId>, value: FeatureId) -> BTreeSet<FeatureId> { 526 + let mut next = set; 527 + next.insert(value); 528 + next 529 + } 530 + 181 531 #[cfg(test)] 182 532 pub(crate) fn sample_blind_extrude(sketch: SketchId) -> ExtrudeFeature { 183 533 use bone_kernel::{ExtrudeDirection, ExtrudeEndCondition, ExtrudeSense, MergeResult}; ··· 199 549 200 550 #[cfg(test)] 201 551 mod tests { 202 - use super::{FeatureEdge, FeatureNode, FeatureTree, sample_blind_extrude}; 552 + use std::collections::BTreeSet; 553 + 554 + use proptest::prelude::*; 555 + 556 + use super::{FeatureEdge, FeatureId, FeatureNode, FeatureTree, sample_blind_extrude}; 203 557 use bone_types::{ExtrudeId, SketchId}; 204 558 205 559 #[test] ··· 279 633 } 280 634 281 635 #[test] 636 + fn push_extrude_preserves_face_bound_child_edge() { 637 + let mut tree = FeatureTree::seeded(); 638 + let sketch = SketchId::default(); 639 + tree.push_sketch(sketch); 640 + let extrude = ExtrudeId::default(); 641 + let ext_feat = tree.push_extrude(extrude, &sample_blind_extrude(sketch)); 642 + let downstream = tree.push_sketch(sketch_key(2)); 643 + let face_edge = FeatureEdge::FaceToSketch { 644 + face_feature: ext_feat, 645 + sketch: downstream, 646 + }; 647 + tree.edges.push(face_edge); 648 + tree.edges.sort_unstable(); 649 + 650 + tree.push_extrude(extrude, &sample_blind_extrude(sketch)); 651 + 652 + assert!( 653 + tree.edges().contains(&face_edge), 654 + "re-pushing an extrude must not erase a downstream face-bound dependency" 655 + ); 656 + assert_eq!( 657 + tree.edges() 658 + .iter() 659 + .filter(|e| matches!(e, FeatureEdge::SketchToExtrude { extrude, .. } if *extrude == ext_feat)) 660 + .count(), 661 + 1, 662 + "the sketch-to-extrude edge stays single after a re-push" 663 + ); 664 + } 665 + 666 + #[test] 282 667 fn remove_extrude_drops_node_and_edge() { 283 668 let mut tree = FeatureTree::seeded(); 284 669 let sketch = SketchId::default(); ··· 332 717 (low, sample_blind_extrude(sketch)), 333 718 (high, sample_blind_extrude(sketch)), 334 719 ]); 335 - rebuilt.rebuild_edges(extrudes.iter().map(|(id, feature)| (*id, feature))); 720 + rebuilt.rebuild_edges( 721 + extrudes.iter().map(|(id, feature)| (*id, feature)), 722 + std::iter::empty(), 723 + ); 336 724 337 725 assert_eq!( 338 726 pushed.edges(), ··· 340 728 "live edge order must match the load rebuild irrespective of push order" 341 729 ); 342 730 assert_eq!(pushed.edges().len(), 2); 731 + } 732 + 733 + #[test] 734 + fn allocate_does_not_reuse_removed_top_id() { 735 + let mut tree = FeatureTree::seeded(); 736 + let first = tree.push_sketch(sketch_key(1)); 737 + tree.remove_sketch(sketch_key(1)); 738 + let second = tree.push_sketch(sketch_key(2)); 739 + assert_ne!( 740 + first, second, 741 + "a removed top id must never be handed out again" 742 + ); 743 + } 744 + 745 + #[test] 746 + fn endpoints_orient_parent_before_child() { 747 + let parent = super::super::key_from_index::<FeatureId>(7); 748 + let child = super::super::key_from_index::<FeatureId>(9); 749 + assert_eq!( 750 + FeatureEdge::SketchToExtrude { 751 + sketch: parent, 752 + extrude: child, 753 + } 754 + .endpoints(), 755 + (parent, child) 756 + ); 757 + assert_eq!( 758 + FeatureEdge::FaceToSketch { 759 + face_feature: parent, 760 + sketch: child, 761 + } 762 + .endpoints(), 763 + (parent, child) 764 + ); 765 + } 766 + 767 + fn sketch_extrude_pair( 768 + tree: &mut FeatureTree, 769 + sketch: SketchId, 770 + extrude: ExtrudeId, 771 + ) -> (FeatureId, FeatureId) { 772 + let sketch_feature = tree.push_sketch(sketch); 773 + let extrude_feature = tree.push_extrude(extrude, &sample_blind_extrude(sketch)); 774 + (sketch_feature, extrude_feature) 775 + } 776 + 777 + #[test] 778 + fn children_and_parents_read_the_edges() { 779 + let mut tree = FeatureTree::seeded(); 780 + let (sketch_feature, extrude_feature) = 781 + sketch_extrude_pair(&mut tree, sketch_key(1), extrude_key(1)); 782 + assert_eq!(tree.children(sketch_feature), vec![extrude_feature]); 783 + assert_eq!(tree.parents(extrude_feature), vec![sketch_feature]); 784 + assert!(tree.children(extrude_feature).is_empty()); 785 + assert!(tree.parents(sketch_feature).is_empty()); 786 + } 787 + 788 + #[test] 789 + fn descendants_and_ancestors_follow_the_chain() { 790 + let mut tree = FeatureTree::seeded(); 791 + let (sketch_feature, extrude_feature) = 792 + sketch_extrude_pair(&mut tree, sketch_key(1), extrude_key(1)); 793 + assert_eq!(tree.descendants(sketch_feature), vec![extrude_feature]); 794 + assert_eq!(tree.ancestors(extrude_feature), vec![sketch_feature]); 795 + assert!(tree.descendants(extrude_feature).is_empty()); 796 + assert!(tree.ancestors(sketch_feature).is_empty()); 797 + } 798 + 799 + #[test] 800 + fn topo_order_lists_every_node_parents_first() { 801 + let mut tree = FeatureTree::seeded(); 802 + let (sketch_feature, extrude_feature) = 803 + sketch_extrude_pair(&mut tree, sketch_key(1), extrude_key(1)); 804 + let order = tree.topo_order(); 805 + let all: BTreeSet<FeatureId> = tree.iter().map(|(id, _)| id).collect(); 806 + assert_eq!( 807 + order.iter().copied().collect::<BTreeSet<_>>(), 808 + all, 809 + "topo order must visit every node exactly once" 810 + ); 811 + let pos = |id| order.iter().position(|other| *other == id); 812 + assert!( 813 + pos(sketch_feature) < pos(extrude_feature), 814 + "a parent must precede its child" 815 + ); 816 + } 817 + 818 + #[test] 819 + fn order_violation_flags_a_child_before_its_parent() { 820 + let mut tree = FeatureTree::seeded(); 821 + let sketch = SketchId::default(); 822 + tree.push_sketch(sketch); 823 + let extrude = ExtrudeId::default(); 824 + tree.push_extrude(extrude, &sample_blind_extrude(sketch)); 825 + assert!( 826 + tree.order_violation().is_none(), 827 + "a freshly built sketch-then-extrude chain is in dependency order", 828 + ); 829 + 830 + let Some(sketch_feature) = tree.feature_of_sketch(sketch) else { 831 + panic!("the sketch resolves to a feature node"); 832 + }; 833 + let Some(extrude_feature) = tree.feature_of_extrude(extrude) else { 834 + panic!("the extrude resolves to a feature node"); 835 + }; 836 + tree.move_before(extrude_feature, sketch_feature); 837 + 838 + assert_eq!( 839 + tree.order_violation().map(FeatureEdge::endpoints), 840 + Some((sketch_feature, extrude_feature)), 841 + "moving the extrude above its sketch is reported as an order violation", 842 + ); 843 + } 844 + 845 + #[test] 846 + fn binding_a_face_reorders_the_sketch_subtree_after_its_target() { 847 + let mut tree = FeatureTree::seeded(); 848 + let early_sketch = tree.push_sketch(sketch_key(1)); 849 + let early_extrude = tree.push_extrude(extrude_key(1), &sample_blind_extrude(sketch_key(1))); 850 + tree.push_sketch(sketch_key(2)); 851 + let body = tree.push_extrude(extrude_key(2), &sample_blind_extrude(sketch_key(2))); 852 + 853 + tree.bind_face_to_sketch(body, early_sketch); 854 + 855 + assert!( 856 + tree.order_violation().is_none(), 857 + "binding an earlier sketch onto a later body drags its subtree past the target", 858 + ); 859 + let order = tree.rollable_order(); 860 + let position = |needle: FeatureId| order.iter().position(|f| *f == needle); 861 + assert!( 862 + position(body) < position(early_sketch), 863 + "the bound sketch now follows the body it sits on", 864 + ); 865 + assert!( 866 + position(early_sketch) < position(early_extrude), 867 + "the bound sketch still precedes its own extrude", 868 + ); 869 + } 870 + 871 + #[test] 872 + fn find_cycle_flags_a_back_edge() { 873 + let mut tree = FeatureTree::seeded(); 874 + let a = tree.push_sketch(sketch_key(1)); 875 + let b = tree.push_sketch(sketch_key(2)); 876 + assert_eq!(tree.find_cycle(), None); 877 + 878 + tree.edges = vec![ 879 + FeatureEdge::SketchToExtrude { 880 + sketch: a, 881 + extrude: b, 882 + }, 883 + FeatureEdge::SketchToExtrude { 884 + sketch: b, 885 + extrude: a, 886 + }, 887 + ]; 888 + tree.edges.sort_unstable(); 889 + 890 + assert!( 891 + tree.find_cycle().is_some(), 892 + "a back edge in the dependency graph must be reported" 893 + ); 894 + } 895 + 896 + #[derive(Clone, Debug)] 897 + enum Op { 898 + Add, 899 + Remove(usize), 900 + } 901 + 902 + fn op_strategy() -> impl Strategy<Value = Op> { 903 + prop_oneof![ 904 + 2 => Just(Op::Add), 905 + 1 => (0usize..1024).prop_map(Op::Remove), 906 + ] 907 + } 908 + 909 + proptest! { 910 + #[test] 911 + fn feature_ids_never_reused(ops in proptest::collection::vec(op_strategy(), 0..64)) { 912 + let tree = FeatureTree::seeded(); 913 + let seeds: Vec<FeatureId> = tree.iter().map(|(id, _)| id).collect(); 914 + let (_, _, _, handed_out) = ops.into_iter().fold( 915 + (tree, Vec::<SketchId>::new(), 0u64, seeds), 916 + |(mut tree, mut live, counter, mut handed_out), op| match op { 917 + Op::Add => { 918 + let sketch = sketch_key(counter + 1); 919 + handed_out.push(tree.push_sketch(sketch)); 920 + live.push(sketch); 921 + (tree, live, counter + 1, handed_out) 922 + } 923 + Op::Remove(index) => { 924 + if !live.is_empty() { 925 + let removed = live.remove(index % live.len()); 926 + tree.remove_sketch(removed); 927 + } 928 + (tree, live, counter, handed_out) 929 + } 930 + }, 931 + ); 932 + let unique: BTreeSet<FeatureId> = handed_out.iter().copied().collect(); 933 + prop_assert_eq!( 934 + unique.len(), 935 + handed_out.len(), 936 + "a feature id held by a reference or snapshot was reused after a delete" 937 + ); 938 + } 343 939 } 344 940 }
+854 -19
crates/bone-document/src/document/mod.rs
··· 1 - use std::collections::BTreeMap; 1 + use std::collections::{BTreeMap, BTreeSet}; 2 + use std::sync::Arc; 2 3 3 4 use bone_kernel::{BrepEdge, BrepFace, BrepSolid, BrepVertex, ExtrudeFeature}; 4 - use bone_types::{BodyId, DocumentId, ExtrudeId, FeatureId, SchemaHeader, SketchId}; 5 + use bone_types::{ 6 + BodyId, DocumentId, ExtrudeId, FaceRef, FeatureGeneration, FeatureId, RollbackMarker, 7 + SchemaHeader, SketchId, SuppressionState, 8 + }; 5 9 use serde::{Deserialize, Serialize}; 6 10 use slotmap::KeyData; 7 11 ··· 13 17 14 18 #[derive(Clone)] 15 19 pub struct ImportedSolid { 16 - solid: BrepSolid, 20 + solid: Arc<BrepSolid>, 17 21 } 18 22 19 23 impl ImportedSolid { 20 24 #[must_use] 21 25 pub fn new(solid: BrepSolid) -> Self { 22 - Self { solid } 26 + Self { 27 + solid: Arc::new(solid), 28 + } 23 29 } 24 30 25 31 #[must_use] 26 32 pub fn solid(&self) -> &BrepSolid { 27 - &self.solid 33 + self.solid.as_ref() 28 34 } 29 35 } 30 36 ··· 140 146 EmptyLabel, 141 147 } 142 148 149 + #[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)] 150 + pub enum BindSketchToFaceError { 151 + #[error("sketch {0:?} not found")] 152 + UnknownSketch(SketchId), 153 + #[error("binding sketch onto feature {0:?} would make the feature graph cyclic")] 154 + WouldCycle(FeatureId), 155 + } 156 + 143 157 #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] 144 158 #[serde(deny_unknown_fields)] 145 159 pub struct DocumentParameters { ··· 186 200 pub parameters: DocumentParameters, 187 201 pub feature_tree: FeatureTree, 188 202 pub sketches: SketchRegistry, 203 + #[serde(default)] 204 + pub sketch_planes: BTreeMap<SketchId, FaceRef>, 205 + #[serde(default)] 206 + pub sketch_high_water: FeatureGeneration, 207 + #[serde(default)] 208 + pub extrude_high_water: FeatureGeneration, 209 + #[serde(default)] 210 + pub body_high_water: FeatureGeneration, 211 + #[serde(default)] 212 + pub rollback: RollbackMarker, 213 + #[serde(default)] 214 + pub suppressed: BTreeSet<FeatureId>, 189 215 #[serde(skip)] 190 216 pub extrudes: BTreeMap<ExtrudeId, ExtrudeFeature>, 191 217 #[serde(skip)] ··· 203 229 parameters: DocumentParameters::new(), 204 230 feature_tree: FeatureTree::seeded(), 205 231 sketches: SketchRegistry::new(), 232 + sketch_planes: BTreeMap::new(), 233 + sketch_high_water: FeatureGeneration::START, 234 + extrude_high_water: FeatureGeneration::START, 235 + body_high_water: FeatureGeneration::START, 236 + rollback: RollbackMarker::AtEnd, 237 + suppressed: BTreeSet::new(), 206 238 extrudes: BTreeMap::new(), 207 239 extrude_labels: BTreeMap::new(), 208 240 } 209 241 } 210 242 211 243 pub(crate) fn rebuild_edges(&mut self) { 212 - self.feature_tree 213 - .rebuild_edges(self.extrudes.iter().map(|(id, feature)| (*id, feature))); 244 + let bindings: Vec<(FeatureId, FeatureId)> = self 245 + .sketch_planes 246 + .iter() 247 + .filter_map(|(sketch_id, face)| { 248 + let sketch = self.feature_tree.feature_of_sketch(*sketch_id)?; 249 + Some((face.label.feature, sketch)) 250 + }) 251 + .collect(); 252 + self.feature_tree.rebuild_edges( 253 + self.extrudes.iter().map(|(id, feature)| (*id, feature)), 254 + bindings.into_iter(), 255 + ); 256 + } 257 + 258 + pub(crate) fn retire_feature(&mut self, feature: FeatureId) { 259 + self.suppressed.remove(&feature); 260 + if self.rollback.feature() == Some(feature) { 261 + let order = self.feature_tree.rollable_order(); 262 + let successor = order 263 + .iter() 264 + .position(|candidate| *candidate == feature) 265 + .and_then(|at| order.get(at + 1)) 266 + .copied(); 267 + self.rollback = successor.map_or(RollbackMarker::AtEnd, RollbackMarker::Above); 268 + } 269 + } 270 + 271 + pub(crate) fn normalize_high_water(&mut self) { 272 + self.feature_tree.raise_high_water(); 273 + self.sketch_high_water = self 274 + .sketch_high_water 275 + .raised_to(max_gen(self.sketches.order().iter().copied())); 276 + self.extrude_high_water = self 277 + .extrude_high_water 278 + .raised_to(max_gen(self.extrudes.keys().copied())); 279 + } 280 + 281 + fn allocate_sketch(&mut self) -> SketchId { 282 + let next = self 283 + .sketch_high_water 284 + .raised_to(max_gen(self.sketches.order().iter().copied())) 285 + .succ(); 286 + self.sketch_high_water = next; 287 + gen_key(next) 288 + } 289 + 290 + fn allocate_extrude(&mut self) -> ExtrudeId { 291 + let next = self 292 + .extrude_high_water 293 + .raised_to(max_gen(self.extrudes.keys().copied())) 294 + .succ(); 295 + self.extrude_high_water = next; 296 + gen_key(next) 214 297 } 215 298 } 216 299 ··· 272 355 sketches: BTreeMap<SketchId, Sketch>, 273 356 bodies: BTreeMap<BodyId, ImportedSolid>, 274 357 ) -> Self { 358 + header.normalize_high_water(); 359 + header.body_high_water = header 360 + .body_high_water 361 + .raised_to(max_gen(bodies.keys().copied())); 275 362 header.rebuild_edges(); 276 363 Self { 277 364 header, ··· 280 367 } 281 368 } 282 369 370 + fn allocate_body(&mut self) -> BodyId { 371 + let next = self 372 + .header 373 + .body_high_water 374 + .raised_to(max_gen(self.bodies.keys().copied())) 375 + .succ(); 376 + self.header.body_high_water = next; 377 + gen_key(next) 378 + } 379 + 283 380 #[must_use] 284 381 pub fn header(&self) -> &DocumentHeader { 285 382 &self.header ··· 360 457 self.header.units = units; 361 458 } 362 459 460 + #[must_use] 461 + pub fn allocate_sketch(&mut self) -> SketchId { 462 + self.header.allocate_sketch() 463 + } 464 + 363 465 pub fn insert_sketch(&mut self, id: SketchId, label: String, sketch: Sketch) { 364 466 let filename = sketch_filename(id); 365 467 self.header 366 468 .sketches 367 469 .insert(id, SketchRegistryEntry { label, filename }); 368 - self.header.feature_tree.push_sketch(id); 470 + self.header.sketch_high_water = self.header.sketch_high_water.raised_to(key_gen(id)); 471 + let existed = self.header.feature_tree.feature_of_sketch(id).is_some(); 472 + let placed = self.header.feature_tree.push_sketch(id); 473 + if !existed { 474 + self.place_at_rollback_bar(placed); 475 + } 369 476 self.sketches.insert(id, sketch); 370 477 let dependents: Vec<ExtrudeId> = self.extrudes_of_sketch(id).collect(); 371 478 dependents.iter().for_each(|extrude| { ··· 379 486 self.sketches.insert(id, sketch) 380 487 } 381 488 489 + pub fn bind_sketch_to_face( 490 + &mut self, 491 + sketch: SketchId, 492 + face: FaceRef, 493 + ) -> Result<(), BindSketchToFaceError> { 494 + let sketch_feature = self 495 + .header 496 + .feature_tree 497 + .feature_of_sketch(sketch) 498 + .ok_or(BindSketchToFaceError::UnknownSketch(sketch))?; 499 + let target = face.label.feature; 500 + let cyclic = target == sketch_feature 501 + || self 502 + .header 503 + .feature_tree 504 + .descendants(sketch_feature) 505 + .contains(&target); 506 + if cyclic { 507 + return Err(BindSketchToFaceError::WouldCycle(target)); 508 + } 509 + self.header.sketch_planes.insert(sketch, face); 510 + self.header 511 + .feature_tree 512 + .bind_face_to_sketch(target, sketch_feature); 513 + Ok(()) 514 + } 515 + 516 + #[must_use] 517 + pub fn sketch_plane_binding(&self, sketch: SketchId) -> Option<FaceRef> { 518 + self.header.sketch_planes.get(&sketch).copied() 519 + } 520 + 521 + #[must_use] 522 + pub fn reorder_is_legal(&self, moved: FeatureId, anchor: FeatureId, before: bool) -> bool { 523 + self.header 524 + .feature_tree 525 + .reorder_is_legal(moved, anchor, before) 526 + } 527 + 528 + pub fn reorder_feature(&mut self, moved: FeatureId, anchor: FeatureId, before: bool) -> bool { 529 + self.header.feature_tree.try_reorder(moved, anchor, before) 530 + } 531 + 532 + #[must_use] 533 + pub fn rollback(&self) -> RollbackMarker { 534 + self.header.rollback 535 + } 536 + 537 + pub fn roll_to_here(&mut self, feature: FeatureId) { 538 + if self.header.feature_tree.rollable_order().contains(&feature) { 539 + self.header.rollback = RollbackMarker::Above(feature); 540 + } 541 + } 542 + 543 + pub fn roll_to_end(&mut self) { 544 + self.header.rollback = RollbackMarker::AtEnd; 545 + } 546 + 547 + pub fn roll_to_previous(&mut self) { 548 + let order = self.header.feature_tree.rollable_order(); 549 + if order.is_empty() { 550 + return; 551 + } 552 + let position = match self.header.rollback { 553 + RollbackMarker::AtEnd => order.len(), 554 + RollbackMarker::Above(feature) => order 555 + .iter() 556 + .position(|candidate| *candidate == feature) 557 + .unwrap_or(order.len()), 558 + }; 559 + self.header.rollback = RollbackMarker::Above(order[position.saturating_sub(1)]); 560 + } 561 + 562 + #[must_use] 563 + pub fn is_rolled_back(&self, feature: FeatureId) -> bool { 564 + self.header 565 + .feature_tree 566 + .rolled_back(self.header.rollback) 567 + .contains(&feature) 568 + } 569 + 570 + fn place_at_rollback_bar(&mut self, feature: FeatureId) { 571 + if let Some(anchor) = self.header.rollback.feature() { 572 + self.header 573 + .feature_tree 574 + .move_before_keeping_order(feature, anchor); 575 + } 576 + } 577 + 578 + #[must_use] 579 + pub fn suppressed(&self) -> &BTreeSet<FeatureId> { 580 + &self.header.suppressed 581 + } 582 + 583 + #[must_use] 584 + pub fn suppression_state(&self, feature: FeatureId) -> SuppressionState { 585 + let effective = self.header.suppressed.contains(&feature) 586 + || self 587 + .header 588 + .feature_tree 589 + .ancestors(feature) 590 + .iter() 591 + .any(|ancestor| self.header.suppressed.contains(ancestor)); 592 + if effective { 593 + SuppressionState::Suppressed 594 + } else { 595 + SuppressionState::Active 596 + } 597 + } 598 + 599 + pub fn suppress(&mut self, feature: FeatureId) { 600 + if self.header.feature_tree.is_datum(feature) { 601 + return; 602 + } 603 + let cascade = self.header.feature_tree.descendants(feature); 604 + self.header.suppressed.insert(feature); 605 + self.header.suppressed.extend(cascade); 606 + } 607 + 608 + pub fn unsuppress(&mut self, feature: FeatureId) { 609 + let cleared = self 610 + .header 611 + .feature_tree 612 + .ancestors(feature) 613 + .into_iter() 614 + .chain(self.header.feature_tree.descendants(feature)) 615 + .chain([feature]); 616 + cleared.for_each(|target| { 617 + self.header.suppressed.remove(&target); 618 + }); 619 + } 620 + 382 621 pub fn insert_extrude(&mut self, id: ExtrudeId, feature: ExtrudeFeature) { 383 - self.header.feature_tree.push_extrude(id, &feature); 622 + self.header.extrude_high_water = self.header.extrude_high_water.raised_to(key_gen(id)); 623 + let existed = self.header.feature_tree.feature_of_extrude(id).is_some(); 624 + let placed = self.header.feature_tree.push_extrude(id, &feature); 625 + if !existed { 626 + self.place_at_rollback_bar(placed); 627 + } 384 628 self.header.extrudes.insert(id, feature); 385 629 self.header 386 630 .extrude_labels ··· 390 634 391 635 #[must_use] 392 636 pub fn commit_extrude(&mut self, feature: ExtrudeFeature) -> ExtrudeId { 393 - let id = next_key(self.header.extrudes.keys().copied()); 637 + let id = self.header.allocate_extrude(); 394 638 self.insert_extrude(id, feature); 395 639 id 396 640 } 397 641 398 642 pub fn remove_extrude(&mut self, id: ExtrudeId) -> Option<ExtrudeFeature> { 399 - self.header.feature_tree.remove_extrude(id); 643 + if let Some(feature) = self.header.feature_tree.feature_of_extrude(id) { 644 + self.header.retire_feature(feature); 645 + self.header.feature_tree.remove_extrude(id); 646 + } 400 647 self.header.extrude_labels.remove(&id); 401 648 self.header.extrudes.remove(&id) 402 649 } ··· 439 686 self.remove_extrude(*extrude); 440 687 }); 441 688 self.header.sketches.remove(id); 442 - self.header.feature_tree.remove_sketch(id); 689 + self.header.sketch_planes.remove(&id); 690 + if let Some(feature) = self.header.feature_tree.feature_of_sketch(id) { 691 + self.header.retire_feature(feature); 692 + self.header.feature_tree.remove_sketch(id); 693 + } 443 694 self.sketches.remove(&id) 444 695 } 445 696 ··· 474 725 build: impl FnOnce(FeatureId) -> Result<BrepSolid, E>, 475 726 ) -> Result<(FeatureId, BodyId), E> { 476 727 let feature = self.header.feature_tree.allocate(); 477 - let body = next_key(self.bodies.keys().copied()); 728 + let body = self.allocate_body(); 478 729 let solid = build(feature)?; 479 730 self.header.feature_tree.push_imported_body(feature, body); 731 + self.place_at_rollback_bar(feature); 480 732 self.bodies.insert(body, ImportedSolid::new(solid)); 481 733 Ok((feature, body)) 482 734 } ··· 513 765 K::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 514 766 } 515 767 516 - pub(crate) fn next_key<K: slotmap::Key>(existing: impl Iterator<Item = K>) -> K { 517 - let highest = existing.map(key_index).max().unwrap_or(0); 518 - let Some(next) = highest.checked_add(1) else { 768 + pub(crate) fn key_gen<K: slotmap::Key>(id: K) -> FeatureGeneration { 769 + if id.is_null() { 770 + FeatureGeneration::START 771 + } else { 772 + FeatureGeneration::new(u64::from(key_index(id))) 773 + } 774 + } 775 + 776 + pub(crate) fn max_gen<K: slotmap::Key>(ids: impl Iterator<Item = K>) -> FeatureGeneration { 777 + ids.map(key_gen).max().unwrap_or(FeatureGeneration::START) 778 + } 779 + 780 + pub(crate) fn gen_key<K: slotmap::Key>(mark: FeatureGeneration) -> K { 781 + let Ok(idx) = u32::try_from(mark.value()) else { 519 782 panic!( 520 783 "exhausted 32-bit key space for {}", 521 784 core::any::type_name::<K>() 522 785 ); 523 786 }; 524 - key_from_index(next) 787 + key_from_index(idx) 525 788 } 526 789 527 790 fn id_stem<K: slotmap::Key>(id: K) -> String { ··· 551 814 #[cfg(test)] 552 815 mod tests { 553 816 use super::feature_tree::sample_blind_extrude; 554 - use super::{Document, RenameExtrudeError, RenameSketchError, Sketch, SketchId}; 555 - use bone_types::{DocumentId, ExtrudeId, Point3, SketchPlaneBasis, Tolerance, UnitVec3}; 817 + use super::{ 818 + BindSketchToFaceError, Document, DocumentHeader, FeatureEdge, RenameExtrudeError, 819 + RenameSketchError, Sketch, SketchId, 820 + }; 821 + use bone_types::{ 822 + DocumentId, ExtrudeId, FaceFingerprint, FaceLabel, FaceRef, FaceRole, FeatureId, Plane3, 823 + Point3, RollbackMarker, SketchPlaneBasis, SuppressionState, Tolerance, UnitVec3, 824 + }; 556 825 557 826 fn xy_basis() -> SketchPlaneBasis { 558 827 let Ok(basis) = SketchPlaneBasis::new( ··· 574 843 } 575 844 576 845 #[test] 846 + fn reorder_rejects_moving_extrude_before_its_source_sketch() { 847 + let (mut document, sketch) = doc_with_sketch(); 848 + let extrude = document.commit_extrude(sample_blind_extrude(sketch)); 849 + let tree = document.feature_tree(); 850 + let (Some(sketch_feature), Some(extrude_feature)) = ( 851 + tree.feature_of_sketch(sketch), 852 + tree.feature_of_extrude(extrude), 853 + ) else { 854 + panic!("the chain resolves to feature ids"); 855 + }; 856 + assert!( 857 + !document.reorder_is_legal(extrude_feature, sketch_feature, true), 858 + "an extrude may not precede its source sketch", 859 + ); 860 + assert!(!document.reorder_feature(extrude_feature, sketch_feature, true)); 861 + } 862 + 863 + #[test] 864 + fn reorder_moves_an_independent_feature_and_keeps_the_dag_valid() { 865 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 866 + let s1 = SketchId::default(); 867 + document.insert_sketch(s1, "Sketch1".to_owned(), Sketch::new(xy_basis())); 868 + let e1 = document.commit_extrude(sample_blind_extrude(s1)); 869 + let s2 = document.allocate_sketch(); 870 + document.insert_sketch(s2, "Sketch2".to_owned(), Sketch::new(xy_basis())); 871 + let e2 = document.commit_extrude(sample_blind_extrude(s2)); 872 + let tree = document.feature_tree(); 873 + let (Some(f1), Some(f2)) = (tree.feature_of_extrude(e1), tree.feature_of_extrude(e2)) 874 + else { 875 + panic!("both extrudes resolve to feature ids"); 876 + }; 877 + assert!( 878 + document.reorder_is_legal(f1, f2, false), 879 + "the first extrude may move after the second", 880 + ); 881 + assert!(document.reorder_feature(f1, f2, false)); 882 + assert!( 883 + document.feature_tree().order_violation().is_none(), 884 + "the reorder leaves every parent before its child", 885 + ); 886 + } 887 + 888 + #[test] 577 889 fn rename_sketch_updates_label() { 578 890 let (mut document, id) = doc_with_sketch(); 579 891 let Ok(()) = document.rename_sketch(id, "Profile") else { ··· 740 1052 } 741 1053 742 1054 #[test] 1055 + fn commit_after_removing_top_extrude_does_not_reuse_id() { 1056 + let (mut document, sketch) = doc_with_sketch(); 1057 + let first = document.commit_extrude(sample_blind_extrude(sketch)); 1058 + let second = document.commit_extrude(sample_blind_extrude(sketch)); 1059 + document.remove_extrude(second); 1060 + let third = document.commit_extrude(sample_blind_extrude(sketch)); 1061 + assert_ne!( 1062 + third, second, 1063 + "removing the top extrude must not free its id for reuse" 1064 + ); 1065 + assert_ne!(third, first); 1066 + } 1067 + 1068 + #[test] 1069 + fn allocate_sketch_walks_above_removed_ids() { 1070 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 1071 + let first = document.allocate_sketch(); 1072 + document.insert_sketch(first, "Sketch1".to_owned(), Sketch::new(xy_basis())); 1073 + let second = document.allocate_sketch(); 1074 + document.insert_sketch(second, "Sketch2".to_owned(), Sketch::new(xy_basis())); 1075 + document.remove_sketch(second); 1076 + let third = document.allocate_sketch(); 1077 + assert_ne!(first, second); 1078 + assert_ne!( 1079 + third, second, 1080 + "a removed sketch id must not be handed out again" 1081 + ); 1082 + assert_ne!(third, first); 1083 + } 1084 + 1085 + fn sample_face_ref(body: super::FeatureId) -> FaceRef { 1086 + FaceRef::new( 1087 + FaceLabel { 1088 + feature: body, 1089 + role: FaceRole::EndCap, 1090 + }, 1091 + FaceFingerprint { 1092 + plane: Plane3::new_unchecked( 1093 + Point3::from_mm(0.0, 0.0, 4.0), 1094 + UnitVec3::x_axis(), 1095 + UnitVec3::y_axis(), 1096 + ), 1097 + centroid: Point3::from_mm(0.0, 0.0, 4.0), 1098 + }, 1099 + ) 1100 + } 1101 + 1102 + #[test] 1103 + fn sketch_plane_binding_records_a_face_to_sketch_edge() { 1104 + let (mut document, base_sketch) = doc_with_sketch(); 1105 + let extrude = document.commit_extrude(sample_blind_extrude(base_sketch)); 1106 + let Some(body) = document.feature_tree().feature_of_extrude(extrude) else { 1107 + panic!("the extrude has a feature node"); 1108 + }; 1109 + let downstream = document.allocate_sketch(); 1110 + document.insert_sketch(downstream, "Sketch2".to_owned(), Sketch::new(xy_basis())); 1111 + let face = sample_face_ref(body); 1112 + let Ok(()) = document.bind_sketch_to_face(downstream, face) else { 1113 + panic!("the face binding is acyclic"); 1114 + }; 1115 + 1116 + assert_eq!(document.sketch_plane_binding(downstream), Some(face)); 1117 + let Some(sketch_feature) = document.feature_tree().feature_of_sketch(downstream) else { 1118 + panic!("the downstream sketch has a feature node"); 1119 + }; 1120 + assert!( 1121 + document 1122 + .feature_tree() 1123 + .edges() 1124 + .contains(&FeatureEdge::FaceToSketch { 1125 + face_feature: body, 1126 + sketch: sketch_feature, 1127 + }), 1128 + "binding a sketch to a face records the dependency edge", 1129 + ); 1130 + } 1131 + 1132 + #[test] 1133 + fn sketch_plane_binding_round_trips_through_the_header() { 1134 + let (mut document, base_sketch) = doc_with_sketch(); 1135 + let extrude = document.commit_extrude(sample_blind_extrude(base_sketch)); 1136 + let Some(body) = document.feature_tree().feature_of_extrude(extrude) else { 1137 + panic!("the extrude has a feature node"); 1138 + }; 1139 + let downstream = document.allocate_sketch(); 1140 + document.insert_sketch(downstream, "Sketch2".to_owned(), Sketch::new(xy_basis())); 1141 + let face = sample_face_ref(body); 1142 + let Ok(()) = document.bind_sketch_to_face(downstream, face) else { 1143 + panic!("the face binding is acyclic"); 1144 + }; 1145 + 1146 + let Ok(ron) = crate::io::ron_io::to_string(document.header()) else { 1147 + panic!("the header serializes"); 1148 + }; 1149 + let Ok(back) = crate::io::ron_io::from_str::<DocumentHeader>(&ron) else { 1150 + panic!("the header deserializes"); 1151 + }; 1152 + assert_eq!(back.sketch_planes.get(&downstream).copied(), Some(face)); 1153 + } 1154 + 1155 + #[test] 1156 + fn removing_a_sketch_drops_its_plane_binding() { 1157 + let (mut document, base_sketch) = doc_with_sketch(); 1158 + let extrude = document.commit_extrude(sample_blind_extrude(base_sketch)); 1159 + let Some(body) = document.feature_tree().feature_of_extrude(extrude) else { 1160 + panic!("the extrude has a feature node"); 1161 + }; 1162 + let downstream = document.allocate_sketch(); 1163 + document.insert_sketch(downstream, "Sketch2".to_owned(), Sketch::new(xy_basis())); 1164 + let Ok(()) = document.bind_sketch_to_face(downstream, sample_face_ref(body)) else { 1165 + panic!("the face binding is acyclic"); 1166 + }; 1167 + 1168 + document.remove_sketch(downstream); 1169 + 1170 + assert_eq!(document.sketch_plane_binding(downstream), None); 1171 + } 1172 + 1173 + #[test] 1174 + fn binding_a_sketch_onto_its_own_descendant_is_rejected_as_cyclic() { 1175 + let (mut document, _base_sketch) = doc_with_sketch(); 1176 + let downstream = document.allocate_sketch(); 1177 + document.insert_sketch(downstream, "Sketch2".to_owned(), Sketch::new(xy_basis())); 1178 + let extrude = document.commit_extrude(sample_blind_extrude(downstream)); 1179 + let Some(body) = document.feature_tree().feature_of_extrude(extrude) else { 1180 + panic!("the downstream extrude has a feature node"); 1181 + }; 1182 + 1183 + assert_eq!( 1184 + document.bind_sketch_to_face(downstream, sample_face_ref(body)), 1185 + Err(BindSketchToFaceError::WouldCycle(body)), 1186 + "binding a sketch onto a face of its own descendant must not form a cycle", 1187 + ); 1188 + assert_eq!( 1189 + document.sketch_plane_binding(downstream), 1190 + None, 1191 + "a rejected bind records no plane", 1192 + ); 1193 + assert!( 1194 + !document 1195 + .feature_tree() 1196 + .edges() 1197 + .iter() 1198 + .any(|edge| matches!(edge, FeatureEdge::FaceToSketch { .. })), 1199 + "a rejected bind records no edge", 1200 + ); 1201 + } 1202 + 1203 + #[test] 1204 + fn binding_a_sketch_absent_from_the_tree_is_rejected() { 1205 + let (mut document, base_sketch) = doc_with_sketch(); 1206 + let extrude = document.commit_extrude(sample_blind_extrude(base_sketch)); 1207 + let Some(body) = document.feature_tree().feature_of_extrude(extrude) else { 1208 + panic!("the extrude has a feature node"); 1209 + }; 1210 + let stranger = document.allocate_sketch(); 1211 + 1212 + assert_eq!( 1213 + document.bind_sketch_to_face(stranger, sample_face_ref(body)), 1214 + Err(BindSketchToFaceError::UnknownSketch(stranger)), 1215 + "a sketch with no feature node cannot carry a plane binding", 1216 + ); 1217 + } 1218 + 1219 + #[test] 1220 + fn removing_a_feature_prunes_its_history_state() { 1221 + let (mut document, sketch) = doc_with_sketch(); 1222 + let extrude = document.commit_extrude(sample_blind_extrude(sketch)); 1223 + let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else { 1224 + panic!("the extrude has a feature node"); 1225 + }; 1226 + document.suppress(feature); 1227 + document.roll_to_here(feature); 1228 + assert!(document.suppressed().contains(&feature)); 1229 + 1230 + document.remove_extrude(extrude); 1231 + 1232 + assert!( 1233 + !document.suppressed().contains(&feature), 1234 + "deleting a feature drops it from the suppressed set", 1235 + ); 1236 + assert_eq!( 1237 + document.rollback(), 1238 + RollbackMarker::AtEnd, 1239 + "deleting the feature the bar sits on resets the rollback marker", 1240 + ); 1241 + } 1242 + 1243 + #[test] 1244 + fn removing_a_sketch_prunes_history_state_of_cascaded_features() { 1245 + let (mut document, sketch) = doc_with_sketch(); 1246 + let extrude = document.commit_extrude(sample_blind_extrude(sketch)); 1247 + let Some(sketch_feature) = document.feature_tree().feature_of_sketch(sketch) else { 1248 + panic!("the sketch has a feature node"); 1249 + }; 1250 + let Some(extrude_feature) = document.feature_tree().feature_of_extrude(extrude) else { 1251 + panic!("the extrude has a feature node"); 1252 + }; 1253 + document.suppress(extrude_feature); 1254 + document.roll_to_here(sketch_feature); 1255 + 1256 + document.remove_sketch(sketch); 1257 + 1258 + assert!( 1259 + document.suppressed().is_empty(), 1260 + "removing a sketch prunes the suppressed set of its cascaded extrude", 1261 + ); 1262 + assert_eq!( 1263 + document.rollback(), 1264 + RollbackMarker::AtEnd, 1265 + "removing the sketch the bar sits on resets the rollback marker", 1266 + ); 1267 + } 1268 + 1269 + #[test] 1270 + fn deleting_the_bar_feature_moves_the_bar_to_its_successor() { 1271 + let (mut document, first) = doc_with_sketch(); 1272 + let second = document.allocate_sketch(); 1273 + document.insert_sketch(second, "Sketch2".to_owned(), Sketch::new(xy_basis())); 1274 + let (Some(first_feature), Some(second_feature)) = ( 1275 + document.feature_tree().feature_of_sketch(first), 1276 + document.feature_tree().feature_of_sketch(second), 1277 + ) else { 1278 + panic!("both sketches resolve to feature nodes"); 1279 + }; 1280 + document.roll_to_here(first_feature); 1281 + 1282 + document.remove_sketch(first); 1283 + 1284 + assert_eq!( 1285 + document.rollback(), 1286 + RollbackMarker::Above(second_feature), 1287 + "deleting the bar feature keeps the remaining tail rolled back", 1288 + ); 1289 + } 1290 + 1291 + #[test] 1292 + fn suppressing_a_datum_is_ignored() { 1293 + let (mut document, _sketch) = doc_with_sketch(); 1294 + let Some(datum) = document 1295 + .feature_tree() 1296 + .iter() 1297 + .find_map(|(id, _)| document.feature_tree().is_datum(id).then_some(id)) 1298 + else { 1299 + panic!("the seeded tree carries datum features"); 1300 + }; 1301 + 1302 + document.suppress(datum); 1303 + 1304 + assert!( 1305 + document.suppressed().is_empty(), 1306 + "a datum never enters the suppressed set", 1307 + ); 1308 + assert_eq!(document.suppression_state(datum), SuppressionState::Active); 1309 + } 1310 + 1311 + #[test] 1312 + fn a_dependent_added_after_suppression_reads_suppressed() { 1313 + let (mut document, sketch) = doc_with_sketch(); 1314 + let Some(sketch_feature) = document.feature_tree().feature_of_sketch(sketch) else { 1315 + panic!("the sketch resolves to a feature node"); 1316 + }; 1317 + document.suppress(sketch_feature); 1318 + 1319 + let extrude = document.commit_extrude(sample_blind_extrude(sketch)); 1320 + let Some(extrude_feature) = document.feature_tree().feature_of_extrude(extrude) else { 1321 + panic!("the extrude resolves to a feature node"); 1322 + }; 1323 + 1324 + assert_eq!( 1325 + document.suppression_state(extrude_feature), 1326 + SuppressionState::Suppressed, 1327 + "a dependent of a suppressed feature reads suppressed even when added later", 1328 + ); 1329 + assert!( 1330 + !document.suppressed().contains(&extrude_feature), 1331 + "the late dependent need not enter the persisted set to read suppressed", 1332 + ); 1333 + } 1334 + 1335 + #[test] 743 1336 fn reinsert_existing_extrude_preserves_renamed_label() { 744 1337 let (mut document, sketch) = doc_with_sketch(); 745 1338 let id = document.commit_extrude(sample_blind_extrude(sketch)); ··· 748 1341 }; 749 1342 document.insert_extrude(id, sample_blind_extrude(sketch)); 750 1343 assert_eq!(document.extrude_label(id), Some("Boss")); 1344 + } 1345 + 1346 + fn sketch_extrude_doc() -> (Document, FeatureId, FeatureId) { 1347 + let (mut document, sketch) = doc_with_sketch(); 1348 + let extrude = document.commit_extrude(sample_blind_extrude(sketch)); 1349 + let (Some(sketch_feature), Some(extrude_feature)) = ( 1350 + document.feature_tree().feature_of_sketch(sketch), 1351 + document.feature_tree().feature_of_extrude(extrude), 1352 + ) else { 1353 + panic!("the sketch and extrude resolve to feature nodes"); 1354 + }; 1355 + (document, sketch_feature, extrude_feature) 1356 + } 1357 + 1358 + #[test] 1359 + fn roll_to_here_marks_the_contiguous_tail_rolled_back() { 1360 + let (mut document, sketch_feature, extrude_feature) = sketch_extrude_doc(); 1361 + let second = document.allocate_sketch(); 1362 + document.insert_sketch(second, "Sketch2".to_owned(), Sketch::new(xy_basis())); 1363 + let Some(second_feature) = document.feature_tree().feature_of_sketch(second) else { 1364 + panic!("the second sketch resolves to a feature node"); 1365 + }; 1366 + 1367 + document.roll_to_here(extrude_feature); 1368 + 1369 + assert_eq!(document.rollback(), RollbackMarker::Above(extrude_feature)); 1370 + assert!( 1371 + !document.is_rolled_back(sketch_feature), 1372 + "a feature above the bar stays live and selectable", 1373 + ); 1374 + assert!( 1375 + document.is_rolled_back(extrude_feature), 1376 + "the feature the bar sits on is rolled back", 1377 + ); 1378 + assert!( 1379 + document.is_rolled_back(second_feature), 1380 + "every feature after the bar is rolled back, the contiguous-tail rule", 1381 + ); 1382 + } 1383 + 1384 + #[test] 1385 + fn roll_to_previous_walks_up_one_feature_then_clamps() { 1386 + let (mut document, sketch_feature, extrude_feature) = sketch_extrude_doc(); 1387 + 1388 + assert_eq!(document.rollback(), RollbackMarker::AtEnd); 1389 + document.roll_to_previous(); 1390 + assert_eq!( 1391 + document.rollback(), 1392 + RollbackMarker::Above(extrude_feature), 1393 + "from the end the bar steps above the last feature", 1394 + ); 1395 + document.roll_to_previous(); 1396 + assert_eq!( 1397 + document.rollback(), 1398 + RollbackMarker::Above(sketch_feature), 1399 + "another step rolls the prior feature back too", 1400 + ); 1401 + document.roll_to_previous(); 1402 + assert_eq!( 1403 + document.rollback(), 1404 + RollbackMarker::Above(sketch_feature), 1405 + "the bar never climbs above the first feature", 1406 + ); 1407 + 1408 + document.roll_to_end(); 1409 + assert_eq!( 1410 + document.rollback(), 1411 + RollbackMarker::AtEnd, 1412 + "roll to end clears the marker", 1413 + ); 1414 + } 1415 + 1416 + #[test] 1417 + fn roll_to_here_ignores_a_datum_feature() { 1418 + let (mut document, _sketch_feature, _extrude_feature) = sketch_extrude_doc(); 1419 + let Some((origin, _)) = document.feature_tree().iter().next() else { 1420 + panic!("the seeded tree opens with the origin datum"); 1421 + }; 1422 + 1423 + document.roll_to_here(origin); 1424 + 1425 + assert_eq!( 1426 + document.rollback(), 1427 + RollbackMarker::AtEnd, 1428 + "a datum can never carry the rollback bar", 1429 + ); 1430 + } 1431 + 1432 + #[test] 1433 + fn a_feature_inserted_while_rolled_back_lands_at_the_bar() { 1434 + let (mut document, _sketch_feature, extrude_feature) = sketch_extrude_doc(); 1435 + document.roll_to_here(extrude_feature); 1436 + 1437 + let inserted = document.allocate_sketch(); 1438 + document.insert_sketch(inserted, "Inserted".to_owned(), Sketch::new(xy_basis())); 1439 + let Some(inserted_feature) = document.feature_tree().feature_of_sketch(inserted) else { 1440 + panic!("the inserted sketch resolves to a feature node"); 1441 + }; 1442 + 1443 + assert!( 1444 + !document.is_rolled_back(inserted_feature), 1445 + "a feature inserted while rolled back lands above the bar, active", 1446 + ); 1447 + assert_eq!( 1448 + document.rollback(), 1449 + RollbackMarker::Above(extrude_feature), 1450 + "inserting at the bar leaves the marker where it was", 1451 + ); 1452 + let order: Vec<FeatureId> = document.feature_tree().iter().map(|(id, _)| id).collect(); 1453 + let position = |target: FeatureId| order.iter().position(|other| *other == target); 1454 + assert!( 1455 + position(inserted_feature) < position(extrude_feature), 1456 + "the inserted feature sits immediately above the bar feature, not at the tree end", 1457 + ); 1458 + } 1459 + 1460 + #[test] 1461 + fn inserting_an_extrude_on_a_rolled_back_sketch_keeps_dependency_order() { 1462 + let (mut document, sketch) = doc_with_sketch(); 1463 + let Some(sketch_feature) = document.feature_tree().feature_of_sketch(sketch) else { 1464 + panic!("the sketch resolves to a feature node"); 1465 + }; 1466 + document.roll_to_here(sketch_feature); 1467 + 1468 + let extrude = document.commit_extrude(sample_blind_extrude(sketch)); 1469 + let Some(extrude_feature) = document.feature_tree().feature_of_extrude(extrude) else { 1470 + panic!("the committed extrude resolves to a feature node"); 1471 + }; 1472 + 1473 + assert!( 1474 + document.feature_tree().order_violation().is_none(), 1475 + "an extrude committed onto a rolled-back sketch must not precede its parent", 1476 + ); 1477 + let order: Vec<FeatureId> = document.feature_tree().iter().map(|(id, _)| id).collect(); 1478 + let position = |target: FeatureId| order.iter().position(|other| *other == target); 1479 + assert!( 1480 + position(sketch_feature) < position(extrude_feature), 1481 + "placing at the bar still lands the child after the parent it depends on", 1482 + ); 1483 + } 1484 + 1485 + #[test] 1486 + fn editing_an_above_bar_feature_does_not_reorder_history() { 1487 + let (mut document, sketch) = doc_with_sketch(); 1488 + let first = document.commit_extrude(sample_blind_extrude(sketch)); 1489 + let _middle = document.commit_extrude(sample_blind_extrude(sketch)); 1490 + let last = document.commit_extrude(sample_blind_extrude(sketch)); 1491 + let Some(bar) = document.feature_tree().feature_of_extrude(last) else { 1492 + panic!("the last extrude resolves to a feature node"); 1493 + }; 1494 + document.roll_to_here(bar); 1495 + let before: Vec<FeatureId> = document.feature_tree().iter().map(|(id, _)| id).collect(); 1496 + 1497 + document.insert_extrude(first, sample_blind_extrude(sketch)); 1498 + 1499 + let after: Vec<FeatureId> = document.feature_tree().iter().map(|(id, _)| id).collect(); 1500 + assert_eq!( 1501 + before, after, 1502 + "re-inserting an existing feature while rolled back must not relocate it to the bar", 1503 + ); 1504 + } 1505 + 1506 + #[test] 1507 + fn unsuppressing_a_parent_restores_its_children() { 1508 + let (mut document, sketch_feature, extrude_feature) = sketch_extrude_doc(); 1509 + 1510 + document.suppress(sketch_feature); 1511 + assert_eq!( 1512 + document.suppression_state(extrude_feature), 1513 + SuppressionState::Suppressed, 1514 + ); 1515 + 1516 + document.unsuppress(sketch_feature); 1517 + 1518 + assert_eq!( 1519 + document.suppression_state(sketch_feature), 1520 + SuppressionState::Active, 1521 + ); 1522 + assert_eq!( 1523 + document.suppression_state(extrude_feature), 1524 + SuppressionState::Active, 1525 + "unsuppressing a feature restores the feature and its children", 1526 + ); 1527 + } 1528 + 1529 + #[test] 1530 + fn suppressing_a_feature_cascades_to_its_dependents() { 1531 + let (mut document, sketch_feature, extrude_feature) = sketch_extrude_doc(); 1532 + 1533 + document.suppress(sketch_feature); 1534 + 1535 + assert_eq!( 1536 + document.suppression_state(sketch_feature), 1537 + SuppressionState::Suppressed, 1538 + ); 1539 + assert_eq!( 1540 + document.suppression_state(extrude_feature), 1541 + SuppressionState::Suppressed, 1542 + "suppressing a feature suppresses its children", 1543 + ); 1544 + } 1545 + 1546 + #[test] 1547 + fn unsuppressing_a_child_restores_its_parent_but_not_its_sibling() { 1548 + let (mut document, sketch) = doc_with_sketch(); 1549 + let left = document.commit_extrude(sample_blind_extrude(sketch)); 1550 + let right = document.commit_extrude(sample_blind_extrude(sketch)); 1551 + let (Some(sketch_feature), Some(left_feature), Some(right_feature)) = ( 1552 + document.feature_tree().feature_of_sketch(sketch), 1553 + document.feature_tree().feature_of_extrude(left), 1554 + document.feature_tree().feature_of_extrude(right), 1555 + ) else { 1556 + panic!("the sketch and both extrudes resolve to feature nodes"); 1557 + }; 1558 + 1559 + document.suppress(sketch_feature); 1560 + assert_eq!( 1561 + document.suppression_state(left_feature), 1562 + SuppressionState::Suppressed, 1563 + ); 1564 + assert_eq!( 1565 + document.suppression_state(right_feature), 1566 + SuppressionState::Suppressed, 1567 + ); 1568 + 1569 + document.unsuppress(left_feature); 1570 + 1571 + assert_eq!( 1572 + document.suppression_state(left_feature), 1573 + SuppressionState::Active, 1574 + "the unsuppressed child comes back", 1575 + ); 1576 + assert_eq!( 1577 + document.suppression_state(sketch_feature), 1578 + SuppressionState::Active, 1579 + "unsuppressing a child restores its parent", 1580 + ); 1581 + assert_eq!( 1582 + document.suppression_state(right_feature), 1583 + SuppressionState::Suppressed, 1584 + "the parent's other child stays suppressed", 1585 + ); 751 1586 } 752 1587 }
+5 -99
crates/bone-document/src/evaluator.rs
··· 1 - use std::collections::{BTreeMap, BTreeSet, btree_map::Entry}; 2 - 3 1 use bone_kernel::{BrepError, BrepSolid, ExtrudeFeature}; 4 2 use bone_solver::SolverError; 5 - use bone_types::{FeatureId, GeometryGeneration}; 3 + use bone_types::{FeatureId, GeometryGeneration, RebuildError}; 6 4 7 5 use crate::Sketch; 8 6 use crate::profile::build_profile; ··· 11 9 pub enum EvaluatedSketch { 12 10 Solved(Sketch), 13 11 Failed(SolverError), 12 + PlaneUnresolved(RebuildError), 14 13 } 15 14 16 15 #[must_use] ··· 27 26 UnsolvedSketch(SolverError), 28 27 #[error(transparent)] 29 28 Kernel(BrepError), 29 + #[error("{0}")] 30 + PlaneUnresolved(RebuildError), 30 31 } 31 32 32 33 #[derive(Clone)] ··· 74 75 .and_then(|profile| bone_kernel::evaluate_extrude(extrude, &profile, feature)) 75 76 .map_err(ExtrudeError::Kernel), 76 77 EvaluatedSketch::Failed(error) => Err(ExtrudeError::UnsolvedSketch(error.clone())), 78 + EvaluatedSketch::PlaneUnresolved(error) => Err(ExtrudeError::PlaneUnresolved(*error)), 77 79 }; 78 80 let generation = result 79 81 .as_ref() ··· 81 83 .map(|solid| GeometryGeneration::from_solid_key(solid.content_key())); 82 84 EvaluatedExtrude { result, generation } 83 85 } 84 - 85 - #[derive(Clone, Debug, Default)] 86 - pub struct FeatureCache { 87 - sketches: BTreeMap<FeatureId, Cached<Sketch, EvaluatedSketch>>, 88 - extrudes: BTreeMap<FeatureId, Cached<(EvaluatedSketch, ExtrudeFeature), EvaluatedExtrude>>, 89 - } 90 - 91 - #[derive(Clone, Debug)] 92 - struct Cached<I, O> { 93 - input: I, 94 - output: O, 95 - } 96 - 97 - fn memoize<I, O>( 98 - cache: &mut BTreeMap<FeatureId, Cached<I, O>>, 99 - feature: FeatureId, 100 - matches: impl FnOnce(&I) -> bool, 101 - build_input: impl FnOnce() -> I, 102 - eval: impl FnOnce(&I) -> O, 103 - ) -> &O { 104 - let cached = match cache.entry(feature) { 105 - Entry::Occupied(mut slot) => { 106 - if !matches(&slot.get().input) { 107 - let input = build_input(); 108 - let output = eval(&input); 109 - *slot.get_mut() = Cached { input, output }; 110 - } 111 - slot.into_mut() 112 - } 113 - Entry::Vacant(slot) => { 114 - let input = build_input(); 115 - let output = eval(&input); 116 - slot.insert(Cached { input, output }) 117 - } 118 - }; 119 - &cached.output 120 - } 121 - 122 - impl FeatureCache { 123 - #[must_use] 124 - pub fn new() -> Self { 125 - Self::default() 126 - } 127 - 128 - pub fn evaluate(&mut self, feature: FeatureId, input: &Sketch) -> &EvaluatedSketch { 129 - memoize( 130 - &mut self.sketches, 131 - feature, 132 - |cached| cached == input, 133 - || input.clone(), 134 - evaluate_sketch, 135 - ) 136 - } 137 - 138 - pub fn evaluate_extrude( 139 - &mut self, 140 - feature: FeatureId, 141 - sketch: &EvaluatedSketch, 142 - extrude: &ExtrudeFeature, 143 - ) -> &EvaluatedExtrude { 144 - memoize( 145 - &mut self.extrudes, 146 - feature, 147 - |(cached_sketch, cached_extrude)| cached_sketch == sketch && cached_extrude == extrude, 148 - || (sketch.clone(), *extrude), 149 - |(sketch, extrude)| evaluate_extrude(feature, sketch, extrude), 150 - ) 151 - } 152 - 153 - #[must_use] 154 - pub fn contains(&self, feature: FeatureId) -> bool { 155 - self.sketches.contains_key(&feature) || self.extrudes.contains_key(&feature) 156 - } 157 - 158 - pub fn invalidate(&mut self, feature: FeatureId) -> bool { 159 - let sketch = self.sketches.remove(&feature).is_some(); 160 - let extrude = self.extrudes.remove(&feature).is_some(); 161 - sketch || extrude 162 - } 163 - 164 - pub fn retain(&mut self, live: impl IntoIterator<Item = FeatureId>) { 165 - let keep: BTreeSet<FeatureId> = live.into_iter().collect(); 166 - self.sketches.retain(|id, _| keep.contains(id)); 167 - self.extrudes.retain(|id, _| keep.contains(id)); 168 - } 169 - 170 - #[must_use] 171 - pub fn len(&self) -> usize { 172 - self.sketches.len() + self.extrudes.len() 173 - } 174 - 175 - #[must_use] 176 - pub fn is_empty(&self) -> bool { 177 - self.sketches.is_empty() && self.extrudes.is_empty() 178 - } 179 - }
+141 -4
crates/bone-document/src/io/folder.rs
··· 6 6 7 7 use bone_kernel::{BrepSolid, ExtrudeFeature}; 8 8 use bone_types::{ 9 - AngleTolerance, BodyId, ChordHeightTolerance, ExtrudeId, SchemaHeader, SchemaVersion, SketchId, 9 + AngleTolerance, BodyId, ChordHeightTolerance, ExtrudeId, FeatureId, SchemaHeader, 10 + SchemaVersion, SketchId, 10 11 }; 11 12 12 13 use crate::document::{ 13 - Document, DocumentHeader, ExtrudeFile, FeatureNode, ImportedSolid, SketchFile, 14 + Document, DocumentHeader, ExtrudeFile, FeatureNode, FeatureTree, ImportedSolid, SketchFile, 14 15 body_brep_filename, body_labels_filename, extrude_filename, sketch_filename, 15 16 }; 16 17 use crate::io::blob::{BlobHash, BlobKind}; ··· 96 97 #[source] 97 98 source: SketchEditError, 98 99 }, 100 + #[error("feature tree lists feature {id:?} more than once")] 101 + DuplicateFeatureId { id: FeatureId }, 102 + #[error("feature tree has a dependency cycle through feature {id:?}")] 103 + FeatureCycle { id: FeatureId }, 104 + #[error("feature {child:?} precedes its parent {parent:?} in feature order")] 105 + FeatureOrderViolation { parent: FeatureId, child: FeatureId }, 106 + #[error("rollback marker references feature {id:?} absent from the feature tree")] 107 + DanglingRollback { id: FeatureId }, 108 + #[error("rollback marker sits on datum feature {id:?}, which is never rollable")] 109 + RollbackOnDatum { id: FeatureId }, 110 + #[error("suppressed set references feature {id:?} absent from the feature tree")] 111 + DanglingSuppressed { id: FeatureId }, 112 + #[error("suppressed set lists datum feature {id:?}, which is never suppressible")] 113 + SuppressedDatum { id: FeatureId }, 99 114 #[error("feature tree references sketch {id:?} not in registry")] 100 115 DanglingTreeSketch { id: SketchId }, 101 116 #[error("registry has sketch {id:?} absent from feature tree")] ··· 334 349 pub fn load(folder: &DocumentFolder) -> Result<Document, FolderError> { 335 350 let header_path = folder.document_file(); 336 351 let header_text = read_to_string(&header_path)?; 352 + check_schema(&peek_schema(&header_path, &header_text)?)?; 337 353 let mut header: DocumentHeader = from_ron(&header_path, &header_text)?; 338 - check_schema(&header.schema)?; 339 354 let (extrudes, extrude_labels) = read_extrudes(folder, &header)?; 340 355 header.extrudes = extrudes; 341 356 header.extrude_labels = extrude_labels; ··· 372 387 373 388 let bodies = read_bodies(folder, &header)?; 374 389 375 - Ok(Document::from_parts(header, sketches, bodies)) 390 + let document = Document::from_parts(header, sketches, bodies); 391 + ensure_acyclic(document.feature_tree())?; 392 + ensure_ordered(document.feature_tree())?; 393 + Ok(document) 394 + } 395 + 396 + fn ensure_acyclic(tree: &FeatureTree) -> Result<(), FolderError> { 397 + match tree.find_cycle() { 398 + Some(id) => Err(FolderErrorKind::FeatureCycle { id }.wrap()), 399 + None => Ok(()), 400 + } 401 + } 402 + 403 + fn ensure_ordered(tree: &FeatureTree) -> Result<(), FolderError> { 404 + match tree.order_violation() { 405 + Some(edge) => { 406 + let (parent, child) = edge.endpoints(); 407 + Err(FolderErrorKind::FeatureOrderViolation { parent, child }.wrap()) 408 + } 409 + None => Ok(()), 410 + } 376 411 } 377 412 378 413 fn read_bodies( ··· 453 488 fn validate_header(header: &DocumentHeader) -> Result<(), FolderError> { 454 489 let tree = &header.feature_tree; 455 490 491 + let duplicate = tree 492 + .iter() 493 + .map(|(id, _)| id) 494 + .scan(BTreeSet::new(), |seen, id| Some((id, !seen.insert(id)))) 495 + .find_map(|(id, repeated)| repeated.then_some(id)); 496 + if let Some(id) = duplicate { 497 + return Err(FolderErrorKind::DuplicateFeatureId { id }.wrap()); 498 + } 499 + 456 500 let registered: BTreeSet<SketchId> = header.sketches.order().iter().copied().collect(); 457 501 let tree_sketches: BTreeSet<SketchId> = tree 458 502 .iter() ··· 483 527 .wrap()); 484 528 } 485 529 530 + let live: BTreeSet<FeatureId> = tree.iter().map(|(id, _)| id).collect(); 531 + if let Some(id) = header.rollback.feature() { 532 + if !live.contains(&id) { 533 + return Err(FolderErrorKind::DanglingRollback { id }.wrap()); 534 + } 535 + if tree.is_datum(id) { 536 + return Err(FolderErrorKind::RollbackOnDatum { id }.wrap()); 537 + } 538 + } 539 + if let Some(&id) = header.suppressed.difference(&live).next() { 540 + return Err(FolderErrorKind::DanglingSuppressed { id }.wrap()); 541 + } 542 + if let Some(&id) = header.suppressed.iter().find(|&&id| tree.is_datum(id)) { 543 + return Err(FolderErrorKind::SuppressedDatum { id }.wrap()); 544 + } 545 + 486 546 Ok(()) 487 547 } 488 548 549 + #[derive(serde::Deserialize)] 550 + #[serde(rename = "DocumentHeader")] 551 + struct SchemaProbe { 552 + schema: SchemaHeader, 553 + } 554 + 555 + fn peek_schema(path: &Path, text: &str) -> Result<SchemaHeader, FolderError> { 556 + from_ron::<SchemaProbe>(path, text).map(|probe| probe.schema) 557 + } 558 + 489 559 fn check_schema(schema: &SchemaHeader) -> Result<(), FolderError> { 490 560 if !schema.is_bone_document() { 491 561 return Err(FolderErrorKind::UnknownSchema { ··· 824 894 panic!("load"); 825 895 }; 826 896 assert!(!loaded.header().extrudes.contains_key(&orphan)); 897 + } 898 + 899 + #[test] 900 + fn validate_header_rejects_duplicate_feature_id() { 901 + use super::{FolderErrorKind, validate_header}; 902 + use bone_types::BodyId; 903 + 904 + let mut header = DocumentHeader::new(document_id(1), "dup".to_owned()); 905 + let Some((existing, _)) = header.feature_tree.iter().next() else { 906 + panic!("the seeded tree has feature nodes"); 907 + }; 908 + let body = BodyId::from(KeyData::from_ffi((1u64 << 32) | 1)); 909 + header.feature_tree.push_imported_body(existing, body); 910 + 911 + let Err(err) = validate_header(&header) else { 912 + panic!("a feature tree with a repeated id must be rejected"); 913 + }; 914 + assert!(matches!( 915 + err.into_kind(), 916 + FolderErrorKind::DuplicateFeatureId { id } if id == existing 917 + )); 918 + } 919 + 920 + #[test] 921 + fn validate_header_rejects_rollback_on_a_datum() { 922 + use super::{FolderErrorKind, validate_header}; 923 + use bone_types::RollbackMarker; 924 + 925 + let mut header = DocumentHeader::new(document_id(1), "datum".to_owned()); 926 + let Some((datum, _)) = header.feature_tree.iter().next() else { 927 + panic!("the seeded tree opens with a datum"); 928 + }; 929 + header.rollback = RollbackMarker::Above(datum); 930 + 931 + let Err(err) = validate_header(&header) else { 932 + panic!("a rollback marker sitting on a datum must be rejected"); 933 + }; 934 + assert!(matches!( 935 + err.into_kind(), 936 + FolderErrorKind::RollbackOnDatum { id } if id == datum 937 + )); 938 + } 939 + 940 + #[test] 941 + fn ensure_ordered_rejects_a_child_before_its_parent() { 942 + use super::{FolderErrorKind, ensure_ordered}; 943 + use crate::document::Document; 944 + 945 + let mut header = DocumentHeader::new(document_id(1), "order".to_owned()); 946 + let sketch = SketchId::from(KeyData::from_ffi((1u64 << 32) | 1)); 947 + let sketch_feature = header.feature_tree.push_sketch(sketch); 948 + let extrude = extrude_id(1); 949 + let extrude_feature = header.feature_tree.push_extrude(extrude, &blind(sketch)); 950 + header.extrudes.insert(extrude, blind(sketch)); 951 + header 952 + .feature_tree 953 + .move_before(extrude_feature, sketch_feature); 954 + 955 + let doc = Document::from_parts(header, BTreeMap::new(), BTreeMap::new()); 956 + let Err(err) = ensure_ordered(doc.feature_tree()) else { 957 + panic!("a child ordered before its parent must be rejected"); 958 + }; 959 + assert!(matches!( 960 + err.into_kind(), 961 + FolderErrorKind::FeatureOrderViolation { parent, child } 962 + if parent == sketch_feature && child == extrude_feature 963 + )); 827 964 } 828 965 }
+12 -6
crates/bone-document/src/lib.rs
··· 1 1 pub mod document; 2 2 pub mod evaluator; 3 3 pub mod io; 4 + pub mod matcher; 4 5 mod profile; 6 + pub mod recompute; 5 7 pub mod sketch; 8 + #[cfg(test)] 9 + mod test_support; 6 10 pub mod undo; 7 11 8 12 pub use bone_kernel::{ 9 - BrepError, BrepSolid, DraftAngle, DraftDirection, DraftMagnitude, ExtrudeDirection, 13 + BrepError, BrepFace, BrepSolid, DraftAngle, DraftDirection, DraftMagnitude, ExtrudeDirection, 10 14 ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, ProfileDefect, ThinWall, 11 15 ThinWallDirection, TruckGap, 12 16 }; 13 17 pub use document::{ 14 - Document, DocumentHeader, DocumentParameters, ExtrudeFile, FeatureEdge, FeatureNode, 15 - FeatureTree, ImportedSolid, PrincipalPlane, RenameExtrudeError, RenameSketchError, SketchFile, 16 - SketchRegistry, SketchRegistryEntry, UnitsPreference, extrude_filename, sketch_filename, 18 + BindSketchToFaceError, Document, DocumentHeader, DocumentParameters, ExtrudeFile, FeatureEdge, 19 + FeatureNode, FeatureTree, ImportedSolid, PrincipalPlane, RenameExtrudeError, RenameSketchError, 20 + SketchFile, SketchRegistry, SketchRegistryEntry, UnitsPreference, extrude_filename, 21 + sketch_filename, 17 22 }; 18 23 pub use evaluator::{ 19 - EvaluatedExtrude, EvaluatedSketch, ExtrudeError, FeatureCache, evaluate_extrude, 20 - evaluate_sketch, 24 + EvaluatedExtrude, EvaluatedSketch, ExtrudeError, evaluate_extrude, evaluate_sketch, 21 25 }; 22 26 pub use io::{ 23 27 BlobHash, BlobKind, DocumentFolder, FolderError, FolderErrorKind, LabelSidecar, RonError, 24 28 from_str, load, read_solid, read_tessellation, save, to_string, write_solid, 25 29 write_tessellation, 26 30 }; 31 + pub use matcher::{ResolvedEntity, ResolvedFace, resolve, resolve_face}; 32 + pub use recompute::{EvaluatedModel, RebuildBudget, RebuildCost, RebuildPass, RecomputeScope}; 27 33 pub use sketch::{ 28 34 ArcData, CircleData, DimensionKind, DimensionRefs, DimensionValue, DimensionValueMismatch, 29 35 EditOutcome, EntityRefs, LineData, PointData, RelationRefs, Sketch, SketchDimension,
+423
crates/bone-document/src/matcher.rs
··· 1 + use bone_kernel::{BrepFace, BrepSolid}; 2 + use bone_types::{ 3 + BrepFaceId, EntityRef, FaceFingerprint, FaceLabel, FaceRef, FeatureId, Resolution, 4 + SketchPlaneBasis, 5 + }; 6 + 7 + use crate::recompute::{EvaluatedModel, RebuildPass}; 8 + 9 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 10 + pub struct ResolvedFace { 11 + body: FeatureId, 12 + face: BrepFaceId, 13 + built_at: RebuildPass, 14 + } 15 + 16 + impl ResolvedFace { 17 + #[must_use] 18 + pub const fn body(self) -> FeatureId { 19 + self.body 20 + } 21 + 22 + #[must_use] 23 + pub const fn face(self) -> BrepFaceId { 24 + self.face 25 + } 26 + } 27 + 28 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 29 + pub enum ResolvedEntity { 30 + Face(ResolvedFace), 31 + } 32 + 33 + #[must_use] 34 + pub fn resolve(model: &EvaluatedModel, reference: EntityRef) -> Resolution<ResolvedEntity> { 35 + match reference { 36 + EntityRef::Face(label, fingerprint) => { 37 + resolve_face(model, label, fingerprint).map(ResolvedEntity::Face) 38 + } 39 + EntityRef::Edge(..) | EntityRef::Vertex(..) => Resolution::Dangling { 40 + last_known: reference, 41 + }, 42 + } 43 + } 44 + 45 + #[must_use] 46 + pub fn resolve_face( 47 + model: &EvaluatedModel, 48 + label: FaceLabel, 49 + fingerprint: FaceFingerprint, 50 + ) -> Resolution<ResolvedFace> { 51 + let dangling = Resolution::Dangling { 52 + last_known: EntityRef::Face(label, fingerprint), 53 + }; 54 + let (Some(solid), Some(built_at)) = (model.body(label.feature), model.built_at(label.feature)) 55 + else { 56 + return dangling; 57 + }; 58 + match unique_face(solid, label) { 59 + Some(face) => Resolution::Resolved(ResolvedFace { 60 + body: label.feature, 61 + face: face.id(), 62 + built_at, 63 + }), 64 + None => dangling, 65 + } 66 + } 67 + 68 + #[must_use] 69 + pub(crate) fn unique_face(solid: &BrepSolid, label: FaceLabel) -> Option<&BrepFace> { 70 + let mut matches = solid.iter_faces().filter(|face| face.label() == label); 71 + let first = matches.next()?; 72 + if matches.next().is_some() { 73 + debug_assert!(false, "face label {label} matched multiple faces"); 74 + return None; 75 + } 76 + Some(first) 77 + } 78 + 79 + impl EvaluatedModel { 80 + #[must_use] 81 + pub fn face(&self, resolved: ResolvedFace) -> Option<&BrepFace> { 82 + (self.built_at(resolved.body) == Some(resolved.built_at)) 83 + .then(|| self.body(resolved.body)) 84 + .flatten() 85 + .and_then(|solid| solid.iter_faces().find(|face| face.id() == resolved.face)) 86 + } 87 + 88 + #[must_use] 89 + pub fn face_ref(&self, resolved: ResolvedFace) -> Option<FaceRef> { 90 + let label = self.face(resolved)?.label(); 91 + let fingerprint = self.body(resolved.body)?.face_fingerprint(resolved.face)?; 92 + Some(FaceRef::new(label, fingerprint)) 93 + } 94 + 95 + #[must_use] 96 + pub fn face_ref_at(&self, body: FeatureId, face: BrepFaceId) -> Option<FaceRef> { 97 + let solid = self.body(body)?; 98 + let label = solid 99 + .iter_faces() 100 + .find(|candidate| candidate.id() == face)? 101 + .label(); 102 + let fingerprint = solid.face_fingerprint(face)?; 103 + Some(FaceRef::new(label, fingerprint)) 104 + } 105 + 106 + #[must_use] 107 + pub fn face_ref_any(&self, face: BrepFaceId) -> Option<FaceRef> { 108 + self.bodies() 109 + .find_map(|(body, _)| self.face_ref_at(body, face)) 110 + } 111 + 112 + #[must_use] 113 + pub fn face_for_sketch(&self, face: BrepFaceId) -> Option<(FaceRef, SketchPlaneBasis)> { 114 + self.bodies().find_map(|(body, solid)| { 115 + let basis = solid.face_plane_basis(face)?; 116 + let face_ref = self.face_ref_at(body, face)?; 117 + Some((face_ref, basis)) 118 + }) 119 + } 120 + } 121 + 122 + #[cfg(test)] 123 + mod tests { 124 + use std::collections::BTreeSet; 125 + 126 + use bone_kernel::BrepFace; 127 + use bone_types::{ 128 + DocumentId, EdgeFingerprint, EdgeLabel, EdgeRole, EntityRef, FaceFingerprint, FaceLabel, 129 + FaceRole, FeatureId, Length, LoopIndex, Plane3, Point3, Resolution, RollbackMarker, 130 + UnitVec3, millimeter, 131 + }; 132 + use proptest::prelude::*; 133 + 134 + use super::{ResolvedEntity, ResolvedFace, resolve, resolve_face}; 135 + use crate::document::Document; 136 + use crate::recompute::{EvaluatedModel, RecomputeScope}; 137 + use crate::sketch::{DimensionValue, SketchEdit}; 138 + use crate::test_support::{ 139 + blind_extrude, chain_handles, circle_with_radius_dim, full_model, push_chain, 140 + }; 141 + 142 + fn placeholder_fingerprint() -> FaceFingerprint { 143 + FaceFingerprint { 144 + plane: Plane3::new_unchecked(Point3::origin(), UnitVec3::x_axis(), UnitVec3::y_axis()), 145 + centroid: Point3::origin(), 146 + } 147 + } 148 + 149 + fn face_labels(model: &EvaluatedModel, body: FeatureId) -> Vec<FaceLabel> { 150 + let Some(solid) = model.body(body) else { 151 + panic!("body is built"); 152 + }; 153 + solid.iter_faces().map(BrepFace::label).collect() 154 + } 155 + 156 + fn resolved_label(model: &EvaluatedModel, resolved: ResolvedFace) -> FaceLabel { 157 + let Some(face) = model.face(resolved) else { 158 + panic!("a resolved face is present in its model"); 159 + }; 160 + face.label() 161 + } 162 + 163 + #[test] 164 + fn face_ref_at_round_trips_a_picked_face() { 165 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 166 + let chain = push_chain(&mut document, "Body", 6.0, 4.0); 167 + let model = full_model(&document); 168 + let Some(label) = face_labels(&model, chain.extrude).into_iter().next() else { 169 + panic!("the body has faces"); 170 + }; 171 + let Resolution::Resolved(resolved) = resolve_face(&model, label, placeholder_fingerprint()) 172 + else { 173 + panic!("the label resolves on its own model"); 174 + }; 175 + let Some(face_ref) = model.face_ref_at(resolved.body(), resolved.face()) else { 176 + panic!("a picked face yields a face reference"); 177 + }; 178 + let Resolution::Resolved(ResolvedEntity::Face(again)) = 179 + resolve(&model, face_ref.entity_ref()) 180 + else { 181 + panic!("the reattach reference resolves back to a face"); 182 + }; 183 + assert_eq!( 184 + again.face(), 185 + resolved.face(), 186 + "reattach targets the same face" 187 + ); 188 + assert_eq!( 189 + model 190 + .face_ref_any(resolved.face()) 191 + .map(|reference| reference.label), 192 + Some(face_ref.label), 193 + ); 194 + } 195 + 196 + #[test] 197 + fn face_for_sketch_yields_a_basis_for_a_planar_cap() { 198 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 199 + let chain = push_chain(&mut document, "Body", 6.0, 4.0); 200 + let model = full_model(&document); 201 + let Some(cap) = face_labels(&model, chain.extrude) 202 + .into_iter() 203 + .find(|label| matches!(label.role, FaceRole::EndCap)) 204 + else { 205 + panic!("the cylinder has a planar cap"); 206 + }; 207 + let Resolution::Resolved(resolved) = resolve_face(&model, cap, placeholder_fingerprint()) 208 + else { 209 + panic!("the cap resolves on its own model"); 210 + }; 211 + assert!( 212 + model.face_for_sketch(resolved.face()).is_some(), 213 + "a planar cap yields a face reference and a sketch basis", 214 + ); 215 + } 216 + 217 + proptest! { 218 + #[test] 219 + fn a_surviving_ref_resolves_and_a_deleted_target_dangles( 220 + kept_radius in 2.0f64..18.0, 221 + doomed_radius in 2.0f64..18.0, 222 + ) { 223 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 224 + let kept = push_chain(&mut document, "Keep", kept_radius, 4.0); 225 + let doomed = push_chain(&mut document, "Drop", doomed_radius, 4.0); 226 + let mut model = full_model(&document); 227 + 228 + let Some(survivor) = face_labels(&model, kept.extrude).into_iter().next() else { 229 + return Err(TestCaseError::fail("the kept body has faces")); 230 + }; 231 + let Some(lost) = face_labels(&model, doomed.extrude).into_iter().next() else { 232 + return Err(TestCaseError::fail("the doomed body has faces")); 233 + }; 234 + 235 + document.remove_extrude(doomed.extrude_id); 236 + model.recompute( 237 + &document, 238 + &BTreeSet::new(), 239 + RollbackMarker::AtEnd, 240 + RecomputeScope::Full, 241 + ); 242 + 243 + let Resolution::Resolved(resolved) = 244 + resolve_face(&model, survivor, placeholder_fingerprint()) 245 + else { 246 + return Err(TestCaseError::fail(format!( 247 + "{survivor} must survive a sibling deletion" 248 + ))); 249 + }; 250 + prop_assert_eq!(resolved.body(), kept.extrude); 251 + prop_assert_eq!(resolved_label(&model, resolved), survivor); 252 + 253 + prop_assert_eq!( 254 + resolve_face(&model, lost, placeholder_fingerprint()), 255 + Resolution::Dangling { 256 + last_known: EntityRef::Face(lost, placeholder_fingerprint()), 257 + }, 258 + "a deleted target dangles with its last-known reference", 259 + ); 260 + } 261 + } 262 + 263 + #[test] 264 + fn a_no_match_label_never_binds_to_a_nearest_face() { 265 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 266 + let sketch_id = document.allocate_sketch(); 267 + let (sketch, circle, _dim) = circle_with_radius_dim(5.0); 268 + document.insert_sketch(sketch_id, "Sketch1".to_owned(), sketch); 269 + let extrude_id = document.commit_extrude(blind_extrude(sketch_id, 4.0)); 270 + let chain = chain_handles(&document, sketch_id, extrude_id); 271 + let model = full_model(&document); 272 + 273 + let absent = FaceLabel { 274 + feature: chain.extrude, 275 + role: FaceRole::Side { 276 + loop_index: LoopIndex::new(u16::MAX), 277 + from: circle, 278 + }, 279 + }; 280 + let resolution = resolve_face(&model, absent, placeholder_fingerprint()); 281 + assert!(resolution.is_dangling()); 282 + assert_eq!(resolution.id(), None, "no nearest-guess id is returned"); 283 + } 284 + 285 + #[test] 286 + fn resolve_routes_a_face_ref_and_dangles_unimplemented_kinds() { 287 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 288 + let sketch_id = document.allocate_sketch(); 289 + let (sketch, circle, _dim) = circle_with_radius_dim(5.0); 290 + document.insert_sketch(sketch_id, "Sketch1".to_owned(), sketch); 291 + let extrude_id = document.commit_extrude(blind_extrude(sketch_id, 4.0)); 292 + let chain = chain_handles(&document, sketch_id, extrude_id); 293 + let model = full_model(&document); 294 + 295 + let Some(label) = face_labels(&model, chain.extrude).into_iter().next() else { 296 + panic!("the body has faces"); 297 + }; 298 + let Resolution::Resolved(ResolvedEntity::Face(resolved)) = 299 + resolve(&model, EntityRef::Face(label, placeholder_fingerprint())) 300 + else { 301 + panic!("a face reference resolves through the dispatcher"); 302 + }; 303 + assert_eq!(resolved.body(), chain.extrude); 304 + 305 + let edge_ref = EntityRef::Edge( 306 + EdgeLabel { 307 + feature: chain.extrude, 308 + role: EdgeRole::StartCapEdge { from: circle }, 309 + }, 310 + EdgeFingerprint { 311 + sample: Point3::origin(), 312 + direction: UnitVec3::x_axis(), 313 + }, 314 + ); 315 + assert!( 316 + resolve(&model, edge_ref).is_dangling(), 317 + "edge resolution is unimplemented, so it dangles rather than guessing", 318 + ); 319 + } 320 + 321 + #[test] 322 + fn a_resolved_face_goes_stale_when_its_body_rebuilds() { 323 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 324 + let sketch_id = document.allocate_sketch(); 325 + let (sketch, _circle, radius_dim) = circle_with_radius_dim(5.0); 326 + document.insert_sketch(sketch_id, "Sketch1".to_owned(), sketch); 327 + let extrude_id = document.commit_extrude(blind_extrude(sketch_id, 4.0)); 328 + let chain = chain_handles(&document, sketch_id, extrude_id); 329 + let mut model = full_model(&document); 330 + 331 + let Some(label) = face_labels(&model, chain.extrude).into_iter().next() else { 332 + panic!("the body has faces"); 333 + }; 334 + let Resolution::Resolved(stale) = resolve_face(&model, label, placeholder_fingerprint()) 335 + else { 336 + panic!("the face resolves before the edit"); 337 + }; 338 + assert!( 339 + model.face(stale).is_some(), 340 + "a fresh handle reads back on the model that produced it", 341 + ); 342 + 343 + let Some(original) = document.sketch(sketch_id).cloned() else { 344 + panic!("the sketch is present"); 345 + }; 346 + let Ok((edited, _)) = original.apply(SketchEdit::UpdateDimensionValue { 347 + id: radius_dim, 348 + value: DimensionValue::Length(Length::new::<millimeter>(9.0)), 349 + }) else { 350 + panic!("the driving radius dimension accepts a new value"); 351 + }; 352 + document.replace_sketch(sketch_id, edited); 353 + model.recompute( 354 + &document, 355 + &BTreeSet::new(), 356 + RollbackMarker::AtEnd, 357 + RecomputeScope::Edited(chain.sketch), 358 + ); 359 + 360 + assert!( 361 + model.face(stale).is_none(), 362 + "a handle from before the rebuild must not read a face out of the new arena", 363 + ); 364 + assert!( 365 + resolve_face(&model, label, placeholder_fingerprint()) 366 + .id() 367 + .is_some(), 368 + "re-resolving against the rebuilt model yields a usable handle", 369 + ); 370 + } 371 + 372 + proptest! { 373 + #[test] 374 + fn a_dimension_edit_keeps_every_face_ref_resolved( 375 + radius in 2.0f64..18.0, 376 + edited in 2.0f64..18.0, 377 + ) { 378 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 379 + let sketch_id = document.allocate_sketch(); 380 + let (sketch, _circle, radius_dim) = circle_with_radius_dim(radius); 381 + document.insert_sketch(sketch_id, "Sketch1".to_owned(), sketch); 382 + let extrude_id = document.commit_extrude(blind_extrude(sketch_id, 4.0)); 383 + let chain = chain_handles(&document, sketch_id, extrude_id); 384 + let mut model = full_model(&document); 385 + 386 + let labels = face_labels(&model, chain.extrude); 387 + 388 + let Some(original) = document.sketch(sketch_id).cloned() else { 389 + prop_assert!(false, "the sketch is present"); 390 + return Ok(()); 391 + }; 392 + let Ok((edited_sketch, _)) = original.apply(SketchEdit::UpdateDimensionValue { 393 + id: radius_dim, 394 + value: DimensionValue::Length(Length::new::<millimeter>(edited)), 395 + }) else { 396 + prop_assert!(false, "a driving radius dimension accepts a new value"); 397 + return Ok(()); 398 + }; 399 + document.replace_sketch(sketch_id, edited_sketch); 400 + model.recompute( 401 + &document, 402 + &BTreeSet::new(), 403 + RollbackMarker::AtEnd, 404 + RecomputeScope::Edited(chain.sketch), 405 + ); 406 + 407 + let unresolved: Vec<FaceLabel> = labels 408 + .iter() 409 + .copied() 410 + .filter(|&label| { 411 + match resolve_face(&model, label, placeholder_fingerprint()) { 412 + Resolution::Resolved(resolved) => resolved_label(&model, resolved) != label, 413 + Resolution::Repaired { .. } | Resolution::Dangling { .. } => true, 414 + } 415 + }) 416 + .collect(); 417 + prop_assert!( 418 + unresolved.is_empty(), 419 + "every face ref must resolve to its identical label across a dimension edit, failed: {unresolved:?}", 420 + ); 421 + } 422 + } 423 + }
+950
crates/bone-document/src/recompute.rs
··· 1 + use std::collections::{BTreeMap, BTreeSet}; 2 + 3 + use bone_kernel::{BrepSolid, ExtrudeFeature}; 4 + use bone_types::{ 5 + BodyId, BuildFailure, FaceRef, FeatureId, GeometryGeneration, RebuildError, RebuildStatus, 6 + RollbackMarker, 7 + }; 8 + 9 + use crate::Sketch; 10 + use crate::document::{Document, FeatureNode, FeatureTree}; 11 + use crate::evaluator::{ 12 + EvaluatedExtrude, EvaluatedSketch, ExtrudeError, evaluate_extrude, evaluate_sketch, 13 + }; 14 + use crate::matcher::unique_face; 15 + 16 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] 17 + pub struct RebuildPass(u64); 18 + 19 + impl RebuildPass { 20 + #[must_use] 21 + const fn succ(self) -> Self { 22 + Self(self.0.saturating_add(1)) 23 + } 24 + } 25 + 26 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 27 + pub enum RecomputeScope { 28 + Full, 29 + Edited(FeatureId), 30 + } 31 + 32 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] 33 + pub struct RebuildCost(usize); 34 + 35 + #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 36 + pub struct RebuildBudget(usize); 37 + 38 + impl RebuildBudget { 39 + pub const INTERACTIVE: Self = Self(2048); 40 + 41 + #[must_use] 42 + pub const fn new(limit: usize) -> Self { 43 + Self(limit) 44 + } 45 + } 46 + 47 + impl RebuildCost { 48 + #[must_use] 49 + pub const fn new(weight: usize) -> Self { 50 + Self(weight) 51 + } 52 + 53 + #[must_use] 54 + pub const fn within(self, budget: RebuildBudget) -> bool { 55 + self.0 <= budget.0 56 + } 57 + } 58 + 59 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 60 + enum SkipState { 61 + Active, 62 + Suppressed, 63 + RolledBack, 64 + } 65 + 66 + #[derive(Clone)] 67 + enum NodeOutput { 68 + Datum, 69 + Sketch(EvaluatedSketch), 70 + Extrude(EvaluatedExtrude), 71 + Imported(BrepSolid), 72 + Unbuilt, 73 + } 74 + 75 + #[derive(Clone, PartialEq)] 76 + enum ParentDigest { 77 + Datum, 78 + Sketch(EvaluatedSketch), 79 + Geometry(GeometryGeneration), 80 + Absent, 81 + } 82 + 83 + #[derive(Clone, PartialEq)] 84 + enum PayloadKey { 85 + Datum, 86 + Sketch(Sketch, Option<FaceRef>), 87 + Extrude(Option<ExtrudeFeature>), 88 + Imported(BodyId), 89 + } 90 + 91 + #[derive(Clone, PartialEq)] 92 + struct NodeFingerprint { 93 + payload: PayloadKey, 94 + parents: Vec<(FeatureId, ParentDigest)>, 95 + } 96 + 97 + struct NodeMemo { 98 + fingerprint: NodeFingerprint, 99 + output: NodeOutput, 100 + digest: ParentDigest, 101 + skip: SkipState, 102 + status: RebuildStatus, 103 + built_at: RebuildPass, 104 + } 105 + 106 + type Memo = BTreeMap<FeatureId, NodeMemo>; 107 + 108 + #[derive(Default)] 109 + pub struct EvaluatedModel { 110 + memo: Memo, 111 + order: Vec<FeatureId>, 112 + pass: RebuildPass, 113 + } 114 + 115 + impl EvaluatedModel { 116 + #[must_use] 117 + pub fn new() -> Self { 118 + Self::default() 119 + } 120 + 121 + pub fn recompute( 122 + &mut self, 123 + document: &Document, 124 + suppressed: &BTreeSet<FeatureId>, 125 + rollback: RollbackMarker, 126 + scope: RecomputeScope, 127 + ) { 128 + let tree = document.feature_tree(); 129 + debug_assert!( 130 + tree.find_cycle().is_none(), 131 + "recompute requires an acyclic feature graph", 132 + ); 133 + debug_assert!( 134 + tree.order_violation().is_none(), 135 + "recompute requires every parent to precede its child in feature order", 136 + ); 137 + let live: BTreeSet<FeatureId> = tree.iter().map(|(id, _)| id).collect(); 138 + let rolled = tree.rolled_back(rollback); 139 + let suppressed = closed_over_descendants(tree, suppressed); 140 + let pass = self.pass.succ(); 141 + 142 + let retained: Memo = core::mem::take(&mut self.memo) 143 + .into_iter() 144 + .filter(|(id, _)| live.contains(id)) 145 + .collect(); 146 + let dirty = dirty_set(tree, &live, &retained, &suppressed, &rolled, scope); 147 + let order = tree.topo_order(); 148 + 149 + self.memo = order.iter().fold(retained, |mut memo, &id| { 150 + if !dirty.contains(&id) { 151 + return memo; 152 + } 153 + let Some(node) = tree.node(id) else { 154 + return memo; 155 + }; 156 + let fingerprint = NodeFingerprint { 157 + payload: payload_key(document, node), 158 + parents: parent_digests(tree, id, &memo), 159 + }; 160 + let skip = skip_state(id, &suppressed, &rolled); 161 + let unchanged = memo 162 + .get(&id) 163 + .is_some_and(|entry| entry.fingerprint == fingerprint && entry.skip == skip); 164 + if !unchanged { 165 + let entry = build_entry(document, id, node, &memo, fingerprint, skip, pass); 166 + memo.insert(id, entry); 167 + } 168 + memo 169 + }); 170 + self.order = order; 171 + self.pass = pass; 172 + } 173 + 174 + #[must_use] 175 + pub fn status(&self, id: FeatureId) -> Option<RebuildStatus> { 176 + self.memo.get(&id).map(|entry| entry.status) 177 + } 178 + 179 + #[must_use] 180 + pub fn body(&self, id: FeatureId) -> Option<&BrepSolid> { 181 + self.memo 182 + .get(&id) 183 + .and_then(|entry| output_solid(&entry.output)) 184 + } 185 + 186 + pub fn bodies(&self) -> impl Iterator<Item = (FeatureId, &BrepSolid)> + '_ { 187 + self.order 188 + .iter() 189 + .filter_map(move |&id| self.body(id).map(|solid| (id, solid))) 190 + } 191 + 192 + #[must_use] 193 + pub fn built_at(&self, id: FeatureId) -> Option<RebuildPass> { 194 + self.memo.get(&id).map(|entry| entry.built_at) 195 + } 196 + 197 + #[must_use] 198 + pub fn rebuild_cost(&self) -> RebuildCost { 199 + RebuildCost( 200 + self.bodies() 201 + .map(|(_, solid)| solid.iter_faces().count() + solid.iter_edges().count()) 202 + .sum(), 203 + ) 204 + } 205 + } 206 + 207 + fn closed_over_descendants(tree: &FeatureTree, seed: &BTreeSet<FeatureId>) -> BTreeSet<FeatureId> { 208 + seed.iter() 209 + .flat_map(|&feature| tree.descendants(feature)) 210 + .chain(seed.iter().copied()) 211 + .collect() 212 + } 213 + 214 + fn dirty_set( 215 + tree: &FeatureTree, 216 + live: &BTreeSet<FeatureId>, 217 + retained: &Memo, 218 + suppressed: &BTreeSet<FeatureId>, 219 + rolled: &BTreeSet<FeatureId>, 220 + scope: RecomputeScope, 221 + ) -> BTreeSet<FeatureId> { 222 + let scoped = match scope { 223 + RecomputeScope::Full => live.clone(), 224 + RecomputeScope::Edited(root) => subtree(tree, root), 225 + }; 226 + let appeared = live.iter().copied().filter(|id| !retained.contains_key(id)); 227 + let reskinned = retained 228 + .iter() 229 + .filter(|(id, entry)| entry.skip != skip_state(**id, suppressed, rolled)) 230 + .flat_map(|(id, _)| subtree(tree, *id)); 231 + appeared 232 + .chain(reskinned) 233 + .filter(|id| live.contains(id)) 234 + .fold(scoped, |mut acc, id| { 235 + acc.insert(id); 236 + acc 237 + }) 238 + } 239 + 240 + fn subtree(tree: &FeatureTree, root: FeatureId) -> BTreeSet<FeatureId> { 241 + tree.descendants(root).into_iter().chain([root]).collect() 242 + } 243 + 244 + fn skip_state( 245 + id: FeatureId, 246 + suppressed: &BTreeSet<FeatureId>, 247 + rolled: &BTreeSet<FeatureId>, 248 + ) -> SkipState { 249 + match (rolled.contains(&id), suppressed.contains(&id)) { 250 + (true, _) => SkipState::RolledBack, 251 + (false, true) => SkipState::Suppressed, 252 + (false, false) => SkipState::Active, 253 + } 254 + } 255 + 256 + fn payload_key(document: &Document, node: FeatureNode) -> PayloadKey { 257 + match node { 258 + FeatureNode::Origin | FeatureNode::PrincipalPlane(_) => PayloadKey::Datum, 259 + FeatureNode::Sketch(sketch) => document.sketch(sketch).map_or(PayloadKey::Datum, |value| { 260 + PayloadKey::Sketch(value.clone(), document.sketch_plane_binding(sketch)) 261 + }), 262 + FeatureNode::Extrude(extrude) => PayloadKey::Extrude(document.extrude(extrude).copied()), 263 + FeatureNode::ImportedBody(body) => PayloadKey::Imported(body), 264 + } 265 + } 266 + 267 + fn parent_digests( 268 + tree: &FeatureTree, 269 + id: FeatureId, 270 + memo: &Memo, 271 + ) -> Vec<(FeatureId, ParentDigest)> { 272 + tree.parents(id) 273 + .into_iter() 274 + .map(|parent| { 275 + let digest = memo 276 + .get(&parent) 277 + .map_or(ParentDigest::Absent, |entry| entry.digest.clone()); 278 + (parent, digest) 279 + }) 280 + .collect() 281 + } 282 + 283 + fn build_entry( 284 + document: &Document, 285 + id: FeatureId, 286 + node: FeatureNode, 287 + memo: &Memo, 288 + fingerprint: NodeFingerprint, 289 + skip: SkipState, 290 + pass: RebuildPass, 291 + ) -> NodeMemo { 292 + let output = match skip { 293 + SkipState::Active => node_output(document, id, node, memo), 294 + SkipState::Suppressed | SkipState::RolledBack => NodeOutput::Unbuilt, 295 + }; 296 + let digest = digest_of(&output, skip); 297 + let status = status_of(&output, skip); 298 + NodeMemo { 299 + fingerprint, 300 + output, 301 + digest, 302 + skip, 303 + status, 304 + built_at: pass, 305 + } 306 + } 307 + 308 + fn node_output(document: &Document, id: FeatureId, node: FeatureNode, memo: &Memo) -> NodeOutput { 309 + match node { 310 + FeatureNode::Origin | FeatureNode::PrincipalPlane(_) => NodeOutput::Datum, 311 + FeatureNode::Sketch(sketch) => { 312 + document 313 + .sketch(sketch) 314 + .map_or(NodeOutput::Unbuilt, |value| { 315 + let evaluated = match document.sketch_plane_binding(sketch) { 316 + None => evaluate_sketch(value), 317 + Some(face) => face_bound_sketch(memo, value, face), 318 + }; 319 + NodeOutput::Sketch(evaluated) 320 + }) 321 + } 322 + FeatureNode::Extrude(extrude) => { 323 + let feature = document.extrude(extrude); 324 + let profile = parent_sketch(document.feature_tree(), id, memo); 325 + match (feature, profile) { 326 + (Some(feature), Some(profile)) => { 327 + NodeOutput::Extrude(evaluate_extrude(id, profile, feature)) 328 + } 329 + _ => NodeOutput::Unbuilt, 330 + } 331 + } 332 + FeatureNode::ImportedBody(body) => document 333 + .imported_body(body) 334 + .map_or(NodeOutput::Unbuilt, |solid| { 335 + NodeOutput::Imported(solid.clone()) 336 + }), 337 + } 338 + } 339 + 340 + fn face_bound_sketch(memo: &Memo, value: &Sketch, face: FaceRef) -> EvaluatedSketch { 341 + let Some(entry) = memo.get(&face.label.feature) else { 342 + return EvaluatedSketch::PlaneUnresolved(RebuildError::DanglingReference( 343 + face.entity_ref(), 344 + )); 345 + }; 346 + let Some(solid) = output_solid(&entry.output) else { 347 + return EvaluatedSketch::PlaneUnresolved(RebuildError::UpstreamUnresolved); 348 + }; 349 + let Some(matched) = unique_face(solid, face.label) else { 350 + return EvaluatedSketch::PlaneUnresolved(RebuildError::DanglingReference( 351 + face.entity_ref(), 352 + )); 353 + }; 354 + match solid.face_plane_basis(matched.id()) { 355 + Some(basis) => evaluate_sketch(&value.with_plane(basis)), 356 + None => EvaluatedSketch::PlaneUnresolved(RebuildError::NonPlanarSketchTarget), 357 + } 358 + } 359 + 360 + fn output_solid(output: &NodeOutput) -> Option<&BrepSolid> { 361 + match output { 362 + NodeOutput::Extrude(extrude) => extrude.solid(), 363 + NodeOutput::Imported(solid) => Some(solid), 364 + NodeOutput::Datum | NodeOutput::Sketch(_) | NodeOutput::Unbuilt => None, 365 + } 366 + } 367 + 368 + fn parent_sketch<'memo>( 369 + tree: &FeatureTree, 370 + id: FeatureId, 371 + memo: &'memo Memo, 372 + ) -> Option<&'memo EvaluatedSketch> { 373 + tree.parents(id).into_iter().find_map(|parent| { 374 + memo.get(&parent).and_then(|entry| match &entry.output { 375 + NodeOutput::Sketch(sketch) => Some(sketch), 376 + NodeOutput::Datum 377 + | NodeOutput::Extrude(_) 378 + | NodeOutput::Imported(_) 379 + | NodeOutput::Unbuilt => None, 380 + }) 381 + }) 382 + } 383 + 384 + fn digest_of(output: &NodeOutput, skip: SkipState) -> ParentDigest { 385 + match skip { 386 + SkipState::Suppressed | SkipState::RolledBack => ParentDigest::Absent, 387 + SkipState::Active => match output { 388 + NodeOutput::Datum => ParentDigest::Datum, 389 + NodeOutput::Sketch(sketch) => ParentDigest::Sketch(sketch.clone()), 390 + NodeOutput::Extrude(extrude) => extrude 391 + .generation() 392 + .map_or(ParentDigest::Absent, ParentDigest::Geometry), 393 + NodeOutput::Imported(solid) => { 394 + ParentDigest::Geometry(GeometryGeneration::from_solid_key(solid.content_key())) 395 + } 396 + NodeOutput::Unbuilt => ParentDigest::Absent, 397 + }, 398 + } 399 + } 400 + 401 + fn status_of(output: &NodeOutput, skip: SkipState) -> RebuildStatus { 402 + match skip { 403 + SkipState::Suppressed | SkipState::RolledBack => RebuildStatus::UpToDate, 404 + SkipState::Active => match output { 405 + NodeOutput::Datum | NodeOutput::Imported(_) => RebuildStatus::UpToDate, 406 + NodeOutput::Unbuilt => RebuildStatus::NeedsRebuild, 407 + NodeOutput::Sketch(sketch) => match sketch { 408 + EvaluatedSketch::Solved(_) => RebuildStatus::UpToDate, 409 + EvaluatedSketch::Failed(_) => { 410 + RebuildStatus::Error(RebuildError::Build(BuildFailure::UnsolvedSketch)) 411 + } 412 + EvaluatedSketch::PlaneUnresolved(error) => RebuildStatus::Error(*error), 413 + }, 414 + NodeOutput::Extrude(extrude) => match extrude.result() { 415 + Ok(_) => RebuildStatus::UpToDate, 416 + Err(ExtrudeError::UnsolvedSketch(_)) => { 417 + RebuildStatus::Error(RebuildError::Build(BuildFailure::UnsolvedSketch)) 418 + } 419 + Err(ExtrudeError::Kernel(_)) => { 420 + RebuildStatus::Error(RebuildError::Build(BuildFailure::Kernel)) 421 + } 422 + Err(ExtrudeError::PlaneUnresolved(_)) => { 423 + RebuildStatus::Error(RebuildError::UpstreamUnresolved) 424 + } 425 + }, 426 + }, 427 + } 428 + } 429 + 430 + #[cfg(test)] 431 + mod tests { 432 + use std::collections::BTreeSet; 433 + 434 + use bone_types::{ 435 + BuildFailure, DocumentId, FaceRef, FeatureId, RebuildError, RebuildStatus, RollbackMarker, 436 + }; 437 + use proptest::prelude::*; 438 + 439 + use super::{EvaluatedModel, RebuildBudget, RebuildCost, RecomputeScope}; 440 + use crate::document::Document; 441 + use crate::test_support::{ 442 + blind_extrude, chain_handles, circle_sketch, end_cap_ref, full_model, open_chain_sketch, 443 + placeholder_fingerprint, push_chain, push_face_bound_chain, side_face_label, 444 + }; 445 + 446 + fn body_max_z(model: &EvaluatedModel, body: FeatureId) -> f64 { 447 + let Some(solid) = model.body(body) else { 448 + panic!("the body is built"); 449 + }; 450 + let Some(aabb) = solid.bounding_box() else { 451 + panic!("a built body has a bounding box"); 452 + }; 453 + aabb.max().coords_mm().2 454 + } 455 + 456 + fn body_min_z(model: &EvaluatedModel, body: FeatureId) -> f64 { 457 + let Some(solid) = model.body(body) else { 458 + panic!("the body is built"); 459 + }; 460 + let Some(aabb) = solid.bounding_box() else { 461 + panic!("a built body has a bounding box"); 462 + }; 463 + aabb.min().coords_mm().2 464 + } 465 + 466 + fn body_keys(model: &EvaluatedModel) -> Vec<(FeatureId, [u8; 16])> { 467 + model 468 + .bodies() 469 + .map(|(id, solid)| (id, solid.content_key().bytes())) 470 + .collect() 471 + } 472 + 473 + #[test] 474 + fn an_extrude_chain_builds_one_body() { 475 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 476 + let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0); 477 + let model = full_model(&document); 478 + 479 + assert!( 480 + model.body(chain.extrude).is_some(), 481 + "the extrude yields a body" 482 + ); 483 + assert_eq!(model.status(chain.extrude), Some(RebuildStatus::UpToDate)); 484 + assert_eq!(model.status(chain.sketch), Some(RebuildStatus::UpToDate)); 485 + assert_eq!(model.bodies().count(), 1, "one extrude, one body"); 486 + } 487 + 488 + #[test] 489 + fn recompute_is_idempotent_without_an_edit() { 490 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 491 + let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0); 492 + let mut model = full_model(&document); 493 + let first = model.built_at(chain.extrude); 494 + 495 + model.recompute( 496 + &document, 497 + &BTreeSet::new(), 498 + RollbackMarker::AtEnd, 499 + RecomputeScope::Edited(chain.sketch), 500 + ); 501 + 502 + assert_eq!( 503 + model.built_at(chain.extrude), 504 + first, 505 + "an edit scope with no real input change must not re-evaluate the subtree", 506 + ); 507 + } 508 + 509 + #[test] 510 + fn recompute_cascade_closes_a_raw_suppressed_sketch() { 511 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 512 + let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0); 513 + let mut model = EvaluatedModel::new(); 514 + let suppressed = BTreeSet::from([chain.sketch]); 515 + model.recompute( 516 + &document, 517 + &suppressed, 518 + RollbackMarker::AtEnd, 519 + RecomputeScope::Full, 520 + ); 521 + 522 + assert!(model.body(chain.extrude).is_none(), "no profile, no body"); 523 + assert_eq!( 524 + model.status(chain.sketch), 525 + Some(RebuildStatus::UpToDate), 526 + "a suppressed feature is not an error", 527 + ); 528 + assert_eq!( 529 + model.status(chain.extrude), 530 + Some(RebuildStatus::UpToDate), 531 + "recompute closes the suppressed set over descendants, so the child is suppressed not stranded", 532 + ); 533 + } 534 + 535 + #[test] 536 + fn a_cascaded_suppression_marks_the_extrude_suppressed_not_stranded() { 537 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 538 + let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0); 539 + document.suppress(chain.sketch); 540 + let mut model = EvaluatedModel::new(); 541 + model.recompute( 542 + &document, 543 + document.suppressed(), 544 + document.rollback(), 545 + RecomputeScope::Full, 546 + ); 547 + 548 + assert!( 549 + model.body(chain.extrude).is_none(), 550 + "a suppressed chain builds no body", 551 + ); 552 + assert_eq!( 553 + model.status(chain.sketch), 554 + Some(RebuildStatus::UpToDate), 555 + "a suppressed feature is not an error", 556 + ); 557 + assert_eq!( 558 + model.status(chain.extrude), 559 + Some(RebuildStatus::UpToDate), 560 + "the cascaded child is suppressed, not a stranded needs-rebuild", 561 + ); 562 + } 563 + 564 + #[test] 565 + fn rolling_back_above_an_extrude_drops_its_body() { 566 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 567 + let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0); 568 + let mut model = EvaluatedModel::new(); 569 + model.recompute( 570 + &document, 571 + &BTreeSet::new(), 572 + RollbackMarker::Above(chain.extrude), 573 + RecomputeScope::Full, 574 + ); 575 + 576 + assert!( 577 + model.body(chain.extrude).is_none(), 578 + "a rolled-back extrude is not evaluated", 579 + ); 580 + assert_eq!(model.bodies().count(), 0); 581 + assert_eq!(model.status(chain.sketch), Some(RebuildStatus::UpToDate)); 582 + } 583 + 584 + #[test] 585 + fn a_feature_both_rolled_back_and_suppressed_stays_up_to_date() { 586 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 587 + let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0); 588 + let mut model = EvaluatedModel::new(); 589 + let suppressed = BTreeSet::from([chain.extrude]); 590 + model.recompute( 591 + &document, 592 + &suppressed, 593 + RollbackMarker::Above(chain.extrude), 594 + RecomputeScope::Full, 595 + ); 596 + 597 + assert!( 598 + model.body(chain.extrude).is_none(), 599 + "a feature that is both rolled back and suppressed builds nothing", 600 + ); 601 + assert_eq!( 602 + model.status(chain.extrude), 603 + Some(RebuildStatus::UpToDate), 604 + "overlapping skip states collapse to a single up-to-date skip, never an error", 605 + ); 606 + } 607 + 608 + #[test] 609 + fn a_suppression_outside_the_edit_scope_still_takes_effect() { 610 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 611 + let a = push_chain(&mut document, "Sketch1", 5.0, 4.0); 612 + let b = push_chain(&mut document, "Sketch2", 6.0, 4.0); 613 + let mut model = full_model(&document); 614 + assert!( 615 + model.body(b.extrude).is_some(), 616 + "b builds before suppression" 617 + ); 618 + 619 + document.replace_sketch(a.sketch_id, circle_sketch(7.0)); 620 + let suppressed = BTreeSet::from([b.sketch]); 621 + model.recompute( 622 + &document, 623 + &suppressed, 624 + RollbackMarker::AtEnd, 625 + RecomputeScope::Edited(a.sketch), 626 + ); 627 + 628 + assert!( 629 + model.body(b.extrude).is_none(), 630 + "suppressing a feature outside the edit scope must still strand its body", 631 + ); 632 + assert_eq!( 633 + model.status(b.sketch), 634 + Some(RebuildStatus::UpToDate), 635 + "a suppressed feature is not an error", 636 + ); 637 + assert!( 638 + model.body(a.extrude).is_some(), 639 + "the edited chain still builds" 640 + ); 641 + } 642 + 643 + #[test] 644 + fn a_rollback_outside_the_edit_scope_still_takes_effect() { 645 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 646 + let a = push_chain(&mut document, "Sketch1", 5.0, 4.0); 647 + let b = push_chain(&mut document, "Sketch2", 6.0, 4.0); 648 + let mut model = full_model(&document); 649 + assert!(model.body(b.extrude).is_some(), "b builds before rollback"); 650 + 651 + document.replace_sketch(a.sketch_id, circle_sketch(7.0)); 652 + model.recompute( 653 + &document, 654 + &BTreeSet::new(), 655 + RollbackMarker::Above(b.extrude), 656 + RecomputeScope::Edited(a.sketch), 657 + ); 658 + 659 + assert!( 660 + model.body(b.extrude).is_none(), 661 + "rolling back a feature outside the edit scope must still drop its body", 662 + ); 663 + } 664 + 665 + #[test] 666 + fn an_unbuildable_profile_reports_a_build_error() { 667 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 668 + let sketch_id = document.allocate_sketch(); 669 + document.insert_sketch(sketch_id, "Sketch1".to_owned(), open_chain_sketch()); 670 + let extrude_id = document.commit_extrude(blind_extrude(sketch_id, 4.0)); 671 + let chain = chain_handles(&document, sketch_id, extrude_id); 672 + let model = full_model(&document); 673 + 674 + assert_eq!( 675 + model.status(chain.sketch), 676 + Some(RebuildStatus::UpToDate), 677 + "the open chain solves, only the profile is unbuildable", 678 + ); 679 + assert_eq!( 680 + model.status(chain.extrude), 681 + Some(RebuildStatus::Error(RebuildError::Build( 682 + BuildFailure::Kernel 683 + ))), 684 + "an open profile is a hard build error, not a stale mark", 685 + ); 686 + assert!(model.body(chain.extrude).is_none()); 687 + } 688 + 689 + proptest! { 690 + #[test] 691 + fn rebuilding_twice_is_bit_identical(radius in 1.0f64..20.0, depth in 1.0f64..20.0) { 692 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 693 + push_chain(&mut document, "Sketch1", radius, depth); 694 + let first = body_keys(&full_model(&document)); 695 + let second = body_keys(&full_model(&document)); 696 + prop_assert_eq!(first, second, "two rebuilds of one document must agree byte for byte"); 697 + } 698 + 699 + #[test] 700 + fn editing_one_chain_leaves_the_other_untouched( 701 + radius_a in 1.0f64..10.0, 702 + radius_b in 1.0f64..10.0, 703 + edited in 11.0f64..20.0, 704 + ) { 705 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 706 + let a = push_chain(&mut document, "Sketch1", radius_a, 4.0); 707 + let b = push_chain(&mut document, "Sketch2", radius_b, 4.0); 708 + let mut model = full_model(&document); 709 + 710 + let b_sketch_before = model.built_at(b.sketch); 711 + let b_extrude_before = model.built_at(b.extrude); 712 + let b_body_before = model.body(b.extrude).map(bone_kernel::BrepSolid::content_key); 713 + let a_extrude_before = model.built_at(a.extrude); 714 + 715 + document.replace_sketch(a.sketch_id, circle_sketch(edited)); 716 + model.recompute( 717 + &document, 718 + &BTreeSet::new(), 719 + RollbackMarker::AtEnd, 720 + RecomputeScope::Edited(a.sketch), 721 + ); 722 + 723 + prop_assert_eq!(model.built_at(b.sketch), b_sketch_before, "sibling sketch must not re-evaluate"); 724 + prop_assert_eq!(model.built_at(b.extrude), b_extrude_before, "sibling extrude must not re-evaluate"); 725 + prop_assert_eq!( 726 + model.body(b.extrude).map(bone_kernel::BrepSolid::content_key), 727 + b_body_before, 728 + "the untouched body must stay byte-identical", 729 + ); 730 + prop_assert!( 731 + model.built_at(a.extrude) > a_extrude_before, 732 + "the edited chain's extrude must rebuild", 733 + ); 734 + } 735 + } 736 + 737 + #[test] 738 + #[cfg_attr( 739 + debug_assertions, 740 + ignore = "frame budget assertions are only meaningful in release builds" 741 + )] 742 + fn single_edit_rebuild_under_frame_budget() { 743 + use std::time::{Duration, Instant}; 744 + 745 + const BASE_MM: f64 = 4.0; 746 + const STEP_MM: f64 = 0.5; 747 + const STEPS: u32 = 16; 748 + 749 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 750 + let chain = push_chain(&mut document, "Sketch1", 5.0, BASE_MM); 751 + let mut model = full_model(&document); 752 + 753 + let step = |document: &mut Document, model: &mut EvaluatedModel, radius_mm: f64| { 754 + document.replace_sketch(chain.sketch_id, circle_sketch(radius_mm)); 755 + let started = Instant::now(); 756 + model.recompute( 757 + document, 758 + &BTreeSet::new(), 759 + RollbackMarker::AtEnd, 760 + RecomputeScope::Edited(chain.sketch), 761 + ); 762 + let elapsed = started.elapsed(); 763 + assert!( 764 + model.body(chain.extrude).is_some(), 765 + "the edit rebuilds a body" 766 + ); 767 + elapsed 768 + }; 769 + 770 + let _warmup = step(&mut document, &mut model, 5.0); 771 + let durations: Vec<Duration> = (0..STEPS) 772 + .map(|i| step(&mut document, &mut model, 5.0 + STEP_MM * f64::from(i))) 773 + .collect(); 774 + let sorted = { 775 + let mut v = durations.clone(); 776 + v.sort_unstable(); 777 + v 778 + }; 779 + let median = sorted[sorted.len() / 2]; 780 + let budget = Duration::from_millis(16); 781 + assert!( 782 + median <= budget, 783 + "median single-edit recompute {median:?} exceeds {budget:?} frame budget; samples {durations:?}", 784 + ); 785 + } 786 + 787 + #[test] 788 + fn a_face_bound_sketch_builds_a_disjoint_body_on_the_cap() { 789 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 790 + let base = push_chain(&mut document, "Base", 6.0, 4.0); 791 + let probe = full_model(&document); 792 + let cap = end_cap_ref(&probe, base.extrude); 793 + let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0); 794 + let model = full_model(&document); 795 + 796 + let descendants = document.feature_tree().descendants(base.extrude); 797 + assert!( 798 + descendants.contains(&top.sketch) && descendants.contains(&top.extrude), 799 + "the face binding records a dependency from the cap's body onto the new chain", 800 + ); 801 + assert_eq!(model.status(top.sketch), Some(RebuildStatus::UpToDate)); 802 + assert!(model.body(top.extrude).is_some()); 803 + assert_eq!(model.bodies().count(), 2, "two separate bodies, no boolean"); 804 + 805 + let cap_z = body_max_z(&model, base.extrude); 806 + assert!( 807 + (body_min_z(&model, top.extrude) - cap_z).abs() < 1.0e-6, 808 + "the derived plane sits on the cap so the second body rises from it", 809 + ); 810 + } 811 + 812 + #[test] 813 + fn an_upstream_edit_moves_the_face_bound_body() { 814 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 815 + let base = push_chain(&mut document, "Base", 6.0, 4.0); 816 + let probe = full_model(&document); 817 + let cap = end_cap_ref(&probe, base.extrude); 818 + let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0); 819 + let mut model = full_model(&document); 820 + let before = model.built_at(top.extrude); 821 + 822 + document.insert_extrude(base.extrude_id, blind_extrude(base.sketch_id, 7.0)); 823 + model.recompute( 824 + &document, 825 + &BTreeSet::new(), 826 + RollbackMarker::AtEnd, 827 + RecomputeScope::Edited(base.extrude), 828 + ); 829 + 830 + assert!( 831 + model.built_at(top.extrude) > before, 832 + "deepening the base re-resolves the face and rebuilds the downstream body", 833 + ); 834 + assert!((body_max_z(&model, base.extrude) - 7.0).abs() < 1.0e-6); 835 + assert!( 836 + (body_min_z(&model, top.extrude) - 7.0).abs() < 1.0e-6, 837 + "the face-bound body follows the cap to its new height", 838 + ); 839 + } 840 + 841 + #[test] 842 + fn a_deleted_cap_dangles_the_face_bound_chain() { 843 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 844 + let base = push_chain(&mut document, "Base", 6.0, 4.0); 845 + let probe = full_model(&document); 846 + let cap = end_cap_ref(&probe, base.extrude); 847 + let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0); 848 + let mut model = full_model(&document); 849 + assert!(model.body(top.extrude).is_some(), "the chain builds first"); 850 + 851 + document.remove_extrude(base.extrude_id); 852 + model.recompute( 853 + &document, 854 + &BTreeSet::new(), 855 + RollbackMarker::AtEnd, 856 + RecomputeScope::Full, 857 + ); 858 + 859 + assert!( 860 + matches!( 861 + model.status(top.sketch), 862 + Some(RebuildStatus::Error(RebuildError::DanglingReference(_))) 863 + ), 864 + "a removed cap leaves the face-bound sketch dangling, never a silent wrong plane", 865 + ); 866 + assert_eq!( 867 + model.status(top.extrude), 868 + Some(RebuildStatus::Error(RebuildError::UpstreamUnresolved)), 869 + "the downstream extrude fails as upstream-unresolved, not as a dangling owner", 870 + ); 871 + assert!(model.body(top.extrude).is_none()); 872 + } 873 + 874 + #[test] 875 + fn a_non_planar_target_is_a_typed_rebuild_error() { 876 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 877 + let base = push_chain(&mut document, "Base", 6.0, 4.0); 878 + let probe = full_model(&document); 879 + let side = side_face_label(&probe, base.extrude); 880 + let top = push_face_bound_chain( 881 + &mut document, 882 + "Top", 883 + FaceRef::new(side, placeholder_fingerprint()), 884 + 1.0, 885 + 2.0, 886 + ); 887 + let model = full_model(&document); 888 + 889 + assert_eq!( 890 + model.status(top.sketch), 891 + Some(RebuildStatus::Error(RebuildError::NonPlanarSketchTarget)), 892 + "a sketch bound to a cylindrical side fails with the non-planar contract", 893 + ); 894 + assert!(model.body(top.extrude).is_none()); 895 + } 896 + 897 + #[test] 898 + fn rebuild_cost_sums_topology_and_gates_on_the_budget() { 899 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 900 + push_chain(&mut document, "Sketch1", 5.0, 4.0); 901 + let model = full_model(&document); 902 + let cost = model.rebuild_cost(); 903 + 904 + assert!( 905 + cost > RebuildCost::default(), 906 + "a built body contributes faces and edges to the cost", 907 + ); 908 + assert!( 909 + cost.within(RebuildBudget::INTERACTIVE), 910 + "a single extrude stays under the interactive budget, so the rebuild is eager", 911 + ); 912 + assert!( 913 + !cost.within(RebuildBudget::new(0)), 914 + "a zero budget forces the deferred path for any non-empty model", 915 + ); 916 + assert_eq!( 917 + EvaluatedModel::new().rebuild_cost(), 918 + RebuildCost::default(), 919 + "an empty model costs nothing to rebuild", 920 + ); 921 + } 922 + 923 + proptest! { 924 + #[test] 925 + fn the_face_bound_body_tracks_the_cap_across_depth_edits( 926 + base_depth in 2.0f64..12.0, 927 + edited_depth in 2.0f64..12.0, 928 + ) { 929 + let mut document = Document::new(DocumentId::default(), "doc".to_owned()); 930 + let base = push_chain(&mut document, "Base", 6.0, base_depth); 931 + let probe = full_model(&document); 932 + let cap = end_cap_ref(&probe, base.extrude); 933 + let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0); 934 + let mut model = full_model(&document); 935 + 936 + document.insert_extrude(base.extrude_id, blind_extrude(base.sketch_id, edited_depth)); 937 + model.recompute( 938 + &document, 939 + &BTreeSet::new(), 940 + RollbackMarker::AtEnd, 941 + RecomputeScope::Edited(base.extrude), 942 + ); 943 + 944 + prop_assert!( 945 + (body_min_z(&model, top.extrude) - edited_depth).abs() < 1.0e-6, 946 + "the face-bound body always rises from the current cap height", 947 + ); 948 + } 949 + } 950 + }
+8
crates/bone-document/src/sketch/mod.rs
··· 86 86 } 87 87 88 88 #[must_use] 89 + pub fn with_plane(&self, plane: SketchPlaneBasis) -> Self { 90 + Self { 91 + plane, 92 + ..self.clone() 93 + } 94 + } 95 + 96 + #[must_use] 89 97 pub fn entities(&self) -> &EntityMap { 90 98 &self.entities 91 99 }
+205
crates/bone-document/src/test_support.rs
··· 1 + use std::collections::BTreeSet; 2 + 3 + use bone_kernel::{ 4 + BrepFace, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, 5 + }; 6 + use bone_types::{ 7 + ExtrudeId, FaceFingerprint, FaceLabel, FaceRef, FaceRole, FeatureId, Length, Plane3, Point2, 8 + Point3, PositiveLength, Resolution, RollbackMarker, SketchDimensionId, SketchEntityId, 9 + SketchId, SketchPlaneBasis, Tolerance, UnitVec3, millimeter, 10 + }; 11 + 12 + use crate::Sketch; 13 + use crate::document::Document; 14 + use crate::matcher::resolve_face; 15 + use crate::recompute::{EvaluatedModel, RecomputeScope}; 16 + use crate::sketch::{DimensionKind, EditOutcome, SketchDimension, SketchEdit, SketchEntity}; 17 + 18 + pub(crate) fn xy_basis() -> SketchPlaneBasis { 19 + let Ok(basis) = SketchPlaneBasis::new( 20 + Point3::origin(), 21 + UnitVec3::x_axis(), 22 + UnitVec3::y_axis(), 23 + Tolerance::new(1e-9), 24 + ) else { 25 + panic!("xy plane basis is orthogonal"); 26 + }; 27 + basis 28 + } 29 + 30 + pub(crate) fn add_point(sketch: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { 31 + let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( 32 + SketchEntity::point(Point2::from_mm(x, y)), 33 + )) else { 34 + panic!("add point"); 35 + }; 36 + (next, id) 37 + } 38 + 39 + fn circle_entities(radius_mm: f64) -> (Sketch, SketchEntityId) { 40 + let (sketch, center) = add_point(Sketch::new(xy_basis()), 0.0, 0.0); 41 + let Ok((next, EditOutcome::Entity(circle))) = sketch.apply(SketchEdit::AddEntity( 42 + SketchEntity::circle(center, Length::new::<millimeter>(radius_mm), false), 43 + )) else { 44 + panic!("add circle"); 45 + }; 46 + (next, circle) 47 + } 48 + 49 + pub(crate) fn circle_sketch(radius_mm: f64) -> Sketch { 50 + circle_entities(radius_mm).0 51 + } 52 + 53 + pub(crate) fn circle_with_radius_dim( 54 + radius_mm: f64, 55 + ) -> (Sketch, SketchEntityId, SketchDimensionId) { 56 + let (sketch, circle) = circle_entities(radius_mm); 57 + let Ok((next, EditOutcome::Dimension(dim))) = 58 + sketch.apply(SketchEdit::AddDimension(SketchDimension::Radius { 59 + target: circle, 60 + value: Length::new::<millimeter>(radius_mm), 61 + kind: DimensionKind::Driving, 62 + })) 63 + else { 64 + panic!("add radius dimension"); 65 + }; 66 + (next, circle, dim) 67 + } 68 + 69 + pub(crate) fn open_chain_sketch() -> Sketch { 70 + let add_line = |sketch: Sketch, from: SketchEntityId, to: SketchEntityId| { 71 + let Ok((next, _)) = 72 + sketch.apply(SketchEdit::AddEntity(SketchEntity::line(from, to, false))) 73 + else { 74 + panic!("add line"); 75 + }; 76 + next 77 + }; 78 + let (sketch, a) = add_point(Sketch::new(xy_basis()), 0.0, 0.0); 79 + let (sketch, b) = add_point(sketch, 10.0, 0.0); 80 + let (sketch, c) = add_point(sketch, 10.0, 5.0); 81 + add_line(add_line(sketch, a, b), b, c) 82 + } 83 + 84 + pub(crate) fn blind_extrude(sketch: SketchId, depth_mm: f64) -> ExtrudeFeature { 85 + let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(depth_mm)) else { 86 + panic!("{depth_mm} mm is a positive depth"); 87 + }; 88 + ExtrudeFeature { 89 + sketch, 90 + direction: ExtrudeDirection::Normal { 91 + sense: ExtrudeSense::Forward, 92 + }, 93 + end_condition: ExtrudeEndCondition::Blind { depth }, 94 + draft: None, 95 + thin_wall: None, 96 + merge_result: MergeResult::Separate, 97 + } 98 + } 99 + 100 + pub(crate) struct Chain { 101 + pub(crate) sketch: FeatureId, 102 + pub(crate) extrude: FeatureId, 103 + pub(crate) sketch_id: SketchId, 104 + pub(crate) extrude_id: ExtrudeId, 105 + } 106 + 107 + pub(crate) fn chain_handles( 108 + document: &Document, 109 + sketch_id: SketchId, 110 + extrude_id: ExtrudeId, 111 + ) -> Chain { 112 + let tree = document.feature_tree(); 113 + let (Some(sketch), Some(extrude)) = ( 114 + tree.feature_of_sketch(sketch_id), 115 + tree.feature_of_extrude(extrude_id), 116 + ) else { 117 + panic!("inserted chain resolves to feature ids"); 118 + }; 119 + Chain { 120 + sketch, 121 + extrude, 122 + sketch_id, 123 + extrude_id, 124 + } 125 + } 126 + 127 + pub(crate) fn push_chain( 128 + document: &mut Document, 129 + label: &str, 130 + radius_mm: f64, 131 + depth_mm: f64, 132 + ) -> Chain { 133 + let sketch_id = document.allocate_sketch(); 134 + document.insert_sketch(sketch_id, label.to_owned(), circle_sketch(radius_mm)); 135 + let extrude_id = document.commit_extrude(blind_extrude(sketch_id, depth_mm)); 136 + chain_handles(document, sketch_id, extrude_id) 137 + } 138 + 139 + pub(crate) fn placeholder_fingerprint() -> FaceFingerprint { 140 + FaceFingerprint { 141 + plane: Plane3::new_unchecked(Point3::origin(), UnitVec3::x_axis(), UnitVec3::y_axis()), 142 + centroid: Point3::origin(), 143 + } 144 + } 145 + 146 + fn face_label_of( 147 + model: &EvaluatedModel, 148 + body: FeatureId, 149 + matches_role: impl Fn(FaceRole) -> bool, 150 + ) -> FaceLabel { 151 + let Some(solid) = model.body(body) else { 152 + panic!("the body is built"); 153 + }; 154 + let Some(label) = solid 155 + .iter_faces() 156 + .map(BrepFace::label) 157 + .find(|label| matches_role(label.role)) 158 + else { 159 + panic!("the body carries the requested face role"); 160 + }; 161 + label 162 + } 163 + 164 + pub(crate) fn end_cap_ref(model: &EvaluatedModel, body: FeatureId) -> FaceRef { 165 + let label = face_label_of(model, body, |role| matches!(role, FaceRole::EndCap)); 166 + let Resolution::Resolved(resolved) = resolve_face(model, label, placeholder_fingerprint()) 167 + else { 168 + panic!("the end cap resolves on the model that built it"); 169 + }; 170 + let Some(face_ref) = model.face_ref(resolved) else { 171 + panic!("a planar cap yields a face reference"); 172 + }; 173 + face_ref 174 + } 175 + 176 + pub(crate) fn side_face_label(model: &EvaluatedModel, body: FeatureId) -> FaceLabel { 177 + face_label_of(model, body, |role| matches!(role, FaceRole::Side { .. })) 178 + } 179 + 180 + pub(crate) fn push_face_bound_chain( 181 + document: &mut Document, 182 + label: &str, 183 + face: FaceRef, 184 + radius_mm: f64, 185 + depth_mm: f64, 186 + ) -> Chain { 187 + let sketch_id = document.allocate_sketch(); 188 + document.insert_sketch(sketch_id, label.to_owned(), circle_sketch(radius_mm)); 189 + let Ok(()) = document.bind_sketch_to_face(sketch_id, face) else { 190 + panic!("the face binding is acyclic"); 191 + }; 192 + let extrude_id = document.commit_extrude(blind_extrude(sketch_id, depth_mm)); 193 + chain_handles(document, sketch_id, extrude_id) 194 + } 195 + 196 + pub(crate) fn full_model(document: &Document) -> EvaluatedModel { 197 + let mut model = EvaluatedModel::new(); 198 + model.recompute( 199 + document, 200 + &BTreeSet::new(), 201 + RollbackMarker::AtEnd, 202 + RecomputeScope::Full, 203 + ); 204 + model 205 + }
+1 -1
crates/bone-document/src/undo.rs
··· 23 23 24 24 pub fn record(&mut self, previous: Document) { 25 25 self.future.clear(); 26 - if self.past.len() == self.capacity.get() { 26 + while self.past.len() >= self.capacity.get() { 27 27 self.past.pop_front(); 28 28 } 29 29 self.past.push_back(previous);
+11 -103
crates/bone-document/tests/evaluator.rs
··· 1 1 use bone_document::{ 2 - DimensionKind, DimensionValue, Document, EditOutcome, EvaluatedSketch, FeatureCache, 3 - FeatureNode, Sketch, SketchDimension, SketchEdit, SketchEntity, SketchRelation, 4 - evaluate_extrude, evaluate_sketch, 2 + DimensionKind, DimensionValue, Document, EditOutcome, EvaluatedSketch, FeatureNode, Sketch, 3 + SketchDimension, SketchEdit, SketchEntity, SketchRelation, evaluate_extrude, evaluate_sketch, 5 4 }; 6 5 use bone_kernel::{ 7 6 BrepEdge, BrepFace, BrepSolid, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ··· 137 136 } 138 137 139 138 #[test] 140 - fn feature_cache_reuses_solution_for_identical_input() { 141 - let sketch = horizontal_line_sketch(10.0); 142 - let mut cache = FeatureCache::new(); 143 - let fid = feature_id(7); 144 - let first = cache.evaluate(fid, &sketch).clone(); 145 - let second = cache.evaluate(fid, &sketch).clone(); 146 - assert_eq!(first, second); 147 - assert_eq!(cache.len(), 1); 148 - assert!(cache.contains(fid)); 149 - } 150 - 151 - #[test] 152 - fn feature_cache_refreshes_on_input_change() { 153 - let mut cache = FeatureCache::new(); 154 - let fid = feature_id(7); 155 - let first = cache.evaluate(fid, &horizontal_line_sketch(10.0)).clone(); 156 - let second = cache.evaluate(fid, &horizontal_line_sketch(20.0)).clone(); 157 - let EvaluatedSketch::Solved(first_solved) = first else { 158 - panic!("first Solved"); 159 - }; 160 - let EvaluatedSketch::Solved(second_solved) = second else { 161 - panic!("second Solved"); 162 - }; 163 - assert_ne!(first_solved, second_solved); 164 - assert_eq!(cache.len(), 1); 165 - } 166 - 167 - #[test] 168 - fn feature_cache_keeps_entries_per_feature() { 169 - let mut cache = FeatureCache::new(); 170 - let a = feature_id(1); 171 - let b = feature_id(2); 172 - cache.evaluate(a, &horizontal_line_sketch(10.0)); 173 - cache.evaluate(b, &horizontal_line_sketch(20.0)); 174 - assert_eq!(cache.len(), 2); 175 - assert!(cache.contains(a)); 176 - assert!(cache.contains(b)); 177 - } 178 - 179 - #[test] 180 - fn feature_cache_invalidate_drops_entry() { 181 - let mut cache = FeatureCache::new(); 182 - let fid = feature_id(7); 183 - cache.evaluate(fid, &horizontal_line_sketch(10.0)); 184 - assert!(cache.invalidate(fid)); 185 - assert!(!cache.contains(fid)); 186 - assert!(!cache.invalidate(fid)); 187 - } 188 - 189 - #[test] 190 - fn feature_cache_retain_drops_absent_features() { 191 - let mut cache = FeatureCache::new(); 192 - let keep = feature_id(1); 193 - let drop = feature_id(2); 194 - cache.evaluate(keep, &horizontal_line_sketch(10.0)); 195 - cache.evaluate(drop, &horizontal_line_sketch(20.0)); 196 - cache.retain([keep]); 197 - assert!(cache.contains(keep)); 198 - assert!(!cache.contains(drop)); 199 - assert_eq!(cache.len(), 1); 200 - } 201 - 202 - #[test] 203 139 fn document_resolves_feature_id_back_to_sketch() { 204 140 let mut doc = Document::new(document_id(1), "d".to_owned()); 205 141 let sid = sketch_id(7); ··· 220 156 } 221 157 222 158 #[test] 223 - fn feature_cache_refreshes_when_feature_id_is_reused() { 159 + fn removed_feature_id_is_never_reused() { 224 160 let mut doc = Document::new(document_id(1), "d".to_owned()); 225 161 let sid_a = sketch_id(10); 226 162 let sid_b = sketch_id(11); ··· 231 167 panic!("A feature present"); 232 168 }; 233 169 234 - let mut cache = FeatureCache::new(); 235 170 let Some(sketch_a) = doc.sketch(sid_a) else { 236 171 panic!("A in map"); 237 172 }; 238 - let EvaluatedSketch::Solved(solved_a) = cache.evaluate(fid_a, sketch_a).clone() else { 173 + let EvaluatedSketch::Solved(solved_a) = evaluate_sketch(sketch_a) else { 239 174 panic!("A solves"); 240 175 }; 241 176 ··· 246 181 }) else { 247 182 panic!("B feature present"); 248 183 }; 249 - assert_eq!( 184 + assert_ne!( 250 185 fid_a, fid_b, 251 - "precondition: FeatureTree reuses the id of a removed feature" 186 + "a removed feature id is never handed to a later feature" 252 187 ); 253 188 254 189 let Some(sketch_b) = doc.sketch(sid_b) else { 255 190 panic!("B in map"); 256 191 }; 257 - let EvaluatedSketch::Solved(solved_b) = cache.evaluate(fid_b, sketch_b).clone() else { 192 + let EvaluatedSketch::Solved(solved_b) = evaluate_sketch(sketch_b) else { 258 193 panic!("B solves"); 259 194 }; 260 - assert_ne!(solved_a, solved_b, "cache must refresh on reused FeatureId"); 195 + assert_ne!( 196 + solved_a, solved_b, 197 + "distinct feature ids cache distinct geometry" 198 + ); 261 199 } 262 200 263 201 fn dimensioned_rectangle(width_mm: f64, height_mm: f64) -> (Sketch, SketchDimensionId) { ··· 350 288 assert!(extrude.result().is_err()); 351 289 assert!(extrude.solid().is_none()); 352 290 assert!(extrude.generation().is_none()); 353 - } 354 - 355 - #[test] 356 - fn feature_cache_extrude_refreshes_when_sketch_changes() { 357 - let mut cache = FeatureCache::new(); 358 - let sketch_feature = feature_id(20); 359 - let extrude_feature = feature_id(21); 360 - let feature = blind(8.0); 361 - 362 - let (narrow, _) = dimensioned_rectangle(10.0, 5.0); 363 - let solved_narrow = cache.evaluate(sketch_feature, &narrow).clone(); 364 - let first = cache 365 - .evaluate_extrude(extrude_feature, &solved_narrow, &feature) 366 - .generation(); 367 - let cached = cache 368 - .evaluate_extrude(extrude_feature, &solved_narrow, &feature) 369 - .generation(); 370 - assert_eq!(first, cached, "identical input hits the cache"); 371 - 372 - let (wide, _) = dimensioned_rectangle(25.0, 5.0); 373 - let solved_wide = cache.evaluate(sketch_feature, &wide).clone(); 374 - let refreshed = cache 375 - .evaluate_extrude(extrude_feature, &solved_wide, &feature) 376 - .generation(); 377 - assert_ne!( 378 - first, refreshed, 379 - "a changed upstream sketch refreshes the extrude" 380 - ); 381 - assert!(cache.contains(extrude_feature)); 382 - assert_eq!(cache.len(), 2); 383 291 } 384 292 385 293 proptest::proptest! {
+4
crates/bone-document/tests/folder_jj.rs
··· 172 172 "expected sketch file in diff:\n{diff}" 173 173 ); 174 174 assert!( 175 + !diff.contains("document.ron"), 176 + "a dimension edit must leave the recipe stable, only the sketch file changes:\n{diff}" 177 + ); 178 + assert!( 175 179 diff.contains('+') && diff.contains('-'), 176 180 "expected text diff markers:\n{diff}" 177 181 );
+332 -20
crates/bone-document/tests/folder_roundtrip.rs
··· 7 7 BrepFace, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, 8 8 }; 9 9 use bone_types::{ 10 - Angle, DocumentId, ExtrudeId, Length, Point2, Point3, PositiveLength, SketchEntityId, SketchId, 11 - SketchPlaneBasis, Tolerance, UnitVec3, degree, millimeter, 10 + Angle, DocumentId, ExtrudeId, FaceFingerprint, FaceLabel, FaceRef, FaceRole, FeatureId, Length, 11 + Plane3, Point2, Point3, PositiveLength, RollbackMarker, SketchEntityId, SketchId, 12 + SketchPlaneBasis, SuppressionState, Tolerance, UnitVec3, degree, millimeter, 12 13 }; 13 14 use slotmap::{Key, KeyData}; 14 15 use tempfile::{TempDir, tempdir}; ··· 52 53 ExtrudeId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 53 54 } 54 55 56 + fn feature_id(idx: u32) -> FeatureId { 57 + FeatureId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 58 + } 59 + 55 60 fn blind_extrude(sketch: SketchId) -> ExtrudeFeature { 56 61 let Ok(depth) = PositiveLength::new(mm(10.0)) else { 57 62 panic!("positive depth"); ··· 68 73 } 69 74 } 70 75 76 + fn separate_extrude(sketch: SketchId) -> ExtrudeFeature { 77 + ExtrudeFeature { 78 + merge_result: MergeResult::Separate, 79 + ..blind_extrude(sketch) 80 + } 81 + } 82 + 83 + fn sample_face_ref(feature: FeatureId) -> FaceRef { 84 + FaceRef::new( 85 + FaceLabel { 86 + feature, 87 + role: FaceRole::EndCap, 88 + }, 89 + FaceFingerprint { 90 + plane: Plane3::new_unchecked( 91 + Point3::from_mm(0.0, 0.0, 10.0), 92 + UnitVec3::x_axis(), 93 + UnitVec3::y_axis(), 94 + ), 95 + centroid: Point3::from_mm(0.0, 0.0, 10.0), 96 + }, 97 + ) 98 + } 99 + 71 100 fn rectangle() -> Sketch { 72 101 let script = [ 73 102 SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), ··· 107 136 }; 108 137 } 109 138 139 + fn folder_bytes(root: &std::path::Path) -> std::collections::BTreeMap<std::path::PathBuf, Vec<u8>> { 140 + let Ok(entries) = std::fs::read_dir(root) else { 141 + panic!("read dir {}", root.display()); 142 + }; 143 + entries 144 + .map(|entry| { 145 + let Ok(entry) = entry else { 146 + panic!("dir entry under {}", root.display()); 147 + }; 148 + entry.path() 149 + }) 150 + .flat_map(|path| { 151 + if path.is_dir() { 152 + folder_bytes(&path).into_iter().collect::<Vec<_>>() 153 + } else { 154 + let Ok(bytes) = std::fs::read(&path) else { 155 + panic!("read {}", path.display()); 156 + }; 157 + vec![(path, bytes)] 158 + } 159 + }) 160 + .collect() 161 + } 162 + 110 163 #[test] 111 164 fn rectangle_roundtrips_through_folder() { 112 165 let dir = ok_dir(); ··· 229 282 else { 230 283 panic!("expected UnsupportedMajor"); 231 284 }; 232 - assert_eq!(found, SchemaVersion::new(9999, 2)); 285 + assert_eq!( 286 + found, 287 + SchemaVersion::new(9999, SchemaHeader::BONE_DOCUMENT_MINOR) 288 + ); 233 289 assert_eq!( 234 290 supported, 235 291 SchemaVersion::new( ··· 237 293 SchemaHeader::BONE_DOCUMENT_MINOR 238 294 ) 239 295 ); 296 + } 297 + 298 + #[test] 299 + fn load_reports_unsupported_major_ahead_of_unknown_fields() { 300 + let dir = ok_dir(); 301 + let folder = DocumentFolder::new(dir.path().join("future_major.bone")); 302 + let mut doc = Document::new(document_id(1), "future".to_owned()); 303 + doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle()); 304 + assert_save(&doc, &folder); 305 + 306 + let header_path = folder.document_file(); 307 + let text = read_file(&header_path); 308 + let bumped = text.replace("major: 1,", "major: 9999,"); 309 + assert_ne!(bumped, text, "schema major pattern missing"); 310 + let injected = bumped.replace( 311 + "DocumentHeader(\n schema:", 312 + "DocumentHeader(\n mystery_field: 7,\n schema:", 313 + ); 314 + assert_ne!(injected, bumped, "document header anchor missing"); 315 + write_file(&header_path, &injected); 316 + 317 + let result = load(&folder).map_err(bone_document::FolderError::into_kind); 318 + let Err(bone_document::FolderErrorKind::UnsupportedMajor { found, .. }) = result else { 319 + panic!("a newer major must refuse outright, not surface a raw field error: {result:?}"); 320 + }; 321 + assert_eq!(found.major, 9999); 240 322 } 241 323 242 324 #[test] ··· 347 429 } 348 430 349 431 #[test] 350 - fn feature_tree_bytes_survive_insert_remove_insert_cycle() { 432 + fn folder_reserves_removed_ids_across_roundtrip() { 351 433 let dir = ok_dir(); 352 - let with_history = DocumentFolder::new(dir.path().join("hist.bone")); 353 - let fresh = DocumentFolder::new(dir.path().join("fresh.bone")); 354 - 355 - let rect = rectangle(); 356 - let mut doc_a = Document::new(document_id(1), "doc".to_owned()); 357 - doc_a.insert_sketch(sketch_id(1), "S".to_owned(), rect.clone()); 358 - doc_a.insert_sketch(sketch_id(2), "T".to_owned(), rect.clone()); 359 - doc_a.remove_sketch(sketch_id(2)); 360 - assert_save(&doc_a, &with_history); 434 + let folder = DocumentFolder::new(dir.path().join("hist.bone")); 361 435 362 - let mut doc_b = Document::new(document_id(1), "doc".to_owned()); 363 - doc_b.insert_sketch(sketch_id(1), "S".to_owned(), rect); 364 - assert_save(&doc_b, &fresh); 436 + let mut doc = Document::new(document_id(1), "doc".to_owned()); 437 + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 438 + doc.insert_sketch(sketch_id(2), "T".to_owned(), rectangle()); 439 + let Some(removed_feature) = doc.feature_tree().feature_of_sketch(sketch_id(2)) else { 440 + panic!("feature node for sketch 2"); 441 + }; 442 + doc.remove_sketch(sketch_id(2)); 443 + assert_save(&doc, &folder); 365 444 366 - let bytes_a = read_file(&with_history.document_file()); 367 - let bytes_b = read_file(&fresh.document_file()); 445 + let Ok(mut reloaded) = load(&folder) else { 446 + panic!("reload"); 447 + }; 368 448 assert_eq!( 369 - bytes_a, bytes_b, 370 - "remove then re-insert leaks tombstones into document.ron" 449 + reloaded.registry().order().len(), 450 + 1, 451 + "the recipe lists only the live sketch, not a tombstone vector" 452 + ); 453 + 454 + let reborn_sketch = reloaded.allocate_sketch(); 455 + assert_ne!( 456 + reborn_sketch, 457 + sketch_id(2), 458 + "a removed sketch id stays reserved across the folder round-trip" 459 + ); 460 + reloaded.insert_sketch(reborn_sketch, "U".to_owned(), rectangle()); 461 + let Some(reborn_feature) = reloaded.feature_tree().feature_of_sketch(reborn_sketch) else { 462 + panic!("feature node for the reborn sketch"); 463 + }; 464 + assert_ne!( 465 + reborn_feature, removed_feature, 466 + "a removed feature id stays reserved across the folder round-trip" 371 467 ); 372 468 } 373 469 ··· 562 658 } 563 659 564 660 #[test] 661 + fn load_refuses_dangling_rollback_marker() { 662 + let dir = ok_dir(); 663 + let folder = DocumentFolder::new(dir.path().join("dangle_rollback.bone")); 664 + let mut doc = Document::new(document_id(1), "dangle".to_owned()); 665 + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 666 + assert_save(&doc, &folder); 667 + 668 + let bogus = feature_id(9999); 669 + patch_header(&folder.document_file(), |h| { 670 + h.rollback = RollbackMarker::Above(bogus); 671 + }); 672 + 673 + let result = load(&folder).map_err(bone_document::FolderError::into_kind); 674 + let Err(bone_document::FolderErrorKind::DanglingRollback { id }) = result else { 675 + panic!("expected DanglingRollback, got {result:?}"); 676 + }; 677 + assert_eq!(id, bogus); 678 + } 679 + 680 + #[test] 681 + fn load_refuses_rollback_marker_on_a_datum() { 682 + let dir = ok_dir(); 683 + let folder = DocumentFolder::new(dir.path().join("rollback_datum.bone")); 684 + let mut doc = Document::new(document_id(1), "datum".to_owned()); 685 + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 686 + let Some((datum, _)) = doc.feature_tree().iter().next() else { 687 + panic!("the seeded tree opens with a datum"); 688 + }; 689 + assert_save(&doc, &folder); 690 + 691 + patch_header(&folder.document_file(), |h| { 692 + h.rollback = RollbackMarker::Above(datum); 693 + }); 694 + 695 + let result = load(&folder).map_err(bone_document::FolderError::into_kind); 696 + let Err(bone_document::FolderErrorKind::RollbackOnDatum { id }) = result else { 697 + panic!("expected RollbackOnDatum, got {result:?}"); 698 + }; 699 + assert_eq!(id, datum); 700 + } 701 + 702 + #[test] 703 + fn load_refuses_dangling_suppressed_feature() { 704 + let dir = ok_dir(); 705 + let folder = DocumentFolder::new(dir.path().join("dangle_suppressed.bone")); 706 + let mut doc = Document::new(document_id(1), "dangle".to_owned()); 707 + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 708 + assert_save(&doc, &folder); 709 + 710 + let bogus = feature_id(9999); 711 + patch_header(&folder.document_file(), |h| { 712 + h.suppressed.insert(bogus); 713 + }); 714 + 715 + let result = load(&folder).map_err(bone_document::FolderError::into_kind); 716 + let Err(bone_document::FolderErrorKind::DanglingSuppressed { id }) = result else { 717 + panic!("expected DanglingSuppressed, got {result:?}"); 718 + }; 719 + assert_eq!(id, bogus); 720 + } 721 + 722 + #[test] 723 + fn load_refuses_suppressed_datum_feature() { 724 + let dir = ok_dir(); 725 + let folder = DocumentFolder::new(dir.path().join("suppressed_datum.bone")); 726 + let mut doc = Document::new(document_id(1), "datum".to_owned()); 727 + doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 728 + let Some((datum, _)) = doc.feature_tree().iter().next() else { 729 + panic!("the seeded tree opens with a datum"); 730 + }; 731 + assert_save(&doc, &folder); 732 + 733 + patch_header(&folder.document_file(), |h| { 734 + h.suppressed.insert(datum); 735 + }); 736 + 737 + let result = load(&folder).map_err(bone_document::FolderError::into_kind); 738 + let Err(bone_document::FolderErrorKind::SuppressedDatum { id }) = result else { 739 + panic!("expected SuppressedDatum, got {result:?}"); 740 + }; 741 + assert_eq!(id, datum); 742 + } 743 + 744 + #[test] 565 745 fn extrude_roundtrips_through_folder() { 566 746 let dir = ok_dir(); 567 747 let folder = DocumentFolder::new(dir.path().join("extrude.bone")); ··· 809 989 ); 810 990 } 811 991 992 + fn sample_solid(feature: FeatureId) -> bone_kernel::BrepSolid { 993 + let evaluated = evaluate_sketch(&closed_rectangle(10.0)); 994 + let extruded = evaluate_extrude(feature, &evaluated, &blind_extrude(SketchId::null())); 995 + let Some(solid) = extruded.solid() else { 996 + panic!("rectangle extrudes to a solid"); 997 + }; 998 + solid.clone() 999 + } 1000 + 1001 + #[test] 1002 + fn imported_body_id_stays_reserved_across_roundtrip() { 1003 + let dir = ok_dir(); 1004 + let folder = DocumentFolder::new(dir.path().join("body.bone")); 1005 + 1006 + let mut doc = Document::new(document_id(1), "body".to_owned()); 1007 + let Ok((_, first)) = doc.import_body(|feature| Ok::<_, ()>(sample_solid(feature))) else { 1008 + panic!("import the first body"); 1009 + }; 1010 + assert_save(&doc, &folder); 1011 + 1012 + let mut reloaded = assert_load(&folder); 1013 + let Ok((_, second)) = reloaded.import_body(|feature| Ok::<_, ()>(sample_solid(feature))) else { 1014 + panic!("import a body after reload"); 1015 + }; 1016 + assert_ne!( 1017 + first, second, 1018 + "a body id allocated before the save is not handed out again after the load" 1019 + ); 1020 + } 1021 + 812 1022 #[test] 813 1023 fn blob_path_matches_locked_shape() { 814 1024 let folder = DocumentFolder::new("/tmp/part.bone"); ··· 835 1045 assert_eq!(components[0], hex[..2]); 836 1046 assert_eq!(stem, &hex[2..]); 837 1047 } 1048 + 1049 + #[test] 1050 + fn phase_three_history_state_round_trips_through_the_folder() { 1051 + let dir = ok_dir(); 1052 + let folder = DocumentFolder::new(dir.path().join("phase3.bone")); 1053 + 1054 + let base_sketch = sketch_id(1); 1055 + let base_extrude = extrude_id(1); 1056 + let cap_sketch = sketch_id(2); 1057 + let cap_extrude = extrude_id(2); 1058 + 1059 + let mut doc = Document::new(document_id(1), "phase3".to_owned()); 1060 + doc.insert_sketch(base_sketch, "Base".to_owned(), rectangle()); 1061 + doc.insert_extrude(base_extrude, blind_extrude(base_sketch)); 1062 + let Some(base_body) = doc.feature_tree().feature_of_extrude(base_extrude) else { 1063 + panic!("base extrude feature present"); 1064 + }; 1065 + 1066 + doc.insert_sketch(cap_sketch, "Top".to_owned(), rectangle()); 1067 + let face = sample_face_ref(base_body); 1068 + let Ok(()) = doc.bind_sketch_to_face(cap_sketch, face) else { 1069 + panic!("the face binding is acyclic"); 1070 + }; 1071 + doc.insert_extrude(cap_extrude, separate_extrude(cap_sketch)); 1072 + 1073 + let Some(cap_sketch_feature) = doc.feature_tree().feature_of_sketch(cap_sketch) else { 1074 + panic!("cap sketch feature present"); 1075 + }; 1076 + let Some(cap_extrude_feature) = doc.feature_tree().feature_of_extrude(cap_extrude) else { 1077 + panic!("cap extrude feature present"); 1078 + }; 1079 + doc.roll_to_here(cap_sketch_feature); 1080 + doc.suppress(cap_extrude_feature); 1081 + 1082 + assert_save(&doc, &folder); 1083 + let loaded = assert_load(&folder); 1084 + 1085 + assert_eq!(loaded, doc, "every phase-3 state survives the round-trip"); 1086 + assert_eq!( 1087 + loaded.sketch_plane_binding(cap_sketch), 1088 + Some(face), 1089 + "the face ref held by the bound sketch persists", 1090 + ); 1091 + assert_eq!( 1092 + loaded.rollback(), 1093 + RollbackMarker::Above(cap_sketch_feature), 1094 + "the rollback marker persists, keyed by a stable feature id", 1095 + ); 1096 + assert_eq!( 1097 + loaded.suppression_state(cap_extrude_feature), 1098 + SuppressionState::Suppressed, 1099 + "the suppressed set persists", 1100 + ); 1101 + assert_eq!( 1102 + loaded.suppression_state(base_body), 1103 + SuppressionState::Active, 1104 + "a feature absent from the suppressed set loads active", 1105 + ); 1106 + 1107 + let before = folder_bytes(folder.path()); 1108 + assert_save(&loaded, &folder); 1109 + let after = folder_bytes(folder.path()); 1110 + assert_eq!( 1111 + before, after, 1112 + "saving the reloaded document reproduces every file byte for byte", 1113 + ); 1114 + } 1115 + 1116 + #[test] 1117 + fn phase_three_header_ron_surface() { 1118 + let base_sketch = sketch_id(1); 1119 + let base_extrude = extrude_id(1); 1120 + let cap_sketch = sketch_id(2); 1121 + let cap_extrude = extrude_id(2); 1122 + 1123 + let mut doc = Document::new(document_id(1), "phase3".to_owned()); 1124 + doc.insert_sketch(base_sketch, "Base".to_owned(), rectangle()); 1125 + doc.insert_extrude(base_extrude, blind_extrude(base_sketch)); 1126 + let Some(base_body) = doc.feature_tree().feature_of_extrude(base_extrude) else { 1127 + panic!("base extrude feature present"); 1128 + }; 1129 + 1130 + doc.insert_sketch(cap_sketch, "Top".to_owned(), rectangle()); 1131 + let Ok(()) = doc.bind_sketch_to_face(cap_sketch, sample_face_ref(base_body)) else { 1132 + panic!("the face binding is acyclic"); 1133 + }; 1134 + doc.insert_extrude(cap_extrude, separate_extrude(cap_sketch)); 1135 + 1136 + let Some(cap_sketch_feature) = doc.feature_tree().feature_of_sketch(cap_sketch) else { 1137 + panic!("cap sketch feature present"); 1138 + }; 1139 + let Some(cap_extrude_feature) = doc.feature_tree().feature_of_extrude(cap_extrude) else { 1140 + panic!("cap extrude feature present"); 1141 + }; 1142 + doc.roll_to_here(cap_sketch_feature); 1143 + doc.suppress(cap_extrude_feature); 1144 + 1145 + let Ok(ron) = to_string(doc.header()) else { 1146 + panic!("ron"); 1147 + }; 1148 + insta::assert_snapshot!("phase_three_header", ron); 1149 + }
+164
crates/bone-document/tests/snapshots/folder_roundtrip__phase_three_header.snap
··· 1 + --- 2 + source: crates/bone-document/tests/folder_roundtrip.rs 3 + expression: ron 4 + --- 5 + #![enable(unwrap_newtypes)] 6 + #![enable(implicit_some)] 7 + #![enable(explicit_struct_names)] 8 + DocumentHeader( 9 + schema: SchemaHeader( 10 + name: "bone-document", 11 + version: SchemaVersion( 12 + major: 1, 13 + minor: 4, 14 + ), 15 + ), 16 + id: SerKey( 17 + idx: 1, 18 + version: 1, 19 + ), 20 + name: "phase3", 21 + units: Millimetre, 22 + parameters: DocumentParameters( 23 + order: [], 24 + entries: {}, 25 + ), 26 + feature_tree: FeatureTree( 27 + entries: [FeatureEntry( 28 + id: SerKey( 29 + idx: 1, 30 + version: 1, 31 + ), 32 + node: Origin, 33 + ), FeatureEntry( 34 + id: SerKey( 35 + idx: 2, 36 + version: 1, 37 + ), 38 + node: PrincipalPlane(Xy), 39 + ), FeatureEntry( 40 + id: SerKey( 41 + idx: 3, 42 + version: 1, 43 + ), 44 + node: PrincipalPlane(Yz), 45 + ), FeatureEntry( 46 + id: SerKey( 47 + idx: 4, 48 + version: 1, 49 + ), 50 + node: PrincipalPlane(Zx), 51 + ), FeatureEntry( 52 + id: SerKey( 53 + idx: 5, 54 + version: 1, 55 + ), 56 + node: Sketch(SerKey( 57 + idx: 1, 58 + version: 1, 59 + )), 60 + ), FeatureEntry( 61 + id: SerKey( 62 + idx: 6, 63 + version: 1, 64 + ), 65 + node: Extrude(SerKey( 66 + idx: 1, 67 + version: 1, 68 + )), 69 + ), FeatureEntry( 70 + id: SerKey( 71 + idx: 7, 72 + version: 1, 73 + ), 74 + node: Sketch(SerKey( 75 + idx: 2, 76 + version: 1, 77 + )), 78 + ), FeatureEntry( 79 + id: SerKey( 80 + idx: 8, 81 + version: 1, 82 + ), 83 + node: Extrude(SerKey( 84 + idx: 2, 85 + version: 1, 86 + )), 87 + )], 88 + id_high_water: 8, 89 + ), 90 + sketches: SketchRegistry( 91 + order: [SerKey( 92 + idx: 1, 93 + version: 1, 94 + ), SerKey( 95 + idx: 2, 96 + version: 1, 97 + )], 98 + entries: { 99 + SerKey( 100 + idx: 1, 101 + version: 1, 102 + ): SketchRegistryEntry( 103 + label: "Base", 104 + filename: "0000000100000001.ron", 105 + ), 106 + SerKey( 107 + idx: 2, 108 + version: 1, 109 + ): SketchRegistryEntry( 110 + label: "Top", 111 + filename: "0000000100000002.ron", 112 + ), 113 + }, 114 + ), 115 + sketch_planes: { 116 + SerKey( 117 + idx: 2, 118 + version: 1, 119 + ): FaceRef( 120 + label: FaceLabel( 121 + feature: SerKey( 122 + idx: 6, 123 + version: 1, 124 + ), 125 + role: EndCap, 126 + ), 127 + fingerprint: FaceFingerprint( 128 + plane: Plane3( 129 + origin: Point3( 130 + x: 0.0, 131 + y: 0.0, 132 + z: 10.0, 133 + ), 134 + x_axis: UnitVec3( 135 + x: 1.0, 136 + y: 0.0, 137 + z: 0.0, 138 + ), 139 + y_axis: UnitVec3( 140 + x: 0.0, 141 + y: 1.0, 142 + z: 0.0, 143 + ), 144 + ), 145 + centroid: Point3( 146 + x: 0.0, 147 + y: 0.0, 148 + z: 10.0, 149 + ), 150 + ), 151 + ), 152 + }, 153 + sketch_high_water: 2, 154 + extrude_high_water: 2, 155 + body_high_water: 0, 156 + rollback: Above(SerKey( 157 + idx: 7, 158 + version: 1, 159 + )), 160 + suppressed: [SerKey( 161 + idx: 8, 162 + version: 1, 163 + )], 164 + )
+8 -1
crates/bone-document/tests/snapshots/folder_snapshots__document_header.snap
··· 10 10 name: "bone-document", 11 11 version: SchemaVersion( 12 12 major: 1, 13 - minor: 2, 13 + minor: 4, 14 14 ), 15 15 ), 16 16 id: SerKey( ··· 60 60 version: 1, 61 61 )), 62 62 )], 63 + id_high_water: 5, 63 64 ), 64 65 sketches: SketchRegistry( 65 66 order: [SerKey( ··· 76 77 ), 77 78 }, 78 79 ), 80 + sketch_planes: {}, 81 + sketch_high_water: 7, 82 + extrude_high_water: 0, 83 + body_high_water: 0, 84 + rollback: AtEnd, 85 + suppressed: [], 79 86 )
+1 -1
crates/bone-document/tests/snapshots/folder_snapshots__extrude_file.snap
··· 10 10 name: "bone-document", 11 11 version: SchemaVersion( 12 12 major: 1, 13 - minor: 2, 13 + minor: 4, 14 14 ), 15 15 ), 16 16 feature: ExtrudeFeature(
+1 -1
crates/bone-document/tests/snapshots/folder_snapshots__sketch_file.snap
··· 10 10 name: "bone-document", 11 11 version: SchemaVersion( 12 12 major: 1, 13 - minor: 2, 13 + minor: 4, 14 14 ), 15 15 ), 16 16 sketch: Sketch(
+234 -7
crates/bone-document/tests/undo.rs
··· 1 + use std::collections::BTreeSet; 1 2 use std::num::NonZeroUsize; 2 3 3 4 use bone_document::{ 4 - Document, EditOutcome, FeatureEdge, Sketch, SketchEdit, SketchEntity, UndoStack, 5 + Document, EditOutcome, EvaluatedModel, FeatureEdge, RecomputeScope, Sketch, SketchEdit, 6 + SketchEntity, UndoStack, evaluate_extrude, evaluate_sketch, 5 7 }; 6 8 use bone_kernel::{ 7 - ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, 9 + BrepSolid, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, 8 10 }; 9 11 use bone_types::{ 10 - DocumentId, ExtrudeId, Length, Point2, Point3, PositiveLength, SketchEntityId, SketchId, 11 - SketchPlaneBasis, Tolerance, UnitVec3, millimeter, 12 + DocumentId, ExtrudeId, FeatureId, Length, Point2, Point3, PositiveLength, RollbackMarker, 13 + SketchEntityId, SketchId, SketchPlaneBasis, Tolerance, UnitVec3, millimeter, 12 14 }; 13 - use slotmap::KeyData; 15 + use slotmap::{Key, KeyData}; 14 16 15 17 fn plane() -> SketchPlaneBasis { 16 18 let Ok(basis) = SketchPlaneBasis::new( ··· 60 62 } 61 63 62 64 fn rectangle() -> Sketch { 65 + rectangle_width(10.0) 66 + } 67 + 68 + fn rectangle_width(width_mm: f64) -> Sketch { 63 69 let Ok((with_points, outcomes)) = Sketch::new(plane()).apply_all(vec![ 64 70 SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 65 - SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 0.0))), 66 - SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 5.0))), 71 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(width_mm, 0.0))), 72 + SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(width_mm, 5.0))), 67 73 SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 5.0))), 68 74 ]) else { 69 75 panic!("rectangle corners"); ··· 78 84 panic!("rectangle edges"); 79 85 }; 80 86 closed 87 + } 88 + 89 + fn sample_solid(feature: FeatureId) -> BrepSolid { 90 + let evaluated = evaluate_sketch(&rectangle()); 91 + let extruded = evaluate_extrude(feature, &evaluated, &blind_extrude(SketchId::null())); 92 + let Some(solid) = extruded.solid() else { 93 + panic!("rectangle extrudes to a solid"); 94 + }; 95 + solid.clone() 81 96 } 82 97 83 98 fn blind_extrude(sketch: SketchId) -> ExtrudeFeature { ··· 298 313 "redo restores the derived edge even though it is never serialized" 299 314 ); 300 315 } 316 + 317 + #[test] 318 + fn undo_and_redo_cross_a_recompute() { 319 + let sid = sketch_id(1); 320 + let xid = extrude_id(1); 321 + let mut live = base_doc(); 322 + live.insert_sketch(sid, "Sketch1".to_owned(), rectangle_width(10.0)); 323 + live.insert_extrude(xid, blind_extrude(sid)); 324 + 325 + let Some(extrude_feature) = live.feature_tree().feature_of_extrude(xid) else { 326 + panic!("extrude feature present"); 327 + }; 328 + let Some(sketch_feature) = live.feature_tree().feature_of_sketch(sid) else { 329 + panic!("sketch feature present"); 330 + }; 331 + 332 + let mut model = EvaluatedModel::new(); 333 + let active = BTreeSet::new(); 334 + model.recompute(&live, &active, RollbackMarker::AtEnd, RecomputeScope::Full); 335 + let Some(narrow) = model.body(extrude_feature).map(BrepSolid::content_key) else { 336 + panic!("the base extrude builds a body"); 337 + }; 338 + 339 + let mut stack = UndoStack::with_capacity(cap(5)); 340 + stack.record(live.clone()); 341 + live.replace_sketch(sid, rectangle_width(14.0)); 342 + model.recompute( 343 + &live, 344 + &active, 345 + RollbackMarker::AtEnd, 346 + RecomputeScope::Edited(sketch_feature), 347 + ); 348 + let Some(wide) = model.body(extrude_feature).map(BrepSolid::content_key) else { 349 + panic!("the widened extrude builds a body"); 350 + }; 351 + assert_ne!(narrow, wide, "widening the profile must change the body"); 352 + 353 + assert!(stack.undo(&mut live)); 354 + model.recompute(&live, &active, RollbackMarker::AtEnd, RecomputeScope::Full); 355 + assert_eq!( 356 + model.body(extrude_feature).map(BrepSolid::content_key), 357 + Some(narrow), 358 + "undo restores the recipe and the rebuild reproduces the pre-edit body", 359 + ); 360 + 361 + assert!(stack.redo(&mut live)); 362 + model.recompute(&live, &active, RollbackMarker::AtEnd, RecomputeScope::Full); 363 + assert_eq!( 364 + model.body(extrude_feature).map(BrepSolid::content_key), 365 + Some(wide), 366 + "redo restores the edited recipe and the body widens again", 367 + ); 368 + } 369 + 370 + #[test] 371 + fn rollback_and_suppression_survive_undo_and_redo() { 372 + let sid = sketch_id(1); 373 + let xid = extrude_id(1); 374 + let mut live = base_doc(); 375 + live.insert_sketch(sid, "Sketch1".to_owned(), rectangle()); 376 + live.insert_extrude(xid, blind_extrude(sid)); 377 + let Some(feature) = live.feature_tree().feature_of_extrude(xid) else { 378 + panic!("extrude feature present"); 379 + }; 380 + let before = live.clone(); 381 + 382 + let mut stack = UndoStack::with_capacity(cap(5)); 383 + stack.record(live.clone()); 384 + live.roll_to_here(feature); 385 + live.suppress(feature); 386 + assert_eq!(live.rollback(), RollbackMarker::Above(feature)); 387 + assert!(live.suppressed().contains(&feature)); 388 + 389 + assert!(stack.undo(&mut live)); 390 + assert_eq!(live, before, "undo restores the pre-history-edit document"); 391 + assert_eq!( 392 + live.rollback(), 393 + RollbackMarker::AtEnd, 394 + "undo clears the rollback marker set by the undone step", 395 + ); 396 + assert!( 397 + live.suppressed().is_empty(), 398 + "undo clears the suppression set by the undone step", 399 + ); 400 + 401 + assert!(stack.redo(&mut live)); 402 + assert_eq!( 403 + live.rollback(), 404 + RollbackMarker::Above(feature), 405 + "redo restores the rollback marker", 406 + ); 407 + assert!( 408 + live.suppressed().contains(&feature), 409 + "redo restores the suppression", 410 + ); 411 + } 412 + 413 + #[test] 414 + fn cloning_a_document_shares_imported_body_storage() { 415 + let mut live = base_doc(); 416 + let Ok((_, body)) = live.import_body(|feature| Ok::<_, ()>(sample_solid(feature))) else { 417 + panic!("import a body"); 418 + }; 419 + 420 + let snapshot = live.clone(); 421 + let Some(live_solid) = live.imported_body(body) else { 422 + panic!("live body present"); 423 + }; 424 + let Some(snapshot_solid) = snapshot.imported_body(body) else { 425 + panic!("snapshot body present"); 426 + }; 427 + assert!( 428 + std::ptr::eq(live_solid, snapshot_solid), 429 + "a clone shares the imported body payload, the snapshot is not a deep brep copy", 430 + ); 431 + } 432 + 433 + #[test] 434 + fn an_undo_needs_a_full_recompute_to_restore_an_out_of_scope_body() { 435 + let sid_a = sketch_id(1); 436 + let xid_a = extrude_id(1); 437 + let sid_b = sketch_id(2); 438 + let xid_b = extrude_id(2); 439 + 440 + let mut live = base_doc(); 441 + live.insert_sketch(sid_a, "A".to_owned(), rectangle_width(10.0)); 442 + live.insert_extrude(xid_a, blind_extrude(sid_a)); 443 + live.insert_sketch(sid_b, "B".to_owned(), rectangle_width(10.0)); 444 + live.insert_extrude(xid_b, blind_extrude(sid_b)); 445 + 446 + let Some(a_sketch_feature) = live.feature_tree().feature_of_sketch(sid_a) else { 447 + panic!("sketch a feature present"); 448 + }; 449 + let Some(b_sketch_feature) = live.feature_tree().feature_of_sketch(sid_b) else { 450 + panic!("sketch b feature present"); 451 + }; 452 + let Some(b_extrude_feature) = live.feature_tree().feature_of_extrude(xid_b) else { 453 + panic!("extrude b feature present"); 454 + }; 455 + 456 + let mut model = EvaluatedModel::new(); 457 + let active = BTreeSet::new(); 458 + model.recompute(&live, &active, RollbackMarker::AtEnd, RecomputeScope::Full); 459 + let Some(b_original) = model.body(b_extrude_feature).map(BrepSolid::content_key) else { 460 + panic!("b builds a body"); 461 + }; 462 + 463 + let mut stack = UndoStack::with_capacity(cap(5)); 464 + stack.record(live.clone()); 465 + live.replace_sketch(sid_b, rectangle_width(18.0)); 466 + model.recompute( 467 + &live, 468 + &active, 469 + RollbackMarker::AtEnd, 470 + RecomputeScope::Edited(b_sketch_feature), 471 + ); 472 + let Some(b_wide) = model.body(b_extrude_feature).map(BrepSolid::content_key) else { 473 + panic!("b rebuilds wide"); 474 + }; 475 + assert_ne!(b_original, b_wide, "widening b must change its body"); 476 + 477 + assert!(stack.undo(&mut live)); 478 + model.recompute( 479 + &live, 480 + &active, 481 + RollbackMarker::AtEnd, 482 + RecomputeScope::Edited(a_sketch_feature), 483 + ); 484 + assert_eq!( 485 + model.body(b_extrude_feature).map(BrepSolid::content_key), 486 + Some(b_wide), 487 + "an edit-scoped recompute after undo cannot see b reverted outside the scope", 488 + ); 489 + 490 + model.recompute(&live, &active, RollbackMarker::AtEnd, RecomputeScope::Full); 491 + assert_eq!( 492 + model.body(b_extrude_feature).map(BrepSolid::content_key), 493 + Some(b_original), 494 + "a full recompute after undo restores every reverted body, the contract the app must use", 495 + ); 496 + } 497 + 498 + #[test] 499 + fn cloning_a_document_shares_sketch_storage() { 500 + let sid = sketch_id(1); 501 + let mut live = base_doc(); 502 + live.insert_sketch(sid, "Sketch1".to_owned(), rectangle_width(10.0)); 503 + 504 + let snapshot = live.clone(); 505 + let Some(live_sketch) = live.sketch(sid) else { 506 + panic!("live sketch present"); 507 + }; 508 + let Some(snapshot_sketch) = snapshot.sketch(sid) else { 509 + panic!("snapshot sketch present"); 510 + }; 511 + assert!( 512 + std::ptr::eq(live_sketch.entities(), snapshot_sketch.entities()), 513 + "a clone shares the sketch entity payload, the snapshot is not a deep copy", 514 + ); 515 + 516 + live.replace_sketch(sid, rectangle_width(14.0)); 517 + let Some(edited_sketch) = live.sketch(sid) else { 518 + panic!("edited sketch present"); 519 + }; 520 + let Some(snapshot_after) = snapshot.sketch(sid) else { 521 + panic!("snapshot survives the edit"); 522 + }; 523 + assert!( 524 + !std::ptr::eq(edited_sketch.entities(), snapshot_after.entities()), 525 + "editing the live document leaves the snapshot's payload untouched", 526 + ); 527 + }
+5 -2
crates/bone-interop/src/step.rs
··· 55 55 Evaluation { 56 56 feature: FeatureId, 57 57 #[source] 58 - source: ExtrudeError, 58 + source: Box<ExtrudeError>, 59 59 }, 60 60 #[error("step geometry uses {kind}, which the reader does not yet bridge")] 61 61 UnsupportedEntity { kind: StepEntityKind }, ··· 152 152 evaluate_extrude(feature, &evaluate_sketch(sketch), extrude) 153 153 .result() 154 154 .clone() 155 - .map_err(|source| StepError::Evaluation { feature, source }) 155 + .map_err(|source| StepError::Evaluation { 156 + feature, 157 + source: Box::new(source), 158 + }) 156 159 } 157 160 158 161 pub fn read(path: &Path, cancel: CancelFlag) -> Result<Document, StepError> {
+37 -3
crates/bone-kernel/src/brep/build.rs
··· 3 3 4 4 use bone_types::{ 5 5 BrepEdgeId, BrepFaceId, BrepLoopId, BrepShellId, BrepVertexId, CreaseAngle, EdgeLabel, 6 - EdgeRole, FaceLabel, Parameter, Point3, SideKind, SketchEntityId, Tolerance, UnitVec3, 7 - VertexLabel, VertexRole, 6 + EdgeRole, FaceFingerprint, FaceLabel, Parameter, Plane3, Point3, SideKind, SketchEntityId, 7 + SketchPlaneBasis, Tolerance, UnitVec3, VertexLabel, VertexRole, 8 8 }; 9 9 use slotmap::{Key, SlotMap}; 10 10 use truck_modeling::{ 11 11 BoundedCurve, Edge, EdgeID, FaceID, ParameterDivision1D, ParametricCurve, ParametricSurface3D, 12 - SPHint2D, SearchNearestParameter, Shell, Solid, VertexID, 12 + SPHint2D, SearchNearestParameter, Shell, Solid, Surface, VertexID, 13 13 }; 14 14 15 15 use super::convert::{point_from_truck, try_unit_from_truck}; ··· 46 46 pub(crate) fn face_index(&self) -> &HashMap<FaceID, BrepFaceId> { 47 47 &self.face_index 48 48 } 49 + 50 + pub(crate) fn truck_face(&self, target: BrepFaceId) -> Option<&truck_modeling::Face> { 51 + let face_id = self 52 + .face_index 53 + .iter() 54 + .find_map(|(id, brep)| (*brep == target).then_some(*id))?; 55 + self.solid.face_iter().find(|face| face.id() == face_id) 56 + } 57 + } 58 + 59 + const FACE_PLANE_TOLERANCE: Tolerance = Tolerance::new(1.0e-9); 60 + 61 + pub(crate) fn face_plane_basis(face: &truck_modeling::Face) -> Option<SketchPlaneBasis> { 62 + let Surface::Plane(plane) = face.surface() else { 63 + return None; 64 + }; 65 + let origin = point_from_truck(plane.origin()); 66 + let x = try_unit_from_truck(plane.u_axis(), FACE_PLANE_TOLERANCE)?; 67 + let normal = try_unit_from_truck(plane.normal(), FACE_PLANE_TOLERANCE)?; 68 + let outward = if face.orientation() { 69 + normal 70 + } else { 71 + normal.reversed() 72 + }; 73 + let y = outward.cross(x, FACE_PLANE_TOLERANCE).ok()?; 74 + SketchPlaneBasis::new(origin, x, y, FACE_PLANE_TOLERANCE).ok() 75 + } 76 + 77 + pub(crate) fn face_fingerprint(face: &truck_modeling::Face) -> Option<FaceFingerprint> { 78 + let basis = face_plane_basis(face)?; 79 + Some(FaceFingerprint { 80 + plane: Plane3::from(basis), 81 + centroid: point_from_truck(super::persist::face_centroid(face)), 82 + }) 49 83 } 50 84 51 85 const LENGTH_DIVISION_TOLERANCE: Tolerance = Tolerance::new(1.0e-4);
+16 -2
crates/bone-kernel/src/brep/mod.rs
··· 2 2 3 3 use bone_types::{ 4 4 Aabb3, AngleTolerance, BrepEdgeId, BrepFaceId, BrepLoopId, BrepShellId, BrepVertexId, 5 - ChordHeightTolerance, CreaseAngle, EdgeLabel, EdgeRole, FaceLabel, Point3, SideKind, 6 - StepEntityKind, Tolerance, VertexLabel, 5 + ChordHeightTolerance, CreaseAngle, EdgeLabel, EdgeRole, FaceFingerprint, FaceLabel, Point3, 6 + SideKind, SketchPlaneBasis, StepEntityKind, Tolerance, VertexLabel, 7 7 }; 8 8 use slotmap::SlotMap; 9 9 use truck_modeling::ShellCondition; ··· 287 287 288 288 pub fn iter_faces(&self) -> impl Iterator<Item = &BrepFace> { 289 289 self.face_order.iter().map(|id| &self.faces[*id]) 290 + } 291 + 292 + #[must_use] 293 + pub fn face_plane_basis(&self, face: BrepFaceId) -> Option<SketchPlaneBasis> { 294 + self.arena 295 + .truck_face(face) 296 + .and_then(build::face_plane_basis) 297 + } 298 + 299 + #[must_use] 300 + pub fn face_fingerprint(&self, face: BrepFaceId) -> Option<FaceFingerprint> { 301 + self.arena 302 + .truck_face(face) 303 + .and_then(build::face_fingerprint) 290 304 } 291 305 292 306 pub fn iter_loops(&self) -> impl Iterator<Item = &BrepLoop> {
+1 -1
crates/bone-kernel/src/brep/persist.rs
··· 216 216 SolidKey::from_bytes(key) 217 217 } 218 218 219 - fn face_centroid(face: &Face) -> truck_modeling::Point3 { 219 + pub(super) fn face_centroid(face: &Face) -> truck_modeling::Point3 { 220 220 let (x, y, z, count) = face 221 221 .boundaries() 222 222 .iter()
+21
crates/bone-render/src/scene.rs
··· 701 701 } 702 702 703 703 #[must_use] 704 + pub fn merge(mut self, other: Self) -> Self { 705 + let Ok(base) = u32::try_from(self.positions.len()) else { 706 + panic!("merged solid scene vertex count fits a u32 index"); 707 + }; 708 + self.positions.extend(other.positions); 709 + self.normals.extend(other.normals); 710 + self.colors.extend(other.colors); 711 + self.pick_ids.extend(other.pick_ids); 712 + self.triangles 713 + .extend(other.triangles.into_iter().map(|tri| tri.map(|i| i + base))); 714 + self 715 + } 716 + 717 + #[must_use] 704 718 pub fn positions(&self) -> &[Point3] { 705 719 &self.positions 706 720 } ··· 852 866 genuine, 853 867 silhouettes, 854 868 }) 869 + } 870 + 871 + #[must_use] 872 + pub fn merge(mut self, other: Self) -> Self { 873 + self.genuine.extend(other.genuine); 874 + self.silhouettes.extend(other.silhouettes); 875 + self 855 876 } 856 877 857 878 #[must_use]
+197
crates/bone-types/src/history.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + use crate::{EntityRef, FeatureId, MatchScore}; 4 + 5 + #[derive( 6 + Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize, 7 + )] 8 + #[serde(transparent)] 9 + pub struct FeatureGeneration(u64); 10 + 11 + impl FeatureGeneration { 12 + pub const START: Self = Self(0); 13 + 14 + #[must_use] 15 + pub const fn new(value: u64) -> Self { 16 + Self(value) 17 + } 18 + 19 + #[must_use] 20 + pub const fn value(self) -> u64 { 21 + self.0 22 + } 23 + 24 + #[must_use] 25 + pub const fn succ(self) -> Self { 26 + Self(self.0.saturating_add(1)) 27 + } 28 + 29 + #[must_use] 30 + pub const fn raised_to(self, other: Self) -> Self { 31 + if other.0 > self.0 { other } else { self } 32 + } 33 + } 34 + 35 + impl core::fmt::Display for FeatureGeneration { 36 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 37 + write!(f, "feat_gen={}", self.0) 38 + } 39 + } 40 + 41 + #[derive( 42 + Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize, 43 + )] 44 + pub enum RollbackMarker { 45 + #[default] 46 + AtEnd, 47 + Above(FeatureId), 48 + } 49 + 50 + impl RollbackMarker { 51 + #[must_use] 52 + pub const fn feature(self) -> Option<FeatureId> { 53 + match self { 54 + Self::AtEnd => None, 55 + Self::Above(id) => Some(id), 56 + } 57 + } 58 + } 59 + 60 + impl core::fmt::Display for RollbackMarker { 61 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 62 + match self { 63 + Self::AtEnd => f.write_str("rollback=end"), 64 + Self::Above(id) => write!(f, "rollback=above({id:?})"), 65 + } 66 + } 67 + } 68 + 69 + #[derive( 70 + Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize, 71 + )] 72 + pub enum SuppressionState { 73 + #[default] 74 + Active, 75 + Suppressed, 76 + } 77 + 78 + impl SuppressionState { 79 + #[must_use] 80 + pub const fn is_suppressed(self) -> bool { 81 + matches!(self, Self::Suppressed) 82 + } 83 + 84 + #[must_use] 85 + pub const fn toggled(self) -> Self { 86 + match self { 87 + Self::Active => Self::Suppressed, 88 + Self::Suppressed => Self::Active, 89 + } 90 + } 91 + } 92 + 93 + impl core::fmt::Display for SuppressionState { 94 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 95 + match self { 96 + Self::Active => f.write_str("active"), 97 + Self::Suppressed => f.write_str("suppressed"), 98 + } 99 + } 100 + } 101 + 102 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 103 + pub enum BuildFailure { 104 + UnsolvedSketch, 105 + Kernel, 106 + } 107 + 108 + impl core::fmt::Display for BuildFailure { 109 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 110 + match self { 111 + Self::UnsolvedSketch => f.write_str("sketch did not solve"), 112 + Self::Kernel => f.write_str("solid build failed"), 113 + } 114 + } 115 + } 116 + 117 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 118 + pub enum RebuildError { 119 + DanglingReference(EntityRef), 120 + NonPlanarSketchTarget, 121 + UpstreamUnresolved, 122 + Build(BuildFailure), 123 + } 124 + 125 + impl core::fmt::Display for RebuildError { 126 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 127 + match self { 128 + Self::DanglingReference(reference) => write!(f, "dangling reference {reference}"), 129 + Self::NonPlanarSketchTarget => f.write_str("sketch plane target is not planar"), 130 + Self::UpstreamUnresolved => f.write_str("depends on a feature that failed to build"), 131 + Self::Build(failure) => failure.fmt(f), 132 + } 133 + } 134 + } 135 + 136 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 137 + #[serde(deny_unknown_fields)] 138 + pub enum RebuildWarning { 139 + RepairedReference { 140 + reference: EntityRef, 141 + score: MatchScore, 142 + }, 143 + } 144 + 145 + impl core::fmt::Display for RebuildWarning { 146 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 147 + match self { 148 + Self::RepairedReference { reference, score } => { 149 + write!(f, "reference {reference} repaired at {score}") 150 + } 151 + } 152 + } 153 + } 154 + 155 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 156 + pub enum RebuildStatus { 157 + UpToDate, 158 + NeedsRebuild, 159 + Warning(RebuildWarning), 160 + Error(RebuildError), 161 + } 162 + 163 + impl RebuildStatus { 164 + #[must_use] 165 + pub const fn is_error(self) -> bool { 166 + matches!(self, Self::Error(_)) 167 + } 168 + 169 + #[must_use] 170 + pub const fn builds_geometry(self) -> bool { 171 + !self.is_error() 172 + } 173 + 174 + #[must_use] 175 + pub const fn worse_of(self, other: Self) -> Self { 176 + match (self, other) { 177 + (own @ Self::Error(_), _) => own, 178 + (_, down @ Self::Error(_)) => down, 179 + (own @ Self::Warning(_), _) => own, 180 + (_, down @ Self::Warning(_)) => down, 181 + (own @ Self::NeedsRebuild, _) => own, 182 + (_, down @ Self::NeedsRebuild) => down, 183 + (own, _) => own, 184 + } 185 + } 186 + } 187 + 188 + impl core::fmt::Display for RebuildStatus { 189 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 190 + match self { 191 + Self::UpToDate => f.write_str("up to date"), 192 + Self::NeedsRebuild => f.write_str("needs rebuild"), 193 + Self::Warning(warning) => warning.fmt(f), 194 + Self::Error(error) => error.fmt(f), 195 + } 196 + } 197 + }
+282 -9
crates/bone-types/src/lib.rs
··· 9 9 pub mod content; 10 10 pub mod dimensioned_serde; 11 11 pub mod display; 12 + pub mod history; 12 13 pub mod icon; 13 14 pub mod label; 15 + pub mod reference; 14 16 pub mod schema; 15 17 pub mod solver; 16 18 pub mod space; ··· 23 25 pub use color::LinearRgba; 24 26 pub use content::SolidKey; 25 27 pub use display::{DisplayMode, ShadingModel}; 28 + pub use history::{ 29 + BuildFailure, FeatureGeneration, RebuildError, RebuildStatus, RebuildWarning, RollbackMarker, 30 + SuppressionState, 31 + }; 26 32 pub use icon::{IconId, IconTile}; 27 33 pub use label::{ 28 34 EdgeLabel, EdgeRole, FaceLabel, FaceRole, ImportOrdinal, LoopIndex, SideKind, VertexLabel, 29 35 VertexRole, 36 + }; 37 + pub use reference::{ 38 + EdgeFingerprint, EntityFingerprint, EntityRef, FaceFingerprint, FaceRef, MatchScore, 39 + Resolution, VertexFingerprint, 30 40 }; 31 41 pub use schema::{SchemaHeader, SchemaVersion}; 32 42 pub use solver::{ ··· 89 99 NormalToRequiresPlane, 90 100 #[error("cubic easing needs finite controls with x1,x2 in 0..=1: ({x1},{y1}) ({x2},{y2})")] 91 101 InvalidEasingControl { x1: f64, y1: f64, x2: f64, y2: f64 }, 102 + #[error("match score must be finite and within 0..=1: {0}")] 103 + MatchScoreOutOfRange(f64), 92 104 } 93 105 94 106 pub type Result<T, E = TypesError> = core::result::Result<T, E>; ··· 373 385 use super::BrepSlot; 374 386 use super::{ 375 387 Aabb3, Angle, AngleTolerance, AxisAngle, BodyId, BrepEdgeId, BrepFaceId, BrepLoopId, 376 - BrepShellId, BrepVertexId, Camera3, ChordHeightTolerance, CreaseAngle, CubicEasing, 377 - DegreesOfFreedom, DisplayMode, DocumentId, EdgeId, EdgeLabel, EdgeRole, ExtrudeId, FaceId, 378 - FaceLabel, FaceRole, FeatureId, ImportOrdinal, Length, LoopId, LoopIndex, MeshGeneration, 379 - NodeId, OrbitState, OrientedBox3, Parameter, Plane3, Point2, Point3, PositiveLength, 380 - Projection, ProjectionKind, ShadingModel, ShellId, SideKind, SketchDimensionId, 381 - SketchEntityId, SketchId, SketchParameterId, SketchPlaneBasis, SketchRelationId, SolidId, 382 - SolverResidual, StandardView, StepEntityId, StepEntityKind, StepFileHeader, StepFileName, 383 - StepOrganization, StepOriginatingSystem, StepSchema, Tolerance, UnitVec2, UnitVec3, Vec2, 384 - Vec3, VertexId, VertexLabel, VertexRole, WireId, ZoomFactor, degree, millimeter, radian, 388 + BrepShellId, BrepVertexId, BuildFailure, Camera3, ChordHeightTolerance, CreaseAngle, 389 + CubicEasing, DegreesOfFreedom, DisplayMode, DocumentId, EdgeFingerprint, EdgeId, EdgeLabel, 390 + EdgeRole, EntityFingerprint, EntityRef, ExtrudeId, FaceFingerprint, FaceId, FaceLabel, 391 + FaceRef, FaceRole, FeatureGeneration, FeatureId, ImportOrdinal, Length, LoopId, LoopIndex, 392 + MatchScore, MeshGeneration, NodeId, OrbitState, OrientedBox3, Parameter, Plane3, Point2, 393 + Point3, PositiveLength, Projection, ProjectionKind, RebuildError, RebuildStatus, 394 + RebuildWarning, Resolution, RollbackMarker, ShadingModel, ShellId, SideKind, 395 + SketchDimensionId, SketchEntityId, SketchId, SketchParameterId, SketchPlaneBasis, 396 + SketchRelationId, SolidId, SolverResidual, StandardView, StepEntityId, StepEntityKind, 397 + StepFileHeader, StepFileName, StepOrganization, StepOriginatingSystem, StepSchema, 398 + SuppressionState, Tolerance, UnitVec2, UnitVec3, Vec2, Vec3, VertexFingerprint, VertexId, 399 + VertexLabel, VertexRole, WireId, ZoomFactor, degree, millimeter, radian, 385 400 }; 386 401 use slotmap::{Key, SlotMap}; 387 402 use uom::si::length::meter; ··· 1417 1432 panic!("123 is a positive instance name"); 1418 1433 }; 1419 1434 assert_ron_round_trip(&entity); 1435 + } 1436 + 1437 + fn sample_face_fingerprint() -> FaceFingerprint { 1438 + let plane = Plane3::new_unchecked( 1439 + Point3::from_mm(1.0, 2.0, 3.0), 1440 + UnitVec3::x_axis(), 1441 + UnitVec3::y_axis(), 1442 + ); 1443 + FaceFingerprint { 1444 + plane, 1445 + centroid: Point3::from_mm(4.0, 5.0, 6.0), 1446 + } 1447 + } 1448 + 1449 + fn sample_face_ref() -> EntityRef { 1450 + EntityRef::Face( 1451 + FaceLabel { 1452 + feature: FeatureId::default(), 1453 + role: FaceRole::EndCap, 1454 + }, 1455 + sample_face_fingerprint(), 1456 + ) 1457 + } 1458 + 1459 + #[test] 1460 + fn entity_ref_round_trips_all_kinds() { 1461 + assert_ron_round_trip(&sample_face_ref()); 1462 + assert_ron_round_trip(&EntityRef::Edge( 1463 + EdgeLabel { 1464 + feature: FeatureId::default(), 1465 + role: EdgeRole::EndCapEdge { 1466 + from: SketchEntityId::default(), 1467 + }, 1468 + }, 1469 + EdgeFingerprint { 1470 + sample: Point3::from_mm(1.0, 0.0, 0.0), 1471 + direction: UnitVec3::z_axis(), 1472 + }, 1473 + )); 1474 + assert_ron_round_trip(&EntityRef::Vertex( 1475 + VertexLabel { 1476 + feature: FeatureId::default(), 1477 + role: VertexRole::EndCapVertex { 1478 + from: SketchEntityId::default(), 1479 + side: SideKind::Corner, 1480 + }, 1481 + }, 1482 + VertexFingerprint { 1483 + point: Point3::origin(), 1484 + }, 1485 + )); 1486 + } 1487 + 1488 + #[test] 1489 + fn face_ref_round_trips_and_projects_to_an_entity_ref() { 1490 + let face = FaceRef::new( 1491 + FaceLabel { 1492 + feature: FeatureId::default(), 1493 + role: FaceRole::EndCap, 1494 + }, 1495 + sample_face_fingerprint(), 1496 + ); 1497 + assert_ron_round_trip(&face); 1498 + assert_eq!(face.entity_ref(), sample_face_ref()); 1499 + } 1500 + 1501 + #[test] 1502 + fn entity_ref_fingerprint_projects_its_kind() { 1503 + assert_eq!( 1504 + sample_face_ref().fingerprint(), 1505 + EntityFingerprint::Face(sample_face_fingerprint()) 1506 + ); 1507 + } 1508 + 1509 + #[test] 1510 + fn entity_ref_display_shows_the_label() { 1511 + let expected = format!("face[{:?}]:{}", FeatureId::default(), FaceRole::EndCap); 1512 + assert_eq!(format!("{}", sample_face_ref()), expected); 1513 + } 1514 + 1515 + #[test] 1516 + fn fingerprint_rejects_unknown_field() { 1517 + let Ok(text) = ron::to_string(&sample_face_fingerprint()) else { 1518 + panic!("serialize face fingerprint"); 1519 + }; 1520 + assert!(ron::from_str::<FaceFingerprint>(&text).is_ok()); 1521 + let mut munged = text.clone(); 1522 + munged.insert_str(1, "bogus:42,"); 1523 + assert!(ron::from_str::<FaceFingerprint>(&munged).is_err()); 1524 + } 1525 + 1526 + #[test] 1527 + fn match_score_rejects_out_of_range_and_non_finite() { 1528 + assert!(MatchScore::new(0.0).is_ok()); 1529 + assert!(MatchScore::new(1.0).is_ok()); 1530 + assert!(MatchScore::new(0.75).is_ok()); 1531 + assert!(MatchScore::new(-0.1).is_err()); 1532 + assert!(MatchScore::new(1.5).is_err()); 1533 + assert!(MatchScore::new(f64::NAN).is_err()); 1534 + assert!(MatchScore::new(f64::INFINITY).is_err()); 1535 + assert!(ron::from_str::<MatchScore>("0.5").is_ok()); 1536 + assert!(ron::from_str::<MatchScore>("1.5").is_err()); 1537 + assert!(ron::from_str::<MatchScore>("-0.1").is_err()); 1538 + assert!(ron::from_str::<MatchScore>("inf").is_err()); 1539 + assert!(ron::from_str::<MatchScore>("NaN").is_err()); 1540 + let Ok(score) = MatchScore::new(0.5) else { 1541 + panic!("0.5 is in range"); 1542 + }; 1543 + assert!((score.value() - 0.5).abs() < f64::EPSILON); 1544 + } 1545 + 1546 + #[test] 1547 + fn resolution_id_and_dangling() { 1548 + let resolved: Resolution<FeatureId> = Resolution::Resolved(FeatureId::default()); 1549 + assert_eq!(resolved.id(), Some(FeatureId::default())); 1550 + assert!(!resolved.is_dangling()); 1551 + assert_ron_round_trip(&resolved); 1552 + 1553 + let Ok(score) = MatchScore::new(0.9) else { 1554 + panic!("0.9 is in range"); 1555 + }; 1556 + let repaired: Resolution<FeatureId> = Resolution::Repaired { 1557 + id: FeatureId::default(), 1558 + score, 1559 + }; 1560 + assert_eq!(repaired.id(), Some(FeatureId::default())); 1561 + 1562 + let dangling: Resolution<FeatureId> = Resolution::Dangling { 1563 + last_known: sample_face_ref(), 1564 + }; 1565 + assert_eq!(dangling.id(), None); 1566 + assert!(dangling.is_dangling()); 1567 + assert_ron_round_trip(&dangling); 1568 + assert_ron_round_trip(&repaired); 1569 + } 1570 + 1571 + #[test] 1572 + fn resolution_repaired_rejects_unknown_field() { 1573 + let Ok(score) = MatchScore::new(0.9) else { 1574 + panic!("0.9 is in range"); 1575 + }; 1576 + let repaired: Resolution<FeatureId> = Resolution::Repaired { 1577 + id: FeatureId::default(), 1578 + score, 1579 + }; 1580 + let Ok(text) = ron::to_string(&repaired) else { 1581 + panic!("serialize repaired resolution"); 1582 + }; 1583 + assert!(ron::from_str::<Resolution<FeatureId>>(&text).is_ok()); 1584 + let Some(idx) = text.find("Repaired(") else { 1585 + panic!("expected Repaired variant in {text}"); 1586 + }; 1587 + let mut munged = text.clone(); 1588 + munged.insert_str(idx + "Repaired(".len(), "bogus:42,"); 1589 + assert!(ron::from_str::<Resolution<FeatureId>>(&munged).is_err()); 1590 + } 1591 + 1592 + #[test] 1593 + fn feature_generation_is_a_monotone_mark() { 1594 + assert_eq!(FeatureGeneration::START, FeatureGeneration::default()); 1595 + assert_eq!(FeatureGeneration::START.value(), 0); 1596 + let mark = FeatureGeneration::new(7); 1597 + assert_eq!(mark.succ(), FeatureGeneration::new(8)); 1598 + assert!(mark < mark.succ()); 1599 + assert_eq!(mark.raised_to(FeatureGeneration::new(3)), mark); 1600 + assert_eq!( 1601 + mark.raised_to(FeatureGeneration::new(12)), 1602 + FeatureGeneration::new(12) 1603 + ); 1604 + let Ok(text) = ron::to_string(&mark) else { 1605 + panic!("serialize feature generation"); 1606 + }; 1607 + assert_eq!(text, "7"); 1608 + assert_ron_round_trip(&mark); 1609 + assert_eq!( 1610 + FeatureGeneration::new(u64::MAX).succ(), 1611 + FeatureGeneration::new(u64::MAX) 1612 + ); 1613 + } 1614 + 1615 + #[test] 1616 + fn rollback_marker_defaults_to_end() { 1617 + assert_eq!(RollbackMarker::default(), RollbackMarker::AtEnd); 1618 + assert_eq!(RollbackMarker::AtEnd.feature(), None); 1619 + assert_eq!( 1620 + RollbackMarker::Above(FeatureId::default()).feature(), 1621 + Some(FeatureId::default()) 1622 + ); 1623 + assert!(RollbackMarker::AtEnd < RollbackMarker::Above(FeatureId::default())); 1624 + assert_ron_round_trip(&RollbackMarker::AtEnd); 1625 + assert_ron_round_trip(&RollbackMarker::Above(FeatureId::default())); 1626 + } 1627 + 1628 + #[test] 1629 + fn suppression_state_toggles() { 1630 + assert_eq!(SuppressionState::default(), SuppressionState::Active); 1631 + assert!(!SuppressionState::Active.is_suppressed()); 1632 + assert!(SuppressionState::Suppressed.is_suppressed()); 1633 + assert_eq!( 1634 + SuppressionState::Active.toggled(), 1635 + SuppressionState::Suppressed 1636 + ); 1637 + assert_eq!( 1638 + SuppressionState::Suppressed.toggled(), 1639 + SuppressionState::Active 1640 + ); 1641 + assert!(SuppressionState::Active < SuppressionState::Suppressed); 1642 + assert_ron_round_trip(&SuppressionState::Active); 1643 + assert_ron_round_trip(&SuppressionState::Suppressed); 1644 + } 1645 + 1646 + #[test] 1647 + fn rebuild_status_honors_warning_versus_error() { 1648 + let dangling = RebuildStatus::Error(RebuildError::DanglingReference(sample_face_ref())); 1649 + assert!(dangling.is_error()); 1650 + assert!(!dangling.builds_geometry()); 1651 + 1652 + let non_planar = RebuildStatus::Error(RebuildError::NonPlanarSketchTarget); 1653 + assert!(!non_planar.builds_geometry()); 1654 + 1655 + let build_failed = RebuildStatus::Error(RebuildError::Build(BuildFailure::Kernel)); 1656 + assert!(build_failed.is_error()); 1657 + assert!(!build_failed.builds_geometry()); 1658 + 1659 + let Ok(score) = MatchScore::new(0.8) else { 1660 + panic!("0.8 is in range"); 1661 + }; 1662 + let repaired = RebuildStatus::Warning(RebuildWarning::RepairedReference { 1663 + reference: sample_face_ref(), 1664 + score, 1665 + }); 1666 + assert!(!repaired.is_error()); 1667 + assert!(repaired.builds_geometry()); 1668 + 1669 + assert!(RebuildStatus::UpToDate.builds_geometry()); 1670 + assert!(RebuildStatus::NeedsRebuild.builds_geometry()); 1671 + 1672 + assert_eq!(dangling.worse_of(repaired), dangling); 1673 + assert_eq!(repaired.worse_of(dangling), dangling); 1674 + assert_eq!(repaired.worse_of(RebuildStatus::NeedsRebuild), repaired); 1675 + assert_eq!( 1676 + RebuildStatus::NeedsRebuild.worse_of(RebuildStatus::UpToDate), 1677 + RebuildStatus::NeedsRebuild 1678 + ); 1679 + assert_eq!( 1680 + RebuildStatus::UpToDate.worse_of(RebuildStatus::UpToDate), 1681 + RebuildStatus::UpToDate 1682 + ); 1683 + 1684 + assert_ron_round_trip(&dangling); 1685 + assert_ron_round_trip(&non_planar); 1686 + assert_ron_round_trip(&build_failed); 1687 + assert_ron_round_trip(&RebuildStatus::Error(RebuildError::Build( 1688 + BuildFailure::UnsolvedSketch, 1689 + ))); 1690 + assert_ron_round_trip(&repaired); 1691 + assert_ron_round_trip(&RebuildStatus::UpToDate); 1692 + assert_ron_round_trip(&RebuildStatus::NeedsRebuild); 1420 1693 } 1421 1694 }
+181
crates/bone-types/src/reference.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + use crate::{EdgeLabel, FaceLabel, Plane3, Point3, Result, TypesError, UnitVec3, VertexLabel}; 4 + 5 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 6 + #[serde(deny_unknown_fields)] 7 + pub struct FaceFingerprint { 8 + pub plane: Plane3, 9 + pub centroid: Point3, 10 + } 11 + 12 + impl core::fmt::Display for FaceFingerprint { 13 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 14 + write!(f, "face@{}", self.centroid) 15 + } 16 + } 17 + 18 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 19 + #[serde(deny_unknown_fields)] 20 + pub struct EdgeFingerprint { 21 + pub sample: Point3, 22 + pub direction: UnitVec3, 23 + } 24 + 25 + impl core::fmt::Display for EdgeFingerprint { 26 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 27 + write!(f, "edge@{}", self.sample) 28 + } 29 + } 30 + 31 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 32 + #[serde(deny_unknown_fields)] 33 + pub struct VertexFingerprint { 34 + pub point: Point3, 35 + } 36 + 37 + impl core::fmt::Display for VertexFingerprint { 38 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 39 + write!(f, "vertex@{}", self.point) 40 + } 41 + } 42 + 43 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 44 + pub enum EntityFingerprint { 45 + Face(FaceFingerprint), 46 + Edge(EdgeFingerprint), 47 + Vertex(VertexFingerprint), 48 + } 49 + 50 + impl core::fmt::Display for EntityFingerprint { 51 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 52 + match self { 53 + Self::Face(g) => g.fmt(f), 54 + Self::Edge(g) => g.fmt(f), 55 + Self::Vertex(g) => g.fmt(f), 56 + } 57 + } 58 + } 59 + 60 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 61 + pub enum EntityRef { 62 + Face(FaceLabel, FaceFingerprint), 63 + Edge(EdgeLabel, EdgeFingerprint), 64 + Vertex(VertexLabel, VertexFingerprint), 65 + } 66 + 67 + impl EntityRef { 68 + #[must_use] 69 + pub fn fingerprint(self) -> EntityFingerprint { 70 + match self { 71 + Self::Face(_, g) => EntityFingerprint::Face(g), 72 + Self::Edge(_, g) => EntityFingerprint::Edge(g), 73 + Self::Vertex(_, g) => EntityFingerprint::Vertex(g), 74 + } 75 + } 76 + } 77 + 78 + impl core::fmt::Display for EntityRef { 79 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 80 + match self { 81 + Self::Face(label, _) => label.fmt(f), 82 + Self::Edge(label, _) => label.fmt(f), 83 + Self::Vertex(label, _) => label.fmt(f), 84 + } 85 + } 86 + } 87 + 88 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 89 + #[serde(deny_unknown_fields)] 90 + pub struct FaceRef { 91 + pub label: FaceLabel, 92 + pub fingerprint: FaceFingerprint, 93 + } 94 + 95 + impl FaceRef { 96 + #[must_use] 97 + pub const fn new(label: FaceLabel, fingerprint: FaceFingerprint) -> Self { 98 + Self { label, fingerprint } 99 + } 100 + 101 + #[must_use] 102 + pub const fn entity_ref(self) -> EntityRef { 103 + EntityRef::Face(self.label, self.fingerprint) 104 + } 105 + } 106 + 107 + impl core::fmt::Display for FaceRef { 108 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 109 + self.label.fmt(f) 110 + } 111 + } 112 + 113 + #[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Serialize, Deserialize)] 114 + #[serde(try_from = "f64", into = "f64")] 115 + pub struct MatchScore(f64); 116 + 117 + impl MatchScore { 118 + pub fn new(value: f64) -> Result<Self> { 119 + if value.is_finite() && (0.0..=1.0).contains(&value) { 120 + Ok(Self(value)) 121 + } else { 122 + Err(TypesError::MatchScoreOutOfRange(value)) 123 + } 124 + } 125 + 126 + #[must_use] 127 + pub const fn value(self) -> f64 { 128 + self.0 129 + } 130 + } 131 + 132 + impl TryFrom<f64> for MatchScore { 133 + type Error = TypesError; 134 + fn try_from(value: f64) -> Result<Self> { 135 + Self::new(value) 136 + } 137 + } 138 + 139 + impl From<MatchScore> for f64 { 140 + fn from(score: MatchScore) -> Self { 141 + score.0 142 + } 143 + } 144 + 145 + impl core::fmt::Display for MatchScore { 146 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 147 + write!(f, "score={}", self.0) 148 + } 149 + } 150 + 151 + #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 152 + #[serde(deny_unknown_fields)] 153 + pub enum Resolution<Id> { 154 + Resolved(Id), 155 + Repaired { id: Id, score: MatchScore }, 156 + Dangling { last_known: EntityRef }, 157 + } 158 + 159 + impl<Id> Resolution<Id> { 160 + #[must_use] 161 + pub fn id(self) -> Option<Id> { 162 + match self { 163 + Self::Resolved(id) | Self::Repaired { id, .. } => Some(id), 164 + Self::Dangling { .. } => None, 165 + } 166 + } 167 + 168 + #[must_use] 169 + pub fn map<U>(self, f: impl FnOnce(Id) -> U) -> Resolution<U> { 170 + match self { 171 + Self::Resolved(id) => Resolution::Resolved(f(id)), 172 + Self::Repaired { id, score } => Resolution::Repaired { id: f(id), score }, 173 + Self::Dangling { last_known } => Resolution::Dangling { last_known }, 174 + } 175 + } 176 + 177 + #[must_use] 178 + pub const fn is_dangling(&self) -> bool { 179 + matches!(self, Self::Dangling { .. }) 180 + } 181 + }
+1 -1
crates/bone-types/src/schema.rs
··· 30 30 impl SchemaHeader { 31 31 pub const BONE_DOCUMENT_NAME: &'static str = "bone-document"; 32 32 pub const BONE_DOCUMENT_MAJOR: u32 = 1; 33 - pub const BONE_DOCUMENT_MINOR: u32 = 2; 33 + pub const BONE_DOCUMENT_MINOR: u32 = 4; 34 34 35 35 #[must_use] 36 36 pub fn bone_document() -> Self {
+9
crates/bone-types/src/space.rs
··· 532 532 let r = (by.to_unit_quaternion() * NVec3::new(x, y, z)).normalize(); 533 533 Self::new_unchecked(r.x, r.y, r.z) 534 534 } 535 + 536 + pub fn cross(self, other: Self, tolerance: Tolerance) -> Result<Self> { 537 + Unit::try_new( 538 + self.0.into_inner().cross(&other.0.into_inner()), 539 + tolerance.value(), 540 + ) 541 + .map(Self) 542 + .ok_or(TypesError::ZeroLengthAxis) 543 + } 535 544 } 536 545 537 546 impl core::fmt::Display for UnitVec3 {
+17
crates/bone-types/tests/snapshots/surface__history_surface.snap
··· 1 + --- 2 + source: crates/bone-types/tests/surface.rs 3 + expression: surface 4 + --- 5 + feature_gen_disp = feat_gen=7 6 + feature_gen_succ = feat_gen=8 7 + rollback_end_disp = rollback=end 8 + rollback_above_disp = rollback=above(FeatureId(null)) 9 + suppress_active = active 10 + suppress_suppressed = suppressed 11 + status_uptodate = up to date 12 + status_needs = needs rebuild 13 + status_dangling = dangling reference face[FeatureId(null)]:end_cap 14 + status_non_planar = sketch plane target is not planar 15 + status_warned = reference face[FeatureId(null)]:end_cap repaired at score=0.5 16 + status_error_flag = true 17 + status_builds_geo = true
+15
crates/bone-types/tests/snapshots/surface__reference_surface.snap
··· 1 + --- 2 + source: crates/bone-types/tests/surface.rs 3 + expression: surface 4 + --- 5 + face_fp_display = face@(4 mm, 5 mm, 6 mm) 6 + edge_fp_display = edge@(1 mm, 0 mm, 0 mm) 7 + vertex_fp_display = vertex@(0 mm, 0 mm, 0 mm) 8 + entity_fp_display = face@(4 mm, 5 mm, 6 mm) 9 + face_ref_display = face[FeatureId(null)]:end_cap 10 + 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) }) 11 + match_score_disp = score=0.875 12 + match_score_value = 0.875 13 + resolved_debug = Resolved(FeatureId(null)) 14 + repaired_debug = Repaired { id: FeatureId(null), score: MatchScore(0.875) } 15 + 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) }) }
+113 -8
crates/bone-types/tests/surface.rs
··· 1 1 use bone_types::{ 2 2 Aabb3, Angle, AngleTolerance, AxisAngle, BodyId, BrepEdgeId, BrepFaceId, BrepLoopId, 3 3 BrepShellId, BrepVertexId, BudgetCeiling, Camera3, ChordHeightTolerance, DegreesOfFreedom, 4 - DisplayMode, DocumentId, EdgeId, EdgeLabel, EdgeRole, ExtrudeId, FaceId, FaceLabel, FaceRole, 5 - FeatureId, ImportOrdinal, Length, LoopId, LoopIndex, NewtonDamping, NewtonStepTolerance, 6 - NodeId, OrbitState, OrientedBox3, Parameter, ParentIndex, Plane3, Point2, Point3, Projection, 7 - ShadingModel, ShellId, SideKind, SketchDimensionId, SketchEntityId, SketchId, SketchItemId, 8 - SketchParameterId, SketchPlaneBasis, SketchRelationId, SolidId, SolverResidual, StandardView, 9 - StepEntityId, StepFileHeader, StepFileName, StepOrganization, StepOriginatingSystem, 10 - StepSchema, Tolerance, UnitVec2, UnitVec3, Vec2, Vec3, VertexId, VertexLabel, VertexRole, 11 - WireId, ZoomFactor, degree, millimeter, radian, 4 + DisplayMode, DocumentId, EdgeFingerprint, EdgeId, EdgeLabel, EdgeRole, EntityRef, ExtrudeId, 5 + FaceFingerprint, FaceId, FaceLabel, FaceRole, FeatureGeneration, FeatureId, ImportOrdinal, 6 + Length, LoopId, LoopIndex, MatchScore, NewtonDamping, NewtonStepTolerance, NodeId, OrbitState, 7 + OrientedBox3, Parameter, ParentIndex, Plane3, Point2, Point3, Projection, RebuildError, 8 + RebuildStatus, RebuildWarning, Resolution, RollbackMarker, ShadingModel, ShellId, SideKind, 9 + SketchDimensionId, SketchEntityId, SketchId, SketchItemId, SketchParameterId, SketchPlaneBasis, 10 + SketchRelationId, SolidId, SolverResidual, StandardView, StepEntityId, StepFileHeader, 11 + StepFileName, StepOrganization, StepOriginatingSystem, StepSchema, SuppressionState, Tolerance, 12 + UnitVec2, UnitVec3, Vec2, Vec3, VertexFingerprint, VertexId, VertexLabel, VertexRole, WireId, 13 + ZoomFactor, degree, millimeter, radian, 12 14 }; 13 15 use slotmap::Key; 14 16 ··· 416 418 header_display = {header}", 417 419 s214 = StepSchema::Ap214, 418 420 s242 = StepSchema::Ap242E2, 421 + ); 422 + insta::assert_snapshot!(surface); 423 + } 424 + 425 + #[test] 426 + fn reference_surface() { 427 + let plane = Plane3::new_unchecked( 428 + Point3::from_mm(1.0, 2.0, 3.0), 429 + UnitVec3::x_axis(), 430 + UnitVec3::y_axis(), 431 + ); 432 + let face_fp = FaceFingerprint { 433 + plane, 434 + centroid: Point3::from_mm(4.0, 5.0, 6.0), 435 + }; 436 + let edge_fp = EdgeFingerprint { 437 + sample: Point3::from_mm(1.0, 0.0, 0.0), 438 + direction: UnitVec3::z_axis(), 439 + }; 440 + let vertex_fp = VertexFingerprint { 441 + point: Point3::origin(), 442 + }; 443 + let face_ref = EntityRef::Face( 444 + FaceLabel { 445 + feature: FeatureId::default(), 446 + role: FaceRole::EndCap, 447 + }, 448 + face_fp, 449 + ); 450 + let Ok(score) = MatchScore::new(0.875) else { 451 + panic!("0.875 is in range"); 452 + }; 453 + let resolved: Resolution<FeatureId> = Resolution::Resolved(FeatureId::default()); 454 + let repaired: Resolution<FeatureId> = Resolution::Repaired { 455 + id: FeatureId::default(), 456 + score, 457 + }; 458 + let dangling: Resolution<FeatureId> = Resolution::Dangling { 459 + last_known: face_ref, 460 + }; 461 + let surface = format!( 462 + "face_fp_display = {face_fp}\n\ 463 + edge_fp_display = {edge_fp}\n\ 464 + vertex_fp_display = {vertex_fp}\n\ 465 + entity_fp_display = {efp}\n\ 466 + face_ref_display = {face_ref}\n\ 467 + face_ref_debug = {face_ref:?}\n\ 468 + match_score_disp = {score}\n\ 469 + match_score_value = {sv}\n\ 470 + resolved_debug = {resolved:?}\n\ 471 + repaired_debug = {repaired:?}\n\ 472 + dangling_debug = {dangling:?}", 473 + efp = face_ref.fingerprint(), 474 + sv = score.value(), 475 + ); 476 + insta::assert_snapshot!(surface); 477 + } 478 + 479 + #[test] 480 + fn history_surface() { 481 + let face_ref = EntityRef::Face( 482 + FaceLabel { 483 + feature: FeatureId::default(), 484 + role: FaceRole::EndCap, 485 + }, 486 + FaceFingerprint { 487 + plane: Plane3::new_unchecked(Point3::origin(), UnitVec3::x_axis(), UnitVec3::y_axis()), 488 + centroid: Point3::from_mm(1.0, 2.0, 3.0), 489 + }, 490 + ); 491 + let Ok(score) = MatchScore::new(0.5) else { 492 + panic!("0.5 is in range"); 493 + }; 494 + let mark = FeatureGeneration::new(7); 495 + let dangling = RebuildStatus::Error(RebuildError::DanglingReference(face_ref)); 496 + let non_planar = RebuildStatus::Error(RebuildError::NonPlanarSketchTarget); 497 + let warned = RebuildStatus::Warning(RebuildWarning::RepairedReference { 498 + reference: face_ref, 499 + score, 500 + }); 501 + let surface = format!( 502 + "feature_gen_disp = {mark}\n\ 503 + feature_gen_succ = {succ}\n\ 504 + rollback_end_disp = {rb_end}\n\ 505 + rollback_above_disp = {rb_above}\n\ 506 + suppress_active = {sa}\n\ 507 + suppress_suppressed = {ss}\n\ 508 + status_uptodate = {uptodate}\n\ 509 + status_needs = {needs}\n\ 510 + status_dangling = {dangling}\n\ 511 + status_non_planar = {non_planar}\n\ 512 + status_warned = {warned}\n\ 513 + status_error_flag = {ef}\n\ 514 + status_builds_geo = {bg}", 515 + succ = mark.succ(), 516 + rb_end = RollbackMarker::AtEnd, 517 + rb_above = RollbackMarker::Above(FeatureId::default()), 518 + sa = SuppressionState::Active, 519 + ss = SuppressionState::Suppressed, 520 + uptodate = RebuildStatus::UpToDate, 521 + needs = RebuildStatus::NeedsRebuild, 522 + ef = dangling.is_error(), 523 + bg = warned.builds_geometry(), 419 524 ); 420 525 insta::assert_snapshot!(surface); 421 526 }
+3 -2
crates/bone-ui/src/widgets/mod.rs
··· 91 91 pub use toolbar::{Toolbar, ToolbarItem, ToolbarOrientation, ToolbarResponse, show_toolbar}; 92 92 pub use tooltip::{Tooltip, TooltipPlacement, TooltipState, show_tooltip}; 93 93 pub use tree_view::{ 94 - DropPlacement, DropTarget, RenameCommit, TreeNode, TreeSelectionMode, TreeView, 95 - TreeViewResponse, TreeViewState, show_tree_view, 94 + ContextMenuRequest, DropPlacement, DropTarget, RenameCommit, RollbackBar, RollbackTarget, 95 + TreeBadge, TreeNode, TreeSelectionMode, TreeView, TreeViewResponse, TreeViewState, 96 + show_tree_view, 96 97 }; 97 98 pub use vector::{ConvexPoly, MAX_CONVEX_VERTS, MAX_PATH_POINTS, PolyPath}; 98 99 pub use visuals::{
+407 -57
crates/bone-ui/src/widgets/tree_view.rs
··· 17 17 use super::text_input::{AlwaysValid, MemoryClipboard, TextInput, TextInputState, show_text_input}; 18 18 use super::visuals::push_focus_ring; 19 19 20 + #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] 21 + pub enum TreeBadge { 22 + RebuildNeeded, 23 + Warning, 24 + Error, 25 + } 26 + 20 27 #[derive(Clone, Debug, PartialEq)] 21 28 pub struct TreeNode { 22 29 pub id: WidgetId, ··· 24 31 pub children: Vec<TreeNode>, 25 32 pub disabled: bool, 26 33 pub icon: Option<IconId>, 34 + pub badge: Option<TreeBadge>, 27 35 } 28 36 29 37 impl TreeNode { ··· 55 63 children, 56 64 disabled: false, 57 65 icon: None, 66 + badge: None, 58 67 } 59 68 } 60 69 ··· 67 76 #[must_use] 68 77 pub fn with_icon(mut self, icon: IconId) -> Self { 69 78 self.icon = Some(icon); 79 + self 80 + } 81 + 82 + #[must_use] 83 + pub fn with_badge(mut self, badge: Option<TreeBadge>) -> Self { 84 + self.badge = badge; 70 85 self 71 86 } 72 87 ··· 86 101 pub enum DropPlacement { 87 102 Before, 88 103 After, 89 - Into, 90 104 } 91 105 92 106 #[derive(Copy, Clone, Debug, PartialEq, Eq)] ··· 95 109 pub placement: DropPlacement, 96 110 } 97 111 112 + #[derive(Copy, Clone, Debug, PartialEq)] 113 + pub struct ContextMenuRequest { 114 + pub target: WidgetId, 115 + pub at: LayoutPos, 116 + } 117 + 118 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 119 + pub enum RollbackTarget { 120 + AtEnd, 121 + Above(WidgetId), 122 + } 123 + 124 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 125 + pub struct RollbackBar<'a> { 126 + pub stops: &'a [WidgetId], 127 + pub marker: RollbackTarget, 128 + } 129 + 98 130 #[derive(Clone, Debug, Default, PartialEq)] 99 131 pub struct TreeViewState { 100 132 pub expanded: BTreeSet<WidgetId>, ··· 126 158 pub row_height: LayoutPx, 127 159 pub indent_step: LayoutPx, 128 160 pub renamable: &'a [WidgetId], 161 + pub illegal_drop: bool, 162 + pub rollback: Option<RollbackBar<'a>>, 129 163 } 130 164 131 165 impl<'a, 'state> TreeView<'a, 'state> { ··· 147 181 row_height: LayoutPx::new(20.0), 148 182 indent_step: LayoutPx::new(16.0), 149 183 renamable: &[], 184 + illegal_drop: false, 185 + rollback: None, 150 186 } 151 187 } 152 188 ··· 159 195 pub const fn renamable(self, renamable: &'a [WidgetId]) -> Self { 160 196 Self { renamable, ..self } 161 197 } 198 + 199 + #[must_use] 200 + pub const fn illegal_drop(self, illegal_drop: bool) -> Self { 201 + Self { 202 + illegal_drop, 203 + ..self 204 + } 205 + } 206 + 207 + #[must_use] 208 + pub const fn rollback(self, rollback: Option<RollbackBar<'a>>) -> Self { 209 + Self { rollback, ..self } 210 + } 162 211 } 163 212 164 213 #[derive(Clone, Debug, PartialEq, Eq)] ··· 174 223 pub rename_committed: Option<RenameCommit>, 175 224 pub rename_cancelled: Option<WidgetId>, 176 225 pub drop_committed: Option<(WidgetId, DropTarget)>, 226 + pub context_menu: Option<ContextMenuRequest>, 227 + pub rollback_moved: Option<RollbackTarget>, 177 228 pub paint: Vec<WidgetPaint>, 178 229 } 179 230 ··· 189 240 row_height, 190 241 indent_step, 191 242 renamable, 243 + illegal_drop, 244 + rollback, 192 245 } = view; 193 246 ctx.a11y 194 247 .push(id, rect, AccessNode::new(Role::Tree).with_label(label)); ··· 211 264 if let Some(id) = entry_id { 212 265 ctx.focus.register_tab_stop(id); 213 266 } 214 - let mut activated: Option<WidgetId> = None; 215 - let mut double_activated: Option<WidgetId> = None; 216 - let mut drop_committed: Option<(WidgetId, DropTarget)> = None; 217 267 let row_rect_of = |idx: usize| row_rect_at(content_rect, idx - window.first_row, row_height); 218 - let row_paint = visible 219 - .iter() 220 - .enumerate() 221 - .skip(window.first_row) 222 - .take(window.last_row - window.first_row) 223 - .map(|(idx, row)| { 224 - draw_row( 225 - ctx, 226 - RowDrawArgs { 227 - row, 228 - row_rect: row_rect_of(idx), 229 - indent_step, 230 - state, 231 - mode, 232 - renamable, 233 - }, 234 - &mut activated, 235 - &mut double_activated, 236 - &mut drop_committed, 237 - ) 238 - }) 239 - .fold(Vec::new(), |mut acc, mut p| { 240 - acc.append(&mut p); 241 - acc 242 - }); 268 + let (row_paint, interactions) = paint_tree_rows( 269 + ctx, 270 + &visible, 271 + state, 272 + renamable, 273 + RowFrame { 274 + content_rect, 275 + row_height, 276 + indent_step, 277 + first_row: window.first_row, 278 + last_row: window.last_row, 279 + mode, 280 + illegal_drop, 281 + }, 282 + ); 283 + let RowInteractions { 284 + mut activated, 285 + double_activated, 286 + drop_committed, 287 + context_menu, 288 + } = interactions; 243 289 paint.extend(row_paint); 290 + let rollback_moved = rollback.and_then(|bar| { 291 + let (bar_paint, moved) = render_rollback_bar( 292 + ctx, 293 + id, 294 + bar, 295 + &visible, 296 + content_rect, 297 + window.first_row, 298 + row_height, 299 + ); 300 + paint.extend(bar_paint); 301 + moved 302 + }); 244 303 let (bar_paint, next_offset) = window_scrollbar(ctx, id, label, &window, state.scroll_offset); 245 304 state.scroll_offset = next_offset; 246 305 paint.extend(bar_paint); ··· 262 321 rename_committed, 263 322 rename_cancelled, 264 323 drop_committed, 324 + context_menu, 325 + rollback_moved, 265 326 paint, 266 327 } 267 328 } 268 329 330 + #[derive(Copy, Clone)] 331 + struct RowFrame { 332 + content_rect: LayoutRect, 333 + row_height: LayoutPx, 334 + indent_step: LayoutPx, 335 + first_row: usize, 336 + last_row: usize, 337 + mode: TreeSelectionMode, 338 + illegal_drop: bool, 339 + } 340 + 341 + struct RowInteractions { 342 + activated: Option<WidgetId>, 343 + double_activated: Option<WidgetId>, 344 + drop_committed: Option<(WidgetId, DropTarget)>, 345 + context_menu: Option<ContextMenuRequest>, 346 + } 347 + 348 + fn paint_tree_rows( 349 + ctx: &mut FrameCtx<'_>, 350 + visible: &[VisibleRow], 351 + state: &mut TreeViewState, 352 + renamable: &[WidgetId], 353 + frame: RowFrame, 354 + ) -> (Vec<WidgetPaint>, RowInteractions) { 355 + let mut out = RowInteractions { 356 + activated: None, 357 + double_activated: None, 358 + drop_committed: None, 359 + context_menu: None, 360 + }; 361 + let paint = visible 362 + .iter() 363 + .enumerate() 364 + .skip(frame.first_row) 365 + .take(frame.last_row - frame.first_row) 366 + .flat_map(|(idx, row)| { 367 + let row_rect = row_rect_at(frame.content_rect, idx - frame.first_row, frame.row_height); 368 + draw_row( 369 + ctx, 370 + RowDrawArgs { 371 + row, 372 + row_rect, 373 + indent_step: frame.indent_step, 374 + state, 375 + mode: frame.mode, 376 + renamable, 377 + illegal_drop: frame.illegal_drop, 378 + }, 379 + &mut RowOutcomes { 380 + activated: &mut out.activated, 381 + double_activated: &mut out.double_activated, 382 + drop_committed: &mut out.drop_committed, 383 + context_menu: &mut out.context_menu, 384 + }, 385 + ) 386 + }) 387 + .collect(); 388 + (paint, out) 389 + } 390 + 391 + fn rollback_boundaries( 392 + bar: RollbackBar<'_>, 393 + visible: &[VisibleRow], 394 + content_rect: LayoutRect, 395 + first_row: usize, 396 + row_height: LayoutPx, 397 + ) -> Vec<(f32, RollbackTarget)> { 398 + let row_top = |index: usize| { 399 + row_rect_at(content_rect, index.saturating_sub(first_row), row_height) 400 + .origin 401 + .y 402 + .value() 403 + }; 404 + let stops: Vec<(usize, WidgetId)> = bar 405 + .stops 406 + .iter() 407 + .filter_map(|stop| { 408 + visible 409 + .iter() 410 + .position(|row| row.id == *stop) 411 + .map(|index| (index, *stop)) 412 + }) 413 + .collect(); 414 + let above = stops 415 + .iter() 416 + .map(|(index, id)| (row_top(*index), RollbackTarget::Above(*id))); 417 + let at_end = stops 418 + .last() 419 + .map(|(index, _)| (row_top(*index) + row_height.value(), RollbackTarget::AtEnd)); 420 + above.chain(at_end).collect() 421 + } 422 + 423 + fn nearest_boundary( 424 + boundaries: &[(f32, RollbackTarget)], 425 + pointer_y: f32, 426 + ) -> Option<RollbackTarget> { 427 + boundaries 428 + .iter() 429 + .min_by(|a, b| (a.0 - pointer_y).abs().total_cmp(&(b.0 - pointer_y).abs())) 430 + .map(|(_, target)| *target) 431 + } 432 + 433 + fn render_rollback_bar( 434 + ctx: &mut FrameCtx<'_>, 435 + tree_id: WidgetId, 436 + bar: RollbackBar<'_>, 437 + visible: &[VisibleRow], 438 + content_rect: LayoutRect, 439 + first_row: usize, 440 + row_height: LayoutPx, 441 + ) -> (Vec<WidgetPaint>, Option<RollbackTarget>) { 442 + let boundaries = rollback_boundaries(bar, visible, content_rect, first_row, row_height); 443 + let Some(&(current_y, _)) = boundaries.iter().find(|(_, target)| *target == bar.marker) else { 444 + return (Vec::new(), None); 445 + }; 446 + let bar_id = tree_id.child(WidgetKey::new("rollback_bar")); 447 + let hit_rect = LayoutRect::new( 448 + LayoutPos::new(content_rect.origin.x, LayoutPx::new(current_y - 3.0)), 449 + LayoutSize::new(content_rect.size.width, LayoutPx::new(6.0)), 450 + ); 451 + let interaction = 452 + ctx.interact(InteractDeclaration::new(bar_id, hit_rect, Sense::DRAGGABLE).focusable(false)); 453 + let dragging = interaction.drag_button == Some(PointerButton::Primary); 454 + let pointer_target = ctx 455 + .input 456 + .pointer 457 + .and_then(|sample| nearest_boundary(&boundaries, sample.position.y.value())); 458 + let moved = interaction 459 + .drag_release() 460 + .then_some(pointer_target) 461 + .flatten() 462 + .filter(|target| *target != bar.marker); 463 + let line_rect = |y: f32| { 464 + LayoutRect::new( 465 + LayoutPos::new(content_rect.origin.x, LayoutPx::new(y - 1.0)), 466 + LayoutSize::new(content_rect.size.width, LayoutPx::new(2.0)), 467 + ) 468 + }; 469 + let mut paint = vec![WidgetPaint::Surface { 470 + rect: line_rect(current_y), 471 + fill: ctx.theme().colors.accent.step(Step12::SOLID), 472 + border: None, 473 + radius: ctx.theme().radius.none, 474 + elevation: None, 475 + }]; 476 + let preview_y = dragging 477 + .then_some(pointer_target) 478 + .flatten() 479 + .and_then(|target| { 480 + boundaries 481 + .iter() 482 + .find(|(_, b)| *b == target) 483 + .map(|(y, _)| *y) 484 + }) 485 + .filter(|y| (*y - current_y).abs() > f32::EPSILON); 486 + if let Some(y) = preview_y { 487 + paint.push(WidgetPaint::Surface { 488 + rect: line_rect(y), 489 + fill: ctx.theme().colors.accent.step(Step12::HOVER_BORDER), 490 + border: None, 491 + radius: ctx.theme().radius.none, 492 + elevation: None, 493 + }); 494 + } 495 + (paint, moved) 496 + } 497 + 269 498 #[derive(Clone, Debug, PartialEq)] 270 499 struct VisibleRow { 271 500 id: WidgetId, ··· 274 503 has_children: bool, 275 504 disabled: bool, 276 505 icon: Option<IconId>, 506 + badge: Option<TreeBadge>, 277 507 } 278 508 279 509 fn flatten(roots: &[TreeNode], expanded: &BTreeSet<WidgetId>, depth: usize) -> Vec<VisibleRow> { ··· 287 517 has_children: node.has_children(), 288 518 disabled: node.disabled, 289 519 icon: node.icon, 520 + badge: node.badge, 290 521 }; 291 522 let children = if expanded.contains(&node.id) { 292 523 flatten(&node.children, expanded, depth + 1) ··· 346 577 state: &'a mut TreeViewState, 347 578 mode: TreeSelectionMode, 348 579 renamable: &'a [WidgetId], 580 + illegal_drop: bool, 349 581 } 350 582 351 583 fn draw_row( 352 584 ctx: &mut FrameCtx<'_>, 353 585 args: RowDrawArgs<'_>, 354 - activated: &mut Option<WidgetId>, 355 - double_activated: &mut Option<WidgetId>, 356 - drop_committed: &mut Option<(WidgetId, DropTarget)>, 586 + outcomes: &mut RowOutcomes<'_>, 357 587 ) -> Vec<WidgetPaint> { 358 588 let RowDrawArgs { 359 589 row, ··· 362 592 state, 363 593 mode, 364 594 renamable, 595 + illegal_drop, 365 596 } = args; 366 597 #[allow(clippy::cast_precision_loss, reason = "tree depth fits f32 mantissa")] 367 598 let indent = LayoutPx::new(row.depth as f32 * indent_step.value()); ··· 395 626 state, 396 627 mode, 397 628 renamable, 398 - outcomes: RowOutcomes { 399 - activated, 400 - double_activated, 401 - drop_committed, 402 - }, 403 629 }, 630 + outcomes, 404 631 ); 405 632 let mut paint = vec![WidgetPaint::Surface { 406 633 rect: row_rect, ··· 419 646 row.disabled, 420 647 )); 421 648 } 649 + let content_rect = badge_content_rect(label_rect, row.badge.is_some()); 422 650 if state.renaming == Some(row.id) { 423 651 paint.extend(draw_rename_editor( 424 - ctx, row.id, &row.label, label_rect, state, 652 + ctx, 653 + row.id, 654 + &row.label, 655 + content_rect, 656 + state, 425 657 )); 426 658 } else { 427 - paint.extend(label_with_glyph_paint(ctx, row, label_rect)); 659 + paint.extend(label_with_glyph_paint(ctx, row, content_rect)); 660 + } 661 + if let Some(badge) = row.badge { 662 + paint.push(badge_dot_paint(ctx, badge, label_rect)); 428 663 } 429 664 push_focus_ring( 430 665 ctx, ··· 436 671 if let Some(target) = state.drop_target 437 672 && target.anchor == row.id 438 673 { 674 + let fill = if illegal_drop { 675 + ctx.theme().colors.danger.step(Step12::SOLID) 676 + } else { 677 + ctx.theme().colors.accent.step(Step12::SOLID) 678 + }; 439 679 paint.push(WidgetPaint::Surface { 440 680 rect: drop_indicator_rect(row_rect, target.placement), 441 - fill: ctx.theme().colors.accent.step(Step12::SOLID), 681 + fill, 442 682 border: None, 443 683 radius: ctx.theme().radius.none, 444 684 elevation: None, ··· 447 687 paint 448 688 } 449 689 690 + const BADGE_GUTTER_PX: f32 = 11.0; 691 + 692 + fn badge_content_rect(label_rect: LayoutRect, has_badge: bool) -> LayoutRect { 693 + if !has_badge { 694 + return label_rect; 695 + } 696 + LayoutRect::new( 697 + LayoutPos::new( 698 + LayoutPx::new(label_rect.origin.x.value() + BADGE_GUTTER_PX), 699 + label_rect.origin.y, 700 + ), 701 + LayoutSize::new( 702 + LayoutPx::saturating_nonneg(label_rect.size.width.value() - BADGE_GUTTER_PX), 703 + label_rect.size.height, 704 + ), 705 + ) 706 + } 707 + 708 + fn badge_dot_paint(ctx: &FrameCtx<'_>, badge: TreeBadge, label_rect: LayoutRect) -> WidgetPaint { 709 + const DOT_PX: f32 = 7.0; 710 + let rect = LayoutRect::new( 711 + LayoutPos::new( 712 + LayoutPx::new(label_rect.origin.x.value() + (BADGE_GUTTER_PX - DOT_PX) / 2.0), 713 + LayoutPx::new( 714 + label_rect.origin.y.value() + (label_rect.size.height.value() - DOT_PX) / 2.0, 715 + ), 716 + ), 717 + LayoutSize::new(LayoutPx::new(DOT_PX), LayoutPx::new(DOT_PX)), 718 + ); 719 + let fill = match badge { 720 + TreeBadge::Error => ctx.theme().colors.danger.step(Step12::SOLID), 721 + TreeBadge::Warning => ctx.theme().colors.warning.step(Step12::SOLID), 722 + TreeBadge::RebuildNeeded => ctx.theme().colors.accent.step(Step12::SOLID), 723 + }; 724 + WidgetPaint::Surface { 725 + rect, 726 + fill, 727 + border: None, 728 + radius: ctx.theme().radius.sm, 729 + elevation: None, 730 + } 731 + } 732 + 450 733 struct RowOutcomes<'a> { 451 734 activated: &'a mut Option<WidgetId>, 452 735 double_activated: &'a mut Option<WidgetId>, 453 736 drop_committed: &'a mut Option<(WidgetId, DropTarget)>, 737 + context_menu: &'a mut Option<ContextMenuRequest>, 454 738 } 455 739 456 740 struct RowInteractionArgs<'a> { ··· 460 744 state: &'a mut TreeViewState, 461 745 mode: TreeSelectionMode, 462 746 renamable: &'a [WidgetId], 463 - outcomes: RowOutcomes<'a>, 464 747 } 465 748 466 749 fn commit_pending_rename( ··· 502 785 state.pending_rename = None; 503 786 } 504 787 505 - fn apply_row_interaction(ctx: &mut FrameCtx<'_>, args: RowInteractionArgs<'_>) { 788 + fn apply_row_interaction( 789 + ctx: &mut FrameCtx<'_>, 790 + args: RowInteractionArgs<'_>, 791 + outcomes: &mut RowOutcomes<'_>, 792 + ) { 506 793 let RowInteractionArgs { 507 794 row, 508 795 row_rect, ··· 510 797 state, 511 798 mode, 512 799 renamable, 513 - outcomes, 514 800 } = args; 515 801 let RowOutcomes { 516 802 activated, 517 803 double_activated, 518 804 drop_committed, 805 + context_menu, 519 806 } = outcomes; 520 807 if row.disabled { 521 808 return; 522 809 } 810 + let secondary = interaction.click_button == Some(PointerButton::Secondary); 811 + if secondary && interaction.click() { 812 + if !state.selection.contains(&row.id) { 813 + update_selection(state, row.id, ModifierMask::NONE, mode); 814 + } 815 + state.focused = Some(row.id); 816 + ctx.focus.request_focus(row.id); 817 + state.pending_rename = None; 818 + if context_menu.is_none() 819 + && let Some(at) = ctx.input.pointer.map(|sample| sample.position) 820 + { 821 + **context_menu = Some(ContextMenuRequest { target: row.id, at }); 822 + } 823 + return; 824 + } 523 825 if interaction.click() { 524 826 let was_selected = state.selection.contains(&row.id); 525 827 let is_double = interaction.double_click(); 526 828 update_selection(state, row.id, ctx.input.modifiers, mode); 527 829 if activated.is_none() { 528 - *activated = Some(row.id); 830 + **activated = Some(row.id); 529 831 } 530 832 state.focused = Some(row.id); 531 833 ctx.focus.request_focus(row.id); ··· 546 848 state.pending_rename = None; 547 849 } 548 850 if double_activated.is_none() { 549 - *double_activated = Some(row.id); 851 + **double_activated = Some(row.id); 550 852 } 551 853 } 552 854 if interaction.drag_start() { ··· 557 859 && let Some(target) = state.drop_target 558 860 { 559 861 if drop_committed.is_none() && src != target.anchor { 560 - *drop_committed = Some((src, target)); 862 + **drop_committed = Some((src, target)); 561 863 } 562 864 state.drag_source = None; 563 865 state.drop_target = None; 564 866 } 565 - if state.drag_source.is_some() && interaction.hover() { 566 - let placement = ctx.input.pointer.map_or(DropPlacement::Into, |p| { 567 - placement_from_pointer(p.position, row_rect) 568 - }); 867 + if state.drag_source.is_some() 868 + && interaction.hover() 869 + && let Some(pointer) = ctx.input.pointer 870 + { 569 871 state.drop_target = Some(DropTarget { 570 872 anchor: row.id, 571 - placement, 873 + placement: placement_from_pointer(pointer.position, row_rect), 572 874 }); 573 875 } 574 876 } ··· 700 1002 } 701 1003 702 1004 fn placement_from_pointer(pointer: LayoutPos, row: LayoutRect) -> DropPlacement { 703 - let third = row.size.height.value() / 3.0; 704 1005 let local = pointer.y.value() - row.origin.y.value(); 705 - if local < third { 1006 + if local * 2.0 < row.size.height.value() { 706 1007 DropPlacement::Before 707 - } else if local > 2.0 * third { 1008 + } else { 708 1009 DropPlacement::After 709 - } else { 710 - DropPlacement::Into 711 1010 } 712 1011 } 713 1012 ··· 724 1023 ), 725 1024 LayoutSize::new(row.size.width, stripe), 726 1025 ), 727 - DropPlacement::Into => row, 728 1026 } 729 1027 } 730 1028 ··· 1039 1337 let mut s = InputSnapshot::idle(at); 1040 1338 s.pointer = Some(PointerSample::new(pos)); 1041 1339 s 1340 + } 1341 + 1342 + fn secondary_press(pos: LayoutPos) -> InputSnapshot { 1343 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 1344 + s.pointer = Some(PointerSample::new(pos)); 1345 + s.buttons_pressed = PointerButtonMask::just(PointerButton::Secondary); 1346 + s 1347 + } 1348 + 1349 + fn secondary_release(pos: LayoutPos) -> InputSnapshot { 1350 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 1351 + s.pointer = Some(PointerSample::new(pos)); 1352 + s.buttons_released = PointerButtonMask::just(PointerButton::Secondary); 1353 + s 1354 + } 1355 + 1356 + #[test] 1357 + fn secondary_click_requests_context_menu_and_selects_row() { 1358 + let roots = sample_tree(); 1359 + let mut state = TreeViewState::default(); 1360 + let mut focus = FocusManager::new(); 1361 + let mut prev = HitState::new(); 1362 + let pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(30.0)); 1363 + let mut last: Option<super::TreeViewResponse> = None; 1364 + [secondary_press(pos), secondary_release(pos), idle(pos)] 1365 + .into_iter() 1366 + .for_each(|mut snap| { 1367 + let (response, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1368 + last = Some(response); 1369 + prev = next; 1370 + }); 1371 + let Some(request) = last.and_then(|r| r.context_menu) else { 1372 + panic!("secondary click requests a context menu"); 1373 + }; 1374 + assert_eq!(request.target, root_id("sketch")); 1375 + assert!(state.selection.contains(&root_id("sketch"))); 1376 + } 1377 + 1378 + #[test] 1379 + fn secondary_click_does_not_activate_row() { 1380 + let roots = sample_tree(); 1381 + let mut state = TreeViewState::default(); 1382 + let mut focus = FocusManager::new(); 1383 + let mut prev = HitState::new(); 1384 + let pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(30.0)); 1385 + [secondary_press(pos), secondary_release(pos), idle(pos)] 1386 + .into_iter() 1387 + .for_each(|mut snap| { 1388 + let (response, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1389 + assert!(response.activated.is_none()); 1390 + prev = next; 1391 + }); 1042 1392 } 1043 1393 1044 1394 #[test]