Compare commits

...

3 Commits

Author SHA1 Message Date
Brennen Herbruck 785ab93f16 Merge origin/main into moved/solidcore-2969
kernel_cases manifest.json: both sides added booleans cases (this branch the
2927 bore-step case and its chain, main one more); the counts are the sum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:37:45 -05:00
Charles Galant b992941d5d fix(topology): flip moved inner loops when DeleteEdge flips surviving face (WR-759)
Fixes #2927.

When delete_manifold_edge (upstream SmBrep::DeleteEdge, SmBrep.cpp:5872-5873, 6194-6197, 6524-6530) merges an outer loop with an inner loop across two coplanar faces of opposite surface normals where surface_to_keep belongs to del_f, it swaps the faces' surfaces and calls flip_face_orientation on surv_f, inverting surv_f's primary faceuse orientation so it matches del_f's. Because same_orientation_faces was never updated after that flip, moving del_f's remaining inner loops onto surv_f flipped them onto the wrong faceuse.

On flanged_spool.step cut at z > 130, merging the tool box's +Z cap face with the spool's -Z step face across r = 35 inverted the r = 31 bore hole on the merged cap face, failing SmBrep Pointer Test 98 twice and inflating the integrated volume from 217496.967 to 609976.137 mm^3.

Toggle same_orientation_faces when flip_face_orientation returns Ok(()), record WR-759 in plan/upstream-issues/, add standalone regression wr759_delete_edge_flipping_surviving_face_preserves_moved_inner_loop_orientation in crates/solidcore/src/topology/brep/p4.rs, add z = 130 / 130.0 bore-step coverage in crates/step-brep/tests/issue_2927_spool_boolean.rs, and add rig case booleans/2927-flanged-spool-cut-at-bore-step.

