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
10 changes: 5 additions & 5 deletions web/src/app/pattern-lab/PatternLabClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
// Pattern Lab v2 — a layered, dockable pattern editing workspace.
//
// The old lab was one pattern + one fixed two-column layout. This shell hosts
// eight dockable panels (dockview) over a layer-stack project (zustand):
// Preview, Layers, Code, Pixel, Knobs, Color Ramp, Graphic Export, Director —
// the list is panels/registry.tsx. Panels rearrange Photoshop-style — drag
// tabs, split groups, float — and the arrangement persists to localStorage
// alongside the project itself.
// nine dockable panels (dockview) over a layer-stack project (zustand):
// Preview, Layers, Code, Pixel, Text, Knobs, Color Ramp, Graphic Export,
// Director — the list is panels/registry.tsx. Panels rearrange
// Photoshop-style — drag tabs, split groups, float — and the arrangement
// persists to localStorage alongside the project itself.
//
// Exports (community publish, firmware build) flatten the visible stack into
// one standalone JS pattern (see lib/lab/flatten.ts), so every downstream
Expand Down
4 changes: 2 additions & 2 deletions web/src/app/pattern-lab/panels/CodePanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,8 @@ describe("CodePanel paste", () => {
});
});

// The hint named every other layer pixel art, written before text layers
// came in (#184). A selected text layer was told to use the Pixel panel.
// The hint named every other layer pixel art, written before the Lab had
// text layers. A selected text layer was told to use the Pixel panel.
describe("CodePanel on another layer", () => {
it("names a text layer and sends it to the Text panel", () => {
useLabStore.getState().addTextLayer();
Expand Down
4 changes: 2 additions & 2 deletions web/src/app/pattern-lab/panels/PixelPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ describe("PixelPanel", () => {
expect(useLabStore.getState().layers.filter((entry) => entry.type === "pixel")).toHaveLength(2);
});

// The hint called every other layer a code pattern, written before text
// layers came in (#184).
// The hint called every other layer a code pattern, written before the Lab
// had text layers.
it("names the selected layer's kind and the panel that edits it", () => {
const state = useLabStore.getState();
state.selectLayer(state.layers.find((entry) => entry.type === "code")!.id);
Expand Down
7 changes: 2 additions & 5 deletions web/src/components/3d/LedMatrixTexture.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as THREE from 'three';
import { getKnobValueDelta, LOGICAL_KNOB_RANGES, LOGICAL_KNOB_TO_WEB_KNOB, toEncoderDelta } from '@/lib/pattern/controls';
import { getKnobValueDelta, LOGICAL_KNOB_RANGES, LOGICAL_KNOB_TO_WEB_KNOB, normalizeKnob, toEncoderDelta } from '@/lib/pattern/controls';
import { compilePatternCode, describePatternError, type PatternModule, type PatternParams } from '@/lib/pattern/harness';

type WebKnobValues = Record<'c1' | 'c2' | 'c3' | 'c4', number>;
Expand All @@ -9,10 +9,7 @@ function getLogicalKnobValues(knobValues: WebKnobValues) {
}

function getLogicalKnobNormalized(logicalKnobValues: number[]) {
return logicalKnobValues.map((value, index) => {
const [min, max] = LOGICAL_KNOB_RANGES[index];
return (value - min) / Math.max(0.0001, max - min);
});
return logicalKnobValues.map((value, index) => normalizeKnob(value, LOGICAL_KNOB_RANGES[index]));
}

export class LedMatrixTexture {
Expand Down
9 changes: 3 additions & 6 deletions web/src/components/community/PatternCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { authClient } from "@/lib/community/auth-client";
import AuthModal from "@/components/community/AuthModal";
import { renderPatternThumb } from "@/lib/community/thumbs";
import { knobSetupFromCode } from "@/lib/community/knobs";
import { KNOB_SPAN_FLOOR, normalizeKnob, tidyKnobValue } from "@/lib/pattern/controls";
import { knobWheelValue, normalizeKnob } from "@/lib/pattern/controls";
import {
describeMatrixShape,
formatMatrix,
Expand Down Expand Up @@ -375,15 +375,12 @@ export default function PatternCard({
if (e.ctrlKey) return;
e.preventDefault();

const [min, max] = knobSetup.ranges[activeKnobIdx] ?? [0, 1];
const span = Math.max(KNOB_SPAN_FLOOR, max - min);
const step = span / 25;
const range = knobSetup.ranges[activeKnobIdx] ?? [0, 1];
const direction = e.deltaY < 0 ? 1 : -1;

setKnobValues((prev) => {
const next = [...prev];
const current = next[activeKnobIdx] ?? min;
next[activeKnobIdx] = Math.max(min, Math.min(max, tidyKnobValue(current + direction * step)));
next[activeKnobIdx] = knobWheelValue(next[activeKnobIdx] ?? range[0], range, direction);
return next;
});
};
Expand Down
57 changes: 57 additions & 0 deletions web/src/components/guide/desk/community/parts.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// The practice wall's card turns its knobs the way components/community/
// PatternCard.tsx does. It kept its own copy of that card's old step, floored
// at 0.001 and rounded to three decimals, so a knob declared 0.0001..0.0004
// fell to 0, below its range, on the first notch, and its bar showed the
// middle of that range as 15%.

import { beforeEach, describe, expect, it } from "vitest";
import { fireEvent, render } from "@testing-library/react";
import { knobWheelValue } from "@/lib/pattern/controls";
import type { PracticePattern } from "./data";
import { PatternCard } from "./parts";
import { usePractice } from "./state";

const FINE: PracticePattern = {
id: "fine",
title: "Fine",
code: "// @knobs Fine=0.0001..0.0004, Tight=0.001..0.002, Hue=0..1, Speed=0.1..10\nfunction draw(display) {}",
hasCpp: false,
handle: "engmung",
license: "MIT",
};

const fresh = usePractice.getState();

function card() {
const { container } = render(<PatternCard p={FINE} />);
const el = container.querySelector<HTMLElement>('[data-pc-card="fine"]');
if (!el) throw new Error("no card");
return { container, el };
}

describe("practice wall card", () => {
beforeEach(() => {
usePractice.setState({ ...fresh, knobs: { fine: [0.00025, 0.0015, 0.5, 5.05] }, demo: null });
});

it("turns a fine knob a 25th of its range a notch, as the community's card does", () => {
const { el } = card();
fireEvent.keyDown(el, { key: "ArrowUp" });
const turned = usePractice.getState().knobs.fine[0];
expect(turned).toBe(0.000262);
expect(turned).toBe(knobWheelValue(0.00025, [0.0001, 0.0004], 1));
});

it("stops a fine knob at the bottom of its range, not at 0", () => {
usePractice.setState({ knobs: { fine: [0.0001, 0.0015, 0.5, 5.05] } });
const { el } = card();
fireEvent.keyDown(el, { key: "ArrowDown" });
expect(usePractice.getState().knobs.fine[0]).toBe(0.0001);
});

it("fills the knob's bar to where the value sits in a fine range", () => {
const { container } = card();
const fill = container.querySelector<HTMLElement>(".metaKnobFill");
expect(fill?.style.width).toBe("50%");
});
});
8 changes: 4 additions & 4 deletions web/src/components/guide/desk/community/parts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { knobSetupFromCode } from "@/lib/community/knobs";
import { knobWheelValue, normalizeKnob } from "@/lib/pattern/controls";
import { describeMatrixShape, matrixFromCode } from "@/lib/pattern/matrix";
import cs from "@/components/community/Community.module.css";
import { deskSignal } from "../deskStore";
Expand Down Expand Up @@ -138,8 +139,8 @@ function PlusMinusIcon({ minus }: { minus: boolean }) {
function turnKnob(id: string, ranges: Array<[number, number]>, k: number, dir: 1 | -1) {
const st = usePractice.getState();
const next = st.knobsOf(id).slice();
const [min, max] = ranges[k] ?? [0, 1];
next[k] = Number(Math.max(min, Math.min(max, (next[k] ?? min) + (dir * Math.max(0.001, max - min)) / 25)).toFixed(3));
const range = ranges[k] ?? [0, 1];
next[k] = knobWheelValue(next[k] ?? range[0], range, dir);
st.setKnobs(id, next);
deskSignal(COMMUNITY_SIGNAL.turned);
}
Expand Down Expand Up @@ -191,8 +192,7 @@ export function PatternCard({ p }: { p: PracticePattern }) {
const noteEdge = overlayShowing && pos === "bottom" ? cs.deckNoteTop : cs.deckNoteBottom;
const label = setup.labels[k] ?? `Knob ${k + 1}`;
const value = knobs[k] ?? 0;
const [kMin, kMax] = setup.ranges[k] ?? [0, 1];
const norm = (value - kMin) / Math.max(0.001, kMax - kMin);
const norm = normalizeKnob(value, setup.ranges[k] ?? [0, 1]);

const open = () => usePractice.getState().go({ kind: "pattern", id: p.id });
const stop = (e: MouseEvent) => {
Expand Down
7 changes: 2 additions & 5 deletions web/src/lib/guide/deviceSim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { PatternRuntime, type ColorRamp, type PatternInput } from "@/lib/pattern
import { parseRampAnnotation } from "@/lib/pattern/ramp";
import { hexToRgb } from "@/lib/pattern/color";
import { knobSetupFromCode } from "@/lib/community/knobs";
import { LOGICAL_KNOB_DEFAULTS, knobUnitsPerTurn } from "@/lib/pattern/controls";
import { LOGICAL_KNOB_DEFAULTS, knobUnitsPerTurn, normalizeKnob } from "@/lib/pattern/controls";
import { livePresets } from "@/lib/presets";
import { BASICS_PACK } from "@/lib/pattern/packs";
import { BUS_MAX, BUS_UNITS_PER_CLICK, DETENTS_PER_TURN, EDGES_PER_DETENT, KNOB_COUNT } from "@/lib/shared/constants";
Expand Down Expand Up @@ -910,10 +910,7 @@ export class DeviceSim {
const input: PatternInput = {
knobDeltas: toPattern.detents,
knobValues: values,
knobNormalized: values.map((v, i) => {
const [min, max] = ranges[i] ?? [0, 1];
return (v - min) / Math.max(0.0001, max - min);
}),
knobNormalized: values.map((v, i) => normalizeKnob(v, ranges[i] ?? [0, 1])),
knobRanges: ranges,
btnPressed: toPattern.presses,
btnHeld: this.knobs.map((k, i) => this.mode === "run" && (k.down || this.remoteDown[i])),
Expand Down
25 changes: 25 additions & 0 deletions web/src/lib/pattern/controls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
knobDetentStep,
knobMidpoint,
knobUnitsPerTurn,
knobWheelValue,
logicalKnobUnitsPerTurn,
normalizeKnob,
tidyKnobValue,
Expand Down Expand Up @@ -142,6 +143,30 @@ describe("reading a knob as 0..1", () => {
});
});

describe("a card's wheel", () => {
it("moves a knob a 25th of its range a notch and stops at each end", () => {
expect(knobWheelValue(0.5, [0, 1], 1)).toBe(0.54);
expect(knobWheelValue(5.05, [0.1, 10], -1)).toBe(4.654);
expect(knobWheelValue(0.98, [0, 1], 1)).toBe(1);
expect(knobWheelValue(0.11, [0.1, 10], -1)).toBe(0.1);
});

it("crosses a range in 25 notches", () => {
let value = 0.1;
for (let notch = 0; notch < 25; notch++) value = knobWheelValue(value, [0.1, 10], 1);
expect(value).toBe(10);
});

it("keeps a range as fine as 0.0001..0.0004 inside its range", () => {
expect(knobWheelValue(0.00025, [0.0001, 0.0004], 1)).toBe(0.000262);
expect(knobWheelValue(0.0001, [0.0001, 0.0004], -1)).toBe(0.0001);
});

it("leaves a knob on a range edited down to nothing where the range is", () => {
expect(knobWheelValue(3, [3, 3], 1)).toBe(3);
});
});

describe("front-panel ids", () => {
it("finds each knob's range through the logical order (Hue, Speed, Mode, Freq)", () => {
expect(webKnobRange("c1")).toEqual(LOGICAL_KNOB_RANGES[0]);
Expand Down
15 changes: 14 additions & 1 deletion web/src/lib/pattern/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function knobDetentStep(range: readonly [number, number]) {
// export and the text effects floored at 0.0001; the community's sandbox and
// cards floored at 0.001, so a knob declared 0.0001..0.0004 sitting at 0.00025
// reached a pattern as 0.5 in the Lab and 0.15 on its card.
export const KNOB_SPAN_FLOOR = 0.0001;
const KNOB_SPAN_FLOOR = 0.0001;

/** Where a value sits in a knob's range, 0 at the bottom and 1 at the top. Not held to 0..1. */
export function normalizeKnob(value: number, range: readonly [number, number]) {
Expand Down Expand Up @@ -69,6 +69,19 @@ export function knobMidpoint(range: readonly [number, number]) {
return tidyKnobValue((range[0] + range[1]) / 2);
}

/**
* Where one notch of a card's wheel leaves a knob: a 25th of its range up or
* down, held to the range. The community's cards and the guide's practice
* wall both turn with it. The practice wall kept a copy of the cards' old
* step, floored at 0.001 and rounded to three decimals, so a 0.0001..0.0004
* knob fell to 0, below its range, on the first notch.
*/
export function knobWheelValue(value: number, range: readonly [number, number], direction: 1 | -1) {
const [min, max] = range;
const step = Math.max(KNOB_SPAN_FLOOR, max - min) / 25;
return Math.max(min, Math.min(max, tidyKnobValue(value + direction * step)));
}

/**
* Where one arrow key leaves a knob: a detent up (ArrowRight, ArrowUp) or
* down (ArrowLeft, ArrowDown), held to the range the way PFParams::apply holds
Expand Down
Loading