test(kernel): re-measure the two wrong M4-K split fixtures

Both failures were wrong expectations in the WIP fixture, not defects in
`evaluate_split_faces`. Neither tolerance was widened; both numbers are
what OCCT actually builds, and the reason each moved is recorded at the
assertion.

**The fillet on a split fragment ran the whole edge, not the fragment.**
`BRepFilletAPI_MakeFillet::Add` builds its contour by PROPAGATION along
tangent-continuous edges, and a face split leaves the wall/top edge
collinear across the imprint, so the band is 40 mm and not the fragment's
10 (17965.66370614359, i.e. 18000 − (1 − π/4)·r²·40). Same at the moved
plane, which is why v2's volume is now identical to v1's — the edit is
proved by the fragment's centroid moving 5 → 6, which the test already
asserted, not by a volume.

**The chain cutter's L stopped inside the body.** Measured: a cutter whose
imprint ENDS in the middle of a face leaves a dangling edge on it and
reading the result's faces back out of the façade throws — and this is not
a chain-only limitation, a plane cutter too small to cross does the same.
The fixture's L now leaves through the +X wall (four crossed faces, ten
faces, eight fragments), and the partial imprint is pinned as a refusal
through both cutter kinds instead of being the accident that made the test
red. It fails closed, so the operation ships; giving that refusal a name of
its own is owed work, and the assertion is what will notice when the error
stops being the opaque one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 09:44:48 +02:00
co-authored by Claude Opus 5
parent f3c353b02d
commit b954d0852c
+110 -19
View File
@@ -267,15 +267,27 @@ fn a_cutter_that_misses_the_body_splits_nothing() {
}
}
/// A chain cutter, on the two chain shapes the operation is proven on: an
/// open two-segment L that crosses clean OUT of the body, and a lone
/// circle. Only the faces the imprint fully crosses split.
///
/// **A cutter that stops INSIDE a face is refused** — measured during the
/// M4-K closeout, and it is not a chain-only limitation: an imprint that
/// ends in the middle of a face leaves a dangling edge on it, and reading
/// the result's faces back out of the façade throws. It fails CLOSED,
/// which is why the operation ships; giving that refusal a name of its own
/// is owed work, and the assertion below is what will notice when the
/// error stops being the opaque one.
#[test]
fn a_chain_cutter_splits_only_the_faces_it_crosses() {
let mut kernel = OcctKernel::new();
let mut ids = Counter(0);
let mut box_table = NameTable::new();
let block = evaluate_box(&mut kernel, &mut box_table, &mut ids, 40.0, 30.0, 15.0).unwrap();
// An open L on the sketch plane at z = 7.5: (10,−5)→(10,15)→(30,15),
// swept ±20 — it crosses the top and bottom (the L imprints on both)
// and exits through the −Y wall.
// An open L on the sketch plane at z = 7.5: (10,−5)→(10,15)→(45,15),
// swept ±20. It enters through the −Y wall and leaves through the +X
// wall, so its imprint crosses four faces: both caps and those two
// walls.
let cutter = SplitCutter::Chain {
segments: vec![
SegmentGeometry::Line {
@@ -284,7 +296,7 @@ fn a_chain_cutter_splits_only_the_faces_it_crosses() {
},
SegmentGeometry::Line {
start: [10.0, 15.0],
end: [30.0, 15.0],
end: [45.0, 15.0],
},
],
placement: vernier_occt_sys::Placement {
@@ -298,16 +310,31 @@ fn a_chain_cutter_splits_only_the_faces_it_crosses() {
let cut = split(&mut kernel, &mut split_table, &mut ids, &block, &cutter).unwrap();
assert_eq!(cut.summary.topology.solids, 1);
close(cut.summary.geometry.volume, 18000.0, "volume unchanged");
assert_eq!(
cut.faces.len(),
7,
"one wall is split in two: the −Y wall the L passes through"
);
let ymin = role(&box_table, OpRole::BoxYMin);
assert!(matches!(cut.verdicts.get(ymin), Some(Verdict::Split(parts)) if parts.len() == 2));
assert_eq!(split_table.len(), 2);
assert_eq!(cut.faces.len(), 10, "four crossed faces split in two");
for crossed in [
OpRole::BoxYMin,
OpRole::BoxXMax,
OpRole::BoxZMin,
OpRole::BoxZMax,
] {
let parent = role(&box_table, crossed);
assert!(
matches!(cut.verdicts.get(parent), Some(Verdict::Split(parts)) if parts.len() == 2),
"{crossed:?} must split, got {:?}",
cut.verdicts.get(parent)
);
}
for missed in [OpRole::BoxXMin, OpRole::BoxYMax] {
let id = role(&box_table, missed);
assert_eq!(
cut.verdicts.get(id),
Some(&Verdict::Continued(id)),
"{missed:?} is not crossed"
);
}
assert_eq!(split_table.len(), 8, "eight fragments, nothing else");
// A closed chain (a circle alone) and a wrongly-declared one.
// A closed chain: a circle alone imprints a disc on both caps.
let ring = SplitCutter::Chain {
segments: vec![SegmentGeometry::Circle {
center: [20.0, 15.0],
@@ -317,12 +344,66 @@ fn a_chain_cutter_splits_only_the_faces_it_crosses() {
half_extent_mm: 20.0,
closed: true,
};
let ringed = split(&mut kernel, &mut split_table, &mut ids, &block, &ring).unwrap();
let mut ring_table = NameTable::new();
let ringed = split(&mut kernel, &mut ring_table, &mut ids, &block, &ring).unwrap();
assert_eq!(
ringed.faces.len(),
8,
"the circle imprints a disc on the top and the bottom"
);
close(ringed.summary.geometry.volume, 18000.0, "volume unchanged");
// A cutter whose imprint stops inside a face: refused, not shipped.
let stops_inside = SplitCutter::Chain {
segments: vec![SegmentGeometry::Line {
start: [10.0, -5.0],
end: [10.0, 15.0],
}],
placement: vernier_occt_sys::Placement {
oz: 7.5,
..vernier_occt_sys::XY_AT_Z0
},
half_extent_mm: 20.0,
closed: false,
};
let mut dangling_table = NameTable::new();
assert!(
matches!(
split(
&mut kernel,
&mut dangling_table,
&mut ids,
&block,
&stops_inside
),
Err(EvaluateError::Kernel(KernelError::Facade(
FacadeError::Occt
)))
),
"an imprint that ends inside a face must fail closed"
);
// Same limitation through a PLANE cutter too small to cross: it is
// the imprint that matters, not the cutter's kind.
let mut small_table = NameTable::new();
assert!(matches!(
split(
&mut kernel,
&mut small_table,
&mut ids,
&block,
&SplitCutter::Plane {
point: [20.0, 0.0, 0.0],
normal: [1.0, 0.0, 0.0],
half_extent_mm: 5.0,
}
),
Err(EvaluateError::Kernel(KernelError::Facade(
FacadeError::Occt
)))
));
// The two input refusals: a chain that lies about closing, and a
// degenerate plane normal.
let lie = SplitCutter::Chain {
segments: vec![SegmentGeometry::Line {
start: [0.0, 0.0],
@@ -391,11 +472,18 @@ fn a_fillet_on_a_split_fragment_survives_moving_the_split_plane() {
sibling: 0,
})
.unwrap();
// The band runs the fragment's 10 mm; the removed material is a
// quarter-round's complement: (1 − π/4)·r²·L.
// RE-MEASURED (M4-K closeout). The fillet does NOT stop at the
// fragment: `BRepFilletAPI_MakeFillet::Add` builds its contour by
// PROPAGATION along tangent-continuous edges, and a face split leaves
// the wall/top edge collinear across the imprint, so the band runs the
// block's whole 40 mm. That is the point of the fixture — the
// fragment's IDENTITY is what the split gives the fillet, not a
// shorter edge — so the number is asserted as what OCCT builds:
// 18000 − (1 − π/4)·r²·40. The removed material is a quarter-round's
// complement.
close(
f1.summary.geometry.volume,
18000.0 - (1.0 - std::f64::consts::FRAC_PI_4) * 4.0 * 10.0,
18000.0 - (1.0 - std::f64::consts::FRAC_PI_4) * 4.0 * 40.0,
"v1 volume",
);
@@ -432,10 +520,13 @@ fn a_fillet_on_a_split_fragment_survives_moving_the_split_plane() {
.unwrap();
assert!(f2.live.contains(&band), "the fillet face keeps its id");
assert_eq!(f1.live, f2.live, "zero churn across the plane edit");
// Unchanged for the same reason: the contour propagates past the
// moved imprint, so the band is 40 mm at both plane positions. The
// edit is proved by the fragment's centroid above, not by a volume.
close(
f2.summary.geometry.volume,
18000.0 - (1.0 - std::f64::consts::FRAC_PI_4) * 4.0 * 12.0,
"v2 volume: the band is 12 long now",
18000.0 - (1.0 - std::f64::consts::FRAC_PI_4) * 4.0 * 40.0,
"v2 volume: the band still runs the whole edge",
);
}