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
46 changes: 28 additions & 18 deletions .claude/skills/roadmap/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,15 +244,20 @@ snapClip family — THREE roots CLOSED 2026-07-16 (#1080, #1082, #1085); deepene
chain partners (weld at 100·tol). FOIL SET GREW: cylinder-slot + groove-mouth +
junction-disc are now mandatory alongside d4/pcut3/divider for ANY section/clip
change — three wrong gate choices were each caught by a different foil.
- REMAINING after the deepened-opening union (which closed the join-edges chain —
the full 44-hole snapClip plate replays analytic F=881 posBad=0, was F=8207
bnd=86): the 0.6mm-nozzle chain still mesh-falls-back at op-cut-5 on a FRESH
native chain (nozzle-scaled cutter dims — dig recipe: replay driver
`replay_snapjoin.rs` in the 2026-07-17 capture cache, CHAIN=1 DUMP_AT=5 on
capture-snapnozzle to mint the honest pre-cut plate, then PLATE/HOLE minimal
repro; captured operands are fallback-poisoned, do NOT replay them directly),
and the bed-flat clip volume 46.701 vs 46.6±0.05 pin (genuine deviation, the
reference kernel passes — per-op dual-kernel volume diff).
- REMAINING after the deepened-opening union AND the plane×cone exact-circle
arc (fixture `snapclip_export_corner_inmem.rs` — the EXPORT chain builds with
forExport=FALSE/tapered pockets; `trim_ellipse_to_boundary_crossings` only
accepted Ellipse sections, so the horizontal cutter-top's exact Circle arc
died in the 16-sample in-both filter and the corner cones never split; both
join-edges chains now replay fully analytic posBad=0 — true-variant F=881,
export-variant F=418): the 0.6mm-nozzle EXPORT chain still breaks at
op-cut-3 (posBad=10 analytic-but-leaky, accepted by the gate; fresh minimal
repro via CHAIN=1 DUMP_AT=3 on capture-snapnozzle-noexp in the 2026-07-17
cache — captured operands are fallback-poisoned, never replay them directly),
the by-edge-id acceptance gate is BLIND to position-duplicate leaks (poison
propagates silently — evaluate a position-quantized gate), and the bed-flat
clip volume 46.701 vs 46.6±0.05 pin (genuine deviation, the reference kernel
passes — per-op dual-kernel volume diff).

Fit-offset groove-mouth sliver family — CLOSED (2026-07-16, PR #1078, fixture
`crates/io/tests/fitoffset_groove_mouth_inmem.rs`): each groove cutter's mouth
Expand Down Expand Up @@ -553,15 +558,20 @@ snapClip family — THREE roots CLOSED 2026-07-16 (#1080, #1082, #1085); deepene
chain partners (weld at 100·tol). FOIL SET GREW: cylinder-slot + groove-mouth +
junction-disc are now mandatory alongside d4/pcut3/divider for ANY section/clip
change — three wrong gate choices were each caught by a different foil.
- REMAINING after the deepened-opening union (which closed the join-edges chain —
the full 44-hole snapClip plate replays analytic F=881 posBad=0, was F=8207
bnd=86): the 0.6mm-nozzle chain still mesh-falls-back at op-cut-5 on a FRESH
native chain (nozzle-scaled cutter dims — dig recipe: replay driver
`replay_snapjoin.rs` in the 2026-07-17 capture cache, CHAIN=1 DUMP_AT=5 on
capture-snapnozzle to mint the honest pre-cut plate, then PLATE/HOLE minimal
repro; captured operands are fallback-poisoned, do NOT replay them directly),
and the bed-flat clip volume 46.701 vs 46.6±0.05 pin (genuine deviation, the
reference kernel passes — per-op dual-kernel volume diff).
- REMAINING after the deepened-opening union AND the plane×cone exact-circle
arc (fixture `snapclip_export_corner_inmem.rs` — the EXPORT chain builds with
forExport=FALSE/tapered pockets; `trim_ellipse_to_boundary_crossings` only
accepted Ellipse sections, so the horizontal cutter-top's exact Circle arc
died in the 16-sample in-both filter and the corner cones never split; both
join-edges chains now replay fully analytic posBad=0 — true-variant F=881,
export-variant F=418): the 0.6mm-nozzle EXPORT chain still breaks at
op-cut-3 (posBad=10 analytic-but-leaky, accepted by the gate; fresh minimal
repro via CHAIN=1 DUMP_AT=3 on capture-snapnozzle-noexp in the 2026-07-17
cache — captured operands are fallback-poisoned, never replay them directly),
the by-edge-id acceptance gate is BLIND to position-duplicate leaks (poison
propagates silently — evaluate a position-quantized gate), and the bed-flat
clip volume 46.701 vs 46.6±0.05 pin (genuine deviation, the reference kernel
passes — per-op dual-kernel volume diff).

Fit-offset groove-mouth sliver family — CLOSED (2026-07-16, PR #1078, fixture
`crates/io/tests/fitoffset_groove_mouth_inmem.rs`): each groove cutter's mouth
Expand Down
51 changes: 40 additions & 11 deletions crates/algo/src/pave_filler/phase_ff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1279,12 +1279,41 @@ fn trim_ellipse_to_boundary_crossings(
ext_a: &FaceExtent,
ext_b: &FaceExtent,
) -> Option<Vec<RawCurve>> {
use brepkit_math::curves::Ellipse3D;

let EdgeCurve::Ellipse(ellipse) = &raw.curve else {
return None;
use brepkit_math::curves::{Circle3D, Ellipse3D};

// The raw plane×analytic section: a tilted plane yields an Ellipse, a
// plane perpendicular to the axis yields an exact Circle. Both share the
// same parameterization surface (project/evaluate by angle).
enum SecCurve<'a> {
Ell(&'a Ellipse3D),
Circ(&'a Circle3D),
}
impl SecCurve<'_> {
fn project(&self, p: Point3) -> f64 {
match self {
SecCurve::Ell(e) => e.project(p),
SecCurve::Circ(c) => c.project(p),
}
}
fn evaluate(&self, t: f64) -> Point3 {
match self {
SecCurve::Ell(e) => e.evaluate(t),
SecCurve::Circ(c) => c.evaluate(t),
}
}
fn edge_curve(&self) -> EdgeCurve {
match self {
SecCurve::Ell(e) => EdgeCurve::Ellipse((*e).clone()),
SecCurve::Circ(c) => EdgeCurve::Circle((*c).clone()),
}
}
}
let sec = match &raw.curve {
EdgeCurve::Ellipse(e) => SecCurve::Ell(e),
EdgeCurve::Circle(c) => SecCurve::Circ(c),
_ => return None,
};
// Must be a closed full ellipse (the raw plane×analytic section).
// Must be a closed full section (the raw plane×analytic intersection).
if (raw.p_start - raw.p_end).length() > 1e-7 {
return None;
}
Expand Down Expand Up @@ -1314,9 +1343,9 @@ fn trim_ellipse_to_boundary_crossings(
let face = topo.face(plane_face).ok()?;
let mut crossings: Vec<Point3> = Vec::new();
let push_crossing = |p: Point3, crossings: &mut Vec<Point3>| {
// Keep only points actually on the section ellipse (rejects a cone's
// Keep only points actually on the section curve (rejects a cone's
// far-nappe root or a seam-line crossing that misses the ellipse).
let foot = Ellipse3D::evaluate(ellipse, ellipse.project(p));
let foot = sec.evaluate(sec.project(p));
if (foot - p).length() > 1e-6 {
return;
}
Expand Down Expand Up @@ -1382,10 +1411,10 @@ fn trim_ellipse_to_boundary_crossings(
return None;
}

// Map each crossing to its ellipse parameter, sort by angle.
// Map each crossing to its angular parameter, sort by angle.
let mut t_pts: Vec<(f64, Point3)> = crossings
.into_iter()
.map(|p| (ellipse.project(p).rem_euclid(std::f64::consts::TAU), p))
.map(|p| (sec.project(p).rem_euclid(std::f64::consts::TAU), p))
.collect();
t_pts.sort_by(|a, b| a.0.total_cmp(&b.0));
// Drop near-duplicate parameters (a crossing hit by two adjacent edges).
Expand All @@ -1406,7 +1435,7 @@ fn trim_ellipse_to_boundary_crossings(
t1 += std::f64::consts::TAU;
}
let t_mid = 0.5 * (t0 + t1);
let mid = Ellipse3D::evaluate(ellipse, t_mid);
let mid = sec.evaluate(t_mid);
if !(ext_a.contains(mid) && ext_b.contains(mid)) {
continue;
}
Expand All @@ -1415,7 +1444,7 @@ fn trim_ellipse_to_boundary_crossings(
continue;
}
arcs.push(RawCurve {
curve: EdgeCurve::Ellipse(ellipse.clone()),
curve: sec.edge_curve(),
bbox: raw.bbox,
t_range: (t0, t1),
p_start: p0,
Expand Down
1 change: 1 addition & 0 deletions crates/io/tests/data/snapclip_export_corner_cutter.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"version":1,"vertices":[{"point":[106.0,-44.6,-2.790000000000001],"tolerance":1e-7},{"point":[106.0,-39.4,-2.790000000000001],"tolerance":1e-7},{"point":[102.5,-44.6,-2.790000000000001],"tolerance":1e-7},{"point":[102.5,-39.4,-2.790000000000001],"tolerance":1e-7},{"point":[102.5,-39.4,-4.4],"tolerance":1e-7},{"point":[106.0,-39.4,-4.4],"tolerance":1e-7},{"point":[102.5,-44.6,-4.4],"tolerance":1e-7},{"point":[106.0,-44.6,-4.4],"tolerance":1e-7}],"edges":[{"start":0,"end":1,"curve":"Line","tolerance":null},{"start":2,"end":0,"curve":"Line","tolerance":null},{"start":3,"end":2,"curve":"Line","tolerance":null},{"start":1,"end":3,"curve":"Line","tolerance":null},{"start":3,"end":4,"curve":"Line","tolerance":null},{"start":5,"end":4,"curve":"Line","tolerance":null},{"start":1,"end":5,"curve":"Line","tolerance":null},{"start":2,"end":6,"curve":"Line","tolerance":null},{"start":4,"end":6,"curve":"Line","tolerance":null},{"start":0,"end":7,"curve":"Line","tolerance":null},{"start":6,"end":7,"curve":"Line","tolerance":null},{"start":7,"end":5,"curve":"Line","tolerance":null}],"wires":[{"edges":[{"edge":0,"forward":false},{"edge":1,"forward":false},{"edge":2,"forward":false},{"edge":3,"forward":false}],"closed":true},{"edges":[{"edge":3,"forward":true},{"edge":4,"forward":true},{"edge":5,"forward":false},{"edge":6,"forward":false}],"closed":true},{"edges":[{"edge":2,"forward":true},{"edge":7,"forward":true},{"edge":8,"forward":false},{"edge":4,"forward":false}],"closed":true},{"edges":[{"edge":1,"forward":true},{"edge":9,"forward":true},{"edge":10,"forward":false},{"edge":7,"forward":false}],"closed":true},{"edges":[{"edge":0,"forward":true},{"edge":6,"forward":true},{"edge":11,"forward":false},{"edge":9,"forward":false}],"closed":true},{"edges":[{"edge":5,"forward":true},{"edge":8,"forward":true},{"edge":10,"forward":true},{"edge":11,"forward":true}],"closed":true}],"faces":[{"outer_wire":0,"inner_wires":[],"surface":{"Plane":{"normal":[0.0,-0.0,1.0],"d":-2.790000000000001}},"reversed":false},{"outer_wire":1,"inner_wires":[],"surface":{"Plane":{"normal":[0.0,1.0,-0.0],"d":-39.4}},"reversed":false},{"outer_wire":2,"inner_wires":[],"surface":{"Plane":{"normal":[-1.0,0.0,0.0],"d":-102.5}},"reversed":false},{"outer_wire":3,"inner_wires":[],"surface":{"Plane":{"normal":[0.0,-1.0,0.0],"d":44.6}},"reversed":false},{"outer_wire":4,"inner_wires":[],"surface":{"Plane":{"normal":[1.0,0.0,0.0],"d":106.0}},"reversed":false},{"outer_wire":5,"inner_wires":[],"surface":{"Plane":{"normal":[-0.0,0.0,-1.0],"d":4.4}},"reversed":false}],"shells":[{"faces":[0,1,2,3,4,5]}],"outer_shell":0,"inner_shells":[],"pcurves":[]}
1 change: 1 addition & 0 deletions crates/io/tests/data/snapclip_export_corner_plate.bin

Large diffs are not rendered by default.

87 changes: 87 additions & 0 deletions crates/io/tests/snapclip_export_corner_inmem.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//! Guard for the plane×cone exact CIRCLE arc at a relief-cutter back corner
//! (the snapClip export-variant op-cut-3 root).
//!
//! The export baseplate uses the simplified tapered pockets, so the deep
//! relief cutter's back corner lands on the pocket's corner CONE. The cutter
//! contributes three sections to the cone face: two marched conics (cone ×
//! back wall, cone × side wall) and the cone × cutter-top CIRCLE arc closing
//! the chain. That horizontal-plane section is an exact `EdgeCurve::Circle`,
//! and `trim_ellipse_to_boundary_crossings` — the exact-arc path that
//! bypasses the sampling pre-filters — only accepted Ellipse sections, so
//! the ~0.11-long arc (2% of the full circle) was dropped by the 16-sample
//! in-both filter. The cone face then received an OPEN 2-conic chain, the
//! internal-loops splitter rejected it, the face stayed unsplit, and the
//! analytic-but-leaky result poisoned the export chain into mesh fallback
//! two cuts later (final bnd=111 nm=6 at export tolerance).
//!
//! Fixtures are the tool's serialized operands (2026-07-17 export-variant
//! capture: plate after op-cut-2, first deep relief cutter).

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

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use brepkit_algo::bop::BooleanOp;
use brepkit_math::vec::Point3;
use brepkit_topology::Topology;
use brepkit_topology::explorer::solid_faces;
use brepkit_topology::face::FaceSurface;
use brepkit_topology::solid::SolidId;

fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/data")
.join(name)
}

fn load(name: &str, topo: &mut Topology) -> SolidId {
brepkit_io::arena_io::deserialize_solid(&std::fs::read(fixture(name)).unwrap(), topo).unwrap()
}

type Q = (i64, i64, i64);

#[test]
fn export_corner_cut_pairs_every_brep_edge() {
let mut topo = Topology::new();
let plate = load("snapclip_export_corner_plate.bin", &mut topo);
let cutter = load("snapclip_export_corner_cutter.bin", &mut topo);

let result = brepkit_algo::gfa::boolean(&mut topo, BooleanOp::Cut, plate, cutter).unwrap();

let faces = solid_faces(&topo, result).unwrap();
let cones = faces
.iter()
.filter(|&&fid| matches!(topo.face(fid).unwrap().surface(), FaceSurface::Cone(_)))
.count();
assert!(
cones >= 80,
"tapered pockets must stay analytic, got {cones} cones"
);

// Position-quantized B-Rep edge pairing: every edge used exactly twice.
// The missing circle arc left the corner cones unsplit — their partners'
// conic sections and the cutter-top ledge rims dangled (posBad=8).
let sc = 1.0e5;
let q = |v: f64| (v * sc).round() as i64;
let qp = |p: Point3| (q(p.x()), q(p.y()), q(p.z()));
let mut occ: HashMap<(Q, Q), usize> = HashMap::new();
for &fid in &faces {
let face = topo.face(fid).unwrap();
for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
for oe in topo.wire(wid).unwrap().edges() {
let e = topo.edge(oe.edge()).unwrap();
let a = qp(topo.vertex(e.start()).unwrap().point());
let b = qp(topo.vertex(e.end()).unwrap().point());
let key = if a <= b { (a, b) } else { (b, a) };
*occ.entry(key).or_default() += 1;
}
}
}
let bad: Vec<_> = occ.iter().filter(|&(_, &c)| c != 2).collect();
assert!(
bad.is_empty(),
"unpaired/overused B-Rep edges at the cutter back corner: {} entries",
bad.len()
);
}
Loading