diff --git a/MAP.md b/MAP.md index 6c4ab7e..b591586 100644 --- a/MAP.md +++ b/MAP.md @@ -39,16 +39,19 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set. framework-neutral: `wasm-iml.ts` (rewired off Solid stores onto an injected `EngineSink`), `engine-host.ts` + `worklet/nisps-processor.ts` (audio), thin WASM wrappers over the core pipelines + curve catalog (the TS `input-pipeline`/`output-pipeline`/`curves` implementations died at P4), `wasm-worker.ts`, - `spine.ts` (the reactive spine BELOW React — `setInput` derives processed→ml→routed eagerly off-render), + `spine.ts` (the reactive spine BELOW React — `setInput` derives processed→ml→routed eagerly off-render; + structural/training state and throttled live-output notifications are separate channels), `engine-api.ts` (`EngineApi` façade incl. live architecture/weight/example metrics and `feedback.*` wrappers over the `nisps_ml_feedback_*` C ABI), - `EngineProvider.tsx`/`useEngine.ts` (React binding via `useSyncExternalStore` version counter). nisps.js is + `EngineProvider.tsx`/`useEngine.ts` (React bindings via `useSyncExternalStore`: state version plus an + opt-in 30 Hz output version for DOM consumers; canvases read live buffers directly). nisps.js is loaded via fetch+indirect-eval (Emscripten MODULARIZE glue has no ES exports), base-aware via `document.baseURI` for the `/next` sub-path. - `manifold/src/primitives/` — the 7 design primitives as typed React (Badge, Button, PillToggle, Slider, Switch, VirtualJoystick, XYPad). Five unused ones were deleted in the 2026-07 sweep (L22). - `manifold/src/console/` — the convertible Console: `ConsoleApp`, `CompositeStage` (single-divider convertible with snap/magnetism/minimap-demotion), `OutputStage`/`SandwichStage`/`ParticleStage`/`Manifold` (canvas, - rect↔circular + feedback markers; ParticleStage has interactive cursor-labelled heatmap sliders, an adjustable - joystick, and double-click whole-screen follow-mouse input), `Dock` (top Mode selector + 5 vertically-centred drawers), `Drawers` + rect↔circular + feedback markers; ParticleStage batches its 400 particles into 32 colour paths per frame, + has interactive cursor-labelled heatmap sliders, an adjustable circular Manifold pad with feedback markers and cursor trail, + and double-click whole-screen follow-mouse input), `Dock` (top Mode selector + 5 vertically-centred drawers), `Drawers` (Learning/Inputs/Outputs/Settings/Help; Learning includes the live model-architecture inspector and Outputs owns the remaining scroll height), `TrainingHealth` (real per-iteration loss curve from `nisps_ml_loss_history` + per-layer weight health from `nisps_ml_get_layer_stats`; rendered only at @@ -73,10 +76,10 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set. - `manifold/src/inputs/` — modular INPUT layer feeding the ML head. The Inputs dock picks ONE exclusive mode (`InputMode` = `internal` | `gamepad` | `midi`; Internal/XY-pad is default). `input-layer.ts` owns a single rAF loop composing the active source's axes → **one dedicated engine input slot per axis, 1:1, no blending** → one - `setInputs`, plus an `onReducedInput` callback the manifold tracks. The WASM net is over-provisioned to a - 32-input head (`MAX_AXES`, `nisps/wasm/bindings.cpp`); unused slots are zero-padded and a zero input is inert, - so idle sources cannot perturb the net. Mean-blending was removed deliberately — it diluted every source and - biased the net toward idle sources' resting values. Active-axis edits follow the persistent I/O policy: + `setInputs`, plus an `onReducedInput` callback the manifold tracks. Normal Manifold models default to a + runtime-shaped 2-input head; the Inputs dock can select 4 inputs, and active sources can grow the head when + they need more slots. Mean-blending was removed deliberately — it diluted every source and biased the net + toward idle sources' resting values. Active-axis edits follow the persistent I/O policy: keep-capacity permutes stable identities in place until more slots are required; exact-I/O reconstructs to the active count. Surviving weights and (under adapt policy) examples are identity-remapped; feedback scratch state resets. Sources: `xy-pad-source` (push-driven), @@ -178,7 +181,7 @@ includes; no `nisps-core`. - C++ identifiers: `PascalCase` types, `snake_case` functions/variables, `kPascalCase` constexpr. JSON keys `snake_case`. TS types `PascalCase`, components `PascalCase.tsx`, modules `kebab-case.ts`. - `Curve` enum lives in `nisps/core/math.hpp` (lowercase: `linear/exp/log/square/sqrt/sigmoid/cubic`, plus the parameterised `centered_power` free function); generated mode headers re-export via `using Curve = ::nisps::Curve;`. Since P4 there is NO TS mirror — the browser samples the WASM catalog (`nisps_curve_apply(+batch)`). - Modes are TSX components composed of primitives; mode parameter contracts are JSON schemas with codegen → C++ **and** TS types (`MF_MODES` derives params/ml-config from the generated schemas since P5; labels/ordering stay a manifold overlay). **No declarative JSON UI.** -- WASM and firmware share the same C++; the browser MLP is runtime-shaped (`MLPCore`, since P2): `nisps_ml_create` honours `(input, output, hidden[3])` with non-positive/null args defaulting to `32→[10,14,18]→126`; `nisps_ml_reshape` warm-starts a new shape. Firmware keeps compile-time `MLP<...>` (zero heap). Per-mode dims are schema-real on both targets since P5.3 (the browser reshapes on mode switch). +- WASM and firmware share the same C++; the browser MLP is runtime-shaped (`MLPCore`, since P2): `nisps_ml_create` honours `(input, output, hidden[3])` with non-positive/null args defaulting to `32→[10,14,18]→126`; `nisps_ml_reshape` warm-starts a new shape. Firmware keeps compile-time `MLP<...>` (zero heap). Manifold normal modes use a 2-input UI default and can opt into 4 inputs; audio-analysis keeps its schema-defined feature arity. Firmware mode shapes remain schema-real. - Cross-platform parity: `scripts/parity-check.sh` enforces native vs WASM agreement within 1e-5. ## Gotchas diff --git a/docs/specs/engine-architecture.md b/docs/specs/engine-architecture.md index 4003ca3..54b7f7a 100644 --- a/docs/specs/engine-architecture.md +++ b/docs/specs/engine-architecture.md @@ -70,12 +70,13 @@ single `backend.send` at the action's tail, off-render. Contract rules: call in the derivation path. - **No per-frame allocation.** Buffers are reused (`routedBuf` threaded through the output pipeline and handed to the backend). -- **Weights mutate only through engine actions** (train / feedback / reshape), each of which bumps - the spine's version counter — there is no write path that can update audio without notifying - the UI. -- **React subscribes via `useSyncExternalStore(subscribe, version)`** — the version counter, not - the arrays; canvases read the live `Float32Array`s imperatively in rAF and never re-render per - frame. +- **Weights mutate only through engine actions** (train / feedback / reshape). Structural/training + state bumps `version`; every inference advances a separate `outputVersion`. Both expose explicit + subscriptions, so audio propagation never depends on React scheduling. +- **React state and live output are separate channels.** Structural/training consumers use + `useSyncExternalStore(subscribe, version)`. DOM output consumers opt into the throttled 30 Hz + `subscribeOutputs/outputVersion` channel. Canvases read and shape the live `Float32Array`s + imperatively in rAF and never re-render per frame. ### 2.2 The live-feedback guarantee and its e2e assertion diff --git a/manifold/ONBOARDING.md b/manifold/ONBOARDING.md index d328386..15bc1ad 100644 --- a/manifold/ONBOARDING.md +++ b/manifold/ONBOARDING.md @@ -66,13 +66,13 @@ bun run test:e2e # Playwright smoke (needs `bun run build` first; runs again │ ConsoleApp = spine of the UI: holds state, picks a Stage, │ │ renders the Dock. "Convertible" = swappable Stages. │ └───────────────┬─────────────────────────────────────────────────-┘ - │ reads engine.version (useSyncExternalStore), reads buffers imperatively + │ reads state/output versions (useSyncExternalStore), canvases read buffers imperatively │ writes via engine.setInput / setParam / feedback.* ┌───────────────▼─────────────────────────────────────────────────┐ │ Engine layer (NO React) src/engine/ src/inputs/ src/feedback/ src/backends/ │ Spine = reactive store below React. Per-frame ML inference is │ - │ eager + synchronous, OFF the render cycle. React only watches │ - │ a monotonic version counter. │ + │ eager + synchronous, OFF the render cycle. React watches state │ + │ changes plus an opt-in 30 Hz output channel for DOM surfaces. │ └───────────────┬─────────────────────────────────────────────────┘ │ C ABI ┌───────────────▼─────────────────────────────────────────────────┐ @@ -109,7 +109,7 @@ Manifold ships a single "composite" altitude. Selection is now a plain three-way |---|---|---|---| | CompositeStage | `CompositeStage.tsx` | **default / hero** | Draggable split-ratio; magnet-snaps to 0.14/0.33/0.5/0.66/0.86; collapses a side to a corner minimap at extremes. | | SandwichStage | `SandwichStage.tsx` | `sandwich===true` (wins over the others) | Three-pane layout: `Manifold` input surface left, 3D parameter-landscape centre (input → MLP heatmap grid → outputs, drag to orbit), compact `OutputStage` right. | -| ParticleStage | `ParticleStage.tsx` | `outputMode==='particles'` | Flow-field visualiser (`flow-field.ts`, 400-particle Canvas2D port) + interactive output heatmap sliders with cursor tooltips + a larger, explicitly adjustable/repositionable joystick; double-click anywhere in the stage enters whole-screen follow-mouse mode. | +| ParticleStage | `ParticleStage.tsx` | `outputMode==='particles'` | Flow-field visualiser (`flow-field.ts`, 400 particles batched into 32 contiguous colour paths per frame) + interactive output heatmap sliders with cursor tooltips + a larger, explicitly adjustable/repositionable circular Manifold pad (feedback markers + cursor trail); double-click anywhere in the stage enters whole-screen follow-mouse mode. | `Manifold.tsx` and `OutputStage.tsx` are no longer top-level stages — they are panes composed by CompositeStage/SandwichStage. `Manifold.tsx` is the full-bleed 2D input surface (canvas trail + pins @@ -208,12 +208,15 @@ a setting → `--r-*` tokens. - `engine-api.ts` — **`EngineApi`, the framework-neutral facade** everything in the UI talks to: `setInput/setInputs`, `getOutputs/routedOutput`, training (`addExample/train/trainAsync/evalLoss`), weights (`getWeights/setWeights/process/randomise`), telemetry - (`architecture/weightCount/exampleCount/lossHistory/getLayerStats`), `subscribe/version/on`, plus nested `.feedback` and `.audio` + (`architecture/weightCount/exampleCount/lossHistory/getLayerStats`), structural `subscribe/version`, + throttled `subscribeOutputs/outputVersion`, and `on`, plus nested `.feedback` and `.audio` facades. **`lossHistory()` reads SPINE STATE, not the MLP handle** — an async train runs on the worker's mirror net, so the main handle's own history is empty for those runs; both paths publish to the spine. - `EngineProvider.tsx` / `useEngine.ts` — the **only** React coupling. `useEngine()` returns the API - (null until WASM ready); `useEngineVersion()` = `useSyncExternalStore(subscribe, version)`. + (null until WASM ready); `useEngineVersion()` watches structural/training state, while + `useEngineOutputVersion()` opts DOM consumers into the throttled 30 Hz output channel. Particle canvases + use neither output subscription nor React state for live values; they shape the reused buffer in rAF. - `engine-host.ts` — main-thread audio wiring: AudioContext (user-gesture gated), fetch `nisps.wasm`, register + feed the worklet. - `wasm-iml.ts` (**~750 lines**) — the ML interface to `nisps.wasm`: one MLP handle, dataset, heap @@ -243,16 +246,16 @@ a setting → `--r-*` tokens. ### Inputs — `src/inputs/` - `input-layer.ts` — composition hub. One rAF loop polls sources, pulls all axes into a vector, - forwards N→engine. **`MAX_AXES = 32`** (WASM net over-provisioned to 32 inputs). **Dedicated - dimensions, NO mean-blending** — each active axis drives its own engine slot 1:1; unused slots - zero-padded (inert). Changing the layout uses the same persisted identity-aware I/O policy as output - cards. + forwards N→engine. **`MAX_AXES = 32`** is the source-vector safety cap, not the model shape. + **Dedicated dimensions, NO mean-blending** — each active axis drives its own engine slot 1:1; + the ConsoleApp reshapes the runtime model when the selected 2/4-input capacity or active layout + requires it. Changing the layout uses the same persisted identity-aware I/O policy as output cards. - `base-source.ts` + sources: `xy-pad-source.ts` (push, 2 axes), `gamepad-source.ts` (single=2 / double=4 axes, deadzone 0.08), `midi-input-source.ts` (Web MIDI, batch CC-learn, multi-port). - `useInputLayer.ts` — React binding; manages exclusive input mode + gamepad stick mode + MIDI device/learn map; exposes `pushPad`, `sources`, `channelLayout`, etc. -- **I/O migration (P2.3, live):** the net is **runtime-shaped**. It boots at the default - over-provisioned 32-input head (zero-padding preserved), and `EngineApi.reshape({ inputSize, … })` +- **I/O migration (P2.3, live):** the net is **runtime-shaped**. Normal Manifold modes boot at + 2 inputs; the Inputs dock can select 4, and active layouts can grow the model further. `EngineApi.reshape({ inputSize, … })` → `WasmIML.reshape`. `engine/io-reshape.ts` owns the identity map: **Keep capacity** (default) permutes weights/examples in place and reconstructs only when active I/O outgrows the net; **Exact I/O** reconstructs whenever active arity changes. Existing examples either adapt by @@ -262,14 +265,15 @@ a setting → `--r-*` tokens. (`wasm-worker.ts`) carries the current dims in its train message and re-creates its mirror net to match. The raw debug `window.__nisps.reshape(nIn)` remains a low-level reconstruct-and-clear call. - **Per-mode net dims (P5.3):** switching INSTRUMENT mode reshapes the net to that mode's schema - `ml` config (`MFMode.ml` — input/hidden/output + legacy spread) via a `ConsoleApp` effect keyed on - `[engine, modeId]`. No confirm modal (switching instrument is deliberate). The effect depends on `engine`, so on boot - it fires once WASM is ready and lands the boot mode's dims (**paf_synth → 4→[10,10,14]→33**, weights - 809 — NOT the 32→126 default). The reshape-offer effect reads the engine's CURRENT `inputSize` + hidden/output config plus Manifold's effective input arity via a `ConsoleApp` effect keyed on + `[engine, modeId, modelInputSize]`. No confirm modal (switching instrument is deliberate). Normal + modes boot at **2 inputs** (paf_synth → 2→[10,10,14]→33, weights 787); the Inputs dock's **4 inputs** + option is retained across mode switches. Audio-analysis keeps its schema-defined input feature count. + The reshape-offer effect reads the engine's CURRENT `inputSize` live, so a mode switch that changes arity doesn't spuriously prompt (its baseline tracks axis - COUNT, unchanged by a pure dim change). Non-schema modes restore `DEFAULT_MODE_ML` (32→126). + COUNT, unchanged by a pure dim change). Manifold-only modes use the same 2/4 input selector. Debug seam for tests: under `?debug=1` ConsoleApp installs `window.__mf` - (`setMode`/`getModeId`/`paramCount`/`modeIds`) — the UI-level analogue of `__nisps`, since no + (`setMode`/`getModeId`/`paramCount`/`modeIds`/`getModelInputSize`/`setModelInputSize`) — the UI-level analogue of `__nisps`, since no in-UI instrument picker exists yet (`ctx.modes`/`setModeId` are plumbed but unrendered). Manifold passes `spread=0` for boot, mode-switch reshapes, direct re-rolls, explore-and-place scratchpad rolls, and VCV-forwarded randomise gestures by default. Settings → Experimental diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx index 61cce01..6b58c15 100644 --- a/manifold/src/console/ConsoleApp.tsx +++ b/manifold/src/console/ConsoleApp.tsx @@ -6,8 +6,9 @@ * - The pseudo-inference (`MF_infer`, sin/cos) is GONE. The `values` every * consumer reads now come from `engine.getOutputs()`, mapped onto the mode's * params by `shapeValues` (status/min/max/curve applied here). Pad/joystick - * motion drives `engine.setInput(x,y)`; we subscribe to engine changes via - * `useEngineVersion` and re-derive `values` imperatively on render. + * motion drives `engine.setInput(x,y)`; structural state and throttled DOM + * outputs have separate subscriptions, while ParticleStage shapes the live + * reused buffer directly in rAF. * - Verdicts wire to the engine: commit → feedback.thumbsUp(); perturb → * feedback.thumbsDown(); reroll → randomise(); each followed by process(). * - The default feedback mode is "Explore and place" → the shared C++ core's @@ -31,6 +32,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { CSSProperties } from 'react'; import { useEngine, + useEngineOutputVersion, useEngineVersion, ExplorationController, DEFAULT_GEOMETRIC_FEEDBACK_CONFIG, @@ -51,6 +53,7 @@ import type { DrawerKey, FeedbackMarker, FeedbackModeUI, + ManifoldInputSize, OutputMode, Pin, SoloMode, @@ -82,6 +85,8 @@ export interface MfDebugHook { setOutputMode: (mode: OutputMode) => void; setMidiCcCount: (count: number) => void; displayOutputCount: () => number; + getModelInputSize: () => ManifoldInputSize; + setModelInputSize: (size: ManifoldInputSize) => void; } declare global { @@ -106,11 +111,12 @@ function pillBtn(color: string): CSSProperties { export function ConsoleApp() { const engine = useEngine(); - const version = useEngineVersion(engine); + const stateVersion = useEngineVersion(engine); const { settings } = useSettings(); const [modeId, setModeId] = useState('paf_synth'); const mode = MF_MODES.find((m) => m.id === modeId) ?? MF_MODES[0]; + const [modelInputSize, setModelInputSizeState] = useState(2); const [params, setParams] = useState(() => mode.params.map((p) => ({ ...p }))); const [paramsModeId, setParamsModeId] = useState(mode.id); const [pos, setPos] = useState<[number, number]>([0.5, 0.5]); @@ -156,6 +162,16 @@ export function ConsoleApp() { // Active output MODE (TOP dock selector) — default Particle System. The dock // backend + audio backend derive from this. const [outputMode, setOutputModeState] = useState(DEFAULT_OUTPUT_MODE); + // Canvas stages read the live buffer imperatively. DOM-heavy stages opt into + // the separate 30 Hz output channel instead of making the whole console a + // per-inference subscriber. + const outputVersion = useEngineOutputVersion( + engine, + outputMode !== 'particles' || + sandwich || + params.some((param) => param.manualOverride), + ); + const version = stateVersion + outputVersion; const outputBackend: BackendId = outputModeDescriptor(outputMode).backend; // Per-backend transport settings (backends-spec §2.3/§2.4). Persisted via the // named-preset system; these are the live working values. @@ -283,19 +299,17 @@ export function ConsoleApp() { if (engine) engine.audio.setBackend(modeEngineId(modeId) as Parameters[0]); }, [engine, modeId]); - // Per-mode net dims (one-core-engine P5.3). On mode switch — and once WASM is - // ready on boot (this effect depends on `engine`, so it fires when the engine - // transitions null→ready with the boot mode) — reshape the runtime-shaped MLP - // to the active mode's schema `ml` config (warm-started; the C-side dataset + - // feedback reset, which the transient reset below also clears). NO confirm - // modal: switching instrument is already a deliberate act. The P2.3 axis-count - // modal stays for input-LAYOUT changes only (see the reshape-offer effect). + // Per-mode net dims (one-core-engine P5.3). Normal Manifold modes expose a + // deliberately smaller 2-input working shape for now, while the Inputs dock + // can opt into the schema's usual 4-input shape. Audio-analysis is different: + // its schema input arity describes the feature vector and must stay intact. useEffect(() => { if (!engine) return; - const { inputSize, outputSize, hidden } = mode.ml; + const inputSize = mode.input === 'audio_in' ? mode.ml.inputSize : modelInputSize; + const { outputSize, hidden } = mode.ml; engine.reshape({ inputSize, outputSize, hidden }, randomisationSpread); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [engine, modeId]); + }, [engine, modeId, modelInputSize]); // reset transient state on mode switch useEffect(() => { @@ -344,8 +358,13 @@ export function ConsoleApp() { return; } const activeMap: DimensionMap = keys.map((key) => previous.get(key) ?? null); + // Keep the explicit dock choice as a minimum capacity. Active sources may + // still require more (for example, a double-stick gamepad), but a source + // with fewer axes must not silently shrink a deliberate 4-D model. + const minimumInputSize = mode.input === 'audio_in' ? mode.ml.inputSize : modelInputSize; + const requiredSize = Math.max(minimumInputSize, keys.length); const target = - resizeTarget(keys.length, inSize, settings.networkResizePolicy) ?? inSize; + resizeTarget(requiredSize, inSize, settings.networkResizePolicy) ?? inSize; const inputMap = completeDimensionMap(activeMap, target, inSize); const needsMigration = target !== inSize || inputMap.some((oldIndex, newIndex) => oldIndex !== newIndex); @@ -369,6 +388,9 @@ export function ConsoleApp() { engine, inputLayoutKey, inputs.engineInputSize, + mode.input, + mode.ml.inputSize, + modelInputSize, randomisationSpread, settings.networkResizePolicy, settings.exampleResizePolicy, @@ -393,11 +415,13 @@ export function ConsoleApp() { setOutputMode, setMidiCcCount, displayOutputCount: () => displayOutputCount, + getModelInputSize: () => modelInputSize, + setModelInputSize: (size) => setModelInputSizeState(size === 4 ? 4 : 2), }; return () => { if (window.__mf) delete window.__mf; }; - }, [modeId, params, displayOutputCount]); + }, [modeId, params, displayOutputCount, modelInputSize]); // Drive a pad/joystick/manifold move through the input layer's XY-pad source, // then mirror the raw position into React state for readouts. The layer's loop @@ -644,7 +668,10 @@ export function ConsoleApp() { } setAddingExample(false); // Snapshot the current input → current (shaped) output as a training example. - engine?.addExample([pos[0], pos[1]], Array.from(values)); + engine?.addExample( + [pos[0], pos[1]], + shapeValues(params, engine?.getOutputs() ?? null), + ); setExamples((e) => e + 1); train(); }; @@ -979,6 +1006,8 @@ export function ConsoleApp() { cvDisconnect, setParams: (next: MFParam[]) => setParams(next), inputs, + modelInputSize, + setModelInputSize: (size: ManifoldInputSize) => setModelInputSizeState(size), spread, setSpread, xavierSpreadEnabled: settings.xavierSpreadEnabled, @@ -1096,6 +1125,7 @@ export function ConsoleApp() { params={displayedParams} values={displayedValues} onChange={setParam} + markers={markers} /> ) : ( ) => void; + markers?: FeedbackMarker[]; } -export function ParticleStage({ pos, onMove, params, values, onChange }: ParticleStageProps) { +export function ParticleStage({ pos, onMove, params, values, onChange, markers = [] }: ParticleStageProps) { const engine = useEngine(); const canvasRef = useRef(null); const vizRef = useRef(null); const barsRef = useRef<(HTMLDivElement | null)[]>([]); const tooltipRef = useRef(null); const outputsRef = useRef(null); - const valuesRef = useRef(values); + const displayOutputsRef = useRef>(values); + const shapedOutputsRef = useRef(new Float32Array(N_VISUAL_OUTPUTS)); + const paramsRef = useRef(params); const hoverRef = useRef(null); const [hover, setHover] = useState(null); const [tooltipPos, setTooltipPos] = useState({ x: 12, y: 28 }); @@ -73,7 +77,7 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl startX: 0, el: null, }); - valuesRef.current = values; + paramsRef.current = params; onMoveRef.current = onMove; const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); @@ -125,7 +129,7 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl x: Math.min(e.clientX + 12, Math.max(12, window.innerWidth - 190)), y: Math.min(e.clientY + 12, Math.max(28, window.innerHeight - 32)), }); - const output = valuesRef.current[i] ?? outputsRef.current?.[i] ?? 0; + const output = displayOutputsRef.current[i] ?? outputsRef.current?.[i] ?? 0; if (tooltipRef.current) tooltipRef.current.textContent = `${VISUAL_PARAM_NAMES[i]}: ${output.toFixed(3)}`; }; @@ -194,7 +198,11 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl const outputs = engine?.getOutputs(); if (outputs) { outputsRef.current = outputs; - const displayOutputs = valuesRef.current.length >= N_VISUAL_OUTPUTS ? valuesRef.current : outputs; + const displayOutputs = + paramsRef.current.length >= N_VISUAL_OUTPUTS + ? shapeValuesInto(paramsRef.current, outputs, shapedOutputsRef.current) + : outputs; + displayOutputsRef.current = displayOutputs; viz.setParams(displayOutputs); // Drive the heatmap bar widths imperatively (cheap; no React churn). for (let i = 0; i < N_VISUAL_OUTPUTS; i++) { @@ -394,7 +402,10 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl )} - {/* Adjustable circular pad — explicit edit handles keep repositioning/resizing deliberate. */} + {/* Adjustable circular pad — reuse Manifold's visual surface so Particle + mode shows the same feedback marks and cursor trail as every other + input presentation. The outer stage owns follow-mouse, therefore the + embedded Manifold's double-click mode is disabled. */}
- +
+ +