lasermade-tools

← All projects

Read the README

Eleven scripts shared by the LaserMadeMusic build repositories — trumpet-elbows-allowed, swept-curve, knotwork-soundholes, living-hinge, slapstick, kalimba, bullroarer, buzz-disc, lyre-harp, square-flute, four-hole-ocarinas, octagonal-flute, panpipes, practice-chanters and ghb-chanter.

trumpet was several repositories until 2026-09-05, among them trumpet-coiled, trumpet-parts and bore-generator; its live successor is trumpet-elbows-allowed. Notes below that name one of those are describing where a lesson came from, not a repository you can clone.

The rest of the build files — every instrument, generator and tool, indexed.

They exist because a writeup that tells someone how to cut wood can be wrong in ways that cost them a sheet, and because a checker that reports the wrong thing is worse than no checker at all. Every one of these has produced a wrong answer at some point. Each section below says which, because that is the part worth remembering.

Python 3 and no Python packages; md2html.py also needs pandoc (brew install pandoc). Nothing here reads or writes outside the paths you give it.

doc-audit.py claims a writeup makes about itself and about files on disk
md2html.py markdown → one self-contained HTML page
svg-stroke-check.py SVG elements whose stroke colour is declared twice and disagrees
make-preview.py a cut file rendered so it can be read on a page
test-ladder.py a strip of squares that finds the cut speed for the sheet in front of you
flat-part-check.py the pre-cut gate for a flat single-sheet part: size, closure, cut order, holes
image-meta-check.py does any tracked image carry metadata — EXIF, GPS, a colour profile, a comment?
turn3d.py a hand-drawn cut file turned into 3mm parts and a page that turns them in 3D
all-gates.sh every gate in every repository, one output, one tally
repro-svg.py does a repository's shipped SVG still come out of its generator?
name-check.py does a cut file's name agree with the geometry inside it?
ignore-audit.py does every .doc-audit-ignore line still suppress something?
rebuild-page.sh regenerate a writeup's page and audit the pair, from any directory

Why these live in their own repository

They are used by every repository above, so none of them can own the tools without all the others depending on it. Tools that know one build stay inside the repository they describe. These eleven know nothing about any particular object, so they sit here.

Until 2026-08-08 they lived in ~/Claude, which is not version controlled. Their bugs are the reason that mattered: the failures below were found by accident, and without history there was no way to see when a check started lying.


doc-audit.py

python3 doc-audit.py WRITEUP.md [--html PAGE.html]
        [--rebuild "python3 md2html.py {md} {out}"]
        [--links] [--run-blocks] [--ignore 'named,in,prose.py'] [--strict-h1]

A repository that legitimately names files it does not ship can say so once, in .doc-audit-ignore at its root — one name per line, # for comments — instead of remembering --ignore on every run. A writeup whose parts come from boxes.py by Florian Festi, an external web generator, needs it: that generator serves every download as RegularBox.svg. Both names belong in the prose and neither will ever be a file there.

Some sites answer every script 403 however real the page: Thingiverse, behind Cloudflare, does. A repository that links one lists the URL in .doc-audit-links at its root, the same way. --links then accepts a 403 from it and nothing else, so a page that has gone (404) still fails. A listed URL that answers 200 again also fails, until it comes off the list, so that file audits itself.

A wrong answer it gave: .json read as .js. The filename pattern accepted an extension wherever it appeared rather than only at the end, so a document mentioning reduced.json was reported as naming a missing reduced.js. It was unfixable from the document's side — the file it named was right there — and it cost a false finding in spirals, which mentions two .json artifacts. The extension must now end the name. Real misses still fail: a document naming ghost.js with no such file still reports it.

Everything it checks is a claim the document makes about itself or about files on disk, so a failure is always a real inconsistency — never a matter of taste. It reports:

File references in prose resolve against the repository root, not the document's directory, so a document in a subdirectory can name files above it.

Link and image targets are checked differently, against the document's own directory, because that is what a browser does with them. see bell.py in prose stays true wherever bell.py lives; <img src="bell.py"> does not. The two checks disagreeing is the point.

