Papercraft: Cut Lines, Fold Lines, and Glue Tabs from One Plate

Part 1 of 4 in The Cutting Room — projects that put cut() and segment labels to work together.

Series: The Cutting Room

  1. Papercraft (this post) — cut lines, fold lines, and glue tabs from one plate
  2. Jigsaw — wavy knives, piece identity, and a scattered puzzle
  3. Garment patterns — named edges, seam allowances, and notches
  4. Stained glass — tinted panes, leading, and a rose window

Prerequisites: This series builds on PathBlock.cut() — slicing a shape along the strokes of a second PathBlock — and on segment labels, the as segment('name') clause that makes parts of a path queryable. Examples 3 and 6 also lean on the parametric sampling tools (get, normal, subPath). Skim those first if any are new.

What it does

When cut() slices a closed shape apart, every piece comes back healed shut — and every healed edge remembers that it used to be a wound. (An open subject severs into open fragments instead; the healing is for closed material.) Each seam carries the automatic segment label cut, so a piece can be asked, after the fact, where was I cut?

let pieces = plate.cut(knife);
let placed = pieces[0].project(20, 30);
placed.segmentAll('cut');   // every healed edge, as drawable runs

Two things to know before the pictures, because everything below leans on them:

  • Query the projected form. A piece is a PathBlock, and a PathBlock answers sub-queries in its own frame — each returned run is rebased so its own start is (0, 0). Call project(x, y) first and the same queries answer in canvas coordinates, ready to draw with.
  • Adjacent commands with the same label merge into one run. Ask segmentAll for two labeled edges that follow each other and you get one queryable run, not two. That is a feature — a fold line that turns a corner is still one fold — and when you do want the pieces back individually, the language has answers: Example 6 shows the rule and the way around it.

Your own labels survive the cut too: name an edge as segment('roof') before cutting and whichever piece keeps that edge still answers for the name. That is the other half of this series' toolkit.

One more thing this series is: a working friction log. These projects were built against the real language, and where they exposed a bug or a missing piece, the fix went back into Pathogen — each post that hit something grows a closing section, What this project taught the language, telling that story.

Why you'd use it

Papercraft is the cleanest possible demonstration, because a paper template is nothing but annotated cuts: solid lines to scissor, dashed lines to fold, tabs to glue. Before seam labels, decorating a cut meant re-deriving where the knife went — intersecting lines by hand, tracking which piece got which fragment. Now the pieces carry the answer. The idiom the whole series leans on:

for (seam in placed.segmentAll('cut')) {
  seam.draw();
}

A projected value knows where it lives, so draw() draws it exactly there — into whatever layer is active, with whatever stroke style that layer carries. Dashed layer, fold lines. Amber layer, highlights. The seam is just a path; the meaning comes from where you draw it.

Example 1 — The first seam

One rectangular plate, one S-curved knife, two pieces. On the left, the pieces drawn plain, reassembled. On the right, the same two pieces nudged apart, after each was asked for its healed seams — stroked in amber with the idiom above.

// viewBox="0 0 480 240" //-- The bare mechanism: cut a plate in two, then ask each piece where it //-- was healed. Left: the pieces, drawn plain. Right: the same pieces with //-- every seam the cut created stroked in amber via segmentAll('cut'). define ViewBox(0, 0, 480, 240); let bg = PathLayer('bg') ${ fill: #0f172a; stroke: none; }; layer('bg').apply { rect(0, 0, 480, 240); } let scene = GroupLayer('scene') ${}; let pieceLayer = PathLayer('pieces') ${ fill: #e7dfd0; stroke: #0f172a; stroke-width: 2; }; let seamLayer = PathLayer('seams') ${ stroke: #f59e0b; stroke-width: 2.5; fill: none; stroke-linecap: round; }; let divider = PathLayer('divider') ${ stroke: #334155; stroke-width: 1; stroke-dasharray: 4 4; fill: none; }; let labels = TextLayer('labels') ${ font-family: monospace; font-size: 9; fill: #94a3b8; text-anchor: middle; }; scene.append(pieceLayer, seamLayer, divider, labels); let plate = @{ h 140 v 100 h -140 z }; let knife = @{ m 88 -15 c -30 50 30 80 -12 130 }; let pieces = plate.cut(knife); // Left: pieces drawn at one shared position — the plate reassembles. pieceLayer.apply { for (piece in pieces) { M 50 70 piece.draw() } } // Right: same pieces nudged apart, and each one is asked for its own // healed seam — two amber curves, one per piece. let knifeX = 88; for (piece in pieces) { let bounds = piece.boundingBox(); let side = calc(bounds.x + bounds.width / 2 < knifeX ? -9 : 9); let placeX = calc(290 + side); pieceLayer.apply { M placeX 70 piece.draw() } // The projected form answers queries in absolute coordinates. let placed = piece.project(placeX, 70); seamLayer.apply { for (seam in placed.segmentAll('cut')) { seam.draw(); } } } divider.apply { M 240 30 L 240 210 } labels.apply { text(120, 200)`the cut pieces`; text(345, 200)`each piece's own segmentAll('cut'), stroked`; } Left: the cut pieces, reassembled. Right: the pieces apart — each strokes its own segmentAll('cut').

