v4 session log

Chronological record of this session’s work, for resuming later. See README.md for the architecture/usage reference of the current state; this file is the history and the “what’s next.”

Where things stand right now (latest session, branch v4-tui)

  • tune.py, an interactive textual TUI, was built essentially from scratch this session and now covers the entire config: Font & Alignment, Type Test, Resin, Gauge, Build, Layout, Quality, Logo, Element tabs, a master/running/saved config-tier system, a real file browser (textual-fspicker) for font paths and Save, and an f3d auto-launch/raise integration for live preview. See README’s “Interactive tuner” section for the current-state reference; this log section (part 7 below) has the blow-by-blow.
  • Critical correctness fix: struck characters were never mirrored. A struck type element must carry a mirror image of the printed glyph (same reason a stamp/slug is cut reversed) - v2’s TwoDText does this (mirror([1,0,0])), v4 never did until this session. Fixed in build_glyph(); also resolved the previously-reported “x offset wrong direction” bug as a side effect (same missing mirror). See README’s “Character mirroring” section.
  • Shaft Gauge Test ported from v2 (GaugeTestSet and its whole supporting cast) - a standalone calibration print for finding element.core_id_offset, plus its own Gauge config section and tuner tab. See README’s “Shaft Gauge Test” section.
  • Draft angle is now config-driven (build.draft_angle_deg, default 55 - the real machine value) instead of a fixed glyph_poc.py constant. Exposed on tune.py’s Font & Alignment tab.
  • Everything from the previous session (the real-Minkowski-sum draft rewrite, the real platen cutout, the facet-count/preview config expansion) is unchanged and still the current mechanism - see “Where things stood” below (renamed from “right now”) and parts 1-6 for that history.

Where things stood at the end of the previous session

  • Branch at the time: v2-refactor. The draft taper mechanism had just been completely rewritten: build_glyph in lib/glyph_poc.py builds it via a real Minkowski sum (manifold3d.Manifold. minkowski_sum), replacing the per-vertex outline-offset approach (and its self-union patch, added and then removed again that same session - see below) entirely. That old code is gone, not kept behind a flag.
  • Every character that was broken at any point that session - H, e, m, ^, M, A, i, o, 0 - was confirmed clean: watertight, winding-consistent, is_volume=True, no self-intersection possible by construction, correct platen scallop on both curvy and straight-stroke letters.
  • Real cost accepted: generation time. A boolean CSG call replaces plain coordinate math. ~60-70s for the full 84-character ring + assembly at the config’s default quality (points_per_mm=15, cone_segments=16), ~16-35s at faster settings (--points-per-mm 8 --cone-segments 12 or lower) with only minor visual quality loss - see README’s “Performance” section.
  • Known-accepted, still open, unrelated to the draft mechanism: 61 inter-character collisions at separation_mm=2.0 (confirmed real via direct boolean intersection, sitting at the embedded root end - not visible from outside), and the HollowSpace margin flag (flickers True/False run to run from floating-point noise at a razor-thin boundary - documented, not new).
  • separation_mm=1.0 still NOT reapplied. Earlier in that session it was found to eliminate the inter-character collisions entirely (at the cost of less embedding-depth margin) - undone by the full revert (see “The detour” below) and never reintroduced. Config still has separation_mm=2.0, still 61 collisions. logo.radial_offset_mm WAS reapplied (see part 6 below), but at the real v2 value (1.5), not the earlier session’s tuned value for the now-reverted separation_mm=2.0 near-miss investigation - re-check if that near-miss matters again.
  • Real platen cutout + facet-count/preview config expansion (part 6, most recent work) landed on top of all of the above - see that section for the current state of quality.*/logo.radial_offset_mm/ build.minkowski_enabled.

1. Started from last session’s resume point: the naive-offset baseline

Picked up with the per-vertex draft offset (make_back/ orthogonal_offset_vertex/join_front_back) still in its accepted, detection-only state (self-intersection on tight glyphs, not repaired). Investigated a visible artifact on the ring: the H self-intersecting fold, first suspected to be caused by LogoText’s “Leonard Chau 2025” engraving crossing the DHIATENSOR k/K/_ column (a real, separate, near-miss - the logo character a in “Leonard” landed within 0.17deg of that column’s angle, only 0.37mm of Z clearance) - fixed by adding a logo.radial_offset_mm knob and re-sweeping it against both the character band and SpeedHoles clearance.

But the fold on H/k/K turned out to be a separate, pre-existing issue: the same self-intersecting-draft-offset limitation documented in README.md, just grown large enough to be visually obvious once DEFAULT_SEPARATION_MM had been bumped from the real 0.5mm to 2.0mm in an earlier session (for embedding-depth margin - see the code comment history for why). Swept separation_mm from 0.5 to 2.0 and confirmed the fold’s magnitude scales with it (expansion width = separation_mm * tan(27.5deg)), even though the underlying self-intersection is present at every value tested, even the real 0.5mm.

This led to checking the actual physical tradeoff directly: embedding_depth_mm = separation_mm - Char_Protrusion - at the real 0.5mm value, the character root lands EXACTLY on the main cylinder’s own surface radius, zero embedding. Confirmed the self-intersecting fold becomes visually obvious somewhere between separation_mm=0.6 and 0.7.

2. Gated self-union repair - implemented, then found to be unsafe

Implemented a loop-type-aware self-union repair in build_glyph: classify each outline loop as outer-island vs. hole using the un-offset front loop’s signed area (a severely self-intersecting hole, like e’s, can flip its own post-offset signed area, so classification has to happen before the offset is applied) - self-union only when every non-simple loop is an outer island, never a hole (confirmed earlier: self-union caps a hole with a spurious flat membrane instead of fixing it). This cut unresolved self-intersection from 71/84 to 11/84 at separation_mm=0.5.

User found this had NOT actually fixed everything - ^ still showed a visible artifact. Investigation found the real problem: manifold3d’s mesh.union(mesh) on a MULTI-ISLAND glyph (a disjoint dot separate from its stem - i, j, ;, ?) doesn’t reliably keep the islands separate; it can weld them together and lose real volume (confirmed on i: 12.47+3.25=15.72mm3 of real islands before repair, only 10.85mm3 survived after, replaced by zero-volume debris). Also found manifold3d’s boolean routinely produces dozens of disconnected zero-volume debris slivers even on genuinely single-island repairs (36/48 repaired characters, up to 94 slivers on 8) - harmless to watertight/is_volume checks but real debris in the final STL.

Fixed both: gated self-union to single-island glyphs only (multi-island non-simple glyphs, 4/84, stay detection-only), and stripped near-zero- volume components after any self-union. Net result at that point: 44 auto-repaired, 27 genuinely unrepaired (down from 71 broken originally).

3. The detour: “completely reapproach this at a different angle”

Tried separation_mm=2.0 (the original, most-broken setting) with the gated self-union repair active - 48 auto-repaired, 23 still unrepaired, watertight throughout. But then m still showed a visible fold even with the repair in place, and the user called for a full stop: “characters still fucking up… undo all measures we did.” Reverted config/ blickensderfer.yaml, lib/blickensderfer.py, lib/glyph_poc.py to the last commit (67ea8d4) - back to the naive-offset baseline, detection- only, no self-union, none of the separation/logo-radius tuning above.

Discussed real alternatives instead of continuing to patch the per-vertex technique: (1) resume the unfinished shapely.buffer()-based lofted taper on the (still-preserved) v4-real-offset branch, or (2) check whether a REAL Minkowski sum via manifold3d (which this whole project originally avoided for performance reasons) is viable now. Checked - manifold3d exposes Manifold.minkowski_sum/Manifold.cylinder directly. Prototyped it on H/e/m/o/i/^ (the hardest failures): all watertight, winding-consistent, is_volume=True, zero self-intersection by construction - including e and m, which nothing in the naive-offset line of work had ever fully resolved. Measured cost: ~16-66s for the full ring depending on quality settings, vs. ~3-6s before. Decided to build this in as the real replacement.

4. Building the real Minkowski-sum draft mechanism

Rewrote build_glyph in lib/glyph_poc.py: extrude the flat glyph into a thin prism, Manifold.cylinder() for the draft cone, minkowski_sum(), convert back to trimesh. Removed the now-dead back_loops_are_simple/ classify_back_loops/self-union machinery entirely (kept make_back/ orthogonal_offset_vertex/join_front_back/make_front, since build_flat_text - LogoText’s flat-engrave pipeline - still uses them with zero offset, which never had a self-intersection problem). Wired a new cone_segments knob through TextRing/Additive/FullElement/ ResinPrint/generate.py/the config, alongside points_per_mm. Updated TextRing’s reporting to drop the now-impossible self-intersection checks, keeping only inter-character collision detection (a genuinely separate, placement-driven issue).

Found and fixed three real, separate bugs while building this, in order:

Bug 1 - doubled depth. First version extruded the prism to the full separation_mm height AND gave the cone its own full separation_mm height. Minkowski sum ADDS extents in each dimension, so the result came out [0, 2*separation_mm], not [0, separation_mm] (confirmed: H’s z-range was [0, 4.0] at separation_mm=2.0). Fixed by shrinking the prism to a thin sliver (tip_h, ~0.01mm) sitting at the tip end, letting the cone alone carry (almost) the full depth.

Bug 2 - cone origin/dilation direction reversed. User caught this: “the minkowski cone shape origin is wrong, the origin should be at the tip.” manifold3d’s cylinder() places its local origin at the radius_low end - the first fix attempt flipped radius_low/ radius_high AND translated, which canceled out and put the dilation back at the TIP instead of the root (confirmed directly: tip came out wider than root, exactly backwards - watertight/is_volume don’t catch a reversed-but-still-valid draft, only checking cross-section width at z=0 vs. z=separation_mm does). Fixed by keeping the cone’s original wide-at-bottom/apex-at-top construction and only translating the whole cone by -cone_height, landing the apex exactly at the origin. Verified: root width minus tip width = exactly 2*expansion_width_mm.

Bug 3 - manifold3d’s raw triangulation noise. User spotted “facet angles are inconsistent… like a poorly done minkowski” on straight edges (M’s strokes). Confirmed: a single straight wall came out as ~24 near-coplanar micro-triangles with normals wobbling by a fraction of a degree - real CSG-algorithm noise, not a geometry error (face-normal check on the wall showed the wobble directly; Manifold.simplify() collapsed 2918 triangles down to 182 at even 0.0005mm tolerance, into a single flat face per straight run). Added simplify_tolerance_mm (default 0.005mm, config + CLI knob) applied to the minkowski_sum result.

User also asked whether reducing the prism’s height (“just do a slice of the curved part”) would speed up the Minkowski call. Tested directly: face count (and timing) is unaffected by tip_h - a straight extrusion’s face count depends on its 2D cross-section, not its Z-thickness. The real cost driver is points_per_mm (already exposed), and the cap triangulation is already minimal (triangle_args='p', no added Steiner points) - no free win available there.

5. Bug 4 - platen curve applied in the wrong order

User: “are you doing minkowski first, then adjusting the top surface for platen curvature? … the draft angle loses angle spec after adjustment.” Correct diagnosis. The version at that point warped only the SWEPT RESULT’s top ring into the platen parabola, after the Minkowski sum - meaning the cone’s own geometry (and therefore the realized draft angle) was only ever valid for a flat tip; nudging just the final ring left the walls built as if the tip were still flat. Visible specifically on horizontal runs far from radius_y_offset (the platen’s tangent point) - M/A’s bottoms, not L/I’s mostly-vertical runs which stay close to it. Confirmed this also matches how the real v2/lib/glyph_pipeline.scad avoids the problem: PlatenCutout() is subtracted from the base extrusion BEFORE the minkowski() call there, never patched onto the result after.

Fixed by moving the platen Z-warp to the PRISM’s top cap, before the Minkowski sum, instead of the swept result’s top ring after. Verified numerically (root width minus tip width still exactly 2*expansion_width_mm - the warp doesn’t disturb the dilation amount) and visually (M/A’s bottoms now match L/I’s clean quality, no faceting).

Also had to re-verify the earlier simplify()-before-warp ordering bug from step 4 didn’t reappear: simplify() must run on the CURVED result now (after the prism warp + Minkowski sum), not on a still-flat intermediate, or it aggressively over-collapses large flat regions before they’re curved (confirmed: straight-stroke letters lost platen-curve fidelity while curvy letters looked fine only because their outline’s own curve-driven vertex density happened to survive). Final order per character: warp prism top cap -> Minkowski sum -> simplify.

5.5. Checkpoint, then a real assembly bug found by inspection

Committed and pushed the Minkowski rewrite (README/SESSION_LOG rewritten to match). Then, from exporting every character to its own STL for visual inspection (export_glyphs.py, new this round - real per-row config values, not generic defaults, so what you see matches the actual element), user noticed no clean edge forms where a character meets the main cylinder in their viewer.

Root cause: TextRing()/Additive() were the only two assembly functions in lib/blickensderfer.py using trimesh.util.concatenate() to combine parts - everything else (Subtractive, SpeedHoles, DrivePin, ResinSupport, …) already used sp.union_all() (a real manifold3d boolean). concatenate() just merges vertex/face arrays with zero boolean resolution, so wherever a character’s embedded root overlapped the main Cylinder() (by design - that’s what “embedded” means) or two characters overlapped each other (61 confirmed pairs), both surfaces stayed fully intact and superimposed - no new edge formed at the actual intersection. Confirmed via volume: concatenating measured 1148mm3 MORE than a real union of the same parts (the double-counted overlap).

Fixed both call sites to use sp.union_all(). Also switched union_all()’s own implementation from a sequential trimesh.union() fold to manifold3d’s native Manifold.batch_boolean() - ~30x faster on the real 86-part case (2.43s -> 0.08s, identical volume), since the fix now unions far more parts than union_all’s original ~12-part use cases. Committed and pushed separately from the Minkowski rewrite.

6. Real platen cutout + facet-count/preview config expansion

User: “the parabola should be a cylinder like blickensderfer” - confirmed the platen mechanism was still taking the flat glyph’s top-face vertices and moving each one’s Z by the small-angle-approximation formula (a vertex nudge, not a real swept surface), and asked for the real thing. Also asked for several new config knobs: logo.radial_offset_mm back (reverted in “the detour”, never reapplied), a minkowski_enabled toggle for fast previews, and separate facet-count (_fn) knobs per surface family instead of the existing surface_fn catch-all - clarified in conversation: inner shaft (cyl_fn, unchanged) and outer cosmetic body should be SEPARATE knobs (not merged), both may be set to 360; the platen cutout needs its own platen_fn once it’s a real cylinder.

Real platen cutout: platen_radius_mm (the existing small-angle approximation coefficient, 1/(2*Rp)) is inverted to recover the real platen radius Rp - no new parameter needed. Built as an actual Manifold.cylinder() (axis along X via .rotate([0,90,0]), tangent to the tip plane at y=radius_y_offset), boolean-subtracted (Manifold.__sub__) from the glyph block BEFORE the Minkowski sum - same ordering lesson as part 5, just with a real cylinder instead of a warp now. Verified the construction directly (cylinder bounds/tangent-point sampled and checked against the exact circle formula, then a synthetic carved-block test checked point-by-point against z = sep + Rp - sqrt(Rp^2-(y-off)^2)) before wiring it into build_glyph - a synthetic test with too-small margin caught a real design requirement along the way: the block must be tall enough, PER GLYPH, that the cylinder reaches every Y this specific glyph spans, or the corners farthest from radius_y_offset survive uncut (still flat) instead of following the real curve. Margin is now computed per-glyph from the exact bulge formula (Rp - sqrt(Rp^2 - dy_max^2)), not a fixed guess.

Confirmed the real cylinder’s own axis position/radius depend only on radius_y_offset/Rp - both per-ROW constants - so it’s identical for every character in a row by construction, addressing a design question raised mid-session about keeping node/curve consistency across a row without needing to explicitly force it.

Verified: z_max values match the old parabola approximation to within 0.0006mm (expected - the parabola IS that circle’s small-angle approximation), same watertight/is_volume/volume figures as before, visually clean on H/M/A/e (the platen-order failure cases from part 5). Per-character timing actually improved slightly (no more per-vertex Python loop).

minkowski_enabled toggle: when false, build_glyph skips the Minkowski sweep entirely and returns the scalloped-but-undrafted block - correct platen curve and placement, no taper. Full ring + assembly in ~3s vs. ~22-70s depending on quality settings - verified end-to-end via generate.py --no-minkowski.

Facet-count reorganization (quality: in the config): added body_fn (main visible cylinder body - Cylinder/ClipCylinder, was incorrectly sharing surface_fn with unrelated detail surfaces), platen_fn (the new real cutout cylinder), and renamed the Minkowski cone’s segment count from build.cone_segments to quality.minkowski_fn (grouped with the other four _fn knobs now) - old build.cone_segments key still works as a fallback for configs that haven’t been updated. cyl_fn (inner shaft/core) and surface_fn (other structural detail) are unchanged.

logo.radial_offset_mm reapplied at the real v2 value (1.5mm) - NOT the earlier session’s tuned value (which was specific to the now-reverted separation_mm=2.0 near-miss investigation in part 1). Real value confirmed via the config comment to still land within ~0.4mm of a DHIATENSOR column at this exact text/spacing - not currently an issue, worth re-checking if either changes.

7. Building tune.py, an interactive config TUI (new session, branch v4-tui)

Started from a request to stop hand-editing YAML between test renders. Built tune.py (textual) incrementally, tab by tab, fixing real bugs found along the way:

Core structure. A generic SECTIONS/FIELDS table drives most tabs ((yaml_key, [path, into, cfg], type, label, help_text) tuples), so most new config fields only need one table entry to get compose/collect/ save/reload behavior for free - used for the later Gauge and draft-angle additions. A few tabs (Layout, Build, Type Test) are bespoke since they need dropdowns/switches/multi-line text rather than plain fields.

Config tiers. Editing the master YAML directly risked losing the “known-good, matches v2” reference file to exploratory tuning. Built a three-tier scheme instead: master is read-only from the TUI’s perspective; all edits/saves go to a gitignored *.running.yaml scratch copy, auto-created and auto-migrated (_migrate_running_config backfills missing top-level AND nested keys from master into a stale running copy without touching customizations) on load; an explicit Save writes the running copy out to wherever you choose via a real file browser (textual-fspicker’s FileSave/FileOpen, added after a plain Input-based path field proved error-prone for font paths especially).

Workflow buttons and f3d integration. Iterated through several designs for “how do I see what I just changed”: an initial manual “launch f3d” button, then auto-launch/raise on Render (_ensure_f3d_after_build() - starts f3d fresh if not running, or wmctrl -a f3d to raise an existing window), then a dedicated Top View camera for text preview specifically (f3d_top_view_cmds.txt’s set_camera top, via f3d’s --command-script flag - found by strings-ing libf3d.so for camera-related command names after a hand-derived --camera-direction=0,0,-1 guess came out rotated 90° wrong, confirmed by direct user testing) plus --camera-orthographic. f3d is killed on quit/terminal-close so it doesn’t accumulate zombie windows across tuning sessions.

Type Test tab. A flat, non-cylindrical CPI/LPI-spaced text preview (type_test.py) for instant font/legibility iteration, independent of the real element pipeline - persists its own text/CPI/LPI to type_test: in the config. Later given the same modified-left/right alignment handling as the real TextRing, so what you see here actually matches final placement.

Bug: quit didn’t save. Plain q never fires while any Input/TextArea has focus (Textual consumes it as literal typed text, not a binding) - easy to hit by accident after typing in a field and hitting q out of habit. Fixed by adding ctrl+q as a second, reliable binding (needed loop.add_signal_handler, not plain signal.signal() - the latter was found to leave signals unfired for seconds while the event loop is blocked) and having both quit paths save first (_save_before_exit).

Bug: Select.BLANK crashes on mount. In the installed textual version, Select.BLANK is aliased to the plain boolean False, not the real “no selection” sentinel (Select.NULL) - using it as a value crashes Select’s own mount-time validation. All usages (Layout tab’s preset dropdown) fixed to Select.NULL. Discovered while testing the Gauge tab, only surfaced once the real config’s layout.rows first became genuinely “custom” (non-matching any preset).

Layout tab: preset editor with a live read-only preview + unlock. A dropdown of presets (QWERTY, DHIATENSOR, …), 3 read-only rows previewing whichever preset is currently selected, and a “Modify glyphs” switch that reveals 3 editable rows (seeded from whichever preset was showing when unlocked) whose hand-edited content is what actually gets saved to layout.rows - not the preset dropdown’s value - whenever the switch is on (layout.modify_glyphs: true in the config, matching how the running config already looked from earlier hand-editing).

Bug: Layout tab’s read-only preview stopped updating on dropdown change. Root cause: the preview-row helper (_display_rows_for_preset()) derived “the current preset” from self.cfg (disk state, only refreshed on save/reload), not the dropdown’s own live in-widget value - so browsing the dropdown without saving left the preview frozen on whatever was last saved. Fixed by adding a second helper, _rows_for_layout_select_value(value), that takes the live value directly and is used by both on_select_changed (browsing) and on_switch_changed (seeding the editable rows on unlock) instead of the disk-state-based one - _display_rows_for_preset() itself is unchanged and still correct for its own remaining callers (compose()/_refresh_widgets_from_cfg(), where self.cfg and the dropdown are legitimately in sync). Verified via direct headless tests; a first test run gave a false negative from a True -> True no-op Switch reassignment (the real config already had modify_glyphs: true), which looked like the fix hadn’t worked until re-tested with a genuine False -> True transition.

Build tab: iterated to a dropdown + independent checkbox. First pass was a 3-option dropdown (Element Only / Element Resin Print / Shaft Gauge, build.target string) added alongside the new Gauge tab port (next item). Simplified per direct feedback to a 2-option dropdown (Element / Shaft Gauge) plus a separate “Resin supports” checkbox, independent of which target is selected - Element uses the checkbox to choose FullElement() vs ResinPrint(); Shaft Gauge always builds with its own resin supports built in regardless of the checkbox, since a gauge print can’t stand on its own. _refresh_widgets_from_cfg maps any stale target: "resin" value (from the first-pass 3-option version) back to "element" for backward compatibility with configs saved during that window.

Gauge tab: ported v2’s [Shaft Gauge Test] feature. GaugeTestSet and its supporting module (CylinderGauge, GaugeResinSupport, GaugeResinSupportsRaft, RevolverSolid, GaugeText, GaugeTestSubtractive) added to lib/blickensderfer.py, ported closely from blickensderfer.scad. RevolverSolid() (hull of the 6 gauge-pocket cylinders) uses trimesh.util.concatenate(...).convex_hull, matching the existing CoreEllipses() pattern, since trimesh has no hull-of-solids primitive. Found and fixed a real porting bug: GaugeText()’s sp.scad_transform had rotate/translate in the wrong order relative to v2’s source (translate() rotate([0,90,0]), translate outermost) - caught because the text mesh’s own Z-center bounds came out at ~-2.57 instead of the expected 11.325; fixed by reordering to ("translate", [...]), ("rotate", [0, 90, 0]). New tuner tab added with Gauge_Offset_Start/Gauge_Offset_Int fields and an explanatory banner about the calibration workflow (print, test-fit each numbered pocket on the real machine, set core_id_offset to whichever fits). Tab order was also changed to Font & Alignment, Type Test, Resin, Gauge, Build, Layout, Quality, Logo, Element per direct request.

Progress output during Render. generate.py’s stdout is piped (not a TTY) when run as a subprocess from tune.py’s Render button, so Python defaults to full block buffering - without explicit flush=True, progress wouldn’t appear live in the TUI’s log pane at all until the whole process finished. Added flush=True throughout the pipeline’s prints, plus new per-character progress lines in TextRing() ("TextRing: [n/total] building 'x' (row R, col C)... 0.42s") so a ~60s render shows visible incremental progress instead of a long silent pause.

Critical fix: struck characters were never mirrored. User noticed printed characters were coming out backwards. Root cause: v2’s TwoDText wraps every struck glyph in mirror([1,0,0]) (a struck type element must carry a mirror image of the printed glyph, the same reason a rubber stamp or hot-metal slug is cut in reverse) - build_glyph() never did this. Fixed by negating X on the already-shifted contours, applied AFTER x_shift (matching v2’s translate-then-mirror order): contours_mm = [c * np.array([-1.0, 1.0]) for c in contours_mm]. This also mathematically resolved the previously-reported “x offset applied in the wrong direction” bug as a side effect (verified: printed_x = -(-(x_local + x_shift)) = x_local + x_shift, matching Type Test’s already-correct convention) - both bugs were the same missing mirror. Deliberately scoped to build_glyph() (struck characters) only - build_flat_text() (LogoText, Type Test) is untouched, since that text is read directly and must never be mirrored.

Draft angle made configurable. glyph_poc.py’s MINK_DRAFT_ANGLE/ DRAFT_HALF_ANGLE_RAD were fixed module constants with no override. Added DEFAULT_DRAFT_ANGLE_DEG + a draft_angle_deg parameter to build_glyph(), threaded through configure()/TextRing/Additive/ FullElement/ResinPrint, a new build.draft_angle_deg: 55.0 config field, a --draft-angle-deg CLI flag, and a tuner field on Font & Alignment (right after the modified-left/right offset fields). Verified via a direct build_glyph() comparison (55° vs 30° producing correctly different, both-watertight geometry), a save round-trip test, and a full generate.py regression run.

Investigated but found to already be correct: a report that “Render Test Text” wasn’t persisting past the Font/Type Test tabs turned out to already work as intended (the button lives outside TabbedContent in the #buttons panel, visible from every tab) - likely a stale-process artifact on the reporting end, not a real bug; left as-is.

Explored but not yet started: shared-module split + Postal port. Research phase complete - an Explore agent compared postal.scad against blickensderfer.scad and confirmed Postal is a strict simplification of the same cylinder-machine family (same TextRing/LetterPlacement radial-wrap scheme, same four shared v2 lib includes); the only code-level (not just parameter-value) divergence found is the HollowSpace/DrivePin/ResinSupport “drive pin trio” - Blickensderfer has 2 selectable drive-pin styles plus a countersink, Postal has one plain rectangular extrude, no countersink. Everything else differs only in parameter values (many already config-driven in v4). Design sketched but not validated or implemented: a new cylinder_machine.py shared module that each machine’s configure() populates via a globals-dict sync (cylinder_machine.__dict__.update({k: v for k, v in g.items() if not k.startswith("__")})) so shared functions can call machine-overridden functions (like HollowSpace) as ordinary bare names - directly mirroring OpenSCAD’s own dynamic redefinition-across-includes behavior. Per this project’s established refactor convention (see memory: build new files, don’t edit originals in place), the plan is cylinder_machine.py + postal.py as new files alongside blickensderfer.py, not edits to it. Not started.

8. Shared-module split + Postal port, implemented

Validated the part-7 design via a Plan agent before writing code (see that agent’s report for the full reasoning) - core verdict: the globals-sync dispatch mechanism is sound (a function’s __globals__ is a live reference to its defining module’s dict, not a snapshot, so cylinder_machine.__dict__.update(...) really does reproduce OpenSCAD’s “last include wins”), with two refinements: filter the sync to uppercase-leading keys only (k[:1].isupper(), avoiding leaking stray imports like np/trimesh into cylinder_machine’s namespace - plus one explicit exception, the lowercase epsilon constant z, found the hard way when ClipCylinder() immediately NameError‘d on first test run), and an _active_machine guard against a future script configuring two machines in one process (not a real risk today - generate.py/ export_glyphs.py each configure exactly once and exit; tune.py never imports these modules, only shells out to generate.py). The validation pass also caught a second real code-level divergence the original research had missed: BottomSlopedSpace()’s floor-Z literal is 0 in Blickensderfer, -z in Postal (both files’ own comment: “to help with z fighting”) - added as a third machine-set global, Bottom_Sloped_Space_Floor_Z.

Extraction: moved everything structurally shared (Cylinder, ClipCylinder, TextRing, Additive, Core, SpeedHoles, BottomSlopedSpace, TopMinkCleanup, WireBite, SecondaryCore, CoreGrooves, CoreChamfer(Shape), CoreEllipses, LogoText, Subtractive, FullElement, the whole resin-support helper family, ResinPrint, the whole Gauge family) into new lib/cylinder_machine.py. lib/blickensderfer.py now holds only configure() and its own HollowSpace/DrivePin/ResinSupport (the countersink versions), re-exporting FullElement/ResinPrint/GaugeTestSet from cylinder_machine so generate.py’s existing bd.FullElement(...) etc. calls needed zero changes.

Regression-verified the extraction didn’t change Blickensderfer’s output at all: ran generate.py config/blickensderfer.running.yaml --no-minkowski --no-core-groove before the refactor (via git stash) and after - byte-identical vertex/face counts, volume, and watertight/ winding/is_volume flags in both the plain-element and --gauge build paths.

Postal port: lib/postal.py ports v2/postal.scad’s HollowSpace() (plain revolve, no countersink at all), DrivePin() (a single centered box, no sink cylinder unioned on - v2’s DrivePin(Offset) takes an unused Offset param, dropped in the port since nothing calls it with one), and ResinSupport() (no per-style ternary, plain-pin half-extents). config/postal.yaml populated from v2/postal.scad’s own values - layout.rows computed programmatically from v2’s Keyboard_Layout_Array/Element_Layout_Array_Map (Physical_Layout, postal.scad:271-274) rather than hand-transcribed, to avoid a 28-character transcription error. Two known placeholders flagged in the config’s own comments: font paths (v2’s real Postal fonts, “Alma Mono”/”FreeMono: style=Bold”, are system font family names, not .ttf file paths v4 needs - reusing Blickensderfer’s font files until real replacements are sourced) and quality.body_fn (v2’s Postal uses DIFFERENT Fn values for Cylinder() vs ClipCylinder() - Cyl_Fn/Surface_Fn respectively - but v4’s shared Body_Fn knob only has one value; set to Postal’s Cyl_Fn, making Cylinder() exact and ClipCylinder() harmlessly over-faceted).

Added a machine: config key (generate.py/export_glyphs.py peek it before import, default "blickensderfer" for zero-touch backward compat with existing configs) and dispatch via importlib.import_module.

Verified end-to-end: generate.py config/postal.yaml (plain element, --gauge, and via export_glyphs.py) all produce watertight/ winding-consistent/is_volume output with no code errors (one character, ‘%’, skipped for a missing glyph in the placeholder font - expected, not a bug). Also directly verified the _active_machine guard fires (RuntimeError) when a test script configures Blickensderfer then Postal in the same process.

Explicitly out of scope for this pass, flagged as follow-up: tune.py support for Postal - its SECTIONS table is hardcoded to Blickensderfer’s exact field set (e.g. the “Element” tab’s drive_pin_countersink_depth/drive_pin_style/etc, none of which exist in Postal’s schema) and would KeyError immediately if pointed at config/postal.yaml. Needs a per-machine SECTIONS variant chosen from the config’s machine: key - orthogonal UI work, not started. Sourcing real Postal font files is also a prerequisite content task, not a code problem.

9. tune.py support for Postal

Closed out part 8’s flagged follow-up. Split the module-level SECTIONS/ FIELDS/LAYOUT_PRESETS into SECTIONS_BY_MACHINE/LAYOUT_PRESETS_BY_MACHINE (Postal’s Element field list drops the 5 drive-pin-countersink keys that don’t exist in its config, everything else shared) and made TuneApp.SECTIONS/.FIELDS/.LAYOUT_PRESETS instance attributes, fixed once at startup from the launch config’s machine: key. All method-level references updated to self.SECTIONS/etc (a bulk regex pass, then fixed a self.self. double-prefix bug the regex introduced on the two lines it was defining, caught immediately via grep).

Deliberately scoped to NOT support hot-swapping between machines within one running session - compose() only builds each tab’s widgets once, so a config for a different machine has a structurally different Element tab (different KEYS, not just different values) that would need real widget teardown/rebuild, not just repopulation. Added a guard in _switch_master_config that peeks the target config’s machine: key and refuses the switch (clear log message pointing at relaunching directly) if it doesn’t match the machine tune.py was launched for.

Fixed config/postal.yaml missing a type_test: section (tune.py’s Type Test tab unconditionally reads self.cfg["type_test"]["text"/"cpi"/"lpi"]

  • crashed on first boot test with a real KeyError, not a hypothetical one). Also fixed a real hardcoded-machine-name bug found in the same pass: action_save’s suggested STL filename was always f"blickensderfer_{timestamp}.stl" regardless of which machine was actually being tuned - now f"{self.machine}_{timestamp}.stl".

Postal’s Layout tab has an empty preset dropdown (v2/postal.scad has only one physical layout, no preset-switching menu like Blickensderfer’s 6) - confirmed Textual’s Select tolerates a genuinely empty options list fine (tested directly, no crash) with allow_blank=True and a machine-aware prompt string (“no named presets for this machine” instead of “custom - not a known preset”).

Verified via headless App.run_test() boot tests (not just visual screenshots) against both configs: correct per-machine field counts (73 total/32 Element fields for Blickensderfer, 68/27 for Postal), correct initial values, a save round-trip on a Postal-only-relevant field (drive_pin_radial), and the cross-machine switch guard actually refusing (confirmed app.machine stays "postal" and app.master_config_path stays on postal.yaml after attempting to switch to blickensderfer.yaml).

Investigated the Postal font placeholders: the user’s local font library DOES have real Alma Mono.otf and FreeMono-Bold.otf files (found via find) - but checking their outlines directly via FreeType’s point tags confirmed both use CFF/cubic curves, not TrueType/quadratic - exactly the silent-failure case documented in README’s “Known limitations” (FreeMono-Bold.otf was literally named there as the confirmed example). Flagged this to the user rather than silently wiring in known-broken fonts or guessing a substitute.

10. Cubic-curve (CFF/OpenType) outline support

User asked directly: “wait so my thing is not otf support?” - clarified that OTF is just a container (can hold either TrueType/quadratic glyf outlines, which already worked, or CFF/cubic outlines, which didn’t) and that the real gap was cubic Bezier support in contour_to_points (lib/glyph_poc.py), not anything about the .otf extension itself. User asked for the real fix rather than a font substitute.

Root cause: contour_to_points only checked FreeType’s on-curve bit (tag & 1) - every off-curve point was treated as a lone quadratic control point via the TrueType “implied midpoint” convention. A CFF outline’s off-curve points come in PAIRS (two consecutive cubic control points before the next on-curve point) - misreading them as quadratic produced a plausible-looking but geometrically wrong curve, with no error raised (the exact bug already documented, now actually understood at the curve-math level instead of just observed as an OTF/TTF divide).

Fix: added cubic_bezier() (mirrors the existing quadratic_bezier()) and a second per-point classification, is_cubic = (tag & 0x3) == 2 (FreeType’s FT_CURVE_TAG macro - 0=quadratic off-curve, 1=on-curve, 2=cubic off-curve), checked before falling through to the existing quadratic path. Cubic spans consume 3 points (this control + the guaranteed-paired second control + the on-curve endpoint) instead of 1-2.

Verified in layers: (1) raw contour extraction against real Alma Mono.otf - every character’s contours are valid simple polygons with sane, character-distinct ink dimensions (e.g. ‘e’ 1.5x1.53mm, ‘M’ 1.5x2.1mm, ‘.’ 0.43x0.38mm at 3mm font size - not a degenerate all-identical artifact); (2) full build_glyph() through the real Minkowski pipeline on both the CFF font and a TrueType font side by side - both watertight/winding-consistent/is_volume, different (correct) per-character bounds; (3) generate.py config/blickensderfer.running.yaml re-run to confirm the TrueType/quadratic path is completely unaffected - byte-identical vert/face/volume numbers to before this change; (4) the full generate.py config/postal.yaml pipeline (element and --gauge, both fonts - Alma Mono.otf for the element, FreeMono-Bold.otf for LogoText/GaugeText) end-to-end, watertight/winding-consistent/ is_volume, 0 characters skipped (previously 1, on the placeholder font’s missing glyph).

config/postal.yaml’s font paths were then switched from the TrueType placeholders to the real Alma Mono.otf/FreeMono-Bold.otf files, since the limitation blocking them is now fixed.

11. Machine picker on startup + Postal’s QWERTY-only layout

User’s explicit request: a machine-picker screen shown on startup (closing out part 9/resuming-item-6’s deferred “master GUI”), plus a “Change Machine” button on the tuner form’s status row that returns to it, and Postal’s Layout tab restricted to a single “QWERTY” preset (its one real physical layout) instead of the empty dropdown from part 9.

Considered a full Textual Screen-stack rewrite (separate MachineSelectScreen/TunerScreen classes, push_screen/ switch_screen) and rejected it as far more invasive than needed - nearly every one of TuneApp’s ~40 methods reference self.query_one(...), and moving compose() to a Screen subclass raises real ambiguity about whether self.query_one on the App still resolves against the active screen’s content (untested assumption, not worth risking on a 1200-line file). Used Textual’s App.recompose() instead (confirmed available in the installed 8.2.8 - removes all mounted children and re-runs compose()): compose() now branches on self.machine is None (picker) vs. not (the existing tuner form, extracted unchanged into _compose_tuner_ui()), and switching states is just setting self.machine/calling _load_machine() then await self.recompose(). Zero changes needed to any of the ~40 existing action/query methods - they all still just call self.query_one(...), now finding whichever widgets the current compose() branch actually built.

_load_machine(config_path): the master/running config bootstrap + per-machine SECTIONS/FIELDS/LAYOUT_PRESETS setup that used to be inline in __init__ (part 9) is now its own method, callable both from __init__ (backward-compat direct CLI launch, python3 tune.py config/x.yaml - still supported, skips the picker) and from the new _select_machine(machine_key) (the picker’s button handler). MACHINES (new module dict, {"blickensderfer": (label, config_path), "postal": (...)}) is the picker’s source of truth and the only place a future third machine needs to be registered.

“Change Machine” button: saves the current form first (reused _save_before_exit() - same courtesy as quitting, so in-progress edits aren’t silently lost on a machine switch), then self.machine = None; await self.recompose() - which is exactly what shows the picker again. _switch_master_config’s existing cross-machine guard (part 9) is now pointed at this button in its log message instead of “relaunch tune.py”, since there’s now a proper in-app path for that.

Postal’s QWERTY preset: LAYOUT_PRESETS_POSTAL = {"QWERTY": [...]} (the same 3 rows already computed for config/postal.yaml from v2’s Keyboard_Layout_Array/Element_Layout_Array_Map in part 8 - reused verbatim, not recomputed) replaces part 9’s empty {} for Postal in LAYOUT_PRESETS_BY_MACHINE. _compose_layout_tab’s help text made properly machine-aware (checks self.machine directly now, not just “are there any presets”) since the old “no presets” branch no longer applies but the Blickensderfer-specific “Ported from v2/lib/layouts/blick_layouts.scad” text would have been wrong for Postal’s one preset too.

Verified via headless App.run_test() + pilot.click() (clicking real buttons, not just calling methods directly): picker shows both machines on a no-arg launch; clicking Postal recomposes into a Postal-scoped form with the Layout tab’s dropdown correctly auto-selecting “QWERTY” (matches config/postal.yaml’s rows exactly, via the existing _current_layout_preset() match-detection); clicking “Change Machine” returns to the picker; picking Blickensderfer afterward correctly rebuilds with its own full field set; direct CLI launch still skips the picker entirely; a save round-trip works correctly on freshly-recomposed widgets (confirming self.inputs isn’t holding stale references from a previous machine’s form). Also confirmed visually via screenshot (converted SVG->PNG with convert) - centered picker layout, correct status text (“machine: Postal | master: config/postal.yaml”), all three status-row buttons fit on one row.

12. Unified resin.raft toggle (was a silent per-machine divergence)

User caught a real issue: Postal’s resin support didn’t look “damn near identical” to Blickensderfer’s like everything else in the shared module - Postal was growing one big continuous raft plate. Root cause: v2’s two machines genuinely differ here - Blickensderfer’s CutGroove() ring sits right at the wall (Cut_Groove_Inner_X=0) and each rod grows its own small raft (Resin_Rod_Raft=true); Postal’s Cut_Groove_Inner_X=-14.9 pushes the ring’s inner profile point all the way to the element’s center axis, forming one continuous plate, and its rods grow no individual raft of their own (Resin_Rod_Raft=false) - this was ported faithfully in part 8, correctly reproducing each machine’s real v2 behavior, but never surfaced as an intentional choice - just two silently-diverging per-machine config defaults.

User’s fix, exactly as specified: a single “Continuous raft” checkbox on the Resin tab - off (now the default for BOTH machines) = Blickensderfer’s original individual-raft behavior, on = Postal’s original continuous-plate behavior - available for EITHER machine now, not Postal-exclusive.

Implementation: added cylinder_machine.resin_raft_config( element_diameter, wall_min_thickness, raft_enabled) - a real shared function (not per-machine copy-paste) that derives both Resin_Rod_Raft/Cut_Groove_Inner_X from the one boolean, called from both blickensderfer.py and postal.py’s configure(). Removed the raw resin.rod_raft/resin.cut_groove_inner_x YAML keys entirely (fully derived now, not independently settable) and replaced with resin.raft: false in both config files - config/blickensderfer.yaml and config/postal.yaml’s resin: sections are now identical in shape. tune.py’s Resin tab field table (already shared between machines, part 9) swapped its cut_groove_inner_x float field for a raft bool field (automatic Switch widget via the existing generic FIELDS mechanism, no special-casing needed).

Verified all 4 combinations (each machine x both settings) via direct generate.py --resin-support runs: Blickensderfer raft:false (default) reproduces the EXACT pre-change ResinSupport vert/face counts (13648/27196); Postal raft:true reproduces Postal’s exact pre-change numbers (15851/31698) - confirming the derivation is mathematically exact, not just visually similar; Postal raft:false (new default) and Blickensderfer raft:true (a combination that never existed in v2) both produce new, watertight/winding-consistent/is_volume geometry. Also verified the new checkbox in tune.py (Switch widget, correct initial value, save round-trip) via headless App.run_test().

Self-caught mistake: an early headless test instantiated TuneApp directly against the user’s REAL master config paths (config/ blickensderfer.yaml) to check the new checkbox’s default value, then saved during the same test - this went through the real master/running migration + save path and actually flipped raft: true in the user’s live, gitignored config/blickensderfer.running.yaml scratch file (their real working config, with real customizations). Caught immediately by checking git status/grepping the file after the test, fixed by hand (set raft: false back, and removed the now-dead migrated-in rod_raft/cut_groove_inner_x lines the same migration had left behind) - the user’s actual settings were otherwise untouched. Lesson for future headless tests that exercise save paths: use a scratch copy of a config, not the user’s real master/running files, even for read-only- seeming checks - TuneApp.__init__ itself performs a real migration side-effect on the running file as soon as it’s constructed.

13. Matched Blickensderfer’s and Postal’s remaining resin values

Follow-up to part 12’s raft unification - user asked to go further and match the actual remaining resin VALUES too, not just the raft toggle. First pass (wrong direction, corrected same turn): changed config/ blickensderfer.yaml’s min_rod_height/raft_od/raft_thickness/ bottom_support_fractions/bottom_support_inner_angle_offset to Postal’s values, on the assumption “match A to B” meant “make A become B” without a stated target. User corrected immediately: “you made it match up to postal, i wanted blickensderfer to be the standard” - Blickensderfer’s ORIGINAL v2 values are the standard both machines unify against, not Postal’s. Reverted config/blickensderfer.yaml back to its original v2 numbers (min_rod_height: 4.0, raft_od: 2.0, raft_thickness: 1.0, bottom_support_fractions: [0.2], bottom_support_inner_angle_offset: 0.5) and instead changed config/ postal.yaml’s values to match THOSE. The two files’ resin: sections are still byte-identical when parsed (yaml.safe_load(...)['resin'] == the other’s) - just anchored to the opposite machine’s original numbers than the first pass.

Regression-verified both directions against the master config files directly (not the user’s real running copies - see part 12’s lesson, applied both times): fully watertight/winding-consistent/is_volume in both passes. Final state’s ResinSupport counts: Blickensderfer 13648/27196 (exactly its original pre-any-of-this-session’s-changes number, confirming the revert is exact), Postal 13719/27322 (now close to Blickensderfer’s, small remaining gap expected from the two machines’ different Element_Diameter/Wall_Min_Thickness/etc, not a bug).

Resuming later

  1. Reapply or re-decide on separation_mm=1.0 (see “Where things stood” above) - logo.radial_offset_mm is back (part 6), but separation_mm is still the reverted 2.0, still 61 collisions.
  2. Inter-character collisions (61 at separation_mm=2.0) - no automatic fix short of redoing placement/size, or accepting the separation_mm=1.0 tradeoff (verified to eliminate them, at the cost of embedding-depth margin).
  3. Performance - if ~60-70s at full quality becomes annoying, points_per_mm/quality.minkowski_fn are the main levers, or build.minkowski_enabled: false for a ~3s undrafted preview - all wired through config + CLI (--no-minkowski).
  4. Alignment offsets - the mechanism is built and now in real use (see the running config’s modified_left_offset_mm/ modified_right_offset_mm), but the base center_offset_mm/ left_offset_mm knobs are still untouched at their 0.0 defaults.
  5. platen_fn/body_fn are both set to 360 right now (per earlier direction, “may both be set at 360”) - 720 was floated for platen_fn if the scallop needs to be smoother; not tested.

14. Roadmap: branch merged to main, next is Calibration then Mignon/Bennett/Helios

v4-tui was fast-forward merged into main and pushed (40 commits, clean linear history, no divergence). New branch v4-models created off main for the next phase: user’s stated order is Mignon next (should reuse a lot from cylinder_machine.py), then Bennett, then Helios (both also cylinder-machine-family) - but the Calibration feature needs to land FIRST, before diving into more machine ports, since every future machine will want it too.

Explicitly NOT started yet - user is about to run out of credits, asked for a plan only, will say “start” when ready to resume. This section is that plan, written here specifically so it survives a context/credit gap.

What “Calibration” actually is (traced from v2, not guessed)

v2/lib/testing.scad (testSweepArray(start, interval, count) - a trivial linear sweep, [start + interval*n for n in 0..count-1]) + v2/lib/glyph_pipeline.scad’s TextRing()/TextRingDebug() (~line 407-451) implement a real, already-designed mechanism, not something to invent from scratch:

  • Test_Layout (bool): every position renders the same Test_Char instead of the real per-position character.
  • Cutout_Test / Baseline_Test (bool, independent - test one variable at a time in practice): when on, adds a PER-COLUMN swept offset (Cutout_Test_Array[col] / Baseline_Test_Array[col], each a 28-long testSweepArray(start, interval, 28)) onto that row’s platen-cutout or character-baseline value - so each of the 28 physical positions around the ring gets a DIFFERENT offset of whichever variable is under test.
  • Whenever any of the three is on, TextRingDebug() echoes one line per position: "character keyboard key 'k' (rendered as 'X') on lowercase row at the 7 oclock position with platen cutout at 0.15mm and character baseline at 0mm" - refChar (the real keyboard key physically at that slot) vs char (what’s actually rendered, Test_Char under Test_Layout) are tracked separately for exactly this reason.
  • Blickensderfer/Postal both default Cutout_Test_Start=0/ Cutout_Test_Int=.05 (Blick) or .7/-.05 (Postal) - testing.scad’s own comment notes Bennett/Mignon/Helios/Hammond use a fixed literal offset array instead of a uniform sweep for this, since they already have measured values - not a blocker for v4’s first pass (a uniform start+interval sweep, matching what the user asked for: “specified intervals of adjustment”).

v4 implementation shape (design, not yet built)

  • Shared, not per-machine: TextRing/placement already live in lib/cylinder_machine.py (see part 8) - a CalibrationElement()-style function belongs there too, so it’s automatically available to Mignon/Bennett/Helios the moment they’re configured, not something reimplemented per machine.
  • Config: new calibration: section (parallel to the existing gauge: section) - test_char (str), variable (“baseline” | “cutout”), start (float), interval (float). Both config/blickensderfer.yaml and config/postal.yaml need it added (shared schema, like resin:/gauge: already are).
  • Build dispatch: extends the existing Build tab pattern (Element / Shaft Gauge) with a third option, Calibration - mirrors how Gauge was added (part 8’s GaugeTestSet/--gauge precedent): a --calibrate flag on generate.py, a CalibrationTextRing() (or a test_layout/test_variable kwarg threaded through the existing TextRing()) that applies the swept per-column offset and forces Test_Char everywhere.
  • Console output: reuse the already-established print(..., flush=True) pattern (part 7/8) - one line per position, same content as v2’s TextRingDebug echo (keyboard key, row label, o’clock position, the actual cutout/baseline value used at that position).
  • tune.py: new “Calibration” tab (test char field, a variable dropdown “baseline”/”cutout”, start/interval fields) - added to SECTIONS_COMMON (shared between machines, like Resin/Gauge already are) so it’s free for every future machine too. Build tab’s dropdown gains “Calibration” as a third option.
  • Saved .txt mapping: user’s explicit ask - “when saving a test element, it also outputs a .txt of the keyboard mapping to set variable.” action_save() (tune.py) already writes an STL + a .yaml sidecar per save (see the module docstring’s Save explanation) - add a third sidecar, a .txt with the same per-position lines the console already printed, when self._last_build_info["kind"] == "calibration" (mirrors the existing _last_build_info tracking used for the STL/YAML metadata header).
  • No new “reference layout” abstraction needed - v2’s Reference_Physical_Layout (a keyboard-key-labels-vs-content-layout split, used by Blickensderfer/Bennett/Mignon for language/layout testing) has no v4 equivalent yet and isn’t needed for a first pass - the “keyboard key at this position” in the console/.txt output can just be whatever layout.rows/DHIATENSOR currently has configured at that row/col (same source TextRing already reads), matching what Postal/Helios/Hammond do in v2 anyway (no reference override - refChar == char’s position source).

After Calibration lands

Mignon, then Bennett, then Helios, each following the extraction playbook already proven twice (part 8’s Blickensderfer/Postal split, part 9’s tune.py per-machine SECTIONS, part 12/13’s shared resin config) - read the real v2 file, diff it against cylinder_machine.py’s existing shared functions to find genuine code-level divergences (not just parameter values, per part 8’s methodology), port only the machine-specific pieces, add config/<machine>.yaml, regression-verify against the master configs directly (not the user’s running copies - see part 12’s lesson).

15. Calibration feature implemented (per part 14’s plan)

User said “start plan” - implemented part 14’s design as written, no deviations from the mechanism (v2’s Cutout_Test/Baseline_Test/ Test_Layout traced through lib/testing.scad + lib/glyph_pipeline. scad’s TextRing/TextRingDebug).

lib/cylinder_machine.py (shared, per part 14’s reasoning - free for Mignon/Bennett/Helios later): place_on_cylinder() gained an optional baseline_mm=None param (overrides BASELINE_ROW[row] when calibrating that variable; None, every other caller, preserves the exact prior behavior - zero risk to the real element path). New CalibrationTextRing()/CalibrationAdditive()/CalibrationElement() (mirroring TextRing/Additive/FullElement’s structure exactly) sweep start + interval*col onto either Cutout_Row[row] or Baseline_Row[row] per physical column, force calibration.test_char everywhere, and print one line per position - keyboard key, o’clock-equivalent, and the exact cutout/baseline values used.

One deliberate deviation from v2, explained in both the code comment and README: the position label uses the REAL physical placement angle (PLACEMENT_MAP[col] * LATITUDE_INT, matching place_on_cylinder’s own angle formula) instead of v2’s raw content-order col. Traced why: v2’s TextRingDebug computes o’clock from the loop’s raw col (content-order index into Physical_Layout), which only equals the true physical position for machines with an IDENTITY Placement_Map (Postal - “no Placement_Map override… the lib defaults Placement_Map to identity”). Blickensderfer’s real placement_map is NOT identity ([13,12,11,...]), so porting v2’s formula literally would have mislabeled every position for Blickensderfer specifically - decided this was worth deviating on (with the reasoning documented) rather than faithfully reproducing what looks like a latent v2 quirk.

Config: new calibration: section (test_char: "X", variable: "cutout", start: 0.0, interval: 0.05) added to both config/blickensderfer.yaml and config/postal.yaml identically (shared schema, matching gauge:/resin:).

generate.py: --calibrate flag (+ --calibration-char/ -variable/-start/-interval overrides), mirrors --gauge’s early-return structure. Writes the STL, then a <stem>_mapping.txt sidecar with the same per-position lines the console printed - the user’s explicit ask (“when saving a test element, it also outputs a .txt of the keyboard mapping”).

tune.py: new shared “Calibration” tab (test_char input, variable dropdown - added as a second special-cased Select alongside the existing mode one, start/interval inputs), Build tab dropdown gained a third “Calibration” option, _run_build dispatches --calibrate (Minkowski forced the same way as a normal element build, since Calibration DOES go through the real draft/placement pipeline unlike Gauge). action_save now copies the _mapping.txt sidecar alongside the saved STL too, gated on self._last_build_info.get("target") == "calibration" (reusing the existing _last_build_info tracking, same pattern as the .yaml metadata sidecar).

Verified thoroughly, at every layer: direct generate.py --calibrate on both machines (Blickensderfer default cutout-sweep AND Postal with --calibration-variable baseline) - all 84 positions built, fully watertight/winding-consistent/is_volume, .txt sidecar content spot- checked (clean, correctly formatted, matches console output exactly). Headless tune.py tests against SCRATCH COPIES of the configs (not the user’s real master/running files - part 12’s lesson applied from the start this time): Calibration tab fields/values, Build dropdown’s third option, a full save round-trip, AND the complete _run_build worker path (real subprocess generate.py invocation via the Preview button) - confirmed the STL and mapping .txt both get written and _last_build_info["target"] == "calibration" is set correctly for action_save’s new logic to key off.

Resuming later

  1. Mignon, then Bennett, then Helios - see part 14’s extraction playbook. Calibration (this part) is done, so all three get it for free via cylinder_machine.py.
  2. Everything in part 14’s own original “Resuming later” list (the pre-existing backlog: separation_mm, inter-character collisions, performance, alignment offsets, platen_fn/body_fn) is still open - unrelated to Calibration, not touched this pass.

16. Two Calibration follow-up fixes from real usage

Bug: Calibration Element ignored --minkowski/--no-minkowski. User: “right now, pressing preview renders it with minkowski.” Root cause: generate.py’s --calibrate branch parsed --minkowski/--no-minkowski (and every other build-quality flag - --points-per-mm/--separation-mm/--cone-segments/ --simplify-tolerance-mm/--platen-fn/--draft-angle-deg) but never actually passed any of them to CalibrationElement() - so tune.py’s Preview button (which sends --no-minkowski) had zero effect; every calibration build silently used the config’s default (minkowski on), the same ~36s path as Render regardless of which button was pressed. Fixed by threading all of them through, matching the normal build_fn(...) call exactly. Confirmed: --no-minkowski now takes ~1.4s (was ~36s regardless of the flag before the fix) - verified both via direct CLI timing AND through tune.py’s actual _run_build worker path (a real subprocess call, not just unit-testing the arg parsing). Also renamed the Build tab’s “Calibration” dropdown option to “Calibration Element” (all UI text/docstrings quoting the old label updated to match).

Redesign: calibration.variable (single baseline-or-cutout choice) replaced with two independent checkboxes. User: “have checkboxes for vary baselines, and vary cutouts. usually youll only have 1 checked at a time.” This is actually a MORE faithful port of v2 than the original single-choice design - v2’s Cutout_Test/Baseline_Test really are two separate booleans (confirmed by re-reading part 14’s own research), not a single enum; the original single-variable implementation was an unnecessary simplification. Replaced calibration.variable: "cutout" with calibration.vary_baseline: false/vary_cutout: true (same net default) in both config files, in CalibrationTextRing/Additive/ Element’s signatures (vary_baseline/vary_cutout bools instead of a variable string), in both machines’ configure(), and in generate.py’s CLI (--calibration-vary-baseline/ --calibration-no-vary-baseline + the -cutout pair, mirroring the --minkowski/--no-minkowski tri-state pattern). tune.py’s Calibration tab: the variable field’s Select special-case removed entirely (two plain bool fields get Switch widgets automatically via the existing generic FIELDS mechanism - no special-casing needed, simpler than what it replaced). Both CAN be checked together (moves baseline and cutout by the same shared offset simultaneously) or both off (no sweep, every position identical) - not just the “usually 1” case, though that’s still the expected normal usage the user described.

Verified all four on/off combinations (default cutout-only, baseline-only override, both-on, both-off) via direct generate.py --calibrate runs - all watertight/winding-consistent/is_volume, .txt mapping output spot-checked to confirm baseline/cutout values actually move independently per combination (both-on: both columns’ values change together; both-off: cutout stays pinned to its row’s fixed value across every column). tune.py’s two Switch widgets verified via headless test against a scratch config (not the user’s real files) - correct initial values, save round-trip.

17. layout.baseline_row/cutout_row exposed on the Element tab

User asked why calibration’s cutout/baseline values print negative - explained the coordinate convention (these arrays are “distance below the clip end,” not absolute Z - place_on_cylinder’s BASELINE_Z_OFFSET (=Element_Height) + baseline_mm is what converts them into an absolute position; the console/.txt output deliberately mirrors the raw config value so it can be pasted straight back in). Immediate follow-up: “missing the values to place baselines and cutouts in the Element tab” - the whole point of Calibration is finding a number to dial in, and there was no dial to turn without hand-editing the YAML.

layout.baseline_row/cutout_row are 3-element inline numeric arrays (one per row) - list-valued, so they never fit the generic FIELDS mechanism’s one-scalar-per-key assumption (same reason layout.rows needed its own bespoke block-list patcher back in the original tune.py build-out). Added a matching bespoke mechanism for INLINE (not block) lists: patch_yaml_list_item(text, key, index, value) - regex-matches key: [...], splits on commas, replaces just the one element, rejoins - leaves everything else in the file untouched, same “surgical patch, not a round-tripped YAML dump” philosophy the other patchers already use.

6 new bespoke Input fields (baseline_row_0/1/2, cutout_row_0/1/2, labeled “Baseline row 0 (lowercase)” etc.) appended to the bottom of the Element tab specifically (_compose_section_tab calls _compose_baseline_cutout_fields() when section == "Element") - not folded into SECTIONS_COMMON["Element"] since they need patch_yaml_list_item instead of patch_yaml_value, same reasoning BASELINE_CUTOUT_KEYS (a module-level set of their 6 self.inputs keys) exists for: _collect_values/_save_to_yaml both check membership to route them to the list-item patcher instead of the generic scalar one, and _refresh_widgets_from_cfg repopulates them explicitly (same pattern Layout’s custom rows and Type Test’s fields already use for their own bespoke widgets).

Verified: patch_yaml_list_item unit-tested directly against a representative YAML snippet (patches only the targeted index, leaves surrounding keys/formatting untouched); full headless round-trip against a scratch config (initial values populate correctly, edit+save persists correctly, a genuinely invalid value is rejected with an error log and None return, matching every other field’s error-handling convention); visually confirmed via screenshot (renders cleanly at the bottom of the scrollable Element tab, correct labels/values); generate.py’s normal build path re-confirmed unaffected (byte-identical ResinPrint output).

18. Fixed Calibration’s reference sourcing (was a real moving-target bug) + sweep defaults

User: “what is the reference baseline/cutout, is it sourced from the running config, or default/master config - we would want to use a fixed value, probably master because if we change config and use that as reference, we could be chasing a value that is always changing after we updated our config.” Checked: CalibrationTextRing used the module globals BASELINE_ROW/CUTOUT_ROW, set by configure() from whatever config the process was given - for a real tune.py Preview/Render, that’s self.config_path, the RUNNING copy. Combined with part 17’s new Element tab fields (which write to that same running copy), this was a genuine bug the user caught before it bit anyone: dial in a value from one calibration pass, hit Preview again, and the sweep would silently re-center on your just-saved edit instead of staying anchored - each pass chasing the previous one’s result instead of converging on a fixed target.

Fix: CalibrationTextRing/Additive/Element gained reference_baseline_row/reference_cutout_row parameters, defaulting to the BASELINE_ROW/CUTOUT_ROW globals when not given (preserves exact prior behavior for direct/CLI callers with no override). New generate.py --calibration-reference-config PATH flag loads layout.baseline_row/cutout_row from a SEPARATE file and passes them in explicitly; tune.py’s _run_build always passes self.master_config_path here for calibration builds. Also added a summary print at the start of every CalibrationTextRing run (test_char=... vary_baseline=... vary_cutout=... start=...mm interval=...mm - reference baseline_row=[...] cutout_row=[...]) so which reference is actually in effect is never ambiguous from the log - this was as much the user’s real question (“what is the reference… sourced from”) as it was a bug report, so making it visible closes the loop properly instead of just fixing the code silently.

Verified the fix is real, not just plausible: built a scratch “running” config with baseline_row[0] hand-edited to -4.5 (simulating an already-dialed-in value) against the real unmodified master (baseline_row[0]=-4) - confirmed via direct CLI that --calibration-reference-config <master> reports reference baseline_row=[-4, ...] and produces baseline=-4.7mm at column 0 (-4 + start(-0.7)), NOT -5.2mm (what -4.5 + -0.7 would give); confirmed the opposite (no reference flag) correctly falls back to whatever config was passed, for backward compat. Then reproduced the exact real-world scenario end-to-end through tune.py’s actual _run_build worker (real subprocess, scratch config, not the user’s real files): set baseline_row_0 to -4.5 via the Element tab widget, ran Preview (which saves the running copy first, confirmed via grep - the running file really did get -4.5), and the resulting .txt mapping still showed baseline=-4.0000mm at column 0 (master’s original, untouched value) - the moving-target bug is gone.

Sweep default changed: user also asked for calibration.start’s default to be -0.7 (was 0.0) “so we tet above and below the set reference” - 0.0 only ever swept upward from the reference (0 to +interval*27); -0.7 (interval unchanged at 0.05) now spans -0.7mm to +0.65mm across the 28 columns, testing both directions. Changed in both config files and both machines’ configure() fallback defaults.

19. Mignon ported (first non-“cylinder machine”)

User: “lets proceed with the roadmap. the next elements. slightly different in that theres no gauge for them but there is calibration.” Dispatched an Explore agent for the same function-by-function comparison methodology used for Postal (part 8) - the result was a much bigger divergence than expected: unlike Postal (one drive-pin-trio difference), Mignon shares almost nothing structural with cylinder_machine.py - only the glyph placement pipeline (TextRing) and the Calibration mechanism are genuinely reusable as-is. Confirmed directly from v2/mignon.scad: no Core/ClipCylinder/WireBite/SpeedHoles/ core_shaft.scad family at all (plain rotate_extrude() shaft bore), a 12-sided polygon body instead of round, a stepped-boss+chamfer top instead of a wire clip, a plain cut-through alignment keyway instead of a countersunk drive pin, top+bottom Minkowski cleanup instead of top-only, and fully bespoke resin-support placement (raft ring + rods at two radii, none of CutGroove/SpeedHoleSupport/DrivePinSupport/ BottomSupports). Also: 7 physical rows/12 columns, not 3/28 - and no Shaft Gauge Test at all (confirmed via v2/mignon.scad:30’s own comment, also true for Bennett/Helios Klimax per their own files).

Two shared, backward-compatible cylinder_machine.py changes (both regression-verified byte-identical against Blickensderfer/Postal):

  1. TextRing/CalibrationTextRing’s hardcoded for row in (0,1,2): -> range(len(DHIATENSOR)) - Mignon’s 7-row Baseline_Regular/Cutout arrays needed this; reduces to the identical 3-row loop for the existing machines’ configs.
  2. place_on_cylinder() gained optional placement_protrusion/ angle_half_step params (None = the exact prior hardcoded Char_Protrusion/0.5 behavior) - v2’s own documented optional- override mechanism (lib/glyph_pipeline.scad’s Letter_Placement_Protrusion/Angle_Half_Step, already designed for exactly this - Bennett/Mignon/Helios override both to 0). Verified build_glyph() itself needs NO Mignon-specific changes - Letter_Extrude_Offset/Letter_Extrude_Depth (v2’s manual per-vertex depth tuning) have no v4 equivalent, since v4’s real-Minkowski-sum architecture already auto-computes margin per-glyph.

lib/mignon.py (~380 lines, new file) reimplements the rest locally: PolygonCylinder/ElementChamfer/ElementLabel/MinkCleanup/ CenterShaft/HollowBody/AlignmentPin/ResinSupport, plus its own Additive/FullElement/ResinPrint/CalibrationAdditive/ CalibrationElement (can’t reuse cylinder_machine.CalibrationElement - it unconditionally builds Cylinder()+ClipCylinder(), which would crash outright on Mignon’s missing Body_Fn/Clip_OD globals). ElementLabel reuses the same “flat, non-drafted” simplification LogoText() already established for Blickensderfer/Postal’s logo (v2’s real version gets a small sphere-minkowski rounding, cosmetic only, not the big draft-cone struck characters get) - explicitly flagged as a known simplification, not silently. ResinPrint() faithfully ports a genuine v2 quirk that looked surprising at first: it FLIPS the whole element upside-down before adding supports (translate([0,0,Element_Height]) rotate([0,180,0]), v2/mignon.scad:438-439) - the label/chamfer end ends up facing the build plate, the shaft/mechanical end faces away from supports.

config/mignon.yaml: real font found locally (Iosevka Etoile.ttf - already genuine TrueType, no cubic-curve concern). Physical layout (German 4 / Layouts[5], 7 rows x 12 columns) computed programmatically from v2/lib/layouts/mignon_layouts.scad’s DEUTSCH4 array through Char_Legend’s remap, same as Postal’s layout.rows derivation - not hand-transcribed. resin.support_height/support_thickness and several element: keys are Mignon-only, not shared with Blickensderfer/Postal’s schema.

generate.py: the HollowSpace() character-root-containment check was Blickensderfer/Postal-specific (Mignon has no HollowSpace() at all - its hollow-out, HollowBody(), is a structurally different taper with no equivalent diagnostic question) - changed to if hasattr(bd, "HollowSpace") instead of assuming every machine has one.

tune.py: SECTIONS_COMMON trimmed to only the two genuinely- universal tabs (Font & Alignment, Calibration) - Logo/Quality/Resin split into *_BLICKPOSTAL and *_MIGNON variants (Mignon’s schemas diverge in all three, not just Element), and Gauge became fully optional (GAUGE_FIELDS, only in SECTIONS_BY_MACHINE["blickensderfer"/"postal"]

  • compose()’s Gauge tab and _compose_build_tab()’s “Shaft Gauge” dropdown option both check "Gauge" in self.SECTIONS now instead of assuming presence). Added "mignon" to MACHINES.

One real bug found and fixed via direct visual verification (not just numeric watertight checks - rendered actual screenshots at multiple angles, since a plausible-but-wrong character/column arrangement can still be watertight): v2/mignon.scad:120’s Latitude_Int=-360/ len(Layout[0]) is NEGATIVE - missed on the first implementation pass (copied Blickensderfer/Postal’s positive 360/columns formula literally). Caught by rendering the built element from multiple angles and cross-checking against a rigorous, non-visual confirmation: exported a single isolated glyph (‘F’) and inspected it from the exact “print face” viewing direction, confirming build_glyph()’s existing struck- character mirror (verified correct for Blickensderfer earlier this session, and 100% shared/unchanged code - not something Mignon-specific config could affect) was working exactly as intended the whole time; the actually-wrong thing was column wrap direction, fixed by hardcoding the sign flip in mignon.py’s own configure() (a real, sourced, machine-specific value, not a cylinder_machine.py change).

Verified thoroughly: all 84 positions (7x12) build successfully with --no-minkowski (fast path, ~0.7s) AND --minkowski (real full-quality draft, ~53s) - fully watertight/winding-consistent/is_volume in both, plus the resin-support/flip path and the Calibration path (including --calibration-vary-baseline forcing BOTH sweep variables on simultaneously). Visually confirmed via multiple f3d renders (isolated single-glyph, plain element, full ResinPrint with supports) - real 12-sided body, correctly placed/legible-when-struck characters, working resin support raft+rods. tune.py regression-verified for both existing machines (identical field counts to before: 78/73 total fields including Calibration’s 5, matching the pre-restructuring 73/68 + 5 exactly) and newly verified for Mignon (50 fields, no Gauge tab mounted, correct Element/Resin values, save round-trip) via headless App.run_test() against scratch config copies throughout - never the user’s real files.

20. Layout tab was hardcoded to 3 rows everywhere - fixed for Mignon’s 7, imported all 30 real presets

Right after the Mignon port landed, testing the Layout tab surfaced the obvious gap: it was written when Blickensderfer/Postal (3 physical rows each) were the only machines, and “3” had leaked into the code as a literal in nine separate places rather than being derived from the config. Mignon has 7 rows and, unlike Postal’s single QWERTY preset, a real catalog of ~30 named language layouts in v2/lib/layouts/mignon_layouts.scad that were never imported.

Importing the presets. Read all 338 lines of mignon_layouts.scad directly and transcribed its 33 raw layout arrays. 3 are empty placeholders in v2 itself (CUSTOMLAYOUT, DEUTSCH_FRAKTUR_GOTISCH, DEUTSCH_FRAKTUR_PROF_STIEHL) and were excluded, leaving 30. Each row was passed through the same Char_Legend=[7,8,9,10,11,0,1,2,3,4,5,6] remap used for Postal’s preset import (part 14/15) - v2 authors the raw arrays in one column order and remaps them to physical placement order at load time, so a literal transcription would have been wrong. Two rows (Georgian rows 2/4, Greek-new-ortography rows 3/4) have 13 characters in v2’s source where every other row has 12 - confirmed this is dead data in v2 itself (Char_Legend only ever indexes 0-11, so v2 never reads a 13th character either) and truncated to r[:12] rather than guessing which character was erroneously included. Result: LAYOUT_PRESETS_MIGNON, a 30-entry dict of 7-row layouts, verified programmatically (all 30 x 7 x 12) before being pasted into tune.py and registered in LAYOUT_PRESETS_BY_MACHINE["mignon"].

The range(3) sweep. grep -n "range(3)" found nine hardcoded occurrences across tune.py: BASELINE_CUTOUT_KEYS (a module-level constant - had to become an instance attribute, self.BASELINE_CUTOUT_KEYS, computed in _load_machine() from len(self.cfg["layout"]["baseline_row"]), since row count now varies per machine), _compose_baseline_cutout_fields’s ROW_LABELS enumeration (silently dropped rows past index 3 - fixed to iterate range(len(values)) and only use ROW_LABELS[i] as an optional parenthetical when in range), _compose_layout_tab (x2: the row-preview widgets and the help text’s literal word “3 rows”), _refresh_widgets_from_cfg (x3), on_select_changed, on_switch_changed, and _save_to_yaml’s custom-rows collection. All now derive the count from actual data (len(display_rows), len(current_rows), len(arr), or n_rows read from config) instead of a literal. Also restructured _compose_layout_tab’s preset-help-text branch from if blickensderfer / elif options / else to explicit per-machine elif branches - the old fallthrough would have matched Mignon into Postal’s “only one physical layout” copy, since Mignon now has options truthy too, just with 30 instead of 1.

Verification, all via headless App.run_test() against scratch config copies: Mignon - 30 presets load, dropdown correctly auto-detects “German 4” as the current preset from config/mignon.yaml’s rows, all 7 read-only preview widgets exist, all 14 baseline_row_N/cutout_row_N Element-tab fields present, switching the dropdown to “Russian 3” + enabling “Modify glyphs” correctly seeds all 7 custom-row Input widgets from LAYOUT_PRESETS_MIGNON["Russian 3"], and a preset-switch-then-save round-trip writes the full 7 new rows to layout.rows. Blickensderfer/Postal regression - both still show exactly 6 baseline/cutout fields and 3 row widgets (unchanged), Gauge tab still mounts, Postal’s single QWERTY preset still auto-detects correctly. Visually confirmed via an App.run_test() SVG screenshot of Mignon’s Layout tab: German 4 selected, all 7 rows rendered with correct content, help text reads “7 rows” (not the old hardcoded “3 rows”).

21. Mignon Element/Logo tab audit: keyboard-legend layout order, a real second “Label” feature, Tallen mode

User audit request: make sure Mignon’s Element tab (and “check all other things”) are genuinely Mignon-specific, not leftover Blickensderfer/Postal material, and that nothing real from v2 is missing. Cross-checked ELEMENT_FIELDS_MIGNON/LOGO_FIELDS_MIGNON/QUALITY_FIELDS_MIGNON/ RESIN_FIELDS_MIGNON and config/mignon.yaml’s actual numeric values field-by-field against v2/mignon.scad’s real customizer sections - no leaked Blickensderfer/Postal fields or wrong-machine values found (every field list was already correctly scoped, every value matched v2 exactly). Found and fixed three real, distinct gaps instead:

Layout preset rows were stored in build order, not keyboard-legend order. The 30 presets imported in part 20 (and config/mignon.yaml’s own layout.rows) were stored as v2’s Char_Legend-remapped Physical_Layout (Char_Legend=[7,8,9,10,11,0,1,2,3,4,5,6], v2/mignon.scad:88,275) - correct for driving the build directly, but not how a person reads the legend off the actual keyboard/manual (v2’s own, separate Layout array). User’s example: 7890-#123456 should read #1234567890-. Algebraically, keyboard = physical[5:] + physical[:5] is the exact inverse of the Char_Legend remap for this specific 7-then-5 split - verified both symbolically and by round-tripping every row of every preset back through Char_Legend and confirming an exact match against the original physical-order data (zero build-output change). Applied that rotation to all 30 LAYOUT_PRESETS_MIGNON entries and to config/mignon.yaml’s layout.rows, added layout.char_legend: [7,8,9,10,11,0,1,2,3,4,5,6] to config, and added the actual remap step (DHIATENSOR = [[row[char_legend[c]] for c in ...] for row in layout["rows"]]) to lib/mignon.py’s configure() - previously DHIATENSOR was just layout["rows"] verbatim, silently relying on the stored data already being pre-remapped. Verified the rebuilt DHIATENSOR is byte-identical to the old hardcoded physical-order string for German 4

  • this is purely a display/edit-order change, the STL is unaffected.

A genuine second “Label” engraved-text feature, not in v2 at all. Re-read v2/mignon.scad’s real [Logo] customizer section (lines 218-236) end to end - it contains exactly ONE engraved-text feature (Cylinder_Label/ElementLabel()), which is what this app’s existing logo.* config/tune.py “Logo” tab already drives (schema-reuse naming, not a second feature - confirmed Blickensderfer’s real [Logo] section the same way: one LogoText(), no separate label concept anywhere in v2). User wants a second, independent one added anyway, same field format as Logo, permanently 180 degrees opposite it. Implemented as a genuinely new v4-only feature: extracted the placement math into _render_engraved_text(text, size, spacing, position_offset, height_offset, font_path), renamed the existing function ElementLabel -> ElementLogo (unchanged behavior, still reads logo.*), added a new ElementLabel() reading a new label.* config section, with Label_Position_Offset computed as Logo_Position_Offset + 180.0 in configure() (an invariant, not a stored/independently-editable value - moving Logo moves Label with it). Additive()/CalibrationAdditive() now union both. tune.py’s LOGO_FIELDS_MIGNON gained 5 parallel label_*-keyed fields (font/text/size/spacing/height-offset, no position field since it’s derived) in the same “Logo” tab, label_font_path added to FONT_PATH_FIELD_KEYS for its own Browse button. Default label.text is empty, NOT a copy of logo.text - checked the math first: at Logo’s real 15deg/char spacing, “Leonard Chau 2026” (18 chars) already spans ~255 degrees, so a second copy 180 degrees away would heavily overlap the first instead of sitting cleanly opposite it (255+255 > 360). Verified via direct FullElement() builds + f3d renders: default (empty label) builds clean; a short test string (“TEST”) renders legibly at the bottom of the chamfer ring while “Leonard Chau 2026” occupies the top - visually confirmed 180 degrees apart, no overlap.

Tallen (Plakatschrift) mode - acknowledged missing in the original port comment, now implemented. v2/mignon.scad:109-115,197: a display-type variant that adds Height_Increase (3mm) to Element_Height and shifts every Baseline row by Tallen_Baseline_Offset (-1.25mm) when Tallen=true - Cutout has no Tallen variant in v2 at all, a real asymmetry (Baseline shifts, Cutout doesn’t), not an oversight to fix. Added element.tallen/height_increase_mm/tallen_baseline_offset_mm to config (off by default, matching v2 and this file’s real untallened German 4 element), wired into configure() (Element_Height/BASELINE_ROW both conditional on Tallen, CUTOUT_ROW untouched), and exposed as three new ELEMENT_FIELDS_MIGNON entries. Verified: Tallen=false reproduces the exact prior Element_Height (40.5) and BASELINE_ROW values; Tallen=true gives 43.5 and every baseline shifted by exactly -1.25 with CUTOUT_ROW unchanged; full builds succeed watertight in both states.

Deliberately NOT touched, flagged as pre-existing/cross-machine gaps rather than Mignon-specific bugs (would be real scope creep to fix here): v2’s “unified” glyph-quality system (Weight_Adj_Mode/Scale_Multiplier/ Y_Scale/Text_Align_Method and friends) has no v4 implementation for ANY machine, not just Mignon (grep confirmed zero references anywhere in lib/*.py). Same for Character_Modifieds/Typeface_2 (per-character baseline shift + secondary-font-by-character) - present in ALL THREE v2 machine files, not ported for any of them. Both belong in a dedicated follow-up, not folded into a “make Mignon’s existing fields correct” pass.

Regression-verified Blickensderfer/Postal: field counts unchanged (78/73), no duplicate self.inputs keys introduced by the new label_*/tallen/etc. keys (checked across the whole file, not just Mignon’s own section, since self.inputs is a single flat dict), no Mignon-only keys leaked into their field sets.

22. tune.py help-text/tooltip Statics were clipped, then found to double-wrap once un-clipped

User report: tooltip/help messages in the TUI get cut off when longer than their box. Traced to .field-help, .picker-help, and .advanced-warning all being pinned to a fixed height (1 or 2 rows) in TuneApp.CSS - any message longer than that was clipped instead of wrapping. Worst offenders were the Gauge/Calibration SECTION_INTROS banners: authored as 7-9 line strings but rendered squashed to a single visible line. Fixed by switching .field-help/.picker-help/ .advanced-warning to height: auto (and .field-row to height: auto + margin-bottom: 1, since it previously relied on a fixed height: 2 for both the Horizontal row and its optional help line).

That surfaced a second, distinct bug: those same banners (and the Layout tab’s per-machine notes, the Build tab’s target explainer, the Type Test intro) were hand-wrapped with embedded \n at some assumed width. With the box now auto-sizing, Textual wrapped each already-broken line a second time - confirmed via a headless App.run_test() region check, Gauge’s 7-line intro rendered at height=14. On a normal-height terminal this pushed the real Offset start/Offset int inputs (Gauge) and test_char/start/interval inputs (Calibration) far enough down the tab to require scrolling past the fold - reported by the user as the text “covering” the inputs and making them impossible to edit. Fixed by removing every manual \n line break from TUI-displayed help text (single flowing string, wraps exactly once) and rewriting the wording shorter throughout - dropped internal v2-source cross-references and redundant clauses. Re-verified headlessly: Gauge’s intro dropped from 14 rendered lines to 6, with both its inputs landing at row 12/15 even in a 24-row terminal. Short one-line field-level hints (e.g. “TrueType font for the struck characters.”) were already concise and left untouched.

New standing rule for any tooltip/help-text Static added to tune.py from here on - see CLAUDE.md: height: auto, never a fixed row count; no manual \n line breaks (let Textual wrap once); keep the wording to 1-2 short sentences.

23. Helios Klimax ported (Bennett’s own port, in between, has no log chapter - see CLAUDE.md’s note on that)

Diffed v2/heliosklimax.scad (unusually, already self-documenting - its own header records a real v1->v2 byte-check correction history) against lib/cylinder_machine.py function-by-function per the standing rule, not assumed from “it’s cylindrical too”. Result: even less shared than Mignon/Bennett - Helios has no SecondaryCore/CoreGrooves/CoreChamfer/ CoreEllipses at all (v2’s own header: “not applicable… in the original”), no Logo/Label engraved text, and no Shaft Gauge Test (same header: “Sections with no Helios equivalent… are omitted”). Only TextRing/CalibrationTextRing/place_on_cylinder (via cylinder_machine._receive_config) are genuinely reused; everything else is new, bespoke code in lib/helios.py.

Two real values resolved that place_on_cylinder’s docstring had flagged as “not yet verified either way” for Helios: placement_protrusion=-0.05 (v2’s Letter_Placement_Protrusion=-.05 - a real, if small, built-in 0.05mm radial inset, distinct from the platen- cutout radius which still uses the full Char_Protrusion) - WRONG, see part 25: this directly copied v2’s raw value without re-deriving it for v4’s different transform structure, the exact mistake lib/bennett.py’s port had already made and fixed. Corrected to Char_Protrusion (the default). and angle_half_step=0 (no half-column centering term, same as Mignon, still correct). Both threaded through as explicit lib/helios.py module globals (Placement_Protrusion/Angle_Half_Step), same pattern Mignon’s configure() uses. Updated place_on_cylinder’s docstring to record Helios’s real verified value instead of leaving it as an open question.

A genuine two-stage difference() in v2’s Assemble(), not a simple additive-minus-subtractive split. v2 nests three difference()s: AlignmentPinSupport()/ClipRetainer() (two bosses) are unioned in after the first round of cuts (HollowingElement/MinkCleanup/ IndicatorHole), then themselves get cut by a second round (AlignmentPinHole/CenterShaftHole/WireClip). Checked whether flattening this into one “union everything, subtract everything” (the pattern every other machine’s FullElement uses) would actually change the result, since that’s a much simpler shape to write - it would have: AlignmentPinSupport’s boss sits at radius 8.92mm, height range [1.5, 4.5]mm, which falls inside HollowingElement’s own cavity extent (radial span ~[4.58, 11.08]mm, height span ~[2.5, 16.2]mm, both computed from the real config values) - in the true nested v2 order the boss is added after that cut already happened, so it’s untouched by it; a naive flattened version would incorrectly eat the boss. lib/helios.py’s _assemble() reproduces the real staged construction directly instead (stage-1 cut, then union the bosses; FullElement’s own final .difference() is only the genuine outer-scope cut). Documented at length in the module docstring so the next person touching this file doesn’t “simplify” it back into the wrong flattened form.

HollowingElement()’s true circular-hull cross-section, ported as a real shapely convex hull of 5 circles (matching v2’s hull(){circle(); circle(); ...} exactly - true rounded corners) rather than cylinder_machine._hollow_space_profile()’s hand-rounded point-list approximation, the same technique WireBite()/mignon.AlignmentPin() already use for a real hull-then-revolve/extrude.

Two declared-but-unused v2 fields, preserved as such rather than wired up or silently dropped (v2’s own header comment confirms both, independently of this port): Cyl_Fn (“critical shaft/pin cylinder facet number”) is declared but never referenced anywhere in v2’s real Assemble()/TypeTest() - every cylinder there uses Surface_Fn instead; kept in config/helios.yaml/lib/helios.py’s configure() for schema parity, not read by any function. Resin_Support/ Resin_Support_* are declared but v2 never builds any actual support geometry with them (no ResinRod/CutGroove-equivalent module anywhere in the file) - ResinSupport()/ResinPrint() in lib/helios.py are therefore a plain no-op/alias to FullElement(), so toggling tune.py’s always-present “Resin supports” checkbox does something sane (produces the plain element) instead of an AttributeError for this one machine. Text_Fn/Text_2D_Fn (v2’s OpenSCAD $fn hook for glyph curves) was not ported even as declared-but-unused, since it has no v4 equivalent at all - v4’s build_glyph() samples curves via points_per_mm vector-tracing, not an OpenSCAD facet count.

tune.py wiring: MACHINES["helios"], SECTIONS_BY_MACHINE["helios"] (Font & Alignment/Calibration/Quality/Resin/Element only - no Logo/Label/Gauge key, per the header note above), LAYOUT_PRESETS_HELIOS (both of v2’s inline arrays, GERMAN_MOD - v2’s real default/only-used one - and the superseded GERMAN, the same “expose what’s textually in the source” treatment Bennett’s redundant CUSTOM preset got), and a LAYOUT_PICKER_HELP["helios"] entry. BASELINE_CUTOUT_KEYS/ROW_LABELS/the Layout tab’s row-count logic were already fully row-count-agnostic from Mignon’s part-20 fix, so Helios’s 4 rows (vs. everyone else’s 3) needed zero additional literal-count fixes - confirmed by grep, not just assumption. Verified the whole form actually composes (not just that the dicts are well-formed) via a headless TuneApp(...).run_test() against a scratch copy in /tmp (never the real config, per the standing warning above) - all 8 expected tabs present, no Logo/Label/Gauge tabs, no exceptions.

Audit pass (per CLAUDE.md’s “every machine port gets its own dated chapter” rule): cross-checked ELEMENT_FIELDS_HELIOS/ QUALITY_FIELDS_HELIOS/RESIN_FIELDS_HELIOS and config/helios.yaml’s actual values field-by-field against v2’s real customizer sections and against Mignon/Bennett’s equivalent lists - no leaked fields from another machine, no missing real v2 field, resin.* lives under resin: (not quality:, avoiding Mignon’s known outlier - see CLAUDE.md “Pick one convention”). No list-valued config key silently unexposed: layout.baseline_row/cutout_row get the existing bespoke patch_yaml_list_item treatment (row-count-agnostic already), layout. placement_map/rows are deliberately YAML-only (same treatment as every other machine).

Verification (hard gate): full-quality build (generate.py config/helios.yaml, config defaults - Minkowski on, points_per_mm=15) completed in ~62s, FullElement: watertight=True winding_consistent=True is_volume=True volume=4276.923mm3, all 84 characters placed with zero skips. 44 inter-character collisions were reported (detection-only, per _check_inter_character_collisions’s existing design) - expected at Helios’s tight 21-column layout on a 27.15mm element, not a defect introduced here. Fast preset (--points-per-mm 8 --cone-segments 12 --no-minkowski) verified clean across every build mode: plain FullElement, --resin-support (byte-identical volume to plain, confirming the no-op alias), --cut-bodies, and --calibrate. Confirmed zero side effects on existing machines: cylinder_machine.py/ scad_primitives.py/glyph_poc.py were not touched by this port (Helios needed none of their machine-specific pieces beyond TextRing/CalibrationTextRing/place_on_cylinder), and a Blickensderfer regression run reproduced the exact baseline from this file’s own “Verifying a geometry-affecting change” section byte-for-byte (verts=42618 faces=85408 ... volume=5666.804mm3).

24. Helios’s shaft bore upgraded to reuse cylinder_machine.py’s core_shaft family

Follow-up to part 23, same session’s continuation. Explicit user direction: “we can reuse the cylinder machines design for chamfering the inner shaft, bottom and top boss, then doing the fancy core stuff that are on all the other cylinders” - i.e. swap Helios’s plain straight shaft-bore cylinder for the same Core()/CoreChamfer()/ SecondaryCore()/CoreEllipses()/CoreGrooves() family Blickensderfer/ Postal/Bennett already reuse from lib/cylinder_machine.py. A real, deliberate DEVIATION from v2 (which part 23’s own audit correctly confirmed had none of this), not a porting correction - documented as such in both config/helios.yaml’s header and lib/helios.py’s module docstring, per CLAUDE.md’s “say so explicitly instead of silently diverging” rule.

No v2 source of truth for the new dimensions. core_chamfer/ core_bottom_offset/core_contact_length/core_web_*/core_groove_* have no real Helios value to port - v2 simply never had this system. Seeded from Bennett’s config instead (closest shaft diameter of any existing machine: 3.4mm vs. Helios’s own 4.16mm, and a close element height too: 18.0mm vs. 18.7mm) as starting estimates, explicitly flagged in both files as not-real-machine-numbers, meant to be tuned against the physical part the same iterative way layout.baseline_row/cutout_row already are - not treated as final values.

A real bridging-alias footgun, caught before it caused a NameError. cylinder_machine.Core() references the bare global name Clip_Height, not Element_Clip_Height - every existing reuser of that function (Blickensderfer/Postal) happens to already have a config field named exactly that, so this had never been exercised with a differently-named source field before. Helios’s own code (ClipRetainer()/WireClip()/ the rest of this module) already used Element_Clip_Height throughout from part 23’s port, and renaming it everywhere purely to satisfy one reused function would have been needless churn - added Clip_Height as a second global for the same value in configure() instead, documented inline as a bridging alias. This is exactly the class of bug CLAUDE.md’s _receive_config() warning already calls out (“a new machine-set global with a lowercase name is silently excluded”) generalized one step further: not just casing, but genuine cross-machine naming mismatches between a shared function’s expectations and a new machine’s own config-derived names - worth checking for on any future reuse of an existing shared function by a new machine.

Core_Top_Z/Core_Taper_Top_Z convention chosen deliberately, not by default. Helios has a real clip (ClipRetainer()/WireClip(), ported faithfully from v2 in part 23), the same situation Blickensderfer/Postal are in (chamfer/taper sit under the clip, Core_Taper_Top_Z=Element_ Height distinct from Core_Top_Z=Element_Height+Clip_Height) - NOT Bennett/Mignon’s clip-less Core_Taper_Top_Z=Core_Top_Z. Picked by matching Helios’s own physical situation to the right sibling precedent, not by copying whichever machine was edited most recently.

Two-stage difference structure preserved. The new core_shaft parts join AlignmentPinHole/WireClip in the genuine final-stage cut (_final_cut(), previously named for CenterShaftHole which this change removes entirely, replaced by Core(0)) - all near the shaft axis (radius well under Element_Square_Hole_Position=8.92mm), so none of them reach back into AlignmentPinSupport’s boss the way a naive single-difference flattening risked for HollowingElement in part 23. Confirmed by the build succeeding watertight/single-volume, not just reasoned about.

build.render_core_groove/quality.groove_fn/quality.cyl_fn added to config/helios.yaml and threaded through FullElement/ CalibrationElement/Subtractive (previously render_core_groove was accepted-but-ignored, matching Mignon’s “no core groove system” pattern - now genuinely honored, since there IS one). quality.cyl_fn goes from declared-but-unused (part 23’s finding, confirmed correct for the original v2 file) to genuinely read, for the first time, by Core()’s shaft-bore facet count.

Verification (hard gate): rebuilt at both fast (--points-per-mm 8 --cone-segments 12 --no-minkowski) and full quality (config defaults) - FullElement: watertight=True winding_consistent=True is_volume=True in every case, all 84 characters placed with zero skips, same 44 informational inter-character collisions as part 23 (unrelated to this change, at the layout/font level not the shaft). Verified --no-core-groove actually changes the output (verts/volume differ from the default-on build) - confirms the toggle is genuinely wired, not silently ignored the way it used to be. --resin-support still produces a byte-identical volume to the plain build, confirming the no-op alias survived this change untouched. No shared module (cylinder_machine.py/scad_primitives.py/glyph_poc.py) was touched in this follow-up at all - only lib/helios.py/config/helios.yaml/ tune.py - so no regression check against other machines was needed this time (zero possible side effect).

25. Helios’s placement_protrusion was wrong - characters sat too deep (user-reported)

User report: “min final char diameter is not working correctly. right now the glyphs are inset into the element too much.” Traced to lib/helios.py’s configure() setting Placement_Protrusion=-0.05 - copied directly from v2’s own Letter_Placement_Protrusion=-.05 (v2/heliosklimax.scad:268) during part 23’s port. That was exactly the mistake lib/bennett.py’s port had already made and fixed, documented at length in cylinder_machine.place_on_cylinder’s own docstring - which part 23 read (and even edited a neighboring line of) without applying its lesson to Helios. Should have been caught during the original port; wasn’t, until it showed up as a physically-wrong build.

The actual bug. v2’s LetterPlacement (where the raw pre-cutout character block is positioned) and PlatenCutout (the cutting cylinder that trims it down to its final visible shape) are TWO INDEPENDENT transforms in v2. Letter_Placement_Protrusion=-.05 only ever moves the former - v2/heliosklimax.scad’s own file-header comment confirms this explicitly: “a small built-in 0.05mm radial inset that only affects placement, not the platen-cutout radius.” The latter - which is what actually determines the physical strike depth, the thing min_final_ character_diameter is supposed to control - uses a completely independent formula, confirmed by this same file’s own “v2.0” comment: Element_Diameter/2+Platen_Diameter/2+Char_Protrusion, the EXACT SAME formula Blickensderfer/Postal use. v4’s build_glyph()/ place_on_cylinder() have no such split at all - the platen scallop is baked into one local mesh, placed by a single radial offset (placement_protrusion) - so place_on_cylinder’s own docstring proves the visible low point lands at exactly Element_Diameter/2+ placement_protrusion. Passing v2’s raw -0.05 (a value that in v2 only ever affected the OTHER, unrelated transform) pinned the low point far closer to the axis than the real machine’s min_final_character_diameter (28.19mm) implies - Char_Protrusion here is (28.19-27.15)/2=0.52mm, vs. the wrongly-configured -0.05mm: over half a millimeter of extra, wrong inset. min_final_character_diameter/Char_Protrusion were effectively dead config values, the identical failure mode Bennett’s port found and named explicitly (lib/bennett.py’s own docstring: “with placement_protrusion=0min_final_character_diameter was a dead config field”).

Fix: removed the Placement_Protrusion global and the placement_protrusion=Placement_Protrusion kwarg from both Additive()’s TextRing() call and CalibrationAdditive()’s CalibrationTextRing() call - omitting it lets place_on_cylinder fall through to its own default (Char_Protrusion), exactly matching Blickensderfer/Postal/ Bennett’s treatment. angle_half_step=0 is untouched - a real, unrelated v2 value with no two-transform split to worry about (angle placement is a single unified transform in both v2 and v4). Corrected the stale claims this introduced: cylinder_machine.place_on_cylinder’s own docstring (previously asserted Helios “genuinely” needed a nonzero value, now explains Helios needs the SAME re-derivation Bennett needed, for the same reason), lib/helios.py’s module docstring/configure() comment, config/helios.yaml’s build section comment, and README.md’s Helios writeup - all previously stated the wrong -0.05 value as verified fact rather than flagging it as unresolved/needing the same treatment Bennett got.

Lesson, stated plainly since it already cost a shipped bug once: reading a docstring that documents a prior fix is not the same as applying its reasoning to new code in the same sitting - place_on_ cylinder’s docstring explicitly named this exact pitfall before part 23 ever touched Helios, and it was still missed. Any future machine that reuses place_on_cylinder needs its OWN check for whether v2’s LetterPlacement/PlatenCutout (or equivalent) are one transform or two, not a default assumption either way, and not a straight copy of whatever raw customizer value v2 happened to expose for the block- placement stage specifically.

Verification (hard gate): rebuilt at fast preset (--points-per-mm 8 --cone-segments 12 --no-minkowski) - watertight, single volume, all 84 characters placed with zero skips, same informational-only inter-character collision set as before (unrelated to this fix). No shared module was touched beyond the docstring correction above (comment-only, zero logic change) - no cross-machine regression risk.

26. f3d never auto-launched for Helios previews - a 33-second diagnostic hiding after “wrote …” (user-reported)

User report: ran a Quick Preview for a Helios element, watched it finish successfully (opened the STL afterward and it looked correct), but f3d never auto-opened - and quitting the TUI printed a scary-looking Exception ignored in: <function BaseSubprocessTransport.__del__> / RuntimeError: Event loop is closed traceback. That traceback is just asyncio cleanup noise (fires whenever a subprocess transport is garbage-collected after the event loop that owned it has already closed) - a symptom, not the cause. Ruled out a hard crash first (headless TuneApp().run_test() boots the picker and composes Helios’s form fine, and a direct rebuild of the STL succeeds watertight/is_volume and renders correctly in f3d standalone - see the render check earlier this session) before finding the real bug.

Root cause: tune.py’s _run_build() only launches f3d after generate.py’s subprocess reports returncode == 0 (lib/cylinder_machine.py’s TuneApp._run_build, unchanged this session). generate.py prints wrote <path> as soon as the STL is on disk, but then - for any machine defining HollowSpace() - runs one more diagnostic step: hollow.contains(part.vertices) per character, to report whether any struck character’s root reaches the hollow cavity. Timed it directly: for Helios, that ONE diagnostic call took 33 seconds (lib/helios.py’s HollowingElement()/HollowSpace() mesh had 95,760 faces - .contains() has no pyembree acceleration in this environment, confirmed by import trimesh.ray.ray_pyembree failing, so it falls back to an O(points x faces) ray-cast). The user saw wrote ... scroll by (looking done, especially since the fast build itself only takes ~2.6s) and quit before the still-running subprocess finished its extra diagnostic pass - killing it before returncode == 0 was ever observed, so f3d’s launch branch never ran. Confirmed the same diagnostic on Blickensderfer takes 0.18s against a 2,682-face HollowSpace() mesh - not a universally slow path, specific to Helios’s own mesh being ~36x heavier for this one internal, invisible feature.

Why Helios’s hollow-cavity mesh was so much heavier: two compounding factors, both introduced during part 23’s port, neither justified by anything actually visible in the printed part. (1) HollowingElement()’s profile is a shapely convex hull of 5 circles (resolution=32 each) - unlike a single-circle hull elsewhere (e.g. WireBite()), 5 SEPARATE circles each contribute their own arc segments to the hull boundary, compounding into 134 profile points (measured), vs. Blickensderfer’s fixed 9-point hand-designed profile. (2) the revolve used sections=Surface_Fn, and Helios’s own config default for surface_fn is 360 (vs. Blickensderfer’s 120) - appropriate for genuinely visible exterior surfaces (Cylinder/ClipRetainer/ WireClip) but wasted on a cavity that’s entirely internal and never seen once printed.

Fix (SUPERSEDED - see part 27: hardcoding a resolution/sections value to route around a diagnostic’s cost, instead of questioning whether the diagnostic should exist, was the wrong fix - reverted, the diagnostic itself was removed instead): HollowingElement() now uses a fixed resolution=6/sections=60 (not Surface_Fn) for its own hull/revolve, chosen empirically to land close to Blickensderfer’s own face count (3,480 vs. 2,682) rather than picked arbitrarily. Diagnostic timing: 33s -> 1.12s (~29x). Full Quick Preview, end-to-end via generate.py exactly as tune.py invokes it: ~4.2s total (previously would have been ~2.6s build + 33s diagnostic ~ 36s) - comfortably inside normal patience. Full-quality Render also got faster as a side effect (the actual boolean cut is lighter too, not just the diagnostic): ~44s vs. the ~79s measured in part 25, still watertight/is_volume/single-volume, identical character placement (still 84/84 placed, same 16 informational inter-character collisions, unrelated to this change).

Lesson: an arbitrary “looks smooth enough” resolution choice made during initial geometry porting (part 23’s resolution=32, chosen with no real justification beyond “seemed fine”) had a real, compounding downstream cost in a totally different code path (a debug diagnostic in generate.py, not the geometry construction itself) that wasn’t exercised/timed until a real user workflow (Quick Preview -> auto-launch f3d) hit it. For any internal/invisible geometry feature (a cavity, a web, a fillet nobody will ever see or touch), default to a LOW, deliberately-chosen resolution rather than reusing the same high-fidelity knob visible exterior surfaces need - and time diagnostic/debug code paths too, not just the main build, before considering a port done.

27. Removed the HollowSpace() diagnostic entirely instead of working around its cost; undid part 26’s hardcoded resolution

User pushback on part 26’s fix, and correct: (1) the HollowSpace() “does any character root reach the hollow cavity” diagnostic in generate.py is redundant - it never gated or failed a build (purely informational), and its value was already known to be noisy/ non-actionable (the “HollowSpace margin is razor-thin by design” section of README.md already documented that it flips True/False run to run from floating-point noise at low points_per_mm - never a reliable signal in the first place). Removing the SLOW THING rather than optimizing around it is the right call when the slow thing wasn’t pulling its weight to begin with. (2) Part 26’s fix - hardcoding resolution=6/sections=60 directly in lib/helios.py - was flagged correctly as violating CLAUDE.md’s real-numbers-live-in-config rule, generalized here to cover facet-count/resolution constants too, not just physical dimensions/tolerances/offsets: a magic number invented to route around a cost imposed by a DIFFERENT piece of code (the diagnostic) has no business being hardcoded into unrelated geometry construction code, config or otherwise - the fact that the diagnostic could just be deleted proves the resolution never needed to change at all.

Removed: the whole HollowSpace()-diagnostic block from generate.py’s main() (the .contains() per-character ray-cast and its print line), plus the now-dead-code comment two branches earlier that referenced it. This is shared code, run for every machine - the diagnostic is gone for Blickensderfer/Postal too, not just Helios (their own HollowSpace() GEOMETRY functions are untouched and still load- bearing - cylinder_machine.Subtractive() still calls them for the real cut; only the EXTRA diagnostic call site in generate.py is gone).

Reverted: lib/helios.py’s HollowingElement() back to resolution=32/sections=Surface_Fn - the same numbers every other revolve in the file uses, no special case. Since the diagnostic that motivated the lower values is gone, and the actual boolean cut was NEVER the bottleneck (measured at 2.6s in part 26, whether the diagnostic ran or not), there was no remaining reason to keep the geometry lower-fidelity. Also deleted HollowSpace() itself from lib/helios.py

  • it existed ONLY as an alias so the (now-removed) diagnostic could find it via hasattr(bd, "HollowSpace"); with that caller gone, it was dead code (Helios’s real build calls HollowingElement() directly, never HollowSpace()).

Verification (hard gate, since generate.py is shared): reran generate.py for all five machines (Blickensderfer/Postal/Mignon/ Bennett/Helios) at fast settings - all watertight/single-volume, zero errors. Blickensderfer reproduces this file’s own documented baseline byte-for-byte (verts=42618 faces=85408 ... volume=5666.804mm3), confirming the diagnostic removal has no effect on any actual build output (it only ever printed an extra line after the STL was already exported). Helios’s own numbers after reverting the resolution exactly match what they were immediately after part 24 (before part 26’s detour) - verts=109100 faces=218224 volume=4206.382mm3 at the same fast-preview-with-core-groove settings - confirming the revert is a clean, complete undo with no incidental drift.

Lesson: when a diagnostic/debug code path turns out to be expensive, the first question should be “does this check still earn its cost” before “how do I make it cheaper” - especially for a check that was already known to be flaky/non-actionable. Optimizing a check that shouldn’t exist just entrenches it, and in this case would have meant carrying a hardcoded, config-rule-violating magic number indefinitely for a problem that had a strictly better fix available.

28. Disabled Helios’s alignment-pin boss (user request); documented Resin supports as a no-op on the Build tab

Two small, explicit user requests, not bugs.

Disabled AlignmentPinSupport() (the boss AlignmentPinHole() is cut through, v2’s real support material around the alignment pin) - commented out rather than deleted, per the user’s own instruction, so it can be reinstated later without re-deriving it from v2. Removed its call from _assemble()’s union list; left AlignmentPinHole() itself untouched (still cuts through the plain wall now, thinner than with the boss - the user asked to remove the boss specifically, not the hole/pin mechanism). Updated the module docstring’s “Two-stage difference” explanation and the _assemble()-adjacent comment to stop describing the boss as active, while keeping the geometric reasoning for why the two-stage difference() structure is still necessary regardless (ClipRetainer() independently needs the same staging - it overlaps MinkCleanup()’s top cutting cylinder’s z-range - so disabling the boss doesn’t simplify _assemble()’s architecture, just removes one union member from it). Verified: still watertight/single-volume, volume dropped by ~52mm3 (the boss’s own material), matching expectations.

Documented “Resin supports” as a no-op for Helios on the Build tab. The checkbox is always shown regardless of machine (_compose_build_tab has no per-machine gating for it, unlike the Gauge/Logo/Label tabs which check "X" in self.SECTIONS), and toggling it for Helios silently does nothing observable - ResinPrint() is already a plain alias to FullElement() (part 23: v2 declares Resin_Support/Resin_Support_* but never builds any actual support geometry). Added a new RESIN_SUPPORT_UNAVAILABLE_NOTE dict (keyed by machine name, "" via .get() for every machine that DOES have real resin supports modeled) appended to the existing help Static beneath the checkbox, following CLAUDE.md’s per-machine-banner-text rule (LAYOUT_PICKER_HELP is the template - a dict lookup, not an if self.machine == "x" branch). Verified via a headless TuneApp(...).run_test() against scratch copies of both a Helios and a Blickensderfer config: the note text is present for Helios, absent for Blickensderfer.

Verification: rebuilt Helios at fast settings (watertight/is_volume, 84/84 characters placed) and reran the Blickensderfer regression (generate.py untouched this round, but tune.py’s shared _compose_build_tab was touched, so machine-neutrality was worth reconfirming) - reproduces the documented baseline byte-for-byte.

29. Hammond port (hammond.scad only) - audit, config/lib/tune.py, verified

Ported hammond.scad (the arc-shaped shuttle body) per the roadmap’s next item. hammond_split.scad deferred - see below, it turned out to be a different machine in all but name.

Audit pass (per CLAUDE.md’s explicit requirement for every machine port). Diffed v2/hammond.scad (1105 lines) + v2/hammond_split.scad (771 lines) + their v2/lib/ includes against lib/cylinder_machine.py/ lib/scad_primitives.py/lib/bennett.py/lib/mignon.py function-by- function, per CLAUDE.md’s porting checklist, before writing anything. Findings:

  • hammond.scad and hammond_split.scad are NOT near-twins (unlike Blickensderfer/Postal) - hammond_split.scad has zero include statements (fully self-contained, its own inlined TextAssemble/ TextPlacement/LetterText/TextRing(side) glyph placement, not wired to the shared glyph_pipeline.scad at all) and its own header explicitly calls itself “closer to IBM’s spherical geometry” than the cylinder family. It’s a completely different two-piece spoke/folder assembly (Arc/Spoke*/Tube/FolderClearance/GlueHoles), unrelated config variable names, and a third independent resin-support implementation. Deferred to its own future port - config/hammond.yaml and lib/hammond.py cover hammond.scad only, with a header comment saying so explicitly.
  • hammond.scad DOES genuinely share the glyph-placement pipeline with the cylinder family, despite being a different form factor - its own v2 header comment (added during v2’s OWN internal migration to the shared lib/glyph_pipeline.scad) proves the arc’s Theta formula reduces algebraically to the shared lib’s (Angle_Half_Step+latitude)*Latitude_Int, by treating the arc as a “fake cylinder” of diameter 2*Shuttle_Arc_Radius. So lib/hammond.py reuses cylinder_machine.place_on_cylinder/ TextRing/CalibrationTextRing (same as Mignon/Bennett/Helios) - confirmed empirically, not just by re-reading the comment: an early smoke test with the real numbers placed 90/90 characters with 0 skips.
  • Body geometry (ShuttleCylinder/AnvilShape/Rib/PinSupport/ ShuttleTaper/Label) shares nothing with cylinder_machine.py and is new code - v2/lib/resin_support.scad’s own header already flagged Hammond (and IBM) as keeping “their own angle-aware rod systems,” not the shared placement layer.

Resin support redesigned, not ported byte-for-byte - explicit user direction: reuse the existing shared rod shape (cylinder_machine._resin_rod(), the same primitive Mignon/Bennett already reuse) rather than porting Hammond’s own v2 ResinRod/RodTip (two independently-invented rod primitives), and treat the angled reinforcement rod between adjacent support rods (gusseting/bracing) as the one genuinely new piece. Added sp.connecting_rod() to lib/scad_primitives.py (a hull of two spheres between arbitrary points, ported from v2’s ConnectingRod() at v2/hammond.scad:419) for this. Placement itself (lib/hammond.py’s ResinSupport()) is a from-scratch grid+raycast scheme against the real, already print-oriented mesh (finds the lowest surface point per (X,Y) grid cell via trimesh’s ray-mesh intersector, drops a rod there, braces grid neighbors together with a gusset) rather than v2’s own ~140-line, 8-tier VertResinSupport2 - deliberately simpler, real v2 numbers (Resin_Support_Spacing) still drive the grid pitch. Verified: 132 rods, 131 braced, on the real 90-character element.

Shared-code fix required: build_glyph()’s real platen-cutout step (lib/glyph_poc.py) unconditionally computed platen_radius_real_mm = 1.0/(2.0*platen_radius_mm) - a real division by zero for Hammond, which strikes a flat anvil (Skip_Platen_Cutout=true in v2) and so has no curved platen at all (platen_diameter=0, PLATEN_RADIUS_MM=0.0, an intentional, not fallback, value - deriving it from 1/platen_diameter like every other machine would ALSO divide by zero, so it’s set directly in configure()). Fixed by adding a real if platen_radius_mm > 0 branch (skip building/subtracting the platen cutting cylinder entirely, and skip the now-unnecessary block-margin sizing) rather than trying to make 0 a numerically-safe magic value - this is the exact v4 equivalent of v2/lib/glyph_pipeline.scad’s own if (!_skipPlatenCutout) PlatenCutout(...) conditional. Every existing machine has a real platen (platen_radius_mm>0), so this is purely additive - confirmed via the full 5-machine regression below.

Shared-code promotion: lib/bennett.py’s private _build_text_string helper (whole-string flat text, halign=center/valign=baseline) was needed identically for Hammond’s Label() (two engraved strings, Shuttle_Label1/Shuttle_Label2), so promoted it to cylinder_machine.build_text_string() per CLAUDE.md’s “extract shared derivations instead of hand-copying” rule, and switched lib/bennett.py’s LabelText() to call the shared version (removing the now-dead private copy and its now-unused freetype/build_flat_text/ get_glyph_contours_and_advance imports).

tune.py wiring: added the MACHINES picker entry, SECTIONS_BY_MACHINE ["hammond"] (Label tab - matching Bennett’s whole-string-label convention, not Logo - plus Quality/Resin/Element, no Gauge - Hammond has no Shaft Gauge Test either), and the matching LABEL_FIELDS_ HAMMOND/QUALITY_FIELDS_HAMMOND/RESIN_FIELDS_HAMMOND/ELEMENT_FIELDS_ HAMMOND tuples. Row-count handling in the Layout tab is already generic (n_rows = len(self.cfg["layout"]["baseline_row"]), not a hardcoded 3 anywhere), so no literal-count fixes were needed for Hammond’s 3-row/ 30-column layout, unlike Mignon’s 7-row port. Found via a headless TuneApp(...).run_test() smoke test (scratch config copy, per CLAUDE.md’s standing warning) that layout.placement_map/latitude_columns/ cutout_row/modify_glyphs are read unconditionally by tune.py even though lib/hammond.py itself doesn’t need them stored (it could compute PLACEMENT_MAP from column count, and CUTOUT_ROW is dead/unused) - added all four as literal YAML keys instead, matching how every other machine stores them, per CLAUDE.md’s “pick one convention” rule. Named layout presets (LAYOUT_PRESETS_BY_MACHINE) not wired for Hammond - falls back to {} (no crash, just no picker options) - Hammond’s other 7 real v2 layout presets (Normal Ideal/Math Universal/DVORAK/DHIATENSOR/Comic Mono/Glagolitic/Attic) are listed in config/hammond.yaml’s header comment but not yet selectable from the TUI.

Verification: ran the CLAUDE.md hard-gate command for all 5 existing machines (blickensderfer/postal/mignon/bennett/helios) before and after - byte-for-byte identical to the documented baselines (blickensderfer: verts=42618 faces=85408 ... volume=5666.804mm3, matching the example in CLAUDE.md itself). Hammond’s own ResinPrint: verts=47945 faces=96858 watertight=True winding_consistent=True is_volume=True volume=4292.355mm3 (90/90 characters placed, 0 skipped), reproduced identically across two separate runs.

Deferred / open questions:

  • Groove=true (v2’s alternate snap-fit/groove assembly variant - GroovedShuttle()/Groove()/PinSupport2()) is deferred entirely - Groove=false (the default, and the only variant ResinPrint’s real v2 dispatch path exercises) is the only one ported.
  • hammond_split.scad - a separate future port, per the audit above.
  • No dedicated Calibration/named-layout-preset support in the TUI yet (see tune.py wiring note above).

30. Hammond follow-up: real relief-height bug, tune.py quit crash, config format bug

Three fixes found via actual user testing (real console preview of the Hammond machine), not derivable from re-reading v2’s source alone.

Character relief height was wrong (user-reported: “the letters aren’t protruding correctly”). Part 29 flagged this as an open question but guessed it might be faithful to v2 as-is; it was not - measuring the actual built mesh (bare shell’s own outer radius vs. a real character’s outer radius with the part-29 numbers) showed characters landing at 37.94mm vs. the shell’s own 37.935mm surface - essentially flush, ~0mm of real relief. Root cause: Letter_Placement_Protrusion=Shuttle_ Thickness (v2:380, what part 29 passed as place_on_cylinder’s placement_protrusion) is only where the character’s BLOCK ROOT/anchor sits, flush with the shell surface - v2’s real extrude chain (Letter_Extrude_Offset=-.5, Letter_Extrude_Depth=Shuttle_Text_ Protrusion+.5, v2:381-382) pushes the ink-bearing FRONT face an additional Shuttle_Text_Protrusion (0.9mm) past that anchor - a distinction part 29’s algebraic re-derivation missed (it correctly verified v2’s real placement RADIUS matches, but conflated “root anchor” with “visible front face”). Fixed by passing placement_protrusion=Shuttle_Thickness+Shuttle_Text_Protrusion in both Additive()/CalibrationAdditive(). Verified: a real character now protrudes ~0.906mm past the shell surface (0.9mm expected). Full ResinPrint rebuild still watertight/valid (volume rose slightly, 4292.355mm3 -> 4373.674mm3, matching the added ink-relief material). Lesson: an algebraic re-derivation that “matches the real v2 value” for one specific transform doesn’t guarantee the SEMANTICS were mapped correctly onto v4’s differently-structured pipeline - visual/measured verification against the actual built mesh is what actually caught this, not another round of reading the same v2 source more carefully.

tune.py crashed quitting from the machine picker (self.machine is None, _save_before_exit() unconditionally iterating self.FIELDS, which is only set once a machine is loaded) - pre-existing bug, not Hammond-specific, surfaced by testing the new picker entry. Fixed with an early return when no machine is loaded (nothing to save).

config/hammond.yaml’s type_test.text used a plain quoted string instead of the |- block-scalar format patch_yaml_text_block() requires - fixed to match every other machine’s config. A stale config/hammond.running.yaml (bootstrapped from the master before this fix landed, and never auto-resynced - a scratch file, gitignored) had to be deleted by hand once for the running copy to pick up the corrected master; not a recurring issue once regenerated.

Also requested: resin.orientation (vertical/horizontal) added to config/hammond.yaml and wired into lib/hammond.py’s ResinPrint() and tune.py’s RESIN_FIELDS_HAMMOND. “vertical” is v2’s real VertResinPrint2 orientation (rotate([0,-90,0]) then translate(-Z_Offset,0,-X_Max), unchanged from part 29); “horizontal” is v2’s real HorizResinPrint orientation - turned out to be simpler than expected: NO rotation at all, just translate(-Z_Offset,0,0) (prints flat, in the shuttle’s own natural as-built frame - the arc’s own X/Y/Z axes are already the print axes, v2 doesn’t reorient it for this path). ResinSupport()’s grid+raycast+gusset scheme (part 29) needed zero changes for this - it already builds against whatever oriented mesh it’s handed, so adding a second orientation was just a different pre- transform, not new support-placement code, unlike a v2-faithful port would have needed (v2’s HorizResinSupport2 is a completely separate ~90-line placement scheme from VertResinSupport2). Verified both: vertical reproduces part 29/30’s exact baseline (volume=4373.674mm3, unchanged); horizontal is a new, different, valid, watertight result (verts=29375 faces=59046 volume=2645.954mm3, 49 rods/41 braced - fewer than vertical’s 132/131, consistent with a flatter print needing less support density).

Still not started: the Groove/rib assembly toggle (RibAssembled() vs. GroovedShuttle() - almost certainly what “rendering with or without the rib” means, Groove=true being the deferred variant from part 29). Unlike the orientation toggle, this needs real NEW body-geometry code (Groove()/PinSupport2()/GroovedShuttle()/ResinChamfer(), ported from v2/hammond.scad following the same pattern as Rib()/ PinSupport()/RibAssembled()), not just a different transform - scoped as its own follow-up, explicitly deferred again by user choice this round (asked which of the two to do first; orientation was picked).

31. Hammond rib/groove assembly toggle (element.groove)

Ported v2’s second, mutually-exclusive assembly mechanism - user specifically wanted this next since it “also affects the resin support.”

New config: element.groove (bool, default false) plus the real v2 values it needs - shuttle_groove_nub_angle, groove_tab_width, groove_opening_offset, support_groove_thickness (derives Support_Groove_R for ResinChamfer()). Shuttle_Groove_Depth/ Shuttle_Groove_Nub_Size are both Shuttle_Thickness/2 (derived, not independently tunable, matching v2:259-260). Confirmed PinSupport2()/ Groove_Retaining_Pin_Diameter are declared in v2 but never actually called/referenced anywhere in v2/hammond.scad - dead code, not ported, same treatment as bennett.yaml’s dead-param callouts.

New lib/hammond.py functions: GrooveShape() (a circumferential snap-fit slot cut into the shell’s inner surface via a disk+tab 2D union minus 4 angled nub-cylinder cutouts, v2:484-500) and ResinChamfer() (a small cone-frustum chamfer at the shell’s bottom-inner edge, v2:789-792). Naming note: the v2 module is literally called Groove(), but Python has one namespace for module globals (unlike OpenSCAD’s separate module/ variable namespaces) - naming the function Groove() would silently shadow the Groove config boolean the moment configure() ran (hit this live: TypeError: 'bool' object is not callable calling hammond. Groove() after configure() had already overwritten it) - renamed to GrooveShape() to avoid the collision.

Additive()/CalibrationAdditive() now branch on Groove: false unions RibAssembled() onto the shell (unchanged, existing path); true subtracts GrooveShape() and ResinChamfer() from the shell instead, with no RibAssembled() at all - matching v2’s real GroovedShuttle() vs. RibbedShuttle() split exactly (mutually exclusive assembly mechanisms, not a partial/additive variant). ShuttleTaper()’s Z-depth also depends on Groove (v2:571’s c=[Rib_Bottom_Z+z+(Groove?2:0), 10]) - was hardcoded to the Groove=false case in part 29, now reads the real flag.

Resin support needed zero changes - confirmed the user’s own intuition (“it also affects the resin support”) is about the GEOMETRY changing, not the support CODE: ResinSupport()’s grid+raycast+gusset scheme (part 29) finds attachment points by ray-casting against whatever mesh it’s handed, so it automatically adapted to the groove variant’s different underlying shape with no changes needed - unlike v2, where HorizResinSupport2/VertResinSupport2 both have to explicitly branch on Groove throughout their own hardcoded placement logic.

Verified all 4 combinations (groove x orientation) via generate.py, all watertight/valid/is_volume=True:

  • groove=false, vertical (existing baseline, unchanged): volume=4373.674mm3
  • groove=true, vertical: verts=45556 faces=92136 volume=4196.261mm3 (132 rods/131 braced - lower volume than the rib variant, consistent with removing the rib+pin material)
  • groove=false, horizontal (from part 30, unchanged)
  • groove=true, horizontal: verts=17197 faces=34406 volume=1887.059mm3 (only 8 rods/0 braced - sparser support grid than the other 3 combos, worth a closer look if this combination is used for a real print, but not a crash/invalid-geometry issue)

tune.py: added groove (bool) plus the 4 new real-value fields to ELEMENT_FIELDS_HAMMOND. Headless TuneApp(...).run_test() smoke test (scratch config, per standing warning) confirms compose/save/quit all still work (70 fields now, up from 64).

32. Connecting-rod bracing pattern corrected; resin.orientation is a dropdown

Two user-reported fixes.

Connecting rods didn’t match v2’s real pattern. Part 29’s ResinSupport() braced grid-adjacent rods in BOTH directions (i+1,j AND i,j+1) with a level, tip-to-tip connection. Re-checked every one of v2’s own ConnectingRod() call sites (v2:627,638,643-658,671,683) - all of them connect two points sharing the SAME angular/lateral position but DIFFERENT longitudinal position (never laterally between two different angular rows), and always drop the brace a few mm below the rod’s own tip contact point (e.g. v2:671’s h-1 -> h-4) rather than connecting flush at the very top. Fixed ResinSupport() to match both properties: braces only along the i (longitudinal) grid axis now, and each endpoint drops by a fixed brace_drop=1.5mm below its own tip (clamped to Resin_Min_Rod_Height) before connecting - a real diagonal brace supporting the rod’s upper region, not a flat cross-tie at the top.

Verified all 4 groove x orientation combinations still watertight/valid after the change (volumes shifted slightly from part 31’s numbers, as expected - roughly half as much bracing material now that only one direction is braced): groove=false/vertical volume=4301.835mm3, groove=true/vertical volume=4141.871mm3, groove=false/horizontal volume=2612.998mm3, groove=true/horizontal unchanged at volume=1887.059mm3 (0 braced either way for that combination, see part 31’s open item about its sparse grid).

resin.orientation is now a dropdown, not a free-text field - mirrors the existing elif key == "mode": special-case in _compose_section_tab (the only other string-enum field, alignment. mode) rather than inventing a new generic mechanism - _collect_values/ _load_current already read/write Select/Input widgets identically via .value, so no changes needed there.

33. Hammond resin support: real VertResinSupport2 port (abandoned the redesign)

Part 32’s fix wasn’t enough - user reported the connecting rods were “still incorrect”: wrong direction/orientation, not attached correctly, wrong placement, all at once. Rather than guess a fourth time, re-pulled the exact v2 source (v2/hammond.scad:596-735) and did a real, literal port of VertResinSupport2() for the “vertical” orientation - same tiers, same coordinates, same ConnectingRod/ResinRod call sites - replacing the grid+raycast redesign entirely for that case (parts 29-32’s approach is kept ONLY as _ResinSupportRaycastGrid(), now used exclusively for “horizontal”, since v2’s own real horizontal-orientation support scheme, HorizResinSupport2, is a separate ~90-line algorithm never in scope to port).

Key derivation, worth recording since it wasn’t obvious from the code alone: v2’s own ResinRod(h1,r1,r2,h2,r3) has its RAFT at local z=0 and TIP at local z=h1 (confirmed by reading its full body - cylinder(h=h1-1,r=r1) from z=0, tip taper at z=[h1-1,h1]) - a DIFFERENT convention from the shared cylinder_machine._resin_rod(h), whose raft sits at z=-(Resin_Min_Rod_Height+Resin_Raft_Thickness) and tip near z=h. VertResinSupport2() wraps its whole union in translate([0,0,-Resin_Support_Min_Height-Resin_Support_Base_Thickness]) (v2:611) to place it correctly relative to the body. Reconciled by re-basing every ResinRod call’s h1 through a local _rod() helper (h = h1 - (Resin_Min_Rod_Height+Resin_Raft_Thickness) before calling the shared primitive - the shared primitive’s own raft-position convention already IS that same shift, so this reproduces v2’s real final world position without applying the outer translate twice) and every ConnectingRod endpoint through _crod() (which DOES subtract the shift explicitly, since sp.connecting_rod has no such built-in convention to double up with).

Real values added: resin.edge_gap (Resin_Support_Edge_Gap, v2:314) and Inner_Arc_Intercept/Outer_Arc_Intercept (v2:343-344, derived in configure()) - both needed by the “Under Rib - Rib Thickness on Edges” tier, neither previously ported since the redesign didn’t need them.

Simplification kept (not what was reported wrong): v2’s ResinRod tip radius (r2) is two-tier - Resin_Support_Contact_Diameter for most rods, Resin_Support_Contact_Diameter_Rib for the “under rib” tiers - collapsed to the single configured Resin_Tip_OD for every rod, since the shared cylinder_machine._resin_rod() reads that from config rather than taking a per-call override. A genuinely minor cosmetic difference (0.4mm vs an unported ~0.2mm tip point), not a placement/structural one.

Verified all 3 affected combinations via generate.py (horizontal was untouched by this change, reproduces part 32’s exact volume=2612.998mm3 baseline):

  • groove=false, vertical: verts=85097 faces=171186 volume=4765.334mm3 (1300 parts - watertight/winding_consistent/is_volume=True)
  • groove=true, vertical: verts=65166 faces=131000 volume=4275.807mm3 (924 parts - fewer tiers, “Under Rib Supports”/”Under Rib - Rib Thickness on Edges” are both if (Groove==false) in v2, faithfully skipped here too)
  • groove=false, horizontal: unchanged (volume=2612.998mm3, raycast fallback)

Also spot-checked (outside the STL export) that the oriented body and the new ResinSupport() land in physically sensible relative positions without any extra normalization step: body’s own lowest point sits at world Z≈0 (matching v2’s real design - no artificial “shift to buildplate” hack needed here, unlike the horizontal raycast fallback, which has no analytic reference and still needs one).

tune.py: added edge_gap to RESIN_FIELDS_HAMMOND (71 fields now, up from 70). Headless smoke test confirms compose/save/quit still work.

34. Hammond: groove dropdown, Math layout preset, auto-derived Is_Math

Three more requests in one pass: the groove checkbox should be a dropdown too, there’s a “Math shuttle” layout variant to wire up (check v1, might not be in v2), and Is_Math should auto-follow row count instead of being a separate toggle.

Checked v1 first, per the request - v1/Hammond/HammondShuttle.scad (the pre-v2-migration original) has the exact same Layouts/IsMath/Math_U system v2 already has (confirmed line-by-line: same 4-row Math_U array, same IsMath=search(..., "Math") derivation) - nothing extra hiding in v1. “Math Universal” (LAYOUTS[2] in v2/lib/layouts/hammond_layouts.scad) is the only real “math shuttle” variant, and it’s the one now wired up.

Is_Math is now derived, not a toggle - element.is_math removed from config/hammond.yaml entirely; lib/hammond.py’s configure() computes Is_Math = len(cfg["layout"]["rows"]) == 4 instead (Math Universal is the only 4-row preset; everything else is 3). Removed the now-dead is_math field from ELEMENT_FIELDS_HAMMOND and the now-unused baseline_row_math YAML key (its real value, -9.89, moved to the preset data below instead of sitting unused in the master config).

Two layout presets added (LAYOUT_PRESETS_HAMMOND in tune.py): “Normal Universal” (the existing default, 3 rows) and “Math Universal” (4 rows, v2/lib/layouts/hammond_layouts.scad’s Math_U). This is the first machine whose presets differ in ROW COUNT, which the existing preset-picker infrastructure wasn’t fully built for - patch_yaml_rows itself was already “row-count-agnostic” (writes however many rows it’s given), but two real problems surfaced from actually testing the switch, not just reading the code:

  1. baseline_row/cutout_row also need to grow to 4 entries when Math Universal is selected (TextRing’s n_rows = len(reference_ baseline_row) would otherwise stay at 3, silently dropping the 4th row’s characters). No existing mechanism resizes these - the per-row BASELINE_CUTOUT_KEYS Input widgets are a fixed set sized once at _load_machine() time. Added LAYOUT_PRESET_BASELINE_ROW_BY_MACHINE (keyed by machine, then preset name - only Hammond has an entry, since no other machine’s presets vary in row count) plus a new patch_yaml_inline_list() (replaces a whole key: [...] array, unlike patch_yaml_list_item which only patches one element) - applied in _save_to_yaml right after patch_yaml_rows, overwriting whatever the (now-stale-length) BASELINE_CUTOUT_KEYS loop had just written.
  2. Selecting a longer preset crashed immediately - on_select_changed (and on_switch_changed, and _refresh_widgets_from_cfg) all update per-row preview/edit widgets by iterating range(len(new_rows)) and querying #layout-original-row-{i}/#layout-custom-row-{i} - fine when every preset has the same row count (true for every other machine), but Math Universal’s 4th row has no matching widget (the fixed set from compose() time only has 3) - immediate NoMatches on selecting the dropdown value. Fixed with a new _update_row_widget() helper (query + no-op if missing, used at all 4 call sites) and a self.inputs.get() guard in _refresh_widgets_from_cfg’s baseline_row/cutout_row widget-sync loop (a plain dict lookup, raises KeyError not NoMatches, needed the same fix for the same reason). The read-only preview/edit widgets for a 4th row don’t appear until a recompose (switching machine and back, or restarting) - a known, accepted UX gap, not a crash.

element.groove is now a dropdown (“Rib” / “No Rib (Groove)”), matching the mode/orientation pattern - added as a key == "groove" special case checked BEFORE the generic typ is bool branch (order matters in the if/elif chain; groove’s type is still bool, so the generic branch would otherwise catch it first and render a Switch). _collect_values/_load_current needed no changes - both already treat Select and Switch widgets identically via .value for a bool-typed field.

Verified: Is_Math auto-derivation (3-row config -> False/ Shuttle_Height=16.6; 4-row config -> True/Shuttle_Height=21.24); full generate.py build on a hand-built 4-row Math config (120 characters = 4x30, 0 skipped, ResinSupport: 1852 parts, volume=5765.334mm3, watertight/winding_consistent/is_volume=True); Normal Universal regression unchanged (volume=4765.334mm3); a full headless TuneApp cycle - switch to Math Universal via the dropdown, save, confirm rows/baseline_row/cutout_row all landed correctly in the RUNNING config (not the master - tripped over this distinction myself mid-debugging) - then a real generate.py build from that exact tune.py-written config reproduces the same volume=5765.334mm3 byte-for-byte; groove dropdown save round-trips correctly (groove: true written and read back).

35. Hammond: real HorizGroovedResin3 port; fixed a wrong rod-height clamp

Two bugs reported after visual inspection of an actual render - neither was visible from the numbers alone (both builds were watertight/valid throughout).

“The resin rods in horizontal position are totally fucked up, need to use cut groove.” Re-read v2’s real ResinPrint() dispatcher (v2:1060- 1073) precisely for the first time - it only ever calls two functions: VertResinPrint2() for Resin_Support_Orientation==0 and HorizGroovedResin3() for ==1. HorizResinPrint/HorizResinSupport2/ HorizGroovedResin/HorizGroovedResin2 (the functions parts 29-32’s raycast fallback was modeled loosely on/named after) are ALL unreferenced legacy code in v2, never actually called by anything. The real horizontal support (HorizGroovedResin3, v2:1006-1021) is a completely different architecture from VertResinSupport2 - not individual rods at all, but a single swept, perforated breakaway-groove RING (Resin2Profile(), a wall-hugging trough cross-section with two circular “cut groove” perforations at its top corners, revolved +-60 degrees around the shuttle’s real angular extent). Ported faithfully: added sp.revolve_polygon_partial(profile, start_deg, end_deg, sections) to scad_primitives.py (a real rotate_extrude(angle=) equivalent - built as a full revolve_polygon() intersected with a wedge solid spanning the angle range, reusing the already-tested full-revolve code and a real manifold boolean instead of hand-triangulating two end caps), then HorizGroovedResinSupport() (the swept ring minus the two perforation cuts minus a shifted ShuttleTaper(), matching v2 exactly).

Also discovered while re-reading the dispatcher: HorizGroovedResin3 always calls GroovedShuttle() regardless of the Groove config value (v2:1006-1021 never checks Groove at all, unlike VertResinPrint2 which does) - a real, deliberate v2 restriction: this orientation is only ever paired with the snap-fit groove body. Added force_groove to Additive()/FullElement()/Subtractive()/ ShuttleTaper() (threaded all the way through, since ShuttleTaper()’s own Groove-conditional taper depth needs to match the forced body too - found this by comparing Subtractive vertex counts between a groove: false config and a groove: true config with orientation forced to horizontal, which should be identical and initially weren’t). Also fixed: when resin support is off, v2 returns the plain body for EITHER orientation with NO reorientation at all (v2:1067-1072) - Resin Print() previously still applied the vertical rotate/translate in that case; now matches v2 exactly.

“There’s a broken resin rod in the vertical direction… missing the base of the rod.” Traced to a defensive clamp in part 33’s port (_rod()’s h = max(h, -Resin_Raft_Thickness + 0.05)), added out of caution without checking whether it was ever actually needed. Computed the real degenerate threshold (where cylinder_machine._resin_rod’s tip sphere would invert past its base sphere) directly from its own tip_z/ lower_z formulas: around h=-2.58 with this config’s default values. The smallest real h1 anywhere in the port (Outer Edge Supportsh_edge2 = Resin_Raft_Thickness, giving h=-Resin_Min_Rod_Height=-2.0) is well above that threshold - already valid, non-degenerate - but the clamp’s own floor (-1.45) was HIGHER than -2.0, so it silently pulled that one rod 0.55mm out of its correct position every time. v2 itself has no such guard on ResinRod() either. Removed the clamp entirely rather than tightening the threshold - checked all constant-h1 call sites algebraically first to confirm none of them actually need one.

Verified: all 4 groove x orientation combinations rebuilt and watertight/valid. Horizontal now correctly forces the grooved body regardless of config (groove: false and groove: true configs produce byte-identical verts=21040 faces=42080 volume=2339.334mm3 when orientation is horizontal - confirming the force_groove fix). Vertical groove=false with the clamp removed: volume=4765.334mm3 (topologically different vert/face count from before the fix - 85096/171184 vs. 85097/171186 - confirming the affected rod’s geometry actually changed, not just cosmetically identical). Math Universal layout regression unchanged (volume=5765.334mm3). Spot-checked Blickensderfer (the only other machine using scad_primitives.py, which gained the new revolve_polygon_partial function) - unaffected, exact baseline match.

36. Hammond: horizontal-ribbed support (real HorizResinSupport2 port) + FDM part export targets

Two more requests: horizontal orientation shouldn’t be forced to the grooved body if the ribbed one can be “properly supported” instead, and a Build-tab dropdown for exporting the shuttle and rib as separate FDM parts (no resin supports needed for those).

Un-forced Groove for horizontal, ported the real ribbed-horizontal support. Part 35 found HorizGroovedResin3 always uses GroovedShuttle() and concluded (wrongly) that horizontal orientation was ONLY ever paired with the groove body in v2. The user pointed at HorizResinPrint2()/HorizResinSupport2() (v2:952-963, 864-950) - confirmed identical in v1/Hammond/HammondShuttle.scad too (only casing/whitespace differ from v2, no logic changes) - real, complete, carefully-built functions supporting the RIBBED body horizontally: along the rib’s own back edge and around the center drive-pin hole, exactly matching the user’s own description before I’d re-read the source. v2’s ResinPrint() dispatcher (v2:1060-1073) only calls VertResinPrint2/ HorizGroovedResin3 directly, but that doesn’t mean the other Horiz* functions are dead - HorizResinPrint2/HorizResinSupport2 are just not wired into that particular dispatcher’s two Resin_Support_Orientation values in the exported customizer, unlike the genuinely-unreferenced HorizResinPrint/HorizGroovedResin/HorizGroovedResin2.

ResinPrint()’s horizontal branch now dispatches on Groove (same as vertical, no longer forced): Groove=true -> HorizGroovedResinSupport() (part 35, unchanged); Groove=false -> new HorizRibbedResinSupport(). Ported the real tiers faithfully: Outer Supports (23 angular positions around both wall-adjacent radii), thetamax/taper-step edge supports, Inner_Arc_Intercept supports, the “under Pinhole” 4-rod cross pattern, three dense angular fan patterns, and the theta-dependent Under Rib - Outer/Radius/Center tiers (the only ones that actually vary per iteration - the rest are loop-invariant in v2 itself, since neither the pinhole block nor the fans reference the outer loop’s y/theta at all, just shadow the name - hoisted out and built once here, same optimization as part 33’s “at the taper” block). Rod shape is a NEW _rod2() helper wrapping the shared cylinder_machine._resin_rod() - ResinRod2(h) is a THIRD bespoke v2 rod primitive (distinct from both ResinRod/part 29’s RodTip), with its own tip-position convention (h - Resin_Tip_OD/2 + Tip_Interference, not h directly) - added the real Tip_Interference (v2:324, 1.2mm - a genuine tip overlap/ interference-fit depth, not negligible) and Shuttle_Pin_Support_ Height2 (v2:236 - otherwise only used by the dead PinSupport2(), but real and needed here) to config/hammond.yaml.

Verified: HorizRibbedResinSupport() standalone (watertight, volume=1015.11mm3); full generate.py build, horizontal+groove=false (verts=36310 faces=72864 volume=2961.561mm3, watertight/valid, Additive: verts=13820 confirming the RIBBED body is used, not forced- groove); horizontal+groove=true and vertical both reproduce their exact pre-this-part baselines unchanged.

FDM part-export targets (“it does not have to be grooved if properly supported… will be printed with FDM so no need for supports here” - a separate, simpler ask on top of the above). Added --hammond- part {shuttle_minus_rib,shuttle_plus_rib,rib_only} to generate.py - plain geometry exports, no resin-print orientation/support machinery at all (FDM’s own slicer handles supports). shuttle_minus_rib/ shuttle_plus_rib are FullElement(force_groove=True/False) (already existed via part 35’s force_groove param); rib_only is a new hammond.RibOnly() (just RibAssembled() alone, meant to be printed separately and glued to a shuttle_minus_rib shell afterward). Wired into tune.py’s Build tab as 3 more build.target dropdown options, gated on self.machine == "hammond" (matching the existing has_gauge pattern) - selecting one of these ignores the Resin supports checkbox entirely (documented in a new help Static), since generate.py’s --hammond-part branch runs before the resin dispatch. Verified all 3 targets individually (watertight/valid: rib_only volume=128.927mm3, shuttle_minus_rib volume=1810.135mm3, shuttle_plus_rib volume=1960.523mm3) and via a full headless TuneApp cycle (select rib_only, save, confirm the constructed subprocess command is generate.py ... --hammond-part rib_only, then actually ran that exact command).

37. Hammond “Element”->”Shuttle” label, editable 4th baseline row, missing-raft fix, resin_support.py extraction

Four requests in one message.

“Element” renamed to “Shuttle” for Hammond’s Build target dropdown. Same underlying "element" target value (generate.py’s dispatch is unchanged) - just the displayed label, computed per-machine (element_label = "Shuttle" if is_hammond else "Element"), also applied to “Calibration Element” -> “Calibration Shuttle” for consistency (only the plain one was explicitly requested, but leaving one renamed and the other not would’ve read as an oversight).

Math layout’s 4th row is now always editable, not just after a recompose. BASELINE_CUTOUT_KEYS (the per-row baseline/cutout Input widgets) used to be sized from the CURRENT config’s own row count - correct for every other machine (fixed per-machine), wrong for Hammond specifically (the first machine whose own presets vary in row count - part 34/36). Now sized from the MAX row count across every real preset for the machine, so a 4th field always exists and is editable regardless of which preset is currently active - missing values default to 0.0 (“not set yet”). patch_yaml_list_item() gained the ability to APPEND a new array element (index==len(items)) instead of only patching existing ones, so saving that 4th field can actually grow baseline_row/ cutout_row from 3 to 4 entries.

Found and fixed a real bug while testing the above: part 34’s LAYOUT_PRESET_BASELINE_ROW_BY_MACHINE override in _save_to_yaml was reapplying the PRESET’s own fixed baseline defaults on every save whenever any preset remained selected (which is effectively always, since “custom” requires unlocking Modify glyphs) - silently discarding any manual edit to baseline_row/cutout_row the moment the very next save happened. Confirmed by testing “type a value into the new 4th field, save” and watching it get reverted. Removed the recurring override entirely; replaced with a one-time live seed in on_select_changed (fires only when the dropdown value actually changes, matching the existing “freshly unlocked - seed the editable copy” convention on_switch_changed already uses for Modify glyphs) - switching to Math Universal now pre-fills all 4 baseline/cutout widgets with the preset’s real values, which the user can still hand-edit before saving, without a later save silently clobbering that edit.

Missing-raft bug: “a single resin rod on either side is missing the raft to the buildplate, that shouldn’t happen.” Traced to v2 itself - ResinRod’s h2=0 parameter (no raft) on 4 specific calls in VertResinSupport2 (the Inner_Arc_Intercept pair in “Under Rib - Rib Thickness on Edges,” matching “on either side” exactly, plus the first Xx-looped pair in “Outer Edge Supports”). Every resin rod needs a real buildplate connection to actually print, so all 4 are now add_raft=True unconditionally - a deliberate correction of a real v2 characteristic, not a faithfulness gap in the port. Volume rose slightly as a result (4765.334mm3 -> 4775.582mm3, matching the added raft material).

Resin code consolidated into lib/resin_support.py - “used in all elements, share from a single file.” resin_rod()/connecting_rod() moved out of scad_primitives.py (pure resin-specific shapes, not generic CAD ops); raft_config() moved out of cylinder_machine.py (renamed from resin_raft_config in its new home, since the “resin_” prefix is redundant inside a module already named for resin support). cylinder_machine._resin_rod()/resin_raft_config() are kept as thin pass-throughs (reading cylinder_machine’s own config-populated globals, then delegating) so blickensderfer.py/postal.py/mignon.py/ bennett.py’s existing call sites don’t need to change at all - only hammond.py’s direct sp.connecting_rod() calls moved to resin_support.connecting_rod() (it never went through cylinder_machine). Net effect: the actual geometry-building logic now lives in exactly one file, regardless of which machine or entry point reaches it.

Verified: all 6 machines (blickensderfer/postal/mignon/bennett/ helios/hammond) reproduce their exact prior generate.py baselines after the extraction - a pure refactor, confirmed zero behavior change. Full headless TuneApp cycle (manual 4th-row edit persists across save; switching to Math Universal live-seeds all 4 baseline/cutout widgets; “Shuttle”/”Calibration Shuttle” labels present) all pass.

38. Hammond vertical resin support: real RodTip() implemented; all vertical-support geometry moved into resin_support.py

Two rounds in one session.

“Did you fix the theta of the tip” - no. ResinSupport()’s _crod() (the connecting rod between the shuttle body and each support point) was standing in for v2’s own separate RodTip() shape (v2/hammond.scad:596-600) at every contact point - wrong diameter (_crod’s capsule end is a plain sphere at the full Resin_Rod_OD, not the narrower Resin_Tip_OD), and, the actual reported bug, no orientation dependence on theta at all. RodTip() is a small needle (a cone from Resin_Rod_OD down to Resin_Tip_OD over 1mm, then a Resin_Tip_OD sphere) that v2 ROTATES about the X axis by theta*s before translating into position (v2:621-624/632-635), so the pointed tip tilts to follow the arc surface’s own curvature/normal at that angular position rather than pointing in a fixed direction. Implemented faithfully and wired into both theta-dependent tiers of ResinSupport() (“at the taper” and “Under Shuttle Arc Radius”). One self-caught arithmetic slip along the way: the placement’s translate needs v2’s own Z0' value already reduced by the same shift _rod()/_crod() rebase by (Resin_Min_Rod_Height+Resin_Raft_Thickness) - an early draft used the raw, un-rebased Z0' and placed every tip 3.5mm too high; caught by re-deriving the cancellation algebraically before testing.

“All that resin tip should go in the new resin library… make it a module”, followed by a broader “all the resin related stuff should go in the new resin_support file including the placement and rotation logic, following the pattern of cylinders” - i.e. don’t just move the new RodTip shape, move the whole vertical-support placement layer (shape-building AND the transform/rotation math) the same way cylinder_machine.place_on_cylinder() owns both a shape’s construction context and its full placement in one function, rather than splitting shape-building into a shared module and leaving positioning as a local closure. ResinSupport()’s three local closures (_rod, _crod, _rod_tip) were doing exactly that split - each called into resin_support.py/cylinder_machine.py for the raw shape, then applied Hammond-specific rebasing/rotation locally. Moved all three placement functions into lib/resin_support.py itself, taking every needed value as an explicit parameter (no closures, no globals):

  • resin_support.vertical_rod(h1, shift, ...) - rebases h1 by shift (the same Resin_Min_Rod_Height+Resin_Raft_Thickness v2:611 wraps the whole union in) before delegating to resin_rod().
  • resin_support.vertical_connecting_rod(p1, p2, diameter, shift) - rebases both endpoints by shift before delegating to connecting_rod().
  • resin_support.rod_tip(x, theta_deg, s, z_offset, arc_radius, rod_od, tip_od, tip_l=1.0, sections=128) - builds the needle shape AND applies its full placement transform (translate/rotate/translate), returning the final positioned mesh directly.

hammond.py’s ResinSupport() closures are now thin one-line wrappers supplying Hammond’s own config globals (Resin_Tip_OD, Resin_Rod_OD, Z_Offset, Shuttle_Arc_Radius, etc.) to these three calls - all the actual shape-building and coordinate-frame math lives in resin_support.py, consistent with the file’s existing resin_rod()/connecting_rod() functions.

Verified: standalone ResinSupport() build (verts=425503 faces=851858 watertight=True volume=2845.034mm3), then full generate.py runs for vertical orientation with both groove: false (ResinPrint: verts=478551 faces=958750 ... volume=4802.476mm3) and groove: true (verts=455026 faces=911368 ... volume=4303.119mm3)

  • both watertight/winding-consistent/is_volume all True. Horizontal orientation (a separate code path, _rod2/HorizRibbedResinSupport, untouched by this refactor) re-checked and still valid (verts=36310 faces=72864 ... volume=2961.561mm3). All 4 sibling machines that share resin_support.py (blickensderfer/postal/mignon/ bennett) reproduce their exact prior baselines (5666.804mm3/5497.237mm3/4644.658mm3/3627.360mm3 respectively) - confirming the refactor is scoped correctly and introduces zero behavior change anywhere except the intended vertical-support tip fix.

39. Hammond Build tab simplified: target dropdown down to Shuttle/Calibration Shuttle/None, Rib is now its own checkbox

“change the build menu for hammond to be dropdown: Shuttle, Calibration Shuttle, None. Checkbox: Rib yes or no.” Reduced part 36’s 6-entry Build target dropdown (Shuttle, Calibration Shuttle, Shuttle-Rib (FDM), Shuttle+Rib (FDM), Rib only (FDM)) down to 3 (Shuttle, Calibration Shuttle, None), and moved element.groove off the Element tab (where it lived as a 2-item Select, “Rib”/”No Rib (Groove)”, since part 32) onto the Build tab as a plain Rib on/off checkbox (build-rib, inverted: Rib on -> groove=False).

The 3 FDM-specific dropdown entries turned out to be fully redundant once Rib became its own checkbox: “Shuttle - Rib”/”Shuttle + Rib” were FullElement(force_groove=True/False) - exactly what a normal Shuttle build already does once the Rib checkbox directly sets element.groove and Resin supports is turned off (confirmed: both groove values still build clean, distinct, watertight FullElements, 1810.082mm3/1960.471mm3). Only “Rib only” (hammond.RibOnly() - no main body at all) had no equivalent, so it became the dropdown’s new “None” entry - generate.py --hammond-part is now a single-choice flag (rib_only only; the shuttle_minus_rib/shuttle_plus_rib choices and their force_groove plumbing were removed from the CLI, though FullElement/ShuttleTaper/Subtractive’s own force_groove parameter stays - it’s still real internal machinery, just no longer invoked from this particular dispatch).

element.groove’s config KEY is unchanged - _collect_values/ _refresh_widgets_from_cfg read/write the Build tab’s Rib switch directly (inverted) instead of going through the generic self.FIELDS loop, the same pattern target/resin_support already used.

Verified: headless TuneApp against a scratch config only (per the standing warning) - Build tab composes with exactly the 3 target options and a working Rib switch; _collect_values() produces the right inverted groove value; a save+reload round-trip (Rib=off, target=none) persists and reloads correctly; a non-Hammond machine (Blickensderfer) still composes/saves with no groove key and no crash. CLI: --hammond-part rib_only still exports a valid RibOnly() (volume=128.927mm3); a normal Shuttle build with groove flipped both ways (Resin supports off, no Minkowski) produces the same two distinct valid meshes the old FDM targets used to.

40. Hammond: horizontal resin support method decoupled from Rib/Groove body assembly (“Cut Groove” vs “Resin Rod”), naming cleanup

Started from a false alarm: user reported vertical-orientation resin support “doesn’t correctly open” in f3d. Investigation (headless render via f3d --output, comparing vertical’s ~959k faces/48MB output against horizontal’s ~73k faces/3.6MB) found no actual defect - the file opens fine, just far larger than other configs. A live screenshot of the user’s own running f3d window looked blank/flat, but turned out to be transient (“now it seems to be working”) - no fix needed. Flagged as a latent risk for later: generate.py’s full.export(out_path) is not atomic (no temp-file+rename), and tune.py’s f3d --watch reload has no explicit write-finished handoff - worth revisiting if this recurs with a concrete repro.

The real ask that followed: part 36 ported BOTH of v2’s real horizontal resin-support schemes (HorizGroovedResinSupport - the swept “cut groove” breakaway ring, v2’s real HorizGroovedResin3; and HorizRibbedResinSupport - the per-rod scheme, v2’s real HorizResinPrint2/HorizResinSupport2, never actually wired into v2’s own ResinPrint() dispatcher but real and complete in the source), but wired the choice 1:1 to element.groove (part 31’s Rib/Groove body toggle, since part 39 the Build tab’s “Rib” checkbox) - matching v2’s own per-scheme body pairing, but with no way to pick, say, the ring support for a Rib body. User wants them independently selectable on the Resin tab: “the resin support should have multiple options in the resin support tab for horizontal. theres a cut groove method, then theres a resin rod method.”

Before implementing, checked whether full independence was even geometrically sound - HorizRibbedResinSupport() turned out to bundle TWO different tiers: an outer-wall rod band (v2:868-884, same radial band HorizGroovedResinSupport’s ring covers, r0..r0+Shuttle_Thickness) and a rib-specific tier (v2:886-946, rods along the rib’s own back plane and around the drive-pin hole - only meaningful if a rib actually exists). User confirmed by inspection: “the supports for the rib are independent of whether its [cut] groove or resin rods. rib supports are always resin rods” - i.e. the ring/rod CHOICE only ever applies to the outer wall (which the ring never conflicts with, rib or no rib); the rib’s own supports have no cut-groove equivalent and are always added whenever element.groove=False, regardless of which outer-wall method is picked.

Implemented exactly that split:

  • HorizRibbedResinSupport() -> two functions: HorizWallRodSupport() (outer-wall tier only, the v2:868-884 rods - the “resin_rod” alternative to the ring) and HorizRibResinSupport() (rib-specific tier, v2:886-946 - added unconditionally whenever Groove=False, independent of the new selector).
  • New config key resin.horizontal_method (cut_groove/resin_rod), read in configure() as Horizontal_Support_Method, fully independent of element.groove - ResinPrint()’s horizontal branch now picks the outer-wall scheme from this, then separately appends HorizRibResinSupport() iff not Groove.
  • New Resin tab dropdown (“Horizontal support method”: Cut Groove/Resin Rod), same Select-widget pattern orientation already uses.
  • Default (resin_rod) matches the master config’s existing element.groove=false default’s old implicit behavior exactly - but since one flat default can’t simultaneously match BOTH old pairings (groove=false->resin_rod, groove=true->cut_groove), and the user’s own hammond.running.yaml currently has groove=true (was implicitly getting the ring), its horizontal_method was explicitly set to cut_groove rather than the new key’s own default, so nothing changes underfoot for that session. Any OTHER pre-existing saved config with groove=true would see its horizontal method silently default to resin_rod on first load unless it’s also fixed up the same way - not currently believed to exist elsewhere, but worth checking if a groove=true config surfaces with the “wrong” support style after this change.

Naming cleanup requested alongside this, since “groove” was being used for two unrelated things with no way to tell them apart by name: element.groove (body assembly: internal rib+pin boss vs. snap-fit groove cut into the shell) is now called “Without Rib” in user-facing text (Element tab’s shuttle_groove_nub_angle/groove_tab_width/ groove_opening_offset/support_groove_thickness help text, Build tab’s Rib checkbox help); the resin-support ring scheme is “Cut Groove” (matches the label Bennett’s own resin tab already uses for its unrelated cut-groove field - existing precedent, not a new coined term). Internal Python identifiers (Groove, GrooveShape()) were NOT renamed - they still trace 1:1 to v2’s own Groove variable for auditability; only user-facing labels/help text and new code changed.

Verified: headless TuneApp against a scratch config (per the standing warning) - Resin tab’s new dropdown composes, defaults to resin_rod, round-trips through _collect_values(). CLI: all 4 (groove, horizontal_method) combinations build valid watertight is_volume=True meshes. The one combination that existed before this change (groove=False + horizontal, i.e. what used to be HorizRibbedResinSupport’s only real path) reproduces the exact same volume (2961.561mm3) as pre-change, with a few dozen fewer verts/faces from the union now being grouped into two sub-unions instead of one flat parts list - a harmless triangulation-order artifact (same class already noted as harmless in ResinSupport()’s own docstring), not a geometry difference. Hard gate re-run for Blickensderfer/Postal/Mignon/ Bennett: exact same verts/faces/volume as CLAUDE.md’s own cited baseline (Blickensderfer’s verts=42618 faces=85408 ... volume=5666.804mm3 matched exactly) - confirms zero cross-machine side effects, as expected since only lib/hammond.py/config/hammond.yaml/ Hammond-scoped tune.py blocks were touched.

41. Hammond: horizontal rib-support height gap fixed, chamfer decoupled from Rib, orientation/method moved to Build tab, vertical support investigated (no bug found)

User bug report covering several distinct issues in one pass, worked through methodically with empirical bounds/render checks rather than by inspection alone (this codebase has already been burned twice by “looks like it should be right” transform bugs - see README.md’s platen-cutout history):

  1. Horizontal rib-support rods fell short of the rib by 0.34mm - HorizRibResinSupport()’s rib_h reused v2’s Shuttle_Height-Shuttle_Rib_Plane-Shuttle_Rib_Thickness formula verbatim from VertResinSupport2’s own “Under Rib” tier, where it’s correct because vertical orientation never flips the body (a rod built to local height h directly reaches local z=h - confirmed old rib_h, 6.66 by default, exactly equals Rib()’s own local bottom z). Horizontal’s body DOES get rotate([180,0,0]) before the resin support (built in the same fixed, unrotated frame either way) is added as a sibling - so h needs to target the FLIPPED surface’s global z instead. Measured directly: RibAssembled()’s flipped local-max maps to global z=8.20 (Shuttle_Height - ((Shuttle_Height- Shuttle_Rib_Plane) + Shuttle_Pin_Support_Height) = `Shuttle_Rib_Plane
    • Shuttle_Pin_Support_Height), but the old formula's rods only reached z=7.86. Corrected rib_h to Shuttle_Rib_Plane - Shuttle_Pin_Support_Height (same "h=<surface's own z>" convention the wall tier's h=0 already uses, letting _rod2's own Tip_Interference supply the same ~1.2mm overlap either tier gets) - re-verified empirically: rods now penetrate the rib by exactly Tip_Interference` (1.2mm), matching the wall tier’s own convention, confirmed visually (rendered screenshots before/after - the “floating pillar” look is gone, tips now visibly merge into the body’s underside). v2 reuses the same formula 1:1 for both orientations and never corrects this - a deliberate v4 divergence, not a port artifact, per explicit user confirmation.
  2. ResinChamfer() decoupled from Rib/Groove - real v2 only ever calls it from GroovedShuttle() (RibbedShuttle() never does), so toggling the Build tab’s Rib checkbox on (element.groove=False) silently dropped the chamfer. Per explicit request (“that should be independent of Rib selection”), Additive()/CalibrationAdditive() now apply it unconditionally before the Rib/Groove branch - another deliberate v4 divergence, documented at both call sites and on ResinChamfer()’s own docstring.
  3. Print Orientation + Horizontal Support Method moved from the Resin tab to the Build tab, above the Debug section, per explicit request - same bespoke-widget pattern as Rib/target/resin_support (#build-orientation/#build-horizontal-method, handled directly in _collect_values/_refresh_widgets_from_cfg, removed from RESIN_FIELDS_HAMMOND/the generic Select-branch dispatch in _compose_section_tab since nothing else used those elif key== branches).
  4. Vertical resin support’s ~1468 parts - investigated, no bug found. User’s intuition (“something is deeply wrong… are they overlapping? its crashing f3d”) was reasonable to check given how large the file is (~950k faces/~48MB, ~13x any other machine’s resin print output), but every concrete check came back clean: reconstructed the part-count arithmetic tier-by-tier (found only a trivial ~6-position duplicate at theta=0 across the s=-1/s=+1 loop - present in v2’s own source too, and negligible against 1468); the in-memory mesh is ONE connected component (not overlapping, not floating - check_and_repair already confirms watertight/is_volume); a real windowed f3d --watch on this machine loaded and rendered it correctly within seconds, no crash, memory usage unremarkable. One real (but practically inert) finding: reloading the EXPORTED stl shows 4207 connected components via trimesh’s default vertex-merge tolerance, vs. 1 in memory - an STL round-trip/float32-precision artifact (gaps are far below print resolution, physically meaningless for manufacturing) that could plausibly explain per-object viewer overhead on a lower-spec machine than this one, but is not a geometry defect and wasn’t touched - no fix applied pending the user confirming whether it’s still reproducing and what f3d actually shows when it does (blank/frozen/error/OOM - the earlier part 40 session also chased a similar report that turned out to be transient).

Verified: hard gate re-run for Hammond default (vertical, verts=472539 faces=946726 ... volume=4795.951mm3) and horizontal+Rib+ resin_rod (regression check on item 1’s fix path); Blickensderfer/ Postal/Mignon/Bennett unaffected (exact baseline match, as expected - only lib/hammond.py/tune.py’s Hammond-scoped blocks touched). Headless TuneApp against a scratch config (per the standing warning): Build tab’s new orientation/method Selects compose, collect, and round-trip through save+reload correctly alongside Rib/target/resin supports.

42. Hammond: horizontal rib-support height was only half-fixed - split into rib-band vs. pin-boss targets; vertical/horizontal “too far/still short” claims investigated further

Follow-up bug report after part 41: “for horizontal on the rib, it is still not touching the rib” - part 41’s fix used ONE corrected height (Shuttle_Rib_Plane - Shuttle_Pin_Support_Height, 8.2 by default) for EVERY tier in HorizRibResinSupport(), but re-reading v2:886-946 directly (not from memory) shows only the “under Pinhole” tier ever subtracted anything - Inner_Arc_Intercept, the 3 fan patterns, and Under Rib Outer/Radius/Center all use the PLAIN Shuttle_Height-Shuttle_Rib_Plane-Shuttle_Rib_Thickness unmodified. Confirmed empirically: Rib() alone (no PinSupport()) is a uniform- thickness extrusion, so its flipped-bottom target is the same constant everywhere in its footprint - Shuttle_Height - Rib()_local_max = Shuttle_Rib_Plane (9.7 by default), not 8.2. 8.2 IS still correct, but only for “under Pinhole” specifically, which targets PinSupport()’s taller “top” piece (the feature that becomes nearest the support after the flip, since flipping reverses which local extreme is closest). Part 41’s single-constant fix left every non-pinhole tier 1.5mm (Shuttle_Pin_Support_Height) short - re-verified with per-tier ray/ bounds checks this time (Rib()’s own flipped z-min = 9.7 matches the corrected rib_h; RibAssembled()’s flipped z-min = 8.2 matches pin_boss_h; overall HorizRibResinSupport() max reach is now 10.9 = 9.7+Tip_Interference, confirmed visually - full row of rods, including the ones under the pin boss, now merge cleanly into the body’s underside with no gap).

Also investigated, per this same report, whether “the horizontal resin rods on shuttle are still wrong, still going too far into the shuttle” and the earlier request to account for chamfer/taper at the vertical outer-edge rods point to a shared root cause (ShuttleTaper()’s triangular wedge cut, which removes material at the arc’s tapered corners) - concretely confirmed the wedge DOES remove material at the extreme horizontal wall-support thetas (raycast: inner-radius surface hit jumps from z~0 to z~9.7, i.e. the Rib itself, right at theta=+-58/60), but a deeper empirical check on the VERTICAL “at the taper” needle-tip tier (using tip-SPHERE-only intersection volume, not whole-needle volume - the cone shaft is deliberately mostly exposed, so whole-needle-volume percentage is the wrong metric and was giving a false positive) showed comparable ~45-50% sphere embedding at the taper corner AND at a normal flat theta=0 position - i.e., no actual taper- specific defect found there once measured correctly. Given the mixed/ inconclusive signal (one real, confirmed geometric gap at the extreme horizontal wall-edge thetas; no confirmed defect in the vertical tier this pass), no additional code change was made for either the vertical chamfer-awareness request or the horizontal wall “too far” report this session - flagged to get more specific reproduction detail (screenshot or exact coordinates) before attempting another fix, rather than guess again the way part 41’s incomplete rib fix did.

Verified: hard gate matches part 41’s own baseline exactly for Hammond vertical/horizontal+Rib and Blickensderfer/Postal/Mignon/ Bennett (only lib/hammond.py’s HorizRibResinSupport() changed this session).

43. Hammond: vertical resin support’s rod_tip() needle was only ~50% embedded (sphere centered exactly on the nominal arc surface) - found the real root cause of the earlier “4207 fragments/crashing f3d” concern

Follow-up to part 42’s “no defect found” on the vertical outer-edge tier - user corrected the diagnosis: “vertical outer edge: along arc circumference not wedge cut. resin rods are meshing at the base of the tip, not the tip of the tip.” Two things wrong with the previous pass: (a) investigated the wrong feature (ShuttleTaper()’s end-cap wedge, not a defect along the arc’s actual circumference), and (b) used the wrong metric (whole-needle volume-overlap %, which is misleading since the needle’s cone SHAFT is deliberately meant to stay mostly exposed - only the tip sphere is supposed to be solidly embedded).

Re-measured with the right metric (tip-sphere-only intersection volume) at a plain mid-arc position (theta=0, nothing taper-related): only ~50% of the sphere’s own volume was inside the body, CONSISTENTLY across every theta tested (0, 30, thetamax/2) - i.e. a real, general defect along the whole arc, exactly matching “along arc circumference,” not a taper-specific one. Root cause: resin_support.rod_tip()’s placement puts the tip sphere’s CENTER at exactly arc_radius (the nominal wall radius) with no interference term at all - since the real wall surface IS at that radius, the sphere sits bisected by it: the half facing the cone/rod shaft (the “base of the tip”) embeds, the outer/pointed half (the actual “tip of the tip”) sits in open air. Every OTHER rod-placement convention in this codebase (_rod()/ resin_rod()’s tip_z=-tip_od/2+inset+h, horizontal’s _rod2()/ Tip_Interference) has some such term; rod_tip() never did. Checked whether this was a dropped v2 term first (v2’s own translate([0,0, Shuttle_Arc_Radius-1]) RodTip() looked like a candidate) - it isn’t: that “-1” is exactly what tip_l already represents in the ported formula (arc_radius-tip_l), already faithfully reproduced. So this is a genuine v4-only gap, not a v2 divergence.

Added a new inset parameter to resin_support.rod_tip() (default 0.0

  • unchanged behavior for any future caller that doesn’t opt in), wired from Hammond’s _rod_tip() closure as Resin_Inset+Resin_Tip_OD/2
  • the same inset convention _rod() already uses, just applied along the needle’s own radial placement axis instead of a fixed Z axis. Re-verified: tip sphere now 100% embedded at every theta tested, cone shaft still ~75% exposed (whole-needle ratio 25%, confirmed) - a proper breakable support point, not a buried rod.

Side effect, found while re-running the vertical hard-gate check: the STL-round-trip fragmentation flagged in part 41 as “physically inert, probably just a viewer/float32 artifact” (4207 disconnected components on reload vs. 1 in memory) dropped to 6 components (5 of which are 2-face numerical noise, not real geometry) once this fix landed - face count also dropped by ~37% (946726->593246), consistent with far fewer razor-thin/marginal boolean seams needing extra triangulation to resolve. This strongly suggests the marginal ~50%-embedded tip contacts WERE the real mechanism behind the “1468 parts…crashing f3d” report part 41 investigated and couldn’t confirm - not a viewer quirk after all, a real (if subtle) geometry defect whose symptom only showed up after STL export, which is why the in-memory single-connected-component check in part 41 didn’t catch it.

HorizWallRodSupport()’s “still going too far into the shuttle” report remains unresolved - _rod2()/cylinder_machine._resin_rod() is a different, shared primitive (also used by Mignon/Bennett with no similar report), and the raycast check in part 41 already confirmed its z=0 target lands on the real wall surface with the intended Tip_Interference overlap. No fix attempted this session for lack of a confirmed root cause - same standing ask as part 41’s close: need more specific reproduction detail to avoid guessing again.

Verified: hard gate re-run for Hammond vertical (new baseline: verts=295799 faces=593246 ... volume=4813.141mm3 - volume increased ~0.4% from the extra embedded material, expected) and horizontal+Rib (unchanged from part 42’s own baseline, confirming the fix is isolated to the vertical-only rod_tip() call site); Blickensderfer/Postal/ Mignon/Bennett unaffected (exact baseline match - rod_tip()’s new inset parameter defaults to 0.0/previous behavior for any caller that doesn’t pass it, and Hammond is its only caller).

44. Hammond: horizontal wall/rib resin rods were burying their entire tip section - _rod2()’s Tip_Interference doesn’t transfer to the substituted rod shape

User clarified part 43’s “along arc circumference” framing further: “vertical: both arc circumference chamfered, because both circumferential edges are exposed with chamfers and needs supports” (both X-extreme positions along the arc need solid support, not just one) and “horizontal: only one arc circumference chamfered, because other side has supports” (context for why the OTHER open item - “still going too far into the shuttle” - matters). Re-verified part 43’s vertical fix against BOTH X extremes (Xx’s first/last values, the two “circumferential edges”) specifically, not just the one X value tested before: sphere embedding is 90-100% at both, confirmed solid - no further change needed there.

For horizontal, user provided a screenshot of hammond_running.stl in f3d - the wall-support rods show no visible pointed tip at all, just a smooth cone merging straight into the body. Traced precisely: _rod2(h)’s h_shared = h - Resin_Tip_OD/2 + Tip_Interference computes the intended contact depth, but was being passed through cylinder_machine._resin_rod(), which unconditionally adds this module’s own Resin_Inset on top - double-counting interference (1.2+0.2=1.4mm effective) against a taper section only Resin_Tip_L=1.0mm long, burying the entire pointed tip underground (confirmed: h=0’s tip section spanned z=[0.0,1.0], all at or past the real z=0 surface, no exposed point) - exactly matching the screenshot.

Went deeper than just removing the double-count: checked v2’s real ResinRod2(h) (v2:850-861) directly - its cone section is a literal cylinder(...,h=2), hardcoded 2mm, nothing to do with any config value. Tip_Interference (1.2mm, v2:324) was calibrated by v2’s original author against THAT 2mm cone (burying 60%, a reasonable proportion) - but this port’s rod SHAPE was already substituted with the shared resin_rod() primitive (Resin_Tip_L=1.0mm, half as long - this module’s own docstring already flags the shape substitution as “a minor, explicitly-accepted simplification”). The same 1.2mm interference against a 1mm cone buries 120% regardless of the double-count bug - a real mismatch between a genuine v2 constant and this port’s different geometry, not fixable by only removing the double-count.

_rod2() now uses the same convention rod_tip() was fixed with in part 43 instead - Resin_Inset+Resin_Tip_OD as the rod-shape’s own inset parameter (not Tip_Interference at all) - giving a solidly-embedded sphere with ~60% of the cone shaft still exposed at defaults (h=0’s tip section now spans z=[-0.6,0.4] against the real z=0 surface). Tip_Interference is no longer consulted by any geometry function - kept in config/globals as real, documented v2 data (not deleted), with both config/hammond.yaml and configure()’s own comment updated to say so explicitly, matching this codebase’s established dead-value-callout convention. Re-verified the rib-support tiers (also built via _rod2, fixed in part 42) still land solidly on target after this change - overlap past the plain-rib target dropped from 1.2mm to 0.6mm (still solidly positive, arguably more reasonable than before), confirmed via bounds check.

Verified: hard gate re-run for Hammond vertical/horizontal+Rib/horizontal+cut_groove and Blickensderfer/Postal/Mignon/Bennett - all exact baseline matches except Hammond horizontal+Rib (verts=39411 faces=79306 volume=3036.524mm3 - new baseline, expected given the rod-shape formula change). STL round-trip component check: horizontal’s reloaded component count is now 1 (was already 1 before this session - the double-counted-but-still-positive overlap wasn’t marginal enough to fragment the way vertical’s rod_tip bug did); vertical still 6 (unchanged, already fixed in part 43).

45. Hammond: parts 43-44’s resin-tip pushes were both wrong - reverted to the real, un-hardcoded convention per explicit correction

User rejected both of the previous two sessions’ fixes directly: “nothing should be hardcoded. the resin tips are still not correct for horizontal shuttle edge, its protruding too far. with inset half of tip diameter, the center of the sphere tip is at the surface of the shuttle edge. same with the horizontal rib resin rods.” Plus a screenshot showing the wall-support tips now visibly poking OUT (part 44 pushed too far the OTHER way, past a clean 60%-exposed cone into looking like excess protrusion) and, separately: “also the rod tips have been fucked up on vertical, they are offset from the resin rods without tips now” - another screenshot showing part 43’s rod_tip() needles visibly floating apart from the straight rod shafts they’re supposed to sit on.

Root mistake in both parts 43 and 44: resin_rod()’s own formula (tip_z=-tip_od/2+inset+h) ALREADY does exactly what the user specified - config/hammond.yaml’s own resin.inset is documented as “tip_od/2, same convention as bennett.yaml”, and with inset=tip_od/2 those two terms cancel exactly, landing the tip sphere’s CENTER at h precisely - “the center of the sphere tip is at the surface.” This was the correct, already-existing, ALREADY-CONFIGURED behavior the whole time; parts 43 and 44 both misdiagnosed it as a defect (a “50/50 split” that needed fixing) and each invented a NEW extra offset (rod_tip’s inset=Resin_Inset+Resin_Tip_OD/2, then _rod2’s Tip_Interference double-count and later Resin_Inset+Resin_Tip_OD) - neither of which existed as any real, named geometric relationship; both were guesses dressed up as fixes.

  • _rod2(h) (horizontal wall+rib tiers): reverted to a plain cylinder_machine._resin_rod(h) call - IDENTICAL to what _rod() (every other straight-rod tier in this file) already does, no extra term. Tip_Interference remains unused (as already noted in part 44 - its real v2 value was calibrated against ResinRod2’s own hardcoded 2mm cone, not this port’s substituted 1mm one, so it was never applicable here to begin with).
  • resin_support.rod_tip() (vertical arc-sweep needle): the inset parameter added in part 43 is removed entirely - back to the original, faithfully-ported placement (sphere center at arc_radius exactly). This ALSO fixes the alignment complaint: the needle shares its (x,theta) position with a SEPARATE _rod()/_crod() pair placed at y=(Shuttle_Arc_Radius-1)*cos(...) (a real v2 constant, a different radius reference) - part 43 pushed ONLY the needle further out along its own axis while that accompanying rod/crod never moved, growing a visible gap between them. Reverting removes that gap too.

One real, disclosed tradeoff: this reintroduces the STL-export fragmentation part 43 fixed as a side effect (4207 components on reload vs. 1 in memory, same as originally found in part 41) - since a sphere CENTERED exactly on a boundary is inherently a razor-edge case for float32 STL export, regardless of whether that’s the geometrically “correct” placement. Per the same reasoning established in part 41, this is physically inert for actual printing (the resulting gaps are far below print resolution) - not reverted or worked around again here, since the user’s correction was explicit and specific about the exact sphere-center placement; flagging it rather than silently re-introducing another invented push to paper over it.

Verified: hard gate re-run for Hammond vertical (exact match to the part-40 pre-fix baseline: verts=472539 faces=946726 volume= 4795.951mm3 - a full, clean revert, as expected) and horizontal+Rib (verts=35313 faces=71110 volume=3015.464mm3 - new baseline, sphere- center-at-target as specified); Blickensderfer/Postal/Mignon/Bennett unaffected (exact baseline match). Rib-support target re-confirmed: sphere center lands at exactly 9.7 (Rib()’s own flipped-bottom, computed in part 42), matching “center of the sphere tip is at the surface” precisely, no residual offset. Horizontal’s reloaded STL component count stays at 1 (unaffected by this session’s changes); vertical’s reverts to 4207 (the disclosed tradeoff above).

46. Hammond: ResinChamfer() disabled (commented out) - the “apply unconditionally” fix from part 41 was never actually right

Follow-up, user’s own words: “i think we remove the chamfer around the arc circumfrence. comment it out. i wanted to put it on the otherside conditionally, but its more hassle. just comment it out for now.” Real v2 only ever calls ResinChamfer() from GroovedShuttle() - RibbedShuttle() never does. Part 41 made it unconditional (regardless of Groove/the Rib checkbox) per a request at the time; the actually- correct behavior would be conditional on which of the two circumferential edges doesn’t already have resin supports touching it (real work, requires knowing which edge that is per orientation/method combination)

  • rather than leave the wrong-for-one-edge unconditional version in place, disabled entirely for now per explicit instruction.

Commented out both call sites (Additive()/CalibrationAdditive()), left ResinChamfer() itself defined (not deleted) for whenever the real per-edge conditional gets built. Updated the two places that had been describing the (now-incorrect) unconditional/dual-consumer state: config/hammond.yaml’s support_groove_thickness comment and tune.py’s matching field description both now say ResinChamfer()’s consumption is disabled - Support_Groove_R (derived from this same config value) is only actually consumed by resin.horizontal_method’s Cut Groove ring now.

Verified: hard gate re-run for Hammond vertical (verts=478551 faces=958750 volume=4802.476mm3), horizontal+Rib (verts=35191 faces=70866 volume=3022.407mm3), and Rib-off/Groove body (verts=461028 faces=923372 volume=4309.645mm3 - the body that used to be ResinChamfer’s ONLY real consumer, confirmed still builds a clean watertight mesh with it removed); Blickensderfer/Postal unaffected (exact baseline match).

47. Hammond: removed the theta==0 s=-1/s=+1 redundant pass in ResinSupport() (pure performance fix, zero geometry change)

User asked to fix the small redundancy flagged while auditing why the vertical build’s part count looked high (“1468 parts… seems excessive”). Confirmed: at theta=0, y=(Shuttle_Arc_Radius-1)*cos(pi/2+ theta*s) and every downstream value derived from it (za, z_common, the needle’s rotation angle theta_deg*s) are exactly s-independent (0*s==0 regardless of sign) - so the s=-1 and s=+1 passes build IDENTICAL parts across all three sub-tiers keyed off that iteration (Under Shuttle Arc Radius, ConRods, Rib Supports), for every X position. v2’s own nested s/theta loop has this exact same structural duplication (harmless there too - union() no-ops on coincident geometry) - not a v4-introduced bug, just wasted work in both. Skipped the redundant s=-1, theta==0 iteration entirely (a continue at the top of the theta loop) - since the s=1 pass alone already produces this iteration’s geometry, this cannot change the built shape.

Verified: default config (Groove=False) part count 1468->1408 (-60, matches the expected per-X-position tier contribution: 3 Under-Arc + 4 ConRods + up to 6 Rib, times 6 X values, times the sub-conditions that actually fire); Groove=True config 1092->1050 (-42, matches losing just the 3+4=7 non-Rib parts per X value, since the Rib tier doesn’t fire when Groove=True). Final ResinPrint volume identical to the pre-fix baseline in both cases (4802.424mm3/4309.592mm3) - confirms zero geometric impact, exactly as expected for removing an exact duplicate. Horizontal and all other machines unaffected (ResinSupport is vertical-only) - exact baseline match.

48. tune.py: added a build progress bar (0-95% character placement, last 5% everything else) - applies to every machine’s Preview/Render/Render Test Text

User request: show progress during Preview/Render so it’s clear when a build isn’t finished yet, mapping “divide all the characters from 0-95, and the last 5% is anything else… then boom 100%.” Implemented as a single shared mechanism in _stream_subprocess() (the one function every build path - _run_build’s Preview/Render and action_render_type_test’s Render Test Text - already funnels its subprocess output through), so it automatically covers every machine without per-machine wiring:

  • _PROGRESS_RE matches generate.py’s own [n/total] progress markers, which BOTH cylinder_machine.TextRing (“TextRing: [45/90] building …”) and CalibrationTextRing (“[45/2700] row 1 col 14 (…)”) already print per-character/per-position - shared by every machine (Blickensderfer/Postal/Mignon/Bennett/Helios/Hammond all route through one or the other for a real Element/Calibration Element build), so no new instrumentation was needed in generate.py itself.
  • Each matched line maps to 95*n/total on a new ProgressBar widget (#build-progress, docked to the bottom of the log pane, per explicit placement request) - naturally lands exactly on 95% when n==total, no separate “placement finished” marker needed.
  • _stream_subprocess resets the bar to 0% before launching the subprocess and jumps it to 100% on a successful exit (the “boom 100%”)
    • everything after character placement (Additive/Subtractive booleans, resin supports, check_and_repair, the STL write) has no comparable per-item signal to report against, so it’s genuinely just “the last 5%, then done,” matching the request as literally as the available signals allow.
  • Builds with no TextRing/CalibrationTextRing call at all (Shaft Gauge, Hammond’s None/RibOnly target) never print a [n/total] line, so the bar just sits at 0% until the jump to 100% - documented on _update_progress’s own docstring as an accepted gap, not a bug (no per-item signal exists to show for those).

Verified: headless TuneApp against scratch configs (per the standing warning) - _update_progress unit-tested against both real line formats (TextRing: [45/90] ...->47.5%, [90/90] ...->95.0%, [1350/2700] row 1 col 14 (...)->47.5%, unrelated lines no-op); full end-to-end Preview build against Blickensderfer showed the bar actually climb 0->12.4->36.2->58.8->84.8->95.0->100 during a real subprocess run; Hammond composes cleanly with the new widget too. Screenshot confirms placement (bottom of the log pane, spanning its width) and legibility.

Also discussed, not yet decided: moving the Preview/Render/Save button row from the bottom of the (fixed 58-col) form panel to below the (variable 1fr-width) log pane, to free vertical space in the form for tab content. Flagged a real tradeoff (button width would vary with terminal size on the 1fr side, unlike the form panel’s predictable sizing) but the screenshot taken while verifying the progress bar showed the two panels are similar widths at a typical window size, so the stretching concern may be smaller in practice than expected - no code change made pending the user’s decision.

49. tune.py: replaced the progress bar’s ETA countdown with a plain elapsed-time counter

User: “the counting timer not working right.” Traced the mechanism: Textual’s built-in ProgressBar ETA only recomputes inside .update(), and .update() is only called from _update_progress() when a new [n/total] line arrives. Character placement (0-95%) is cheap and finishes in well under a second for most builds; the actual slow part (Additive/Subtractive booleans, resin supports - part 48’s own “last 5%, no per-item signal” design) never calls .update() at all. Confirmed by inspecting textual.eta.ETA directly: speed needs >=1 second of real span between samples to compute anything, and once no new samples arrive, _display_eta simply never gets recomputed - the countdown freezes at a stale value for the entire slow phase, then jumps straight to done. Not just imprecise - actively misleading (implies “almost done” while the build still has most of its real wall-clock time left).

Replaced with a plain elapsed-time Static (#build-elapsed), ticking every 0.2s via self.set_interval() for the lifetime of _stream_subprocess’s subprocess call, alongside the existing ProgressBar (now show_eta=False, keeping just the percentage). Elapsed time needs no speed extrapolation and can’t go stale the same way - it’s just wall-clock counting, always correct by construction.

Verified: headless TuneApp against scratch configs (per the standing warning) - confirmed the elapsed counter keeps ticking through the “stuck at 95%” phase where the old ETA would have frozen (0.6s -> 1.0s -> 1.2s -> 1.6s -> 1.8s -> 2.2s -> 2.5s while progress sat at 95%, then jumped to 100%); both Blickensderfer and Hammond compose cleanly with the new widget. Screenshot confirms layout/legibility - “95% 7.3s” reads cleanly where the old “62% –:–:–” (or a frozen, wrong countdown) used to sit.

50. tune.py: console (RichLog) now reflows its scrollback on resize instead of staying wrapped at whatever width it was written at

User: “if i resize the window, the console text history gets… right now if i expand it, it stays constricted. and if its wide and i shrink it, it goes off page.” Traced to a real, confirmed RichLog behavior (read its source directly, not assumed): write() computes the render width ONCE per call - from the widget’s CURRENT scrollable width at that moment, via its own default expand=False/shrink=True logic - and bakes it permanently into the stored Strip objects. Nothing re-wraps already-written lines on resize; RichLog.on_resize() only flushes deferred first-render writes, confirmed by reading it directly. So every line stays wrapped at whichever width was current when it was originally written, exactly matching both halves of the report (expanding leaves old narrow-wrapped lines narrow; shrinking leaves old wide-wrapped lines wide enough to overflow).

Added ReflowingRichLog(RichLog): keeps its own plain-text history list (via an overridden write() that appends before delegating), and on on_resize(), if the widget’s own width actually changed AFTER its first known size (guarded to avoid duplicating the base class’s deferred-render flush on the very first resize), clears and re-writes every stored line at the new width. Swapped both compose()’s RichLog(...) call and log_line()’s query_one over to the new class - one-line surface change, all the actual behavior lives in the new subclass.

Verified: headless TuneApp with real pilot.resize_terminal() calls (not just size guesses) - 5 long lines written at 180 cols rendered as 20 wrapped display-lines; shrinking to 60 cols reflowed the SAME 5 logical lines into 955 display-lines; expanding back to 180 returned to exactly 20 - and the underlying history stayed at 5 entries throughout (confirms no duplication across repeated resizes, the real risk with a re-write-everything approach). Screenshots at 140 cols and 90 cols confirm the visual wrap point actually moves with the window and no horizontal overflow occurs. Hammond and Blickensderfer both compose and resize cleanly with the new widget.

51. tune.py: “Change Machine” now closes any open f3d window

User: “if f3d is open and i change machine, it closes the dialog… otherwise… hammond_running i just refreshed [when it should have been blickensderfer].” Real bug: f3d is launched with --watch <path> for whichever machine was active at the time (e.g. output/hammond_running.stl); switching to a different machine and rendering writes to a DIFFERENT path (output/blickensderfer_running.stl etc.) that the already-running f3d was never told about. _ensure_f3d_after_build’s “already running, just raise the window” branch has no way to know the watched file is now for the wrong machine, so the old model just sits there unrefreshed while the new one silently never appears.

Fixed at the source: _change_machine() (the “Change Machine” button handler) now calls _kill_f3d() before returning to the machine picker, so whichever machine gets picked next always starts from “no f3d running” and _ensure_f3d_after_build launches a fresh instance pointed at ITS real output path. Also hardened _kill_f3d() itself to reset self._f3d_proc = None immediately after calling .terminate(), rather than leaving it set until the OS reaps the process - the existing callers (atexit/quit) never cared since the app was exiting anyway, but _change_machine() keeps running afterward and needs _ensure_f3d_after_build’s “is one already running” check to see “no” right away, not race against SIGTERM’s asynchronous delivery.

Verified with a REAL f3d process (this machine has a live display - not mocked): launched actual f3d --watch .../hammond*.stl -g -x, confirmed it alive via .poll(), called _change_machine(), confirmed the real OS process was dead afterward and _f3d_proc was None. Full end-to-end: killed old f3d, switched to Blickensderfer, ran a Preview with the f3d-preview checkbox on - confirmed a NEW f3d process launched with cmdline f3d --watch .../blickensderfer_running.stl -g -x, the correct new machine’s real path. Confirmed the user’s own actual live f3d session (watching the real hammond_running.stl, not a scratch file) was untouched by any of this testing.

52. Hammond: RibOnly() gets a groove-interface flange with FDM fit clearance; Build target consolidated to Shuttle/Rib/Shuttle with Rib/Calibration Shuttle

Two related requests: (1) “the rib only should also have the groove shape” plus a configurable FDM fit-tolerance offset, and (2) “remove Rib checkbox, and just go with dropdown options Shuttle, Rib, Shuttle with Rib” - both about the same underlying mechanism (the Rib/Groove body split), so handled together. Confirmed the geometry design with the user before implementing, given real physical-fit stakes (guessing wrong means unprintable parts): “positive space for the slot, negative space for the nubs” for the flange, “shrink the Rib’s male features” for the offset direction.

Geometry (lib/hammond.py): GrooveShape() (the circumferential snap-fit slot cutter, real v2, only ever subtracted from a Groove=true shell) gained three v4-only parameters, all defaulting to reproduce its exact original cutter behavior: shrink (shrinks the disk radius/tab half-width, for FDM fit clearance), include_tab (the tab’s x=50 reach is a real construction sentinel per its own docstring - only valid as an oversized CUTTER, would be a literal 50mm spike as positive material, so RibOnly() passes include_tab=False), trim_to_arc (the disk is a full 360deg circle in its real cutter use, but RibOnly()’s Rib()+PinSupport() body only spans the real ~120deg arc - without this the flange would be mostly floating disk far beyond the rib’s own footprint; reuses the exact same wedge-complement trim Rib() applies to itself, _wedge_complement_poly(p1, apex, p3), just re-derived at the flange’s own larger radius since the trim is angle-defined from a fixed apex, not radius-defined). RibOnly() now unions GrooveShape(shrink=Rib_Interface_Offset, include_tab=False, trim_to_arc=True) onto RibAssembled(). New config value element.rib_interface_offset_mm (default 0.15mm) - v4-only, not a v2 value, exposed on the Element tab.

Caught and fixed two real modeling mistakes before shipping (both via direct bounds/volume checks, not assumed): the tab’s x=50 overshoot initially got included wholesale (RibOnly() bounds showed a literal x=50 spike); and before trim_to_arc, the flange was a full 360deg disk (RibOnly() volume was 1074mm3, mostly empty floating disk far past the rib’s own ~120deg footprint) - both confirmed visually via f3d screenshots after fixing (a properly-bounded ~120deg arc flange with visible retention notches, volume 241.9mm3, matching RibAssembled()’s 128.9mm3 plus a sensible flange addition).

UI (tune.py): Hammond’s Build target dropdown (Shuttle/ Calibration Shuttle/None) and separate Rib checkbox (element.groove, inverted) are consolidated into one dropdown - HAMMOND_BUILD_OPTIONS/ HAMMOND_BUILD_TARGET_GROOVE map each of the 4 new option labels directly to a (build.target, element.groove) pair: “Shuttle”-> (element, groove=True), “Rib”->(none, groove=True - RibOnly() ignores Groove entirely, kept True just for consistency with “Shuttle”’s own meaning), “Shuttle with Rib”->(element, groove=False, today’s default), “Calibration Shuttle”->(calibration, groove=False - always the fused body regardless of whichever of the other 3 was last picked, since Calibration validates the real default print variant, not independently-reconstructable state). _hammond_build_dropdown_value() is the reverse mapping, for populating the dropdown from a loaded config. _collect_values()/_refresh_widgets_from_cfg() both route through this translation for Hammond only; non-Hammond machines’ #build-select handling is untouched. Trimmed the new Build-target help text down from an initial ~20-line draft to 5 lines, per CLAUDE.md’s own “well under 10 rendered lines” tab-banner convention - caught by screenshot review, not written short the first time.

Verified: geometry - RibOnly() watertight/winding_consistent/ is_volume all True, volume 241.948mm3, confirmed via --hammond-part rib_only CLI path too; GrooveShape()’s default-args shell-cutter path confirmed byte-for-byte unaffected (Groove=true body’s ResinPrint volume 4309.645mm3 exactly matches the pre-change baseline). UI - headless TuneApp (scratch config): all 4 dropdown values produce the correct (target, groove) pair; save+reload round-trip preserves the dropdown’s displayed selection for both “rib” and “shuttle_with_rib”; non-Hammond machines’ #build-select unaffected (no groove key collected, dropdown options/values unchanged). Screenshot confirms the final Build tab layout and trimmed help text. Full hard gate: Hammond vertical/horizontal-Rib-on/Rib-only-CLI and Blickensderfer/Postal all match established baselines exactly.

53. Hammond: added “Calibration Shuttle with Rib” - part 52’s Calibration handling was wrong

User: “will also need Calibration Shuttle with Rib.” Part 52 gave Calibration only ONE dropdown entry, hardcoded to groove=False (the fused/Rib body) regardless of whichever of Shuttle/Rib/Shuttle-with-Rib was picked elsewhere - reasoned at the time as “Calibration validates the real default variant, not independently-reconstructable state.” Wrong: Calibration should mirror the SAME Rib/Without-Rib split real Shuttle has, not be pinned to one hardcoded choice.

Added a 5th option, “Calibration Shuttle with Rib” -> (calibration, groove=False); the existing “Calibration Shuttle” now means (calibration, groove=True) instead of the old hardcoded groove=False - matching how “Shuttle”/”Shuttle with Rib” already split (groove=True/ False respectively). _hammond_build_dropdown_value()’s calibration branch now checks groove the same way its element branch always did, instead of returning a fixed value. _run_build’s own calibration dispatch needed no changes - both new options share build.target= “calibration”, differing only in the element.groove that’s already saved to the config before that subprocess launches.

Verified: headless TuneApp - all 5 dropdown values produce the correct (target, groove) pair; save+reload round-trip preserves the dropdown’s displayed selection for both new calibration options. generate.py --calibrate for both groove=True/False configs both build clean, valid, watertight CalibrationElements with genuinely different volumes (1857.061mm3 vs 2000.507mm3), confirming the Rib/Without-Rib distinction is real for Calibration too, not a no-op.

54. Hammond: fixed RibOnly()’s misaligned flange trim - part 52’s trim_to_arc re-derived p1/p3 at the wrong radius

User: “The Rib only is not the same as when its installed on the shuttle together. the two regressed. the Rib only has a flat line, should have the same curves.” Root cause found by direct numeric check (not assumed): part 52’s trim_to_arc re-derived the flange’s wedge- trim p1/p3 using Rib()’s own half_ang_rad-from-ORIGIN formula, but evaluated at the flange’s own larger radius (Shuttle_Arc_Radius+ Shuttle_Groove_Depth-shrink instead of Rib()’s plain Shuttle_Arc_ Radius). The trim’s apex is (Z_Offset, 0) - offset from the origin - so a point at the “same angle from origin” but a DIFFERENT radius does NOT sit on the same ray from that apex. Confirmed exactly: the angle from apex to Rib()’s own p1 is precisely -90deg (apex.x equals p1.x exactly, both being Shuttle_Arc_Radius*cos(half_ang)), but the re-derived flange p1 (at the larger radius) landed at -89.53deg instead

  • a real, measurable ~0.47deg rotation between the two pieces’ trim boundaries, which is what showed up as a misaligned seam/flat facet where the flange should have smoothly continued Rib()’s own curve.

Fixed by extending the exact SAME p1/p3 points outward along their own existing ray from apex (apex + 3.0*(p1_rib-apex), a generous but not re-derived scale factor - safe since this disk’s radius only exceeds Rib()’s R by Shuttle_Groove_Depth, a few tenths of a mm) instead of re-deriving new points at a different radius. Guarantees identical trim angles regardless of which radius the disk being trimmed actually is.

Verified: RibOnly()’s bounds X-min now matches RibAssembled()’s own X-min EXACTLY (18.28713799 both, to 8 decimal places - was measurably off before this fix) and matches the full fused body’s own X-min too (FullElement()’s bounds, same value). Shell-cutter path (default args) confirmed byte-for-byte unaffected (GrooveShape() bounds/volume identical to the established baseline). Full hard gate: Groove body ResinPrint volume 4309.645mm3 matches baseline exactly; RibOnly() via --hammond-part rib_only CLI still builds a clean, valid, watertight mesh (volume 243.599mm3, up slightly from the pre-fix 241.948mm3 as expected - the corrected, non-rotated trim boundary keeps marginally more material than the rotated/clipped one did).

55. Hammond: RibOnly() flange was a SOLID disk to the center, not a ring - part 54’s angle fix was correct but incomplete

User, with screenshots: “it is still wrong” - part 54 fixed the trim boundary’s ANGLE (confirmed correct - RibOnly()’s edge lines up with RibAssembled()’s to 8 decimal places) but missed a second, separate problem also causing the reported “flat line”: GrooveShape()’s disk is a SOLID disk reaching to the center (Point(0,0).buffer(radius)), which is harmless in its real cutter use (the target shell is itself only a thin band near the outer wall, so the cutter’s own material toward the center has no shell material to remove there anyway) - but as RibOnly()’s positive flange, that solid center is real, visible material. The wedge trim’s straight chord, cutting across a SOLID DISK all the way to the center rather than a narrow band, produces a large flat facet - screenshots confirmed exactly this: a wide, flat straight edge running most of the piece’s height, not a wedge-trim edge scaled to a thin band’s actual width.

Added an inner cutout to GrooveShape()’s trim_to_arc=True path, turning the disk into a proper RING from Shuttle_Arc_Radius- Shuttle_Rib_Width-1.0 (1mm past Rib()’s own real inner radius, for guaranteed union overlap, not just a coincident boundary) out to the disk’s own outer radius - matching Rib()’s real radial band width instead of reaching to the center.

Verified: RibOnly() volume dropped from 243.599mm3 to 141.991mm3 (removing the large solid center wedge, as expected); screenshot confirms a proper crescent/band silhouette matching Rib()’s own shape, no more large flat chord. Shell-cutter path (default args) confirmed byte-for-byte unaffected. Full hard gate: Groove body ResinPrint volume 4309.645mm3 matches baseline exactly; RibOnly() via --hammond-part rib_only CLI still builds a clean, valid, watertight mesh.

56. Hammond: RibOnly() was missing its center tang, plus a new “Rib” tab and per-row “(math)” label

Three small, related follow-ups, all from explicit user requests.

Row label: “math/universal layout ui doesnt work properly” turned out not to be a functional bug at all - a headless TuneApp test (select “Math Universal”, collect values, save) found no exception and no data loss. The user’s own follow-up clarified what “doesn’t work properly” meant: ROW_LABELS (tune.py) only had 3 entries (lowercase/uppercase/figs), so the Element tab’s 4th baseline/ cutout row (real, editable, and correctly seeded per part 1207’s/ on_select_changed’s existing machinery - see _compose_baseline_ cutout_fields) rendered as a bare “Baseline row 3” with no indication of what it’s for. Added "math" as ROW_LABELS[3] - now reads “Baseline row 3 (math)”/”Cutout row 3 (math)”. No other layer of this (seeding, n_rows sizing, save/patch) needed a fix - it was working correctly and just needed the label.

New “Rib” tab: element.rib_interface_offset_mm moved out of the Element tab into its own new “Rib” tab (RIB_FIELDS_HAMMOND, registered in SECTIONS_BY_MACHINE["hammond"], composed in _compose_tuner_ui right after Element), per explicit request - “put it in a new tab Rib. note value is for Rib only for FDM printing.” A new SECTION_INTROS["Rib"] banner states these are v4-only FDM print-fit knobs for Build target Rib specifically, never the fused Shuttle-with-Rib print or the Shuttle body.

Nub clearance growth: “also shift the nub size larger too for that.” Shuttle_Groove_Nub_Size (Shuttle_Thickness/2, v2:260, derived, not independently tunable) is shared as-is between GrooveShape()’s two real uses: the Shuttle-side cutter (unaffected, must stay exact per v2) and RibOnly()’s flange (the new v4-only positive-material reuse from parts 51/54/55). Added a nub_grow parameter to GrooveShape() (default 0.0, reproduces the original exactly) that only grows the 4 nub-clearance cutout radii, independent of the existing shrink parameter (which only affects the disk/tab’s outer boundary). New element.rib_nub_growth_mm config key (default 0.15mm, same starting point as rib_interface_offset_mm), exposed as the Rib tab’s 2nd field, consumed only by RibOnly()’s own GrooveShape() call.

Missing tang: “the rib only is missing the tang in the center that inserts into the slot cut out on the shuttle.” RibOnly() (parts 51/54/ 55) explicitly passed include_tab=False to GrooveShape(), on the reasoning that the “tab” only makes sense as a cutter (it creates the Shuttle-side opening; the flange being inserted through that opening “doesn’t need an equivalent feature of its own”). That reasoning was wrong for the v4-only two-piece FDM assembly path: with no tang, a printed RibOnly() has nothing that physically plugs through the opening the Shuttle-side cutter creates - it’s not actually insertable.

Fix: RibOnly() now passes include_tab=True. The real cutter’s own tab is a 50mm-overshoot box running from the rotation axis (x=0) out past the shell, a deliberate sentinel that only makes sense as a cutter (harmless there - it only ever cuts empty air past the shell’s real material); reused as positive material verbatim it would be a 50mm spike. Added two new GrooveShape() parameters, both defaulting to reproduce the original cutter shape exactly:

  • tab_length (default 50.0): the tab’s far edge along local +x. RibOnly() passes the new Rib_Tang_Length global, which defaults (element.rib_tang_length_mm omitted) to Shuttle_Arc_Radius+ Shuttle_Thickness - the Shuttle’s own real outer wall radius (see ShuttleCylinder()) - a DERIVED value, not an invented literal, deliberately left out of hammond.yaml/hammond.running.yaml (only a commented-out placeholder line, with a comment explaining why) so it keeps tracking those two globals rather than needing to be hand-kept in sync with them. Not exposed in the Rib tab for the same reason - patch_yaml_value requires a live (uncommented) key to already exist in the config text, and a hand-added override is expected to be rare enough that direct YAML editing is fine (same “YAML-only, documented” treatment CLAUDE.md already endorses for layout.placement_map/char_legend).
  • tab_start (default 0.0): the tab’s near edge. RibOnly() passes Shuttle_Arc_Radius-Shuttle_Rib_Width-1.0 - the same inner-radius/ overlap-margin already used by GrooveShape()’s own inner_hole cut (part 55) - so the tang is a short feature contiguous with the flange’s own ring, not a spike running all the way in to the rotation axis.

Verified: --hammond-part rib_only (master config/hammond.yaml) now builds verts=3262 faces=6524 watertight=True winding_consistent=True is_volume=True volume=148.983mm3 (up from part 55’s 141.991mm3 - consistent with adding one small tang feature). f3d screenshot confirms a small rectangular tang centered on the flange, contiguous with the curved band, not a spike. Full hard gate: stashed just lib/hammond.py and re-ran the Shuttle-side groove-cutter path (element.groove=true, full ResinPrint) both before and after the stash - identical verts=458769 faces=918854 volume=4309.645mm3 (all validity flags True) both times, confirming the real cutter call site (bare GrooveShape(), all new params at their reproduce-original defaults) is byte-for-byte unaffected. Master groove=false (fused Shuttle with Rib) and Blickensderfer (ResinPrint volume=5666.804mm3, matching this file’s own documented baseline) both still build clean. Headless TuneApp test against a scratch config copy confirms: #tab-rib pane exists with exactly field-rib_interface_offset_mm/field-rib_nub_ growth_mm; those two are no longer under #tab-element; a save round-trip writes both keys correctly; Element tab’s baseline/cutout rows render “Baseline row 3 (math)”/”Cutout row 3 (math)”.

57. Hammond: RibOnly()’s new tang (part 56) sized to match the slot’s own oversized cut, not the rib’s real thickness

Follow-up to part 56, same session. Part 56’s tang reused GrooveShape()’s tab exactly as the real cutter builds it, including its Z padding (Shuttle_Rib_Thickness+Groove_Opening_Offset, split evenly above/below the disk’s own span) - Groove_Opening_Offset’s whole purpose is letting the CUTTER open the Shuttle’s slot slightly taller than the rib material, so an assembled rib has real clearance. Passing that same oversized dimension through to the tang as positive material undid that

  • both sides used the same (Shuttle_Rib_Thickness+Groove_Opening_Offset) dimension, so the tang fit the slot snugly with no Z play at all.

User: “let the tang on rib only be the thickness of the rib too. so there will be clearance in the slot.” Added tab_z_pad to GrooveShape() (default None -> resolves to Groove_Opening_Offset, reproducing the original cutter shape exactly at its one real call site). RibOnly() now passes tab_z_pad=0.0, so its tang’s height is exactly Shuttle_Rib_Thickness - same Z-span as the disk itself, no pad - while the Shuttle-side slot is still cut with the real pad (unaffected, uses GrooveShape()’s own default). The mismatch between the two is now the intended clearance.

Verified: RibOnly() volume dropped 148.983mm3 -> 142.362mm3 (thinner tang, expected direction/magnitude - Groove_Opening_Offset is 0.5mm over the tang’s small footprint). f3d screenshot shows the tang now flush with the flange’s own thickness instead of a slightly taller pad. Shell-cutter path re-verified byte-for-byte unaffected the same way as part 56 (stash lib/hammond.py, rebuild element.groove= true’s full ResinPrint before/after): verts=458769 faces=918854 volume=4309.645mm3 identical both times.

58. Hammond: added “Flat Bottom” checkbox to the Rib tab - RibOnly() can now omit the top pin-support boss

PinSupport()’s boss is built as two mirrored halves (“top”/”bottom”, one on each Z side of the rib plane) unioned together (v2:513-530, faithfully ported). For RibOnly() printed as its own standalone FDM part, having protruding material on BOTH Z sides means the part can’t sit flat on the buildplate without support material under one side’s overhang.

Added flat_bottom parameter to PinSupport() (default False, reproduces the original two-sided boss exactly) - when True, skips building “top” entirely and returns just “bottom” (after the same Anvil_ID clearance-ring difference() as before). Threaded through RibAssembled(flat_bottom=False) (also defaults False, so the fused Shuttle with Rib assembly’s two other call sites - Additive()/ CalibrationAdditive(), both sp.union_all([shell, RibAssembled()]) - are unaffected since neither passes it). RibOnly() now passes flat_bottom=Rib_Flat_Bottom, a new element.rib_flat_bottom config bool (default false), exposed as a checkbox in the Rib tab (part 56) per explicit request: “add a checkbox, Flat Bottom. when checked, only render bottom pin support, not the top one, so it can be printed flat on a buildplate.”

Verified: rib_flat_bottom=false reproduces part 57’s exact RibOnly() output (verts=3275 faces=6550 volume=142.362mm3, byte-for-byte). rib_flat_bottom=true gives a smaller, still-valid mesh (verts=2874 faces=5748 volume=123.825mm3 watertight=True is_volume=True) - f3d screenshots confirm one side is now flat (no boss protrusion, just the pin-support hole visible) and the other retains the single bottom boss. Fused Shuttle with Rib build (--no- resin-support, rib_flat_bottom=true in the test config) still gives FullElement volume=1960.523mm3, matching its own pre-existing baseline exactly, confirming zero effect on that path. Master-default full ResinPrint (element.groove=false) also re-verified unchanged (verts=474495 faces=950638 volume=4802.476mm3). Headless TuneApp test against a scratch config: the Rib tab’s #field-rib_flat_bottom Switch exists, toggling it and saving writes rib_flat_bottom: true correctly.

59. Hammond: RibOnly()’s nub-clearance cutouts were getting re-filled by the union with RibAssembled()

User, from a live f3d view of a real build: “the nub cutouts for rib only i think you have to subtract them last cus theres something else thats protruding into it” (screenshot: a shallow dent along the flange’s outer edge instead of a clean cutout).

Root cause: GrooveShape() subtracts its 4 nub-clearance cylinders INTERNALLY, before returning (real cutter behavior, correct there). RibOnly() built union_all([RibAssembled(), GrooveShape(...)]) - i.e. unioned an ALREADY nub-cut flange onto RibAssembled(). But Rib()’s own solid band occupies the same radial footprint the flange’s ring does (both span roughly Shuttle_Arc_Radius- Shuttle_Rib_Width to Shuttle_Arc_Radius+Shuttle_Groove_Depth) - so wherever they overlap, union() just restores whatever material the already-cut flange was missing, since Rib()’s own material there was never cut. The cutout came out as a shallow dent (only the thin sliver of flange NOT covered by Rib()’s own band actually stayed empty), not a real hole through the assembled part.

Fixed by reordering: extracted the nub-cylinder construction into a new _groove_nubs(nub_grow=0.0) helper (returns the unioned 4-cylinder cutter in GrooveShape()’s own local/untranslated frame). GrooveShape() gained include_nubs=True (default reproduces the original internal cut exactly - the real Shuttle-side cutter call site is unaffected). RibOnly() now passes include_nubs=False, builds the FULL union of RibAssembled()+flange first, then subtracts _groove_nubs(Rib_Nub_ Growth) (translated by +BASELINE_Z_OFFSET to match the combined solid’s real placed frame) from that combined result as its own final step - nothing downstream can re-fill the cutouts afterward, since nothing gets unioned after this subtraction.

Verified: RibOnly() (master defaults) volume 142.362mm3 -> 142.270mm3 (small decrease, expected - the cutouts now actually remove material from Rib()’s band where they previously didn’t). f3d close-up on the outer edge shows a real cutout at the nub location instead of a shallow dent. flat_bottom=true combined with the fix still builds a clean, valid, watertight mesh (123.732mm3, down slightly from part 58’s 123.825mm3, same direction/cause). Full hard gate: shell-cutter path (element.groove=true, stashed lib/hammond.py before/after) identical verts=458769 faces=918854 volume=4309.645mm3 both times; master-default full ResinPrint (verts=474495 faces=950638 volume=4802.476mm3) unchanged.

60. Hammond Split Shuttle port - a from-scratch machine, not a Hammond follow-on

Ported hammond_split.scad (771 lines, “Split Hammond 1 Shuttle” / “HammondSplitShuttle2.scad” pre-migration) per the roadmap’s next item (part 29’s “Resuming later” #2). Confirmed via re-reading the real v2 source directly that part 29’s audit was right: this file shares essentially nothing with hammond.scad beyond the machine name - zero include statements, its own inlined TextAssemble/TextPlacement/ LetterText/TextRing(side) glyph pipeline (NOT wired to the shared glyph_pipeline.scad, and NOT reusing cylinder_machine.place_on_ cylinder/TextRing the way lib/hammond.py does), a completely different two-piece Left/Right spoke/folder assembly (Arc/Spoke*/ Rib/Tube/FolderClearance/FolderCutaway/GlueHoles/GlueGroove), and a THIRD independent resin-support scheme (a tiered Arc/Folder/Ring grid plus a diagonal cross-bracing “fence” lattice - neither cylinder_ machine’s placement layer nor Hammond’s own VertResinSupport2). New config/hammond_split.yaml + lib/hammond_split.py (~900 lines) cover this file only - real machine numbers ported line-by-line from v2, with comments citing v2 line numbers throughout, matching config/hammond. yaml/bennett.yaml’s precedent.

What genuinely reuses shared code, and what’s new: resin_support. resin_rod/connecting_rod do NOT apply here (their real math is Hammond-specific, per resin_support.rod_tip()’s own docstring citing v2/hammond.scad line numbers directly) - hammond_split’s ResinRod/ ResinTip/ResinRodClean are their own faithful port, but connecting_ rod() (the hull-of-two-spheres capsule primitive) IS reused for the new fence lattice, since the underlying shape is identical even though the placement math differs completely. The struck-glyph pipeline reuses glyph_poc.build_glyph() directly for the real default (Mink_On=false): platen_radius_mm=0 (no curved platen - same “Skip_Platen_Cutout” trick lib/hammond.py established) + minkowski_enabled=False + align_ kwargs reproduces v2’s linear_extrude(Glyph_Height+1) Text(...) exactly (mirror + halign=center + valign=baseline). Mink_On=true could NOT reuse build_glyph()/build_flat_text_drafted() though - both tie the draft cone’s height to the extrusion depth, but this machine’s real Mink_Height (2mm) is an INDEPENDENT fixed value, unrelated to Glyph_ Height (0.8mm) - confirmed by working through v2’s actual minkowski+ difference(cube) construction by hand (see _letter_text_drafted()’s own docstring for the full derivation, including why the realized taper ends up a mild shrinking-to-zero flare rather than a normal “wide root” draft given the real v2 numbers). Needed its own small Minkowski helper built directly from two newly-promoted shared primitives, scad_primitives. to_manifold()/from_manifold() (previously private to glyph_poc.py, now a third call site - promoted per this repo’s “extract shared derivations” rule, with glyph_poc._to_manifold/_from_manifold kept as thin pass-throughs so nothing else had to change). Logo() (the engraved Left/Right name/year label) reuses cylinder_machine.build_text_string() directly, same as Hammond’s own Label().

Real bug found and fixed in shared code during this port: a new scad_primitives.mirror() primitive (needed for the Left/Right mirrored- body pair - the one genuinely new OpenSCAD-primitive-equivalent this port required) initially called .invert() after apply_transform() to “fix” face winding for the reflection’s negative determinant - this DOUBLE-flipped it, since trimesh.Trimesh.apply_transform() already auto-corrects winding for a negative-determinant transform internally. Caught immediately via a real, not synthetic, failure: side=1’s real TextRing() intersection raised ValueError: Not all meshes are not volumes!, traced to the mirrored Arc() having is_volume=False despite watertight=True/winding_consistent=True (negative volume). Fixed by removing the manual .invert() call - confirmed with a plain trimesh.creation.box() mirror test showing apply_transform alone already gives the correct positive volume.

tune.py wiring: MACHINES entry, SECTIONS_BY_MACHINE["hammond_ split"] (Font & Alignment/Calibration/Label/Quality/Resin/Element tabs - no Gauge/Logo key, same reasons as Hammond), and a new is_hammond_split branch in _compose_build_tab/_collect_values/_refresh_widgets_from_ cfg for Render Left/Render Right (the one genuine “which variant to build” Build-tab toggle this machine has - real config-driven fields, build.render_left/render_right, no CLI flag needed since _run_build persists the config before invoking generate.py as a subprocess, same mechanism every other config field already uses). Initially also added a “Minkowski draft” on/off Switch to the Build tab before discovering _run_build ALREADY forces --minkowski/--no-minkowski unconditionally based on which button was pressed (Quick Preview vs. Render), for every machine, precisely so this is never a per-config toggle - removed that switch and moved mink_draft_angle_deg/mink_height onto the Font & Alignment tab as plain tunable fields instead (matching every other machine’s draft_angle_deg convention: the ANGLE is tunable, whether the draft runs at all is not). Also found and fixed two real config bugs surfaced by a real headless TuneApp(...) smoke test against a scratch copy (per CLAUDE.md’s standing warning): char_mod.font_path/size_mm collided with font.path/size_mm’s literal key text (tune.py’s patch_yaml_value() matches by literal YAML key text anywhere in the file, not scoped by section - would have silently patched the wrong field) - renamed to char_mod_font_path/char_mod_size_mm; and build.target was missing entirely (every other machine’s config has it as pure UI-state, not consumed by the lib itself). Also added the full layout.baseline_row/cutout_row/placement_map/latitude_ columns/modify_glyphs key set tune.py’s shared Layout tab reads unconditionally for every machine, even though lib/hammond_split.py only actually needs baseline_row (stored as the final absolute per-row value directly, replacing the baseline_gaps/baseline_offset decomposition an earlier version of this config used) - same “dead but required for the TUI” treatment part 29 already established for Hammond’s own Cutout_Row.

Calibration: v2 has no calibration render mode for this machine at all (unlike hammond.scad, which reuses the shared lib’s real one) - tune.py’s Build target dropdown and Calibration tab are unconditional for every machine though, so a real crash-prone gap would have existed without one. Added a v4-only CalibrationElement()/CalibrationAdditive() that strikes calibration.test_char at every real placement slot, vary_baseline shifting each of the 3 baseline ROWS (not per-column - this machine has no per-column baseline_row/cutout_row sweep structure) by start+row*interval; vary_cutout is accepted-but-ignored, same reason cutout_row is a documented no-op (no curved-platen/cutout concept anywhere in this machine’s real geometry).

Verification: full CLAUDE.md hard-gate command run for all 6 existing machines before/after the scad_primitives.py/glyph_poc.py shared-code changes - blickensderfer’s verts=42618 faces=85408 volume=5666.804mm3 matches the exact number cited in CLAUDE.md itself; postal/mignon/ bennett/helios/hammond all still watertight/valid volumes, no crashes. Hammond Split’s own builds, all watertight/is_volume=True: FullElement (no resin) verts=35911 volume=8042.298mm3; ResinPrint (master defaults, real quality settings) verts=222941 volume=13445.430mm3; Mink_On=true path verts=44376 volume=8191.777mm3; --calibrate --calibration-vary-baseline verts=36165 volume=8060.662mm3. Full tune.py round-trip also verified headlessly against a scratch config copy (never the real master/running files, per CLAUDE.md’s standing warning): machine picker, all 6 tabs compose without error (70 FIELDS total), _collect_values/_save_to_yaml/_refresh_widgets_from_cfg round-trip cleanly, and a real end-to-end Quick Preview AND full Render (resin supports + Minkowski draft both on, via the actual _run_build worker and generate.py subprocess) both completed successfully - Render: watertight=True volume=13594.85mm3, verts=230464.

Deferred / open questions:

  • Groove=false-equivalent alternate assembly - v2/hammond_split.scad has no such variant at all (unlike Hammond’s real element.groove) - nothing deferred here, just noting the two machines’ assembly-variant surface area genuinely differs, not an oversight.
  • Named layout presets - only Ideal_Element (Layout_Selection=0, the real default) is wired; Qwerty_Element is a real, complete v2 preset not yet exposed in tune.py’s picker (LAYOUT_PRESETS_BY_ MACHINE has no "hammond_split" entry yet - falls back to {}, same as Hammond’s own still-unwired 7 presets).
  • TypeTest() - v2’s own inlined flat-layout preview (a single flat 30-char/row array, unlike TextAssemble’s split halves) was not ported as its own function - tune.py’s generic Type Test tab (config’s type_test.cpi/lpi/text) already covers the same role machine-independently, same pre-existing gap noted for Mignon/Helios in the “Resuming later” list below.

61. Hammond Split follow-up: progress bar stuck at 0%, f3d showing “[EMPTY]”

Two real bugs reported after actually using the part 60 port in the live TUI: the Build tab’s progress bar never moved during a build (stayed at 0% until jumping straight to 100%), and f3d’s preview window showed "[EMPTY]" after a Preview/Render.

Progress bar: traced to tune.py’s _update_progress/_PROGRESS_RE parsing a literal "[n/total]" pattern out of the subprocess’s stdout - cylinder_machine.TextRing/CalibrationTextRing print this on every character (flush=True), which Blickensderfer/Postal/Mignon/Bennett/ Helios/Hammond all get for free by genuinely calling those shared functions (confirmed via grep - all six really do call cylinder_ machine.TextRing). Hammond Split’s own TextAssemble()/ CalibrationTextRing() build their own from-scratch character loop (see part 60 - this machine doesn’t reuse the shared glyph-placement layer at all) and never got matching instrumentation added. Fixed by adding the same "[n/total] building {char!r} (row {baseline})..." prints (flush=True) to both functions, plus cylinder_machine.TextRing’s other habit worth copying: catch a per-character exception, print " SKIPPED (...)", and continue rather than aborting the whole build over one bad glyph (a real robustness gap the original port didn’t have at all). Verified: generate.py output now shows live [1/45] .. [45/ 45] progress per side. Documented as a standing CLAUDE.md TUI rule so a FUTURE from-scratch glyph loop doesn’t reproduce the same gap.

f3d "[EMPTY]": investigated three candidate causes before finding the real one. (1) Confirmed the STL itself was never actually empty or corrupt - ran f3d <file> --no-render --verbose=debug directly (headless, no GUI needed) against a freshly-generated Hammond Split STL and got a clean 77876 polygons load, not empty. (2) Found and fixed a REAL, if narrow, footgun while investigating: scad_primitives.union_all([]) silently returns a 0-vertex Trimesh rather than raising - with the new part-60 Render Left/Render Right Build-tab switches both off, _build()/ CalibrationElement() would have exported a genuinely empty, valid- looking STL with zero error or warning anywhere in the log. Added an explicit ValueError guard at the top of both functions (“Render Left and Render Right are both off - nothing to build”) - not confirmed as THE cause (the real-session running config had both switches on), but a real bug regardless, worth fixing on its own. (3) The actual explanation: generate.py’s full.export(out_path) (plain trimesh export, not atomic - opens/truncates the destination file directly, then writes) races against f3d’s OWN independent filesystem watcher under --watch, which is not told anything by tune.py and can fire on that truncate/ open event before the write completes - loading a 0-byte or partial file at that instant. This isn’t Hammond-Split-specific (every machine writes its STL the same non-atomic way), just more likely to be noticed here since this was the first machine exercised heavily right after a fresh port. Fixed with a new generate.py _atomic_export() (write to a temp file in the same directory, os.replace() into place - same-filesystem rename is atomic on POSIX, so the destination path only ever shows a fully-complete file) - all four of generate.py’s .export() call sites now go through it. Documented as a standing CLAUDE.md rule: every future output-writing call site must use _atomic_export(), never a bare .export().

Verification: full CLAUDE.md hard-gate command re-run for all 7 machines (the 6 existing plus Hammond Split) after the generate.py/ lib/hammond_split.py changes - every one byte-for-byte identical to part 60’s own numbers (confirms the atomic-export/progress-print/empty- guard changes are purely additive, no behavior change to the actual built geometry).

62. Console logging standardized into lib/build_log.py; f3d “[EMPTY]”’s REAL cause found

Follow-up to part 61, prompted by the user directly diagnosing the actual "[EMPTY]" mechanism themselves (“its because the running stl file didnt exist at first… if it DNE and its created, open it”) - part 61’s _atomic_export() fix was real and worth keeping, but wasn’t the actual cause of what was observed.

Real cause: f3d loads whatever file is “current” AT LAUNCH, then watches it for further changes (confirmed via f3d --help’s own text: --watch “Watch current file and automatically reload it whenever it is modified on disk”). tune.py’s _ensure_f3d_after_build only reuses an already-launched self._f3d_proc or launches fresh - it never checked whether the ALREADY-RUNNING instance it’s about to reuse was ever actually pointed at THIS out_path while that path had real content. If f3d is launched (by tune.py or by hand) against a path with no file on disk yet - the very first Preview for a brand-new machine/output path before anything’s ever built it, for instance - it shows an empty scene, and its filesystem watch has no inode to attach a watch to, so it never recovers even once the file is later created by a real, successful build - persistently "[EMPTY]" no matter how many builds follow. Confirmed empirically: f3d --watch <path> with <path> created only AFTER launch, followed by another build to the SAME path, still shows empty - the watch never picks up the new file after the fact.

Fix: TuneApp now tracks self._f3d_out_path (the path the currently-tracked f3d instance is actually watching). _ensure_f3d_ after_build forces a fresh kill+relaunch (via _kill_f3d(), already used for the machine-switch case) whenever the requested out_path differs from self._f3d_out_path - which includes “first successful build for this path this session” (self._f3d_out_path starts None). Since _ensure_f3d_after_build is only ever called after a build’s returncode == 0, this guarantees f3d is only ever pointed at a path AFTER a build has already confirmed it exists on disk. Verified headlessly (scratch config, no real master/running touched): fresh launch tracks the path and stays alive; a second call with the SAME path reuses the same process (same pid); a call with a DIFFERENT path force-relaunches (new pid) - all three cases behave correctly.

Console logging standardization (the user’s separate “should we have a console output lib” question, prompted by noticing part 61’s progress- bar fix was itself evidence of undisciplined duplication): new lib/ build_log.py - progress_start/progress_done/progress_skipped/ progress_line (the "[n/total] ..." shape tune.py’s _PROGRESS_RE parses) and mesh_report() (the one authoritative verts=.../ watertight=... summary line, always the full field set). Found real drift while extracting it: glyph_poc.py had its own report() that NOTHING in the actual build pipeline called (wrong format, multi-line, no flush=True) - confirmed via grep it had zero external call sites, just its own __main__ CLI block. Left report() in place (a genuinely different, more-verbose format suited to interactive single-glyph inspection, not the piped-subprocess pipeline) but added a docstring making that distinction explicit, so it doesn’t look like an abandoned duplicate of build_log.mesh_report() to a future reader. Wired build_ log into generate.py (all 4 of its verts=.../watertight=... print sites), cylinder_machine.TextRing (the canonical progress-printing loop every other machine’s TextRing() call already benefits from), and lib/hammond_split.py’s TextAssemble()/CalibrationTextRing() (day- one consistency for code that was, itself, only just written last session). Left Mignon/Bennett/Helios/Hammond’s own Additive()/ Subtractive() summary prints as a follow-up, not touched in this pass - they already match mesh_report()’s shape closely enough to not be urgent, and retrofitting four more files’ surrounding logic is real additional risk for a session already touching shared code in three other files.

Verification: full CLAUDE.md hard-gate command re-run for all 7 machines - every one byte-for-byte identical to part 60/61’s own numbers. build_log-routed TextRing output confirmed identical in format/ content to before the refactor (spot-checked Blickensderfer).

63. Console logging sweep completed - every remaining hand-written summary print migrated

Direct follow-up to part 62’s “left as a follow-up, not touched in this pass” - the user asked explicitly to carry every other element to the same convention, so this pass finished the sweep rather than leaving it open. Found via grep -rn 'watertight=' across the whole repo (not just the two files touched in part 62):

  • lib/mignon.py, lib/bennett.py, lib/helios.py, lib/hammond.py’s own Additive()/Subtractive()/CalibrationAdditive()/ ResinSupport() prints (10 sites total) - all previously showed only watertight=, a hand-abbreviated subset of mesh_report()’s full field set. Migrated via a small regex script (matched the exact print(f"LABEL: verts=... faces=... "\n f"watertight=...", flush=True) shape across all 4 files in one pass) rather than hand-editing each - caught and fixed one real bug the regex introduced: lib/hammond.py’s one dynamic-label call site (print(f"{label}: verts=..."), label a real variable built from HorizWallRodSupport/HorizGroovedResin3+ HorizRibResinSupport combinations) got literal-quoted as "{label}" by the naive text substitution - caught by grepping for mesh_report(.* "\{ after the fact, fixed to pass label unquoted.
  • lib/cylinder_machine.py ITSELF had 5 more of the same shape (its own Additive/CalibrationAdditive/Subtractive/ResinSupport summary lines, separate from the TextRing progress loop part 62 already converted) - missed in part 62 because that pass only grepped the machine-module files, not the shared module they all build on top of.
  • type_test.py - genuinely the same class of bug part 61 found in generate.py: subprocess-invoked by tune.py’s “Render Test Text” action, writing to the SAME output.stl_name path _ensure_f3d_after_ build later watches, via a bare non-atomic mesh.export(), with a summary print that had neither the full field set NOR flush=True. Since there were now two real callers (generate.py and type_test. py), atomic_export() (part 62’s generate.py-local _atomic_ export()) was promoted into lib/build_log.py itself - generate.py now calls build_log.atomic_export() too, removing its own local copy entirely rather than keeping two implementations in sync by hand.
  • export_glyphs.py (a standalone “not part of generate.py’s normal flow” diagnostic - exports every character as its own STL for visual inspection) - not subprocess-driven by tune.py so the f3d race doesn’t apply the same way, but had the identical drift (partial field set, no flush=True) and is exactly the kind of script a future session might copy the OLD pattern from if left alone. Folded its [n/total]+filename+flag reporting into one mesh_report() call (label = f"[{done}/{total}] {fname}{flag}") rather than leaving a parallel format.
  • glyph_poc.report() deliberately left AS IS (not migrated) - confirmed again it has zero call sites outside its own standalone __main__ CLI block, and serves a genuinely different purpose (verbose interactive single-glyph inspection, bbox included) - already documented as such in part 62, not re-litigated here.

Verification: full CLAUDE.md hard-gate re-run for all 7 machines, byte-for-byte identical to part 62’s numbers. Also spot-checked type_ test.py and export_glyphs.py directly (both produce valid watertight output with the new mesh_report()/atomic_export() formatting). CLAUDE.md’s build-pipeline logging rule updated to name the full list of migrated files, so “is this migrated yet” has one answer instead of requiring a fresh grep every time.

64. Hammond Split: wired up the real v1/v2 “Normal” (flat, no-resin) build target - Assemble() was already correct, just unexposed

The user asked whether Hammond Split has “different resin print orientations.” First answer was wrong: assumed v2 only has one real render target (the vertical ResinPrint) and started designing a from-scratch “horizontal” mode as a v4 invention. The user pushed back directly (“i think youre wrong, what are the genstyles”) - the right call, and exactly the instinct CLAUDE.md’s “always diff against the real v2 source” rule is meant to enforce.

Checked v1/Hammond/HammondSplitShuttle.scad (the pre-migration original, distinct from HammondSplitShuttle2.scad which became v2/hammond_split.scad) closely and found a real, literal customizer comment: GenStyle = 1; //[0:Normal, 1:ResinPrint, 2:ResinPrintL, 3:ResinPrintR, 4:NormalL, 5:NormalR]. v2/hammond_split.scad:60 still carries the same split, just renamed: Render_Mode=1;//[0:Normal, 1:ResinPrint, 2:Type Test]. So there really are two complete, real render targets in the source, not one:

  • ResinPrint (v1 GenStyle 1-3, v2 Render_Mode==1) - the vertical, tipped-up, resin-supported layout. The only one wired up in part 60’s original port session (ResinPrint()/FullElement(), both funneling through _build(), which always applies ResPrintOrient()).
  • Normal (v1 GenStyle 0/4/5, v2 Render_Mode==0) - a flat, un-rotated, UNCONDITIONALLY resin-free layout (neither LeftShuttleAssembled/ RightShuttleAssembled (v1) nor Assemble() (v1 or v2) ever call a resin-rod module - confirmed by inspection). lib/hammond_split.py already had a faithful, working port of this as Assemble() - built correctly during the original port session, but never wired into generate.py/tune.py as a selectable target, AND its own docstring made a false claim: “the two halves overlap… NOT itself a printable layout.” Empirically disproven this session:
    left volume  = 4003.87...   right volume = 4038.53...   sum = 8042.4058...
    Assemble(True, True) union volume = 8042.4058...   (diff: -2.22e-14, float noise)
    watertight=True, is_volume=True
    

    The two halves’ bounding boxes overlap in Y (both _mirror_side()’s pure Y-reflection, no translation, leaves them sharing the same X/Z frame), but the actual solids are disjoint - the union volume exactly equals the sum of the parts. Assemble(render_left=True, render_right=False) alone is also confirmed valid/watertight (matches v1’s NormalL/GenStyle 4).

Fix, following a Plan-mode design review (a Plan agent validated the approach against the actual current code before implementation, catching nothing that needed changing - the design held up):

  • lib/hammond_split.py: fixed Assemble()’s docstring (cites v2:544- 549/v2:60 and v1/HammondSplitShuttle.scad:16,720-733’s real GenStyle values, states the disjointness fact instead of the false overlap claim), added the same “both Render Left/Right off” ValueError guard _build()/ CalibrationElement() already have. New NormalElement(...) entry point (same accept-but-ignore kwarg signature as FullElement/ResinPrint, same points_per_mm/Minkowski override-and-restore pattern as _build(), calls bare Assemble() - the guard now fires for free). Minor doc fix to CalibrationElement()’s stale cross-reference (used to claim “no equivalent flat mode exists” - no longer true).
  • generate.py: new --hammond-split-normal flag (machine-namespaced like the existing --hammond-part, since this concept has no analog on any other machine), new early-return dispatch block modeled on --hammond- part’s skeleton but passing the full quality-kwarg battery (like --calibrate’s block, since NormalElement() goes through the real glyph/Minkowski pipeline). Being an early return before the generic resin_support = ... fork means --resin-support/--no-resin-support are simply never consulted - confirmed identical output with either flag.
  • tune.py: is_hammond_split gets a third dropdown option (“Normal (flat, no resin)”, value "normal") alongside Element/Calibration Element - stays a plain 3-option Select, not Hammond’s own elaborate consolidated-dropdown pattern (no body-assembly-variant axis to fold in here). Help text explains Normal is always resin-free regardless of the checkbox - deliberately NOT via RESIN_SUPPORT_UNAVAILABLE_NOTE (that dict’s contract is “this whole machine never supports resin,” which is false for hammond_split’s Element/Resin target; a per-machine note there would mislead while Element is selected) - inline help text only, no new widget-disabling logic. _refresh_widgets_from_cfg’s own valid_targets fallback list needed the same "normal" addition, or the dropdown selection would silently revert to “Element” on every reload/reset - the one part of this that would have been easy to miss. New elif values["target"] == "normal": branch in _run_build adds --hammond- split-normal and forces Minkowski the same way the calibration branch does. The existing Render Left/Right switches (added in part 60) needed NO changes - Assemble()/NormalElement() already consume the same build.render_left/render_right globals those switches already write.
  • config/hammond_split.yaml: header comment and build.target comment extended to document both real targets and why they coexist.

Verification: direct CLI matrix (both sides, left-only, right-only via scratch config copies, both-off correctly raises the ValueError instead of silently writing an empty STL, --resin-support/--no-resin-support confirmed true no-ops, cross-machine hasattr guard fires cleanly against blickensderfer.yaml). Headless tune.py test against a scratch config copy (never the real master/running files, per CLAUDE.md’s standing warning): dropdown offers the new option, save/reload round-trips build.target: normal correctly (including through _refresh_widgets_from_cfg’s fix), and a real end-to-end Quick Preview via _run_build produced a valid watertight STL. Full 7-machine hard gate plus hammond_split’s own existing FullElement/ResinPrint paths run directly - every number byte-for-byte identical to part 63’s baseline, confirming this was purely additive.

65. IBM/Selectric port: Selectric I/II, Selectric III, Selectric Composer (all three, split per user request)

User requested v2/ibm.scad be split into 3 separate v4 machines (Selectric I/II, Selectric Composer, Selectric III) rather than one ibm.py with a mode switch, plus a shared lib for the ~80% of the file that’s byte-identical across the three v2 Render_Mode branches. Done in a worktree (.claude/worktrees/selectric-port, branch worktree-selectric-port), merged to main as part of part 67 below.

Audit pass: diffed v2/ibm.scad (1045 lines) + v2/lib/layouts/ ibm_layouts.scad (243 lines) function-by-function before writing anything, per CLAUDE.md’s port process rule (see conversation - this wasn’t skipped). Confirmed IBM/Selectric shares nothing structurally with lib/cylinder_machine.py (a different form factor, per CLAUDE.md’s taxonomy) - new shared module lib/spherical_machine.py was written from scratch, not derived from the cylinder family.

Architecture: lib/spherical_machine.py holds FullBody/ SolidCleanup/Teeth/Tooth/Notch/the character-glyph pipeline (SingleMinkowskiChar/AssembleMinkowski)/labels (Del/FontName/ Labels)/resin supports (ResinRodAssemble)/TextGauge - all byte-identical across the 3 v2 modes, just parametrized instead of Render_Mode-array-indexed. Three thin machine modules (lib/ selectric12.py, lib/selectric_composer.py, lib/selectric3.py) each carry their own configure() + character/hemisphere layout data (lib/ layouts/selectric12_layout.py / selectric_composer_layout.py / selectric3_layout.py) + one config YAML each (config/selectric12.yaml / selectric_composer.yaml / selectric3.yaml), following the standard configure(config_path)/_require_configured()/FullElement/ ResinPrint/Additive entry-point contract. Dynamic dispatch ( spherical_machine._receive_config) mirrors cylinder_machine.py’s own mechanism exactly.

Character-embedding pipeline is a genuine reimplementation, not a build_glyph() reuse: build_glyph()’s cylinder-family platen-cutout model doesn’t directly generalize to the sphere - v2’s real PlatenCutout() is built via its own independent rotate/translate stack sharing longitude/latitude with the character, not expressed relative to the character’s own local frame the way build_glyph() assumes (worked this out by hand-deriving both transform stacks before writing code, see conversation). SingleMinkowskiChar() is a direct, literal port of v2’s Text()/PositionText()/PlatenCutout()/SingleMinkowski(), reusing only glyph_poc.py’s low-level 2D contour-extraction helpers (get_glyph_contours_and_advance/classify_and_triangulate/ alignment_x_offset), not its 3D placement/draft logic. Also notably different from build_glyph()’s order of operations: v2 POSITIONS the character in world coordinates FIRST, then Minkowski-sums with a draft cone built at the origin (rotated only, never translated) - verified by hand that this is still a correct local dilation (a Minkowski operand’s absolute position doesn’t matter, only its own point-set-as-offset- vectors), not “fixed” to match build_glyph()’s draft-then-place order.

Hemisphere layout data: v2’s keyboard-layout strings are OpenSCAD multi-line string literals indexed character-by-character including embedded newlines - not reproducible from the .scad source alone without a real OpenSCAD interpreter to test the exact indexing semantics against. Sidestepped: v2’s own S12_HEMISPHERE_MAP/S3_HEMISPHERE_MAP/ COMPOSER_HEMISPHERE_MAP are already precomputed, hardcoded permutation tables (the source comments say so explicitly) - copied verbatim into each lib/layouts/*.py, paired with the keyboard-order strings stripped of whitespace to a clean flat sequence, rather than re-deriving the permutation from scratch.

Resin supports: per the user’s own mid-session suggestion, reused lib/resin_support.py’s shared resin_rod()/connecting_rod() for every rod/strut in ResinRodAssemble(), rather than porting v2’s ResinRod/ResinTip as bespoke angle-aware geometry. Turned out v2’s own a1 tilt-angle parameter on those two modules is dead code in the current file - every real call site in ResinRodAssemble() passes a1=0 (the angle-tilted variants are commented out) - so no angle-aware system was actually needed; this is a real simplification (accepted, since resin supports are removed after printing, not part of the measured physical part), not an oversight - v2’s Rod_Base_C/exact raft-chamfer proportions have no equivalent slot in the shared resin_rod() and are dropped, called out in each config’s resin: comment rather than silently diverging.

Verified: all 3 machines via generate.py with the REAL Minkowski draft (--cone-segments 12) and resin supports on (the actual hard-gate condition, not just an undrafted preview):

selectric12:         ResinPrint: verts=75003 faces=150214 watertight=True winding_consistent=True is_volume=True volume=7521.792mm3
selectric3:           ResinPrint: verts=73387 faces=146982 watertight=True winding_consistent=True is_volume=True volume=7548.874mm3
selectric_composer:   ResinPrint: verts=73923 faces=148062 watertight=True winding_consistent=True is_volume=True volume=7529.368mm3

All three volumes cluster tightly (~7.52-7.55cm3) - expected, since all 3 modes share the same physical ball, and a useful independent cross-check that nothing was silently broken per-machine. selectric_composer’s build took considerably longer (CPU-bound, ~15-20min vs ~5min for the other two) - not root-caused (candidates: a particularly complex glyph outline among its punctuation-heavy character set feeding a larger face count into manifold3d’s minkowski_sum, whose cost scales with the product of face counts; or CPU contention with another concurrent build on the same machine) - didn’t block since it completed successfully, but worth investigating if it recurs. Confirmed via git status that only new files were added - no existing shared module (cylinder_machine.py/scad_primitives.py/ glyph_poc.py/resin_support.py) was touched, so no regression risk to any other machine and no need to re-run the cross-machine hard-gate comparison.

Not yet done: Calibration entry points (CalibrationElement/ CalibrationAdditive not implemented - tune.py’s Calibration tab and Build-target option are correctly gated off rather than exposing a button that would crash, see below); drain holes (Drain, defaults off in v2, so no default-behavior gap); Composer’s own pica/units type-test system (TextGaugeComposerLine2, genuinely different from Selectric I/II & III’s shared CPI TextGauge() - tune.py’s generic Type Test tab still works for all 3, same “generic flat CPI/LPI preview, not machine-specific” convention every other machine already uses); non-US language variants (Composer’s UK/Nordic/German/Latin, Selectric I/II’s Custom layout). Composer’s alignment offsets (x_pos_offset/ y_pos_offset/alignment.mode) were carried over verified line-by-line against v2 per explicit user directive (print-critical) - still worth an actual rendered visual comparison against v2’s own OpenSCAD output before calling that fully closed, not just a config-diff check.

66. tune.py wired up for all 3 Selectric machines

Follow-up to part 65 - user wanted to drive/inspect the port themselves via the TUI rather than through generated renders. Added all 3 machines (selectric12/selectric3/selectric_composer) to tune.py’s MACHINES dict plus new shared field tables (FONT_FIELDS_SELECTRIC12, FONT_FIELDS_SELECTRIC_COMPOSER - Composer sizes by cap-height, not direct point size, so gets its own Font & Alignment table - ELEMENT_FIELDS_SELECTRIC/LABEL_FIELDS_SELECTRIC/ QUALITY_FIELDS_SELECTRIC/RESIN_FIELDS_SELECTRIC, shared across all 3 since their config schema is byte-identical, unlike Bennett/Mignon/ Helios which each needed fully separate tables).

Real, previously-latent tune.py bugs found and fixed while doing this - not Selectric-specific hacks, genuine gaps the cylinder-family’s uniformity had been masking:

  • _load_machine() unconditionally computed len(self.cfg["layout"] ["baseline_row"]) - KeyErrors immediately for any machine with no editable keyboard-layout concept. Added self.HAS_LAYOUT_TAB = "rows" in self.cfg.get("layout", {}), now gating the Layout tab (compose()), _compose_baseline_cutout_fields(), and every layout-touching branch in _save_to_yaml/_refresh_widgets_from_cfg - same pattern as the existing "Gauge" in self.SECTIONS gate for machines with no Shaft Gauge Test.
  • compose()/_compose_build_tab() treated the “Calibration” tab and Build-target option as unconditional (every machine so far always merged in SECTIONS_COMMON, which includes it) - now gated on "Calibration" in self.SECTIONS, mirroring the Gauge pattern exactly.
  • action_render_type_test() hardcoded the cylinder-family’s Font & Alignment field key names (self.inputs["mode"]/"center_offset_mm"/ "modified_left_chars"/etc.) directly, and self.inputs["size_mm"] for font size - both assumptions break for a machine with a genuinely different alignment schema (Selectric’s custom_h_chars/x_pos_offset convention) or sizing convention (Composer’s cap-height). Rewrote to build the type_test.py command incrementally, checking key in self.inputs before adding each optional flag, and deriving font_size_mm from composer_cap_height/2.834 when size_mm isn’t present.

Config key collision caught before it caused silent data corruption: tune.py’s patch_yaml_value() matches by bare key TEXT across the WHOLE file (not scoped by section), same reasoning as LABEL_FIELDS_MIGNON’s label_-prefixing. font.path/font.size_mm and font2.path/font2.size_mm would have collided (both literally path:/size_mm:) - renamed font2:’s keys to font2_path/ font2_size_mm/font2_chars (font2_composer_cap_height for Composer) in all 3 configs and their configure() functions. Also renamed alignment.h_alignment to alignment.mode in all 3 configs to match tune.py’s existing generic center/left Select-widget convention (_compose_section_tab’s elif key == "mode": special case) - gets a proper dropdown for free instead of a plain text Input, and makes action_render_type_test’s self.inputs["mode"] lookup work unmodified for Selectric too.

Missing config keys found via testing, not inspection: build.target (referenced by _collect_values/_save_to_yaml but absent from all 3 YAMLs - patch_yaml_value only replaces existing keys, never inserts), type_test.lpi (assumed present by the generic Type Test tab), and type_test.text needed to be a YAML block scalar (text: |- + indented line) rather than a quoted string - patch_yaml_text_block’s regex requires the block form specifically.

Verified: headless TuneApp(...).run_test() against scratch copies only (never the real master/running configs, per the standing warning) - for all 3 machines: compose() builds cleanly, a full _collect_values/ _save_to_yaml/_refresh_widgets_from_cfg round-trip succeeds, and - critically - the REAL subprocess paths actually run to completion: _run_build(fast=True) (Quick Preview, calls generate.py for real) and action_render_type_test() (calls type_test.py for real, exercising Composer’s cap-height font-size fallback specifically) both populate _last_build_info, confirming a real returncode == 0, not just “no Python exception.” Machine-picker flow (_select_machine) uses the same _load_machine() path already covered by this testing, not separately exercised.

67. IBM/Selectric (parts 65-66) merged to main - build_log wiring, rebase conflict resolution, stale volume note explained

Follow-up to parts 65-66: worktree-selectric-port (PR #35) predated this session’s own CLAUDE.md updates (part 62-64’s lib/build_log.py extraction, plus the “Keep doing this” callout warning that a from- scratch glyph loop doesn’t inherit [n/total] progress instrumentation for free) - so the branch needed both a real rebase onto main (29 commits ahead, including the entire Hammond Split port and the build_log sweep) and a CLAUDE.md-compliance fix before merging, not just a fast-forward.

build_log wiring: spherical_machine.py’s AssembleMinkowski() had its own from-scratch per-character loop (doesn’t reuse cylinder_ machine.TextRing, see that module’s docstring) with only a single post-loop summary print - no [n/total] progress markers, exactly the gap CLAUDE.md now calls out by name. Rewired to match hammond_split. TextAssemble()’s template exactly: build_log.progress_start/done/ skipped per character, skip-on-exception instead of aborting the whole build, progress_summary at the end. Also migrated FullElement()’s and ResinPrint()’s hand-written verts=.../watertight=... prints (missing winding_consistent/is_volume/volume - the exact drift pattern parts 62-63 swept out of every other machine) to build_log.mesh_report().

Rebase conflicts: SESSION_LOG.md collided on part numbers (both branches had independently used 40/41) - resolved by renumbering the PR’s entries to 65/66 and this entry to 67, not by re-deriving content. tune.py conflicted in _compose_build_tab()/_refresh_widgets_from_cfg() because main had independently added is_hammond_split/hammond_split_ normal_target handling (part 64) in the same functions the PR’s own has_calibration gating fix (part 66’s “real, previously-latent tune.py bugs” writeup) touched. Merged by hand: kept is_hammond/is_hammond_ split branching structure from main, added has_calibration gating into the non-Hammond branch’s valid_targets/options list/help text exactly as part 66 intended. Verified: python3 -c "import ast; ast. parse(...)" on both files post-merge, then headless tune.py still not run against master/running configs per the standing warning - deferred to whoever next opens the TUI for this machine, same as parts 65-66’s own verification already covered the field-by-field composition logic this merge didn’t touch.

Stale volume note explained, not a rebase regression: post-rebase selectric3 builds at volume=8002.442mm3 (verts=72561 faces=145506), not part 65’s reported ~7548.874mm3. Diffed resin_support.py/ scad_primitives.py/glyph_poc.py/cylinder_machine.py between the PR’s merge-base and main’s tip - real changes exist in all four, but none touch anything spherical_machine.py actually calls (confirmed by reading each diff, not assumed). Ran the ORIGINAL, un-rebased PR commit (24e47c3) against the same config/selectric3.yaml in an isolated git worktree (/tmp/.../orig_pr_check) as a control: produced the IDENTICAL verts=72561 faces=145506 volume=8002.442mm3 - proving the rebase changed nothing geometrically. Conclusion: part 65’s own verification note was written before part 66’s later config-key renames/ additions in the SAME session (font2.path->font2_path, alignment. h_alignment->alignment.mode, plus the missing build.target/ type_test.lpi/type_test.text keys patch_yaml_value can’t insert) and was simply never re-run afterward - the CURRENT numbers are the true baseline for the final committed config state, not a regression. All 3 Selectric machines still cluster tightly (selectric12=7961.611mm3, selectric_composer=7969.495mm3, selectric3=8002.442mm3, ~0.5% spread)

  • same physical-ball cross-check part 65 already relied on, just at the corrected absolute number.

Regression check: blickensderfer (verts=42618 faces=85408 volume=5666.804mm3) and hammond_split (verts=220665 faces=445362 volume=13445.375mm3) both matched their pre-merge baselines exactly, run on this session’s un-rebased main tip for comparison - the merge has no side effects on any existing machine.

CLAUDE.md updated: the machine taxonomy’s “Ported so far”/”Remaining, per the roadmap” list (part of “Porting a new machine”) said IBM was “still unstarted” - corrected to list IBM/Selectric as done (3 machines, lib/spherical_machine.py) and note nothing remains on the roadmap.

68. Real em-to-mm DPI bug found and fixed across the WHOLE fleet - every machine’s glyphs were ~28% undersized; Selectric slowness diagnosed to minkowski_fn, not the hollow-body cut; two config-key outliers fixed

User asked whether v4’s font-size input actually reproduces OpenSCAD’s real text(size=) output - lib/glyph_poc.py’s docstring claimed scale = font_size_mm / units_per_EM because “OpenSCAD’s text(size=) scales its font so the em-square is Font_Size mm,” but this had never actually been checked against a real OpenSCAD render, only derived on paper.

Empirical test: installed real OpenSCAD (openscad-nightly, the snap at /snap/bin/openscad-nightly - no plain openscad on PATH, documented in a new CLAUDE.md “OpenSCAD binary” section). Rendered text("H", size=10, font="DejaVu Sans Mono:style=Book") for real, exported STL, measured its bounding box with trimesh: 6.5033x10.1248mm. Built the same “H” through glyph_poc.build_flat_text(font_size_mm=10) with the same font file: 4.6826x7.2900mm. Ratio on BOTH axes: 1.3888-1.3889, i.e. exactly 100/72. Confirmed by feeding v4 font_size_mm * 100/72 instead - reproduced OpenSCAD’s real dimensions to <0.01%. Root cause: OpenSCAD’s text(size=X) does NOT scale the em-square to X mm - it renders as if size were a point value at 100 DPI, so real output is (100/72) larger than a literal em-square reading.

Impact: every machine’s Font_Size/size_mm config value is transcribed straight from real v2 .scad source (per this file’s porting rule) and only produces the correct physical dimension when run through OpenSCAD’s actual convention - so every v4 machine’s glyphs were ~28% too small linearly versus the real v2 geometry, invisible to the hard-gate check because that only ever compared v4 against itself, never against a real OpenSCAD render.

Fix: added glyph_poc.em_to_mm_scale(font_size_mm, units_per_em) (the single named home for the 100/72 constant, OPENSCAD_TEXT_DPI_ FACTOR) and rewired all 8 call sites that used to compute fs / face.units_per_EM by hand - glyph_poc.py (3x: build_flat_text, build_flat_text_drafted, build_glyph), cylinder_machine.py (2x: build_text_string, LogoText’s label helper), spherical_machine.py (3x: the main glyph builder, the Composer number-label helper, and the typeface-name label helper - this last pair matters because their get_glyph_contours_and_advance advance-width calc and their build_flat_text mesh call must use the SAME scale or spacing drifts out of sync with glyph size). Also corrected glyph_poc.py’s module docstring, which stated the old, now-disproven derivation.

New hard-gate baselines recorded for all 10 machines (every one shifted up, as expected - both volume and verts/faces increased, since bigger glyphs at the same points_per_mm density sample more contour points over the now-longer arc length): blickensderfer 5666.804->5730.295mm3 (verts 42618->49720), postal ->5555.794mm3, mignon ->4676.755mm3, bennett ->3681.781mm3, helios ->4310.913mm3 (also surfaced 80 inter-character collisions in TextRing - detection-only, not a build failure, but a direct consequence of glyphs now being their real size against a layout tuned against the previously- undersized ones - not chased further this session), hammond ->4877.488mm3, hammond_split 13445.375->13495.980mm3, selectric12 7961.611->8054.455mm3, selectric3 8002.442->8103.048mm3, selectric_composer 7969.495->8070.024mm3.

Selectric build-time investigation (user noticed Selectric builds run long, guessed the hollow-body/radii cut): ran /usr/bin/time -v on a full, untruncated Selectric3 build. Total wall clock 210.9s, of which the per-character AssembleMinkowski loop alone was 207.3s (98%) - SubtractFromFull (the actual hollow-body boolean) and everything else combined took ~3.6s. Not the hollow-body cut. Per-character breakdown showed a handful of curvy glyphs dominating (%=23.80s, 6=17.91s, 5=17.49s, ?=16.39s, 9=15.17s, 0=14.95s, ¢=10.77s, )=10.31s, (=8.89s, /=7.32s, Z=6.52s, z=5.91s) against straight-stroke letters finishing in well under a second - consistent with manifold3d’s documented Minkowski cost scaling with the PRODUCT of both operands’ face counts: Selectric’s quality.minkowski_fn=20 (vs. 12 on bennett/helios/ hammond/mignon, 16 on blickensderfer) multiplies against contour-point counts that are now also ~28% higher post-fix. Also noted, as a separate open question: 1609s user / 918s system time at 1198% CPU with 38.8M involuntary context switches - real thread-contention overhead on top of the raw Minkowski cost, not chased down this session. User is independently checking the GUI (tune.py) build log to cross-check which stage is slowest there.

Two config-key outliers fixed while auditing Quality/Resin tab consistency across all 10 machines (prompted by the minkowski_fn investigation above): (1) Mignon’s resin_fn lived under quality: instead of resin: like every other machine (the exact outlier this file’s “Pick one convention” section already named) - moved to resin.resin_fn in config/mignon.yaml/.running.yaml, lib/mignon.py’s configure(), and tune.py’s field lists (out of QUALITY_FIELDS_MIGNON, into RESIN_FIELDS_MIGNON - literally moves the control from the Quality tab to the Resin tab). (2) Hammond Split’s Minkowski facet-count knob was spelled quality.mink_fn where every other machine spells it quality.minkowski_fn - renamed in config/hammond_split.yaml/.running.yaml, lib/hammond_split.py, and tune.py. Both verified as pure relocations: re-ran the hard-gate check for both machines post-rename, got byte-identical verts/faces/ volume to the just-recorded post-em-fix baselines above.

69. Selectric family gets a real Layout tab - 8 rows (4 lowercase + 4 uppercase), named presets, Modify glyphs

All 3 Selectric machines (selectric12, selectric3, selectric_composer) previously had tune.py’s HAS_LAYOUT_TAB hard off ("Selectric machines have no editable keyboard-layout concept at all") because their character content was 100% hardcoded Python constants (LOWERCASE88_US/UPPERCASE88_US etc. in lib/layouts/ selectric*_layout.py), never read from config. User asked for a real Layout tab, using v2’s existing default layouts as presets, porting any additional real presets that exist, plus custom editability - shaped as 8 rows (4 lowercase, 4 capitals/shifted).

What changed:

  • config/selectric12.yaml/selectric3.yaml/selectric_composer.yaml gained layout.rows (8 strings) and layout.modify_glyphs (bool) - the same shape as the cylinder family’s own layout.rows/ modify_glyphs, letting tune.py’s existing Layout tab machinery apply almost unchanged. Row content is byte-identical to what the removed Python constants held (verified via the hard-gate re-run below) - this is a pure relocation from code to config, matching CLAUDE.md’s “real machine numbers live in YAML” rule, not a new port.
  • lib/layouts/selectric12_layout.py/selectric3_layout.py/ selectric_composer_layout.py: removed the now-redundant hardcoded case-content strings; longitude_latitude() now takes the config- driven lowercase string as a parameter instead of closing over a module constant. The fixed hemisphere permutation tables (S12_HEMISPHERE_MAP/S3_HEMISPHERE_MAP/COMPOSER_HEMISPHERE_MAP) stay as Python constants, unexposed - same treatment as the cylinder family’s own placement_map (never a tune.py widget either).
  • lib/selectric12.py/selectric3.py/selectric_composer.py’s configure(): build CASES88_LOWER/CASES88_UPPER by concatenating layout["rows"][:4]/[4:], with an assert that each concatenates to exactly the hemisphere map’s real length (44 for S12/Composer, 48 for S3) - a custom edit that breaks this fails loud at build time instead of silently misplacing characters.
  • New port work (the “add more if you have them” part): Composer’s 4 additional real v2 language variants - UK, Nordic, German, Latin (v2/lib/layouts/ibm_layouts.scad:126-188, never ported to v4 before)
    • added as named presets in tune.py’s new LAYOUT_PRESETS_SELECTRIC_COMPOSER alongside United States, all verified to share the exact same per-row-length shape and to total 44 characters per case. Selectric I/II and III each get only their one real v2 layout (UNITED_STATES) as a preset - v2 has no second language variant for either.
  • A real Selectric-specific design problem, not just data-shuffling: Selectric’s character content is consumed by FLAT keyboard index (spherical_machine.AssembleMinkowski: case_str[kb_index]), not per-row placement like the cylinder family’s placement_map - so unlike Blickensderfer/Mignon/etc (where “shorter rows just leave some positions unstruck”), shortening one Selectric row would silently shift every LATER character in that same case onto the wrong physical ball position. tune.py gained a new HAS_FLAT_INDEXED_ROWS flag (true whenever layout.rows exists with no placement_map - i.e. the Selectric family) and a new _layout_row_caps() helper (per-row caps instead of the cylinder family’s one shared cap). _save_to_yaml’s Modify-glyphs path now validates every row’s length is UNCHANGED before writing when HAS_FLAT_INDEXED_ROWS - a violation logs a red error and skips just that field (every other pending change still saves), rather than writing data that would corrupt the layout.
  • HAS_LAYOUT_TAB decoupled from a new HAS_BASELINE_CUTOUT flag: the old code used one flag to gate BOTH the Layout tab and the Element tab’s baseline_row/cutout_row widgets, on the assumption that having one implies having the other. Selectric breaks that assumption
    • it now has a real Layout tab but has no layout.baseline_row/ cutout_row concept at all (its own per-row calibration arrays - row_latitudes/platen_longitude_offsets/baseline_longitude_ offsets/minkowski_longitudinal_offsets - are a different shape, 4 fixed physical ball rows, and aren’t wired into tune.py yet, unchanged by this session). HAS_BASELINE_CUTOUT ("baseline_row" in layout) now gates _compose_baseline_cutout_fields/the _refresh_widgets_ from_cfg baseline/cutout loop independently of HAS_LAYOUT_TAB.

Verification: hard-gate re-run (--no-minkowski) on all 3 Selectric configs before/after (via git stash on just the touched files) - verts/faces/volume/watertight all byte-identical, confirming the config-relocation didn’t change any geometry. Additionally smoke-tested tune.py itself headless via Textual’s run_test() against scratch config copies (never the real config/ directory, per this file’s own standing warning): Layout tab mounts for all 3 machines, preset dropdown correctly detects the on-disk default as a named preset, per-row max_length matches each row’s real length, a preset switch (Composer US -> GERMAN) writes the right rows, a deliberately-shortened custom row is rejected (rows stay unchanged) instead of silently saved, and Blickensderfer (cylinder family) is unaffected - Layout tab and Element tab baseline/cutout fields both still present, no-op save still preserves rows.

Left open (not part of this ask): Selectric’s own per-row calibration arrays (row_latitudes etc.) still aren’t exposed in tune.py at all - same “list-valued config key needs an explicit decision” gap CLAUDE.md already flags elsewhere, not newly introduced here. Composer’s own pica/units type-test system and Calibration entry points for all 3 Selectric machines remain unported (pre-existing gaps, see parts 65-66).

Resuming later

  1. Hammond follow-up work (parts 30-31): (a) DONE - resin. orientation (vertical/horizontal print); (b) DONE - element.groove (rib vs. snap-fit assembly, part 31); (c) STALE, already superseded - this used to flag the groove+horizontal combination’s sparse placeholder resin-support grid as worth revisiting; part 35 replaced that placeholder with the real ported HorizGroovedResin3 (HorizGroovedResinSupport()) and part 40 made the choice between it and the rod scheme independently selectable, so this item no longer applies as written; (d) go through tune.py’s Hammond tabs field-by-field against config/hammond.yaml for anything still missing (named layout presets, Calibration wiring - see part 29’s open items).
  2. IBM/Selectric (parts 65-67) is now merged to main - all 3 modes (Selectric I/II, Selectric III, Selectric Composer) are built, wired into tune.py, routed through lib/build_log.py (part 67), and hard-gate verified. Per the roadmap this was the last machine listed in CLAUDE.md’s taxonomy. A real Layout tab (part 69) and Composer’s non-US language variants (UK/Nordic/German/Latin) are now done too - remaining Selectric-specific work is Calibration entry points, drain holes, Composer’s own pica type-test system, and exposing Selectric’s own per-row calibration arrays (row_latitudes/platen_longitude_ offsets/baseline_longitude_offsets/minkowski_longitudinal_ offsets) in tune.py (see part 69’s “Left open”).
  3. Bennett’s port (between Mignon and Helios) has no SESSION_LOG.md chapter of its own - per CLAUDE.md, that’s flagged as correlating with Bennett having more small undocumented inconsistencies than Mignon. Worth a dedicated retroactive audit pass if anyone’s in that area.
  4. Mignon’s TypeTest() (v2/mignon.scad:449-466) was not ported - tune.py’s Type Test tab still works for Mignon (it’s a generic flat CPI/LPI preview, not machine-specific), but the real fixed-pitch TypeTest() module itself (JoinRows/AlignedText-based) has no v4 equivalent for any machine yet, not just Mignon - pre-existing gap. Same is true for Helios’s own TypeTest() (v2/heliosklimax.scad:366-383).
  5. Everything in part 14’s original “Resuming later” list (separation_mm, inter-character collisions, performance, alignment offsets, platen_fn/body_fn) is still open.
  6. v2’s “unified” glyph-quality system (Weight_Adj_Mode/Scale_Multiplier/ Y_Scale/Text_Align_Method/Text_Align_Modified*) and the Character_ Modifieds/Typeface_2 per-character override systems have no v4 implementation for ANY machine (not Mignon-specific) - see part 21.

70. points_per_mm -> flatness_tolerance_mm: adaptive glyph-outline sampling fleet-wide (2026-07-26)

Investigation started from a suspicion that glyph_poc.contour_to_points() was generating far more mesh detail than glyph shapes actually need - confirmed with a standalone diagnostic (lib/contour_inspect.py): the old points_per_mm fixed-rate scheme subdivided STRAIGHT on-curve segments at the same density as curves, leaving 46-92% of a straight-stroke glyph’s points geometrically redundant (collinear within DEFAULT_SIMPLIFY_ TOLERANCE_MM).

Abandoned detour - platen height-field. First tried replacing the platen’s real boolean cylinder subtraction with a per-vertex Z-displacement height field (exact circle equation, applied to the base solid before Minkowski, same ordering the existing hard rule already requires). Result: MORE post-Minkowski faces than the real pipeline in every character tested (contour points down 46-71%, final faces still up 24-52%) - the boolean cut’s own CSG-generated topology happens to be more Minkowski/simplify- friendly than a direct height-field warp of the original glyph triangulation. Also had an independent sign bug (bulged the wrong direction - caught by eye, not by volume-matching, which barely moved since the platen bulge is tiny next to the Minkowski taper - a real lesson: volume-match alone doesn’t catch inverted curvature at small- perturbation scale). Abandoned; kept as lib/heightfield_poc.py, investigation history only, not wired into production.

Adopted approach - adaptive contour, real boolean platen unchanged. Isolated the ONE actual change: contour_to_points() now flattens curves via adaptive/recursive de Casteljau subdivision with a flatness-tolerance stopping test (flatten_quadratic/flatten_cubic - the standard technique behind cairo/Skia/AGG curve flattening, confirmed against matplotlib. path.Path.to_polygons() before adopting it) instead of a fixed points- per-mm rate; straight on-curve segments get ZERO subdivision. Real platen boolean cut and real Minkowski sweep left completely untouched. Validated on DejaVu Sans Mono (quadratic curves) and Alma Mono Thin (cubic CFF curves, also the font that historically exposed a real tag-parsing bug in this same function - a meaningful stress test, not a redundant one): volumes match old pipeline to 0.03-1%, all watertight/winding_consistent/ is_volume checks pass, build time drops 1.3x-4.5x (biggest wins on straight-stroke-heavy glyphs like ‘M’/’A’, smallest on all-curve glyphs like ‘O’, exactly where the old scheme had the least redundancy to begin with).

Real bug found during broader testing - sail/spike artifact. User visually caught a spike sticking out past a character’s linear-extrusion silhouette on bennett_average_mono (AverageMono.otf, not yet tested). Root cause (confirmed via direct triangle-level inspection, not guessed): a long straight edge correctly left unsubdivided by adaptive flattening (e.g. ‘d’/’l’/’k’s stem) becomes ONE tall wall quad in the extruded prism. When the real platen boolean cylinder cuts through that wall, it has to insert a new vertex wherever it actually crosses - with no nearby original vertex to bridge to, manifold3d’s CSG re-triangulation bridges the gap with a triangle spanning nearly the character’s full height AND depth in two faces (confirmed on AverageMono’s ‘d’, ‘p’, ‘k’, ‘N’, ‘V’, among others - 8794 such faces across a 58-character sweep). The old points_per_mm scheme never hit this because it always left many intermediate points along any long straight run for the cut to bridge to.

Fix: platen_y_breakpoints() inserts a shared grid of Y-breakpoints into every character’s contour before triangulation, spaced to match the REAL platen cutting cylinder’s own facet resolution (Rp * 2*pi/platen_fn) - same Y values for every character in a row (depends only on row-level constants: Rp, radius_y_offset_mm, platen_fn), not a per-glyph computation, per explicit user direction. Confirmed: worst remaining wall segment across the same 58-character sweep dropped from spanning the full character height (2.3mm+) to ~0.4mm - matching ordinary wall geometry at that breakpoint spacing, not a defect (verified the metric itself wasn’t a false positive: legitimate long HORIZONTAL edges, e.g. ‘E’/’L’/’F’/’Z’’s bars, also show up as “long” by a naive xy-distance check but are genuinely safe since Y is constant along them, so the platen cut is at one fixed Z the whole way - no gap possible; filtering for real Y-variation confirmed the remaining faces are all small, legitimate wall segments).

Second bug found in the same investigation - simplify() reintroducing the defect. User asked to disable Manifold.simplify() “after Minkowski” specifically. Doing only that left a smaller but still-real sail (worst case 2.10mm). Direct A/B measurement (disable vs. not, same character, same everything else) showed build_glyph’s PREVIEW-path simplify() call (inside the not minkowski_enabled branch - not literally “after Minkowski”) was independently reintroducing the artifact on top of an otherwise-clean cut mesh: disabling it alone dropped the worst sail from 2.10mm to 1.37mm (a real, legitimate long edge). All three call sites feeding from the adaptive-contour pipeline are now disabled (commented out, not deleted): build_glyph’s preview path, build_glyph’s post-Minkowski path, build_flat_text_drafted’s post- Minkowski path. spherical_machine.SingleMinkowskiChar’s own post- Minkowski call is disabled the same way; its PRE-Minkowski call (cleaning the boolean-diff’d mesh before minkowski_sum - documented 552x speedup avoiding a 2206s-per-character regression) is NOT touched, a genuinely different role.

Net result after both fixes, DejaVu A/M/O/l, Minkowski-enabled, vs. the original points_per_mm pipeline:

       original pipeline   fixed (breakpoints, simplify off)   speedup
A          0.391s               0.152s                          2.6x
M          0.690s               0.255s                          2.7x
O          0.853s               0.585s                          1.5x
l          0.307s               0.190s                          1.6x

Less dramatic than the very first (buggy) adaptive-only measurement (3.6x/4.5x/1.3x/1.7x - taken before the sail bug was found, since simplify() was still masking it and face counts were correspondingly lower), but still a clear, real win across the board, and now actually correct. Final face counts are higher than the pre-fix numbers (e.g. ‘A’: 814 verts/1624 faces vs. 252/500) - the honest cost of the breakpoint safeguard plus simplify() fully disabled; worth someone revisiting later whether a narrower, still-safe simplify() tolerance could be reintroduced now that the input is better-conditioned, but not attempted in this session.

Fleet-wide rename: build.points_per_mm -> build.flatness_tolerance_ mm across all 42 config/*.yaml files, every machine module’s configure(), cylinder_machine.py, spherical_machine.py, glyph_poc.py (including the CLI), generate.py/type_test.py/export_glyphs.py CLI flags, and all 7 tune.py QUALITY_FIELDS_* lists (_BLICKPOSTAL, _MIGNON, _BENNETT, _HELIOS, _HAMMOND, _HAMMOND_SPLIT, _SELECTRIC - covering every machine). Hard cutover, no back-compat alias, per this codebase’s existing rename precedents (resin_fn/ minkowski_fn section moves). Single uniform default (0.005mm) replaces both the config-driven 8-15 range and the hardcoded Python 20.0 defaults in decorative Logo/Label call sites (LabelText/ElementLogo/ ElementLabel/LogoText/build_text_string). quality.text_fn (a already-dead v2 leftover, confirmed unused in both hammond.py and hammond_split.py before this session) was NOT touched - it was never the live knob and this change doesn’t revive it.

lib/contour_inspect.py and lib/heightfield_poc.py (this session’s two prototype/diagnostic files) are kept as-is per explicit user direction - investigation history, not production code, not deleted.

71. Y-breakpoint mechanism (part 70) built, then removed again - the real platen boolean cut didn’t need help after all (2026-07-26)

Follow-up to part 70’s “sail/spike” finding and fix. After landing the shared platen_y_breakpoints()/insert_y_breakpoints() mechanism and iterating on its spacing rule twice (uniform angle-step, then a Z-interval step per explicit user direction - “near platen tangent is long edge since its near flat, then…gets more and more frequent” - confirmed numerically: first interval ~0.40mm at the tangent shrinking to ~0.03mm by dy~3mm), a NEW problem was found: the breakpoint insertion caused a fan of degenerate thin triangles in the flat 2D cap triangulation wherever a long breakpoint-dense edge met an already-dense original curve region (a serif/curl - confirmed visually on AverageMono ‘l’ via a direct matplotlib plot of classify_and_triangulate’s own output). Root cause: insert_y_breakpoints only adds an isolated vertex to whichever single boundary edge crosses a breakpoint - it never constrains the interior triangulation to connect a left-side breakpoint to its corresponding right-side one (no “rung”), so Delaunay is left to improvise the interior and does it badly under locally mismatched point density. Attempted mitigation (skip breakpoint insertion on edges below a length threshold) reduced but did not eliminate this, since the LONG edges immediately adjacent to a dense curve region still got breakpoints, so the density mismatch just moved one edge over.

User then asked for a genuine clean-slate re-derivation: removed the whole platen_y_breakpoints/insert_y_breakpoints mechanism and its call site from build_glyph() entirely (function definitions deleted, not just disabled), keeping only the validated adaptive contour tracing (part 70’s real, independent win) and the ORIGINAL, byte-identical real-boolean-cylinder-subtraction platen mechanism - confirmed via git show main:v4/lib/glyph_poc.py diff that this scallop-carving block was never touched at any point this session. simplify() stays disabled at all 4 call sites (3 in glyph_poc.py, 1 in spherical_machine.py) per explicit user direction - not restored.

Re-tested the original “sail” concern with NO breakpoints, using a properly SIZE-NORMALIZED metric this time (no face’s longest edge may exceed 1.5x that character’s own bounding-box diagonal) instead of an absolute-mm threshold - the absolute-threshold version had been flagging legitimate large-but-flat wall facets (all 3 vertices sharing the same X, i.e. lying entirely within one flat vertical wall plane, confirmed by the user directly inspecting the STL in F3D: “that is fine, its the side of the leg”) as false positives. With the size-normalized check: 0 flagged faces, all watertight/is_volume/winding-consistent, across every character in BOTH:

  • the cylinder family (AverageMono via glyph_poc.build_glyph directly, the same 58-character sweep that found the original concern), and
  • the spherical family (FreeSans via spherical_machine.SingleMinkowskiChar, configured through the real selectric12.yaml, tested directly since this module has its own independent copy of the platen-cut/Minkowski logic - never shares code with glyph_poc.build_glyph).

Also directly confirmed via a wireframe render (--edges in f3d) that the real boolean cut subdivides a stem’s wall finely wherever the platen curve actually changes, and leaves it as one larger (but still correctly in-silhouette, still flat) facet wherever the curve doesn’t change - exactly the adaptive behavior wanted, for free, with no pre-conditioning of the input contour at all. This fully validates the user’s original instinct (“since that’s platen cut’s job”) over the mid-session breakpoint detour.

Current final state: adaptive/flatness-tolerance contour tracing (part 70, kept), real boolean platen cut (unchanged from main this whole session), simplify() disabled at all 4 call sites (kept disabled), no Y-breakpoint mechanism (built then fully removed). No commits made yet - everything is still uncommitted working-tree state in the worktree-contour-inspect worktree.

Resuming later

  1. Hard-gate verification sweep (all 42 configs, --no-minkowski, before/after against this session’s own pre-edit baseline in /tmp/hf_baseline) has NOT been completed - explicitly paused by the user mid-session (“stop making all 40 configs”) in favor of targeted single-character/single-config checks instead. Whether to still run the full sweep before committing is an open question, not a decision already made.
  2. Whether a narrower simplify() tolerance could safely come back is moot for now - explicit user direction this round was to leave it disabled, not revisit.
  3. Nothing has been committed yet. lib/contour_inspect.py/lib/ heightfield_poc.py are intentionally kept; several stray out_*.stl/ out_contour_*.png test-output files under lib/ from ad hoc testing this session are NOT meant to be kept and should be cleaned up before any commit.

72. Dead quality.* parameter cleanup (2026-07-26)

Full audit of every quality.* key across all 42 configs, cross- referenced against real consumption in lib/*.py and tune.py’s 7 QUALITY_FIELDS_* lists. Two genuinely dead parameters found and removed entirely (config lines, lib assignment lines, tune.py field tuples where present):

  • quality.text_fn (7 configs: hammond.yaml, hammond_alma_mono.yaml, hammond_century_schoolbook_mono.yaml, hammond_comic_mono.yaml, hammond_compagnon.yaml, hammond_iosevka_fixed_slab.yaml, hammond_split.yaml) - a v2 leftover (Text_Fn/Text_2D_Fn) assigned in hammond.py/hammond_split.py’s configure() but never read by any geometry function since v4’s freetype pipeline uses flatness_tolerance_mm instead, not an $fn- style facet count. hammond_split’s tune.py tooltip already disclosed this (“Not currently consumed”); hammond’s own tune.py tooltip did NOT (“Glyph curve smoothness” - misleadingly implied it was live) - both field tuples removed from tune.py entirely rather than just documented as dead.
  • quality.resin_fn (6 non-split hammond configs) - a pure orphan duplicate of the real, live resin.resin_fn (which every machine actually reads); never referenced anywhere in code, never exposed in tune.py at all. Config-only cleanup, no lib/tune.py changes needed.

Every other quality.* key across the fleet (cyl_fn, surface_fn, groove_fn, alignment_hole_fn, minkowski_fn, platen_fn, body_fn) confirmed genuinely live via direct trace into a real geometry call (e.g. sections=Body_Fn, circular_segments=Platen_Fn) - no further action needed on those.

Verified: all 42 configs still parse as valid YAML, hammond.yaml and hammond_split.yaml both still build successfully end-to-end (generate.py ... --no-minkowski) post-removal.

73. All remaining simplify() calls disabled fleet-wide; the one “load-bearing” exception turned out to be moot (2026-07-27)

User: “disable all simplification.” Found and disabled the two remaining active call sites this session had missed: spherical_machine. SingleMinkowskiChar()’s pre-Minkowski call (previously left alone, documented as load-bearing for a 552x speedup avoiding a 2206s-per- character regression on Alma Mono ‘M’), and hammond_split. _letter_text_drafted()’s post-Minkowski call (never touched at all this session - found via a full grep -rn "\.simplify(" sweep of lib/*.py, not previously known about).

Re-tested the specific regression case the pre-Minkowski call was protecting against, now with zero simplify() calls anywhere in the call chain: SingleMinkowskiChar('M', ..., font=Alma Mono, minkowski_ enabled=True) completes in 5.75s (was 2206s before ANY of this session’s changes, with the old points_per_mm contour scheme). Confirms the adaptive contour tracing (part 70) already fixes the root cause that call existed to work around - the old fixed-rate scheme produced a boolean-cut mesh bloated with CSG noise (666 faces, 164 real) that exploded through minkowski_sum; the adaptive contour’s much sparser, cleaner base mesh doesn’t have that problem to begin with, so the workaround is no longer needed either.

grep -rn "\.simplify(" lib/*.py now shows zero active (non-commented) calls anywhere in the codebase.

User independently confirmed in real use (via tune.py, own testing): full-font builds (all characters, real Minkowski enabled) for the cylinder family complete in under a minute at flatness_tolerance_mm =0.01.

74. Cylinder-vs-sphere render speed investigation; a real fix landed, but the original “24x slower” signal didn’t hold up at scale (2026-07-27)

User asked why spherical renders were much slower than cylinder renders at the same font/quality settings. Initial single-character timing tests (Alma Mono ‘M’/’O’ via spherical_machine.SingleMinkowskiChar) showed a dramatic ~24x gap (6.7s/9.4s vs cylinder’s 0.28s/0.39s). Root-caused via a controlled reproduction: SingleMinkowskiChar rotates its draft cone into real sphere position (`sp.scad_transform(cone_hull, (“rotate”, [90

  • latitude, 0, 90 + longitude]))) BEFORE calling minkowski_sum, while cylinder_machine/build_glyph always sums with a Z-up, un-rotated cone and only rotates the FINISHED result afterward (place_on_cylinder`, a separate later step). Confirmed on an isolated reproduction: identical base mesh, rotated cone = 3.5s/11262 faces, axis-aligned cone = 0.17s/1424 faces - a real, ~20x, reproducible effect in isolation.

Implemented the fix: instead of rotating the cone into position, rotate scalloped by the INVERSE of that same rotation into the cone’s canonical frame, sum with the untouched axis-aligned cone, then rotate the SUM forward by the original rotation - mathematically exact for any single rigid rotation R applied identically to both operands (R(A) (+) R(B) = R(A (+) B)). Verified correctness directly: byte-identical volumes and vertex/face counts against the pre-fix code across all 26 uppercase Alma Mono characters.

However: re-measured across the FULL uppercase alphabet (not just one or two characters) and the dramatic slowdown did not reproduce at all - old and new code both totaled ~6.07s/6.08s (1.00x, no measurable difference), and neither ‘M’ nor ‘O’ individually showed anything close to their original 6.7s/9.4s single-character measurements this time either (both back to ~0.2-0.5s). The original 24x signal looks like it was an anomaly in those specific single-character test runs (system noise, first-call warm-up, or similar), not a real, systematic per- character cost - my isolated reproduction was measuring something real in isolation, but that effect doesn’t dominate the ACTUAL end-to-end cost once embedded in the real pipeline/call pattern.

Clean, direct full-alphabet comparison (cylinder vs already-fixed spherical, same font, same run methodology): cylinder 4.41s, spherical 6.11s - a real but modest ~1.4x gap, plausibly just inherent to spherical’s more complex positioning (real spherical trig vs a simple translation), larger platen-cutter cylinder (Cyl_Fn=360 -> 1440 faces), and fixed 6mm Character_Block_Height_Mm block. Not chased further - user confirmed real full-font spherical builds (Alma Mono) at 26s (flatness_tolerance_mm=0.01) and 37s (=0.005), both comfortably fast in absolute terms and consistent with the tighter-tolerance-means- more-points-means-slower relationship expected from part 70.

The rotation-avoidance fix is KEPT (correct, harmless, zero regressions found) despite not delivering the dramatic win originally expected - worth having on general principle (matches cylinder_machine’s own proven pattern) even without a large measured benefit on this particular workload.

75. Type Slug family ported - 5 new machines, v1 (not v2) as ground truth (2026-07-28)

User asked to migrate “type slugs” into v4, framing them as “more for novelty, not real stuff.” These turned out to live ONLY in v1/Type Slugs/*.scad - standalone decorative type-slug replicas that were never carried into v2 at all (unlike every other v4 machine so far), so this port’s ground truth is v1 directly, a first for this codebase - config YAML/lib code below cross-reference v1 line numbers the way every other machine cross-references v2 line numbers.

Taxonomy (verified function-by-function against both real v1 sources before writing anything, per CLAUDE.md’s own rule): two genuinely different body-construction engines, not five independent ports or one grab-bag module.

  • “Wing body” family (lib/wing_slug.py shared engine): rounded hull body (4 corner posts + 2 wing-radius cylinders), real Minkowski- drafted struck text, optional SVG logo, resin support, loop/post/ side-hole. Three thin variants: lib/type_slug.py (TypeSlug.scad, the generic base engine), lib/vogue_slug.py (VogueSlug.scad, carries the real 2-piece “Vogue Foundry” mark + True_Vogue font - the one replica modeled from real drawings), lib/gauge_slug.py (GaugeTypeSlugSlug. scad - no text/logo at all, a tick-mark measuring ladder instead; per the user’s own clarification, this is the ONE real functional member of the family, meant to install on the machine and measure/calibrate, not a novelty).
  • “Box body” family (lib/box_slug.py shared engine): plain rectangular body, straight polygon-cut wing tapers, N stacked struck characters (config-driven list length) + N platen cylinders, no logo, no resin support at all. Two thin variants: lib/oliver_slug.py (OliverSlug.scad, 3 chars, a real Oliver typewriter slug replica), lib/lumi_slug.py (LumiSlug.scad, 4 chars + a loop pendant, pure novelty).
  • Same “shared engine + thin variant” split lib/spherical_machine.py + lib/selectric12.py/selectric3.py/selectric_composer.py already established - confirmed applicable here independently, not assumed from that precedent.

New shared infrastructure, not machine-specific:

  • lib/svg_import.py - v4 had no SVG-import capability at all (v1 relied on OpenSCAD’s built-in import(svg)). Real from-scratch path-data parser (M/L/H/V/C/S/Q/T/A/Z, absolute+relative) reusing glyph_poc’s adaptive de Casteljau bezier flattening for curves and elliptical-arc- to-bezier conversion for A, feeding glyph_poc.classify_and_ triangulate()’s existing nesting-depth hole/island fill logic (same problem a multi-path logo has as a multi-contour glyph). Deliberately does NOT reproduce OpenSCAD’s own px/DPI SVG-import scaling convention (would need the same kind of empirical DPI calibration glyph_poc. OPENSCAD_TEXT_DPI_FACTOR needed for text() - not worth it for a decorative logo mark, not a physically-toleranced struck character) - callers pick their own logo.scale_mm_per_unit explicitly instead of inheriting v1’s SVG_Scale=1/40*SVG_Size.
  • scad_primitives.torus() - extracted since wing_slug.Loop()/ box_slug.Loop() both need the exact same torus recipe (byte- identical between TypeSlug.scad’s and LumiSlug.scad’s real Loop blocks).

Real bugs found and fixed during this port (not shipped):

  1. sp.scad_transform() op-ordering, backwards in 5 of 7 initial call sites. Re-derived the convention carefully against spherical_ machine.py’s own PlatenCutout() (3 chained ops with an explicit “source top-to-bottom” comment matching its ops list order 1:1) - confirmed the rule is ops-list-order = literal source top-to-bottom order (whichever transform is OUTERMOST/appears first in the nested OpenSCAD source goes FIRST in the list). Caught and fixed in _wing_cyl_verts, _platen_cyl_pair, CopyrightText, PostHole, SideHole before ever running geometry - Post()/Loop() were already correct. Would have silently produced wrong (but still watertight-looking) part geometry if shipped.
  2. scad_primitives.torus()’s initial single sections parameter drove BOTH the tube cross-section AND the sweep resolution (matching v1’s own literal $fn=360 reused for both rotate_extrude and its child circle()) - measured a real 129,600-vertex torus for one small keyring loop (confirmed via Lumi Slug’s real config values: a 66K-vertex FullElement for 4 characters vs Oliver Slug’s 630-vertex FullElement for 3, a disproportionate jump that was the actual tell). Split into independent sections (sweep, stays 360) and tube_sections (new quality.loop_tube_fn knob, default 32) - verts dropped ~10x fleet-wide with no visible quality loss, still a real, own config knob per CLAUDE.md’s facet-count-in-YAML rule, not a hardcoded reduction.
  3. tune.py FIELDS key collisions - character.enabled/logo. enabled/gauge.enabled all being the bare YAML key enabled (and logo.depth_mm/label.depth_mm both being depth_mm) would have broken patch_yaml_value()’s save path, which matches by bare key TEXT across the whole file with no section-scoping at all (confirmed by reading patch_yaml_value’s own regex - ^\s*{key}:\s*..., first match wins). Renamed to char_enabled/logo_enabled/gauge_enabled/ logo_depth_mm/label_depth_mm in all 3 wing-family configs + their configure() readers. Verified with a real round-trip test (see below) that catches exactly this class of bug, not just eyeballed.

Audit against sibling machines/conventions (per CLAUDE.md’s own “every machine port gets an explicit audit pass” rule):

  • Entry points match the fleet convention exactly: configure(), _require_configured(), FullElement/ResinPrint/Additive, all accepting-but-ignoring separation_mm/render_core_groove (neither concept exists for this form factor - same precedent Selectric already set). box_slug.py’s ResinSupport()/ResinPrint() (real no-op passthrough) matches lib/helios.py’s own established pattern for a machine with zero resin-support geometry, including the matching RESIN_SUPPORT_UNAVAILABLE_NOTE entries in tune.py.
  • build.resin_support is a real build-time toggle (ResinPrint vs FullElement dispatch) for the wing family, NOT baked permanently into the geometry the way v1’s single Resin_Support variable was - a deliberate structural improvement matching every other v4 machine’s own convention, explicitly called out in config/type_slug.yaml’s comments rather than silently diverging from v1. build.minkowski_enabled similarly defaults to true (a real drafted build) on every new config even where v1’s own literal default was Debug_No_Minkowski=true (Oliver/Lumi) - explicitly noted in both configs’ comments as an intentional, fleet-consistency change, not a silent divergence, per CLAUDE.md’s “say so explicitly” rule.
  • Dead v1 customizer variables are called out in config comments, not silently dropped or silently wired to a fake no-op parameter: Side_Hole_Offset (declared, never referenced by hole_coords) and Lower_Wing_Angle (declared for Oliver/Lumi, but both real wing-angle cuts use Upper_Wing_Angle even for the “lower” cut - a real, preserved v1 quirk, not a transcription choice made here). TypeSlug.scad’s own SVG_Vogue_Enable branch (a byte-for-byte duplicate of its SVG_Enable branch, not the real Vogue mark) is called out as dead/redundant leftover, not ported as a second toggle.
  • New tune.py “Slugs” MACHINE_CATEGORIES group added (4th column, fully data-driven from the existing generic per-loop composer, no new hardcoded-count literals to fix - this family has no keyboard grid at all, so none of the “9 hardcoded row/column literals” class of bug Mignon’s port hit applies). Gauge Slug’s tick-mark fields deliberately named "Ticks" in SECTIONS_BY_MACHINE, NOT "Gauge" - the existing "Gauge" section name means Blickensderfer/Postal’s Shaft Gauge Test build-target option (_compose_build_tab’s has_gauge/ GaugeTestSet() dispatch), which gauge_slug.py does not implement; reusing that name would have wired a broken “Shaft Gauge” build-target button into the Build tab.
  • No Layout tab (no layout: config section at all - HAS_LAYOUT_TAB is naturally False via the existing "rows" in layout check, same mechanism Selectric’s own “no editable keyboard-layout concept” already relies on - no new code needed). No Calibration tab either (CalibrationElement/CalibrationAdditive not implemented for this family, matching spherical_machine.py’s own explicit “deferred to a follow-up pass” precedent, not a silent gap).

Verification performed:

  • generate.py run for all 5 new configs, both --no-minkowski and a REAL Minkowski build (the invariant --no-minkowski alone can’t verify) - every one watertight/winding_consistent/is_volume=True, including Vogue Slug’s real 2-piece SVG Minkowski draft and Type Slug’s real AR1.svg logo + struck text + loop combined build. Bounding-box sanity-checked against each config’s own real Body_Width/ Body_Length/Body_Height (caught the scad_transform bug above before it could hide as a false-positive “watertight” pass).
  • tune.py headless-tested against SCRATCH copies only (never the real master/running configs, per this file’s own part-12 warning) - TuneApp construction, self.SECTIONS/self.FIELDS contents, and a real round-trip patch_yaml_value() pass over every field for all 5 machines (write each field’s own current value back, re-parse, assert identical) - this is what actually caught bug 3 above, not a visual inspection.
  • Existing-config regression check (bennett.yaml, selectric12.yaml, both --no-minkowski): still watertight/winding_consistent/is_volume= True after this session’s scad_primitives.py/tune.py edits (both purely additive - a new torus() function, new dict entries/FIELDS lists, no existing function or entry modified).

Not done in this pass (no signal it’s wanted, and matches Selectric’s own precedent of deferring the same feature at first port): CalibrationElement/CalibrationAdditive for any of the 5 new machines.

Correction, found by the user running the real TUI (not caught by this part’s own “headless-tested” verification claim above): _compose_tuner_ui() never actually called _compose_section_tab ("Character")/("Ticks") for the two new section names this port added - self.FIELDS (built by flattening SECTIONS_BY_MACHINE) included their fields regardless, so self.inputs (populated only by _compose_section_tab actually running) was missing entries for them, crashing on self.inputs[key] (KeyError: 'char_enabled') the moment any generic FIELDS loop touched one - reproduced live via “Reset to Defaults”. The headless test this part described above only exercised TuneApp.__init__ (SECTIONS/FIELDS construction, patch_yaml_value() round-trips against raw config values) - it never actually mounted the app, so it could not have caught a missing-_compose_section_tab()-call bug like this one. Fixed by adding the two missing if "Character"/ "Ticks" in self.SECTIONS: yield from self._compose_section_tab(...) calls. Re-verified properly this time using Textual’s own App.run_test() harness (which DOES mount the app for real) - confirms every one of the 5 new machines’ self.FIELDS now has a real widget in self.inputs, and reproduces+confirms-fixed the exact action_reset_ defaults() crash path the user hit; also re-ran the same run_test() check against bennett/selectric12 to confirm the two new conditional blocks don’t affect any existing machine. Lesson for next time: a “headless” tune.py test that never calls run_test()/mounts the app cannot verify anything about widget composition or self. inputs - construction-only testing of TuneApp is real but partial coverage, not a substitute for actually mounting it.

76. Part 75’s branch merged to main; all repo SVGs relocated into v4/assets/svg/; svg_import.py gained real <g transform> support; Helios gets its own v1-sourced SVG logo (2026-07-31)

Part 75’s Type Slug port had been sitting on an unmerged branch (worktree-hidden-bubbling-frog) - user noticed the “Type Slugs” v1 folder existed and asked to check for prior work before assuming it needed porting from scratch. main hadn’t moved since that branch’s merge-base, so it merged as a pure fast-forward (git merge --ff-only, zero conflicts).

SVG relocation. That branch’s configs (type_slug.yaml/ vogue_slug.yaml/gauge_slug.yaml) referenced v1/Type Slugs/*.svg directly by absolute path rather than copying assets into v4. Per explicit user direction (“move all the svgs to v4”), all 6 repo SVGs (helios-klimax.svg, Leo.svg, AR1.svg, vogue-foundry-arrow.svg, vogue-foundry-v.svg, vogue-foundry-complete.svg) were copied to a new v4/assets/svg/ directory and the 3 configs’ svg_file/ vogue_arrow_svg_file/vogue_v_svg_file paths repointed there. This is NOT the same move as fonts staying external in /home/lchau/fonts/ - these SVGs already lived inside this same git repo (sibling v1/ directory), so relocating them is an internal reorg, not a duplication of a genuinely-external asset library; the font convention was not changed. Regression-checked (--no-minkowski, verts/faces/volume identical before/after) for type_slug/vogue_slug. Leo.svg is copied but wired to nothing - grepping all of v1 found no .scad that ever imports it; it appears to be unused standalone art even in v1.

svg_import.py bug found while porting Helios’s own logo. Attempting to reuse lib/svg_import.py (part 75) for v1/HeliosKlimax/ HeliosKlimaxTester.scad’s SVG_Logo feature (helios-klimax.svg, lines 103-104/365-368 - a real feature, “Logo” not “log”: user’s original ask was misheard/mistyped as implementing “the log the same as v1”, corrected mid-conversation once they clarified) surfaced a real gap: helios-klimax.svg (a potrace-style export) wraps its paths in <g transform="translate(0,906) scale(0.1,-0.1)">, which parse_svg_ contours_mm silently ignored (module docstring claimed “no <g transform=…> content… confirmed by inspection” - true only for the two files actually in use at the time, AR1.svg/vogue-foundry-*.svg, which happen to have no such wrapper). Ignoring it produced raw contours 10x too large. Fixed by adding a real _parse_transform() (translate/scale/rotate/matrix, chained, SVG-spec composition order) and walking the tree with accumulated transforms instead of a flat root.iter() for <path> tags. Verified two ways: (1) parsed helios-klimax.svg’s raw contour bbox now comes out ~1873x687, in the right ballpark of its own 1936x906 viewBox (was 18735x6873, 10x off, before the fix); (2) type_slug/vogue_slug/gauge_slug re-ran byte-identical (same verts/faces/volume) after the change, confirming no regression for files with no <g transform>.

Helios’s own SVG logo. Ported as a DELIBERATE v1-sourced addition, not a v2 port - v2/heliosklimax.scad’s own header explicitly has no Logo concept for Helios at all (a fact lib/helios.py’s docstring, config/ helios.yaml’s header, and tune.py’s SECTIONS comment all previously asserted unconditionally; all three corrected to distinguish “no engraved TEXT” (still true, v2-sourced) from “no SVG mark” (now false, v1-sourced) rather than silently contradicting the new code). v1’s own SVG_Scale=.03 is meaningless in v4 (OpenSCAD’s import() bakes in its own px/DPI convention svg_import.py deliberately doesn’t reproduce - see that module’s docstring) - so the real physical size v1 actually produces had to be independently established: rendered linear_extrude (0.31) scale([.03,.03]) import("helios-klimax.svg", center=true) through the real openscad-nightly binary (this repo’s own top-level CLAUDE.md “OpenSCAD binary” cross-check technique), measured a real 19.83mm x 7.27mm bounding box, and divided by the SVG’s own raw path-unit extents to get scale_mm_per_unit=0.010583. New config/ helios.yaml logo: section (logo_enabled default false, svg_file, scale_mm_per_unit, logo_depth_mm, x_offset_mm); new lib/helios.py Logo() (flat SVG -> extrude_triangulation -> sp. scad_transform translate-then-rotate, matching v1’s real transform order - no Minkowski draft, v1’s own SVG_Logo isn’t drafted either), wired into _final_cut() gated on Logo_Enabled (same difference() stage v1 cuts it in, alongside Cutting Center Shaft Hole/Wire Clip/Speed Holes); new tune.py LOGO_FIELDS_HELIOS/"Logo" SECTIONS_BY_MACHINE entry, same generic-FIELDS convention as the Type Slug family’s Logo tab.

Defaulted OFF, unlike v1: SVG_Logo=true was v1’s own Tester-customizer default, but that feature only ever lived in the experimental HeliosKlimaxTester.scad, never in the polished HeliosKlimaxElement.scad, and its real size (19.8mm x 7.3mm) is large relative to element. element_diameter=27.15mm - directly confirmed by dumping Logo()’s mesh bounds (x:[-9.89,-2.61] y:[-9.91,9.91]), whose corners fall slightly outside the element’s own radius (13.575mm). Left enabled for the user to dial in x_offset_mm/scale_mm_per_unit against the real part, the same “estimate, not measured” treatment this same config file already gives core_chamfer etc.

Hard-gate verification: helios.yaml with logo_enabled: false (the new default) reproduces the pre-change baseline exactly (verts= 118755 faces=237534 volume=4201.434mm3, --no-minkowski). With logo_ enabled: true, still watertight/winding_consistent/is_volume=True, volume drops to 4196.183mm3 (~5.25mm3 removed by the engraving cut, a plausible order of magnitude for a shallow 0.3mm-deep mark of this size).

77. Bennett: 3 independent label radial distances exposed; Hammond Split Type Test now honors Char_Mod; ⅌ layout archaeology; TODO logged for fleet-wide Char_Mod (2026-08-01)

Bennett label radial distances. LabelText() (lib/bennett.py) used to nest Shuttle_Label1a inside Shuttle_Label1b’s group, sharing ONE group translate/rotate with Label1a further offset by a local translate([0,2.25,0]) - since the shared rotate([180,0,90]) maps local (x,y,z) to world (y,x,-z), that nested local-y offset actually landed entirely in world X (radial direction), meaning v2’s Label1a and Label1b were always two independent radial numbers (3.45mm/5.70mm at stock Shaft_Diameter=3.4), just expressed as one offset nested inside another. Split into three flat, independently configurable values - label.label1a_radial_mm/label1b_radial_mm/label2_radial_mm - each label now gets its own scad_transform instead of sharing a group. Added to config/bennett.yaml and all 4 font-variant profiles plus bennett.running.yaml, and to tune.py’s LABEL_FIELDS_BENNETT (Label tab). Hard-gate verified: generate.py --no-minkowski on all 5 configs produces byte-identical verts/faces/watertight/volume before and after (compared via git stash for the pre-change baseline) - a pure refactor, not a behavior change, at the defaults.

Hammond Split Type Test didn’t honor Char_Mod. User reported “modified characters” seemed broken; investigation of the real per-character font/size-swap path (TextAssemble()’s is_mod = char in Char_Mod) showed it working correctly in isolation (different bbox/volume when forced). The real bug: type_test.py (the flat CPI/LPI preview tune.py’s “Type Test” button drives) applies ONE font/size to every character in the test string - it has no concept of Char_Mod at all, unlike TextAssemble(). Fixed by adding optional mod_chars/mod_font_path/ mod_font_size_mm params to build_type_test_line() (same ch in mod_chars check, no-op when mod_chars="") and --mod-chars/ --mod-font-path/--mod-font-size-mm CLI flags; tune.py’s action_render_type_test now passes Hammond Split’s char_mod.char/ char_mod_font_path/char_mod_size_mm fields through, gated on the "char" input key (unique to FONT_FIELDS_HAMMOND_SPLIT, so a no-op for every other machine). Verified: a forced mod char now produces different verts/volume in the Type Test output; the no---mod-*-flags path is byte-identical to before the change.

⅌ (per-unit sign) archaeology - not a bug, a real author decision, left as-is. User asked to check v1-v4 for any layout that actually contains Char_Mod’s default character (⅌) - it’s genuinely absent from every version’s ACTIVE default layout (Ideal_Element/IDEAL/ idealElement, Layout_Selection=0 in every version including v4’s config/hammond_split.yaml), confirmed byte-for-byte. It only appears in the unwired Qwerty_Element alternate (all versions) - so Char_Mod has been a no-op under the shipped default in every version, not just v4. One real historical trace found: v1/Hammond/ HammondSplitShuttle.scad:22 (Feb 17 2024, the oldest version) has a COMMENTED-OUT LAYOUT array labeled “Layout as ‘stamped’” whose figures row has ⅌ in place of £ at the same position - i.e. an earlier draft’s “real machine” layout DID include ⅌ where the shipped IDEAL array now has £. But by HammondSplitShuttle2.scad (Jan 14 2026, the direct ancestor of v2/hammond_split.scad and everything after), that commented alternate is gone entirely, with no trace - £ has been the sole, uncommented figures-row value at that position across every version since. Read together: this looks like a deliberate real-machine correction made early in v1 (the physical Hammond’s actual stamped key may print £ there, not ⅌) that was carried forward cleanly for two years and two full rewrites, with Char_Mod="⅌"/CharMod/charMod left unchanged alongside it the entire time - not a copy-paste drop. Not changed here; flagging the evidence for the user to decide (leave Char_Mod as a dead-by-default example value, repoint it at a character that’s actually in Ideal_Element, or wire up Qwerty_Element as a real second layout preset).

Follow-up (same session): both £/⅌ variants installed as real, selectable presets, per explicit user direction (“there is a version that contains the per symbol. i want to install both layouts, the one with and one without”). New LAYOUT_PRESETS_HAMMOND_SPLIT in tune.py ("IDEAL (£)" - the shipped default, byte-identical to config/ hammond_split.yaml’s on-disk rows - and "IDEAL (⅌)" - identical rows 0/1, row 2’s one £/⅌ character swapped per the v1 archaeology above), wired into LAYOUT_PRESETS_BY_MACHINE["hammond_split"] and a new LAYOUT_PICKER_HELP["hammond_split"] banner noting Char_Mod only has an effect under the ⅌ variant. hammond_split previously had NO named-layout picker at all (only “Modify glyphs” freehand editing) - this is the first one. Verified both build clean: default config unchanged (confirms the tune.py-only change has no effect until someone actually picks the ⅌ preset and saves), and a scratch copy with row 2’s £ swapped for ⅌ builds watertight/winding_consistent/is_volume=True with a real (if small) volume delta - confirming Char_Mod actually engages end-to-end once ⅌ is present in the active layout.

TODO (explicit user request, not started): generalize Char_Mod (per-character font/size override) to every other machine. Currently Hammond Split-only (config/hammond_split.yaml’s char_mod: section, lib/hammond_split.py’s TextAssemble()/LetterText(), tune.py’s FONT_FIELDS_HAMMOND_SPLIT, and now type_test.py’s --mod-* flags above). No other machine’s config/lib/tune.py has an equivalent concept

  • every other machine’s glyph loop (cylinder_machine.TextRing, spherical_machine’s per-side loops, the Type Slug family) calls a single shared font_path/font_size_mm for every character with no per-character override hook at all. Before starting: check whether any other machine’s real v2/v1 source has its own equivalent concept (this repo’s “always diff against the real v2 source before porting/adding a feature” rule) rather than assuming Hammond Split’s exact shape (single char_mod.char string + one alternate font/size) is the right generalization for every machine - a machine with its own from-scratch glyph loop (like Hammond Split) will need this wired by hand the way build_log progress instrumentation did (see “Porting a new machine” / “TUI (tune.py)” sections of CLAUDE.md), while a machine that already reuses cylinder_machine.TextRing/place_on_cylinder will need the override threaded through that shared path instead - almost certainly NOT a copy-paste of Hammond Split’s own code.

78. Selectric Composer’s real proportional-spacing Type Test ported (2026-08-01)

User flagged “composer type test is very special, v1 maybe v2” - confirmed identical in both v1/IBM/IBM2.scad and v2/ibm.scad (+ v2/lib/layouts/ibm_layouts.scad), and already noted as a deferred gap in lib/selectric_composer.py’s own docstring from the original Selectric port (part 65). Unlike every other machine’s Type Test (and Selectric I/II/III’s own TextGauge()), which use fixed monospace CPI slots, the real Composer used genuine proportional/justified-typesetting spacing: Composer_Pitch_List gives each character an integer UNIT width (3-9 units - 'M'/'W'/'m'=9 down to punctuation/space=3, the real Composer’s historical escapement-unit convention), converted to mm via 25.4/Units_Per_Inch (72/84/96 - the “Red/Yellow/Blue wheel” setting), and cumulativeSum() of those per-character units drives each character’s x-position - entirely different geometry per line than a uniform slot width.

Ported the core mechanic, not v2’s specific keyboard-gauge UI around it. v2’s TextGaugeComposerLine2 defaults to printing the ENTIRE 88-char keyboard (KBSTRING = CASES88[0]+CASES88[1]) auto-wrapped into 8 rows via hardcoded index breakpoints (GetRow()), with a CUSTOM_TEST_STRING toggle to swap in one unwrapped line instead. v4’s Type Test box already lets the user free-type arbitrary (optionally multi-line via literal \n) text for every machine, so that convention was kept as-is rather than porting KBSTRING/GetRow/the toggle - Composer’s line just uses proportional per-character spacing instead of fixed CPI, same free-typed content model as every other machine. Line spacing still comes from the existing lpi field (v2 instead hardcodes Font_Size_Selected*2*row, tied to KBSTRING’s fixed-row layout - not meaningful for freeform text).

Data: Composer_Pitch_List (121 real [char, units] pairs, extracted verbatim from v2/ibm.scad:94-111 via script, not hand-transcribed) lives in config/selectric_composer.yaml (+ .running.yaml)’s type_test.pitch_list, per this repo’s “real machine numbers live in config YAML” rule - kept as an ORDERED LIST, not a dict: v2’s search()-based lookup returns the FIRST match, and ü/ö/( legitimately appear twice in the real table with different unit values at each occurrence - a dict would silently keep whichever duplicate key YAML parsing happens to keep last, backwards from v2’s real semantics. type_test.units_per_inch already existed (stubbed in during the original Selectric port, comment said “not wired up yet”); added type_test.default_units: 9 for v2’s SearchChar(...)==undef?9:... fallback.

Code: type_test.py gained _composer_unit_width() (linear first-match scan over the ordered pitch list, mirroring search() exactly) and build_type_test_line() grew optional composer_pitch_list/ composer_units_per_inch/composer_default_units params - when given, each line’s per-character x position becomes (cumulative units before char + this char's own units/2 - line's total units/2) * unit_dist_mm, the direct proportional generalization of the existing fixed-CPI centering formula (verified algebraically identical to the old formula when every character’s width is forced equal). None (the default) keeps the exact old fixed-CPI behavior - a no-op for every other machine. New --composer-config <path> CLI flag loads type_test.pitch_list/units_per_inch/default_units from a machine YAML directly (loading the whole section rather than serializing 121 pairs onto the command line). tune.py’s Type Test tab gained a Composer-only “Units/inch” field (bespoke widget, same pattern as the existing cpi/lpi fields - type_test.pitch_list itself stays YAML-only, no widget, same “list-valued key needs an explicit decision” treatment as layout.placement_map elsewhere) and action_render_type_test now passes --composer-config self.config_path when self.machine == "selectric_composer", a no-op everywhere else.

Verified: iMiM (i=3 units, M=9 units - a maximally different pair) renders to a 7.68mm-wide block under proportional spacing vs. 8.95mm under fixed CPI at the same CPI/font settings - confirms real, different geometry, not just a pass-through. An unlisted character (tested with an emoji) falls back to default_units=9 without crashing. Both config/selectric_composer.yaml and .running.yaml verified to parse with all 121 pitch_list entries intact.

79. Vogue Slug: AR1 logo scale fixed for real - the earlier “fix” (commit 4aade67) had re-broken it the other way (2026-08-01)

User reported the AR1 logo’s scale was off on Vogue Slug. Root cause: logo.scale_mm_per_unit was a SINGLE config field shared between two genuinely different real v1 values - v1/Type Slugs/VogueSlug.scad has SVG_Scale=1/40*SVG_Size for AR1/Logo() and a separate SVG_V1_Scale=1/80*SVG_V1_Size for the real 2-piece Vogue Foundry mark (VogueMark()) - never the same variable in the source (:55-56). A prior session (commit 4aade67, “fix vogue_slug.yaml - logo. scale_mm_per_unit left at wrong value”) fixed this field FOR the Vogue mark (0.0025, giving it a correct ~1.6x1.6mm footprint) by re-tuning the one shared number - which necessarily left AR1 wrong instead whenever it’s enabled (0.0025 is ~8x too small for AR1.svg’s own much larger viewBox), since only one real value can live in one shared field. Confirmed AR1’s real target size independently: v1/Type Slugs/ TypeSlug.scad’s own SVG_Scale formula uses the byte-identical SVG_Size=2, so config/type_slug.yaml’s own empirically-tuned scale_mm_per_unit: 0.02 (~2mm AR1 span) is the same real target Vogue Slug’s AR1 needs, not a coincidence to re-derive from scratch.

Real fix: split into two fields, matching v1’s own two-variable reality instead of re-tuning one shared number again (which would just move the bug back to whichever logo is OFF at any given moment - same failure mode, different value). config/vogue_slug.yaml (+.running. yaml)/type_slug.yaml/gauge_slug.yaml’s logo: section: scale_mm_ per_unit stays AR1-only (reverted to 0.02, matching type_slug.yaml exactly); new vogue_scale_mm_per_unit (0.0025) is Vogue-mark-only - added to type_slug/gauge_slug too even though vogue_enabled: false there (same “populate even when unused” treatment vogue_arrow_svg_ file/vogue_v_svg_file already get, since wing_slug._receive_config picks up every capital-leading global unconditionally regardless of which logo is enabled). lib/wing_slug.py’s VogueMark() now reads a new Vogue_Scale_Mm_Per_Unit global instead of reusing Logo_Scale_Mm_ Per_Unit; all three sibling configure()s (lib/vogue_slug.py/ type_slug.py/gauge_slug.py) set it from logo["vogue_scale_mm_per_ unit"]. tune.py’s shared LOGO_FIELDS_SLUG gained the new field.

config/vogue_slug.running.yaml needed care, not a blind copy from master: it had live user session state (logo_enabled: true, vogue_ enabled: false - the user had isolated AR1 alone to see the bad scale, matching their report) that must not be clobbered, plus what looks like a partial external auto-sync of this session’s master-file edit (a duplicate vogue_scale_mm_per_unit block prepended above the OLD, still-unfixed scale_mm_per_unit: 0.0025/stale comment) that needed cleaning up rather than re-applying on top. Fixed in place: kept both live toggles, fixed scale_mm_per_unit to 0.02, de-duplicated the comment block.

Verified via direct wing_slug.Logo()/VogueMark() calls against vogue_slug.running.yaml (master’s own font.path turned out to be a separate, pre-existing dangling reference to a deleted ~/Downloads/ file, unrelated to this fix - .running.yaml’s own font path is real and was used instead): AR1 now spans 1.97x2.09mm (matches type_slug’s own real AR1 output), Vogue mark independently spans 1.64x1.62mm (matches its own documented ~1.6x1.6mm target) - both correct simultaneously for the first time. type_slug/gauge_slug (vogue_ enabled: false in both, scale_mm_per_unit unchanged at 0.02) hard- gate re-verified byte-identical (--no-minkowski) to confirm zero side effects on the two machines that weren’t the point of this fix.

80. Layout presets extracted out of tune.py into lib/layouts/; three real manufacturer catalogs transcribed; two inherited missing-letter bugs found and fixed (2026-08-11)

Long session, four distinct threads. Ordered as they happened.

Thread 1 - two inherited transcription bugs, same shape. The user supplied a Hammond type-shuttle catalog (Form QQ-10M-11-20-W, Nov 1920) and later Blickensderfer type-wheel catalog scans. Both turned up a real defect that had shipped since v1:

  • Hammond Ideal read ?zxqkjg**d**mpcfld, where the catalog reads b - dropping b from the layout entirely and duplicating d (v1/Hammond/HammondSplitShuttle.scad:24, v2/hammond_split.scad:76).
  • Blickensderfer CHARIENSTU_DE/_MOD read ...GMDB:WKJ**U** where it should be Y - dropping Y, duplicating U (v1/Blickensderfer/Blickensderfer2.scad:82,85,88, v2/lib/layouts/blick_layouts.scad:18,21,24).

Neither is a v4 porting slip; both are errors in the ORIGINAL source, so this is the documented narrow exception to CLAUDE.md’s “v2 is ground truth” rule - v2 was itself transcribed from these catalogs, so on layout CONTENT specifically the catalog is the earlier authority. Recorded at both config/definition sites so neither gets “corrected” back.

The cheap check that generalises: compare the lowercase row’s letter inventory against the uppercase row’s. A duplicated letter plus a missing one is this bug class’s signature. It found the Blickensderfer bug mechanically, without reading the scan at all, and would have found the Hammond one. Now run over every Blickensderfer preset - all clean (HEBREW_ENGL’s row 0 is Hebrew so the Latin check correctly doesn’t apply).

Thread 2 - tune.py no longer holds layout data (per explicit user direction: “layouts should not be hardcoded in tune.py”). ~840 lines moved out (4802 -> 3964) into lib/layouts/<machine>_layout.py, aggregated by a new lib/layouts/__init__.py. That package already existed for the 3 Selectric machines’ hemisphere permutations, so this extended an established home rather than inventing one. Blocks were relocated verbatim by line range, not retyped; all 18 layout tables dumped to JSON before and after are byte-identical.

Two things the move surfaced, both worth remembering:

  • The package is reachable under TWO module names - lib.layouts (repo root on sys.path: tune.py, font_coverage.py) and plain layouts (lib/ on sys.path: lib/selectric12.py’s from layouts.selectric12_layout import ...). Adding __init__.py turned it from a namespace package into a real one, so that __init__ now executes on BOTH paths - its imports must therefore be RELATIVE. The first version used absolute imports and only passed because cwd happened to be on sys.path; it would have broken the Selectric build path from any other directory. Verified both names resolve from a foreign cwd and by a real selectric12 build.
  • font_coverage.py --preset used to import tune purely to reach these tables, so listing a charset required textual. It now reads lib/layouts directly, which has no third-party imports - confirmed by loading it on bare system python where import textual fails.

Thread 3 - Selectric layout/mapping pairing. The user asked whether Selectric’s multiple character mappings belong in layouts too (they already did) and observed that several layouts can share one mapping. Each layout now NAMES its map via a PRESET_HEMISPHERE_MAP in the same module, many-to-one, with LAYOUT_PRESET_HEMISPHERE_MAP_BY_MACHINE DERIVED from those rather than hand-written. This closed a silent bad-geometry hole: an unpaired preset does not fall back to a default - _save_to_yaml (tune.py:3052) only patches layout.hemisphere_map when the lookup hits, so the config silently kept the PREVIOUS preset’s value, and these maps are position-only, so a mismatch builds a WRONG typeball with every character present. Each module now asserts full pairing coverage at import; verified by adding an unpaired GERMAN preset in a scratch copy (fails with unpaired=['GERMAN']). Composer is deliberately exempt (one fixed map, no hemisphere_map config key at all) and says so.

Thread 4 - catalog transcription. Three sources: Hammond 1920 (by shuttle number), Hammond 1915 (35pp, by LANGUAGE - the more comprehensive one), Blickensderfer (14 page scans, by language/market). The 1915 catalog independently CONFIRMS the 1920-derived Ideal layouts character for character, five years apart; the Blickensderfer scans confirm DHIATENSOR likewise; and an independent re-transcription of Hammond’s Universal rows came out byte-identical to the shipped Normal Universal.

Imported 9 catalog-derived layouts total (hammond 10 presets, hammond_split 11, blickensderfer 8). Full narrative, every judgement call with what would have to be true for it to be wrong, the complete not-imported list with per-entry reasons, and a prioritised backlog now live in LAYOUT_TRANSCRIPTION.md - written because several imports rest on reasoning rather than on reading a glyph, and that reasoning previously existed only in commit messages.

The single most useful technique: read the catalog by COLUMN, not by row. Each column is one physical key at three shift levels, so the rows constrain each other. That is what settled ƒ vs f (row 0 already has an f; a figures row doesn’t repeat a letter with its own key), the & re-homed onto the shifted . key in the fractions shuttles, and two apparent oddities that turned out to be print artifacts rather than characters (shuttle 26’s p:-, 41’s P:: - both the ;/: key, whose forms are fixed by the keyboard).

Also established, from the user: “Caps and Small Caps” is a TYPEFACE, not a layout. Row 0 stores lowercase, because on a small-caps face the lowercase codepoints ARE the small capitals, so a correct font makes the cases match on its own. Universal 27/27E consequently collapses into the standard layout and is defined by reference to it. Spanish 5A does NOT collapse - its figures-row accents are genuinely full capitals (ÑÍÉÚ), reached by the figure shift rather than the case shift.

Also fixed along the way: CLAUDE.md’s hard-gate command no longer parsed (--points-per-mm was removed fleet-wide when contour sampling became adaptive; it is --flatness-tolerance-mm, and needs .venv/bin/python3). A full audit of CLAUDE.md against the tree found four more stale claims - save_config() (now _save_to_yaml()), the simplify() “no exceptions” claim (lib/heightfield_poc.py still has it, but is an unimported dead POC outside the pipeline), Bennett’s alignment_hole_height (since resolved as case (b)), and the “five quality.*_fn knobs” (a config has six - groove_fn is deliberately not one, being twist sampling rather than a facet count). README’s Minkowski note still explained cost scaling via points_per_mm.

Verified throughout: all 18 layout tables byte-identical across the extraction; TuneApp constructs for all 10 machines (scratch config copies only, per the standing warning); hard gate unchanged on every config touched - hammond verts=470670 faces=942988 volume=4876.156mm3, hammond_split verts=35240 faces=70544 volume=8081.034mm3 (moved once, by exactly the d->b fix, then stable), blickensderfer verts=38310 faces=76792 volume=5646.195mm3, plus clean selectric12/selectric3/ selectric_composer/mignon builds.

Resuming later

  1. Hammond 1915 Latin languages - biggest clean vein. Only English, Dutch, Spanish done. Croatian (58, 12C) and Danish (87, 88) on PDF p.14; Portuguese (63, 63A, 63B, 106), Roumanian (92), Polish (156, 153B, 157) on p.20. Pages 10-13, 15-19, 21-35 not sampled at all - more languages live there.
  2. Blickensderfer pages 0161-0168 - 8 of 14 page scans unread, same structure and yield as the ones already done.
  3. Hammond 1920 leftovers (41, 162, 184, 23E/23F/23G, 136) - try the ~300MB source TIFFs rather than more zooming on the small PDF; that is the specific thing likely to settle 162’s damaged glyph after 4%.
  4. Blocked on fonts, not reading: all non-Latin sets, and the 1915 pre-reform Cyrillic (needs ѣ, і, hard-sign ъ). Check font_coverage.py before starting - transcribing is pointless if no available font carries the glyphs.
  5. Known font gap, pre-existing, NOT caused by these imports: both Hammond machines are configured for OCR-A II Regular, which lacks the fractions and every accent - but equally lacks ¢/°/× from the already-shipped Normal Universal. AverageMono Mod covers all the new layouts. Worth deciding whether the shipped configs should point at a font that can actually build their own default layout.
  6. README.md was NOT audited this session (only CLAUDE.md was, plus one stale points_per_mm line in README’s Minkowski note). Its two surviving LAYOUT_PRESETS mentions were checked and are fine - line 136 describes TuneApp.LAYOUT_PRESETS as an instance attribute, still true, and line 203 names LAYOUT_PRESETS_MIGNON without claiming a location. A full README pass against current code is still owed, though, the way CLAUDE.md just got one.

81. Blickensderfer catalog read end to end - 8 to 28 presets, and CATALOG_INDEX.md’s second table became generated (2026-08-12)

Picks up part 80’s punch-list item 2 (“Blickensderfer pages 0161-0168 - 8 of 14 page scans unread”) and closes it: all 14 pages are now read, and 73 of the 84 shuttles they contain are imported, up from 10.

Working from ~/Blickensderfer-Catalog (local copies, ~2517x5548 px - markedly more legible than the Hammond scans; the letter rows read at 620 px wide and no glyph needed more than 900%).

20 new presets, from 8 to 28

Two batches. The first covered the by-language pages and the British market (GERMAN, DANISH, HUNGARIAN, BRITISH_SCIENTIFIC_FRACTION + its Mimeograph variant, DHIATENSOR_BRITISH, BRITISH_AMERICAN, BRITISH_INDIA, CHEMICAL_ENGLISH, CHEMICAL_UNIVERSAL, COSMOPOLITAN, UNIVERSAL_FRACTION, UNIVERSAL_LITERARY, UNIVERSAL_ACCENT); the second the American wheels and two more German ones (DHIATENSOR_FRACTION + its 447 variant, ENGLISH_JAPANESE, UNIVERSAL_FRACTION_US, GERMAN_ESZETT, GERMAN_FRACTION).

All 28 verified 28 columns wide, letter-inventory-consistent between rows 0 and 1, no two presets identical, and every non-ASCII character checked to have a real unicodedata.name().

The recurring structure is worth recording: $£ is a one-position American/British split that appears four separate times (QWERTY/QWERTY_BRITISH, DHIATENSOR/DHIATENSOR_BRITISH, DHIATENSOR_FRACTION/BRITISH_SCIENTIFIC_FRACTION, UNIVERSAL_FRACTION_US/UNIVERSAL_FRACTION), and BRITISH_AMERICAN is the one wheel carrying both.

Two wrong deferrals, same failure mode

Both were recorded in part 80 as reasons NOT to import, and both were wrong - a group judged on one glance instead of read:

  1. “The British Imperial/Scientific/Universal fraction variants … each packs a different dense fraction bank … needing per-entry checking rather than one shared reading.” They don’t. Twenty-one entries collapse to four layouts plus four genuine one- or two-position variants. This single deferral was hiding roughly a sixth of the catalog.
  2. “ENGLISH-JAPANESE 332/333: kana, needs its own pass.” There is no kana on it at all - it is a Latin trading wheel carrying ¥ $ £.

Lesson, kept narrow: a shared heading predicts nothing about the rows under it. Read one entry per group before deferring the group.

Genuinely held (11 of 84), all script or codepoint, none legibility

Bohemian 426/443 (doubled dead-key accents), Armenian 218, Ancient Greek 309, Hebrew 354/358/348/351, Bulgarian 452½, British Telegraph 376 (non-standard row shape), and Special British 387 - whose last five slots are shilling numerators 1⁄ 3⁄ 5⁄ 7⁄ 9⁄ cast as single pieces of type. Only the first has a codepoint (, U+215F) and layout.rows is one character per position, so 387 is held on that alone; its reading is not in doubt. (GERMAN_FRACTION/204 uses that same and DOES import, because there it appears alone.)

German came out three ways, and the third settled the first

  • CHARIENSTU_DE (v1/v2): ¨ … ö … ä … ß
  • GERMAN (404/423/378/489): № … ä … ö … ₰
  • GERMAN_ESZETT (303): ¨ … ä … ö … ß

303 carries CHARIENSTU_DE’s ¨/ß but GERMAN’s ä/ö order, which confirms ¨/ß as genuine rather than errors that / correct, and leaves CHARIENSTU_DE’s ä-at-19 as the minority reading (two catalogued wheels put ä at 8). Not enough to overwrite a v1/v2 array - unlike the WKJUWKJY fix there is no letter-inventory argument here, only a 2-to-1 count - so all three ship side by side. Separately, German 423’s row 1 was a third independent confirmation of WKJY.

An earlier draft of this work mis-attributed 303 to GERMAN; caught and corrected when page 18 was read properly.

CATALOG_INDEX.md’s Blickensderfer half is now generated

It had gone stale (“6 read, 8 not yet surveyed … Sighted: 29 shuttles”) precisely because it was the one hand-maintained table in a document whose whole premise is computed status. New lib/layouts/blickensderfer_catalog.py holds all 84 entries as data and gen_catalog_index.py now emits both sections.

The two machines get there differently, and the module says why: Hammond descriptions encode the layout, so status is classified from them; Blickensderfer descriptions don’t (“Small Roman, British Scientific” is a plain wheel on one page and a fraction wheel on another), so the observed preset is recorded per entry and the generator instead verifies that the named preset exists - SystemExit on a rename, tested by temporarily renaming BRITISH_INDIA in a throwaway copy of the tree. Regenerating twice is byte-identical.

Also fixed the index’s own intro, which claimed both tables came from printed numerical indexes. Blickensderfer has none - its denominator is what the scans contain, which is a real count of what is in hand but not of what the company made.

Denominator

Catalog pages present in the scans: 3-14, 18, 21, six entries each. Pages 1-2, 15-17, 19-20 are absent - roughly 42 more shuttles. Alphabetically 15-17 fall between English-Japanese and German (French and Esperanto sit there) and 19-20 between German and Greek. Nothing to be done without more scans, so this is now the binding Blickensderfer constraint rather than unread pages.

Verification

Pure data/doc change; no geometry touched. Hard gate byte-identical on all three plausibly-affected configs:

blickensderfer  ResinPrint:  verts=38310  faces=76792  volume=5646.195mm3
hammond         ResinPrint:  verts=470670 faces=942988 volume=4876.156mm3
hammond_split   FullElement: verts=35240  faces=70544  volume=8081.034mm3

lib/layouts still imports identically under both of its names (lib.layouts and plain layouts), and headless TuneApp against scratch config copies reaches 28/45/46 presets for blickensderfer/hammond/hammond_split.

Resuming later

  1. Blickensderfer scripts - Armenian 218, Ancient Greek 309, Hebrew 354/348/351, Bulgarian 452½. All read cleanly at this resolution; each needs a script-aware pass, not better scans. 358 needs no reading at all once 354 is done - the catalog states in prose that it is 354 with £ for $.
  2. Hammond 1915 Latin languages - unchanged from part 80’s item 1 and still the biggest remaining vein.
  3. Hammond 1920 leftovers (41, 162, 184, 23E/23F/23G, 136) - unchanged; try the ~300MB source TIFFs.
  4. Part 80’s font-gap item 5 (both Hammond machines configured for OCR-A II Regular, which cannot build their own default layout) is still open and untouched.

82. Preset naming unified fleet-wide - six machines moved off SCREAMING_SNAKE onto Hammond’s documented convention (2026-08-12)

Two naming conventions had been coexisting. Hammond and Hammond Split used a documented, compositional scheme; Mignon informally matched it; Blickensderfer, Bennett, Helios and the three Selectrics spelled their presets SCREAMING_SNAKE. Part 81’s twenty new Blickensderfer presets made the split conspicuous enough to fix.

One convention now, all ten machines and 166 presets:

<Keyboard>[, <Language>][ (<Variant>)]

Title Case; a comma introduces the language, or the variant when there is no language; parentheses hold the variant once a language is named, with multiple variants comma-separated inside one pair.

“Keyboard” means whatever axis a machine’s own sources divide its range along - Ideal/Universal for the Hammonds, the letter arrangement for Blickensderfer. A machine with one keyboard starts at the language.

Blickensderfer gained a real keyboard axis in the process

Its 28 presets had been named ad hoc by whatever distinguished them (BRITISH_LITERARY, UNIVERSAL_ACCENT, CHARIENSTU_DE), which hid the structure. Sorting them under the three letter arrangements the catalog itself divides by - DHIATENSOR, QWERTY, CHARIENSTU - made two things visible that the old names actively obscured:

  • HUNGARIAN is a DHIATENSOR wheel, not the “wholly different letter arrangement” its own comment claimed. Positions 0/2/3/6/7/8 are unchanged, “hiatens” sits at 10-16 exactly as on the plain wheel, and “lcm” follows at 19-21; only the vowel/accent slots move. Now DHIATENSOR, Hungarian, with the offsets recorded.
  • COSMOPOLITAN and UNIVERSAL_ACCENT are one market on two keyboards (catalog p.9 prints them under the same COSMOPOLITAN heading). Now DHIATENSOR, Cosmopolitan and QWERTY, Cosmopolitan, which also corrects part 81’s $£ claim from “three times over” to four - the pairing is easier to count when the names line up.

SCREAMING_SNAKE now means “v1/v2 source quote”

blickensderfer_layout.py, helios_layout.py and the two Selectric modules all quote v1/v2 .scad array names in their comments (LAYOUT=GERMAN_MOD, Layouts=[GERMAN, GERMAN_MOD, ROTUNDA], S12_HEMISPHERE_MAP_FINNISH_SWEDISH). Those were deliberately NOT renamed - they are citations, and each module now says so. This is also why the rename could not be done as a blanket text substitution: the mechanical pass touched only quoted dict keys and blickensderfer_catalog.py’s preset column, and every prose reference was rewritten by hand.

The convention’s authoritative statement moved from hammond_layout.py (where it read “both Hammond machines, keep it”) to lib/layouts/__init__.py’s docstring, since it is no longer a per-machine rule; hammond_layout.py now points at it rather than restating it. CLAUDE.md gained a matching bullet.

Verification - the point of the exercise is that NOTHING moved

  • Row data identical. Each machine’s set of row-tuples compared against HEAD - byte-for-byte unchanged across all ten.
  • Associations preserved. Every old key’s rows compared against its new key’s rows through the explicit rename map, plus a check that no unrenamed preset changed and no count changed. All pass.
  • The Selectric 12/3 modules’ import-time asserts (PRESET_HEMISPHERE_MAP must cover LAYOUT_PRESETS exactly) would have caught a half-done rename on those two; they pass, and the derived LAYOUT_PRESET_HEMISPHERE_MAP_BY_MACHINE came out renamed in lockstep.
  • lib/layouts still imports identically under both of its names.
  • Headless TuneApp against scratch config copies reaches the right preset count on all ten machines (28/1/30/4/3/45/46/2/2/5).
  • font_coverage.py --preset exercised on five renamed presets across five machines; its unknown-preset error still lists the real choices.
  • Hard gate unchanged: blickensderfer verts=38310 faces=76792 volume=5646.195mm3. (Nothing here can reach geometry - generate.py reads layout.rows from YAML and never sees a preset name - but the gate was run anyway.)

Renaming is safe in general because no config stores a preset NAME, only the rows it produced. The one place names ARE stored is blickensderfer_catalog.py’s preset column, and gen_catalog_index.py exits non-zero when one stops resolving - so a future half-done rename fails loudly instead of leaving stale “imported” rows.

Resuming later

Part 81’s punch list is unchanged and still current. Nothing new opened here. Two naming judgements were deliberately left alone rather than “unified”, and should stay that way unless there is a reason:

  1. Mignon’s variant numbers (English 2, Danish 3) are the real Mignon type-cylinder numbers, not an informal suffix - left bare rather than parenthesised into English (2).
  2. Roumanian (Hammond) vs Romanian (Mignon) is a real spelling difference between two primary sources of different dates. Each is faithful to its own catalog; unifying them would make one of them wrong.

83. Fonts are now picked by name from the installed list, not by hunting for a file (2026-08-12)

Asked for: “for font selection, rather than selecting font file, can we set it so that we have a dropdown of installed system fonts? make sure itll work for both linux and windows.”

Every font path field in tune.py - font.path, logo.font_path, and the per-machine label/legend font paths (FONT_PATH_FIELD_KEYS) - now has an Installed button beside the original file browser, which is now labelled File. The two sit on their own row under the input rather than beside it; the form pane is 58 columns, and a label plus two buttons left the path field too narrow to read.

Configs are untouched by any of this. They still store a plain absolute path, so generate.py and every machine module are unaffected - this only changes how that path gets chosen.

What counts as “installed” (lib/system_fonts.py)

New module, deliberately separate from the TUI the same way lib/layouts/ is:

  • Linux: fc-list, not a hardcoded directory list. This is the load- bearing choice. ~/fonts is a registered fontconfig directory on this machine (one <dir> line in ~/.config/fontconfig/fonts.conf) and holds most of the real library - 1388 of the 2214 files found. Any /usr/share/fonts-style scan misses all of it; that scan survives only as the fallback for a machine with no fontconfig, where it finds 729.
  • Windows: a real directory scan of %WINDIR%\Fonts plus %LOCALAPPDATA%\Microsoft\Windows\Fonts - the per-user one matters, it’s where the non-admin “install for me only” default puts fonts. Names come from each file’s own name table via freetype.
  • One entry per FILE, deduped by path: a config field holds a path with no face index, so a .ttc collection that fontconfig reports as several faces collapses to its first face - picking it selects the file, which is what the pipeline would load anyway. Filtered to the four extensions the glyph pipeline can actually load, and cached.

Family/style always come from the font’s own name table, never the filename - a filename is frequently a renamed or version-numbered copy that doesn’t say which typeface it holds, which is the whole reason a name-based picker beats a file browser here.

The picker, and the cap that got removed

First version capped the list at 300 matches. Reported: “lets say i dont know what font i want to use. i want to keep scrolling. i cap out at 300.” Fair - browsing the whole library is a real way to use this, and a cap turns that into a dead end.

Measured before changing anything: the full 2214-entry list costs ~21ms in add_options plus ~67ms to settle, against ~33ms for 300. The cap was buying about 50ms per keystroke in exchange for truncating the answer. Removed.

What the cap was actually covering was the worst-case keystroke, not the list size - a filter still matching nearly everything (typing “a”, which every path contains) rebuilds all 2214 rows at 150-240ms, which reads as lag while typing. That is now handled by debouncing the filter 150ms, which scales with the library instead of truncating it. Two edge cases that needed handling and are covered by tests: Enter flushes a pending rebuild before picking (typing then immediately pressing Enter must not act on the previous keystroke’s list), and dismissal cancels the timer so it can’t fire against a screen that’s already gone.

Also: PageUp/PageDown by the list’s own visible height, driven from the filter box without tabbing away, since arrow-at-a-time through a couple thousand fonts isn’t browsing. The currently-configured font is pinned to the top and pre-highlighted - otherwise the picker opens scrolled to “A…” with no sign of what’s selected - and the highlighted entry’s full path shows below the list, since two installed files can carry the same family name.

A filtered list rather than a Select dropdown, despite the request saying “dropdown”: 2214 options in an overlay with no way to type at it is unusable, and Textual’s Select has no filter.

Verified

Headless (scratch config copies only - TuneApp.__init__ has a real migrate+save side effect, so it never points at a live config) across Blickensderfer, Hammond, Mignon, Bennett and Selectric 12: every font field gets both buttons, the picker opens/filters/pins/picks, the input and its “Currently selected:” label follow, Esc leaves the value alone, the last entry of the uncapped list is reachable, and paging moves by a full page.

Run on both platforms, not just claimed to work on both:

  fonts enumeration
Linux (fc-list) 2214 0.13s
Linux (fallback scan) 729 0.11s
Windows (C:\Windows\Fonts) 704 0.37s

The same smoke test passes on the Windows box against real Windows font paths. No geometry code was touched, so the mesh hard gate doesn’t apply.

Resuming later

Part 81’s punch list is unchanged. One thing opened here, on the Windows checkout rather than in the code:

  1. config/selectric12.yaml and selectric3.yaml are modified in the Windows working tree, pointing font.path/font2_path at C:/Windows/Fonts/arial.ttf/times.ttf instead of the committed Linux paths. They were stashed to let the pull through and popped back. selectric_composer.yaml conflicted (upstream had deliberately moved it to Glass Antiqua) and was resolved in favour of upstream. This feature is what makes those hand-edits unnecessary - the fields can be re-pointed from the TUI now - so they should probably be reverted so that tree stops going dirty on every pull. Also stash@{1} there holds encoding="utf-8" fixes for 10 machine modules that are already upstream verbatim; it’s redundant and can be dropped.

84. Sections moved off the horizontal tab bar onto a vertical list - all of them visible at once (2026-08-12)

Reported: “the tab selection in tuner.py is cumbersome to use. you dont see the other options. maybe we can take up some more space that occupies the console output for better/easier navigation.”

Confirmed before changing anything, by rendering Mignon’s form at 150x40: 12 tabs exist, five fit (“Font & Alignment, Type Test, Resin, Calibration, Build”), and the remaining seven are off the end of the bar with nothing on screen saying so. Machines run 9-14 sections.

The tab bar is now a vertical list down the left of the form (#section-nav, an OptionList), and TabbedContent’s own horizontal bar is hidden in CSS. Vertical rather than a wrapped/two-row horizontal bar because the cost lands on width, which the log pane can spare, rather than height, which the form cannot.

TabbedContent itself stays - it still owns which pane is visible, so every _compose_*_tab method, every tab id, and the Build tab’s tab-build-style ids are untouched. Highlighting a section in the list sets TabbedContent.active; switching happens on HIGHLIGHTED rather than SELECTED so arrowing through the list moves through sections live, the way the old bar’s left/right did.

_tab_specs()

The nav list and the panes have to agree about which tabs exist, and that set is genuinely machine-conditional (Bennett has Label where others have Logo, the Selectrics have no Layout or Calibration, only the Type Slug family has Character/Ticks). Rather than repeat that branching, one new method returns (pane id, nav label, compose callable) per tab and both are built from it. _compose_section_tab’s inline pane-id derivation became _section_tab_id() so the nav can point at the same ids.

Widths, and the overlap bug this surfaced

#form went 58 -> 78: the nav’s 20 columns are added to what was there, so the content half stays 56 and no field row re-lays out. max-width: 70% keeps a narrow terminal from starving the log pane. Verified at 150x40 (form 78, log 72) and 100x30 (form 68, log 26).

One real bug found by checking regions rather than eyeballing the render: TabbedContent’s own DEFAULT_CSS is width: 100%, which inside the new Horizontal means 100% of the whole row, not the part left over beside the nav - so it laid out 76 columns wide starting at x=21, running 20 columns past the form’s right edge and under the log pane. Fixed with an explicit #tabs { width: 1fr; }. The tests now assert that nothing in the form spills past its right edge, since the crude text-extraction screenshots did NOT make this obvious - overlapping widgets just look like merged text.

Verified

Headless across Blickensderfer, Hammond, Mignon, Bennett and Selectric 12 (scratch config copies only): nav entries and pane ids match exactly and in order, every section is reachable and actually switches the visible pane, every machine’s full section list fits without scrolling at both 150x40 and 100x30, arrow keys move through sections, and no widget overflows the form pane. The font-picker tests from part 83 all still pass against the new layout.

Resuming later

Part 81’s punch list and part 83’s Windows-checkout note are unchanged. Nothing new opened here.

85. RobertG’s OpenSCAD Blickensderfer generator compared against v4; four of its ideas ported, plus the early drive fitting and wheel cosmetics (2026-08-14)

Started as a read of someone else’s work - a zip of OpenSCAD files for generating new Blickensderfer type wheels (RobertG, badonoer.blogspot.com, CC BY-SA 4.0, March 2025: a 370-line engine, a 37-line layouts file, 7 per-wheel configs). It is FDM-targeted where v4 is not, and one machine deep where v4 is ten.

What the comparison actually turned up

Independent convergence on the numbers nobody has documented. He measured a real Blickensderfer 7; v4 ported from v2. Element diameter 34 vs 34.0, 28 columns both, shaft bore 3.34 vs 3.175+0.14=3.315 (within 0.025mm from two unrelated derivations). Baselines 4.00/10.11/15.75 from drum top vs v4’s -4/-10.3/-16.1 from clip end: row 1 identical, drift growing to 0.35mm by row 3 - and his drum is 0.40mm shorter than v4’s, so the two sets may be describing the same physical positions from different datums. He explicitly disclaims these as “best-guess estimates only… based on only a single typewriter”, which is exactly what v4’s calibration sweep exists to settle.

He also independently arrived at v4’s hardest-won invariant: his typeSlug() differences the character slab against the platen cylinder INSIDE the minkowski(), on the base solid - the lesson README says v4 learned twice.

Where he is ahead: nozzle compensation (absent from v4 entirely), and a much richer per-glyph tweak vocabulary. Where v4 is ahead: manifold3d instead of OpenSCAD’s minkowski() (his own post says “a render may take a few minutes”), 28 layout presets plus a catalog index vs his 6, and any watertightness/manifold verification at all - he has none.

Ported (four commits)

Caret drop / underscore lift (alignment.caret_drop_mm/ underscore_lift_mm, 0.0 fleet-wide). The Blickensderfer’s caret sits on the baseline but no common digital font ships that glyph - U+005E is drawn at cap height because there it doubles as the spacing circumflex accent. Same shape of problem for U+005F, which many TTFs sink below the baseline to clear descenders. Both are pure translations, so one signed offset each. The running font is a textbook case: ^ at y=[1.95, 3.26], _ at y=[-0.47, -0.25].

Landed in the existing alignment: section, not a new one - these are per-character positional nudges exactly like modified_left_chars, just on Y, so they ride the already-threaded ALIGN_KWARGS channel and needed no signature changes anywhere. glyph_poc.alignment_offset() now returns (dx, dy) in one call so a caller cannot apply one axis and forget the other; alignment_x_offset() survives as a thin wrapper.

Extended to the spherical family too, on request. The redundancy question that raised, answered: this does NOT make the Selectrics’ custom_v_chars/custom_v_offset redundant and neither subsumes the other - custom_v is ONE arbitrary character set sharing ONE offset, caret/underscore is TWO groups with independent values. Worth knowing custom_v_chars is "" in all three Selectric configs, so its faithfully-ported -0.2 has never applied to anything. The print-critical Composer values are x_pos_offset/y_pos_offset, untouched here.

Early drive-pin style. Drive_Pin_Style=1 was config-settable but raised NotImplementedError. The early fitting is a SLOT (hull of two cylinders spanning 3.5mm radially, 2.20mm wide, countersink d=3.5 at radius 11.05), and its long axis runs radially where the later pin’s runs tangentially - real v2 asymmetry. v2 repeats the same style ternary in three places (the pin cut, HollowSpace’s countersink boss, ResinSupport’s drive-pin support); all three now resolve through one _drive_pin_countersink(), so the style cannot be half-wired. Dropdown went on Element, not Build - it selects which machine generation the element physically fits.

Wheel cosmetics - round/notched/banded, on a new Cosmetics tab. Faceting count is derived from layout.latitude_columns and deliberately not configurable: characters sit at (0.5 + col) * Latitude_Int and a trimesh cylinder’s first vertex is at angle 0, so an N-section cylinder’s corners land exactly halfway between columns with no phase rotation. Band Z is derived from baseline midpoints plus a per-band offset so bands follow the rows if the baselines are retuned.

Two mechanisms worth reusing next time

SELECT_FIELD_OPTIONS replaced the hardcoded elif key == "mode" branch in _compose_section_tab, so a new dropdown field is one dict entry rather than another branch. And the Cosmetics tab proved _tab_specs() earns its keep: registering the section only in SECTIONS_BY_MACHINE produced a tab with no widgets and a KeyError on save - exactly the failure that method exists to prevent.

Verified

Full-fleet gate at every step: all 14 buildable configs byte-identical to the pre-session commit (2d63583), run from a clean worktree. Knobs confirmed live rather than inert in each case - caret/underscore move glyphs by exactly the requested mm on both cylinder and spherical families, the early slot measures X=[9.300, 12.800] Y=[-1.750, 1.750] matching v2’s arithmetic, and banded’s ring at z=10.000 is a clean cylinder at r=16.7431 against a target of 16.7431.

Notch cutters are hulled outward, not bare cylinders

Follow-up on the notched style, on the user’s call: each notch cutter is a hull of two same-diameter cylinders, the second notch_extension further out radially, rather than one cylinder on the facet corner.

The reason is the Minkowski draft skirt, not the body wall - and it does not show up under --no-minkowski at all, where the change is volume- neutral (5444.759mm3 either way). A cylinder centred on the corner lies entirely within Element_Diameter/2 + Notch_Diameter/2, but characters protrude to Char_Protrusion beyond the body and their draft sweep flares outward from the base, spreading angularly toward the corners in exactly the radial band a bare cylinder never reaches.

Measured with a real Minkowski build (blickensderfer, notched): the hulled cutter removes an extra 0.1221mm3 in 89 pieces, reaching to r=17.721 with 92.3% of it outside the r=17.000 body wall - i.e. almost entirely material standing proud of the body, which is the flare. Mesh also gets simpler, 106502 -> 104914 faces, both watertight. This is the one change here that genuinely needed a Minkowski-enabled gate run rather than the usual --no-minkowski one.

Builds can be cancelled, and starting one stops the last

Reported: press Render, then Preview while it is still going - the preview loads, but the render keeps running in the background.

Cause: run_worker(exclusive=True) cancels the previous WORKER coroutine, which was sitting in async for line in proc.stdout. The coroutine dies, the child process does not - it just runs to completion unattended, burning CPU on output nobody will ever see. Nothing in _stream_subprocess had ever held a reference to the process for long enough to kill it.

Fix is three small pieces:

  • self._build_proc tracks the live child, and _stream_subprocess’s finally now kills it on ANY exit path including cancellation. It only clears the shared handle if self._build_proc is proc, because a cancelled coroutine’s finally can run AFTER its replacement has already installed its own process - clearing unconditionally would have hidden the Cancel button for a job that was still running.
  • _stream_subprocess also kills any surviving child on ENTRY, since worker cancellation is asynchronous and ordering between the two is not guaranteed. Belt and braces, and it makes the guarantee explicit rather than timing-dependent.
  • A CANCEL button (and c binding), living in the progress row rather than the main button row - it belongs to the job in flight, and the three primary buttons are equal-width 1fr, so a fourth would narrow all of them permanently for something only meaningful part of the time. Hidden unless a job is running.

SIGTERM, not SIGINT, deliberately: Python installs no SIGTERM handler so the OS default action applies immediately, whereas a KeyboardInterrupt would not be raised until control returned to the interpreter - possibly minutes later, inside manifold3d’s C++ Minkowski code, which is exactly the wait being cancelled. A signal death gives returncode < 0, reported as a yellow “cancelled”, not a red error.

Covers type_test.py/font_coverage.py/legend runs too, since they all go through the same _stream_subprocess.

Verified headlessly at the OS process level, not just in the UI: the Cancel button takes a real build from alive=True to alive=False and clears the handle/hides the button; and pressing Preview during a Render kills the render’s actual pid while the preview’s own pid keeps running. No orphaned processes and no partial output file left behind.

Type Test audited knob by knob

Asked to confirm every adjustment actually reaches, and actually moves, the Type Test preview - not just that the flags are accepted.

Dumped the real command action_render_type_test builds for Blickensderfer/Selectric 12/Hammond Split/Mignon (monkeypatching _stream_subprocess to capture rather than run), then rendered A/B pairs and measured the mesh. All ten knobs move the geometry by exactly the requested amount, on the running font (Royal Vogue v3):

knob measured
caret_drop_mm 2.4 dy -2.4000
underscore_lift_mm 0.7 dy +0.7000
modified_left_offset_mm -1.0 dx -1.0000
modified_right_offset_mm +1.0 dx +1.0000
align_mode centre -> left dx +1.4132 (half X’s advance)
center_offset_mm 0.8 dx +0.8000
left_offset_mm 0.8 dx +0.8000
font size 3.7 -> 5.0 glyph 1.1501 taller
cpi 10 -> 5 pitch 2.54 -> 5.08
lpi 6 -> 3 line pitch 4.2333 -> 8.4667

One real gap found and fixed: the Selectric family’s Font2 (font2_chars/font2_path/font2_size_mm, v2 Font2_Chars ibm.scad:205) was never passed to Type Test, so the preview rendered every character in the base font even where the real ball would use font2. type_test.py already had the mechanism - --mod-chars/--mod-font-path/ --mod-font-size-mm, built for Hammond Split’s Char_Mod - so only the passthrough was missing. Latent rather than live: font2_chars is "" in all three Selectric configs, so nothing was actually being lost yet. Composer correctly sizes font2 by cap height (font2_composer_cap_height/2.834) like its base font, not a direct mm size. Verified with font2_chars seeded to “12”: FreeSans vs FreeSerif digits render at volume 0.9501 vs 0.6703mm3.

Deliberately still NOT passed, and correct as-is: Selectric’s x_pos_offset/y_pos_offset. Both are uniform translations of every glyph, so in a flat multi-character sheet they would shift the whole preview identically and tell you nothing. Its custom_h_chars/ custom_v_chars are per-character and WOULD matter, but are empty in all three configs; wiring custom_v in would need the y-offset mechanism generalized to arbitrary character sets (the same merge direction noted for caret/underscore above), so it is left alone rather than half-solved.

The tuned running copy promoted to be the master’s defaults

First attempt read “used as a new preset” as a variant config (blickensderfer_freemono_thin.yaml, following the existing <machine>_<font>.yaml convention). Wrong reading - the ask was for these to become config/blickensderfer.yaml’s own DEFAULTS. Variant removed, values promoted into the master instead.

Applied as targeted value edits rather than copying the running file over the master, because the two had drifted in COMMENTS (the master carries the block comment explaining drive_pin_style plus inline notes on the early-slot trio; the running copy has tune.py’s flatter versions). Copying wholesale would have silently thrown the better documentation away.

Promoted: FreeMono Thin, logo text_spacing 7.0 / radial_offset_mm 1.6, the QWERTY preset with modify_glyphs: false, both modified-char x-nudges to 0.0, caret_drop_mm: 2.4, resin_support: false, wheel_style: notched at notch_diameter: 0.5, and band offsets/heights [1.2, 1.0] / [1.2, 1.0] - the last of which quietly closes the open 2.0mm-band item, since those fit the 1.406/1.860mm real gaps.

Nothing in element:, quality: or resin: changed - every delta was a preference-level setting, so the 1:1-from-v2 geometry this file documents itself as carrying is untouched.

Two comments had to be corrected rather than left to rot, since both asserted defaults that are no longer true: the caret/underscore block said “Both 0.0 = … the behavior every config had before these keys existed”, and the cosmetics block called “round” the default. Both now scope that claim to the other machines.

New gate baseline for this config, since the change is deliberate: FullElement: verts=29489 faces=59058 volume=4354.352mm3 (FullElement, not ResinPrint, because resin_support is now false). The old ResinPrint: verts=38310 faces=76792 volume=5646.195mm3 line quoted throughout this session’s earlier parts no longer applies to blickensderfer. All 13 other configs verified byte-identical in the same run.

Verified beyond the build: a headless TuneApp load (scratch copy) shows every promoted value in its widget with the Layout tab resolving to QWERTY and the resin-support switch off, and action_reset_defaults() restores them after the widgets are scribbled over - which is what makes “defaults” mean anything.

Example renders refreshed to the new defaults

example_renders/blickensderfer.stl and its cross-section companion rebuilt from the promoted config, and both thumbnails regenerated through generate_thumbnails.py (the headless-Chrome/Three.js harness, so they match the live viewer rather than a second renderer).

Checked before publishing that the running copy really was the committed config rather than assuming it: a full-quality build from config/blickensderfer.yaml gives faces=164004 volume=4470.243mm3, identical to output/blickensderfer_running.stl. The only difference is verts (81962 vs 81921), which is STL reload merging coincident vertices, not a different mesh. Worth noting both files also report watertight=False when loaded back through trimesh - that is STL being a triangle soup on round-trip, true of every example render here and not a defect; generate.py’s own in-memory summary says watertight=True.

The cross-section was regenerated too rather than left behind: it is produced by generate.py --cross-section-angle-deg 0 (tune.py’s Build tab exposes the same thing as a switch + angle), so it is reproducible rather than a hand-made one-off, and leaving it on the old round-body Royal Vogue geometry next to a freshly notched main render would have been a visible inconsistency on the site.

Both now show what the promoted defaults actually build: the notched faceting with its corner grooves, mirrored FreeMono Thin glyphs, and the QWERTY layout.

Draft skirts clipped to their own slot; the collision check retired

Reported as drafts “leaking over to adjacent slots”, provoked by setting the draft angle to 90 degrees.

Diagnosed first. The flare is widest at the glyph’s ROOT and the column pitch is SMALLEST there (the root sits at the innermost radius), so the root is exactly where neighbours meet. At the real 55-degree draft that is 1.04mm per side against a 3.48mm root pitch - ‘M’ beside ‘M’ overlaps by 1.94mm3, measured. At 90 degrees the flare is 2.0mm per side, wider than the whole slot, and the same pair overlaps by 10.96mm3.

Also found while measuring, worth recording: separation_mm (2.0) happens to equal Char_Protrusion + Wall_Min_Thickness (0.5 + 1.5) exactly, so every character’s widest cross-section is precisely COPLANAR with the hollow interior wall. Two independently-set config values colliding; every glyph’s minimum radius measured exactly 15.500. Worth knowing, since coplanar faces are a degenerate boolean configuration regardless of the clipping question.

Fix is build.clip_to_cell: intersect each PLACED character with its own angular slot. Verified at 90 degrees - M|M, m|m and A|V all go from 10.96/8.76/4.70mm3 of overlap to exactly 0.0000mm3.

The wedge lives in scad_primitives.clip_to_angular_cell rather than any one machine, because all three families distribute characters by rotation about Z and so clip with the same shape. It is ONE triangle (axis plus a point at each boundary angle) extruded in Z, not an arc sector: only the two flat sides ever cut, and as planes through the axis they are exact at any radius. Wired into the cylinder family (both TextRing and CalibrationTextRing), Hammond Split (both its own loops), and the spherical family (clipped before the ring’s own global rotation, hence the raw longitude).

Off by default fleet-wide, so all 14 configs stay byte-identical. The checkbox is exposed for cylinder and shuttle only, per explicit user direction - the slug family strikes one character per element and has no neighbouring slot at all, and the Selectrics’ effect is marginal (measured: 0.001mm3). The config key and geometry are wired for those anyway so behavior stays uniform; only the checkbox is withheld.

_check_inter_character_collisions no longer runs on every build. It was printing ~179 tuples per build, and it reports CONTACT rather than overlap - so once clipping is on and neighbours abut exactly along their shared boundary it flags them anyway (measured: 165 “collisions” on a build whose adjacent characters have a pairwise boolean intersection of exactly 0.0mm3). Even with clipping off, what it flagged was buried inside the wall, which Additive() unions in regardless, so there was never anything to act on. The function is kept for calling by hand when a specific glyph pair needs investigating; its docstring now says all of this.

Font & Alignment profiles, machine-independent

Named profiles for the Font & Alignment tab, saved to config/profiles/font_and_alignment/<slug>.yaml and applicable to ANY machine - dial a font in on Blickensderfer, then carry it to Hammond or a Selectric without retyping. lib/font_profiles.py holds the load/save/ match logic, kept out of tune.py for the same reason lib/layouts/ and lib/system_fonts.py are, and free of third-party imports beyond PyYAML.

Profile values are keyed by DOTTED CONFIG PATH (font.size_mm), not by tune.py field key: field keys are per-machine table entries that can be renamed, the config path is the thing actually being set, and it keeps a profile file readable and hand-editable without knowing anything about the TUI.

Cross-machine application is partial and lossless-by-omission - a path the target has is applied, one it lacks is skipped, one it has that the profile is silent about keeps its value. Nothing is coerced or invented. All three counts are reported in the log. Measured with a Blickensderfer profile:

target applied ignored left as-is
blickensderfer / mignon 13 0 0
hammond_split 13 0 4 (its char_mod trio + mink_height)
selectric12 6 7 9

The universally-portable core is six values: font.path, font.size_mm, alignment.mode, alignment.caret_drop_mm, alignment.underscore_lift_mm, build.draft_angle_deg.

“Current selection” is DERIVED by comparing values, never stored in the config - same convention as _current_layout_preset(), so hand-editing a field correctly clears the selection instead of leaving a stale name pointing at something no longer true. Only the paths a profile actually carries are compared, so a profile from another family still reads as active when everything it does specify matches.

Rather than a persistent per-field warning UI (the user was unsure it was needed - “or maybe dont need that at all”), the flag is three log lines on apply. The one worth reading is “left unchanged”: that is where a cross-family profile leaves real knobs at whatever they were.

One synchronisation done: build.mink_draft_angle_deg -> build.draft_angle_deg for Hammond Split, the last machine spelling that concept differently. Same class of outlier as quality.mink_fn -> quality.minkowski_fn (CLAUDE.md “Pick one convention”), and generate.py already passed it as draft_angle_deg internally - only the config key was odd. Verified byte-identical, and it is why hammond_split now takes 13 of 13 values from a cylinder profile instead of 12.

Synchronisations NOT done, deliberately - each is a genuine semantic equivalence, but each has a hazard that makes it a separate, deliberate change rather than a drive-by:

  • alignment.custom_h_chars/custom_h_offset (Selectric) is the same concept as modified_left_chars/modified_left_offset_mm, but the spherical path applies it as x_pos_offset - custom_h_offset, i.e. SIGN-INVERTED. Aliasing without reconciling that would silently move glyphs the wrong way.
  • font2.* (Selectric) is the same concept as char_mod.* (Hammond Split) - already established when both were wired to type_test’s --mod-* flags. But Composer sizes font2 by CAP HEIGHT (font2_composer_cap_height), not mm, so a naive size alias would be a unit error.
  • alignment.x_pos_offset overlaps center_offset_mm/left_offset_mm, but the Composer’s is flagged print-critical and byte-exact from v2.

Secondary font extended to the cylinder family and Hammond

Asked whether the font2 feature should apply to cylinder and shuttle. Measured the case rather than guessing, against the 170 distinct characters across all 28 Blickensderfer layout presets:

font missing
Royal Vogue v3 87 (fractions, currency, accents)
Erica Type 52 (including “@”)
Alma Mono / CMU Typewriter / Glass Antiqua 50 each
Iosevka / Noto Sans Mono / Kurinto 28-42 (Hebrew)
FreeMono, FreeMono Thin, AverageMono, FreeSans/Serif 0

So yes - the gap is real for most typewriter faces, and total for the Hebrew-English preset. Wired into cylinder_machine.TextRing (and CalibrationTextRing) via a _font_for(char) helper, the same shape spherical_machine already used.

Proved on a real element: Royal Vogue as the main font with FreeSans as font 2 for “1/4 1/2 3/4 GBP” - characters Royal Vogue has NONE of - takes the build from verts=27344 volume=4400.315mm3 to verts=27962 volume=4404.946mm3, i.e. four characters that were silently empty become real struck glyphs.

Hammond Split’s char_mod: renamed to font2: so all three families now spell one concept one way (CLAUDE.md “Pick one convention”). Both were faithful ports of different v2 names (Char_Mod vs Font2_); font2 wins on majority (3 machines to 1) and the internal globals keep v2’s Char_Mod names, which remain the right cross-reference for that machine’s own source. Byte-identical, and it paid for itself immediately: tune.py’s Type Test passthrough collapsed from two branches to one, and three entries dropped out of EQUIVALENT_PATHS because the paths now match directly.

Note the leaf names stay prefixed (font2_path, not font_path) - the existing comment in config/hammond_split.yaml explains why, and it is load-bearing: patch_yaml_value() searches for a FIELDS key’s literal text anywhere in the file, not scoped by section, so a leaf shared with font.path would silently patch the wrong line.

Fields live in FONT_FIELDS_CYLINDER (SECTIONS_COMMON + font2 + clip_to_cell), NOT in SECTIONS_COMMON: the slug family shares that table and implements neither, and a field whose config path does not exist raises rather than degrading. Caught by the 15-machine compose test.

Profile deletion, and a note on the storage format

“How to delete? Thought it would be a yaml, not something proprietary.” It IS plain YAML and always was - `config/profiles/font_and_alignment/

.yaml`, one file per profile, keyed by dotted config path, with a header comment pointing at `lib/font_profiles.py`. Deleting the file is a complete deletion; nothing indexes or caches them (`list_profiles()` reads the directory each call). The gap was that the TUI offered no route, which is a fair thing to read as opacity. Added a Delete profile button beside Save, with a confirmation showing the exact path - deleting removes a real file the user wrote and nothing in the app can undo it, and naming the path means it can be recovered from git or a backup if the answer was wrong. `delete_profile()` matches on DISPLAY name rather than re-slugifying, so a profile whose file was renamed by hand still deletes the file the picker was actually showing. Profiles are NOT gitignored, deliberately: they are small, portable and worth sharing between checkouts, same as the configs they complement. ### Profile rename A profile has two halves to its identity - the `name:` inside the file (what the picker shows) and the filename slug derived from it - and nothing kept them in step. Editing `name:` by hand did work, since `list_profiles()` reads the display name from inside the file and never from the filename, but left the two mismatched and the file hard to find later. The only other route was Save-under-a-new-name then delete the old, which is a duplicate-and-prune, not a rename. `rename_profile()` rewrites both halves, preserving `values` and `saved_from`. Renaming to a name that slugifies to the same file (changing only case or punctuation) rewrites in place rather than deleting itself, and a rename that would land on a DIFFERENT existing profile's file raises instead of silently overwriting it. Verified: file renamed and old removed, name/saved_from/16 values intact, case-only rename keeps one file, collision refused. The Font & Alignment profile row is now Save / Delete / Rename. ### Fix: loading a machine opened the profile apply dialog Reported immediately after the profile work: "opening a machine instantly opens font preset checklist apply". Textual fires `Select.Changed` for a PROGRAMMATIC assignment exactly as it does for a click, and this dropdown's handler applies the chosen profile. Composing the Font & Alignment tab seeds the dropdown with whichever profile currently matches the config - which is a programmatic assignment - so simply loading a machine that had a matching profile opened the apply dialog before the user had done anything. Two guards, because they cover different cases: - `self._suppress_profile_apply`, set around every place the dropdown is given a value in code (`_set_profile_select`), covering the re-syncs after save/rename/delete/cancel. - A no-op check: selecting the profile that is ALREADY fully active would set every value to what it already is, so it returns early. This is what covers COMPOSE, where the widget is constructed with a value rather than assigned one, and there is no assignment to wrap. Reproduced headlessly first (seed a matching profile, load, assert no modal was pushed), then verified three ways: silent on load, silent when re-picking the already-active profile, and still prompting for a genuinely different one. All 15 machines load with no spurious modal. ### Profile selection now tracks the form live Asked for the picker to read (none) when the values don't match a profile, at startup or at any time. Two real gaps behind that: 1. `_current_font_profile()` compared against `self.cfg` - what is on DISK - so after editing a field the picker happily kept naming a profile whose values were no longer in the form. It now reads the WIDGETS (`_live_font_values()`), coercing each value with its field's declared type so "3.7" typed into an Input compares equal to a 3.7 in a profile. A field mid-edit that doesn't parse is omitted, so the match fails - which is the right answer while someone is typing. 2. Nothing re-evaluated on edit. The status line even claimed "editing any field below clears this" while nothing implemented it. Now every Font & Alignment Input, Switch and Select edit calls `_refresh_font_profile_status()`, which updates the dropdown as well as the status line. The match runs per keystroke, so profiles are cached in memory (`_cached_profiles()`) and invalidated explicitly on save/rename/delete rather than re-reading and YAML-parsing every file each time. Verified in both directions, which matters - a selection that clears but never comes back would be its own bug: matching on load, (none) after editing the font size, back to the profile when the value is typed back, (none) again after toggling the clip Switch and after changing the align mode Select. No spurious apply dialog at any point, all 15 machines load clean. ### Draft depth split from character depth, and both renamed Chasing a "spike on the X" that appeared only with resin support turned into two findings, neither of them about resin support. **The spike is not a glyph defect.** BAD vs GOOD (the user's own saved STLs, with sidecar configs differing in exactly one line, `resin_support`) have BIT-IDENTICAL character geometry: 9748 vertices each, max nearest-neighbour distance 0.0000mm, zero support-only vertices proud of the wall, and element-intersect-support of 0.00000mm3 in the character region. What differs is that `ResinPrint()` FLIPS Bennett 180 degrees, so the two renders are not the same view - and the mesh carries ~1700 near-zero-area slivers at the wall radius whose normals are ill-defined, so they shade differently depending on which side faces the camera. Those slivers come from `Additive`, not the support: `Cylinder()` alone has 0, `Additive` (84 characters unioned into it) has 1067 all at the wall, `ResinSupport` alone has 0, and the union adds 2. They cannot be stripped - `nondegenerate_faces()` at any tolerance breaks watertightness. **The real coupling.** `cone_h = separation_mm - tip_h` meant the taper ran the ENTIRE character depth, so one number set both how deep the character is buried and how much it flares. That is why lowering it to shrink the flare eventually lifted the roots clear of the wall and left the characters floating free of the body, and why spike positions reshuffled unpredictably between 1.4 and 1.5 - these are boolean retessellation artifacts, not stable geometry. Now separate, and ADDITIVE (per explicit user choice over the clamped alternative): total depth = straight + taper, with the taper added BELOW the straight part. The tip stays at `z_local = pre_minkowski_char_height_mm`, which is what `place_on_cylinder` anchors on, so the strike face does not move when either value changes. Hammond Split already worked this way (its own Mink_Height, from v2's `Mink_Radius = tan(angle/2)*Mink_Height`). **Renamed, because the old names were actively misleading** - `separation_mm` reads as character SPACING, which is the opposite of what it meant: | was | now | meaning | | --- | --- | --- | | `build.separation_mm` | `build.pre_minkowski_char_height_mm` | distance from the platen low point to the bottom of the glyph extrusion | | `build.mink_height` (+ Hammond Split's own) | `build.minkowski_cone_height_mm` | the draft cone's height | ~200 occurrences across 22 files, plus the `--separation-mm` CLI flag -> `--pre-minkowski-char-height-mm`. Both old keys are still read as fallbacks. TUI labels are "Straight depth (mm)" and "Draft depth (mm)" on the Quality tab (the old single field was labelled "Draft depth", which is why it was never obvious it also controlled burial depth). **New baselines** - volumes are IDENTICAL on all six cylinder machines, only tessellation shifts (the tip sliver is now accounted on the other side of the cone): bennett 64314 128980 3604.045 blickensderfer 29501 59082 4354.352 hammond 470599 942846 4876.156 helios 71812 143648 4215.391 mignon 20963 41998 4646.497 postal 38725 77606 5449.207 The other 8 configs are unchanged. Minkowski-enabled blickensderfer moves to verts=80570 faces=161220 volume=4468.936mm3 (was 4469.587) - a 0.015% difference, the extra 0.01mm of buried root. **What it buys**, measured on Bennett with the root kept inside the wall: | straight | draft | root | slivers | | --- | --- | --- | --- | | 0.0 | 2.0 | 14.450 (through) | 1922 | | 0.5 | 0.9 | 15.050 (in wall) | **555** | | 0.7 | 0.7 | 15.050 (in wall) | 659 | 3.5x fewer slivers at identical volume (3147.322) - the combination that was impossible while one number did both jobs. ### Preview stopped inheriting the cone height; names made literal Two follow-ups, both my errors. **The preview was wrong.** The no-Minkowski path was building its block at glyph height PLUS cone height, on the reasoning that the preview should "match the final extent". That is backwards: the preview IS the pre-Minkowski solid, so the cone must not appear in it at all. The symptom was a small glyph height still looking sunk into the cylinder in preview - the opposite of what the number says. Preview and render now build the SAME block; the only difference is whether the cone is summed onto it. Measured, glyph height 0.1 / cone 2.0 on Bennett (surface r=15.950): preview r=[16.350, 16.549] floats clear of the surface render r=[14.350, 16.549] sunk 1.60mm **The labels were wrong**, which is what made the whole exchange circular - they read "Straight depth" / "Draft depth" while the user was consistently saying "pre-Minkowski glyph height" and "Minkowski cone height". Now labelled exactly that. Whatever value goes in is that dimension, literally: total depth = glyph height + cone height, strike face fixed. **Defaults changed** from glyph 0.0 / cone 2.0 to glyph 0.1 / cone 1.9. 0.0 was faithful to the original (which really did extrude a 0.01mm sliver and let the cone carry everything), but it is a useless number to hand a user - the cone alone sets the whole depth, which is the coupling this work existed to remove, and with the preview fix above it made Quick Preview show almost nothing. 0.1/1.9 keeps total depth at the historical 2.0. Render path is unaffected by all of this: blickensderfer stays at volume=4468.936mm3. Preview-path baselines move (that is the fix): bennett 54063 108218 3554.919 blickensderfer 24557 48918 4317.672 hammond 469050 939472 4738.146 helios 65751 131238 4146.287 mignon 18290 36336 4621.277 postal 34401 68682 5397.200 The other 8 configs are unchanged. ### Sliver fix: body wall pre-banded, gated on round style Following "still getting artifacts in render with minkowski, maybe something to do with boolean union?" - the hunch was right, and the fix landed. My first pass concluded the opposite because I measured the wrong machine (Bennett's PREVIEW and blickensderfer's RENDER), and the user corrected it: the issue is Bennett. **Mechanism**, isolated by measurement: Cylinder() alone 0 slivers (longest edge 18.019mm - each wall face spans the FULL height) TextRing (84 chars unioned) 213 slivers, longest 4.077mm union(TextRing, Cylinder) 1093 slivers, longest 15.837mm ResinSupport alone 0; the union with it adds 2 trimesh's cylinder is two triangles per angular section spanning the entire height, so punching 84 character roots through them makes manifold retessellate full-height faces into long near-zero-area needles. They render as phantom spikes (a zero-area triangle has no meaningful normal) and cannot be deleted - `nondegenerate_faces()` at any tolerance breaks watertightness. **Fix:** pre-split the wall into bands roughly as tall as they are wide, so the boolean never has a full-height face to shatter. Band count is derived from Surface_Fn via `scad_primitives.square_z_segments()`, so it scales with the machine's own resolution and adds no tunable. Real Minkowski renders, identical volume in every case: | machine | unbanded | banded | | --- | --- | --- | | bennett | 1746 slivers, longest 16.806mm | **222, longest 9.800mm** | | postal | 1296, longest 15.351mm | **218, longest 7.007mm** | | blickensderfer (notched) | 3451 | 10682 - WORSE | Bennett also gets a SIMPLER mesh out of it (261492 -> 241690 faces). **Gated on round style, and that gate is measured.** On a notched or banded body the cosmetic cutters are themselves full-height - 28 of them at the facet corners - and dominate the retessellation, so banding the body backfires. Blickensderfer is notched and is left unbanded; making its bands finer made it worse still (10686). Helios and Mignon build their own bodies and are untouched pending their own measurement. New `--no-minkowski` baselines: bennett 56148 112388 3554.919 and postal 41147 82174 5397.200. The other 12 are unchanged, blickensderfer included. ### RESOLVED: a real pre-Minkowski glyph height is what fixes the artifacts The user found it: **pre-Minkowski glyph height 1.0mm + Minkowski cone height 1.5mm renders with no artifacts, preview to render.** Bennett's default is now those values. That setting only became expressible because the two depths were split (see above) - before, one number set both, and the shipped configuration had an effectively ZERO glyph extrusion (0.01mm originally, 0.1mm after the split) with the cone carrying the whole depth. A character built that way is essentially a pure cone sweep with no straight prism, and that is the input that tessellates badly. Giving it a real 1mm prism to sum with produces clean walls. Worth being blunt about the two things I changed that were NOT the fix: - **Body-wall banding** (previous commit) genuinely reduces sliver counts - Bennett 1746 -> 222, postal 1296 -> 218 - and is kept, but it was not what the user was seeing. - **My first "reverted, doesn't work" conclusion** was drawn from blickensderfer's render while the reported machine was Bennett. Wrong machine, wrong conclusion. **Coincident surfaces in the resin support, cleaned up.** The user asked whether there is z-fighting between the resin support and groove - there is, and it is real: Bennett's `ring_outer` was built at exactly `Element_Diameter` and the groove torus centred on `Element_Diameter/2`, so both share a surface EXACTLY with the element's own wall. Coincident surfaces are a degenerate boolean input and are precisely what a viewer renders as z-fighting. Insetting both by one epsilon (the ring a hair inside the element, which is also the safe direction for a support that must snap away cleanly) takes coincident face pairs from 310 to 260, and those at the wall radius from 95 to 44. Kept as a cleanup even though it was not the artifact the user was chasing. New baseline, bennett: 60065 120482 3603.688 (glyph 1.0 / cone 1.5 is a 2.5mm total character depth, up from 2.0). The other 13 are unchanged. Still open on Bennett: at 1.0/1.5 the root sits at r=13.950 against an inner wall of 14.950, so characters still punch through into the hollow. That is the sliver source identified earlier and is independent of the artifact the user was seeing - total depth under 1.5mm would keep them in the wall, at the cost of less anchoring. ### Cut-groove fidelity became adjustable (it was hardcoded) Asked whether the cut groove's detail is adjustable - it was not. The tube cross-section was a hardcoded literal in both implementations, and absurdly fine for the feature: Bennett spent **15360 faces** describing a 0.75mm groove (64 tube segments revolved at Surface_Fn=120), and the shared CutGroove used a hardcoded 32 for 9840 faces. Both now use `resin.resin_fn`, the knob that already sets every other resin-support facet count on these machines (rods, raft) - reusing the machine's existing knob rather than inventing a special-cased number, per CLAUDE.md's geometry-invariants rule. At the shipped resin_fn=20 that is 15360 -> 4800 faces for Bennett's groove and 9840 -> 6960 for the shared one, and it is now a real dial: | resin_fn | bennett faces | volume | | --- | --- | --- | | 8 | 198098 | 3653.974 | | 12 | 198722 | 3657.111 | | 20 (shipped) | 200178 | 3664.542 | | 32 | 202674 | 3667.129 | Volume moves with it because a coarser polygon inscribes less area, so the groove cuts marginally shallower - that is the expected trade of a fidelity knob, not a defect. The revolve resolution (around the element) stays on Surface_Fn: it is the groove's visible circumference and would face up badly at resin_fn. New baselines: bennett 57425 115202 3604.009, postal 39707 79294 5397.698. The other 12 are unchanged. ### Note on the wall-banding metric The user's read is right and worth recording: sliver count was MY proxy, not the artifact, and the banding may have helped for reasons that metric never measured (it re-meshes the whole outer wall, which is also where coincident-surface and shading artifacts live). Blickensderfer is currently NOT banded - the gate is `wheel_style == round` and it is notched - so its behaviour is unchanged from before that commit. ### Banding gate removed; notch fidelity became adjustable **Gate removed.** Wall banding now applies to every wheel style, not just round. It was gated on the basis that banding raised blickensderfer's SLIVER count (3451 -> 10682) - but that metric turned out not to predict the artifacts actually being seen, and the user reports no errors on blickensderfer anywhere with banding active. Keeping a gate justified only by a discredited proxy was the wrong call. Blickensderfer now gets 19 wall bands instead of 1. **Notch fidelity.** Same problem as the cut groove: the notch cutter was built at Surface_Fn, spending 484 faces per cutter x 28 = **13552 faces** on a 0.5mm decorative score line. Now `cosmetics.notch_fn` (default 16), which drops that to 28 x 68 = **1904**. It lives in `cosmetics:` beside notch_diameter/notch_extension rather than reusing an unrelated knob - resin_fn would have been the wrong section for a body cosmetic, and Surface_Fn is what was already too fine. A real dial (blickensderfer whole-element): | notch_fn | faces | volume | | --- | --- | --- | | 8 | 118778 | 4469.551 | | 12 | 119032 | 4467.171 | | 16 (shipped) | 119208 | 4466.315 | | 32 | 120218 | 4465.478 | Volume drifts slightly with it for the same reason the groove's does - a coarser cutter polygon removes marginally less material. New baseline, blickensderfer: 21780 43364 4318.768 (both changes land on it). The other 13 are unchanged. ### 1.0 / 1.5 made the fleet-wide default All six cylinder-family machines now ship `pre_minkowski_char_height_mm: 1.0` and `minkowski_cone_height_mm: 1.5`, the combination the user verified renders artifact-free on Bennett. They were 0.1 / 1.9 (Bennett already 1.0 / 1.5). Total character depth goes 2.0mm -> 2.5mm. `hammond_split` is deliberately NOT changed: its `minkowski_cone_height_mm` is 2.0 because that is v2's real `Mink_Height=2` (hammond_split.scad:70), and it has no `pre_minkowski_char_height_mm` at all - its extrusion is `Glyph_Height`. Changing a genuine ported v2 value to match a v4 tuning choice would be a silent divergence, so it stays until there is a reason. **Worth knowing before printing:** the deeper total puts the character root through the inner wall on all three machines where the wall is measurable - blickensderfer root 15.000 vs inner wall 15.500 through postal root 14.550 vs inner wall 14.900 through bennett root 13.950 vs inner wall 14.950 through That is the sliver source identified earlier, and 2.5mm makes it slightly worse than 2.0mm did. It has no effect on the printed outer surface - the roots are buried in, and now through, the wall - but if the interior matters, a total under protrusion + wall thickness keeps them contained. New baselines: blickensderfer 29264 58608 4355.448, postal 43638 87432 5449.705, mignon 20958 41988 4646.497, helios 71812 143648 4215.391, hammond 470569 942786 4876.156. Bennett unchanged (already at these values). Real Minkowski renders all build watertight: blickensderfer 4470.035mm3, bennett 3667.480mm3, postal 5541.722mm3. ### hammond_split moved onto the v4 depth convention It was the last machine with its own spelling: the glyph's extrusion depth was the implicit expression `Glyph_Height + 1.0`, written out at three call sites, with no config key at all. Now `build.pre_minkowski_char_height_mm` like everywhere else, driving both the letter extrusion AND the `Arc()` band the letters are trimmed to - those two were previously the same expression by coincidence of being typed twice, and are now the same value by construction. Set to 1.0/1.5 with the rest of the fleet. **Its cone still behaves differently, and that is v2, not an oversight.** `_letter_text_drafted` minkowski-sums with a cone spanning z=[-cone_height, 0] and then subtracts everything below z=0, so the cone shapes a flare WITHIN the glyph height rather than adding depth beneath it the way the cylinder family's does. Both the config comment and the tune.py field help now say so explicitly, since the shared key name otherwise implies shared behaviour. **Corrected same session:** the schema move was byte-identical, but it was shipped with the values ALSO changed to 1.0/1.5, which is a real geometry change and regressed the gate (35240 70544 8081.034 -> 35267 70598 7976.807). This machine's pre-existing effective glyph band was `Glyph_Height + 1.0` = 0.8 + 1.0 = **1.8mm**, not 1.0 - so setting 1.0 shrank it by 0.8mm and the characters sat shallower. Restored to 1.8 / 2.0 (its own prior values, cone = v2's Mink_Height), and the gate is back to 35240 70544 8081.034 exactly. Real Minkowski render 8082.303mm3. The lesson worth keeping: a rename/reschema commit and a value change are two different commits. Bundling them here made a pure no-op look like a regression and hid which half caused it. ### Correction: roots through the inner wall are a non-issue Flagged twice in earlier entries as a concern. It is not one, and the user was right to push back: `FullElement = Additive.difference( Subtractive)` and `HollowSpace()` is one of Subtractive's parts, so the hollowing happens AFTER the characters are unioned into the cylinder - anything a root pushes past the inner wall is simply cut away by it. Better still, the old 2.0mm total put blickensderfer's root at exactly 15.500 = the inner wall, which is the degenerate coincident-surface case. At 2.5mm it sits 0.5mm clear inside the hollow, so the cut is unambiguous. The new default is cleaner than the old one, not worse. ### min_final_character_diameter is now fleet-wide The two Hammond machines expressed character protrusion two different ways, and neither matched the cylinder family's: - **Hammond** had `element.shuttle_text_protrusion` (0.9mm) - a real named value, just not the fleet's spelling. - **Hammond Split** had NO protrusion value at all. It fell out of a bare literal in `_text_placement_ops`: `translate([Arc_OD/2 - 1.0, ...])`. That 1.0 tied protrusion to the glyph extrusion depth, so changing one moved the other - which is exactly what made the earlier 1.8 -> 1.0 glyph-height change so damaging. It did not scale the protrusion, it took it from 0.8mm to ZERO: the characters would have sat flush with the arc surface, not merely shallower. Both now derive protrusion from `element.min_final_character_diameter`, the same key the cylinder machines use - the diameter a caliper reads across two opposing character tips: | machine | surface diameter | protrusion | min_final | | --- | --- | --- | --- | | hammond | element_diameter + 2*shuttle_thickness = 75.87 | 0.9 | **77.67** | | hammond_split | arc_od = 75.00 | 0.8 | **76.60** | Per the user's own point, "min" carries no minimum-vs-maximum meaning on these machines - they strike a flat anvil, so there is no platen scallop to vary the diameter across the character. The name is kept anyway because one fleet-wide spelling beats a second nearly-identical key, and both configs say so in a comment. Verified byte-identical on all 14 configs, and the decoupling demonstrated directly - protrusion now tracks min_final alone: glyph h min_final -> protrusion 1.8 76.6 -> 0.800 1.0 76.6 -> 0.800 1.8 77.0 -> 1.000 1.0 77.0 -> 1.000 Hammond's `shuttle_text_protrusion` survives only as the fallback for a config predating the key; its own tune.py field is replaced by the diameter, so there is one place to set this per machine rather than two spellings. ### Dead variables removed, and the protrusion convention audited against v1/v2 **Removed** (all now unreachable, every config already migrated): `mink_height` fallbacks in 6 modules, `separation_mm` fallback, hammond_split's `char_mod` fallback, `element.glyph_height` (set but read by nothing after the depth split - and still an editable tune.py field, so it was a trap: changing it did nothing), and `element.shuttle_text_protrusion` (superseded by the derived value). Config keys dropped with a comment naming the v2 variable they came from, per CLAUDE.md's "say so, don't silently diverge". All 14 configs byte-identical after. **Audit: does every machine use the same system?** Three groups, and the answer is yes for two of them. *Cylinder family - v2 states the value directly, and v4 matches exactly:* | machine | v2 | v4 | | --- | --- | --- | | blickensderfer | `Min_Final_Character_Diameter=35` (blickensderfer.scad:174) | 35.0 | | postal | `=34.1` (postal.scad:146) | 34.1 | | mignon | `=19.4` (mignon.scad:191) | 19.4 | | bennett | `=32.9` (bennett.scad:180) | 32.9 | | helios | `=28.19` (heliosklimax.scad:199) | 28.19 | All five verbatim, and v2's own `Char_Protrusion=(Min_Final_Character_ Diameter-Element_Diameter)/2` is the formula v4 uses. *Hammond family - v2 has NO such variable; v4's value is a faithful re-expression, confirmed by byte-identical output:* - Hammond: v2 `Shuttle_Text_Protrusion=.9`, `Shuttle_Thickness=1.36`, `Shuttle_Arc_Radius~36.52` (hammond.scad:202-208). v4's 77.67 = 2*36.575 + 2*1.36 + 2*0.9, and the derived protrusion is 0.9 - v2's number exactly. - Hammond Split: v2 has only `Arc_OD=75`, `Glyph_Height=.8` and the literal `translate([Arc_OD/2-1, ...])` (hammond_split.scad:344). Its implied protrusion is (Arc_OD/2 - 1 + Glyph_Height + 1) - Arc_OD/2 = 0.8. v4's 76.6 gives exactly that. *Spherical family - same relationship, under v2's own names, left alone:* `Type_Altitude=(Max_OD-Sphere_OD)/2` (ibm.scad:412) is structurally identical to `Char_Protrusion=(Min_Final_Character_Diameter-Element_ Diameter)/2`. `Max_OD=34.9` / `Sphere_OD=33.4` (ibm.scad:309,313) carry through to v4 unchanged, giving 0.75mm. Renaming to the cylinder spelling would gain consistency but lose the v2 cross-reference on a family whose config is deliberately annotated line-by-line against ibm.scad - not done without a reason beyond tidiness. *Slug family - no equivalent, and correctly so:* one character on a flat block, no surface to stand proud of. Its depth concept is `engraving_depth_mm` (v1 Engraving_Depth), a different quantity. ### min_final_character_diameter is now genuinely fleet-wide The spherical family's `element.max_od` renamed to `element.min_final_character_diameter`, so every machine with a striking surface names that dimension the same way. Safe to do because `Max_OD` had exactly one real job in both v2 and v4 - `Type_Altitude=(Max_OD- Sphere_OD)/2` (ibm.scad:412), which is structurally identical to the cylinder family's `Char_Protrusion=(Min_Final_Character_Diameter- Element_Diameter)/2`. Its only other appearance was as a generous radius bound in `spherical_machine`'s cell-clip wedge. `Type_Altitude` keeps v2's name as the derived protrusion, and both the config and the lib carry the `Max_OD` / ibm.scad:313 cross-reference, so the v2 correspondence this family's config is annotated for is not lost - which was the only reason for leaving it alone earlier. Final state - 9 of 15 machines have a striking surface, and all 9 now use the same key: blickensderfer 35.0 postal 34.1 mignon 19.4 bennett 32.9 helios 28.19 hammond 77.67 hammond_split 76.6 selectric12 / selectric3 / selectric_composer 34.9 The 5 slug machines have none, correctly: one character on a flat block, no surface to stand proud of. Byte-identical on all 14 buildable configs; 15/15 compose and collect. ### Minkowski cone height unified to one spelling on all 15 machines Three spellings existed for the same knob. Now one - `build.minkowski_cone_height_mm`: - 7 machines already had it (cylinder family + Hammond Split). - **Selectrics** had `mink_cone_height_mm` - a pure spelling outlier, same concept, same v2 origin (ibm.scad:575,581). Renamed, byte-identical. - **Slug family** stored `minkowski_multiplier`, a MULTIPLIER of engraving_depth rather than a height. Now stored absolutely, with the engine deriving v1's multiplier form back so geometry is unchanged: type/vogue/gauge 0.5 x 1.6 = 0.8, oliver/lumi 0.7 x 1.5 = 1.05. **hammond_split moved to 1.0/1.5**, now that it is safe to. Volume is IDENTICAL (8081.034) - only tessellation shifts (35240 -> 35228 verts) - because protrusion is no longer tied to glyph height. The same change earlier would have zeroed the protrusion; the min_final_character_diameter work is what made it a non-event. ### All 15 machines now carry both depths, at 1.0 / 2.0 Cone height set to 2.0 fleet-wide (was 1.5 on the cylinder family, 2.0 on hammond_split/Selectrics, 0.8-1.05 on the slugs). Every machine builds watertight at it, including the slug family whose cone more than doubled. **Correction to the previous entry**, which claimed the spherical and slug families could not have a pre-Minkowski height because their `character_block_height_mm` is a construction margin. The margin part is true; the conclusion was not. The user's point is the correct one: what has to reach into the body is the CONE, not the block - so the block height is free, and the key applies perfectly well. Measured directly. selectric12 at cone 2.0, block height swept: 6.0 -> verts=58718 vol=8053.677 3.0 -> verts=58350 vol=8053.677 1.5 -> verts=58262 vol=8053.677 1.0 -> verts=58099 vol=8053.677 Identical volume throughout, and a *simpler* mesh as it comes down - so v2's `linear_extrude(6)` was over-generous by ~6x and cost faces for nothing. type_slug behaves the same (236.540 at every height). So `character_block_height_mm` was renamed to `pre_minkowski_char_height_mm` on all 8 machines that had it, and set to 1.0. Volumes unchanged everywhere; the three Selectrics get measurably smaller meshes (selectric12 41353 -> 40997 verts, composer 26623 -> 26111). Every one of the 15 machines now reads the same two keys at the same values. What differs between families is only what the pre-Minkowski height DOES: on the cylinder family and Hammond Split it sets the character's depth (total = pre + cone); on the spherical and slug families the body boolean trims the block, so it is a margin and any sufficient value gives identical output. Both configs say so. The user's safety note is recorded with it: a taller cone is the safer direction, because a wide character's roots approaching the sphere surface would otherwise cut off abruptly instead of meshing into it. ### Quality tabs synchronised With the parameters unified, the TABS were still inconsistent - the user spotted that Hammond Split did not expose them. Surveying all 15 found three different arrangements: - cylinder family + Selectrics: both depths on Quality - **Hammond Split**: both on Font & Alignment instead - **all 5 slug machines**: neither exposed anywhere, despite both keys being in their configs and driving real geometry - and their Quality tabs did not even carry `flatness_tolerance_mm` Now one `QUALITY_CORE_FIELDS` list (flatness tolerance, pre-Minkowski glyph height, Minkowski cone height) spliced at the head of every machine's Quality tab by `_quality_with_core()`, which drops any duplicate the machine's own list already had. Every Quality tab opens with the same three fields in the same order, and a machine cannot silently miss them again - which is exactly how the slug family lost all three. Hammond Split's two depths were removed from Font & Alignment in the same pass, so they appear once, on the tab everyone else has them on. TUI-only: all 14 configs byte-identical, 15/15 compose and collect and carry both depth values through `_collect_values()`. One self-inflicted detour worth noting: the regex that removed Hammond Split's Font & Alignment copies was written broadly enough that it also stripped the two fields out of `QUALITY_CORE_FIELDS` itself, leaving nine machines with only `flatness_tolerance_mm`. Caught immediately by asserting every tab's first three keys rather than eyeballing one machine. ### Font profiles no longer capture build values Reported: a saved Font & Alignment profile carried `build.minkowski_cone_height_mm` and `build.pre_minkowski_char_height_mm`, "its not part of font". Correct - and the leak was wider than those two. A profile captures whatever is on the Font & Alignment tab, and that tab was carrying build keys as well as font/alignment ones: - `build.draft_angle_deg` on **all 15** machines - `build.clip_to_cell` on the 7 cylinder/shuttle machines - (the two depths, on Hammond Split, until the previous commit moved them) So every profile ever saved has been storing geometry settings alongside the typeface, and applying one to another machine silently carried them across. That is exactly the wrong thing for a profile whose whole point is "the same typeface setup, on a different machine". Both moved to Quality, where the rest of the build values already live: `draft_angle_deg` joins `QUALITY_CORE_FIELDS` (universal, so it belongs in the shared head alongside flatness/pre/cone), `clip_to_cell` appends to the 6 Quality lists of the machines that implement it. Font & Alignment is now exclusively `font.*` / `font2.*` / `alignment.*` / `char_mod.*` - verified by asserting no other path prefix appears on that tab for any machine, and by checking a real profile capture: 14 values, zero non-font keys. Profiles saved before this still carry the old keys; they are reported as "no field or equivalent here, ignored" on apply and set nothing, which is how the user noticed. Re-saving a profile drops them. TUI-only: all 14 configs byte-identical, 15/15 compose and collect. ### Shuttle bodies banded too The user noticed the two Hammond machines' arcs were not getting the wall banding the cylinder family got, and preferred the banded look. Applied to both: - **Hammond**: `ShuttleCylinder()` is a plain `cylinder_z`, so it takes `z_segments` directly, same as the cylinder family's body. - **Hammond Split**: `Arc()` revolves a 4-POINT rectangle, so its radial faces were one full-height quad per angular section. Now the vertical edges carry intermediate points - geometrically the same rectangle, more points along it. **Numerically this does almost nothing**, which is worth recording so the change is not later mistaken for a fix: | | unbanded | banded | | --- | --- | --- | | hammond | 49566 slivers | 49543 (-0.05%), +2502 verts | | hammond_split | 1049 slivers | 998 (-5%), +287 verts | Volumes identical both ways (4876.526 / 8082.303 on the real Minkowski render). So unlike Bennett and Postal - where banding cut slivers ~8x - the shuttles' slivers come from somewhere else, and this is a tessellation/appearance change rather than a defect fix. Kept because the user prefers the result visually, not because a metric improved. New baselines: hammond 473076 947800 4876.156, hammond_split 35517 71098 8081.034. The other 12 unchanged. ### Mignon banded too - and it needed it most Asked whether Mignon needs wall banding. It did, and it was NOT getting it: the banding added earlier went onto `sp.cylinder_z`, but Mignon's shipped `cylinder_shape: 0` takes the OTHER branch - `PolygonCylinder()`, a 12-gon prism built with `trimesh.creation.extrude_polygon`. So the patch was inert for the default config. It was also the worst case in the fleet: the bare prism's longest face edge is **46.750mm** on a 40.5mm element, because `extrude_polygon` makes one full-height quad per facet and the prism is over-extruded by 6. Rebuilt with `sp.linear_extrude_twist(..., twist=0, z_steps=N)` - the same prism, just with intermediate rings; its fan caps are valid because a regular 12-gon is convex. Volume identical (4676.551). Effect on the real Minkowski render: slivers 1145 -> 1218 (slightly MORE) longest 30.294mm -> 5.415mm (5.6x shorter) Recorded because the two metrics disagree: the count rose while the worst case collapsed. The worst case is the one that matters here - a 30mm sliver spans most of the element and is exactly what renders as a dramatic spike, where 5mm ones are local. This is another data point that sliver COUNT has been a poor proxy all session. All seven bodies that take a character union are now banded: blickensderfer/postal (`cylinder_machine.Cylinder`), mignon (`PolygonCylinder`), bennett and helios (own `Cylinder`), hammond (`ShuttleCylinder`), hammond_split (`Arc`). The Selectrics and the slug family have no equivalent - a ball and a flat block respectively. New baseline: mignon 23689 47450 4646.497. The other 13 unchanged. ### Mignon's banding clamped - and only Mignon's The user's "super detailed" note was about MIGNON specifically; the first response to it retargeted the band height fleet-wide, which changed all five banded machines when only one was over-detailed. Reverted: the facet-width rule is back for everyone, with an optional `min_band_height_mm` clamp that only Mignon passes. Mignon is a genuine outlier, not a general problem - a small body (18.64mm) at `surface_fn: 360` makes the facets tiny, so the plain rule asked for **286 bands of 0.163mm**. Swept against the alternatives: mignon 12 bands (3.9mm) -> worst 5.415mm, 71950 faces <- knee 286 bands (0.2mm) -> worst 5.415mm, 77226 faces <- was shipping Clamped to ~2mm bands (23), which sits past the knee with margin: worst sliver still 5.415mm, 5068 faces saved. No other machine's diameter/facet-count combination comes near - blickensderfer 19 bands at 0.903mm, postal 20 at 0.870mm, bennett 22 at 0.818mm, helios 79 at 0.237mm - so none of them clamp, and all are byte-identical to before. **Kept from the sweep, worth knowing:** blickensderfer gains nothing from banding at any count. Its worst sliver is 17.150mm - the full element height - from 1 band to 40, because its full-height notch cutters dominate rather than the wall. The round-only gate removed earlier had the right answer for the wrong reason. Left enabled since the user likes the result; the measurement is here if that changes. ### Resuming later 1. **Band height 2.0mm does not fit.** Measured clear wall between ink bands on blickensderfer's current font/layout is 1.406mm (rows 2-3) and 1.860mm (rows 1-2), so a 2mm band clips ascenders/descenders wherever it is centred. Offsets left at 0.0 rather than baking in font-specific numbers. Tuned values for this font would be offsets +1.7 / +1.2, heights ~1.2. Corroboration: RobertG's own bands are 1.5mm and 1.4mm. 2. **`punctFatten` / `weightOffset` not built** - explained but deferred pending a go-ahead. Both are 2D outline offsets before extrusion, so in v4 they are `.buffer()` at `compose_glyph_polygon()`'s return, one choke point every machine inherits. Three things to get right: shapely buffers with ROUND joins by default and would silently round off every serif (needs `join_style=2`/`3` plus a mitre limit); a negative buffer can erase or split a hairline stroke, so it needs an empty/multi-part guard; and it changes the face count feeding Minkowski. 3. **`hashTwist`/`skewAngle` not ported and not recommended.** hashTwist rotates the whole `#` glyph including its horizontal bars, where a period `#` slants only the verticals. skewAngle is a shear, not a real italic, and thins vertical strokes by cos(theta) - RobertG's own Italic wheel sets it to 0 and uses a real italic font instead. 4. **`config/vogue_slug.yaml` does not build**, before or after this session. Its `font_path` is `~/Downloads/True_Vogue_final(1)_really_THIS_ONE.ttf`, which no longer exists. Pre-existing; left alone rather than guessed at. 5. **TODO, requested explicitly: walk every machine and produce fresh default profiles and example renders.** Both are stale after this session, for concrete reasons: - **Example renders.** `example_renders/` still holds the Aug 1 builds for most machines - only `blickensderfer.stl` and `blickensderfer_xsection.stl` were refreshed. Since those were made, the fleet gained: 1.0/2.0 character depths, wall banding on the cylinder family and both shuttles, notched/banded wheel styles, `notch_fn`/`resin_fn` fidelity knobs, and the hulled notch cutters. Regenerate with `generate.py`, then `generate_thumbnails.py --only ` (see part 85's own note - the cross-section is reproducible via `--cross-section-angle-deg 0`, not hand-made). - **Font & Alignment profiles.** Any profile saved before the last commit stores `build.draft_angle_deg` / `build.clip_to_cell` (and, if saved from Hammond Split, the two depths). They are ignored on apply but clutter the file. Re-saving each under the same name drops them. - Worth doing per machine rather than in bulk: it is also the natural moment to eyeball each render against the new defaults, which is the only check that has reliably caught the artifacts this session (the numeric proxies did not). 6. Part 81's punch list and part 83's Windows-checkout note are unchanged. ## 86. Licensing: GPL for the code, non-commercial for the designs, and why one license could not do the job (2026-08-15) The repo had no LICENSE file at all, on a public GitHub remote since September 2023 - which means default "all rights reserved": everyone could read it, nobody could legally reuse it. Fixed, but the shape it had to take was not obvious and is worth recording. ### What the requirements actually were Stated in an interview rather than picked off a menu, because no single license covers them: contributions must stay open with changes published and attribution kept; commercial sale of type elements reserved to the copyright holder; other people free to build on the work and have it merged back; the copyright holder does sell prints, and will continue to; anyone may print their own freely. Later clarified, and load-bearing: **the code itself is never sold - only physical prints.** ### The hole a code license alone cannot close The output of a GPL program is not covered by the GPL. So licensing only the code would have left the non-commercial intent applying to the published STLs and nothing else: anyone could fork the pipeline (staying GPL, entirely legally), run it, generate their own STLs, and sell prints. That is why `config/**` is under the design license rather than the code license. The machine dimension and calibration data is the part a working type element cannot be produced without, and it is read at runtime as data - never compiled or linked into the program - so the two licenses sit side by side without conflicting. `lib/layouts/**` is the deliberate exception and is called out in both NOTICE and README so it does not get "tidied up" later: it is pure layout data, but `tune.py` imports it directly, and a non-commercial license inside an imported module would make the combined program undistributable. It stays GPL. ### The contributor grant, and why it is narrow First draft had every contributor grant commercial rights, justified as keeping the option to relicense or sell the software. The clarification that the code is never sold invalidated that reasoning entirely, and the grant was rewritten to the real one: - **Code contributions carry no extra term at all** - plain GPL, no contributor agreement, no assignment. Using GPL software to produce output you sell is unrestricted, so a contributor's code copyright never obstructs print sales. - **Design data contributions carry the grant**, because they arrive under a NonCommercial license. Without it, a contributed machine config would bind the copyright holder to the *contributor's* non-commercial term and bar selling prints of that machine - a contribution that quietly subtracts from the project. That split is only unambiguous because of an invariant this project already enforces: real machine numbers live in config YAML, never in code (CLAUDE.md, "Geometry invariants"). Every PR therefore lands obviously on one side or the other. CONTRIBUTING.md ties the two rules together explicitly so neither drifts. ### Two dependency findings **`triangle` is the only dependency with a condition.** The LGPL-3.0 Python wrapper bundles Shewchuk's Triangle C library, which reserves *commercial redistribution* to direct arrangement with its author. It is load-bearing, not incidental: `glyph_poc.py:689` calls `trimesh.creation.triangulate_polygon` with no engine argument and `mapbox_earcut` is not installed, so `triangle` is what actually runs. Scope, since it is easy to over-read: running the pipeline and selling the prints it makes is unaffected (private use is free; the restriction is on redistributing Triangle, not on what you make with it), and distributing PACKAGING_PLAN.md's exe free of charge is fine with notices intact. Only *selling* a binary with it bundled needs permission. Written into PACKAGING_PLAN.md's caveats, where it will actually be read, including the LGPL relinking problem a single-file exe creates and the `mapbox_earcut` escape hatch - noting that `_open_touching_geometry()`'s segfault workaround was written against `triangle`'s behavior and would need retesting against earcut. **Part 85's prior art needed a ruling.** RobertG's Blickensderfer generator is CC BY-SA 4.0, and ShareAlike is incompatible with a NonCommercial term - so if anything of his had landed in `config/**`, licensing those files NC would have been a violation. Checked: the existing config comments credit him for the "idea/problem statement", which is the correct characterization, and part 85 records the dimensional values as ported from v2 with his numbers used as an independent cross-check. Ideas, methods and measured facts are outside copyright, so no derivative work exists and ShareAlike never triggers. Recorded in NOTICE as a considered position rather than left as silence, along with what would change if code or text were ever ported from it. ### Files Repository root, since this covers v1 through v4: `LICENSE` (GPL-3.0, verbatim from `/usr/share/common-licenses/GPL-3`), `LICENSE-DESIGNS` (CC BY-NC-SA 4.0, verbatim from creativecommons.org), `NOTICE` (scope map, Otto Koponen and Selectric Rescue attribution, prior art, dependencies, font caveat), `CONTRIBUTING.md`. License sections added to both READMEs. Site: a `license.md` page in plain language, a `license:` block in `_config.yml` driving a new `_includes/footer.html` override, and a nav entry. The footer override follows the same pattern and carries the same kind of explanatory comment as the existing `header.html` override. It exists for a specific reason: the site serves downloadable STLs from `/assets/models/`, and a non-commercial term nobody is shown is a term that does not travel with the download. `sync_docs.sh` gained four rewrite rules so README.md's `../LICENSE`-style links resolve to GitHub blob URLs instead of 404ing on the site. ### Verified Jekyll build clean (only the theme's own pre-existing sass deprecation warnings). `/license/` renders; the footer block renders on every page with all three links resolving and no double-escaped entities; the nav carries License. The `sync_docs.sh` rewrites tested against the real README - all three `../` links become absolute, none left behind. No geometry code was touched, so the CLAUDE.md hard gate does not apply. ### Not done - **`v4/lib/mignon.py` has an uncommitted debug edit** (line ~316): a `BANDS` environment-variable override that bypasses `Surface_Fn`, left over from part 85's banding work. It violates the "numbers live in config YAML" invariant. Left alone rather than folded into a licensing commit - it wants its own decision. - Nothing was published to leonardchau.com's project page; that lives outside this repo. ## 87. The one body that was never banded: Helios (2026-08-16) Asked plainly: "helios has no banding?" It did not. ### How it hid Part 85 banded every body that receives character roots, then went through a phase where banding was wrongly concluded not to help and the whole thing was reverted with a `git checkout --` across five lib files. Re-applying it covered `cylinder_machine.py`, `bennett.py` and (later, with the 2.0mm clamp) `mignon.py` - but not `helios.py`. Its `Cylinder()` went back to the plain form and stayed there. The tell was in the gate output the whole time and was read as good news: Helios came back byte-identical (71812 / 143648 / 4215.391) through BOTH subsequent changes to how band counts are derived. A machine whose walls are actually banded cannot be unaffected when the band-count rule changes twice. "Unchanged on a change that should have touched it" is a finding, not a pass - the same shape as part 85's sliver-count lesson, one level up. ### What it was costing Real render, `config/helios.yaml --cone-segments 12`: before 1723 slivers, worst 18.068mm after 548 slivers, worst 11.005mm volume 4295.474mm3 both ways, watertight/winding/is_volume True 18.068mm is not a coincidence - `Element_Height` is 18.7mm. The worst sliver was spanning the entire element, which is exactly the two-triangles-per-section trimesh cylinder wall being retessellated around 80-odd character roots. Clamped at `min_band_height_mm=2.0`, matching Mignon and for the same reason: Helios is a small body at `Surface_Fn=360`, so the bare facet-width rule asks for 79 bands of 0.237mm. That is the over-fine regime already rejected once, and the measurement above is with the clamp - the fine version buys nothing extra. Every character-receiving body is now banded: `cylinder_machine. Cylinder`, `bennett.Cylinder`, `helios.Cylinder`, `mignon. PolygonCylinder`, `hammond.ShuttleCylinder`, `hammond_split.Arc`. Mignon and Helios carry the 2.0mm clamp; the other four use the plain facet-width rule, which the user explicitly asked be left alone ("i liked how all the elements behaved, jist was referring t omignon"). ### Gate 14 configs, `--no-minkowski`: helios 143648 -> 148800 faces at an unchanged 4215.391mm3 (more faces is the point - that is the wall being pre-split). All 13 others byte-identical. ### Also resolved Part 86's "Not done" flagged an uncommitted `BANDS` environment-variable override in `lib/mignon.py` violating the config-YAML invariant. It is gone - it was removed when `PolygonCylinder()` was rebuilt for the clamp, before that part was written. Confirmed by grep: no `environ` reads in `mignon.py`, `helios.py`, `cylinder_machine.py` or `scad_primitives.py`. ### Resuming later Unchanged from part 85, minus the `BANDS` item: - Per-machine default Font & Alignment profiles + refreshed example renders, one machine at a time (user asked to be reminded). - `config/vogue_slug.yaml` still will not build - its font path (`~/Downloads/True_Vogue_final(1)_really_THIS_ONE.ttf`) does not exist on this machine. It is the one config outside the 14-config gate. - punctFatten/weightOffset explained but not implemented, awaiting a go-ahead. hashTwist/skewAngle deliberately not ported. ## 88. Every facet count in quality:, every quality: key on the tab (2026-08-16) Asked for, looking at an over-detailed Helios render: "we need to expose the variables fo the quality tab. the facet numbers for different things, the banding heights, and consistentize everything across all machines". ### What the audit actually turned up Cross-referencing every config's `quality:` block against every `QUALITY_FIELDS_*` table against the `*_Fn` globals each machine module really reads. Most facet knobs were already exposed correctly; the findings were narrower and more specific than "the tab is incomplete": 1. **Band height was not a config key at all** - `min_band_height_mm=2.0` was written into `mignon.py:321` and `helios.py:323` as a literal. Part 85/87's own work introduced it. It is a resolution constant in code, the exact thing CLAUDE.md's "numbers live in config YAML" rule names. 2. **`cosmetics.notch_fn`** - a facet count filed under the feature it belongs to rather than under `quality:`, with a hardcoded `16` default in `blickensderfer.py`/`postal.py`. This is the knob asked about earlier ("same for the notches in notched version of blickensderfer, how to reduce that one, its highly uneccessarily detailed") - it existed, but not where anyone would look for it. 3. **`gauge.hole_fn`** - same shape, in the three wing-slug configs. 4. **Three names for one concept in code**: the runtime global was `Mink_Fn` (Hammond Split, spherical family) or `Minkowski_Fn` (slug family), and the cone default was `DEFAULT_MINK_FN` (Hammond Split) or `DEFAULT_CONE_SEGMENTS` (the other six). The CONFIG key `quality.minkowski_fn` had already been unified in an earlier pass - which is the lesson: unifying a key is not finished until the global it feeds matches. ### Two things that looked like findings and were not Worth recording so the next audit does not re-chase them: - `spherical_machine` appears to read `Platen_Fn` while no Selectric config defines it. The only occurrence is inside a COMMENT explaining that it deliberately has no such variable and reuses `Cyl_Fn`. - `Body_Fn` looks unset on Bennett/Helios/Mignon/Hammond. It is only read by `cylinder_machine` functions those four never reach, since each has its own `Cylinder()`. Latent, not active - and left alone rather than "fixed" into a change with no observable effect. `resin.resin_fn` stays under `resin:` on purpose - that is the settled fleet-wide convention from an earlier pass, and moving it would have re-forked the thing this session was unforking. ### Verification The whole change was designed to move zero geometry, so the gate is the proof rather than a formality: 14 configs, `--no-minkowski`, all byte-identical to part 87's numbers. Presence is not the same as being wired, so each new knob was also shown to actually do something: min_band_height_mm 0 -> 6 58608 -> 56164 faces, vol 4355.448 BOTH notch_fn 16 -> 6 58608 -> 56236 faces, vol 4355.448 -> 4361.837 gauge_hole_fn 40 -> 8 6986 -> 5300 faces, vol 86.525 -> 86.874 The band row is the interesting one: face count moves and volume does not, which is the signature of a pure tessellation change and is what banding must always look like. The two cutter knobs shift volume slightly in the correct direction - a coarser cutter removes less. Tab coverage is now checked BOTH ways for all 15 machines (every `quality:` key has a field, no field points at a missing key). That check is a dozen lines and worth re-running when adding a machine. ### Still open - ~~`output/helios_running.stl` is not watertight~~ - **WRONG, retracted the same day; see part 89.** The mesh is watertight. That reading came from re-loading the STL, which is not how to ask the question, and the "specific to resin_support+logo" part was wrong too - resin off/logo off gives the identical result. - `helios.running.yaml` still carries two keys deleted fleet-wide, `build.points_per_mm` and `build.simplify_tolerance_mm`. Harmless (nothing reads them) but they should be migrated out. - Unchanged from part 87: per-machine default profiles + example renders; `config/vogue_slug.yaml`'s font path missing. ## 89. "Not watertight" was a bad measurement, not a bad mesh (2026-08-16) Part 88 closed with a flagged defect: `output/helios_running.stl` not watertight, 171 zero-area faces, guessed to be specific to `resin_support: true` + `logo_enabled: true`. Asked, reasonably: "why is it not watertight, it should habe been". It should have been, and it is. Both halves of that flag were wrong. ### What the measurement actually was Watertightness was read by loading the finished STL from disk. That is not the same question `generate.py` answers - `generate.py` reports on the in-memory mesh, before export. Checked directly, with no file involved: IN MEMORY watertight=True edge multiplicity {2: 311844} AFTER merge_vertices() watertight=False edge multiplicity {2: 311604, 4: 120} Every one of 311844 edges has exactly two faces. The mesh is closed and 2-manifold as built and as written. ### Why any reload disagrees No mesh format stores trimesh's in-memory connectivity - STL stores three loose corners per triangle - so every loader rebuilds topology by welding vertices at equal positions. This model contains 60 vertex pairs at EXACTLY distance 0.0: coincident coordinates that are topologically distinct points, where two sheets of the surface touch. Welding fuses them, and each fused pair converts two ordinary edges into one 4-face edge. The arithmetic is exact, which is what makes this certain rather than merely plausible: 103936 - 103876 = 60 vertices lost 120 = 2 x 60 non-manifold edges Not a float32 precision effect, which was the first hypothesis and was tested: the pairs are at distance 0.0, not at rounding distance, and a PLY round-trip behaves identically. ### The bit that should have caught it immediately **Zero 1-face edges.** A mesh with holes has edges belonging to exactly one face. Every machine has none. "Not watertight" with no boundary edges is never a hole - it can only be non-manifold-ness, which on a solid that manifold3d produced points at the reader, not the geometry. Check the 1-face count BEFORE reporting a watertightness failure; it separates "has holes" from "was welded on load" in one number. Fleet-wide, all 14 gate outputs, 0 holes each: clean round-trip (0 non-manifold): bennett, gauge_slug, hammond_split, lumi_slug, mignon, oliver_slug, postal, selectric_composer welded contacts: selectric12 4, selectric3 8, blickensderfer 32, helios 120, type_slug 644, hammond 7091 No print consequence: a closed surface is what a slicer needs, and it welds the same way, handling a 4-face edge where two solids kiss. ### Genuinely open - **Hammond's 7091** is two orders of magnitude above the rest and is the one number here that looks like a real finding rather than incidental contact - large surfaces meeting exactly, not 60 stray points. Not chased. - `helios.running.yaml` still carries `build.points_per_mm` and `build.simplify_tolerance_mm`, deleted fleet-wide. Nothing reads them. - Unchanged: per-machine default profiles + example renders; `config/vogue_slug.yaml`'s font path missing.