Join keeps exact face attachments on faces it leaves unchanged (#420) (#453)

A Union dropped every construction certificate, so after any join no face
could take a durable sketch: sketch.create failed with "this face has no
exact planar source" even on a face the join never touched (#420, and the
"CREATE SKETCH: operation failed" reports #344, #195).

- kernel: map planar sources through a Union from both operands, with the
  same exact-support rule Cut uses. Coplanar supports of the two operands
  carry distinct identities, so a face merged from both stays without one.
- doc: Join extrude and Join box tools are certified like new bodies; a
  cap moved off its designed height (into the target, or past a curved To
  face) is not certified. Join producers are accepted when certifying.

Tests: both operands' faces attach and follow an upstream edit and a
reopen; a merged coplanar face is refused and fixed:true works; a face a
join covers whole is lost without moving to the new roof. Tests that used
a joined face as their "uncertified" example now use a merged face; the
trimmed-face case in a_sketch_on_a_face_stays_on_that_face now stays
attached to the surviving piece.


Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Irakli KourtisandClaude Opus 5.5 authored and GitHub committed 2026-10-12 20:31:32 -04:00
1 parent 9e60080b10
commit 722bd007c8
8 files changed
+199 -41

No files matched your search

+4 -2
View File
@@ -248,8 +248,10 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
};
}
r = r.map(|result| result.map(|body| body.inherit_regularization_protection(a).inherit_regularization_protection(b)));
if matches!(op, BoolOp::Cut | BoolOp::Union) {
r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, op == BoolOp::Cut)).transpose());
match op {
BoolOp::Cut => r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, true)).transpose()),
BoolOp::Union => r = r.and_then(|result| result.map(|body| body.with_preserved_union_planar_sources(a, b)).transpose()),
BoolOp::Intersect => {}
}
if r.is_ok() {
merge(diagnostics);
+65 -16
View File
@@ -102,13 +102,13 @@ pub(super) fn descriptor_cost(source: &FaceSource) -> Option<usize> {
impl Body {
/// Preserve uniquely certified unchanged planar construction supports.
pub fn with_preserved_planar_sources(self, input: &Body) -> Result<Self> {
self.map_planar_sources(input, None, None, &[], false)
self.map_planar_sources(&[input], None, None, &[], false)
}
/// Generate distinct outer/inner roles from the exact original supports of
/// an inside shell. Generated rims and curved walls are intentionally absent.
pub(crate) fn with_shell_planar_sources(self, input: &Body, thickness: f64, open: &[Vec3]) -> Result<Self> {
self.map_planar_sources(input, Some(thickness), None, open, false)
self.map_planar_sources(&[input], Some(thickness), None, open, false)
}
/// Retain exact shell roles, and construction roles for Cut, only on unchanged
@@ -118,19 +118,38 @@ impl Body {
if !input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, constructed))) {
return Ok(self_);
}
self_.map_planar_sources(input, None, Some(tool), &[], constructed)
self_.map_planar_sources(&[input], None, Some(tool), &[], constructed)
}
fn map_planar_sources(mut self, input: &Body, shell: Option<f64>, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result<Self> {
self.regularization_protected |= input.regularization_protected;
let Some(sources) = input.face_sources() else { return Ok(self) };
if input.solid.face_iter().count() > 16_384
/// Retain exact construction and shell roles through a Union from both operands, only on
/// unchanged supports. Coplanar supports of the two operands carry distinct identities, so
/// a face merged from both stays without a role.
pub(crate) fn with_preserved_union_planar_sources(self, a: &Body, b: &Body) -> Result<Self> {
let self_ = self.inherit_regularization_protection(a).inherit_regularization_protection(b);
if ![a, b].iter().any(|input| input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, true)))) {
return Ok(self_);
}
self_.map_planar_sources(&[a, b], None, None, &[], true)
}
fn map_planar_sources(mut self, inputs: &[&Body], shell: Option<f64>, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result<Self> {
// Every boolean (a Cut's tool, or a Union's second operand) leaves the result with
// faces whose support no input role describes.
let boolean = blocker.is_some() || inputs.len() > 1;
for input in inputs {
self.regularization_protected |= input.regularization_protected;
}
if inputs.iter().all(|input| input.face_sources().is_none()) {
return Ok(self);
}
if inputs.len() > 2
|| inputs.iter().any(|input| input.solid.face_iter().count() > 16_384)
|| self.solid.face_iter().count() > 16_384
|| blocker.is_some_and(|b| b.solid.face_iter().count() > 16_384)
{
return Err(KernelError::Invalid("planar source face budget exceeded".into()));
}
if sources.len() != input.solid.face_iter().count() {
if inputs.iter().any(|input| input.face_sources().is_some_and(|sources| sources.len() != input.solid.face_iter().count())) {
return Err(KernelError::Invalid("planar source alignment mismatch".into()));
}
if shell.is_some_and(|t| !t.is_finite() || t <= 0.) {
@@ -139,20 +158,22 @@ impl Body {
if open.len() > 256 {
return Ok(self);
}
let opening_tolerance = if shell.is_some() { input.size() * 1e-4 + 1e-6 } else { 0. };
let opening_tolerance = if shell.is_some() { inputs.iter().map(|input| input.size()).fold(0., f64::max) * 1e-4 + 1e-6 } else { 0. };
let mapped = guard("exact planar support lineage", || {
let competing: Vec<_> = blocker.into_iter().flat_map(|b| b.solid.face_iter()).filter_map(actual_plane).collect();
let mut candidates = Vec::new();
let mut unknown_supports = Vec::new();
for (face, source) in input.solid.face_iter().zip(sources) {
let faces = inputs.iter().flat_map(|input| {
let sources = input.face_sources().unwrap_or_default();
input.solid.face_iter().enumerate().map(move |(index, face)| (face, sources.get(index).and_then(Option::as_ref)))
});
for (face, source) in faces {
let Some(actual) = actual_plane(face) else { continue };
let Some(source) = source.as_ref() else {
let Some(source) = source else {
unknown_supports.push(actual);
continue;
};
if (blocker.is_some() && !boolean_source(source, constructed))
|| (shell.is_some() && matches!(source, FaceSource::ShellPlanar { .. }))
{
if (boolean && !boolean_source(source, constructed)) || (shell.is_some() && matches!(source, FaceSource::ShellPlanar { .. })) {
unknown_supports.push(actual);
continue;
}
@@ -207,7 +228,7 @@ impl Body {
.face_sources()
.and_then(|sources| sources.get(index))
.and_then(|s| s.as_ref())
.filter(|s| (blocker.is_some() || self.face_was_coalesced(index)) && !boolean_source(s, constructed));
.filter(|s| (boolean || self.face_was_coalesced(index)) && !boolean_source(s, constructed));
let keep = existing.and_then(|s| {
descriptor_cost(s).and_then(|cost| {
metadata.checked_sub(cost).map(|remaining| {
@@ -251,7 +272,7 @@ impl Body {
});
}
}
if found.is_none() && (blocker.is_some() || self.face_was_coalesced(index)) {
if found.is_none() && (boolean || self.face_was_coalesced(index)) {
found = self
.face_sources()
.and_then(|sources| sources.get(index))
@@ -428,6 +449,34 @@ mod tests {
assert!((copy.tessellate(0.01).unwrap().measure().volume - mesh.measure().volume).abs() < 1e-6);
}
#[test]
fn union_keeps_unchanged_supports_of_both_operands_and_drops_shared_ones() {
use crate::{BoolOp, boolean};
let a = box_solid(Vec3::ZERO, Vec3::new(20., 20., 10.)).unwrap().with_box_certificate(1).unwrap();
// A taller box overlapping one side: the bottoms share z = 0, the tops do not.
let b = box_solid(Vec3::new(10., 5., 0.), Vec3::new(30., 15., 15.)).unwrap().with_box_certificate(2).unwrap();
let joined = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
let sources = joined.face_sources().unwrap();
let mut seen = Vec::new();
for (i, face) in joined.solid.face_iter().enumerate() {
let plane = actual_plane(face).unwrap();
let source = sources[i].as_ref();
let height = plane.origin.z;
if plane.normal().z > 0.999 && (height - 10.).abs() < 1e-9 {
assert!(matches!(source, Some(FaceSource::Box { producer: Some(1), axis: 2, high: true, .. })), "{source:?}");
seen.push("a top");
} else if plane.normal().z > 0.999 && (height - 15.).abs() < 1e-9 {
assert!(matches!(source, Some(FaceSource::Box { producer: Some(2), axis: 2, high: true, .. })), "{source:?}");
seen.push("b top");
} else if plane.normal().z < -0.999 {
assert!(source.is_none(), "a bottom merged from both operands has no single identity: {source:?}");
seen.push("bottom");
}
}
for want in ["a top", "b top", "bottom"] {
assert!(seen.contains(&want), "{want} missing from {seen:?}");
}
}
#[test]
fn coincident_competing_source_planes_and_tool_planes_stay_unknown() {
let a = box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(1).unwrap();
let b = box_solid(Vec3::new(20., 0., 0.), Vec3::new(30., 10., 10.)).unwrap().with_box_certificate(2).unwrap();