Note that each piece answers separately: two amber curves, one per piece, each the exact edge where that piece was healed. Seams come from the piece you ask, which is what makes everything downstream per-piece by construction.

Example 2 — Cut lines and fold lines

The first real template. An accordion card is one plate cut by three vertical creases — and an accordion's creases alternate: mountain, valley, mountain. That's three line styles, not two, and the knife itself carries the distinction. Name a knife's edge as segment('mountain') and the seams it heals come back labeled cut.mountain — so the plate's boundary is where scissors go (solid red), and one loop over the seams routes each fold to its dash style by asking which knife made it.

// viewBox="0 0 480 220" //-- A four-panel accordion card as one template: the plate's boundary is //-- the cut line (solid), and the creases alternate mountain / valley — //-- each knife is named, so its healed seams carry the fold's name and //-- one loop routes every seam to the right dash style. define ViewBox(0, 0, 480, 220); let bg = PathLayer('bg') ${ fill: #0f172a; stroke: none; }; layer('bg').apply { rect(0, 0, 480, 220); } let scene = GroupLayer('scene') ${}; let panelLayer = PathLayer('panels') ${ fill: #e7dfd0; stroke: none; }; let cutLayer = PathLayer('cut-line') ${ stroke: #ef4444; stroke-width: 2; fill: none; }; let mountainLayer = PathLayer('mountain-folds') ${ stroke: #334155; stroke-width: 1.5; fill: none; stroke-dasharray: 7 3 1.5 3; }; let valleyLayer = PathLayer('valley-folds') ${ stroke: #475569; stroke-width: 1.5; fill: none; stroke-dasharray: 5 4; }; let cutCaption = TextLayer('cut-caption') ${ font-family: monospace; font-size: 9; fill: #f87171; text-anchor: middle; }; let mountainCaption = TextLayer('mountain-caption') ${ font-family: monospace; font-size: 9; fill: #94a3b8; text-anchor: middle; }; let valleyCaption = TextLayer('valley-caption') ${ font-family: monospace; font-size: 9; fill: #94a3b8; text-anchor: middle; }; scene.append(panelLayer, cutLayer, mountainLayer, valleyLayer, cutCaption, mountainCaption, valleyCaption); let card = @{ h 288 v 96 h -288 z }; // An accordion alternates fold directions — so the creases alternate // names. A knife's label rides through the cut: the seams it heals come // back labeled cut.mountain / cut.valley. let creases = []; for (i in 0..2) { let foldName = 'valley'; if (i % 2 == 0) { foldName = 'mountain'; } creases.push(@{ m calc(72 * (i + 1)) -15 l 0 126 as segment(foldName); }); } let panels = card.cut(creases); let originX = 96; let originY = 48; panelLayer.apply { for (piece in panels) { M originX originY piece.draw() } } // The original outline is the cut line: scissors follow the solid path. cutLayer.apply { card.drawTo(originX, originY); } // One pass over the physical seams, each routed by the name its knife // left on it — no fold is drawn twice, and no geometry bookkeeping // decides which crease is which. for (seam in panels.seams()) { if (seam.segmentAll('cut.mountain').length > 0) { mountainLayer.apply { seam.project(originX, originY).draw(); } } else { valleyLayer.apply { seam.project(originX, originY).draw(); } } } // Captions sit under what they name: the valley crease is at x=240, // the right mountain crease at x=312 (nearest the right caption). cutCaption.apply { text(96, 175)`solid = cut`; } valleyCaption.apply { text(240, 175)`dashed = valley fold`; } mountainCaption.apply { text(388, 175)`dash-dot = mountain fold`; } The outline is the cut line; each named knife's seams keep its name — mountain and valley folds dash differently.

