feat: preserve modeling intent across sketches, assemblies, and fabrication
Persist typed sketch dimensions and bounded ordered configuration rules in format 8. Use exact occurrence paths and source identity for editing, movement, appearance, and mass. Preserve sheet material regions through cutting, folding, and exact DXF export; retain nested cavities and material islands. Validation: cargo xtask ci passed, including 368 engine tests, 178 UI tests, 68 UI scenarios, 136 kernel tests, and the 10-crate wasm check. Actual preview workflows and legacy format-7 origin-corner reopening were inspected.
This commit is contained in:
1 parent
d71bdb78ec
commit
07932d0f42
65 files changed
+6145
-484
No files matched your search
@@ -0,0 +1,182 @@
|
||||
use crate::context_menu::{self, Target};
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::Sel;
|
||||
|
||||
fn fixture() -> (Harness, u64, u64, u64) {
|
||||
let mut h = Harness::new();
|
||||
let part = h.app.run("component.create", json!({"name":"Block"})).unwrap();
|
||||
let component = part["component"].as_u64().unwrap();
|
||||
let first = part["occurrence"].as_u64().unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
let second = h.app.run("occurrence.copy", json!({"occurrence":first,"translate":[50,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.frames(3);
|
||||
(h, component, first, second)
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness, path: &[u64], control: &str) {
|
||||
let point = h.call("ui.at", json!({"occurrence":path,"control":control}));
|
||||
assert_eq!(point["ok"], true, "{point}");
|
||||
let p = &point["result"];
|
||||
let result = h.call("ui.click", json!({"x":p[0],"y":p[1]}));
|
||||
assert_eq!(result["ok"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_second_row_selects_exact_path_and_menu_actions() {
|
||||
let (mut h, component, first, second) = fixture();
|
||||
click(&mut h, &[second], "row");
|
||||
assert_eq!(h.app.session.selection, vec![Sel::Occurrence { path: vec![second] }]);
|
||||
let bounds = crate::viewport::selection_bounds(&h.app).unwrap();
|
||||
assert!((bounds.center().x - 55.0).abs() < 1e-8);
|
||||
let target = Target::Occurrence { path: vec![second] };
|
||||
let items = context_menu::items(&h.app, &target);
|
||||
let independent = items.iter().find(|i| i.id == "component.paste_new").unwrap();
|
||||
assert_eq!(independent.label, "Paste New (Own Features)");
|
||||
assert_eq!(independent.params.as_ref().unwrap()["occurrence"], second);
|
||||
assert_eq!(independent.params.as_ref().unwrap()["path"], json!([second]));
|
||||
let properties = items.iter().find(|i| i.id == "ui.properties").unwrap();
|
||||
assert_eq!(properties.params.as_ref().unwrap()["occurrence"], second);
|
||||
context_menu::run_item(&mut h.app, properties, egui::Pos2::ZERO);
|
||||
assert!((h.app.menu.props.as_ref().unwrap().1["total"]["center_of_mass"][0].as_f64().unwrap() - 55.0).abs() < 1e-8);
|
||||
let movement = items.iter().find(|i| i.id == "ui.moveOccurrence").unwrap();
|
||||
context_menu::run_item(&mut h.app, movement, egui::Pos2::ZERO);
|
||||
assert_eq!(h.app.tree.occurrence_move.as_ref().unwrap().path, vec![second]);
|
||||
let ground = items.iter().find(|i| i.id == "occurrence.ground").unwrap();
|
||||
context_menu::run_item(&mut h.app, ground, egui::Pos2::ZERO);
|
||||
assert!(h.app.session.doc.occurrences.iter().find(|o| o.id == second).unwrap().grounded);
|
||||
assert!(!h.app.session.doc.occurrences.iter().find(|o| o.id == first).unwrap().grounded);
|
||||
h.app.tree.occurrence_move = None;
|
||||
click(&mut h, &[second], "activate");
|
||||
assert_eq!(h.app.session.active_component, component);
|
||||
assert_eq!(h.app.session.active_occurrence_path, Some(vec![second]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_eye_and_fold_are_independent_and_survive_rename() {
|
||||
let (mut h, component, first, second) = fixture();
|
||||
click(&mut h, &[second], "eye");
|
||||
assert!(h.app.session.occurrence_hidden(&[second]));
|
||||
assert!(h.app.session.occurrence_visible(&[first]));
|
||||
let second_body = h.app.session.occurrence_bodies(&[second], true).unwrap()[0].clone();
|
||||
assert!(!h.app.session.body_visible(&second_body));
|
||||
click(&mut h, &[second], "fold");
|
||||
assert!(h.app.tree.collapsed.contains(&format!("o:{second}")));
|
||||
assert!(!h.app.tree.collapsed.contains(&format!("o:{first}")));
|
||||
h.app.run("component.rename", json!({"component":component,"name":"Renamed Block"})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(h.app.session.occurrence_hidden(&[second]));
|
||||
click(&mut h, &[second], "eye");
|
||||
assert!(h.app.session.occurrence_visible(&[second]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_move_dialog_keeps_other_pending_moves_and_reuses_initial_baseline() {
|
||||
let (mut h, _, first, second) = fixture();
|
||||
h.app.run("occurrence.move", json!({"occurrence":first,"translate":[0,10,0],"capture":false})).unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false})).unwrap();
|
||||
let first_pending = h.app.session.pending_moves[&first];
|
||||
let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![second] });
|
||||
let movement = items.iter().find(|i| i.id == "ui.moveOccurrence").unwrap();
|
||||
context_menu::run_item(&mut h.app, movement, egui::Pos2::ZERO);
|
||||
let mut m = h.app.tree.occurrence_move.clone().unwrap();
|
||||
m.pivot =
|
||||
Some(solvecraft_engine::doc::apply_point(&h.app.session.occurrence_placement(&[second]).unwrap().world, solvecraft_engine::geom::Vec3::ZERO));
|
||||
m.translate = [10.0, 0.0, 0.0];
|
||||
crate::browser::apply_occurrence_move(&mut h.app, &m);
|
||||
let at = h.app.session.occurrence_placement(&[second]).unwrap();
|
||||
assert!((solvecraft_engine::doc::apply_point(&at.world, solvecraft_engine::geom::Vec3::ZERO).x - 65.0).abs() < 1e-8);
|
||||
m.translate = [3.0, 0.0, 0.0];
|
||||
crate::browser::apply_occurrence_move(&mut h.app, &m);
|
||||
let at = h.app.session.occurrence_placement(&[second]).unwrap();
|
||||
assert!((solvecraft_engine::doc::apply_point(&at.world, solvecraft_engine::geom::Vec3::ZERO).x - 58.0).abs() < 1e-8);
|
||||
assert_eq!(h.app.session.pending_moves[&first], first_pending);
|
||||
crate::browser::revert_occurrence_dialog(&mut h.app, &m);
|
||||
let restored = h.app.session.occurrence_placement(&[second]).unwrap();
|
||||
assert!((solvecraft_engine::doc::apply_point(&restored.world, solvecraft_engine::geom::Vec3::ZERO).x - 55.0).abs() < 1e-8);
|
||||
assert_eq!(h.app.session.pending_moves[&first], first_pending);
|
||||
h.app.run("component.revert_position", json!({"occurrences":[second]})).unwrap();
|
||||
context_menu::run_item(&mut h.app, movement, egui::Pos2::ZERO);
|
||||
let mut fresh = h.app.tree.occurrence_move.clone().unwrap();
|
||||
assert!(!fresh.had_pending);
|
||||
fresh.translate = [2.0, 0.0, 0.0];
|
||||
crate::browser::apply_occurrence_move(&mut h.app, &fresh);
|
||||
crate::browser::revert_occurrence_dialog(&mut h.app, &fresh);
|
||||
assert!(!h.app.session.pending_moves.contains_key(&second));
|
||||
assert_eq!(h.app.session.pending_moves[&first], first_pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_delete_targets_second_copy_even_if_first_was_selected() {
|
||||
let (mut h, _, first, second) = fixture();
|
||||
click(&mut h, &[first], "row");
|
||||
let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![second] });
|
||||
let delete = items.iter().find(|i| i.id == "ui.delete").unwrap();
|
||||
context_menu::run_item(&mut h.app, delete, egui::Pos2::ZERO);
|
||||
crate::delete::confirm(&mut h.app, true);
|
||||
assert!(h.app.session.doc.occurrences.iter().any(|o| o.id == first));
|
||||
assert!(!h.app.session.doc.occurrences.iter().any(|o| o.id == second));
|
||||
assert_eq!(h.app.session.world_state().bodies.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_new_component_keeps_inactive_second_parent_authoring_context() {
|
||||
let (mut h, _, first, second) = fixture();
|
||||
assert_eq!(h.app.session.active_component, 0);
|
||||
let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![second] });
|
||||
let create = items.iter().find(|i| i.id == "component.create").unwrap();
|
||||
assert_eq!(create.params.as_ref().unwrap()["parent_path"], json!([second]));
|
||||
context_menu::run_item(&mut h.app, create, egui::Pos2::ZERO);
|
||||
let active = h.app.session.active_occurrence_path.as_ref().unwrap();
|
||||
assert_eq!(active.len(), 2);
|
||||
assert_eq!(active[0], second);
|
||||
assert_ne!(active[0], first);
|
||||
h.app.run("solid.box", json!({"corner":[51,1,1],"length":2,"width":2,"height":2})).unwrap();
|
||||
let feature = h.app.session.model.state().bodies.last().unwrap().feature;
|
||||
let mut centers: Vec<f64> =
|
||||
h.app.session.world_state().bodies.iter().filter(|b| b.feature == feature).map(|b| b.mesh().bounds().center().x).collect();
|
||||
centers.sort_by(f64::total_cmp);
|
||||
assert_eq!(centers.len(), 2);
|
||||
assert!((centers[0] - 2.0).abs() < 1e-8 && (centers[1] - 52.0).abs() < 1e-8, "{centers:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_body_appearance_editor_inherits_definition_precision() {
|
||||
let (mut h, _, _, second) = fixture();
|
||||
h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":"#123456","appearance":"Tinted source","opacity":0.123456})).unwrap();
|
||||
let name = h.app.session.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
h.app.session.selection = vec![Sel::Body { name }];
|
||||
let (kind, _) = crate::dialogs_appearance::start(&h.app, "appearance.assign").unwrap();
|
||||
let crate::dialogs::Kind::Appearance(look) = kind else { panic!("appearance editor") };
|
||||
assert_eq!(look.color, [0x12, 0x34, 0x56]);
|
||||
assert_eq!(look.opacity, 0.123456);
|
||||
assert_eq!(look.look.as_deref(), Some("Tinted source"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_nested_shared_child_has_distinct_rows_and_scope() {
|
||||
let mut h = Harness::new();
|
||||
let parent = h.app.run("component.create", json!({"name":"Assembly"})).unwrap();
|
||||
let parent_component = parent["component"].as_u64().unwrap();
|
||||
let parent_occurrence = parent["occurrence"].as_u64().unwrap();
|
||||
let child = h.app.run("component.create", json!({"name":"Child"})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
let other = h.app.run("occurrence.copy", json!({"component":parent_component,"translate":[80,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.frames(3);
|
||||
click(&mut h, &[other, child], "row");
|
||||
assert_eq!(h.app.session.selection, vec![Sel::Occurrence { path: vec![other, child] }]);
|
||||
assert!((crate::viewport::selection_bounds(&h.app).unwrap().center().x - 85.0).abs() < 1e-8);
|
||||
let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![other, child] });
|
||||
assert!(items.iter().any(|i| i.label.starts_with("Shared child:")));
|
||||
click(&mut h, &[other, child], "fold");
|
||||
assert!(h.app.tree.collapsed.contains(&format!("o:{other}/{child}")));
|
||||
assert!(!h.app.tree.collapsed.contains(&format!("o:{parent_occurrence}/{child}")));
|
||||
click(&mut h, &[other, child], "eye");
|
||||
assert!(h.app.session.occurrence_visible(&[parent_occurrence, child]));
|
||||
assert!(!h.app.session.occurrence_visible(&[other, child]));
|
||||
let delete = items.iter().find(|i| i.id == "ui.delete").unwrap();
|
||||
context_menu::run_item(&mut h.app, delete, egui::Pos2::ZERO);
|
||||
assert!(h.app.menu.confirm.is_some(), "shared child deletion must communicate scope before removing both renderings");
|
||||
}
|
||||
Reference in new issue
Block a user