A Linkage That Dimensions Itself
Part 3 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 (this post) — a four-bar linkage
- The Panel Prints Its Own Drill Schedule — 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 and the subscription model from Thinking and Drawing in Parallel, and it reuses their panel idiom: a form on the left, a twin on the right, both drawing the same block. Labels are the
as segment(...)andas endpoint(...)clauses from Name Your Corners.
A four-bar linkage is four bars and four pivots. Two pivots are fixed to the ground; the crank turns about one of them, the rocker swings about the other, and the coupler joins the two moving ends. It is the first mechanism in every kinematics course and the drive behind windscreen wipers, bicycle brakes and a good share of robot grippers. The drawing a teacher or a robotics mentor hands out is not the linkage but the dimensioned linkage. Every pivot is named, every bar carries its length, the crank angle is marked, measured from the ground line, and the whole thing is checked against the one rule that says whether the shortest bar can turn all the way round.
Today that drawing is made twice. The bars go in one tool, the callouts go on by hand, and the two part company the moment a link length changes. This post builds the linkage as a form and lets the twin annotate it entirely from what the form knows about itself. Four lengths and an angle at the top of every file are the whole design:
let ground = 120;
let crank = 35;
let coupler = 95;
let rocker = 70;
let crankAngle = 60deg;
Change any of them and everything on the right re-solves; the first sample below is the place to try it.
The form knows its own numbers
The form is a labelled path block. Each bar is a relative line, each bar
is named as segment, and each bar ends at a pivot named as endpoint:
return @{
l toA.x toA.y as segment('crank'), endpoint('A');
l toB.x toB.y as segment('coupler'), endpoint('B');
l toO4.x toO4.y as segment('rocker'), endpoint('O4');
z as segment('ground'), endpoint('O2');
};
The pivot positions come from a small solver above it. The crank end is
trigonometry; the coupler end is where the coupler's reach from that
point meets the rocker's reach from its ground pivot, which the law of
cosines settles in a few lines. The names pinA and pinB are not a
stylistic choice: A alone is the arc command, and the friction log
below has more to say about that.
//-- A four-bar linkage as a form: ground, crank, coupler, rocker, four
//-- pivots. Right: the form already knows its links by name and length.
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 dims = 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, 260);
}
// ─── The linkage: change these numbers and everything re-solves ─
let ground = 120;
let crank = 35;
let coupler = 95;
let rocker = 70;
let crankAngle = 60deg;
// Pivots for one crank angle. O2 and O4 are fixed to the ground; A rides
// the crank; B is where the coupler's reach meets the rocker's reach —
// the law of cosines, because there is no circle–circle helper yet.
fn linkageAt(theta) {
let O2 = Point(0, 0);
let O4 = Point(ground, 0);
let pinA = Point(crank * cos(theta), -crank * sin(theta));
let dx = O4.x - pinA.x;
let dy = O4.y - pinA.y;
let reach = sqrt(dx * dx + dy * dy);
let toward = atan2(dy, dx);
let open = acos((coupler * coupler + reach * reach - rocker * rocker) / (2 * coupler * reach));
let pinB = Point(pinA.x + coupler * cos(toward - open), pinA.y + coupler * sin(toward - open));
return {
O2: O2,
pinA: pinA,
pinB: pinB,
O4: O4,
};
}
// The form: four links, each named, each ending at a named pivot. Blocks
// are relative, so every link is the step from the pivot before it.
// (`A` alone would be the arc command, hence pinA and pinB.)
fn linkageBlock(theta) {
let pivots = linkageAt(theta);
let toA = pivots.pinA;
let toB = Point(pivots.pinB.x - pivots.pinA.x, pivots.pinB.y - pivots.pinA.y);
let toO4 = Point(pivots.O4.x - pivots.pinB.x, pivots.O4.y - pivots.pinB.y);
return @{
l toA.x toA.y as segment('crank'), endpoint('A');
l toB.x toB.y as segment('coupler'), endpoint('B');
l toO4.x toO4.y as segment('rocker'), endpoint('O4');
z as segment('ground'), endpoint('O2')
};
}
// Draw the form into a panel: the bars, then a pivot ring at every joint
// the bars report — the form asks itself where its pivots are.
fn drawLinkage(bars, pivots, theta) {
let form = linkageBlock(theta);
bars.apply {
M 16 100 form.draw()
}
pivots.apply {
for (pivot in bars.queryAll('endpoint')) {
circle(pivot.x, pivot.y, 3.5);
}
}
}
// ─── Left panel ────────────────────────────────────────────────
let leftPanel = GroupLayer('left') #{
translate-x: 40;
translate-y: 60;
};
let leftBars = PathLayer('left-bars') #{
stroke: fg_auto;
stroke-width: 2;
stroke-linejoin: round;
fill: none;
};
let leftPivots = PathLayer('left-pivots') #{
fill: bg_color;
stroke: fg_auto;
stroke-width: 1.5;
};
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;
};
leftPanel.append(leftBars, leftPivots, leftEyebrow, leftNote);
// ─── Right panel ────────────────────────────────────────────────
let rightPanel = GroupLayer('right') #{
translate-x: 270;
translate-y: 60;
};
let rightBars = PathLayer('right-bars') #{
stroke: fg_auto;
stroke-width: 2;
stroke-linejoin: round;
fill: none;
};
let rightPivots = PathLayer('right-pivots') #{
fill: bg_color;
stroke: fg_auto;
stroke-width: 1.5;
};
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;
};
let rightTable = TextLayer('right-table') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 0.5;
fill: dims;
text-anchor: start;
};
rightPanel.append(rightBars,
rightPivots,
rightEyebrow,
rightNote,
rightTable);
drawLinkage(leftBars, leftPivots, crankAngle);
drawLinkage(rightBars, rightPivots, crankAngle);
// The table is the form's own answer: one line per named link.
let links = rightBars.queryAll('segment');
rightTable.apply {
for ([link, i] in links) {
text(142, 66 + i * 11)`${link.label} ${round(link.commands[0].length)}`;
}
}
leftEyebrow.apply {
text(0, -18)`THE FORM`;
}
leftNote.apply {
text(0, 166)`four links, four pivots, one crank angle`;
}
rightEyebrow.apply {
text(0, -18)`SEGMENT, ASKED`;
}
rightNote.apply {
text(0, 166)`${links.length} links, named where they were drawn`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 240 40
L 240 232
}
The right panel is the first annotation and the smallest: queryAll('segment')
returns the four bars in drawing order, each with its label and its
length, and a loop prints them level with the bars they describe.
Nothing on the right knows the number 95; it asked.
Pivots, named where they were drawn
The pivot names came from the form, so the twin only has to put them
somewhere sensible. A subscription on the twin's bars layer delivers
each Endpoint with its label. The direction to push the name is the
one from part 2: the incoming bar's end tangent, turned by half the
joint's own turn, which points away from the bars whichever way the
linkage bends.
//-- Every pivot named by a subscription: the twin's form layer delivers
//-- each joint, its label, and the direction that points away from the bars.
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 bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 480, 260);
}
// ─── The linkage: change these numbers and everything re-solves ─
let ground = 120;
let crank = 35;
let coupler = 95;
let rocker = 70;
let crankAngle = 60deg;
// Pivots for one crank angle. O2 and O4 are fixed to the ground; A rides
// the crank; B is where the coupler's reach meets the rocker's reach —
// the law of cosines, because there is no circle–circle helper yet.
fn linkageAt(theta) {
let O2 = Point(0, 0);
let O4 = Point(ground, 0);
let pinA = Point(crank * cos(theta), -crank * sin(theta));
let dx = O4.x - pinA.x;
let dy = O4.y - pinA.y;
let reach = sqrt(dx * dx + dy * dy);
let toward = atan2(dy, dx);
let open = acos((coupler * coupler + reach * reach - rocker * rocker) / (2 * coupler * reach));
let pinB = Point(pinA.x + coupler * cos(toward - open), pinA.y + coupler * sin(toward - open));
return {
O2: O2,
pinA: pinA,
pinB: pinB,
O4: O4,
};
}
// The form: four links, each named, each ending at a named pivot. Blocks
// are relative, so every link is the step from the pivot before it.
// (`A` alone would be the arc command, hence pinA and pinB.)
fn linkageBlock(theta) {
let pivots = linkageAt(theta);
let toA = pivots.pinA;
let toB = Point(pivots.pinB.x - pivots.pinA.x, pivots.pinB.y - pivots.pinA.y);
let toO4 = Point(pivots.O4.x - pivots.pinB.x, pivots.O4.y - pivots.pinB.y);
return @{
l toA.x toA.y as segment('crank'), endpoint('A');
l toB.x toB.y as segment('coupler'), endpoint('B');
l toO4.x toO4.y as segment('rocker'), endpoint('O4');
z as segment('ground'), endpoint('O2')
};
}
// Draw the form into a panel: the bars, then a pivot ring at every joint
// the bars report — the form asks itself where its pivots are.
fn drawLinkage(bars, pivots, theta) {
let form = linkageBlock(theta);
bars.apply {
M 16 100 form.draw()
}
pivots.apply {
for (pivot in bars.queryAll('endpoint')) {
circle(pivot.x, pivot.y, 3.5);
}
}
}
// ─── Left panel ────────────────────────────────────────────────
let leftPanel = GroupLayer('left') #{
translate-x: 40;
translate-y: 60;
};
let leftBars = PathLayer('left-bars') #{
stroke: fg_auto;
stroke-width: 2;
stroke-linejoin: round;
fill: none;
};
let leftPivots = PathLayer('left-pivots') #{
fill: bg_color;
stroke: fg_auto;
stroke-width: 1.5;
};
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;
};
leftPanel.append(leftBars, leftPivots, leftEyebrow, leftNote);
// ─── Right panel ────────────────────────────────────────────────
let rightPanel = GroupLayer('right') #{
translate-x: 270;
translate-y: 60;
};
let rightBars = PathLayer('right-bars') #{
stroke: fg_auto;
stroke-width: 2;
stroke-linejoin: round;
fill: none;
};
let rightPivots = PathLayer('right-pivots') #{
fill: bg_color;
stroke: fg_auto;
stroke-width: 1.5;
};
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;
};
let rightDots = PathLayer('right-dots') #{
fill: points;
stroke: none;
};
let rightNames = TextLayer('right-names') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 0.8;
fill: points;
text-anchor: middle;
};
rightPanel.append(rightBars,
rightPivots,
rightEyebrow,
rightNote,
rightDots,
rightNames);
// Declared before the form is drawn. The name is the label the form gave
// the pivot; the direction is the incoming link's tangent turned by half
// the joint's own turn — outward, whichever way the linkage bends.
rightBars.subscribe('endpoint') {|pivot|
rightDots.apply {
circle(pivot.x, pivot.y, 2.5);
}
let heading = pivot.command.block.tangent(1).angle;
let away = heading + pivot.turn.rad / 2 - PI() / 2;
rightNames.apply {
text(pivot.x + cos(away) * 12, pivot.y + sin(away) * 12 + 3)`${pivot.label}`;
}
};
drawLinkage(leftBars, leftPivots, crankAngle);
drawLinkage(rightBars, rightPivots, crankAngle);
leftEyebrow.apply {
text(0, -18)`THE FORM`;
}
leftNote.apply {
text(0, 166)`pivots drawn, not yet named`;
}
rightEyebrow.apply {
text(0, -18)`ENDPOINT, SUBSCRIBED`;
}
rightNote.apply {
text(0, 166)`O2 and O4 fixed, A and B moving`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 240 40
L 240 232
}
A dimension line, written once
A dimension line is the drafting convention for "this is how long":
a line parallel to the bar, an arrowhead at each end, short extension
lines reaching toward the bar with a small gap, and the value beside
it. The post writes one function for it, and the function takes a
Segment, not coordinates:
fn dimension(link, offset) {
let bar = link.commands[0];
let barNormal = link.block.normal(0.5);
...
}
Everything the drawing needs is on that struct. The bar's start and
end place the extension lines. Its length is the value. Its label
is the caption. link.block.normal(0.5) gives the same side of every
bar relative to travel, which for a loop drawn this way is the outside,
so one call dimensions a bar that runs left, right, up or at 23 degrees.
The arrowheads are one small block with its tip at the origin, rotated
into place at each end.
//-- Dimension lines from the links themselves: each segment's ends, length,
//-- label and outward normal draw its own extension lines, arrows and value.
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 dims = 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, 260);
}
// ─── The linkage: change these numbers and everything re-solves ─
let ground = 120;
let crank = 35;
let coupler = 95;
let rocker = 70;
let crankAngle = 60deg;
// Pivots for one crank angle. O2 and O4 are fixed to the ground; A rides
// the crank; B is where the coupler's reach meets the rocker's reach —
// the law of cosines, because there is no circle–circle helper yet.
fn linkageAt(theta) {
let O2 = Point(0, 0);
let O4 = Point(ground, 0);
let pinA = Point(crank * cos(theta), -crank * sin(theta));
let dx = O4.x - pinA.x;
let dy = O4.y - pinA.y;
let reach = sqrt(dx * dx + dy * dy);
let toward = atan2(dy, dx);
let open = acos((coupler * coupler + reach * reach - rocker * rocker) / (2 * coupler * reach));
let pinB = Point(pinA.x + coupler * cos(toward - open), pinA.y + coupler * sin(toward - open));
return {
O2: O2,
pinA: pinA,
pinB: pinB,
O4: O4,
};
}
// The form: four links, each named, each ending at a named pivot. Blocks
// are relative, so every link is the step from the pivot before it.
// (`A` alone would be the arc command, hence pinA and pinB.)
fn linkageBlock(theta) {
let pivots = linkageAt(theta);
let toA = pivots.pinA;
let toB = Point(pivots.pinB.x - pivots.pinA.x, pivots.pinB.y - pivots.pinA.y);
let toO4 = Point(pivots.O4.x - pivots.pinB.x, pivots.O4.y - pivots.pinB.y);
return @{
l toA.x toA.y as segment('crank'), endpoint('A');
l toB.x toB.y as segment('coupler'), endpoint('B');
l toO4.x toO4.y as segment('rocker'), endpoint('O4');
z as segment('ground'), endpoint('O2')
};
}
// One arrowhead, drawn once with its tip at the origin, rotated into place.
let tip = @{
l -7 -3
l 0 6
z
};
// Draw the form into a panel: the bars, then a pivot ring at every joint
// the bars report — the form asks itself where its pivots are.
fn drawLinkage(bars, pivots, theta) {
let form = linkageBlock(theta);
bars.apply {
M 16 120 form.draw()
}
pivots.apply {
for (pivot in bars.queryAll('endpoint')) {
circle(pivot.x, pivot.y, 3.5);
}
}
}
// ─── Left panel ────────────────────────────────────────────────
let leftPanel = GroupLayer('left') #{
translate-x: 40;
translate-y: 60;
};
let leftBars = PathLayer('left-bars') #{
stroke: fg_auto;
stroke-width: 2;
stroke-linejoin: round;
fill: none;
};
let leftPivots = PathLayer('left-pivots') #{
fill: bg_color;
stroke: fg_auto;
stroke-width: 1.5;
};
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;
};
leftPanel.append(leftBars, leftPivots, leftEyebrow, leftNote);
// ─── Right panel ────────────────────────────────────────────────
let rightPanel = GroupLayer('right') #{
translate-x: 270;
translate-y: 60;
};
let rightBars = PathLayer('right-bars') #{
stroke: fg_auto;
stroke-width: 2;
stroke-linejoin: round;
fill: none;
};
let rightPivots = PathLayer('right-pivots') #{
fill: bg_color;
stroke: fg_auto;
stroke-width: 1.5;
};
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;
};
let extLines = PathLayer('ext-lines') #{
stroke: dims;
stroke-width: 0.5;
fill: none;
};
let dimLines = PathLayer('dim-lines') #{
stroke: dims;
stroke-width: 0.75;
fill: none;
};
let dimArrows = PathLayer('dim-arrows') #{
fill: dims;
stroke: none;
};
let dimText = TextLayer('dim-text') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 0.5;
fill: dims;
text-anchor: middle;
};
rightPanel.append(rightBars,
rightPivots,
rightEyebrow,
rightNote,
extLines,
dimLines,
dimArrows,
dimText);
// A dimension line for one link, from what the link already knows: its
// ends, its length, its label, and its outward normal at the midpoint.
fn dimension(link, offset) {
let bar = link.commands[0];
let barNormal = link.block.normal(0.5);
let nx = cos(barNormal.angle);
let ny = sin(barNormal.angle);
let from = bar.start;
let to = bar.end;
extLines.apply {
M calc(from.x + nx * (offset - 10)) calc(from.y + ny * (offset - 10))
L calc(from.x + nx * (offset + 4)) calc(from.y + ny * (offset + 4))
M calc(to.x + nx * (offset - 10)) calc(to.y + ny * (offset - 10))
L calc(to.x + nx * (offset + 4)) calc(to.y + ny * (offset + 4))
}
dimLines.apply {
M calc(from.x + nx * offset) calc(from.y + ny * offset)
L calc(to.x + nx * offset) calc(to.y + ny * offset)
}
let along = link.block.tangent(0.5).angle;
let head = tip.rotate(along);
let tail = tip.rotate(along + PI());
dimArrows.apply {
M calc(to.x + nx * offset) calc(to.y + ny * offset) head.draw()
M calc(from.x + nx * offset) calc(from.y + ny * offset) tail.draw()
}
// The label sits past the line; a sideways normal pushes it clear of it.
let mid = barNormal.point;
let clear = offset + 9 + 14 * abs(nx);
dimText.apply {
text(mid.x + nx * clear, mid.y + ny * clear + 3)`${link.label} ${round(bar.length)}`;
}
}
drawLinkage(leftBars, leftPivots, crankAngle);
drawLinkage(rightBars, rightPivots, crankAngle);
for (link in rightBars.queryAll('segment')) {
dimension(link, 12);
}
leftEyebrow.apply {
text(0, -18)`THE FORM`;
}
leftNote.apply {
text(0, 166)`no lengths written anywhere but the top`;
}
rightEyebrow.apply {
text(0, -18)`ONE FN, FOUR LINKS`;
}
rightNote.apply {
text(0, 166)`dimension(link, 12) for each segment`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 240 40
L 240 232
}
Two angles
The crank angle is measured at the crank's ground pivot, from the
ground line to the crank. Both directions are on the pivot:
query('endpoint(O2)') returns it, next is the bar that leaves and
its start tangent is the crank's heading, command is the ground bar
arriving and its end tangent, reversed, is the ground line. An arc from
the one to the other, the short way round, is the textbook mark.
The second angle is the one that decides whether the mechanism is any good. The transmission angle is the angle between the coupler and the rocker at pivot B. Force passes from one to the other through it, and when it drops toward zero the rocker binds instead of moving; a rule of thumb keeps it between 40 and 140 degrees, no more than 50 degrees away from square. The path already turns at B by some amount, and the transmission angle is what is left of a straight line after that turn.
//-- Two angles the form knows: the crank angle at O2, from the heading of
//-- the link that leaves it, and the transmission angle at B, from its turn.
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 angles = oklch(0.55 0.16 320);
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 480, 260);
}
// ─── The linkage: change these numbers and everything re-solves ─
let ground = 120;
let crank = 35;
let coupler = 95;
let rocker = 70;
let crankAngle = 60deg;
// Pivots for one crank angle. O2 and O4 are fixed to the ground; A rides
// the crank; B is where the coupler's reach meets the rocker's reach —
// the law of cosines, because there is no circle–circle helper yet.
fn linkageAt(theta) {
let O2 = Point(0, 0);
let O4 = Point(ground, 0);
let pinA = Point(crank * cos(theta), -crank * sin(theta));
let dx = O4.x - pinA.x;
let dy = O4.y - pinA.y;
let reach = sqrt(dx * dx + dy * dy);
let toward = atan2(dy, dx);
let open = acos((coupler * coupler + reach * reach - rocker * rocker) / (2 * coupler * reach));
let pinB = Point(pinA.x + coupler * cos(toward - open), pinA.y + coupler * sin(toward - open));
return {
O2: O2,
pinA: pinA,
pinB: pinB,
O4: O4,
};
}
// The form: four links, each named, each ending at a named pivot. Blocks
// are relative, so every link is the step from the pivot before it.
// (`A` alone would be the arc command, hence pinA and pinB.)
fn linkageBlock(theta) {
let pivots = linkageAt(theta);
let toA = pivots.pinA;
let toB = Point(pivots.pinB.x - pivots.pinA.x, pivots.pinB.y - pivots.pinA.y);
let toO4 = Point(pivots.O4.x - pivots.pinB.x, pivots.O4.y - pivots.pinB.y);
return @{
l toA.x toA.y as segment('crank'), endpoint('A');
l toB.x toB.y as segment('coupler'), endpoint('B');
l toO4.x toO4.y as segment('rocker'), endpoint('O4');
z as segment('ground'), endpoint('O2')
};
}
// Draw the form into a panel: the bars, then a pivot ring at every joint
// the bars report — the form asks itself where its pivots are.
fn drawLinkage(bars, pivots, theta) {
let form = linkageBlock(theta);
bars.apply {
M 16 100 form.draw()
}
pivots.apply {
for (pivot in bars.queryAll('endpoint')) {
circle(pivot.x, pivot.y, 3.5);
}
}
}
// ─── Left panel ────────────────────────────────────────────────
let leftPanel = GroupLayer('left') #{
translate-x: 40;
translate-y: 60;
};
let leftBars = PathLayer('left-bars') #{
stroke: fg_auto;
stroke-width: 2;
stroke-linejoin: round;
fill: none;
};
let leftPivots = PathLayer('left-pivots') #{
fill: bg_color;
stroke: fg_auto;
stroke-width: 1.5;
};
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;
};
leftPanel.append(leftBars, leftPivots, leftEyebrow, leftNote);
// ─── Right panel ────────────────────────────────────────────────
let rightPanel = GroupLayer('right') #{
translate-x: 270;
translate-y: 60;
};
let rightBars = PathLayer('right-bars') #{
stroke: fg_auto;
stroke-width: 2;
stroke-linejoin: round;
fill: none;
};
let rightPivots = PathLayer('right-pivots') #{
fill: bg_color;
stroke: fg_auto;
stroke-width: 1.5;
};
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;
};
let rightArcs = PathLayer('right-arcs') #{
stroke: angles;
stroke-width: 1;
fill: none;
};
let rightAngles = TextLayer('right-angles') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 0.5;
fill: angles;
text-anchor: middle;
};
rightPanel.append(rightBars,
rightPivots,
rightEyebrow,
rightNote,
rightArcs,
rightAngles);
// An angle arc at a pivot between two headings, the short way round, with
// the degrees on the bisector. Headings are radians on the screen.
fn angleArc(center, radius, fromHeading, toHeading,
arcs, labels) {
let delta = toHeading - fromHeading;
if (delta > PI()) {
delta = delta - 2 * PI();
}
if (delta < -PI()) {
delta = delta + 2 * PI();
}
let sweep = 0;
if (delta > 0) {
sweep = 1;
}
let startX = center.x + radius * cos(fromHeading);
let startY = center.y + radius * sin(fromHeading);
let endX = center.x + radius * cos(toHeading);
let endY = center.y + radius * sin(toHeading);
arcs.apply {
M startX startY
A radius radius 0 0 sweep endX endY
}
let mid = fromHeading + delta / 2;
labels.apply {
text(center.x + (radius + 10) * cos(mid), center.y + (radius + 10) * sin(mid) + 3)`${round(abs(deg(delta)))}°`;
}
}
drawLinkage(leftBars, leftPivots, crankAngle);
drawLinkage(rightBars, rightPivots, crankAngle);
// The crank angle: from the ground's direction to the crank's heading.
let pivotO2 = rightBars.query('endpoint(O2)');
// The ground arrives at O2 heading back along itself; reversed, that is the
// ground line the crank angle is measured from.
let groundOut = pivotO2.command.block.tangent(1).angle + PI();
let crankHeading = pivotO2.next.block.tangent(0).angle;
angleArc(pivotO2.point,
20,
groundOut,
crankHeading,
rightArcs,
rightAngles);
// The transmission angle: what is left of a straight line at B after the
// path turns from the coupler onto the rocker.
let pivotB = rightBars.query('endpoint(B)');
let couplerBack = pivotB.command.block.tangent(1).angle + PI();
let rockerOut = pivotB.next.block.tangent(0).angle;
angleArc(pivotB.point,
14,
couplerBack,
rockerOut,
rightArcs,
rightAngles);
leftEyebrow.apply {
text(0, -18)`THE FORM`;
}
leftNote.apply {
text(0, 166)`crankAngle = 60deg, typed once`;
}
rightEyebrow.apply {
text(0, -18)`HEADING AND TURN`;
}
rightNote.apply {
text(0, 166)`transmission angle ${round(180 - abs(pivotB.turn.deg))}° — keep it 40°–140°`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 240 40
L 240 232
}
Both arcs come from one helper that takes a centre and two headings.
The transmission angle is 180° − |turn|, printed by the note without
the arc ever being measured.
Five positions, no new code
This is the sample that argues for the whole series. A loop draws four more positions, 72 degrees apart, into a ghost layer, with the working position drawn on top. The annotation code is the subscription from part 2, narrowed to the two moving pivots and pointed at the ghost layer as well as the bars. Every moving pivot of every position gets its ring, and each ghost crank is labelled with the angle read from the bar that arrives at it.
//-- The same linkage at five crank angles, 72° apart. The annotation code does not
//-- change: one subscription marks every moving pivot of every position, and
//-- one query traces where B went.
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 angles = oklch(0.55 0.16 320);
let trail = oklch(0.55 0.16 27);
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 480, 260);
}
// ─── The linkage: change these numbers and everything re-solves ─
let ground = 120;
let crank = 35;
let coupler = 95;
let rocker = 70;
let crankAngle = 60deg;
// Pivots for one crank angle. O2 and O4 are fixed to the ground; A rides
// the crank; B is where the coupler's reach meets the rocker's reach —
// the law of cosines, because there is no circle–circle helper yet.
fn linkageAt(theta) {
let O2 = Point(0, 0);
let O4 = Point(ground, 0);
let pinA = Point(crank * cos(theta), -crank * sin(theta));
let dx = O4.x - pinA.x;
let dy = O4.y - pinA.y;
let reach = sqrt(dx * dx + dy * dy);
let toward = atan2(dy, dx);
let open = acos((coupler * coupler + reach * reach - rocker * rocker) / (2 * coupler * reach));
let pinB = Point(pinA.x + coupler * cos(toward - open), pinA.y + coupler * sin(toward - open));
return {
O2: O2,
pinA: pinA,
pinB: pinB,
O4: O4,
};
}
// The form: four links, each named, each ending at a named pivot. Blocks
// are relative, so every link is the step from the pivot before it.
// (`A` alone would be the arc command, hence pinA and pinB.)
fn linkageBlock(theta) {
let pivots = linkageAt(theta);
let toA = pivots.pinA;
let toB = Point(pivots.pinB.x - pivots.pinA.x, pivots.pinB.y - pivots.pinA.y);
let toO4 = Point(pivots.O4.x - pivots.pinB.x, pivots.O4.y - pivots.pinB.y);
return @{
l toA.x toA.y as segment('crank'), endpoint('A');
l toB.x toB.y as segment('coupler'), endpoint('B');
l toO4.x toO4.y as segment('rocker'), endpoint('O4');
z as segment('ground'), endpoint('O2')
};
}
// ─── Left panel ────────────────────────────────────────────────
let leftPanel = GroupLayer('left') #{
translate-x: 40;
translate-y: 60;
};
let leftBars = PathLayer('left-bars') #{
stroke: fg_auto;
stroke-width: 2;
stroke-linejoin: round;
fill: none;
};
let leftPivots = PathLayer('left-pivots') #{
fill: bg_color;
stroke: fg_auto;
stroke-width: 1.5;
};
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 leftGhosts = PathLayer('left-ghosts') #{
stroke: fg_hair;
stroke-width: 1;
stroke-linejoin: round;
fill: none;
};
leftPanel.append(leftBars,
leftPivots,
leftEyebrow,
leftNote,
leftGhosts);
// ─── Right panel ────────────────────────────────────────────────
let rightPanel = GroupLayer('right') #{
translate-x: 270;
translate-y: 60;
};
let rightBars = PathLayer('right-bars') #{
stroke: fg_auto;
stroke-width: 2;
stroke-linejoin: round;
fill: none;
};
let rightPivots = PathLayer('right-pivots') #{
fill: bg_color;
stroke: fg_auto;
stroke-width: 1.5;
};
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;
};
let rightGhosts = PathLayer('right-ghosts') #{
stroke: fg_hair;
stroke-width: 1;
stroke-linejoin: round;
fill: none;
};
let rightDots = PathLayer('right-dots') #{
fill: bg_color;
stroke: points;
stroke-width: 1;
};
let rightTrace = PathLayer('right-trace') #{
stroke: trail;
stroke-width: 1.25;
stroke-dasharray: 3 3;
fill: none;
};
let rightAngles = TextLayer('right-angles') #{
font-family: font;
font-size: 7;
font-weight: 700;
letter-spacing: 0.5;
fill: angles;
text-anchor: middle;
};
rightPanel.append(rightBars,
rightPivots,
rightEyebrow,
rightNote,
rightGhosts,
rightDots,
rightTrace,
rightAngles);
// Every moving pivot of every position, whichever layer it lands on.
fn markPivots(source) {
source.subscribe('endpoint(A), endpoint(B)') {|pivot|
rightDots.apply {
circle(pivot.x, pivot.y, 2.5);
}
};
}
markPivots(rightGhosts);
markPivots(rightBars);
// The crank angle beside each ghost's A, read from the crank that arrives there.
rightGhosts.subscribe('endpoint(A)') {|pivot|
let heading = pivot.command.block.tangent(1).angle;
// Above A when the crank points up, below it when the crank points down:
// always on the empty side of the ghost.
let side = -1;
if (sin(heading) > 0) {
side = 1;
}
// Screen headings wrap at ±180°; a crank angle counts 0..360 the other way.
let angle = -deg(heading);
if (angle < 0) {
angle = angle + 360;
}
rightAngles.apply {
text(pivot.x + cos(heading) * 4, pivot.y + side * 10 + 2.5)`${round(angle)}°`;
}
};
let positions = [
132deg,
204deg,
276deg,
348deg,
];
for (theta in positions) {
let ghost = linkageBlock(theta);
leftGhosts.apply {
M 40 100 ghost.draw()
}
rightGhosts.apply {
M 40 100 ghost.draw()
}
}
let form = linkageBlock(crankAngle);
leftBars.apply {
M 40 100 form.draw()
}
rightBars.apply {
M 40 100 form.draw()
}
leftPivots.apply {
for (pivot in leftBars.queryAll('endpoint')) {
circle(pivot.x, pivot.y, 3.5);
}
}
rightPivots.apply {
for (pivot in rightBars.queryAll('endpoint(O2), endpoint(O4)')) {
circle(pivot.x, pivot.y, 3.5);
}
}
// Where B went: every B the ghosts drew, in crank-angle order.
let path = rightGhosts.queryAll('endpoint(B)');
let first = path[0];
rightTrace.apply {
M first.x first.y
for (stop in path.slice(1)) {
L stop.x stop.y
}
}
leftEyebrow.apply {
text(0, -18)`FIVE POSITIONS`;
}
leftNote.apply {
text(0, 166)`one loop over crank angles`;
}
rightEyebrow.apply {
text(0, -18)`SAME SUBSCRIPTIONS`;
}
rightNote.apply {
text(0, 166)`${path.length} ghosts, one working position, no new code`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 240 40
L 240 232
}
The dashed trace is a query, not a subscription: after the loop,
queryAll('endpoint(B)') on the ghost layer returns every B in
crank-angle order, and a polyline through them is the arc B rides about
O4, the rocker's swing sampled four times. Add a fifth ghost to the
loop and both the rings and the trace follow.
The worksheet
The finished figure puts everything on one twin: names, four dimension lines, both angles, and a verdict. The Grashof condition is the rule that says whether the shortest bar can rotate fully: the shortest plus the longest must not exceed the sum of the other two. The twin sorts the four lengths it queried and prints the test with its numbers.
//-- The worksheet figure: names, dimensions, both angles, and the Grashof
//-- test — every number on the right asked of the form on the left.
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 dims = oklch(0.55 0.16 80);
let angles = oklch(0.55 0.16 320);
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 480, 260);
}
// ─── The linkage: change these numbers and everything re-solves ─
let ground = 120;
let crank = 35;
let coupler = 95;
let rocker = 70;
let crankAngle = 60deg;
// Pivots for one crank angle. O2 and O4 are fixed to the ground; A rides
// the crank; B is where the coupler's reach meets the rocker's reach —
// the law of cosines, because there is no circle–circle helper yet.
fn linkageAt(theta) {
let O2 = Point(0, 0);
let O4 = Point(ground, 0);
let pinA = Point(crank * cos(theta), -crank * sin(theta));
let dx = O4.x - pinA.x;
let dy = O4.y - pinA.y;
let reach = sqrt(dx * dx + dy * dy);
let toward = atan2(dy, dx);
let open = acos((coupler * coupler + reach * reach - rocker * rocker) / (2 * coupler * reach));
let pinB = Point(pinA.x + coupler * cos(toward - open), pinA.y + coupler * sin(toward - open));
return {
O2: O2,
pinA: pinA,
pinB: pinB,
O4: O4,
};
}
// The form: four links, each named, each ending at a named pivot. Blocks
// are relative, so every link is the step from the pivot before it.
// (`A` alone would be the arc command, hence pinA and pinB.)
fn linkageBlock(theta) {
let pivots = linkageAt(theta);
let toA = pivots.pinA;
let toB = Point(pivots.pinB.x - pivots.pinA.x, pivots.pinB.y - pivots.pinA.y);
let toO4 = Point(pivots.O4.x - pivots.pinB.x, pivots.O4.y - pivots.pinB.y);
return @{
l toA.x toA.y as segment('crank'), endpoint('A');
l toB.x toB.y as segment('coupler'), endpoint('B');
l toO4.x toO4.y as segment('rocker'), endpoint('O4');
z as segment('ground'), endpoint('O2')
};
}
// One arrowhead, drawn once with its tip at the origin, rotated into place.
let tip = @{
l -7 -3
l 0 6
z
};
// Draw the form into a panel: the bars, then a pivot ring at every joint
// the bars report — the form asks itself where its pivots are.
fn drawLinkage(bars, pivots, theta) {
let form = linkageBlock(theta);
bars.apply {
M 16 120 form.draw()
}
pivots.apply {
for (pivot in bars.queryAll('endpoint')) {
circle(pivot.x, pivot.y, 3.5);
}
}
}
// ─── Left panel ────────────────────────────────────────────────
let leftPanel = GroupLayer('left') #{
translate-x: 40;
translate-y: 60;
};
let leftBars = PathLayer('left-bars') #{
stroke: fg_auto;
stroke-width: 2;
stroke-linejoin: round;
fill: none;
};
let leftPivots = PathLayer('left-pivots') #{
fill: bg_color;
stroke: fg_auto;
stroke-width: 1.5;
};
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;
};
leftPanel.append(leftBars, leftPivots, leftEyebrow, leftNote);
// ─── Right panel ────────────────────────────────────────────────
let rightPanel = GroupLayer('right') #{
translate-x: 264;
translate-y: 60;
};
let rightBars = PathLayer('right-bars') #{
stroke: fg_auto;
stroke-width: 2;
stroke-linejoin: round;
fill: none;
};
let rightPivots = PathLayer('right-pivots') #{
fill: bg_color;
stroke: fg_auto;
stroke-width: 1.5;
};
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;
};
let rightDots = PathLayer('right-dots') #{
fill: points;
stroke: none;
};
let rightNames = TextLayer('right-names') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 0.8;
fill: points;
text-anchor: middle;
};
let extLines = PathLayer('ext-lines') #{
stroke: dims;
stroke-width: 0.5;
fill: none;
};
let dimLines = PathLayer('dim-lines') #{
stroke: dims;
stroke-width: 0.75;
fill: none;
};
let dimArrows = PathLayer('dim-arrows') #{
fill: dims;
stroke: none;
};
let dimText = TextLayer('dim-text') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 0.5;
fill: dims;
text-anchor: middle;
};
let rightArcs = PathLayer('right-arcs') #{
stroke: angles;
stroke-width: 1;
fill: none;
};
let rightAngles = TextLayer('right-angles') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 0.5;
fill: angles;
text-anchor: middle;
};
rightPanel.append(rightBars,
rightPivots,
rightEyebrow,
rightNote,
rightDots,
rightNames,
extLines,
dimLines,
dimArrows,
dimText,
rightArcs,
rightAngles);
// A dimension line for one link, from what the link already knows: its
// ends, its length, its label, and its outward normal at the midpoint.
fn dimension(link, offset) {
let bar = link.commands[0];
let barNormal = link.block.normal(0.5);
let nx = cos(barNormal.angle);
let ny = sin(barNormal.angle);
let from = bar.start;
let to = bar.end;
extLines.apply {
M calc(from.x + nx * (offset - 10)) calc(from.y + ny * (offset - 10))
L calc(from.x + nx * (offset + 4)) calc(from.y + ny * (offset + 4))
M calc(to.x + nx * (offset - 10)) calc(to.y + ny * (offset - 10))
L calc(to.x + nx * (offset + 4)) calc(to.y + ny * (offset + 4))
}
dimLines.apply {
M calc(from.x + nx * offset) calc(from.y + ny * offset)
L calc(to.x + nx * offset) calc(to.y + ny * offset)
}
let along = link.block.tangent(0.5).angle;
let head = tip.rotate(along);
let tail = tip.rotate(along + PI());
dimArrows.apply {
M calc(to.x + nx * offset) calc(to.y + ny * offset) head.draw()
M calc(from.x + nx * offset) calc(from.y + ny * offset) tail.draw()
}
// The label sits past the line; a sideways normal pushes it clear of it.
let mid = barNormal.point;
let clear = offset + 9 + 14 * abs(nx);
dimText.apply {
text(mid.x + nx * clear, mid.y + ny * clear + 3)`${round(bar.length)}`;
}
}
// An angle arc at a pivot between two headings, the short way round, with
// the degrees on the bisector. Headings are radians on the screen.
fn angleArc(center, radius, fromHeading, toHeading,
arcs, labels) {
let delta = toHeading - fromHeading;
if (delta > PI()) {
delta = delta - 2 * PI();
}
if (delta < -PI()) {
delta = delta + 2 * PI();
}
let sweep = 0;
if (delta > 0) {
sweep = 1;
}
let startX = center.x + radius * cos(fromHeading);
let startY = center.y + radius * sin(fromHeading);
let endX = center.x + radius * cos(toHeading);
let endY = center.y + radius * sin(toHeading);
arcs.apply {
M startX startY
A radius radius 0 0 sweep endX endY
}
let mid = fromHeading + delta / 2;
labels.apply {
text(center.x + (radius + 10) * cos(mid), center.y + (radius + 10) * sin(mid) + 3)`${round(abs(deg(delta)))}°`;
}
}
rightBars.subscribe('endpoint') {|pivot|
rightDots.apply {
circle(pivot.x, pivot.y, 2.5);
}
let heading = pivot.command.block.tangent(1).angle;
let away = heading + pivot.turn.rad / 2 - PI() / 2;
rightNames.apply {
text(pivot.x + cos(away) * 11, pivot.y + sin(away) * 11 + 3)`${pivot.label}`;
}
};
drawLinkage(leftBars, leftPivots, crankAngle);
drawLinkage(rightBars, rightPivots, crankAngle);
let links = rightBars.queryAll('segment');
for (link in links) {
dimension(link, 22);
}
let pivotO2 = rightBars.query('endpoint(O2)');
// The ground arrives at O2 heading back along itself; reversed, that is the
// ground line the crank angle is measured from.
let groundOut = pivotO2.command.block.tangent(1).angle + PI();
angleArc(pivotO2.point,
20,
groundOut,
pivotO2.next.block.tangent(0).angle,
rightArcs,
rightAngles);
let pivotB = rightBars.query('endpoint(B)');
angleArc(pivotB.point,
14,
pivotB.command.block.tangent(1).angle + PI(),
pivotB.next.block.tangent(0).angle,
rightArcs,
rightAngles);
// Grashof: the shortest plus the longest link against the other two. When it
// holds, the shortest link can turn full circle — here that link is the crank.
let lengths = links.map() {|link| link.commands[0].length}.sort() {|lo, hi| lo - hi};
let shortest = lengths[0];
let longest = lengths[3];
let others = lengths[1] + lengths[2];
let kind = 'non-Grashof';
if (shortest + longest <= others) {
kind = 'Grashof';
}
leftEyebrow.apply {
text(0, -18)`THE FORM`;
}
leftNote.apply {
text(0, 166)`ground ${ground} · crank ${crank} · coupler ${coupler} · rocker ${rocker}`;
}
rightEyebrow.apply {
text(0, -18)`THE WORKSHEET`;
}
rightNote.apply {
text(0, 166)`${kind}: ${round(shortest)} + ${round(longest)} ≤ ${round(others)}`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 240 40
L 240 232
}
Change crank to 60 at the top of the file. The pivots move, the
dimension values change, the two angles re-solve, and the note reads
non-Grashof: 60 + 120 ≤ 165, the test it just failed. That is the
whole reason to build the drawing this way.
What this project taught the language
This series doubles as a working friction log (part 1 explains the convention). The linkage was the first form built as a labelled block and drawn twice, and it found more than the first two posts together.
Labels now travel with a drawn block. The form is a labelled @{ }
block, drawn into each panel's bars layer with M 16 100 form.draw(),
and the twin asks that layer for endpoint(A). The first attempt
answered "available endpoint labels: none". The relative serializer had
carried every label in the commands it tracked, but only the emitted
text reached the layer's record, which re-parsed it from scratch. Part 1's
comb never noticed because its labels were authored inside the layer's
own apply block. The statement now keeps its arguments' tracked commands
and copies their labels back onto the parsed ones by position. Labels
travel with their block
through draw() and drawTo() alike, and a block's corner operations
are not applied a second time on the way in.
A is the arc command. Every kinematics text names the moving
pivots A and B, and L A.x A.y inside a block is a parse error, because
in path-argument position a single letter that is a path command is the
command. The same rule caught a test that named a lambda parameter s.
So the pivots are pinA and pinB in code and 'A' and 'B' in their
labels, and the first sample says so in a comment. Not a bug, but the
domain post has to say it early.
There is no circle-meets-circle, and blocks are relative-only. The
solver needs the point where the coupler's reach meets the rocker's
reach; intersectionPoints() works on bounding boxes, so the samples
use the law of cosines. A block also cannot be written from absolute
pivots, so each bar is the step from the pivot before it. Both are
documented; both are exactly the arithmetic the series promised to take
away. A Command.heading member would also spare the samples
pivot.next.block.tangent(0).angle.
Markers are per element, not per subpath. The first dimension lines
used marker-start and marker-end for the arrowheads, and four lines
in one layer got two arrows, at the first vertex of the first line and
the last vertex of the last. The samples rotate one arrowhead block into
place instead. A layer option that emits each subpath as its own element
when markers are set is the language-side answer, and it is logged.
Two gates in the sample pipeline were wrong, and both are fixed. The
first validation of this post's six figures reported 51 collisions,
because the check compared each label with the bounding box of a whole
path, and a label inside the linkage's hull overlaps the bars' box. It
now samples the label's rectangle against the geometry's real stroke
and fill, and the same six figures report none. Then a slip in how the
samples were generated left #{{ … }} around ten style blocks, and the
formatter rewrote every one as #{} and reported success: it falls back
to an error-recovery parse so a missing semicolon can still be
formatted, and the recovered tree simply omitted the unparseable text.
The playground and VS Code format through the same function. It now
refuses when the recovery skipped real text. One more gate came out of
the review: a label that strays across the panel divider is invisible to
an area test, so the validator now flags any text crossing a hairline
divider.
Where to go next
Part 4 leaves the classroom for the workbench. A Eurorack front panel
prints its own drill schedule from queryAll('call(circle)'), the table
of hole numbers, diameters and coordinates the panel shop needs.
The reference pages are Path Queries and Subscriptions. Every sample above opens in the playground with one click: change a link length at the top and watch the worksheet re-solve.