Where it has been wrong. Its first run produced four failures, all of which were its own bugs rather than the document's. It also reported boxes.py and RegularBox.svg as missing from a writeup that named them correctly — they are an external tool and the filename it serves, correctly named in prose, and the fix was the ignore file above rather than any change to a document that was right. Later, a document in a subdirectory was told its neighbours did not exist, because the checker rooted everything at the document's own directory; and the orphan check compared bare filenames, so coupons/README.md excluded the root README.md from the pool and then reported files only that README mentions.

.doc-audit-generated at the root, one directory path per line, declares directories whose contents are machine output. The orphan check then treats the directory as documented rather than each file inside it. It exists for bore-designs, which is a design library: seven directories of cut files, twenty-seven SVGs in one of them, and naming every one in prose to satisfy a check would be worse writing, not better. It is declared, not inferred — taking "this folder has its own README" as the signal was the obvious alternative and would let any repository hide files from the check by dropping a README into a directory. Two of the seven have no README anyway, so inference would not even have worked.

Most recently, on 2026-10-02, its first run outside these repositories gave three wrong list counts in one project, Logic Research. "2,868 rules" was read as "868 rules", because a comma is a word boundary. "348 rules in 9 families" was counted against the three families listed after it: a numeral is a measured total, not a list being introduced, which the check already knew for "checks" and now applies to every noun. And a section of three bold-lead paragraphs counted two, because one lead held a code span with a * in it and the bold pattern stopped there. Across 43 documents the only verdicts that changed were those three, and seeded miscounts are still caught.

Before that, the displayed-image check read a thumbnail as a missing picture. A page showed a 214KB copy of a 1.4MB photograph and linked the original from it — which is what a page should do, rather than making every reader pull the full file to see the picture — and the check, which counted only <img src> and ![](), reported the original as named but never shown. An anchor wrapping an image now counts as showing it. The check still fails a photograph a document names and displays nowhere, which is the case it was written for.

The gallery rule added at the same time was wrong on its first push, in the opposite direction: flex items default to min-width: auto and will not shrink below their intrinsic width, so four bell renderings each took a row to themselves and the page got longer rather than tidier. min-width: 0 on the item is what makes them share a row. It was caught by screenshotting the deployed page, which is the only thing that would have.

Before that --run-blocks failed a document that contains no fenced blocks at all. It snapshotted the tree before running them, unconditionally, and trumpet-curved holds a 148MB video over the stash cap — which it then reported as a file a block "may have modified". No block existed to modify it. The snapshot is now skipped when there is nothing to run, and files over the cap are tracked by size and mtime, so the failure states that a file changed rather than that one might have.

Before that it had no notion of a path at all. Every file reference, prose or image tag alike, was satisfied by a name existing anywhere under the repository. So when trumpet-curved moved its parts into subdirectories and nothing rewrote the paths, the writeup passed 15/15 while 12 of the 13 images and 10 of the links on the published page returned 404. Nothing local could see it; the only symptom was on the deployed site. The path check above exists because of that, and it catches all 22 when replayed against the commit that would have shipped them.

Verify anything it flags against the source before acting on it.

md2html.py

python3 md2html.py WRITEUP.md PAGE.html

Pandoc does the parsing, as GitHub-flavoured markdown, so the page reads the way GitHub renders the same file. Until 2026-10-06 this script was its own parser, about 250 lines of regular expressions, and every construct it had not met yet published wrong — the history below is that list. What stays in the script is what is ours: the two markers, a small Lua filter, and the page shell. The filter rewrites .md links to .html, boxes each table, and turns an image alone in its paragraph into a figure, inlining a local SVG.

One self-contained page: no external CSS, no fonts, no scripts. SVG figures are inlined into the HTML, which is why doc-audit.py treats a page older than any of its figures as stale — regenerating an SVG does not change the page that already swallowed a copy of it. Tab title comes from the document's own first # heading.

Raw HTML blocks pass through unescaped, which is what makes the thumbnail-gallery tables in these writeups render as tables rather than as visible source.

What changed on the published pages with the switch. On all twenty-one pages the visible text came out the same, bar one cell. Six differences in all:

Before pandoc it had no branch for an indented code block — four leading spaces, CommonMark's older fence-free form — so those lines fell through to the paragraph joiner and were run together into prose. A design library with fifteen of them published its column-aligned walk tables as N N3 U1 N3 U1 N3 N y and z only 1 section, 0 stranded N N3 U2 …, while reading correctly on GitHub, which renders them.

