Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
07e931b46This file is pinned to the selected release. Download the original to open it in your design tool.
// ── Lab render engine ────────────────────────────────────────────────────────
// 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.
import {
PatternRuntime,
createIdleInput,
knobTargetToDelta,
type ColorRamp,
} from "@/lib/pattern/harness";
import {
knobUnitsPerTurn,
LOGICAL_KNOB_WRAP,
} from "@/lib/pattern/controls";
import { hexToRgb } from "@/lib/pattern/color";
import type { MatrixSize } from "@/lib/pattern/matrix";
import { applyMaskToCoverage, compositeLayers, type CompositeEntry } from "./compositor";
import type { CodeLayer, Layer, RampState } from "./types";
const RECOMPILE_DEBOUNCE_MS = 180;
export function rampStateToHarness(ramp: RampState): ColorRamp {
return {
stops: ramp.stops.map((stop) => ({
position: stop.position,
color: hexToRgb(stop.color),
alpha: stop.alpha,
})),
mode: ramp.mode,
wrap: ramp.wrap,
};
}
type LayerEntry = {
runtime: PatternRuntime;
code: string;
width: number;
height: number;
pendingCode: string | null;
pendingAt: number;
// Ramp cache: rebuild the harness ramp only when the layer's ramp state
// object identity changes (zustand updates are immutable).
rampState: RampState | null;
ramp: ColorRamp | null;
};
/** The store slice the engine consumes each frame. */
export type EngineProject = {
matrix: MatrixSize;
layers: Layer[];
activeLayerId: string;
knobs: number[];
ranges: Array<[number, number]>;
};
export type EngineFrame = {
data: Uint8ClampedArray;
width: number;
height: number;
renderMs: number;
};
export class LabEngine {
private entries = new Map<string, LayerEntry>();
/** Latest per-layer runtime error (null = healthy). */
readonly errors = new Map<string, string | null>();
private out = new Uint8ClampedArray(0);
private outWidth = 0;
private outHeight = 0;
// Encoder button state, pressed via the Knobs panel; applies to the ACTIVE
// code layer only (the physical device also routes buttons to the running
// pattern).
private btnHeld = [false, false, false, false];
private btnPending = [false, false, false, false];
pressButton(index: number) {
if (this.btnHeld[index]) return;
this.btnHeld[index] = true;
this.btnPending[index] = true;
}
releaseButton(index: number) {
this.btnHeld[index] = false;
}
releaseAllButtons() {
this.btnHeld = [false, false, false, false];
}
/** Drop cached runtimes for layers that no longer exist. */
private prune(layers: Layer[]) {
const ids = new Set(layers.map((layer) => layer.id));
for (const id of this.entries.keys()) {
if (!ids.has(id)) {
this.entries.delete(id);
this.errors.delete(id);
}
}
}
private ensureEntry(layer: CodeLayer, matrix: MatrixSize, now: number): LayerEntry {
let entry = this.entries.get(layer.id);
if (!entry || entry.width !== matrix.width || entry.height !== matrix.height) {
const runtime = new PatternRuntime(matrix.width, matrix.height);
const result = runtime.loadCode(layer.code);
this.errors.set(layer.id, result.ok ? null : result.error ?? "Pattern failed to load.");
entry = {
runtime,
code: layer.code,
width: matrix.width,
height: matrix.height,
pendingCode: null,
pendingAt: 0,
rampState: null,
ramp: null,
};
this.entries.set(layer.id, entry);
return entry;
}
if (layer.code !== entry.code) {
// Debounce recompiles so typing doesn't re-run setup() per keystroke.
if (entry.pendingCode !== layer.code) {
entry.pendingCode = layer.code;
entry.pendingAt = now;
} else if (now - entry.pendingAt >= RECOMPILE_DEBOUNCE_MS) {
const result = entry.runtime.loadCode(layer.code);
this.errors.set(layer.id, result.ok ? null : result.error ?? "Pattern failed to load.");
entry.code = layer.code;
entry.pendingCode = null;
}
}
return entry;
}
// Previous frame's knob values, for delta computation — one shared set for
// the whole stack, exactly like the four physical encoders.
private prevKnobs: number[] | null = null;
/**
* Advance every visible code layer one frame and composite the stack.
* `layers[0]` is the top of the stack (UI order); compositing walks
* back-to-front. Every layer receives the SAME knob input.
*/
render(project: EngineProject, dt: number, time: number): EngineFrame {
const { matrix, layers, activeLayerId, knobs, ranges } = project;
const startedAt = performance.now();
this.prune(layers);
if (this.outWidth !== matrix.width || this.outHeight !== matrix.height) {
this.out = new Uint8ClampedArray(matrix.width * matrix.height * 4);
this.outWidth = matrix.width;
this.outHeight = matrix.height;
}
const btnPressed = [...this.btnPending];
const btnHeld = [...this.btnHeld];
this.btnPending = [false, false, false, false];
const previous = this.prevKnobs ?? knobs;
const knobDeltas = knobs.map((value, knobIndex) =>
knobTargetToDelta(
previous[knobIndex] ?? value,
value,
LOGICAL_KNOB_WRAP[knobIndex],
knobUnitsPerTurn(ranges[knobIndex] ?? [0, 1]),
),
);
const knobNormalized = knobs.map((value, knobIndex) => {
const range = ranges[knobIndex] ?? [0, 1];
const span = Math.max(0.0001, range[1] - range[0]);
return (value - range[0]) / span;
});
this.prevKnobs = [...knobs];
// Pass 1 — advance every visible code layer's runtime, mask layers
// included: a code mask animates like any other pattern.
for (let index = layers.length - 1; index >= 0; index--) {
const layer = layers[index];
if (!layer.visible || layer.type !== "code") continue;
if (layer.role !== "mask" && layer.opacity <= 0) continue;
const entry = this.ensureEntry(layer, matrix, startedAt);
const runtime = entry.runtime;
if (entry.rampState !== layer.ramp) {
entry.rampState = layer.ramp;
entry.ramp = rampStateToHarness(layer.ramp);
runtime.setRamp(entry.ramp);
}
runtime.recolor = layer.recolor;
const isActive = layer.id === activeLayerId;
const result = runtime.renderFrame(
dt,
time,
createIdleInput(knobDeltas, {
knobValues: knobs,
knobNormalized,
knobRanges: ranges,
btnPressed: isActive ? btnPressed : undefined,
btnHeld: isActive ? btnHeld : undefined,
}),
);
this.errors.set(layer.id, result.ok ? null : result.error ?? "Runtime error.");
}
// 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.
const compositeEntries = this.buildEntries(matrix, layers, null);
compositeLayers(this.out, compositeEntries, matrix.width * matrix.height);
return {
data: this.out,
width: matrix.width,
height: matrix.height,
renderMs: performance.now() - startedAt,
};
}
/** A layer's CURRENT buffer at this frame size, or null if unavailable. */
private bufferOf(layer: Layer, matrix: MatrixSize): Uint8ClampedArray | null {
if (layer.type === "pixel") {
return layer.width === matrix.width && layer.height === matrix.height ? layer.data : null;
}
const entry = this.entries.get(layer.id);
return entry && entry.width === matrix.width && entry.height === matrix.height
? entry.runtime.data
: null;
}
/**
* Bottom→top composite entries with mask grouping: a mask layer contributes
* no paint — its buffer ANDs into the coverage of the nearest paint layer
* BELOW it (multiple stacked masks intersect).
*/
private buildEntries(
matrix: MatrixSize,
layers: Layer[],
excludeId: string | null,
): CompositeEntry[] {
const pixelCount = matrix.width * matrix.height;
const entries: CompositeEntry[] = [];
let lastPaint: CompositeEntry | null = null;
for (let index = layers.length - 1; index >= 0; index--) {
const layer = layers[index];
if (layer.id === excludeId || !layer.visible) continue;
const buffer = this.bufferOf(layer, matrix);
if (!buffer) continue;
if (layer.role === "mask") {
if (!lastPaint) continue; // mask at the very bottom gates nothing
if (!lastPaint.mask) lastPaint.mask = new Uint8Array(pixelCount).fill(1);
applyMaskToCoverage(
lastPaint.mask,
buffer,
layer.type === "pixel",
layer.maskInvert,
pixelCount,
);
continue;
}
if (layer.opacity <= 0) {
// Invisible paint still anchors masks above it, so they don't leak
// onto a deeper layer.
lastPaint = { data: buffer, opacity: 0, blend: layer.blend };
continue;
}
const entry: CompositeEntry = { data: buffer, opacity: layer.opacity, blend: layer.blend };
entries.push(entry);
lastPaint = entry;
}
return entries;
}
/**
* Per-pixel coverage (0..255) of the visible paint layers at the CURRENT
* frame — the alpha channel a transparent export needs. The composite
* itself is always opaque (an LED panel is black when off), so this is a
* separate read over the same entries `render` composites: alpha union down
* the stack, layer opacity and masks included. No time advance. Used by the
* capture module (lib/lab/capture); the live preview never calls it.
*/
coverage(matrix: MatrixSize, layers: Layer[], out: Uint8ClampedArray) {
const pixelCount = matrix.width * matrix.height;
out.fill(0, 0, pixelCount);
for (const entry of this.buildEntries(matrix, layers, null)) {
const opacity = Math.max(0, Math.min(1, entry.opacity));
if (opacity <= 0) continue;
const source = entry.data;
const mask = entry.mask;
for (let i = 0; i < pixelCount; i++) {
if (mask && mask[i] === 0) continue;
const alpha = (source[i * 4 + 3] / 255) * opacity;
if (alpha <= 0) continue;
out[i] = 255 - (255 - out[i]) * (1 - alpha);
}
}
}
/**
* Composite the current buffers of every visible layer EXCEPT `excludeId`
* (no time advance) — the pixel editor's alignment backdrop.
*/
compositeBackdrop(matrix: MatrixSize, layers: Layer[], excludeId: string): EngineFrame {
if (this.outWidth !== matrix.width || this.outHeight !== matrix.height) {
this.out = new Uint8ClampedArray(matrix.width * matrix.height * 4);
this.outWidth = matrix.width;
this.outHeight = matrix.height;
}
const backdrop = new Uint8ClampedArray(matrix.width * matrix.height * 4);
compositeLayers(backdrop, this.buildEntries(matrix, layers, excludeId), matrix.width * matrix.height);
return { data: backdrop, width: matrix.width, height: matrix.height, renderMs: 0 };
}
}
/** One shared engine per lab session. */
export const labEngine = new LabEngine();