diff --git a/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte b/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte index d7ea608..0c1fda4 100644 --- a/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte +++ b/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte @@ -57,6 +57,11 @@ * provides all chrome (the main graph's field renderer). */ chrome?: 'full' | 'none'; + /** + * GPU picking: when provided, clicking a memory node in the field calls + * back with its memory id (the host opens its inspector panel). + */ + onpick?: (memoryId: string) => void; } let { @@ -68,9 +73,22 @@ ondemochange, onexit, embedded = false, - chrome = 'full' + chrome = 'full', + onpick }: Props = $props(); + // GPU picking — screen px → NDC → NodeRenderer.pickAt (one readback/click). + let canvasLayerEl: HTMLDivElement | null = $state(null); + async function handleFieldClick(e: MouseEvent) { + if (!onpick || !renderer || !canvasLayerEl) return; + const rect = canvasLayerEl.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1; + const ndcY = -(((e.clientY - rect.top) / rect.height) * 2 - 1); + const hit = await renderer.pickAt(ndcX, ndcY); + if (hit) onpick(hit.id); + } + // Human labels for the switcher chips — short, mono, uppercase (visual DNA §7.3). const DEMO_LABELS: Record = { 'recall-path': 'RECALL', @@ -266,8 +284,15 @@ class="{embedded ? 'absolute' : 'fixed'} inset-0 overflow-hidden bg-[#05060a]" class:cursor-none={capture} > - -
+ + +
{ + const device = this.engine.gpuDevice; + if (!device || !this.nodeBuffer || !this.graph || this.nodeCount === 0) return null; + + const byteSize = this.nodeCount * FLOATS_PER_NODE * 4; + const staging = device.createBuffer({ + label: 'observatory-pick-staging', + size: byteSize, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ + }); + const encoder = device.createCommandEncoder({ label: 'observatory-pick-copy' }); + encoder.copyBufferToBuffer(this.nodeBuffer, 0, staging, 0, byteSize); + device.queue.submit([encoder.finish()]); + let data: Float32Array; + try { + await staging.mapAsync(GPUMapMode.READ); + data = new Float32Array(staging.getMappedRange().slice(0)); + } catch { + staging.destroy(); + return null; + } + staging.unmap(); + staging.destroy(); + + // Same camera inputs the frame pass uses (compute() above). + const w = this.engine.params[6] || 1; + const h = this.engine.params[7] || 1; + const phase = this.engine.params[1]; + const m = orbitCamera(phase, w / h, ORBIT_DISTANCE).viewProj; // column-major + + // Projected-disc hit test: fovY 50° → f = 1/tan(25°); a node of world + // radius r at clip-w distance projects to ~r·f/w in NDC y. A small + // floor keeps faint distant nodes clickable; score <1.6 allows a + // forgiving halo around the disc; lowest score (closest relative to + // its disc) wins. + const f = 1 / Math.tan((50 * Math.PI) / 360); + let best = -1; + let bestScore = Infinity; + for (let i = 0; i < this.nodeCount; i++) { + const b = i * FLOATS_PER_NODE + NODE_LANE.posRadius; + const x = data[b]; + const y = data[b + 1]; + const z = data[b + 2]; + const r = data[b + 3]; + const cw = m[3] * x + m[7] * y + m[11] * z + m[15]; + if (cw <= 0) continue; // behind the camera + const cx = (m[0] * x + m[4] * y + m[8] * z + m[12]) / cw; + const cy = (m[1] * x + m[5] * y + m[9] * z + m[13]) / cw; + const projR = Math.max((r * f) / cw, 0.012); + const score = Math.hypot(cx - ndcX, cy - ndcY) / projR; + if (score < 1.6 && score < bestScore) { + bestScore = score; + best = i; + } + } + if (best < 0) return null; + return { index: best, id: this.graph.nodes[best].id }; + } + dispose(): void { this.nodeBuffer?.destroy(); this.edgeBuffer?.destroy(); diff --git a/apps/dashboard/src/routes/(app)/graph/+page.svelte b/apps/dashboard/src/routes/(app)/graph/+page.svelte index 18c5428..b518285 100644 --- a/apps/dashboard/src/routes/(app)/graph/+page.svelte +++ b/apps/dashboard/src/routes/(app)/graph/+page.svelte @@ -318,7 +318,13 @@ disown MAIN renderer: GPU force sim, HDR bloom, the recall wavefront sweeping the real graph on a 12s loop. Page chrome stays DOM. -->
- +
{:else}