The Panel Prints Its Own Drill Schedule

Part 4 of 5 in Drawing Without Bookkeeping — a form on one side, its annotated twin on the other, and nothing copied between them.

Series: Drawing Without Bookkeeping

  1. Ask the Pathquery() and queryAll()
  2. Thinking and Drawing in Parallelsubscribe()
  3. A Linkage That Dimensions Itself — a four-bar linkage
  4. The Panel Prints Its Own Drill Schedule (this post) — a Eurorack front panel
  5. The Fretboard Is a Formula — a fretboard from one scale length

Prerequisites: This post assumes the selector grammar from Ask the Path, the subscription model from Thinking and Drawing in Parallel, and the call noun in particular: everything one statement drew, as one match. The panel idiom is the series': a form on the left, its twin on the right. Group layers and their scale are in the layers reference.

A Eurorack module is a circuit behind a front panel, and the panel is a constraint grid. It is 3U tall, three rack units, which the rail standard makes 128.5 mm. Its width is a whole number of HP, horizontal pitch, at 5.08 mm each, less a hair of clearance. Two rail slots near the top and bottom edges take the mounting screws, at positions the standard fixes from the left edge; wide panels get a second pair. Then come the holes that matter to the circuit: jacks, pots, a LED. Builders keep these numbers in wiki tables and re-derive them per module.

The one artifact the panel shop needs is the drill schedule: a table of hole numbers, diameters and centre coordinates, in millimetres. Today it is typed out by hand from the drawing, and it is wrong the moment a jack moves. This post draws the panel as a form and lets the twin print the schedule.

Drawn in millimetres

Every sample draws the panel in millimetres. A group layer carries the page position and a scale of 1.4, and everything inside it is authored in real units:

let rightPanel = GroupLayer('right') #{
  translate-x: 220;
  translate-y: 40;
  scale: 1.4;
};

Layer records are kept in the layer's own coordinates, so a query on a layer inside that group answers in millimetres, and a text layer inside the group prints them without conversion. The schedule is true because nothing between the hole and the table ever multiplied by 1.4. Hole 1 reports x = 15.1 in the schedule, which is millimetres across the panel. On the page that same hole sits 21.1 units from the panel's left edge, and no query ever sees that number.

The form itself is short. One number at the top, hp, sets the width. A slot() function draws a rail slot where the standard puts it, and drawHoles() places every control on quarters of whatever the width turns out to be, each one a circle() call named where it is drawn:

circle(mid, 24, 3.5) as segment('rate');
circle(left, 82, 3) as segment('in');

The third argument is a radius, so those are the ⌀7 hole a 9 mm pot needs and the ⌀6 a 3.5 mm jack needs; the LED is ⌀3.

//-- A 6HP Eurorack panel drawn in millimetres: the outline, the rail //-- slots, and every hole as a circle() call. Right: what the holes layer //-- can already say about itself. define ViewBox(0, 0, 480, 260); // ─── Core tokens ─────────────────────────────────────────────── let bg_color = Color(CSSVar('--bg', #d0d7f0)); let fg_auto = Color('#0d1638'); let fg_muted = Color('#0d1638').alpha(0.6); let fg_hair = Color('#0d1638').alpha(0.22); let points = oklch(0.55 0.16 260); let font = 'sans-serif'; let mono = 'monospace'; let bg = PathLayer('bg') #{ fill: bg_color; stroke: none; }; bg.apply { rect(0, 0, 480, 260); } // ─── The panel, in millimetres. HP is the parameter. ────────── let hp = 6; let height = 128.5; let unit = 5.08; let width = hp * unit - 0.3; // A rail slot: 5.5 × 3.2, centred where the rail standard wants it. fn slot(cx, cy) { roundRect(cx - 2.75, cy - 1.6, 5.5, 3.2, 1.6); } // Every hole the panel needs, named where it is drawn. Jacks are ⌀6, // pots ⌀7, the LED ⌀3; columns sit on quarters of whatever the width is. fn drawHoles(holes) { let mid = width / 2; let left = width / 4; let right = width * 3 / 4; holes.apply { slot(7.5, 3); slot(7.5, height - 3); if (hp >= 10) { slot(width - 7.5, 3); slot(width - 7.5, height - 3); } circle(mid, 24, 3.5) as segment('rate'); circle(mid, 48, 3.5) as segment('depth'); circle(mid, 64, 1.5) as segment('led'); circle(left, 82, 3) as segment('in'); circle(right, 82, 3) as segment('cv'); circle(left, 98, 3) as segment('out-a'); circle(right, 98, 3) as segment('out-b'); circle(left, 114, 3) as segment('sync'); circle(right, 114, 3) as segment('gate'); } } fn drawOutline(outline) { outline.apply { roundRect(0, 0, width, height, 1.5); } } // One decimal, sign kept, always shown — there is no toFixed yet. fn mm(value) { let sign = ''; if (value < 0) { sign = '-'; } let tenths = round(abs(value) * 10); let whole = floor(tenths / 10); return `${sign}${whole}.${tenths - whole * 10}`; } // ─── Left panel: page position and scale, children in millimetres ── let leftPanel = GroupLayer('left') #{ translate-x: 80; translate-y: 40; scale: 1.4; }; let leftOutline = PathLayer('left-outline') #{ stroke: fg_auto; stroke-width: 0.6; fill: none; }; let leftHoles = PathLayer('left-holes') #{ stroke: fg_auto; stroke-width: 0.5; fill: none; }; leftPanel.append(leftOutline, leftHoles); // ─── Right panel: page position and scale, children in millimetres ── let rightPanel = GroupLayer('right') #{ translate-x: 220; translate-y: 40; scale: 1.4; }; let rightOutline = PathLayer('right-outline') #{ stroke: fg_auto; stroke-width: 0.6; fill: none; }; let rightHoles = PathLayer('right-holes') #{ stroke: fg_auto; stroke-width: 0.5; fill: none; }; let rightTable = TextLayer('right-table') #{ font-family: font; font-size: 5.5; font-weight: 700; letter-spacing: 0.3; fill: points; text-anchor: start; }; rightPanel.append(rightOutline, rightHoles, rightTable); // ─── Captions, on the page ───────────────────────────────────── let leftEyebrow = TextLayer('left-eyebrow') #{ font-family: font; font-size: 8; font-weight: 700; letter-spacing: 3; fill: fg_muted; text-anchor: start; }; let leftNote = TextLayer('left-note') #{ font-family: font; font-size: 8; letter-spacing: 0.5; fill: fg_auto; text-anchor: start; }; let rightEyebrow = TextLayer('right-eyebrow') #{ font-family: font; font-size: 8; font-weight: 700; letter-spacing: 3; fill: fg_muted; text-anchor: start; }; let rightNote = TextLayer('right-note') #{ font-family: font; font-size: 8; letter-spacing: 0.5; fill: fg_auto; text-anchor: start; }; drawOutline(leftOutline); drawHoles(leftHoles); drawOutline(rightOutline); drawHoles(rightHoles); // Asked, not counted: the calls the holes layer recorded, by name. let holes = rightHoles.queryAll('call(circle)'); let slots = rightHoles.queryAll('call(slot)'); let named = rightHoles.queryAll('segment'); rightTable.apply { text(40, 24)`${holes.length} round holes`; text(40, 32)`${slots.length} rail slots`; text(40, 40)`${named.length} named controls`; text(40, 52)`width ${mm(width)} mm`; text(40, 60)`height ${height} mm`; } leftEyebrow.apply { text(80, 30)`THE FORM, 6HP`; } leftNote.apply { text(80, 242)`one number at the top: hp`; } rightEyebrow.apply { text(220, 30)`CALL, ASKED`; } rightNote.apply { text(220, 242)`every count is a query`; } // ─── Divider ─────────────────────────────────────────────────── let divider = PathLayer('divider') #{ stroke: fg_hair; stroke-width: 0.5; fill: none; }; divider.apply { M 190 30 L 190 235 } Left: a 6HP panel — outline, two rail slots, nine holes — from one number. Right: what the holes layer reports when asked for call(circle), call(slot) and segment, with the size the form was built from underneath.

