Skip to content

fix(fillet): variable fillet removes material instead of inflating volume#739

Merged
andymai merged 1 commit into
mainfrom
feat/parity-fillet-variable-vertex-blend-volume
May 29, 2026
Merged

fix(fillet): variable fillet removes material instead of inflating volume#739
andymai merged 1 commit into
mainfrom
feat/parity-fillet-variable-vertex-blend-volume

Conversation

@andymai
Copy link
Copy Markdown
Owner

@andymai andymai commented May 29, 2026

Summary

A variable-radius fillet on a convex edge was bulging the blend surface outward and trimming the adjacent planar faces with a setback that did not match the blend boundary. The result was a non-watertight shell whose volume exceeded the base solid (~1.3x on a unit cube) — geometrically impossible for a fillet, which can only remove material on a convex edge.

Fix

  • Compute a single shared inward contact point per (vertex, edge, face) and use it both to trim the planar faces and to anchor the blend boundary, so the trimmed-face vertices and the blend boundary coincide (closes the watertightness gap).
  • Place the arc middle control point at the tangent-cone apex (the edge point) so the rational-quadratic cross-section bulges concavely into the solid rather than outward.
  • Adopt the validated rolling-ball inward sign convention and reverse blend faces whose normal points inward.
  • Split the corner on side faces sharing a filleted-edge endpoint so adjacent trims stay consistent.

Testing

  • New fillet_variable_removes_material_linear_law asserts the filleted volume is strictly less than the base solid.
  • Full brepkit-operations fillet suite green (51 passed, 1 ignored).
  • cargo fmt --check, cargo clippy -p brepkit-operations --all-targets -- -D warnings, and scripts/check-boundaries.sh all clean. Full pre-push test suite + cargo-deny passed.

…lume

Variable-radius fillet on a convex edge was bulging the blend surface
outward and trimming adjacent faces with a setback that did not match the
blend boundary, producing a non-watertight shell with volume above the base
solid (~1.3x on a unit cube).

Compute a single shared inward contact point per (vertex, edge, face) and
use it both to trim the planar faces and to anchor the blend boundary, so
the trimmed-face vertices and blend boundary coincide. Place the arc middle
control point at the tangent-cone apex (the edge point) so the rational
quadratic bulges concavely into the solid. Adopt the validated rolling-ball
inward sign convention and reverse blend faces whose normal points inward.
Split the corner on side faces sharing a filleted-edge endpoint.
Copilot AI review requested due to automatic review settings May 29, 2026 13:55
@andymai andymai enabled auto-merge (squash) May 29, 2026 13:55
@github-actions
Copy link
Copy Markdown
Contributor

WASM Binary Size

File main PR Delta
brepkit_wasm.wasm 4.69 MB 4.70 MB +6.9 KB (+.1%)

⚠️ Size increased slightly.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes variable-radius fillet construction so convex-edge fillets reliably remove material (no volume inflation) and produce a watertight, manifold result by making the planar-face trims and blend boundaries coincide and by correcting the rolling-ball cross-section control-point/sign conventions.

Changes:

  • Adds a shared (vertex, edge, face) -> contact_point map and uses it for both planar trimming and fillet-surface boundary anchoring to eliminate trim/blend gaps.
  • Updates variable fillet canal surface cross-sections to use a consistent inward rolling-ball sign convention and sets the rational-arc middle control point at the tangent-cone apex (edge point).
  • Adds a regression test asserting variable fillets on a convex edge reduce volume and still yield a manifold, genus-0 shell.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
crates/operations/src/fillet/mod.rs Reworks variable-radius fillet trimming and NURBS canal construction; introduces shared contact map and new face-reversal handling logic.
crates/operations/src/fillet/geometry.rs Adds CrossSection + cross_section_dirs helper to centralize rolling-ball cross-section direction/sign computation.
crates/operations/src/fillet/tests.rs Adds a new regression test verifying variable fillet removes material and maintains manifold/genus-0 topology.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +665 to +679
(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 +887 to +891
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 +620 to +627
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);
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 29, 2026

Greptile Summary

This PR fixes a variable-radius fillet that was inflating solid volume by correcting three geometric errors: the cross-section direction convention (blend was bulging outward), the middle NURBS control point placement (mid_cp = p rather than a bisector offset), and the trimmed-face/blend-boundary mismatch (now a single shared contact map ensures coincident vertices).

  • geometry.rs: Extracts a new cross_section_dirs helper with the corrected inward sign convention (ld_k.dot(other_normal) < 0) and packages it into a CrossSection struct reused by both the contact-map build and the canal-surface loop.
  • mod.rs: Builds fillet_contact_map once for watertight shell assembly; adds a (false, false, true) match arm to split side-face corners at filleted-edge endpoints; replaces the bisector-offset mid-control-point with the edge point itself so the rational quadratic arc cuts material instead of adding it; adds post-assembly face reversal for fillet faces whose mid-normal points inward.
  • tests.rs: Adds fillet_variable_removes_material_linear_law asserting volume < 1000 on a 10³ box, manifold validity, and Euler genus 0.

