Skip to content

perf(topology): speed up traced tab generation 3.5–5.6× - #692

Merged
adrianschmidt merged 2 commits into
mainfrom
perf/traced-tab-reduce
Nov 1, 2026
Merged

adrianschmidt merged 2 commits into
mainfrom
perf/traced-tab-reduce

Conversation

@adrianschmidt-bot

@adrianschmidt-bot adrianschmidt-bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #686.

What

The traced cut styles spent most of their generation time inside bezier-js. This adds src/puzzle/topology/bezier-js-port.ts, which ports the hot bezier-js 6.1.4 code paths without changing any results. One commit per part:

  1. reduce(): bezier-js builds several throwaway Bezier objects per 0.01 step just to ask simple() of a trial split. The port runs that check on raw control points with the same float operations in the same order. Returned parts still come from the real split(), and the original's range-dropping early return is kept, since completeReduction repairs it.
  2. curveintersects/pairiteration: bezier-js recomputes bbox() 2–3 times per sub-curve, and again on every call for the cached reduced parts. The port computes each subdivision's bbox once and passes it down, and caches the reduced parts' bboxes in a WeakMap. Pair order and dedup are unchanged.

bboxoverlap is copied rather than taken from Bezier.getUtils(), because it decides which pairs pairiteration recurses into. Taking it from the library would mix a future bezier-js's overlap test with 6.1.4's iteration.

Geometry is unchanged

  • Both digest tripwires (traced-geometry-digest, dcel-broad-phase-equivalence) pass unmodified.
  • I also checked 30 extra configs (5 traced styles × 3 sizes at 3:2 and 3:1 × 2 seeds) before and after, with identical piece-path digests.
  • bezier-js-port.test.ts compares both ports with bezier-js using exact Object.is equality on every point and _t1/_t2, across edge cases and 200 random cubics. For intersection it compares 100 random curve pairs, including repeat calls that hit the cache, plus a pair whose bounding boxes touch exactly. Deliberately breaking the port (sqrt → hypot, a different lerp formula, swapped pair order, >= → > in bboxOverlap) makes these tests fail.
  • The random sets catch logic errors within the first few dozen inputs but almost never last-bit float drift, which the edge cases catch. More random inputs would only cost CI time.

Timings

At 192 pieces (16×12, 1200×800), median of 3 seeds, Node 22 on an M-series Mac:

Style Before reduce port + bbox reuse Speedup
Triangles 6.09 s 1.75 s 1.08 s 5.6×
Wavy 1.67 s 0.54 s 0.41 s 4.1×
Wavy, borderless 1.97 s 0.62 s 0.48 s 4.2×
Classic (traced) 0.67 s 0.21 s 0.19 s 3.5×

Not measured: generation through the worker in a browser, or on phones.

Test plan

  • npm test on Node 22 (.nvmrc): 3,646 passed
  • npm run lint, tsc
  • Spot-check a traced new game in the preview deploy

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown

🧩 Preview deployed: https://puzzlewise.github.io/dev/

@adrianschmidt-bot

Copy link
Copy Markdown
Collaborator Author

Consolidated PR Review

PR Summary

PR #692 (perf(topology): speed up traced tab generation 3.5–5.6×, closes #686) adds src/puzzle/topology/bezier-js-port.ts, a bit-identical port of bezier-js 6.1.4's reduce() and curveintersects/pairiteration that evaluates trial splits on raw control points and computes/caches each sub-curve's bbox once, and routes completeReduction and Curve.intersect through it. completeReduction gains an optional parts parameter, replacing the test-only curve.reduce monkey-patch. 5 files changed, +319/−16; the two digest tripwires pass unmodified and a new equivalence test compares the port against the library with exact Object.is on every point.

Review tier: full — 335 changed lines across 5 files after noise filtering, so all six dimension reviewers ran.

Merge Readiness -- READY TO MERGE ✅

