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
43 changes: 43 additions & 0 deletions crates/operations/src/fillet/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,49 @@ pub(super) fn edge_v_samples(curve: &EdgeCurve) -> usize {
}
}

/// Per-station rolling-ball cross-section directions at an edge sample point.
///
/// `ld1`/`ld2` are unit directions lying in faces 1/2 respectively, normal to
/// the edge tangent, pointing away from the edge into the channel between the
/// faces (i.e. toward the other face's material side). The contact points are
/// `p + ld_k * r`. `bisector` points into the dihedral; `half_angle` is half
/// the arc sweep. The sign convention matches the validated constant-radius
/// rolling-ball path: `ld_k` is chosen opposite to the other face's outward
/// normal so the blend cuts a concave channel inward rather than bulging out.
pub(super) struct CrossSection {
pub(super) ld1: Vec3,
pub(super) ld2: Vec3,
pub(super) bisector: Vec3,
pub(super) half_angle: f64,
}

/// Compute the cross-section directions at a sample point, given the edge
/// tangent and the two faces' outward normals there. Degenerate cross products
/// fall back to the supplied reference directions.
pub(super) fn cross_section_dirs(
tan: Vec3,
n1: Vec3,
n2: Vec3,
fallback_d1: Vec3,
fallback_d2: Vec3,
) -> CrossSection {
let c1 = tan.cross(n1);
let c2 = tan.cross(n2);
let ld1 = if c1.dot(n2) < 0.0 { c1 } else { -c1 };
let ld2 = if c2.dot(n1) < 0.0 { c2 } else { -c2 };
let ld1 = ld1.normalize().unwrap_or(fallback_d1);
let ld2 = ld2.normalize().unwrap_or(fallback_d2);
let cos_half = ld1.dot(ld2).clamp(-1.0, 1.0);
let half_angle = cos_half.acos() / 2.0;
Comment on lines +129 to +130
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 variable cos_half is named as if it holds cos(half_angle), but it actually holds cos(full_angle)ld1.dot(ld2) is the cosine of the full arc sweep. The half-angle is then computed as acos(cos_full) / 2. This inverted naming could cause confusion when reading the formula, e.g. in the weight expression cs.half_angle.cos() downstream.

Suggested change
let cos_half = ld1.dot(ld2).clamp(-1.0, 1.0);
let half_angle = cos_half.acos() / 2.0;
let cos_full = ld1.dot(ld2).clamp(-1.0, 1.0);
let half_angle = cos_full.acos() / 2.0;
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/operations/src/fillet/geometry.rs
Line: 129-130

Comment:
The variable `cos_half` is named as if it holds `cos(half_angle)`, but it actually holds `cos(full_angle)``ld1.dot(ld2)` is the cosine of the full arc sweep. The half-angle is then computed as `acos(cos_full) / 2`. This inverted naming could cause confusion when reading the formula, e.g. in the weight expression `cs.half_angle.cos()` downstream.

```suggestion
    let cos_full = ld1.dot(ld2).clamp(-1.0, 1.0);
    let half_angle = cos_full.acos() / 2.0;
```

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 bisector = (ld1 + ld2).normalize().unwrap_or(fallback_d1);
CrossSection {
ld1,
ld2,
bisector,
half_angle,
}
}

