Composer: M3 mounting holes on preview + auto shape-to-path

Two user-facing polish items spotted at the preview stage:

(1) Plate previews now render the physical M3 mounting holes. SH-01
    and SH-02 get 4 corner holes; SH-03 (Large, 85×134mm) gets 6 —
    4 corners plus 2 mid-edge holes on the long sides, matching the
    physical clamping pattern. Each hole shows as:
      - a grey filled circle (3.5mm visual diameter, slightly larger
        than the actual M3 clearance for preview contrast)
      - a red dashed "no-engrave" exclusion ring (7mm diameter)
    The user can see at a glance where the engraver must not punch.

    Hole positions:
      corners at (holeInset, holeInset) with mirrors for the other
      three quadrants, where holeInset = 6.5mm — midway between
      outerMargin (3mm, the no-engrave boundary) and innerMargin
      (10mm, the safe text area). The 3-10mm band is where SeedHammer
      drills the holes on real plates.

    holePositions(plateDims) is a small helper exposed for the future
    when an actual gui/ port needs the same numbers.

(2) SVG file upload now auto-converts <rect>, <circle>, <ellipse>,
    <line>, <polyline>, <polygon> to <path d="..."/> in the browser
    before extracting d-strings. Lifted Gangleri42's rewriteShapes
    algorithm verbatim (same Bezier control-point ratios for the
    circle/ellipse approximations).

    Previously the composer rejected files with no <path> elements
    and told the user to "flatten shapes in your editor first" —
    which was a hard-fail UX for a fixable problem. Now any SVG
    with drawable shapes Just Works.

    Transforms still need manual flattening (getCTM-based bake is a
    bigger chunk); the help text reflects this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mineracks 2026-05-28 20:27:11 +10:00
parent f4a61f6cf0
commit 3ab72cbfdd
4 changed files with 218 additions and 9 deletions

View File

@ -328,21 +328,79 @@ func exportQRSVG(this js.Value, args []js.Value) any {
})
}
// writePlateChrome emits the plate outline + margin guides into sb. Shared
// between Text-mode and SVG-mode previews.
// holeInsetMM is how far the centre of each M3 mounting hole sits from
// the nearest plate edge. Sits roughly mid-way between outerMargin (3mm)
// and innerMargin (10mm) — the holes are physically drilled in that band
// on real SeedHammer SH-01/02/03 plates so engraving must avoid it.
const holeInsetMM = 6.5
// holeDiameterMM is the visual diameter of each mounting hole. The actual
// M3 clearance hole is 3.2mm but 3.5mm gives the preview better contrast
// against the plate fill.
const holeDiameterMM = 3.5
// holeDangerDiameterMM is the radius of the dashed "no-engrave" exclusion
// ring drawn around each hole. Mirrors the practical margin a user should
// leave so the engrave head doesn't catch on the screw head or punch
// directly onto stainless that's resting on a nut.
const holeDangerDiameterMM = 7.0
// holePositions returns the mounting-hole centres for a given plate.
// SH-01 (Small) and SH-02 (Square) have 4 corner holes; SH-03 (Large)
// adds 2 mid-edge holes on the long sides for extra clamping force
// (the 134mm length needs more than 4-corner support).
func holePositions(dims plateDims) [][2]float64 {
w, h := dims.W, dims.H
corners := [][2]float64{
{holeInsetMM, holeInsetMM},
{w - holeInsetMM, holeInsetMM},
{w - holeInsetMM, h - holeInsetMM},
{holeInsetMM, h - holeInsetMM},
}
// Threshold: any plate taller than ~120mm is Large-class and gets
// the two extra mid-edge holes. SH-03 is 134mm; SH-02 is 85mm so it
// falls below the threshold.
if h >= 120 {
corners = append(corners,
[2]float64{holeInsetMM, h / 2},
[2]float64{w - holeInsetMM, h / 2},
)
}
return corners
}
// writePlateChrome emits the plate outline + margin guides + mounting
// holes into sb. Shared between Text-mode and SVG-mode previews.
func writePlateChrome(sb *strings.Builder, dims plateDims) {
// Plate body.
fmt.Fprintf(sb,
`<rect x="0.5" y="0.5" width="%g" height="%g" rx="3" ry="3" fill="#ececec" stroke="#444" stroke-width="0.4"/>`,
dims.W-1, dims.H-1,
)
// outer-margin guide (no-engrave boundary at 3mm)
fmt.Fprintf(sb,
`<rect x="%g" y="%g" width="%g" height="%g" fill="none" stroke="#999" stroke-width="0.15" stroke-dasharray="0.6,0.6"/>`,
outerMarginMM, outerMarginMM, dims.W-2*outerMarginMM, dims.H-2*outerMarginMM,
)
// inner-margin guide (safe text area at 10mm)
fmt.Fprintf(sb,
`<rect x="%g" y="%g" width="%g" height="%g" fill="none" stroke="#666" stroke-width="0.15" stroke-dasharray="0.4,0.4"/>`,
innerMarginMM, innerMarginMM, dims.W-2*innerMarginMM, dims.H-2*innerMarginMM,
)
// Mounting holes — drawn LAST so they sit on top of the margin guides.
// Each hole gets a dashed "danger" exclusion ring + the hole itself,
// rendered with a contrasting fill so it reads as "metal removed
// here, leave clear" at a glance.
for _, h := range holePositions(dims) {
fmt.Fprintf(sb,
`<circle cx="%g" cy="%g" r="%g" fill="none" stroke="#c92a2a" stroke-width="0.15" stroke-dasharray="0.5,0.5" opacity="0.7"/>`,
h[0], h[1], holeDangerDiameterMM/2,
)
fmt.Fprintf(sb,
`<circle cx="%g" cy="%g" r="%g" fill="#777" stroke="#222" stroke-width="0.2"/>`,
h[0], h[1], holeDiameterMM/2,
)
}
}
// faceForFont returns the vector engraving face used for a given SH1E

