Implement Sophia MVP scaffold (stages 0–3 + topology pivot)
Six-crate Rust workspace (core/sim/llm/store/server/bin) backing a Three.js + WebGL frontend. Live at http://127.0.0.1:7777 via `cargo run`. Sim - Event-driven scheduler with min-heap, per-engram tick, staggered Spawn events (40 ms apart) so each engram's flight is visually readable. - Solid-donut torus topology (replaces original spherical density-driven shell, see Topology Pivot in docs/system-analysis.md). Configurable major/minor radii in config.toml; live `POST /api/galaxy/:id/resize`. - Physics: Verlet integration + friction; in-hole pull + galactic spin (CCW around +z) for spiral-ejection ejection from the donut centre; soft tube boundary with velocity-reflecting wall. - Cosine-weighted gravity (kiddo k-NN within radius 25, threshold 0.50) and synapse formation (threshold 0.62) gated to inside-the-tube only. - LM Studio integration via OpenAI-compatible REST: batched embeddings, optional Bearer auth, semaphore-bounded parallel ops per §13.5. Server - axum HTTP + WebSocket. Routes: /healthz, /api/galaxy CRUD, /seed, /ingest, /resize, /engrams/:id, /ws/galaxy/:id/events. - Binary 12-byte-aligned position frames at ~20 Hz; JSON for sparse events (Hello, EngramCreated, SynapseCreated, TorusUpdated). - Layered config: config.toml (defaults) + config.local.toml (secrets, gitignored) merged on startup. Frontend - Vite + vanilla TypeScript + three.js 0.169. - Engrams render as additive bloom-friendly point sprites with a per-engram hash-driven hue rotation and breathing pulse. - Comet-style velocity-aligned trails; additive ribbon synapses whose endpoints track engram positions every frame. - UnrealBloomPass + ACES tone-mapping for the linked-particles look. - HUD shows torus dims, engram + synapse counts, LM Studio status; controls for seed, ingest, and live torus resize. Docs - README replaced with docs/IDEA.md; system-analysis.md updated with the topology pivot decisions and Galaxy Ejection refinement notes (the implementation plan lives in ~/.claude/plans, gitignored). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
131
web/index.html
Normal file
131
web/index.html
Normal file
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sophia</title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: #050608;
|
||||
color: #cfd6df;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
overflow: hidden;
|
||||
}
|
||||
#stage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
#hud {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
background: rgba(0,0,0,0.4);
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
#hud .k { opacity: 0.55; }
|
||||
#controls {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
background: rgba(0,0,0,0.4);
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
#controls .ctrl-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
#controls input, #controls button {
|
||||
font: inherit;
|
||||
background: #11151b;
|
||||
color: #cfd6df;
|
||||
border: 1px solid #2a323d;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
#controls input { width: 64px; }
|
||||
#controls button { cursor: pointer; }
|
||||
#controls button:hover { background: #1a2129; }
|
||||
#controls button:disabled { opacity: 0.5; cursor: progress; }
|
||||
#ingest {
|
||||
position: fixed;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
width: 340px;
|
||||
background: rgba(0,0,0,0.55);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
#ingest textarea {
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
background: #0c1014;
|
||||
color: #cfd6df;
|
||||
border: 1px solid #2a323d;
|
||||
border-radius: 3px;
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#ingest .row { display: flex; justify-content: space-between; align-items: center; margin-top: 6px; }
|
||||
#ingest .hint { opacity: 0.55; font-size: 11px; }
|
||||
#ingest button {
|
||||
font: inherit;
|
||||
background: #1a2129;
|
||||
color: #cfd6df;
|
||||
border: 1px solid #2a323d;
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#ingest button:hover { background: #232c36; }
|
||||
#ingest button:disabled { opacity: 0.5; cursor: progress; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="stage"></canvas>
|
||||
<div id="hud">
|
||||
<div><span class="k">sophia</span> v0.0.1</div>
|
||||
<div><span class="k">stage</span> <span id="hud-stage">2 — slate + lm studio</span></div>
|
||||
<div><span class="k">galaxy</span> <span id="hud-galaxy">…</span></div>
|
||||
<div><span class="k">torus</span> <span id="hud-torus">…</span></div>
|
||||
<div><span class="k">engrams</span> <span id="hud-count">0</span></div>
|
||||
<div><span class="k">synapses</span> <span id="hud-synapses">0</span></div>
|
||||
<div><span class="k">lm studio</span> <span id="hud-lm">…</span></div>
|
||||
<div><span class="k">health</span> <span id="hud-health">…</span></div>
|
||||
</div>
|
||||
<div id="controls">
|
||||
<div class="ctrl-row">
|
||||
<input id="seed-n" type="number" min="1" max="5000" value="200" title="number of synthetic engrams" />
|
||||
<button id="seed-btn">seed</button>
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<input id="resize-major" type="number" min="1" max="10000" step="10" value="100" title="major radius (donut hole)" />
|
||||
<input id="resize-minor" type="number" min="1" max="2000" step="5" value="30" title="minor radius (tube)" />
|
||||
<button id="resize-btn">resize</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ingest">
|
||||
<textarea id="ingest-text" placeholder="One engram per line. Empty lines are skipped. e.g. Pasta carbonara uses guanciale, eggs, pecorino, and pepper. Backpropagation computes gradients of a loss with respect to weights. The fall of Constantinople occurred in 1453."></textarea>
|
||||
<div class="row">
|
||||
<span class="hint">embeds via LM Studio, then spawns at center</span>
|
||||
<button id="ingest-btn">ingest</button>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1069
web/package-lock.json
generated
Normal file
1069
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
web/package.json
Normal file
19
web/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "sophia-web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"three": "^0.169.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/three": "^0.169.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
214
web/src/engram_mesh.ts
Normal file
214
web/src/engram_mesh.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import type { EngramSnapshot, PositionFrame } from "./ws_client";
|
||||
|
||||
// RGB triples in 0..1. Tuned for additive blending against a dark blue
|
||||
// background — colors should be saturated and energetic so they read
|
||||
// clearly even when many overlap.
|
||||
const STATE_COLOR: Record<string, [number, number, number]> = {
|
||||
idle: [1.0, 0.72, 0.42], // warm amber
|
||||
searching: [1.0, 0.88, 0.40], // bright gold
|
||||
conversing: [0.82, 0.64, 1.0], // soft violet
|
||||
synthesizing: [0.43, 0.91, 0.72], // mint
|
||||
memorize: [0.65, 0.85, 1.0], // sky blue
|
||||
decaying: [1.0, 0.48, 0.48], // coral red
|
||||
deprecated: [0.49, 0.53, 0.58], // muted slate
|
||||
};
|
||||
|
||||
// Vertex / fragment shaders for crisp glowing point sprites tuned to match
|
||||
// the linked-particles reference (small jewel-tone dots, not soft puffs).
|
||||
// - gl_PointSize scales with inverse depth so far-away engrams shrink.
|
||||
// - Per-particle hash + uTime drives a slow breathing pulse with each
|
||||
// engram phase-shifted so the cluster doesn't blink in unison.
|
||||
// - The fragment paints a tight core with a faint halo; bloom in scene.ts
|
||||
// adds the cinematic spread without us having to over-emit per pixel.
|
||||
// Inline RGB↔HSV helpers (Sam Hocevar's branchless versions). Used to give
|
||||
// each engram a small per-particle hue offset around its state's base color
|
||||
// so a cluster of "idle" engrams reads as a constellation of varied warm
|
||||
// tones rather than a single uniform amber.
|
||||
const HSV_GLSL = /* glsl */ `
|
||||
vec3 rgb2hsv(vec3 c) {
|
||||
vec4 K = vec4(0.0, -1.0/3.0, 2.0/3.0, -1.0);
|
||||
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
|
||||
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
|
||||
float d = q.x - min(q.w, q.y);
|
||||
float e = 1.0e-10;
|
||||
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
|
||||
}
|
||||
vec3 hsv2rgb(vec3 c) {
|
||||
vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
|
||||
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
|
||||
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
|
||||
}
|
||||
`;
|
||||
|
||||
const VERT_SHADER = /* glsl */ `
|
||||
${HSV_GLSL}
|
||||
attribute float aSize;
|
||||
attribute vec3 aColor;
|
||||
attribute float aHash;
|
||||
uniform float uPixelScale;
|
||||
uniform float uTime;
|
||||
uniform float uHueJitter;
|
||||
varying vec3 vColor;
|
||||
varying float vPulse;
|
||||
void main() {
|
||||
// Per-particle hue rotation: small offset around the state color, signed
|
||||
// by hash so the cluster spreads in both directions on the colour wheel.
|
||||
vec3 hsv = rgb2hsv(aColor);
|
||||
hsv.x = fract(hsv.x + (aHash - 0.5) * uHueJitter);
|
||||
vColor = hsv2rgb(hsv);
|
||||
|
||||
float phase = aHash * 6.2831853;
|
||||
vPulse = 1.0 + 0.15 * sin(uTime * 0.9 + phase);
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_PointSize = aSize * (uPixelScale / max(-mv.z, 1.0));
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAG_SHADER = /* glsl */ `
|
||||
varying vec3 vColor;
|
||||
varying float vPulse;
|
||||
void main() {
|
||||
vec2 d = gl_PointCoord - vec2(0.5);
|
||||
float r2 = dot(d, d);
|
||||
if (r2 > 0.25) discard;
|
||||
// Punchy dot: tight core, very faint halo. Falloff exponents tuned so
|
||||
// the dot reads as a pinpoint at typical camera distance — bloom does
|
||||
// the rest of the visual work.
|
||||
float core = exp(-r2 * 36.0);
|
||||
float halo = exp(-r2 * 7.0) * 0.10;
|
||||
float a = core + halo;
|
||||
gl_FragColor = vec4(vColor * (0.55 + 0.45 * core) * vPulse, a);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Renders all Engrams of a galaxy as a single glowing point cloud.
|
||||
* Each Engram is one vertex with per-vertex color and size; the shader
|
||||
* paints it as a soft additive disc.
|
||||
*
|
||||
* Stage 1: positions arrive at ~20 Hz from a binary WS frame; colors are
|
||||
* static (everyone IDLE). Per-instance state changes will arrive in Stage 4.
|
||||
*/
|
||||
export class EngramMesh {
|
||||
private readonly points: THREE.Points;
|
||||
private readonly material: THREE.ShaderMaterial;
|
||||
private readonly positionAttr: THREE.BufferAttribute;
|
||||
private readonly colorAttr: THREE.BufferAttribute;
|
||||
private readonly sizeAttr: THREE.BufferAttribute;
|
||||
private readonly hashAttr: THREE.BufferAttribute;
|
||||
private readonly capacity: number;
|
||||
/** Highest instance_idx + 1 seen so far. Bounds the draw range. */
|
||||
private maxIdx = 0;
|
||||
/** Optional hook fired when an engram's base color is set/updated. The
|
||||
* trail renderer subscribes so head + tail share the same colour. */
|
||||
public onColorAssigned: ((idx: number, r: number, g: number, b: number) => void) | null = null;
|
||||
|
||||
constructor(scene: THREE.Scene, capacity = 5000) {
|
||||
this.capacity = capacity;
|
||||
|
||||
const geom = new THREE.BufferGeometry();
|
||||
this.positionAttr = new THREE.BufferAttribute(new Float32Array(capacity * 3), 3);
|
||||
this.colorAttr = new THREE.BufferAttribute(new Float32Array(capacity * 3), 3);
|
||||
this.sizeAttr = new THREE.BufferAttribute(new Float32Array(capacity), 1);
|
||||
// Per-particle random hash in [0, 1), used to phase-shift the brightness
|
||||
// pulse so the cluster doesn't blink in unison. Filled lazily on
|
||||
// upsert so engrams always have a stable hash for their lifetime.
|
||||
this.hashAttr = new THREE.BufferAttribute(new Float32Array(capacity), 1);
|
||||
this.positionAttr.setUsage(THREE.DynamicDrawUsage);
|
||||
this.colorAttr.setUsage(THREE.DynamicDrawUsage);
|
||||
this.sizeAttr.setUsage(THREE.DynamicDrawUsage);
|
||||
geom.setAttribute("position", this.positionAttr);
|
||||
geom.setAttribute("aColor", this.colorAttr);
|
||||
geom.setAttribute("aSize", this.sizeAttr);
|
||||
geom.setAttribute("aHash", this.hashAttr);
|
||||
geom.setDrawRange(0, 0);
|
||||
|
||||
this.material = new THREE.ShaderMaterial({
|
||||
vertexShader: VERT_SHADER,
|
||||
fragmentShader: FRAG_SHADER,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
uniforms: {
|
||||
// Tunable. Larger = bigger dots. Bumped from 1500 → 2800 so engrams
|
||||
// are clearly readable as moving dots during their in-hole flight,
|
||||
// not just as bloom smears.
|
||||
uPixelScale: { value: 2800.0 },
|
||||
// Seconds since scene start; updated by `tick()` from the animation loop.
|
||||
uTime: { value: 0.0 },
|
||||
// Hue rotation amplitude in [0..1]. 0.18 ≈ ±32° around the state hue.
|
||||
uHueJitter: { value: 0.18 },
|
||||
},
|
||||
});
|
||||
|
||||
this.points = new THREE.Points(geom, this.material);
|
||||
// Positions update faster than three.js can compute bounds; skip culling.
|
||||
this.points.frustumCulled = false;
|
||||
scene.add(this.points);
|
||||
}
|
||||
|
||||
/** Advance the shader's clock so the breathing pulse animates. */
|
||||
tick(timeSeconds: number): void {
|
||||
this.material.uniforms.uTime.value = timeSeconds;
|
||||
}
|
||||
|
||||
applyHello(engrams: EngramSnapshot[]): void {
|
||||
for (const e of engrams) this.upsertEngram(e);
|
||||
}
|
||||
|
||||
upsertEngram(e: EngramSnapshot): void {
|
||||
const idx = e.instance_idx;
|
||||
if (idx >= this.capacity) {
|
||||
console.warn(`engram instance_idx ${idx} exceeds capacity ${this.capacity}`);
|
||||
return;
|
||||
}
|
||||
const color = STATE_COLOR[e.state] ?? STATE_COLOR.idle;
|
||||
const colorArr = this.colorAttr.array as Float32Array;
|
||||
colorArr[idx * 3] = color[0];
|
||||
colorArr[idx * 3 + 1] = color[1];
|
||||
colorArr[idx * 3 + 2] = color[2];
|
||||
this.colorAttr.needsUpdate = true;
|
||||
this.onColorAssigned?.(idx, color[0], color[1], color[2]);
|
||||
|
||||
const sizeArr = this.sizeAttr.array as Float32Array;
|
||||
// Server gives e.size = 1.0 in Stage 1+. The base value is small so the
|
||||
// dots read as pinpoints (combined with bloom for the halo). Per-engram
|
||||
// size will diverge once federation lands (Stage 4+).
|
||||
sizeArr[idx] = Math.max(0.6, e.size * 0.8);
|
||||
this.sizeAttr.needsUpdate = true;
|
||||
|
||||
// Per-particle hash: only set on first upsert for this slot, so the
|
||||
// pulse phase stays stable across re-upserts (e.g. state changes).
|
||||
const hashArr = this.hashAttr.array as Float32Array;
|
||||
if (hashArr[idx] === 0) {
|
||||
hashArr[idx] = Math.random() || 0.5;
|
||||
this.hashAttr.needsUpdate = true;
|
||||
}
|
||||
|
||||
const posArr = this.positionAttr.array as Float32Array;
|
||||
posArr[idx * 3] = e.position[0];
|
||||
posArr[idx * 3 + 1] = e.position[1];
|
||||
posArr[idx * 3 + 2] = e.position[2];
|
||||
this.positionAttr.needsUpdate = true;
|
||||
|
||||
if (idx + 1 > this.maxIdx) this.maxIdx = idx + 1;
|
||||
this.points.geometry.setDrawRange(0, this.maxIdx);
|
||||
}
|
||||
|
||||
applyPositionFrame(frame: PositionFrame): void {
|
||||
const n = Math.min(frame.n, this.capacity);
|
||||
const posArr = this.positionAttr.array as Float32Array;
|
||||
posArr.set(frame.positions.subarray(0, n * 3), 0);
|
||||
this.positionAttr.needsUpdate = true;
|
||||
if (n > this.maxIdx) this.maxIdx = n;
|
||||
this.points.geometry.setDrawRange(0, this.maxIdx);
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.maxIdx;
|
||||
}
|
||||
}
|
||||
140
web/src/engram_trails.ts
Normal file
140
web/src/engram_trails.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import type { PositionFrame } from "./ws_client";
|
||||
|
||||
/**
|
||||
* Comet-style trails behind each engram.
|
||||
*
|
||||
* Each engram is rendered as a single line segment from
|
||||
* `position - velocity * tailScale` → `position`
|
||||
* where `velocity` is computed from the delta between consecutive position
|
||||
* frames. So the tail is *long* when an engram is moving fast (e.g. the
|
||||
* fountain phase right after birth) and *short* when it's drifting in the
|
||||
* tube. The tail vertex is transparent, the head vertex is opaque, and
|
||||
* additive blending + bloom in the post-pipeline gives the comet glow.
|
||||
*
|
||||
* One line segment per engram → 2 vertices each → very cheap.
|
||||
*/
|
||||
const TAIL_SCALE = 1.0; // multiplied onto inter-frame delta
|
||||
|
||||
const VERT_SHADER = /* glsl */ `
|
||||
attribute float aAlpha;
|
||||
varying vec3 vColor;
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
vColor = color;
|
||||
vAlpha = aAlpha;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAG_SHADER = /* glsl */ `
|
||||
varying vec3 vColor;
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
gl_FragColor = vec4(vColor, vAlpha);
|
||||
}
|
||||
`;
|
||||
|
||||
export class EngramTrails {
|
||||
private readonly mesh: THREE.LineSegments;
|
||||
private readonly positionAttr: THREE.BufferAttribute;
|
||||
private readonly colorAttr: THREE.BufferAttribute;
|
||||
private readonly alphaAttr: THREE.BufferAttribute;
|
||||
/** Last frame's position per engram, used to derive velocity. */
|
||||
private readonly previousPositions: Float32Array;
|
||||
/** Whether each engram has had at least one previous frame stored. */
|
||||
private readonly hasPrevious: Uint8Array;
|
||||
private readonly capacity: number;
|
||||
private maxIdx = 0;
|
||||
|
||||
constructor(scene: THREE.Scene, capacity = 5000) {
|
||||
this.capacity = capacity;
|
||||
this.previousPositions = new Float32Array(capacity * 3);
|
||||
this.hasPrevious = new Uint8Array(capacity);
|
||||
|
||||
// Two vertices per engram: index 2*i = tail, 2*i+1 = head.
|
||||
const vertexCount = capacity * 2;
|
||||
const geom = new THREE.BufferGeometry();
|
||||
this.positionAttr = new THREE.BufferAttribute(new Float32Array(vertexCount * 3), 3);
|
||||
this.colorAttr = new THREE.BufferAttribute(new Float32Array(vertexCount * 3), 3);
|
||||
this.alphaAttr = new THREE.BufferAttribute(new Float32Array(vertexCount), 1);
|
||||
this.positionAttr.setUsage(THREE.DynamicDrawUsage);
|
||||
this.colorAttr.setUsage(THREE.DynamicDrawUsage);
|
||||
this.alphaAttr.setUsage(THREE.DynamicDrawUsage);
|
||||
geom.setAttribute("position", this.positionAttr);
|
||||
geom.setAttribute("color", this.colorAttr);
|
||||
geom.setAttribute("aAlpha", this.alphaAttr);
|
||||
geom.setDrawRange(0, 0);
|
||||
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: VERT_SHADER,
|
||||
fragmentShader: FRAG_SHADER,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
|
||||
this.mesh = new THREE.LineSegments(geom, mat);
|
||||
this.mesh.frustumCulled = false;
|
||||
scene.add(this.mesh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the colour for one engram's trail. Both vertices share the colour;
|
||||
* the gradient is implemented via per-vertex alpha (tail = 0, head = 1).
|
||||
* Called by the EngramMesh whenever it learns about a new/updated engram.
|
||||
*/
|
||||
setEngramColor(idx: number, r: number, g: number, b: number): void {
|
||||
if (idx >= this.capacity) return;
|
||||
const colorArr = this.colorAttr.array as Float32Array;
|
||||
const alphaArr = this.alphaAttr.array as Float32Array;
|
||||
// Tail vertex.
|
||||
colorArr[idx * 6] = r;
|
||||
colorArr[idx * 6 + 1] = g;
|
||||
colorArr[idx * 6 + 2] = b;
|
||||
alphaArr[idx * 2] = 0.0;
|
||||
// Head vertex.
|
||||
colorArr[idx * 6 + 3] = r;
|
||||
colorArr[idx * 6 + 4] = g;
|
||||
colorArr[idx * 6 + 5] = b;
|
||||
alphaArr[idx * 2 + 1] = 0.85;
|
||||
this.colorAttr.needsUpdate = true;
|
||||
this.alphaAttr.needsUpdate = true;
|
||||
}
|
||||
|
||||
applyPositionFrame(frame: PositionFrame): void {
|
||||
const n = Math.min(frame.n, this.capacity);
|
||||
const posArr = this.positionAttr.array as Float32Array;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const cx = frame.positions[i * 3];
|
||||
const cy = frame.positions[i * 3 + 1];
|
||||
const cz = frame.positions[i * 3 + 2];
|
||||
let dx = 0, dy = 0, dz = 0;
|
||||
if (this.hasPrevious[i] === 1) {
|
||||
dx = cx - this.previousPositions[i * 3];
|
||||
dy = cy - this.previousPositions[i * 3 + 1];
|
||||
dz = cz - this.previousPositions[i * 3 + 2];
|
||||
} else {
|
||||
this.hasPrevious[i] = 1;
|
||||
}
|
||||
// Tail vertex (behind the engram, opposite the velocity vector).
|
||||
posArr[i * 6] = cx - dx * TAIL_SCALE;
|
||||
posArr[i * 6 + 1] = cy - dy * TAIL_SCALE;
|
||||
posArr[i * 6 + 2] = cz - dz * TAIL_SCALE;
|
||||
// Head vertex (current position).
|
||||
posArr[i * 6 + 3] = cx;
|
||||
posArr[i * 6 + 4] = cy;
|
||||
posArr[i * 6 + 5] = cz;
|
||||
// Roll the previous-position buffer forward.
|
||||
this.previousPositions[i * 3] = cx;
|
||||
this.previousPositions[i * 3 + 1] = cy;
|
||||
this.previousPositions[i * 3 + 2] = cz;
|
||||
}
|
||||
this.positionAttr.needsUpdate = true;
|
||||
if (n > this.maxIdx) this.maxIdx = n;
|
||||
this.mesh.geometry.setDrawRange(0, this.maxIdx * 2);
|
||||
}
|
||||
}
|
||||
169
web/src/main.ts
Normal file
169
web/src/main.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { startScene } from "./scene";
|
||||
|
||||
const canvas = document.getElementById("stage") as HTMLCanvasElement;
|
||||
const healthEl = document.getElementById("hud-health");
|
||||
const lmEl = document.getElementById("hud-lm");
|
||||
const galaxyEl = document.getElementById("hud-galaxy");
|
||||
const countEl = document.getElementById("hud-count");
|
||||
const seedBtn = document.getElementById("seed-btn") as HTMLButtonElement | null;
|
||||
const seedNInput = document.getElementById("seed-n") as HTMLInputElement | null;
|
||||
const ingestBtn = document.getElementById("ingest-btn") as HTMLButtonElement | null;
|
||||
const ingestText = document.getElementById("ingest-text") as HTMLTextAreaElement | null;
|
||||
const torusEl = document.getElementById("hud-torus");
|
||||
const synapsesEl = document.getElementById("hud-synapses");
|
||||
const resizeBtn = document.getElementById("resize-btn") as HTMLButtonElement | null;
|
||||
const majorInput = document.getElementById("resize-major") as HTMLInputElement | null;
|
||||
const minorInput = document.getElementById("resize-minor") as HTMLInputElement | null;
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const r = await fetch(url, init);
|
||||
if (!r.ok) {
|
||||
const body = await r.text();
|
||||
throw new Error(`${url} → HTTP ${r.status}: ${body}`);
|
||||
}
|
||||
const body = await r.text();
|
||||
if (body.length === 0) throw new Error(`${url} → empty body`);
|
||||
try {
|
||||
return JSON.parse(body) as T;
|
||||
} catch (e) {
|
||||
throw new Error(`${url} → invalid JSON: ${String((e as Error).message)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function showFatal(msg: string): void {
|
||||
if (galaxyEl) galaxyEl.textContent = msg;
|
||||
if (healthEl) healthEl.textContent = msg;
|
||||
console.error(msg);
|
||||
}
|
||||
|
||||
type HealthBody = {
|
||||
status: string;
|
||||
stage: number;
|
||||
lm_studio?: {
|
||||
reachable: boolean;
|
||||
chat_model_loaded: boolean;
|
||||
embedding_model_loaded: boolean;
|
||||
configured_chat_model: string;
|
||||
configured_embedding_model: string;
|
||||
};
|
||||
};
|
||||
|
||||
function renderHealth(h: HealthBody): void {
|
||||
if (healthEl) healthEl.textContent = `${h.status} (stage ${h.stage})`;
|
||||
if (lmEl) {
|
||||
if (!h.lm_studio) {
|
||||
lmEl.textContent = "—";
|
||||
} else if (!h.lm_studio.reachable) {
|
||||
lmEl.textContent = "unreachable";
|
||||
} else {
|
||||
const chat = h.lm_studio.chat_model_loaded ? "✓ chat" : "✗ chat";
|
||||
const emb = h.lm_studio.embedding_model_loaded ? "✓ embed" : "✗ embed";
|
||||
lmEl.textContent = `${chat} · ${emb}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
fetch("/healthz")
|
||||
.then((r) => (r.ok ? (r.json() as Promise<HealthBody>) : Promise.reject(new Error(`status ${r.status}`))))
|
||||
.then(renderHealth)
|
||||
.catch((err) => {
|
||||
if (healthEl) healthEl.textContent = `unreachable (${String(err.message ?? err)})`;
|
||||
});
|
||||
|
||||
let galaxies: Array<{ id: string; name: string }>;
|
||||
try {
|
||||
galaxies = await fetchJson<Array<{ id: string; name: string }>>("/api/galaxy");
|
||||
} catch (e) {
|
||||
showFatal(`backend unreachable — start \`cargo run\`. (${String((e as Error).message)})`);
|
||||
return;
|
||||
}
|
||||
if (galaxies.length === 0) {
|
||||
showFatal("no galaxies — server should auto-create one on boot");
|
||||
return;
|
||||
}
|
||||
const galaxy = galaxies[0];
|
||||
|
||||
startScene(canvas, galaxy.id, {
|
||||
onCount: (n) => { if (countEl) countEl.textContent = String(n); },
|
||||
onGalaxyName: (name) => { if (galaxyEl) galaxyEl.textContent = name; },
|
||||
onTorus: (majorR, minorR) => {
|
||||
if (torusEl) torusEl.textContent = `R=${majorR.toFixed(0)} r=${minorR.toFixed(0)}`;
|
||||
if (majorInput && document.activeElement !== majorInput) majorInput.value = String(majorR);
|
||||
if (minorInput && document.activeElement !== minorInput) minorInput.value = String(minorR);
|
||||
},
|
||||
onSynapses: (n) => { if (synapsesEl) synapsesEl.textContent = String(n); },
|
||||
});
|
||||
|
||||
if (seedBtn && seedNInput) {
|
||||
seedBtn.addEventListener("click", async () => {
|
||||
const n = Math.max(1, Math.min(5000, parseInt(seedNInput.value, 10) || 200));
|
||||
seedBtn.disabled = true;
|
||||
try {
|
||||
await fetch(`/api/galaxy/${galaxy.id}/seed?n=${n}`, { method: "POST" });
|
||||
} finally {
|
||||
seedBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (resizeBtn && majorInput && minorInput) {
|
||||
resizeBtn.addEventListener("click", async () => {
|
||||
const major = parseFloat(majorInput.value);
|
||||
const minor = parseFloat(minorInput.value);
|
||||
if (!isFinite(major) || !isFinite(minor)) {
|
||||
alert("major_radius and minor_radius must be numbers");
|
||||
return;
|
||||
}
|
||||
resizeBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/api/galaxy/${galaxy.id}/resize`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ major_radius: major, minor_radius: minor }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
alert(`resize failed: HTTP ${res.status}\n${body}`);
|
||||
}
|
||||
} catch (e) {
|
||||
alert(`resize failed: ${String((e as Error).message)}`);
|
||||
} finally {
|
||||
resizeBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (ingestBtn && ingestText) {
|
||||
ingestBtn.addEventListener("click", async () => {
|
||||
const lines = ingestText.value
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
if (lines.length === 0) return;
|
||||
ingestBtn.disabled = true;
|
||||
const before = ingestBtn.textContent;
|
||||
ingestBtn.textContent = `embedding ${lines.length}…`;
|
||||
try {
|
||||
const res = await fetch(`/api/galaxy/${galaxy.id}/ingest`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ texts: lines }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
alert(`ingest failed: HTTP ${res.status}\n${body}`);
|
||||
return;
|
||||
}
|
||||
ingestText.value = "";
|
||||
} catch (e) {
|
||||
alert(`ingest failed: ${String((e as Error).message)}`);
|
||||
} finally {
|
||||
ingestBtn.disabled = false;
|
||||
ingestBtn.textContent = before;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap().catch((e) => console.error("bootstrap failed", e));
|
||||
203
web/src/scene.ts
Normal file
203
web/src/scene.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
|
||||
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
|
||||
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
|
||||
import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
|
||||
|
||||
import { EngramMesh } from "./engram_mesh";
|
||||
import { EngramTrails } from "./engram_trails";
|
||||
import { SynapseMesh } from "./synapse_mesh";
|
||||
import type { EngramSnapshot, EventHandlers, SynapseDto } from "./ws_client";
|
||||
import { connectGalaxy } from "./ws_client";
|
||||
|
||||
export type SceneHooks = {
|
||||
onCount: (n: number) => void;
|
||||
onGalaxyName: (name: string) => void;
|
||||
onTorus: (majorRadius: number, minorRadius: number) => void;
|
||||
onSynapses?: (count: number) => void;
|
||||
};
|
||||
|
||||
// State-color table mirrored from EngramMesh; needed here so we can pre-paint
|
||||
// synapse endpoints when an engram first arrives.
|
||||
const STATE_COLOR: Record<string, [number, number, number]> = {
|
||||
idle: [1.0, 0.72, 0.42],
|
||||
searching: [1.0, 0.88, 0.40],
|
||||
conversing: [0.82, 0.64, 1.0],
|
||||
synthesizing: [0.43, 0.91, 0.72],
|
||||
memorize: [0.65, 0.85, 1.0],
|
||||
decaying: [1.0, 0.48, 0.48],
|
||||
deprecated: [0.49, 0.53, 0.58],
|
||||
};
|
||||
|
||||
/**
|
||||
* Scene: dark background, axis helper, wireframe torus boundary, engram
|
||||
* point-cloud fed by a WebSocket binary stream of position frames.
|
||||
*
|
||||
* Per the Topology Pivot, the world is a fixed solid donut; the wireframe
|
||||
* torus shows the boundary the engrams live inside.
|
||||
*/
|
||||
export function startScene(canvas: HTMLCanvasElement, galaxyId: string, hooks: SceneHooks): void {
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight, false);
|
||||
// Render with sRGB output and ACES-style tone-mapping so the bloom-amplified
|
||||
// additive engrams don't clip to white. This makes the cinematic glow read
|
||||
// properly against the dark background.
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.0;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x050608);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
55,
|
||||
window.innerWidth / window.innerHeight,
|
||||
0.1,
|
||||
10_000,
|
||||
);
|
||||
// Default view positions the camera above + behind the donut so it reads
|
||||
// immediately as a donut on first paint. Recomputed when we get the real
|
||||
// shape from `Hello`.
|
||||
camera.position.set(0, 180, 300);
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
const controls = new OrbitControls(camera, canvas);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
|
||||
scene.add(new THREE.AxesHelper(20));
|
||||
|
||||
// Torus boundary — the visible wall of the Space of Recollection. Built
|
||||
// with unit radii and scaled per (majorR, minorR) update so we can avoid
|
||||
// rebuilding geometry on every resize.
|
||||
const torusGeo = new THREE.TorusGeometry(1, 1, 16, 64);
|
||||
const torusMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x344058,
|
||||
wireframe: true,
|
||||
transparent: true,
|
||||
opacity: 0.22,
|
||||
});
|
||||
const torusMesh = new THREE.Mesh(torusGeo, torusMat);
|
||||
// TorusGeometry sits in the XY plane (extending along z by tube radius).
|
||||
// Our spine is the circle in the z=0 plane, so no rotation needed.
|
||||
scene.add(torusMesh);
|
||||
|
||||
// Trails + synapses go in FIRST so they render *under* the engram dots —
|
||||
// when the head sits on top of a line endpoint, the dot occludes the join.
|
||||
const trails = new EngramTrails(scene);
|
||||
const synapses = new SynapseMesh(scene);
|
||||
const engrams = new EngramMesh(scene);
|
||||
// Forward each engram's base colour to the line renderers so endpoints
|
||||
// match the head dot's hue.
|
||||
engrams.onColorAssigned = (idx, r, g, b) => {
|
||||
trails.setEngramColor(idx, r, g, b);
|
||||
};
|
||||
|
||||
// Helper: register a fresh engram with the synapse mesh so any pending
|
||||
// synapse referencing it can be wired up. Re-applies state colour.
|
||||
function registerEngramForSynapses(snapshot: EngramSnapshot): void {
|
||||
const color = STATE_COLOR[snapshot.state] ?? STATE_COLOR.idle;
|
||||
synapses.registerEngram(snapshot.id, snapshot.instance_idx, color[0], color[1], color[2]);
|
||||
}
|
||||
|
||||
function applySynapse(s: SynapseDto): void {
|
||||
synapses.addSynapse(s.id, s.a, s.b, s.weight);
|
||||
hooks.onSynapses?.(synapses.count());
|
||||
}
|
||||
|
||||
// Post-processing: bloom for the cinematic glow. Tuned for additive
|
||||
// particle sources — low threshold (most particle pixels are bright
|
||||
// enough to bloom), moderate strength, small radius for crisp halos
|
||||
// rather than washed-out smear.
|
||||
const composer = new EffectComposer(renderer);
|
||||
composer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
composer.setSize(window.innerWidth, window.innerHeight);
|
||||
composer.addPass(new RenderPass(scene, camera));
|
||||
const bloom = new UnrealBloomPass(
|
||||
new THREE.Vector2(window.innerWidth, window.innerHeight),
|
||||
0.4, // strength — restrained, lets the dots stay crisp
|
||||
0.5, // radius
|
||||
0.4, // threshold — only the brightest cores bloom (was 0.1, blew out)
|
||||
);
|
||||
composer.addPass(bloom);
|
||||
composer.addPass(new OutputPass());
|
||||
|
||||
function applyTorus(majorR: number, minorR: number): void {
|
||||
// Three.js's TorusGeometry with major=1, minor=1 produces a torus where
|
||||
// the tube radius and the major radius are both 1. Non-uniform scaling
|
||||
// breaks that — scaling x/y by `majorR` would also stretch the tube
|
||||
// cross-section. Easiest: rebuild geometry on each resize. This happens
|
||||
// rarely (initial Hello + explicit /resize calls).
|
||||
torusMesh.geometry.dispose();
|
||||
torusMesh.geometry = new THREE.TorusGeometry(majorR, minorR, 16, 96);
|
||||
// Fit camera so the donut is comfortably framed.
|
||||
// Lower height factor (0.30 vs 0.55) gives a more head-on view that
|
||||
// reads the donut shape better and shows the front portal clearly.
|
||||
const fitDist = (majorR + minorR) * 2.4;
|
||||
camera.position.set(0, fitDist * 0.30, fitDist);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.far = Math.max(camera.far, fitDist * 6);
|
||||
camera.updateProjectionMatrix();
|
||||
hooks.onTorus(majorR, minorR);
|
||||
}
|
||||
|
||||
const handlers: EventHandlers = {
|
||||
onHello: (galaxy, list, helloSynapses) => {
|
||||
hooks.onGalaxyName(galaxy.name);
|
||||
torusMesh.position.set(galaxy.center[0], galaxy.center[1], galaxy.center[2]);
|
||||
applyTorus(galaxy.major_radius, galaxy.minor_radius);
|
||||
engrams.applyHello(list);
|
||||
// Register every engram with the synapse mesh BEFORE replaying synapses
|
||||
// so the (a, b) UUID lookups resolve immediately instead of going to
|
||||
// the pending queue.
|
||||
for (const e of list) registerEngramForSynapses(e);
|
||||
for (const s of helloSynapses) applySynapse(s);
|
||||
hooks.onCount(engrams.count());
|
||||
},
|
||||
onEngramCreated: (snapshot) => {
|
||||
engrams.upsertEngram(snapshot);
|
||||
registerEngramForSynapses(snapshot);
|
||||
hooks.onCount(engrams.count());
|
||||
},
|
||||
onSynapseCreated: (synapse) => {
|
||||
applySynapse(synapse);
|
||||
},
|
||||
onTorusUpdated: (center, majorR, minorR) => {
|
||||
torusMesh.position.set(center[0], center[1], center[2]);
|
||||
applyTorus(majorR, minorR);
|
||||
},
|
||||
onPositionFrame: (frame) => {
|
||||
engrams.applyPositionFrame(frame);
|
||||
trails.applyPositionFrame(frame);
|
||||
synapses.applyPositionFrame(frame);
|
||||
hooks.onCount(engrams.count());
|
||||
},
|
||||
onClose: () => {
|
||||
console.warn("ws closed — refresh to reconnect");
|
||||
},
|
||||
onError: (e) => console.error("ws error", e),
|
||||
};
|
||||
connectGalaxy(galaxyId, handlers);
|
||||
|
||||
function onResize(): void {
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h, false);
|
||||
composer.setSize(w, h);
|
||||
bloom.setSize(w, h);
|
||||
}
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
const startedAt = performance.now();
|
||||
function animate(): void {
|
||||
controls.update();
|
||||
engrams.tick((performance.now() - startedAt) / 1000);
|
||||
composer.render();
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
animate();
|
||||
}
|
||||
182
web/src/synapse_mesh.ts
Normal file
182
web/src/synapse_mesh.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import type { PositionFrame } from "./ws_client";
|
||||
|
||||
/**
|
||||
* Renders synapses (Stage 3) as additive line segments connecting two
|
||||
* engrams. Endpoints are looked up from the engram position buffer on every
|
||||
* position frame, so the lines track engram motion automatically.
|
||||
*
|
||||
* Uses a fixed-capacity vertex buffer; one segment per synapse → 2 vertices
|
||||
* per synapse → 6 floats of position per synapse.
|
||||
*/
|
||||
const VERT_SHADER = /* glsl */ `
|
||||
attribute float aAlpha;
|
||||
varying vec3 vColor;
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
vColor = color;
|
||||
vAlpha = aAlpha;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAG_SHADER = /* glsl */ `
|
||||
varying vec3 vColor;
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
gl_FragColor = vec4(vColor, vAlpha);
|
||||
}
|
||||
`;
|
||||
|
||||
type SynapseEntry = {
|
||||
/** Slot in the line-segments geometry (0..capacity-1). */
|
||||
slot: number;
|
||||
/** instance_idx of the two engrams this connects. */
|
||||
aIdx: number;
|
||||
bIdx: number;
|
||||
weight: number;
|
||||
};
|
||||
|
||||
type Pending = {
|
||||
id: string;
|
||||
a: string;
|
||||
b: string;
|
||||
weight: number;
|
||||
};
|
||||
|
||||
export class SynapseMesh {
|
||||
private readonly mesh: THREE.LineSegments;
|
||||
private readonly positionAttr: THREE.BufferAttribute;
|
||||
private readonly colorAttr: THREE.BufferAttribute;
|
||||
private readonly alphaAttr: THREE.BufferAttribute;
|
||||
private readonly capacity: number;
|
||||
/** synapse_id → entry. */
|
||||
private readonly bySynapseId = new Map<string, SynapseEntry>();
|
||||
/** Engram UUID → instance_idx, populated as engrams arrive. */
|
||||
private readonly engramIdx = new Map<string, number>();
|
||||
/** Synapses waiting for one of their endpoints to be registered. */
|
||||
private readonly pending: Pending[] = [];
|
||||
/** Engram colour cache so we don't recompute on every frame. */
|
||||
private readonly engramColor: Float32Array;
|
||||
private nextSlot = 0;
|
||||
|
||||
constructor(scene: THREE.Scene, capacity = 8000) {
|
||||
this.capacity = capacity;
|
||||
this.engramColor = new Float32Array(5000 * 3);
|
||||
|
||||
const vertexCount = capacity * 2;
|
||||
const geom = new THREE.BufferGeometry();
|
||||
this.positionAttr = new THREE.BufferAttribute(new Float32Array(vertexCount * 3), 3);
|
||||
this.colorAttr = new THREE.BufferAttribute(new Float32Array(vertexCount * 3), 3);
|
||||
this.alphaAttr = new THREE.BufferAttribute(new Float32Array(vertexCount), 1);
|
||||
this.positionAttr.setUsage(THREE.DynamicDrawUsage);
|
||||
this.colorAttr.setUsage(THREE.DynamicDrawUsage);
|
||||
this.alphaAttr.setUsage(THREE.DynamicDrawUsage);
|
||||
geom.setAttribute("position", this.positionAttr);
|
||||
geom.setAttribute("color", this.colorAttr);
|
||||
geom.setAttribute("aAlpha", this.alphaAttr);
|
||||
geom.setDrawRange(0, 0);
|
||||
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: VERT_SHADER,
|
||||
fragmentShader: FRAG_SHADER,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
|
||||
this.mesh = new THREE.LineSegments(geom, mat);
|
||||
this.mesh.frustumCulled = false;
|
||||
scene.add(this.mesh);
|
||||
}
|
||||
|
||||
/** Register an engram so synapses referencing it can be wired up. */
|
||||
registerEngram(id: string, instanceIdx: number, r: number, g: number, b: number): void {
|
||||
this.engramIdx.set(id, instanceIdx);
|
||||
if (instanceIdx * 3 + 2 < this.engramColor.length) {
|
||||
this.engramColor[instanceIdx * 3] = r;
|
||||
this.engramColor[instanceIdx * 3 + 1] = g;
|
||||
this.engramColor[instanceIdx * 3 + 2] = b;
|
||||
}
|
||||
// Try to materialise any pending synapses now that this engram is known.
|
||||
if (this.pending.length > 0) {
|
||||
const stillPending: Pending[] = [];
|
||||
for (const p of this.pending) {
|
||||
if (!this.tryMaterialise(p)) stillPending.push(p);
|
||||
}
|
||||
this.pending.length = 0;
|
||||
this.pending.push(...stillPending);
|
||||
}
|
||||
}
|
||||
|
||||
/** Add a synapse by engram UUIDs. Defers if either endpoint is unknown. */
|
||||
addSynapse(id: string, a: string, b: string, weight: number): void {
|
||||
if (this.bySynapseId.has(id)) return;
|
||||
const p: Pending = { id, a, b, weight };
|
||||
if (!this.tryMaterialise(p)) {
|
||||
this.pending.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
private tryMaterialise(p: Pending): boolean {
|
||||
const aIdx = this.engramIdx.get(p.a);
|
||||
const bIdx = this.engramIdx.get(p.b);
|
||||
if (aIdx === undefined || bIdx === undefined) return false;
|
||||
if (this.nextSlot >= this.capacity) {
|
||||
console.warn("SynapseMesh capacity reached; ignoring further synapses");
|
||||
return true; // treat as resolved so we stop waiting on it
|
||||
}
|
||||
const slot = this.nextSlot++;
|
||||
this.bySynapseId.set(p.id, { slot, aIdx, bIdx, weight: p.weight });
|
||||
this.applyEndpointColors(slot, aIdx, bIdx);
|
||||
// Alpha tied to weight; a small floor so very weak synapses still register.
|
||||
const alpha = Math.max(0.08, Math.min(0.75, p.weight));
|
||||
const alphaArr = this.alphaAttr.array as Float32Array;
|
||||
alphaArr[slot * 2] = alpha;
|
||||
alphaArr[slot * 2 + 1] = alpha;
|
||||
this.alphaAttr.needsUpdate = true;
|
||||
this.mesh.geometry.setDrawRange(0, this.nextSlot * 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Apply the latest position frame to all synapse endpoints. */
|
||||
applyPositionFrame(frame: PositionFrame): void {
|
||||
const posArr = this.positionAttr.array as Float32Array;
|
||||
const src = frame.positions;
|
||||
const n = frame.n;
|
||||
let dirty = false;
|
||||
for (const entry of this.bySynapseId.values()) {
|
||||
const a = entry.aIdx, b = entry.bIdx;
|
||||
if (a >= n || b >= n) continue;
|
||||
const slot = entry.slot;
|
||||
posArr[slot * 6] = src[a * 3];
|
||||
posArr[slot * 6 + 1] = src[a * 3 + 1];
|
||||
posArr[slot * 6 + 2] = src[a * 3 + 2];
|
||||
posArr[slot * 6 + 3] = src[b * 3];
|
||||
posArr[slot * 6 + 4] = src[b * 3 + 1];
|
||||
posArr[slot * 6 + 5] = src[b * 3 + 2];
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) this.positionAttr.needsUpdate = true;
|
||||
}
|
||||
|
||||
/** Number of synapses currently rendered (for HUD). */
|
||||
count(): number {
|
||||
return this.bySynapseId.size;
|
||||
}
|
||||
|
||||
/** Re-paint a synapse's endpoint colours after one of its engrams updates. */
|
||||
private applyEndpointColors(slot: number, aIdx: number, bIdx: number): void {
|
||||
const colorArr = this.colorAttr.array as Float32Array;
|
||||
colorArr[slot * 6] = this.engramColor[aIdx * 3];
|
||||
colorArr[slot * 6 + 1] = this.engramColor[aIdx * 3 + 1];
|
||||
colorArr[slot * 6 + 2] = this.engramColor[aIdx * 3 + 2];
|
||||
colorArr[slot * 6 + 3] = this.engramColor[bIdx * 3];
|
||||
colorArr[slot * 6 + 4] = this.engramColor[bIdx * 3 + 1];
|
||||
colorArr[slot * 6 + 5] = this.engramColor[bIdx * 3 + 2];
|
||||
this.colorAttr.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
130
web/src/ws_client.ts
Normal file
130
web/src/ws_client.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* WebSocket client for the Sophia simulation event stream.
|
||||
*
|
||||
* Wire protocol matches `crates/sophia-server/src/ws.rs`:
|
||||
* - text frames: JSON, tagged via `type` field
|
||||
* - binary frames: position frames, 12-byte header + float region
|
||||
* [u32 LE tag=0x01][u32 LE t_ms][u32 LE n][n × (f32 LE x, f32 LE y, f32 LE z)]
|
||||
* The header is 12 bytes (not 9) so the float region is 4-byte aligned and
|
||||
* can be wrapped as a Float32Array view without copying.
|
||||
*/
|
||||
|
||||
export type EngramSnapshot = {
|
||||
id: string;
|
||||
instance_idx: number;
|
||||
position: [number, number, number];
|
||||
size: number;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export type GalaxyInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
engram_count: number;
|
||||
center: [number, number, number];
|
||||
major_radius: number;
|
||||
minor_radius: number;
|
||||
};
|
||||
|
||||
export type SynapseDto = {
|
||||
id: string;
|
||||
a: string;
|
||||
b: string;
|
||||
weight: number;
|
||||
};
|
||||
|
||||
export type SimEventMsg =
|
||||
| {
|
||||
type: "hello";
|
||||
galaxy: GalaxyInfo;
|
||||
engrams: EngramSnapshot[];
|
||||
synapses: SynapseDto[];
|
||||
}
|
||||
| { type: "engram_created"; snapshot: EngramSnapshot }
|
||||
| { type: "synapse_created"; synapse: SynapseDto }
|
||||
| {
|
||||
type: "torus_updated";
|
||||
center: [number, number, number];
|
||||
major_radius: number;
|
||||
minor_radius: number;
|
||||
};
|
||||
|
||||
export type PositionFrame = {
|
||||
t_ms: number;
|
||||
n: number;
|
||||
/** Flat array, length 3*n: x,y,z,x,y,z,... in instance_idx order. */
|
||||
positions: Float32Array;
|
||||
};
|
||||
|
||||
export type EventHandlers = {
|
||||
onHello?: (
|
||||
galaxy: GalaxyInfo,
|
||||
engrams: EngramSnapshot[],
|
||||
synapses: SynapseDto[],
|
||||
) => void;
|
||||
onEngramCreated?: (snapshot: EngramSnapshot) => void;
|
||||
onSynapseCreated?: (synapse: SynapseDto) => void;
|
||||
onTorusUpdated?: (
|
||||
center: [number, number, number],
|
||||
majorRadius: number,
|
||||
minorRadius: number,
|
||||
) => void;
|
||||
onPositionFrame?: (frame: PositionFrame) => void;
|
||||
onClose?: (ev: CloseEvent) => void;
|
||||
onError?: (ev: Event) => void;
|
||||
};
|
||||
|
||||
export function connectGalaxy(galaxyId: string, handlers: EventHandlers): WebSocket {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = `${proto}//${window.location.host}/ws/galaxy/${galaxyId}/events`;
|
||||
const ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
try {
|
||||
if (typeof ev.data === "string") {
|
||||
const msg = JSON.parse(ev.data) as SimEventMsg;
|
||||
switch (msg.type) {
|
||||
case "hello":
|
||||
handlers.onHello?.(msg.galaxy, msg.engrams, msg.synapses);
|
||||
break;
|
||||
case "engram_created":
|
||||
handlers.onEngramCreated?.(msg.snapshot);
|
||||
break;
|
||||
case "synapse_created":
|
||||
handlers.onSynapseCreated?.(msg.synapse);
|
||||
break;
|
||||
case "torus_updated":
|
||||
handlers.onTorusUpdated?.(msg.center, msg.major_radius, msg.minor_radius);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const frame = decodePositionFrame(ev.data as ArrayBuffer);
|
||||
if (frame) handlers.onPositionFrame?.(frame);
|
||||
}
|
||||
} catch (e) {
|
||||
// Surface any decode error instead of silently dropping the frame —
|
||||
// a malformed binary frame used to silently kill the position stream.
|
||||
console.error("ws message handler failed", e);
|
||||
}
|
||||
});
|
||||
ws.addEventListener("close", (ev) => handlers.onClose?.(ev));
|
||||
ws.addEventListener("error", (ev) => handlers.onError?.(ev));
|
||||
return ws;
|
||||
}
|
||||
|
||||
function decodePositionFrame(buf: ArrayBuffer): PositionFrame | null {
|
||||
const view = new DataView(buf);
|
||||
const tag = view.getUint32(0, true);
|
||||
if (tag !== 0x01) return null;
|
||||
const t_ms = view.getUint32(4, true);
|
||||
const n = view.getUint32(8, true);
|
||||
const expected = 12 + n * 12;
|
||||
if (buf.byteLength < expected) return null;
|
||||
// Float region starts at byte 12 — 4-byte aligned, so we can wrap it as a
|
||||
// Float32Array view without copying. (Float32Array constructor throws if
|
||||
// the byte offset is not a multiple of 4; that's why the header is padded
|
||||
// to 12 bytes instead of a tighter 9 bytes.)
|
||||
const positions = new Float32Array(buf, 12, n * 3);
|
||||
return { t_ms, n, positions };
|
||||
}
|
||||
18
web/tsconfig.json
Normal file
18
web/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
20
web/vite.config.ts
Normal file
20
web/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// In dev, the Vite server runs on :5173 and proxies API/WS calls to the
|
||||
// Rust server on :7777 so the same fetch/WebSocket code works in both modes.
|
||||
// In `build`, the output goes to `dist/` which the Rust server statically serves.
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
target: "es2022",
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/healthz": "http://127.0.0.1:7777",
|
||||
"/api": "http://127.0.0.1:7777",
|
||||
"/ws": { target: "ws://127.0.0.1:7777", ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user