/// Compute the outward surface normal of a `FaceSurface` at a given 3D point.
///
/// For analytic surfaces this is exact (no parameter-space projection needed).
Expand Down
269 changes: 203 additions & 66 deletions crates/operations/src/fillet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,8 +500,74 @@ pub fn fillet_variable(
.map(|(eid, law)| (eid.index(), law))
.collect();

// Use the constant-radius trimming from the basic fillet for the planar faces.
// The NURBS canal surface replaces the fillet face.
// Shared contact map: the SAME inward contact point used both to trim the
// adjacent faces and to anchor the blend boundary, keyed by
// (vertex_index, edge_index, face_index). Computing it once guarantees the
// trimmed face boundary and the blend boundary coincide (watertight shell).
// Per-end radius: the edge's start vertex uses R(0), the end uses R(1).
let fillet_contact_map: HashMap<(usize, usize, usize), Point3> = {
let mut map = HashMap::new();
for (edge_id, law) in edge_laws {
let edge = topo.edge(*edge_id)?;
let p_start = topo.vertex(edge.start())?.point();
let p_end = topo.vertex(edge.end())?.point();

let Some(face_list) = edge_to_faces.get(&edge_id.index()) else {
continue;
};
if face_list.len() < 2 {
continue;
}
let f1 = face_list[0];
let f2 = face_list[1];

let (Some(surf1), Some(surf2)) = (
face_surfaces.get(&f1.index()),
face_surfaces.get(&f2.index()),
) else {
continue;
};

let edge_curve = edge.curve().clone();
if geometry::sample_edge_tangent(&edge_curve, p_start, p_end, 0.0).length() < tol.linear
{
continue;
}

for &(t, vid) in &[(0.0_f64, edge.start()), (1.0_f64, edge.end())] {
let r = law.evaluate(t);
let p = geometry::sample_edge_point(&edge_curve, p_start, p_end, t);
let tan = geometry::sample_edge_tangent(&edge_curve, p_start, p_end, t);
let Ok(local_dir) = tan.normalize() else {
continue;
};
let (Some(n1), Some(n2)) = (
face_surface_normal_at(surf1, p),
face_surface_normal_at(surf2, p),
) else {
continue;
};
let cs = geometry::cross_section_dirs(local_dir, n1, n2, local_dir, local_dir);
map.insert((vid.index(), edge_id.index(), f1.index()), p + cs.ld1 * r);
map.insert((vid.index(), edge_id.index(), f2.index()), p + cs.ld2 * r);
}
}
map
};

// Vertices at endpoints of filleted edges. A side face (one that shares
// such a vertex but whose own edges are not filleted) must split that
// corner into the two blend contact points, or the blend boundary is left
// unmatched and the shell becomes non-manifold.
let mut vertex_fillet_endpoints: HashSet<usize> = HashSet::new();
for (edge_id, _) in edge_laws {
let edge = topo.edge(*edge_id)?;
vertex_fillet_endpoints.insert(edge.start().index());
vertex_fillet_endpoints.insert(edge.end().index());
}

// Trim planar faces by replacing each filleted-edge boundary vertex with
// the shared contact point. The NURBS canal surface replaces the fillet face.
let mut all_specs: Vec<FaceSpec> = Vec::new();

for &face_id in &shell_face_ids {
Expand Down Expand Up @@ -531,6 +597,7 @@ pub fn fillet_variable(
}

let mut new_verts: Vec<Point3> = Vec::with_capacity(n + target_set.len());
let fi = face_id.index();

for i in 0..n {
let prev_i = if i == 0 { n - 1 } else { i - 1 };
Expand All @@ -540,32 +607,76 @@ pub fn fillet_variable(
let pos = poly.positions[i];
let prev_pos = poly.positions[prev_i];
let next_pos = poly.positions[next_i];
let vi = poly.vertex_ids[i].index();
let at_fillet_endpoint = vertex_fillet_endpoints.contains(&vi);

// Look up per-edge radius at this vertex:
// - "before" edge (prev_i): vertex i is at its end → evaluate(1.0)
// - "after" edge (i): vertex i is at its start → evaluate(0.0)
let radius_before = edge_law_map
.get(&poly.wire_edge_ids[prev_i].index())
.map_or(0.0, |law| law.evaluate(1.0));
let radius_after = edge_law_map
.get(&poly.wire_edge_ids[i].index())
.map_or(0.0, |law| law.evaluate(0.0));

match (before_filleted, after_filleted) {
(false, false) => new_verts.push(pos),
(true, false) => {
let dir = (next_pos - pos).normalize()?;
new_verts.push(pos + dir * radius_before);
match (before_filleted, after_filleted, at_fillet_endpoint) {
(false, false, false) => new_verts.push(pos),
// Side face: vertex sits at a filleted-edge endpoint but neither
// of this face's edges is filleted. Split the corner into the two
// blend contacts at this vertex (one per filleted-adjacent face),
// ordered toward prev/next to keep the wire convex.
(false, false, true) => {
let mut unique_contacts: Vec<Point3> = Vec::new();
for (&(vi_k, _, _), &pt) in &fillet_contact_map {
if vi_k == vi
&& !unique_contacts
.iter()
.any(|uc| (*uc - pt).length() < tol.linear)
{
unique_contacts.push(pt);
Comment on lines +620 to +627
}
}
if unique_contacts.len() >= 2 {
let approx_prev = (prev_pos - pos)
.normalize()
.map_or(pos, |d| pos + d * tol.linear);
let d0 = (unique_contacts[0] - approx_prev).length();
let d1 = (unique_contacts[1] - approx_prev).length();
if d0 <= d1 {
new_verts.push(unique_contacts[0]);
new_verts.push(unique_contacts[1]);
} else {
new_verts.push(unique_contacts[1]);
new_verts.push(unique_contacts[0]);
}
} else {
new_verts.push(pos);
}
Comment on lines +619 to +645
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Contact collection ignores adjacency — wrong corners for multi-edge fillets

The (false, false, true) arm collects every contact where vi_k == vi, across all edges and faces in fillet_contact_map. For a single filleted edge (the current test case) this yields exactly the 2 correct contacts. But if two filleted edges share this vertex — which vertex_fillet_endpoints would mark as at_fillet_endpoint = true — the map contains 4 contacts at vi (2 per edge × 2 faces). The code then takes unique_contacts[0] and unique_contacts[1] based on non-deterministic HashMap iteration order, and the proximity sort only decides which of those two comes first. The two selected contacts may belong to different edges, producing a side face that doesn't connect correctly to either blend boundary and leaving the shell non-manifold with no error raised.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/operations/src/fillet/mod.rs
Line: 619-645

Comment:
**Contact collection ignores adjacency — wrong corners for multi-edge fillets**

The `(false, false, true)` arm collects every contact where `vi_k == vi`, across all edges and faces in `fillet_contact_map`. For a single filleted edge (the current test case) this yields exactly the 2 correct contacts. But if two filleted edges share this vertex — which `vertex_fillet_endpoints` would mark as `at_fillet_endpoint = true` — the map contains 4 contacts at `vi` (2 per edge × 2 faces). The code then takes `unique_contacts[0]` and `unique_contacts[1]` based on non-deterministic `HashMap` iteration order, and the proximity sort only decides which of those two comes first. The two selected contacts may belong to different edges, producing a side face that doesn't connect correctly to either blend boundary and leaving the shell non-manifold with no error raised.

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

Fix in Claude Code

}
(false, true) => {
let dir = (prev_pos - pos).normalize()?;
new_verts.push(pos + dir * radius_after);
(true, false, _) => {
let ei = poly.wire_edge_ids[prev_i].index();
if let Some(&pt) = fillet_contact_map.get(&(vi, ei, fi)) {
new_verts.push(pt);
} else {
let dir = (next_pos - pos).normalize()?;
new_verts.push(pos + dir * edge_law_map[&ei].evaluate(1.0));
}
}
(true, true) => {
let dir_prev = (prev_pos - pos).normalize()?;
new_verts.push(pos + dir_prev * radius_before);
let dir_next = (next_pos - pos).normalize()?;
new_verts.push(pos + dir_next * radius_after);
(false, true, _) => {
let ei = poly.wire_edge_ids[i].index();
if let Some(&pt) = fillet_contact_map.get(&(vi, ei, fi)) {
new_verts.push(pt);
} else {
let dir = (prev_pos - pos).normalize()?;
new_verts.push(pos + dir * edge_law_map[&ei].evaluate(0.0));
}
}
(true, true, _) => {
let ei_after = poly.wire_edge_ids[i].index();
if let Some(&pt) = fillet_contact_map.get(&(vi, ei_after, fi)) {
new_verts.push(pt);
} else {
let dir_prev = (prev_pos - pos).normalize()?;
new_verts.push(pos + dir_prev * edge_law_map[&ei_after].evaluate(0.0));
}
let ei_before = poly.wire_edge_ids[prev_i].index();
if let Some(&pt) = fillet_contact_map.get(&(vi, ei_before, fi)) {
new_verts.push(pt);
} else {
let dir_next = (next_pos - pos).normalize()?;
new_verts.push(pos + dir_next * edge_law_map[&ei_before].evaluate(1.0));
}
Comment on lines +665 to +679
Comment on lines +669 to +679
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 Direct HashMap indexing edge_law_map[&ei] panics if the key is absent. The previous code used map_or(0.0, |law| ...) as a safe fallback. While the invariant (after_filleted == true implies ei ∈ edge_law_map) currently holds, replacing .map_or with direct indexing removes the defensive pattern the codebase was using everywhere else. The same pattern appears at the fallback for all three filleted-arm branches.

Suggested change
} else {
let dir_prev = (prev_pos - pos).normalize()?;
new_verts.push(pos + dir_prev * edge_law_map[&ei_after].evaluate(0.0));
}
let ei_before = poly.wire_edge_ids[prev_i].index();
if let Some(&pt) = fillet_contact_map.get(&(vi, ei_before, fi)) {
new_verts.push(pt);
} else {
let dir_next = (next_pos - pos).normalize()?;
new_verts.push(pos + dir_next * edge_law_map[&ei_before].evaluate(1.0));
}
} else {
let dir_prev = (prev_pos - pos).normalize()?;
let r = edge_law_map.get(&ei_after).map_or(0.0, |l| l.evaluate(0.0));
new_verts.push(pos + dir_prev * r);
}
let ei_before = poly.wire_edge_ids[prev_i].index();
if let Some(&pt) = fillet_contact_map.get(&(vi, ei_before, fi)) {
new_verts.push(pt);
} else {
let dir_next = (next_pos - pos).normalize()?;
let r = edge_law_map.get(&ei_before).map_or(0.0, |l| l.evaluate(1.0));
new_verts.push(pos + dir_next * r);
}

Rule Used: New code must not introduce unwrap(), expect(), or... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/operations/src/fillet/mod.rs
Line: 669-679

Comment:
Direct HashMap indexing `edge_law_map[&ei]` panics if the key is absent. The previous code used `map_or(0.0, |law| ...)` as a safe fallback. While the invariant (`after_filleted == true` implies `ei ∈ edge_law_map`) currently holds, replacing `.map_or` with direct indexing removes the defensive pattern the codebase was using everywhere else. The same pattern appears at the fallback for all three filleted-arm branches.

```suggestion
                    } else {
                        let dir_prev = (prev_pos - pos).normalize()?;
                        let r = edge_law_map.get(&ei_after).map_or(0.0, |l| l.evaluate(0.0));
                        new_verts.push(pos + dir_prev * r);
                    }
                    let ei_before = poly.wire_edge_ids[prev_i].index();
                    if let Some(&pt) = fillet_contact_map.get(&(vi, ei_before, fi)) {
                        new_verts.push(pt);
                    } else {
                        let dir_next = (next_pos - pos).normalize()?;
                        let r = edge_law_map.get(&ei_before).map_or(0.0, |l| l.evaluate(1.0));
                        new_verts.push(pos + dir_next * r);
                    }
```

**Rule Used:** New code must not introduce unwrap(), expect(), or... ([source](https://app.greptile.com/review/custom-context?memory=no-new-unwrap))

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

Fix in Claude Code

}
}
}
Expand All @@ -581,6 +692,7 @@ pub fn fillet_variable(

// Build variable-radius NURBS canal surfaces for each edge.
let n_samples = 5; // Number of cross-sections along each edge
let mut fillet_face_indices: Vec<usize> = Vec::new();

for (edge_id, law) in edge_laws {
let edge = topo.edge(*edge_id)?;
Expand Down Expand Up @@ -621,24 +733,11 @@ pub fn fillet_variable(
let edge_dir = edge_tan.normalize()?;

// Reference cross-section at t=0 for fallback directions.
let cross1_ref = edge_dir.cross(n1_start);
let cross2_ref = edge_dir.cross(n2_start);
let d1_ref = if cross1_ref.dot(n2_start) > 0.0 {
cross1_ref
} else {
-cross1_ref
};
let d2_ref = if cross2_ref.dot(n1_start) > 0.0 {
cross2_ref
} else {
-cross2_ref
};
let d1_ref = d1_ref.normalize().unwrap_or(d1_ref);
let d2_ref = d2_ref.normalize().unwrap_or(d2_ref);
let cos_half_ref = d1_ref.dot(d2_ref).clamp(-1.0, 1.0);
let half_angle = cos_half_ref.acos() / 2.0;
let cs_ref = geometry::cross_section_dirs(edge_dir, n1_start, n2_start, edge_dir, edge_dir);
let d1_ref = cs_ref.ld1;
let d2_ref = cs_ref.ld2;

if half_angle.abs() < tol.angular {
if cs_ref.half_angle.abs() < tol.angular {
continue;
}

Expand Down Expand Up @@ -667,32 +766,44 @@ pub fn fillet_variable(
let ln1 = face_surface_normal_at(surf1, p).unwrap_or(n1_start);
let ln2 = face_surface_normal_at(surf2, p).unwrap_or(n2_start);

// Vertex blend direction: uses original convention (toward other face's
// outward normal). The vertex blend fills the gap between fillet strips
// at a vertex, and its geometry depends on the edge fillet positions.
// TODO(#260): vertex blend direction may need adjustment after edge
// fillet direction fix — currently causes ~30% volume inflation on
// all-edges fillet. Investigate vertex blend contact point computation.
let c1 = local_dir.cross(ln1);
let c2 = local_dir.cross(ln2);
let ld1 = if c1.dot(ln2) > 0.0 { c1 } else { -c1 };
let ld2 = if c2.dot(ln1) > 0.0 { c2 } else { -c2 };
let ld1 = ld1.normalize().unwrap_or(d1_ref);
let ld2 = ld2.normalize().unwrap_or(d2_ref);

let local_cos = ld1.dot(ld2).clamp(-1.0, 1.0);
let local_half = local_cos.acos() / 2.0;
let bisector = (ld1 + ld2).normalize().unwrap_or(d1_ref);

let contact1 = p + ld1 * r;
let contact2 = p + ld2 * r;
let mid_dist = r / local_half.cos().max(0.01);
let mid_cp = p + bisector * mid_dist;

sample_weights.push(local_half.cos().max(0.01));
let cs = geometry::cross_section_dirs(local_dir, ln1, ln2, d1_ref, d2_ref);

// cos(φ/2) is the rational-quadratic arc weight; clamp to a positive
// floor so nearly-coplanar faces (φ/2 → π/2) don't yield a zero
// weight (degenerate control point).
let w = cs.half_angle.cos().max(0.01);
let contact1 = p + cs.ld1 * r;
let contact2 = p + cs.ld2 * r;
// The middle control point is the apex of the tangent cone — the
// intersection of the two contact tangents. For a rolling ball on
// surfaces meeting at the edge this is the edge point itself, so the
// weighted arc bulges concavely toward the solid interior (cutting
// material). Placing it on the bisector ray past the ball center
// would bulge the blend outward and add volume.
let mid_cp = p;

sample_weights.push(w);
grid.push(vec![contact1, mid_cp, contact2]);
}

// Anchor the blend boundary contacts to the shared contact map so the
// interpolated NURBS boundary coincides exactly with the trimmed-face
// vertices (bitwise-identical, no duplicate vertices in assembly).
let v_start = edge.start().index();
let v_end = edge.end().index();
if let Some(&pt) = fillet_contact_map.get(&(v_start, edge_id.index(), f1.index())) {
grid[0][0] = pt;
}
if let Some(&pt) = fillet_contact_map.get(&(v_start, edge_id.index(), f2.index())) {
grid[0][2] = pt;
}
if let Some(&pt) = fillet_contact_map.get(&(v_end, edge_id.index(), f1.index())) {
grid[n_v - 1][0] = pt;
}
if let Some(&pt) = fillet_contact_map.get(&(v_end, edge_id.index(), f2.index())) {
grid[n_v - 1][2] = pt;
}

// Build a rational NURBS surface with exact circular arc cross-sections.
// u-direction: degree 2, 3 CPs with weights [1, cos(α/2), 1]
// v-direction: interpolated through sampled stations along the edge
Expand Down Expand Up @@ -758,7 +869,33 @@ pub fn fillet_variable(
reversed: false,
inner_wires: vec![],
});

// Mark for reversal if the surface mid-normal points into the dihedral
// (toward the solid) rather than outward.
let srf_mid_normal = match &all_specs[all_specs.len() - 1] {
FaceSpec::Surface {
surface: FaceSurface::Nurbs(srf),
..
} => srf.normal(0.5, 0.5).unwrap_or(cs_ref.bisector),
_ => cs_ref.bisector,
};
if srf_mid_normal.dot(cs_ref.bisector) > 0.0 {
fillet_face_indices.push(all_specs.len() - 1);
}
}

let solid_id = crate::boolean::assemble_solid_mixed(topo, &all_specs, tol)?;

if !fillet_face_indices.is_empty() {
let solid_data = topo.solid(solid_id)?;
let shell = topo.shell(solid_data.outer_shell())?;
Comment on lines +887 to +891
let face_ids: Vec<_> = shell.faces().to_vec();
for &fi in &fillet_face_indices {
if fi < face_ids.len() {
topo.face_mut(face_ids[fi])?.set_reversed(true);
}
}
}
Comment on lines +887 to 898
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Face reversal depends on undocumented index correspondence between all_specs and assembled shell.faces()

fillet_face_indices stores raw indices into all_specs (e.g., the value of all_specs.len() - 1 at the time each fillet face is pushed). After assemble_solid_mixed those indices are then used as indices into shell.faces().to_vec(). This works today because: (a) no CylindricalFace specs are present (so the cylindrical-first reordering in the assembler does not apply), and (b) no face spec has fewer than 3 vertices (which would cause the assembler's continue to skip a face, shifting all subsequent indices). Neither constraint is documented, checked at the call-site, or enforced. A future caller that adds even one CylindricalFace spec or receives a degenerate planar face would silently reverse the wrong face without a compile-time or runtime warning.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/operations/src/fillet/mod.rs
Line: 887-898

Comment:
**Face reversal depends on undocumented index correspondence between `all_specs` and assembled `shell.faces()`**

`fillet_face_indices` stores raw indices into `all_specs` (e.g., the value of `all_specs.len() - 1` at the time each fillet face is pushed). After `assemble_solid_mixed` those indices are then used as indices into `shell.faces().to_vec()`. This works today because: (a) no `CylindricalFace` specs are present (so the cylindrical-first reordering in the assembler does not apply), and (b) no face spec has fewer than 3 vertices (which would cause the assembler's `continue` to skip a face, shifting all subsequent indices). Neither constraint is documented, checked at the call-site, or enforced. A future caller that adds even one `CylindricalFace` spec or receives a degenerate planar face would silently reverse the wrong face without a compile-time or runtime warning.

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

Fix in Claude Code


crate::boolean::assemble_solid_mixed(topo, &all_specs, tol)
Ok(solid_id)
}
Loading
Loading