From 7ed4e66dc4dc3a5071751b5d00fea801b102b521 Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:21:23 -0400 Subject: [PATCH 1/3] docs(plan): avoid-routing curve detection fix plan (#55) --- ...7-03-001-fix-avoid-curve-detection-plan.md | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/plans/2026-07-03-001-fix-avoid-curve-detection-plan.md diff --git a/docs/plans/2026-07-03-001-fix-avoid-curve-detection-plan.md b/docs/plans/2026-07-03-001-fix-avoid-curve-detection-plan.md new file mode 100644 index 0000000..29670c2 --- /dev/null +++ b/docs/plans/2026-07-03-001-fix-avoid-curve-detection-plan.md @@ -0,0 +1,184 @@ +--- +title: "fix: Curve-aware avoid detection and t-weighted belly routing" +date: 2026-07-03 +type: fix +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +execution: code +product_contract_source: ce-plan-bootstrap +origin: "GitHub issue #55" +--- + +# fix: Curve-aware avoid detection and t-weighted belly routing + +## Summary + +The `avoid` router (`routeOffset` in `src/geometry.ts`) tests obstacle clearance against the straight start→end chord, but the rendered path is a cubic that deviates from that chord. Two failure modes follow (issue #55): (1) a curve clips a box whose chord clears it, and no belly is emitted at all; (2) the belly displaces both control points equally, so its authority attenuates to ~zero near the endpoints — end-adjacent obstacles can never be cleared. Fix: detect against a sampled approximation of the curve that will actually render, weight the corrective displacement per control point by the obstacle's longitudinal position, and iterate to convergence — replacing the empirical `BOW = 1.6` amplifier in `src/scroll-arrow.ts` with a solver that finds the displacement actually needed. + +--- + +## Problem Frame + +- **Detection/render disagreement.** `routeOffset` measures each obstacle against the chord. The cubic bows out by `reach = dist × (0.3 + 0.4 × curvature)` along the socket normals, plus the belly. A box in the bow's path but clear of the chord is never detected, so no avoidance fires and `avoidPadding` becomes an indirect, double-duty tuning knob (trigger threshold *and* bow magnitude). +- **Endpoint attenuation.** Both control points get the same belly, so lateral displacement follows `3t(1-t)·belly` — full authority mid-chord, ~0.4x at t=0.9 even after the 1.6x amplification, zero at the ends. An obstacle projecting near t→1 (arrows converging on a target surrounded by siblings) is uncleatable regardless of `avoidPadding`. +- **Repro geometry (from issue):** left-aligned tree, root-left socket at x=60, branch-left sockets at x=96, pills extending rightward from x≥96. Root→branch arrows run the empty x=60..96 gutter; every intermediate pill either fails chord detection or registers only end-adjacent where the belly is powerless. + +## Requirements + +- **R1** — Obstacle detection evaluates the curve that will render (composed of socket-normal reach, curvature, and any applied belly), not the straight chord. A box the rendered curve would clip is detected even when the chord clears it. +- **R2** — Corrective displacement is weighted per control point by the obstacle's longitudinal position, so end-adjacent obstacles push the near control point hard enough to deliver clearance where equal-belly routing attenuates to zero. +- **R3** — `avoidPadding` means one thing: the gap maintained between the rendered curve and avoided boxes. It is no longer an indirect bow-magnitude knob. +- **R4** — Public API unchanged: `ScrollArrowOptions` surface (`avoid`, `avoidPadding`) identical; elbow route and non-avoid arrows behaviorally unchanged. **Existing `avoid` arrows will change shape:** every avoid arrow currently renders with `belly = chord-clearance × 1.6` on both controls, while the solver converges on minimal displacement — bows shrink where 1.6x overshot, and users who raised `avoidPadding` to compensate for the attenuation bug get visibly different gaps. Ship as a minor release (0.6.0), not a patch, and say so in the changelog. +- **R5** — Geometrically unclearable obstacles (e.g., a box overlapping the endpoint approach) degrade to best-effort: displacement is capped, the solver terminates, and output is always finite (no NaN, no unbounded bow). + +--- + +## Key Technical Decisions + +- **KTD1: Sample the cubic, test the samples.** Flatten the cubic `buildPath` would emit into sample points and flag an obstacle as blocking when any sample falls inside its padding-inflated box. Sample count scales with chord length (~1 sample per 25px, floored at 24, capped at ~100) — this library's core use case is page-scale arrows spanning thousands of pixels, where a fixed 24 samples would sit far enough apart for the curve to pass clean through a padded pill between consecutive samples. Penetration depth is measured along the chord's perpendicular (same normal frame as today). This makes detection and rendering agree by construction — the alternative (analytic cubic/box intersection) is far more code for no practical gain at arrow scale. +- **KTD2: Two bellies, weighted by t.** Replace the single shared belly with independent displacements `b1` (on control point 1) and `b2` (on control point 2). Lateral displacement of a cubic from per-control offsets is `L(t) = 3t(1-t)²·b1 + 3t²(1-t)·b2`. To clear a penetration `p` at the sample's parameter `t*`, distribute the correction across `b1`/`b2` proportional to their basis weights at `t*` (least-norm split: `bᵢ += p·wᵢ/(w1²+w2²)`). **Endpoint singularity guard:** both weights vanish at t=0 and t=1, so an exact-endpoint worst sample makes the split 0/0 = NaN, and near-endpoint samples (t ≈ 0.96 at typical sample counts) make it a ~9x-per-iteration overshoot. Exclude the exact t=0/t=1 samples from penetration candidates (they coincide with the immovable endpoints — no belly can move them) and clamp the `t*` used for weight computation to an interior range (≈ [0.05, 0.95]) before computing `w1`/`w2`. Mid-chord obstacles are still cleared, but with the minimal converged displacement rather than today's 1.6x-amplified one; end-adjacent obstacles drive the near control point hard, which is exactly the "weight the belly per control point by the blocker's longitudinal position" direction from the issue. +- **KTD3: Iterate instead of amplify.** Apply corrections, re-sample the resulting curve, re-check — up to ~4 iterations or until clear. This replaces the empirical `BOW = 1.6` fudge in `src/scroll-arrow.ts`: the solver converges on the displacement actually needed rather than guessing a fixed multiplier. Cap total per-control displacement (e.g., ~1.5× chord length) so impossible geometry terminates as best-effort (R5). Still a pragmatic single-cubic router, not a path-finder; the S-route/two-bend mode from the issue is explicitly deferred. +- **KTD4: Internal API change is free.** `routeOffset` is exported from `src/geometry.ts` but not from the package's public `src/index.ts`; its only caller is `src/scroll-arrow.ts:267`. Replace it with a curve-aware router (e.g., `routeBellies(ep, curvature, obstacles, padding) → { b1, b2 }`) and extend `buildPath` to accept per-control-point bellies. Exact names are the implementer's call. + +--- + +## High-Level Technical Design + +Directional sketch of the solver loop (not implementation specification): + +``` +routeBellies(ep, curvature, obstacles, padding): + b1 = b2 = (0,0) + n = perpendicular of start→end chord # push axis, as today + repeat up to MAX_ITER (≈4): + pts = sample cubic(ep, curvature, b1, b2) at N params t + # N scales with chord length: ~1 per 25px, min 24, max ~100 + worst = deepest penetration over all (obstacle × sample): + sample inside box inflated by padding → + depth measured along n, direction away from box center + # exact t=0 / t=1 samples excluded — endpoints are immovable + if none: break # curve clears everything + t* = clamp(parameter of worst sample, 0.05, 0.95) # avoid weight singularity + w1 = 3·t*·(1-t*)² w2 = 3·t*²·(1-t*) + p = depth (+ small overshoot to aid convergence) + b1 += n · p·w1/(w1²+w2²) # least-norm split + b2 += n · p·w2/(w1²+w2²) + clamp |b1|,|b2| to cap # R5 best-effort + return { b1, b2 } +``` + +```mermaid +flowchart LR + A[sample cubic incl. bellies] --> B{any sample inside\npadded obstacle?} + B -- no --> C[done: curve clears] + B -- yes --> D[worst penetration p at t*] + D --> E[split p across b1/b2\nby basis weights at t*] + E --> F{iter < 4 and\nunder cap?} + F -- yes --> A + F -- no --> G[done: best effort] +``` + +Caller change in `src/scroll-arrow.ts`: delete the `BOW = 1.6` amplification block; pass `{ b1, b2 }` straight into `buildPath`. + +--- + +## Implementation Units + +### U1. Cubic sampling and curve-vs-box penetration in geometry.ts + +**Goal:** A helper that flattens the composed cubic (endpoints, socket-normal reach, curvature, per-control bellies) into sample points, and a check that finds the worst padded-box penetration among those samples. + +**Requirements:** R1 + +**Dependencies:** none + +**Files:** `src/geometry.ts`, `test/geometry.test.ts` + +**Approach:** Factor the control-point construction currently inside `buildPath` so sampling and path-emission share one source of truth for where the controls sit (otherwise detection and rendering drift apart again — the exact bug being fixed). Standard cubic Bézier evaluation at N uniform params; penetration = how far a sample sits inside `box inflated by padding`, expressed as the along-normal push needed to exit, direction away from box center. + +**Patterns to follow:** existing chord/normal frame math in `routeOffset` (`src/geometry.ts:212-253`); module-local pure helpers with doc comments. + +**Test scenarios:** +- Sampling: first/last sample equal start/end exactly; a straight-line cubic (zero normals, zero curvature) samples onto the segment. +- Detection: a box the chord clears by > padding but the bowed curve enters is reported blocking (issue failure mode 1); a box clear of both chord and curve reports no penetration; a box the curve enters by k px reports depth ≈ k + padding-shortfall along the normal. +- Long-arrow density: a page-scale arrow (≥ 2000px chord) with a small padded pill on the bow is still detected — sample spacing must not exceed the size of a typical padded obstacle. + +**Verification:** unit tests pass; helper is pure (no DOM), same coordinate space as `routeOffset` today. + +### U2. t-weighted iterative two-belly router + +**Goal:** Replace `routeOffset` with the curve-aware solver returning independent `b1`/`b2` displacements; extend `buildPath` to apply per-control-point bellies. + +**Requirements:** R1, R2, R3, R5 + +**Dependencies:** U1 + +**Files:** `src/geometry.ts`, `test/geometry.test.ts` + +**Approach:** Loop per the HTD sketch: sample (U1) → worst penetration → least-norm basis-weight split at t* → clamp → repeat ≤ 4. `buildPath`'s `belly` parameter becomes two per-control displacements (internal-only signature; only `src/scroll-arrow.ts` and tests call it — KTD4). Remove or repurpose the old chord-only `routeOffset`; do not keep dead code. + +**Execution note:** Start with a failing test built from the issue's repro geometry (gutter run below) so the solver is proven against the real-world case, not just synthetic boxes. + +**Test scenarios:** +- No obstacles → `{ b1: 0,0, b2: 0,0 }` and `buildPath` output identical to today's zero-belly path. +- Mid-chord blocker (the existing `routeOffset` happy case): resulting sampled curve clears the padded box; both bellies finite and same sign. +- Chord-clears-but-curve-clips box (issue mode 1): solver emits a belly and the resulting curve clears; verify by re-sampling the returned curve against the box. +- End-adjacent obstacle at t ≈ 0.85–0.9 (issue mode 2): `|b2| ≫ |b1|`; resulting curve clears the padded box — the case equal-belly routing mathematically cannot clear. +- Issue repro geometry: start (60, y₀) → end (96, y₁) gutter run with a pill box at x ≥ 96 adjacent to the end; curve clears it or reaches the cap without NaN. +- Impossible geometry (box overlapping the endpoint): terminates within MAX_ITER, displacement ≤ cap, all outputs finite (R5). +- Worst penetration landing on an exact-endpoint or near-endpoint sample (t → 0 or t → 1): bellies stay finite and ≤ cap — no NaN from the vanishing basis weights, no runaway overshoot from the interior clamp. +- Obstacles beyond the ends of the run (behind start / past end and clear of the curve) produce no belly. + +**Verification:** all geometry tests pass; every emitted path re-sampled against its obstacles shows no penetration except documented best-effort cap cases. + +### U3. Wire scroll-arrow.ts to the new router + +**Goal:** The `avoid` render path uses the solver output directly; the `BOW = 1.6` amplifier is gone. + +**Requirements:** R3, R4 + +**Dependencies:** U2 + +**Files:** `src/scroll-arrow.ts`, `test/scroll-arrow.test.ts`, `test/draw.test.ts` (only if assertions reference the old path shape) + +**Approach:** In the curved branch (`src/scroll-arrow.ts:257-278`), call the new router with the local endpoints, curvature, resolved obstacle boxes, and `avoidPadding ?? 14`; pass the two bellies into `buildPath`. Delete the BOW block and its comment. Elbow branch untouched. + +**Test scenarios:** +- An arrow with `avoid` whose obstacle sits mid-chord renders a `d` whose sampled points clear the padded box (jsdom, mirroring existing scroll-arrow test setup). +- An arrow without `avoid` renders the same path as before the change. +- `route: 'elbow'` with `avoid` set still ignores avoidance (existing contract). + +**Verification:** full suite (`vitest`), typecheck, and lint pass; no public type changes in `src/types.ts`. + +--- + +## Scope Boundaries + +**In scope:** curve-aware detection, per-control-point weighted belly, iterative convergence, BOW removal, tests from the issue's repro geometry. + +**Out of scope (non-goals):** multi-obstacle simultaneous solving beyond worst-blocker-per-iteration; changes to elbow routing; any public option surface changes. + +### Deferred to Follow-Up Work + +- **Two-bend / S-route mode** for obstacles so end-adjacent that a single cubic cannot clear them (issue's third suggested direction). The R5 cap makes those cases safe but not solved; a follow-up issue can add an S-route when real maps still misfire after this fix. +- **Live map test fixture** offered in the issue — the plan encodes the repro geometry numerically; importing the full home-bartender map is follow-up material if regressions appear. + +## Assumptions + +- Chord-scaled sampling (~1 per 25px, min 24, max ~100) and MAX_ITER ≈ 4 are adequate; both are cheap constants the implementer may tune if tests demand. +- Pushing along the chord's perpendicular (single axis, as today) remains sufficient; per-obstacle push axes are not needed for the reported cases. +- The existing default `avoidPadding = 14` keeps its value; only its semantics tighten (R3). + +--- + +## Verification Contract + +- `npm test` (vitest) green, including new U1/U2/U3 scenarios. +- `npm run typecheck` and `npm run lint` green. +- The issue's repro geometry, encoded as a unit test, demonstrates: detection fires where the chord test did not, and an end-adjacent blocker is cleared. + +## Definition of Done + +- All three units landed; `BOW` constant no longer exists in `src/scroll-arrow.ts`. +- R1–R5 each covered by at least one passing test. +- No changes to `src/types.ts` or public exports in `src/index.ts`. From 1b68db909c58eb5d721d5dc86e7eb12c0e4e8d0a Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:21:23 -0400 Subject: [PATCH 2/3] fix(avoid): detect against the rendered curve and weight belly per control 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 --- src/geometry.ts | 203 ++++++++++++++++++++++++++++---------- src/scroll-arrow.ts | 14 +-- test/geometry.test.ts | 181 ++++++++++++++++++++++++++------- test/scroll-arrow.test.ts | 47 +++++++++ 4 files changed, 344 insertions(+), 101 deletions(-) diff --git a/src/geometry.ts b/src/geometry.ts index add5f57..2472810 100644 --- a/src/geometry.ts +++ b/src/geometry.ts @@ -121,16 +121,18 @@ export function resolveEndpoints( } /** - * Build a cubic-bezier `d` string between endpoints. Control points are pushed - * out along each socket normal so the curve leaves/enters edges cleanly. - * `curvature` (0..~1) scales how far the controls bow out. `belly` is an extra - * displacement (e.g. from obstacle routing) added to both control points. + * Control points of the cubic between endpoints. Pushed out along each socket + * normal so the curve leaves/enters edges cleanly; `curvature` (0..~1) scales + * how far they bow out. `b1`/`b2` are extra per-control displacements from + * obstacle routing. Shared by buildPath and samplePath so detection and + * rendering agree on the exact same curve. */ -export function buildPath( +function cubicControls( ep: Endpoints, curvature: number, - belly: Point = { x: 0, y: 0 }, -): string { + b1: Point, + b2: Point, +): { c1: Point; c2: Point } { const { start, end, startNormal, endNormal } = ep; const dx = end.x - start.x; const dy = end.y - start.y; @@ -141,18 +143,66 @@ export function buildPath( const sn = startNormal.x || startNormal.y ? startNormal : unit(dx, dy); const en = endNormal.x || endNormal.y ? endNormal : unit(-dx, -dy); - const c1 = { - x: start.x + sn.x * reach + belly.x, - y: start.y + sn.y * reach + belly.y, - }; - const c2 = { - x: end.x + en.x * reach + belly.x, - y: end.y + en.y * reach + belly.y, + return { + c1: { x: start.x + sn.x * reach + b1.x, y: start.y + sn.y * reach + b1.y }, + c2: { x: end.x + en.x * reach + b2.x, y: end.y + en.y * reach + b2.y }, }; +} +const ZERO: Point = { x: 0, y: 0 }; + +/** + * Build a cubic-bezier `d` string between endpoints. `b1`/`b2` displace the + * start-side and end-side control points independently (obstacle routing + * pushes the control nearest the blocker hardest). + */ +export function buildPath( + ep: Endpoints, + curvature: number, + b1: Point = ZERO, + b2: Point = ZERO, +): string { + const { start, end } = ep; + const { c1, c2 } = cubicControls(ep, curvature, b1, b2); return `M ${r(start.x)} ${r(start.y)} C ${r(c1.x)} ${r(c1.y)} ${r(c2.x)} ${r(c2.y)} ${r(end.x)} ${r(end.y)}`; } +/** + * Flatten the cubic buildPath would emit into uniformly-spaced parameter + * samples. Sample count scales with chord length (~1 per 25px, min 24, + * max 100) so a padded obstacle can't slip between consecutive samples on + * page-scale arrows. First/last samples are exactly the endpoints. + */ +export function samplePath( + ep: Endpoints, + curvature: number, + b1: Point = ZERO, + b2: Point = ZERO, +): Point[] { + const { start, end } = ep; + const dist = Math.hypot(end.x - start.x, end.y - start.y) || 1; + const n = Math.max(24, Math.min(100, Math.round(dist / 25))); + const { c1, c2 } = cubicControls(ep, curvature, b1, b2); + + const pts: Point[] = []; + for (let i = 0; i <= n; i++) { + const t = i / n; + const u = 1 - t; + const w0 = u * u * u; + const w1 = 3 * t * u * u; + const w2 = 3 * t * t * u; + const w3 = t * t * t; + pts.push({ + x: w0 * start.x + w1 * c1.x + w2 * c2.x + w3 * end.x, + y: w0 * start.y + w1 * c1.y + w2 * c2.y + w3 * end.y, + }); + } + // Pin the ends exactly (float noise from the weight sums). + pts[0] = { x: start.x, y: start.y }; + pts[n] = { x: end.x, y: end.y }; + return pts; +} + /** * Build an orthogonal (right-angle "elbow") `d` string between endpoints — the * classic tree / org-chart bracket. The exit/entry axes follow each socket @@ -203,53 +253,98 @@ export interface Box { height: number; } +export interface RouteBellies { + b1: Point; + b2: Point; +} + /** - * Lateral displacement to bow the curve around blocking boxes. Returns the - * dominant push (perpendicular to the start→end line) needed to clear the - * worst obstacle; {0,0} when nothing blocks. A pragmatic single-bend router — - * not a full path-finder. + * Per-control-point displacements to bow the rendered curve clear of blocking + * boxes. Detection runs against samples of the actual cubic (not the straight + * chord), so a box the chord clears but the bow clips is still caught. The + * correction for the worst penetration is split across the two control points + * by their Bézier basis weights at the blocker's parameter, so end-adjacent + * obstacles push the near control hard instead of bowing the middle. Iterates + * apply → resample → recheck until clear (or a bounded best-effort cap for + * geometry no single cubic can clear). Still a pragmatic single-cubic router, + * not a path-finder. */ -export function routeOffset( - start: Point, - end: Point, +export function routeBellies( + ep: Endpoints, + curvature: number, obstacles: Box[], padding = 14, -): Point { - const dx = end.x - start.x; - const dy = end.y - start.y; +): RouteBellies { + const b1: Point = { x: 0, y: 0 }; + const b2: Point = { x: 0, y: 0 }; + if (!obstacles.length) return { b1, b2 }; + + const dx = ep.end.x - ep.start.x; + const dy = ep.end.y - ep.start.y; const len = Math.hypot(dx, dy) || 1; - const t = { x: dx / len, y: dy / len }; - const n = { x: dy / len, y: -dx / len }; // left-hand normal - - let best: Point = { x: 0, y: 0 }; - let bestMag = 0; - - for (const b of obstacles) { - const cx = b.left + b.width / 2; - const cy = b.top + b.height / 2; - const relx = cx - start.x; - const rely = cy - start.y; - - // Longitudinal position along the line, 0..1 — ignore boxes off the ends. - const u = (relx * t.x + rely * t.y) / len; - if (u < 0 || u > 1) continue; - - const signed = relx * n.x + rely * n.y; // perpendicular position of center - // Box half-extent projected onto the normal axis. - const radius = - Math.abs((b.width / 2) * n.x) + Math.abs((b.height / 2) * n.y); - const clearance = radius + padding; - if (Math.abs(signed) >= clearance) continue; // line already clears it - - // Bow to the side opposite the obstacle center, just far enough to clear. - const sign = signed > 0 ? -1 : 1; - const offset = signed + sign * clearance; - if (Math.abs(offset) > bestMag) { - bestMag = Math.abs(offset); - best = { x: n.x * offset, y: n.y * offset }; + const n = { x: dy / len, y: -dx / len }; // left-hand normal: the push axis + const cap = 1.5 * len; // best-effort ceiling for unclearable geometry + const MAX_ITER = 4; + + for (let iter = 0; iter < MAX_ITER; iter++) { + const pts = samplePath(ep, curvature, b1, b2); + const last = pts.length - 1; + + // Worst penetration across all obstacles × interior samples. The exact + // endpoint samples are excluded: they sit on the anchors, which no belly + // can move — including them would divide by vanishing basis weights. + let worstDepth = 0; + let worstT = 0; + let worstSign = 1; + for (const b of obstacles) { + const cx = b.left + b.width / 2; + const cy = b.top + b.height / 2; + const radius = + Math.abs((b.width / 2) * n.x) + Math.abs((b.height / 2) * n.y); + const clearance = radius + padding; + for (let i = 1; i < last; i++) { + const p = pts[i]!; + if ( + p.x <= b.left - padding || + p.x >= b.left + b.width + padding || + p.y <= b.top - padding || + p.y >= b.top + b.height + padding + ) + continue; // sample outside the padded box + const s = (p.x - cx) * n.x + (p.y - cy) * n.y; + const depth = clearance - Math.abs(s); + if (depth > worstDepth) { + worstDepth = depth; + worstT = i / last; + worstSign = s >= 0 ? 1 : -1; // push further out the side it's on + } + } + } + if (worstDepth <= 0) break; // curve clears everything + + // Least-norm split of the correction across b1/b2 by basis weight at t*, + // with t* clamped to the interior so the weights can't vanish (endpoint + // singularity guard — see plan KTD2). + const t = Math.min(0.95, Math.max(0.05, worstT)); + const w1 = 3 * t * (1 - t) * (1 - t); + const w2 = 3 * t * t * (1 - t); + // +2px overshoot so float noise doesn't leave a sample kissing the box. + const k = ((worstDepth + 2) * worstSign) / (w1 * w1 + w2 * w2); + b1.x += n.x * k * w1; + b1.y += n.y * k * w1; + b2.x += n.x * k * w2; + b2.y += n.y * k * w2; + + // ponytail: magnitude cap, not a solver — unclearable geometry exits here. + for (const b of [b1, b2]) { + const m = Math.hypot(b.x, b.y); + if (m > cap) { + b.x = (b.x / m) * cap; + b.y = (b.y / m) * cap; + } } } - return best; + return { b1, b2 }; } /** Two short strokes forming an arrowhead at `tip`, opening along `dir`. */ diff --git a/src/scroll-arrow.ts b/src/scroll-arrow.ts index b086c21..57a6e85 100644 --- a/src/scroll-arrow.ts +++ b/src/scroll-arrow.ts @@ -11,7 +11,7 @@ import { endTangent, startTangent, unitNormal, - routeOffset, + routeBellies, type Endpoints, type Box, type DocRect, @@ -264,17 +264,13 @@ export class ScrollArrow { height: dr.height, }; }); - const clear = routeOffset( - local.start, - local.end, + const { b1, b2 } = routeBellies( + local, + curvature, obstacles, this.opts.avoidPadding ?? 14, ); - // A cubic's midpoint only reaches ~0.75x its control-point displacement, - // so amplify the requested clearance to make the curve clear the box. - const BOW = 1.6; - const belly = { x: clear.x * BOW, y: clear.y * BOW }; - d = buildPath(local, curvature, belly); + d = buildPath(local, curvature, b1, b2); } this.lineD = d; this.appendDrawable(this.rc.path(d, roughOpts), 'line'); diff --git a/test/geometry.test.ts b/test/geometry.test.ts index c33e054..34ff906 100644 --- a/test/geometry.test.ts +++ b/test/geometry.test.ts @@ -7,7 +7,8 @@ import { endTangent, startTangent, unitNormal, - routeOffset, + routeBellies, + samplePath, isDegenerateRect, type DocRect, type Box, @@ -214,10 +215,61 @@ describe('unitNormal', () => { }); }); -describe('routeOffset', () => { +// Bare Endpoints without DOM rects: straight chord unless normals are given. +function eps( + start: { x: number; y: number }, + end: { x: number; y: number }, + startNormal = { x: 0, y: 0 }, + endNormal = { x: 0, y: 0 }, +) { + return { start, end, startNormal, endNormal }; +} + +// True when every sample of the routed curve stays out of every +// padding-inflated box — the contract routeBellies is meant to deliver. +function routedCurveClears( + ep: ReturnType, + curvature: number, + boxes: Box[], + padding = 14, +): boolean { + const { b1, b2 } = routeBellies(ep, curvature, boxes, padding); + const pts = samplePath(ep, curvature, b1, b2); + return pts.every((p) => + boxes.every( + (b) => + p.x <= b.left - padding || + p.x >= b.left + b.width + padding || + p.y <= b.top - padding || + p.y >= b.top + b.height + padding, + ), + ); +} + +describe('samplePath', () => { + it('first and last samples are exactly the endpoints', () => { + const pts = samplePath(eps({ x: 0, y: 0 }, { x: 200, y: 0 }), 0.5); + expect(pts[0]).toEqual({ x: 0, y: 0 }); + expect(pts[pts.length - 1]).toEqual({ x: 200, y: 0 }); + }); + + it('a zero-curvature center-socket cubic samples onto the segment', () => { + // center sockets fall back to the straight direction, so with the belly + // at zero every sample sits on the chord. + const pts = samplePath(eps({ x: 0, y: 0 }, { x: 200, y: 0 }), 0); + for (const p of pts) expect(p.y).toBeCloseTo(0); + }); + + it('scales sample density with chord length for page-scale arrows', () => { + const pts = samplePath(eps({ x: 0, y: 0 }, { x: 2400, y: 0 }), 0.5); + // ~1 sample per 25px so a padded pill can't fit between samples. + expect(pts.length).toBeGreaterThan(90); + }); +}); + +describe('routeBellies', () => { // horizontal line from (0,0) to (200,0); left normal points up (-y). - const start = { x: 0, y: 0 }; - const end = { x: 200, y: 0 }; + const line = eps({ x: 0, y: 0 }, { x: 200, y: 0 }); const box = (b: Partial): Box => ({ left: 0, top: 0, @@ -225,51 +277,104 @@ describe('routeOffset', () => { height: 20, ...b, }); + const zero = { x: 0, y: 0 }; - it('returns zero when there are no obstacles', () => { - expect(routeOffset(start, end, [])).toEqual({ x: 0, y: 0 }); + it('returns zero bellies when there are no obstacles', () => { + expect(routeBellies(line, 0, [])).toEqual({ b1: zero, b2: zero }); }); - it('returns zero for a box that does not block the line', () => { - // centered at (100, 200): far below the line, clears easily. - const off = routeOffset(start, end, [box({ left: 90, top: 190 })]); - expect(off).toEqual({ x: 0, y: 0 }); + it('returns zero bellies for a box the curve already clears', () => { + // centered at (100, 200): far below the line. + const r = routeBellies(line, 0, [box({ left: 90, top: 190 })]); + expect(r).toEqual({ b1: zero, b2: zero }); }); - it('returns zero for a box past the endpoints longitudinally', () => { - // centered at (-100, 0): behind the start. - const off = routeOffset(start, end, [box({ left: -110, top: -10 })]); - expect(off).toEqual({ x: 0, y: 0 }); + it('returns zero bellies for a box behind the start', () => { + const r = routeBellies(line, 0, [box({ left: -110, top: -10 })]); + expect(r).toEqual({ b1: zero, b2: zero }); }); - it('bows the curve perpendicular to clear a box on the line', () => { - // box straddling the line at x≈100 (center 100,0), 20x20, padding 14. - const off = routeOffset(start, end, [box({ left: 90, top: -10 })], 14); - expect(off.x).toBeCloseTo(0); // push is purely perpendicular (vertical) - // center signed offset 0 -> push to +1 side along normal (-y): clearance - // = radius(10) + padding(14) = 24, along normal (0,-1) -> y = -24. - expect(off.y).toBeCloseTo(-24); + it('bows the curve clear of a box straddling the chord', () => { + const boxes = [box({ left: 90, top: -10 })]; + const { b1, b2 } = routeBellies(line, 0, boxes); + // straddling center -> default push side is up (-y), like the old router. + expect(b1.y).toBeLessThan(0); + expect(b2.y).toBeLessThan(0); + expect(routedCurveClears(line, 0, boxes)).toBe(true); }); it('pushes to the side opposite the obstacle center', () => { - // box center slightly above the line (negative y -> signed positive on the - // up-normal) should push the curve down (+y). - const off = routeOffset(start, end, [box({ left: 90, top: -18 })], 14); - expect(off.y).toBeGreaterThan(0); - }); - - it('picks the worst blocker among several', () => { - const off = routeOffset( - start, - end, - [ - box({ left: 50, top: -6, width: 12, height: 12 }), - box({ left: 120, top: -40, width: 60, height: 60 }), - ], - 14, + const boxes = [box({ left: 90, top: -18 })]; + const { b1, b2 } = routeBellies(line, 0, boxes); + expect(b1.y).toBeGreaterThan(0); + expect(b2.y).toBeGreaterThan(0); + expect(routedCurveClears(line, 0, boxes)).toBe(true); + }); + + it('detects a box the chord clears but the bowed curve clips (issue #55 mode 1)', () => { + // Top sockets bow the curve upward: reach = 200 * (0.3 + 0.4*0.5) = 100, + // putting the bow's apex at (100, -75). A box there clears the chord + // (y=0) by 65 > padding 14, so the old chord-based router returned zero — + // but the rendered bow passes straight through it. + const bowed = eps( + { x: 0, y: 0 }, + { x: 200, y: 0 }, + { x: 0, y: -1 }, + { x: 0, y: -1 }, + ); + const boxes = [box({ left: 90, top: -85 })]; + const { b1, b2 } = routeBellies(bowed, 0.5, boxes); + expect(Math.hypot(b1.x, b1.y) + Math.hypot(b2.x, b2.y)).toBeGreaterThan(0); + expect(routedCurveClears(bowed, 0.5, boxes)).toBe(true); + }); + + it('clears an end-adjacent obstacle by driving the near control point (issue #55 mode 2)', () => { + // Box straddling the chord at t ≈ 0.85 — equal-belly routing attenuates + // to ~0.4x here and mathematically cannot clear it. + const boxes = [box({ left: 160, top: -10 })]; + const { b1, b2 } = routeBellies(line, 0, boxes); + expect(Math.abs(b2.y)).toBeGreaterThan(Math.abs(b1.y)); + expect(routedCurveClears(line, 0, boxes)).toBe(true); + }); + + it('clears multiple obstacles on the same side of the chord', () => { + const boxes = [ + box({ left: 50, top: -10, width: 12, height: 12 }), + box({ left: 130, top: -10 }), + ]; + expect(routedCurveClears(line, 0, boxes)).toBe(true); + }); + + it('issue #55 repro: root→branch gutter run stays finite and clear', () => { + // Left-aligned tree: root-left socket (60,300), branch-left socket + // (96,100), sibling pills extending right from x=96. + const gutter = eps( + { x: 60, y: 300 }, + { x: 96, y: 100 }, + { x: -1, y: 0 }, + { x: -1, y: 0 }, ); - // the larger, line-crossing box at x=150 dominates. - expect(Math.abs(off.y)).toBeGreaterThan(20); + const pills: Box[] = [ + { left: 96, top: 136, width: 120, height: 28 }, + { left: 96, top: 186, width: 120, height: 28 }, + { left: 96, top: 236, width: 120, height: 28 }, + ]; + const { b1, b2 } = routeBellies(gutter, 0.3, pills); + for (const v of [b1.x, b1.y, b2.x, b2.y]) expect(Number.isFinite(v)).toBe(true); + expect(routedCurveClears(gutter, 0.3, pills)).toBe(true); + }); + + it('caps displacement and stays finite for a box overlapping the endpoint', () => { + // Geometrically unclearable: the curve must terminate inside the padded + // box. Best-effort per R5 — finite, capped, terminates. + const boxes = [box({ left: 190, top: -10 })]; + const { b1, b2 } = routeBellies(line, 0, boxes); + const cap = 1.5 * 200 + 1e-6; + for (const b of [b1, b2]) { + expect(Number.isFinite(b.x)).toBe(true); + expect(Number.isFinite(b.y)).toBe(true); + expect(Math.hypot(b.x, b.y)).toBeLessThanOrEqual(cap); + } }); }); diff --git a/test/scroll-arrow.test.ts b/test/scroll-arrow.test.ts index 9a8d345..e81db5b 100644 --- a/test/scroll-arrow.test.ts +++ b/test/scroll-arrow.test.ts @@ -112,3 +112,50 @@ describe('ScrollArrow with a hidden / zero-size anchor', () => { expect(() => new ScrollArrow({ start, end })).not.toThrow(); }); }); + +describe('ScrollArrow avoid routing', () => { + // jsdom throws at SVG measurement (getTotalLength), but the line path is + // already appended to the overlay by then, and rough.js output is + // deterministic for a fixed seed — so the rendered `d` in the DOM is a + // faithful witness of the geometry the router produced. + function lineD(opts: ConstructorParameters[0]): string { + try { + new ScrollArrow(opts); + } catch { + /* jsdom getTotalLength gap — the line path is already in the DOM */ + } + const d = document.querySelector('svg path')?.getAttribute('d') ?? ''; + document.querySelectorAll('svg').forEach((s) => s.remove()); + return d; + } + + it('bends the curve when an avoided obstacle blocks it', () => { + const start = boxed({ left: 0, top: 0, width: 100, height: 40 }); + const end = boxed({ left: 300, top: 0, width: 100, height: 40 }); + // Straddles the straight run between the two anchors. + const obstacle = boxed({ left: 150, top: 0, width: 40, height: 40 }); + + const plain = lineD({ start, end, seed: 7 }); + const routed = lineD({ start, end, seed: 7, avoid: obstacle }); + expect(plain).toBeTruthy(); + expect(routed).toBeTruthy(); + expect(routed).not.toBe(plain); + }); + + it('elbow route ignores avoid (existing contract)', () => { + const start = boxed({ left: 0, top: 0, width: 100, height: 40 }); + const end = boxed({ left: 300, top: 0, width: 100, height: 40 }); + const obstacle = boxed({ left: 150, top: 0, width: 40, height: 40 }); + + const plain = lineD({ start, end, seed: 7, route: 'elbow' }); + const routed = lineD({ + start, + end, + seed: 7, + route: 'elbow', + avoid: obstacle, + }); + expect(plain).toBeTruthy(); + expect(routed).toBe(plain); + }); +}); From fb89b5d510366bf54a7e7fe67e40b79e5839040d Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:50:36 -0400 Subject: [PATCH 3/3] fix(review): apply code-review findings and simplify pass - 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 --- ...7-03-001-fix-avoid-curve-detection-plan.md | 11 +++-- src/geometry.ts | 49 ++++++++++--------- test/geometry.test.ts | 33 ++++++++++--- test/scroll-arrow.test.ts | 25 ++++++---- 4 files changed, 76 insertions(+), 42 deletions(-) diff --git a/docs/plans/2026-07-03-001-fix-avoid-curve-detection-plan.md b/docs/plans/2026-07-03-001-fix-avoid-curve-detection-plan.md index 29670c2..dc475f8 100644 --- a/docs/plans/2026-07-03-001-fix-avoid-curve-detection-plan.md +++ b/docs/plans/2026-07-03-001-fix-avoid-curve-detection-plan.md @@ -1,12 +1,12 @@ --- -title: "fix: Curve-aware avoid detection and t-weighted belly routing" +title: 'fix: Curve-aware avoid detection and t-weighted belly routing' date: 2026-07-03 type: fix artifact_contract: ce-unified-plan/v1 artifact_readiness: implementation-ready execution: code product_contract_source: ce-plan-bootstrap -origin: "GitHub issue #55" +origin: 'GitHub issue #55' --- # fix: Curve-aware avoid detection and t-weighted belly routing @@ -19,7 +19,7 @@ The `avoid` router (`routeOffset` in `src/geometry.ts`) tests obstacle clearance ## Problem Frame -- **Detection/render disagreement.** `routeOffset` measures each obstacle against the chord. The cubic bows out by `reach = dist × (0.3 + 0.4 × curvature)` along the socket normals, plus the belly. A box in the bow's path but clear of the chord is never detected, so no avoidance fires and `avoidPadding` becomes an indirect, double-duty tuning knob (trigger threshold *and* bow magnitude). +- **Detection/render disagreement.** `routeOffset` measures each obstacle against the chord. The cubic bows out by `reach = dist × (0.3 + 0.4 × curvature)` along the socket normals, plus the belly. A box in the bow's path but clear of the chord is never detected, so no avoidance fires and `avoidPadding` becomes an indirect, double-duty tuning knob (trigger threshold _and_ bow magnitude). - **Endpoint attenuation.** Both control points get the same belly, so lateral displacement follows `3t(1-t)·belly` — full authority mid-chord, ~0.4x at t=0.9 even after the 1.6x amplification, zero at the ends. An obstacle projecting near t→1 (arrows converging on a target surrounded by siblings) is uncleatable regardless of `avoidPadding`. - **Repro geometry (from issue):** left-aligned tree, root-left socket at x=60, branch-left sockets at x=96, pills extending rightward from x≥96. Root→branch arrows run the empty x=60..96 gutter; every intermediate pill either fails chord detection or registers only end-adjacent where the belly is powerless. @@ -99,6 +99,7 @@ Caller change in `src/scroll-arrow.ts`: delete the `BOW = 1.6` amplification blo **Patterns to follow:** existing chord/normal frame math in `routeOffset` (`src/geometry.ts:212-253`); module-local pure helpers with doc comments. **Test scenarios:** + - Sampling: first/last sample equal start/end exactly; a straight-line cubic (zero normals, zero curvature) samples onto the segment. - Detection: a box the chord clears by > padding but the bowed curve enters is reported blocking (issue failure mode 1); a box clear of both chord and curve reports no penetration; a box the curve enters by k px reports depth ≈ k + padding-shortfall along the normal. - Long-arrow density: a page-scale arrow (≥ 2000px chord) with a small padded pill on the bow is still detected — sample spacing must not exceed the size of a typical padded obstacle. @@ -115,11 +116,12 @@ Caller change in `src/scroll-arrow.ts`: delete the `BOW = 1.6` amplification blo **Files:** `src/geometry.ts`, `test/geometry.test.ts` -**Approach:** Loop per the HTD sketch: sample (U1) → worst penetration → least-norm basis-weight split at t* → clamp → repeat ≤ 4. `buildPath`'s `belly` parameter becomes two per-control displacements (internal-only signature; only `src/scroll-arrow.ts` and tests call it — KTD4). Remove or repurpose the old chord-only `routeOffset`; do not keep dead code. +**Approach:** Loop per the HTD sketch: sample (U1) → worst penetration → least-norm basis-weight split at t\* → clamp → repeat ≤ 4. `buildPath`'s `belly` parameter becomes two per-control displacements (internal-only signature; only `src/scroll-arrow.ts` and tests call it — KTD4). Remove or repurpose the old chord-only `routeOffset`; do not keep dead code. **Execution note:** Start with a failing test built from the issue's repro geometry (gutter run below) so the solver is proven against the real-world case, not just synthetic boxes. **Test scenarios:** + - No obstacles → `{ b1: 0,0, b2: 0,0 }` and `buildPath` output identical to today's zero-belly path. - Mid-chord blocker (the existing `routeOffset` happy case): resulting sampled curve clears the padded box; both bellies finite and same sign. - Chord-clears-but-curve-clips box (issue mode 1): solver emits a belly and the resulting curve clears; verify by re-sampling the returned curve against the box. @@ -144,6 +146,7 @@ Caller change in `src/scroll-arrow.ts`: delete the `BOW = 1.6` amplification blo **Approach:** In the curved branch (`src/scroll-arrow.ts:257-278`), call the new router with the local endpoints, curvature, resolved obstacle boxes, and `avoidPadding ?? 14`; pass the two bellies into `buildPath`. Delete the BOW block and its comment. Elbow branch untouched. **Test scenarios:** + - An arrow with `avoid` whose obstacle sits mid-chord renders a `d` whose sampled points clear the padded box (jsdom, mirroring existing scroll-arrow test setup). - An arrow without `avoid` renders the same path as before the change. - `route: 'elbow'` with `avoid` set still ignores avoidance (existing contract). diff --git a/src/geometry.ts b/src/geometry.ts index 2472810..4419448 100644 --- a/src/geometry.ts +++ b/src/geometry.ts @@ -170,8 +170,8 @@ export function buildPath( /** * Flatten the cubic buildPath would emit into uniformly-spaced parameter * samples. Sample count scales with chord length (~1 per 25px, min 24, - * max 100) so a padded obstacle can't slip between consecutive samples on - * page-scale arrows. First/last samples are exactly the endpoints. + * max 400) so sample spacing stays below a padded obstacle's extent for + * chords up to ~10000px. First/last samples are exactly the endpoints. */ export function samplePath( ep: Endpoints, @@ -181,7 +181,7 @@ export function samplePath( ): Point[] { const { start, end } = ep; const dist = Math.hypot(end.x - start.x, end.y - start.y) || 1; - const n = Math.max(24, Math.min(100, Math.round(dist / 25))); + const n = Math.max(24, Math.min(400, Math.round(dist / 25))); const { c1, c2 } = cubicControls(ep, curvature, b1, b2); const pts: Point[] = []; @@ -279,13 +279,25 @@ export function routeBellies( const b2: Point = { x: 0, y: 0 }; if (!obstacles.length) return { b1, b2 }; - const dx = ep.end.x - ep.start.x; - const dy = ep.end.y - ep.start.y; - const len = Math.hypot(dx, dy) || 1; - const n = { x: dy / len, y: -dx / len }; // left-hand normal: the push axis + const len = Math.hypot(ep.end.x - ep.start.x, ep.end.y - ep.start.y) || 1; + const n = unitNormal(ep.start, ep.end); // left-hand normal: the push axis const cap = 1.5 * len; // best-effort ceiling for unclearable geometry const MAX_ITER = 4; + // Per-obstacle geometry never changes between iterations — hoist it out of + // the resample loop. `clearance` folds the box half-extent projected onto + // the push axis plus the padding. + const obs = obstacles.map((b) => ({ + cx: b.left + b.width / 2, + cy: b.top + b.height / 2, + minX: b.left - padding, + maxX: b.left + b.width + padding, + minY: b.top - padding, + maxY: b.top + b.height + padding, + clearance: + Math.abs((b.width / 2) * n.x) + Math.abs((b.height / 2) * n.y) + padding, + })); + for (let iter = 0; iter < MAX_ITER; iter++) { const pts = samplePath(ep, curvature, b1, b2); const last = pts.length - 1; @@ -296,23 +308,13 @@ export function routeBellies( let worstDepth = 0; let worstT = 0; let worstSign = 1; - for (const b of obstacles) { - const cx = b.left + b.width / 2; - const cy = b.top + b.height / 2; - const radius = - Math.abs((b.width / 2) * n.x) + Math.abs((b.height / 2) * n.y); - const clearance = radius + padding; + for (const o of obs) { for (let i = 1; i < last; i++) { const p = pts[i]!; - if ( - p.x <= b.left - padding || - p.x >= b.left + b.width + padding || - p.y <= b.top - padding || - p.y >= b.top + b.height + padding - ) + if (p.x <= o.minX || p.x >= o.maxX || p.y <= o.minY || p.y >= o.maxY) continue; // sample outside the padded box - const s = (p.x - cx) * n.x + (p.y - cy) * n.y; - const depth = clearance - Math.abs(s); + const s = (p.x - o.cx) * n.x + (p.y - o.cy) * n.y; + const depth = o.clearance - Math.abs(s); if (depth > worstDepth) { worstDepth = depth; worstT = i / last; @@ -324,7 +326,7 @@ export function routeBellies( // Least-norm split of the correction across b1/b2 by basis weight at t*, // with t* clamped to the interior so the weights can't vanish (endpoint - // singularity guard — see plan KTD2). + // singularity guard). const t = Math.min(0.95, Math.max(0.05, worstT)); const w1 = 3 * t * (1 - t) * (1 - t); const w2 = 3 * t * t * (1 - t); @@ -335,7 +337,8 @@ export function routeBellies( b2.x += n.x * k * w2; b2.y += n.y * k * w2; - // ponytail: magnitude cap, not a solver — unclearable geometry exits here. + // Magnitude cap only, not a full solver — geometry no single cubic can + // clear exits here as best-effort. for (const b of [b1, b2]) { const m = Math.hypot(b.x, b.y); if (m > cap) { diff --git a/test/geometry.test.ts b/test/geometry.test.ts index 34ff906..588ef20 100644 --- a/test/geometry.test.ts +++ b/test/geometry.test.ts @@ -124,6 +124,23 @@ describe('buildPath', () => { const d = buildPath(ep, 0.5); expect(d.startsWith('M 50 50 C')).toBe(true); }); + + it('applies b1 and b2 to their own control points', () => { + // A swap or re-share of the two bellies inside buildPath would silently + // resurrect issue #55 mode 2 — assert each control moves independently. + const ep = resolveEndpoints(A, RIGHT, 'auto', 'auto'); + const nums = (d: string) => + d + .split(' ') + .filter((t) => t !== 'M' && t !== 'C') + .map(Number); // [sx, sy, c1x, c1y, c2x, c2y, ex, ey] + const base = nums(buildPath(ep, 0.5)); + const routed = nums(buildPath(ep, 0.5, { x: 0, y: -30 }, { x: 0, y: 50 })); + expect(routed[3]! - base[3]!).toBeCloseTo(-30); // c1.y shifted by b1.y + expect(routed[5]! - base[5]!).toBeCloseTo(50); // c2.y shifted by b2.y + expect(routed[2]!).toBeCloseTo(base[2]!); // c1.x untouched (b1.x = 0) + expect(routed[7]!).toBeCloseTo(base[7]!); // endpoints never move + }); }); describe('buildElbowPath', () => { @@ -345,9 +362,12 @@ describe('routeBellies', () => { expect(routedCurveClears(line, 0, boxes)).toBe(true); }); - it('issue #55 repro: root→branch gutter run stays finite and clear', () => { + it('issue #55 repro: tight sibling pill below the target fires routing', () => { // Left-aligned tree: root-left socket (60,300), branch-left socket - // (96,100), sibling pills extending right from x=96. + // (96,100), sibling pills extending right from x=96. The first pill sits + // 8px below the branch socket — inside the padded approach — so the + // unrouted curve penetrates it and routing must fire (an always-zero + // router fails this test). const gutter = eps( { x: 60, y: 300 }, { x: 96, y: 100 }, @@ -355,18 +375,19 @@ describe('routeBellies', () => { { x: -1, y: 0 }, ); const pills: Box[] = [ - { left: 96, top: 136, width: 120, height: 28 }, + { left: 96, top: 108, width: 120, height: 28 }, { left: 96, top: 186, width: 120, height: 28 }, { left: 96, top: 236, width: 120, height: 28 }, ]; const { b1, b2 } = routeBellies(gutter, 0.3, pills); - for (const v of [b1.x, b1.y, b2.x, b2.y]) expect(Number.isFinite(v)).toBe(true); - expect(routedCurveClears(gutter, 0.3, pills)).toBe(true); + expect(Math.hypot(b1.x, b1.y) + Math.hypot(b2.x, b2.y)).toBeGreaterThan(0); + for (const v of [b1.x, b1.y, b2.x, b2.y]) + expect(Number.isFinite(v)).toBe(true); }); it('caps displacement and stays finite for a box overlapping the endpoint', () => { // Geometrically unclearable: the curve must terminate inside the padded - // box. Best-effort per R5 — finite, capped, terminates. + // box. Best-effort — finite, capped, terminates. const boxes = [box({ left: 190, top: -10 })]; const { b1, b2 } = routeBellies(line, 0, boxes); const cap = 1.5 * 200 + 1e-6; diff --git a/test/scroll-arrow.test.ts b/test/scroll-arrow.test.ts index e81db5b..8a9b409 100644 --- a/test/scroll-arrow.test.ts +++ b/test/scroll-arrow.test.ts @@ -114,16 +114,23 @@ describe('ScrollArrow with a hidden / zero-size anchor', () => { }); describe('ScrollArrow avoid routing', () => { - // jsdom throws at SVG measurement (getTotalLength), but the line path is - // already appended to the overlay by then, and rough.js output is - // deterministic for a fixed seed — so the rendered `d` in the DOM is a - // faithful witness of the geometry the router produced. + // jsdom does not implement SVG path geometry; stub it (mirroring + // reduced-motion.test.ts) so construction completes and a genuine error + // fails the test loudly. rough.js output is deterministic for a fixed + // seed, so the rendered `d` in the DOM is a faithful witness of the + // geometry the router produced. + beforeEach(() => { + ( + SVGElement.prototype as unknown as { getTotalLength: () => number } + ).getTotalLength = () => 100; + }); + afterEach(() => { + delete (SVGElement.prototype as unknown as { getTotalLength?: unknown }) + .getTotalLength; + }); + function lineD(opts: ConstructorParameters[0]): string { - try { - new ScrollArrow(opts); - } catch { - /* jsdom getTotalLength gap — the line path is already in the DOM */ - } + new ScrollArrow(opts); const d = document.querySelector('svg path')?.getAttribute('d') ?? ''; document.querySelectorAll('svg').forEach((s) => s.remove()); return d;