From 293d660d6ca24b5e60900ee383f7aac952d95f32 Mon Sep 17 00:00:00 2001 From: selune <35337584+0xlillith@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:46:34 +0200 Subject: [PATCH] fix(kernel): preserve intentional Split Face lines in contact joins (#782) Intentional Split Face lines survive a join with partial planar contact, so the far face stays in two pieces. Follow-up to #704 / #674. --- crates/engine/src/boolean_regression_tests.rs | 29 ++++++++ crates/kernel/src/coplanar.rs | 4 +- crates/kernel/src/issue_674_tests.rs | 70 +++++++++++++++++++ crates/kernel/src/lib.rs | 2 + docs/robustness.md | 8 +++ .../partial_face_contact_pattern.json | 8 +++ 6 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 crates/kernel/src/issue_674_tests.rs create mode 100644 examples/regressions/partial_face_contact_pattern.json diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index f6cb451f..c0222b6e 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -5,6 +5,35 @@ use serde_json::{Value, json}; use crate::Session; +/// Runs in PR CI as well as the heavier fixed-seed suite. Two pattern copies are +/// disconnected, and one cylinder overlaps the block by half its volume. +#[test] +fn partial_face_contact_pattern_has_analytic_material() { + // Repeated construction exposes re-imprinting a cylinder through both of its + // side faces, which can create a redundant arc along the first imprint. + for _ in 0..8 { + check_partial_face_contact_pattern(); + } +} + +fn check_partial_face_contact_pattern() { + let s = replay(include_str!("../../../examples/regressions/partial_face_contact_pattern.json")); + let (mut volume, mut area, mut shells) = (0.0, 0.0, 0); + for b in &s.model.state().bodies { + assert!(b.body.validity().is_empty(), "{:?}", b.body.validity()); + let m = b.body.tessellate(1e-5).unwrap().measure(); + volume += m.volume; + area += m.area; + shells += b.body.shell_count(); + } + let expected_volume = 25.0 * 47.5 * 37.5 + 3.5 * std::f64::consts::PI * 25.0 * 2.5; + let expected_area = 2.0 * (25.0 * 47.5 + 25.0 * 37.5 + 47.5 * 37.5) + 237.5 * std::f64::consts::PI - 25.0; + assert!((volume - expected_volume).abs() < 0.02, "{volume} expected {expected_volume}"); + assert!((area - expected_area).abs() < 0.02, "{area} expected {expected_area}"); + assert_eq!(shells, 3); + println!("#674 volume {volume:.6} / {expected_volume:.6} mm³; area {area:.6} / {expected_area:.6} mm²; {shells} shells"); +} + fn replay(script: &str) -> Session { let mut s = Session::default(); let script: Value = serde_json::from_str(script).unwrap(); diff --git a/crates/kernel/src/coplanar.rs b/crates/kernel/src/coplanar.rs index f943bd26..0acac927 100644 --- a/crates/kernel/src/coplanar.rs +++ b/crates/kernel/src/coplanar.rs @@ -716,7 +716,9 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option> { let from: Vec = solid.face_iter().filter_map(|f| prov.get(&f.id()).cloned()).collect(); // Each edge's origins in the solid's edge order. let origins = crate::body::edge_order(solid.face_iter()).0.iter().map(|e| origins.get(&e.id()).cloned()).collect(); - crate::boolean_record::heal_build(solid, size, &[], Some(from), origins, &record::counts(&[a, b])) + let keep: Vec<_> = a.split_keep().iter().chain(b.split_keep()).cloned().collect(); + crate::boolean_record::heal_build(solid, size, &keep, Some(from), origins, &record::counts(&[a, b])) + .map(|body| body.with_split_lines(Some(std::sync::Arc::new(keep)))) })) } diff --git a/crates/kernel/src/issue_674_tests.rs b/crates/kernel/src/issue_674_tests.rs new file mode 100644 index 00000000..8872d01f --- /dev/null +++ b/crates/kernel/src/issue_674_tests.rs @@ -0,0 +1,70 @@ +//! Partial planar contact must sew a solid without displacing or inventing material. +use crate::*; +use solidcraft_geom::Vec3; +use std::f64::consts::PI; + +#[test] +fn split_contact_arc_crossing_one_edge_twice_preserves_the_body() { + use truck_modeling::Surface; + let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap(); + let b = cylinder(Vec3::new(0.0, 7.5, 0.0), -Vec3::X, 5.0, 2.5).unwrap(); + let face = a.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_some_and(|n| n.dot(-Vec3::X) > 0.99)).unwrap(); + let side = b.solid.face_iter().position(|f| !matches!(f.surface(), Surface::Plane(_))).unwrap(); + let r = split_faces(&a, &[face], &SplitTool::Face { body: &b, face: side }).unwrap(); + assert_eq!(r.face_count(), a.face_count() + 1); + assert!(r.validity().is_empty(), "{:?}", r.validity()); + assert!(r.history().is_some()); + let (before, after) = (measure(&a).unwrap(), measure(&r).unwrap()); + assert!((before.volume - after.volume).abs() < 1e-7); + assert!((before.area - after.area).abs() < 1e-7); +} + +#[test] +fn partial_contact_does_not_join_tangent_or_separated_faces() { + let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap(); + for z in [-5.0, -6.0] { + let b = cylinder(Vec3::new(0.0, 7.5, z), -Vec3::X, 5.0, 2.5).unwrap(); + assert!(crate::coplanar::imprint_union(&a, &b, &|_| true).is_none()); + } +} + +#[test] +fn partial_contact_retains_an_authored_split_away_from_the_contact() { + let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap(); + let face = a.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_some_and(|n| n.dot(Vec3::X) > 0.99)).unwrap(); + let a = split_faces(&a, &[face], &SplitTool::Plane(solidcraft_geom::Plane { origin: Vec3::new(0.0, 0.0, 15.0), ..solidcraft_geom::Plane::XY })) + .unwrap(); + let b = cylinder(Vec3::new(0.0, 7.5, 0.0), -Vec3::X, 5.0, 2.5).unwrap(); + let r = boolean(&a, &b, BoolOp::Union).unwrap().unwrap(); + assert_eq!(r.split_lines(), a.split_lines()); + assert!(r.regularization_protected); + assert!(r.validity().is_empty()); + let remote_faces = + r.faces(0.01).unwrap().iter().filter(|f| f.plane_normal.is_some_and(|n| n.dot(Vec3::X) > 0.99) && (f.centroid.x - 25.0).abs() < 1e-7).count(); + assert_eq!(remote_faces, 2); +} + +#[test] +fn partial_circular_contact_preserves_material_and_history() { + for z in [-3.0_f64, 0.0, 3.0] { + for angle in [0.0, 0.37] { + let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap(); + let b = cylinder(Vec3::new(0.0, 7.5, z), -Vec3::X, 5.0, 2.5).unwrap(); + let place = |body: &Body| transform(body, Vec3::new(4.0, -8.0, 2.0), Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0), angle).unwrap(); + let (a, b) = (place(&a), place(&b)); + for reversed in [false, true] { + let (a, b) = if reversed { (&b, &a) } else { (&a, &b) }; + let r = boolean(a, b, BoolOp::Union).unwrap().unwrap(); + assert!(r.validity().is_empty(), "z={z} angle={angle}: {:?}", r.validity()); + assert_eq!(r.shell_count(), 1); + assert!(r.history().is_some(), "missing history z={z} angle={angle}"); + let m = measure(&r).unwrap(); + let volume = 25.0 * 47.5 * 37.5 + PI * 25.0 * 2.5; + let contact = 25.0 * (-z / 5.0).acos() + z * (25.0 - z * z).sqrt(); + let area = 2.0 * (25.0 * 47.5 + 25.0 * 37.5 + 47.5 * 37.5) + 2.0 * PI * 5.0 * (5.0 + 2.5) - 2.0 * contact; + assert!((m.volume - volume).abs() < 0.1, "z={z} angle={angle}: volume {} expected {volume}", m.volume); + assert!((m.area - area).abs() < 0.2, "z={z} angle={angle}: area {} expected {area}", m.area); + } + } + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index f6aa3043..98f48baf 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -40,6 +40,8 @@ mod intersection_curve_tests; #[cfg(test)] mod issue_616_tests; #[cfg(test)] +mod issue_674_tests; +#[cfg(test)] mod issue_679_tests; mod lip; mod loopblend; diff --git a/docs/robustness.md b/docs/robustness.md index 3cb832e3..7d74df53 100644 --- a/docs/robustness.md +++ b/docs/robustness.md @@ -97,6 +97,14 @@ fixes below (sound face pushes, no panics in the boolean's final assembly). ### Fixed +- Back-to-back contact joins preserve intentional Split Face boundaries, including on + faces away from the contact. The glue pass retains their lines during healing and in + the resulting body for subsequent operations. Partial circular-contact regressions + cover offsets, rotation, operand order, volume, area and history; the seed-16 pattern + also runs in the ordinary engine suite with analytic volume and area checks. + Imprinting visits a shared analytic support only once: the two side faces of a + cylinder must not recut the same outline into redundant arcs. + - **Shell corners where a curved face meets another at an angle** (the biggest cause, 215 of the 363 failed designs before). Moving a vertex took one linear step, exact only for planes; a cylinder meeting a plane at an angle (a shallow arc side) landed off the moved surface. diff --git a/examples/regressions/partial_face_contact_pattern.json b/examples/regressions/partial_face_contact_pattern.json new file mode 100644 index 00000000..9c14fe59 --- /dev/null +++ b/examples/regressions/partial_face_contact_pattern.json @@ -0,0 +1,8 @@ +{ + "description": "Issue #674: a patterned cylinder cap touches only part of a block face; fuse the shared region without adding material beyond the edge.", + "commands": [ + {"command": "solid.box", "params": {"height": 37.5, "length": 25.0, "width": 47.5}}, + {"command": "solid.cylinder", "params": {"axis": [1.0, 0.0, 0.0], "base": [0.0, 7.5, 0.0], "height": 2.5, "operation": "join", "radius": 5.0}}, + {"command": "solid.pattern.circular", "params": {"axis": "Z", "count": 4, "features": ["Cylinder1"]}} + ] +}