Skip to content

fix(fillet): variable-radius fillet removes material on convex edges#743

Closed
andymai wants to merge 1 commit into
mainfrom
feat/parity-variable-fillet-adds-material
Closed

fix(fillet): variable-radius fillet removes material on convex edges#743
andymai wants to merge 1 commit into
mainfrom
feat/parity-variable-fillet-adds-material

Conversation

@andymai
Copy link
Copy Markdown
Owner

@andymai andymai commented May 29, 2026

Problem

The variable-radius (canal-surface) fillet path inflated solid volume on convex
edges: filleting an edge of a unit cube produced a solid with volume greater than
the original. A fillet on a convex edge must remove material, so this was
geometrically impossible and produced a malformed shell.

Root causes

  1. Wrong in-plane contact direction sign. The cross-section direction toward
    each face's rolling-ball contact was selected by comparing against the
    neighbouring face's outward normal with the wrong sign, so the blend bulged
    outward (away from the material) instead of inward.
  2. Arc middle control point placed away from the edge. The rational-quadratic
    arc's middle control point was offset along the bisector away from the sharp
    edge, so the arc bulged outward rather than toward the material.
  3. Seam vertices not shared. Trimmed neighbour-face boundary vertices were
    derived independently from the blend rails, producing a non-watertight seam.

Fix

  • Add geometry::inward_section_dir(tangent, n_self, n_other) — the in-plane
    direction pointing into the material wedge (opposite the face's own outward
    normal), with the sign fixed unambiguously by the neighbour normal. Used for
    both the per-station canal build and the t=0 fallback.
  • Place the arc middle control point at the original sharp-edge point so the
    rational arc bulges toward the material (convex fillet that removes material).
  • Pre-compute the rail contact point at each filleted-edge endpoint keyed by
    (vertex, edge, face); trimming and the blend rails reuse these exact positions
    for a watertight seam.
  • Set the blend strip's reversed flag from the bisector so the strip's outward
    normal points out of the solid.

This also removes the previous workaround note about ~30% volume inflation on the
all-edges path — the corrected direction convention resolves it.

Tests

  • fillet_variable_linear_law now asserts the filleted unit cube has volume < 1.
  • fillet_variable_linear_law_removes_material — box(20,20,20), single-edge
    linear law: result is manifold, has exactly one added blend face, and
    7000 < V < 8000 (material removed).
  • fillet_variable_constant_law_matches_rolling_ball — a constant-law variable
    fillet matches the rolling-ball fillet volume within 1%.

All brepkit-operations fillet tests pass; fmt, clippy (-D warnings),
crate-boundary checks, and the full pre-push suite are green.

The variable-radius canal-surface path chose the in-plane contact
direction by comparing against the neighbouring face's outward normal
with the wrong sign, and placed the arc middle control point away from
the edge. On a convex edge this inverted the blend so it bulged outward,
producing a malformed shell whose volume exceeded the original solid.

Use the inward section-plane direction (opposite each face's own outward
normal), put the arc middle control point at the sharp-edge point so the
arc bulges toward the material, derive the trimmed neighbour-face
boundary vertices from the same contact rails for a watertight seam, and
set the blend strip's reversed flag from the bisector.
Copilot AI review requested due to automatic review settings May 29, 2026 16:56
@andymai andymai enabled auto-merge (squash) May 29, 2026 16:57
@andymai andymai disabled auto-merge May 29, 2026 16:58
@andymai
Copy link
Copy Markdown
Owner Author

andymai commented May 29, 2026

Closing as duplicate. This gap (variable-radius fillet inflating volume on convex edges) was already fixed and released in #739 (v2.101.1), which landed an equivalent fix to the same three files. This branch was cut from an older main before #739 merged. No action needed.

@andymai andymai closed this May 29, 2026
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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@andymai andymai deleted the feat/parity-variable-fillet-adds-material branch May 29, 2026 16:59
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 29, 2026

Greptile Summary

