solid.mirror: mirror bodies of placed components where they are shown (#1896)

Thanks @NOWN-DEV.
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-17 04:08:22 -04:00
1 parent 9ce940b0b1
commit 2831426c51
9 files changed
+198 -8

No files matched your search

+15
View File
@@ -244,6 +244,21 @@ impl Document {
m
}
/// World transform of one placement of `component`: the occurrence `path` from the root
/// (empty for the root itself). `None` when the path no longer leads to that component.
pub fn path_transform(&self, component: u64, path: &[u64]) -> Option<Mat> {
if path.len() > MAX_PLACEMENT_DEPTH + 1 {
return None;
}
let (mut m, mut c) = (IDENTITY, 0);
for id in path {
let o = self.occurrences.iter().find(|o| o.id == *id && o.parent == c)?;
m = mat_mul(&m, &o.transform);
c = o.component;
}
(c == component).then_some(m)
}
/// Every placement of each component: (component, occurrence path ids, world transform).
/// A branch stops past [`MAX_PLACEMENT_DEPTH`] levels, the walk past [`MAX_PLACEMENTS`] placements.
pub fn placements(&self) -> Vec<(u64, Vec<u64>, Mat)> {
+5
View File
@@ -682,6 +682,11 @@ pub enum FeatureKind {
/// Join each mirrored body with its original.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
combine: bool,
/// Where each of `bodies` is mirrored from: the occurrence path of the placement it was
/// picked at. The body is mirrored where that placement shows it, as the feature's own
/// component sees it. Empty in older designs, which mirror the bodies' own geometry.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
placements: Vec<Vec<u64>>,
},
/// A drilled hole (simple, counterbore or countersink) at a point, into the material.
Hole {
+31 -4
View File
@@ -1039,7 +1039,9 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
FeatureKind::Revolve { to, .. } => to.is_some(),
_ => false,
};
if to_object {
// A mirror of placed bodies follows where they are placed.
let placed_mirror = matches!(&f.kind, FeatureKind::Mirror { placements, .. } if !placements.is_empty());
if to_object || placed_mirror {
return if doc.occurrences.is_empty() && doc.body_offsets.is_empty() { 0 } else { placements_hash(doc) };
}
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 };
@@ -3741,6 +3743,20 @@ fn mirror_matrix(pl: &Plane) -> Mat {
const MAX_INSTANCES: usize = 1000;
/// Where a mirrored body is, in the frame of the mirror's component: through the placement it
/// was picked at and the feature's component frame. `None` when that is the body's own frame
/// (and in older designs, which saved no placement).
fn mirrored_placement(doc: &Document, f: &Feature, mb: &ModelBody, path: Option<&[u64]>) -> Result<Option<Mat>> {
let Some(path) = path else { return Ok(None) };
let owner = doc.model_body_component(mb);
let lost = || DocError::Invalid(format!("the placement `{}` was mirrored at no longer exists", mb.name));
let world = doc.path_transform(owner, path).ok_or_else(lost)?;
let world = doc.model_body_offset(mb).map(|offset| crate::mat_mul(&world, offset)).unwrap_or(world);
let inverse = crate::mat_inverse(&doc.component_transform(f.component)).ok_or_else(lost)?;
let rel = crate::mat_mul(&inverse, &world);
Ok((!crate::is_identity(&rel)).then_some(rel))
}
/// The placements (column-major matrices) of a pattern's copies, the original excluded.
pub fn pattern_matrices(doc: &Document, st: &ModelState, p: &crate::PatternKind) -> Result<Vec<[[f64; 4]; 4]>> {
let (vals, _) = doc.param_values();
@@ -4670,17 +4686,28 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
Ok(())
}
FeatureKind::Mirror { features, plane, bodies, combine } => {
FeatureKind::Mirror { features, plane, bodies, combine, placements } => {
let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?;
if bodies.is_empty() {
return replay(doc, vals, f, st, &crate::plain(features), &[mirror_matrix(&pl)], warning);
}
let m = mirror_matrix(&pl);
let mut output = 0;
for n in bodies {
for (k, n) in bodies.iter().enumerate() {
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { continue };
let mirrored = moved_copy(&mb, kernel::transform_matrix(&mb.body, m)?, 1);
// A body of a component placed elsewhere is mirrored where it is shown: the copy
// (in this feature's component) from its placement, a joined body in its own frame.
let placed = mirrored_placement(doc, f, &mb, placements.get(k).map(Vec::as_slice))?;
let (copy, own) = match placed {
Some(rel) => {
let inverse = crate::mat_inverse(&rel).ok_or_else(|| DocError::Invalid(format!("the placement of `{n}` is singular")))?;
let copy = crate::mat_mul(&m, &rel);
(copy, crate::mat_mul(&inverse, &copy))
}
None => (m, m),
};
let mirrored = moved_copy(&mb, kernel::transform_matrix(&mb.body, if *combine { own } else { copy })?, 1);
if *combine {
let joined = kernel::boolean(&mb.body, &mirrored.body, BoolOp::Union)?
.ok_or_else(|| DocError::Invalid("the mirror join produced nothing".into()))?;
@@ -0,0 +1,5 @@
//! A Mirror of bodies keeps the placement each body was picked at (`placements`: one occurrence
//! path per body), and mirrors the body where that placement shows it, so a body of a moved or
//! inserted component, or of one of its further instances, is mirrored where it is seen. Older
//! designs need no transformation: a Mirror without `placements` mirrors the bodies' own
//! geometry as before; the last release asks for an update instead of mirroring them there.
+22
View File
@@ -1358,6 +1358,7 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() };
let plane = plane_param(s, p.get("plane"), cmd)?;
let combine = bool_(p, "combine").unwrap_or(false);
let (bodies, placements) = placed_bodies(s, bodies, cmd)?;
add_feature(
s,
p,
@@ -1366,10 +1367,31 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
plane,
bodies: bodies.into_iter().map(BodyName).collect(),
combine,
placements,
},
)
}
/// Bodies picked where the viewport shows them: each world name ("Body1", or "Body1 (Occ:2)"
/// for a further instance) becomes its component's body name, with the occurrence path of the
/// placement it was picked at. Names stay as they are (and no placement is kept) while every
/// component is in place, or when one of them is not a body.
fn placed_bodies(s: &Session, names: Vec<String>, cmd: &str) -> Result<(Vec<String>, Vec<Vec<u64>>)> {
if names.is_empty() || !crate::frames::placed(s) {
return Ok((names, Vec::new()));
}
let frames = s.doc.body_frames_with_source(&s.model.state());
let mut picked = Vec::with_capacity(names.len());
for name in &names {
let Some((_, own, _, path, _)) = frames.iter().find(|(world, ..)| world == name) else { return Ok((names, Vec::new())) };
if path.iter().any(|id| s.pending_moves.contains_key(id)) {
return Err(bad(cmd, format!("`{name}` has a position that is not captured yet: Capture Position (component.capture_position) first")));
}
picked.push((own.clone(), path.clone()));
}
Ok(picked.into_iter().unzip())
}
/// The other name of an origin plane (`Top` for `XY`, `XY` for `Top`...), if `name` is one.
pub(super) fn origin_plane_alias(name: &str) -> Option<&'static str> {
match name.trim().to_ascii_uppercase().as_str() {
+6 -2
View File
@@ -36,7 +36,8 @@ enum K {
/// component frame, whichever component the geometry under it belongs to (a sketch in the
/// root on a moved component's face finds the face where it is shown).
Here,
/// A plane object for a new sketch or canvas: into the active component's frame.
/// A plane object for a new sketch or canvas (or a mirror's copies): into the active
/// component's frame.
HerePlane,
}
@@ -59,7 +60,10 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
"solid.press_pull" => &[("face", Pick), ("edges", Picks)],
"solid.replace_face" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)],
"solid.align" => &[("from", Pick), ("from_face", Pick), ("to", Pick), ("to_face", Pick), ("bodies", Body)],
"solid.mirror" | "solid.split_body" => &[("plane", Plane), ("bodies", Body), ("body", Body)],
// A mirror keeps the placement its bodies were picked at (`cmd::features::placed_bodies`);
// its plane is in the active component, where the copies go.
"solid.mirror" => &[("plane", HerePlane)],
"solid.split_body" => &[("plane", Plane), ("bodies", Body), ("body", Body)],
"plastic.boss" | "plastic.snap_fit" | "plastic.rest" => {
&[("position", Pick), ("direction", Dir), ("hook", Dir), ("along", Dir), ("body", Body)]
}
+1
View File
@@ -314,6 +314,7 @@ mod large_step_import;
mod loft_cut;
mod loft_ring;
mod loft_spacing;
mod mirror_placed;
mod mirror_planes;
#[cfg(not(target_arch = "wasm32"))]
mod native_cad_import;
+105
View File
@@ -0,0 +1,105 @@
//! `solid.mirror` of bodies in a placed component (a moved occurrence, a further instance, an
//! inserted design) mirrors each body where it is shown. Before, it mirrored the component's own
//! geometry and put the copy in the active component, so the copy lost the placement.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use super::run;
use crate::*;
fn world_box(s: &Session, body: &str) -> (Vec3, Vec3) {
let b = s.world_state().body(body).unwrap_or_else(|| panic!("no body `{body}`")).mesh().bounds();
(b.min, b.max)
}
/// The box of `body` mirrored in the world YZ plane.
fn mirrored_yz(s: &Session, body: &str) -> (Vec3, Vec3) {
let (lo, hi) = world_box(s, body);
(Vec3::new(-hi.x, lo.y, lo.z), Vec3::new(-lo.x, hi.y, hi.z))
}
fn assert_box(got: (Vec3, Vec3), want: (Vec3, Vec3)) {
assert!(got.0.dist(want.0) < 1e-6 && got.1.dist(want.1) < 1e-6, "got {got:?}, want {want:?}");
}
/// A component "Arm" holding "ArmBody", its occurrence turned and moved; the root is active.
fn moved_arm(s: &mut Session) -> (u64, u64) {
let c = run(s, "component.create", json!({"name": "Arm"}));
let (component, occurrence) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap());
run(s, "solid.box", json!({"corner": [10, 0, 0], "length": 20, "width": 4, "height": 6, "body_name": "ArmBody"}));
run(s, "component.activate", json!({"component": 0}));
run(s, "occurrence.move", json!({"occurrence": occurrence, "axis": [1, 0, 0], "angle": "90 deg", "translate": [30, 50, 0]}));
(component, occurrence)
}
fn created(r: &Value) -> String {
r["created_bodies"][0].as_str().unwrap().to_string()
}
#[test]
fn a_moved_components_body_is_mirrored_where_it_is_shown() {
let mut s = Session::default();
let (_, occurrence) = moved_arm(&mut s);
let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"})));
assert_box(world_box(&s, &copy), mirrored_yz(&s, "ArmBody"));
// The copy follows its source when the occurrence moves again.
run(&mut s, "occurrence.move", json!({"occurrence": occurrence, "space": "world", "translate": [0, 200, 0]}));
assert_box(world_box(&s, &copy), mirrored_yz(&s, "ArmBody"));
// It survives a save and reopen.
let t = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_box(world_box(&t, &copy), mirrored_yz(&t, "ArmBody"));
}
#[test]
fn a_further_instance_is_mirrored_at_its_own_placement() {
let mut s = Session::default();
let (component, _) = moved_arm(&mut s);
run(&mut s, "occurrence.copy", json!({"component": component, "translate": [0, 0, 40]}));
let names: Vec<String> = s.world_state().bodies.iter().map(|b| b.name.clone()).collect();
let instance = names.iter().find(|n| n.starts_with("ArmBody (")).cloned().unwrap();
let r = run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody", instance], "plane": "YZ"}));
let copies: Vec<String> = r["created_bodies"].as_array().unwrap().iter().map(|n| n.as_str().unwrap().to_string()).collect();
assert_eq!(copies.len(), 2, "{r}");
assert_box(world_box(&s, &copies[0]), mirrored_yz(&s, "ArmBody"));
assert_box(world_box(&s, &copies[1]), mirrored_yz(&s, &instance));
}
#[test]
fn a_plane_picked_on_a_moved_body_is_a_plane_of_the_active_component() {
let mut s = Session::default();
moved_arm(&mut s);
// A world plane x = 30 through the moved body: the copy is mirrored in it, as shown.
let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": {"origin": [30, 50, 0], "normal": [1, 0, 0]}})));
let (lo, hi) = world_box(&s, "ArmBody");
assert_box(world_box(&s, &copy), (Vec3::new(60.0 - hi.x, lo.y, lo.z), Vec3::new(60.0 - lo.x, hi.y, hi.z)));
}
#[test]
fn a_joined_mirror_of_a_moved_body_stays_in_its_component() {
let mut s = Session::default();
moved_arm(&mut s);
// A world plane through the body's far end: the joined body doubles along x where it is.
let (lo, hi) = world_box(&s, "ArmBody");
run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": {"origin": [hi.x, 0, 0], "normal": [1, 0, 0]}, "combine": true}));
let len = hi.x - lo.x;
assert_box(world_box(&s, "ArmBody"), (lo, Vec3::new(hi.x + len, hi.y, hi.z)));
}
#[test]
fn bodies_in_place_keep_no_placement() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Base"}));
run(&mut s, "solid.mirror", json!({"bodies": ["Base"], "plane": "YZ"}));
let f = s.doc.features.last().unwrap();
assert!(matches!(&f.kind, solidcraft_doc::FeatureKind::Mirror { placements, .. } if placements.is_empty()), "{:?}", f.kind);
}
#[test]
fn a_position_not_captured_yet_is_refused() {
let mut s = Session::default();
let (_, occurrence) = moved_arm(&mut s);
run(&mut s, "occurrence.move", json!({"occurrence": occurrence, "translate": [5, 0, 0], "capture": false}));
let e = s.execute("solid.mirror", &json!({"bodies": ["ArmBody"], "plane": "YZ"})).unwrap_err().to_string();
assert!(e.contains("Capture Position"), "{e}");
}
+8 -2
View File
@@ -4921,14 +4921,20 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::Mirror { features, plane, bodies, .. } => {
FeatureKind::Mirror { features, plane, bodies, placements, .. } => {
let mut d = start("solid.mirror")?;
let ids: Vec<u64> = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect();
// Replay references stay features, including features that created a body.
let as_features = !features.is_empty();
if let Some(inp) = d.inputs.get_mut(0) {
if !bodies.is_empty() {
inp.items = bodies.iter().map(|name| Sel::Body { name: name.to_string() }).collect();
// Each body as the placement it was mirrored at shows it (an instance by its world name).
let own = s.model.state();
let world = |k: usize, name: &str| match (placements.get(k), own.body(name)) {
(Some(path), Some(b)) => s.doc.world_body_name(&own, name, s.doc.model_body_component(b), path),
_ => name.to_string(),
};
inp.items = bodies.iter().enumerate().map(|(k, name)| Sel::Body { name: world(k, name) }).collect();
} else if as_features {
inp.accept = selection::FEATURES;
inp.label = objects_label(inp.accept);