diff --git a/apps/solidcraft-cli/tests/corpus/README.md b/apps/solidcraft-cli/tests/corpus/README.md index a4b4a5b7..5216c111 100644 --- a/apps/solidcraft-cli/tests/corpus/README.md +++ b/apps/solidcraft-cli/tests/corpus/README.md @@ -16,6 +16,7 @@ hostile variants of it never panic. | `modern-path-pattern` | bd98035 | pattern on path (oracle 53) | | `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule | | `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) | +| `move-bodies` | 00baf1d | `solid.move` before it could copy: two bodies, one translated in place, one translated and turned 90 deg about Z (no `copy` key anywhere in the file) | | `split-body-pieces` | v0.3.2 (07e263d) | a slot along X cuts a block into two bodies; a fillet, a chamfer and an appearance refer to the pieces by body name (`Body1`, `Body1 (1)`) | | `issue-489-stacked-join` | v0.6.0 (809ecf7), desktop | a 70 × 84 extrude, then a 70 × 62 strip joined on its top face, sketched partly over the face's projected edges (issue #489; expected values from main at 2880525) | diff --git a/apps/solidcraft-cli/tests/corpus/move-bodies.expected.json b/apps/solidcraft-cli/tests/corpus/move-bodies.expected.json new file mode 100644 index 00000000..dd298758 --- /dev/null +++ b/apps/solidcraft-cli/tests/corpus/move-bodies.expected.json @@ -0,0 +1,194 @@ +{ + "bodies": [ + { + "area_mm2": 2400.0, + "bbox": { + "max": [ + 50.0, + 20.0, + 20.0 + ], + "min": [ + 30.0, + 0.0, + 0.0 + ] + }, + "center_of_mass": [ + 40.0, + 10.0, + 10.0 + ], + "density_g_cm3": 1.29, + "edges": 12, + "face_types": { + "plane": 6 + }, + "faces": 6, + "kernel": { + "edges": 12, + "faces": 6, + "shells": 1, + "vertices": 8 + }, + "mass_g": 10.32, + "material": "Default", + "name": "Block", + "vertices": 8, + "volume_mm3": 8000.0 + }, + { + "area_mm2": 600.0, + "bbox": { + "max": [ + 3.552713678800501e-15, + 90.0, + 10.0 + ], + "min": [ + -9.999999999999998, + 80.0, + 0.0 + ] + }, + "center_of_mass": [ + -4.999999999999999, + 85.0, + 5.000000000000001 + ], + "density_g_cm3": 1.29, + "edges": 12, + "face_types": { + "plane": 6 + }, + "faces": 6, + "kernel": { + "edges": 12, + "faces": 6, + "shells": 1, + "vertices": 8 + }, + "mass_g": 1.29, + "material": "Default", + "name": "Peg", + "vertices": 8, + "volume_mm3": 1000.0 + } + ], + "body_count": 2, + "params": [], + "sketches": [ + { + "curves": 4, + "dof": 2, + "id": 1, + "name": "Base", + "profiles": 1, + "status": "solved", + "visible": false + }, + { + "curves": 4, + "dof": 4, + "id": 3, + "name": "Peg", + "profiles": 1, + "status": "solved", + "visible": false + } + ], + "timeline": [ + { + "boolean_diagnostics": { + "events": [], + "truncated": false + }, + "error": null, + "id": 1, + "ms": 0.204585, + "name": "Base", + "rolled_back": false, + "suppressed": false, + "type": "Sketch", + "warning": null + }, + { + "boolean_diagnostics": { + "events": [], + "truncated": false + }, + "error": null, + "id": 2, + "ms": 2.318662, + "name": "Extrude1", + "rolled_back": false, + "suppressed": false, + "type": "ExtrudeFeature", + "warning": null + }, + { + "boolean_diagnostics": { + "events": [], + "truncated": false + }, + "error": null, + "id": 3, + "ms": 0.109196, + "name": "Peg", + "rolled_back": false, + "suppressed": false, + "type": "Sketch", + "warning": null + }, + { + "boolean_diagnostics": { + "events": [], + "truncated": false + }, + "error": null, + "id": 4, + "ms": 0.338859, + "name": "Extrude2", + "rolled_back": false, + "suppressed": false, + "type": "ExtrudeFeature", + "warning": null + }, + { + "boolean_diagnostics": { + "events": [], + "truncated": false + }, + "error": null, + "id": 5, + "ms": 0.312259, + "name": "Move1", + "rolled_back": false, + "suppressed": false, + "type": "MoveFeature", + "warning": null + }, + { + "boolean_diagnostics": { + "events": [], + "truncated": false + }, + "error": null, + "id": 6, + "ms": 0.23144699999999999, + "name": "Move2", + "rolled_back": false, + "suppressed": false, + "type": "MoveFeature", + "warning": null + } + ], + "total": { + "area_mm2": 3000.0, + "edges": 24, + "faces": 12, + "mass_g": 11.61, + "vertices": 16, + "volume_mm3": 9000.0 + } +} diff --git a/apps/solidcraft-cli/tests/corpus/move-bodies.solidcraft b/apps/solidcraft-cli/tests/corpus/move-bodies.solidcraft new file mode 100644 index 00000000..1beb6154 --- /dev/null +++ b/apps/solidcraft-cli/tests/corpus/move-bodies.solidcraft @@ -0,0 +1 @@ +{"features":[{"id":1,"name":"Base","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"},{"id":"k5","p":0,"q":1,"type":"coincident"}],"counters":{"k":5,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[0.0,0.0]},{"fixed":false,"id":"p2","pos":[20.0,0.0]},{"fixed":false,"id":"p3","pos":[20.0,20.0]},{"fixed":false,"id":"p4","pos":[0.0,20.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Block"],"extent":{"direction":"positive","distance":"20"},"id":2,"name":"Extrude1","operation":"new_body","param_names":["d1"],"profiles":{"type":"all"},"sketch":1,"suppressed":false,"type":"extrude"},{"id":3,"name":"Peg","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"}],"counters":{"k":4,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[40.0,0.0]},{"fixed":false,"id":"p2","pos":[50.0,0.0]},{"fixed":false,"id":"p3","pos":[50.0,10.0]},{"fixed":false,"id":"p4","pos":[40.0,10.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Peg"],"extent":{"direction":"positive","distance":"10"},"id":4,"name":"Extrude2","operation":"new_body","param_names":["d2"],"profiles":{"type":"all"},"sketch":3,"suppressed":false,"type":"extrude"},{"bodies":["Block"],"id":5,"name":"Move1","param_names":["d3","d4","d5"],"suppressed":false,"translate":["30","0","0"],"type":"move"},{"angle":"90 deg","bodies":["Peg"],"id":6,"name":"Move2","param_names":["d6","d7","d8","d9"],"rotate_axis":[0.0,0.0,1.0],"suppressed":false,"translate":["0","40","0"],"type":"move"}],"format":"solidcraft/15","name":"old-move-bodies","next_id":7,"params":[],"units":"mm"} \ No newline at end of file diff --git a/apps/solidcraft-cli/tests/corpus/scripts/move-bodies.json b/apps/solidcraft-cli/tests/corpus/scripts/move-bodies.json new file mode 100644 index 00000000..2d2ec0f7 --- /dev/null +++ b/apps/solidcraft-cli/tests/corpus/scripts/move-bodies.json @@ -0,0 +1,14 @@ +{ + "commands": [ + {"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [20, 20]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": 20, "body_name": "Block"}}, + {"command": "sketch.create", "params": {"plane": "XY", "name": "Peg"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [40, 0], "p1": [50, 10]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": 10, "operation": "new", "body_name": "Peg"}}, + {"command": "solid.move", "params": {"bodies": ["Block"], "translate": [30, 0, 0]}}, + {"command": "solid.move", "params": {"bodies": ["Peg"], "translate": [0, 40, 0], "axis": [0, 0, 1], "angle": "90 deg"}} + ] +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index e54f98ef..f4fb4f20 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -942,6 +942,10 @@ pub enum FeatureKind { rotate_axis: Option, #[serde(default, skip_serializing_if = "Option::is_none")] angle: Option, + /// When set, the original bodies are kept and the transform is applied to a new copy + /// of each instead of moving them in place. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + copy: bool, }, /// A non-parametric base feature: bodies imported from a STEP file. The STEP text is kept /// in the design (it is the B-rep), so the design reopens without the original file. diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index fad29d84..e3d56d48 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3808,7 +3808,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: st.bodies.push(ModelBody::traced_new(name, b, f.id)); Ok(()) } - FeatureKind::Move { bodies, translate, rotate_axis, angle } => { + FeatureKind::Move { bodies, translate, rotate_axis, angle, copy } => { let t = Vec3::new(val(vals, &translate[0], Kind::Length)?, val(vals, &translate[1], Kind::Length)?, val(vals, &translate[2], Kind::Length)?); let ang = match angle { @@ -3816,11 +3816,20 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: None => 0.0, }; let axis = rotate_axis.unwrap_or(Vec3::Z); + // With `copy` set, the source body is kept in place and the transform lands on a new + // body pushed beside it instead of replacing it. + let mut output = 0; for n in bodies { let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?; - if let Some(mb) = st.bodies.get_mut(i) { - let nb = Traced::step(kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?, &[&mb.traced()]); - *mb = ModelBody::traced_new(mb.name.clone(), nb, mb.feature); + let Some(mb) = st.bodies.get(i).cloned() else { continue }; + let nb = kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?; + if *copy { + let traced = moved_copy(&mb, nb, 1); + let name = copy_body_name(st, f, output, &mb.name); + st.bodies.push(ModelBody::traced_new(name, traced, f.id)); + output += 1; + } else if let Some(slot) = st.bodies.get_mut(i) { + *slot = ModelBody::traced_new(mb.name.clone(), Traced::step(nb, &[&mb.traced()]), mb.feature); } } Ok(()) diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 1214db0f..89ff8b60 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -107,7 +107,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("presspull") .key("Q") .params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"), - CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"), + CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place)"), ]; /// Where an extrude starts and which way is positive: a point inside the (first) profile or @@ -875,7 +875,11 @@ fn move_bodies(s: &mut Session, p: &Value) -> Result { if let Some(a) = &angle { check_expr(s, a, Kind::Angle, cmd, "angle")?; } - add_feature(s, p, FeatureKind::Move { bodies, translate: t, rotate_axis: p.get("axis").and_then(vec3), angle }) + add_feature( + s, + p, + FeatureKind::Move { bodies, translate: t, rotate_axis: p.get("axis").and_then(vec3), angle, copy: bool_(p, "copy").unwrap_or(false) }, + ) } fn source_features(s: &Session, p: &Value, cmd: &str) -> Result> { @@ -1536,3 +1540,7 @@ mod offset_plane_face_tests; #[cfg(test)] #[path = "stacked_face_tests.rs"] mod stacked_face_tests; + +#[cfg(test)] +#[path = "solid_move_copy_tests.rs"] +mod solid_move_copy_tests; diff --git a/crates/engine/src/cmd/solid_move_copy_tests.rs b/crates/engine/src/cmd/solid_move_copy_tests.rs new file mode 100644 index 00000000..4095bfcc --- /dev/null +++ b/crates/engine/src/cmd/solid_move_copy_tests.rs @@ -0,0 +1,86 @@ +//! `solid.move` with `copy: true`: the original body stays where it was, a new body is +//! pushed at the transform, and an older design file (no `copy` key at all) still loads. +use super::*; + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap() +} + +fn cube20(s: &mut Session, name: &str) { + run(s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "body_name": name})); +} + +fn volume(body: &solidcraft_doc::ModelBody) -> f64 { + solidcraft_kernel::measure(&body.body).unwrap().volume +} + +#[test] +fn copy_keeps_the_original_and_adds_a_transformed_body() { + let mut s = Session::default(); + cube20(&mut s, "Cube"); + run(&mut s, "solid.move", json!({"bodies": ["Cube"], "translate": [30, 0, 0], "copy": true})); + let state = s.model.state(); + assert_eq!(state.bodies.len(), 2, "a copy move must add one body, not replace the original"); + + let original = state.body("Cube").unwrap(); + let original_bounds = original.mesh().bounds(); + assert!(original_bounds.min.dist(Vec3::ZERO) < 1e-6, "the original body must stay at its own position: {original_bounds:?}"); + assert!(original_bounds.max.dist(Vec3::new(20.0, 20.0, 20.0)) < 1e-6, "{original_bounds:?}"); + + let copy = state.bodies.iter().find(|b| b.name != "Cube").unwrap(); + let copy_bounds = copy.mesh().bounds(); + assert!(copy_bounds.min.dist(Vec3::new(30.0, 0.0, 0.0)) < 1e-6, "the copy must land at the translated position: {copy_bounds:?}"); + assert!(copy_bounds.max.dist(Vec3::new(50.0, 20.0, 20.0)) < 1e-6, "{copy_bounds:?}"); + + let original_volume = volume(original); + let copy_volume = volume(copy); + assert!((original_volume - 8000.0).abs() < 1e-3, "original volume: {original_volume}"); + assert!((copy_volume - 8000.0).abs() < 1e-3, "copy volume: {copy_volume}"); + assert!((original_volume - copy_volume).abs() < 1e-6, "the copy must have the same mass properties as the source"); + + let total: f64 = state.bodies.iter().map(volume).sum(); + assert!((total - 16000.0).abs() < 1e-3, "total volume across both bodies: {total}"); +} + +#[test] +fn without_copy_the_move_still_replaces_the_body_in_place() { + let mut s = Session::default(); + cube20(&mut s, "Cube"); + run(&mut s, "solid.move", json!({"bodies": ["Cube"], "translate": [30, 0, 0]})); + let state = s.model.state(); + assert_eq!(state.bodies.len(), 1, "a plain move must not create a second body"); + let moved = state.body("Cube").unwrap(); + let bounds = moved.mesh().bounds(); + assert!(bounds.min.dist(Vec3::new(30.0, 0.0, 0.0)) < 1e-6, "{bounds:?}"); +} + +#[test] +fn an_older_design_with_no_copy_key_at_all_still_loads_and_moves_in_place() { + let mut s = Session::default(); + cube20(&mut s, "Cube"); + run(&mut s, "solid.move", json!({"bodies": ["Cube"], "translate": [5, 0, 0]})); + let saved = s.doc.to_json(); + assert!(!saved.contains("\"copy\""), "a move without the flag must serialize with no `copy` key, as an older file would read: {saved}"); + + // Simulate a design saved before this feature existed: strip the key even if present, then + // reopen through the same loader a real design file goes through. + let mut old: Value = serde_json::from_str(&saved).unwrap(); + let features = old.get_mut("features").and_then(Value::as_array_mut).expect("features array"); + let mut touched = false; + for f in features.iter_mut() { + if f.get("type").and_then(Value::as_str) == Some("move") + && let Some(obj) = f.as_object_mut() + { + obj.remove("copy"); + touched = true; + } + } + assert!(touched, "the move feature must be present in the saved design"); + + let migrated = solidcraft_doc::Document::from_json(&old.to_string()).unwrap(); + let reopened = Session::new(migrated); + let state = reopened.model.state(); + assert_eq!(state.bodies.len(), 1, "a pre-copy design must still move the one body in place, never duplicate it"); + let moved = state.body("Cube").unwrap(); + assert!(moved.mesh().bounds().min.dist(Vec3::new(5.0, 0.0, 0.0)) < 1e-6); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e2468998..f06eea53 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1854,7 +1854,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { combo(ui, "sw_op", &OP_LABELS, operation); ui.end_row(); } - k @ Kind::Move { .. } => enter |= crate::dialogs_move::rows(ui, k), + k @ Kind::Move { .. } => { + enter |= crate::dialogs_move::rows(ui, k); + row_label(ui, "Create Copy"); + extra_check(ui, &mut d.extra, "copy"); + ui.end_row(); + } Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } => { row_label(ui, "Extents"); let cur = if to_object_on(&d.inputs) { 1 } else { usize::from(opts.all) * 2 }; @@ -2195,9 +2200,12 @@ fn extra_choice(ui: &mut egui::Ui, extra: &mut serde_json::Map, i /// A checkbox stored in the dialog's extra parameters. fn extra_check(ui: &mut egui::Ui, extra: &mut serde_json::Map, key: &str) { let mut on = extra.get(key).and_then(Value::as_bool).unwrap_or(false); - if ui.checkbox(&mut on, "").changed() { + let r = ui.checkbox(&mut on, ""); + if r.changed() { extra.insert(key.into(), json!(on)); } + // The box carries no text of its own, so a scenario reaches it by its row handle. + publish_row(&r); } /// A value field stored in the dialog's extra parameters (`default` until edited). @@ -3776,9 +3784,9 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Move { bodies, translate, rotate_axis, angle, copy } => { let mut d = start("solid.move")?; - crate::dialogs_move::for_feature(&mut d, bodies, translate, *rotate_axis, angle.as_ref()); + crate::dialogs_move::for_feature(&mut d, bodies, translate, *rotate_axis, angle.as_ref(), *copy); d } other => { diff --git a/crates/ui-egui/src/dialogs_move.rs b/crates/ui-egui/src/dialogs_move.rs index f0e54bae..4cc12aee 100644 --- a/crates/ui-egui/src/dialogs_move.rs +++ b/crates/ui-egui/src/dialogs_move.rs @@ -226,6 +226,9 @@ pub fn params(app: &SolveApp, d: &Dialog) -> Result { p["translate"] = num(at - from); } } + if d.extra.get("copy").and_then(Value::as_bool).unwrap_or(false) { + p["copy"] = json!(true); + } Ok(p) } @@ -285,7 +288,7 @@ pub fn drag(app: &SolveApp, d: &mut Dialog, g: Drag) { /// The dialog for editing a move: Free Move with its translation and, about a world axis /// through the origin, its angle (another axis is kept as it is). -pub fn for_feature(d: &mut Dialog, bodies: &[String], translate: &[String; 3], axis: Option, angle: Option<&String>) { +pub fn for_feature(d: &mut Dialog, bodies: &[String], translate: &[String; 3], axis: Option, angle: Option<&String>, copy: bool) { let [x, y, z] = translate.clone(); let mut k = kind(FREE); if let Kind::Move { x: kx, y: ky, z: kz, rx, ry, rz, .. } = &mut k { @@ -311,4 +314,7 @@ pub fn for_feature(d: &mut Dialog, bodies: &[String], translate: &[String; 3], a if let Some(inp) = d.inputs.first_mut() { inp.items = bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect(); } + if copy { + d.extra.insert("copy".into(), json!(true)); + } } diff --git a/crates/ui-egui/src/locales/de.json b/crates/ui-egui/src/locales/de.json index 333fca23..f5f25d43 100644 --- a/crates/ui-egui/src/locales/de.json +++ b/crates/ui-egui/src/locales/de.json @@ -69,6 +69,7 @@ "ui.counterbore": "Tiefenbohrung", "ui.countersink": "Senkung", "ui.create": "Erstellen", + "ui.create_copy": "Kopie erstellen", "ui.create_sketch": "Skizze erstellen", "ui.current": "aktuell", "ui.curvature": "Krümmung", diff --git a/crates/ui-egui/src/locales/en.json b/crates/ui-egui/src/locales/en.json index 482d371f..e3a9c266 100644 --- a/crates/ui-egui/src/locales/en.json +++ b/crates/ui-egui/src/locales/en.json @@ -69,6 +69,7 @@ "ui.counterbore": "Counterbore", "ui.countersink": "Countersink", "ui.create": "Create", + "ui.create_copy": "Create Copy", "ui.create_sketch": "Create Sketch", "ui.current": "current", "ui.curvature": "Curvature", diff --git a/crates/ui-egui/src/locales/ja.json b/crates/ui-egui/src/locales/ja.json index f837c0b0..f5cb8919 100644 --- a/crates/ui-egui/src/locales/ja.json +++ b/crates/ui-egui/src/locales/ja.json @@ -69,6 +69,7 @@ "ui.counterbore": "座ぐり", "ui.countersink": "皿もみ", "ui.create": "作成", + "ui.create_copy": "コピーを作成", "ui.create_sketch": "スケッチを作成", "ui.current": "現在", "ui.curvature": "曲率", diff --git a/crates/ui-egui/src/locales/ko.json b/crates/ui-egui/src/locales/ko.json index 5785375c..43b559ae 100644 --- a/crates/ui-egui/src/locales/ko.json +++ b/crates/ui-egui/src/locales/ko.json @@ -69,6 +69,7 @@ "ui.counterbore": "카운터보어", "ui.countersink": "카운터싱크", "ui.create": "만들기", + "ui.create_copy": "복사본 만들기", "ui.create_sketch": "스케치 만들기", "ui.current": "현재", "ui.curvature": "곡률", diff --git a/crates/ui-egui/tests/scenarios/solid_move_copy.json b/crates/ui-egui/tests/scenarios/solid_move_copy.json new file mode 100644 index 00000000..f0840869 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/solid_move_copy.json @@ -0,0 +1,49 @@ +[ + {"note": "solid.move Create Copy: the box keeps the original body where it was and adds a transformed copy beside it. Escape commits nothing, a typed distance reaches the command unchanged, and reopening the feature brings the box back ticked."}, + {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20}}}, + {"call": "ui.view", "params": {"view": "iso"}}, + + {"note": "Cancelled: the window opens, the box is ticked, a distance is typed, Escape leaves the design exactly as it was."}, + {"start": "solid.move"}, + {"click": {"world": [10, 10, 20]}}, + {"expect": {"dialog": "Move", "bodies": 1, "features": 1, "errors": 0}}, + {"click": {"handle": "dialog:Create Copy"}}, + {"click": {"handle": "dialog:X Distance"}}, + {"key": "A", "cmd": true}, + {"text": "30 mm"}, + {"expect": {"dialog": "copy\": Bool(true)"}}, + {"key": "Escape"}, + {"expect": {"dialog": null, "errors": 0, "bodies": 1, "features": 1, "volume": 8000}}, + {"expect": {"bbox": {"body": "Body1", "min": [0, 0, 0], "max": [20, 20, 20]}}}, + + {"note": "Typed: the 30 mm reaches solid.move, the original stays at the origin, the copy lands 30 mm along X with the same volume."}, + {"start": "solid.move"}, + {"click": {"world": [10, 10, 20]}}, + {"click": {"handle": "dialog:Create Copy"}}, + {"click": {"handle": "dialog:X Distance"}}, + {"key": "A", "cmd": true}, + {"text": "30 mm"}, + {"key": "Enter"}, + {"expect": {"dialog": null, "errors": 0, "bodies": 2, "volume": 16000}}, + {"expect": {"bbox": {"body": "Body1", "min": [0, 0, 0], "max": [20, 20, 20]}}}, + {"expect": {"bbox": {"body": "Body1 (2)", "min": [30, 0, 0], "max": [50, 20, 20]}}}, + {"expect": {"body_volume": {"body": "Body1", "volume": 8000}}}, + {"expect": {"body_volume": {"body": "Body1 (2)", "volume": 8000}}}, + + {"note": "The saved flag comes back: editing the move reopens the window with the box ticked and the distance filled in."}, + {"call": "ui.editFeature", "params": {"feature": "Move1"}}, + {"expect": {"dialog": "x: \"30 mm\""}}, + {"expect": {"dialog": "copy\": Bool(true)"}}, + {"key": "Escape"}, + {"expect": {"dialog": null, "errors": 0, "bodies": 2}}, + + {"note": "Dragged: the triad moves a copy the same way, and the original is left behind."}, + {"start": "solid.move"}, + {"click": {"world": [10, 10, 20]}}, + {"click": {"handle": "dialog:Create Copy"}}, + {"drag": [{"gizmo": "x"}, {"gizmo": "x", "by": [20, 0, 0]}]}, + {"key": "Enter"}, + {"expect": {"dialog": null, "errors": 0, "bodies": 3}}, + {"expect": {"bbox": {"body": "Body1", "min": [0, 0, 0], "max": [20, 20, 20]}}}, + {"shot": "solid_move_copy"} +]