<!-- readme-only --> on a line of its own drops the paragraph after it from the page while leaving it in the README. It exists because a repository whose page is generated from its own README published that README's "Read the writeup" line on the writeup — a link to the page you were already reading, on seven pages. Marked rather than detected: this converter knows only its input and output paths, and guessing the site URL from the directory name breaks the moment a directory and its repository differ, as test/ and bore-designs do. GitHub renders the comment as nothing, so the README is unaffected.

<!-- page-only TEXT --> on a line of its own is the reverse: GitHub hides it, and the page renders TEXT as an ordinary paragraph. It carries the "Read the README" link at the top of a page generated from its README, which on the README would point at itself.

Where it has been wrong, all before pandoc. It escaped raw HTML blocks, so galleries appeared as their own markup; and it had no blockquote branch, so > lines rendered as literal text with the marker showing.

The checkers, audited against documents they should reject

Done 2026-09-08. Each check was given something it ought to fail.

flat-part-check.py — all eight hold. A file whose user unit is not a millimetre, a part too big for the bed, a genuinely open cut path, an ink outside the palette, a hole under the floor, a hole outside the outline, a hole too near an edge, and a hole moved into the last cut stage — every one fails, and the open path correctly drags black frees the part down with it.

Four probes came back clean and were wrong before the tool was: growing the canvas is not growing the part; a path whose ends still meet is closed whether or not it has a z; the near edge of a long thin blade is across it, not along it; and the cut order is the ink, not the document order. A check that does not fire has not been tested until you know your input reached it.

doc-audit.py — effective, with two narrow blind spots. A named file that is not there, a heading level skipped, a stale page, a doubled word, a count that contradicts its list, and a figure with no text alternative all fail.

What the figure check found. An inlined SVG carries no alt, and md2html.py was dropping it: the description written in the markdown reached nothing, and a page was accessible only where the drawing happened to carry its own aria-label. The two had already drifted — living-hinge-guide.md says "x across the width" and panel-convention.svg says "x runs across the width". md2html.py now uses the alt as the label when the SVG has none, and leaves the drawing's own label alone where it has one. Every published page regenerates byte-identical.

What the unrendered-markdown check found, on its first sweep. md2html.py converts an image only when it is alone on its line. Anything else — two on one line, or one whose alt text is hard-wrapped, which at 80 columns is easy to write without noticing — falls through to the inline pass, which does not handle images, and reaches the page as visible image markdown — a literal exclamation mark, the alt text in brackets, the path in parentheses — instead of a picture.

Nothing caught it. every link and image path resolves reads that same unconverted syntax out of the markdown, so the path resolves, the file exists, and the check passes: the picture is simply not on the page. Two images on one line published that way on 2026-09-13 from a document reporting 17 passed, 0 failed, and the sweep that added this check then found a second, older one — the two cheek plates on trumpet/README.md, the repository's front page, where a four-line alt text had been printing as literal markdown for as long as the photograph had been there.

The check reads the page's text: <pre> and <code> come out first, since a writeup may quote markdown on purpose and this README does, then the remaining tags, so no attribute value can trip it. Nine repositories, twenty-four documents, one real failure and no false ones.

The cause is fixed too, for the case that bites. md2html.py now joins a wrapped image back onto one line before matching it, so an alt text may run to as many lines as it needs. A run that never closes is left exactly as it was, so prose that merely opens with those two characters is untouched. Re-rendering all twenty published pages with the change produces twenty byte-identical files: the only document it would have changed had already been fixed by hand, and no source in any of the nine repositories still wraps an image.

Two images on one line were not a gallery before pandoc and needed raw HTML — a <p> holding two <img>, which is what the stylesheet's gallery rule selects for. Pandoc now writes exactly that paragraph from the markdown, so they are one. The gate is what tells you which of the two you have written.

A link inside backticks is quoted syntax, not a link. every link and image path resolves read the raw source, so this README reporting on the check above was itself reported as linking to a file called "path". It reads the prose now, with fences and inline code spans blanked. every file the document names exists still reads the raw source deliberately: "generated by bell.py" is a real reference to a real file, and that looser claim is what it is there to catch.

