You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This is an executable plan written to survive /clear or compaction. A fresh assistant (or maintainer) should be able to pick it up cold from the references below.
The gap you see in the 3D output viewport is the bug this plan fixes. (Pipeline: Compose 10×50mm → Cut rip=4° 4-slice → Arrange with 2 spacers + 2 reorders + 2 rotates → Cut rip=0° 4-slice → Arrange with reorders. The upstream Arrange produces a non-rectangular panel; today's cursor-slide only translates along Z so the X-drift propagates.)
Code reference points:
end-grain/src/state/pipeline.ts — executeArrange (current cursor-slide around line ~460; the function this plan rewrites), anchorPanel (bottom), makeSpacerPanel (~1180), computeCutNormalForArrange (will be deprecated).
end-grain/src/state/edits.ts — editsToggleFlip, editsSetShift, editsReorderSlice, editsRotate90 (this last one is deleted by this plan).
end-grain/src/state/arrangeActions.ts — shared action helpers; rotate90Selection is deleted by this plan along with the UI button.
end-grain/src/ui/ArrangeControls.svelte — "Rotate 90°" button is removed from the action toolbar.
end-grain/OPERATION_HARNESS.md — the harness iteration loop; how to use the focused harness for fast feedback.
Context — the maker's language
A cutting-board maker stands at a bench. They've already cut strips into slices. Now they're pushing the slices back together, one at a time, into the growing assembly. They pick up the next slice, orient it the way they want (flip it end-over-end, nudge it sideways), and press it onto the edge of the assembly until it's flush. Repeat.
That's Arrange. The algorithm's job is to place each piece in the position a maker would push it into, with the orientations the maker asked for.
The bug we started from: reorder a slice in an Arrange whose upstream is non-rectangular, and the assembled panel shows a visible gap (see ?scenario=arrange1-bug URL above). The current cursor-slide only translates each slice along Z, so X-drift from a parallelogram upstream propagates untouched — the reordered slice lands at the right Z but the wrong X, and a gap opens.
The fundamental issue: the cursor-slide model assumes all pieces share a single cut-normal mate axis. That was a useful simplification when every slice came from one Cut. It breaks the moment upstream isn't rectangular. And the fix isn't a smarter version of cursor-slide — it's a different model: just push the pieces together, one rigid translation at a time, and let the maker's edits apply in the placed frame afterwards.
Invariant to establish
Arrange places pieces in sequence by pushing each piece along +Z against the previous piece's exposed face until first contact. Per-piece PlaceEdits (flip, shift) are applied after the push, in the piece's placed position. The algorithm does not apply any pre-push rotation on the maker's behalf. In-plane position within the contact is set by centring outlines when contact is coplanar face-to-face; shift edits translate from that centred baseline.
Post-assembly, anchorPanel still anchors the final panel to the maker's frame (principle 5 unchanged).
Scope — what's removed
Delete per-slice 90° rotation from Arrange. 90° re-orientation is a Cut-level concern (orientation=90). Keeping it in Arrange produces pathological layouts (side-face mates that the maker rarely wants). The UI button, the editsRotate90 helper, the rotate90Selection arrange-action, and the R keyboard shortcut all go.
Non-180° rotate ops on legacy saved designs: treat as no-ops and emit a status: 'warning' with a reason. Don't auto-migrate — surface it so the user can re-do the design with orientation=90 on the upstream Cut.
Enumerate the six principal axis directions in world space. For each, compute the panel's projection along that axis and collect the mesh faces whose world-space outward normal is within ε of that axis. Pick the axis with max dot(axis, direction). Return the winning axis's face polygon (merged across segments), its exact normal, its signed offset, and the polygon's centroid.
Edge cases:
Ties (multiple axes equidistant from direction): shouldn't occur for direction = ±Z on axis-aligned panels; for rotated panels, break ties deterministically (prefer the one with greater surface area, then lexicographic axis order).
Empty panel: return null; caller treats as "no exposed face, nothing to push against."
Unit test: cube → findExposedFace(cube, +Z) returns the +Z face. A parallelogram prism from a rip=15° cut → returns its +Z-most cut face (normal aligned with the cut normal, not axis-aligned).
Step 2: rewrite executeArrange
The new body, in pseudocode:
function executeArrange(feature, ctx):
slices = ctx.lastSlices
if slices.length === 0: return error('arrange without upstream cut')
// 1. Apply reorder edits to establish sequence order.
order = applyReorders(slices, reorderEditsFor(feature.id))
// 2. Build the assembly, piece by piece.
let assembly = null // null means 'bench so far'
for sliceIdx in order:
piece = slices[sliceIdx].clone() // fresh from cut, no edits yet
// 2a. Identify A's exposed face (the surface piece will land on).
// For the first piece, A is the bench plane.
expose = assembly === null
? { normal: [0, 0, 1], offset: 0, centroid: null }
: findExposedFace(assembly, [0, 0, 1])
// 2b. Identify B's incoming face (the surface that will touch A).
incoming = findExposedFace(piece, [-expose.normal])
// 2c. Push B along −A.exposed.normal until faces are coplanar.
const dAlongNormal = expose.offset - incoming.offset
piece = piece.translate(dAlongNormal × (-expose.normal))
// 2d. In-plane centring (only when the push produced coplanar contact).
if antiParallel(incoming.normal, expose.normal) && expose.centroid !== null:
const inPlaneShift = expose.centroid - incoming.centroid (projected into contact plane)
piece = piece.translate(inPlaneShift)
// 2e. Apply this slice's post-placement edits in the placed frame.
for edit in nonReorderEditsFor(feature.id, sliceIdx):
if edit.op.kind === 'rotate' && edit.op.degrees === 180:
// Flip: 180° rotation about Y, pivoting at piece's current bbox centre.
piece = piece.rotateAbout(+Y, π, piece.bboxCentre())
elif edit.op.kind === 'rotate':
// Non-180 rotates: out of scope (see "Scope — what's removed"). Legacy
// saved designs keep running; emit a warning on the result and ignore
// this edit.
result.status = 'warning'; result.statusReason = 'per-slice rotate !== 180° is no longer supported; use Cut.orientation=90 instead'
elif edit.op.kind === 'shift':
// In-plane translation within the contact plane.
const inPlaneAxis = <tangent to contact plane; for rip=0 this is +X; for rip≠0 this is the chord direction>
piece = piece.translate(edit.op.delta × inPlaneAxis)
// 2f. Insert any spacers keyed after this sliceIdx (same push + edit rules).
for spacer after sliceIdx:
spacerPanel = makeSpacerPanel(spacer, piece, expose.normal) // see note below
// Treat spacer as another piece: find its exposed face (on the side facing next),
// recompute expose from the now-assembled piece, etc.
// (For v1, keep makeSpacerPanel's existing construction; its low/high faces
// are axis-aligned with the cut normal so the push mates cleanly.)
… recurse …
// 2g. Concat piece into assembly; it becomes A for the next iteration.
assembly = concat(assembly, piece) OR piece if assembly === null
// 3. Anchor and return.
ctx.lastPanel = anchorPanel(assembly)
Step 3: simplify / delete
editsRotate90 in src/state/edits.ts — delete.
rotate90Selection in src/state/arrangeActions.ts — delete. Update handleArrangeKey to drop the R case.
"Rotate 90°" button in src/ui/ArrangeControls.svelte — delete.
computeCutNormalForArrange in src/state/pipeline.ts — delete if unused after the rewrite.
Pipeline's rotate op handling: keep parsing (legacy-safe), but only 180° is effective; other angles emit a warning.
Step 4: shift edit semantics
Today's shift is translate(delta, 0, 0) — along world X. In the push-them-together model, shift is lateral translation within the contact plane. For rip=0 cuts the contact plane is XY and shift-along-X is unchanged. For rip≠0 the contact plane is tilted; shift should be along the chord direction of the cut (in-plane-X of the tilted plane). Update the pipeline's shift handling to translate along the contact plane's in-plane-X axis. No UI change — the number the maker enters still means "lateral nudge in mm" in the contact plane's frame.
If that's hard to get right on the first pass, it's acceptable to ship step 2 with world-X shift (current behaviour) and follow up with the chord-direction fix in a separate commit. Flag the deferral in the PR body.
Expected test fallout
Run cd end-grain && npx vitest run first to record baseline (should be 206 green after the current HEAD).
Identity path (same-cut slices, no edits): byte-identical output expected. If any identity test breaks, the push implementation has a bug — do not update the expected values; fix the impl.
Flip tests: may differ for rip > 0 parallelogram slices because flip now applies after the push instead of before. For rip=0 rectangular slices the flip is self-symmetric and output is identical. For rip > 0, inspect the snapshot tests in test/snapshot/ visually via npx vitest run -u only after confirming the new result is physically correct — a bookmatched parallelogram pattern should still look bookmatched. Do not accept -u updates without a direct visual check.
90°-rotate tests: delete along with the feature. If any snapshot test was specifically exercising per-slice 90°, remove the test and file a cleanup note.
Spacer tests: should pass unchanged. Spacers go through the same push + centre path.
New regression test: add a pipeline test that reproduces the arrange1-bug scenario programmatically and asserts the assembled panel has no X-gap (bbox.min.x equal across all volumes along the +Z edge, or a simpler: final bbox's max.x == 0 and min.x is a single uniform value).
Verification
cd end-grain && npx vitest run — expect all green after test updates.
http://localhost:5174/end-grain/3d-arrange.html?scenario=arrange1-bug&reorder=0:3,3:2,2:3,3:0,0:1,1:3,3:1 — the gap closes. This is the acceptance test for the refactor.
Click All → Flip in the harness on a rip=15° cut — all slices bookmatch; no visible gaps between slices.
Workbench: reload http://localhost:5174/end-grain/cutting-board-workbench.html?design=untitled (the user's saved design that motivated this work). Both Arrange stages should now render flush. Final panel bbox unchanged in Z/X extents; no gap visible in the 3D viewport.
Screenshot discipline: resize_page to ≤1200×800 before take_screenshot. Prefer take_snapshot (a11y tree) for verifying bbox/result numbers over pixel screenshots.
Out of scope (file as separate issues if they bite)
Cross-cut mating (pieces from different Cuts in one Arrange): today's linear-pipeline invariant (principle 6) keeps every slice sharing a Cut provenance. When/if a future op needs to arrange pieces from multiple sources, revisit.
Edge-on-face / corner-on-face / partial-overlap contacts: the algorithm produces them naturally when the maker's orientations don't line up face-to-face; in-plane centring still applies the fallback but the visual result could be unusual. No affordance beyond flip/shift for explicitly requesting such contacts.
Exposed-face override by the maker (a Cut.exposedFace or Arrange.exposedFaceOverride edit): today "exposed face" is automatically +Z. If the maker ever wants to present a different face of A to the next piece, that becomes a new affordance.
Chord-direction shift (step 4's nuance): may ship later if step 2 is easier with world-X shift first.
align PlaceEdit op (explicit in-plane alignment override): not needed under the push model, since centring is the default and shift is the override. If a use case emerges, file then.
Commit shape
Prefer a single refactor commit if tests allow:
refactor(arrange): replace cursor-slide with push-them-together mating
Arrange now places each piece by pushing it along +Z onto the
previous piece's exposed face until first contact, then applies
flip/shift edits in the placed frame. Replaces the single-cut-
normal cursor-slide that silently broke for non-rectangular
upstreams (see #47's arrange1-bug scenario).
Per-slice 90° rotate removed; 90° re-orientation is Cut.orientation
now.
Prompt: <the user's push-them-together elaboration>
If rotate-90 deletion + test updates get bulky, stage as two commits:
refactor(arrange): push-them-together placement model (new executeArrange + findExposedFace + tests; keep rotate-90 working).
gh issue view 45 — read the principles, especially 3, 5, 6, 8.
gh issue view 46 — skim the anchor plan so you know what's already landed.
gh issue view 47 — this plan.
git checkout -b feat/arrange-push-them-together from the integration branch (check git branch first; most likely master or feat/bench-edge-anchor).
Start the dev server if not running: npx vite --port 5174.
Open the focused harness: http://localhost:5174/end-grain/3d-arrange.html?scenario=arrange1-bug&reorder=0:3,3:2,2:3,3:0,0:1,1:3,3:1. See the gap. That's your acceptance test.
cd end-grain && npx vitest run — record baseline green count.
Implement Step 1 (findExposedFace) with a direct unit test. Commit.
Implement Step 2 (executeArrange rewrite). Run vitest; expect identity-path green, flip-path possibly changed per "Expected test fallout". Commit.
This is an executable plan written to survive
/clearor compaction. A fresh assistant (or maintainer) should be able to pick it up cold from the references below.Pre-reading (~8 min, read before touching code)
upstreamOutputForis a single-chain walk.339b6a5. TheanchorPanelutility and "cursor starts at 0" foundation this plan builds on. Do not re-litigate bench anchoring.http://localhost:5174/end-grain/3d-arrange.html?scenario=arrange1-bug&reorder=0:3,3:2,2:3,3:0,0:1,1:3,3:1src/ArrangeApp.svelte → buildArrange1BugScenario.end-grain/src/state/pipeline.ts—executeArrange(current cursor-slide around line ~460; the function this plan rewrites),anchorPanel(bottom),makeSpacerPanel(~1180),computeCutNormalForArrange(will be deprecated).end-grain/src/domain/Panel.ts—Segmenttype,cut,translate,rotateAbout,projectOnto,extractYExtremePolygon(pattern for face-recovery helpers),boundingBox.end-grain/src/state/edits.ts—editsToggleFlip,editsSetShift,editsReorderSlice,editsRotate90(this last one is deleted by this plan).end-grain/src/state/arrangeActions.ts— shared action helpers;rotate90Selectionis deleted by this plan along with the UI button.end-grain/src/ui/ArrangeControls.svelte— "Rotate 90°" button is removed from the action toolbar.end-grain/OPERATION_HARNESS.md— the harness iteration loop; how to use the focused harness for fast feedback.Context — the maker's language
A cutting-board maker stands at a bench. They've already cut strips into slices. Now they're pushing the slices back together, one at a time, into the growing assembly. They pick up the next slice, orient it the way they want (flip it end-over-end, nudge it sideways), and press it onto the edge of the assembly until it's flush. Repeat.
That's Arrange. The algorithm's job is to place each piece in the position a maker would push it into, with the orientations the maker asked for.
The bug we started from: reorder a slice in an Arrange whose upstream is non-rectangular, and the assembled panel shows a visible gap (see
?scenario=arrange1-bugURL above). The current cursor-slide only translates each slice along Z, so X-drift from a parallelogram upstream propagates untouched — the reordered slice lands at the right Z but the wrong X, and a gap opens.The fundamental issue: the cursor-slide model assumes all pieces share a single cut-normal mate axis. That was a useful simplification when every slice came from one Cut. It breaks the moment upstream isn't rectangular. And the fix isn't a smarter version of cursor-slide — it's a different model: just push the pieces together, one rigid translation at a time, and let the maker's edits apply in the placed frame afterwards.
Invariant to establish
Arrange places pieces in sequence by pushing each piece along +Z against the previous piece's exposed face until first contact. Per-piece PlaceEdits (flip, shift) are applied after the push, in the piece's placed position. The algorithm does not apply any pre-push rotation on the maker's behalf. In-plane position within the contact is set by centring outlines when contact is coplanar face-to-face; shift edits translate from that centred baseline.
Post-assembly,
anchorPanelstill anchors the final panel to the maker's frame (principle 5 unchanged).Scope — what's removed
Delete per-slice 90° rotation from Arrange. 90° re-orientation is a Cut-level concern (
orientation=90). Keeping it in Arrange produces pathological layouts (side-face mates that the maker rarely wants). The UI button, theeditsRotate90helper, therotate90Selectionarrange-action, and theRkeyboard shortcut all go.Non-180°
rotateops on legacy saved designs: treat as no-ops and emit astatus: 'warning'with a reason. Don't auto-migrate — surface it so the user can re-do the design withorientation=90on the upstream Cut.Implementation
Step 1:
findExposedFacehelperAdd to
Panel.ts(nearextractYExtremePolygon):Enumerate the six principal axis directions in world space. For each, compute the panel's projection along that axis and collect the mesh faces whose world-space outward normal is within ε of that axis. Pick the axis with max
dot(axis, direction). Return the winning axis's face polygon (merged across segments), its exact normal, its signed offset, and the polygon's centroid.Edge cases:
direction): shouldn't occur fordirection = ±Zon axis-aligned panels; for rotated panels, break ties deterministically (prefer the one with greater surface area, then lexicographic axis order).Unit test: cube →
findExposedFace(cube, +Z)returns the +Z face. A parallelogram prism from a rip=15° cut → returns its +Z-most cut face (normal aligned with the cut normal, not axis-aligned).Step 2: rewrite
executeArrangeThe new body, in pseudocode:
Step 3: simplify / delete
editsRotate90insrc/state/edits.ts— delete.rotate90Selectioninsrc/state/arrangeActions.ts— delete. UpdatehandleArrangeKeyto drop theRcase.src/ui/ArrangeControls.svelte— delete.computeCutNormalForArrangeinsrc/state/pipeline.ts— delete if unused after the rewrite.rotateop handling: keep parsing (legacy-safe), but only 180° is effective; other angles emit a warning.Step 4: shift edit semantics
Today's
shiftistranslate(delta, 0, 0)— along world X. In the push-them-together model, shift is lateral translation within the contact plane. For rip=0 cuts the contact plane is XY and shift-along-X is unchanged. For rip≠0 the contact plane is tilted; shift should be along the chord direction of the cut (in-plane-X of the tilted plane). Update the pipeline'sshifthandling to translate along the contact plane's in-plane-X axis. No UI change — the number the maker enters still means "lateral nudge in mm" in the contact plane's frame.If that's hard to get right on the first pass, it's acceptable to ship step 2 with world-X shift (current behaviour) and follow up with the chord-direction fix in a separate commit. Flag the deferral in the PR body.
Expected test fallout
Run
cd end-grain && npx vitest runfirst to record baseline (should be 206 green after the current HEAD).Identity path (same-cut slices, no edits): byte-identical output expected. If any identity test breaks, the push implementation has a bug — do not update the expected values; fix the impl.
Flip tests: may differ for rip > 0 parallelogram slices because flip now applies after the push instead of before. For rip=0 rectangular slices the flip is self-symmetric and output is identical. For rip > 0, inspect the snapshot tests in
test/snapshot/visually vianpx vitest run -uonly after confirming the new result is physically correct — a bookmatched parallelogram pattern should still look bookmatched. Do not accept-uupdates without a direct visual check.90°-rotate tests: delete along with the feature. If any snapshot test was specifically exercising per-slice 90°, remove the test and file a cleanup note.
Spacer tests: should pass unchanged. Spacers go through the same push + centre path.
New regression test: add a pipeline test that reproduces the
arrange1-bugscenario programmatically and asserts the assembled panel has no X-gap (bbox.min.x equal across all volumes along the +Z edge, or a simpler: final bbox's max.x == 0 and min.x is a single uniform value).Verification
cd end-grain && npx vitest run— expect all green after test updates.http://localhost:5174/end-grain/3d-arrange.html(default scenario) — identity arrange renders unchanged.http://localhost:5174/end-grain/3d-arrange.html?scenario=arrange1-bug&reorder=0:3,3:2,2:3,3:0,0:1,1:3,3:1— the gap closes. This is the acceptance test for the refactor.http://localhost:5174/end-grain/3d-arrange.html?rip=15identity — parallelogram assembly, unchanged overall outline.http://localhost:5174/end-grain/cutting-board-workbench.html?design=untitled(the user's saved design that motivated this work). Both Arrange stages should now render flush. Final panel bbox unchanged in Z/X extents; no gap visible in the 3D viewport.resize_pageto ≤1200×800 beforetake_screenshot. Prefertake_snapshot(a11y tree) for verifying bbox/result numbers over pixel screenshots.Out of scope (file as separate issues if they bite)
+Z. If the maker ever wants to present a different face of A to the next piece, that becomes a new affordance.alignPlaceEdit op (explicit in-plane alignment override): not needed under the push model, since centring is the default and shift is the override. If a use case emerges, file then.Commit shape
Prefer a single refactor commit if tests allow:
If rotate-90 deletion + test updates get bulky, stage as two commits:
refactor(arrange): push-them-together placement model(new executeArrange + findExposedFace + tests; keep rotate-90 working).refactor(arrange): remove per-slice 90° rotate(delete feature + UI + tests).How to start (fresh session)
gh issue view 45— read the principles, especially 3, 5, 6, 8.gh issue view 46— skim the anchor plan so you know what's already landed.gh issue view 47— this plan.git checkout -b feat/arrange-push-them-togetherfrom the integration branch (checkgit branchfirst; most likelymasterorfeat/bench-edge-anchor).npx vite --port 5174.http://localhost:5174/end-grain/3d-arrange.html?scenario=arrange1-bug&reorder=0:3,3:2,2:3,3:0,0:1,1:3,3:1. See the gap. That's your acceptance test.cd end-grain && npx vitest run— record baseline green count.findExposedFace) with a direct unit test. Commit.executeArrangerewrite). Run vitest; expect identity-path green, flip-path possibly changed per "Expected test fallout". Commit.