Skip to content

Latest commit

 

History

History
136 lines (84 loc) · 28 KB

File metadata and controls

136 lines (84 loc) · 28 KB

Engines: Pad Generation, G-code, Tuner, Tooling

Pad Generation Data Flow

Pad Generation: User input → parse_pad_list()can_all_pads_fit() check → generate_svg() or generate_gcode() → output files

Sizing Calculations: get_disc_diameter() in svg_engine.py applies material-specific offsets:

  • Felt: pad_size - felt_offset
  • Card: pad_size - (felt_offset + card_to_felt_offset)
  • Leather: pad_size + 2*(felt_thickness + wrap) with star bonus for small pads
  • Exact: pad_size unchanged

Per-Size-Range Settings: Sizing rules, dart/star settings, engraving settings, and engraving placement each support a Universal/Range mode. In Universal mode, one set of values applies to all pad sizes. In Range mode, users define size ranges with different values. Four helper functions in config.py centralize this lookup — engines should always use these instead of reading raw settings["dart_*"] etc:

  • get_dart_settings_for_size(pad_size, settings) → returns range dict or None (no match = no stars)
  • get_sizing_for_size(pad_size, settings) → returns sizing dict (no match = universal fallback)
  • get_engraving_settings_for_size(pad_size, settings) → returns engraving on/off + font sizes (universal fallback)
  • get_engraving_placement_for_size(pad_size, settings) → returns placement mode/value per material (universal fallback)

Key difference: dart ranges return None on no match (opt-in), while the other three always return a dict (every pad needs values).

Darted leather engraving: Leather pads with darts use the darted_leather key in engraving_location for placement (default 2.5mm from outside), separate from regular leather placement. The dart config's engraving_on flag still controls whether darted leather is engraved at all.

Nesting

Nesting Algorithm: _nest_discs() implements greedy circle-packing, shared by both can_all_pads_fit() and generate_svg(). Supports both rectangular sheets and custom polygon shapes. nest_pads() is the public API for the preview window. Edge bias controls scan direction: cardinal (N/S/E/W) scan linearly from the edge, corners (NW/NE/SW/SE) use distance-from-corner scoring with smallest-first sort order for efficient corner packing.

Vectorized scan paths (v2.40+): the radial-bias rectangle scan (_scan_radial_numpy) and both polygon scans (_find_best_polygon_large_numpy, _find_best_polygon_small_numpy) build a numpy candidate grid, compute distances/scores in one bulk op, mask occupied cells via per-pad bounding-box updates, then argmin to pick the closest valid cell. ~100× faster than the Python loop on real-world inputs; bit-identical placements (argmin's first-index-of-min matches the Python reference's strict-less-than y-then-x tie-break). A Python reference (_scan_radial_python, _find_best_polygon_*_python) is preserved verbatim as the parity target and as the runtime fallback when numpy is unavailable (macOS Intel build — try-import at the top of svg_engine.py sets _HAS_NUMPY). tools/test_nesting_parity.py (rectangle) and tools/test_polygon_parity.py (polygon) assert exact-match placements across all 9 edge biases, every polygon shape, and max-quantity fills.

Nesting Preview: NestingPreviewWindow in ui_dialogs.py shows the nested layout before file generation. Works in standard mode (one material at a time) and scrap mode. The generate flow nests first via nest_pads(), shows the preview, then writes files via generate_svg_from_placed() / generate_gcode_from_placed() — no double-nesting. Frame & Cut (machine integration, v2.62) reuses the same window before the head moves — it passes proceed_label="Continue →" (it streams to the laser rather than writing files, so "Save Files" would mislead) and a cancel there consumes nothing.

SVG & G-code Rendering

Shared Rendering Helpers: SVG rendering is centralized in _render_svg_discs() and _create_svg_drawing() in svg_engine.py. Both generate_svg() and generate_svg_from_placed() call these. Similarly, generate_gcode() delegates to generate_gcode_from_placed() after nesting, so all G-code disc rendering logic lives in one place.

