diff --git a/crates/algo/src/classifier/analytic.rs b/crates/algo/src/classifier/analytic.rs index 0f31e672..588f07cc 100644 --- a/crates/algo/src/classifier/analytic.rs +++ b/crates/algo/src/classifier/analytic.rs @@ -953,31 +953,45 @@ fn try_build_composite_classifier(topo: &Topology, solid: SolidId) -> Option Option { if planes.len() < 4 { return None; } + let axis_tol = 1e-9; 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| { + // 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| -> 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()? diff --git a/crates/operations/src/boolean/mod.rs b/crates/operations/src/boolean/mod.rs index ac2a38eb..0afb987d 100644 --- a/crates/operations/src/boolean/mod.rs +++ b/crates/operations/src/boolean/mod.rs @@ -2267,18 +2267,36 @@ fn solid_inner_wire_count(topo: &Topology, solid: SolidId) -> Result= 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, crate::OperationsError> { + let mut counts: std::collections::HashMap = 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 @@ -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 { - use std::collections::HashMap; - - let s = topo.solid(solid)?; - let shell = topo.shell(s.outer_shell())?; - let mut counts: HashMap = 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)) } diff --git a/crates/operations/tests/regress_hexwall_cuts.rs b/crates/operations/tests/regress_hexwall_cuts.rs new file mode 100644 index 00000000..b3603b99 --- /dev/null +++ b/crates/operations/tests/regress_hexwall_cuts.rs @@ -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") +} + +/// 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); +}