diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index f554fd02d..fdd84386f 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -71,7 +71,7 @@ A layered, dockable workspace: nine panels over a layer-stack project. A panel i | :--- | :--- | :--- | | Preview, Layers, Code, Knobs, Color Ramp | `panels/` | The editor proper. Layers are code (JS), pixel (RGBA buffer, edited in place and versioned by `rev`) or text; ramps are per code or text layer, knobs are one shared set | | Pixel | `panels/PixelPanel.tsx` + `panels/pixel/` + `lib/lab/pixelTools.ts` | Pixel-art editor. The panel composes one hook per concern (`usePixelToolState`, `usePixelSelection`, `usePixelViewport`, `usePixelCanvases`, `usePixelPointer`, `usePixelKeyboard`) and two toolbar components; `pixelToCode.ts` turns a sprite into standalone pattern code | -| Text | `panels/TextPanel.tsx` + `lib/lab/text.ts`, `textRaster.ts`, `textFonts.ts` | Text layers. Words, size, place, alignment and font are drawn into the same RGBA buffer a pixel layer holds on every change, so the engine, masks, Graphic Export and both exports read a bitmap and never the font. Fonts are the site's own, the device's generic families, or a file the person picks, read with `FontFace` and kept in this browser's IndexedDB (`patternflow_lab_fonts_v1`) only when they ask. Drag on Preview moves the selected text | +| Text | `panels/TextPanel.tsx` + `lib/lab/text.ts`, `textRaster.ts`, `textFonts.ts` | Text layers. Words, size, place, alignment and font are drawn into the same RGBA buffer a pixel layer holds on every change, so the engine, masks, Graphic Export and both exports read a bitmap and never the font. Fonts are the site's own, the device's generic families, or a file the person picks, read with `FontFace` and kept in this browser's IndexedDB (`patternflow_lab_fonts_v1`) only when they ask. Drag on Preview moves the selected text. Copy as code hands the drawn letters out as a pattern that needs no font, through Pixel's `lib/lab/pixelToCode.ts`. `lib/lab/textExport.test.ts` compiles the To hardware header with the host's C++ compiler and checks it against the preview | | Graphic Export | `panels/CapturePanel.tsx` + `lib/lab/capture/` | Stills and clips at print sizes, rendered by a second engine in a Web Worker (WebCodecs + `mediabunny`, all client-side), plus a GLSL shader twin for posters. `probe.ts` decides whether a pattern can be re-rendered at another size or must be upscaled | | Director | `panels/DirectorPanel.tsx` + `panels/director/` + `lib/lab/director/` | Knob automation over time — keyframes, bezier eases — baked to `.pfs` show files (`src/lib/pattern/pfst.ts` is the codec) and exported as MIDI. The panel composes `useTransportView`, `useTimelineZoom`, `useShowEditing`, the file helpers and two bars; the lanes are `DirectorLanes.tsx` | diff --git a/web/src/app/pattern-lab/HardwareModal.test.tsx b/web/src/app/pattern-lab/HardwareModal.test.tsx index eb4db6989..e1eaf3cfe 100644 --- a/web/src/app/pattern-lab/HardwareModal.test.tsx +++ b/web/src/app/pattern-lab/HardwareModal.test.tsx @@ -155,6 +155,38 @@ describe("HardwareModal", () => { } }); + // Text and pixel layers are in the scaffold as pixels, so with no code + // layer the header is whole. The window used to count "0 layers to + // translate — 0 done" and send the person to a model anyway. + it("a stack of text alone is a finished header, with no model to ask", async () => { + const lab = useLabStore.getState(); + const starter = lab.layers[0].id; + lab.addTextLayer(); + useLabStore.getState().removeLayer(starter); + expect(useLabStore.getState().layers.map((layer) => layer.type)).toEqual(["text"]); + const writeText = vi.fn<(text: string) => Promise>(() => Promise.resolve()); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + try { + render(); + expect(screen.getByText(/Nothing here needs a model/)).toHaveTextContent("The board needs no font for the letters."); + expect(screen.queryByText(/to translate/)).toBeNull(); + expect(screen.queryByText(/Translating it is a model/)).toBeNull(); + expect(screen.queryByText("Copy prompt")).toBeNull(); + + fireEvent.click(screen.getByText("Copy .h")); + expect(await screen.findByText("Copied ✓")).toBeTruthy(); + const header = String(writeText.mock.calls[0][0]); + expect(header).toMatch(/^#pragma once$/m); + expect(header).toContain('"Text 1": text as drawn, RLE'); + expect(header).not.toContain("TRANSLATION BEGIN"); + + fireEvent.click(screen.getByText("Next →")); + expect(screen.getByText("On to hardware")).toBeTruthy(); + } finally { + delete (navigator as { clipboard?: unknown }).clipboard; + } + }); + it("Esc over the Publish modal closes Publish alone and keeps the header", async () => { render(); pasteHeaderAndContinue(); diff --git a/web/src/app/pattern-lab/HardwareModal.tsx b/web/src/app/pattern-lab/HardwareModal.tsx index dda5ada6a..272b71284 100644 --- a/web/src/app/pattern-lab/HardwareModal.tsx +++ b/web/src/app/pattern-lab/HardwareModal.tsx @@ -9,7 +9,7 @@ import { buildCppPrompt } from "@/lib/lab/cppPrompt"; import { currentPerformanceJson } from "@/lib/lab/director/publish"; import { needsFlatten } from "@/lib/lab/flatten"; import { labPatternName, useLabStore } from "@/lib/lab/store"; -import { isCodeLayer } from "@/lib/lab/types"; +import { isCodeLayer, isTextLayer } from "@/lib/lab/types"; import styles from "./PatternLab.module.css"; import local from "./HardwareModal.module.css"; @@ -94,6 +94,10 @@ export default function HardwareModal({ [stacked, exportData.scaffold, pastes, single, name], ); + // Text and pixel layers go into the scaffold as the pixels they show, so + // a stack with no code layer is the whole header, no model in the loop. + const nothingToTranslate = stacked && exportData.units.length === 0; + const translated = exportData.units.filter( (unit) => (pastes[unit.index] ?? "").trim().length > 0 && cleanPastedUnit(pastes[unit.index] ?? "", unit.index) !== null, ).length; @@ -186,8 +190,10 @@ export default function HardwareModal({

