mirror of
https://github.com/mineracks/seedhammer-v1-companion.git
synced 2026-06-26 22:01:05 +10:00
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:
parent
f4a61f6cf0
commit
3ab72cbfdd
@ -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
69
platform/v1/platform.go
Normal 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
|
||||
@ -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();
|
||||
|
||||
@ -61,11 +61,10 @@
|
||||
<div class="editor-head">
|
||||
<h2>SVG paths</h2>
|
||||
<small>
|
||||
Upload an .svg file. Each <code><path d="..."></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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user