The PR is strictly better than main: the same geometry (verified by reviewers re-running the port test, both digest tripwires and the curve/complete-reduction tests on Node 22 at head 73afa87, 89/89 passing) at a fraction of the generation time, with no new inputs, dependencies, logging gaps or failure paths. No [High] or [Medium] findings survived; no (design) findings were raised.

  • Blockers: None
  • Non-blocking but worth addressing: two small [Low] follow-ups in Top Recommendations (a test-assertion form that hides failure values, and a missing entry in CLAUDE.md's geometry-contract module list).

1. Backward Compatibility -- SAFE ✅

The compatibility surface is the geometry reproducibility contract (share links and seeds must regenerate identical piece paths); the PR preserves it, and the code-level API change is additive and internal. No significant issues found.

  • The port was compared line by line against the installed bezier-js 6.1.4 (node_modules/bezier-js/src/utils.js pairiteration, bezier.js curveintersects/overlaps, split/hull/simple/normal/utils.map/utils.angle/utils.lerp): float operations and order match, pair order and dedup are unchanged, the "tA/tB" template literal yields the same number-to-string output, and the original's range-dropping return [] maps to continue nextSegment, which also skips the tail push. The lockfile resolves 6.1.4, so the port and the library calls it still makes come from the same version.
  • completeReduction(curve, parts = reduce(curve)) only gains an optional parameter; its one production caller (curve.ts:94) is unchanged. completeReduction no longer calls curve.reduce(), so stubbing it would be a no-op; I confirmed no remaining test in src assigns or spies on .reduce.
  • The WeakMap bbox cache assumes points are never mutated in place; nothing in src writes to .points or calls Bezier.update(), and subCurve builds new Bezier objects.
  • Positives: Copying bboxOverlap with the 6.1.4 iteration means a future bezier-js 6.x bump cannot silently change pair selection inside the port, while the digest tripwires still catch drift in the library code it calls.

2. Code Quality -- GOOD ✅

The port is small, well-named and faithful to bezier-js 6.1.4, and the new parts parameter is a cleaner test seam than the removed monkey-patch. Two low-value test observations, nothing blocking.

  • [Low] bezier-js-port.test.ts:14,16 (expectIdenticalParts) asserts expect(Object.is(a, b) && Object.is(c, d)).toBe(true), so a failure prints only "expected false to be true" without the field, index or values that differed. Vitest's toBe already compares with Object.is (so -0 vs 0 and last-ULP differences still fail), meaning expect(part._t1).toBe(want._t1) / expect(p.x).toBe(want.points[j].x) are equally strict and print both values. Fixing it buys: when a bezier-js upgrade or a port edit breaks equality somewhere inside the 2,000-cubic loop, the developer sees the diverging values instead of adding logging to find them. Mechanical change.
  • [Low] bezier-js-port.ts:170, the else if (t2 === 1) branch of isSimpleSplit, is never reached by the tests: the reviewer counted 0 hits on the test inputs and across 200,000 random cubics, because t2 is built by repeated += 0.01 and never lands exactly on 1.0; mutating the branch to take the left half left all 11 tests passing. The branch is correct by inspection (it mirrors bezier-js's split(t1).right shortcut) and keeping it is the faithful choice, so there is no code change to make; this only qualifies the PR's statement that deliberately breaking the port fails the tests, which holds for the paths the tests reach. Covering it would require exporting isSimpleSplit for a direct test, which is not worth it for an effectively dead branch.
  • Positives: The port keeps bezier-js's float operations exactly while remaining readable (halve, isSimple, continue nextSegment), and the equivalence tests guard against passing vacuously (pairsWithHits > 100) and exercise the cached second call.

3. Architecture -- GOOD ✅

Dependency direction is clean (the port imports only types from bezier-js; complete-reduction.ts and curve.ts import the port), the port mirrors the library's signatures, and every piece of copied library knowledge is guarded by in-repo tests, so a bezier-js bump that changes a copied routine fails here before it reaches users. No (design) findings.

  • Ownership was checked for each piece of knowledge the port reads or copies: the reduce/split/simple/pairiteration/bboxoverlap logic (owner: bezier-js, copied deliberately and pinned by bezier-js-port.test.ts plus both digest tripwires); the Bezier.bbox() mid/size fields behind the Box cast (always filled by getminmax, and bezier-js's own bboxoverlap depends on them); _t1/_t2/_3d/order/points (all public in @types/bezier-js 4.1.3); and the "tA/tB" string pairs, now produced and parsed in-repo (bezier-js-port.ts:111, curve.ts:349-354). Keeping the string form lets the port test compare against bezier-js with toEqual and keeps the indexOf dedup bit-identical.
  • [Low] CLAUDE.md:32 lists the modules where the geometry contract forbids "simplify the math" refactors (apply-tabs.ts, dcel.ts, curve-clamp.ts); verified at head. bezier-js-port.ts is now the module most exposed to exactly that edit (sqrt(a*a+b*b) → Math.hypot, a different lerp form) and is not on the list. The port test and tripwires would reject such an edit, so fixing this only buys the next developer a heads-up before spending time on a change the tests will refuse. (complete-reduction.ts and curve.ts were already absent from the list before this PR.)
  • Positives:
    • Replacing the test-only curve.reduce monkey-patch with the parts parameter on completeReduction is a cleaner seam with the production caller unchanged.
    • Copying bboxOverlap rather than taking it from Bezier.getUtils() keeps the recursion decision and the iteration it drives at the same library version, and the PR says why.

4. Security -- SAFE ✅

Pure numeric geometry code with no new inputs, I/O, network, storage, serialization, dependencies or privilege boundaries. No significant issues found.

  • The only callers are completeReduction and Curve.intersect, both fed by seeded puzzle generation as on the target branch; no user-controlled string reaches the port.
  • Termination: the reduce loops mirror bezier-js exactly (every path advances t1, bails via continue nextSegment, or runs t2 past 1 + step); pairIteration recursion terminates on the same bbox-size threshold as upstream; the non-cubic/3D case defers to the library.
  • Memory: the WeakMap cache is collected with its curves; the module-level Float64Array scratch buffers are fixed-size and used synchronously.
  • Licensing: the copied code carries bezier-js's MIT notice (bezier-js-port.ts:7-8), and no new packages are added.
  • Positives: Bit-identical output pinned by Object.is tests and the digest tripwires means swapping library internals for local copies opens no silent-divergence path.

5. Observability -- SAFE ✅

Nothing in the PR logs, catches or wraps errors, and neither did the library code it replaces; the topology math modules have no logging on the target branch and the only signals they emit (thrown Errors in curve.ts) are untouched. No significant issues found.

  • The port adds no new failure points: degenerate inputs give NaN/[] exactly as bezier-js 6.1.4 does, and the tests pin that.
  • The speedup can be checked in production without new instrumentation: the generationMs field on the new-game-started Umami event (src/app/new-game-payload.ts:42, src/analytics/game-context.ts:48) already records real-device generation time, which covers the PR's unmeasured browser-worker and phone cases.
  • Positives: The port is pure and stateless apart from a WeakMap cache, so it adds no runtime behavior that needs watching.

6. Performance -- GOOD ✅

The ports remove the hot allocations and repeated bbox() work the issue profiled without adding costs on the hot path. No significant issues found.

  • bboxCache is keyed only on the reduced parts that Curve._reducedSegments already keeps alive; the transient split(0.5) children in pairIteration are not cached, so the cache cannot grow without bound.
  • pairIteration computes 4 child bboxes per level versus upstream's 2 + 8; pair order and the O(n²) indexOf dedup are unchanged, and the result lists are tiny.
  • reduce reuses the module-level scratch buffers (safe: synchronous, non-reentrant) and allocates one Float64Array per extrema segment, replacing ~4 Bezier objects per 0.01 step.
  • Checked and deliberately not raised: destructuring a Float64Array in isSimple costs ~4 ns/call (~10 ms of ~1 s for Triangles-192, ~1%); cachedBox(left) is a WeakMap hit inside the inner loop rather than hoisted, which costs nothing measurable.
  • Positives: The changes target the profiled hotspot directly, stay allocation-light, and use an identity-keyed WeakMap that is correct by construction.

Overall Verdicts

Dimension Verdict
Backward Compatibility SAFE ✅
Code Quality GOOD ✅
Architecture GOOD ✅
Security SAFE ✅
Observability SAFE ✅
Performance GOOD ✅
Merge Readiness READY TO MERGE ✅

Top Recommendations

No [High] or [Medium] issues were found. Two small, concrete [Low] follow-ups:

  1. [Low from Code Quality] In bezier-js-port.test.ts:14,16, replace each expect(Object.is(a, b) && Object.is(c, d)).toBe(true) with separate expect(a).toBe(b) / expect(c).toBe(d) assertions. Same strictness (toBe is Object.is), but a failure then prints the differing values and index.
  2. [Low from Architecture] Add bezier-js-port.ts to the geometry-contract module list at CLAUDE.md:32, so the next developer is warned before attempting a float-math "simplification" the port test and tripwires will reject.

@adrianschmidt-bot

Copy link
Copy Markdown
Collaborator Author

Responses to the consolidated review:

1. expectIdenticalParts assertion form: ⚡️ 4a5842b. Each field is now asserted with toBe, labeled with its part and point index. The check is just as exact, and a failure now shows the differing values.

2. Unreached t2 === 1 branch in isSimpleSplit: no change. As the review says, the branch mirrors bezier-js's split(t1).right shortcut. Nothing proves t2 can never land exactly on 1, so removing or altering the branch would risk changing output. The PR description doesn't claim more than the tests show: it lists the three mutations that were tried, and each fails the tests.

3. Add bezier-js-port.ts to the module list at CLAUDE.md:32: declined.

  • That list is part of the comment policy. It names the modules where comments explaining how the code works are allowed, because the geometry contract rules out the clearer rewrite. The port doesn't depend on that exception.
  • The list doesn't cover every module the digests guard (curve.ts and complete-reduction.ts aren't on it either).
  • The warning is already where someone editing the port will see it: the module header says every float operation mirrors the original in value and order.
  • A Math.hypot-style rewrite fails the port test, which was confirmed by trying it.

🤖 Generated with Claude Code

adrianschmidt
adrianschmidt previously approved these changes Nov 1, 2026
@adrianschmidt-bot

Copy link
Copy Markdown
Collaborator Author

Consolidated PR Review

PR Summary

PR #692 (perf(topology): speed up traced tab generation 3.5–5.6×, closes #686) adds src/puzzle/topology/bezier-js-port.ts, a bit-identical port of bezier-js 6.1.4's reduce() and curveintersects/pairiteration that evaluates trial splits on raw control points and computes each sub-curve's bbox once (caching the reduced parts' bboxes in a WeakMap), and routes completeReduction and Curve.intersect through it. completeReduction gains an optional parts parameter that replaces the test-only curve.reduce monkey-patch. 5 files changed, +321/−16. This is a re-review at head 9064e79: since the previously reviewed 73afa87, production code is unchanged and the only edits are in bezier-js-port.test.ts (labeled per-field toBe assertions, as the prior round asked, and random-sample counts cut from 2,000/300 to 200/100 to fit CI's 5 s vitest timeout).

Review tier: full — 337 changed lines across 5 files after noise filtering, so all six dimension reviewers ran.

Merge Readiness -- READY TO MERGE ✅

The PR is strictly better than main: the same geometry (the port test, both digest tripwires and the curve/complete-reduction tests pass at head under Node 22.23.4, re-run by three reviewers) at a fraction of the generation time, with no new inputs, dependencies, logging gaps or failure paths. No [High] or [Medium] findings survived; no (design) findings were raised.

  • Blockers: None
  • Non-blocking but worth addressing: two [Low] follow-ups in Top Recommendations (a stale "2,000 cubics / 300 pairs" figure in the squash-target commit message and PR description, and a missing boundary case in the curveIntersects test).

1. Backward Compatibility -- SAFE ✅

The compatibility surface is the geometry reproducibility contract (seeds and share links must regenerate identical piece paths); the PR preserves it, and its one API change is additive and internal. No significant issues found.

  • The stale sample-size figures in the PR description (2,000 cubics / 300 pairs vs. the test's 200 / 100) were raised here too; consolidated under Code Quality, which also covers the commit message that survives the autosquash.
  • Checked and not raised: completeReduction(curve, parts = reduce(curve)) only adds an optional parameter; its one production caller (curve.ts:94) is unchanged, and no remaining code in src assigns to or stubs .reduce. The new exports (reduce, curveIntersects) are consumed only inside src/puzzle/topology. The lockfile resolves bezier-js 6.1.4 (package-lock.json:4933), the version the port mirrors, so the port and the library methods it still calls (split, extrema, bbox) come from the same release.
  • Positives: Bit-identical output is pinned by exact toBe (Object.is) assertions and by the unmodified digest tripwires, so existing seeds and share links regenerate the same puzzles.

2. Code Quality -- GOOD ✅

The prior round's expectIdenticalParts follow-up is done the right way (per-field toBe, same Object.is strictness, labeled with part and point index). The two fixups that cut random-input counts rest on measured detection data: the reviewer re-checked that Math.hypot for sqrt still fails straightLine/collapsedHandles, the alternative lerp form fails droppedTail/sharpReversal/straightLine, and a swapped pair order, an ||-for-&& threshold edit and a one-sided simple() sign check all fail at 200 cubics / 100 pairs. Two low-severity follow-ups.

  • [Low] The commit message of 4e1e578 (the squash target for fixup 9914d73) and the PR description still say "2,000 random cubics", and the description also says "300 random curve pairs"; the test now uses 200 and 100 (bezier-js-port.test.ts:47,69, verified at head). block-autosquash-commits.yml requires the fixups to be autosquashed before merge, which drops their messages, so the permanent history keeps the 2,000 figure and loses 9914d73's explanation of why 200 suffices. Fix: reword 4e1e578 during the autosquash (an amend! commit or git rebase -i reword) and edit the PR description. What it buys: the history and PR state what the test actually checks. Small, and a minute's work. (Also raised by Backward Compatibility.)
  • [Low] The curveIntersects equivalence test (bezier-js-port.test.ts:63-79) never reaches the boundary of bboxOverlap (bezier-js-port.ts:150): its inputs are random cubics in a 0–100 box, so no two bounding boxes ever touch exactly. Verified: mutating >= d to > d leaves all 11 port tests passing; only the triangles r6 traced v2 digest tripwire catches it. Boxes that touch exactly are common in real puzzles (straight border edges have a zero-height box and cut curves end on them): a horizontal cubic (0,50)…(100,50) paired with a curve starting at (50,50) yields [] from both bezier-js and the port but a hit from the mutant. The PR copies bboxoverlap precisely because it "decides which pairs pairiteration recurses into", and the port's own test does not pin that decision. Fix: add one or two touching/axis-aligned pairs like the probe above to the curveIntersects test. What it buys: an edit to bboxOverlap fails a test that names the cause instead of a digest mismatch the next developer has to bisect. Low because the tripwire already guards it. (Two further mutations survive every test including the tripwires, < → <= on the size threshold and removing pairIteration's dedup; not raised, since the first needs an exact float tie and the second is undone by Curve.intersect's first-hit-wins dedup.)
  • Positives: The assertion follow-up keeps exact equality while printing the diverging values, and the sample-size cuts were justified by mutation data rather than guesswork.

3. Architecture -- GOOD ✅

Dependency direction is clean (the port imports only types from bezier-js; complete-reduction.ts and curve.ts import the port), and the port's hybrid shape (library extrema()/split()/bbox() for every returned Bezier, in-repo copies only for the trial simple() check and the bboxoverlap/pairiteration recursion decision) is deliberate and explained in the PR. No (design) findings.

  • Ownership re-checked line by line against the installed bezier-js 6.1.4 (bezier.js, utils.js): reduce, the split(t1, t2) shortcut precedence, hull, simple (via derive and __normal2), utils.map/angle/lerp, pairiteration (including the empty-pairs path, which upstream returns unfiltered and the port filters, with the same [] result) and bboxoverlap match in operations and order. The Box cast reads getminmax output whose mid/size are always filled for 2D; a bezier-js change there would fail the port test and tripwires rather than drift silently. _t1/_t2/_3d/order/points are public in @types/bezier-js 4.1.3. The "tA/tB" string pairs are now produced (bezier-js-port.ts:111) and parsed (curve.ts:349-354) in-repo; keeping the string form lets the port test compare against the library with toEqual and keeps the indexOf dedup identical.
  • The module-level bboxCache is a second place (after Curve.reducedSegment's caches) that rests on Bezier.points never being mutated after construction. Nothing in src writes to .points, calls Bezier.update() or assigns point coordinates on bezier-js objects; subCurve and split build fresh objects. Not raised: nothing fixes it short of making points immutable, and that buys nothing today.
  • Checked and not raised: the parts = reduce(curve) default on completeReduction is a test seam on a production signature, but the module's purpose is repairing reduce()'s output, so owning the call is cohesive and the seam replaces a monkey-patch. The prior round's CLAUDE.md:32 suggestion was declined with reasoning that holds (the list is part of the comment policy, not a registry of digest-guarded modules).
  • Positives: The hot-path copies are the minimum the speedup needs, and every returned Bezier still comes from the library, so the surface a future bezier-js bump can move stays guarded by the existing tripwires rather than by the port alone.

4. Security -- SAFE ✅

Pure numeric geometry with no new inputs, I/O, network, storage, serialization, dependencies or privilege boundaries. No significant issues found.

  • Input surface is unchanged: the port's only callers are completeReduction (complete-reduction.ts:41) and Curve.intersect (curve.ts:346), reached from dcel.ts:167 and apply-tabs.ts:151,227 on seeded generation, exactly as on main. No user-controlled string reaches the port.
  • Termination: reduce mirrors upstream (bezier.js:572-630); every path advances t1 by at least one step, exits via continue nextSegment, or runs t2 past 1 + step. NaN control points make every trial non-simple, so the segment is dropped on the first step, as upstream. pairIteration recurses on the same bbox-size threshold and the same copied bboxoverlap, so degenerate (NaN/Infinity) inputs behave identically to the target branch.
  • Shared state: the module-level scratch buffers are only written and read synchronously inside reduce, and each worker gets its own module instance. The WeakMap cache holds only parts Curve._reducedSegments already retains.
  • Supply chain: no new packages; bezier-js stays ^6.1.4, lockfile-resolved with an integrity hash; the copied code keeps the MIT notice (bezier-js-port.ts:7-8).
  • Positives: Bit-identical output pinned by Object.is equivalence tests and unmodified digest tripwires means replacing library internals with local copies opens no silent-divergence path.

5. Observability -- SAFE ✅

Nothing in the PR logs, catches or wraps errors, and neither did the library code it replaces; the topology math modules have no logging on the target branch. No significant issues found.

  • The changed production code adds no catch, wrap or swallow sites; the two throw new Error calls in curve.ts:49,109 are untouched. Degenerate inputs still give NaN/[] exactly as bezier-js 6.1.4 does, so there is no new silent-failure path.
  • A failing equivalence check now prints the part, point index, field and both values (bezier-js-port.test.ts:10-21), at the same Object.is strictness.
  • Real-device generation time, which the PR says it did not measure, is already recorded by the generationMs field on the new-game-started Umami event, so the speedup can be confirmed in production without new instrumentation.
  • Positives: The port is pure apart from an identity-keyed WeakMap cache, so it adds no runtime behavior anyone needs to watch.

6. Performance -- GOOD ✅

The port goes straight at the profiled hotspot: it drops the ~4 throwaway Bezier objects per 0.01 step in reduce() and the repeated bbox() calls in pairiteration, and adds no new cost on the hot path. No significant issues found.

  • bboxCache lifetime: its only keys are the reduced parts that Curve._reducedSegments holds; the transient split(0.5) children in pairIteration go through uncached bbox(). Each tab candidate is a new Curve, so its entries become collectable with it, and the cache cannot grow without bound.
  • Each pairIteration level computes 4 child bboxes where upstream computes about 10. Pair order and the quadratic indexOf dedup are unchanged, and the result lists hold a handful of strings.
  • reduce allocates one Float64Array per extrema segment and reuses the module-level trialRight/trialSplit scratch buffers; safe because the code is synchronous and never re-enters itself.
  • The labeled assertions build a string per toBe (~200 cubics × a few parts × 10 fields): milliseconds in a test file, no production effect.
  • Positives: The changes stay allocation-light on the inner loop, and the identity-keyed WeakMap cache is correct by construction.

Overall Verdicts

Dimension Verdict
Backward Compatibility SAFE ✅
Code Quality GOOD ✅
Architecture GOOD ✅
Security SAFE ✅
Observability SAFE ✅
Performance GOOD ✅
Merge Readiness READY TO MERGE ✅

Top Recommendations

No [High] or [Medium] issues were found. Two concrete [Low] follow-ups:

  1. [Low from Code Quality] Add one or two touching/axis-aligned curve pairs to the curveIntersects test in bezier-js-port.test.ts:63-79 (for example the horizontal cubic (0,50)…(100,50) against a cubic starting at (50,50), expecting []), so that an edit to bboxOverlap's >= d boundary (bezier-js-port.ts:150) fails a test that names the cause rather than only the triangles r6 traced v2 digest tripwire.
  2. [Low from Code Quality] When autosquashing, reword 4e1e578 (and edit the PR description) to say 200 random cubics and 100 curve pairs instead of 2,000 and 300, carrying over 9914d73's reason for the smaller set, so the permanent history matches what bezier-js-port.test.ts:47,69 checks.

adrianschmidt-bot and others added 2 commits November 1, 2026 14:10
Traced cut styles spent 60-68% of generation in bezier-js's reduce():
for every 0.01 step it builds several Bezier objects from a trial
split only to ask simple() of the result. Tab candidates are fresh
curves, so the per-curve reduction cache never hits.

Port reduce() for 2D cubics and run the trial simple() check on raw
control points with the same float operations in the same order.
Returned parts still come from the real split(), and the original's
range-dropping early return is kept for completeReduction to repair,
so generated geometry is bit-identical. A new test compares the port
with bezier-js exactly (Object.is) over edge cases and 200 random
cubics; both digest tripwires are unchanged. Random cubics catch logic
errors within the first few dozen but almost never last-ULP drift,
which the edge cases catch, so a larger set would only cost CI time.

At 192 pieces (16x12, Node 22): Triangles 6.1 s -> 1.75 s, Wavy
1.67 s -> 0.54 s, Classic (traced) 0.67 s -> 0.21 s.

Refs #686

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bezier-js's curveintersects/pairiteration recompute bbox() (extrema
plus root finding) 2-3 times per sub-curve, and again on every call
for the cached reduced parts Curve.intersect hands it.

Port both: each subdivision's bbox is computed once and passed down,
and the reduced parts' bboxes are cached in a WeakMap across calls.
Float operations, pair order and dedup are unchanged, so results are
identical; a new test compares the port with bezier-js, including on
repeat calls that hit the cache, and both digest tripwires are
unchanged.

At 192 pieces (16x12, Node 22), on top of the reduce() port:
Triangles 1.75 s -> 1.08 s, Wavy 0.54 s -> 0.41 s, Classic (traced)
0.21 s -> 0.19 s.

Refs #686

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adrianschmidt-bot

Copy link
Copy Markdown
Collaborator Author

Responses to the re-review:

1. Stale "2,000 cubics / 300 pairs": done. While squashing, 16520b7 was reworded to say 200 random cubics and why a larger set wouldn't add coverage. The PR description now says 200 cubics and 100 pairs.

2. bboxOverlap's touching boundary was untested: done in d25959c, using the flat cubic (0,50)…(100,50) against a curve starting at (50,50). The test asserts that bezier-js returns [] and that the port matches; with >= changed to > it fails. Two corrections to the review's reasoning:

  • Straight border edges never reach curveIntersects, because Curve.intersect sends straight segments down its line-intersection path.
  • Two curves meeting at a shared vertex don't produce an exact touch either, because float rounding of the bboxes breaks the tie.

So the probe is a constructed exact tie. The digest catching the > mutation shows that such ties do still happen in real generation.

The fixups are squashed; the branch is two commits on fec25cb7, with the final code unchanged by the squash.

🤖 Generated with Claude Code

@adrianschmidt
adrianschmidt merged commit b6168b0 into main Nov 1, 2026
5 checks passed
@adrianschmidt
adrianschmidt deleted the perf/traced-tab-reduce branch November 1, 2026 13:30
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.

Speed up traced tab generation: bezier-js reduce() dominates

2 participants