all-gates.sh

Runs every gate in every repository and prints one tally.

bash all-gates.sh
...
GATES FAILING: 0

Eighteen ribbon bore runs -- twelve over the six open shapes, two over the torus, one each over the scallop, the racetrack and the oval, and one on --port-at -- regress.py over every block design, doc-audit over every page in every repository on its REPOS= line, svg-stroke-check over every SVG, and flat-part-check over the flat parts -- then four questions about what is committed, and finally whether every repository is clean and pushed.

ribbon page and sheets refuse alike (2026-09-16) runs the command lines the design-page rows never did: an unknown flag to both tools, the viewer with no --out, three --port-at lines the generator refuses, and the scallop's and racetrack's reported radius. Every one of those passed before, because the viewer kept its own copy of the generator's flag handling.

Three gates were added on 2026-09-15, all for holes this harness had.

external links resolve, every markdown       101 links in 34 docs
every published page is current with README   10 pages
doc-audit ... --run-blocks                    34 docs

The first is the one that cost something. gernreich.github.io served a full-height GitHub 404 panel where a bore viewer belongs, because trumpet moved a design into built/ and the front page still embedded the old path. Every gate here passed the whole time: the link is absolute, so no relative-path check resolves it, and neither doc-audit loop passed --links. It needs the network and it FAILS rather than skips without one, for the reason the whole file gives below -- a run that checked no links has not checked the links.

The second exists because the paired-page loop is for md in *.md at each repository root, so it sees nine documents. trumpet publishes five more from subdirectory READMEs, and the every-markdown loop drops --html, so none of them had ever been compared with its source. All ten were current when the gate was written, which is luck.

Gernreich, the profile README, was in no loop at all and is now in both.

The four are the ones a gate usually forgets, because they compare the code with what shipped rather than running the code at all:

ribbon sheets reproduce byte-identical    53/53
previews current with their cut files     44/44
search tools reproduce their output
Boxes install matches tools/

A harness that runs the generators and never diffs them against the artefacts cannot tell you the sheets on disk are the sheets the code draws, which is this project's entire claim. The last of the four matters most quietly: bore_split shells out to the INSTALLED copy of snakeboxvar.py, so editing the one in tools/ and re-running changes nothing, silently.

About two and a half minutes on ten cores; it was twelve until 2026-10-18, when the gates went into lanes that run side by side. A lane is a group of gates that must keep their order; each runs in a subshell of its own with its output held back and printed whole, so the report reads the same every run. The doc-audit pass that executes each document's blocks runs first and alone, since it writes into the repositories and restores them; the coil search gates rebuild their folder in place, so the gates that read every document wait for them. The tally counts each lane's failures, and a lane that stops before its last gate fails as one. regress.py, repro.py, run_checks.sh, repro-svg.py and ignore-audit.py also run their own designs, rows or entries at once, and the harness holds numpy's maths library to one thread per process so that the processes do not fight over the cores. The link pass is the only gate here that needs the network.

It exists because of a failure of reading, not of checking. Every one of those gates already existed and every one was passing; what kept going wrong was running them one at a time and not reading one of the answers -- a page regenerated and its audit then run in a different repository, a tally printed and pushed over. Nine gates run by hand are nine chances to read only eight.

ignore-audit.py — does every exemption still suppress something?

Every line of a .doc-audit-ignore is a standing claim: this name is prose, not a file here. Every other gate checks an artefact, some code, or a document. Nothing checked the suppressions, and an exemption that has stopped doing anything is worse than none — an assertion nobody re-reads, sitting in the one list whose entire job is to be trusted.

It removes one entry at a time from a copy and re-runs every document in that repository. If nothing starts failing, the entry is dead weight. Two were dead, both in trumpet, which exempted parts/LICENSE and tools/LICENSE from the orphan check. That check matches a tracked file by basename as well as by path, so the README's own mention of LICENSE had always covered every copy.

It audits both suppression files. .doc-audit-ignore exempts a name; .doc-audit-generated exempts a whole directory, and is much the blunter instrument — six lines in trumpet exempt 439 tracked files. That second one went unaudited when the first was, which is the fault this repository keeps rediscovering: fixing an instance without asking where else it lives. 70 exemptions across nine repositories, and every directory-level one does still suppress something.