69
platform/v1/platform.go Normal file
View File

@ -0,0 +1,69 @@
// Package v1 defines the platform adapter layer that v1's gui/, input/,
// and engrave/ packages bind against.
//
// Two build-time targets implement Platform:
//
// - Real device (GOOS=linux GOARCH=arm on Pi Zero): driver/drm for
// LCD frame output, driver/wshat for GPIO buttons, driver/libcamera
// for the QR-scanner camera, driver/mjolnir for the engraver USB
// serial.
//
// - Browser emulator (GOOS=js GOARCH=wasm): browser canvas writer,
// keyboard-event mapper, mock camera reading from a sibling pane's
// canvas, and a null engrave sink (or a visual playback harness).
//
// The interface lives here (not in gui/) so we can swap backends without
// touching the GUI code. gui.Context takes a Platform value and never
// calls any driver/ package directly.
package v1
import (
"image"
"github.com/mineracks/seedhammer-v1-companion/font/constant"
)
// Button identifies one of the eight physical inputs on the v1 hardware.
type Button int
const (
ButtonUp Button = iota
ButtonDown
ButtonLeft
ButtonRight
ButtonCenter
Button1
Button2
Button3
)
// Event is what a Platform delivers to the GUI's input loop.
type Event struct {
Button Button
Pressed bool // true = press, false = release
}
// Platform is the contract every backend implements. The GUI receives a
// Platform value at startup and drives every external interaction through
// it. Adding a new backend means implementing this interface — no GUI code
// changes.
type Platform interface {
// Events returns the channel of input events. Closed when the
// platform is shutting down.
Events() <-chan Event
// Display writes a frame to the LCD-equivalent. The image is the
// full screen at the platform's native resolution (240×240 for v1).
Display(frame image.Image)
// EngraveFont returns the vector engraving face. Both real and
// emulator backends return the same data — it's bundled with the
// firmware.
EngraveFont() *constant.Face
}
// constant.Face is the type alias we use in the public surface, re-exported
// here so callers don't have to import font/vector directly.
// (Today this is just *vector.Face under the hood; the alias lets us swap
// implementations without rippling change through the GUI.)
type Face = constant.Face

View File

