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
- Ask the Path —
query()andqueryAll()- Thinking and Drawing in Parallel —
subscribe()- A Linkage That Dimensions Itself — a four-bar linkage
- The Panel Prints Its Own Drill Schedule (this post) — a Eurorack front panel
- 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
callnoun 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 theirscaleare 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
}
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
}
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
}
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
}
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 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
}
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.