The Glyph Pipeline
The glyph pipeline
Split out of README.md to keep it direct - see
README.md for setup, usage and the machine list.
The glyph pipeline (lib/glyph_poc.py)
For one character (build_glyph):
get_glyph_contours_and_advance: walk FreeType’s outline (on/ off-curve tagged points) into flat polylines, subdividing curved spans adaptively tobuild.flatness_tolerance_mmvia recursive de Casteljau (flatten_quadratic/flatten_cubic). Straight segments get ZERO subdivision; a curve gets exactly as many points as its own curvature needs. This replaced a fixedpoints_per_mmrate, which subdivided straight strokes as densely as curves. Both quadratic (TrueType) and cubic (CFF/OpenType) outlines are handled - the cubic path was a real bug once, now fixed (seeLIMITATIONS.md).classify_and_triangulate: classify each closed contour by nesting-depth parity (even=solid island, odd=hole - not just “contained by something”, which breaks on genuinely nested glyphs like DejaVu’s0with its slash mark nested inside its own counter) and triangulate each island (with its holes) viatriangle. Flat, z=0.- Build a block (
trimesh.creation.extrude_triangulation) sitting at the tip end, tall enough to contain the real platen cut for this specific glyph’s own Y-extent (computed from the exact circle-sag formula, not a fixed guess - see “Real platen cutout”) - the undrafted, undilated glyph shape, plus margin. - Carve the platen scallop with a REAL cylinder subtraction
(
manifold3dboolean, not a per-vertex approximation) - see “Real platen cutout” below. - Minkowski-sum the scalloped block with a draft cone
(
manifold3d.Manifold.minkowski_sum) - apex (radius=0) registered exactly at the world origin so it lines up with the tip, wide base (radius=expansion_width_mm) below it atz=-cone_height. The sum’s cross-section at the tip is therefore the block’s own (curved, per step 4) top surface unchanged; at the root (z=0) it’s that same surface dilated/offset outward byexpansion_width_mm- the draft taper, with no self-intersection possible on any input topology (holes, disjoint islands, arbitrarily narrow gaps - a real Minkowski sum can’t fold). Skippable viabuild.minkowski_enabled: false(or--no-minkowski) for a fast, undrafted preview - see “Performance”. alignment_x_offset: horizontal placement within the glyph’s own advance box (see “Alignment”) - applied to the contours before step 2, not listed in pipeline order above.
manifold3d’s raw minkowski_sum output is drastically over-triangulated
on flat regions (a single straight wall came out as ~24 near-coplanar
micro-triangles with normals wobbling by a fraction of a degree from
floating-point noise - visible as faceting on straight strokes). A
Manifold.simplify() post-pass was tried to collapse that cleanly, but
was removed fleet-wide after it was found to reintroduce its own thin
spike/sliver defects against the adaptive contour method’s sparser input
(see CLAUDE.md’s “Geometry invariants” - the adaptive contour tracing
itself, not a simplify() post-pass, is what keeps triangle count in
check now).
cylinder_machine.TextRing (shared, not per-machine) calls build_glyph
84 times for Blickensderfer (3 rows x 28 columns) and places each result
on the cylinder via place_on_cylinder.
Real platen cutout
The platen scallop is a REAL boolean cylinder subtraction (matching the
real machine and v2’s PlatenCutout()), not a per-vertex parabola
approximation (an earlier version of this pipeline used the small-angle
approximation of the same circle, z = (y-radius_y_offset)^2 *
platen_radius + separation_mm, applied to whatever vertices happened to
survive triangulation). platen_radius_mm is still that same
approximation coefficient (1/(2*Rp)) - inverted internally to recover
the real platen radius Rp, rather than adding a redundant parameter -
used to build an actual cylinder (axis along X, tangent to the tip plane
at y=radius_y_offset, platen_fn segments), boolean-subtracted from the
block before the Minkowski sum.
Before, not after, matters: an earlier version subtracted nothing and
instead nudged only the swept result’s top ring into the parabola after
the Minkowski sum. That’s wrong - the cone’s geometry, and therefore the
realized draft angle, is only valid for whatever shape it’s actually
summed with. Nudging just the final ring left the walls built as if the
tip were still flat, so wherever the platen bulge is large (far from
radius_y_offset - e.g. the bottom of M/A, vs. L/I’s mostly-
vertical runs which stay close to it) the wall no longer tapered at the
specified angle over the actual (now longer) distance to the tip - visible
as inconsistent facets on those specific edges. Carving the scallop in
first means the cone (unmodified, exactly as specified) sweeps a surface
that’s already the correct curved shape, so the draft angle is preserved
everywhere by construction.
The cylinder’s axis position/radius depend only on radius_y_offset and
Rp - both per-row constants, identical for every character in a row -
so the underlying curve is the exact same real cylinder machine-wide per
row, not independently approximated per glyph; only where it intersects
each glyph’s own silhouette differs, which is correct.
Character mirroring
A struck type element carries a MIRROR-IMAGE of the desired printed glyph
- striking is a reflection through the contact plane, same reason a rubber
stamp or hot-metal slug is cut reversed.
v2’sTwoDTextwraps the whole aligned/shifted glyph inmirror([1,0,0]);v4never did this until this was found and fixed inbuild_glyph()(negating X on the already-shifted contours, afterx_shift, matching v2’s translate-then-mirror order). Fixing this also resolved a previously-reported “x offset wrong direction” bug as a side effect - both were the same missing mirror. Scoped tobuild_glyph()(struck characters) only -build_flat_text()(LogoText, Type Test) is deliberately untouched, since that text is read directly, never struck.
Draft angle is configurable
build.draft_angle_deg (config + --draft-angle-deg CLI override, also
on tune.py’s Font & Alignment tab) sets the Minkowski draft cone’s
half-angle - expansion_width_mm = separation_mm * tan(draft_angle_deg /
2). Defaults to 55.0, the real machine value. Previously a fixed
glyph_poc.py module constant with no override.
Draft direction (character protrusion)
The print face’s deepest/narrowest point (at y=radius_y_offset, where the
platen scallop is zero) sits at a FIXED radius,
Element_Diameter/2 + Char_Protrusion - matching where
PlatenCutout()’s cylinder actually touches the character in
v2/lib/glyph_pipeline.scad. The root (the wider, separation_mm-drafted
end) sits inward from that anchor by separation_mm - like a nail
driven in with a wide head sitting proud, not a flush base that only
widens sideways. This means the root’s reach toward the hollow chamber
scales directly with separation_mm; it is not automatically safe just
because it’s “the embedded end” (see the HollowSpace margin note below).
Alignment (character centering)
Two base modes, plus two independent modified-character override groups
layered on top - a from-scratch scheme (not a port of
v2/lib/glyph_pipeline.scad’s 4-method AlignedText), configured under
alignment: in the YAML:
mode: center- shift by-advance/2(centers the ADVANCE box, same convention v2’s nativehalign=centeruses - not the ink bounding box) pluscenter_offset_mm.mode: left- no centering shift, justleft_offset_mm(0 = the glyph’s natural FreeType pen origin, unmoved).modified_left_chars(default"!,.;:)") get an additional shift of-modified_left_offset_mm;modified_right_chars(default"(") get+modified_right_offset_mm. A character matching both resolves to the left group (checked first).
All offsets default to 0.0 (no-op) until set in the config.
Performance
The draft taper is a real Minkowski sum (manifold3d), not plain
coordinate math, so generation time is real and tunable via two knobs
(quality.minkowski_fn / --cone-segments and
build.flatness_tolerance_mm / --flatness-tolerance-mm, both
config-driven) - manifold3d’s own docs warn
Minkowski cost scales with the product of the two operands’ face counts.
Measured for the full 84-character ring + assembly. These numbers are
historical: they were taken under the old fixed-rate points_per_mm
sampling, which no longer exists, so the left column is not a knob you
can set today and the timings are not directly comparable to a current
build. Kept for the shape of the tradeoff (cost falls off steeply as
either knob drops), not as current figures. The adaptive tracer that
replaced points_per_mm measured a 1.3x-4.5x build-time reduction on
its own, with no correctness loss.
| points_per_mm (removed) | minkowski_fn | full ring + assembly |
|---|---|---|
| 15 (then-default) | 16 (config default) | ~60-70s |
| 8 | 12 | ~30-35s |
| 6 | 8 | ~16s |
Quality difference between the fast and default settings is minor
(confirmed visually on e/m, the hardest glyphs). Use the fast settings
while iterating, the config defaults for a final export.
For the fastest possible iteration (placement/layout only, no draft),
set build.minkowski_enabled: false or pass --no-minkowski - this skips
the Minkowski sweep entirely (by far the most expensive step) and returns
each character as an undrafted block: correct platen curve and glyph
footprint/placement, no taper. Measured: the full ring + assembly in
~3s instead of ~30-70s. Not a substitute for a real export - re-enable
before generating anything meant to be printed.