Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
26 changes: 26 additions & 0 deletions web/src/app/pattern-lab/HardwareModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>>(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
try {
render(<Lab />);
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(<Lab />);
pasteHeaderAndContinue();
Expand Down
4 changes: 4 additions & 0 deletions web/src/app/pattern-lab/HardwareModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}
</p>
) : (
<p className={styles.modalNote}>
Expand Down
7 changes: 5 additions & 2 deletions web/src/app/pattern-lab/panels/RampPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -437,7 +438,9 @@ function RampEditor({ layer }: { layer: RampLayer }) {
)}
{layer.type === "text" && layer.spec.fill === "solid" && (
<p className={local.rampHint}>
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:"}{" "}
<button type="button" onClick={() => setTextSpec(layer.id, { fill: "rampX" })}>
across
</button>{" "}
Expand Down
66 changes: 64 additions & 2 deletions web/src/app/pattern-lab/panels/TextPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(<TextPanel />);
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(<TextPanel />);
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(<TextPanel />);
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(<TextPanel />);
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(<RampPanel />);
expect(screen.getByText(/This text is one colour/)).toBeInTheDocument();
unmount();
store().setTextSpec(text().id, { effect: "ramp" });
render(<RampPanel />);
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<void>>(() => Promise.resolve());
Expand Down
73 changes: 70 additions & 3 deletions web/src/app/pattern-lab/panels/TextPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -55,6 +60,27 @@ const FILL_LABEL: Record<TextFill, string> = {

const ALIGN_LABEL = { left: "Left", center: "Center", right: "Right" } as const;

const EFFECT_LABEL: Record<TextEffect, string> = {
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<Exclude<TextEffect, "none">, 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<CodeCopyState, string> = {
idle: "Copy as code",
copied: "Copied ✓",
Expand Down Expand Up @@ -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<HTMLInputElement | null>(null);
const [remember, setRemember] = useState(false);
Expand Down Expand Up @@ -322,7 +352,7 @@ function TextEditor({ layer }: { layer: TextLayer }) {
))}
</select>
</label>
{spec.fill === "solid" && (
{spec.fill === "solid" && spec.effect !== "ramp" && (
<label className={local.field}>
<span>Color</span>
<input
Expand All @@ -340,11 +370,44 @@ function TextEditor({ layer }: { layer: TextLayer }) {
sharp edges: each LED fully on or off
</label>
<p className={local.note}>
{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."}
</p>

<div className={local.row}>
<label className={local.field}>
<span>Effect</span>
<select value={spec.effect} onChange={(event) => set({ effect: event.target.value as TextEffect })}>
{TEXT_EFFECTS.map((effect) => (
<option key={effect} value={effect}>
{EFFECT_LABEL[effect]}
</option>
))}
</select>
</label>
{spec.effect !== "none" && (
<label className={local.field}>
<span>Speed knob</span>
<select value={spec.effectKnob} onChange={(event) => set({ effectKnob: Number(event.target.value) })}>
{knobLabels.map((label, index) => (
<option key={index} value={index}>
{label || `Knob ${index + 1}`}
</option>
))}
</select>
</label>
)}
</div>
{spec.effect !== "none" && (
<p className={local.note}>
{`${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.`}
</p>
)}

<div className={local.actions}>
<button
type="button"
Expand All @@ -353,7 +416,11 @@ function TextEditor({ layer }: { layer: TextLayer }) {
>
{COPY_LABEL[codeCopy]}
</button>
<span className={local.note}>The letters as pixels: the code needs no font. Change the words and copy again.</span>
<span className={local.note}>
{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."}
</span>
</div>
</div>
</div>
Expand Down
41 changes: 9 additions & 32 deletions web/src/lib/lab/capture/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)));
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading