Skip to content

feat(avoid): curve-aware detection and per-control-point belly routing#56

Merged
dancj merged 3 commits into
stagingfrom
fix/avoid-curve-routing
Jul 3, 2026
Merged

feat(avoid): curve-aware detection and per-control-point belly routing#56
dancj merged 3 commits into
stagingfrom
fix/avoid-curve-routing

Conversation

@dancj

@dancj dancj commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

avoid now works for the arrows it previously couldn't route. Before: an arrow's rendered bow could cut straight through a box listed in avoid (detection tested the straight start→end chord, which cleared it), and an obstacle sitting near the arrow's endpoint could never be cleared no matter how high avoidPadding was set (the belly's authority attenuates to zero at the ends). Both failure modes are from the root→branch gutter runs in issue #55.

The router now samples the cubic that will actually render and checks those samples against each padding-inflated box, so detection and rendering agree by construction. The correction is split across the two control points by their Bézier basis weights at the blocker's position — end-adjacent obstacles drive the near control point hard instead of bowing the middle — and the solver iterates (apply → resample → recheck, ≤4 rounds) until the curve clears. Geometry no single cubic can clear (a box overlapping the endpoint) exits finite and capped at 1.5× chord length.

Behavior change

Every existing avoid arrow renders differently: the old router amplified its single belly by an empirical BOW = 1.6, while the solver converges on the minimal displacement — bows shrink where 1.6x overshot. This PR is titled feat(avoid): deliberately so the release tooling bumps minor (0.6.0), not patch.

Design notes

  • avoidPadding now means one thing: the gap between the rendered curve and avoided boxes. It is no longer a double-duty trigger-threshold/bow-magnitude knob.
  • Sample density scales with chord length (~1 per 25px, 24–400), so a padded pill can't slip between samples on page-scale arrows.
  • Exact endpoint samples are excluded from penetration candidates and the weight parameter is clamped to the interior — the basis weights vanish at t=0/1, and an unguarded split would emit NaN.
  • Known limits (documented, out of scope): obstacles on opposite sides of the chord can oscillate within the iteration budget, and a deep unclearable end-adjacent box now renders a larger best-effort swing than the old small-but-clipping bow. A two-bend/S-route mode is the deferred follow-up for both.

Validation

  • 150 tests pass (vitest), including: chord-clears-but-curve-clips detection, end-adjacent clearance the old router mathematically could not deliver, the issue's gutter repro (asserts routing actually fires), endpoint-overlap finiteness/cap, long-chord detection density, and direct per-control-point wiring of buildPath.
  • Typecheck, eslint, prettier, and tsup build all clean.
  • Multi-agent code review ran (7 reviewers + per-finding validation); all confirmed actionable findings applied. Browser smoke test skipped: agent-browser not installed in this environment.

Related: #55


Compound Engineering
Claude Code

dancj added 3 commits July 3, 2026 15:21
…ntrol point

The avoid router measured obstacle clearance against the straight
start->end chord, but the drawn path is a cubic that bows away from that
chord - so a curve could clip a box whose chord cleared it, and no belly
was emitted at all. The belly also displaced both control points equally,
attenuating to zero at the endpoints, so end-adjacent obstacles could
never be cleared regardless of avoidPadding.

routeOffset is replaced by routeBellies: it samples the cubic that will
actually render (chord-scaled density, ~1 per 25px), finds the worst
padded-box penetration, and splits the correction across the two control
points by their Bezier basis weights at the blocker's parameter -
iterating up to 4 times until the resampled curve clears. Exact endpoint
samples are excluded and t* is clamped to the interior so the vanishing
basis weights can't produce NaN or runaway overshoot; displacement is
capped at 1.5x chord length for geometry no single cubic can clear.

The empirical BOW=1.6 amplifier in scroll-arrow is gone - the solver
converges on the displacement actually needed. Existing avoid arrows will
render with minimal rather than 1.6x-amplified bows, so this should ship
in a minor release.

Closes #55
- Raise samplePath's sample cap to 400 so small padded obstacles are
  still detected on page-scale chords (probe: 6000px chord missed a
  straddling box at the old 100-sample cap)
- Make the issue #55 repro test actually fire routing (tight sibling
  pill inside the padded approach) and assert nonzero bellies, so an
  always-zero router fails it
- Test buildPath's per-control belly wiring directly (a b1/b2 swap
  previously passed the whole suite)
- Stub SVGElement getTotalLength in the avoid tests instead of
  swallowing constructor errors with a bare try/catch
- Reuse unitNormal, hoist per-obstacle geometry out of the solver's
  iteration loop, clean up comments, format
@dancj
dancj merged commit dd8bbef into staging Jul 3, 2026
2 checks passed
@dancj
dancj deleted the fix/avoid-curve-routing branch July 3, 2026 21:02
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.

1 participant