Read the right panel's third line. The slots were drawn by a function called slot, and a call is the statement you wrote, so they answer to call(slot) and never get mixed in with the circles. That is the rule part 1 stated as a caveat, doing useful work. The circles sit inside drawHoles() too, and they still answer to call(circle): statements written inside a layer's own apply block are that layer's calls, wherever the block was opened. It is the call inside slot() that is hidden behind the function's name.

Numbered in drawing order

A subscription on call(circle) delivers each hole as it was recorded. The callback gets the hole's block, and the block knows its centre and its bounding box, which is to say the hole's position and diameter:

rightHoles.subscribe('call(circle)') {|hole, i|
  let centre = hole.block.centerPoint();
  let r = hole.block.boundingBox().width / 2;
  ...
};

//-- Every hole numbered and crossed by one subscription on call(circle): //-- the number just outside its rim, the cross on its centre, in drawing order. define ViewBox(0, 0, 480, 260); // ─── Core tokens ─────────────────────────────────────────────── let bg_color = Color(CSSVar('--bg', #d0d7f0)); let fg_auto = Color('#0d1638'); let fg_muted = Color('#0d1638').alpha(0.6); let fg_hair = Color('#0d1638').alpha(0.22); let points = oklch(0.55 0.16 260); let font = 'sans-serif'; let mono = 'monospace'; let bg = PathLayer('bg') #{ fill: bg_color; stroke: none; }; bg.apply { rect(0, 0, 480, 260); } // ─── The panel, in millimetres. HP is the parameter. ────────── let hp = 6; let height = 128.5; let unit = 5.08; let width = hp * unit - 0.3; // A rail slot: 5.5 × 3.2, centred where the rail standard wants it. fn slot(cx, cy) { roundRect(cx - 2.75, cy - 1.6, 5.5, 3.2, 1.6); } // Every hole the panel needs, named where it is drawn. Jacks are ⌀6, // pots ⌀7, the LED ⌀3; columns sit on quarters of whatever the width is. fn drawHoles(holes) { let mid = width / 2; let left = width / 4; let right = width * 3 / 4; holes.apply { slot(7.5, 3); slot(7.5, height - 3); if (hp >= 10) { slot(width - 7.5, 3); slot(width - 7.5, height - 3); } circle(mid, 24, 3.5) as segment('rate'); circle(mid, 48, 3.5) as segment('depth'); circle(mid, 64, 1.5) as segment('led'); circle(left, 82, 3) as segment('in'); circle(right, 82, 3) as segment('cv'); circle(left, 98, 3) as segment('out-a'); circle(right, 98, 3) as segment('out-b'); circle(left, 114, 3) as segment('sync'); circle(right, 114, 3) as segment('gate'); } } fn drawOutline(outline) { outline.apply { roundRect(0, 0, width, height, 1.5); } } // One decimal, sign kept, always shown — there is no toFixed yet. fn mm(value) { let sign = ''; if (value < 0) { sign = '-'; } let tenths = round(abs(value) * 10); let whole = floor(tenths / 10); return `${sign}${whole}.${tenths - whole * 10}`; } // ─── Left panel: page position and scale, children in millimetres ── let leftPanel = GroupLayer('left') #{ translate-x: 80; translate-y: 40; scale: 1.4; }; let leftOutline = PathLayer('left-outline') #{ stroke: fg_auto; stroke-width: 0.6; fill: none; }; let leftHoles = PathLayer('left-holes') #{ stroke: fg_auto; stroke-width: 0.5; fill: none; }; leftPanel.append(leftOutline, leftHoles); // ─── Right panel: page position and scale, children in millimetres ── let rightPanel = GroupLayer('right') #{ translate-x: 220; translate-y: 40; scale: 1.4; }; let rightOutline = PathLayer('right-outline') #{ stroke: fg_auto; stroke-width: 0.6; fill: none; }; let rightHoles = PathLayer('right-holes') #{ stroke: fg_auto; stroke-width: 0.5; fill: none; }; let rightCrosses = PathLayer('right-crosses') #{ stroke: points; stroke-width: 0.35; fill: none; }; let rightNumbers = TextLayer('right-numbers') #{ font-family: font; font-size: 3.6; font-weight: 700; letter-spacing: 0.2; fill: points; text-anchor: start; }; rightPanel.append(rightOutline, rightHoles, rightCrosses, rightNumbers); // ─── Captions, on the page ───────────────────────────────────── let leftEyebrow = TextLayer('left-eyebrow') #{ font-family: font; font-size: 8; font-weight: 700; letter-spacing: 3; fill: fg_muted; text-anchor: start; }; let leftNote = TextLayer('left-note') #{ font-family: font; font-size: 8; letter-spacing: 0.5; fill: fg_auto; text-anchor: start; }; let rightEyebrow = TextLayer('right-eyebrow') #{ font-family: font; font-size: 8; font-weight: 700; letter-spacing: 3; fill: fg_muted; text-anchor: start; }; let rightNote = TextLayer('right-note') #{ font-family: font; font-size: 8; letter-spacing: 0.5; fill: fg_auto; text-anchor: start; }; // Declared before a hole is drawn: each circle() the twin's holes layer // records gets a centre cross and its number just outside its rim. rightHoles.subscribe('call(circle)') {|hole, i| let centre = hole.block.centerPoint(); rightCrosses.apply { M calc(centre.x - 1.2) centre.y L calc(centre.x + 1.2) centre.y M centre.x calc(centre.y - 1.2) L centre.x calc(centre.y + 1.2) } let r = hole.block.boundingBox().width / 2; rightNumbers.apply { text(centre.x + r + 0.6, centre.y - r + 1.2)`${i + 1}`; } }; drawOutline(leftOutline); drawHoles(leftHoles); drawOutline(rightOutline); drawHoles(rightHoles); leftEyebrow.apply { text(80, 30)`THE FORM`; } leftNote.apply { text(80, 242)`nine holes, two slots`; } rightEyebrow.apply { text(220, 30)`CALL(CIRCLE), SUBSCRIBED`; } rightNote.apply { text(220, 242)`numbered in drawing order`; } // ─── Divider ─────────────────────────────────────────────────── let divider = PathLayer('divider') #{ stroke: fg_hair; stroke-width: 0.5; fill: none; }; divider.apply { M 190 30 L 190 235 } One subscription on call(circle): a centre cross through every hole and its number just outside the rim, in drawing order. The slots are not circles and are left alone.