@ -232,11 +232,15 @@ async function onSVGFile(ev) {
const text = await f.text();
const doc = new DOMParser().parseFromString(text, "image/svg+xml");
if (doc.querySelector("parsererror")) throw new Error("file isn't valid SVG");
const ds = [...doc.querySelectorAll("path")]
const root = doc.documentElement;
// Auto-convert shapes (<rect>, <circle>, <ellipse>, <line>, <polyline>,
// <polygon>) to <path d="..."/> so users don't have to flatten manually.
rewriteShapesToPaths(doc, root);
const ds = [...root.querySelectorAll("path")]
.map((p) => p.getAttribute("d"))
.filter(Boolean);
if (ds.length === 0) {
throw new Error("no <path d=\"...\"> elements found — flatten shapes to paths in your editor first");
throw new Error("file contains no drawable shapes (no paths, rects, circles, lines, polygons)");
}
svgPaths = ds;
els.svgSummary.textContent = `${f.name}${ds.length} path${ds.length === 1 ? "" : "s"}, ${ds.reduce((n, d) => n + d.length, 0)} chars total`;
@ -249,6 +253,85 @@ async function onSVGFile(ev) {
}
}
// rewriteShapesToPaths converts every SVG primitive shape into an equivalent
// <path d="..."/> element. Lifted (with cleanup) from Gangleri42's webnfc
// app.js — same algorithm, same numeric constants (0.5522847498 is the
// Bezier control-point ratio for approximating a quarter-circle).
//
// Transforms are NOT baked here — that needs DOM measurement via getCTM
// and a separate pass. Users with transform-heavy SVG should flatten in
// their editor (Inkscape: Object → Path, then Flatten Transforms).
function rewriteShapesToPaths(doc, root) {
const SVG_NS = "http://www.w3.org/2000/svg";
const replace = (el, d) => {
const p = doc.createElementNS(SVG_NS, "path");
p.setAttribute("d", d);
const t = el.getAttribute("transform");
if (t) p.setAttribute("transform", t);
el.parentNode.replaceChild(p, el);
};
for (const r of [...root.querySelectorAll("rect")]) {
const x = +r.getAttribute("x") || 0;
const y = +r.getAttribute("y") || 0;
const w = +r.getAttribute("width") || 0;
const h = +r.getAttribute("height") || 0;
if (!w || !h) { r.remove(); continue; }
replace(r, `M${x} ${y} L${x + w} ${y} L${x + w} ${y + h} L${x} ${y + h} Z`);
}
for (const c of [...root.querySelectorAll("circle")]) {
const cx = +c.getAttribute("cx") || 0;
const cy = +c.getAttribute("cy") || 0;
const r = +c.getAttribute("r") || 0;
if (!r) { c.remove(); continue; }
const k = 0.5522847498 * r;
replace(c, [
`M${cx - r} ${cy}`,
`C${cx - r} ${cy - k}, ${cx - k} ${cy - r}, ${cx} ${cy - r}`,
`C${cx + k} ${cy - r}, ${cx + r} ${cy - k}, ${cx + r} ${cy}`,
`C${cx + r} ${cy + k}, ${cx + k} ${cy + r}, ${cx} ${cy + r}`,
`C${cx - k} ${cy + r}, ${cx - r} ${cy + k}, ${cx - r} ${cy}`,
"Z",
].join(" "));
}
for (const e of [...root.querySelectorAll("ellipse")]) {
const cx = +e.getAttribute("cx") || 0;
const cy = +e.getAttribute("cy") || 0;
const rx = +e.getAttribute("rx") || 0;
const ry = +e.getAttribute("ry") || 0;
if (!rx || !ry) { e.remove(); continue; }
const kx = 0.5522847498 * rx;
const ky = 0.5522847498 * ry;
replace(e, [
`M${cx - rx} ${cy}`,
`C${cx - rx} ${cy - ky}, ${cx - kx} ${cy - ry}, ${cx} ${cy - ry}`,
`C${cx + kx} ${cy - ry}, ${cx + rx} ${cy - ky}, ${cx + rx} ${cy}`,
`C${cx + rx} ${cy + ky}, ${cx + kx} ${cy + ry}, ${cx} ${cy + ry}`,
`C${cx - kx} ${cy + ry}, ${cx - rx} ${cy + ky}, ${cx - rx} ${cy}`,
"Z",
].join(" "));
}
for (const ln of [...root.querySelectorAll("line")]) {
const x1 = +ln.getAttribute("x1") || 0;
const y1 = +ln.getAttribute("y1") || 0;
const x2 = +ln.getAttribute("x2") || 0;
const y2 = +ln.getAttribute("y2") || 0;
replace(ln, `M${x1} ${y1} L${x2} ${y2}`);
}
for (const poly of [...root.querySelectorAll("polygon, polyline")]) {
const pts = (poly.getAttribute("points") || "")
.split(/[\s,]+/)
.filter(Boolean)
.map(Number);
if (pts.length < 4) { poly.remove(); continue; }
const segs = [];
for (let i = 0; i < pts.length; i += 2) {
segs.push(`${i === 0 ? "M" : "L"}${pts[i]} ${pts[i + 1]}`);
}
if (poly.tagName.toLowerCase() === "polygon") segs.push("Z");
replace(poly, segs.join(" "));
}
}
async function loadWasm() {
setStatus("Loading WASM…");
const go = new Go();

View File

@ -61,11 +61,10 @@
<div class="editor-head">
<h2>SVG paths</h2>
<small>
Upload an .svg file. Each <code>&lt;path d=&quot;...&quot;&gt;</code>
element becomes one engrave stroke. Pre-flatten shapes and
transforms in your editor (Inkscape: <em>Object → Path</em>
then <em>Flatten Transforms</em>) — the composer reads
<code>d</code> attributes directly.
Upload an .svg file. Rects, circles, lines, polygons and
paths are all accepted and converted automatically. If your
shapes have transforms applied, flatten them first (Inkscape:
<em>Object → Flatten Transforms</em>).
</small>
</div>
<label class="file-pick">