diff --git a/apps/dashboard/src/lib/components/MemoryCinema.svelte b/apps/dashboard/src/lib/components/MemoryCinema.svelte index 38712d3..68bb823 100644 --- a/apps/dashboard/src/lib/components/MemoryCinema.svelte +++ b/apps/dashboard/src/lib/components/MemoryCinema.svelte @@ -78,18 +78,29 @@ // Deterministic layout: spread path nodes on a gentle spiral so the camera // has distinct world positions to fly between (independent of the WebGL // graph's internal coordinates — keeps the sandbox isolated). + // Lay the beat nodes out on a TIGHT, BOUNDED shell centered on the origin — + // fixed radius, no per-beat growth. Earlier this grew (22 + i*6) so each beat + // sat farther out and the camera+storm marched off into space ("flying off"). + // A bounded shell keeps the whole composition centered; cinematic variety + // comes from the camera angle/move/standoff, not from translating across a + // huge volume. The focused node is always re-centered by recenterOn() below. + const SHELL_RADIUS = 14; function layoutPositions(p: CinemaPath): Map { const pos = new Map(); const n = p.beats.length; for (let i = 0; i < n; i++) { - const angle = (i / Math.max(1, n)) * Math.PI * 2 * 1.4; - const radius = 22 + i * 6; + // Distribute beats evenly on a sphere (golden-angle spiral) so they + // never clump and never exceed SHELL_RADIUS from center. + const t = n > 1 ? i / (n - 1) : 0.5; + const y = 1 - t * 2; // 1..-1 + const r = Math.sqrt(Math.max(0, 1 - y * y)); + const theta = i * 2.399963; // golden angle pos.set( p.beats[i].nodeId, new THREE.Vector3( - Math.cos(angle) * radius, - (i % 2 === 0 ? 1 : -1) * (4 + i * 2), - Math.sin(angle) * radius + Math.cos(theta) * r * SHELL_RADIUS, + y * SHELL_RADIUS * 0.5, + Math.sin(theta) * r * SHELL_RADIUS ) ); } @@ -235,7 +246,7 @@ stage = 'done'; statusLine = 'End of tour.'; }, - }, { reducedMotion, shots }); + }, { reducedMotion, shots, centerOnOrigin: webgpuActive }); stage = 'playing'; statusLine = webgpuActive diff --git a/apps/dashboard/src/lib/graph/cinema/director.ts b/apps/dashboard/src/lib/graph/cinema/director.ts index 8681f88..6252cbc 100644 --- a/apps/dashboard/src/lib/graph/cinema/director.ts +++ b/apps/dashboard/src/lib/graph/cinema/director.ts @@ -37,12 +37,18 @@ export interface DirectorOptions { * director — every value falls back to the constants above. When present, * each shot's move/angle/dutch/standoff/flight/dwell/cut directs that beat. */ shots?: ResolvedShot[]; + /** When true, the camera frames the WORLD ORIGIN every shot (the WebGPU storm + * is pinned there) instead of flying out to scattered node positions — so the + * subject is ALWAYS centered and can never fly off-screen. Camera variety + * comes purely from angle/standoff/orbit. Used by the WebGPU sandbox path. */ + centerOnOrigin?: boolean; } type Phase = 'idle' | 'flying' | 'dwelling' | 'done'; const _tmpDir = new THREE.Vector3(); const _tmpUp = new THREE.Vector3(0, 1, 0); +const _origin = new THREE.Vector3(0, 0, 0); export class CinemaDirector { private camera: THREE.PerspectiveCamera; @@ -81,6 +87,7 @@ export class CinemaDirector { standoff: opts.standoff ?? 26, reducedMotion: opts.reducedMotion ?? false, shots: opts.shots ?? [], + centerOnOrigin: opts.centerOnOrigin ?? false, }; } @@ -122,11 +129,18 @@ export class CinemaDirector { this.phase = 'done'; } - /** Compute the camera stand-off position for a beat's node, directed by its - * shot (move / angle / standoff). With no shot, reproduces the original + /** The focal point a beat frames: the world ORIGIN in centered mode (storm is + * pinned there), else the node's laid-out position. */ + private focalPoint(beat: CinemaBeat): THREE.Vector3 | null { + if (this.opts.centerOnOrigin) return _origin; + return this.positions.get(beat.nodeId) ?? null; + } + + /** Compute the camera stand-off position for a beat's focal point, directed by + * its shot (move / angle / standoff). With no shot, reproduces the original * framing exactly: standoff = opts.standoff, +0.35 up-bias (filmic tilt). */ private framePosition(beat: CinemaBeat, index: number, out: THREE.Vector3): THREE.Vector3 { - const nodePos = this.positions.get(beat.nodeId); + const nodePos = this.focalPoint(beat); if (!nodePos) { // Node has no resolved position yet — keep current framing. return out.copy(this.camera.position); @@ -159,7 +173,7 @@ export class CinemaDirector { private beginFlightTo(index: number): void { const beat = this.path.beats[index]; - const nodePos = this.positions.get(beat.nodeId); + const nodePos = this.focalPoint(beat); const shot = this.shotAt(index); this.fromPos.copy(this.camera.position); @@ -205,7 +219,7 @@ export class CinemaDirector { } } else if (this.phase === 'dwelling') { if (!this.opts.reducedMotion) { - const nodePos = this.positions.get(this.path.beats[this.beatIndex].nodeId); + const nodePos = this.focalPoint(this.path.beats[this.beatIndex]); if (nodePos) { this.target.lerp(nodePos, 0.02); // gentle settle keeps the shot alive // An orbit shot slowly revolves the camera around the node diff --git a/apps/dashboard/src/lib/graph/cinema/sandbox.ts b/apps/dashboard/src/lib/graph/cinema/sandbox.ts index e3ab3f7..eec67d4 100644 --- a/apps/dashboard/src/lib/graph/cinema/sandbox.ts +++ b/apps/dashboard/src/lib/graph/cinema/sandbox.ts @@ -13,6 +13,12 @@ import * as THREE from 'three'; import type { SemanticRole, SemanticComputeStorm } from './storm'; +// The storm lives at the world origin, permanently. The camera always looks here +// and is clamped to a safe distance band so the subject can never leave frame. +const ORIGIN = new THREE.Vector3(0, 0, 0); +const MIN_CAM_DIST = 18; +const MAX_CAM_DIST = 46; + export function isWebGPUSupported(): boolean { return typeof navigator !== 'undefined' && 'gpu' in navigator; } @@ -153,26 +159,37 @@ export class CinemaSandbox { this.booted = true; } - /** Retarget the storm + look the camera at the beat (called by the director). */ - transitionTo(role: SemanticRole, worldPos: THREE.Vector3): void { + /** Retarget the storm's MODE/ignition for a beat. The storm is permanently + * centered at the WORLD ORIGIN (see render) so it is always dead-center in + * frame — worldPos here only conveys which node, not where the storm sits. */ + transitionTo(role: SemanticRole, _worldPos: THREE.Vector3): void { if (!this.booted) return; - this.storm.transitionTo(role, worldPos); + this.storm.transitionTo(role, ORIGIN); } - /** Render one frame. Camera is driven externally (director mutates position/target). - * A single frame's failure must not crash the tour — it's caught and surfaced - * via a thrown error the caller already handles (drops to camera-only). */ + /** Render one frame. The storm is pinned to the origin and the camera always + * looks at the origin, so the storm CANNOT leave the frame. The director + * varies only the camera's orbital position/angle (set via cameraRef), and we + * clamp that to a safe distance band here as a final guarantee. */ async render(deltaSeconds: number): Promise { if (!this.booted) return; - this.camera.lookAt(this.target); - // Keep the storm inside the frame: derive the largest world radius that - // fully fits the camera's vertical FOV at the current distance to target, - // minus a margin so the glow halo stays on-screen too. The storm clamps - // itself to this each frame, so it reframes as the camera flies. - const dist = this.camera.position.distanceTo(this.target); + // Hard guarantee: clamp the camera into a distance band from origin so a + // runaway director move can never push the subject out of view, then look + // dead at the origin where the storm lives. + const distToOrigin = this.camera.position.length(); + if (distToOrigin < MIN_CAM_DIST || distToOrigin > MAX_CAM_DIST || !Number.isFinite(distToOrigin)) { + const d = Math.min(MAX_CAM_DIST, Math.max(MIN_CAM_DIST, distToOrigin || MAX_CAM_DIST)); + if (distToOrigin > 1e-3) this.camera.position.setLength(d); + else this.camera.position.set(0, 12, d); + } + this.camera.lookAt(ORIGIN); + + // Size the containment sphere to the camera's FOV at the origin so the + // storm always fully fits the frame with margin. + const dist = this.camera.position.length(); const vfov = (this.camera.fov * Math.PI) / 180; - const fitRadius = Math.tan(vfov / 2) * dist * 0.62; // 0.62 = on-screen margin + const fitRadius = Math.tan(vfov / 2) * dist * 0.55; this.storm.setContainRadius(fitRadius); await this.storm.update(deltaSeconds);