One thing the sample does not have to do: dedupe. Each interior fold is shared by two panels, so asking every panel for its seams would stroke each fold twice — and two dashed strokes running opposite directions fill in each other's gaps. panels.seams(), asked of the cut result as a whole, answers with each physical seam exactly once, so the fold pass is one loop with no ownership rules. (Per-piece segmentAll('cut') is still the right query when you want each piece's own view of its edges — Example 1 and the tabs to come.)

Example 3 — Glue tabs that grow on seams

Tabs are the first payoff that would genuinely hurt to do by hand. The seam is a parametric path, so get(t) walks along it and normal(t) points straight out of it — which is all a trapezoid tab needs. The left piece grows three tabs along its healed edge, every other seventh of the way; the outer boundary, which was never cut, stays clean.

// viewBox="0 0 480 240" //-- Glue tabs, generated: the left piece grows trapezoid tabs along its //-- healed seam — and only there. get(t) walks the seam, normal(t) points //-- the tabs outward, and the original boundary never grows a tab. define ViewBox(0, 0, 480, 240); let bg = PathLayer('bg') ${ fill: #0f172a; stroke: none; }; layer('bg').apply { rect(0, 0, 480, 240); } let scene = GroupLayer('scene') ${}; let tabLayer = PathLayer('tabs') ${ fill: #cbbfa3; stroke: #ef4444; stroke-width: 1.5; }; let pieceLayer = PathLayer('pieces') ${ fill: #e7dfd0; stroke: #0f172a; stroke-width: 2; }; let labels = TextLayer('labels') ${ font-family: monospace; font-size: 9; fill: #94a3b8; text-anchor: middle; }; scene.append(tabLayer, pieceLayer, labels); let plate = @{ h 150 v 110 h -150 z }; let knife = @{ m 92 -15 c -26 45 26 75 -10 140 }; let pieces = plate.cut(knife); // Pull the two pieces apart so the tabs have room to show. let plateBB = plate.boundingBox(); let plateCx = calc(plateBB.x + plateBB.width / 2); for (piece in pieces) { let bounds = piece.boundingBox(); let side = calc(bounds.x + bounds.width / 2 < plateCx ? -34 : 34); let placeX = calc(165 + side); pieceLayer.apply { M placeX 55 piece.draw() } // Tabs go on the left piece only. if (side < 0) { let placed = piece.project(placeX, 55); tabLayer.apply { for (seam in placed.segmentAll('cut')) { // Walk the seam in sevenths; every other interval grows a tab. for (k in 0..2) { let t0 = calc((k * 2 + 1) / 7); let t1 = calc((k * 2 + 2) / 7); let tabStart = seam.get(t0); let tabEnd = seam.get(t1); // Seam normals point away from the piece's material — // guaranteed by the cut, so the tab needs no direction test. let outwardAngle = seam.normal(calc((t0 + t1) / 2)).angle; let edgeAngle = calc(outwardAngle + PI() / 2); // Trapezoid: seam edge tabStart→tabEnd, outer edge tapered inward by 4. M calc(tabStart.x) calc(tabStart.y) L calc(tabStart.x + cos(outwardAngle) * 13 + cos(edgeAngle) * 4) calc(tabStart.y + sin(outwardAngle) * 13 + sin(edgeAngle) * 4) L calc(tabEnd.x + cos(outwardAngle) * 13 - cos(edgeAngle) * 4) calc(tabEnd.y + sin(outwardAngle) * 13 - sin(edgeAngle) * 4) L calc(tabEnd.x) calc(tabEnd.y) } } } } } labels.apply { text(240, 205)`tabs ride the seam - the outer boundary stays clean`; } get(t) walks the seam, normal(t) aims the tab; the uncut boundary grows nothing.

The one judgment call in the sample is direction. normal(t) always returns the left-hand normal of the path's travel — but Example 1 showed that the two pieces traverse their shared seam in opposite directions, so "left of the seam" lands inside one piece and outside the other. The sample settles it by comparing the normal against the direction to the piece's own center — if it points inward, flip it by adding PI().

