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:"}{" "}
setTextSpec(layer.id, { fill: "rampX" })}>
across
{" "}
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" && (
Color
- {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."}
+
+
+ Effect
+ set({ effect: event.target.value as TextEffect })}>
+ {TEXT_EFFECTS.map((effect) => (
+
+ {EFFECT_LABEL[effect]}
+
+ ))}
+
+
+ {spec.effect !== "none" && (
+
+ Speed knob
+ set({ effectKnob: Number(event.target.value) })}>
+ {knobLabels.map((label, index) => (
+
+ {label || `Knob ${index + 1}`}
+
+ ))}
+
+
+ )}
+
+ {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.`}
+
+ )}
+
{COPY_LABEL[codeCopy]}
- 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,
};
}