spaxel/dashboard/js/viz3d.js
jedarden bcd19ad756 feat(dashboard): 3D spatial visualization with humanoid SkinnedMesh figures
Implements plan item 17 — full Three.js Phase 3 visualization layer:

- Room bounds: floor, ceiling, semi-transparent walls and wireframe edges
  built from registry_state room config (width/depth/height/origin)
- Floor plan texture: upload any image via status-bar button; mapped to
  ground plane via THREE.TextureLoader
- Humanoid figures: SkinnedMesh + AnimationMixer with 13-bone skeleton;
  four AnimationClip postures (standing, walking, seated, lying); smooth
  0.35 s crossfade transitions; walking speed scales animation timeScale;
  figures orient in direction of travel
- Vertical pillar anchors: line from floor to ceiling at each blob position
- Footprint trails: floor-level Line geometry following blob.trail history
- Node meshes: OctahedronGeometry at registry node 3-D positions; link
  lines redrawn whenever link_active/inactive events arrive
- View presets: 3D perspective, top-down orthographic, first-person follow
  (lerps camera to track first active blob)
- WebSocket: handles registry_state, loc_update, link_inactive message types
  newly routed through handleJSONMessage; existing CSI/motion pipeline intact

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 00:26:25 -04:00

566 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Spaxel Viz3D Phase 3 spatial visualization
*
* Handles: room bounds, floor-plan texture, humanoid SkinnedMesh + AnimationMixer,
* node meshes, link lines, blob trails, vertical pillar anchors, view presets.
*
* Depends on Three.js r128 being loaded before this script.
*/
const Viz3D = (function () {
'use strict';
// ── module state ──────────────────────────────────────────────────────────
let _scene, _camera, _controls, _clock;
let _room = null;
let _roomObjs = { floor: null, ceiling: null, walls: [], edges: null };
let _nodeMeshes = new Map(); // mac → THREE.Mesh
let _linkLines = new Map(); // id → THREE.Line
let _activeLinks = new Map(); // id → { node_mac, peer_mac }
let _blobs3D = new Map(); // blobId → blobObj
let _mixers = [];
let _floorTex = null;
let _followId = null;
const BLOB_COLORS = [0xef5350, 0x66bb6a, 0x42a5f5, 0xffa726, 0xab47bc, 0x26c6da];
const TRAIL_COLORS = [0xff8a80, 0xa5d6a7, 0x90caf9, 0xffcc80, 0xce93d8, 0x80deea];
// ── init / tick ───────────────────────────────────────────────────────────
function init(scene, camera, controls) {
_scene = scene;
_camera = camera;
_controls = controls;
_clock = new THREE.Clock();
}
function update() {
const dt = _clock.getDelta();
for (let i = 0; i < _mixers.length; i++) _mixers[i].update(dt);
if (_followId !== null) {
const b = _blobs3D.get(_followId);
if (b) {
const p = b.group.position;
_camera.position.lerp(new THREE.Vector3(p.x + 1.5, 1.8, p.z + 3.5), 0.07);
_controls.target.lerp(new THREE.Vector3(p.x, 1.3, p.z), 0.07);
_controls.update();
}
}
}
// ── room bounds ───────────────────────────────────────────────────────────
function clearRoom() {
const r = _roomObjs;
if (r.floor) _scene.remove(r.floor);
if (r.ceiling) _scene.remove(r.ceiling);
if (r.edges) _scene.remove(r.edges);
r.walls.forEach(w => _scene.remove(w));
_roomObjs = { floor: null, ceiling: null, walls: [], edges: null };
}
function applyRoom(cfg) {
clearRoom();
_room = cfg;
const w = cfg.width, d = cfg.depth, h = cfg.height;
const ox = cfg.origin_x || 0, oz = cfg.origin_z || 0;
const cx = ox + w / 2, cz = oz + d / 2;
// floor
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(w, d),
new THREE.MeshLambertMaterial({ color: 0x1e2a3a, map: _floorTex, side: THREE.FrontSide })
);
floor.rotation.x = -Math.PI / 2;
floor.position.set(cx, 0.001, cz);
_scene.add(floor);
_roomObjs.floor = floor;
// ceiling (dim, transparent)
const ceil = new THREE.Mesh(
new THREE.PlaneGeometry(w, d),
new THREE.MeshLambertMaterial({ color: 0x1a2030, transparent: true, opacity: 0.25, side: THREE.BackSide })
);
ceil.rotation.x = Math.PI / 2;
ceil.position.set(cx, h, cz);
_scene.add(ceil);
_roomObjs.ceiling = ceil;
// walls (semi-transparent, double-sided)
const wallMat = new THREE.MeshLambertMaterial({ color: 0x243040, transparent: true, opacity: 0.13, side: THREE.DoubleSide });
[
{ pw: w, ry: 0, px: cx, py: h / 2, pz: oz },
{ pw: w, ry: Math.PI, px: cx, py: h / 2, pz: oz + d },
{ pw: d, ry: Math.PI / 2, px: ox, py: h / 2, pz: cz },
{ pw: d, ry:-Math.PI / 2, px: ox + w, py: h / 2, pz: cz },
].forEach(({ pw, ry, px, py, pz }) => {
const m = new THREE.Mesh(new THREE.PlaneGeometry(pw, h), wallMat);
m.rotation.y = ry;
m.position.set(px, py, pz);
_scene.add(m);
_roomObjs.walls.push(m);
});
// wireframe edges (floor rect + ceiling rect + 4 verticals)
const e = [ox,oz, ox+w,oz, ox+w,oz+d, ox,oz+d, ox,oz]; // perimeter loop
const verts = [];
for (let i = 0; i < e.length - 1; i += 2) {
verts.push(e[i],0,e[i+1], e[i+2],0,e[i+3]);
verts.push(e[i],h,e[i+1], e[i+2],h,e[i+3]);
}
[[ox,oz],[ox+w,oz],[ox+w,oz+d],[ox,oz+d]].forEach(([ex, ez]) => {
verts.push(ex,0,ez, ex,h,ez);
});
const edgeGeo = new THREE.BufferGeometry();
edgeGeo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
const edges = new THREE.LineSegments(edgeGeo, new THREE.LineBasicMaterial({ color: 0x556677, transparent: true, opacity: 0.55 }));
_scene.add(edges);
_roomObjs.edges = edges;
}
// ── floor plan texture ────────────────────────────────────────────────────
function uploadFloorPlan(file) {
const url = URL.createObjectURL(file);
new THREE.TextureLoader().load(url, function (tex) {
_floorTex = tex;
if (_roomObjs.floor) {
_roomObjs.floor.material.map = tex;
_roomObjs.floor.material.needsUpdate = true;
}
URL.revokeObjectURL(url);
});
}
// ── node meshes ───────────────────────────────────────────────────────────
function applyNodeRegistry(nodes) {
const incoming = new Set(nodes.map(n => n.mac));
_nodeMeshes.forEach((m, mac) => {
if (!incoming.has(mac)) { _scene.remove(m); _nodeMeshes.delete(mac); }
});
nodes.forEach(n => {
let m = _nodeMeshes.get(n.mac);
if (!m) {
const col = n.virtual ? 0x80cbc4 : 0x4fc3f7;
m = new THREE.Mesh(
new THREE.OctahedronGeometry(0.12, 0),
new THREE.MeshPhongMaterial({ color: col, emissive: col, emissiveIntensity: 0.35, shininess: 60 })
);
_scene.add(m);
_nodeMeshes.set(n.mac, m);
}
m.position.set(n.pos_x, n.pos_y, n.pos_z);
});
_rebuildLinkLines();
}
function applyLinks(links) {
_activeLinks.clear();
(links || []).forEach(l => {
const id = l.id || `${l.node_mac}:${l.peer_mac}`;
_activeLinks.set(id, l);
});
_rebuildLinkLines();
}
function _rebuildLinkLines() {
_linkLines.forEach(l => _scene.remove(l));
_linkLines.clear();
_activeLinks.forEach((link, id) => {
const a = _nodeMeshes.get(link.node_mac);
const b = _nodeMeshes.get(link.peer_mac);
if (!a || !b) return;
const geo = new THREE.BufferGeometry().setFromPoints([a.position.clone(), b.position.clone()]);
const line = new THREE.Line(geo, new THREE.LineBasicMaterial({ color: 0x2a5a7a, transparent: true, opacity: 0.6 }));
_scene.add(line);
_linkLines.set(id, line);
});
}
// ── humanoid SkinnedMesh factory ──────────────────────────────────────────
//
// Bone index constants
const BI = { ROOT:0, PELVIS:1, SPINE:2, CHEST:3, HEAD:4,
LS:5, LE:6, RS:7, RE:8,
LH:9, LK:10, RH:11, RK:12 };
function _buildBones() {
const bones = [];
function b(name, x, y, z) {
const bn = new THREE.Bone();
bn.name = name;
bn.position.set(x, y, z);
bones.push(bn);
return bn;
}
// local positions relative to parent
const root = b('root', 0, 0, 0);
const pelvis = b('pelvis', 0, 0.9, 0);
const spine = b('spine', 0, 0.25, 0); // world y ≈ 1.15
const chest = b('chest', 0, 0.25, 0); // world y ≈ 1.4
const head = b('head', 0, 0.22, 0); // world y ≈ 1.62
const ls = b('l_shoulder', -0.18, 0, 0); // world (-0.18, 1.4, 0)
const le = b('l_elbow', -0.25, 0, 0); // world (-0.43, 1.4, 0)
const rs = b('r_shoulder', 0.18, 0, 0);
const re = b('r_elbow', 0.25, 0, 0);
const lh = b('l_hip', -0.1, 0, 0); // world (-0.1, 0.9, 0)
const lk = b('l_knee', 0, -0.44, 0); // world (-0.1, 0.46, 0)
const rh = b('r_hip', 0.1, 0, 0);
const rk = b('r_knee', 0, -0.44, 0);
root.add(pelvis);
pelvis.add(spine);
spine.add(chest);
chest.add(head);
chest.add(ls); ls.add(le);
chest.add(rs); rs.add(re);
pelvis.add(lh); lh.add(lk);
pelvis.add(rh); rh.add(rk);
return bones;
}
// Merge an array of {geo, boneIdx} into one BufferGeometry with skinning attrs.
function _mergeWithSkin(parts) {
let totalVerts = 0;
const indexArrays = [];
parts.forEach(({ geo }) => {
if (!geo.index) geo = geo.toNonIndexed();
totalVerts += geo.attributes.position.count;
});
const pos = new Float32Array(totalVerts * 3);
const nrm = new Float32Array(totalVerts * 3);
const si = new Float32Array(totalVerts * 4); // skinIndex
const sw = new Float32Array(totalVerts * 4); // skinWeight
const idxArr = [];
let vOff = 0;
parts.forEach(({ geo: g, boneIdx }) => {
if (!g.index) g = g.toNonIndexed();
const p = g.attributes.position.array;
const n = g.attributes.normal ? g.attributes.normal.array : null;
const cnt = g.attributes.position.count;
for (let i = 0; i < cnt; i++) {
pos[(vOff+i)*3+0] = p[i*3+0];
pos[(vOff+i)*3+1] = p[i*3+1];
pos[(vOff+i)*3+2] = p[i*3+2];
if (n) { nrm[(vOff+i)*3+0]=n[i*3+0]; nrm[(vOff+i)*3+1]=n[i*3+1]; nrm[(vOff+i)*3+2]=n[i*3+2]; }
si[(vOff+i)*4] = boneIdx;
sw[(vOff+i)*4] = 1.0;
}
if (g.index) {
const ia = g.index.array;
for (let i = 0; i < ia.length; i++) idxArr.push(ia[i] + vOff);
} else {
for (let i = 0; i < cnt; i++) idxArr.push(vOff + i);
}
vOff += cnt;
});
const merged = new THREE.BufferGeometry();
merged.setAttribute('position', new THREE.BufferAttribute(pos, 3));
merged.setAttribute('normal', new THREE.BufferAttribute(nrm, 3));
merged.setAttribute('skinIndex', new THREE.BufferAttribute(new Uint16Array(si), 4));
merged.setAttribute('skinWeight',new THREE.BufferAttribute(sw, 4));
merged.setIndex(idxArr);
return merged;
}
function _buildBodyGeometry() {
const parts = [];
function cyl(rT, rB, h, segs, boneIdx, tx, ty, tz, rx, ry, rz) {
const g = new THREE.CylinderGeometry(rT, rB, h, segs);
const m = new THREE.Matrix4().compose(
new THREE.Vector3(tx, ty, tz),
new THREE.Quaternion().setFromEuler(new THREE.Euler(rx||0, ry||0, rz||0)),
new THREE.Vector3(1,1,1)
);
g.applyMatrix4(m);
parts.push({ geo: g, boneIdx });
}
function sph(r, ws, hs, boneIdx, tx, ty, tz) {
const g = new THREE.SphereGeometry(r, ws, hs);
g.translate(tx, ty, tz);
parts.push({ geo: g, boneIdx });
}
// torso (spine)
cyl(0.13, 0.10, 0.48, 8, BI.SPINE, 0, 1.16, 0);
// shoulder bar (chest)
cyl(0.05, 0.05, 0.34, 6, BI.CHEST, 0, 1.40, 0, 0, 0, Math.PI/2);
// neck + head
cyl(0.05, 0.055,0.12, 6, BI.HEAD, 0, 1.58, 0);
sph(0.11, 8, 6, BI.HEAD, 0, 1.72, 0);
// left upper arm cylinder along X
cyl(0.04, 0.04, 0.22, 6, BI.LS, -0.30, 1.40, 0, 0, 0, Math.PI/2);
// left forearm
cyl(0.035,0.03, 0.20, 6, BI.LE, -0.54, 1.40, 0, 0, 0, Math.PI/2);
// right upper arm cylinder along +X
cyl(0.04, 0.04, 0.22, 6, BI.RS, 0.30, 1.40, 0, 0, 0,-Math.PI/2);
// right forearm
cyl(0.035,0.03, 0.20, 6, BI.RE, 0.54, 1.40, 0, 0, 0,-Math.PI/2);
// left upper leg
cyl(0.065,0.055,0.42, 7, BI.LH, -0.10, 0.68, 0);
// left lower leg
cyl(0.05, 0.04, 0.42, 7, BI.LK, -0.10, 0.25, 0);
// right upper leg
cyl(0.065,0.055,0.42, 7, BI.RH, 0.10, 0.68, 0);
// right lower leg
cyl(0.05, 0.04, 0.42, 7, BI.RK, 0.10, 0.25, 0);
return _mergeWithSkin(parts);
}
function _qFlat(euler) {
const q = new THREE.Quaternion().setFromEuler(
new THREE.Euler(euler[0], euler[1], euler[2])
);
return [q.x, q.y, q.z, q.w];
}
function _buildAnimClips() {
function qt(name, times, eulerFrames) {
const vals = [];
eulerFrames.forEach(e => vals.push(..._qFlat(e)));
return new THREE.QuaternionKeyframeTrack(`${name}.quaternion`, times, vals);
}
function staticTrack(name, euler) {
const q = _qFlat(euler);
return new THREE.QuaternionKeyframeTrack(`${name}.quaternion`, [0, 1], [...q, ...q]);
}
function identTrack(name) {
return staticTrack(name, [0,0,0]);
}
// ── standing: identity pose ──
const standTracks = ['l_hip','r_hip','l_knee','r_knee','l_shoulder','r_shoulder']
.map(identTrack);
const standing = new THREE.AnimationClip('standing', 1, standTracks);
// ── walking: 1.2 s loop, 5 keyframes ──
const wt = [0, 0.3, 0.6, 0.9, 1.2];
function walkSwing(name, a0, a1) {
return qt(name, wt, [
[a0,0,0], [a1,0,0], [a0,0,0], [a1,0,0], [a0,0,0]
]);
}
const walking = new THREE.AnimationClip('walking', 1.2, [
walkSwing('l_hip', -0.50, 0.50),
walkSwing('r_hip', 0.50, -0.50),
walkSwing('l_knee', 0.00, 0.45),
walkSwing('r_knee', 0.45, 0.00),
walkSwing('l_shoulder', 0.28, -0.28),
walkSwing('r_shoulder',-0.28, 0.28),
]);
// ── seated: hips flexed, knees bent ──
const seated = new THREE.AnimationClip('seated', 1, [
staticTrack('pelvis', [-Math.PI/2, 0, 0]),
staticTrack('l_hip', [ Math.PI/2, 0, 0]),
staticTrack('r_hip', [ Math.PI/2, 0, 0]),
staticTrack('l_knee', [-Math.PI/2, 0, 0]),
staticTrack('r_knee', [-Math.PI/2, 0, 0]),
]);
// ── lying: whole figure horizontal ──
const lying = new THREE.AnimationClip('lying', 1, [
staticTrack('root', [-Math.PI/2, 0, 0]),
]);
return { standing, walking, seated, lying };
}
function _buildHumanoid(color) {
const bones = _buildBones();
const geo = _buildBodyGeometry();
const mat = new THREE.MeshPhongMaterial({
color: color || 0x4fc3f7,
skinning: true,
shininess: 40,
});
const mesh = new THREE.SkinnedMesh(geo, mat);
mesh.add(bones[0]);
mesh.bind(new THREE.Skeleton(bones));
const mixer = new THREE.AnimationMixer(mesh);
const clips = _buildAnimClips();
const actions = {};
Object.entries(clips).forEach(([name, clip]) => {
const a = mixer.clipAction(clip);
a.setLoop(THREE.LoopRepeat, Infinity);
actions[name] = a;
});
actions.standing.play();
return { mesh, mixer, actions, posture: 'standing' };
}
function _setPosture(h, posture) {
if (h.posture === posture) return;
const from = h.actions[h.posture];
const to = h.actions[posture];
if (from) from.fadeOut(0.35);
if (to) to.reset().fadeIn(0.35).play();
h.posture = posture;
}
// ── blob management ───────────────────────────────────────────────────────
function _createBlobObj(id) {
const ci = id % BLOB_COLORS.length;
const color = BLOB_COLORS[ci];
const group = new THREE.Group();
_scene.add(group);
const humanoid = _buildHumanoid(color);
group.add(humanoid.mesh);
_mixers.push(humanoid.mixer);
// footprint trail (max 60 pts, Y=floor)
const trailPos = new Float32Array(60 * 3);
const trailGeo = new THREE.BufferGeometry();
trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPos, 3));
trailGeo.setDrawRange(0, 0);
const trail = new THREE.Line(
trailGeo,
new THREE.LineBasicMaterial({ color: TRAIL_COLORS[ci % TRAIL_COLORS.length], transparent: true, opacity: 0.5 })
);
trail.frustumCulled = false;
_scene.add(trail);
// vertical pillar anchor
const pillarGeo = new THREE.BufferGeometry();
pillarGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array([0,0,0, 0,2.5,0]), 3));
const pillar = new THREE.Line(
pillarGeo,
new THREE.LineBasicMaterial({ color: 0x445566, transparent: true, opacity: 0.3 })
);
_scene.add(pillar);
return { group, humanoid, trail, pillar };
}
function _removeBlobObj(id, obj) {
_scene.remove(obj.group);
_scene.remove(obj.trail);
_scene.remove(obj.pillar);
const idx = _mixers.indexOf(obj.humanoid.mixer);
if (idx !== -1) _mixers.splice(idx, 1);
_blobs3D.delete(id);
if (_followId === id) _followId = null;
}
function _updateTrail(obj, trailData) {
if (!trailData || trailData.length === 0) return;
const arr = obj.trail.geometry.attributes.position.array;
const cnt = Math.min(trailData.length, 60);
for (let i = 0; i < cnt; i++) {
arr[i*3+0] = trailData[i][0];
arr[i*3+1] = 0.02;
arr[i*3+2] = trailData[i][1];
}
obj.trail.geometry.attributes.position.needsUpdate = true;
obj.trail.geometry.setDrawRange(0, cnt);
}
function _updatePillar(obj, x, z, height) {
const a = obj.pillar.geometry.attributes.position.array;
a[0]=x; a[1]=0.05; a[2]=z;
a[3]=x; a[4]=height-0.05; a[5]=z;
obj.pillar.geometry.attributes.position.needsUpdate = true;
}
function applyLocUpdate(blobs) {
const seen = new Set();
blobs.forEach(b => {
seen.add(b.id);
let obj = _blobs3D.get(b.id);
if (!obj) { obj = _createBlobObj(b.id); _blobs3D.set(b.id, obj); }
obj.group.position.set(b.x, 0, b.z);
const speed = Math.sqrt(b.vx*b.vx + b.vz*b.vz);
_setPosture(obj.humanoid, speed > 0.25 ? 'walking' : 'standing');
if (speed > 0.25) {
obj.humanoid.actions.walking.timeScale = Math.min(speed * 1.8, 2.5);
obj.group.rotation.y = Math.atan2(b.vx, b.vz);
}
_updateTrail(obj, b.trail);
if (_room) _updatePillar(obj, b.x, b.z, _room.height);
});
_blobs3D.forEach((obj, id) => {
if (!seen.has(id)) _removeBlobObj(id, obj);
});
}
// ── message handlers ──────────────────────────────────────────────────────
function handleRegistryState(msg) {
applyRoom(msg.room);
applyNodeRegistry(msg.nodes || []);
}
function handleLocUpdate(msg) {
applyLocUpdate(msg.blobs || []);
}
function handleLinkActive(msg) {
const id = msg.id || `${msg.node_mac}:${msg.peer_mac}`;
_activeLinks.set(id, msg);
_rebuildLinkLines();
}
function handleLinkInactive(msg) {
_activeLinks.delete(msg.id);
const line = _linkLines.get(msg.id);
if (line) { _scene.remove(line); _linkLines.delete(msg.id); }
}
// ── view presets ──────────────────────────────────────────────────────────
function setViewPreset(preset, blobId) {
_followId = null;
_controls.enabled = true;
const cx = _room ? (_room.origin_x||0) + _room.width / 2 : 5;
const cz = _room ? (_room.origin_z||0) + _room.depth / 2 : 5;
const h = _room ? _room.height : 2.5;
if (preset === 'topdown') {
_camera.up.set(0, 0, -1);
_camera.position.set(cx, Math.max(h * 4, 12), cz);
_controls.target.set(cx, 0, cz);
_controls.update();
} else if (preset === 'perspective') {
_camera.up.set(0, 1, 0);
_camera.position.set(cx + 8, h * 2.5, cz + 8);
_controls.target.set(cx, 0, cz);
_controls.update();
} else if (preset === 'follow') {
_camera.up.set(0, 1, 0);
_followId = (blobId !== undefined) ? blobId
: (_blobs3D.size > 0 ? _blobs3D.keys().next().value : null);
}
}
// ── public API ────────────────────────────────────────────────────────────
return {
init,
update,
handleRegistryState,
handleLocUpdate,
handleLinkActive,
handleLinkInactive,
applyLinks,
uploadFloorPlan,
setViewPreset,
};
})();