The variable is centre and not c, for the reason part 3 gave: in path-argument position, c is the cubic command.

The schedule

The schedule is a query over the same layer, printed as a table in a monospace text layer inside the group. Each row is the hole's number, its diameter from the block's bounding box, and its centre. The numbers are rounded to a tenth of a millimetre by a four-line mm() helper, because there is no toFixed yet, and the friction log says so.

//-- The drill schedule the panel shop needs, printed by the panel: one row //-- per hole with its number, diameter and centre, all read from the form. define ViewBox(0, 0, 480, 260); // ─── Core tokens ─────────────────────────────────────────────── let bg_color = Color(CSSVar('--bg', #d0d7f0)); let fg_auto = Color('#0d1638'); let fg_muted = Color('#0d1638').alpha(0.6); let fg_hair = Color('#0d1638').alpha(0.22); let points = oklch(0.55 0.16 260); let font = 'sans-serif'; let mono = 'monospace'; let bg = PathLayer('bg') #{ fill: bg_color; stroke: none; }; bg.apply { rect(0, 0, 480, 260); } // ─── The panel, in millimetres. HP is the parameter. ────────── let hp = 6; let height = 128.5; let unit = 5.08; let width = hp * unit - 0.3; // A rail slot: 5.5 × 3.2, centred where the rail standard wants it. fn slot(cx, cy) { roundRect(cx - 2.75, cy - 1.6, 5.5, 3.2, 1.6); } // Every hole the panel needs, named where it is drawn. Jacks are ⌀6, // pots ⌀7, the LED ⌀3; columns sit on quarters of whatever the width is. fn drawHoles(holes) { let mid = width / 2; let left = width / 4; let right = width * 3 / 4; holes.apply { slot(7.5, 3); slot(7.5, height - 3); if (hp >= 10) { slot(width - 7.5, 3); slot(width - 7.5, height - 3); } circle(mid, 24, 3.5) as segment('rate'); circle(mid, 48, 3.5) as segment('depth'); circle(mid, 64, 1.5) as segment('led'); circle(left, 82, 3) as segment('in'); circle(right, 82, 3) as segment('cv'); circle(left, 98, 3) as segment('out-a'); circle(right, 98, 3) as segment('out-b'); circle(left, 114, 3) as segment('sync'); circle(right, 114, 3) as segment('gate'); } } fn drawOutline(outline) { outline.apply { roundRect(0, 0, width, height, 1.5); } } // One decimal, sign kept, always shown — there is no toFixed yet. fn mm(value) { let sign = ''; if (value < 0) { sign = '-'; } let tenths = round(abs(value) * 10); let whole = floor(tenths / 10); return `${sign}${whole}.${tenths - whole * 10}`; } // ─── Left panel: page position and scale, children in millimetres ── let leftPanel = GroupLayer('left') #{ translate-x: 80; translate-y: 40; scale: 1.4; }; let leftOutline = PathLayer('left-outline') #{ stroke: fg_auto; stroke-width: 0.6; fill: none; }; let leftHoles = PathLayer('left-holes') #{ stroke: fg_auto; stroke-width: 0.5; fill: none; }; leftPanel.append(leftOutline, leftHoles); // ─── Right panel: page position and scale, children in millimetres ── let rightPanel = GroupLayer('right') #{ translate-x: 220; translate-y: 40; scale: 1.4; }; let rightOutline = PathLayer('right-outline') #{ stroke: fg_auto; stroke-width: 0.6; fill: none; }; let rightHoles = PathLayer('right-holes') #{ stroke: fg_auto; stroke-width: 0.5; fill: none; }; let rightCrosses = PathLayer('right-crosses') #{ stroke: points; stroke-width: 0.35; fill: none; }; let rightNumbers = TextLayer('right-numbers') #{ font-family: font; font-size: 3.6; font-weight: 700; letter-spacing: 0.2; fill: points; text-anchor: start; }; let rightColumns = TextLayer('right-columns') #{ font-family: mono; font-size: 4.6; letter-spacing: 0; fill: fg_auto; text-anchor: end; }; let rightRows = TextLayer('right-rows') #{ font-family: mono; font-size: 4.6; letter-spacing: 0; fill: fg_muted; text-anchor: start; }; rightPanel.append(rightOutline, rightHoles, rightCrosses, rightNumbers, rightColumns, rightRows); // ─── Captions, on the page ───────────────────────────────────── let leftEyebrow = TextLayer('left-eyebrow') #{ font-family: font; font-size: 8; font-weight: 700; letter-spacing: 3; fill: fg_muted; text-anchor: start; }; let leftNote = TextLayer('left-note') #{ font-family: font; font-size: 8; letter-spacing: 0.5; fill: fg_auto; text-anchor: start; }; let rightEyebrow = TextLayer('right-eyebrow') #{ font-family: font; font-size: 8; font-weight: 700; letter-spacing: 3; fill: fg_muted; text-anchor: start; }; let rightNote = TextLayer('right-note') #{ font-family: font; font-size: 8; letter-spacing: 0.5; fill: fg_auto; text-anchor: start; }; // Declared before a hole is drawn: each circle() the twin's holes layer // records gets a centre cross and its number just outside its rim. rightHoles.subscribe('call(circle)') {|hole, i| let centre = hole.block.centerPoint(); rightCrosses.apply { M calc(centre.x - 1.2) centre.y L calc(centre.x + 1.2) centre.y M centre.x calc(centre.y - 1.2) L centre.x calc(centre.y + 1.2) } let r = hole.block.boundingBox().width / 2; rightNumbers.apply { text(centre.x + r + 0.6, centre.y - r + 1.2)`${i + 1}`; } }; // The drill schedule: one row per hole, in drawing order, every value read // from the hole's own block — its centre and its bounding box. The number // columns are end-anchored so the decimal points line up. fn schedule(source, col, rows, x0, y0) { let holes = source.queryAll('call(circle)'); col.apply { text(x0 + 2, y0)`#`; text(x0 + 13, y0)`⌀`; text(x0 + 26, y0)`x`; text(x0 + 42, y0)`y`; for ([hole, i] in holes) { let centre = hole.block.centerPoint(); let box = hole.block.boundingBox(); let y = y0 + 6 * (i + 1); text(x0 + 2, y)`${i + 1}`; text(x0 + 13, y)`${mm(box.width)}`; text(x0 + 26, y)`${mm(centre.x)}`; text(x0 + 42, y)`${mm(centre.y)}`; } } let slots = source.queryAll('call(slot)'); let slotBox = source.query('call(slot)').block.boundingBox(); rows.apply { text(x0, y0 + 6 * (holes.length + 2))`${slots.length} rail slots ${mm(slotBox.width)} × ${mm(slotBox.height)}`; text(x0, y0 + 6 * (holes.length + 3))`${holes.length} holes, ${hp}HP, ${mm(width)} × ${height}`; } } drawOutline(leftOutline); drawHoles(leftHoles); drawOutline(rightOutline); drawHoles(rightHoles); schedule(rightHoles, rightColumns, rightRows, 40, 8); leftEyebrow.apply { text(80, 30)`THE FORM`; } leftNote.apply { text(80, 242)`drawn once, in millimetres`; } rightEyebrow.apply { text(220, 30)`THE DRILL SCHEDULE`; } rightNote.apply { text(220, 242)`#, ⌀, x, y — from each hole's block`; } // ─── Divider ─────────────────────────────────────────────────── let divider = PathLayer('divider') #{ stroke: fg_hair; stroke-width: 0.5; fill: none; }; divider.apply { M 190 30 L 190 235 } The drill schedule the panel shop needs: number, diameter, x and y for every hole, plus the slot count and the panel size, every value read from the holes layer in millimetres.