Example 4 — Pieces that introduce themselves

So far the seams did all the talking. This example is about your labels: a house-shaped plate names its roof as segment('roof') and its floor as segment('base'), then a horizontal cut splits it. Each piece still answers for the names it kept — the roof piece strokes and counts its roof, the base piece its base.

// viewBox="0 0 480 215" //-- Name the edges before you cut, and the pieces introduce themselves. //-- A house-shaped plate labels its roof and base; after a horizontal cut //-- each piece still answers for the labels it kept — stroked and counted. define ViewBox(0, 0, 480, 215); let bg = PathLayer('bg') ${ fill: #0f172a; stroke: none; }; layer('bg').apply { rect(0, 0, 480, 215); } let scene = GroupLayer('scene') ${}; let pieceLayer = PathLayer('pieces') ${ fill: #e7dfd0; stroke: #0f172a; stroke-width: 2; }; let roofLayer = PathLayer('roof-runs') ${ stroke: #2dd4bf; stroke-width: 3; fill: none; stroke-linecap: round; }; let baseLayer = PathLayer('base-runs') ${ stroke: #c084fc; stroke-width: 3; fill: none; stroke-linecap: round; }; let roofCount = TextLayer('roof-count') ${ font-family: monospace; font-size: 9; fill: #2dd4bf; }; let baseCount = TextLayer('base-count') ${ font-family: monospace; font-size: 9; fill: #c084fc; }; scene.append(pieceLayer, roofLayer, baseLayer, roofCount, baseCount); let house = @{ l 70 -50 as segment('roof'); l 70 50 as segment('roof'); v 90 h -140 as segment('base'); z }; let knife = @{ m -15 30 l 170 0 }; let pieces = house.cut(knife); for (piece in pieces) { // The labels place the pieces too: the roof piece slides up, the // other slides down — no coordinate checks anywhere. let roofRuns = piece.segmentAll('roof'); let originY = calc(roofRuns.length > 0 ? 66 : 90); pieceLayer.apply { M 120 originY piece.draw() } let placed = piece.project(120, originY); let baseRuns = placed.segmentAll('base'); roofRuns = placed.segmentAll('roof'); roofLayer.apply { for (run in roofRuns) { run.draw(); } } baseLayer.apply { for (run in baseRuns) { run.draw(); } } // Each piece reports which of the plate's names it still owns, // color-keyed to the strokes. let placedBounds = placed.boundingBox(); let labelY = calc(placedBounds.y + placedBounds.height / 2 + 3); roofCount.apply { text(310, labelY)`roof ${roofRuns.length}`; } baseCount.apply { text(378, labelY)`base ${baseRuns.length}`; } } Labels survive the cut: each piece strokes and counts the names it kept.

The counts are the quiet star. segmentAll('roof') on the top piece has length 1, on the bottom piece length 0 — which means a program can identify pieces by what they kept, with no geometry tests at all. The sample practices what it preaches: even the up-or-down placement of each piece is decided by the roof query, not by a coordinate check. The garment-pattern post (part 3) builds its whole workflow on this.

Example 5 — The exploded view

Assembly diagrams pull pieces apart along rays from the center, and boundingBox() gives every piece its own ray for free. The ghost of the uncut plate stays behind, dashed; each drifted piece strokes its seams amber, so mating edges face each other across the gaps.