doc-audit.py grew --ignore-file for this, so the audit never edits the file it is auditing.

The harness exits non-zero now

Until 2026-09-11 all-gates.sh ended on an echo, so it returned 0 however many gates failed — all-gates.sh && git push pushed regardless. The only signal was a line of text to read. This file's own header records the fault it was written to stop, "a tally printed and pushed over, twice", and its answer was another line to read; it was then pushed over four more times in a single session. The failing gates are now named again at the end, where the last few lines cannot show clean-and-pushed without also showing what failed.

name-check.py — does a cut file's name agree with the geometry inside it?

ribbon-double-volute-bore10-45deg-R94-step60-1180mm asserts a section, a facet angle, a radius, a step and a centreline. Every other gate here compares an artefact with the code that draws it; none of them reads the name. The reproduction gate is handed the stem on the command line, so a design called -1180mm reproduces perfectly whatever its centreline actually is, and a folder called R62-pitch46 keeps that name after the pitch moves underneath it.

That matters because for several of these numbers the name is the only record, and it is what a person reads off a sheet at the machine to know which bore they are holding.

It runs each design and compares nine kinds of claim — bore, facet angle, centreline, single radius, radius span, pitch, step, lobes, shape — against what the generator prints. All thirteen designs agree: R72 against R71.754, R35to113 against R34.662 and R112.903, 1180mm against 1179.9.

Designs are read from all-gates.sh's own rr lines rather than listed twice, and it takes a path so that table can be a copy. That is not a convenience: the first attempt at proving the check can fail edited the live all-gates.sh in place, once per claim type, and was interrupted mid-loop with five stems corrupted. Restored from git, and the tool changed so that demonstrating it works cannot damage what it checks.

repro-svg.py — does a repository's shipped SVG still come out of its generator?

Both of trumpet's reproduction gates are trumpet-only, and every other repository that ships generator-drawn cut files had none: knotwork-soundholes draws 13, living-hinge 15, and nothing checked any of them.

They all reproduce. The finding was not drift — it was that for four of the knots the command that drew them was recorded nowhere: not in the README, not in the filename, not in a comment. AMP and HW do not scale with R_HOLE, and the filename carries only leads, bights and radius, so every sample drawn at a non-default radius was unreproducible from anything in the repository. They were recovered on 2026-09-10 by reading the parameters back out of each SVG's own description — the ribbon width is 2*HW, the cosine amplitude is AMP, the rim overrun is BITE — and each then matched byte for byte.

lyre-harp joined them on 2026-10-13 with 17 files. Its generator lived in trumpet-elbows-allowed until 2026-10-14 and is now its own, but it imports the swept-curve generator from the checkout beside it, so a change there that moves the frame fails here. square-flute followed on 2026-10-13 with 42, its generators its own and only that same path writer borrowed, and ten hand-drawn originals from 2017 listed under !. four-hole-ocarinas followed on 2026-10-17 with 19, its generator borrowing the square flute's plate, packer and finger size, and so the path writer too. octagonal-flute followed on 2026-10-18 with 7, borrowing the same from the square flute and its joint at any angle from the ocarinas.

Each row draws into a directory of its own, since 2026-10-18, and the rows run at once. Most commands write every sheet they draw into $(dirname $OUT), and that was one directory for the whole manifest, so a row whose generator failed could find the file an earlier row had left there and pass on it -- shown with a two-row manifest whose second command is false, which passed before and fails now.

The manifest is .repro in the repository root, one line per shipped SVG:

shipped/path.svg :: shell command writing the file to $OUT

A command of ! means the file ships without a generator, deliberately, with the reason in the comment above it. It still has to be listed — an SVG no line claims is a failure, not a silence, which is how the 13 knotwork previews and the hand-nested coupon sheet were found in the first place. Three things shipped under that marker when it was written, and each was checked as far as it can be:

Since 2026-09-10 only the coupon sheet still ships as !. Both sets of previews now have a preview script in their own repository that redraws them byte-identical, keeping the look each repository chose, so nothing on a front page changed. Re-nesting the coupon sheet is the author's call, so the gate records it and says so.

What doc-audit.py got wrong on 27 documents

