Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions crates/algo/src/classifier/analytic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -953,31 +953,45 @@ fn try_build_composite_classifier(topo: &Topology, solid: SolidId) -> Option<Ana
}
}

// A box model is only valid when the plane set actually IS a box:
// every plane axis-aligned with at most 2 distinct offsets per axis.
// Plane soups from solids with extra features (e.g. an oblique-walled
// hole cut through a cavity wall) previously collapsed into a garbage
// min/max box that confidently misclassified interior cavity points,
// poisoning every subsequent boolean on the solid.
let build_box = |planes: &[(Vec3, f64)]| -> Option<AnalyticClassifier> {
if planes.len() < 4 {
return None;
}
let axis_tol = 1e-9;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The axis_tol value of 1e-9 is a magic constant sitting between f64::EPSILON (~2.2e-16) and tol.linear (1e-7). The intent — reject any normal that isn't essentially exactly axis-aligned — is clear from the comment, but the choice of 1e-9 vs. something like f64::EPSILON * 4096.0 (a few thousand ULPs) is unexplained and easy to accidentally bump. A named constant with a short rationale comment would make future adjustments deliberate rather than arbitrary.

Suggested change
let axis_tol = 1e-9;
// Allow at most ~4096 ULPs of deviation from a unit axis component;
// this is far tighter than tol.linear (1e-7) so only truly axis-aligned
// normals pass, while 30°/60° hex-prism faces (component ≈ 0.866) are
// unambiguously rejected.
let axis_tol = f64::EPSILON * 4096.0; // ≈ 9.1e-13
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/algo/src/classifier/analytic.rs
Line: 966

Comment:
The `axis_tol` value of `1e-9` is a magic constant sitting between `f64::EPSILON` (~2.2e-16) and `tol.linear` (1e-7). The intent — reject any normal that isn't essentially exactly axis-aligned — is clear from the comment, but the choice of `1e-9` vs. something like `f64::EPSILON * 4096.0` (a few thousand ULPs) is unexplained and easy to accidentally bump. A named constant with a short rationale comment would make future adjustments deliberate rather than arbitrary.

```suggestion
        // Allow at most ~4096 ULPs of deviation from a unit axis component;
        // this is far tighter than tol.linear (1e-7) so only truly axis-aligned
        // normals pass, while 30°/60° hex-prism faces (component ≈ 0.866) are
        // unambiguously rejected.
        let axis_tol = f64::EPSILON * 4096.0; // ≈ 9.1e-13
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

let mut x_vals = Vec::new();
let mut y_vals = Vec::new();
let mut z_vals = Vec::new();
for &(normal, d) in planes {
if normal.x().abs() > 0.5 {
if normal.x().abs() > 1.0 - axis_tol {
x_vals.push(d / normal.x());
} else if normal.y().abs() > 0.5 {
} else if normal.y().abs() > 1.0 - axis_tol {
y_vals.push(d / normal.y());
} else if normal.z().abs() > 0.5 {
} else if normal.z().abs() > 1.0 - axis_tol {
z_vals.push(d / normal.z());
} else {
// Oblique plane — this is not a box.
return None;
}
}
if x_vals.is_empty() || y_vals.is_empty() || z_vals.is_empty() {
return None;
}
let sort = |v: &mut Vec<f64>| {
// Sort and dedup within tolerance; more than 2 distinct offsets on
// an axis means extra faces the box cannot represent.
let sort_dedup = |v: &mut Vec<f64>| -> bool {
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
v.dedup_by(|a, b| (*a - *b).abs() < tol.linear);
v.len() <= 2
};
sort(&mut x_vals);
sort(&mut y_vals);
sort(&mut z_vals);
if !sort_dedup(&mut x_vals) || !sort_dedup(&mut y_vals) || !sort_dedup(&mut z_vals) {
return None;
}
let x_min = *x_vals.first()?;
let x_max = if x_vals.len() >= 2 {
*x_vals.last()?
Expand Down
53 changes: 29 additions & 24 deletions crates/operations/src/boolean/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2267,18 +2267,36 @@ fn solid_inner_wire_count(topo: &Topology, solid: SolidId) -> Result<i64, crate:
/// Genus-aware Euler balance for a closed orientable surface with holed faces.
///
/// Euler-Poincare for a closed surface of genus `g`: `V - E + F - L = 2(1 - g)`,
/// so the inner-wire surplus `euler - L` equals `2 - 2g` and is valid only when
/// it is a non-negative even number no greater than 2: `2` for genus 0, `0` for
/// genus 1 (e.g. a tunnel cut through a shelled box). A negative surplus would
/// imply genus >= 2 (not produced by these booleans) or a miscounted shell, so
/// it is rejected. Callers must pair this with a closed-manifold check — the
/// relation only holds for closed surfaces.
/// so the inner-wire surplus `euler - L` equals `2 - 2g` and is valid when it
/// is an even number no greater than 2: `2` for genus 0, `0` for genus 1, and
/// negative even values for genus >= 2 (e.g. a thin wall pierced by N
/// through-holes has genus N). Odd or > 2 surpluses indicate a miscounted
/// shell. Callers must pair this with a closed-manifold check — the relation
/// only holds for closed surfaces.
const fn euler_balanced(euler: i64, inner_wires: i64) -> bool {
let surplus = euler - inner_wires;
#[allow(clippy::manual_range_contains)]
{
surplus >= 0 && surplus <= 2 && surplus % 2 == 0
surplus <= 2 && surplus % 2 == 0
}

/// Count edge uses across ALL shells of a solid (outer + inner cavity
/// shells). Hollow solids keep cavity faces in inner shells — an
/// outer-shell-only walk silently misses their edges, letting open or
/// non-manifold cavity shells pass the acceptance gates.
fn solid_edge_use_counts(
topo: &Topology,
solid: SolidId,
) -> Result<std::collections::HashMap<usize, usize>, crate::OperationsError> {
let mut counts: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
let face = topo.face(fid)?;
for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
let wire = topo.wire(wid)?;
for oe in wire.edges() {
*counts.entry(oe.edge().index()).or_insert(0) += 1;
}
}
}
Ok(counts)
}

/// Check whether every shell of a solid is a closed manifold: every edge
Expand Down Expand Up @@ -2326,25 +2344,12 @@ fn shell_is_closed_manifold(
Ok(counts.values().all(|&c| c == 2))
}

/// Check whether a solid's outer shell has free edges: edges used by only
/// Check whether a solid's boundary has free edges: edges used by only
/// one wire occurrence. A free edge means the shell is open (e.g. a phantom
/// membrane face left a circle edge unmatched), which is never a valid
/// boolean result even when Euler accidentally balances.
fn has_free_edges(topo: &Topology, solid: SolidId) -> Result<bool, crate::OperationsError> {
use std::collections::HashMap;

let s = topo.solid(solid)?;
let shell = topo.shell(s.outer_shell())?;
let mut counts: HashMap<usize, usize> = HashMap::new();
for &fid in shell.faces() {
let face = topo.face(fid)?;
for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
let wire = topo.wire(wid)?;
for oe in wire.edges() {
*counts.entry(oe.edge().index()).or_insert(0) += 1;
}
}
}
let counts = solid_edge_use_counts(topo, solid)?;
Ok(counts.values().any(|&c| c == 1))
}

Expand Down
113 changes: 113 additions & 0 deletions crates/operations/tests/regress_hexwall_cuts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//! Regression: sequential disjoint hex-prism cuts through one wall of a
//! hollow thin-walled box must each remove exactly the analytic prism
//! volume. Historically cut 2 onward was erratic (wrong deltas, gouges)
//! and from cut ~13 every cut silently no-opped while returning Ok.

#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use brepkit_math::mat::Mat4;
use brepkit_math::vec::Vec3;
use brepkit_operations::boolean::{BooleanOp, boolean};
use brepkit_operations::extrude::extrude;
use brepkit_operations::primitives;
use brepkit_operations::transform::transform_solid;
use brepkit_topology::Topology;
use brepkit_topology::builder::{make_planar_face_from_wire, make_regular_polygon_wire};

const HEX_R: f64 = 1.8;
const WEB: f64 = 0.8;
const WALL_T: f64 = 1.2;

fn make_hex_prism(topo: &mut Topology, r: f64, depth: f64) -> brepkit_topology::solid::SolidId {
let wire = make_regular_polygon_wire(topo, r, 6, 1e-7).expect("hex wire");
let face = make_planar_face_from_wire(topo, wire).expect("hex face");
extrude(topo, face, Vec3::new(0.0, 0.0, 1.0), depth).expect("hex prism")
}

fn make_hollow_bin(topo: &mut Topology) -> brepkit_topology::solid::SolidId {
let outer = primitives::make_box(topo, 84.0, 84.0, 30.0).expect("outer");
let cavity =
primitives::make_box(topo, 84.0 - 2.0 * WALL_T, 84.0 - 2.0 * WALL_T, 30.0).expect("cavity");
transform_solid(topo, cavity, &Mat4::translation(WALL_T, WALL_T, WALL_T)).expect("mv cavity");
boolean(topo, BooleanOp::Cut, outer, cavity).expect("hollow shell")
}
Comment on lines +27 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 make_hollow_bin produces an open-top bin, not a six-sided hollow box. The cavity is 30.0 mm tall (matching the outer box), translated by WALL_T = 1.2 mm in z, so it runs from z = 1.2 to z = 31.2. Since the outer box ceiling is at z = 30.0, the top face is fully removed by the boolean cut — the bin has no lid. The PR description says "hollow 84×84×30 box (1.2mm walls)", which implies closed walls on all sides. For the specific regression being tested (front-wall hex cuts) this doesn't affect correctness, but a comment noting the intentionally open top would prevent confusion, and a future test that relies on top-wall geometry would silently use the wrong shape.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/operations/tests/regress_hexwall_cuts.rs
Line: 27-33

Comment:
**`make_hollow_bin` produces an open-top bin, not a six-sided hollow box.** The cavity is `30.0` mm tall (matching the outer box), translated by `WALL_T = 1.2` mm in z, so it runs from `z = 1.2` to `z = 31.2`. Since the outer box ceiling is at `z = 30.0`, the top face is fully removed by the boolean cut — the bin has no lid. The PR description says "hollow 84×84×30 box (1.2mm walls)", which implies closed walls on all sides. For the specific regression being tested (front-wall hex cuts) this doesn't affect correctness, but a comment noting the intentionally open top would prevent confusion, and a future test that relies on top-wall geometry would silently use the wrong shape.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code


/// Honeycomb centers on the front wall (y=0), prism axis along +y,
/// piercing the full 1.2mm wall thickness.
fn hex_centers(n: usize) -> Vec<(f64, f64)> {
let col_sp = 3.0_f64.sqrt() * HEX_R + WEB;
let row_sp = 1.5 * HEX_R + WEB;
let mut centers = Vec::with_capacity(n.min(256));
let mut row = 0usize;
'outer: loop {
let z = 5.0 + row as f64 * row_sp;
if z > 27.0 {
break;
}
let x_off = if row % 2 == 1 { col_sp / 2.0 } else { 0.0 };
let mut col = 0usize;
loop {
let x = 5.0 + x_off + col as f64 * col_sp;
if x > 79.0 {
break;
}
if centers.len() >= n {
break 'outer;
}
centers.push((x, z));
col += 1;
}
row += 1;
}
centers
}

fn run_sequential_hex_cuts(n: usize) {
let depth = WALL_T * 4.0;
let hex_area = 1.5 * 3.0_f64.sqrt() * HEX_R * HEX_R;
let expected_delta = hex_area * WALL_T;

let mut topo = Topology::new();
let mut result = make_hollow_bin(&mut topo);
let mut prev_vol =
brepkit_operations::measure::solid_volume(&topo, result, 0.1).expect("shell volume");

let mut failures = Vec::new();
for (i, (x, z)) in hex_centers(n).iter().enumerate() {
let prism = make_hex_prism(&mut topo, HEX_R, depth);
let rot = Mat4::rotation_x(-std::f64::consts::FRAC_PI_2);
let mat = Mat4::translation(*x, WALL_T / 2.0 - depth / 2.0, *z) * rot;
transform_solid(&mut topo, prism, &mat).expect("mv prism");
result = boolean(&mut topo, BooleanOp::Cut, result, prism)
.unwrap_or_else(|e| panic!("cut {} failed: {e}", i + 1));
let vol =
brepkit_operations::measure::solid_volume(&topo, result, 0.1).expect("cut volume");
let delta = prev_vol - vol;
if (delta - expected_delta).abs() > 0.05 {
failures.push(format!(
"cut {}: delta {delta:.3} expected {expected_delta:.3}",
i + 1
));
}
prev_vol = vol;
}
assert!(
failures.is_empty(),
"{} of {n} cuts removed the wrong volume:\n{}",
failures.len(),
failures.join("\n")
);
}

#[test]
fn hexwall_sequential_cuts_20() {
run_sequential_hex_cuts(20);
}

/// Full honeycomb wall: every center the spacing pattern yields (133 with
/// these parameters). Also guards against the historical multi-minute
/// mesh-fallback churn — this must complete in seconds.
#[test]
fn hexwall_sequential_cuts_full() {
run_sequential_hex_cuts(usize::MAX);
}
Comment on lines +107 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No timeout guard for the "must complete in seconds" invariant. The comment explicitly calls out the historical multi-minute mesh-fallback churn and says the test must complete in seconds, but there is no enforcement. If a future change silently re-introduces the fallback, the test would still pass — it would just be very slow. Consider wrapping the call with a deadline: e.g. spawn the heavy work on a thread and join with a Duration, or use a test harness timeout attribute. Without this, the performance invariant is documentation-only.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/operations/tests/regress_hexwall_cuts.rs
Line: 107-113

Comment:
**No timeout guard for the "must complete in seconds" invariant.** The comment explicitly calls out the historical multi-minute mesh-fallback churn and says the test must complete in seconds, but there is no enforcement. If a future change silently re-introduces the fallback, the test would still pass — it would just be very slow. Consider wrapping the call with a deadline: e.g. spawn the heavy work on a thread and `join` with a `Duration`, or use a test harness timeout attribute. Without this, the performance invariant is documentation-only.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Loading