// viewBox="0 0 480 260" //-- The assembly diagram: four pieces drift away from the plate's center //-- along their own centroid rays, the ghost of the uncut plate stays //-- behind, and every mating edge glows — that's the seam group again. define ViewBox(0, 0, 480, 260); let bg = PathLayer('bg') ${ fill: #0f172a; stroke: none; }; layer('bg').apply { rect(0, 0, 480, 260); } let scene = GroupLayer('scene') ${}; let ghost = PathLayer('ghost') ${ stroke: #334155; stroke-width: 1; stroke-dasharray: 3 4; fill: none; }; let pieceLayer = PathLayer('pieces') ${ fill: #e7dfd0; stroke: #0f172a; stroke-width: 2; }; let seamLayer = PathLayer('seams') ${ stroke: #f59e0b; stroke-width: 2; fill: none; stroke-linecap: round; }; let labels = TextLayer('labels') ${ font-family: monospace; font-size: 9; fill: #94a3b8; text-anchor: middle; }; scene.append(ghost, pieceLayer, seamLayer, labels); let plate = @{ h 150 v 110 h -150 z }; let knives = @{ m 68 -15 c -20 40 20 80 -4 140 m -79 -83 l 180 12 }; let pieces = plate.cut(knives); let originX = 165; let originY = 70; ghost.apply { plate.drawTo(originX, originY); } // Drift each piece along the ray from the plate's bounding-box center // to its own. let plateBB = plate.boundingBox(); let plateCenterX = calc(plateBB.x + plateBB.width / 2); let plateCenterY = calc(plateBB.y + plateBB.height / 2); for (piece in pieces) { let bounds = piece.boundingBox(); let dx = calc(bounds.x + bounds.width / 2 - plateCenterX); let dy = calc(bounds.y + bounds.height / 2 - plateCenterY); let len = calc(sqrt(dx * dx + dy * dy)); let placeX = calc(originX + dx / len * 34); let placeY = calc(originY + dy / len * 34); pieceLayer.apply { M placeX placeY piece.draw() } let placed = piece.project(placeX, placeY); seamLayer.apply { for (seam in placed.segmentAll('cut')) { seam.draw(); } } } labels.apply { text(240, 235)`amber edges are the mates — seams face seams`; } Pieces drift along rays to their bounding-box centers; amber seam faces amber seam.

Nothing here is new — it is Example 1's stroke and a square root — but this is the moment the toolkit starts reading as a diagram rather than a demo: the seams are doing the explanatory work a technical illustrator would do with a highlighter.

Example 6 — The kit sheet