It had only ever been pointed at the nine documents that have a published page. Aimed at the eighteen design notes as well, it produced 20 failures, of which one was real. The four defects behind the other nineteen, all fixed 2026-09-10:

A numeral before "checks" is now taken as a gate's assertion count rather than a list claim. Across nine repositories every claim written in digits is one of those and every genuine one spells the number out -- 17 instances, no exceptions. Three checks over two items is still caught.

The real finding, for the record: "Two things to know:" over three bullets, in the notes for the coil whose parts are cut.

Every gate in it has been watched fail. That is this repository's own rule applied to the thing that enforces it, and it was not done when the script was written. Appending one byte to a shipped cut file fires three of them at once and nothing else:

ribbon sheets reproduce byte-identical    FAIL 1 differ
previews current with their cut files     FAIL 1 stale
every repo clean                          FAIL 1 changed

Editing parts.json fires search tools reproduce their output and that alone, so the gates are specific rather than cascading. Boxes install matches tools/ was watched fail by changing a default in the installed copy. every repo pushed was proved in a scratch repository, since breaking it in a real one means an unpushed commit. doc-audit and flat-part-check have failed repeatedly in ordinary use.

The one exception is regress.py's own verdict line, which has never been seen to fail here -- that gate is inherited whole from the repository it checks, and its history of catching things is recorded there rather than proved here.

Where it has been wrong. Its first run printed nothing at all for lasermade-tools, Gernreich.github.io and trumpet -- the three most-edited repositories -- because it paired README.md with README.html and those build index.html. Nine repositories, six lines, and no complaint. A filter that matches nothing reports a clean run over no files, which check.py had already learned once and this then repeated.

svg-stroke-check.py

python3 svg-stroke-check.py FILE.svg [FILE.svg ...]
python3 svg-stroke-check.py --dir DIR [--dir DIR] [--quiet]
python3 svg-stroke-check.py ... --fix

Finds SVG elements that declare their stroke colour twice, in two places that disagree — a style property saying one colour and a presentation attribute saying another. The CSS cascade resolves that to the style property, and browsers and Inkscape both agree, but not every laser importer applies the cascade. One that reads the attribute instead puts the part in a different cut stage.

In these repositories colour is the cut order, so that is not a cosmetic difference: it is a part cut at the wrong moment, after the material holding it has already been freed. Elements that declare the same colour twice are reported separately and are harmless — no importer can disagree about them.

--fix deletes the redundant presentation attribute and keeps the style property that was winning anyway, so what the file cuts does not change. It compares path data before and after and refuses to write if anything but the attribute moved. Exit status is 1 when conflicts exist, so it can gate a commit.

Why it exists. A cut file carried sixteen such paths: style saying green or cyan, attribute saying black. Read the attribute way, all sixteen panels — including the eight nested inside the plate holes — moved to the final stage, after the cut that frees the plates. Nothing in the file looked wrong, and no other check could see it.

flat-part-check.py

python3 flat-part-check.py FILE.svg [FILE.svg ...]
python3 flat-part-check.py --dir DIR [--dir DIR] [--quiet]
python3 flat-part-check.py ... --bed 600x305 --min-edge 3 --min-hole 2 --weld 0.05

The bore repositories have a gate: bore_split.py --write checks every net it writes, and nothing is cut from a file that has not passed. The flat parts — bullroarer blades, buzz discs, anything cut from one sheet with holes in it — had no equivalent. This is it.

It reads geometry, not drawing. Paths, rects, circles, ellipses, polygons and lines are flattened to polylines with every enclosing transform applied, and then measured:

check the mistake it is looking for
millimetre-true a user unit that is not a millimetre cuts at 96/25.4 and looks right on screen the whole time
fits the bed refused at the machine, or silently cropped
cut paths are closed an outline that does not meet does not free the part
ink is in the palette colour is the cut order here; an unknown colour is a stage nothing runs
black frees the part the outline must be last, or the part moves while its holes are still being cut
holes cut before the outline the same mistake, one stage at a time
holes are inside the outline a hole on the waste, and usually a transform that was missed
hole is big enough the cord holes are the point of these parts
edge distance the cord hole is where a bullroarer fails — too little material and it tears out

Exit status is 1 if any file fails, so it can gate a commit. previews/ is skipped under --dir: those are display renderings and are not cut.