Fixes three root causes of the variable-radius fillet incorrectly inflating solid volume on convex edges: the in-plane contact direction sign, the arc middle control point placement, and non-watertight seam vertices. The new inward_section_dir helper centralises the corrected sign convention, mid_cp is set to the original sharp-edge point so the rational arc bulges into the material, and a pre-pass populates a contact_map so the canal surface and trimmed neighbour faces share identical boundary vertices.

  • geometry.rs: Adds inward_section_dir(tangent, n_self, n_other) that picks the in-plane direction pointing into the material wedge, disambiguating the sign via the neighbour normal.
  • mod.rs: Pre-computes rail contact points once, reuses them in both the face-trimming loop and canal-surface grid-snapping, and derives strip_reversed from the bisector at s=0.
  • tests.rs: New tests assert vol < 1.0 on the unit cube, manifold + face-count + range checks on a 20\u00b3 box, and constant-law/rolling-ball volume parity within 1%.

Confidence Score: 4/5

Safe to merge for all practical geometries; the two flagged paths are narrow degenerate cases that don't affect boxes or standard convex shapes.

The core geometric fix is sound and well-exercised by three new tests. The two open issues sit behind degenerate-geometry guards that normal solids never reach.

The fallback paths in mod.rs lines 695-698 and the strip_reversed computation at line 837 are worth a second look.

Important Files Changed

Filename Overview
crates/operations/src/fillet/geometry.rs Adds inward_section_dir helper; sign is fixed by the neighbour normal. Logic is geometrically sound but the fallback path in mod.rs that consumes this function skips normalisation in one branch.
crates/operations/src/fillet/mod.rs Rewrites the variable-radius canal-surface builder: pre-computes watertight contact-map, sets mid_cp = p, snaps rail endpoints, derives strip_reversed from bisector_ref. Core logic correct; one normalisation regression and one degenerate-bisector path flagged.
crates/operations/src/fillet/tests.rs Adds three new tests covering material removal, manifold check, and constant-law vs rolling-ball volume parity. Sphere-test tolerance widened from 5% to 6%.

Fix All in Claude Code

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

---

### Issue 1 of 2
crates/operations/src/fillet/mod.rs:695-698
The fallback when `inward_section_dir` returns `None` returns the raw (unnormalized) cross product `edge_dir.cross(n1_start)`. The old code explicitly normalized: `d1_ref.normalize().unwrap_or(d1_ref)`. If this degenerate path is hit, `d1_ref` is near-zero and is later used as the fallback in `ld1 = geometry::inward_section_dir(...).unwrap_or(d1_ref)`, making `contact1 = p + ld1 * r ≈ p` (zero-radius contact). Adding `.normalize().unwrap_or(edge_dir)` keeps parity with the old behavior and makes the fallback unit-length when non-zero.

```suggestion
        let d1_ref = geometry::inward_section_dir(edge_dir, n1_start, n2_start)
            .unwrap_or_else(|| edge_dir.cross(n1_start).normalize().unwrap_or(edge_dir));
        let d2_ref = geometry::inward_section_dir(edge_dir, n2_start, n1_start)
            .unwrap_or_else(|| edge_dir.cross(n2_start).normalize().unwrap_or(edge_dir));
```

### Issue 2 of 2
crates/operations/src/fillet/mod.rs:837
**Degenerate bisector used as `bisector_ref`**

`bisector = (ld1 + ld2).normalize().unwrap_or(d1_ref)` falls back to `d1_ref` (face-1 inward direction) when `ld1 + ld2` is near-zero — which happens for nearly-flat convex edges (~180°) where the two inward directions are nearly antiparallel. In that case `bisector_ref` ends up being the inward direction for face 1 alone, and `strip_normal.dot(bisector_ref) > 0.0` can give the wrong `strip_reversed` flag, producing an inside-out blend face. A dedicated zero-check or using a hard-coded fall-through orientation would be more robust for this edge case.

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