Confidence Score: 3/5

Safe to merge for the single-edge fillet case, but the side-face corner-split logic and the post-assembly face reversal both have correctness gaps that would surface as silent wrong geometry on multi-edge fillets.

The single-edge test passes and the geometric fix (mid-CP at edge point, shared contact map) is sound. However, the (false, false, true) arm collects all contacts at a vertex across every edge in the map without filtering to contacts adjacent to the current face — for two or more filleted edges sharing a vertex this picks arbitrary contacts and produces a non-manifold side face with no error raised. Separately, the post-assembly face reversal maps all_specs indices directly to shell.faces() indices, an ordering that breaks silently the moment a degenerate face spec is skipped during assembly.

crates/operations/src/fillet/mod.rs — the contact-collection loop (line 619) and the post-assembly reversal block (lines 887–898)

Important Files Changed

Filename Overview
crates/operations/src/fillet/mod.rs Core fix: shared contact map for watertight trimming, inverted blend direction, and mid-CP moved to edge point. Two logic issues: multi-edge contact collection in the side-face arm collects contacts across all edges at a vertex (not just the adjacent one), and fillet face reversal relies on undocumented all_specs→face_ids index correspondence.
crates/operations/src/fillet/geometry.rs New CrossSection struct and cross_section_dirs helper extract the rolling-ball sign convention. Minor naming issue: cos_half holds the cosine of the full angle, not the half angle.
crates/operations/src/fillet/tests.rs New fillet_variable_removes_material_linear_law test verifies volume reduction, manifold validity, and Euler genus for a single-edge variable fillet. Correctly placed in the #[cfg(test)] module.

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
crates/operations/src/fillet/mod.rs:619-645
**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.

### Issue 2 of 4
crates/operations/src/fillet/mod.rs:887-898
**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.

### Issue 3 of 4
crates/operations/src/fillet/mod.rs:669-679
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);
                    }
```

### Issue 4 of 4
crates/operations/src/fillet/geometry.rs:129-130
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;
```

Reviews (1): Last reviewed commit: "fix(fillet): variable fillet removes mat..." | Re-trigger Greptile

Comment on lines +619 to +645
(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);
}
}
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);
}
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

Comment on lines +887 to 898
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())?;
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);
}
}
}
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

Comment on lines +669 to +679
} 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));
}
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

Comment on lines +129 to +130
let cos_half = ld1.dot(ld2).clamp(-1.0, 1.0);
let half_angle = cos_half.acos() / 2.0;
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

@andymai andymai merged commit 7398d23 into main May 29, 2026
31 of 33 checks passed
@andymai andymai deleted the feat/parity-fillet-variable-vertex-blend-volume branch May 29, 2026 15:04
@brepkit brepkit Bot mentioned this pull request May 29, 2026
brepkit Bot added a commit that referenced this pull request May 29, 2026
🤖 I have created a release *beep* *boop*
---


##
[2.101.1](v2.101.0...v2.101.1)
(2026-05-29)


### Bug Fixes

* **fillet:** variable fillet removes material instead of inflating
volume ([#739](#739))
([7398d23](7398d23))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: brepkit[bot] <265643962+brepkit[bot]@users.noreply.github.com>
andymai added a commit to andymai/brepjs that referenced this pull request May 29, 2026
## Summary

brepkit previously inflated solid volume on a variable-radius fillet (a
fillet on a convex edge can only ever remove material), so
`kernelOps.variableFilletRadius` was registered as a `skip` divergence.
The upstream kernel fix resolves the volume-inflation bug.

This PR:
- Removes the `kernelOps.variableFilletRadius` divergence entry from
`tests/helpers/kernelDivergences.ts`.
- Un-skips the `fillet variable radius` test in
`tests/kernel-ops.test.ts` so it runs on every kernel, asserting the
filleted volume stays strictly below the base box (8000) and above 7000
— a regression guard against the old inflation bug.
- Drops the now-unused `skipIfDiverges` import.

## Verification

Built the kernel from the upstream fix branch, swapped the wasm into
this worktree, and confirmed the un-skipped test passes on all three
kernels (brepkit, occt-wasm, occt) with full cross-kernel agreement.

## ⚠️ Blocked

**Do not merge yet.** This is blocked on the brepkit-wasm release that
contains the upstream fix:

- Upstream fix: andymai/brepkit#739

Once that release ships and `brepkit-wasm` is bumped here, this PR can
be marked ready and merged. Until then, CI will fail against the brepkit
kernel because the published wasm still inflates the volume.

## Note on remaining divergences

`modifierFns.variableFilletRadius` and
`modifierFns.variableFilletCallback` remain `it.skip(...)` at the
test-runner level because the reference kernel itself crashes on
variable-radius fillet (memory access out of bounds) — those are not
brepkit-only divergences and are intentionally left untouched.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants