Composer: switch to vector engraving face + swap layout columns

Three tightly-related polish changes:

1. Preview now uses font/constant.Font — the v1 firmware's *vector*
   engraving face — instead of bitmap Comfortaa. Every <path> stroke
   in the preview is the actual stroke the engraver will follow on
   the plate. Reads the same MoveTo/LineTo segment data the upstream
   stepper consumes. vector-effect=non-scaling-stroke keeps the
   stroke width visually constant regardless of the SVG scale.

   The bitmap-faithful renderer is gone — engrave fidelity is more
   useful than LCD fidelity for the composer use case. The
   bitmap.Face import path is dropped from main.go.

2. Layout columns swapped. Editor was on the right, preview on the
   left. Now editor (taller, wider) is on the LEFT and plate picker +
   preview + actions stack on the RIGHT. Matches the natural
   "pick a plate → type → see result" reading order. Plate section
   gains a .plate-card class so it can be placed in the grid area.

3. Stale-error fix: typing in any input now clears the Show-SH1E-
   bytes "enter at least one line first" warning. Previously stuck
   around until the bytes button was clicked again with valid input.

Also extends the composer's JS export surface (composerPreviewSVG,
composerEncodeSVG, composerQRSVG) and adds the SVG-mode helpers in
Go (readSVGArgs, makeSVGDesign, writePlateChrome). JS shell doesn't
call these yet — wiring lands in the next commit (stage #3 of the
five-stage push).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mineracks 2026-05-28 20:21:32 +10:00
parent daac2d8ac5
commit 50b5301545
4 changed files with 223 additions and 126 deletions

View File

@ -17,11 +17,10 @@ import (
"syscall/js"
"github.com/kortschak/qr"
"golang.org/x/image/math/fixed"
"github.com/mineracks/seedhammer-v1-companion/font/bitmap"
"github.com/mineracks/seedhammer-v1-companion/font/comfortaa"
"github.com/mineracks/seedhammer-v1-companion/engrave/wire/sh1e"
"github.com/mineracks/seedhammer-v1-companion/font/constant"
"github.com/mineracks/seedhammer-v1-companion/font/vector"
)
const composerVersion = "v0.1-phase1-milestone"
@ -32,6 +31,9 @@ func main() {
js.Global().Set("composerEncodeText", js.FuncOf(exportEncodeText))
js.Global().Set("composerPreviewText", js.FuncOf(exportPreviewText))
js.Global().Set("composerQR", js.FuncOf(exportQR))
js.Global().Set("composerPreviewSVG", js.FuncOf(exportPreviewSVG))
js.Global().Set("composerEncodeSVG", js.FuncOf(exportEncodeSVG))
js.Global().Set("composerQRSVG", js.FuncOf(exportQRSVG))
// Block forever so the Go runtime keeps the exported funcs alive.
select {}
}
@ -195,153 +197,240 @@ func exportPreviewText(this js.Value, args []js.Value) any {
dims := plateDimsByID[plateType]
var sb strings.Builder
// Note: SVG y-axis grows downward (consistent with our XMM/YMM
// "from plate-origin top-left" convention).
// SVG y-axis grows downward — consistent with our XMM/YMM "from
// plate-origin top-left" convention.
fmt.Fprintf(&sb,
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %g %g" preserveAspectRatio="xMidYMid meet" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">`,
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %g %g" preserveAspectRatio="xMidYMid meet">`,
dims.W, dims.H,
)
writePlateChrome(&sb, dims)
// Plate outline (rounded-corner rect)
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,
)
// outerMargin guide (dashed, light)
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,
)
// innerMargin guide (dashed, slightly darker)
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,
)
// Text blocks — render each as glyph-faithful bitmap from
// font/comfortaa. Each bitmap pixel becomes a small SVG rect inside
// a scale+translate group, so the preview matches what the firmware's
// LCD would show when rendering the same plate locally.
// Text blocks — render via the same vector engraving face the v1
// firmware streams to the MarkingWay head. Every <path> stroke in
// the preview is a stroke the engraver would actually punch.
for _, l := range layoutLines(lines) {
renderTextRowBitmap(&sb, l)
renderTextRow(&sb, l)
}
sb.WriteString(`</svg>`)
return sb.String()
}
// faceForFont returns the bitmap face used to render a given SH1E font ID
// at the given point size. v1.3.0 ships ~one face per font name (the
// engraver and the LCD both consume the same bitmap data), so size is
// approximate — we pick the closest available variant.
// ─── SVG-mode exports ────────────────────────────────────────────────────
//
// Future: when we add multi-size faces or vector-form engrave preview,
// this is where the lookup grows.
func faceForFont(id sh1e.FontID, sizePt uint16) *bitmap.Face {
switch id {
case sh1e.FontComfortaa:
// Comfortaa Bold17 ≈ 17px tall; Regular16 ≈ 16px. For our
// 12pt design (~16px equivalent), Regular16 is the closest fit.
return comfortaa.Regular16
case sh1e.FontPoppins, sh1e.FontConstant:
// Both fall back to Comfortaa until we wire their faces in.
return comfortaa.Regular16
// In SVG mode the composer takes a list of SVG path d-strings (extracted
// by the JS shell from an uploaded .svg file) and stamps them onto the
// plate. Each d-string becomes one sh1e.SvgPath, scaled to fit the plate
// body and anchored at the plate origin.
// readSVGArgs extracts (plateType, []d-strings) from JS args.
func readSVGArgs(args []js.Value) (sh1e.PlateType, []string, error) {
if len(args) != 2 {
return 0, nil, fmt.Errorf("expected (plateType, paths[]), got %d args", len(args))
}
return comfortaa.Regular16
plateType := sh1e.PlateType(args[0].Int())
jsPaths := args[1]
n := jsPaths.Length()
paths := make([]string, 0, n)
for i := 0; i < n; i++ {
s := jsPaths.Index(i).String()
if s == "" {
continue
}
paths = append(paths, s)
}
return plateType, paths, nil
}
// renderTextRowBitmap writes an SVG <g> for a single line, using the
// bitmap face's glyph data. Each "on" pixel in the bitmap becomes part
// of a horizontal-run <rect>. The whole group is scaled to match the
// requested fontMM size and translated to (l.XMM, l.YMM).
// makeSVGDesign builds an sh1e.Design with one SvgPath per d-string. For
// Phase 1 we anchor every path at the plate body's top-left interior
// corner (innerMargin, innerMargin) at 100% scale; richer positioning
// arrives in a follow-up commit once the composer has on-plate drag UI.
func makeSVGDesign(plateType sh1e.PlateType, paths []string) sh1e.Design {
svgPaths := make([]sh1e.SvgPath, 0, len(paths))
for _, d := range paths {
svgPaths = append(svgPaths, sh1e.SvgPath{
XMM: innerMarginMM,
YMM: innerMarginMM,
ScalePct: 100,
PathD: d,
})
}
return sh1e.Design{
PlateType: plateType,
SvgPaths: svgPaths,
}
}
// exportPreviewSVG: composerPreviewSVG(plateType, pathDStrings) -> string
//
// (l.XMM, l.YMM) is treated as the TOP-LEFT of the glyph cell — the
// translate target is the glyph top, with the baseline offset built
// into the scaling.
func renderTextRowBitmap(sb *strings.Builder, l lineLayout) {
face := faceForFont(l.FontID, l.Size)
// Renders the plate outline + the supplied SVG paths, rendered native via
// the browser's own <path d="..."/> support. Each path is drawn at the
// anchor (innerMargin, innerMargin) at 100% scale.
func exportPreviewSVG(this js.Value, args []js.Value) any {
plateType, paths, err := readSVGArgs(args)
if err != nil {
return jsError(err)
}
if int(plateType) >= len(plateDimsByID) {
return jsError(fmt.Errorf("unknown plate type %d", plateType))
}
dims := plateDimsByID[plateType]
var sb strings.Builder
fmt.Fprintf(&sb,
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %g %g" preserveAspectRatio="xMidYMid meet">`,
dims.W, dims.H,
)
writePlateChrome(&sb, dims)
for _, d := range paths {
// Each path is placed at (innerMargin, innerMargin) via a translate.
fmt.Fprintf(&sb,
`<g transform="translate(%g %g)" fill="none" stroke="#111" stroke-width="0.3" stroke-linecap="round" stroke-linejoin="round"><path d="%s"/></g>`,
float64(innerMarginMM), float64(innerMarginMM), escapeXML(d),
)
}
sb.WriteString(`</svg>`)
return sb.String()
}
// exportEncodeSVG: composerEncodeSVG(plateType, pathDStrings) -> Uint8Array
func exportEncodeSVG(this js.Value, args []js.Value) any {
plateType, paths, err := readSVGArgs(args)
if err != nil {
return jsError(err)
}
bytes, err := sh1e.Encode(makeSVGDesign(plateType, paths))
if err != nil {
return jsError(err)
}
return uint8Array(bytes)
}
// exportQRSVG: composerQRSVG(plateType, pathDStrings) -> {svg, modules, bytes}
func exportQRSVG(this js.Value, args []js.Value) any {
plateType, paths, err := readSVGArgs(args)
if err != nil {
return jsError(err)
}
payload, err := sh1e.Encode(makeSVGDesign(plateType, paths))
if err != nil {
return jsError(err)
}
code, err := qr.Encode(string(payload), qr.M)
if err != nil {
return jsError(fmt.Errorf("qr encode: %w", err))
}
return js.ValueOf(map[string]any{
"svg": qrSVG(code),
"modules": code.Size,
"bytes": len(payload),
})
}
// writePlateChrome emits the plate outline + margin guides into sb. Shared
// between Text-mode and SVG-mode previews.
func writePlateChrome(sb *strings.Builder, dims plateDims) {
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,
)
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,
)
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,
)
}
// faceForFont returns the vector engraving face used for a given SH1E
// font ID. Every SH1E font currently maps to font/constant — that's the
// only outline font the v1 firmware ships, and it's the one the engraver
// actually punches strokes from. Bitmap faces (font/comfortaa,
// font/poppins) are LCD-only and not used here.
//
// When upstream lands more vector faces, this map grows. The composer's
// preview is therefore guaranteed to look like what the engraver
// produces, because it's literally walking the same segment data the
// engrave pipeline does.
func faceForFont(id sh1e.FontID) *vector.Face {
// Single face for now. Future: switch on id.
_ = id
return constant.Font
}
// renderTextRow emits an SVG <g> containing the stroked outline of a
// single text line. Uses the vector engraving face — every segment is a
// MoveTo or LineTo, identical to what the engraver's stepper will follow.
//
// (l.XMM, l.YMM) is treated as the TOP-LEFT of the glyph cell. The
// translate target is the baseline = YMM + ascent*scale, so glyphs
// render with their top edge at YMM.
//
// vector-effect="non-scaling-stroke" keeps the visible stroke width
// constant regardless of the scale transform, matching the engraver's
// fixed punch dot size.
func renderTextRow(sb *strings.Builder, l lineLayout) {
face := faceForFont(l.FontID)
if face == nil {
return
}
// SH1E layout treats Size in points. Convert to the corresponding
// physical height in mm: ~0.33 mm/pt is the rule of thumb we use
// elsewhere in the file.
fontMM := float64(l.Size) * 0.33
// The bitmap is rendered in pixel units. Pick a scale factor so the
// glyph's em-square (≈16 px tall for Regular16) maps to fontMM.
const facePx = 16.0
scale := fontMM / facePx
// Baseline sits a cap-height below the top-left anchor. Glyph
// bounds.Min.Y is negative for the ascender region; rendering at
// y = ascentPx places the glyph top exactly at originY=0 in the
// pre-translate coordinate space.
const ascentPx = 13 // Comfortaa Regular16 ascent
// Horizontal alignment shifts the entire group. Compute the line's
// pixel width by walking glyphs once.
cursorPx := fixed.Int26_6(0)
prevR := rune(0)
for _, r := range l.Text {
if prevR != 0 {
cursorPx += face.Kern(prevR, r)
}
_, advance, ok := face.Glyph(r)
if ok {
cursorPx += advance
}
prevR = r
metrics := face.Metrics()
emHeight := float64(metrics.Height)
if emHeight <= 0 {
return
}
fontMM := float64(l.Size) * 0.33
scale := fontMM / emHeight
// First pass: total advance for horizontal alignment math.
totalAdvance := 0
for _, r := range l.Text {
adv, _, ok := face.Decode(r)
if ok {
totalAdvance += adv
}
}
totalPx := float64(cursorPx) / 64.0
originX := float64(l.XMM)
switch l.Alignment {
case sh1e.AlignCenter:
originX -= totalPx * scale * 0.5
originX -= float64(totalAdvance) * scale * 0.5
case sh1e.AlignRight:
originX -= totalPx * scale
originX -= float64(totalAdvance) * scale
}
baselineY := float64(l.YMM) + float64(metrics.Ascent)*scale
fmt.Fprintf(sb,
`<g transform="translate(%g %g) scale(%g)" fill="#111">`,
originX, float64(l.YMM)+float64(ascentPx)*scale, scale,
`<g transform="translate(%g %g) scale(%g)" fill="none" stroke="#111" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke">`,
originX, baselineY, scale,
)
// Second pass: actually emit rects.
cursorPx = 0
prevR = 0
cursorX := 0
for _, r := range l.Text {
if prevR != 0 {
cursorPx += face.Kern(prevR, r)
}
img, advance, ok := face.Glyph(r)
adv, segs, ok := face.Decode(r)
if !ok {
prevR = r
continue
}
cursorPxInt := int(cursorPx >> 6)
b := img.Bounds()
for y := b.Min.Y; y < b.Max.Y; y++ {
x := b.Min.X
for x < b.Max.X {
if img.AlphaAt(x, y).A < 0x80 {
x++
continue
}
runStart := x
for x < b.Max.X && img.AlphaAt(x, y).A >= 0x80 {
x++
}
fmt.Fprintf(sb,
`<rect x="%d" y="%d" width="%d" height="1"/>`,
cursorPxInt+runStart, y, x-runStart,
)
var d strings.Builder
for {
seg, hasMore := segs.Next()
if !hasMore {
break
}
switch seg.Op {
case vector.SegmentOpMoveTo:
fmt.Fprintf(&d, "M%d %d ", cursorX+seg.Arg.X, seg.Arg.Y)
case vector.SegmentOpLineTo:
fmt.Fprintf(&d, "L%d %d ", cursorX+seg.Arg.X, seg.Arg.Y)
}
}
cursorPx += advance
prevR = r
if d.Len() > 0 {
fmt.Fprintf(sb, `<path d="%s"/>`, d.String())
}
cursorX += adv
}
sb.WriteString(`</g>`)
}

View File

@ -94,17 +94,20 @@ main {
@media (min-width: 720px) {
main {
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1.2fr);
/* Editor on the LEFT (taller column, fixed width); plate picker,
preview and actions stacked on the RIGHT. Matches the natural
composer flow: "pick a plate → type your text → see the result". */
grid-template-columns: minmax(320px, 1.2fr) minmax(320px, 1fr);
grid-template-areas:
"tabs tabs"
"preview editor"
"preview actions"
"footer footer";
"editor plate"
"editor preview"
"editor actions"
"footer footer";
}
.card.tabs-card { grid-area: tabs; }
.card.editor-card { grid-area: editor; align-self: start; }
.card.plate-card { grid-area: plate; align-self: start; }
.card.preview-card { grid-area: preview; align-self: start; }
.card.editor-card { grid-area: editor; }
.card.actions-card { grid-area: actions; }
.card.actions-card { grid-area: actions; align-self: start; }
.card.footer-card { grid-area: footer; }
}

View File

@ -94,6 +94,12 @@ function scheduleRefresh() {
// Debounce keystrokes; 80ms feels live but doesn't thrash the WASM.
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = setTimeout(refresh, 80);
// Clear any stale Show-SH1E-bytes error as soon as the user types again.
if (!els.output.hidden && els.output.classList.contains("error")) {
els.output.hidden = true;
els.output.classList.remove("error");
els.output.textContent = "";
}
}
function refresh() {

View File

@ -21,7 +21,7 @@
</header>
<main>
<section class="card glass" aria-label="Plate">
<section class="card glass plate-card" aria-label="Plate">
<div class="editor-head">
<h2>Plate</h2>
<small>Pick the stainless plate you'll engrave on.</small>
@ -38,9 +38,8 @@
</div>
</div>
<p class="preview-note">
Rendered with the same Comfortaa bitmap data the v1 firmware ships
on the LCD. Each pixel in the preview corresponds to a pixel the
engraver will punch.
Rendered with the v1 firmware's vector engraving face — every line
you see is a stroke the engraver will actually punch on the plate.
</p>
</section>