SVG Output Modes (compatibility_mode): Options > Settings > Export Settings has an "Enable Inkscape/Compatibility Mode (unitless SVG)" toggle (setting key: compatibility_mode, default False). Both modes emit the same SVG root (width="Xmm" height="Ymm" viewBox="0 0 X Y"), so physical dimensions are identical either way — the difference is inside the document. Default mode declares profile='tiny' and writes every child attribute with explicit mm suffixes (e.g. r="12.5mm", stroke_width="0.1mm"); compatibility mode omits the profile and writes bare numbers (r=12.5, stroke_width=0.1) that inherit the viewBox's 1-unit-per-mm mapping. The flag gates a branch per unit-suffixed element type in _render_svg_discs() (outline, hole, engraving text — the dart path is always unitless, which is fine) and _render_svg_dies() (plus die-specific elements). When adding new SVG element types to either renderer, remember to branch on compatibility_mode the same way or emit only the unitless form. Historical context: the flag exists for older tool chains that rejected per-attribute unit suffixes or SVG Tiny 1.2; modern Inkscape/LightBurn/Illustrator handle both forms fine.

Coordinate Systems: SVG uses Y=0 at top (Y increases downward), while G-code uses Y=0 at bottom (Y increases upward). The gcode_engine.py functions flip Y coordinates (sheet_height_mm - cy) to ensure G-code output matches the SVG preview.

G-code Engine Details

gcode_engine.py contains two font systems: STROKE_FONT (single-stroke outlines for "line" engraving mode) and FILLED_FONT (Roboto glyph outlines for "filled" raster mode). Both fonts contain digits, period, hyphen, space, and 12 letters (D E S I G N B Y P H L O) — the letters cover the Phil Noy credit text engraved on tooling parts. Stroke letters are hand-designed single-stroke approximations matching the digit style; filled letters are extracted from tools/Roboto-Regular.ttf via tools/extract_font_outlines.py. To regenerate the filled letters, edit CHARS in the extractor and re-run; paste the output into the FILLED_FONT and FILLED_CHAR_WIDTHS blocks in gcode_engine.py.

Filled engraving uses scan-line fill with even-odd rule to convert font outlines into horizontal raster lines, with optional overscan (extending lines beyond character edges so the laser reaches full speed). generate_gcode_from_placed() is the main entry point — it supports two cut grouping modes ("layer": all engravings → all holes → all cuts; "pad": complete each disc before moving to the next) and per-layer air assist control (M8 on / M9 off). The internal _collect_disc_strokes() helper extracts per-disc stroke data for both modes.

Arc text helpers: get_text_strokes_arc() and get_filled_text_strokes_arc() lay text out along an arc with rigid per-character rotation (each character is a rotation+translation, not a polar warp). The filled variant runs scan-line fill IN THE CHARACTER'S LOCAL FRAME and then transforms each scan segment to world coordinates via the basis vectors, so curved text fills solid like flat text instead of using horizontal scan lines that would misalign on rotated glyphs. Both helpers take (text, font_size_mm, cx, cy, radius, center_angle_rad, side='top'|'bottom') where 'top' sweeps CW with character tops radially outward and 'bottom' sweeps CCW with character tops radially inward (so text reads upright when viewed from outside the circle). The svg_engine die/holder renderers call the same helpers and emit polylines so the SVG preview matches the gcode output exactly.

Polygon Shape Tool