Everything at once: a hexagonal medallion, three straight knives through the center, six numbered wedges exploded into a ring around the assembled ghost. Each wedge tabs one of its healed edges (red — cut around the tab) and fold-dashes the other (glue your neighbor's tab under it), and wears its piece number — derived from its angle around the ring, because cut() makes no promise about the order pieces come back in.

// viewBox="0 0 480 300" //-- The finished kit sheet: a hex medallion cut into six wedges, exploded //-- into a ring. Every wedge tabs one healed seam, fold-dashes the other, //-- and wears its number — the knives are named, so each radial edge is //-- queried by the knife that made it. define ViewBox(0, 0, 480, 300); let bg = PathLayer('bg') ${ fill: #0f172a; stroke: none; }; layer('bg').apply { rect(0, 0, 480, 300); } let scene = GroupLayer('scene') ${}; let ghost = PathLayer('ghost') ${ stroke: #334155; stroke-width: 1; stroke-dasharray: 3 4; fill: none; }; let tabLayer = PathLayer('tabs') ${ fill: #cbbfa3; stroke: #ef4444; stroke-width: 1.2; }; let wedgeLayer = PathLayer('wedges') ${ fill: #e7dfd0; stroke: #0f172a; stroke-width: 2; }; let foldLayer = PathLayer('folds') ${ stroke: #475569; stroke-width: 1.4; fill: none; stroke-dasharray: 4 3; }; let numbers = TextLayer('numbers') ${ font-family: monospace; font-size: 10; fill: #0f172a; text-anchor: middle; }; let title = TextLayer('title') ${ font-family: monospace; font-size: 10; fill: #94a3b8; text-anchor: middle; letter-spacing: 3; }; let caption = TextLayer('caption') ${ font-family: monospace; font-size: 9; fill: #94a3b8; text-anchor: middle; }; scene.append(ghost, tabLayer, wedgeLayer, foldLayer, numbers, title, caption); let plate = @{ polygon(0, 0, 62, 6); }; let knifeReach = 78; // Three straight knives through the center, 60 degrees apart — each its // own block, handed to cut() as an array, and each named for its seams. let knives = []; for (k in 0..2) { let knifeAngle = calc(k * PI() / 3); let dirX = cos(knifeAngle); let dirY = sin(knifeAngle); knives.push(@{ m calc(0 - knifeReach * dirX) calc(0 - knifeReach * dirY) l calc(knifeReach * 2 * dirX) calc(knifeReach * 2 * dirY) as segment(`k${k}`); }); } let wedges = plate.cut(knives); let originX = 240; let originY = 158; ghost.apply { plate.drawTo(originX, originY); } for (wedge in wedges) { // Drift outward along the ray to the wedge's bounding-box center. let bounds = wedge.boundingBox(); let dx = calc(bounds.x + bounds.width / 2); let dy = calc(bounds.y + bounds.height / 2); let len = calc(sqrt(dx * dx + dy * dy)); let placeX = calc(originX + dx / len * 44); let placeY = calc(originY + dy / len * 44); wedgeLayer.apply { M placeX placeY wedge.draw() } let placed = wedge.project(placeX, placeY); let placedBounds = placed.boundingBox(); let wedgeCenterX = calc(placedBounds.x + placedBounds.width / 2); let wedgeCenterY = calc(placedBounds.y + placedBounds.height / 2); // A wedge's two radial edges came from two DIFFERENT knives, and each // knife left its name on the seams it healed — so exact sub-label // queries hand each edge back on its own, no merged-run surgery. for (k in 0..2) { for (seam in placed.segmentAll(`cut.k${k}`)) { let edgeMid = seam.get(0.5); // Which side of the centroid ray does this edge sit on? let cross = calc((wedgeCenterX - placeX) * (edgeMid.y - placeY) - (wedgeCenterY - placeY) * (edgeMid.x - placeX)); if (cross > 0) { // Clockwise edge: glue tab, pointing away from the wedge. let tabStart = seam.get(0.12); let tabEnd = seam.get(0.88); // Seam normals point away from the wedge's material — guaranteed // by the cut, so the tab needs no direction test. let outwardAngle = seam.normal(0.5).angle; let edgeAngle = calc(outwardAngle + PI() / 2); tabLayer.apply { M calc(tabStart.x) calc(tabStart.y) L calc(tabStart.x + cos(outwardAngle) * 10 + cos(edgeAngle) * 4) calc(tabStart.y + sin(outwardAngle) * 10 + sin(edgeAngle) * 4) L calc(tabEnd.x + cos(outwardAngle) * 10 - cos(edgeAngle) * 4) calc(tabEnd.y + sin(outwardAngle) * 10 - sin(edgeAngle) * 4) L calc(tabEnd.x) calc(tabEnd.y) } } else { // Counter-clockwise edge: fold line for the neighbor's tab. foldLayer.apply { seam.draw(); } } } } // Number by angle around the ring, not by loop order — cut() makes // no promise about the order pieces come back in. let ringAngle = calc(atan2(dy, dx) + PI()); let num = calc(floor(ringAngle / (PI() / 3)) + 1); numbers.apply { text(wedgeCenterX, calc(wedgeCenterY + 3))`${num}`; } } title.apply { text(240, 32)`HEX MEDALLION KIT`; } caption.apply { text(240, 278)`tab the red edge, fold the dashed one, rejoin in order`; } The finished kit sheet: tab the red edge, fold the dashed one, rejoin in order.

And here is the merge rule from the top of the post, doing real work. A wedge's two radial edges meet at the hexagon's center, and under the umbrella query segmentAll('cut') they come back as one V-shaped run (adjacent seam commands merge regardless of which knife made them). But the knives are named — k0, k1, k2 — and each wedge's two edges come from two different knives, so the exact queries segmentAll('cut.k0')… hand each edge back on its own. The sample decides tab-or-fold per edge by which side of the wedge's center ray it lies on. If you ever ask for two seams and receive one, the umbrella merge is why — and a named knife (or, for a run one label covers, the :atomic pseudo-selector) re-divides it.

What this project taught the language

The friction-log promise from the top of the post, kept — what building this project changed in Pathogen:

The seam idiom became a real draw(). When this series first shipped, the loop above took two lines per seam: seam.drawTo(seam.startPoint.x, seam.startPoint.y) — "draw yourself where you already are," said with two property reads and a re-anchor. Worse, the same expression applied to a whole cut piece silently drew it in the wrong place, because a piece's projected startPoint was, at the time, its frame origin rather than its first command (the garment post tells that part of the story — including the epilogue where startPoint itself was later made truthful). Projected values now have an in-place draw(): it anchors on the value's first command by definition, so the misplacement cannot be written at all and the idiom is one self-evident line.

// before
seam.drawTo(seam.startPoint.x, seam.startPoint.y);

// after
seam.draw();

Example 2 grew up twice. The fold lines you see above are the result of two rounds of the loop. First, dedup: the original sample kept shared folds from double-drawing with an ownership rule — each panel stroked only the seams on its right-hand side, a midpoint-versus-center comparison that worked and taught nothing. Cut results now answer pieces.seams(): each physical seam exactly once, subject-local like the pieces themselves, so the double-draw bug class (opposite-phase dashes filling each other's gaps) is unwritable. Second, direction: even deduped, every fold shipped in one dash style, because all seams shared one anonymous cut group and mountain-versus-valley — the distinction a real accordion template turns on — was inexpressible. Knife labels now ride through the cut: an edge authored as segment('mountain') heals into seams labeled cut.mountain, the umbrella segmentAll('cut') still answers everything, and sub-label queries answer one knife at a time.

// original: per-panel ownership rule (~10 lines)
for (seam in placed.segmentAll('cut')) {
  let mid = seam.get(0.5);
  if (mid.x > panelCenterX) {
    seam.draw();
  }
}

// today: each physical seam once, routed by the knife that made it
for (seam in panels.seams()) {
  if (seam.segmentAll('cut.mountain').length > 0) {
    mountainLayer.apply {
      seam.project(originX, originY).draw();
    }
  } else {
    valleyLayer.apply {
      seam.project(originX, originY).draw();
    }
  }
}

The knife names paid off a second time in Example 6: a wedge's two radial edges come from two different knives, so exact sub-label queries return each edge on its own, and the merged-V subPath surgery at guessed fractions is gone.

The tabs' direction test turned out to be dead code. Example 3 settles which way a tab points by reading the seam normal — and the original sample compared it against the piece's center, flipping when it aimed inward: three lines of ceremony per tab. Working the friction log revealed the flip never fires. A cut always orients each piece's outline the same way around its material, so a seam normal points out of the piece by construction. The guarantee is now documented and pinned by tests, and both tab samples dropped the dance — with byte-identical output, the strongest proof the code was dead. The purest friction-log lesson in the series: the feature existed all along; the fix was a sentence, and the sentence was the feature.

// before: probe, dot product, conditional flip
let n = seam.normal(tabMid);
let toCenter = calc(cos(n.angle) * (cx - n.point.x) + sin(n.angle) * (cy - n.point.y));
let outwardAngle = calc(toCenter > 0 ? n.angle + PI() : n.angle);

// after: the normal already faces out of the material
let outwardAngle = seam.normal(tabMid).angle;

The medallion's knives stopped doing arithmetic. Example 6's three knives were originally one cutter block whose strokes chained together with hand-computed relative moves — and one of those moves shipped wrong before review caught it. cut() now accepts an array of cutters, so the sample builds one single-stroke knife per angle in a loop and hands the set over in a single call. A knife that states only "start here, cut this" has no arithmetic to get wrong.

The query language grew its : half. When knife names landed (Example 2's second act, above), the . in cut.mountain was only half of a deliberate decision: label names reserve all punctuation, with : explicitly held back for CSS-style pseudo-selectors that didn't exist yet. Now they do — :atomic, :first, :last, and :nth(k). :atomic is the merge rule's official escape hatch (one block per drawing command — a labeled circle() hands back its individual arcs), and the position family selects whole runs from a group. Honest accounting: nothing in this series needs them anymore — the sub-labels above already dissolved the case that demanded an escape hatch — which is exactly why they shipped as query syntax rather than yet another method: the grammar was already paid for.

// before: recover an individual edge by fraction guessing
let firstArc = run.subPath(0, 0.5);

// after: ask for it
let arcs = wheel.segmentAll('rim:atomic');
let lastTooth = comb.segment('tooth:last');

The traps got fences. Two hazards this series stepped on never bit the published samples only because a style guideline banned the ammunition. Both are now language rules instead of etiquette. Single-letter variables that shadow path commands (let m = 25; then L m 40) used to fail with a Missing ';' pointed at punctuation nowhere near the mistake; the compiler and the editor now say what actually happened ('m' is a path command here — write calc(m), or rename the variable), with a one-click calc() wrap in the playground. And the angle-suffix names pi, deg, and rad are now reserved words: suffix only, never a variable, so calc(pi) explains itself instead of reporting an undefined variable two spellings away from three working ones (0.5pi, PI(), deg(x)).

Where to go next