Kerf is not modelled. Every measurement is of the path as drawn, and the beam takes its width from both sides of that line — a 3.0mm hole cuts about 3.1mm, a 3.0mm wall comes out about 2.9mm. Set --min-edge with that in hand rather than at the limit.

Why it exists, and the two wrong answers it gave first. Both were found by running it on files known to be good, which is the only way this kind of bug surfaces.

It called buzz-disc/BuzzDisc1.svg three kinds of broken. The disc's outline carries no z — Inkscape wrote the return point as an ordinary node, and the ends coincide to 0.00009mm, which cuts identically. Worse was what followed: with the outline discounted, the largest closed path was a 5mm cord hole, so the gate reported that a cord hole freed the part and that the other cord hole lay outside it. One wrong assumption, three confident failures, all of them nonsense.

Then it called four of the five bullroarer blades open. They are drawn as a top curve and a bottom curve meeting at the two tips: two open paths that cut one closed ring. Open paths of the same colour are now stitched end to end within --weld before anything is judged, and the report says how many joins it made.

The point of both: a checker that reports the wrong thing is worse than no checker, and this one had to be run against known-good work before it was worth pointing at anything else.

turn3d.py

import turn3d
parts = turn3d.parts(CUTFILE)                 # outlines, holes, engraves; mm, y up
turn3d.write(PAGE, title, designs, note, by)

The bores, the flute and the lyre-harp each have a 3D viewer because a generator draws them, and the viewer is built from the same geometry. Kalimba, slapstick, bullroarer and buzz-disc are hand-drawn SVGs with no generator, so their viewers are built from the cut file itself, read by flat-part-check.py's own parser: what turns on screen is what the laser cuts.

parts() sorts the rings by nesting. A ring inside nothing is a part, a ring inside a part is its hole, and blue lines are engraves drawn on whichever part they fall on. Containment is decided by most of a ring's vertices, not its first: the slapstick's three paddles share their cut lines, and a first vertex on a shared line read as inside the neighbour, which made one paddle a hole in another.

It reads a sheet of separate parts. A rosette sheet is a cut-out with no single outline, so its openings come back as dozens of small parts — the same limit flat-part-check.py has.

