From 453047612a837a9765142fc5a53bdbe1575194dc Mon Sep 17 00:00:00 2001 From: monkey-w1n5t0n Date: Sun, 28 Jun 2026 20:50:54 +0200 Subject: [PATCH] feat(manifold): parameter-sandwich centre-stage toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fuse the /learn parameter-landscape sandbox into the main console: - New SandwichStage: ports the 3D landscape renderer to React, driven by the REAL EngineApi (inferBatch samples each output channel over the 2D input grid; getOutputs feeds the live probe). Re-samples on engine version bumps; orbit camera (top-down = flat 2D, tilt to reveal the stack), centred + grows to fill. - Dock: new bottom toggle (SandwichIcon) → ctx.sandwich. - ConsoleApp: when on, the sandwich replaces the centre stage in a 3-zone flex — shrunken Manifold (left) · sandwich (centre, fills) · compact OutputStage (right) — reusing pos/markers/verdicts. Opening it auto-closes the drawer so the output zone is visible. Layers = the mode's params (capped 8). typecheck + build green; verified headless (toggle on/off, sample, tilt). --- manifold/src/console/ConsoleApp.tsx | 67 ++++++++- manifold/src/console/Dock.tsx | 27 +++- manifold/src/console/SandwichStage.tsx | 198 +++++++++++++++++++++++++ manifold/src/console/icons.tsx | 13 ++ 4 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 manifold/src/console/SandwichStage.tsx diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx index d0bf780..74a52a0 100644 --- a/manifold/src/console/ConsoleApp.tsx +++ b/manifold/src/console/ConsoleApp.tsx @@ -28,6 +28,7 @@ import { MF_MODES, modeEngineId, seededGradient, shapeValues } from './model'; import type { MFParam } from './model'; import { CompositeStage } from './CompositeStage'; import { ParticleStage } from './ParticleStage'; +import { SandwichStage } from './SandwichStage'; import { SplitStage } from './SplitStage'; import { OutputStage } from './OutputStage'; import { InputMini } from './InputMini'; @@ -107,6 +108,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp const [rev, setRev] = useState(1); const [active, setActive] = useState('learn'); const [depth, setDepth] = useState('peek'); + // Sandwich (parameter-landscape) centre-stage toggle — dock-bottom layers icon. + const [sandwich, setSandwich] = useState(false); // Learning-behaviour store (dock-spec §1; rl-feedback-design). Default // feedback mode = "Explore and place"; default solo = "Mask gradients". @@ -771,7 +774,55 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp bottom: 0, }} > - {outputMode === 'particles' ? ( + {sandwich ? ( + // Sandwich centre-stage: shrunken input (left) · landscape stack + // (centre, fills) · compact outputs (right). Replaces the Mode stage. +
+
+ +
+
+ {engine && ( + p.name)} + /> + )} +
+
+ +
+
+ ) : outputMode === 'particles' ? ( ) : focus === 'composite' ? ( - + { + setSandwich(v); + // reveal the full 3-zone layout: a drawer would overlay the output zone + if (v) setActive(null); + }} + /> ); } diff --git a/manifold/src/console/Dock.tsx b/manifold/src/console/Dock.tsx index 4f46ea7..3eede63 100644 --- a/manifold/src/console/Dock.tsx +++ b/manifold/src/console/Dock.tsx @@ -28,6 +28,7 @@ import { OscIcon, SynthIcon, EditorIcon, + SandwichIcon, CloseIcon, ExpandIcon, GLYPH_FALLBACK, @@ -173,11 +174,14 @@ export interface DockProps { setActive: (k: DrawerKey | null) => void; depth: DrawerDepth; setDepth: (d: DrawerDepth) => void; + /** Sandwich (parameter-landscape) centre-stage toggle. */ + sandwich: boolean; + setSandwich: (v: boolean) => void; } const ORDER: DrawerKey[] = ['learn', 'inputs', 'route', 'settings', 'help']; -export function Dock({ ctx, active, setActive, depth, setDepth }: DockProps) { +export function Dock({ ctx, active, setActive, depth, setDepth, sandwich, setSandwich }: DockProps) { const { settings } = useSettings(); const mono = settings.monochromeIcons; const restColour = unfocusedIconCss(settings.unfocusedIconColour); @@ -368,6 +372,27 @@ export function Dock({ ctx, active, setActive, depth, setDepth }: DockProps) { > {ORDER.map(iconBtn)} + {/* Sandwich toggle — pinned at the BOTTOM of the rail. */} + ); diff --git a/manifold/src/console/SandwichStage.tsx b/manifold/src/console/SandwichStage.tsx new file mode 100644 index 0000000..e5db0ff --- /dev/null +++ b/manifold/src/console/SandwichStage.tsx @@ -0,0 +1,198 @@ +/** + * SandwichStage — the N-dimensional "parameter sandwich" as a centre stage, + * driven by the REAL engine (the same `EngineApi` instance the rest of the + * console uses). Ported from the standalone /learn renderer. + * + * Each layer is one output channel of the MLP, sampled across the 2-D input + * grid via `engine.inferBatch` → that surface IS the parameter's landscape. + * The grid is re-sampled only when the net changes (`version`); a per-frame + * rAF reads `engine.getOutputs()` for the live probe at the current input. + * + * Three-zone metaphor (input → MLP stack → outputs): this component owns the + * MIDDLE zone. ConsoleApp docks a shrunken Manifold (left) and OutputStage + * (right) around it. Drag the canvas to orbit; it lands top-down (a flat 2-D + * map) and tilts into the 3-D stack. The stack is centred and grows to fill + * the available height as more parameters are shown. + */ +import { useEffect, useRef } from 'react'; +import type { EngineApi } from '../engine'; + +const GRID = 12; +const N = GRID; +const SX = 300, SZ = 300, LAYER_GAP = 120, ZAMP = 92, MARGIN = 34; +// Reference scale: each layer adds a fixed ~px gap (so the stack GROWS with the +// layer count) until it would overflow, at which point the fit scale caps it. +const REF_SCALE = 0.8; + +export interface SandwichStageProps { + engine: EngineApi; + /** Engine version counter — re-sample the landscape grid when it bumps. */ + version: number; + /** Current 2-D input (drives the probe column). */ + pos: [number, number]; + /** How many output channels to render as layers (top→down). */ + layerCount: number; + /** Per-layer labels (the mode's param names). */ + names: string[]; +} + +export function SandwichStage({ engine, version, pos, layerCount, names }: SandwichStageProps) { + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const gridRef = useRef(null); + const posRef = useRef(pos); + posRef.current = pos; + const Kref = useRef(layerCount); + Kref.current = Math.max(1, layerCount); + const namesRef = useRef(names); + namesRef.current = names; + const yaw = useRef(0); + const pitch = useRef(Math.PI / 2); // top-down (looks 2-D) until you tilt it + const view = useRef({ cw: 800, ch: 600, scale: 1, ceny: 0 }); + + const OUT = engine.architecture.outputSize; + + // ---- re-sample the landscape grid whenever the net changes ---- + useEffect(() => { + const pts: [number, number][] = []; + for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) pts.push([i / N, j / N]); + try { + gridRef.current = engine.inferBatch(pts); + } catch { + gridRef.current = null; + } + }, [engine, version]); + + // ---- render loop ---- + useEffect(() => { + const canvas = canvasRef.current; + const wrap = wrapRef.current; + if (!canvas || !wrap) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); + + const computeView = () => { + const v = view.current; + const K = Kref.current; + v.ceny = -(K - 1) * LAYER_GAP / 2 + ZAMP / 2; // centroid (centre vertically) + let R = 1; + for (let k = 0; k < K; k++) for (const z of [0, 1]) for (const gx of [-0.5, 0.5]) for (const gy of [-0.5, 0.5]) { + const X = gx * SX, Y = (-k * LAYER_GAP + z * ZAMP) - v.ceny, Z = gy * SZ; + const d = Math.sqrt(X * X + Y * Y + Z * Z); + if (d > R) R = d; + } + const fit = (Math.min(v.cw, v.ch) / 2 - MARGIN) / R; + v.scale = Math.min(REF_SCALE, fit); // grow with K, then cap to fit + }; + const resize = () => { + const r = wrap.getBoundingClientRect(); + view.current.cw = Math.max(160, Math.round(r.width)); + view.current.ch = Math.max(160, Math.round(r.height)); + canvas.width = view.current.cw * DPR; + canvas.height = view.current.ch * DPR; + ctx.setTransform(DPR, 0, 0, DPR, 0, 0); + computeView(); + }; + const ro = new ResizeObserver(resize); + ro.observe(wrap); + resize(); + + const project = (gx: number, gy: number, zv: number, k: number) => { + const v = view.current; + const X = gx * SX, Y = (-k * LAYER_GAP + zv * ZAMP) - v.ceny, Z = -gy * SZ; + const cyA = Math.cos(yaw.current), syA = Math.sin(yaw.current); + const x1 = X * cyA - Z * syA, z1 = X * syA + Z * cyA; + const cpA = Math.cos(pitch.current), spA = Math.sin(pitch.current); + const y2 = Y * cpA - z1 * spA, z2 = Y * spA + z1 * cpA; + return { x: v.cw / 2 + x1 * v.scale, y: v.ch / 2 - y2 * v.scale, depth: z2, wx: X, wy: Y, wz: Z }; + }; + const hueOf = (k: number, K: number) => (K > 1 ? 26 + (196 - 26) * k / (K - 1) : 26); + const colCss = (k: number, K: number, a: number) => `hsla(${hueOf(k, K).toFixed(0)},85%,60%,${a})`; + const LIGHT = (() => { const v = [0.35, 0.86, 0.36]; const m = Math.hypot(v[0], v[1], v[2]); return [v[0] / m, v[1] / m, v[2] / m]; })(); + const clamp = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x); + + let raf = 0; + const draw = () => { + const v = view.current; + const K = Kref.current; + const grid = gridRef.current; + computeView(); + ctx.clearRect(0, 0, v.cw, v.ch); + const H = (k: number, i: number, j: number) => (grid ? grid[(j * (N + 1) + i) * OUT + k] : 0.5); + + const quads: { a: any; b: any; d: any; e: any; hue: number; L: number; depth: number }[] = []; + const labels: { x: number; y: number; hue: number; name: string }[] = []; + for (let k = 0; k < K; k++) { + const hue = hueOf(k, K); + const P: { x: number; y: number; depth: number; wx: number; wy: number; wz: number }[][] = []; + for (let j = 0; j <= N; j++) { P[j] = []; for (let i = 0; i <= N; i++) P[j][i] = project(i / N - 0.5, j / N - 0.5, H(k, i, j), k); } + for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) { + const a = P[j][i], b = P[j][i + 1], d = P[j + 1][i + 1], e = P[j + 1][i]; + const ux = d.wx - a.wx, uy = d.wy - a.wy, uz = d.wz - a.wz, vx = b.wx - e.wx, vy = b.wy - e.wy, vz = b.wz - e.wz; + const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx, nm = Math.hypot(nx, ny, nz) || 1; + const L = clamp(0.32 + 0.62 * Math.abs((nx * LIGHT[0] + ny * LIGHT[1] + nz * LIGHT[2]) / nm)); + quads.push({ a, b, d, e, hue, L, depth: (a.depth + b.depth + d.depth + e.depth) / 4 }); + } + const lp = project(-0.5, 0.5, H(k, 0, N), k); + labels.push({ x: lp.x, y: lp.y, hue, name: namesRef.current[k] || `p${k}` }); + } + quads.sort((p, q) => q.depth - p.depth); + for (const Q of quads) { + ctx.fillStyle = `hsla(${Q.hue},80%,${(26 + Q.L * 44).toFixed(0)}%,0.66)`; + ctx.strokeStyle = `hsla(${Q.hue},80%,82%,0.18)`; ctx.lineWidth = 0.6; + ctx.beginPath(); ctx.moveTo(Q.a.x, Q.a.y); ctx.lineTo(Q.b.x, Q.b.y); ctx.lineTo(Q.d.x, Q.d.y); ctx.lineTo(Q.e.x, Q.e.y); ctx.closePath(); + ctx.fill(); ctx.stroke(); + } + ctx.font = '11px var(--font-mono, monospace)'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + for (const Lb of labels) { ctx.fillStyle = `hsla(${Lb.hue},85%,74%,0.95)`; ctx.fillText(Lb.name, Lb.x - 6, Lb.y); } + + // probe column at the current input, marker on each slab from the LIVE output + const out = engine.getOutputs(); + const gx = posRef.current[0] - 0.5, gy = posRef.current[1] - 0.5; + const top = project(gx, gy, 1.18, 0), bot = project(gx, gy, -0.18, K - 1); + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1; ctx.setLineDash([3, 4]); + ctx.beginPath(); ctx.moveTo(top.x, top.y); ctx.lineTo(bot.x, bot.y); ctx.stroke(); ctx.setLineDash([]); + for (let k = 0; k < K; k++) { + const val = clamp(out[k] ?? 0.5); + const p = project(gx, gy, val, k), fl = project(gx, gy, 0, k); + ctx.strokeStyle = colCss(k, K, 0.5); ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(fl.x, fl.y); ctx.stroke(); + ctx.fillStyle = colCss(k, K, 1); ctx.shadowColor = colCss(k, K, 1); ctx.shadowBlur = 12; + ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; + ctx.fillStyle = 'var(--bg, #0d0d0d)'; ctx.beginPath(); ctx.arc(p.x, p.y, 2.2, 0, Math.PI * 2); ctx.fill(); + } + raf = requestAnimationFrame(draw); + }; + raf = requestAnimationFrame(draw); + return () => { cancelAnimationFrame(raf); ro.disconnect(); }; + }, [engine, OUT]); + + // ---- drag to orbit (yaw + pitch; pitch up to π/2 = exact top-down) ---- + const rotating = useRef(false); + const last = useRef({ x: 0, y: 0 }); + return ( +
+ { + rotating.current = true; + (e.currentTarget as HTMLCanvasElement).setPointerCapture?.(e.pointerId); + last.current = { x: e.clientX, y: e.clientY }; + }} + onPointerMove={(e) => { + if (!rotating.current) return; + yaw.current += (e.clientX - last.current.x) * 0.01; + pitch.current = Math.max(0.06, Math.min(Math.PI / 2, pitch.current + (e.clientY - last.current.y) * 0.008)); + last.current = { x: e.clientX, y: e.clientY }; + }} + onPointerUp={() => { rotating.current = false; }} + onPointerCancel={() => { rotating.current = false; }} + /> +
+ PARAMETER SANDWICH · drag to rotate · top-down = the flat 2-D map +
+
+ ); +} diff --git a/manifold/src/console/icons.tsx b/manifold/src/console/icons.tsx index 6ae3b3e..58e3cf7 100644 --- a/manifold/src/console/icons.tsx +++ b/manifold/src/console/icons.tsx @@ -50,6 +50,19 @@ export function ModeIcon({ size = 18, style }: IconProps) { ); } +/** Sandwich — stacked parameter-landscape slabs (the layer toggle). */ +export function SandwichIcon({ size = 18, style }: IconProps) { + return svg( + size, + style, + <> + + + + , + ); +} + /** Learning — a brain-ish node graph. */ export function LearningIcon({ size = 18, style }: IconProps) { return svg(