* Honor design units in sketch dimensions and measurements
* fix: apply document units to specialized length controls
* Accept explicit units in length fields; document design-unit dimension values
Length fields in a design-unit control now evaluate what is typed as a
length expression: a bare number stays in the design's units, while an
explicit unit ("10 mm", "0.5 in", "1 in + 5 mm") is converted.
Document that a bare numeric sketch.dimension value from scripts, the
control channel or MCP is read in the design's units.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
---------
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
13 KiB
Rust
233 lines
13 KiB
Rust
use crate::context_menu::{self, Target};
|
|
use crate::scenario::Harness;
|
|
use serde_json::json;
|
|
use solvecraft_engine::Sel;
|
|
|
|
#[test]
|
|
fn replaced_body_name_uses_evaluated_owner_in_browser_and_component_actions() {
|
|
let mut h = Harness::new();
|
|
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})).unwrap();
|
|
let transferred = h.app.run("component.from_bodies", json!({"bodies":["Block"]})).unwrap();
|
|
let owner = transferred["components"][0]["component"].as_u64().unwrap();
|
|
assert_eq!(context_menu::component_bodies(&h.app, owner), ["Block"]);
|
|
h.app.run("solid.remove", json!({"bodies":["Block"]})).unwrap();
|
|
h.app.run("solid.box", json!({"corner":[30,0,0],"length":5,"width":5,"height":5,"body_name":"Block"})).unwrap();
|
|
h.frames(2);
|
|
let state = h.app.session.model.state();
|
|
let body = state.body("Block").unwrap();
|
|
assert_eq!(h.app.session.doc.model_body_component(body), 0);
|
|
assert_eq!(context_menu::component_bodies(&h.app, 0), ["Block"]);
|
|
assert!(context_menu::component_bodies(&h.app, owner).is_empty());
|
|
assert_eq!(super::entries(&h.app, 0, "bodies").iter().map(|entry| entry.key.as_str()).collect::<Vec<_>>(), ["Block"]);
|
|
assert!(super::entries(&h.app, owner, "bodies").is_empty());
|
|
let bounds = h.app.session.world_state().body("Block").unwrap().mesh().bounds();
|
|
assert!((bounds.min.x - 30.0).abs() < 1e-9);
|
|
assert!((bounds.max.x - 35.0).abs() < 1e-9);
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
#[test]
|
|
fn occurrence_distance_editor_reads_inches_and_keeps_four_digits_in_model_space() {
|
|
let (mut h, _, first, _) = fixture();
|
|
h.app.run("document.units", json!({"units":"in"})).unwrap();
|
|
let target = Target::Occurrence { path: vec![first] };
|
|
let items = context_menu::items(&h.app, &target);
|
|
let movement = items.iter().find(|i| i.id == "ui.moveOccurrence").unwrap();
|
|
context_menu::run_item(&mut h.app, movement, egui::Pos2::ZERO);
|
|
h.settle();
|
|
let original = h.app.session.doc.to_json();
|
|
let point = h.call("ui.at", json!({"handle":"occurrence-move:distance:0"}));
|
|
let at = &point["result"];
|
|
h.call("ui.click", json!({"x":at[0],"y":at[1],"double":true}));
|
|
h.settle();
|
|
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
|
h.step(&json!({"text":"1.2345"})).unwrap();
|
|
h.step(&json!({"key":"Enter"})).unwrap();
|
|
let m = h.app.tree.occurrence_move.as_ref().unwrap();
|
|
assert!((m.translate[0] - 31.3563).abs() < 1e-8, "{:?}", m.translate);
|
|
assert_eq!(h.app.session.doc.to_json(), original, "pending movement does not alter the stored design");
|
|
h.app.session.selection = vec![Sel::Occurrence { path: vec![first] }];
|
|
let bounds = crate::viewport::selection_bounds(&h.app).unwrap();
|
|
assert!((bounds.min.x - 31.3563).abs() < 1e-6);
|
|
let movement = h.app.tree.occurrence_move.clone().unwrap();
|
|
crate::browser::revert_occurrence_dialog(&mut h.app, &movement);
|
|
assert!(h.app.session.pending_moves.is_empty());
|
|
}
|