Comment on lines +695 to +698
let d1_ref = geometry::inward_section_dir(edge_dir, n1_start, n2_start)
.unwrap_or_else(|| edge_dir.cross(n1_start));
let d2_ref = geometry::inward_section_dir(edge_dir, n2_start, n1_start)
.unwrap_or_else(|| edge_dir.cross(n2_start));
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 fallback when inward_section_dir returns None returns the raw (unnormalized) cross product edge_dir.cross(n1_start). The old code explicitly normalized: d1_ref.normalize().unwrap_or(d1_ref). If this degenerate path is hit, d1_ref is near-zero and is later used as the fallback in ld1 = geometry::inward_section_dir(...).unwrap_or(d1_ref), making contact1 = p + ld1 * r ≈ p (zero-radius contact). Adding .normalize().unwrap_or(edge_dir) keeps parity with the old behavior and makes the fallback unit-length when non-zero.

Suggested change
let d1_ref = geometry::inward_section_dir(edge_dir, n1_start, n2_start)
.unwrap_or_else(|| edge_dir.cross(n1_start));
let d2_ref = geometry::inward_section_dir(edge_dir, n2_start, n1_start)
.unwrap_or_else(|| edge_dir.cross(n2_start));
let d1_ref = geometry::inward_section_dir(edge_dir, n1_start, n2_start)
.unwrap_or_else(|| edge_dir.cross(n1_start).normalize().unwrap_or(edge_dir));
let d2_ref = geometry::inward_section_dir(edge_dir, n2_start, n1_start)
.unwrap_or_else(|| edge_dir.cross(n2_start).normalize().unwrap_or(edge_dir));
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/operations/src/fillet/mod.rs
Line: 695-698

Comment:
The fallback when `inward_section_dir` returns `None` returns the raw (unnormalized) cross product `edge_dir.cross(n1_start)`. The old code explicitly normalized: `d1_ref.normalize().unwrap_or(d1_ref)`. If this degenerate path is hit, `d1_ref` is near-zero and is later used as the fallback in `ld1 = geometry::inward_section_dir(...).unwrap_or(d1_ref)`, making `contact1 = p + ld1 * r ≈ p` (zero-radius contact). Adding `.normalize().unwrap_or(edge_dir)` keeps parity with the old behavior and makes the fallback unit-length when non-zero.

```suggestion
        let d1_ref = geometry::inward_section_dir(edge_dir, n1_start, n2_start)
            .unwrap_or_else(|| edge_dir.cross(n1_start).normalize().unwrap_or(edge_dir));
        let d2_ref = geometry::inward_section_dir(edge_dir, n2_start, n1_start)
            .unwrap_or_else(|| edge_dir.cross(n2_start).normalize().unwrap_or(edge_dir));
```

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

Fix in Claude Code

// `bisector_ref` points from the edge toward the material interior, so
// the strip's outward normal must oppose it. Reverse when they agree.
let strip_normal = surface.normal(0.5, 0.5).unwrap_or(bisector_ref);
let strip_reversed = strip_normal.dot(bisector_ref) > 0.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 Degenerate bisector used as bisector_ref

bisector = (ld1 + ld2).normalize().unwrap_or(d1_ref) falls back to d1_ref (face-1 inward direction) when ld1 + ld2 is near-zero — which happens for nearly-flat convex edges (~180°) where the two inward directions are nearly antiparallel. In that case bisector_ref ends up being the inward direction for face 1 alone, and strip_normal.dot(bisector_ref) > 0.0 can give the wrong strip_reversed flag, producing an inside-out blend face. A dedicated zero-check or using a hard-coded fall-through orientation would be more robust for this edge case.

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

Comment:
**Degenerate bisector used as `bisector_ref`**

`bisector = (ld1 + ld2).normalize().unwrap_or(d1_ref)` falls back to `d1_ref` (face-1 inward direction) when `ld1 + ld2` is near-zero — which happens for nearly-flat convex edges (~180°) where the two inward directions are nearly antiparallel. In that case `bisector_ref` ends up being the inward direction for face 1 alone, and `strip_normal.dot(bisector_ref) > 0.0` can give the wrong `strip_reversed` flag, producing an inside-out blend face. A dedicated zero-check or using a hard-coded fall-through orientation would be more robust for this edge case.

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

Fix in Claude Code

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