The board runs C++, so everything below — building an image, installing a module, publishing as hardware-ready — needs this pattern as a .h header - first. Translating it is a model’s job: copy the prompt, paste what comes - back. + first. + {nothingToTranslate + ? null + : " Translating it is a model’s job: copy the prompt, paste what comes back."}

{stacked ? ( @@ -202,11 +208,19 @@ export default function HardwareModal({ spellCheck={false} /> -

- {exportData.units.length} layer{exportData.units.length === 1 ? "" : "s"} to - translate — {translated} done. Untranslated layers keep a compiling stub, so a - partial port still builds. -

+ {nothingToTranslate ? ( +

+ Nothing here needs a model. Text and pixel layers go into the header as the + pixels they show, so it is finished as it is. + {layers.some(isTextLayer) ? " The board needs no font for the letters." : null} +

+ ) : ( +

+ {exportData.units.length} layer{exportData.units.length === 1 ? "" : "s"} to + translate — {translated} done. Untranslated layers keep a compiling stub, so a + partial port still builds. +

+ )} {exportData.units.map((unit) => (
diff --git a/web/src/app/pattern-lab/panels/TextPanel.module.css b/web/src/app/pattern-lab/panels/TextPanel.module.css index e0746424f..4ef8ac4b1 100644 --- a/web/src/app/pattern-lab/panels/TextPanel.module.css +++ b/web/src/app/pattern-lab/panels/TextPanel.module.css @@ -72,14 +72,16 @@ height: 32px; } -.fontFile { +.fontFile, +.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; } -.fontFile button { +.fontFile button, +.actions button { min-height: 32px; border-radius: 4px; border: 1px solid rgba(23, 21, 18, 0.3); @@ -90,7 +92,8 @@ cursor: pointer; } -.fontFile button:hover:not(:disabled) { +.fontFile button:hover:not(:disabled), +.actions button:hover:not(:disabled) { background: #171512; color: #f7f3ea; } @@ -133,7 +136,8 @@ @media (hover: none) { .field select, .field input[type="number"], - .fontFile button { + .fontFile button, + .actions button { min-height: 44px; } diff --git a/web/src/app/pattern-lab/panels/TextPanel.test.tsx b/web/src/app/pattern-lab/panels/TextPanel.test.tsx index 55f8ccdd7..50adca8d0 100644 --- a/web/src/app/pattern-lab/panels/TextPanel.test.tsx +++ b/web/src/app/pattern-lab/panels/TextPanel.test.tsx @@ -132,6 +132,56 @@ describe("TextPanel", () => { }); }); +describe("Copy as code", () => { + function clipboard() { + const writeText = vi.fn<(text: string) => Promise>(() => Promise.resolve()); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + return writeText; + } + + it("copies the letters as they are drawn now, and the new ones after an edit", async () => { + store().addTextLayer(); + store().setTextSpec(text().id, { text: "ab", x: 2, y: 4 }); + const writeText = clipboard(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Copy as code" })); + await act(async () => {}); + expect(screen.getByRole("button", { name: "Copied ✓" })).toBeTruthy(); + const first = String(writeText.mock.calls[0][0]); + expect(first).toContain('// Text: "ab"'); + // Two letters, a 2×2 block each: a 4 × 2 sprite. + expect(first).toContain("const SPRITE_W = 4, SPRITE_H = 2;"); + + fireEvent.change(screen.getByRole("textbox", { name: "Text" }), { target: { value: "abcd" } }); + fireEvent.click(screen.getByRole("button", { name: "Copied ✓" })); + const second = String(writeText.mock.calls[1][0]); + expect(second).toContain('// Text: "abcd"'); + expect(second).toContain("const SPRITE_W = 8, SPRITE_H = 2;"); + }); + + it("says so when no letter is on the frame", () => { + store().addTextLayer(); + store().setTextSpec(text().id, { text: "" }); + const writeText = clipboard(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Copy as code" })); + expect(writeText).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "No letters on the frame" })).toBeTruthy(); + }); + + it("says so when the browser refuses the clipboard", async () => { + store().addTextLayer(); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: () => Promise.reject(new Error("denied")) }, + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Copy as code" })); + await act(async () => {}); + expect(screen.getByRole("button", { name: "Copy failed" })).toBeTruthy(); + }); +}); + describe("dragging text on Preview", () => { it("moves the selected text by whole LED pixels", () => { store().addTextLayer(); diff --git a/web/src/app/pattern-lab/panels/TextPanel.tsx b/web/src/app/pattern-lab/panels/TextPanel.tsx index 8131922a0..5163db611 100644 --- a/web/src/app/pattern-lab/panels/TextPanel.tsx +++ b/web/src/app/pattern-lab/panels/TextPanel.tsx @@ -6,8 +6,13 @@ // // Fonts come from the site, the device or a file the person picks // (lib/lab/textFonts.ts). A file is read in this browser and goes nowhere. +// +// Copy as code takes the letters as they are drawn now, the way Pixel's does +// a drawing (lib/lab/pixelToCode.ts): a pattern that needs no font. import { useEffect, useRef, useState, type ChangeEvent } from "react"; +import { useResettingState } from "@/lib/hooks/useResettingState"; +import { buildPixelPatternCode } from "@/lib/lab/pixelToCode"; import { useActiveLayer, useLabStore } from "@/lib/lab/store"; import { FONT_FILE_ACCEPT, @@ -32,6 +37,7 @@ import { type TextFill, type TextLayer, } from "@/lib/lab/types"; +import type { CodeCopyState } from "./pixel/PixelToolbars"; import local from "./TextPanel.module.css"; import dock from "../LabPanels.module.css"; @@ -49,6 +55,13 @@ const FILL_LABEL: Record = { const ALIGN_LABEL = { left: "Left", center: "Center", right: "Right" } as const; +const COPY_LABEL: Record = { + idle: "Copy as code", + copied: "Copied ✓", + empty: "No letters on the frame", + failed: "Copy failed", +}; + /** The font picker's entries, kept up as fonts are added, remembered or arrive. */ function useFontOptions(): FontOption[] { const [options, setOptions] = useState(fontOptions); @@ -136,9 +149,22 @@ function TextEditor({ layer }: { layer: TextLayer }) { const [remember, setRemember] = useState(false); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(null); + const [codeCopy, flashCodeCopy] = useResettingState("idle", 1400); const { spec } = layer; const set = (patch: Parameters[1]) => setTextSpec(layer.id, patch); + const copyAsCode = () => { + const code = buildPixelPatternCode(layer); + if (!code) { + flashCodeCopy("empty"); + return; + } + navigator.clipboard + .writeText(code) + .then(() => flashCodeCopy("copied")) + .catch(() => flashCodeCopy("failed")); + }; + const missing = resolveFont(spec.font).missing; const groups = (["site", "system", "file"] as const).map((group) => ({ group, @@ -318,6 +344,17 @@ function TextEditor({ layer }: { layer: TextLayer }) { ? "Drag the text in Preview to move it." : "Drag the text in Preview to move it. The colors are this layer's Color Ramp."}

+ +
+ + The letters as pixels: the code needs no font. Change the words and copy again. +
); diff --git a/web/src/lib/lab/pixelToCode.ts b/web/src/lib/lab/pixelToCode.ts index 5c3d0c7a0..bc79592c1 100644 --- a/web/src/lib/lab/pixelToCode.ts +++ b/web/src/lib/lab/pixelToCode.ts @@ -1,8 +1,10 @@ -// ── Pixel layer → standalone pattern code ──────────────────────────────────── -// Turns one pixel-art layer into a self-contained JS pattern whose whole -// reason to exist is being PASTED INTO AN AI CHAT: the user draws a sprite, -// copies it as code, and asks an AI to animate or distort it. The pixel -// layer itself is untouched — this is a copy, not a conversion. +// ── Pixel or text layer → standalone pattern code ──────────────────────────── +// Turns one pixel-art or text layer into a self-contained JS pattern whose +// whole reason to exist is being PASTED INTO AN AI CHAT: the user draws a +// sprite or sets a word, copies it as code, and asks an AI to animate or +// distort it. The layer itself is untouched — this is a copy, not a +// conversion. A text layer goes as the pixels it was last drawn as, so the +// pattern needs no font; new words in the Lab mean copying again. // // What an AI needs to work on pixel art without wrecking it, learned the // hard way in hExport.ts ("the LLM never sees machine data"): @@ -11,7 +13,9 @@ // · an ASCII preview comment so the model can SEE what it is distorting // · an inverse-mapping draw loop (screen pixel → sprite coordinate) with // a marked DISTORT block — coordinate warps are the natural lever, and -// inverse mapping leaves no holes +// inverse mapping leaves no holes — and a marked LIGHT block after the +// sample, where a pixel's colour and opacity can change (a twinkle, a +// colour running through the letters) // · the harness contract in the header, because the chat has no other // way to know what draw(display, params, time) means // @@ -23,7 +27,8 @@ import { withMatrixAnnotation } from "@/lib/pattern/matrix"; import { rleEncodeRGBA } from "./flatten"; import { bytesToBase64 } from "./serialize"; -import type { PixelLayer } from "./types"; +import { fontLabel } from "./textFonts"; +import type { RasterLayer } from "./types"; type SpriteCrop = { x: number; @@ -34,7 +39,7 @@ type SpriteCrop = { }; /** Tight bounding box of non-transparent pixels; null when nothing is drawn. */ -function cropToSprite(layer: PixelLayer): SpriteCrop | null { +function cropToSprite(layer: RasterLayer): SpriteCrop | null { const { width, height, data } = layer; let minX = width; let minY = height; @@ -134,11 +139,41 @@ function decodeRLE(s, n) { return out; }`; +/** What the code says it is, and the warps it suggests: a sprite bends, a word moves. */ +function describeLayer(layer: RasterLayer) { + if (layer.type === "text") { + const { text, size, font } = layer.spec; + const words = text.length > 48 ? `${text.slice(0, 47)}…` : text; + return { + title: `Text: ${commentSafe(JSON.stringify(words))}`, + what: `As it stands it draws the text below, unchanged, exactly where it sits in +// the Lab. The letters were drawn in ${commentSafe(fontLabel(font))} at ${size} px and are carried +// here as pixels, so the pattern needs no font. It exists to be modified: +// scroll it, wave it, make it twinkle, run colour through it.`, + object: "the letters", + warps: `// const span = display.width + SPRITE_W; // scroll left, and in +// sx = ((Math.round(sx + time * 20) % span) + span) % span; // again from the right +// sy += Math.sin(sx * 0.3 + time * 4) * 2; // wave along the line +// sy += Math.sin(time) * 4; // bob up and down`, + }; + } + return { + title: `Pixel sprite: ${commentSafe(layer.name)}`, + what: `As it stands it draws the sprite below, unchanged, exactly where it was drawn. +// It exists to be modified: animate the sprite, distort it, add effects.`, + object: "the image", + warps: `// sx += Math.sin(sy * 0.4 + time * 2) * 2; // wavy +// sy += Math.sin(time) * 4; // bob up and down +// const s = 1 + 0.2 * Math.sin(time * 3); // breathe: divide +// sx = SPRITE_W / 2 + (sx - SPRITE_W / 2) / s; // around the centre`, + }; +} + /** * Build the standalone pattern. Returns null when the layer is fully * transparent — there is no sprite to export. */ -export function buildPixelPatternCode(layer: PixelLayer): string | null { +export function buildPixelPatternCode(layer: RasterLayer): string | null { const sprite = cropToSprite(layer); if (!sprite) return null; @@ -155,16 +190,16 @@ export function buildPixelPatternCode(layer: PixelLayer): string | null { const preview = asciiPreview(sprite) .map((row) => `// ${row}`) .join("\n"); + const about = describeLayer(layer); - const code = `// Pixel sprite: ${commentSafe(layer.name)} — exported by Patternflow Pattern Lab. + const code = `// ${about.title} — exported by Patternflow Pattern Lab. // -// WHAT THIS IS — a JavaScript pattern for a ${layer.width}×${layer.height} LED matrix. As it -// stands it draws the sprite below, unchanged, exactly where it was drawn. -// It exists to be modified: animate the sprite, distort it, add effects. +// WHAT THIS IS — a JavaScript pattern for a ${layer.width}×${layer.height} LED matrix. +// ${about.what} // // THE ONE RULE — never retype, reflow, or shorten the SPRITE_DATA string // or the decoder functions. They are machine data; one wrong character -// destroys the image. Copy them through verbatim and make every change in +// destroys ${about.object}. Copy them through verbatim and make every change in // draw() (or an added update()). // // THE HARNESS — draw(display, params, time) runs every frame. time is in @@ -177,10 +212,13 @@ export function buildPixelPatternCode(layer: PixelLayer): string | null { // HOW TO DISTORT — draw() walks every screen pixel and computes which // sprite pixel lands there (sx, sy), then samples nearest-neighbour so the // art stays crisp. Warp sx/sy inside the DISTORT block: -// sx += Math.sin(sy * 0.4 + time * 2) * 2; // wavy -// sy += Math.sin(time) * 4; // bob up and down -// const s = 1 + 0.2 * Math.sin(time * 3); // breathe: divide -// sx = SPRITE_W / 2 + (sx - SPRITE_W / 2) / s; // around the centre +${about.warps} +// +// HOW TO LIGHT — after the sample, r, g, b (0–255) and a (0–1) are that +// pixel's colour and opacity. Change them inside the LIGHT block: +// const tw = 0.6 + 0.4 * Math.sin(time * 6 + x * 1.7 + y * 2.3); // twinkle +// r *= tw; g *= tw; b *= tw; +// a *= 0.5 + 0.5 * Math.sin(time * 2); // fade in and out // // The sprite, roughly (. = transparent, : dim → @ bright): ${preview} @@ -213,10 +251,14 @@ export function draw(display, params, time) { const i = sample(sx, sy); if (i < 0) continue; - const a = SPRITE[i + 3] / 255; + let r = SPRITE[i], g = SPRITE[i + 1], b = SPRITE[i + 2]; + let a = SPRITE[i + 3] / 255; + + // ── LIGHT HERE ── change r, g, b or a (examples in header) + if (a <= 0) continue; // Alpha premultiplies onto the black panel: LEDs emit light. - display.setPixel(x, y, SPRITE[i] * a, SPRITE[i + 1] * a, SPRITE[i + 2] * a); + display.setPixel(x, y, r * a, g * a, b * a); } } }`; diff --git a/web/src/lib/lab/textExport.test.ts b/web/src/lib/lab/textExport.test.ts new file mode 100644 index 000000000..735273a01 --- /dev/null +++ b/web/src/lib/lab/textExport.test.ts @@ -0,0 +1,591 @@ +// A text layer on its way to the device. The Lab draws the letters into a +// bitmap once (text.ts) and both exports carry that bitmap, so the device +// never needs the font. What was missing until 23 October 2026 was a check +// that the three renderers agree: the Lab preview, the JS the flattener +// writes, and the C++ header the To hardware window builds. The header half +// compiles the scaffold with the host's C++ compiler against the firmware's +// own math, colour, memory and param headers; only the panel, the encoders +// and Arduino.h are stood in for, and the stand-in canvas keeps the frame in +// logical coordinates, the ones the pattern writes. The compiler is the +// host's, not the S3's: the floats are the same 32 bits, but the Xtensa build +// may fuse a multiply and an add that x86 does in two steps. + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { createIdleInput, PatternRuntime } from "@/lib/pattern/harness"; +import type { MatrixSize } from "@/lib/pattern/matrix"; +import { LabEngine } from "./engine"; +import { flattenLayers } from "./flatten"; +import { assembleH, buildHExport } from "./hExport"; +import { buildPixelPatternCode } from "./pixelToCode"; +import { paintText } from "./text"; +import { + BLEND_MODES, + DEFAULT_RAMP_STATE, + TEXT_RAMP_STATE, + createPixelLayer, + createTextLayer, + defaultTextSpec, + type BlendMode, + type CodeLayer, + type Layer, + type PixelLayer, + type RampState, + type TextLayer, + type TextSpec, +} from "./types"; + +const FIRMWARE = path.resolve(__dirname, "../../../../firmware/patternflow"); + +function random(seed: number) { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 2 ** 32; + }; +} + +// Edge coverage cycles through these, so both sides of each threshold turn +// up: 127 and 128 for sharp edges and pixel masks, 1 and 254 at the ends. +// -1 is a random one. Until the 127 and 128 were here, moving the header's +// mask threshold to 129 passed every case. +const EDGES = [127, 128, 1, 254, 64, 191, -1]; + +/** + * Letters stood in for by blocks: glyph-sized boxes on one line, each with + * holes and edges the font only partly covers. Soft coverage is what a real + * font leaves on the edge of a stem. + */ +function glyphCoverage(frame: MatrixSize, next: () => number): Uint8ClampedArray { + const { width, height } = frame; + const coverage = new Uint8ClampedArray(width * height); + const glyphW = Math.max(3, Math.floor(width / 10)); + const glyphH = Math.max(4, Math.floor(height / 3)); + const top = Math.floor(next() * (height - glyphH)); + let edges = 0; + for (let left = Math.floor(next() * glyphW); left + glyphW <= width; left += glyphW + 1 + Math.floor(next() * 3)) { + for (let y = top; y < top + glyphH; y++) { + for (let x = left; x < left + glyphW; x++) { + const edge = x === left || x === left + glyphW - 1 || y === top || y === top + glyphH - 1; + const hole = !edge && (x - left) % 2 === 1 && (y - top) % 3 === 1; + if (hole) continue; + const value = EDGES[edges++ % EDGES.length]; + coverage[y * width + x] = !edge ? 255 : value < 0 ? Math.floor(next() * 256) : value; + } + } + } + return coverage; +} + +function textLayer( + id: string, + frame: MatrixSize, + next: () => number, + spec: Partial, + common: Partial> = {}, + ramp: RampState = TEXT_RAMP_STATE, +): TextLayer { + const layer = createTextLayer(frame, id); + const full = { ...defaultTextSpec(frame), ...spec }; + return { + ...layer, + ...common, + id, + name: id, + spec: full, + ramp, + data: paintText(glyphCoverage(frame, next), full, ramp, frame.width, frame.height), + }; +} + +/** Pixel art under the text: random colour, a third of it clear, some of it half there. */ +function artLayer(id: string, frame: MatrixSize, next: () => number, common: Partial = {}): PixelLayer { + const layer = createPixelLayer(frame, id); + for (let i = 0; i < frame.width * frame.height; i++) { + const roll = next(); + if (roll < 0.33) continue; + layer.data.set( + [Math.floor(next() * 256), Math.floor(next() * 256), Math.floor(next() * 256), roll < 0.5 ? Math.floor(next() * 256) : 255], + i * 4, + ); + } + return { ...layer, ...common, id, name: id }; +} + +// One code layer with its C++ written out by hand, the way the To hardware +// window's paste box takes it, so the header has a real layer under a text +// mask and not only the black stub. +const STRIPES_JS = `export function draw(display) { + for (let y = 0; y < display.height; y++) + for (let x = 0; x < display.width; x++) + display.setPixel(x, y, (x * 16) & 255, (y * 32) & 255, 128); +}`; +const STRIPES_CPP = (index: number) => `namespace L${index} { + void setup() {} + void update(float dt, const InputFrame& input) { (void)dt; (void)input; } + void draw() { + for (int y = 0; y < FRAME_H; y++) + for (int x = 0; x < FRAME_W; x++) + L${index}_setPixel(x, y, (x * 16) & 255, (y * 32) & 255, 128); + } +}`; + +function stripes(id: string): CodeLayer { + return { + id, + type: "code", + name: id, + visible: true, + opacity: 1, + blend: "normal", + role: "paint", + maskInvert: false, + code: STRIPES_JS, + ramp: DEFAULT_RAMP_STATE, + recolor: false, + knobsAnnotationRaw: null, + matrixAnnotationRaw: null, + }; +} + +type Case = { name: string; frame: MatrixSize; layers: Layer[] }; + +function preview({ frame, layers }: Case): Uint8ClampedArray { + return new LabEngine().render( + { matrix: frame, layers, activeLayerId: layers[0].id, knobs: [0, 0, 0, 0], ranges: [[0, 1], [0, 1], [0, 1], [0, 1]] }, + 1 / 60, + 0, + ).data; +} + +function exportedJs({ frame, layers }: Case): Uint8ClampedArray { + const runtime = new PatternRuntime(frame.width, frame.height); + expect(runtime.loadCode(flattenLayers(layers, frame)).error).toBeUndefined(); + expect(runtime.renderFrame(1 / 60, 0, createIdleInput([0, 0, 0, 0])).error).toBeUndefined(); + return runtime.data; +} + +/** The first pixel that differs, as a sentence, or null. RGB only: the panel has no alpha. */ +function firstDifference(a: Uint8ClampedArray, b: Uint8ClampedArray, width: number): string | null { + for (let i = 0; i < a.length / 4; i++) { + for (let c = 0; c < 3; c++) { + if (a[i * 4 + c] !== b[i * 4 + c]) { + return `(${i % width}, ${Math.floor(i / width)}): ${Array.from(a.subarray(i * 4, i * 4 + 3))} against ${Array.from(b.subarray(i * 4, i * 4 + 3))}`; + } + } + } + return null; +} + +// Opacities whose fractions have an odd denominator (4/5, 3/5, 1/5). Every +// value the composite can reach then has an odd denominator too (255 is odd), +// so none is ever exactly half a step. The browser's doubles and the S3's +// floats could still part on a value within a float's error of a half; none +// of these stacks has one. HALVES below takes 50% and 25%. +const ODD_OPACITIES = [1, 0.8, 0.6, 0.2]; +const HALF_OPACITIES = [0.5, 0.25]; + +/** Text and art in any order, blend and opacity, sharp or soft, solid or ramp. */ +function randomStack(seed: number, opacities: number[]): Case { + const frame = { width: 24, height: 12 }; + const next = random(seed); + const layers: Layer[] = []; + for (let k = 0; k < 4; k++) { + const common = { + blend: BLEND_MODES[Math.floor(next() * BLEND_MODES.length)] as BlendMode, + opacity: opacities[Math.floor(next() * opacities.length)], + }; + layers.push( + k % 2 === 0 + ? textLayer(`Text${k}`, frame, next, { + fill: (["solid", "rampX", "rampY"] as const)[Math.floor(next() * 3)], + sharp: next() < 0.5, + color: `#${Math.floor(next() * 0xffffff).toString(16).padStart(6, "0")}`, + }, common) + : artLayer(`Art${k}`, frame, next, common), + ); + } + return { name: `Random${seed}`, frame, layers }; +} + +const CASES: Case[] = []; +{ + const panel = { width: 128, height: 64 }; + CASES.push({ name: "Alone", frame: panel, layers: [textLayer("Hello", panel, random(1), {})] }); +} +{ + const frame = { width: 64, height: 32 }; + const next = random(2); + CASES.push({ + name: "SoftRampOverArt", + frame, + layers: [ + textLayer("Soft", frame, next, { fill: "rampX", sharp: false }, { blend: "screen", opacity: 0.8 }, { + ...TEXT_RAMP_STATE, + stops: TEXT_RAMP_STATE.stops.map((stop, i) => ({ ...stop, alpha: i === 1 ? 0.4 : 1 })), + }), + artLayer("Art", frame, next), + ], + }); +} +{ + const frame = { width: 32, height: 16 }; + CASES.push({ + name: "MaskOverCode", + frame, + layers: [textLayer("Mask", frame, random(3), { fill: "rampY" }, { role: "mask" }), stripes("Stripes")], + }); +} +{ + const frame = { width: 32, height: 16 }; + const next = random(4); + CASES.push({ + name: "InvertedMaskOverArt", + frame, + layers: [ + textLayer("Top", frame, next, { color: "#3fa7ff" }, { blend: "add", opacity: 0.6 }), + textLayer("Mask", frame, next, { sharp: false }, { role: "mask", maskInvert: true }), + artLayer("Art", frame, next), + ], + }); +} +// Seeded, so a failure names the same pixels every run. +for (let seed = 11; seed <= 30; seed++) CASES.push(randomStack(seed, ODD_OPACITIES)); + +// At 50% and 25% too. A channel can then land on exactly half a step, and +// the header and the preview may round it apart (the header tests below). +const HALVES: Case[] = []; +for (let seed = 11; seed <= 60; seed++) { + HALVES.push({ ...randomStack(seed, [...ODD_OPACITIES, ...HALF_OPACITIES]), name: `Halves${seed}` }); +} + +// The half step the seeds first found: an art pixel (131, 7, 75) at alpha +// 187, added at 50%. Its blue is 75 × 187/255 × 0.5 = 27.5. +const HALF_STEP: Case = (() => { + const frame = { width: 1, height: 1 }; + const art = createPixelLayer(frame, "HalfStep"); + art.data.set([131, 7, 75, 187]); + return { name: "HalfStep", frame, layers: [{ ...art, id: "HalfStep", blend: "add", opacity: 0.5 }] }; +})(); + +describe("a text layer in the flattened JS", () => { + it.each([...CASES, ...HALVES].map((entry) => [entry.name, entry] as const))("%s: same pixels as the preview", (_name, entry) => { + expect(firstDifference(preview(entry), exportedJs(entry), entry.frame.width)).toBeNull(); + }); +}); + +// ── the C++ header ── + +const CXX = ["c++", "g++", "clang++"].find((compiler) => { + try { + execFileSync(compiler, ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +}); + +// GitHub's runners have g++. A desk without a compiler skips the header half; +// CI does not get to. +if (!CXX && process.env.CI) throw new Error("no C++ compiler on this runner, and the header test needs one"); + +const STUBS: Record = { + "Arduino.h": `#pragma once +#include +#include +#include +#include +#include +#define PI 3.1415926535897932384626433832795 +#define TWO_PI 6.283185307179586476925286766559 +#define HALF_PI 1.5707963267948966192313216916398 +`, + "config.h": `#pragma once +#define PANEL_RES_W 128 +#define PANEL_RES_H 64 +`, + "src/core_display.h": `#pragma once +`, + // Field for field as firmware/patternflow/src/core_encoders.h has it. + "src/core_encoders.h": `#pragma once +#include +struct InputFrame { + long knobs[4]; + int knobDeltas[4]; + bool btnPressed[4]; + bool btnHeld[4]; + uint32_t now; + bool knobAudioActive[4]; + float knobAudioValue[4]; + bool paramAbsoluteActive[4]; + uint16_t paramAbsolute[4]; +}; +`, + "src/core_canvas.h": `#pragma once +#include +#include "config.h" +namespace PFCanvas { +inline uint8_t frame[PANEL_RES_W * PANEL_RES_H * 3]; +inline int frameW = PANEL_RES_W, frameH = PANEL_RES_H; +inline int presents = 0; +inline void setFrame(int w, int h) { frameW = w; frameH = h; } +inline void setPixel(int x, int y, uint8_t r, uint8_t g, uint8_t b) { + if ((unsigned)x >= (unsigned)frameW || (unsigned)y >= (unsigned)frameH) return; + uint8_t* p = frame + ((size_t)y * frameW + x) * 3; + p[0] = r; p[1] = g; p[2] = b; +} +inline void present() { presents++; } +} +`, +}; + +type HeaderCase = Case & { namespaceName: string }; + +/** Every case's header in one program: build once, print each frame as hex. */ +function runHeaders(cases: Case[]): Map { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-text-header-")); + dirs.push(dir); + for (const [file, text] of Object.entries(STUBS)) { + fs.mkdirSync(path.dirname(path.join(dir, "stub", file)), { recursive: true }); + fs.writeFileSync(path.join(dir, "stub", file), text); + } + + const headers: HeaderCase[] = cases.map((entry) => { + const built = buildHExport({ + name: entry.name, + matrix: entry.frame, + layers: entry.layers, + knobs: [0, 0, 0, 0], + ranges: [[0, 1], [0, 1], [0, 1], [0, 1]], + knobLabels: ["A", "B", "C", "D"], + }); + const units: Record = {}; + built.units.forEach((unit) => (units[unit.index] = STRIPES_CPP(unit.index))); + fs.writeFileSync(path.join(dir, `${entry.name}.h`), assembleH(built.scaffold, units)); + return { ...entry, namespaceName: built.namespaceName }; + }); + + const main = `#include +${headers.map((entry) => `#include "${entry.name}.h"`).join("\n")} + +static void dump(const char* name) { + printf("%s ", name); + for (int i = 0; i < PFCanvas::frameW * PFCanvas::frameH * 3; i++) printf("%02x", PFCanvas::frame[i]); + printf(" %d\\n", PFCanvas::presents); +} + +int main() { + InputFrame input{}; +${headers + .map( + (entry) => ` PFCanvas::setFrame(PANEL_RES_W, PANEL_RES_H); + PFCanvas::presents = 0; + memset(PFCanvas::frame, 0, sizeof PFCanvas::frame); + ${entry.namespaceName}::setup(); + ${entry.namespaceName}::update(1.0f / 60.0f, input); + ${entry.namespaceName}::draw(); + dump("${entry.name}");`, + ) + .join("\n")} + return 0; +} +`; + fs.writeFileSync(path.join(dir, "main.cpp"), main); + const binary = path.join(dir, "headers"); + execFileSync(CXX!, ["-std=c++17", "-O0", "-Wall", "-Werror", "-Wno-unused-function", "-Wno-unused-variable", "-I", path.join(dir, "stub"), "-I", FIRMWARE, "-I", dir, "main.cpp", "-o", binary], { + cwd: dir, + stdio: ["ignore", "pipe", "pipe"], + }); + + const frames = new Map(); + for (const line of execFileSync(binary, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).trim().split("\n")) { + const [name, hex, presents] = line.split(" "); + expect(presents, `${name} presented once`).toBe("1"); + const entry = cases.find((candidate) => candidate.name === name)!; + const rgba = new Uint8ClampedArray(entry.frame.width * entry.frame.height * 4); + for (let i = 0; i < rgba.length / 4; i++) { + for (let c = 0; c < 3; c++) rgba[i * 4 + c] = parseInt(hex.slice((i * 3 + c) * 2, (i * 3 + c) * 2 + 2), 16); + rgba[i * 4 + 3] = 255; + } + frames.set(name, rgba); + } + return frames; +} + +const dirs: string[] = []; +afterAll(() => { + for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true }); +}); + +// ── exact fractions, for the half steps ── + +type Fraction = [bigint, bigint]; + +// The site's TypeScript targets ES2017, so no 1n literals: BigInt(1). +const ZERO = BigInt(0); +const ONE = BigInt(1); +const TWO = BigInt(2); + +function gcd(a: bigint, b: bigint): bigint { + a = a < ZERO ? -a : a; + while (b) [a, b] = [b, a % b]; + return a; +} +function fraction(n: bigint | number, d: bigint | number = 1): Fraction { + const [bn, bd] = [BigInt(n), BigInt(d)]; + const g = gcd(bn, bd) || ONE; + return [bn / g, bd / g]; +} +const plus = (a: Fraction, b: Fraction) => fraction(a[0] * b[1] + b[0] * a[1], a[1] * b[1]); +const times = (a: Fraction, b: Fraction) => fraction(a[0] * b[0], a[1] * b[1]); +const minus = (a: Fraction, b: Fraction) => plus(a, [-b[0], b[1]]); + +const OPACITY_FRACTIONS = new Map( + ([[1, 1, 1], [0.8, 4, 5], [0.6, 3, 5], [0.2, 1, 5], [0.5, 1, 2], [0.25, 1, 4]] as const).map( + ([opacity, n, d]) => [opacity, fraction(n, d)], + ), +); + +/** + * One channel of the composite before it is rounded, worked in fractions: + * compositor.ts's sums with nothing lost. For stacks of paint layers only, + * which is what HALVES holds. + */ +function exactChannel(layers: Layer[], pixel: number, channel: number): Fraction { + let value = fraction(0); + const full = fraction(255); + for (const layer of [...layers].reverse()) { + const data = (layer as PixelLayer | TextLayer).data; + const a = times(fraction(data[pixel * 4 + 3], 255), OPACITY_FRACTIONS.get(layer.opacity)!); + if (a[0] === ZERO) continue; + const s = fraction(data[pixel * 4 + channel]); + const keep = times(value, minus(fraction(1), a)); + if (layer.blend === "add") value = plus(value, times(s, a)); + else if (layer.blend === "multiply") value = plus(keep, times(times(value, fraction(s[0], 255)), a)); + else if (layer.blend === "screen") { + const screened = minus(full, times(times(minus(full, value), minus(full, s)), fraction(1, 255))); + value = plus(keep, times(screened, a)); + } else value = plus(keep, times(s, a)); + } + return value; +} + +/** n + 1/2 for a whole n: a value two correct roundings may take either way. */ +const isHalfStep = ([n, d]: Fraction) => d === TWO && n % TWO !== ZERO; + +describe.skipIf(!CXX)("a text layer in the C++ header", () => { + let frames: Map | null = null; + const header = (name: string) => (frames ??= runHeaders([...CASES, ...HALVES, HALF_STEP])).get(name)!; + + it.each(CASES.map((entry) => [entry.name, entry] as const))("%s: same pixels as the preview", (name, entry) => { + expect(firstDifference(preview(entry), header(name), entry.frame.width)).toBeNull(); + }, 60_000); + + // The browser works in doubles and the S3 in floats. Neither holds 187/255 + // exactly, so at 27.5 one lands a hair under and the other a hair over, and + // the LED is one step brighter on the panel than in the Lab. + it("an exact half step can round either way: one level, in the one channel", () => { + const shown = preview(HALF_STEP); + const panel = header(HALF_STEP.name); + expect(exactChannel(HALF_STEP.layers, 0, 2)).toEqual(fraction(55, 2)); + expect([shown[2], panel[2]]).toEqual([27, 28]); + expect(firstDifference(shown.slice(0, 2), panel.slice(0, 2), 1)).toBeNull(); + }, 60_000); + + it("at 50% and 25% the two part only on half steps, and by one", () => { + const parted: string[] = []; + for (const entry of HALVES) { + const shown = preview(entry); + const panel = header(entry.name); + for (let i = 0; i < shown.length; i++) { + if ((i & 3) === 3 || shown[i] === panel[i]) continue; + const pixel = i >> 2; + const exact = exactChannel(entry.layers, pixel, i & 3); + expect(isHalfStep(exact), `${entry.name} (${pixel % entry.frame.width}, ${Math.floor(pixel / entry.frame.width)}) is ${exact[0]}/${exact[1]}`).toBe(true); + expect(Math.abs(shown[i] - panel[i])).toBe(1); + parted.push(entry.name); + } + } + // Rare, and not none: the check above has something to look at. + console.log("PARTED", parted.join(" ")); + expect(parted.length).toBeGreaterThan(0); + expect(parted.length).toBeLessThan(20); + }, 60_000); +}); + +// ── Copy as code ── + +describe("a text layer copied as code", () => { + const alone = (layer: TextLayer): Case => ({ + name: layer.name, + frame: { width: layer.width, height: layer.height }, + layers: [{ ...layer, opacity: 1, blend: "normal", role: "paint" }], + }); + + function run(code: string, frame: MatrixSize, time = 0): Uint8ClampedArray { + const runtime = new PatternRuntime(frame.width, frame.height); + expect(runtime.loadCode(code).error).toBeUndefined(); + expect(runtime.renderFrame(1 / 60, time, createIdleInput([0, 0, 0, 0])).error).toBeUndefined(); + return runtime.data; + } + + const sharp = CASES[0].layers[0] as TextLayer; + const soft = CASES[1].layers[0] as TextLayer; + + it.each([["sharp, one colour", sharp], ["soft, a ramp with a see-through stop", soft]] as const)( + "%s: draws the pixels the preview shows, with no font", + (_name, layer) => { + const code = buildPixelPatternCode(layer)!; + expect(code).toContain(`// Text: "${layer.spec.text}"`); + expect(firstDifference(preview(alone(layer)), run(code, alone(layer).frame), layer.width)).toBeNull(); + }, + ); + + it("marks where a warp and a change of light go", () => { + const code = buildPixelPatternCode(sharp)!; + expect(code).toContain("// ── DISTORT HERE ──"); + expect(code).toContain("// ── LIGHT HERE ──"); + }); + + // The header's examples are what a model copies first, so they have to work. + it("its scroll example moves the letters left a pixel at a time and brings them back", () => { + const layer = alone(sharp); + const code = buildPixelPatternCode(sharp)!.replace( + "// ── DISTORT HERE ──", + `const span = display.width + SPRITE_W; + sx = ((Math.round(sx + time * 20) % span) + span) % span; + // ──`, + ); + const still = run(buildPixelPatternCode(sharp)!, layer.frame); + expect(firstDifference(run(code, layer.frame, 0), still, 128)).toBeNull(); + + // One second is 20 pixels to the left. + const moved = run(code, layer.frame, 1); + const shifted = new Uint8ClampedArray(still.length); + for (let y = 0; y < 64; y++) { + for (let x = 0; x < 128 - 20; x++) shifted.set(still.subarray((y * 128 + x + 20) * 4, (y * 128 + x + 21) * 4), (y * 128 + x) * 4); + } + const lit = (data: Uint8ClampedArray) => data.some((byte, i) => (i & 3) !== 3 && byte > 0); + expect(lit(still)).toBe(true); + // The columns the letters left, and nothing yet coming in on the right. + expect(firstDifference(moved, shifted, 128)).toBeNull(); + }); + + it("its twinkle example runs", () => { + const code = buildPixelPatternCode(soft)!.replace( + "// ── LIGHT HERE ──", + `const tw = 0.6 + 0.4 * Math.sin(time * 6 + x * 1.7 + y * 2.3); + r *= tw; g *= tw; b *= tw; + a *= 0.5 + 0.5 * Math.sin(time * 2); + // ──`, + ); + run(code, alone(soft).frame, 0.3); + }); + + it("is null when no letter lands on the frame", () => { + expect(buildPixelPatternCode({ ...sharp, data: new Uint8ClampedArray(sharp.data.length) })).toBeNull(); + }); +});