From bbf0fcec7492054d6988b66648c54660a2ede925 Mon Sep 17 00:00:00 2001 From: Patternflow Bot Date: Sun, 18 Oct 2026 22:57:57 +0900 Subject: [PATCH 01/52] web: Pattern Lab's preview shows add layers the way the device does From Patternflow-lab/Patternflow#108 --- web/src/lib/lab/compositor.test.ts | 177 +++++++++++++++++++++++++++++ web/src/lib/lab/compositor.ts | 75 ++++++------ 2 files changed, 218 insertions(+), 34 deletions(-) create mode 100644 web/src/lib/lab/compositor.test.ts diff --git a/web/src/lib/lab/compositor.test.ts b/web/src/lib/lab/compositor.test.ts new file mode 100644 index 000000000..f91874232 --- /dev/null +++ b/web/src/lib/lab/compositor.test.ts @@ -0,0 +1,177 @@ +// The Lab preview against the flattened export. Until 18 October 2026 the +// preview wrote each layer's result into the byte buffer before blending the +// next one, so anything an add layer pushed past 255 was cut off there. The +// export and the C++ header add up in floats to the top layer and clamp once, +// so two (200, 0, 0) add layers under a (128, 128, 128) multiply showed R 128 +// in the Lab and 201 on the device. + +import { describe, expect, it } from "vitest"; +import { createIdleInput, PatternRuntime } from "@/lib/pattern/harness"; +import { compositeLayers, type CompositeEntry } from "./compositor"; +import { LabEngine, type EngineProject } from "./engine"; +import { flattenLayers } from "./flatten"; +import { BLEND_MODES, type BlendMode, type PixelLayer } from "./types"; + +type RGBA = [number, number, number, number]; + +function solid(pixelCount: number, color: RGBA): Uint8ClampedArray { + const data = new Uint8ClampedArray(pixelCount * 4); + for (let i = 0; i < pixelCount; i++) data.set(color, i * 4); + return data; +} + +function onePixel(entries: CompositeEntry[]): number[] { + const out = new Uint8ClampedArray(4); + compositeLayers(out, entries, 1); + return Array.from(out); +} + +const RED_200: RGBA = [200, 0, 0, 255]; +const GREY_128: RGBA = [128, 128, 128, 255]; + +describe("compositeLayers", () => { + it("carries an add layer's overflow into a multiply above it", () => { + const add = { data: solid(1, RED_200), opacity: 1, blend: "add" as const }; + const multiply = { data: solid(1, GREY_128), opacity: 1, blend: "multiply" as const }; + expect(onePixel([add, add, multiply])).toEqual([201, 0, 0, 255]); + }); + + it("carries it under a half-opacity normal layer too", () => { + const add = { data: solid(1, RED_200), opacity: 1, blend: "add" as const }; + const half = { data: solid(1, GREY_128), opacity: 0.5, blend: "normal" as const }; + expect(onePixel([add, add, half])).toEqual([255, 64, 64, 255]); + }); + + it("clamps the finished pixel to 255", () => { + const add = { data: solid(1, RED_200), opacity: 1, blend: "add" as const }; + expect(onePixel([add, add])).toEqual([255, 0, 0, 255]); + }); + + it("rounds half up once, as the export's setPixel does", () => { + // 1 × 0.5 = 0.5 → 1. A Uint8ClampedArray store rounds half to even (0). + const add = { data: solid(1, [1, 3, 5, 255]), opacity: 0.5, blend: "add" as const }; + expect(onePixel([add])).toEqual([1, 2, 3, 255]); + }); + + it("skips a pixel its mask switches off", () => { + const add = { data: solid(2, RED_200), opacity: 1, blend: "add" as const }; + const masked = { ...add, mask: new Uint8Array([1, 0]) }; + const out = new Uint8ClampedArray(8); + compositeLayers(out, [add, masked], 2); + expect(Array.from(out)).toEqual([255, 0, 0, 255, 200, 0, 0, 255]); + }); +}); + +// The same stack through both paths: the engine the preview draws with, and +// the pattern flattenLayers writes, run by the runtime that plays exported +// patterns. Seeded, so a failure names the same pixels every run. +describe("preview matches the flattened export", () => { + const W = 16; + const H = 8; + const N = W * H; + + function random(seed: number) { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 2 ** 32; + }; + } + + function pixelLayer( + id: string, + next: () => number, + blend: BlendMode, + opacity: number, + role: PixelLayer["role"] = "paint", + ): PixelLayer { + const data = new Uint8ClampedArray(N * 4); + for (let i = 0; i < data.length; i++) { + const roll = next(); + if ((i & 3) !== 3) data[i] = Math.floor(roll * 256); + // Plenty of fully opaque and fully clear pixels, the rest in between. + else data[i] = roll < 0.3 ? 255 : roll < 0.5 ? 0 : Math.floor(next() * 256); + } + return { + id, + type: "pixel", + name: id, + visible: true, + opacity, + blend, + role, + maskInvert: false, + width: W, + height: H, + data, + rev: 0, + }; + } + + // layers[0] is the top of the stack, as in the Layers panel. + function stack(seed: number): PixelLayer[] { + const next = random(seed); + const layers: PixelLayer[] = []; + for (let k = 0; k < 6; k++) { + const blend = BLEND_MODES[Math.floor(next() * BLEND_MODES.length)]; + const opacity = [1, 0.5, 0.25, 0.8][Math.floor(next() * 4)]; + layers.unshift(pixelLayer(`paint-${k}`, next, blend, opacity)); + } + // A mask over the third paint layer from the top. + layers.splice(2, 0, pixelLayer("mask", next, "normal", 1, "mask")); + return layers; + } + + function project(layers: PixelLayer[]): EngineProject { + return { + matrix: { width: W, height: H }, + layers, + activeLayerId: layers[0].id, + knobs: [0, 0, 0, 0], + ranges: [[0, 1], [0, 1], [0, 1], [0, 1]], + }; + } + + function exported(layers: PixelLayer[]): Uint8ClampedArray { + const runtime = new PatternRuntime(W, H); + const loaded = runtime.loadCode(flattenLayers(layers, { width: W, height: H })); + expect(loaded.error).toBeUndefined(); + const result = runtime.renderFrame(1 / 60, 0, createIdleInput([0, 0, 0, 0])); + expect(result.error).toBeUndefined(); + return runtime.data; + } + + function differences(a: Uint8ClampedArray, b: Uint8ClampedArray): string[] { + const found: string[] = []; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) found.push(`byte ${i}: preview ${a[i]}, export ${b[i]}`); + } + return found; + } + + it("the add, add, multiply stack from the bug", () => { + const add = (id: string): PixelLayer => ({ ...pixelLayer(id, () => 0, "add", 1), data: solid(N, RED_200) }); + const layers: PixelLayer[] = [ + { ...pixelLayer("multiply", () => 0, "multiply", 1), data: solid(N, GREY_128) }, + add("add-2"), + add("add-1"), + ]; + const preview = new LabEngine().render(project(layers), 1 / 60, 0).data; + expect(Array.from(preview.subarray(0, 4))).toEqual([201, 0, 0, 255]); + expect(differences(preview, exported(layers))).toEqual([]); + }); + + it.each([1, 2, 3, 4, 5, 6, 7, 8])("random stack %i", (seed) => { + const layers = stack(seed); + const preview = new LabEngine().render(project(layers), 1 / 60, 0).data; + expect(differences(preview, exported(layers))).toEqual([]); + }); + + it.each([1, 2, 3])("the Pixel panel backdrop, random stack %i", (seed) => { + const layers = stack(seed); + const hidden = layers[0].id; + const backdrop = new LabEngine().compositeBackdrop({ width: W, height: H }, layers, hidden).data; + const rest = layers.filter((layer) => layer.id !== hidden); + expect(differences(backdrop, exported(rest))).toEqual([]); + }); +}); diff --git a/web/src/lib/lab/compositor.ts b/web/src/lib/lab/compositor.ts index e422a7c6b..51cfe347c 100644 --- a/web/src/lib/lab/compositor.ts +++ b/web/src/lib/lab/compositor.ts @@ -1,8 +1,8 @@ // ── Layer compositor ───────────────────────────────────────────────────────── // Straight-alpha painter's algorithm over an opaque black base (the physical // LED panel is black when off). Blend modes are limited to what the firmware -// can afford per pixel; the flattened JS export replicates this math 1:1, so -// keep the two in sync (see flatten.ts). +// can afford per pixel; the flattened JS export and the C++ header replicate +// this math 1:1, so keep the three in sync (see flatten.ts and hExport.ts). import type { BlendMode } from "./types"; @@ -43,30 +43,31 @@ export function applyMaskToCoverage( } } -/** Fill the target with opaque black, then blend entries bottom → top. */ +/** + * Blend entries bottom → top over opaque black. Each pixel adds up in floats + * and is rounded and clamped to a byte once, after the top layer — the order + * flatten.ts and hExport.ts use. Two add layers at (200, 0, 0) carry R = 400 + * into a multiply above them, so (128, 128, 128) leaves 201 rather than 128. + */ export function compositeLayers( out: Uint8ClampedArray, entries: CompositeEntry[], pixelCount: number, ) { + const opacities = entries.map((entry) => Math.max(0, Math.min(1, entry.opacity))); + for (let i = 0; i < pixelCount; i++) { const j = i * 4; - out[j] = 0; - out[j + 1] = 0; - out[j + 2] = 0; - out[j + 3] = 255; - } - - for (const entry of entries) { - const source = entry.data; - const opacity = Math.max(0, Math.min(1, entry.opacity)); - if (opacity <= 0) continue; - const blend = entry.blend; - const mask = entry.mask; + let r = 0; + let g = 0; + let b = 0; - for (let i = 0; i < pixelCount; i++) { - if (mask && mask[i] === 0) continue; - const j = i * 4; + for (let e = 0; e < entries.length; e++) { + const opacity = opacities[e]; + if (opacity <= 0) continue; + const entry = entries[e]; + if (entry.mask && entry.mask[i] === 0) continue; + const source = entry.data; const alpha = (source[j + 3] / 255) * opacity; if (alpha <= 0) continue; const sr = source[j]; @@ -74,24 +75,30 @@ export function compositeLayers( const sb = source[j + 2]; const inv = 1 - alpha; - if (blend === "add") { - out[j] = out[j] + sr * alpha; - out[j + 1] = out[j + 1] + sg * alpha; - out[j + 2] = out[j + 2] + sb * alpha; - } else if (blend === "multiply") { - out[j] = out[j] * inv + ((out[j] * sr) / 255) * alpha; - out[j + 1] = out[j + 1] * inv + ((out[j + 1] * sg) / 255) * alpha; - out[j + 2] = out[j + 2] * inv + ((out[j + 2] * sb) / 255) * alpha; - } else if (blend === "screen") { - out[j] = out[j] * inv + (255 - ((255 - out[j]) * (255 - sr)) / 255) * alpha; - out[j + 1] = out[j + 1] * inv + (255 - ((255 - out[j + 1]) * (255 - sg)) / 255) * alpha; - out[j + 2] = out[j + 2] * inv + (255 - ((255 - out[j + 2]) * (255 - sb)) / 255) * alpha; + if (entry.blend === "add") { + r += sr * alpha; + g += sg * alpha; + b += sb * alpha; + } else if (entry.blend === "multiply") { + r = r * inv + ((r * sr) / 255) * alpha; + g = g * inv + ((g * sg) / 255) * alpha; + b = b * inv + ((b * sb) / 255) * alpha; + } else if (entry.blend === "screen") { + r = r * inv + (255 - ((255 - r) * (255 - sr)) / 255) * alpha; + g = g * inv + (255 - ((255 - g) * (255 - sg)) / 255) * alpha; + b = b * inv + (255 - ((255 - b) * (255 - sb)) / 255) * alpha; } else { - out[j] = out[j] * inv + sr * alpha; - out[j + 1] = out[j + 1] * inv + sg * alpha; - out[j + 2] = out[j + 2] * inv + sb * alpha; + r = r * inv + sr * alpha; + g = g * inv + sg * alpha; + b = b * inv + sb * alpha; } - // Alpha stays 255: the base is opaque. } + + // Math.round, not the array's own round-half-to-even: the export's + // setPixel rounds this way, and the header's (int)(c + 0.5) does too. + out[j] = Math.min(255, Math.round(r)); + out[j + 1] = Math.min(255, Math.round(g)); + out[j + 2] = Math.min(255, Math.round(b)); + out[j + 3] = 255; // the base is opaque } } From 6bba03c7266bf1c2794192f14758dbb92e238769 Mon Sep 17 00:00:00 2001 From: Patternflow Bot Date: Mon, 19 Oct 2026 09:00:19 +0900 Subject: [PATCH 02/52] web: the Live Editor says why pasted code does not run From Patternflow-lab/Patternflow#109 --- web/src/components/3d/HeroScene.tsx | 10 +- .../components/3d/LedMatrixTexture.test.ts | 105 ++++++++++++++++++ web/src/components/3d/LedMatrixTexture.ts | 42 +++++-- .../sections/PatternPanel.module.css | 15 +++ .../components/sections/PatternPanel.test.tsx | 43 +++++++ web/src/components/sections/PatternPanel.tsx | 7 ++ web/src/store/useAppStore.ts | 6 + 7 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 web/src/components/3d/LedMatrixTexture.test.ts create mode 100644 web/src/components/sections/PatternPanel.test.tsx diff --git a/web/src/components/3d/HeroScene.tsx b/web/src/components/3d/HeroScene.tsx index 99f6ae700..da7f2fee4 100644 --- a/web/src/components/3d/HeroScene.tsx +++ b/web/src/components/3d/HeroScene.tsx @@ -222,7 +222,12 @@ function ProductPreview({ const pattern = patterns[activePatternId] || patterns['patternFlowOriginal']; const defaults = useMemo(() => pattern.defaults || {}, [pattern]); - const ledMatrix = useMemo(() => new LedMatrixTexture(), []); + // The texture says when the editor's code stops running, not each frame, so + // the panel under the editor re-renders only when the reason changes. + const ledMatrix = useMemo( + () => new LedMatrixTexture((message) => useAppStore.getState().setCustomJsError(message)), + [], + ); useEffect(() => { if (activePatternId === 'custom') { @@ -230,6 +235,9 @@ function ProductPreview({ } }, [customJsCode, activePatternId, ledMatrix]); + // The globe replaces this scene on the Inside tab; the next one starts clean. + useEffect(() => () => useAppStore.getState().setCustomJsError(null), []); + const ledMat = useMemo(() => new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 }, diff --git a/web/src/components/3d/LedMatrixTexture.test.ts b/web/src/components/3d/LedMatrixTexture.test.ts new file mode 100644 index 000000000..971a0b864 --- /dev/null +++ b/web/src/components/3d/LedMatrixTexture.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LedMatrixTexture } from './LedMatrixTexture'; + +// The landing page's Live Editor preview. Code pasted into the editor that +// threw used to go to console.error and nowhere else: sixty lines a second in +// the console, nothing on the page. + +const knobs = { c1: 0, c2: 2, c3: 0.06, c4: 0 }; + +const RED = `export function draw(display) { + display.setPixel(0, 0, 255, 0, 0); +}`; + +const THROWS_IN_DRAW = `export function setup(params) { + params.t = 0; +} + +export function draw(display, params) { + const v = missing * 2; + display.setPixel(0, 0, v, v, v); +}`; + +function frames(texture: LedMatrixTexture, count: number) { + for (let i = 0; i < count; i++) texture.render(1 / 60, i / 60, knobs, knobs); +} + +/** Pixel (0, 0) on the panel: the texture stores rows bottom-up. */ +function topLeft(texture: LedMatrixTexture) { + const data = texture.texture.image.data as Uint8Array; + const idx = 63 * 128 * 4; + return [data[idx], data[idx + 1], data[idx + 2]]; +} + +function setup() { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const onError = vi.fn<(message: string | null) => void>(); + return { texture: new LedMatrixTexture(onError), onError }; +} + +describe('LedMatrixTexture errors', () => { + it('reports a draw() that throws once, not on every frame, with its line', () => { + const { texture, onError } = setup(); + texture.loadCode(THROWS_IN_DRAW); + frames(texture, 60); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith('missing is not defined\nline 6: const v = missing * 2;'); + expect(console.error).toHaveBeenCalledTimes(1); + }); + + it('keeps the last pattern that ran while the code does not parse', () => { + const { texture, onError } = setup(); + texture.loadCode(RED); + frames(texture, 1); + texture.loadCode('export function draw(display) {\n display.setPixel(0, 0,'); + frames(texture, 1); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toMatch(/^Unexpected/); + expect(topLeft(texture)).toEqual([255, 0, 0]); + }); + + it('keeps the last pattern when setup() throws, and quotes the setup line', () => { + const { texture, onError } = setup(); + texture.loadCode(RED); + texture.loadCode(`export function setup(params) { + params.grid = params.cells.map(() => 0); +} + +export function draw(display) { + display.setPixel(0, 0, 0, 0, 255); +}`); + frames(texture, 1); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toMatch(/\nline 2: params\.grid = params\.cells\.map\(\(\) => 0\);$/); + expect(topLeft(texture)).toEqual([255, 0, 0]); + }); + + it('says what is missing when the code has no draw()', () => { + const { texture, onError } = setup(); + texture.loadCode('export function setup(params) {}'); + + expect(onError).toHaveBeenCalledWith('Pattern must export draw(display, params, time).'); + }); + + it('clears the message once the code runs again', () => { + const { texture, onError } = setup(); + texture.loadCode(THROWS_IN_DRAW); + frames(texture, 2); + texture.loadCode(RED); + frames(texture, 2); + + expect(onError.mock.calls.map(([message]) => message === null)).toEqual([false, true]); + expect(topLeft(texture)).toEqual([255, 0, 0]); + }); + + it('says nothing for code that runs', () => { + const { texture, onError } = setup(); + texture.loadCode(RED); + frames(texture, 60); + + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/3d/LedMatrixTexture.ts b/web/src/components/3d/LedMatrixTexture.ts index adf1d9eb7..f8064dde8 100644 --- a/web/src/components/3d/LedMatrixTexture.ts +++ b/web/src/components/3d/LedMatrixTexture.ts @@ -1,6 +1,6 @@ import * as THREE from 'three'; import { getKnobValueDelta, LOGICAL_KNOB_RANGES, LOGICAL_KNOB_TO_WEB_KNOB, toEncoderDelta } from '@/lib/pattern/controls'; -import { compilePatternCode, type PatternModule, type PatternParams } from '@/lib/pattern/harness'; +import { compilePatternCode, describePatternError, type PatternModule, type PatternParams } from '@/lib/pattern/harness'; type WebKnobValues = Record<'c1' | 'c2' | 'c3' | 'c4', number>; @@ -23,6 +23,10 @@ export class LedMatrixTexture { private userModule: PatternModule | null = null; private userParams: PatternParams = {}; + // The code userModule was compiled from, so a throw from update() or draw() + // can quote its line. + private source: string | null = null; + private error: string | null = null; // Create a display wrapper to match ESP32 API structure private displayApi = { @@ -71,25 +75,43 @@ export class LedMatrixTexture { } }; - constructor() { + /** + * `onError` hears why the editor's code is not running, with the line it + * failed on when the stack gives one, and null once new code runs. Only + * when that changes: a draw() that throws throws on every frame. + */ + constructor(private readonly onError?: (message: string | null) => void) { this.data = new Uint8Array(this.width * this.height * 4); this.texture = new THREE.DataTexture(this.data, this.width, this.height, THREE.RGBAFormat); this.texture.needsUpdate = true; } - // Evaluate the custom code and setup the module + private setError(message: string | null) { + if (message === this.error) return; + this.error = message; + this.onError?.(message); + } + + // Evaluate the custom code and setup the module. Code that will not compile + // or set up leaves the last pattern that did running: while someone types, + // most keystrokes leave code that does not parse yet. public loadCode(code: string) { try { - this.userModule = compilePatternCode(code); - this.userParams = {}; - - this.userModule.setup?.(this.userParams); - + const userModule = compilePatternCode(code); + const userParams: PatternParams = {}; + userModule.setup?.(userParams); + + this.userModule = userModule; + this.userParams = userParams; + this.source = code; + this.setError(null); + // Clear the screen this.data.fill(0); this.texture.needsUpdate = true; } catch (e) { console.error("Failed to load pattern code:", e); + this.setError(describePatternError(e, code)); } } @@ -139,7 +161,11 @@ export class LedMatrixTexture { // Update texture this.texture.needsUpdate = true; } catch (e) { + // The first throw stays until the code changes. A pattern that throws + // on some frames only would otherwise flash the message on and off. + if (this.error !== null) return; console.error("Runtime error in pattern code:", e); + this.setError(describePatternError(e, this.source)); } } } diff --git a/web/src/components/sections/PatternPanel.module.css b/web/src/components/sections/PatternPanel.module.css index 3e9eac6c1..fa6ccfd2f 100644 --- a/web/src/components/sections/PatternPanel.module.css +++ b/web/src/components/sections/PatternPanel.module.css @@ -25,6 +25,21 @@ overflow: hidden; } +/* The message, and on its own line the line of code it failed on when the + stack gives one. */ +.editorError { + margin: 0; + padding: 10px 14px; + border-top: 1px solid var(--pf-rule); + background: var(--pf-cream); + color: var(--pf-danger); + font-family: var(--pf-mono); + font-size: var(--pf-fs-mono); + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + /* One line under the editor instead of four numbered steps: what to do here, and the single button that belongs to it. */ .editorFoot { diff --git a/web/src/components/sections/PatternPanel.test.tsx b/web/src/components/sections/PatternPanel.test.tsx new file mode 100644 index 000000000..8f12e0683 --- /dev/null +++ b/web/src/components/sections/PatternPanel.test.tsx @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import type { SectionContent } from '@/lib/content'; +import { useAppStore } from '@/store/useAppStore'; +import PatternPanel from './PatternPanel'; + +// The Live Editor on /pattern. The preview reports why pasted code is not +// running (components/3d/LedMatrixTexture.ts); the panel shows it under the +// editor. + +vi.mock('@monaco-editor/react', () => ({ default: () =>