The Fretboard Is a Formula
Part 5 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 — a Eurorack front panel
- The Fretboard Is a Formula (this post) — 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 millimetre trick from part 4: a group layer carries the page position and scale, and everything inside it is drawn in real units. The fretboard is long and thin, so the twin sits below the form instead of beside it.
A guitar's scale length is the vibrating length of an open string, from the nut at the headstock end to the saddle at the bridge; the saddle is then set a little past it for intonation, which is a separate problem. Every fret follows from the scale length. Fret n sits where the remaining string is n twelfths of an octave shorter, which is the scale length divided by the twelfth root of two, n times over. That root is about 1.0595, the step from one semitone to the next, and twelve of them double the pitch. What the division takes off the scale is the fret's distance from the nut:
fn fretFrom(scale, n) {
return scale - scale / pow(2, n / 12);
}
That is the whole of equal temperament, and the whole of a fretboard template. Builders get the numbers from a web calculator and mark the board by hand. A fanned-fret or multi-scale instrument — a longer scale on the bass side than the treble — doubles the arithmetic and the chances of a slot in the wrong place. This post draws the board from the formula and lets the twin do the luthier's tables.
The form knows where the octave is
The form is a tapered outline and one slot per fret: a for loop over
22 frets, each slot a line from the top edge to the bottom edge at the
formula's x. The whole board is drawn in millimetres inside a scaled
group, and the nut is at x = 0. Every slot's x is therefore its
distance from the nut, with nothing to convert.
//-- A 22-fret fretboard drawn in millimetres from one scale length: the
//-- taper and every slot follow from the twelfth root of two. Below: what
//-- the slots layer can already say about itself.
define ViewBox(0, 0, 480, 300);
// ─── 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 marks = oklch(0.55 0.16 80);
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 480, 300);
}
// ─── The fretboard, in millimetres. One scale length is the design. ─
let scaleLength = 647.7;
let fretCount = 22;
let nutWidth = 43;
let endWidth = 56;
// Equal temperament: fret n sits where the remaining string is n twelfths
// of an octave shorter; what is taken off the scale is the fret's distance
// from the nut.
fn fretFrom(scale, n) {
return scale - scale / pow(2, n / 12);
}
fn boardLengthFor(scale) {
return fretFrom(scale, fretCount) + 10;
}
fn widthAt(x, length) {
return nutWidth + (endWidth - nutWidth) * x / length;
}
// The form: a tapered outline and one slot per fret, nut at x = 0.
fn drawBoard(outline, slots, scale) {
let length = boardLengthFor(scale);
outline.apply {
M 0 0
L length 0
L length endWidth
L 0 nutWidth
z
}
slots.apply {
for (n in 1..fretCount) {
let x = fretFrom(scale, n);
let w = widthAt(x, length);
M x 0
L x w
}
}
}
// Two decimals, sign kept, always shown — there is no toFixed yet.
fn mm(value) {
let sign = '';
if (value < 0) {
sign = '-';
}
let hundredths = round(abs(value) * 100);
let whole = floor(hundredths / 100);
let frac = hundredths - whole * 100;
let pad = '';
if (frac < 10) {
pad = '0';
}
return `${sign}${whole}.${pad}${frac}`;
}
// ─── Top board: page position and scale, children in millimetres ──
let topPanel = GroupLayer('top') #{
translate-x: 25;
translate-y: 62;
scale: 0.9;
};
let topOutline = PathLayer('top-outline') #{
stroke: fg_auto;
stroke-width: 0.8;
fill: none;
};
let topSlots = PathLayer('top-slots') #{
stroke: fg_auto;
stroke-width: 0.6;
fill: none;
};
topPanel.append(topOutline, topSlots);
// ─── Bottom board: page position and scale, children in millimetres ──
let bottomPanel = GroupLayer('bottom') #{
translate-x: 25;
translate-y: 178;
scale: 0.9;
};
let bottomOutline = PathLayer('bottom-outline') #{
stroke: fg_auto;
stroke-width: 0.8;
fill: none;
};
let bottomSlots = PathLayer('bottom-slots') #{
stroke: fg_auto;
stroke-width: 0.6;
fill: none;
};
let bottomOctave = PathLayer('bottom-octave') #{
stroke: marks;
stroke-width: 1.4;
fill: none;
};
let bottomTable = TextLayer('bottom-table') #{
font-family: font;
font-size: 5.5;
font-weight: 700;
letter-spacing: 0.3;
fill: points;
text-anchor: start;
};
bottomPanel.append(bottomOutline, bottomSlots, bottomOctave, bottomTable);
// ─── Captions, on the page ─────────────────────────────────────
let topEyebrow = TextLayer('top-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let topNote = TextLayer('top-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
let bottomEyebrow = TextLayer('bottom-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let bottomNote = TextLayer('bottom-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
drawBoard(topOutline, topSlots, scaleLength);
drawBoard(bottomOutline, bottomSlots, scaleLength);
// Asked, not computed twice: the octave slot is the twelfth line, and
// it sits at half the scale length.
let frets = bottomSlots.queryAll('command(line)');
let octave = bottomSlots.query('command(line):nth(11)');
let last = bottomSlots.query('command(line):last');
bottomOctave.apply {
octave.block.draw();
}
bottomTable.apply {
text(0, 68)`${frets.length} slots · octave at ${mm(octave.start.x)} mm, half of ${scaleLength} · fret ${frets.length} at ${mm(last.start.x)} mm`;
}
topEyebrow.apply {
text(25, 48)`THE FORM, 25.5 IN`;
}
topNote.apply {
text(25, 134)`scale 647.7 mm, 22 frets, 43 to 56 mm wide`;
}
bottomEyebrow.apply {
text(25, 166)`COMMAND(LINE), ASKED`;
}
bottomNote.apply {
text(25, 282)`the octave check, from the twelfth slot`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 25 150
L 455 150
}
The line under the second board is the octave check every luthier does
by eye. command(line):nth(11) is the twelfth slot; its block is
drawn back in gold and its start.x is 323.85, half of 647.7. Nothing
computed it twice.
Numbered by ordinal
A subscription on command(line) delivers every slot in drawing order,
and its second parameter is the ordinal among the matches. The fret
number is that ordinal plus one, and the slot's own start.x is where
it goes:
slots.subscribe('command(line)') {|fret, i|
numbers.apply {
text(fret.start.x, -2.5)`${i + 1}`;
}
};
//-- Every fret numbered by a subscription on command(line): the slot's own
//-- x places the number, and the subscription's ordinal is the fret.
define ViewBox(0, 0, 480, 300);
// ─── 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 bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 480, 300);
}
// ─── The fretboard, in millimetres. One scale length is the design. ─
let scaleLength = 647.7;
let fretCount = 22;
let nutWidth = 43;
let endWidth = 56;
// Equal temperament: fret n sits where the remaining string is n twelfths
// of an octave shorter; what is taken off the scale is the fret's distance
// from the nut.
fn fretFrom(scale, n) {
return scale - scale / pow(2, n / 12);
}
fn boardLengthFor(scale) {
return fretFrom(scale, fretCount) + 10;
}
fn widthAt(x, length) {
return nutWidth + (endWidth - nutWidth) * x / length;
}
// The form: a tapered outline and one slot per fret, nut at x = 0.
fn drawBoard(outline, slots, scale) {
let length = boardLengthFor(scale);
outline.apply {
M 0 0
L length 0
L length endWidth
L 0 nutWidth
z
}
slots.apply {
for (n in 1..fretCount) {
let x = fretFrom(scale, n);
let w = widthAt(x, length);
M x 0
L x w
}
}
}
// Two decimals, sign kept, always shown — there is no toFixed yet.
fn mm(value) {
let sign = '';
if (value < 0) {
sign = '-';
}
let hundredths = round(abs(value) * 100);
let whole = floor(hundredths / 100);
let frac = hundredths - whole * 100;
let pad = '';
if (frac < 10) {
pad = '0';
}
return `${sign}${whole}.${pad}${frac}`;
}
// ─── Top board: page position and scale, children in millimetres ──
let topPanel = GroupLayer('top') #{
translate-x: 25;
translate-y: 62;
scale: 0.9;
};
let topOutline = PathLayer('top-outline') #{
stroke: fg_auto;
stroke-width: 0.8;
fill: none;
};
let topSlots = PathLayer('top-slots') #{
stroke: fg_auto;
stroke-width: 0.6;
fill: none;
};
topPanel.append(topOutline, topSlots);
// ─── Bottom board: page position and scale, children in millimetres ──
let bottomPanel = GroupLayer('bottom') #{
translate-x: 25;
translate-y: 178;
scale: 0.9;
};
let bottomOutline = PathLayer('bottom-outline') #{
stroke: fg_auto;
stroke-width: 0.8;
fill: none;
};
let bottomSlots = PathLayer('bottom-slots') #{
stroke: fg_auto;
stroke-width: 0.6;
fill: none;
};
let bottomNumbers = TextLayer('bottom-numbers') #{
font-family: font;
font-size: 6;
font-weight: 700;
letter-spacing: 0.2;
fill: points;
text-anchor: middle;
};
bottomPanel.append(bottomOutline, bottomSlots, bottomNumbers);
// ─── Captions, on the page ─────────────────────────────────────
let topEyebrow = TextLayer('top-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let topNote = TextLayer('top-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
let bottomEyebrow = TextLayer('bottom-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let bottomNote = TextLayer('bottom-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
// Declared before a slot is drawn: every line the slots layer records is
// a fret, and the subscription's ordinal is the fret number, less one.
fn numberFrets(slots, numbers) {
slots.subscribe('command(line)') {|fret, i|
numbers.apply {
text(fret.start.x, -2.5)`${i + 1}`;
}
};
}
numberFrets(bottomSlots, bottomNumbers);
drawBoard(topOutline, topSlots, scaleLength);
drawBoard(bottomOutline, bottomSlots, scaleLength);
topEyebrow.apply {
text(25, 48)`THE FORM`;
}
topNote.apply {
text(25, 134)`22 slots, unnumbered`;
}
bottomEyebrow.apply {
text(25, 166)`COMMAND(LINE), SUBSCRIBED`;
}
bottomNote.apply {
text(25, 282)`the ordinal, plus one`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 25 150
L 455 150
}
The distance column
The table a luthier wants is the distance of every slot from the nut, to
a hundredth of a millimetre. It is the same subscription with a
different callback, and the number is fret.start.x itself. The label
hangs below its slot, turned a quarter turn at its anchor with the
third argument of text(), so 22 of them fit under a board 430 pixels
wide.
//-- The luthier's table, printed under the board: each slot's distance from
//-- the nut to a hundredth of a millimetre, read from the slot itself.
define ViewBox(0, 0, 480, 300);
// ─── 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, 300);
}
// ─── The fretboard, in millimetres. One scale length is the design. ─
let scaleLength = 647.7;
let fretCount = 22;
let nutWidth = 43;
let endWidth = 56;
// Equal temperament: fret n sits where the remaining string is n twelfths
// of an octave shorter; what is taken off the scale is the fret's distance
// from the nut.
fn fretFrom(scale, n) {
return scale - scale / pow(2, n / 12);
}
fn boardLengthFor(scale) {
return fretFrom(scale, fretCount) + 10;
}
fn widthAt(x, length) {
return nutWidth + (endWidth - nutWidth) * x / length;
}
// The form: a tapered outline and one slot per fret, nut at x = 0.
fn drawBoard(outline, slots, scale) {
let length = boardLengthFor(scale);
outline.apply {
M 0 0
L length 0
L length endWidth
L 0 nutWidth
z
}
slots.apply {
for (n in 1..fretCount) {
let x = fretFrom(scale, n);
let w = widthAt(x, length);
M x 0
L x w
}
}
}
// Two decimals, sign kept, always shown — there is no toFixed yet.
fn mm(value) {
let sign = '';
if (value < 0) {
sign = '-';
}
let hundredths = round(abs(value) * 100);
let whole = floor(hundredths / 100);
let frac = hundredths - whole * 100;
let pad = '';
if (frac < 10) {
pad = '0';
}
return `${sign}${whole}.${pad}${frac}`;
}
// ─── Top board: page position and scale, children in millimetres ──
let topPanel = GroupLayer('top') #{
translate-x: 25;
translate-y: 62;
scale: 0.9;
};
let topOutline = PathLayer('top-outline') #{
stroke: fg_auto;
stroke-width: 0.8;
fill: none;
};
let topSlots = PathLayer('top-slots') #{
stroke: fg_auto;
stroke-width: 0.6;
fill: none;
};
topPanel.append(topOutline, topSlots);
// ─── Bottom board: page position and scale, children in millimetres ──
let bottomPanel = GroupLayer('bottom') #{
translate-x: 25;
translate-y: 178;
scale: 0.9;
};
let bottomOutline = PathLayer('bottom-outline') #{
stroke: fg_auto;
stroke-width: 0.8;
fill: none;
};
let bottomSlots = PathLayer('bottom-slots') #{
stroke: fg_auto;
stroke-width: 0.6;
fill: none;
};
let bottomNumbers = TextLayer('bottom-numbers') #{
font-family: font;
font-size: 6;
font-weight: 700;
letter-spacing: 0.2;
fill: points;
text-anchor: middle;
};
let bottomDistances = TextLayer('bottom-distances') #{
font-family: mono;
font-size: 5;
letter-spacing: 0;
fill: fg_muted;
text-anchor: end;
};
bottomPanel.append(bottomOutline, bottomSlots, bottomNumbers, bottomDistances);
// ─── Captions, on the page ─────────────────────────────────────
let topEyebrow = TextLayer('top-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let topNote = TextLayer('top-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
let bottomEyebrow = TextLayer('bottom-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let bottomNote = TextLayer('bottom-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
// Declared before a slot is drawn: every line the slots layer records is
// a fret, and the subscription's ordinal is the fret number, less one.
fn numberFrets(slots, numbers) {
slots.subscribe('command(line)') {|fret, i|
numbers.apply {
text(fret.start.x, -2.5)`${i + 1}`;
}
};
}
// The distance from the nut is the slot's own x. The label hangs below
// the board, turned a quarter turn at its anchor.
fn distanceLabels(slots, labels) {
slots.subscribe('command(line)') {|fret|
labels.apply {
text(fret.start.x + 2, fret.end.y + 3, -90deg)`${mm(fret.start.x)}`;
}
};
}
numberFrets(bottomSlots, bottomNumbers);
distanceLabels(bottomSlots, bottomDistances);
drawBoard(topOutline, topSlots, scaleLength);
drawBoard(bottomOutline, bottomSlots, scaleLength);
topEyebrow.apply {
text(25, 48)`THE FORM`;
}
topNote.apply {
text(25, 134)`one formula, once`;
}
bottomEyebrow.apply {
text(25, 166)`DISTANCE FROM THE NUT`;
}
bottomNote.apply {
text(25, 282)`fret.start.x, to 0.01 mm, turned -90deg at its anchor`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 25 150
L 455 150
}
There is no toFixed, so the samples carry part 4's mm() helper,
extended to two decimals and to negative numbers. Four lines became
thirteen; the friction log has said so twice now.
Marker dots by range
Marker dots — the inlays on the face of the board that tell a player's
hand where it is — sit at frets 3, 5, 7, 9, 12, 15, 17, 19 and 21, and
the twelfth gets two. Two things about them are worth being precise
about. The frets themselves are picked with one :nth list, and :nth
counts from zero, so fret 3 is :nth(2). A dot does not sit on its
fret. It sits in the space before it, halfway back to the previous slot,
on the board's centre line.
//-- Marker dots by range: the marker frets are picked with one :nth list
//-- (0-based), and each dot sits in the space before its fret.
define ViewBox(0, 0, 480, 300);
// ─── 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 marks = oklch(0.55 0.16 80);
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 480, 300);
}
// ─── The fretboard, in millimetres. One scale length is the design. ─
let scaleLength = 647.7;
let fretCount = 22;
let nutWidth = 43;
let endWidth = 56;
// Equal temperament: fret n sits where the remaining string is n twelfths
// of an octave shorter; what is taken off the scale is the fret's distance
// from the nut.
fn fretFrom(scale, n) {
return scale - scale / pow(2, n / 12);
}
fn boardLengthFor(scale) {
return fretFrom(scale, fretCount) + 10;
}
fn widthAt(x, length) {
return nutWidth + (endWidth - nutWidth) * x / length;
}
// The form: a tapered outline and one slot per fret, nut at x = 0.
fn drawBoard(outline, slots, scale) {
let length = boardLengthFor(scale);
outline.apply {
M 0 0
L length 0
L length endWidth
L 0 nutWidth
z
}
slots.apply {
for (n in 1..fretCount) {
let x = fretFrom(scale, n);
let w = widthAt(x, length);
M x 0
L x w
}
}
}
// Two decimals, sign kept, always shown — there is no toFixed yet.
fn mm(value) {
let sign = '';
if (value < 0) {
sign = '-';
}
let hundredths = round(abs(value) * 100);
let whole = floor(hundredths / 100);
let frac = hundredths - whole * 100;
let pad = '';
if (frac < 10) {
pad = '0';
}
return `${sign}${whole}.${pad}${frac}`;
}
// ─── Top board: page position and scale, children in millimetres ──
let topPanel = GroupLayer('top') #{
translate-x: 25;
translate-y: 62;
scale: 0.9;
};
let topOutline = PathLayer('top-outline') #{
stroke: fg_auto;
stroke-width: 0.8;
fill: none;
};
let topSlots = PathLayer('top-slots') #{
stroke: fg_auto;
stroke-width: 0.6;
fill: none;
};
topPanel.append(topOutline, topSlots);
// ─── Bottom board: page position and scale, children in millimetres ──
let bottomPanel = GroupLayer('bottom') #{
translate-x: 25;
translate-y: 178;
scale: 0.9;
};
let bottomOutline = PathLayer('bottom-outline') #{
stroke: fg_auto;
stroke-width: 0.8;
fill: none;
};
let bottomSlots = PathLayer('bottom-slots') #{
stroke: fg_auto;
stroke-width: 0.6;
fill: none;
};
let bottomMarks = PathLayer('bottom-marks') #{
stroke: marks;
stroke-width: 1.4;
fill: none;
};
let bottomDots = PathLayer('bottom-dots') #{
fill: points;
stroke: none;
};
let bottomNumbers = TextLayer('bottom-numbers') #{
font-family: font;
font-size: 6;
font-weight: 700;
letter-spacing: 0.2;
fill: points;
text-anchor: middle;
};
bottomPanel.append(bottomOutline,
bottomSlots,
bottomMarks,
bottomDots,
bottomNumbers);
// ─── Captions, on the page ─────────────────────────────────────
let topEyebrow = TextLayer('top-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let topNote = TextLayer('top-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
let bottomEyebrow = TextLayer('bottom-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let bottomNote = TextLayer('bottom-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
// Declared before a slot is drawn: every line the slots layer records is
// a fret, and the subscription's ordinal is the fret number, less one.
fn numberFrets(slots, numbers) {
slots.subscribe('command(line)') {|fret, i|
numbers.apply {
text(fret.start.x, -2.5)`${i + 1}`;
}
};
}
numberFrets(bottomSlots, bottomNumbers);
drawBoard(topOutline, topSlots, scaleLength);
drawBoard(bottomOutline, bottomSlots, scaleLength);
// Frets 3, 5, 7, 9, 12, 15, 17, 19 and 21 — as :nth counts them, from 0.
let markerFrets = [
3,
5,
7,
9,
12,
15,
17,
19,
21,
];
for (fret in bottomSlots.queryAll('command(line):nth(2, 4, 6, 8, 11, 14, 16, 18, 20)')) {
bottomMarks.apply {
fret.block.draw();
}
}
// A dot lives in the space before its fret: halfway back to the previous
// slot, on the board's centre line. The twelfth gets two.
let frets = bottomSlots.queryAll('command(line)');
for (n in markerFrets) {
let here = frets[n - 1];
let before = frets[n - 2];
let x = (here.start.x + before.start.x) / 2;
let w = (here.end.y + before.end.y) / 2;
bottomDots.apply {
if (n == 12) {
circle(x, w / 3, 2.2);
circle(x, w * 2 / 3, 2.2);
} else {
circle(x, w / 2, 2.2);
}
}
}
topEyebrow.apply {
text(25, 48)`THE FORM`;
}
topNote.apply {
text(25, 134)`no dots yet`;
}
bottomEyebrow.apply {
text(25, 166)`NTH, THEN THE SPACE BEFORE`;
}
bottomNote.apply {
text(25, 282)`:nth(2, 4, 6, 8, 11, 14, 16, 18, 20) · dots between`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 25 150
L 455 150
}
The tint is a query drawn back onto the board: each match's block is
the slot on its own, and fret.block.draw() puts it down in place in
a second colour. The dots need the slot before as well as the slot
itself, so they come from the full list by fret number, which reads the
way a luthier would say it. That leaves the nine frets written twice,
once for :nth from zero and once from one, because :nth takes
literal indices; the friction log has the note.
One scale length, or another
Change scaleLength and every slot, number and dot moves. The sample
draws two boards to make the comparison visible: a 25.5-inch scale on
top and a 24.75-inch scale below, the two most common choices, with the
same subscriptions and the same dot function on both.
//-- Change the scale length and every slot, number and dot moves. Above,
//-- a 25.5-inch board; below, the same file's annotations on a 24.75-inch
//-- board — 19 mm shorter at the octave, nothing else changed.
define ViewBox(0, 0, 480, 300);
// ─── 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 second = oklch(0.55 0.16 320);
let marks = oklch(0.55 0.16 80);
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 480, 300);
}
// ─── The fretboard, in millimetres. One scale length is the design. ─
let scaleLength = 647.7;
let fretCount = 22;
let nutWidth = 43;
let endWidth = 56;
// Equal temperament: fret n sits where the remaining string is n twelfths
// of an octave shorter; what is taken off the scale is the fret's distance
// from the nut.
fn fretFrom(scale, n) {
return scale - scale / pow(2, n / 12);
}
fn boardLengthFor(scale) {
return fretFrom(scale, fretCount) + 10;
}
fn widthAt(x, length) {
return nutWidth + (endWidth - nutWidth) * x / length;
}
// The form: a tapered outline and one slot per fret, nut at x = 0.
fn drawBoard(outline, slots, scale) {
let length = boardLengthFor(scale);
outline.apply {
M 0 0
L length 0
L length endWidth
L 0 nutWidth
z
}
slots.apply {
for (n in 1..fretCount) {
let x = fretFrom(scale, n);
let w = widthAt(x, length);
M x 0
L x w
}
}
}
// Two decimals, sign kept, always shown — there is no toFixed yet.
fn mm(value) {
let sign = '';
if (value < 0) {
sign = '-';
}
let hundredths = round(abs(value) * 100);
let whole = floor(hundredths / 100);
let frac = hundredths - whole * 100;
let pad = '';
if (frac < 10) {
pad = '0';
}
return `${sign}${whole}.${pad}${frac}`;
}
// ─── Top board: page position and scale, children in millimetres ──
let topPanel = GroupLayer('top') #{
translate-x: 25;
translate-y: 62;
scale: 0.9;
};
let topOutline = PathLayer('top-outline') #{
stroke: fg_auto;
stroke-width: 0.8;
fill: none;
};
let topSlots = PathLayer('top-slots') #{
stroke: fg_auto;
stroke-width: 0.6;
fill: none;
};
let topNumbers = TextLayer('top-numbers') #{
font-family: font;
font-size: 6;
font-weight: 700;
letter-spacing: 0.2;
fill: points;
text-anchor: middle;
};
let topOctave = PathLayer('top-octave') #{
stroke: marks;
stroke-width: 1.4;
fill: none;
};
let topDots = PathLayer('top-dots') #{
fill: points;
stroke: none;
};
topPanel.append(topOutline,
topSlots,
topOctave,
topNumbers,
topDots);
// ─── Bottom board: page position and scale, children in millimetres ──
let bottomPanel = GroupLayer('bottom') #{
translate-x: 25;
translate-y: 178;
scale: 0.9;
};
let bottomOutline = PathLayer('bottom-outline') #{
stroke: fg_auto;
stroke-width: 0.8;
fill: none;
};
let bottomSlots = PathLayer('bottom-slots') #{
stroke: fg_auto;
stroke-width: 0.6;
fill: none;
};
let bottomNumbers = TextLayer('bottom-numbers') #{
font-family: font;
font-size: 6;
font-weight: 700;
letter-spacing: 0.2;
fill: second;
text-anchor: middle;
};
let bottomOctave = PathLayer('bottom-octave') #{
stroke: marks;
stroke-width: 1.4;
fill: none;
};
let bottomDots = PathLayer('bottom-dots') #{
fill: second;
stroke: none;
};
bottomPanel.append(bottomOutline,
bottomSlots,
bottomOctave,
bottomNumbers,
bottomDots);
// ─── Captions, on the page ─────────────────────────────────────
let topEyebrow = TextLayer('top-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let topNote = TextLayer('top-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
let bottomEyebrow = TextLayer('bottom-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let bottomNote = TextLayer('bottom-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
let shorterScale = 628.65;
// Declared before a slot is drawn: every line the slots layer records is
// a fret, and the subscription's ordinal is the fret number, less one.
fn numberFrets(slots, numbers) {
slots.subscribe('command(line)') {|fret, i|
numbers.apply {
text(fret.start.x, -2.5)`${i + 1}`;
}
};
}
fn markerDots(slots, dots) {
let frets = slots.queryAll('command(line)');
for (n in [
3,
5,
7,
9,
12,
15,
17,
19,
21,
]) {
let here = frets[n - 1];
let before = frets[n - 2];
let x = (here.start.x + before.start.x) / 2;
let w = (here.end.y + before.end.y) / 2;
dots.apply {
if (n == 12) {
circle(x, w / 3, 2.2);
circle(x, w * 2 / 3, 2.2);
} else {
circle(x, w / 2, 2.2);
}
}
}
}
numberFrets(topSlots, topNumbers);
numberFrets(bottomSlots, bottomNumbers);
drawBoard(topOutline, topSlots, scaleLength);
drawBoard(bottomOutline, bottomSlots, shorterScale);
markerDots(topSlots, topDots);
markerDots(bottomSlots, bottomDots);
let octaveLong = topSlots.query('command(line):nth(11)');
let octaveShort = bottomSlots.query('command(line):nth(11)');
topOctave.apply {
octaveLong.block.draw();
}
bottomOctave.apply {
octaveShort.block.draw();
}
topEyebrow.apply {
text(25, 48)`25.5 IN · 647.7 MM`;
}
topNote.apply {
text(25, 134)`octave at ${mm(octaveLong.start.x)} mm`;
}
bottomEyebrow.apply {
text(25, 166)`24.75 IN · 628.65 MM`;
}
bottomNote.apply {
text(25, 282)`octave at ${mm(octaveShort.start.x)} mm — the same subscriptions, the same dots`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 25 150
L 455 150
}
The fanned finale
A fanned-fret board has two scale lengths, one for each edge, and one fret chosen to stand straight. Each slot runs from its bass-side position to its treble-side position, and the treble side is shifted so the chosen fret is perpendicular. The nut leans one way, the far end the other, and every slot in between leans a little differently.
//-- A fanned-fret board: two scale lengths, 27 inches on the bass side and
//-- 25.5 on the treble, with fret 7 perpendicular. Every slot leans a
//-- little differently, and the same subscription numbers them all.
define ViewBox(0, 0, 480, 300);
// ─── 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 marks = oklch(0.55 0.16 80);
let font = 'sans-serif';
let mono = 'monospace';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 480, 300);
}
// ─── The fretboard, in millimetres. One scale length is the design. ─
let scaleLength = 647.7;
let fretCount = 22;
let nutWidth = 43;
let endWidth = 56;
// Equal temperament: fret n sits where the remaining string is n twelfths
// of an octave shorter; what is taken off the scale is the fret's distance
// from the nut.
fn fretFrom(scale, n) {
return scale - scale / pow(2, n / 12);
}
fn boardLengthFor(scale) {
return fretFrom(scale, fretCount) + 10;
}
fn widthAt(x, length) {
return nutWidth + (endWidth - nutWidth) * x / length;
}
// The form: a tapered outline and one slot per fret, nut at x = 0.
fn drawBoard(outline, slots, scale) {
let length = boardLengthFor(scale);
outline.apply {
M 0 0
L length 0
L length endWidth
L 0 nutWidth
z
}
slots.apply {
for (n in 1..fretCount) {
let x = fretFrom(scale, n);
let w = widthAt(x, length);
M x 0
L x w
}
}
}
// Two decimals, sign kept, always shown — there is no toFixed yet.
fn mm(value) {
let sign = '';
if (value < 0) {
sign = '-';
}
let hundredths = round(abs(value) * 100);
let whole = floor(hundredths / 100);
let frac = hundredths - whole * 100;
let pad = '';
if (frac < 10) {
pad = '0';
}
return `${sign}${whole}.${pad}${frac}`;
}
// ─── Top board: page position and scale, children in millimetres ──
let topPanel = GroupLayer('top') #{
translate-x: 25;
translate-y: 62;
scale: 0.82;
};
let topOutline = PathLayer('top-outline') #{
stroke: fg_auto;
stroke-width: 0.8;
fill: none;
};
let topSlots = PathLayer('top-slots') #{
stroke: fg_auto;
stroke-width: 0.6;
fill: none;
};
topPanel.append(topOutline, topSlots);
// ─── Bottom board: page position and scale, children in millimetres ──
let bottomPanel = GroupLayer('bottom') #{
translate-x: 25;
translate-y: 178;
scale: 0.82;
};
let bottomOutline = PathLayer('bottom-outline') #{
stroke: fg_auto;
stroke-width: 0.8;
fill: none;
};
let bottomSlots = PathLayer('bottom-slots') #{
stroke: fg_auto;
stroke-width: 0.6;
fill: none;
};
let bottomMarks = PathLayer('bottom-marks') #{
stroke: marks;
stroke-width: 1.4;
fill: none;
};
let bottomNumbers = TextLayer('bottom-numbers') #{
font-family: font;
font-size: 6;
font-weight: 700;
letter-spacing: 0.2;
fill: points;
text-anchor: middle;
};
let bottomLean = TextLayer('bottom-lean') #{
font-family: mono;
font-size: 5;
letter-spacing: 0;
fill: marks;
text-anchor: middle;
};
bottomPanel.append(bottomOutline,
bottomSlots,
bottomMarks,
bottomNumbers,
bottomLean);
// ─── Captions, on the page ─────────────────────────────────────
let topEyebrow = TextLayer('top-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let topNote = TextLayer('top-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
let bottomEyebrow = TextLayer('bottom-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let bottomNote = TextLayer('bottom-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
let bassScale = 685.8;
let trebleScale = scaleLength;
let perpendicularFret = 7;
// Each fret runs from its bass-side position to its treble-side position.
// The treble side is shifted so the chosen fret stands straight, and the
// nut and the far end lean the opposite ways.
fn drawFannedBoard(outline, slots) {
let shift = fretFrom(bassScale, perpendicularFret) - fretFrom(trebleScale, perpendicularFret);
let bassEnd = boardLengthFor(bassScale);
let trebleEnd = shift + boardLengthFor(trebleScale);
outline.apply {
M 0 0
L bassEnd 0
L trebleEnd endWidth
L shift nutWidth
z
}
slots.apply {
for (n in 1..fretCount) {
let bassX = fretFrom(bassScale, n);
let trebleX = shift + fretFrom(trebleScale, n);
let w = widthAt(bassX, bassEnd);
M bassX 0
L trebleX w
}
}
}
// Declared before a slot is drawn: every line the slots layer records is
// a fret, and the subscription's ordinal is the fret number, less one.
fn numberFrets(slots, numbers) {
slots.subscribe('command(line)') {|fret, i|
numbers.apply {
text(fret.start.x, -2.5)`${i + 1}`;
}
};
}
numberFrets(bottomSlots, bottomNumbers);
drawFannedBoard(topOutline, topSlots);
drawFannedBoard(bottomOutline, bottomSlots);
// The straight fret, by position; the lean of the nut and the last fret,
// from each slot's own tangent (a straight slot points due down: 90°).
for (fret in bottomSlots.queryAll('command(line):nth(6)')) {
bottomMarks.apply {
fret.block.draw();
}
}
for (fret in bottomSlots.queryAll('command(line):nth(0, 21)')) {
let lean = deg(fret.block.tangent(0).angle) - 90;
bottomLean.apply {
text(fret.end.x, fret.end.y + 7)`${mm(lean)}°`;
}
}
topEyebrow.apply {
text(25, 48)`THE FORM, FANNED`;
}
topNote.apply {
text(25, 134)`bass 685.8 mm, treble 647.7 mm, fret 7 straight`;
}
bottomEyebrow.apply {
text(25, 166)`SAME SUBSCRIPTION, LEANING SLOTS`;
}
bottomNote.apply {
text(25, 282)`numbers from the slots' ends, leans from their tangents`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 25 150
L 455 150
}
The numbering did not change: a slot's start is still its bass end,
whatever angle the slot runs at. The lean of a fret is a new question,
and the struct answers it too. fret.block.tangent(0).angle is the
direction the slot runs, in radians. A straight slot points due down,
so the lean is deg() of that angle, less 90.
What this project taught the language
This series doubles as a working friction log (part 1 explains the convention). Two entries this time, and the first is a fix.
A text layer's transform now reaches its text. The distance column
was first written as a text layer turned a quarter turn with
rotate: -0.5pi, which the layers
reference promises for
text layers. Every label came out unrotated. The evaluator computed the
transform and both emitters, the CLI's and the one the playground and
VS Code share, dropped it for text layers while honouring it for paths
and groups. Both now compose the layer's transform with a per-text
rotation. The samples ended up using that per-text form instead,
text(x, y, -90deg), which turns each label about its own anchor and
is the better tool for a column of them.
:nth takes literal indices. The marker frets are written twice in
the fourth sample, [3, 5, 7, 9, …] for the dots and :nth(2, 4, 6, 8, …) for the tint, and the two lists are kept in step by hand. A
selector that accepted a list value, or an interpolated :nth, would
collapse them into one. The same sample also shipped a wrong lean
before mm() learned to keep a minus sign, which is the toFixed
entry from part 4 coming due.
Where the series lands
Five posts, thirty samples, and one rule held throughout: the twin
never repeats a coordinate. Queries ask a path for things by kind and
get back structs that know their own geometry; subscriptions let the
asking happen wherever the drawing does. A linkage dimensioned itself,
a panel printed its own drill schedule, and a fretboard placed itself
from a formula. The friction log along the way turned into seven
evaluator fixes and two in the tooling, and a short list of things the
language still wants:
a circle-meets-circle helper, toFixed, per-subpath markers, a column
primitive for text.
The reference pages are Path Queries and Subscriptions. Every sample above opens in the playground with one click: change the scale length and watch the board re-solve.