Nothing in the table was typed, the slot size in the footer included: it is the bounding box of the first call(slot). Move a jack in drawHoles() and its row follows; add a hole and the table grows by one.

The legend

The legend is the printing on the panel itself: a name under every control and a scale arc round every pot, the 270° sweep with a tick at each end and one at noon. The names came from the form, where every circle() was drawn as segment('…'), so the twin asks for segment and gets the label with the block. The pots are segment(rate), segment(depth), and the arc's radius is the hole's own radius plus a margin.

//-- The legend a panel prints on itself: a name under every control and a //-- scale arc round every pot, placed from the holes the form already drew. define ViewBox(0, 0, 480, 260); // ─── Core tokens ─────────────────────────────────────────────── let bg_color = Color(CSSVar('--bg', #d0d7f0)); let fg_auto = Color('#0d1638'); let fg_muted = Color('#0d1638').alpha(0.6); let fg_hair = Color('#0d1638').alpha(0.22); let legend = oklch(0.55 0.16 200); let font = 'sans-serif'; let mono = 'monospace'; let bg = PathLayer('bg') #{ fill: bg_color; stroke: none; }; bg.apply { rect(0, 0, 480, 260); } // ─── The panel, in millimetres. HP is the parameter. ────────── let hp = 6; let height = 128.5; let unit = 5.08; let width = hp * unit - 0.3; // A rail slot: 5.5 × 3.2, centred where the rail standard wants it. fn slot(cx, cy) { roundRect(cx - 2.75, cy - 1.6, 5.5, 3.2, 1.6); } // Every hole the panel needs, named where it is drawn. Jacks are ⌀6, // pots ⌀7, the LED ⌀3; columns sit on quarters of whatever the width is. fn drawHoles(holes) { let mid = width / 2; let left = width / 4; let right = width * 3 / 4; holes.apply { slot(7.5, 3); slot(7.5, height - 3); if (hp >= 10) { slot(width - 7.5, 3); slot(width - 7.5, height - 3); } circle(mid, 24, 3.5) as segment('rate'); circle(mid, 48, 3.5) as segment('depth'); circle(mid, 64, 1.5) as segment('led'); circle(left, 82, 3) as segment('in'); circle(right, 82, 3) as segment('cv'); circle(left, 98, 3) as segment('out-a'); circle(right, 98, 3) as segment('out-b'); circle(left, 114, 3) as segment('sync'); circle(right, 114, 3) as segment('gate'); } } fn drawOutline(outline) { outline.apply { roundRect(0, 0, width, height, 1.5); } } // One decimal, sign kept, always shown — there is no toFixed yet. fn mm(value) { let sign = ''; if (value < 0) { sign = '-'; } let tenths = round(abs(value) * 10); let whole = floor(tenths / 10); return `${sign}${whole}.${tenths - whole * 10}`; } // ─── Left panel: page position and scale, children in millimetres ── let leftPanel = GroupLayer('left') #{ translate-x: 80; translate-y: 40; scale: 1.4; }; let leftOutline = PathLayer('left-outline') #{ stroke: fg_auto; stroke-width: 0.6; fill: none; }; let leftHoles = PathLayer('left-holes') #{ stroke: fg_auto; stroke-width: 0.5; fill: none; }; leftPanel.append(leftOutline, leftHoles); // ─── Right panel: page position and scale, children in millimetres ── let rightPanel = GroupLayer('right') #{ translate-x: 220; translate-y: 40; scale: 1.4; }; let rightOutline = PathLayer('right-outline') #{ stroke: fg_auto; stroke-width: 0.6; fill: none; }; let rightHoles = PathLayer('right-holes') #{ stroke: fg_auto; stroke-width: 0.5; fill: none; }; let rightArcs = PathLayer('right-arcs') #{ stroke: legend; stroke-width: 0.4; fill: none; }; let rightLegend = TextLayer('right-legend') #{ font-family: font; font-size: 3.6; font-weight: 700; letter-spacing: 0.3; fill: legend; text-anchor: middle; }; rightPanel.append(rightOutline, rightHoles, rightArcs, rightLegend); // ─── Captions, on the page ───────────────────────────────────── let leftEyebrow = TextLayer('left-eyebrow') #{ font-family: font; font-size: 8; font-weight: 700; letter-spacing: 3; fill: fg_muted; text-anchor: start; }; let leftNote = TextLayer('left-note') #{ font-family: font; font-size: 8; letter-spacing: 0.5; fill: fg_auto; text-anchor: start; }; let rightEyebrow = TextLayer('right-eyebrow') #{ font-family: font; font-size: 8; font-weight: 700; letter-spacing: 3; fill: fg_muted; text-anchor: start; }; let rightNote = TextLayer('right-note') #{ font-family: font; font-size: 8; letter-spacing: 0.5; fill: fg_auto; text-anchor: start; }; // A pot's scale: a 270° arc round the hole with a tick at each end and one // at noon. The arc's radius is the hole's plus a margin — both from the block. fn scaleArc(hole) { let centre = hole.block.centerPoint(); let r = hole.block.boundingBox().width / 2 + 2.2; let startX = centre.x + r * cos(135deg); let startY = centre.y + r * sin(135deg); let endX = centre.x + r * cos(45deg); let endY = centre.y + r * sin(45deg); rightArcs.apply { M startX startY A r r 0 1 1 endX endY M startX startY L calc(centre.x + (r + 1.2) * cos(135deg)) calc(centre.y + (r + 1.2) * sin(135deg)) M endX endY L calc(centre.x + (r + 1.2) * cos(45deg)) calc(centre.y + (r + 1.2) * sin(45deg)) M centre.x calc(centre.y - r) L centre.x calc(centre.y - r - 1.2) } } drawOutline(leftOutline); drawHoles(leftHoles); drawOutline(rightOutline); drawHoles(rightHoles); // Names came from the form: every circle() was drawn `as segment('…')`. for (control in rightHoles.queryAll('segment')) { let centre = control.block.centerPoint(); let r = control.block.boundingBox().width / 2; rightLegend.apply { text(centre.x, centre.y + r + 5.4)`${control.label}`; } } for (pot in rightHoles.queryAll('segment(rate), segment(depth)')) { scaleArc(pot); } leftEyebrow.apply { text(80, 30)`THE FORM`; } leftNote.apply { text(80, 242)`holes only`; } rightEyebrow.apply { text(220, 30)`THE LEGEND`; } rightNote.apply { text(220, 242)`names from segment, arcs from the pots`; } // ─── Divider ─────────────────────────────────────────────────── let divider = PathLayer('divider') #{ stroke: fg_hair; stroke-width: 0.5; fill: none; }; divider.apply { M 190 30 L 190 235 } The legend layer: a name under every named control, and a scale arc with three ticks round each pot, both placed from the hole's centre and diameter.

