Give solid.move the copy half of its Move/Copy label (#498)

solid.move takes copy: true to keep the source bodies and transform a new copy of each, with a Create Copy box in the Move dialog. Designs without it save and load exactly as before.
This commit is contained in:
William Dunn authored and GitHub committed 2026-10-13 01:54:01 -04:00
1 parent 69e54f85d2
commit 7d00df0a3f
15 files changed
+395 -11

No files matched your search

+4
View File
@@ -942,6 +942,10 @@ pub enum FeatureKind {
rotate_axis: Option<Vec3>,
#[serde(default, skip_serializing_if = "Option::is_none")]
angle: Option<String>,
/// 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.
+13 -4
View File
@@ -3808,7 +3808,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, 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<String, Value>, 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(())
+10 -2
View File
@@ -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<Value> {
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<Vec<String>> {
@@ -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;
@@ -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);
}
+12 -4
View File
@@ -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<String, Value>, i
/// A checkbox stored in the dialog's extra parameters.
fn extra_check(ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, 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<usize>) -> Option<Dia
}
d
}
FeatureKind::Move { bodies, translate, rotate_axis, angle } => {
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 => {
+7 -1
View File
@@ -226,6 +226,9 @@ pub fn params(app: &SolveApp, d: &Dialog) -> Result<Value, String> {
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<Vec3>, angle: Option<&String>) {
pub fn for_feature(d: &mut Dialog, bodies: &[String], translate: &[String; 3], axis: Option<Vec3>, 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));
}
}
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -69,6 +69,7 @@
"ui.counterbore": "座ぐり",
"ui.countersink": "皿もみ",
"ui.create": "作成",
"ui.create_copy": "コピーを作成",
"ui.create_sketch": "スケッチを作成",
"ui.current": "現在",
"ui.curvature": "曲率",
+1
View File
@@ -69,6 +69,7 @@
"ui.counterbore": "카운터보어",
"ui.countersink": "카운터싱크",
"ui.create": "만들기",
"ui.create_copy": "복사본 만들기",
"ui.create_sketch": "스케치 만들기",
"ui.current": "현재",
"ui.curvature": "곡률",
@@ -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"}
]