The page's explanation sits folded under "How to use this view", so on a phone, where the hub frames it about 370px wide, the model keeps the frame until someone asks. The page also follows the theme the site's pages store (ctg-theme, set by the hub's button), so a framed viewer turns light or dark with the page around it.

Where each part goes is the one thing a cut file does not say, so each repository has a viewer.py that places them and calls write(). Its .repro line rebuilds the page, so the viewer is gated like any other page.

make-preview.py

python3 make-preview.py CUTFILE.svg [OUT.svg]      # default previews/<name>
python3 make-preview.py CUTFILE.svg --ground=#faf7f0  # another ground colour

A cut file draws hairlines on no background: nearly invisible in a browser, and a black one disappears on a dark page. This thickens the stroke, paints a light ground, and darkens the inks that cannot be seen against it.

It changes the colours, and that is the point. Three of the six cut-order inks fail the WCAG 3:1 graphics minimum on the white ground — green at 1.37:1, cyan at 1.25:1, orange at 2.52:1 — so a faithful rendering of the palette is an unreadable picture. The darkened equivalents keep the hue and the sequence and clear 5:1. Geometry, sheet position and cut order are untouched, and both are checked against the source before the file is written.

The cut order it renders is shared by every build repository here: blue engraves, then green → orange → cyan → black, black always the cut that frees the part, violet always skip. A file uses only the stages it needs.

The ground is white, #ffffff, in every repository since 2026-10-13; it was cream (#faf7f0) before. The darkened inks were chosen against the cream, and white being lighter only raises their contrast: orange, the lowest, goes from 4.83:1 to 5.17:1. --ground=#rrggbb sets another colour, at the cost of rechecking those figures.

test-ladder.py

python3 test-ladder.py OUT.svg [--speeds 15,20,25,30] [--square 15]

Settings for a sheet of plywood are a property of that sheet, not of the species on the invoice. Baltic birch from two suppliers, or the same supplier two packs apart, will not cut at the same speed, and the number that worked last month is a guess this month. This draws four identical squares, each stroked in a different ink so the importer gives it its own speed field, with its speed engraved beneath it in seven-segment line art — line art rather than text, so no font substitution on import can turn a number into something else or into nothing.

Cut it, push each square out from below, and the fastest one that drops free unaided is the ceiling. Production runs 15-20% slower than that, so a void or a damp patch does not cost a part.

Colour here is one speed per layer, not cut order — the only exception in these repositories, and it is forced. xTool Studio and XCS split an import into processing layers by colour, so a separate colour is the only place a separate speed can live. Same ink would mean one layer and one speed: a row of identical squares and no ladder at all.

The inks are still the house stages, so make-preview.py renders a ladder and svg-stroke-check.py reads it. Four rungs, because four stages actually cut — green, orange, cyan, black — with blue engraving the labels as it always does. Violet is never emitted, since it means skip and a skipped rung is a missing answer. Four is also the better way to work: bracket coarse, then bisect. 15,25,35,45 finds the decade and a second run across the winning pair finds the number, which lands closer than six rungs guessed in one pass. Decimal speeds are accepted for that second run.

Where it has been wrong. Its first run crashed on every ladder it was asked to draw. Speeds are parsed as floats, so 15 became 15.0, and the digit table had no glyph for . — a generator that had been described as validated could not render its own default arguments. The decimal point is now a baseline tick, which is what makes bisecting below whole numbers possible at all.

The rungs themselves are untested against material. The geometry is checked — square count, ink uniqueness, label glyphs, parseable XML — and the ladder is known to survive both sibling tools. Whether 15,20,25,30 brackets 3mm Baltic birch on a 55W tube is exactly the question the ladder exists to answer, and nothing here has answered it yet. Treat the default as a starting bracket, not a recommendation.

rebuild-page.sh

rebuild-page.sh [DIR] [--md=WRITEUP.md] [--html=PAGE.html] [--links]

md2html.py and then doc-audit.py --rebuild, run from DIR, which defaults to README.md and index.html in the current directory. It prints the audit's failed lines and its tally and nothing else, so a clean run is one line, and like all-gates.sh it is fail-closed: it exits 0 only when it sees 0 failed.

It exists because the same two commands were written out by hand in eight CLAUDE.md files, each with its own relative path to this repository, and one of them, in the swept-curve folder, regenerated a README that was not there. The path to this repository now lives in one place.

all-gates.sh runs it on two throwaway pages it makes for the purpose, never a real one: a clean page must pass and a page naming a missing file must fail. A copy of the script that always exits 0 fails that gate.

The review method these tools serve

skills/writeup-review/SKILL.md is the Claude Code skill that runs these tools as the mechanical half of a document review, and then reads the document through thirteen lenses: eight on one document — follow it literally, at other numbers, misread it, recompute its figures, check its links, its accessibility, read it backwards, look at the deployed page — and five across a set of documents. ~/.claude/skills/writeup-review is a symlink to that directory, so the skill is edited and committed here, beside the tools it names. It was moved in on 2026-10-02, having been unversioned before that, for the same reason the tools were.

The lenses apply to any project. Only the first section is specific to these repositories: all-gates.sh here, doc-audit.py alone elsewhere, with what it cannot see outside its home spelled out.

Two more skills live beside it, symlinked into ~/.claude/skills the same way, added 2026-10-09:

A skill costs one line of description in a session until it is used, where the same text in a CLAUDE.md is read in full every time. That is the reason these two procedures are skills: each is needed in some sessions and not most.

Checking this page

index.html is this README rendered by md2html.py and committed, not built on the server, so it goes stale silently unless it is regenerated after every edit:

python3 md2html.py README.md index.html
python3 doc-audit.py README.md --html index.html

The names that check would otherwise trip on now live in .doc-audit-ignore rather than in an --ignore flag on the command line, so the documented self-check needs no arguments. They are the usage-synopsis placeholders, and bell.py, which lives nowhere at all, since it is the invented example in the doc-audit.py section showing prose and image paths resolving differently. bell.py was missing from the flag for as long as the flag existed, so the documented self-check reported two failures against a README that was right.

Auditing this README is also what turned up the quoted-example bug fixed above: the line describing the list-count check quotes "Three things", and the checker read its own example as a claim.


Released under CC0 1.0.