HP is the parameter

Change hp from 6 to 10; nothing about the panel is touched. The page positions shift a little to fit the wider outline. The outline widens to 50.5 mm and the two jack columns re-flow onto the wider quarters. From 10 HP the sample adds a second pair of rail slots at the right edge, the way wide panels are usually mounted. The schedule prints the new coordinates.

//-- HP is the parameter. The same file at 10HP: the outline widens, a second //-- pair of rail slots appears, the columns re-flow, and the schedule follows. define ViewBox(0, 0, 480, 260); // ─── Core tokens ─────────────────────────────────────────────── let bg_color = Color(CSSVar('--bg', #d0d7f0)); let fg_auto = Color('#0d1638'); let fg_muted = Color('#0d1638').alpha(0.6); let fg_hair = Color('#0d1638').alpha(0.22); let points = oklch(0.55 0.16 260); let font = 'sans-serif'; let mono = 'monospace'; let bg = PathLayer('bg') #{ fill: bg_color; stroke: none; }; bg.apply { rect(0, 0, 480, 260); } // ─── The panel, in millimetres. HP is the parameter. ────────── let hp = 10; let height = 128.5; let unit = 5.08; let width = hp * unit - 0.3; // A rail slot: 5.5 × 3.2, centred where the rail standard wants it. fn slot(cx, cy) { roundRect(cx - 2.75, cy - 1.6, 5.5, 3.2, 1.6); } // Every hole the panel needs, named where it is drawn. Jacks are ⌀6, // pots ⌀7, the LED ⌀3; columns sit on quarters of whatever the width is. fn drawHoles(holes) { let mid = width / 2; let left = width / 4; let right = width * 3 / 4; holes.apply { slot(7.5, 3); slot(7.5, height - 3); if (hp >= 10) { slot(width - 7.5, 3); slot(width - 7.5, height - 3); } circle(mid, 24, 3.5) as segment('rate'); circle(mid, 48, 3.5) as segment('depth'); circle(mid, 64, 1.5) as segment('led'); circle(left, 82, 3) as segment('in'); circle(right, 82, 3) as segment('cv'); circle(left, 98, 3) as segment('out-a'); circle(right, 98, 3) as segment('out-b'); circle(left, 114, 3) as segment('sync'); circle(right, 114, 3) as segment('gate'); } } fn drawOutline(outline) { outline.apply { roundRect(0, 0, width, height, 1.5); } } // One decimal, sign kept, always shown — there is no toFixed yet. fn mm(value) { let sign = ''; if (value < 0) { sign = '-'; } let tenths = round(abs(value) * 10); let whole = floor(tenths / 10); return `${sign}${whole}.${tenths - whole * 10}`; } // ─── Left panel: page position and scale, children in millimetres ── let leftPanel = GroupLayer('left') #{ translate-x: 60; translate-y: 40; scale: 1.4; }; let leftOutline = PathLayer('left-outline') #{ stroke: fg_auto; stroke-width: 0.6; fill: none; }; let leftHoles = PathLayer('left-holes') #{ stroke: fg_auto; stroke-width: 0.5; fill: none; }; leftPanel.append(leftOutline, leftHoles); // ─── Right panel: page position and scale, children in millimetres ── let rightPanel = GroupLayer('right') #{ translate-x: 210; translate-y: 40; scale: 1.4; }; let rightOutline = PathLayer('right-outline') #{ stroke: fg_auto; stroke-width: 0.6; fill: none; }; let rightHoles = PathLayer('right-holes') #{ stroke: fg_auto; stroke-width: 0.5; fill: none; }; let rightCrosses = PathLayer('right-crosses') #{ stroke: points; stroke-width: 0.35; fill: none; }; let rightNumbers = TextLayer('right-numbers') #{ font-family: font; font-size: 3.6; font-weight: 700; letter-spacing: 0.2; fill: points; text-anchor: start; }; let rightColumns = TextLayer('right-columns') #{ font-family: mono; font-size: 4.6; letter-spacing: 0; fill: fg_auto; text-anchor: end; }; let rightRows = TextLayer('right-rows') #{ font-family: mono; font-size: 4.6; letter-spacing: 0; fill: fg_muted; text-anchor: start; }; rightPanel.append(rightOutline, rightHoles, rightCrosses, rightNumbers, rightColumns, rightRows); // ─── Captions, on the page ───────────────────────────────────── let leftEyebrow = TextLayer('left-eyebrow') #{ font-family: font; font-size: 8; font-weight: 700; letter-spacing: 3; fill: fg_muted; text-anchor: start; }; let leftNote = TextLayer('left-note') #{ font-family: font; font-size: 8; letter-spacing: 0.5; fill: fg_auto; text-anchor: start; }; let rightEyebrow = TextLayer('right-eyebrow') #{ font-family: font; font-size: 8; font-weight: 700; letter-spacing: 3; fill: fg_muted; text-anchor: start; }; let rightNote = TextLayer('right-note') #{ font-family: font; font-size: 8; letter-spacing: 0.5; fill: fg_auto; text-anchor: start; }; // Declared before a hole is drawn: each circle() the twin's holes layer // records gets a centre cross and its number just outside its rim. rightHoles.subscribe('call(circle)') {|hole, i| let centre = hole.block.centerPoint(); rightCrosses.apply { M calc(centre.x - 1.2) centre.y L calc(centre.x + 1.2) centre.y M centre.x calc(centre.y - 1.2) L centre.x calc(centre.y + 1.2) } let r = hole.block.boundingBox().width / 2; rightNumbers.apply { text(centre.x + r + 0.6, centre.y - r + 1.2)`${i + 1}`; } }; // The drill schedule: one row per hole, in drawing order, every value read // from the hole's own block — its centre and its bounding box. The number // columns are end-anchored so the decimal points line up. fn schedule(source, col, rows, x0, y0) { let holes = source.queryAll('call(circle)'); col.apply { text(x0 + 2, y0)`#`; text(x0 + 13, y0)`⌀`; text(x0 + 26, y0)`x`; text(x0 + 42, y0)`y`; for ([hole, i] in holes) { let centre = hole.block.centerPoint(); let box = hole.block.boundingBox(); let y = y0 + 6 * (i + 1); text(x0 + 2, y)`${i + 1}`; text(x0 + 13, y)`${mm(box.width)}`; text(x0 + 26, y)`${mm(centre.x)}`; text(x0 + 42, y)`${mm(centre.y)}`; } } let slots = source.queryAll('call(slot)'); let slotBox = source.query('call(slot)').block.boundingBox(); rows.apply { text(x0, y0 + 6 * (holes.length + 2))`${slots.length} rail slots ${mm(slotBox.width)} × ${mm(slotBox.height)}`; text(x0, y0 + 6 * (holes.length + 3))`${holes.length} holes, ${hp}HP, ${mm(width)} × ${height}`; } } drawOutline(leftOutline); drawHoles(leftHoles); drawOutline(rightOutline); drawHoles(rightHoles); schedule(rightHoles, rightColumns, rightRows, 60, 8); leftEyebrow.apply { text(60, 30)`THE FORM, ${hp}HP`; } leftNote.apply { text(60, 242)`hp = ${hp}: four slots now`; } rightEyebrow.apply { text(210, 30)`THE SCHEDULE FOLLOWS`; } rightNote.apply { text(210, 242)`nothing else in the file changed`; } // ─── Divider ─────────────────────────────────────────────────── let divider = PathLayer('divider') #{ stroke: fg_hair; stroke-width: 0.5; fill: none; }; divider.apply { M 190 30 L 190 235 } The same file at hp = 10: a wider outline, four rail slots, the columns re-flowed, and a schedule with the new coordinates and the new slot count. Nothing in the panel's own definition changed.

