feat(manifold): improve output controls and particle input

This commit is contained in:
monkey-w1n5t0n 2026-07-25 15:45:40 +02:00
parent b5891a6ab9
commit 7e308a4e62
7 changed files with 266 additions and 46 deletions

5
MAP.md
View file

@ -47,7 +47,8 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set.
- `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), `Dock` (top Mode selector + 5 vertically-centred drawers), `Drawers`
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`
(Learning/Inputs/Outputs/Settings/Help; Learning includes the live model-architecture inspector and
expanded 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
@ -60,7 +61,7 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set.
Switching mode reshapes the WASM net to the mode's `ml` dims (ConsoleApp P5.3; boot mode paf_synth →
4→[10,10,14]→33).
- `manifold/src/dock/``OutputControlRow` (editable names, corner delete, cycling off/fixed/live status, mute + solo/arm + min/max/curve, plus MIDI card fields), `output-state.ts`,
`OutputsBackendConfig.tsx` (per-backend specialised Outputs panel — the sole per-backend editor; the centered add-card control lives below the rows).
`OutputsBackendConfig.tsx` (per-backend specialised Outputs panel — the sole per-backend editor; centered prepend/append add-card controls live above and below the rows).
- `manifold/src/backends/``OutputBackend` adapter + `BackendManager` (spine consumer); `midi-backend.ts`
(WebMIDI), `osc-backend.ts`+`osc-client.ts` (OSC-over-WS), `vcv-backend.ts` (VCV-over-WS), `cv-backend.ts`
(`UseqCvBackend` — uSEQ CV/gate over USB Web Serial, backend id `cvgate`) + `useq-protocol.ts` (v2 wire

View file

@ -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 + corner joystick. |
| 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. |
`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
@ -157,8 +157,9 @@ for the narrow pane.
surface there rather than inventing one. The panel renders "no training run yet" when the
core has no history; it never synthesises a curve.
- **inputs** — enable/configure input sources (XY pad / MIDI / gamepad).
- **route** (label "Outputs") — per-output control matrix + per-backend config; at expanded depth
the output rows fill the remaining drawer height and scroll independently.
- **route** (label "Outputs") — per-output control matrix + per-backend config; centered prepend/append `+`
controls are available in both condensed and expanded views, and at expanded depth the output rows fill the
remaining drawer height and scroll independently. The orange drawer tab uses `<` to expand and `>` to condense.
- **settings** — icon style (monochrome/colour), input-map shape (xy/joystick/rect/circular), corner radius.
- **help** — keymap pills + loop explanation.
- `src/dock/` holds the output-routing internals used by the `route` drawer:
@ -168,7 +169,8 @@ for the narrow pane.
- `OutputControlRow.tsx` — one output card: editable name · M(mute) · S(solo/arm) · cycling
off/fixed/live status · dual-range · live value; the curve pad is a right-hand column and MIDI mode
adds CC#/channel fields. The delete `×` sits on the upper-right corner. **Writes eagerly to the shared
`MFParam` store via `onChange`.** The centered `+` control follows the last visible card.
`MFParam` store via `onChange`.** The Outputs drawer has centered `+` controls that prepend before the first
visible card or append after the last.
- `OutputsBackendConfig.tsx` — preset bar (save/restore/rename/delete) + transport/device config (MIDI
port/templates, OSC path/range, VCV polarity); MIDI per-output fields live on `OutputControlRow`.

View file

@ -737,13 +737,11 @@ export function ConsoleApp() {
setOutputCounts((counts) => ({ ...counts, [outputMode]: activeCards.length }));
};
const addOutput = () => {
const addOutput = (placement: 'prepend' | 'append' = 'append') => {
const active = params.slice(0, displayOutputCount);
const next = params[displayOutputCount] ?? createOutputParam(params.length);
const spares = params
.slice(displayOutputCount + (params[displayOutputCount] ? 1 : 0))
.filter((param) => param.id !== next.id);
applyOutputCards([...active, next], spares);
const spares = params.filter((param) => param.id !== next.id && !active.some((p) => p.id === param.id));
applyOutputCards(placement === 'prepend' ? [next, ...active] : [...active, next], spares);
};
const deleteOutput = (index: number) => {

View file

@ -31,7 +31,6 @@ import {
EditorIcon,
SandwichIcon,
CloseIcon,
ExpandIcon,
GLYPH_FALLBACK,
} from './icons';
import type { IconProps } from './icons';
@ -309,14 +308,14 @@ export function Dock({ ctx, active, setActive, depth, setDepth, sandwich, setSan
top: '50%',
transform: 'translateY(-50%)',
width: 22,
height: 56,
height: 40,
borderRadius: 'var(--r-2) 0 0 var(--r-2)',
border: '1px solid var(--glass-line)',
borderRight: 'none',
background: 'var(--glass)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
color: 'var(--fg-mute)',
color: 'var(--accent)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
@ -324,7 +323,9 @@ export function Dock({ ctx, active, setActive, depth, setDepth, sandwich, setSan
zIndex: 1,
}}
>
<ExpandIcon size={12} />
<span aria-hidden="true" style={{ fontSize: 22, lineHeight: 1, fontFamily: 'var(--font-mono)' }}>
{expanded ? '>' : '<'}
</span>
</button>
{/* Inner scroll wrapper — holds header + content, scrolls independently. */}
<div

View file

@ -747,6 +747,30 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
const expanded = depth === 'expanded';
const rows = expanded ? activeParams : activeParams.slice(0, 6);
const addButton = (placement: 'prepend' | 'append') => (
<button
type="button"
aria-label={placement === 'append' ? '+ output' : '+ output (prepend)'}
title={placement === 'append' ? 'Add parameter after the last card' : 'Add parameter before the first card'}
onClick={() => ctx.addOutput(placement)}
style={{
alignSelf: 'center',
width: 30,
height: 26,
margin: '4px 0 2px',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
background: 'var(--bg-2)',
color: 'var(--accent)',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
fontSize: 18,
lineHeight: 1,
}}
>
+
</button>
);
return (
<>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
@ -774,6 +798,7 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
overflow: 'auto',
}}
>
{addButton('prepend')}
{rows.map((p) => {
const i = ctx.params.indexOf(p);
const labelled = { ...p, name: nameFor(p) };
@ -789,28 +814,7 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
/>
);
})}
<button
type="button"
aria-label="+ output"
title="Add parameter"
onClick={ctx.addOutput}
style={{
alignSelf: 'center',
width: 30,
height: 26,
margin: '4px 0 2px',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
background: 'var(--bg-2)',
color: 'var(--accent)',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
fontSize: 18,
lineHeight: 1,
}}
>
+
</button>
{addButton('append')}
</div>
{!expanded && activeParams.length > 6 && (
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>+{activeParams.length - 6} more expand to edit</span>

View file

@ -9,8 +9,9 @@
* value the same `.heatmap-strip` the a-immersive app shows above the
* flow field (NOT the Boldness/Memory/Precision macro axes, which the
* deployed a-immersive never used);
* a small circular pad in the bottom-left corner that drives the 2D input
* (engine.setInput) the "joystick" of the immersive app.
* a large circular pad that drives the 2D input (engine.setInput) the
* "joystick" of the immersive app; its explicit edit handles reposition
* and resize it without making normal input gestures destructive.
*
* The canvas animates on its own rAF clock so particles keep flowing between
* inferences; only the *field* parameters change when the MLP outputs do. The
@ -18,7 +19,7 @@
* churn React state every frame.
*/
import { useEffect, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from 'react';
import { useEngine } from '../engine';
import { VirtualJoystick } from '../primitives/VirtualJoystick';
import type { MFParam } from './model';
@ -49,6 +50,22 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl
const [hover, setHover] = useState<number | null>(null);
const [tooltipPos, setTooltipPos] = useState({ x: 12, y: 28 });
const [clampMarker, setClampMarker] = useState<{ i: number; value: number } | null>(null);
const [followMouse, setFollowMouse] = useState(false);
const onMoveRef = useRef(onMove);
const [padSize, setPadSize] = useState(200);
const [padPosition, setPadPosition] = useState(() => ({
left: 24,
top: typeof window === 'undefined' ? 24 : Math.max(24, window.innerHeight - 224),
}));
const [padEdit, setPadEdit] = useState(false);
const padGesture = useRef<{
kind: 'move' | 'resize';
startX: number;
startY: number;
left: number;
top: number;
size: number;
} | null>(null);
const clampTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const drag = useRef<{ i: number; moved: boolean; startX: number; el: HTMLDivElement | null }>({
i: -1,
@ -57,6 +74,49 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl
el: null,
});
valuesRef.current = values;
onMoveRef.current = onMove;
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
const beginPadGesture = (e: ReactPointerEvent<HTMLElement>, kind: 'move' | 'resize') => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture?.(e.pointerId);
padGesture.current = {
kind,
startX: e.clientX,
startY: e.clientY,
left: padPosition.left,
top: padPosition.top,
size: padSize,
};
};
const movePadGesture = (e: ReactPointerEvent<HTMLElement>) => {
const gesture = padGesture.current;
if (!gesture) return;
e.preventDefault();
e.stopPropagation();
if (gesture.kind === 'move') {
setPadPosition({
left: clamp(gesture.left + e.clientX - gesture.startX, 8, Math.max(8, window.innerWidth - padSize - 56)),
top: clamp(gesture.top + e.clientY - gesture.startY, 8, Math.max(8, window.innerHeight - padSize - 8)),
});
} else {
const maxSize = Math.max(140, Math.min(window.innerWidth - gesture.left - 56, window.innerHeight - gesture.top - 8, 360));
setPadSize(clamp(gesture.size + e.clientX - gesture.startX, 140, maxSize));
}
};
const endPadGesture = (e: ReactPointerEvent<HTMLElement>) => {
e.stopPropagation();
padGesture.current = null;
};
const toggleFollowMouse = (e?: ReactMouseEvent<HTMLDivElement>) => {
e?.preventDefault();
setFollowMouse((active) => !active);
};
const updateHover = (i: number, e: ReactPointerEvent<HTMLDivElement>) => {
hoverRef.current = i;
@ -168,8 +228,35 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl
};
}, []);
useEffect(() => {
if (!followMouse) return;
const onWinMove = (e: PointerEvent) => {
const w = window.innerWidth || 1;
const h = window.innerHeight || 1;
onMoveRef.current(clamp(e.clientX / w, 0, 1), clamp(1 - e.clientY / h, 0, 1));
};
const onWinKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setFollowMouse(false);
};
window.addEventListener('pointermove', onWinMove);
window.addEventListener('keydown', onWinKey);
return () => {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('keydown', onWinKey);
};
}, [followMouse]);
return (
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', background: '#0d0d0d' }}>
<div
onDoubleClick={toggleFollowMouse}
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
background: '#0d0d0d',
cursor: followMouse ? 'none' : 'default',
}}
>
{/* Main view — the flow-field particle system */}
<canvas
ref={canvasRef}
@ -227,7 +314,7 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl
height: 16,
background: 'rgba(255,255,255,0.04)',
overflow: 'hidden',
cursor: 'ew-resize',
cursor: followMouse ? 'none' : 'ew-resize',
touchAction: 'none',
}}
>
@ -285,9 +372,136 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl
}}
/>
{/* Bottom-left circular pad — drives the 2D input */}
<div style={{ position: 'absolute', left: 18, bottom: 18, zIndex: 20 }}>
<VirtualJoystick size={120} position={pos} onMove={onMove} ariaLabel="particle input pad" />
{followMouse && (
<div
style={{
position: 'absolute',
top: 30,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 26,
padding: '4px 10px',
border: '1px solid var(--accent)',
borderRadius: 'var(--r-pill)',
background: 'rgba(0,0,0,0.78)',
color: 'var(--accent)',
fontFamily: 'var(--font-mono)',
fontSize: 10,
pointerEvents: 'none',
}}
>
FOLLOW MOUSE · ESC OR DOUBLE-CLICK TO EXIT
</div>
)}
{/* Adjustable circular pad — explicit edit handles keep repositioning/resizing deliberate. */}
<div
style={{
position: 'absolute',
left: padPosition.left,
top: padPosition.top,
width: padSize,
height: padSize,
zIndex: 20,
pointerEvents: 'none',
}}
>
<VirtualJoystick
size={padSize}
position={pos}
onMove={onMove}
disabled={padEdit || followMouse}
ariaLabel="particle input pad"
style={{ pointerEvents: padEdit || followMouse ? 'none' : 'auto' }}
/>
<button
type="button"
aria-label={padEdit ? 'Finish adjusting joystick' : 'Adjust joystick size and position'}
title={padEdit ? 'Finish adjusting joystick' : 'Adjust joystick size and position'}
onClick={(e) => {
e.stopPropagation();
setPadEdit((editing) => !editing);
}}
style={{
position: 'absolute',
top: 6,
right: 6,
zIndex: 3,
width: 24,
height: 24,
border: '1px solid var(--line-strong)',
borderRadius: 'var(--r-1)',
background: 'rgba(0,0,0,0.72)',
color: 'var(--accent)',
cursor: 'pointer',
pointerEvents: 'auto',
fontFamily: 'var(--font-mono)',
fontSize: 14,
lineHeight: 1,
}}
>
{padEdit ? '✓' : '⋮'}
</button>
{padEdit && (
<>
<button
type="button"
aria-label="Move joystick"
title="Drag to move joystick"
onPointerDown={(e) => beginPadGesture(e, 'move')}
onPointerMove={movePadGesture}
onPointerUp={endPadGesture}
onPointerCancel={endPadGesture}
style={{
position: 'absolute',
top: 6,
left: 6,
zIndex: 3,
width: 24,
height: 24,
border: '1px solid var(--accent)',
borderRadius: 'var(--r-1)',
background: 'rgba(0,0,0,0.72)',
color: 'var(--accent)',
cursor: 'move',
pointerEvents: 'auto',
fontFamily: 'var(--font-mono)',
fontSize: 13,
lineHeight: 1,
}}
>
</button>
<button
type="button"
aria-label="Resize joystick"
title="Drag to resize joystick"
onPointerDown={(e) => beginPadGesture(e, 'resize')}
onPointerMove={movePadGesture}
onPointerUp={endPadGesture}
onPointerCancel={endPadGesture}
style={{
position: 'absolute',
right: 4,
bottom: 4,
zIndex: 3,
width: 28,
height: 28,
border: '1px solid var(--accent)',
borderRadius: 'var(--r-1)',
background: 'rgba(0,0,0,0.72)',
color: 'var(--accent)',
cursor: 'nwse-resize',
pointerEvents: 'auto',
fontFamily: 'var(--font-mono)',
fontSize: 13,
lineHeight: 1,
}}
>
</button>
</>
)}
</div>
</div>
);

View file

@ -76,7 +76,7 @@ export interface ConsoleCtx {
params: MFParam[];
/** Patch one output row in the shared store (drives stage + dock in sync). */
setParam: (i: number, patch: Partial<MFParam>) => void;
addOutput: () => void;
addOutput: (placement?: 'prepend' | 'append') => void;
deleteOutput: (i: number) => void;
/** Active backend outputs currently presented by the stage + routing rows. */
displayOutputCount: number;