Users can draw custom polygon shapes for irregular leather skins. Grid size: default 17×17 inches (1" squares) or 43×43 cm (1cm squares); auto-grows in PolygonDrawWindow.__init__ to at least ceil(max(laser_bed_x_max, laser_bed_y_max) / mm_per_unit) so a user with a 500mm bed gets a 20-inch grid even if their stored polygon_draw_grid_size_in setting is smaller. The polygon nesting algorithm (_nest_discs_polygon()) uses ray-casting for point-in-polygon checks and distance-to-edge calculations for circle fitting. The rectangle algorithm remains the fast path when no custom shape is defined.

Vertex placement: free-floating (_canvas_to_grid returns floats), not snapped to integer grid intersections — 1-inch granularity is too coarse for accurate scrap tracing. Hand-drawn AND camera-captured polygons share this float convention; downstream nesting + machine-coord round-trip handle non-integer points natively.

Live camera overlay (v2.5+): PolygonDrawWindow._render_overlay warps the live camera feed (via cv2.warpPerspective) onto the polygon canvas at 1:1 grid scale, anchored at machine (0, 0) by default (canvas BL = bed origin). Users can trace their scrap by eye on top of the camera image, or click "Get from camera" for auto-detection via CameraCaptureDialog.

Scrap Mode

Allows users to place pads across multiple irregular scrap pieces instead of requiring one large sheet. Key components:

  • Session state in self.scrap_session tracks original pads, remaining pads, scrap count, locked material, AND large-batch optimization opt-in flag
  • try_nest_partial(pads, ..., optimize=False) and compute_remaining_pads() in svg_engine.py handle partial placement
  • generate_svg_from_placed() / generate_gcode_from_placed() generate output from pre-computed placements
  • Progress popup window (scrap_remaining_window) shows two columns: Remaining and Done
  • Materials are locked (disabled) during active session to prevent switching
  • Files named with _scrap1, _scrap2 suffixes
  • After each scrap with a polygon loaded, a dialog asks whether to unload, keep the shape, OR re-capture from camera (when calibrated)

Large-batch optimization (v2.5+): when remaining pad count is ≥ 75, _maybe_prompt_large_batch_optimization shows a one-time-per-session opt-in popup. If user accepts (scrap_session['optimize'] = True), try_nest_partial(optimize=True) routes through _multistart_nest — runs the greedy nester with 5 different disc orderings (largest-first, smallest-first, 3 seeded random shuffles) and keeps the result with the most placed pads. Costs ~5-30s extra compute per scrap; typically fits 5-15% more pads on dense batches. Default off — small batches use the single-pass greedy. Reproducible across runs (RNG seeded with 0).

Engraving Auto-Fit

When engraved text (pad size number) would impinge on the disc edge, both engines prefer shifting the text toward the disc center over shrinking it. Only scales down as a last resort when text can't fit even when centered. SVG engine uses bounding-box corner checks; G-code engine uses actual stroke-point measurements. Both maintain a 0.5mm clearance margin. The 80% radius check (font_size >= r * 0.8) disables engraving entirely before auto-fit runs.

SD Card & Eject

Two mechanisms:

  • "Eject SD card after G-code export" checkbox (Windows only, below Generate buttons): auto-ejects removable drives after G-code generation. Uses GetDriveTypeW to detect removable drives; silently skips non-removable destinations.
  • File → Send G-code to SD Card: guided workflow that copies a .gcode file, optionally clears old files, and ejects (Windows only via PowerShell COM object).

Strobe Tuner

12-wheel chromatic stroboscopic tuner. Architecture:

  • tuner_engine.py: Pure audio/math — FFT-based pitch detection, per-pitch-class phase tracking, per-ring (octave) magnitude extraction. TunerEngine manages the sounddevice input stream; TunerResult holds magnitudes, phase offsets, ring magnitudes, and cents errors for all 12 pitch classes. ReferencePlayer outputs reference tones. Magnitude normalization is gated: max_mag must exceed threshold * 1.5 before normalizing to 0-1, otherwise all magnitudes are zeroed. This prevents sensitive mics from showing wheel activity on room noise.
  • tuner_tab.py: All tkinter UI — StrobeWheel class renders one disc (annular sector polygons with wedge mask), TunerTabMixin builds the tab with control panel: three labeled slider groups (DISP: SENS/BRIGHT/FPS | PITCH: A=/KEY | BIAS: NOTE per-wheel/OCT. per-ring), flat/pilot/sharp indicator, vintage backlit VU meter. The VU needle has damped movement (lerp toward target each frame). Sensitivity gain: the VU path uses a quadratic curve (sens**2) so the low end of the slider has fine control; the wheel-brightness path uses a linear curve — they intentionally share the slider but respond differently. Theme walker is bypassed via _skip_theme and _dark_canvas flags on all dark widgets.
  • The tuner auto-starts/stops when switching tabs (_tuner_start/_tuner_stop called from on_tab_changed in main.py).
  • macOS is canvas-only: _HAS_GPU_RENDERER is forced False on darwin at the top of tuner_tab.py — Tk Aqua's winfo_id() returns a MacDrawable pointer, not an NSView, and handing it to wgpu segfaults natively in objc_msgSend (no Python fallback possible). The same gate lives in build.py (no --hidden-import on darwin) and CI (no Rust/maturin on macOS runners). The "install tuner_render" CPU-mode hint is suppressed on macOS. Full mechanism in CLAUDE.md "GPU Tuner Renderer".
  • Transposition support: wheel labels and VU readout both apply the shift from TRANSPOSITION_SHIFTS, with octave correction when the shift wraps past C.
  • GPU/Canvas constant alignment: these constants exist on both sides of the Python/Rust boundary and must stay in sync: DIM_MULTIPLIER (tuner_tab.pytuner_renderer/src/shader.wgsl), BRIGHTNESS_GAMMA (tuner_tab.pytuner_renderer/src/renderer.rs, applied in host code, not the shader), MAGNITUDE_THRESHOLD (tuner_tab.pyrenderer.rs), CENTER_GAP_FRACTION/CENTER_GAP (tuner_tab.pyshader.wgsl), and RING_SEGMENTS (tuner_tab.py ↔ the ring_segments() switch in shader.wgsl). If you change any of them, update both sides. Known divergence: RING_GAP_FRACTION (Python, 0.05 off the ring's outer edge) vs RING_GAP (shader, 0.015 off each side) — the two renderers draw slightly different ring gaps; align these if anyone complains about the look differing between GPU and CPU mode.
  • ReferencePlayer in tuner_engine.py can output reference tones, but no UI control currently calls it (the play button was removed with the "Experimental" section) — it's exercised only by tools/test_tuner_engine.py.

Audio Stream Health

Both tuner_engine.py and toner_engine.py import AudioRingBuffer from audio_utils.py for stream health monitoring. The ring buffer tracks a write counter; if the analysis loop detects no new audio data for ~1 second, the engine automatically restarts the sounddevice stream. This recovers from silent callback death on Windows.

Tooling Tab

Accordion-style UI with die insert and die holder SVG/G-code generation. Small dies ≤39.5mm (50mm OD), large ≥40mm (70mm OD). Die holders 85mm OD; user picks 5-layer (solid + magnet + 2× pin + ring) or 6-layer (solid + magnet + 3× pin + ring), with magnet hole 6.5mm, pin holes 3.5mm. Variant is Small / Large / Both, where Both = two complete independent holders (one of each size) nested onto the same sheet — the layers are cemented together permanently, so there's no convertible-holder concept. User defines sheet size (W × H + in/mm) like Die Inserts; engine raises ValueError if pieces don't fit. Helpers _holder_pieces_for(variant, layer_count), _pack_holder_grid(...), and _min_holder_sheet(...) live in svg_engine.py and are imported by gcode_engine.py so SVG and G-code stay aligned. Canvas widgets need explicit handling in the resonance theme walker.

Die Organizer (SVG-only): A fourth Tooling section. The two source SVGs (tooling_assets/die_organizer_upper.svg and _lower.svg, Matt's CAD output) are bundled as data assets and copied byte-for-byte to the user's chosen path on Generate. No G-code path — instructions in the section direct the user to LightBurn or similar (LightBurn handles kerf comp, layer mapping, and material profiles better than we would, and the organizer is a one-and-done part). generate_die_organizer_svg(variant, filename, settings) in svg_engine.py resolves the asset via sys._MEIPASS when frozen and __file__ otherwise. build.py bundles tooling_assets/ via --add-data. To update the design, just drop a replacement SVG into tooling_assets/ — no parser to update.

Pad Press Spacers: bundled 3D-printable STL files (pad_press_spacers/*.stl) copied byte-for-byte to the user's chosen path via ToolingTabMixin._save_pad_spacer_stl. Three sets: half-step (3.0/3.5/4.0/4.5 mm × 4 each, 16 total), quarter-step (3.25/3.75/4.25 mm × 4 each, 12 total), and an organizer rack with 7 compartments. No engine code — they're static assets bundled via build.py --add-data.

Camera Calibration (v2.5): integrated single-pass calibration that solves camera intrinsics + lens distortion + pixel-to-MACHINE-mm homography in one workflow. CameraCalibrationDialog (ui_dialogs.py) opens with a horizontal-split layout (preview left, controls right) showing two phases. Phase 1 (engrave): live camera + jog cluster + MPos display + Home Laser button + Frame (continuous-loop bbox tracer, follows jogs) + Engrave button. The engrave commits the calibration card at machine (head_x − card_w/2, head_y − card_h/2); uses the basswood G-code preset's filled_engraving_speed/power/passes/line_spacing from gcode_settings.basswood (so users tune their machine's basswood feeds once and the card engrave matches). Bed-bounds safety check refuses to engrave/frame if the card would extend off the bed. Phase 2 (capture): live preview + multi-pose ChArUco captures with explicit reference / intrinsics substates — the user takes 1+ reference captures (card untouched at engraved position, pooled in calibrate_from_frames for the machine-mm homography) then clicks "Done with references" and moves the card for the remaining intrinsics captures. Retake-last + Reset-all recovery buttons let users iterate on bad detections without losing other captures. After save, calibration JSON has homography_px_to_machine_mm (replaces the old homography_px_to_mm which was board-frame). pixels_to_mm returns machine coords directly. is_legacy_calibration detects pre-v2.5 files; load_calibration returns None on those (forces recalibration).

Frame & Cut (v2.5): Pad Maker > Frame & Cut button. Manual-only — there's no "Auto" mode anymore (the v3.0-dev auto-frame was stripped because head MPos drift made it unreliable). On click, a nesting preview of the layout shows first (the per-scrap subset in Scrap Mode, the whole batch in standard mode); Adjust backs out without consuming a scrap (v2.62). After the preview is confirmed:

  1. FalconRunDialog opens in jog-only mode (empty G-code, _is_jog_only_mode=True) with the title "Position the head at your material's bottom-left corner…". Buttons: Home Laser (re-homes mid-dialog), jog cluster, Try Auto Locate (drives the head to the polygon's LB-vertex via $J=G90 X… Y… when _custom_polygon_lb_machine is set and the laser has been homed this session), Cancel, Start Frame →.
  2. On Start Frame, on_frame_and_cut builds the framing G-code with a G92 X{lb_x} Y{lb_y} prefix where (lb_x, lb_y) is the polygon outline's leftmost-lowest vertex in framing's Y-flipped Y-UP frame. This makes work (0, 0) map to the polygon's bbox-BL even when the user jogs to a non-bbox-BL vertex (tilted scraps where the visible BL corner is not at bbox-min-Y).
  3. generate_polygon_framing_gcode(custom_polygon_outline) emits the trace, ROTATED so polygon[0] is the vertex closest to (0, 0) (the BL after Y-flip). G-code starts with G0 X0 Y0 — no head-jump at trace start regardless of user's click order. Closes with M5 only (NO trailing G0 X0 Y0; that would land the head at bbox-BL between loop iterations, breaking the G92-with-LB-offset alignment).
  4. Framing runs in FalconRunDialog loop mode (loop=True) at low power until user clicks "Looks Good — Cut!" — that sets _cut_requested=True, last pass completes naturally, dialog closes.
  5. Cut runs with the SAME G92 X{lb_x} Y{lb_y} prefix as framing so both passes share work-origin.

Scrap-mode Frame & Cut (v2.6): when Scrap Mode is on, Frame & Cut runs one scrap per click — it routes through the shared _scrap_begin_partial() helper (same partial-nest + session bookkeeping as the file-export _generate_gcode_scrap_mode path) instead of a full nest, then _frame_cut_scrap_advance() bumps the scrap count, decrements remaining, and shows the continue/recapture dialog — but only after the cut streams to completion (cut_dlg._final_reason == "complete"), so a stopped or errored cut leaves the scrap re-cuttable. The G92/outline logic above is unchanged, so each re-captured scrap aligns on its own jogged origin. The session can start without a save folder (Frame & Cut streams, never writes files); if the user later switches to file-export Generate mid-session, _scrap_begin_partial prompts for a folder then. Covered by tools/test_frame_cut_scrap.py.

Outline vs Inset (v2.5): camera-captured polygons get split into TWO storage representations sharing one coordinate origin:

  • self.custom_polygon — the INSET shape (camera_capture.inset_polygon_mm applied, default 3mm), used for pad nesting placement so cuts land safely inside the visible scrap edge.
  • self.custom_polygon_outline — the ORIGINAL un-inset shape, used for the framing trace so the visible trace matches the actual scrap edge.

Both are normalized using the OUTLINE's bbox-min-x + max-y in _set_custom_polygon_from_y_up(points_y_up_mm, outline_y_up_mm=None). Pad placements in custom_polygon's storage frame sit inside the inset bounds (e.g. (3, 3) to (97, 97) for a 100mm scrap with 3mm inset), so after the G92 zero-at-jog they land at the right machine positions. Plain hand-drawn polygons (no overlay → no inset path) get custom_polygon_outline = None; on_frame_and_cut falls back to custom_polygon in that case.

FalconRunDialog jog-only mode: dialogs constructed with empty gcode_lines (e.g. the position-the-head step before framing) enter _is_jog_only_mode = True (the _total == 0 check). Progress bar / line counter / elapsed timer are hidden (they'd just read 0 forever). State label initial value is "Position the head, then click {label}." instead of "Connecting...". X-out and explicit Cancel button → _on_close_window / _on_cancel_jog_clicked set _final_reason = "cancelled"; the post-stream "advance" button (e.g. Start Frame →) is rewired in _on_done to _on_advance_clicked which just destroys without clobbering _final_reason (left as "complete" from _on_done). Three distinct close paths — Cancel, X, and Advance — must stay distinct or the caller's if _final_reason != "complete" check misfires.

Machine menu (Pad Maker > Options > Machine): groups Falcon control and camera settings — Home Laser, Test Connection, Clear Errors ($X), Reset Falcon (Ctrl-X soft-reset), Camera Calibration, Camera-Polygon Inset Margin. Gated behind two levels: the experimental_machine_menu Feature Set toggle (off by default), AND the menu items inside it grey out until camera_capture.load_calibration() returns non-None. The inset margin (camera_polygon_inset_mm, default 3mm) shrinks camera-captured polygons before nesting as a safety buffer against camera measurement error at the edges of its view.

Falcon serial wake-up (v2.5): falcon_sender._stream_loop opens every stream with _wake_parser_sync — sends G4 P0.01 (10ms no-op dwell) and waits for ok before entering the main streaming loop. Without this, the Falcon's command parser can sit dormant after an idle gap and silently swallow the first stream line (head makes a small move then stops, Grbl reports Idle, streamer hangs waiting for acks). The ? real-time status ping that follows is now mostly redundant but kept for initial state acquisition. Tests in test_falcon_sender.py use a MockSerial that passes the G4 P0.01 wake line through with ok unconditionally (it's a streamer-internal command, not test-controlled). The stream_lines_only helper filters the wake line out of mock.completed_lines for assertions.

Scrap mode + camera capture: the scrap-continue dialog (_show_scrap_continue_dialog) offers a "Re-capture from camera" shortcut alongside Unload/Keep Shape when a calibration exists, streamlining the per-scrap workflow (each scrap is a different piece, so the user typically wants a fresh polygon for each). Camera capture is consolidated into the single "Draw / Capture Shape" dialog — the duplicate main-pane "Get from camera" button was removed.

Speed & Power Test (v2.40+, beta): laser-settings calibration tool. Generates a sheet of small test discs at user-defined sweeps of speed / power / passes / air-assist; each disc gets a 2-digit ID and a legend.txt mapping IDs to parameters. Engine entry point generate_feeds_speeds_test_gcode in gcode_engine.py. Matrix expansion via build_feeds_speeds_matrix and grid packing via _grid_pack_discs (both in svg_engine.py), wired through ToolingTabMixin._prepare_feeds_speeds_pieces. Preview window SpeedPowerTestPreview in ui_dialogs.py shows the layout (color-coded by air state when "Also test with air off" is on) before the file dialog; toggleable via the "Preview before saving" checkbox. G-code only — per-disc parameters can't survive an SVG round-trip, so the SVG path was deliberately not added. Engraving feed/power are exposed as editable fields so the labels stay legible when the cut settings are still unknown (the whole point of the tool).

Inner diameter / washer mode (v2.62): an optional "Hole diameter" field (0 = solid disc, the default and historical behavior) turns each test piece into a washer/ring for shim stock (e.g. flute padding shims). When set, generate_feeds_speeds_test_gcode cuts the inner hole FIRST (so the part stays anchored to the sheet while the hole is cut) then the outer perimeter, both in the same per-disc C{id} layer at that disc's speed/power/passes. Because the center is now cut away, the ID label moves into the lower ring: the shared pure helper feeds_speeds_label_geometry(diameter, inner_diameter) in svg_engine.py returns (label_dy_mm, font_size_mm)dy=0/historical font for a solid disc, mid-annulus offset + ring-width-auto-fit font (floored at FEEDS_SPEEDS_LABEL_MIN_FONT_MM) for a washer. Both the G-code engraving and the SpeedPowerTestPreview canvas call this helper so the drawn label matches the cut. feeds_speeds_ring_fits_label backs a non-blocking UI warning when the ring is too thin to engrave a legible ID. Validation refuses a hole ≥ the disc diameter.

Sheet size is a soft target, not a hard cap: if the matrix doesn't fit the entered sheet, _prepare_feeds_speeds_pieces grows the sheet (each axis only as far as needed via _min_feeds_speeds_sheet) and proceeds instead of erroring — the layout preview shows the enlarged sheet before the file dialog, so nothing happens blind. The live fit indicator reads "→ sheet will grow to fit" rather than a hard failure.

Tests in tools/test_feeds_speeds_tester.py cover the label geometry, the holed-cut G-code (inner-before-outer, ring label drop), and the sheet auto-grow path.

Phil Noy Credit (non-negotiable)

The die holders and die inserts implement Phil Noy's pad-making method, which Phil shared freely. The Tooling tab has a paragraph at the top crediting Phil with a clickable link to noysaxophonesupplies.com. Every die holder retaining ring is engraved "DESIGNED BY PHIL NOY" arced along the top of the annulus (replacing the previous redundant size-range label). Every die insert is engraved "NOY" arced along the bottom of the ring opposite the size number. Engravings respect the user's filled/line engraving_mode so they match the visual style of the size labels. Do not modify, hide, shrink, or remove this credit — it was missing from an earlier version, Phil was upset about it, and Matt re-added it 2026-04-07 to do right by him.