import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js'; import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js'; // ---- Config -------------------------------------------------------------- const MM = 0.001; // millimetre -> Three.js metre scale const statusEl = document.getElementById('status'); const countEl = document.getElementById('count'); // ---- Scene setup --------------------------------------------------------- const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0a0a14); const camera = new THREE.PerspectiveCamera( 55, window.innerWidth / window.innerHeight, 0.01, 100 ); camera.position.set(0.75, 0.9, 1.6); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.target.set(0.75, 0.2, 0.35); // centre of a 1500x700 build in metres scene.add(new THREE.AmbientLight(0x8090b0, 0.8)); const key = new THREE.DirectionalLight(0xffffff, 1.0); key.position.set(2, 3, 1); scene.add(key); // ---- Ghost tint gradients (from recovered GhostType data) ---------------- const GHOST_GRADIENTS = { Red: { top: 0xf65151, bottom: 0xff2678 }, Yellow: { top: 0xb57f0b, bottom: 0xfff35d }, Blue: { top: 0x529eff, bottom: 0x51eaf1 }, }; // We don't have per-ghost colour on the client snapshot yet, so default to Blue; // colour wiring comes when we feed the enriched roster in. Kept simple for M1. function ghostMaterial(colorKey = 'Blue') { const g = GHOST_GRADIENTS[colorKey] || GHOST_GRADIENTS.Blue; return new THREE.MeshStandardMaterial({ color: g.bottom, emissive: g.top, emissiveIntensity: 0.6, transparent: true, opacity: 0.85, roughness: 0.4, }); } // ---- Build footprint + anchor markers (drawn once from /api/scene) -------- let worldDims = { x: 1500, y: 600, z: 700 }; function drawBuildFootprint() { const w = worldDims.x * MM; const d = worldDims.z * MM; const geo = new THREE.PlaneGeometry(w, d); const mat = new THREE.MeshStandardMaterial({ color: 0x141422, roughness: 0.9, side: THREE.DoubleSide, }); const plane = new THREE.Mesh(geo, mat); plane.rotation.x = -Math.PI / 2; // origin is a corner, so shift plane so corner sits at (0,0,0) plane.position.set(w / 2, 0, d / 2); scene.add(plane); const grid = new THREE.GridHelper(Math.max(w, d), 15, 0x334466, 0x223355); grid.position.set(w / 2, 0.001, d / 2); scene.add(grid); } function drawAnchors(anchors) { const mat = new THREE.MeshStandardMaterial({ color: 0x00e5c0, emissive: 0x00e5c0, emissiveIntensity: 0.5, }); anchors.forEach((a) => { const s = (a.fiducialSizeMm || 40) * MM; const geo = new THREE.BoxGeometry(s, s * 0.15, s); const m = new THREE.Mesh(geo, mat); m.position.set(a.position.x * MM, a.position.y * MM, a.position.z * MM); scene.add(m); }); } // ---- Ghost pool ---------------------------------------------------------- const ghostMeshes = new Map(); // spawnId -> THREE.Mesh function ensureGhost(spawnId) { if (ghostMeshes.has(spawnId)) return ghostMeshes.get(spawnId); const geo = new THREE.IcosahedronGeometry(0.05, 1); // placeholder until OBJ wired const mesh = new THREE.Mesh(geo, ghostMaterial('Blue')); scene.add(mesh); ghostMeshes.set(spawnId, mesh); return mesh; } function applyState(ghosts) { const seen = new Set(); for (const g of ghosts) { seen.add(g.spawnId); const mesh = ensureGhost(g.spawnId); mesh.position.set(g.pos.x * MM, g.pos.y * MM, g.pos.z * MM); mesh.rotation.y = (g.yawDeg * Math.PI) / 180; } // remove any ghosts no longer present for (const [id, mesh] of ghostMeshes) { if (!seen.has(id)) { scene.remove(mesh); ghostMeshes.delete(id); } } countEl.textContent = String(ghosts.length); } // ---- WebSocket sync ------------------------------------------------------ function connect() { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const ws = new WebSocket(`${proto}://${location.host}/sync`); ws.onopen = () => { statusEl.textContent = 'connected'; statusEl.className = 'ok'; }; ws.onclose = () => { statusEl.textContent = 'reconnecting…'; statusEl.className = 'warn'; setTimeout(connect, 1500); }; ws.onerror = () => ws.close(); ws.onmessage = (ev) => { const msg = JSON.parse(ev.data); if (msg.type === 'hello') { if (msg.world?.buildSize) worldDims = msg.world.buildSize; drawBuildFootprint(); if (msg.anchors) drawAnchors(msg.anchors); } else if (msg.type === 'state') { applyState(msg.ghosts); } }; } connect(); // ---- Render loop --------------------------------------------------------- function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } animate(); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });