Fix mirror/pattern feature copy dropping bottom slice on hollow parts (#6452) (#7994)

* claim: #6452

* Engine: a pattern or mirror copy of a join keeps its ends as drawn (#6452)

The copy's tool is rebuilt against the current state, which already holds
the source feature's own material; the embedded-join-end trim (#2059)
read that material as the target already filling the copy's bottom band
and trimmed it away before the copy matrix was applied. Mirroring or
patterning a join on a hollow part silently lost the floor-level slice.
Copies now skip the trim: their ends are where the source drew them.

* chore: re-trigger duplicate PR check

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
This commit is contained in:
Dahak Builds
2026-11-02 14:03:53 +00:00
committed by GitHub
parent 1d98b59883
commit 768dc253dd
2 changed files with 55 additions and 3 deletions
+7 -3
View File
@@ -5334,11 +5334,15 @@ const APART_GAP: f64 = 1e-3;
/// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state.
/// `to_body` is the body a To Object extent runs to (an extrude), when it found one.
/// `copy` builds the tool of a pattern's or mirror's source: the state already holds the source
/// feature's own material, so a join end that reads as buried in the target there may be bare
/// material where the copy lands — its ends are left as drawn (#6452).
fn feature_tools_with_constructor(
doc: &Document,
vals: &BTreeMap<String, Value>,
f: &Feature,
st: &ModelState,
copy: bool,
constructor: &mut Option<ExtrudeConstructor>,
to_body: &mut Option<String>,
) -> Result<Vec<Traced>> {
@@ -5599,7 +5603,7 @@ fn feature_tools_with_constructor(
}
}
let surroundings = if exact_planar_cut || touching_batch { None } else { Surroundings::of(st, *operation, &plain_targets) };
let join_surroundings = if *operation == Operation::Join && !touching_batch {
let join_surroundings = if *operation == Operation::Join && !touching_batch && !copy {
// Trimming may discard a tool's material, so only bodies this feature
// actually joins can establish that the discarded end is already filled.
let participants = if plain_targets.is_empty() { &f.participants } else { &plain_targets };
@@ -7013,7 +7017,7 @@ fn replay(
// points follow their named faces, as the feature's own did (#2121): a To Object
// extrude's copies reach the face where it is now, not where it was picked.
let moved = with_named_face_points(doc, st, src).map(|(g, _)| g);
let tools = feature_tools_with_constructor(doc, vals, moved.as_ref().unwrap_or(src), st, &mut None, &mut None)?;
let tools = feature_tools_with_constructor(doc, vals, moved.as_ref().unwrap_or(src), st, true, &mut None, &mut None)?;
for (k, m) in mats.iter().enumerate() {
let start = copies.len();
for t in &tools {
@@ -7612,7 +7616,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::Sweep { operation, targets, .. } => {
let mut constructor = None;
let mut to_body = None;
let tools = feature_tools_with_constructor(doc, vals, f, st, &mut constructor, &mut to_body)?;
let tools = feature_tools_with_constructor(doc, vals, f, st, false, &mut constructor, &mut to_body)?;
// Cuts through sheet metal become cut-outs of its flat pattern.
if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
+48
View File
@@ -1155,3 +1155,51 @@ fn editing_a_rolled_back_pattern_says_it_is_not_built() {
assert_eq!(reply["rolled_back"], json!(true), "{reply}");
assert_eq!(reply["warning"], "1 of 1 cut copies remove no material", "{reply}");
}
/// A 100 x 60 x 30 box shelled 5 mm (floor included) with a 20 x 11 boss joined to its +Y wall:
/// 67,500 mm³ hollow, 73,500 mm³ with the boss, which adds 6,000 mm³ (#6452).
fn hollow_box_with_join_boss() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -30], "p1": [50, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Base", "distance": 30, "operation": "new", "name": "Block", "body_name": "Box"}));
run(&mut s, "solid.shell", json!({"faces": [[0, 0, 30]], "thickness": 5, "name": "Shell"}));
assert!(rel(volume(&mut s), 67500.0) < 1e-6, "hollow box");
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Boss"}));
run(&mut s, "sketch.line", json!({"closed": true, "points": [[20, 29], [40, 29], [40, 40], [20, 40]]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Boss", "distance": 30, "operation": "join", "targets": ["Box"], "name": "Join Boss"}));
assert!(rel(volume(&mut s), 73500.0) < 1e-6, "joined boss");
s
}
/// The copy of a join is built where the source stands, and the source's own material used to
/// read as the target already filling the copy's bottom band: the mirrored boss lost its
/// 20 x 10 x 5 mm floor-level slice with no warning (#6452).
#[test]
fn mirror_of_a_join_on_a_hollow_part_copies_the_whole_boss() {
let mut s = hollow_box_with_join_boss();
run(&mut s, "solid.mirror", json!({"features": ["Join Boss"], "plane": "XZ", "name": "Mirror Boss"}));
assert!(rel(volume(&mut s), 79500.0) < 1e-6, "mirrored copy lost material");
}
#[test]
fn mirror_of_a_join_on_a_hollow_part_copies_the_whole_boss_across_a_custom_plane() {
let mut s = hollow_box_with_join_boss();
run(&mut s, "solid.mirror", json!({"features": ["Join Boss"], "plane": {"origin": [0, 0, 0], "normal": [0, 1, 0]}, "name": "Mirror Boss"}));
assert!(rel(volume(&mut s), 79500.0) < 1e-6, "mirrored copy lost material");
}
/// A pure translation loses the same slice: the copy must be congruent to the original,
/// whatever filled the space where its tool was rebuilt (#6452).
#[test]
fn rectangular_pattern_of_a_join_on_a_hollow_part_copies_the_whole_boss() {
let mut s = hollow_box_with_join_boss();
run(
&mut s,
"solid.pattern.rectangular",
json!({"features": ["Join Boss"], "dir1": [0, -1, 0], "count1": 2, "spacing1": 69, "name": "Pattern Boss"}),
);
assert!(rel(volume(&mut s), 79500.0) < 1e-6, "patterned copy lost material");
}