The handoff

Two drawings from one source, side by side: the panel the builder sees, with its legend, and the drill drawing the panel shop gets, with its numbered holes and its schedule. The left panel is no longer the bare form. It is the form plus its legend, which is what the builder is handed.

//-- The handoff drawing: the panel with its legend on the left, the drill //-- drawing with numbered holes and the schedule on the right — one source. define ViewBox(0, 0, 480, 260); // ─── Core tokens ─────────────────────────────────────────────── let bg_color = Color(CSSVar('--bg', #d0d7f0)); let fg_auto = Color('#0d1638'); let fg_muted = Color('#0d1638').alpha(0.6); let fg_hair = Color('#0d1638').alpha(0.22); let points = oklch(0.55 0.16 260); let legend = oklch(0.55 0.16 200); let font = 'sans-serif'; let mono = 'monospace'; let bg = PathLayer('bg') #{ fill: bg_color; stroke: none; }; bg.apply { rect(0, 0, 480, 260); } // ─── The panel, in millimetres. HP is the parameter. ────────── let hp = 6; let height = 128.5; let unit = 5.08; let width = hp * unit - 0.3; // A rail slot: 5.5 × 3.2, centred where the rail standard wants it. fn slot(cx, cy) { roundRect(cx - 2.75, cy - 1.6, 5.5, 3.2, 1.6); } // Every hole the panel needs, named where it is drawn. Jacks are ⌀6, // pots ⌀7, the LED ⌀3; columns sit on quarters of whatever the width is. fn drawHoles(holes) { let mid = width / 2; let left = width / 4; let right = width * 3 / 4; holes.apply { slot(7.5, 3); slot(7.5, height - 3); if (hp >= 10) { slot(width - 7.5, 3); slot(width - 7.5, height - 3); } circle(mid, 24, 3.5) as segment('rate'); circle(mid, 48, 3.5) as segment('depth'); circle(mid, 64, 1.5) as segment('led'); circle(left, 82, 3) as segment('in'); circle(right, 82, 3) as segment('cv'); circle(left, 98, 3) as segment('out-a'); circle(right, 98, 3) as segment('out-b'); circle(left, 114, 3) as segment('sync'); circle(right, 114, 3) as segment('gate'); } } fn drawOutline(outline) { outline.apply { roundRect(0, 0, width, height, 1.5); } } // One decimal, sign kept, always shown — there is no toFixed yet. fn mm(value) { let sign = ''; if (value < 0) { sign = '-'; } let tenths = round(abs(value) * 10); let whole = floor(tenths / 10); return `${sign}${whole}.${tenths - whole * 10}`; } // ─── Left panel: page position and scale, children in millimetres ── let leftPanel = GroupLayer('left') #{ translate-x: 80; translate-y: 40; scale: 1.4; }; let leftOutline = PathLayer('left-outline') #{ stroke: fg_auto; stroke-width: 0.6; fill: none; }; let leftHoles = PathLayer('left-holes') #{ stroke: fg_auto; stroke-width: 0.5; fill: none; }; let leftArcs = PathLayer('left-arcs') #{ stroke: legend; stroke-width: 0.4; fill: none; }; let leftLegend = TextLayer('left-legend') #{ font-family: font; font-size: 3.6; font-weight: 700; letter-spacing: 0.3; fill: legend; text-anchor: middle; }; leftPanel.append(leftOutline, leftHoles, leftArcs, leftLegend); // ─── Right panel: page position and scale, children in millimetres ── let rightPanel = GroupLayer('right') #{ translate-x: 220; translate-y: 40; scale: 1.4; }; let rightOutline = PathLayer('right-outline') #{ stroke: fg_auto; stroke-width: 0.6; fill: none; }; let rightHoles = PathLayer('right-holes') #{ stroke: fg_auto; stroke-width: 0.5; fill: none; }; let rightCrosses = PathLayer('right-crosses') #{ stroke: points; stroke-width: 0.35; fill: none; }; let rightNumbers = TextLayer('right-numbers') #{ font-family: font; font-size: 3.6; font-weight: 700; letter-spacing: 0.2; fill: points; text-anchor: start; }; let rightColumns = TextLayer('right-columns') #{ font-family: mono; font-size: 4.6; letter-spacing: 0; fill: fg_auto; text-anchor: end; }; let rightRows = TextLayer('right-rows') #{ font-family: mono; font-size: 4.6; letter-spacing: 0; fill: fg_muted; text-anchor: start; }; rightPanel.append(rightOutline, rightHoles, rightCrosses, rightNumbers, rightColumns, rightRows); // ─── Captions, on the page ───────────────────────────────────── let leftEyebrow = TextLayer('left-eyebrow') #{ font-family: font; font-size: 8; font-weight: 700; letter-spacing: 3; fill: fg_muted; text-anchor: start; }; let leftNote = TextLayer('left-note') #{ font-family: font; font-size: 8; letter-spacing: 0.5; fill: fg_auto; text-anchor: start; }; let rightEyebrow = TextLayer('right-eyebrow') #{ font-family: font; font-size: 8; font-weight: 700; letter-spacing: 3; fill: fg_muted; text-anchor: start; }; let rightNote = TextLayer('right-note') #{ font-family: font; font-size: 8; letter-spacing: 0.5; fill: fg_auto; text-anchor: start; }; fn scaleArc(hole, arcs) { let centre = hole.block.centerPoint(); let r = hole.block.boundingBox().width / 2 + 2.2; let startX = centre.x + r * cos(135deg); let startY = centre.y + r * sin(135deg); let endX = centre.x + r * cos(45deg); let endY = centre.y + r * sin(45deg); arcs.apply { M startX startY A r r 0 1 1 endX endY M startX startY L calc(centre.x + (r + 1.2) * cos(135deg)) calc(centre.y + (r + 1.2) * sin(135deg)) M endX endY L calc(centre.x + (r + 1.2) * cos(45deg)) calc(centre.y + (r + 1.2) * sin(45deg)) M centre.x calc(centre.y - r) L centre.x calc(centre.y - r - 1.2) } } // Declared before a hole is drawn: each circle() the twin's holes layer // records gets a centre cross and its number just outside its rim. rightHoles.subscribe('call(circle)') {|hole, i| let centre = hole.block.centerPoint(); rightCrosses.apply { M calc(centre.x - 1.2) centre.y L calc(centre.x + 1.2) centre.y M centre.x calc(centre.y - 1.2) L centre.x calc(centre.y + 1.2) } let r = hole.block.boundingBox().width / 2; rightNumbers.apply { text(centre.x + r + 0.6, centre.y - r + 1.2)`${i + 1}`; } }; // The drill schedule: one row per hole, in drawing order, every value read // from the hole's own block — its centre and its bounding box. The number // columns are end-anchored so the decimal points line up. fn schedule(source, col, rows, x0, y0) { let holes = source.queryAll('call(circle)'); col.apply { text(x0 + 2, y0)`#`; text(x0 + 13, y0)`⌀`; text(x0 + 26, y0)`x`; text(x0 + 42, y0)`y`; for ([hole, i] in holes) { let centre = hole.block.centerPoint(); let box = hole.block.boundingBox(); let y = y0 + 6 * (i + 1); text(x0 + 2, y)`${i + 1}`; text(x0 + 13, y)`${mm(box.width)}`; text(x0 + 26, y)`${mm(centre.x)}`; text(x0 + 42, y)`${mm(centre.y)}`; } } let slots = source.queryAll('call(slot)'); let slotBox = source.query('call(slot)').block.boundingBox(); rows.apply { text(x0, y0 + 6 * (holes.length + 2))`${slots.length} rail slots ${mm(slotBox.width)} × ${mm(slotBox.height)}`; text(x0, y0 + 6 * (holes.length + 3))`${holes.length} holes, ${hp}HP, ${mm(width)} × ${height}`; } } drawOutline(leftOutline); drawHoles(leftHoles); drawOutline(rightOutline); drawHoles(rightHoles); // The legend on the finished panel; the schedule on the drill drawing. for (control in leftHoles.queryAll('segment')) { let centre = control.block.centerPoint(); let r = control.block.boundingBox().width / 2; leftLegend.apply { text(centre.x, centre.y + r + 5.4)`${control.label}`; } } for (pot in leftHoles.queryAll('segment(rate), segment(depth)')) { scaleArc(pot, leftArcs); } schedule(rightHoles, rightColumns, rightRows, 40, 8); leftEyebrow.apply { text(80, 30)`THE PANEL`; } leftNote.apply { text(80, 242)`what the builder sees`; } rightEyebrow.apply { text(220, 30)`THE DRILL DRAWING`; } rightNote.apply { text(220, 242)`what the panel shop gets`; } // ─── Divider ─────────────────────────────────────────────────── let divider = PathLayer('divider') #{ stroke: fg_hair; stroke-width: 0.5; fill: none; }; divider.apply { M 190 30 L 190 235 } Left: the finished panel with its legend. Right: the drill drawing with numbered holes and the schedule. One form, two audiences, no copying.