Co-Authored-By: Antigravity <noreply@google.com>
2026-11-08 16:34:22 -05:00
Charles Galant 23c8f45798 fix(step-brep): normalize STEP face outer bounds and vertex-touching loops (#2927)
Order FACE_OUTER_BOUND first when present (rejecting >= 2 outer bounds); when omitted on a multi-bound face, select the outer loop on planar faces via solidcore's Curve::calculate_tight_bounding_box (erroring on ties within EFF_ZERO), classify open non-periodic sheets via solidcore's topology::face::p3::compute_loop_orientation_3d (requiring a unique CCW UV outer loop enclosing all interior bounds within EFF_ZERO_PARAM), and refuse periodic/closed surfaces (cylinders, cones, spheres, tori, surfaces of revolution) rather than guessing.

Splice vertex-touching loops on the same face into single Eulerian material-on-left boundary cycles in <= m - 1 merges so SmFace::AssertValid Face Test 39 (Loops Share Vertex) is satisfied in both BuildMode::Default and BuildMode::StitchOnly.

Adds integration suite crates/step-brep/tests/issue_2927_spool_boolean.rs (evaluating closed-form / Gauss-Legendre reference volumes, uncut-spool B-spline chamfer residuals via OdeIsoCurveLength z-divergence, Level2 validity, additivity, mirror symmetry, and Lipschitz continuity across the 25 non-bore-step #2927 cuts plus 6 exterior boundary cuts and 5 fine collar cuts) and rig case booleans/2927-flanged-spool-cut-through-axis.

Part of #2927.

Co-Authored-By: Antigravity <noreply@google.com>
2026-11-08 16:34:22 -05:00
13 changed files with 11482 additions and 37 deletions
+267 -6
View File
@@ -42,7 +42,10 @@
//! - **Surface swap.** `DeleteEdge` swaps the two faces' surfaces through
//! `SmFace::SetSurface` (ported in `topology.face_p1`, which moves an owned
//! `Surface`): the swap takes the surface out of the deleted face and hands the
//! replaced one back.
//! replaced one back. When `flip_face` then flips the surviving face's orientation
//! (`SmBrep.cpp:6196`), `same_orientation_faces` is toggled too (`WR-759`) so loops
//! moved from `del_f` onto `surv_f` (`SmBrep.cpp:6526`) land on the matching faceuse
//! instead of the inverted one.
//! - **`SmLockBrepBox`** is RAII upstream; `StitchFaces` holds `topology.brep_p1`'s
//! [`LockBrepBox`] guard (unlocked on drop, on every path). Its lock
//! (`SmBrep::LockBoundingBox`, unit `query.brep`) is not ported yet.
@@ -885,7 +888,7 @@ fn delete_manifold_edge(
let split_loop = lu == lu_rad;
// mark when face and radial face have same orientation
let same_orientation_faces =
let mut same_orientation_faces =
eu1::faceuse_orientation(b, faceuse)? == eu1::faceuse_orientation(b, fu_rad)?;
// Determine if either end of edge is a strut end
@@ -985,10 +988,19 @@ fn delete_manifold_edge(
// swap the surfaces between the faces
swap_face_surfaces(k, b, env.key_of, sf, df)?;
if flip_face {
ignore_status(crate::error::passed_on(
face1::flip_face_orientation(b, sf),
k.fixes.brep_edit_statuses_pass_on,
)?)?;
// WR-759: `SmFace::FlipFaceOrientation` (`SmFace.cpp:10171-10198`)
// returns `Err` (`NER`) only before flipping `m_pFU` and the
// faceuses' orientations (`:10191-10193`), and `Ok(())`
// (`SM_SUCCESS`) whenever the flip happened; an ignored pass-on
// `Err` therefore did not flip `sf` and must leave
// `same_orientation_faces` unchanged.
match face1::flip_face_orientation(b, sf) {
Ok(()) => same_orientation_faces = !same_orientation_faces,
Err(e) => ignore_status(crate::error::passed_on::<()>(
Err(e),
k.fixes.brep_edit_statuses_pass_on,
)?)?,
}
}
}
// else: WARN (asked to keep a surface not used by the faces)
@@ -2364,4 +2376,253 @@ mod tests {
assert!(matches!(r, Err(SolidError::InvalidInput { .. })));
assert_eq!(b.face(f).unwrap().rectangular_trim, face2::UNSURE);
}
#[test]
fn wr759_delete_edge_flipping_surviving_face_preserves_moved_inner_loop_orientation() {
use crate::curve::curve::Curve;
use crate::curve::ellipse::Ellipse;
use crate::curve::line::Line;
use crate::geom::axis2_placement::Axis2Placement;
use crate::geom::tol::USE_DEFAULT;
use crate::mass::integrator::properties::{brep_volume, PropertyAttributes};
use crate::math::{Point3, Vec3};
use crate::surface::cone::Cone;
use crate::surface::plane::Plane;
use crate::surface::surface::Surface;
use crate::topology::brep::validation::{check_brep, CheckLevel};
use crate::topology::brep_constructor::{BrepConstructor, VertexNode};
use crate::topology::trimming_tools::create_uv_trim_curves;
let mut k = Kernel::default();
let frame_top_up = Axis2Placement::new(Point3::ZERO, Vec3::X, Vec3::Y); // z = 0, +Z
let frame_top_dn = Axis2Placement::new(Point3::ZERO, Vec3::X, -Vec3::Y); // z = 0, -Z
let frame_bot_up = Axis2Placement::new(Point3::new(0.0, 0.0, -5.0), Vec3::X, Vec3::Y); // z = -5, +Z
let frame_bot_dn = Axis2Placement::new(Point3::new(0.0, 0.0, -5.0), Vec3::X, -Vec3::Y); // z = -5, -Z
let plane_dom = Extent2d::from_bounds(-15.0, -15.0, 15.0, 15.0);
let c_top_10 = Curve::from(
Ellipse::create_canonical_circle(&k, &frame_top_up, 10.0, None, None).unwrap(),
);
let c_top_5 = Curve::from(
Ellipse::create_canonical_circle(&k, &frame_top_up, 5.0, None, None).unwrap(),
);
let c_top_2 = Curve::from(
Ellipse::create_canonical_circle(&k, &frame_top_up, 2.0, None, None).unwrap(),
);
let c_bot_10 = Curve::from(
Ellipse::create_canonical_circle(&k, &frame_bot_up, 10.0, None, None).unwrap(),
);
let c_bot_2 = Curve::from(
Ellipse::create_canonical_circle(&k, &frame_bot_up, 2.0, None, None).unwrap(),
);
let seam_10 = Curve::from(Line::from_points(
Point3::new(10.0, 0.0, -5.0),
Point3::new(10.0, 0.0, 0.0),
3,
));
let seam_2 = Curve::from(Line::from_points(
Point3::new(2.0, 0.0, -5.0),
Point3::new(2.0, 0.0, 0.0),
3,
));
let mut cyl_10 =
Surface::from(Cone::create_canonical_cylinder(&k, &frame_bot_up, 10.0).unwrap());
cyl_10
.adjust_step_uv_domain(&k, &Extent2d::from_bounds(0.0, -1.0, 360.0, 6.0))
.unwrap();
let cyl_10_dom = cyl_10.natural_uv_domain_with_nurb(&k).unwrap();
let mut cyl_2 =
Surface::from(Cone::create_canonical_cylinder(&k, &frame_bot_up, 2.0).unwrap());
cyl_2
.adjust_step_uv_domain(&k, &Extent2d::from_bounds(0.0, -1.0, 360.0, 6.0))
.unwrap();
let cyl_2_dom = cyl_2.natural_uv_domain_with_nurb(&k).unwrap();
let mut c = BrepConstructor::new();
c.start_brep(&mut k, USE_DEFAULT).unwrap();
c.start_region(true, None).unwrap();
c.start_shell().unwrap();
let v_t10 = c.start_vertex_of_loop(Point3::new(10.0, 0.0, 0.0)).unwrap();
let v_t5 = c.start_vertex_of_loop(Point3::new(5.0, 0.0, 0.0)).unwrap();
let v_t2 = c.start_vertex_of_loop(Point3::new(2.0, 0.0, 0.0)).unwrap();
let v_b10 = c
.start_vertex_of_loop(Point3::new(10.0, 0.0, -5.0))
.unwrap();
let v_b2 = c.start_vertex_of_loop(Point3::new(2.0, 0.0, -5.0)).unwrap();
// Face 1 (`surv_f`): z = 0 annulus r in [5, 10] on +Z plane, outward normal +Z (Same).
let surv_f = c.start_face(OrientType::Same).unwrap();
c.set_face_surface(
Surface::from(Plane::create_canonical(&frame_top_up).unwrap()),
&plane_dom,
)
.unwrap();
c.start_loop(OrientType::Same).unwrap();
let e_t10 = c
.start_edge(&mut k, OrientType::Same, None, Some(v_t10), Some(v_t10))
.unwrap();
c.set_edge_curve(&k, Some(c_top_10), false).unwrap();
let n_t10 = VertexNode::from_point(Point3::new(10.0, 0.0, 0.0));
c.set_limits(&k, &n_t10, &n_t10).unwrap();
c.end_edge().unwrap();
c.end_loop(&k).unwrap();
c.start_loop(OrientType::Same).unwrap();
let e_mid = c
.start_edge(&mut k, OrientType::Opposite, None, Some(v_t5), Some(v_t5))
.unwrap();
c.set_edge_curve(&k, Some(c_top_5), false).unwrap();
let n_t5 = VertexNode::from_point(Point3::new(5.0, 0.0, 0.0));
c.set_limits(&k, &n_t5, &n_t5).unwrap();
c.end_edge().unwrap();
c.end_loop(&k).unwrap();
c.end_face().unwrap();
// Face 2 (`del_f`): z = 0 annulus r in [2, 5] on -Z plane, outward normal +Z (Opposite).
let del_f = c.start_face(OrientType::Opposite).unwrap();
c.set_face_surface(
Surface::from(Plane::create_canonical(&frame_top_dn).unwrap()),
&plane_dom,
)
.unwrap();
c.start_loop(OrientType::Opposite).unwrap();
c.start_edge(&mut k, OrientType::Same, Some(e_mid), None, None)
.unwrap();
c.end_edge().unwrap();
c.end_loop(&k).unwrap();
c.start_loop(OrientType::Opposite).unwrap();
let e_t2 = c
.start_edge(&mut k, OrientType::Opposite, None, Some(v_t2), Some(v_t2))
.unwrap();
c.set_edge_curve(&k, Some(c_top_2), false).unwrap();
let n_t2 = VertexNode::from_point(Point3::new(2.0, 0.0, 0.0));
c.set_limits(&k, &n_t2, &n_t2).unwrap();
c.end_edge().unwrap();
c.end_loop(&k).unwrap();
c.end_face().unwrap();
// Face 3: z = -5 bottom annulus r in [2, 10] on -Z plane, outward normal -Z (Same).
c.start_face(OrientType::Same).unwrap();
c.set_face_surface(
Surface::from(Plane::create_canonical(&frame_bot_dn).unwrap()),
&plane_dom,
)
.unwrap();
c.start_loop(OrientType::Same).unwrap();
let e_b10 = c
.start_edge(&mut k, OrientType::Opposite, None, Some(v_b10), Some(v_b10))
.unwrap();
c.set_edge_curve(&k, Some(c_bot_10), false).unwrap();
let n_b10 = VertexNode::from_point(Point3::new(10.0, 0.0, -5.0));
c.set_limits(&k, &n_b10, &n_b10).unwrap();
c.end_edge().unwrap();
c.end_loop(&k).unwrap();
c.start_loop(OrientType::Same).unwrap();
let e_b2 = c
.start_edge(&mut k, OrientType::Same, None, Some(v_b2), Some(v_b2))
.unwrap();
c.set_edge_curve(&k, Some(c_bot_2), false).unwrap();
let n_b2 = VertexNode::from_point(Point3::new(2.0, 0.0, -5.0));
c.set_limits(&k, &n_b2, &n_b2).unwrap();
c.end_edge().unwrap();
c.end_loop(&k).unwrap();
c.end_face().unwrap();
// Face 4: outer cylinder r = 10, z in [-5, 0], outward normal +R (Same).
c.start_face(OrientType::Same).unwrap();
c.set_face_surface(cyl_10, &cyl_10_dom).unwrap();
c.start_loop(OrientType::Same).unwrap();
c.start_edge(&mut k, OrientType::Same, Some(e_b10), None, None)
.unwrap();
c.end_edge().unwrap();
let e_s10 = c
.start_edge(&mut k, OrientType::Same, None, Some(v_b10), Some(v_t10))
.unwrap();
c.set_edge_curve(&k, Some(seam_10), false).unwrap();
c.set_limits(&k, &n_b10, &n_t10).unwrap();
c.end_edge().unwrap();
c.start_edge(&mut k, OrientType::Opposite, Some(e_t10), None, None)
.unwrap();
c.end_edge().unwrap();
c.start_edge(&mut k, OrientType::Opposite, Some(e_s10), None, None)
.unwrap();
c.end_edge().unwrap();
c.end_loop(&k).unwrap();
c.end_face().unwrap();
// Face 5: inner bore cylinder r = 2, z in [-5, 0], outward normal -R (Opposite).
c.start_face(OrientType::Opposite).unwrap();
c.set_face_surface(cyl_2, &cyl_2_dom).unwrap();
c.start_loop(OrientType::Opposite).unwrap();
c.start_edge(&mut k, OrientType::Opposite, Some(e_b2), None, None)
.unwrap();
c.end_edge().unwrap();
let e_s2 = c
.start_edge(&mut k, OrientType::Same, None, Some(v_b2), Some(v_t2))
.unwrap();
c.set_edge_curve(&k, Some(seam_2), false).unwrap();
c.set_limits(&k, &n_b2, &n_t2).unwrap();
c.end_edge().unwrap();
c.start_edge(&mut k, OrientType::Same, Some(e_t2), None, None)
.unwrap();
c.end_edge().unwrap();
c.start_edge(&mut k, OrientType::Opposite, Some(e_s2), None, None)
.unwrap();
c.end_edge().unwrap();
c.end_loop(&k).unwrap();
c.end_face().unwrap();
c.end_shell(&mut k, false).unwrap();
c.end_region().unwrap();
let mut b = c.end_brep().unwrap();
for f in brep1::faces(&b, None).unwrap() {
create_uv_trim_curves(&k, &mut b, f).unwrap();
}
let pre = check_brep(&mut k, &mut b, CheckLevel::Level0).unwrap();
assert!(
pre.pointer_reports.is_empty(),
"pre-merge pointer reports: {:?}",
pre.pointer_reports
);
b.set_editing_enabled(true);
let serial = b.notify_serial().unwrap_or(1);
let key_of = |entity| super::super::p3::topology_ref(serial, entity);
let mut records = crate::topology::topology::NotifyRecords::new();
super::super::p3::seed_records(&mut records, &b, &key_of);
let mut attributes = AttributeStore::default();
let mut env = DeleteEnv {
records: &mut records,
attributes: &mut attributes,
key_of: &key_of,
context: None,
};
delete_edge(&mut k, &mut b, &mut env, e_mid, Some(del_f)).unwrap();
b.refresh_cached_topology();
assert_eq!(brep1::faces(&b, None).unwrap().len(), 4);
create_uv_trim_curves(&k, &mut b, surv_f).unwrap();
let chk = check_brep(&mut k, &mut b, CheckLevel::Level0).unwrap();
assert!(
chk.pointer_reports.is_empty(),
"expected clean topology after merging opposite-normal faces, got: {:?}",
chk.pointer_reports
);
let store = AttributeStore::default();
let attrs = PropertyAttributes {
store: &store,
key_of: &|_| crate::geom::attribute::ObjectKey(0),
};
let vol = brep_volume(&k, &mut b, 1e-7, &attrs).unwrap();
let expected_vol = core::f64::consts::PI * (100.0 - 4.0) * 5.0; // 480 * pi
assert!(
(vol - expected_vol).abs() < 5e-2,
"merged hollow cylinder volume {vol} != {expected_vol}"
);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1,80 @@
{
"area": "booleans",
"bodies": {
"b1": {
"keeping_splines": false,
"trusted": true
},
"b2": {
"keeping_splines": false,
"trusted": true
}
},
"calls": [
{
"inputs": [
"b1"
],
"op": "tagged",
"outputs": [
"b3"
],
"params": {
"operand": 0
}
},
{
"inputs": [
"b2"
],
"op": "tagged",
"outputs": [
"b4"
],
"params": {
"operand": 1
}
},
{
"inputs": [
"b3",
"b4"
],
"op": "boolean",
"outputs": [
"b5"
],
"params": {
"op": "subtract"
}
}
],
"case": "2927-flanged-spool-cut-at-bore-step",
"expected": {
"body_count": 1,
"method": "Subtracting z >= 130 removes the upper 10 mm [130, 140] of the scalloped top flange right at the horizontal bore step plane z = 130 (r = 31 -> 35). From V_full = 248052.0320278483 mm^3, the removed [130, 140] slab has 9 mm of constant scalloped section A(0) = 5509.766677996032 mm^2 on [130, 139], 1 mm of upper chamfer [139, 140] of exact Simpson integral (A(0) + 4 A(0.5) + A(1)) / 6 = 5444.496350618749 mm^2, less the r = 35 bore of height 10 (pi * 1225 * 10 = 38484.510006474966 mm^3) and 6 countersunk holes (each pi * 25 * 9 + pi/3 * (25 + 30 + 36) = 802.1533245966821 mm^3), removing 30555.35494957667 mm^3 and leaving 217496.67707827163 mm^3.",
"outcome": "solid",
"source": "analytic",
"sources": [
"tangency-labs/solidcore#2927"
],
"volume_mm3": 217496.67707827163,
"what": "half-space box z >= 130 subtracted from the 92-face flanged spool right on the inner bore step plane z = 130 (r = 31 to r = 35), where merging the tool's +Z cap face with the spool's -Z step face across r = 35 previously inverted the r = 31 bore loop (WR-759) and returned volume 609976 mm^3"
},
"expressible": true,
"gallery_note": "Horizontal cut on the z = 130 bore step plane: merging the opposite-normal annular step face with the tool cap preserves the r = 31 inner bore loop.",
"operation_expressible": true,
"records": "2927-flanged-spool-cut-at-bore-step.bodies.json.gz",
"result": [
{
"body": "spool",
"made": "b5"
}
],
"sources": [
"tangency-labs/solidcore#2927"
],
"ungraded": [],
"what": "half-space box z >= 130 subtracted from the 92-face flanged spool right on the inner bore step plane z = 130 (r = 31 to r = 35), where merging the tool's +Z cap face with the spool's -Z step face across r = 35 previously inverted the r = 31 bore loop (WR-759) and returned volume 609976 mm^3",
"why_not": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,80 @@
{
"area": "booleans",
"bodies": {
"b1": {
"keeping_splines": false,
"trusted": true
},
"b2": {
"keeping_splines": false,
"trusted": true
}
},
"calls": [
{
"inputs": [
"b1"
],
"op": "tagged",
"outputs": [
"b3"
],
"params": {
"operand": 0
}
},
{
"inputs": [
"b2"
],
"op": "tagged",
"outputs": [
"b4"
],
"params": {
"operand": 1
}
},
{
"inputs": [
"b3",
"b4"
],
"op": "boolean",
"outputs": [
"b5"
],
"params": {
"op": "subtract"
}
}
],
"case": "2927-flanged-spool-cut-through-axis",
"expected": {
"body_count": 1,
"method": "The 92-face flanged spool is symmetric across y = 0, so subtracting the half-space box y >= 0 leaves half of the exact spool volume V_full / 2. On z in [0, 128] the outer revolved profile consists of four 45-deg frusta r = 64 <-> 65 of height 1 (4 * pi/3 * (64^2 + 64*65 + 65^2)), two flange cylinders r = 65 of height 3 (2 * pi * 65^2 * 3), one barrel cylinder r = 35 of height 83 (pi * 35^2 * 83), three concave quarter-tori (R0 = 45, rho = 10, each pi * (45^2 * 10 - pi/2 * 45 * 10^2 + 2000/3)) and one neck concave quarter-torus (R0 = 40, rho = 5, pi * (40^2 * 5 - pi/2 * 40 * 25 + 250/3)), less the bore r = 31 of height 128 and 24 bolt holes r = 4 of height 5. On z in [128, 140] the 6-lobe scalloped flange (6 convex arcs R1 = 10 - delta at R = 45 and 6 tangent concave arcs R2 = 40 + delta, exact quadratic in delta integrated over [128, 129], [129, 139], [139, 140]) less the stepped bore (r = 31 on [128, 130], r = 35 on [130, 140]) and 6 countersunk holes (r = 5 on [128, 139], frustum 5 -> 6 on [139, 140]) gives V_full = 248052.0320278483 mm^3, hence V_full / 2 = 124026.01601392415 mm^3.",
"outcome": "solid",
"source": "analytic",
"sources": [
"tangency-labs/solidcore#2927"
],
"volume_mm3": 124026.01601392415,
"what": "half-space box y >= 0 subtracted from the 92-face flanged spool (planes, cylinders, cones, four concave fillet tori and 30 flange holes) along its axis: previously returned an empty 8-face shell of volume 0"
},
"expressible": true,
"gallery_note": "Axial y = 0 cut across all four concave fillet tori, chamfered bolt-circle flanges, stepped inner bore and scalloped top flange.",
"operation_expressible": true,
"records": "2927-flanged-spool-cut-through-axis.bodies.json.gz",
"result": [
{
"body": "spool",
"made": "b5"
}
],
"sources": [
"tangency-labs/solidcore#2927"
],
"ungraded": [],
"what": "half-space box y >= 0 subtracted from the 92-face flanged spool (planes, cylinders, cones, four concave fillet tori and 30 flange holes) along its axis: previously returned an empty 8-face shell of volume 0",
"why_not": []
}
@@ -20,7 +20,7 @@
}
},
"booleans": {
"cases": 125,
"cases": 127,
"chain": {
"1": 9,
"121": 1,
@@ -29,7 +29,7 @@
"17": 1,
"193": 1,
"28": 1,
"3": 2,
"3": 4,
"31": 1,
"4": 3,
"5": 1,
@@ -40,9 +40,9 @@
"8": 6,
"9": 2
},
"expressible": 110,
"expressible": 112,
"not_expressible": 12,
"operation_expressible": 113,
"operation_expressible": 115,
"reasons": {
"a final body built or changed by SolidCraft (its own geometry or a change to the kernel's record)": 10,
"refused or failed in SolidCraft, not in a kernel operation": 2
File diff suppressed because it is too large Load Diff
+4 -11
View File
@@ -586,19 +586,12 @@ fn face_input(kernel: &Kernel, body: &Body, face: &sm::Face) -> Result<FaceInput
curves: Vec::new(),
orients: Vec::new(),
};
for bound in &face.loops {
let Bound::Edges(edges) = &bound.bound else {
for bound in constructor::normalized_face_loops(kernel, body, face)
.map_err(|message| BuildError { message })?
{
let constructor::NormalizedLoop::Edges(oriented) = bound else {
continue;
};
let oriented: Vec<(usize, bool)> = if bound.orientation {
edges.iter().map(|edge| (edge.edge, edge.forward)).collect()
} else {
edges
.iter()
.rev()
.map(|edge| (edge.edge, !edge.forward))
.collect()
};
out.loops.push(oriented.len());
for (index, forward) in oriented {
let edge = body.edges.get(index).ok_or_else(|| BuildError {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
#### WR-759. `SmBrep::DeleteEdge` leaves `bSameOrientationFaces` stale after flipping the surviving face
- **Severity:** Wrong result
- **Area:** Topology, attributes, solvers
- **Location:** `source/SMLib/src/SmBrep.cpp:5872-5873, 6194-6197, 6524-6530`, `SmBrep::DeleteEdge` at v1.0.1 (`3fd5b9548d19aa8c12c29bc9637a96d42a06f8a5`).
- **What happens:** `bSameOrientationFaces` is computed before face-surface swapping (`:5872-5873`). When merging an outer loop with an inner loop (`:5901-5906`), the face owning the inner loop is forced to be `pSurvF` regardless of `pOptSurfaceToKeep`. If `pDelF` owns `pOptSurfaceToKeep` and the two faces have opposite surface normals (`bFlipFace == TRUE`), `SmBrep::DeleteEdge` swaps their surfaces and calls `pSurvF->FlipFaceOrientation()` (`:6194-6197`), inverting `pSurvF`'s primary faceuse orientation so it now matches `pDelF`'s. `bSameOrientationFaces` is never updated after that flip, so when `pDelF`'s remaining loops are moved onto `pSurvF` (`:6524-6530`), `if (!bSameOrientationFaces) pMoveLoop->m_pLU = pMoveLoop->m_pLU->GetOtherLoopuse();` flips each moved loop onto the wrong side of `pSurvF`.
- **Trigger:** Merging two coplanar faces with opposite surface normals across a shared closed loop that is inner on one face and outer on the other, passing `pDelF`'s surface as `pOptSurfaceToKeep`, when `pDelF` also carries an inner hole loop. Reached in Issue [#2927](https://github.com/tangency-labs/solidcore/issues/2927) (`flanged_spool.step` cut at `z = 130`, where the tool box's `+Z` cap face merges across `r = 35` with the spool's `-Z` step face `r in [31, 35]`, inverting the `r = 31` bore loop on the merged cap face, failing `SmBrep` Pointer Test 98 `Edgeuses share same region`, and inflating the integrated volume from `217496.967` to `609976.137`).
- **How found:** Issue [#2927](https://github.com/tangency-labs/solidcore/issues/2927), traced in `topology::brep::p4::delete_edge` during [PR #2969](https://github.com/tangency-labs/solidcore/pull/2969) and verified directly in `source/SMLib/src/SmBrep.cpp:5872-6531`.
- **Confirmed:** yes, by reading the pinned C++ source (`source/SMLib/src/SmBrep.cpp` at `3fd5b9548d19`):
```cpp
// source/SMLib/src/SmBrep.cpp:5872-5873
SmBoolean bSameOrientationFaces = ( pLU-> GetFaceuse()->GetOrientation()
== pLURad->GetFaceuse()->GetOrientation() ) ;
// ...
// source/SMLib/src/SmBrep.cpp:6194-6197
if(bFlipFace )
{
pSurvF->FlipFaceOrientation();
}
// ...
// source/SMLib/src/SmBrep.cpp:6524-6530
SmLoop *pMoveLoop = sLoops[ii];
SER(pDelF->RemoveLoop(pMoveLoop));
if (!bSameOrientationFaces)
{
pMoveLoop->m_pLU = pMoveLoop->m_pLU->GetOtherLoopuse();
}
SER(pSurvF->InsertLoop(pMoveLoop));
```
Reproduced on the direct port in `wr759_delete_edge_flipping_surviving_face_preserves_moved_inner_loop_orientation` (`crates/solidcore/src/topology/brep/p4.rs`, where a concentric hollow cylinder `r in [2, 10], h = 5` returns `520 * pi = 1633.628` and Pointer Test 98 without the toggle vs exact `480 * pi = 1507.964` with it) and on `cut z > 130` (`crates/step-brep/tests/issue_2927_spool_boolean.rs`). With the toggle applied when `flip_face_orientation` succeeds (`SmFace.cpp:10171-10198` returns `NER` only prior to flipping `m_pFU`), the merged `z = 130` cap face has exact area `3912.008850 mm^2` (`3.6e-8 mm^2` from closed form) and common-origin volume additivity within `1.3e-5 mm^3`; the `+0.290 mm^3` difference in `brep_volume` (`217496.967` vs `217496.677`) is not a kernel defect — `brep_volume` integrates `iint (z - z_0) n_z dA` (`OdeIsoCurveLength`), so shifting `z_0` to `bbox.mid().z = 65 mm` shifts the `+0.588 mm^3` origin integral by `-65 mm * 0.004596 mm^2 = -0.299 mm^3` due to the input STEP file's pre-existing `6.663e-3 mm` B-spline chamfer-edge tolerance on `z in [128, 129]`, shared identically by non-coincident cuts `z = 129.5, 130.5, 135.0`.