A self-hosted web application for reliably converting text, SVG, and bitmap geometry into FluidNC-compatible gcode for the LowRider v4 CNC. Designed for engraving and cutting signs, fixtures, and parts from real-world SVG input with predictable, repeatable output.
- Reliability over feature breadth. Better to do five operations excellently than fifteen poorly.
- Real-world tuned. Every setting tunable that has caused a real-world problem in actual use (hard-won list at the bottom of this doc).
- Visual confidence. No cut runs without the operator seeing exactly what the bit will trace, where origin lands, and where tabs go.
- No surprises. Generated gcode includes header comments documenting every parameter used. Files are reproducible from saved presets.
- Self-hostable. Vanilla stack (HTML/CSS/JS/PHP) so it runs on any LAMP/LEMP host without exotic dependencies.
Frontend:
- Vanilla HTML/CSS/JavaScript (no framework). Modern ES2020+.
- Canvas API for toolpath preview rendering.
- File API for SVG upload, Blob/URL for gcode download.
- LocalStorage for in-session preset autosave.
Backend:
- PHP 8.1+ (no framework needed — keep it simple).
- SQLite for preset/material/bit storage (single-file DB, zero setup).
- File I/O for gcode artifacts (optional persistence; primary delivery is download).
Geometry libraries:
- Client-side: a polygon offsetting library is mandatory. Recommended: a JS port of the Clipper library (e.g.
clipper-liborpolygon-clipping). Hand-rolling polygon offset is a trap — corners and self-intersections are non-trivial. - SVG path parsing: write a small parser for
M / L / H / V / C / S / Q / T / A / Zcommands. Treat curves by tessellating into line segments at a controllable tolerance (default 0.1mm).
Why this stack: Vanilla JS keeps the project alive across years without framework churn. PHP is the lowest-friction backend for a hobbyist-grade web app on a shared host. SQLite eliminates database setup.
┌─────────────────────────────────────────────────────────────────┐
│ BROWSER │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────┐ │
│ │ Text/SVG/Bitmap│ │ Toolpath │ │ Settings panel │ │
│ │ SVG parse │→ │ generation │→ │ (live preview) │ │
│ └──────────────┘ └──────────────┘ └────────────────────┘ │
│ │ │ │ │
│ └──────────────────┼────────────────────┘ │
│ ↓ │
│ ┌───────────────┐ │
│ │ Canvas │ │
│ │ preview │ │
│ └───────────────┘ │
│ ↓ │
│ ┌───────────────┐ │
│ │ Gcode emitter │ │
│ └───────────────┘ │
│ ↓ │
│ ┌───────────────┐ │
│ │ Download .gcode │
│ └───────────────┘ │
└───────────────┬─────────────────────────────────────────────────┘
│ HTTP (only for preset persistence)
↓
┌─────────────────────────────────────────────────────────────────┐
│ PHP │
│ /api/presets /api/materials /api/bits /api/jobs/save │
│ ↓ │
│ SQLite DB │
└─────────────────────────────────────────────────────────────────┘
Critical design decision: all geometry and gcode generation happens client-side. PHP only handles persistence. This means:
- No round-trip latency when adjusting settings.
- Live preview updates as the operator drags sliders.
- The tool works offline once loaded (good for shop computers with flaky wifi).
- Built-in typed-text geometry generation with font loading (
opentype.js) and layout controls (fit-to-sign, letter height, alignment, anchor, offsets, spacing, frame, and parametric graphics).
The SVG parser must handle transformed/nested real-world files and resolve units to millimetres. Curves are tessellated by configurable tolerance.
- Accept PNG/JPG/JPEG/WebP/BMP uploads (max 8 MB).
- Decode with
createImageBitmap, then downscale for tracing responsiveness (current implementation caps width at 1400 px). - Convert RGBA to luminance + alpha-aware grayscale and threshold into a binary grid.
- Trace pixel-edge loops into closed contours.
- Apply filters/cleanup: min-island area and Douglas-Peucker simplification.
- Convert traced points into mm geometry using mm-per-pixel scaling.
- Run tracing in a Web Worker (
js/workers/trace-worker.js) with main-thread fallback. - Guard against stale async results: only the latest trace run can commit geometry.
The parser must reliably handle SVGs from the real-world tools the user employs:
- OpenSCAD exports (e.g. the V1 strut_plate.svg — single big
<path>with manyM..L..zsubpaths). - Inkscape exports (multiple
<path>elements, possibly with transforms). - Illustrator/Affinity exports (likely with deeply nested transforms).
- Hand-made SVGs.
Required parser behavior:
- Walk every element in the SVG. Apply CSS/XML
transform=attributes cumulatively as you descend. - Resolve units. SVG can specify
width="800mm",width="800"(units depend on viewBox), or pixel sizes. Compute the SVG-unit to mm ratio at the root and apply to all coordinates. Unit suffix is matched case-insensitively (100MM,5INparse the same as100mm,5in). - Honour
preserveAspectRatioon the root<svg>(defaultxMidYMid meet— uniform scale with centering). Non-uniformwidth/heightagainstviewBoxno longer silently stretches geometry. - Tessellate curves. Convert
C / S / Q / T / Asegments to line segments at a configurable chord-tolerance (default 0.1mm). Degenerate cubics with coincident endpoints are detected and treated as flat — without this guard, self-returning curves (legal SVG) recurse to depth 24 and produce ~33M points. - Resolve
<use>references. Including<use>of<symbol>(which is otherwise skipped), withwidth/heightoverrides that scale the instance against the symbol's viewBox. - CSS hidden classes. Parse
<style>blocks for.classname{display:none}/visibility:hiddenrules and skip elements whoseclassattribute matches. - Identify closed subpaths. A subpath ending in
Zis a closed polygon. Open paths whose last vertex lies within 0.01 mm of the first are promoted to closed automatically (many hand-made SVGs omit theZ). - Classify each subpath as one of:
- Hole (small closed polygon, e.g. bolt-clearance hole) — flag by user-settable area threshold (default <50mm²).
- Outer profile (large closed polygon).
- Open curve / engraving line (not closed).
- Winding-order detection. Determine if each polygon is CW or CCW. Holes inside outer profiles must have opposite winding from their parent (per SVG even-odd or non-zero fill rules).
- Bounding box. Compute overall and per-subpath bounding boxes for centering / scaling.
Recommended parser interface:
const geometry = parseSvg(svgText);
// geometry = {
// width_mm, height_mm,
// subpaths: [
// { type: 'outer'|'hole'|'open', points: [[x,y],...], closed: bool, winding: 'cw'|'ccw' },
// ...
// ]
// }The tool supports six operations, selectable per-job:
- Bit traces the path centerline. Multi-pass when
|finalDepth| > DOC— steps from 0 down to final depth in DOC increments. Shallow engraves stay single-pass. - No tool radius compensation.
- Useful for: logos, signage on two-color HDPE, dimensional verification ("groove-center to groove-center"), text.
- Bit traces the path outside the geometry, offset outward by
tool_radius + finishing_allowance. - Multi-depth passes from 0 down to final depth.
- Tabs on final pass only: bit lifts at N evenly-spaced positions to leave material connections.
- Useful for: cutting out part outlines.
- Bit traces the path inside the geometry, offset inward.
- Multi-depth passes.
- Useful for: cutting opening cutouts.
- V-carves every closed contour with a V-bit using included-angle geometry.
- Final depth is a cap; wider regions may hit the cap while narrow regions run shallower.
- Multi-depth internal profile offset for interior cutouts/openings.
- Circularity check first: only features that are approximately circular (≥ 8 vertices, polygon area within 5% of the equivalent circle's area, and max radial deviation < 8% of mean radius) get a drill cycle. Non-circular small features (squares, irregular blobs) fall back to profile-in so the cutter follows the actual shape — drilling a square as a circle would cut a circle ~41% larger than the square's inscribed radius. The operator can override by setting an explicit target hole diameter.
- For circular features where
bit_diameter > hole_diameter: pecked plunge cycle at the hole center (creates oversized hole). Retract above the stock surface between pecks so chips clear. - For circular features where
bit_diameter < hole_diameter: bit plunges at center, traces a circle at radius(hole_d/2 - bit_r), returns. Multi-depth with chip-clearing retract above stock between every depth pass. - Useful for: M3/M5/M6 mounting hole patterns.
Every setting that has caused a real-world problem during actual cutting. Settings group into Material, Bit, Operation, Job, and Output.
| Setting | Type | Default | Note |
|---|---|---|---|
| Name | string | — | e.g. "1/4 single-flute O-flute (HDPE)" |
| Diameter (mm) | float | — | Cutting diameter, not shank |
| Shank diameter (mm) | float | — | For documentation |
| Flute count | int | 2 | 1 for plastics, 2-3 for wood |
| Cutting length (mm) | float | — | Max plunge before chuck collision |
| Type | enum | upcut | upcut / downcut / compression / O-flute / V-bit |
| Setting | Type | Default | Note |
|---|---|---|---|
| Name | string | — | e.g. "1/4 inch plywood" |
| Thickness (mm) | float | — | Nominal — operator should measure actual |
| Recommended bit type | enum | — | Filters bit picker |
| Recommended RPM | int | — | Informational (Makita is manual) |
| Recommended cut feed | int | — | mm/min |
| Recommended plunge feed | int | — | mm/min |
| Recommended DOC per pass | float | — | mm |
| Through-cut overage | float | 0.65mm | How much deeper than thickness for clean through |
| Setting | Type | Default | Note |
|---|---|---|---|
| Operation | enum | engrave | engrave / pocket / vcarve / profile-out / profile-in / drill |
| Final depth (mm) | float | — | Negative = below stock surface |
| DOC per pass (mm) | float | — | Positive value |
| Finishing allowance (mm) | float | 0.15 | Added to offset for clean edge |
| Tool offset override (mm) | float | auto | Auto = tool_radius + finishing |
| Plunge style | enum | straight | straight / peck / helical |
| Peck retract distance (mm) | float | 2.0 | Only for peck plunge |
| Setting | Type | Default | Note |
|---|---|---|---|
| Enable tabs | bool | true | |
| Tab count per profile | int | 4 | |
| Tab thickness (mm) | float | 1.5 | Material left under the bit at the tab. When the material thickness is known (selected from the library) and the cut goes through it, tab Z anchors to the material bottom so this number is the actual remaining tab. Without material info, the formula falls back to "raise the bit tabThickness mm above finalDepth" — which can produce a thinner tab when the cut overshoots into the spoilboard. |
| Tab width (mm) | float | 6.0 | Along perimeter |
| Tab placement | enum | even | currently even spacing in implementation |
| Setting | Type | Default | Note |
|---|---|---|---|
| Scale (%) | float | 100 | Uniform scaling |
| Rotation | enum | 0° | 0 / 90 / 180 / 270 CW or CCW |
| Origin position | enum | bottom-left | bottom-left / center / top-left / custom |
| Stock margin (mm) | float | 0 | Extra stock around part bounds (0 = stock equals part size) |
| Hole-vs-trace threshold (mm²) | int | 50 | Subpaths below this area = drill cycle |
| Target hole diameter (mm) | float | — | For closed circles below threshold |
| Setting | Type | Default | Note |
|---|---|---|---|
| Machine cutting area X (mm) | int | 1270 | Validates job fits |
| Machine cutting area Y (mm) | int | 2540 | Validates job fits |
| Safe Z (mm) | float | 10 | Rapid height above stock |
| Pre-stock Z (mm) | float | 2 | Last height before plunge |
| Rapid feed (mm/min) | int | 5000 | For time estimates |
| Use M0 pauses | bool | false | True = pause for manual router on/off |
| Spindle RPM in M3 | int | 18000 | Informational unless VFD connected |
| Filename pattern | string | {job}_{material}_{bit}_{date}.gcode |
Templated |
| Header comment template | textarea | — | Templated with job parameters |
Single-page application, three columns desktop / stacked mobile:
┌─────────────────────────────────────────────────────────────────┐
│ LEFT (320px) │ CENTER (flex) │ RIGHT (320px) │
│ │ │ │
│ Artwork: │ ┌────────────┐ │ Settings (tabs): │
│ - Text sign │ │ │ │ - Operation │
│ - Upload SVG │ │ Canvas │ │ - Tabs │
│ - Trace bitmap │ │ preview │ │ - Geometry │
│ Operation picker │ │ │ │ - Machine │
│ Material picker │ │ │ │ - Bit │
│ Bit picker │ │ │ │ - Material │
│ Job presets │ └────────────┘ │ │
│ │ │ Live job summary: │
│ Pre-flight checks: │ Toolbar: │ - Stock required │
│ - Fits machine? ✓ │ [Zoom +/-] │ - Est. runtime │
│ - Bit can reach │ [Fit] │ - Total Z passes │
│ full depth? ✓ │ [Rapids] │ - Chip load │
│ - HDPE + flutes? │ [Tabs] │ - Cut distance │
│ - Tabs ≥ 1mm? │ [Reference] │ - Rapid distance │
│ │ │ │
│ Buttons: │ │ │
│ [Generate gcode] │ │ │
│ [Save preset] │ │ │
└─────────────────────────────────────────────────────────────────┘
Canvas preview details:
- Background: machine envelope as a dashed blue rectangle (sized per machine settings).
- Material/stock outline as a filled light gray rectangle.
- Original SVG geometry as thin light gray lines (reference).
- Computed toolpath as colored lines: green for engrave, blue for profile-out, magenta for profile-in, orange for drill plunges.
- Rapid moves as faint dashed lines (toggleable).
- Tabs as small red X marks on the toolpath.
- Origin marker (red crosshair at 0,0).
- Tooltip on hover: shows X, Y, current Z, current feed rate.
- Pan: middle-click drag. Zoom: scroll wheel.
Every generated file follows this structure:
; ====== {Job name} ======
; Material: {material}
; Bit: {bit}
; Operation: {operation}
; Final depth: {z}mm DOC: {doc}mm × {n} passes
; Spindle: {rpm} RPM (dial {makita_dial}) -- INFO ONLY for manual router
; Feed: {feed} mm/min cut, {plunge} mm/min plunge
; Origin: FRONT-LEFT of stock, Z=0 on material top
; Stock needed: {sx} × {sy} mm
; Machine envelope check: {fits/warning}
; Generated by LowRider Forge v{version} on {timestamp}
;
G21 ; mm
G90 ; absolute coords
G94 ; feed per minute
G17 ; XY plane
M5 ; spindle off (no-op for manual router)
G0 Z{safe_z}
{optional M0 pause for manual router on}
M3 S{rpm} ; no-op for manual router; informational
; --- Operation body (drills first, then profiles) ---
{generated toolpath}
; --- Footer ---
G0 Z{safe_z}
M5 ; spindle off BEFORE parking (no-op on
; the Makita, but safe for VFD setups)
G0 X0 Y0
{optional M0 pause for manual router off}
M30Hard rules drawn from real-world experience:
- No
M0pauses by default. They block the program waiting for cycle-start which trips users up. Optional flag for those who want them. - All XY coordinates positive. Translate geometry after rotation/scaling so origin is bottom-left of bounding box plus margin. The LowRider homes front-right with positive-X-and-Y work area.
- Drill operations before profiles. Plunge cycles when bit is freshest = cleanest holes.
- Outer profiles last. Once outer is cut, part can move — anything depending on it being held must happen earlier.
- Multi-depth passes step from 0 down to final. Never start at final depth on pass one. Always include final depth as the last pass even if it's a small step.
- Tabs only on the final pass. Earlier passes go through full perimeter without lifting.
- Plunge feed always slower than cut feed. Typically 25-40% of cut feed.
- Per-segment feed rate annotation. Every
G1line includesFvalue. FluidNC tolerates omitted F (uses previous), but explicit F survives any controller quirk. - G2/G3 arc center via I/J offset, not R. Full-circle support requires I/J because R-form ambiguates direction.
For each subpath, by operation type:
depths = compute_pass_depths(final_z, doc) ; same logic as profile-out
for each subpath:
G0 X{first}, G0 Z{safe}, G0 Z{pre-stock}
for depth in depths:
if open subpath and not first pass:
G0 Z{pre-stock}, G0 X{first} ; rapid back to start
plunge from current Z to depth (honours peck/helical)
for each subsequent point:
G1 X Y F{cut} Z{depth}
if closed: G1 X{first} Y{first} F{cut} Z{depth}
G0 Z{safe}
offset_path = polygon_offset(subpath, +offset_distance, mitre_join)
tabs = place_tabs(offset_path, count, half_width)
depths = compute_pass_depths(final_z, doc)
for depth in depths:
G0 X{first of offset_path}, G0 Z{pre-stock}
G1 Z{depth} F{plunge}
if depth == final and tabs:
for each point:
z = tab_z if near_tab(point) else depth
G1 X Y Z F{cut}
else:
for each point:
G1 X Y F{cut}
G0 Z{safe}
if not is_approximately_circular(subpath) and no target hole diameter:
# cutting a square as a circle would gouge corners — fall back
return profile_in(subpath)
hpr = (hole_d/2) - bit_r ; can be near-zero or negative for oversized hole
if hpr <= 0.05:
# bit at least as wide as hole — peck-plunge in place
G0 X{center}, G0 Z{safe}, G0 Z{pre-stock}
for depth in depths:
if not first pass:
G0 Z{pre-stock} ; retract above stock for chip clearing
G0 Z{prev_depth + 0.5} ; rapid back into cleared hole
descend to depth (honours peck plunge style)
G0 Z{safe}
else:
# trace a circle inside the hole
G0 X{center}, G0 Z{safe}, G0 Z{pre-stock}
for depth in depths:
if not first pass:
G0 Z{pre-stock} ; retract above stock
G0 Z{prev_depth + 0.5} ; rapid back into cleared circle
descend to depth (honours peck)
G1 X{center+hpr} Y{center} F{cut}
G2 X{center+hpr} Y{center} I{-hpr} J0 F{cut}
G1 X{center} Y{center} F{cut}
G0 Z{safe}
Peck retracts inside descend() always rise above the stock surface
(max(preStockZ, fromZ)), not back to wherever the plunge started — so chips
clear even when the descent begins deep inside an existing hole on pass 2+.
Blocking (error level — gcode generation refused):
| Check | Action |
|---|---|
finalDepth >= 0 |
Block — would drive bit upward into spindle / cut nothing |
safeZ <= 0 |
Block — bit drags during rapids |
| ` | reachDepth |
| Geometry fits within machine envelope | Block; show overlap on canvas |
Toolpath has negative X/Y on bottom-left origin |
Block — LowRider work area is positive-only |
| HDPE + multi-flute bit | Block — multi-flute melts HDPE |
| V-carve without a V-bit + valid included angle | Block |
Tab thickness < 1mm |
Block — tabs snap mid-cut |
Warnings (advisory):
| Check | Action |
|---|---|
docPerPass > bit.diameter_mm (non-engrave/v-carve) |
Warn — chip load risk |
feedPlunge > feedCut |
Warn — plunge is hardest move |
| O-flute-recommended material + multi-flute bit | Warn — generalises the HDPE/acrylic rule |
| V-bit on profile-out / profile-in / pocket / drill | Warn — no flutes along depth |
| Chip load outside 0.05–0.30 mm window | Warn |
Per-plunge depth > max(3, bit.diameter_mm * 3) mm with plungeStyle = straight |
Warn — suggest peck/helical |
| Stock margin ≤ tool offset on profile-out | Warn; offer fix (suppressed if fix would exceed envelope) |
| Through-cut depth shallower than material or much deeper than (thickness + overage) | Info / warn (profile-out only) |
| Hole diameter ≤ bit diameter on a drill feature | Warn; oversized hole noted in gcode header |
| Non-circular small feature on drill op | Info — auto-fallback to profile-in |
| Tabs above 2 mm thick | Warn — flush-trim cleanup needed |
| `tabThickness >= | finalDepth |
preStockZ >= safeZ |
Warn — pre-stock should be the smaller of the two |
| Multiple closed contours overlap | Warn — manual review needed |
| Machine envelope X/Y mismatch with SVG orientation | Suggest 90° rotation |
| Bitmap trace > 30k nodes | Info — cut may be slow |
| Bitmap trace > 120k nodes | Warn — preview and cut will be slow |
| Spindle RPM far from material recommendation | Info |
CREATE TABLE bits (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
diameter_mm REAL NOT NULL,
shank_diameter_mm REAL,
flute_count INTEGER NOT NULL,
cutting_length_mm REAL,
type TEXT NOT NULL,
notes TEXT
);
CREATE TABLE materials (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
thickness_mm REAL,
recommended_rpm INTEGER,
recommended_feed_cut INTEGER,
recommended_feed_plunge INTEGER,
recommended_doc_mm REAL,
through_cut_overage_mm REAL DEFAULT 0.65,
notes TEXT
);
CREATE TABLE presets (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
bit_id INTEGER REFERENCES bits(id),
material_id INTEGER REFERENCES materials(id),
operation TEXT NOT NULL,
settings_json TEXT NOT NULL,
created_at INTEGER,
updated_at INTEGER
);
CREATE TABLE jobs (
id INTEGER PRIMARY KEY,
filename TEXT NOT NULL,
preset_id INTEGER REFERENCES presets(id),
svg_hash TEXT NOT NULL,
gcode_path TEXT,
settings_json TEXT NOT NULL,
created_at INTEGER
);Minimal endpoints — most work is client-side:
GET /api/bits → list of all bits
POST /api/bits → create bit
PUT /api/bits/:id → update
DELETE /api/bits/:id → delete
GET /api/materials → list of all materials
POST /api/materials → create
PUT /api/materials/:id → update
DELETE /api/materials/:id
GET /api/presets → list
POST /api/presets → save current settings as preset
GET /api/presets/:id → load preset
POST /api/jobs/save → optionally persist a generated gcode file
GET /api/jobs/:id → download persisted gcode
All responses are JSON. No authentication for V1 (assume local network). Future: token-based auth.
Bits:
- 1/4" 2-flute upcut (general wood/foam) — diameter 6.35, flutes 2, cutting length 25mm
- 1/8" 2-flute upcut (detail wood, SpeTool W04021) — diameter 3.175, flutes 2, cutting length 25.4mm
- 1/4" single-flute O-flute (plastics) — diameter 6.35, flutes 1, cutting length 25mm
- 1/8" single-flute O-flute (plastic detail) — diameter 3.175, flutes 1, cutting length 17mm
- 60° V-bit (sign engraving) — special handling
Materials:
- 1.5" rigid insulation foam (38mm)
- 1/4" plywood (6.35mm, actual usually 5.5-6.5mm — note in tooltip)
- 1/4" MDF (6.35mm)
- 1/4" hardboard (6.35mm)
- 1/2" plywood (12.7mm)
- 2-color HDPE (cap 0.5mm over core, full sheet 1/8" or 1/4")
- HDPE solid (3mm / 6mm / 12mm)
- 1/4" acrylic (cast, NOT extruded)
- 6061-T6 aluminum (3mm — special slow-feed presets)
Sample presets (linking bit + material + sensible defaults):
- "Foam dimensional engrave" — 1/8" upcut, foam, engrave at -4mm, F2000
- "Plywood profile cut with tabs" — 1/8" upcut, 1/4" plywood, profile-out, depth -8mm
- "HDPE 2-color sign engrave" — 1/4" O-flute single, 2-color HDPE, engrave at -0.5mm, F1500
- "Aluminum 6061 profile" — 1/4" single-flute aluminum bit (separate from O-flute), 3mm 6061, DOC 0.5mm, F800
Real lessons from real bench time — every one of these caused a problem during my own use of similar workflows. The tool should make them difficult or impossible to repeat:
- M0 silently halts the program. Default it off. If on, the UI shows a warning banner: "Program will pause and require Cycle Start to continue."
:lowdirection-pin modifier needs single quotes in YAML. This is a config issue not a gcode issue, but noted here so the docs/help cover it.- "1/4 inch" plywood is rarely actually 6.35mm. Material tooltip: "Measure your actual thickness before cutting; nominal 1/4" plywood is often 5.5-6.0mm."
- Unsurfaced spoilboard can have 2mm of dish. Build in a "through-cut overage" setting (default 0.65mm) that the operator can crank up if their spoilboard is rough.
- Tabs too thin = part breaks loose mid-cut. Don't let tabs go below 1mm thickness. Warning above 2mm: "Will require flush-trim cleanup."
- Single flute is mandatory for HDPE. When user picks an HDPE material with a multi-flute bit, show a red banner: "Multi-flute bits melt HDPE — use single-flute O-flute."
- Bit cutting length limits depth. Validate
abs(final_z) <= bit.cutting_length(error past it, warning within 1 mm of it). The flute length limits engagement in the material; Safe Z is a rapid height above the stock and must not be added to the comparison — doing so falsely blocks shallow V-carves and engraves with short-flute bits (a 10 mm-flute V-bit cutting 2 mm deep is fine at any Safe Z). - Strut plates and similar parametric parts aren't linearly scalable. When user uploads an SVG that looks like it might be parametric (multiple repeated brace patterns), show an info banner: "If this is a parametric part designed for a specific dimension, regenerate the SVG at the correct size rather than scaling."
- Sharp corners + high feed = wiggle. When feed × acceleration suggests corner overshoot beyond 0.2mm, suggest dropping the feed.
- Two-color HDPE cap layers vary by manufacturer. Default engrave depth 0.3-0.5mm; document range in the material tooltip.
- Machine axes can be reversed by physical rewiring. If the operator reports "moves wrong direction," the fix is
:lowmodifier on motor direction_pin — but the tool can't help with config (out of scope). Document this in a troubleshooting section. - Stock origin convention matters. Always say "front-left corner of stock" in the gcode header, and reinforce in UI with a diagram. Operators have set origin to center of stock, top-left, etc., and run a job designed for front-left = ruined material.
- Hole diameter vs bit diameter. If hole_d < bit_d, fall back to plunge cycle and clearly note this in the generated gcode header.
- Air pass before real cut. Offer a "generate air pass" toggle that produces a separate gcode file identical to the main one but with all Z values offset by +25mm. Lets the operator verify the toolpath in space before any cutting.
- Z=0 reference inconsistency. Always say "Z=0 on top of material" in header; never assume Z=0 is on the spoilboard.
- Tab thickness vs through-cut overage. "Tab thickness" must mean material remaining under the bit, not bit rise above final depth. With a 0.65 mm spoilboard overage on a 6.35 mm material, a 1.5 mm "tab thickness" using the naive
tabZ = finalDepth + tabThicknessformula leaves only 0.85 mm of actual material — under the validator's 1 mm minimum and prone to snapping. When material thickness is known, anchor tab Z to the material bottom. - Drill non-circular features. A small square classified as a "hole" by area would, without a circularity check, be drilled as a circle ~41% larger than its inscribed radius — gouging past every corner. Verify circularity (polygon-vs-circle area ratio and radial uniformity) before drilling; fall back to profile-in for non-circular features.
- Engrave depth. A "single shallow depth" engrave is fine for 0.5–3 mm; for anything deeper, step in DOC increments like every other op. A 5 mm engrave plunged in one move on a small bit will snap the cutter.
- Peck retract destination. On multi-pass drilling, the peck-retract must rise above the stock surface, not back to wherever
fromZwas. Pass 2 starts inside the existing hole — retracting to that fromZ leaves the bit in its own chips. - Spindle-off timing in the footer. Emit
M5before the rapid back toX0 Y0. With a manual Makita it's a no-op; with a wired VFD, keeping the spindle on through the parking move risks dragging a spinning bit across freshly cut work if Safe Z is mis-set. - Safe Z is part of bit reach. The shank must clear the work at retract, not just the cutting tip. Validate
|final_z| + max(2, safeZ) < bit.cutting_length. - SVG self-returning cubics. A cubic Bezier with
p0 == p3(legal SVG, common in stylized blobs) fails any non-zero flatness test by ratio. Detect zero-chord curves and bound the recursion depth — otherwise 2²⁵ ≈ 33M points get generated and the browser hangs. - SVG viewport aspect handling. Default
preserveAspectRatiois uniform with centering. Applying independent X/Y scales when viewport aspect differs from viewBox aspect cuts physically wrong-sized parts.
- Probe support for surface mapping (auto-Z-leveling per the diagnostic procedure we already use).
- G2/G3 arc fitting for circles instead of polyline approximation — produces cleaner toolpaths and smaller files.
- Adaptive clearing for pockets (Trochoidal milling) — complex but valuable for deeper aluminum work.
- DXF input in addition to SVG.
- Post-processor selection: GRBL, FluidNC, Marlin, gSender flavor — most just need different M-codes.
- Multi-tool jobs: one SVG, multiple operations, multiple bits, with tool-change pauses between.
- Cloud preset sync (login + shared library).
- Mobile-friendly preview that lets operator view the toolpath on a phone while at the machine.
- Direct upload to FluidNC via the controller's WebUI API (no SD card swap needed).
cnc-sign-maker/
├── index.html Single-page app
├── css/
│ └── styles.css
├── js/
│ ├── app.js Top-level state, event wiring
│ ├── svg-parser.js Path parsing, tessellation
│ ├── bitmap-tracer.js Bitmap raster → traced contour geometry
│ ├── text-geometry.js Typed text + font → sign geometry
│ ├── shapes.js Parametric shape library
│ ├── geometry.js Polygon ops (uses Clipper)
│ ├── toolpath.js Generates toolpaths per operation
│ ├── gcode-emitter.js Toolpath → gcode strings
│ ├── preview.js Canvas rendering, pan/zoom, drag
│ ├── validation.js Pre-flight checks
│ ├── presets.js API client + LocalStorage autosave
│ ├── workers/
│ │ └── trace-worker.js Bitmap tracing in a Web Worker
│ └── lib/
│ ├── clipper.js Vendored Clipper 6.4.2 (Boost license)
│ └── opentype.js Vendored opentype.js (MIT)
├── img/
│ └── logo.svg
├── api/
│ ├── index.php Router
│ ├── bits.php
│ ├── materials.php
│ ├── presets.php
│ ├── jobs.php
│ └── db.php SQLite schema, seed data, helpers
├── fonts/ Bundled open-licensed sign fonts (+ licenses)
├── samples/ Test SVGs (square, circle, holes plate, text)
├── data/
│ ├── forge.sqlite Created on first run (gitignored)
│ ├── .htaccess Denies direct web access to data
│ └── jobs/ Saved gcode files (gitignored)
├── install.sh Optional initial DB seed + permissions
├── .htaccess Root server config (Apache / cPanel)
├── spec.md This document
└── README.md
For each release, ship with sample jobs that produce known-good gcode. A reference set of (SVG input, expected gcode output) pairs the developer can diff against to catch regressions.
Test SVGs shipped in samples/:
square.svg— a simple square (validates basic offsetting and tabs).circle.svg— a circle (validates G2/G3 arc emission or polyline approximation).holes-plate.svg— a plate with multiple holes (validates complex geometry with outer profiles, hole-vs-trace classification and drill cycles).text-sign.svg— a piece of text (validates curve tessellation).
- FluidNC configuration management (use the controller's WebUI).
- Probe-based auto-leveling (covered in stretch goals).
- Real-time gcode streaming (use FluidNC's WebUI).
- 3D / multi-axis output.
- Lathe or laser conversions.
- Account systems / multi-user.
- DXF / DWG input.
- 3D STL slicing.
- DOC: Depth Of Cut per pass — how far the bit descends each pass during multi-pass operations.
- Tool offset: Distance the bit center travels from the part edge to leave a finished surface; equals bit radius plus finishing allowance.
- Tab: A small region where the bit lifts during the final pass, leaving a thin material connection that holds the part to the surrounding stock.
- Engrave: A shallow centerline cut tracing the line drawing itself, with no compensation.
- Profile cut: A through-cut along the outside (or inside) of a closed path with tool radius compensation.
- Plunge feed: Z-axis descent speed during initial entry, typically slower than XY cut feed.
- Chip load: Material removed per tooth per revolution; key feed-rate sanity check. Computed as
feed_mm/min ÷ (RPM × flute_count). - Pre-stock height: A safe Z just above the material (e.g. 2mm) used as a transition between rapids and plunges.
- Upload SVG.
- Select bit (or accept the auto-suggested one based on material).
- Select material (or accept the auto-suggested DOC and feeds).
- Choose operation (engrave / pocket / vcarve / profile-out / profile-in / drill).
- Set final depth and confirm tabs (for profile-out).
- Position and rotate to fit your stock and machine envelope.
- Review preview — visually confirm the toolpath, tabs, origin.
- Click Generate.
- Download the gcode.
- Upload to your controller and run a clean air pass first if you've changed anything material.
This spec captures every gotcha I've hit in real CNC work to date. As you build and run jobs, the gotchas list at section 14 will grow — append to it. That section is the most valuable part of this document.
This spec reflects the currently shipped behavior in the repository, including bitmap tracing, workerized tracing (via importScripts of bitmap-tracer.js so the algorithm lives in one file) with stale-run protection, six operation modes, and the current tab behavior (even placement, material-aware Z anchoring on through-cuts). The seeded library ships 19 bits, 20 materials and 4 sample presets (see api/db.php), and the shape builder offers 22 base shapes plus 6 border variants (see js/shapes.js).
A pre-flight audit before live use surfaced several correctness issues that the current code now fixes:
- Tab Z on through-cuts now anchors to the material bottom when the material thickness is known, so the Tab thickness setting equals the actual remaining material — the shipped plywood preset used to produce 0.85 mm tabs while the UI said 1.5 mm.
- Peck drilling retracts above the stock surface between every peck and every depth pass, then rapids back down through the cleared hole — proper G73-style chip clearing on multi-pass drills.
- Non-circular drill features fall back to profile-in instead of being cut as oversized circles (a square classified as a "hole" by area would otherwise be cut as a circle ~41% larger than the design).
- Engrave steps in DOC increments like every other operation; shallow engraves stay single-pass.
- SVG degenerate cubic Beziers (legal self-returning curves) no longer trigger a 33 M-point recursion that hung the browser.
<use>of<symbol>now produces geometry (was silently skipped);<use width/height>on a viewBox'd target scales the instance correctly.- SVG
preserveAspectRatiois honoured — the parser used to stretch viewport-resized files. - Footer order:
M5emits before the parking rapid so a VFD-controlled spindle (if ever wired) doesn't spin through the return move. - Validation severity corrected:
finalDepth >= 0is now an error;safeZ <= 0blocks; the bit cutting-length check compares flute length against cut depth alone (adding Safe Z falsely blocked short-flute V-bits); the through-cut depth check no longer fires (false positive) on profile-in. - New validation warnings: DOC > bit diameter, plunge feed > cut feed, V-bit on a non-V-carve op, O-flute-recommended material with a multi-flute bit (generalises the HDPE/acrylic rule), spindle RPM vs material recommendation.