What this project taught the language

This series doubles as a working friction log (part 1 explains the convention). The panel was gentler than the linkage: two entries, both about numbers as text.

There is no toFixed. A drill schedule wants 7.0, not 7, and 22.6, not 22.60000000000001. round() gives an integer, so the samples carry a nine-line mm() helper that rounds to tenths, keeps the sign, and glues the whole and the fraction back together. It is fine for one decimal and tedious for three, and its first draft was wrong for negative numbers, which part 5's fanned board found. A toFixed(n) on numbers, or a format argument on text, is the obvious addition.

Per-text styles exist, and the samples missed them. The schedule wanted right-aligned numbers beside a left-aligned footer, and the samples answer with two text layers, because text-anchor looked like a layer property. It is also a per-call one: text() takes an optional style block as its fourth argument, text(x, y, 0deg, #{ text-anchor: end; }). The layers reference says so, in a section two hundred lines below the one that introduces text(), and that introduction now points at it. A column primitive, tab stops on a text layer, would still spare a table its arithmetic, and it is logged.

Where to go next

Part 5 ends the series on the luthier's bench: a fretboard whose every slot is placed from one scale length by the twelfth root of two. The twin numbers the frets, prints their distances from the nut, and drops the marker dots by :nth range, then does it all again for a fanned-fret board with two scale lengths.

The reference pages are Path Queries and Subscriptions. Every sample above opens in the playground with one click: change hp and watch the schedule follow.