fix(doc): extrude and revolve in a moved component from another component's sketch (#403) (#407)

* fix(doc): extrude and revolve in a moved component from another component's sketch

A feature built from a sketch of another component read the sketch's plane as if it were in
the feature's own component. When the two components are placed differently (a moved or
jointed part), the tool landed transformed along with the feature's component: a join on a
part turned half a turn about Z, from a root sketch, came out on the far side of the origin.

Extrude and Revolve now see the sketch through both placements, as sweeps along 3D paths and
sketches on another component's face already do (sketch_in_feature_frame, using
profile_in_frame). The sheet-metal rotated-frames test built its cap witness from a sketch of
the moved component while the root was active, relying on the old reading; it now builds the
witness in the sketch's own component, which is what it measures against.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test: To Object extrude in a moved component from a root sketch

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* style: rustfmt

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
MqriusandClaude Opus 5.5 authored and GitHub committed 2026-10-12 18:54:19 -04:00
1 parent 0154572580
commit 968a22897c
3 files changed
+84 -2

No files matched your search

+21 -2
View File
@@ -1986,7 +1986,11 @@ fn feature_tools_with_constructor(
let unnamed = |bodies: Vec<Body>| -> Vec<Traced> { bodies.into_iter().map(Traced::unnamed).collect() };
match &f.kind {
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
let ss = sketch_in_feature_frame(
doc,
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
// From / To Object: where the face or point lies along the normal, measured over the
// middle of the profiles.
@@ -2129,7 +2133,11 @@ fn feature_tools_with_constructor(
Ok(tools)
}
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, convention, to, .. } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
let ss = sketch_in_feature_frame(
doc,
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let (o, d) = revolve_axis(&ss, axis)?;
let angles = if to.is_some() {
@@ -2420,6 +2428,17 @@ fn map_wire(wire: &mut solidcraft_sketch::Wire, matrix: &Mat) {
}
}
/// A feature's sketch as its component sees it: a sketch of another component that is placed
/// differently is moved through both placements (as `state_in_frame` does for sketches on faces),
/// so the tool is built where the sketch is shown. The sketch itself when they share a frame.
fn sketch_in_feature_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64) -> Result<SolvedSketch> {
let owner = doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
if owner == consumer || doc.occurrences.is_empty() {
return Ok(sketch.clone());
}
profile_in_frame(doc, sketch, consumer)
}
fn profile_in_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64) -> Result<SolvedSketch> {
let owner = doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
let inverse =
+2
View File
@@ -1411,6 +1411,8 @@ fn partial_depth_intersect_full_gauge_in_rotated_frames_does_not_invent_pockets(
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]}));
run(&mut s, "sketch.finish", json!({}));
let mut tool_session = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
// The witness is built in the sketch's own component, so its caps lie in the sketch's frame.
run(&mut tool_session, "component.activate", json!({"component":owner}));
run(&mut tool_session, "solid.extrude", json!({"sketch":"WorldTool","distance":2.5,"body_name":"CapWitness"}));
let witness_state = tool_session.model.state();
let witness = &witness_state.body("CapWitness").unwrap().body;
@@ -47,3 +47,64 @@ fn moved_component_picks_and_frames() {
run(&mut s, "solid.fillet", json!({"edges": [[25, ib.max.y, 0]], "radius": 1}));
assert!(vol(&mut s, "ArmBody") < v0 - 1.0, "the instance's pick reached the definition");
}
/// An extrude in a moved component, made from a sketch of another component (here the root), is
/// built where the sketch is shown: the sketch is seen through both placements, as a sketch on
/// that component's face already is. Before, its plane was read as if it were in the feature's
/// own component, so the join landed turned along with the moved component.
#[test]
fn extrude_in_a_moved_component_from_a_root_sketch() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-40, -5], "p1": [0, 5]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "body_name": "Jaw"}));
let made = run(&mut s, "component.from_bodies", json!({"bodies": ["Jaw"]}));
let jaw = made["components"][0]["component"].as_u64().unwrap();
// Turned half a turn about Z: the jaw now lies along +X (x 0..40, y -5..5).
run(&mut s, "occurrence.move", json!({"occurrence": "Jaw:1", "axis": [0, 0, 1], "angle": "180 deg", "origin": [0, 0, 0]}));
run(&mut s, "component.activate", json!({"component": 0}));
// A root sketch: a tab on the jaw's +Y side, x 30..40, y 5..7 in the world.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Tab"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [30, 5], "p1": [40, 7]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "component.activate", json!({"component": jaw}));
run(&mut s, "solid.extrude", json!({"sketch": "Tab", "distance": 10, "operation": "join", "targets": ["Jaw"]}));
let b = s.world_state().body("Jaw").unwrap().mesh().bounds();
let near = |a: f64, e: f64| (a - e).abs() < 1e-6;
assert!(near(b.min.x, 0.0) && near(b.max.x, 40.0) && near(b.min.y, -5.0) && near(b.max.y, 7.0), "{b:?}");
let v = run(&mut s, "inspect.measure", json!({"bodies": ["Jaw"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(v, 4000.0 + 200.0) < 1e-6, "{v}");
}
/// To Object in a moved component: the picked face is a world point, kept in the feature's own
/// component like its other picks, so the extrude ends at that face (here the jaw's top, 10 mm
/// above the sketch) wherever the component is placed.
#[test]
fn extrude_to_a_face_in_a_moved_component() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-40, -5], "p1": [0, 5]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "body_name": "Jaw"}));
let made = run(&mut s, "component.from_bodies", json!({"bodies": ["Jaw"]}));
let jaw = made["components"][0]["component"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence": "Jaw:1", "axis": [0, 0, 1], "angle": "180 deg", "origin": [0, 0, 0]}));
// Lifted too, so the jaw's own frame and the world differ in height (x 0..40, z 5..15).
run(&mut s, "occurrence.move", json!({"occurrence": "Jaw:1", "translate": [0, 0, 5]}));
run(&mut s, "component.activate", json!({"component": 0}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Tab"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [30, 5], "p1": [40, 7]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "component.activate", json!({"component": jaw}));
// Up to the jaw's top face, picked in the world at its middle: the tab rises from z 0 to 15.
run(&mut s, "solid.extrude", json!({"sketch": "Tab", "to": [20, 0, 15], "operation": "join", "targets": ["Jaw"]}));
let b = s.world_state().body("Jaw").unwrap().mesh().bounds();
let near = |a: f64, e: f64| (a - e).abs() < 1e-6;
assert!(
near(b.min.x, 0.0) && near(b.max.x, 40.0) && near(b.min.y, -5.0) && near(b.max.y, 7.0) && near(b.min.z, 0.0) && near(b.max.z, 15.0),
"{b:?}"
);
let v = run(&mut s, "inspect.measure", json!({"bodies": ["Jaw"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(v, 4000.0 + 10.0 * 2.0 * 15.0) < 1e-6, "{v}");
}