166 lines
5.1 KiB
JavaScript
166 lines
5.1 KiB
JavaScript
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/addons/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);
|
|
});
|