diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs
index 50f19717..3b5cb980 100644
--- a/crates/doc/src/eval.rs
+++ b/crates/doc/src/eval.rs
@@ -1986,7 +1986,11 @@ fn feature_tools_with_constructor(
let unnamed = |bodies: Vec
| -> Vec { 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 {
+ 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 {
let owner = doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
let inverse =
diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs
index 501d971e..ba8993de 100644
--- a/crates/engine/src/cmd/sheet_tests.rs
+++ b/crates/engine/src/cmd/sheet_tests.rs
@@ -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;
diff --git a/crates/engine/src/tests/component_frames.rs b/crates/engine/src/tests/component_frames.rs
index b6ce2d53..2bd2ffb2 100644
--- a/crates/engine/src/tests/component_frames.rs
+++ b/crates/engine/src/tests/component_frames.rs
@@ -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}");
+}