diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index fdd84386f..2160a6f54 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. 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 | +| 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`. An effect (scroll, twinkle, run the ramp) moves the letters each frame at the speed of one knob: `lib/lab/textEffects.ts` for the preview, the same steps in `flatten.ts` and `hExport.ts`, and a scroll or ramp keeps the whole line past the frame's edges as the layer's `source`. `lib/lab/textExport.test.ts` compiles the To hardware header with the host's C++ compiler and checks it against the preview, frame after frame for moving text | | 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 e1eaf3cfe..943ef2e5d 100644 --- a/web/src/app/pattern-lab/HardwareModal.test.tsx +++ b/web/src/app/pattern-lab/HardwareModal.test.tsx @@ -187,6 +187,32 @@ describe("HardwareModal", () => { } }); + it("says a scrolling text scrolls on the board too, and the header carries the steps", async () => { + const lab = useLabStore.getState(); + const starter = lab.layers[0].id; + lab.addTextLayer(); + useLabStore.getState().removeLayer(starter); + const [layer] = useLabStore.getState().layers; + if (layer.type !== "text") throw new Error("not text"); + const height = layer.height; + const source = { x: 4, width: 10, data: new Uint8ClampedArray(10 * height * 4).fill(255) }; + useLabStore.setState({ layers: [{ ...layer, spec: { ...layer.spec, effect: "scroll" }, source }] }); + 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("Text that moves moves there too, on the same knob."); + fireEvent.click(screen.getByText("Copy .h")); + expect(await screen.findByText("Copied ✓")).toBeTruthy(); + const header = String(writeText.mock.calls[0][0]); + expect(header).toContain('"Text 1": text, scroll on knob 2'); + expect(header).toContain("advanceText(&L0_phase, dt, knobNorm[1], 128.0f, (float)L0_PERIOD);"); + expect(header).toContain("textScroll(layerBuf[0], L0_src"); + } 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 272b71284..b63fbf1b7 100644 --- a/web/src/app/pattern-lab/HardwareModal.tsx +++ b/web/src/app/pattern-lab/HardwareModal.tsx @@ -9,6 +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 { activeTextEffect } from "@/lib/lab/textEffects"; import { isCodeLayer, isTextLayer } from "@/lib/lab/types"; import styles from "./PatternLab.module.css"; import local from "./HardwareModal.module.css"; @@ -213,6 +214,9 @@ export default function HardwareModal({ 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} + {layers.some((layer) => isTextLayer(layer) && layer.visible && activeTextEffect(layer)) + ? " Text that moves moves there too, on the same knob." + : null}

) : (

diff --git a/web/src/app/pattern-lab/panels/RampPanel.tsx b/web/src/app/pattern-lab/panels/RampPanel.tsx index ab43fb0ed..329bd7bff 100644 --- a/web/src/app/pattern-lab/panels/RampPanel.tsx +++ b/web/src/app/pattern-lab/panels/RampPanel.tsx @@ -4,7 +4,8 @@ // layer keeps its own ramp. New in the layered lab: per-stop ALPHA, drawn over // a checkerboard, which is what lets a value field fade out and reveal the // layers underneath. A selected text layer has a ramp too: its letters take -// these colours across or down when the Text panel's fill says so. +// these colours across or down when the Text panel's fill says so, and run +// through them when its effect does. import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -437,7 +438,9 @@ function RampEditor({ layer }: { layer: RampLayer }) { )} {layer.type === "text" && layer.spec.fill === "solid" && (

- This text is one colour. Fill it with the ramp:{" "} + {layer.spec.effect === "ramp" + ? "The letters run through these colours all together. Spread the ramp over them:" + : "This text is one colour. Fill it with the ramp:"}{" "} {" "} diff --git a/web/src/app/pattern-lab/panels/TextPanel.test.tsx b/web/src/app/pattern-lab/panels/TextPanel.test.tsx index 50adca8d0..6292bec09 100644 --- a/web/src/app/pattern-lab/panels/TextPanel.test.tsx +++ b/web/src/app/pattern-lab/panels/TextPanel.test.tsx @@ -8,19 +8,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; vi.mock("@/lib/lab/textRaster", () => ({ - textCoverage: (spec: { text: string; x: number; y: number }, _font: string, width: number, height: number) => { + textCoverage: (spec: { text: string; x: number; y: number }, _font: string, width: number, height: number, left = 0) => { const coverage = new Uint8ClampedArray(width * height); for (let x = spec.x; x < spec.x + spec.text.length * 2; x++) { for (let y = spec.y - 1; y < spec.y + 1; y++) { - if (x >= 0 && x < width && y >= 0 && y < height) coverage[y * width + x] = 255; + if (x - left >= 0 && x - left < width && y >= 0 && y < height) coverage[y * width + x - left] = 255; } } return coverage; }, + // A scroll draws the whole line: where the blocks above start and end. + textReach: (spec: { text: string; x: number }) => ({ left: spec.x - 1, right: spec.x + spec.text.length * 2 + 1 }), })); import TextPanel from "./TextPanel"; import PreviewPanel from "./PreviewPanel"; +import RampPanel from "./RampPanel"; import { useLabStore } from "@/lib/lab/store"; import type { TextLayer } from "@/lib/lab/types"; @@ -132,6 +135,65 @@ describe("TextPanel", () => { }); }); +describe("an effect on the text", () => { + it("scrolls on the Speed knob by default, and says where that knob is", () => { + store().addTextLayer(); + render(); + expect(screen.queryByRole("combobox", { name: "Speed knob" })).toBeNull(); + fireEvent.change(screen.getByRole("combobox", { name: "Effect" }), { target: { value: "scroll" } }); + expect(text().spec.effect).toBe("scroll"); + expect(text().source).not.toBeNull(); + expect(screen.getByRole("combobox", { name: "Speed knob" })).toHaveValue("1"); + const { knobLabels, knobs, ranges } = store(); + const share = Math.round(((knobs[1] - ranges[1][0]) / (ranges[1][1] - ranges[1][0])) * 100); + expect(share).toBeGreaterThan(0); + expect(screen.getByText(`${knobLabels[1]} sets how fast the letters run out to the left`, { exact: false })).toHaveTextContent( + `It is at ${share}% now. All the way down stands the effect still.`, + ); + }); + + it("says nothing moves while the chosen knob is all the way down", () => { + store().addTextLayer(); + store().setTextSpec(text().id, { effect: "twinkle" }); + render(); + useLabStore.setState({ knobs: [store().ranges[0][0], ...store().knobs.slice(1)] }); + fireEvent.change(screen.getByRole("combobox", { name: "Speed knob" }), { target: { value: "0" } }); + expect(text().spec.effectKnob).toBe(0); + expect(screen.getByText(`${store().knobLabels[0]} sets how fast each LED dims`, { exact: false })).toHaveTextContent( + "It is all the way down, so nothing moves yet: turn it up in Knobs.", + ); + }); + + it("names the knobs as the Knobs panel does", () => { + store().addTextLayer(); + store().setTextSpec(text().id, { effect: "scroll" }); + useLabStore.setState({ knobLabels: ["Hue", "Rate", "Mode", "Shift"] }); + render(); + expect(screen.getByRole("option", { name: "Rate" })).toBeInTheDocument(); + expect(screen.getByText(/Rate sets how fast/)).toBeInTheDocument(); + }); + + it("running the ramp puts away the one color, which it does not use", () => { + store().addTextLayer(); + render(); + expect(screen.getByLabelText("Color")).toBeInTheDocument(); + fireEvent.change(screen.getByRole("combobox", { name: "Effect" }), { target: { value: "ramp" } }); + expect(screen.queryByLabelText("Color")).toBeNull(); + expect(screen.getByText(/The colors are this layer's Color Ramp/)).toBeInTheDocument(); + }); + + it("the Color Ramp panel no longer calls a one-color text that runs the ramp one colour", () => { + store().addTextLayer(); + const { unmount } = render(); + expect(screen.getByText(/This text is one colour/)).toBeInTheDocument(); + unmount(); + store().setTextSpec(text().id, { effect: "ramp" }); + render(); + expect(screen.queryByText(/This text is one colour/)).toBeNull(); + expect(screen.getByText(/run through these colours all together/)).toBeInTheDocument(); + }); +}); + describe("Copy as code", () => { function clipboard() { const writeText = vi.fn<(text: string) => Promise>(() => Promise.resolve()); diff --git a/web/src/app/pattern-lab/panels/TextPanel.tsx b/web/src/app/pattern-lab/panels/TextPanel.tsx index 5163db611..eca9e6b02 100644 --- a/web/src/app/pattern-lab/panels/TextPanel.tsx +++ b/web/src/app/pattern-lab/panels/TextPanel.tsx @@ -9,6 +9,9 @@ // // 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. +// +// An effect moves the letters every frame at a speed one knob sets +// (lib/lab/textEffects.ts), and the export and the firmware header carry it. import { useEffect, useRef, useState, type ChangeEvent } from "react"; import { useResettingState } from "@/lib/hooks/useResettingState"; @@ -30,10 +33,12 @@ import { } from "@/lib/lab/textFonts"; import { TEXT_ALIGNS, + TEXT_EFFECTS, TEXT_MAX_CHARS, TEXT_SIZE_MAX, TEXT_SIZE_MIN, isTextLayer, + type TextEffect, type TextFill, type TextLayer, } from "@/lib/lab/types"; @@ -55,6 +60,27 @@ const FILL_LABEL: Record = { const ALIGN_LABEL = { left: "Left", center: "Center", right: "Right" } as const; +const EFFECT_LABEL: Record = { + none: "None", + scroll: "Scroll", + twinkle: "Twinkle", + ramp: "Run the ramp", +}; + +/** What the knob does to each effect, said after the knob's name. */ +const EFFECT_SPEED: Record, string> = { + scroll: "sets how fast the letters run out to the left and come back from the right, up to a panel's width a second", + twinkle: "sets how fast each LED dims and comes back", + ramp: "sets how fast the colors run along this layer's Color Ramp and back", +}; + +/** Where a knob sits in its range, 0..1, the way the effect reads it. */ +function knobShare(value: number | undefined, range: [number, number] | undefined): number { + if (value === undefined || !range) return 0; + const share = (value - range[0]) / Math.max(0.0001, range[1] - range[0]); + return Math.max(0, Math.min(1, share)); +} + const COPY_LABEL: Record = { idle: "Copy as code", copied: "Copied ✓", @@ -144,6 +170,10 @@ export default function TextPanel() { function TextEditor({ layer }: { layer: TextLayer }) { const setTextSpec = useLabStore((state) => state.setTextSpec); + const knobLabels = useLabStore((state) => state.knobLabels); + const speed = useLabStore((state) => + Math.round(knobShare(state.knobs[layer.spec.effectKnob], state.ranges[layer.spec.effectKnob]) * 100), + ); const options = useFontOptions(); const fileRef = useRef(null); const [remember, setRemember] = useState(false); @@ -322,7 +352,7 @@ function TextEditor({ layer }: { layer: TextLayer }) { ))} - {spec.fill === "solid" && ( + {spec.fill === "solid" && spec.effect !== "ramp" && (

- {spec.fill === "solid" + {spec.fill === "solid" && spec.effect !== "ramp" ? "Drag the text in Preview to move it." : "Drag the text in Preview to move it. The colors are this layer's Color Ramp."}

+
+ + {spec.effect !== "none" && ( + + )} +
+ {spec.effect !== "none" && ( +

+ {`${knobLabels[spec.effectKnob] || `Knob ${spec.effectKnob + 1}`} ${EFFECT_SPEED[spec.effect]}. `} + {speed === 0 + ? "It is all the way down, so nothing moves yet: turn it up in Knobs." + : `It is at ${speed}% now. All the way down stands the effect still.`} +

+ )} +
- The letters as pixels: the code needs no font. Change the words and copy again. + + {spec.effect === "none" + ? "The letters as pixels: the code needs no font. Change the words and copy again." + : "The letters as pixels, standing still: the effect goes with Export and To hardware."} +
diff --git a/web/src/lib/lab/capture/core.ts b/web/src/lib/lab/capture/core.ts index e85dac88d..ca3dd1ede 100644 --- a/web/src/lib/lab/capture/core.ts +++ b/web/src/lib/lab/capture/core.ts @@ -8,6 +8,8 @@ import type { MatrixSize } from "@/lib/pattern/matrix"; import { LabEngine } from "../engine"; +import { stretchNearest } from "../stretch"; +import { activeTextEffect } from "../textEffects"; import { isRasterLayer, type Layer, type RasterLayer } from "../types"; import { CAPTURE_SIDE_MAX, @@ -20,6 +22,10 @@ import { type WireProject, } from "./types"; +// Lives in lib/lab/stretch.ts since the engine stretches a text effect's +// frame with it too; probe.ts and the tests still import it from here. +export { stretchNearest }; + export function clampSide(value: number): number { if (!Number.isFinite(value)) return CAPTURE_SIDE_MIN; return Math.max(CAPTURE_SIDE_MIN, Math.min(CAPTURE_SIDE_MAX, Math.round(value))); @@ -90,38 +96,6 @@ export function resolveGeometry( }; } -/** - * Nearest-neighbour stretch, independent per axis — a 128×64 sprite sheet - * drawn for the panel lands on a 1050×600 card without a blurry resample, - * and an off-aspect output simply stretches it the way the pattern beneath - * stretches too. - */ -export function stretchNearest( - src: Uint8ClampedArray, - srcWidth: number, - srcHeight: number, - dstWidth: number, - dstHeight: number, -): Uint8ClampedArray { - const out = new Uint8ClampedArray(dstWidth * dstHeight * 4); - if (srcWidth <= 0 || srcHeight <= 0) return out; - for (let y = 0; y < dstHeight; y++) { - const sy = Math.min(srcHeight - 1, Math.floor((y * srcHeight) / dstHeight)); - const srcRow = sy * srcWidth; - const dstRow = y * dstWidth; - for (let x = 0; x < dstWidth; x++) { - const sx = Math.min(srcWidth - 1, Math.floor((x * srcWidth) / dstWidth)); - const si = (srcRow + sx) * 4; - const di = (dstRow + x) * 4; - out[di] = src[si]; - out[di + 1] = src[si + 1]; - out[di + 2] = src[si + 2]; - out[di + 3] = src[si + 3]; - } - } - return out; -} - /** * Merge a wire project into the previous one: a pixel or text layer that arrived * without its buffer keeps the copy from last time. A layer with neither is @@ -254,6 +228,9 @@ export class CaptureCore { const layers = project.layers.map((layer) => { if (!isRasterLayer(layer)) return layer; if (layer.width === render.width && layer.height === render.height) return layer; + // An effect draws a new frame each tick at the layer's own size, and + // the engine stretches that frame (engine.ts stepText). + if (layer.type === "text" && activeTextEffect(layer)) return layer; live.add(layer.id); const cached = this.stretched.get(layer.id); if (cached && cached.width === render.width && cached.height === render.height) { diff --git a/web/src/lib/lab/engine.ts b/web/src/lib/lab/engine.ts index 1b5388dc1..7977b6582 100644 --- a/web/src/lib/lab/engine.ts +++ b/web/src/lib/lab/engine.ts @@ -2,10 +2,12 @@ // Owns one PatternRuntime per code layer and composites the visible stack into // a single RGBA frame each animation tick. Lives OUTSIDE React: the preview // panel drives it from requestAnimationFrame and reads the store imperatively, -// so knob turns and code edits never restart the loop. +// so knob turns and code edits never restart the loop. A text layer with an +// effect gets a phase and a frame buffer here too (textEffects.ts). import { PatternRuntime, + buildRampLUTRGBA, createIdleInput, knobTargetToDelta, type ColorRamp, @@ -17,7 +19,15 @@ import { import { hexToRgb } from "@/lib/pattern/color"; import type { MatrixSize } from "@/lib/pattern/matrix"; import { applyMaskToCoverage, compositeLayers, type CompositeEntry } from "./compositor"; -import { isRasterLayer, type CodeLayer, type Layer, type RampState } from "./types"; +import { stretchNearest } from "./stretch"; +import { + TEXT_EFFECT_RATE, + activeTextEffect, + advanceTextPhase, + renderTextEffect, + textEffectPeriod, +} from "./textEffects"; +import { isRasterLayer, type CodeLayer, type Layer, type RampState, type TextLayer } from "./types"; const RECOMPILE_DEBOUNCE_MS = 180; @@ -46,6 +56,17 @@ type LayerEntry = { ramp: ColorRamp | null; }; +/** A text effect's running state: where it is, and the frame it drew last. */ +type TextFx = { + phase: number; + /** At the layer's own size. */ + frame: Uint8ClampedArray; + /** `frame` at the matrix size, when the two differ (a Graphic Export render grid). */ + stretched: Uint8ClampedArray | null; + rampState: RampState | null; + lut: Uint8Array | null; +}; + /** The store slice the engine consumes each frame. */ export type EngineProject = { matrix: MatrixSize; @@ -64,6 +85,7 @@ export type EngineFrame = { export class LabEngine { private entries = new Map(); + private textFx = new Map(); /** Latest per-layer runtime error (null = healthy). */ readonly errors = new Map(); @@ -100,6 +122,43 @@ export class LabEngine { this.errors.delete(id); } } + for (const id of this.textFx.keys()) { + if (!ids.has(id)) this.textFx.delete(id); + } + } + + /** + * Move a text layer's effect on by `dt` and draw its frame. The phase + * lives as long as the layer, so changing the words keeps the scroll where + * it is (taken round the new width). + */ + private stepText(layer: TextLayer, matrix: MatrixSize, dt: number, knobNormalized: number[]) { + const effect = activeTextEffect(layer); + if (!effect) return; + const count = layer.width * layer.height * 4; + let fx = this.textFx.get(layer.id); + if (!fx) { + fx = { phase: 0, frame: new Uint8ClampedArray(count), stretched: null, rampState: null, lut: null }; + this.textFx.set(layer.id, fx); + } + if (fx.frame.length !== count) fx.frame = new Uint8ClampedArray(count); + if (effect === "ramp" && fx.rampState !== layer.ramp) { + fx.rampState = layer.ramp; + fx.lut = buildRampLUTRGBA(rampStateToHarness(layer.ramp)); + } + const period = textEffectPeriod(effect, layer); + fx.phase = advanceTextPhase( + fx.phase % period, + dt, + knobNormalized[layer.spec.effectKnob] ?? 0, + TEXT_EFFECT_RATE[effect], + period, + ); + renderTextEffect(layer, effect, fx.phase, fx.lut, fx.frame); + fx.stretched = + layer.width === matrix.width && layer.height === matrix.height + ? null + : stretchNearest(fx.frame, layer.width, layer.height, matrix.width, matrix.height, fx.stretched); } private ensureEntry(layer: CodeLayer, matrix: MatrixSize, now: number): LayerEntry { @@ -211,6 +270,14 @@ export class LabEngine { this.errors.set(layer.id, result.ok ? null : result.error ?? "Runtime error."); } + // Text layers with an effect move on by the same dt, masks included, at + // the speed of the knob each one is set to. + for (const layer of layers) { + if (layer.type !== "text" || !layer.visible) continue; + if (layer.role !== "mask" && layer.opacity <= 0) continue; + this.stepText(layer, matrix, dt, knobNormalized); + } + // Pass 2 — group paint entries with the mask layers sitting above them, // then composite. On error the runtime keeps its last good frame — it // still composites, so a typo mid-edit dims nothing. @@ -227,6 +294,12 @@ export class LabEngine { /** A layer's CURRENT buffer at this frame size, or null if unavailable. */ private bufferOf(layer: Layer, matrix: MatrixSize): Uint8ClampedArray | null { + if (layer.type === "text" && activeTextEffect(layer)) { + const fx = this.textFx.get(layer.id); + if (fx && fx.frame.length === layer.width * layer.height * 4) { + return fx.stretched ?? (layer.width === matrix.width && layer.height === matrix.height ? fx.frame : null); + } + } if (isRasterLayer(layer)) { return layer.width === matrix.width && layer.height === matrix.height ? layer.data : null; } diff --git a/web/src/lib/lab/flatten.ts b/web/src/lib/lab/flatten.ts index e95946bac..a64a44dd4 100644 --- a/web/src/lib/lab/flatten.ts +++ b/web/src/lib/lab/flatten.ts @@ -27,6 +27,7 @@ import { KNOBS_ANNOTATION_RE, buildKnobsAnnotationLine, defaultKnobState } from import { exportStack } from "./compositor"; import { rampStateToHarness } from "./engine"; import { bytesToBase64 } from "./serialize"; +import { TEXT_EFFECT_RATE, activeTextEffect, textEffectPeriod, type ActiveTextEffect } from "./textEffects"; /** * Run-length encode straight RGBA bytes as {count-1, r, g, b, a} tuples @@ -61,13 +62,13 @@ export function rleEncodeRGBA(data: Uint8ClampedArray): Uint8ClampedArray { } /** The embed expression for one pixel layer: RLE when it wins, raw otherwise. */ -function pixelBufferLiteral(data: Uint8ClampedArray): string { +function pixelBufferLiteral(data: Uint8ClampedArray, length = "__W * __H * 4"): string { const rle = rleEncodeRGBA(data); return rle.length < data.length - ? `__rle("${bytesToBase64(rle)}", __W * __H * 4)` - : `__b64("${bytesToBase64(data)}", __W * __H * 4)`; + ? `__rle("${bytesToBase64(rle)}", ${length})` + : `__b64("${bytesToBase64(data)}", ${length})`; } -import { isRasterLayer, type CodeLayer, type KnobRange, type Layer } from "./types"; +import { isRasterLayer, type CodeLayer, type KnobRange, type Layer, type RampLayer, type TextLayer } from "./types"; export type FlattenKnobs = { labels: string[]; ranges: KnobRange[] }; @@ -92,11 +93,85 @@ function commentSafe(name: string): string { return name.replace(/[\r\n\u2028\u2029]/g, " "); } -function lutLiteral(layer: CodeLayer): string { +function lutLiteral(layer: RampLayer): string { const lut = buildRampLUTRGBA(rampStateToHarness(layer.ramp)); return `[${Array.from(lut).join(",")}]`; } +/** + * The fx object of a moving text layer. Scroll and ramp read the source, + * twinkle the bitmap; ramp brings its ramp as a LUT. + */ +function textFxLiteral(layer: TextLayer, effect: ActiveTextEffect): string { + const source = effect === "twinkle" ? { x: 0, width: layer.width, data: layer.data } : layer.source!; + return `{ + kind: "${effect}", knob: ${layer.spec.effectKnob}, rate: ${TEXT_EFFECT_RATE[effect]}, period: ${textEffectPeriod(effect, layer)}, phase: 0, + srcX: ${source.x}, srcW: ${source.width}, lead: ${layer.width - source.x}, + lut: ${effect === "ramp" ? lutLiteral(layer) : "null"}, + src: ${pixelBufferLiteral(source.data, `${source.width} * __H * 4`)}, + }`; +} + +// textEffects.ts, in the export's own words. Keep the two in step: +// textExport.test.ts compares them pixel for pixel. +const TEXT_FX_HELPERS = ` +// Text effects: each frame a moving text layer is drawn again from what it +// carries, at a phase one knob speeds up (0 stands it still). Whole numbers +// per pixel, so the firmware header lights the same LEDs. +function __fxHash(x, y) { + let h = Math.imul(x + 1, 0x9e3779b1) ^ Math.imul(y + 1, 0x85ebca77); + h ^= h >>> 15; + h = Math.imul(h, 0x2c1b3c6d); + h ^= h >>> 12; + return h & 255; +} + +function __fxAdvance(F, dt, input) { + const k = input && input.knobNormalized ? Number(input.knobNormalized[F.knob]) : 0; + const n = k > 0 ? (k < 1 ? k : 1) : 0; + let p = F.phase + dt * (n * F.rate); + if (!(p >= 0)) p = 0; + else if (p >= F.period) p %= F.period; + F.phase = p; +} + +function __fxDraw(L) { + const F = L.fx, B = L.buf, S = F.src, W = F.srcW, s = Math.floor(F.phase); + if (F.kind === "scroll") { + const m = (((s + F.lead) % F.period) + F.period) % F.period, shift = m - __W; + for (let y = 0; y < __H; y++) { + for (let x = 0; x < __W; x++) { + const j = (y * __W + x) * 4, c = x + shift; + if (c < 0 || c >= W) { B[j] = B[j + 1] = B[j + 2] = B[j + 3] = 0; continue; } + const i = (y * W + c) * 4; + B[j] = S[i]; B[j + 1] = S[i + 1]; B[j + 2] = S[i + 2]; B[j + 3] = S[i + 3]; + } + } + } else if (F.kind === "twinkle") { + for (let y = 0; y < __H; y++) { + for (let x = 0; x < __W; x++) { + const j = (y * __W + x) * 4, a = S[j + 3]; + B[j] = S[j]; B[j + 1] = S[j + 1]; B[j + 2] = S[j + 2]; + if (a === 0) { B[j + 3] = 0; continue; } + const u = (s + __fxHash(x, y)) & 255, tri = u < 128 ? u : 255 - u; + B[j + 3] = Math.floor((a * (255 - tri - (tri >> 1)) + 127) / 255); + } + } + } else { + const T = F.lut; + for (let y = 0; y < __H; y++) { + for (let x = 0; x < __W; x++) { + const j = (y * __W + x) * 4, c = x - F.srcX, i = (y * W + c) * 4; + const a = c < 0 || c >= W ? 0 : S[i + 3]; + if (a === 0) { B[j] = B[j + 1] = B[j + 2] = B[j + 3] = 0; continue; } + const u = (S[i] + s) & 511, k = (u < 256 ? u : 511 - u) * 4; + B[j] = T[k]; B[j + 1] = T[k + 1]; B[j + 2] = T[k + 2]; + B[j + 3] = Math.floor((a * T[k + 3] + 127) / 255); + } + } + } +}`; + /** Does this composition even need flattening, or is it a single plain pattern? */ export function needsFlatten(layers: Layer[]): boolean { const visible = layers.filter( @@ -186,8 +261,28 @@ function __mkDisplay(L) { }; }`); + // Text layers that move: each carries what it moves and a phase the knob + // speeds up. The helpers are textEffects.ts step for step. + const effects = new Map(); + for (const layer of stack) { + const effect = layer.type === "text" ? activeTextEffect(layer) : null; + if (layer.type === "text" && effect) effects.set(layer, effect); + } + if (effects.size > 0) parts.push(TEXT_FX_HELPERS); + for (const layer of stack) { const roleProps = `role: "${layer.role}", maskInvert: ${layer.maskInvert}`; + const effect = layer.type === "text" ? effects.get(layer) : undefined; + if (layer.type === "text" && effect) { + parts.push(` +// ── text layer, ${effect} on knob ${layer.spec.effectKnob + 1}: ${commentSafe(layer.name)} ── +__stack.push({ + kind: "pixel", opacity: ${layer.opacity}, blend: "${layer.blend}", ${roleProps}, + buf: new Uint8ClampedArray(__W * __H * 4), + fx: ${textFxLiteral(layer, effect)}, +});`); + continue; + } if (isRasterLayer(layer)) { // A text layer travels as the pixels it was last drawn as: the font // stays in the browser that has it. @@ -267,6 +362,7 @@ export function setup(params) { export function update(dt, input, params) { for (const L of __stack) { + if (L.fx) __fxAdvance(L.fx, dt, input); if (L.kind !== "code") continue; L.lastInput = input; __mirror(L); @@ -276,6 +372,7 @@ export function update(dt, input, params) { export function draw(display, params, time) { for (const L of __stack) { + if (L.fx) __fxDraw(L); if (L.kind !== "code" || !L.fDraw) continue; L.buf.fill(0); __mirror(L); @@ -326,7 +423,7 @@ export function draw(display, params, time) { // Carry the shared knob labels/ranges so reopening the flattened pattern // restores the same tuning. - const hasCode = stack.some((layer): layer is CodeLayer => layer.type === "code"); + const hasCode = stack.some((layer): layer is CodeLayer => layer.type === "code") || effects.size > 0; const knobState = knobConfig ?? { labels: defaultKnobState().labels, ranges: defaultKnobState().ranges, diff --git a/web/src/lib/lab/hExport.ts b/web/src/lib/lab/hExport.ts index 77e979fd3..f5d85ec9a 100644 --- a/web/src/lib/lab/hExport.ts +++ b/web/src/lib/lab/hExport.ts @@ -33,7 +33,8 @@ import { codeUsesValueField } from "@/lib/pattern/ramp"; import { KNOBS_ANNOTATION_RE } from "./annotations"; import { exportStack } from "./compositor"; import { rampStateToHarness } from "./engine"; -import { isRasterLayer, type CodeLayer, type KnobRange, type Layer } from "./types"; +import { TEXT_EFFECT_RATE, activeTextEffect, textEffectPeriod, type ActiveTextEffect } from "./textEffects"; +import { isRasterLayer, type CodeLayer, type KnobRange, type Layer, type RampLayer, type TextLayer } from "./types"; // ── helpers ── @@ -105,7 +106,7 @@ function byteRows(values: number[], perRow: number, indent: string): string { return rows.join(",\n"); } -function lutRows(layer: CodeLayer): string { +function lutRows(layer: RampLayer): string { const lut = buildRampLUTRGBA(rampStateToHarness(layer.ramp)); const entries: string[] = []; for (let i = 0; i < 256; i++) { @@ -267,9 +268,44 @@ static const uint8_t MASK_LAYER[${maskCount}] = {${maskFlat.map((m) => m.layerI static const uint8_t MASK_PXART[${maskCount}] = {${maskFlat.map((m) => (m.pixelArt ? 1 : 0)).join(", ") || "0"}}; static const uint8_t MASK_INVERT[${maskCount}] = {${maskFlat.map((m) => (m.invert ? 1 : 0)).join(", ") || "0"}};`); + // Text layers that move: what each one moves, its phase, and for a ramp + // its LUT. The steps are textEffects.ts, written in C++ below. + const effects = new Map(); + stack.forEach((layer, index) => { + const effect = layer.type === "text" ? activeTextEffect(layer) : null; + if (layer.type === "text" && effect) effects.set(index, { layer, effect }); + }); + // Per-code-layer ramp LUTs + paint helpers; pixel layers as RLE. for (let index = 0; index < stack.length; index++) { const layer = stack[index]; + const moving = effects.get(index); + if (moving) { + const { layer: text, effect } = moving; + const source = effect === "twinkle" ? { x: 0, width: text.width, data: text.data } : text.source!; + const rle = rleEncode(source.data); + parts.push(` +// ── L${index} "${text.name.replace(/\n/g, " ")}": text, ${effect} on knob ${text.spec.effectKnob + 1} — ${ + effect === "twinkle" ? "the letters" : `what it moves, ${source.width} columns from x = ${source.x}` + }, RLE {count,r,g,b,a}, decoded once in setup ── +static const uint8_t L${index}_RLE[${rle.length}] = { +${byteRows(rle, 20, " ")} +}; +static uint8_t* L${index}_src = nullptr; +static float L${index}_phase = 0.0f; +constexpr int L${index}_SRC_X = ${source.x}; +constexpr int L${index}_SRC_W = ${source.width}; +constexpr int L${index}_PERIOD = ${textEffectPeriod(effect, text)}; +constexpr int L${index}_LEAD = ${text.width - source.x};${ + effect === "ramp" + ? ` +static const uint8_t L${index}_RAMP[256][4] = { +${lutRows(text)} +};` + : "" + }`); + continue; + } if (isRasterLayer(layer)) { const rle = rleEncode(layer.data); parts.push(` @@ -314,12 +350,31 @@ ${SLOT_END(index)}`); // Deterministic pipeline. const pixelDecodes = stack .map((layer, index) => - isRasterLayer(layer) - ? ` rleDecode(L${index}_RLE, sizeof(L${index}_RLE), layerBuf[${index}]);` - : null, + effects.has(index) + ? ` if (!L${index}_src) L${index}_src = (uint8_t*)PFMem::alloc((size_t)L${index}_SRC_W * FRAME_H * 4); + if (!L${index}_src) return; + rleDecode(L${index}_RLE, sizeof(L${index}_RLE), L${index}_src, (size_t)L${index}_SRC_W * FRAME_H * 4);` + : isRasterLayer(layer) + ? ` rleDecode(L${index}_RLE, sizeof(L${index}_RLE), layerBuf[${index}]);` + : null, ) .filter(Boolean) .join("\n"); + const effectUpdates = [...effects] + .map( + ([index, { layer, effect }]) => + ` advanceText(&L${index}_phase, dt, knobNorm[${layer.spec.effectKnob}], ${fmtFloat(TEXT_EFFECT_RATE[effect])}, (float)L${index}_PERIOD);`, + ) + .join("\n"); + const effectDraws = [...effects] + .map(([index, { effect }]) => + effect === "scroll" + ? ` textScroll(layerBuf[${index}], L${index}_src, L${index}_SRC_W, L${index}_PERIOD, L${index}_LEAD, (int)L${index}_phase);` + : effect === "twinkle" + ? ` textTwinkle(layerBuf[${index}], L${index}_src, (int)L${index}_phase);` + : ` textRamp(layerBuf[${index}], L${index}_src, L${index}_SRC_X, L${index}_SRC_W, L${index}_RAMP, (int)L${index}_phase);`, + ) + .join("\n"); const layerSetups = codeLayers.map(({ index }) => ` L${index}::setup();`).join("\n"); const layerUpdates = codeLayers.map(({ index }) => ` L${index}::update(dt, input);`).join("\n"); const layerDraws = codeLayers @@ -333,9 +388,8 @@ ${SLOT_END(index)}`); parts.push(` // ── Deterministic pipeline (do not edit) ── -static void rleDecode(const uint8_t* rle, size_t len, uint8_t* out) { +static void rleDecode(const uint8_t* rle, size_t len, uint8_t* out, size_t cap = (size_t)PIX_N * 4) { size_t o = 0; - const size_t cap = (size_t)PIX_N * 4; for (size_t i = 0; i + 4 < len && o < cap; i += 5) { for (int k = 0; k < rle[i] && o < cap; k++) { out[o++] = rle[i + 1]; out[o++] = rle[i + 2]; out[o++] = rle[i + 3]; out[o++] = rle[i + 4]; @@ -364,7 +418,7 @@ static void recolorBuffer(uint8_t* buf, const uint8_t lut[256][4]) { } } -void setup() { +${effects.size > 0 ? TEXT_FX_CPP : ""}void setup() { for (int i = 0; i < LAYER_N; i++) if (!layerBuf[i]) layerBuf[i] = (uint8_t*)PFMem::alloc((size_t)PIX_N * 4); for (int i = 0; i < LAYER_N; i++) @@ -379,14 +433,14 @@ void update(float dt, const InputFrame& input) { PFParams::apply(input, i, &knobVal[i], KNOB_MIN[i], KNOB_MAX[i], KNOB_STEP[i]); knobNorm[i] = (knobVal[i] - KNOB_MIN[i]) / (KNOB_MAX[i] - KNOB_MIN[i]); } -${layerUpdates} +${effectUpdates ? `${effectUpdates}\n` : ""}${layerUpdates} } void draw() {${isPanelFrame ? "" : "\n PFCanvas::setFrame(FRAME_W, FRAME_H);"} for (int i = 0; i < LAYER_N; i++) if (!layerBuf[i]) { PFCanvas::present(); return; } - -${layerDraws} +${[...effects.keys()].map((index) => ` if (!L${index}_src) { PFCanvas::present(); return; }\n`).join("")} +${effectDraws ? `${effectDraws}\n` : ""}${layerDraws} // Composite bottom → top over the panel's black — same math as the web preview. for (int y = 0; y < FRAME_H; y++) { @@ -463,6 +517,75 @@ ${layerDraws} }; } +// textEffects.ts in C++. Each pixel is integer work, and the phase only adds +// dt × speed and takes a remainder, as the preview's does; textExport.test.ts +// compiles this and compares the frames. +const TEXT_FX_CPP = `// ── Text effects: a moving text layer drawn again each frame from what it +// carries, at a phase one knob speeds up (0 stands it still) ── +static inline void advanceText(float* phase, float dt, float knob, float rate, float period) { + const float n = knob > 0.0f ? (knob < 1.0f ? knob : 1.0f) : 0.0f; + float p = *phase + dt * (n * rate); + if (!(p >= 0.0f)) p = 0.0f; + else if (p >= period) p = fmodf(p, period); + *phase = p; +} + +static inline int twinkleHash(int x, int y) { + uint32_t h = (uint32_t)(x + 1) * 0x9e3779b1u ^ (uint32_t)(y + 1) * 0x85ebca77u; + h ^= h >> 15; + h *= 0x2c1b3c6du; + h ^= h >> 12; + return (int)(h & 255u); +} + +static void textScroll(uint8_t* out, const uint8_t* src, int srcW, int period, int lead, int step) { + int m = (step + lead) % period; + if (m < 0) m += period; + const int shift = m - FRAME_W; + for (int y = 0; y < FRAME_H; y++) { + for (int x = 0; x < FRAME_W; x++) { + uint8_t* p = out + (((size_t)y * FRAME_W + x) << 2); + const int c = x + shift; + if (c < 0 || c >= srcW) { p[0] = p[1] = p[2] = p[3] = 0; continue; } + const uint8_t* s = src + (((size_t)y * srcW + c) << 2); + p[0] = s[0]; p[1] = s[1]; p[2] = s[2]; p[3] = s[3]; + } + } +} + +static void textTwinkle(uint8_t* out, const uint8_t* src, int step) { + for (int y = 0; y < FRAME_H; y++) { + for (int x = 0; x < FRAME_W; x++) { + const size_t j = ((size_t)y * FRAME_W + x) << 2; + const int a = src[j + 3]; + out[j] = src[j]; out[j + 1] = src[j + 1]; out[j + 2] = src[j + 2]; + if (a == 0) { out[j + 3] = 0; continue; } + const int u = (step + twinkleHash(x, y)) & 255; + const int tri = u < 128 ? u : 255 - u; + out[j + 3] = (uint8_t)((a * (255 - tri - (tri >> 1)) + 127) / 255); + } + } +} + +static void textRamp(uint8_t* out, const uint8_t* src, int srcX, int srcW, const uint8_t lut[256][4], int step) { + for (int y = 0; y < FRAME_H; y++) { + for (int x = 0; x < FRAME_W; x++) { + uint8_t* p = out + (((size_t)y * FRAME_W + x) << 2); + const int c = x - srcX; + if (c < 0 || c >= srcW) { p[0] = p[1] = p[2] = p[3] = 0; continue; } + const uint8_t* s = src + (((size_t)y * srcW + c) << 2); + const int a = s[3]; + if (a == 0) { p[0] = p[1] = p[2] = p[3] = 0; continue; } + const int u = (s[0] + step) & 511; + const uint8_t* k = lut[u < 256 ? u : 511 - u]; + p[0] = k[0]; p[1] = k[1]; p[2] = k[2]; + p[3] = (uint8_t)((a * k[3] + 127) / 255); + } + } +} + +`; + // ── per-layer translation prompt ── function buildLayerPrompt({ diff --git a/web/src/lib/lab/pixelToCode.ts b/web/src/lib/lab/pixelToCode.ts index bc79592c1..0e5476619 100644 --- a/web/src/lib/lab/pixelToCode.ts +++ b/web/src/lib/lab/pixelToCode.ts @@ -27,6 +27,7 @@ import { withMatrixAnnotation } from "@/lib/pattern/matrix"; import { rleEncodeRGBA } from "./flatten"; import { bytesToBase64 } from "./serialize"; +import { activeTextEffect } from "./textEffects"; import { fontLabel } from "./textFonts"; import type { RasterLayer } from "./types"; @@ -38,9 +39,21 @@ type SpriteCrop = { data: Uint8ClampedArray; }; -/** Tight bounding box of non-transparent pixels; null when nothing is drawn. */ +/** + * Tight bounding box of non-transparent pixels; null when nothing is drawn. + * A scrolling text crops its source instead, the whole line from wherever + * it starts, so the letters past the frame's edges come along to be + * scrolled in the chat too. + */ function cropToSprite(layer: RasterLayer): SpriteCrop | null { - const { width, height, data } = layer; + if (layer.type === "text" && activeTextEffect(layer) === "scroll" && layer.source) { + const crop = cropBuffer(layer.source.data, layer.source.width, layer.height); + return crop && { ...crop, x: crop.x + layer.source.x }; + } + return cropBuffer(layer.data, layer.width, layer.height); +} + +function cropBuffer(data: Uint8ClampedArray, width: number, height: number): SpriteCrop | null { let minX = width; let minY = height; let maxX = -1; diff --git a/web/src/lib/lab/serialize.ts b/web/src/lib/lab/serialize.ts index 897f1c72e..41e965a84 100644 --- a/web/src/lib/lab/serialize.ts +++ b/web/src/lib/lab/serialize.ts @@ -37,7 +37,9 @@ import { type PixelLayer, type RampState, type TextLayer, + type TextSource, } from "./types"; +import { sourceColumnLimit } from "./textEffects"; import { defaultKnobState, matchKnobsAnnotation } from "./annotations"; import { LAB_STORAGE, readStorage, removeStorage, writeStorage } from "./persist"; import { matchMatrixAnnotation } from "@/lib/pattern/matrix"; @@ -283,6 +285,11 @@ export function serializeProject(project: LabProject): string | null { px: bytesToBase64(layer.data), spec: layer.spec, ramp: layer.ramp, + // A scrolling or ramp-running layer keeps what it moves too, past + // the frame's edges: without the font it could not be drawn again. + ...(layer.source + ? { src: { x: layer.source.x, w: layer.source.width, px: bytesToBase64(layer.source.data) } } + : {}), }; } return { @@ -384,6 +391,7 @@ function readLayer(raw: unknown): Layer | null { rev: 0, spec: normalizeTextSpec(isRecord(raw.spec) ? raw.spec : {}, defaultTextSpec(frame), frame), ramp: isRecord(raw.ramp) ? readRamp(raw.ramp) : cloneRampState(TEXT_RAMP_STATE), + source: readTextSource(raw.src, width, height), }; return layer; } @@ -401,6 +409,17 @@ function readLayer(raw: unknown): Layer | null { return null; } +/** A saved text source, or null when it is missing or does not fit the frame. */ +function readTextSource(raw: unknown, width: number, height: number): TextSource | null { + if (!isRecord(raw) || typeof raw.px !== "string") return null; + const x = Number(raw.x); + const columns = Number(raw.w); + if (!Number.isInteger(x) || !Number.isInteger(columns) || columns <= 0) return null; + if (columns > sourceColumnLimit(width, height) || Math.abs(x) > width * 8 + columns) return null; + const data = base64ToBytes(raw.px, columns * height * 4); + return data ? { x, width: columns, data } : null; +} + export function deserializeProject(json: string): (LabProject & { savedAt: number }) | null { let parsed: unknown; try { diff --git a/web/src/lib/lab/store/shared.ts b/web/src/lib/lab/store/shared.ts index 15aecadae..2c3599e72 100644 --- a/web/src/lib/lab/store/shared.ts +++ b/web/src/lib/lab/store/shared.ts @@ -116,11 +116,22 @@ export function resizePixelLayers(layers: Layer[], matrix: MatrixSize): Layer[] rev: layer.rev + 1, }; if (resized.type === "text") { + const dx = Math.floor((matrix.width - layer.width) / 2); resized.spec = { ...resized.spec, - x: resized.spec.x + Math.floor((matrix.width - layer.width) / 2), + x: resized.spec.x + dx, y: resized.spec.y + Math.floor((matrix.height - layer.height) / 2), }; + // The source keeps its columns and moves by the same offset; its rows + // are cut or padded the way the bitmap's are. + const source = resized.source; + resized.source = source + ? { + x: source.x + dx, + width: source.width, + data: resizeSurface(source.data, source.width, layer.height, source.width, matrix.height), + } + : null; } return resized; }); @@ -132,8 +143,8 @@ export function updateLayer(layers: Layer[], id: string, patch: (layer: Layer) = } /** - * Patch the ramp of a code or text layer. A text layer filled with its ramp - * is drawn again, since its colours live in its bitmap. + * Patch the ramp of a code or text layer. A text layer filled with its ramp, + * or running it, is drawn again, since its colours live in its bitmap. */ export function updateRampLayer( layers: Layer[], @@ -143,7 +154,7 @@ export function updateRampLayer( return layers.map((layer) => { if (layer.id !== id || !isRampLayer(layer)) return layer; const next = patch(layer); - return next.type === "text" && next.ramp !== layer.ramp && next.spec.fill !== "solid" + return next.type === "text" && next.ramp !== layer.ramp && (next.spec.fill !== "solid" || next.spec.effect === "ramp") ? drawTextLayer(next) : next; }); diff --git a/web/src/lib/lab/stretch.ts b/web/src/lib/lab/stretch.ts new file mode 100644 index 000000000..7ed99e6c9 --- /dev/null +++ b/web/src/lib/lab/stretch.ts @@ -0,0 +1,38 @@ +// ── Nearest-neighbour stretch ──────────────────────────────────────────────── +// The Graphic Export stage (capture/core.ts) stretches pixel and text layers +// to its render grid with this, and the engine stretches a text effect's +// frame the same way each tick. + +/** + * Nearest-neighbour stretch, independent per axis — a 128×64 sprite sheet + * drawn for the panel lands on a 1050×600 card without a blurry resample, + * and an off-aspect output simply stretches it the way the pattern beneath + * stretches too. + */ +export function stretchNearest( + src: Uint8ClampedArray, + srcWidth: number, + srcHeight: number, + dstWidth: number, + dstHeight: number, + into?: Uint8ClampedArray | null, +): Uint8ClampedArray { + const size = dstWidth * dstHeight * 4; + const out = into && into.length === size ? into : new Uint8ClampedArray(size); + if (srcWidth <= 0 || srcHeight <= 0) return out; + for (let y = 0; y < dstHeight; y++) { + const sy = Math.min(srcHeight - 1, Math.floor((y * srcHeight) / dstHeight)); + const srcRow = sy * srcWidth; + const dstRow = y * dstWidth; + for (let x = 0; x < dstWidth; x++) { + const sx = Math.min(srcWidth - 1, Math.floor((x * srcWidth) / dstWidth)); + const si = (srcRow + sx) * 4; + const di = (dstRow + x) * 4; + out[di] = src[si]; + out[di + 1] = src[si + 1]; + out[di + 2] = src[si + 2]; + out[di + 3] = src[si + 3]; + } + } + return out; +} diff --git a/web/src/lib/lab/text.ts b/web/src/lib/lab/text.ts index 4ef23377d..35cde4bf6 100644 --- a/web/src/lib/lab/text.ts +++ b/web/src/lib/lab/text.ts @@ -5,13 +5,20 @@ // there on a text layer IS pixel art to the compositor, the masks, the // Graphic Export stage and both exports: what the device runs never needs // the font (flatten.ts and hExport.ts carry the bitmap). +// +// A layer that scrolls or runs its ramp is drawn once more, into its source +// (types.ts TextSource): the whole of each line, past the frame's edges, which +// textEffects.ts turns into a frame per tick. Its bitmap is then that frame +// at phase 0, so the Layers panel, Copy as code and a save show the letters +// where the effect starts them. import { buildRampLUTRGBA } from "@/lib/pattern/harness"; import { hexToRgb } from "@/lib/pattern/color"; import { rampStateToHarness } from "./engine"; +import { renderTextEffect, sourceColumnLimit } from "./textEffects"; import { ensureFontReady, resolveFont } from "./textFonts"; -import { textCoverage } from "./textRaster"; -import type { RampState, TextLayer, TextSpec } from "./types"; +import { textCoverage, textReach } from "./textRaster"; +import type { RampState, TextLayer, TextSource, TextSpec } from "./types"; export type InkBounds = { minX: number; minY: number; maxX: number; maxY: number }; @@ -33,6 +40,30 @@ export function inkBounds(alpha: Uint8ClampedArray, width: number, height: numbe return maxX < 0 ? null : { minX, minY, maxX, maxY }; } +/** Coverage as the alpha a pixel shows: all or nothing when sharp. */ +function textAlpha(coverage: Uint8ClampedArray, sharp: boolean, count: number): Uint8ClampedArray { + const alpha = new Uint8ClampedArray(count); + for (let i = 0; i < count; i++) { + const a = coverage[i] ?? 0; + alpha[i] = sharp ? (a >= 128 ? 255 : 0) : a; + } + return alpha; +} + +/** + * Where each pixel sits on the ramp, 0..255: from the first lit column to + * the last (rampX) or the first lit row to the last (rampY). Null for solid. + */ +function rampPlace(fill: TextSpec["fill"], bounds: InkBounds): ((x: number, y: number) => number) | null { + if (fill === "solid") return null; + const spanX = Math.max(1, bounds.maxX - bounds.minX); + const spanY = Math.max(1, bounds.maxY - bounds.minY); + return (x, y) => { + const t = fill === "rampX" ? (x - bounds.minX) / spanX : (y - bounds.minY) / spanY; + return Math.round(Math.max(0, Math.min(1, t)) * 255); + }; +} + /** * Colour a coverage map into straight RGBA. * @@ -50,20 +81,14 @@ export function paintText( height: number, ): Uint8ClampedArray { const count = width * height; - const alpha = new Uint8ClampedArray(count); - for (let i = 0; i < count; i++) { - const a = coverage[i] ?? 0; - alpha[i] = spec.sharp ? (a >= 128 ? 255 : 0) : a; - } - + const alpha = textAlpha(coverage, spec.sharp, count); const out = new Uint8ClampedArray(count * 4); const bounds = inkBounds(alpha, width, height); if (!bounds) return out; const solid = hexToRgb(spec.color); - const lut = spec.fill === "solid" ? null : buildRampLUTRGBA(rampStateToHarness(ramp)); - const spanX = Math.max(1, bounds.maxX - bounds.minX); - const spanY = Math.max(1, bounds.maxY - bounds.minY); + const place = rampPlace(spec.fill, bounds); + const lut = place ? buildRampLUTRGBA(rampStateToHarness(ramp)) : null; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { @@ -71,15 +96,14 @@ export function paintText( const a = alpha[i]; if (a === 0) continue; const j = i * 4; - if (!lut) { + if (!place || !lut) { out[j] = solid[0]; out[j + 1] = solid[1]; out[j + 2] = solid[2]; out[j + 3] = a; continue; } - const t = spec.fill === "rampX" ? (x - bounds.minX) / spanX : (y - bounds.minY) / spanY; - const k = Math.round(Math.max(0, Math.min(1, t)) * 255) * 4; + const k = place(x, y) * 4; out[j] = lut[k]; out[j + 1] = lut[k + 1]; out[j + 2] = lut[k + 2]; @@ -89,6 +113,83 @@ export function paintText( return out; } +/** + * The ramp effect's source from a coverage map: red is each pixel's place + * on the ramp (0 everywhere for a solid fill, so the whole word changes + * colour at once), alpha is how much the letters cover it. Unlit pixels are + * all zero, so a run of them packs into one RLE entry. + */ +export function rampSource( + coverage: Uint8ClampedArray, + spec: Pick, + width: number, + height: number, +): Uint8ClampedArray { + const count = width * height; + const alpha = textAlpha(coverage, spec.sharp, count); + const out = new Uint8ClampedArray(count * 4); + const bounds = inkBounds(alpha, width, height); + if (!bounds) return out; + const place = rampPlace(spec.fill, bounds); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = y * width + x; + if (alpha[i] === 0) continue; + out[i * 4] = place ? place(x, y) : 0; + out[i * 4 + 3] = alpha[i]; + } + } + return out; +} + +/** + * Cut a coverage strip down to the columns something is lit in, by the + * layer's sharpness. Null when nothing is. + */ +function cropToLetters( + coverage: Uint8ClampedArray, + sharp: boolean, + left: number, + width: number, + height: number, +): { x: number; width: number; coverage: Uint8ClampedArray } | null { + const bounds = inkBounds(textAlpha(coverage, sharp, width * height), width, height); + if (!bounds) return null; + const cropWidth = bounds.maxX - bounds.minX + 1; + const cropped = new Uint8ClampedArray(cropWidth * height); + for (let y = 0; y < height; y++) { + const from = y * width + bounds.minX; + cropped.set(coverage.subarray(from, from + cropWidth), y * cropWidth); + } + return { x: left + bounds.minX, width: cropWidth, coverage: cropped }; +} + +/** + * The scroll or ramp source for a spec, or null when no letter lands in it. + * Undefined where there is no canvas to draw on. + */ +export function drawTextSource( + spec: TextSpec, + ramp: RampState, + fontCss: string, + width: number, + height: number, +): TextSource | null | undefined { + const reach = textReach(spec, fontCss); + if (!reach) return undefined; + const columns = Math.min(reach.right - reach.left, sourceColumnLimit(width, height)); + if (columns <= 0) return null; + const coverage = textCoverage(spec, fontCss, columns, height, reach.left); + if (!coverage) return undefined; + const letters = cropToLetters(coverage, spec.sharp, reach.left, columns, height); + if (!letters) return null; + const data = + spec.effect === "ramp" + ? rampSource(letters.coverage, spec, letters.width, height) + : paintText(letters.coverage, spec, ramp, letters.width, height); + return { x: letters.x, width: letters.width, data }; +} + /** * The layer drawn again from its spec and ramp at its own size. Where there * is no canvas (the server, a test) the bitmap stays as it was. A font still @@ -96,14 +197,24 @@ export function paintText( * (store.ts listens to textFonts). */ export function drawTextLayer(layer: TextLayer): TextLayer { - const font = resolveFont(layer.spec.font); - ensureFontReady(layer.spec.font, layer.spec.size); - const coverage = textCoverage(layer.spec, font.css, layer.width, layer.height); - const size = layer.width * layer.height * 4; - const data = coverage - ? paintText(coverage, layer.spec, layer.ramp, layer.width, layer.height) - : layer.data.length === size - ? layer.data - : new Uint8ClampedArray(size); - return { ...layer, data, rev: layer.rev + 1 }; + const { spec, width, height } = layer; + const font = resolveFont(spec.font); + ensureFontReady(spec.font, spec.size); + const size = width * height * 4; + const kept = layer.data.length === size ? layer.data : new Uint8ClampedArray(size); + + if (spec.effect === "scroll" || spec.effect === "ramp") { + const source = drawTextSource(spec, layer.ramp, font.css, width, height); + if (source === undefined) return { ...layer, data: kept, rev: layer.rev + 1 }; + const next: TextLayer = { ...layer, source, data: new Uint8ClampedArray(size), rev: layer.rev + 1 }; + if (source) { + const lut = spec.effect === "ramp" ? buildRampLUTRGBA(rampStateToHarness(layer.ramp)) : null; + renderTextEffect(next, spec.effect, 0, lut, next.data); + } + return next; + } + + const coverage = textCoverage(spec, font.css, width, height); + const data = coverage ? paintText(coverage, spec, layer.ramp, width, height) : kept; + return { ...layer, data, source: coverage ? null : layer.source, rev: layer.rev + 1 }; } diff --git a/web/src/lib/lab/textEffects.test.ts b/web/src/lib/lab/textEffects.test.ts new file mode 100644 index 000000000..ca09e27d2 --- /dev/null +++ b/web/src/lib/lab/textEffects.test.ts @@ -0,0 +1,302 @@ +// Text effects, added 24 October 2026: a text layer scrolls, twinkles or +// runs its ramp at the speed of one knob (textEffects.ts). These tests pin +// the parts around the frames: the phase, the source text.ts draws past the +// frame's edges, the save, a resize, the engine and the store. +// textExport.test.ts holds the preview, the flattened JS and the firmware +// header to the same LEDs. +// +// The canvas is stood in for as in text.test.ts: each character lights a +// 2×2 block on spec.y, the line starting at spec.x (left-aligned here). + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./textRaster", () => { + const lineLeft = (spec: { text: string; x: number; align: string }) => + spec.align === "left" ? spec.x : spec.align === "right" ? spec.x - spec.text.length * 2 : spec.x - spec.text.length; + return { + textCoverage: vi.fn( + (spec: { text: string; x: number; y: number; align: string }, _font: string, width: number, height: number, left = 0) => { + const coverage = new Uint8ClampedArray(width * height); + const start = lineLeft(spec); + for (let y = spec.y - 1; y < spec.y + 1; y++) { + for (let x = start; x < start + spec.text.length * 2; x++) { + const column = x - left; + if (column >= 0 && column < width && y >= 0 && y < height) coverage[y * width + column] = 255; + } + } + return coverage; + }, + ), + textReach: vi.fn((spec: { text: string; x: number; align: string }) => { + const start = lineLeft(spec); + return { left: start - 1, right: start + spec.text.length * 2 + 1 }; + }), + }; +}); + +import { buildRampLUTRGBA } from "@/lib/pattern/harness"; +import { textCoverage } from "./textRaster"; +import { drawTextLayer, paintText } from "./text"; +import { + TEXT_EFFECT_RATE, + activeTextEffect, + advanceTextPhase, + renderTextEffect, + textEffectPeriod, + twinkleHash, +} from "./textEffects"; +import { LabEngine } from "./engine"; +import { rampStateToHarness } from "./engine"; +import { deserializeProject, serializeProject } from "./serialize"; +import { useLabStore } from "./store"; +import { resizePixelLayers } from "./store/shared"; +import { createPixelLayer, createTextLayer, type Layer, type TextLayer, type TextSpec } from "./types"; + +const frame = { width: 16, height: 8 }; + +function textAt(spec: Partial, size = frame): TextLayer { + const layer = createTextLayer(size, "Text"); + return drawTextLayer({ ...layer, spec: { ...layer.spec, align: "left", x: 2, y: 4, ...spec } }); +} + +/** Columns with something lit, as frame x. */ +function litColumns(data: Uint8ClampedArray, width: number): number[] { + const columns = new Set(); + for (let i = 0; i < data.length / 4; i++) if (data[i * 4 + 3] > 0) columns.add(i % width); + return [...columns].sort((a, b) => a - b); +} + +const render = (engine: LabEngine, layers: Layer[], knobs: number[], dt: number, size = frame) => + engine.render({ matrix: size, layers, activeLayerId: layers[0].id, knobs, ranges: [[0, 1], [0, 1], [0, 1], [0, 1]] }, dt, 0) + .data.slice(); + +/** Columns with an LED on in an engine frame, which is opaque: by colour. */ +function shownColumns(data: Uint8ClampedArray, width: number): number[] { + const columns = new Set(); + for (let i = 0; i < data.length / 4; i++) if (data[i * 4] + data[i * 4 + 1] + data[i * 4 + 2] > 0) columns.add(i % width); + return [...columns].sort((a, b) => a - b); +} + +describe("the phase", () => { + it("adds dt × the knob's share of the speed", () => { + expect(advanceTextPhase(0, 1, 0.5, 128, 1000)).toBe(64); + expect(advanceTextPhase(10, 0.25, 1, 128, 1000)).toBe(42); + }); + + it("stands still at the bottom of the knob, and with no knob reading at all", () => { + expect(advanceTextPhase(7, 1, 0, 128, 100)).toBe(7); + expect(advanceTextPhase(7, 1, -3, 128, 100)).toBe(7); + expect(advanceTextPhase(7, 1, Number.NaN, 128, 100)).toBe(7); + }); + + it("goes no faster past the top of the knob, and starts again after its period", () => { + expect(advanceTextPhase(0, 1, 4, 128, 1000)).toBe(128); + expect(advanceTextPhase(90, 0.25, 1, 128, 100)).toBe(22); + }); + + it("twinkles each LED on its own beat", () => { + const beats = new Set(); + for (let x = 0; x < 16; x++) beats.add(twinkleHash(x, 3)); + expect(beats.size).toBeGreaterThan(10); + expect(twinkleHash(5, 2)).toBe(twinkleHash(5, 2)); + for (let i = 0; i < 64; i++) expect(twinkleHash(i, i * 3) & ~255).toBe(0); + }); +}); + +describe("drawing a moving text", () => { + beforeEach(() => { + vi.mocked(textCoverage).mockClear(); + }); + + it("scroll draws the whole line, past the frame's right edge, and rests where the text sits", () => { + // 12 characters are 24 columns from x = 2: ten of them past a 16-wide frame. + const layer = textAt({ text: "PATTERNFLOW!", effect: "scroll" }); + expect(layer.source?.x).toBe(2); + expect(layer.source?.width).toBe(24); + expect(activeTextEffect(layer)).toBe("scroll"); + expect(litColumns(layer.data, 16)).toEqual([2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]); + expect(textEffectPeriod("scroll", layer)).toBe(24 + 16); + }); + + it("scroll brings the letters that were off the frame through it", () => { + const layer = textAt({ text: "PATTERNFLOW!", effect: "scroll" }); + const out = new Uint8ClampedArray(layer.data.length); + // Twenty columns on, the last ten letters' columns fill the frame from the left. + renderTextEffect(layer, "scroll", 20, null, out); + expect(litColumns(out, 16)).toEqual([0, 1, 2, 3, 4, 5]); + // Twenty-six on, the line has left the frame; one more and it is back on the right. + renderTextEffect(layer, "scroll", 26, null, out); + expect(litColumns(out, 16)).toEqual([]); + renderTextEffect(layer, "scroll", 27, null, out); + expect(litColumns(out, 16)).toEqual([15]); + }); + + it("a ramp that runs rests on the same colours as a ramp fill that does not", () => { + const still = textAt({ text: "HEY", fill: "rampX" }); + const running = textAt({ text: "HEY", fill: "rampX", effect: "ramp" }); + expect(Array.from(running.data)).toEqual(Array.from(still.data)); + // Red holds the place on the ramp: the first lit column 0, the last 255. + const source = running.source!; + expect(source.data[(3 * source.width) * 4]).toBe(0); + expect(source.data[(3 * source.width + source.width - 1) * 4]).toBe(255); + }); + + it("a one-colour text that runs the ramp takes the ramp's colours, all letters at once", () => { + const layer = textAt({ text: "HEY", effect: "ramp" }); + const lut = buildRampLUTRGBA(rampStateToHarness(layer.ramp)); + const j = (4 * 16 + 2) * 4; + expect(Array.from(layer.data.subarray(j, j + 3))).toEqual(Array.from(lut.subarray(0, 3))); + const out = new Uint8ClampedArray(layer.data.length); + renderTextEffect(layer, "ramp", 300, lut, out); + // 300 is past the far end: back down to 511 - 300 = 211. + expect(Array.from(out.subarray(j, j + 3))).toEqual(Array.from(lut.subarray(211 * 4, 211 * 4 + 3))); + }); + + it("twinkle needs no source, and dims a lit LED to a quarter at most", () => { + const layer = textAt({ text: "HEY", effect: "twinkle" }); + expect(layer.source).toBeNull(); + const out = new Uint8ClampedArray(layer.data.length); + for (let phase = 0; phase < 256; phase += 17) { + renderTextEffect(layer, "twinkle", phase, null, out); + for (let i = 0; i < out.length; i += 4) { + expect(out.subarray(i, i + 3)).toEqual(layer.data.subarray(i, i + 3)); + if (layer.data[i + 3] === 0) expect(out[i + 3]).toBe(0); + else expect(out[i + 3]).toBeGreaterThanOrEqual(65); + } + } + }); + + it("no effect again drops the source", () => { + const layer = drawTextLayer({ ...textAt({ text: "HEY", effect: "scroll" }), spec: { ...textAt({}).spec, text: "HEY", effect: "none" } }); + expect(layer.source).toBeNull(); + }); + + it("a scroll whose source is missing shows the bitmap as it is", () => { + const layer = { ...textAt({ text: "HEY", effect: "scroll" }), source: null }; + expect(activeTextEffect(layer)).toBeNull(); + const shown = render(new LabEngine(), [layer], [0, 1, 0, 0], 0.5); + expect(shownColumns(shown, 16)).toEqual(litColumns(layer.data, 16)); + }); +}); + +describe("moving text in the engine", () => { + it("moves at the knob it is set to: half of the speed knob is 64 pixels a second", () => { + const layer = textAt({ text: "AB", x: 4, effect: "scroll", effectKnob: 1 }); + const engine = new LabEngine(); + // 1/16 s at half speed is 4 columns to the left. + expect(shownColumns(render(engine, [layer], [1, 0.5, 1, 1], 1 / 16), 16)).toEqual([0, 1, 2, 3]); + expect(TEXT_EFFECT_RATE.scroll * 0.5 * (1 / 16)).toBe(4); + }); + + it("the other knobs do nothing to it", () => { + const layer = textAt({ text: "AB", x: 4, effect: "scroll", effectKnob: 1 }); + expect(shownColumns(render(new LabEngine(), [layer], [1, 0, 1, 1], 1), 16)).toEqual([4, 5, 6, 7]); + }); + + it("a twinkling mask gates the layer under it as it twinkles", () => { + const art = createPixelLayer(frame, "Art"); + art.data.fill(255); + const mask: TextLayer = { ...textAt({ text: "ABCDEFGH", x: 0, effect: "twinkle", effectKnob: 0 }), role: "mask" }; + const engine = new LabEngine(); + const lit = (data: Uint8ClampedArray) => { + let n = 0; + for (let i = 0; i < data.length; i += 4) if (data[i] > 0) n++; + return n; + }; + const counts = new Set(); + for (let f = 0; f < 8; f++) counts.add(lit(render(engine, [mask, art], [1, 0, 0, 0], 1 / 32))); + // 32 LEDs under the letters; some are below the mask's half at any beat, and which changes. + expect(Math.max(...counts)).toBeLessThan(32); + expect(counts.size).toBeGreaterThan(1); + }); + + it("is stretched to a render grid bigger than the layer, the way Graphic Export renders", () => { + const layer = textAt({ text: "AB", x: 4, effect: "scroll", effectKnob: 1 }); + const big = { width: 32, height: 16 }; + const shown = render(new LabEngine(), [layer], [0, 0.5, 0, 0], 1 / 16, big); + expect(shownColumns(shown, 32)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); + }); +}); + +describe("a moving text saved and opened again", () => { + const project = (layers: Layer[]) => ({ ...useLabStore.getState(), layers, activeLayerId: layers[0].id }); + + it("keeps its effect, its knob and what it moves", () => { + const layer = textAt({ text: "PATTERNFLOW!", effect: "scroll", effectKnob: 3 }); + const opened = deserializeProject(serializeProject(project([layer]))!)!; + const back = opened.layers[0] as TextLayer; + expect(back.spec.effect).toBe("scroll"); + expect(back.spec.effectKnob).toBe(3); + expect(back.source?.x).toBe(2); + expect(Array.from(back.source!.data)).toEqual(Array.from(layer.source!.data)); + }); + + it("opens a save from before effects as still letters", () => { + const json = JSON.parse(serializeProject(project([textAt({ text: "HEY" })]))!); + delete json.layers[0].spec.effect; + delete json.layers[0].spec.effectKnob; + const back = deserializeProject(JSON.stringify(json))!.layers[0] as TextLayer; + expect(back.spec.effect).toBe("none"); + expect(back.spec.effectKnob).toBe(1); + expect(back.source).toBeNull(); + }); + + it("drops a source that does not fit its frame, and the scroll then stands", () => { + const json = JSON.parse(serializeProject(project([textAt({ text: "PATTERNFLOW!", effect: "scroll" })]))!); + json.layers[0].src.w = 23; + const back = deserializeProject(JSON.stringify(json))!.layers[0] as TextLayer; + expect(back.source).toBeNull(); + expect(activeTextEffect(back)).toBeNull(); + }); +}); + +describe("a moving text on a resized frame", () => { + it("its source moves with the letters, columns kept and rows re-centred", () => { + const layer = textAt({ text: "PATTERNFLOW!", effect: "scroll" }); + const [resized] = resizePixelLayers([layer], { width: 24, height: 12 }) as TextLayer[]; + expect(resized.source?.x).toBe(2 + 4); + expect(resized.source?.width).toBe(24); + expect(resized.source?.data.length).toBe(24 * 12 * 4); + // Row 3 of 8 is row 5 of 12. + expect(resized.source!.data[(5 * 24) * 4 + 3]).toBe(255); + expect(activeTextEffect(resized)).toBe("scroll"); + }); +}); + +describe("moving text in the store", () => { + beforeEach(() => { + useLabStore.getState().discardProject(); + useLabStore.getState().setMatrix(frame); + }); + + const active = () => { + const state = useLabStore.getState(); + return state.layers.find((layer) => layer.id === state.activeLayerId) as TextLayer; + }; + + it("choosing an effect draws its source, and the knob is kept in range", () => { + useLabStore.getState().addTextLayer(); + useLabStore.getState().setTextSpec(active().id, { effect: "scroll", effectKnob: 9 }); + expect(active().source).not.toBeNull(); + expect(active().spec.effectKnob).toBe(3); + }); + + it("a ramp edit redraws a one-colour text that runs the ramp", () => { + useLabStore.getState().addTextLayer(); + useLabStore.getState().setTextSpec(active().id, { effect: "ramp" }); + const before = active().data; + useLabStore.getState().setLayerRampStops(active().id, [ + { position: 0, color: "#00ff00", alpha: 1 }, + { position: 1, color: "#0000ff", alpha: 1 }, + ]); + expect(active().data).not.toBe(before); + const lit = Array.from(active().data).findIndex((byte, i) => (i & 3) === 3 && byte > 0) - 3; + expect(Array.from(active().data.subarray(lit, lit + 3))).toEqual([0, 255, 0]); + }); + + it("paints a still text exactly as before", () => { + const layer = textAt({ text: "HEY" }); + const coverage = vi.mocked(textCoverage).mock.results.at(-1)!.value as Uint8ClampedArray; + expect(Array.from(layer.data)).toEqual(Array.from(paintText(coverage, layer.spec, layer.ramp, 16, 8))); + }); +}); diff --git a/web/src/lib/lab/textEffects.ts b/web/src/lib/lab/textEffects.ts new file mode 100644 index 000000000..eef2049ba --- /dev/null +++ b/web/src/lib/lab/textEffects.ts @@ -0,0 +1,187 @@ +// ── Text effects: the letters moving, frame by frame ───────────────────────── +// A text layer with an effect hands the compositor a new frame each tick, +// made from its bitmap or its source (types.ts TextSource) at a phase that +// one of the four knobs speeds up. These steps are written three times: here +// for the Lab preview (engine.ts), as JavaScript in the flattened export +// (flatten.ts) and as C++ in the firmware header (hExport.ts). Each pixel is +// whole-number work, and the one float, the phase, only ever adds +// dt × speed and takes a remainder, so the three light the same LEDs +// (textExport.test.ts holds them to it). +// +// Turned all the way down, the knob stops the effect where it is. + +import type { TextEffect, TextLayer, TextSource } from "./types"; + +export type ActiveTextEffect = Exclude; + +/** + * Phase steps a second with the knob all the way up. + * scroll pixels: a 128-wide panel crossed in a second + * twinkle a beat is 256 steps, so two beats a second + * ramp along the ramp and back is 512 steps, so once a second + */ +export const TEXT_EFFECT_RATE: Record = { + scroll: 128, + twinkle: 512, + ramp: 512, +}; + +/** A source is at most this many bytes: as much as the biggest frame (512 × 512) holds. */ +export const TEXT_SOURCE_MAX_BYTES = 512 * 512 * 4; + +/** + * How many columns a source may have on a frame this tall: never fewer than + * the frame's, and 4096 on the 128 × 64 panel. Letters past that are left out. + */ +export function sourceColumnLimit(width: number, height: number): number { + return Math.max(width, Math.floor(TEXT_SOURCE_MAX_BYTES / (height * 4))); +} + +export const TWINKLE_PERIOD = 256; +export const RAMP_PERIOD = 512; + +/** A source the layer's frame can be read from: every row, at least one column. */ +export function sourceFits(source: TextSource | null, height: number): source is TextSource { + return ( + source !== null && + Number.isInteger(source.x) && + Number.isInteger(source.width) && + source.width > 0 && + source.data.length === source.width * height * 4 + ); +} + +/** + * The effect the layer actually runs, or null. Scroll and ramp need a source + * that fits; without one (no letter on the frame, a save from a build that + * did not keep it) the layer shows its bitmap as it is, and the exports do + * the same. + */ +export function activeTextEffect(layer: TextLayer): ActiveTextEffect | null { + const effect = layer.spec.effect; + if (effect === "none") return null; + if (effect === "twinkle") return "twinkle"; + return sourceFits(layer.source, layer.height) ? effect : null; +} + +/** How many steps the phase counts before it starts again. */ +export function textEffectPeriod(effect: ActiveTextEffect, layer: TextLayer): number { + if (effect === "twinkle") return TWINKLE_PERIOD; + if (effect === "ramp") return RAMP_PERIOD; + // Scroll: the letters' own width out to the left, then the frame's width + // coming back in from the right. + return (layer.source?.width ?? 0) + layer.width; +} + +/** + * The phase after `dt` seconds with the knob at `knob` (0..1 of its range). + * flatten.ts and hExport.ts write this same sum, in this order. + */ +export function advanceTextPhase(phase: number, dt: number, knob: number, rate: number, period: number): number { + const n = knob > 0 ? (knob < 1 ? knob : 1) : 0; + let next = phase + dt * (n * rate); + if (!(next >= 0)) next = 0; + else if (next >= period) next %= period; + return next; +} + +/** A byte per LED for the twinkle's beat, the same on every renderer: 32-bit multiplies and shifts. */ +export function twinkleHash(x: number, y: number): number { + let h = Math.imul(x + 1, 0x9e3779b1) ^ Math.imul(y + 1, 0x85ebca77); + h ^= h >>> 15; + h = Math.imul(h, 0x2c1b3c6d); + h ^= h >>> 12; + return h & 255; +} + +/** + * Draw the layer's frame at `phase` into `out` (width × height × 4 of the + * layer). `lut` is the layer's ramp as 256 RGBA entries; only ramp reads it. + * The effect must be `activeTextEffect(layer)`. + */ +export function renderTextEffect( + layer: TextLayer, + effect: ActiveTextEffect, + phase: number, + lut: Uint8Array | null, + out: Uint8ClampedArray, +): void { + const { width, height } = layer; + const step = Math.floor(phase); + + if (effect === "twinkle") { + // Each lit LED runs a triangle beat from full to about a quarter and back, + // started at its own place in it. Alpha carries the dimming, so over + // another layer the letters fade towards it rather than to black. + const data = layer.data; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const j = (y * width + x) * 4; + const a = data[j + 3]; + out[j] = data[j]; + out[j + 1] = data[j + 1]; + out[j + 2] = data[j + 2]; + if (a === 0) { + out[j + 3] = 0; + continue; + } + const u = (step + twinkleHash(x, y)) & 255; + const tri = u < 128 ? u : 255 - u; + const k = 255 - tri - (tri >> 1); + out[j + 3] = Math.floor((a * k + 127) / 255); + } + } + return; + } + + const source = layer.source!; + const src = source.data; + const srcWidth = source.width; + + if (effect === "scroll") { + // m counts the columns the letters have travelled, from out of sight on + // the right; at phase 0 they sit where the Text panel put them. + const period = srcWidth + width; + const lead = width - source.x; + const m = (((step + lead) % period) + period) % period; + const shift = m - width; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const j = (y * width + x) * 4; + const c = x + shift; + if (c < 0 || c >= srcWidth) { + out[j] = out[j + 1] = out[j + 2] = out[j + 3] = 0; + continue; + } + const i = (y * srcWidth + c) * 4; + out[j] = src[i]; + out[j + 1] = src[i + 1]; + out[j + 2] = src[i + 2]; + out[j + 3] = src[i + 3]; + } + } + return; + } + + // Ramp: each pixel's place on the ramp moves on by the step, there and + // back, so the colours never jump from the ramp's end to its start. At + // phase 0 this is text.ts paintText exactly. + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const j = (y * width + x) * 4; + const c = x - source.x; + const i = (y * srcWidth + c) * 4; + const a = c < 0 || c >= srcWidth ? 0 : src[i + 3]; + if (a === 0 || !lut) { + out[j] = out[j + 1] = out[j + 2] = out[j + 3] = 0; + continue; + } + const u = (src[i] + step) & 511; + const k = (u < 256 ? u : 511 - u) * 4; + out[j] = lut[k]; + out[j + 1] = lut[k + 1]; + out[j + 2] = lut[k + 2]; + out[j + 3] = Math.floor((a * lut[k + 3] + 127) / 255); + } + } +} diff --git a/web/src/lib/lab/textExport.test.ts b/web/src/lib/lab/textExport.test.ts index 735273a01..e98709aa3 100644 --- a/web/src/lib/lab/textExport.test.ts +++ b/web/src/lib/lab/textExport.test.ts @@ -2,7 +2,9 @@ // 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 +// writes, and the C++ header the To hardware window builds. Since 24 October +// a text layer can also scroll, twinkle or run its ramp (textEffects.ts), and +// the moving cases below hold the three to the same LEDs frame after frame. 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 @@ -21,7 +23,10 @@ import { LabEngine } from "./engine"; import { flattenLayers } from "./flatten"; import { assembleH, buildHExport } from "./hExport"; import { buildPixelPatternCode } from "./pixelToCode"; -import { paintText } from "./text"; +import { paintText, rampSource } from "./text"; +import { renderTextEffect, type ActiveTextEffect } from "./textEffects"; +import { buildRampLUTRGBA } from "@/lib/pattern/harness"; +import { rampStateToHarness } from "./engine"; import { BLEND_MODES, DEFAULT_RAMP_STATE, @@ -151,23 +156,63 @@ function stripes(id: string): CodeLayer { }; } -type Case = { name: string; frame: MatrixSize; layers: Layer[] }; +/** + * A stack to render. A moving one also says where its four knobs sit (0..1 + * ranges, so a value is its own share), the seconds a frame takes, and + * which frames to compare, counted from 1. + */ +type Case = { + name: string; + frame: MatrixSize; + layers: Layer[]; + knobs?: number[]; + dt?: number; + frames?: number[]; +}; -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; +const RANGES: Array<[number, number]> = [[0, 1], [0, 1], [0, 1], [0, 1]]; +const knobsOf = (entry: Case) => entry.knobs ?? [0, 0, 0, 0]; +const framesOf = (entry: Case) => entry.frames ?? [1]; + +/** The preview at each of the case's frames, by frame number. */ +function previewFrames(entry: Case): Map { + const { frame, layers } = entry; + const engine = new LabEngine(); + const dt = entry.dt ?? 1 / 60; + const wanted = framesOf(entry); + const out = new Map(); + for (let f = 1; f <= wanted[wanted.length - 1]; f++) { + const data = engine.render( + { matrix: frame, layers, activeLayerId: layers[0].id, knobs: knobsOf(entry), ranges: RANGES }, + dt, + (f - 1) * dt, + ).data; + if (wanted.includes(f)) out.set(f, data.slice()); + } + return out; } -function exportedJs({ frame, layers }: Case): Uint8ClampedArray { +const preview = (entry: Case) => previewFrames(entry).get(1)!; + +/** The flattened JS at each of the case's frames, fed the knobs the way the Lab feeds a pattern. */ +function exportedJsFrames(entry: Case): Map { + const { frame, layers } = entry; 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; + const dt = entry.dt ?? 1 / 60; + const knobs = knobsOf(entry); + const input = createIdleInput([0, 0, 0, 0], { knobValues: knobs, knobNormalized: knobs, knobRanges: RANGES }); + const wanted = framesOf(entry); + const out = new Map(); + for (let f = 1; f <= wanted[wanted.length - 1]; f++) { + expect(runtime.renderFrame(dt, (f - 1) * dt, input).error).toBeUndefined(); + if (wanted.includes(f)) out.set(f, runtime.data.slice()); + } + return out; } +const exportedJs = (entry: Case) => exportedJsFrames(entry).get(1)!; + /** 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++) { @@ -271,10 +316,241 @@ const HALF_STEP: Case = (() => { return { name: "HalfStep", frame, layers: [{ ...art, id: "HalfStep", blend: "add", opacity: 0.5 }] }; })(); +// ── moving text ── + +/** + * A text layer with an effect, and what it moves drawn the way text.ts + * draws it: for scroll and ramp a strip that starts left of the frame and + * runs on past its right edge, the bitmap being that strip at phase 0. + */ +function movingLayer( + id: string, + frame: MatrixSize, + next: () => number, + effect: ActiveTextEffect, + spec: Partial, + common: Partial> = {}, + ramp: RampState = TEXT_RAMP_STATE, +): TextLayer { + const layer = textLayer(id, frame, next, { ...spec, effect }, common, ramp); + if (effect === "twinkle") return layer; + const x = -Math.floor(frame.width / 3); + const width = frame.width + Math.floor(frame.width / 2) + 5; + const coverage = glyphCoverage({ width, height: frame.height }, next); + const data = + effect === "scroll" + ? paintText(coverage, layer.spec, ramp, width, frame.height) + : rampSource(coverage, layer.spec, width, frame.height); + const moving: TextLayer = { ...layer, source: { x, width, data }, data: new Uint8ClampedArray(layer.data.length) }; + renderTextEffect(moving, effect, 0, effect === "ramp" ? buildRampLUTRGBA(rampStateToHarness(ramp)) : null, moving.data); + return moving; +} + +// A sixty-fourth of a second, and knobs at quarters: dt × speed is then a +// sum a float adds up without rounding, so the header's phase is the +// preview's to the bit. Scroll at a knob of 0.5 is a pixel a frame. +const STEP = 1 / 64; +const QUARTERS = [0, 0.25, 0.5, 0.75, 1]; + +const MOVING: Case[] = []; +{ + const frame = { width: 32, height: 16 }; + // 53 columns of source and the frame's 32: round once in 85 frames. + MOVING.push({ + name: "ScrollAlone", + frame, + layers: [movingLayer("Scroll", frame, random(5), "scroll", { effectKnob: 1 })], + knobs: [0, 0.5, 0, 0], + dt: STEP, + frames: [1, 2, 40, 85, 86, 130], + }); +} +{ + const frame = { width: 32, height: 16 }; + const next = random(6); + MOVING.push({ + name: "ScrollSoftRampOverArt", + frame, + layers: [ + movingLayer("Soft", frame, next, "scroll", { effectKnob: 0, fill: "rampX", sharp: false }, { blend: "screen", opacity: 0.8 }), + artLayer("Art", frame, next), + ], + knobs: [0.75, 0, 0, 0], + dt: STEP, + frames: [1, 3, 57, 120], + }); +} +{ + const frame = { width: 32, height: 16 }; + MOVING.push({ + name: "ScrollMaskOverCode", + frame, + layers: [movingLayer("Mask", frame, random(7), "scroll", { effectKnob: 3, fill: "rampY" }, { role: "mask" }), stripes("Stripes")], + knobs: [0, 0, 0, 1], + dt: STEP, + frames: [1, 10, 50], + }); +} +{ + const frame = { width: 32, height: 16 }; + const next = random(8); + // Four steps a frame: one beat of 256 in 64 frames. + MOVING.push({ + name: "TwinkleOverArt", + frame, + layers: [ + movingLayer("Twinkle", frame, next, "twinkle", { effectKnob: 1, sharp: false }, { blend: "add", opacity: 0.6 }), + artLayer("Art", frame, next), + ], + knobs: [0, 0.5, 0, 0], + dt: STEP, + frames: [1, 31, 64, 65, 100], + }); +} +{ + const frame = { width: 32, height: 16 }; + MOVING.push({ + name: "TwinkleMaskOverCode", + frame, + layers: [movingLayer("Mask", frame, random(9), "twinkle", { effectKnob: 2 }, { role: "mask", maskInvert: true }), stripes("Stripes")], + knobs: [0, 0, 0.25, 0], + dt: STEP, + frames: [1, 40, 129], + }); +} +{ + const frame = { width: 32, height: 16 }; + // Two steps a frame: the far end of the ramp at frame 128, back by 256. + MOVING.push({ + name: "RampAcross", + frame, + layers: [movingLayer("Ramp", frame, random(10), "ramp", { effectKnob: 1, fill: "rampX" })], + knobs: [0, 0.25, 0, 0], + dt: STEP, + frames: [1, 64, 128, 129, 200, 256, 257], + }); +} +{ + const frame = { width: 32, height: 16 }; + const next = random(11); + MOVING.push({ + name: "RampSolidOverArt", + frame, + layers: [ + movingLayer("Solid", frame, next, "ramp", { effectKnob: 1, sharp: false }, { blend: "multiply", opacity: 0.6 }, { + ...TEXT_RAMP_STATE, + stops: TEXT_RAMP_STATE.stops.map((stop, i) => ({ ...stop, alpha: i === 2 ? 0.4 : 1 })), + }), + artLayer("Art", frame, next), + ], + knobs: [0, 1, 0, 0], + dt: STEP, + frames: [1, 33, 64, 65], + }); +} +{ + const frame = { width: 32, height: 16 }; + MOVING.push({ + name: "StandingStill", + frame, + layers: [movingLayer("Still", frame, random(12), "scroll", { effectKnob: 0 })], + knobs: [0, 1, 1, 1], + dt: STEP, + frames: [1, 50], + }); +} +// Any effect on any knob, over art or under it, at any blend. +for (let seed = 41; seed <= 52; seed++) { + const frame = { width: 24, height: 12 }; + const next = random(seed); + const effect = (["scroll", "twinkle", "ramp"] as const)[seed % 3]; + const layers: Layer[] = []; + for (let k = 0; k < 3; k++) { + const common = { + blend: BLEND_MODES[Math.floor(next() * BLEND_MODES.length)] as BlendMode, + opacity: ODD_OPACITIES[Math.floor(next() * ODD_OPACITIES.length)], + }; + layers.push( + k === 1 + ? movingLayer(`Moving${seed}`, frame, next, effect, { + effectKnob: Math.floor(next() * 4), + fill: (["solid", "rampX", "rampY"] as const)[Math.floor(next() * 3)], + sharp: next() < 0.5, + }, common) + : artLayer(`Art${k}`, frame, next, common), + ); + } + MOVING.push({ + name: `Moving${seed}`, + frame, + layers, + knobs: [0, 1, 2, 3].map(() => QUARTERS[1 + Math.floor(next() * 4)]), + dt: STEP, + frames: [1, 23, 77], + }); +} + +// The flattened JS works in doubles like the preview, so a knob anywhere +// and the Lab's own frame time must agree too. +const MOVING_ANYHOW: Case[] = MOVING.slice(0, 7).map((entry) => ({ + ...entry, + name: `${entry.name}Anyhow`, + knobs: [0.3, 0.37, 0.81, 0.66], + dt: 1 / 60, + frames: [1, 45, 200], +})); + 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(); }); + + it.each([...MOVING, ...MOVING_ANYHOW].map((entry) => [entry.name, entry] as const))( + "%s: moving, same pixels as the preview frame after frame", + (_name, entry) => { + const shown = previewFrames(entry); + const js = exportedJsFrames(entry); + for (const f of framesOf(entry)) { + expect(firstDifference(shown.get(f)!, js.get(f)!, entry.frame.width), `frame ${f}`).toBeNull(); + } + }, + ); +}); + +describe("moving text, as the preview shows it", () => { + const frames = (name: string) => previewFrames(MOVING.find((entry) => entry.name === name)!); + const same = (a: Uint8ClampedArray, b: Uint8ClampedArray) => firstDifference(a, b, 32) === null; + + it("scrolls: a pixel a frame at half the knob, and back where it started after its period", () => { + const shown = frames("ScrollAlone"); + expect(same(shown.get(1)!, shown.get(2)!)).toBe(false); + expect(same(shown.get(1)!, shown.get(40)!)).toBe(false); + // Frame 85 has gone round once: the rest frame. 86 is frame 1 again. + const rest = (MOVING[0].layers[0] as TextLayer).data; + expect(same(shown.get(85)!, rest)).toBe(true); + expect(same(shown.get(86)!, shown.get(1)!)).toBe(true); + }); + + it("twinkles with the beat, and is the same again after it", () => { + const shown = frames("TwinkleOverArt"); + expect(same(shown.get(1)!, shown.get(31)!)).toBe(false); + expect(same(shown.get(1)!, shown.get(65)!)).toBe(true); + }); + + it("runs the ramp to its end and back", () => { + const shown = frames("RampAcross"); + expect(same(shown.get(1)!, shown.get(128)!)).toBe(false); + expect(same(shown.get(1)!, shown.get(257)!)).toBe(true); + const rest = (MOVING[5].layers[0] as TextLayer).data; + expect(same(shown.get(256)!, rest)).toBe(true); + }); + + it("stands still with its knob all the way down, at the letters' rest", () => { + const shown = frames("StandingStill"); + const rest = (MOVING[7].layers[0] as TextLayer).data; + expect(same(shown.get(1)!, rest)).toBe(true); + expect(same(shown.get(50)!, rest)).toBe(true); + }); }); // ── the C++ header ── @@ -358,8 +634,8 @@ function runHeaders(cases: Case[]): Map { name: entry.name, matrix: entry.frame, layers: entry.layers, - knobs: [0, 0, 0, 0], - ranges: [[0, 1], [0, 1], [0, 1], [0, 1]], + knobs: knobsOf(entry), + ranges: RANGES, knobLabels: ["A", "B", "C", "D"], }); const units: Record = {}; @@ -371,8 +647,8 @@ function runHeaders(cases: Case[]): Map { const main = `#include ${headers.map((entry) => `#include "${entry.name}.h"`).join("\n")} -static void dump(const char* name) { - printf("%s ", name); +static void dump(const char* name, int frame) { + printf("%s@%d ", name, frame); for (int i = 0; i < PFCanvas::frameW * PFCanvas::frameH * 3; i++) printf("%02x", PFCanvas::frame[i]); printf(" %d\\n", PFCanvas::presents); } @@ -380,15 +656,21 @@ static void dump(const char* name) { int main() { InputFrame input{}; ${headers - .map( - (entry) => ` PFCanvas::setFrame(PANEL_RES_W, PANEL_RES_H); + .map((entry) => { + const wanted = framesOf(entry); + // 1/60 as the scaffold's own callers write it; a moving case picks a + // step a float holds exactly, so the phase adds up the same in both. + const dt = entry.dt === undefined ? "1.0f / 60.0f" : `${entry.dt}f`; + return ` 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}");`, - ) + for (int f = 1; f <= ${wanted[wanted.length - 1]}; f++) { + ${entry.namespaceName}::update(${dt}, input); + ${entry.namespaceName}::draw(); + if (${wanted.map((f) => `f == ${f}`).join(" || ")}) dump("${entry.name}", f); + }`; + }) .join("\n")} return 0; } @@ -403,8 +685,9 @@ ${headers 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 [caseName, frameNumber] = name.split("@"); + expect(presents, `${name} presented once a frame`).toBe(frameNumber); + const entry = cases.find((candidate) => candidate.name === caseName)!; 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); @@ -478,12 +761,24 @@ 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)!; + const header = (name: string, frame = 1) => + (frames ??= runHeaders([...CASES, ...HALVES, HALF_STEP, ...MOVING])).get(`${name}@${frame}`)!; 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); + it.each(MOVING.map((entry) => [entry.name, entry] as const))( + "%s: moving, same pixels as the preview frame after frame", + (name, entry) => { + const shown = previewFrames(entry); + for (const f of framesOf(entry)) { + expect(firstDifference(shown.get(f)!, header(name, f), entry.frame.width), `frame ${f}`).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. @@ -585,6 +880,16 @@ describe("a text layer copied as code", () => { run(code, alone(soft).frame, 0.3); }); + it("a scrolling text copies its whole line, and still rests where the Lab shows it", () => { + const scroll = MOVING[0].layers[0] as TextLayer; + const code = buildPixelPatternCode(scroll)!; + const columns = Number(code.match(/const SPRITE_W = (\d+)/)![1]); + expect(columns).toBeGreaterThan(scroll.width); + const frame = { width: scroll.width, height: scroll.height }; + const still: Case = { ...alone(scroll), knobs: [0, 0, 0, 0] }; + expect(firstDifference(preview(still), run(code, frame), scroll.width)).toBeNull(); + }); + it("is null when no letter lands on the frame", () => { expect(buildPixelPatternCode({ ...sharp, data: new Uint8ClampedArray(sharp.data.length) })).toBeNull(); }); diff --git a/web/src/lib/lab/textRaster.ts b/web/src/lib/lab/textRaster.ts index 5c6a30b39..31d5e3ddc 100644 --- a/web/src/lib/lab/textRaster.ts +++ b/web/src/lib/lab/textRaster.ts @@ -13,26 +13,65 @@ export function textLines(text: string): string[] { return text.replace(/\r\n?/g, "\n").split("\n"); } +function context2d(width: number, height: number): CanvasRenderingContext2D | null { + if (typeof document === "undefined") return null; + scratch ??= document.createElement("canvas"); + scratch.width = width; + scratch.height = height; + return scratch.getContext("2d", { willReadFrequently: true }); +} + +/** Where a line starts, in whole frame pixels, given how wide the font sets it. */ +function lineLeft(spec: TextSpec, lineWidth: number): number { + return spec.align === "left" + ? Math.round(spec.x) + : spec.align === "right" + ? Math.round(spec.x - lineWidth) + : Math.round(spec.x - lineWidth / 2); +} + +/** + * The frame columns the lines reach, from the first to one past the last, + * with a pixel to spare on each side for a glyph that leans out of its box. + * Null where there is no canvas, or no letters. + */ +export function textReach(spec: TextSpec, fontCss: string): { left: number; right: number } | null { + const context = context2d(1, 1); + if (!context) return null; + context.font = `${spec.size}px ${fontCss}`; + let left = Infinity; + let right = -Infinity; + for (const line of textLines(spec.text)) { + if (!line) continue; + const metrics = context.measureText(line); + const start = lineLeft(spec, metrics.width); + left = Math.min(left, start - Math.max(0, metrics.actualBoundingBoxLeft || 0)); + right = Math.max(right, start + Math.max(metrics.width, metrics.actualBoundingBoxRight || 0)); + } + if (!Number.isFinite(left) || !Number.isFinite(right)) return null; + return { left: Math.floor(left) - 1, right: Math.ceil(right) + 1 }; +} + /** - * Coverage per pixel in rows from the top. Null where there is no canvas to - * draw on: the server, a test without one. + * Coverage per pixel in rows from the top, for `width` columns from frame + * column `left`. Null where there is no canvas to draw on: the server, a + * test without one. * * Each line's left edge and the block's top are rounded to whole pixels * before drawing. Letting the canvas centre a line puts a pixel font's stems * on half pixels whenever the line is an odd number of pixels wide, and the - * sharp edges then drop or double a column. + * sharp edges then drop or double a column. `left` is whole too, so a strip + * wider than the frame covers each LED the frame shares with it exactly as + * the frame does. */ export function textCoverage( spec: TextSpec, fontCss: string, width: number, height: number, + left = 0, ): Uint8ClampedArray | null { - if (typeof document === "undefined") return null; - scratch ??= document.createElement("canvas"); - scratch.width = width; - scratch.height = height; - const context = scratch.getContext("2d", { willReadFrequently: true }); + const context = context2d(width, height); if (!context) return null; context.clearRect(0, 0, width, height); @@ -45,14 +84,7 @@ export function textCoverage( const top = Math.round(spec.y - (lines.length * spec.size) / 2); lines.forEach((line, index) => { if (!line) return; - const lineWidth = context.measureText(line).width; - const left = - spec.align === "left" - ? Math.round(spec.x) - : spec.align === "right" - ? Math.round(spec.x - lineWidth) - : Math.round(spec.x - lineWidth / 2); - context.fillText(line, left, top + index * spec.size); + context.fillText(line, lineLeft(spec, context.measureText(line).width) - left, top + index * spec.size); }); const pixels = context.getImageData(0, 0, width, height).data; diff --git a/web/src/lib/lab/types.ts b/web/src/lib/lab/types.ts index be2603f6c..ffcf6093e 100644 --- a/web/src/lib/lab/types.ts +++ b/web/src/lib/lab/types.ts @@ -107,6 +107,16 @@ export const TEXT_ALIGNS: TextAlign[] = ["left", "center", "right"]; export type TextFill = "solid" | "rampX" | "rampY"; export const TEXT_FILLS: TextFill[] = ["solid", "rampX", "rampY"]; +/** + * What moves the letters, frame by frame, at a speed one knob sets + * (lib/lab/textEffects.ts): + * scroll the lines run out to the left and come back in from the right + * twinkle each LED dims and brightens on its own beat + * ramp the colours run along the layer's ramp and back + */ +export type TextEffect = "none" | "scroll" | "twinkle" | "ramp"; +export const TEXT_EFFECTS: TextEffect[] = ["none", "scroll", "twinkle", "ramp"]; + export type TextSpec = { /** What it says. A line break starts a new line. */ text: string; @@ -126,17 +136,33 @@ export type TextSpec = { fill: TextFill; /** Every LED fully on or off, the way pixel art is. Off keeps the font's soft edges as dimmer LEDs. */ sharp: boolean; + effect: TextEffect; + /** The knob that sets the effect's speed, 0..3. Turned all the way down, the effect stands still. */ + effectKnob: number; }; export const TEXT_SIZE_MIN = 4; export const TEXT_SIZE_MAX = 128; export const TEXT_MAX_CHARS = 200; +/** + * What the scroll and ramp effects work from, drawn with the bitmap: every + * row of the frame, but only the columns the letters reach, `x` being the + * first of them in frame pixels. It may start left of the frame and run past + * its right edge, so a line longer than the panel scrolls through whole. + * scroll the letters in colour + * ramp in each pixel, red is where it sits on the ramp (0..255) and + * alpha is how much of it the letters cover + */ +export type TextSource = { x: number; width: number; data: Uint8ClampedArray }; + export type TextLayer = LayerCommon & Raster & { type: "text"; spec: TextSpec; /** Used when `spec.fill` is a ramp; edited in the Color Ramp panel like a code layer's. */ ramp: RampState; + /** Null with no effect, for twinkle (it works on the bitmap) and when no letter lands. */ + source: TextSource | null; }; export type Layer = CodeLayer | PixelLayer | TextLayer; @@ -288,6 +314,10 @@ export function defaultTextSpec(matrix: MatrixSize): TextSpec { color: "#e8552e", fill: "solid", sharp: true, + effect: "none", + // The second knob is the firmware's Speed, and the only one whose + // starting value is not at the bottom of its range. + effectKnob: 1, }; } @@ -323,6 +353,8 @@ export function normalizeTextSpec( color: typeof raw.color === "string" && HEX.test(raw.color) ? raw.color.toLowerCase() : previous.color, fill: TEXT_FILLS.includes(raw.fill as TextSpec["fill"]) ? (raw.fill as TextSpec["fill"]) : previous.fill, sharp: typeof raw.sharp === "boolean" ? raw.sharp : previous.sharp, + effect: TEXT_EFFECTS.includes(raw.effect as TextEffect) ? (raw.effect as TextEffect) : previous.effect, + effectKnob: whole(raw.effectKnob, previous.effectKnob, 0, 3), }; } @@ -343,6 +375,7 @@ export function createTextLayer(matrix: MatrixSize, name: string): TextLayer { rev: 0, spec: defaultTextSpec(matrix), ramp: cloneRampState(TEXT_RAMP_STATE), + source: null, }; }