Files
ruvnet--RuView/examples/three.js/demos/01-helpers.html
T
rUv 27a6edba8b feat(examples/three.js): cinematic skinned realtime pose demo + folder reorg (#584)
* feat(examples/three.js): cinematic skinned realtime pose demo + ESP32 CSI bridge

Five-stage example progression exploring three.js helpers (ADR-097 surface) as
a viewer for live RuView sensor data:

1. helpers-demo.html              — clean ADR-097 helper reference (GridHelper,
                                    PolarGridHelper, BoxHelper, AxesHelper),
                                    file://-safe, no backend
2. helpers-cinematic.html         — same scene + UnrealBloomPass + pseudo-CSI
                                    sonar pings + tomography sweep + procedural
                                    cyber floor + ambient drift particles
3. helpers-skinned.html           — replaces sphere skeleton with Mixamo X Bot
                                    via GLTFLoader from threejs.org CDN, plays
                                    bundled animations with additive blending
4. helpers-skinned-fbx.html       — same but loads a local Mixamo FBX (needs
                                    serve-demo.py — file:// can't fetch local
                                    siblings). Drop X Bot.fbx alongside.
5. helpers-skinned-realtime.html  — webcam → MediaPipe Pose Heavy →
                                    poseWorldLandmarks → direct quaternion
                                    retargeting onto the Mixamo skeleton.
                                    Real ESP32-S3 CSI streamed over WebSocket
                                    from ruvultra (Tailscale, port 8766).

Supporting:
  - serve-demo.py             threaded HTTP server with no-cache headers
                               (fixes net::ERR_EMPTY_RESPONSE on the FBX path)
  - ruvultra-csi-bridge.py    ESP32 RuView firmware tick → WebSocket bridge,
                               runs as systemd-run unit on ruvultra

Bugs found + fixed along the way (all documented in code comments):
  - FBX exports yield TWO parallel Bone trees with identical names; only the
    SkinnedMesh.skeleton.bones one drives visible deformation. model.traverse
    finds orphans.
  - Mixamo FBX nests a zero-length wrapper bone above the real bone, same name.
    bone.children[0].getWorldPosition == bone.getWorldPosition → restDir is
    (0,0,0) → setFromUnitVectors collapses to identity. Walk past same-named
    same-position wrappers when computing tail.
  - AnimationMixer.update() with a "stopped" action still mutates bones unless
    enabled=false is set.

Retargeting layer in helpers-skinned-realtime.html:
  - 12 bones direct quaternion retarget (arms × 2, legs × 2, spine × 3, neck)
  - Hips root rotation from shoulder/hip line basis (torso twist + lean)
  - Neck aims at ear-midpoint (kp 7+8), not nose (kp 0), to remove the
    forward bias of the protruding-nose anchor
  - One Euro Filter per landmark per axis (Casiez 2012) — adaptive low-pass
  - Visibility-weighted per-bone slerp gain — occluded limbs relax to rest
  - URL toggles: ?mirror= ?yflip= ?zflip= ?cnn=0/1/2 ?csi=ws://...

Live CSI integration:
  - Bridge parses adaptive_ctrl tick lines (motion/presence/rssi/yield)
  - Browser fans single ESP32 reading across 4 UI nodes with phase-shifted
    wobble (0.88–1.00 × sin(t·0.55 + offsetᵢ))
  - EMA α=0.06 (~3 sec time constant), HUD update throttled 3 Hz

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(examples/three.js): organize into demos/screenshots/server/assets + add README

Flatten the 13-file flat layout into purposeful subfolders so the demo
collection has a clean top-level entry point (README.md) and the file roles
are obvious from a directory listing.

Layout:
  demos/         01..05 — numbered for the progression (helpers → cinematic →
                          skinned → skinned-fbx → skinned-realtime)
  screenshots/   one PNG per demo, matching the demo's filename prefix
  server/        serve-demo.py + ruvultra-csi-bridge.py
  assets/        X Bot.fbx (gitignored, used by demos 04 and 05)

Touched files (beyond the renames):
- 04-skinned-fbx.html, 05-skinned-realtime.html: MODEL_URL now resolves
  '../assets/X%20Bot.fbx' instead of './X%20Bot.fbx'
- server/serve-demo.py: chdir() walks 3 levels up to repo root (was 2), and
  the URL banner now lists all 5 demos
- .gitignore: comment refresh — points at assets/ and screenshots/
- 05-skinned-realtime.html also picks up in-flight fps-tune work from this
  branch (Holistic script, SMOOTH_K URL param, slerp gain scaling) since
  those edits and the rename hit the same file

Verified end-to-end:
- python examples/three.js/server/serve-demo.py
- all 5 demos return 200, X Bot.fbx returns 200 from new asset/ path
- demos 04 + 05 render the X Bot mesh; 0 JS errors via browser eval
- screenshots reproduced match the originals

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 17:01:02 -04:00

588 lines
25 KiB
HTML
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.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RuView · ADR-097 · three.js helpers in the point cloud viewer</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='10' fill='%23e8a634'/></svg>">
<style>
:root {
--bg: #0a0a0a;
--bg-panel: rgba(0, 0, 0, 0.88);
--amber: #e8a634;
--amber-dim: #4a3a1a;
--amber-hot: #ffc04d;
--grid-major: #444444;
--grid-minor: #222222;
--green: #4f4;
--blue: #4cf;
--text-mute: #888;
--border: #2a2a2a;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--amber);
font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
canvas { display: block; }
/* Top-left HUD */
#info {
position: absolute;
top: 16px;
left: 16px;
padding: 14px 16px;
background: var(--bg-panel);
border: 1px solid var(--amber);
border-radius: 8px;
min-width: 280px;
max-width: 340px;
font-size: 12px;
line-height: 1.55;
z-index: 10;
backdrop-filter: blur(6px);
box-shadow: 0 4px 24px rgba(232, 166, 52, 0.08);
}
#info h1 { margin: 0 0 2px 0; font-size: 14px; letter-spacing: 0.5px; }
#info .sub { font-size: 11px; color: var(--text-mute); margin-bottom: 10px; }
#info .row { display: flex; justify-content: space-between; gap: 12px; margin: 2px 0; }
#info .row .k { color: var(--text-mute); }
#info .row .v { color: var(--amber); font-variant-numeric: tabular-nums; }
#info .row .v.live { color: var(--green); }
/* Bottom-left helper toggle panel */
#controls {
position: absolute;
bottom: 16px;
left: 16px;
padding: 12px 14px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 12px;
z-index: 10;
backdrop-filter: blur(6px);
min-width: 220px;
}
#controls h2 {
margin: 0 0 8px 0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--text-mute);
font-weight: 600;
}
#controls label {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
cursor: pointer;
user-select: none;
}
#controls label:hover { color: var(--amber-hot); }
#controls input[type=checkbox] {
accent-color: var(--amber);
width: 14px;
height: 14px;
cursor: pointer;
}
#controls .helper-swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
margin-left: auto;
}
/* Bottom-right ADR badge */
#adr-badge {
position: absolute;
bottom: 16px;
right: 16px;
padding: 8px 12px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 11px;
color: var(--text-mute);
z-index: 10;
backdrop-filter: blur(6px);
}
#adr-badge a { color: var(--amber); text-decoration: none; }
#adr-badge a:hover { color: var(--amber-hot); }
/* Top-right legend */
#legend {
position: absolute;
top: 16px;
right: 16px;
padding: 12px 14px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 11px;
z-index: 10;
backdrop-filter: blur(6px);
min-width: 200px;
}
#legend h2 {
margin: 0 0 8px 0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--text-mute);
font-weight: 600;
}
#legend .item {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
}
#legend .dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
#legend .label { font-size: 11px; line-height: 1.3; }
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
</head>
<body>
<div id="info">
<h1>RuView · Helpers Demo</h1>
<div class="sub">ADR-097 · three.js helpers for the point cloud viewer</div>
<div class="row"><span class="k">Scene</span><span class="v live">● SYNTHETIC</span></div>
<div class="row"><span class="k">Skeleton</span><span class="v">17 kpts · COCO</span></div>
<div class="row"><span class="k">Point cloud</span><span class="v" id="pc-count">— pts</span></div>
<div class="row"><span class="k">Sensor nodes</span><span class="v">4 · multistatic</span></div>
<div class="row"><span class="k">Frame rate</span><span class="v" id="fps">— fps</span></div>
<div class="row"><span class="k">Bbox volume</span><span class="v" id="bbox-vol">— m³</span></div>
</div>
<div id="controls">
<h2>Helpers</h2>
<label><input type="checkbox" id="t-grid" checked>GridHelper<span class="helper-swatch" style="background:#444"></span></label>
<label><input type="checkbox" id="t-polar" checked>PolarGridHelper<span class="helper-swatch" style="background:#4a3a1a"></span></label>
<label><input type="checkbox" id="t-bbox" checked>BoxHelper<span class="helper-swatch" style="background:#e8a634"></span></label>
<label><input type="checkbox" id="t-axes" checked>AxesHelper<span class="helper-swatch" style="background:linear-gradient(90deg,#f44,#4f4,#4cf)"></span></label>
<label><input type="checkbox" id="t-nodebox" checked>Per-node BoxHelpers<span class="helper-swatch" style="background:#4cf"></span></label>
</div>
<div id="legend">
<h2>Scene</h2>
<div class="item"><span class="dot" style="background:#ffff00"></span><span class="label">COCO-17 keypoints (yellow)</span></div>
<div class="item"><span class="dot" style="background:#ffffff"></span><span class="label">Bones (white lines)</span></div>
<div class="item"><span class="dot" style="background:#4cf"></span><span class="label">Face point cloud (cyan→white)</span></div>
<div class="item"><span class="dot" style="background:#e8a634"></span><span class="label">ESP32 sensor nodes</span></div>
</div>
<div id="adr-badge">
ADR-097 · <a href="https://threejs.org/examples/#webgl_helpers" target="_blank" rel="noopener">three.js helpers</a>
</div>
<script>
// =====================================================================
// RuView · ADR-097 · three.js helpers demo
// --------------------------------------------------------------------
// Self-contained, no backend. Demonstrates how `GridHelper`,
// `PolarGridHelper`, `BoxHelper`, and `AxesHelper` slot into the
// RuView point cloud viewer (`v2/crates/wifi-densepose-pointcloud
// /src/viewer.html`). Open this file in a browser — no build step.
//
// The scene contains:
// 1. A synthetic walking, breathing 17-keypoint skeleton.
// 2. A face-shaped point cloud attached to the skeleton head.
// 3. Four multistatic sensor-node markers arranged around the room.
// 4. All four ADR-097 helpers, toggleable from the bottom-left panel.
//
// Coordinate frame matches the production viewer:
// +X = right, +Y = up, +Z = away from camera.
// Floor at y = -1.5, person hip at y = 0, head reaches ~ y = 0.7.
// =====================================================================
const COCO_BONES = [
// head
[0, 1], [0, 2], [1, 3], [2, 4],
// torso
[5, 6], [5, 11], [6, 12], [11, 12],
// left arm
[5, 7], [7, 9],
// right arm
[6, 8], [8, 10],
// left leg
[11, 13], [13, 15],
// right leg
[12, 14], [14, 16],
];
// Static "T-pose" skeleton in local frame, animated each frame.
// 17 keypoints in COCO order. Units: meters.
const SKELETON_BASE = {
0: [ 0.00, 0.65, 0.00], // nose
1: [-0.04, 0.68, 0.04], // L eye
2: [ 0.04, 0.68, 0.04], // R eye
3: [-0.08, 0.64, 0.00], // L ear
4: [ 0.08, 0.64, 0.00], // R ear
5: [-0.18, 0.45, 0.00], // L shoulder
6: [ 0.18, 0.45, 0.00], // R shoulder
7: [-0.22, 0.20, 0.00], // L elbow
8: [ 0.22, 0.20, 0.00], // R elbow
9: [-0.26, -0.05, 0.00], // L wrist
10: [ 0.26, -0.05, 0.00], // R wrist
11: [-0.10, 0.00, 0.00], // L hip
12: [ 0.10, 0.00, 0.00], // R hip
13: [-0.12, -0.40, 0.00], // L knee
14: [ 0.12, -0.40, 0.00], // R knee
15: [-0.12, -0.80, 0.00], // L ankle
16: [ 0.12, -0.80, 0.00], // R ankle
};
// ---------------------------------------------------------------------
// Scene + camera + renderer
// ---------------------------------------------------------------------
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0a);
scene.fog = new THREE.Fog(0x0a0a0a, 6, 14);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.05, 100);
camera.position.set(3.0, 1.4, 4.2);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = 1.5;
controls.maxDistance = 12;
controls.maxPolarAngle = Math.PI * 0.92;
// ---------------------------------------------------------------------
// ADR-097 helpers — wired to checkbox toggles
// ---------------------------------------------------------------------
// GridHelper — Cartesian floor reference. Establishes "down" and
// scale: 4 m × 4 m floor, 20 divisions = 0.2 m grid spacing.
const gridHelper = new THREE.GridHelper(4, 20, 0x444444, 0x222222);
gridHelper.position.y = -1.5;
scene.add(gridHelper);
// PolarGridHelper — multistatic geometry reference. 16 radial
// divisions (angular bins) × 4 concentric circles, centered on
// the fusion target. Matches the bin count in
// signal/src/ruvsense/multistatic.rs:attention_weight().
const polarHelper = new THREE.PolarGridHelper(2.2, 16, 4, 64, 0x4a3a1a, 0x2a1f10);
polarHelper.position.y = -1.499; // a hair above grid to avoid z-fight
scene.add(polarHelper);
// AxesHelper — XYZ tripod at origin. Red = X, green = Y, blue = Z.
const axesHelper = new THREE.AxesHelper(0.5);
axesHelper.position.set(0, -1.49, 0);
scene.add(axesHelper);
// BoxHelper — per-person bounding volume. Refreshed each frame
// after the skeleton is updated. Color = RuView amber.
let bboxHelper = null;
// ---------------------------------------------------------------------
// Skeleton — joint spheres + bone lines, animated
// ---------------------------------------------------------------------
const skeletonGroup = new THREE.Group();
scene.add(skeletonGroup);
const jointGeo = new THREE.SphereGeometry(0.025, 12, 12);
const jointMat = new THREE.MeshBasicMaterial({ color: 0xffff00 });
const joints = [];
for (let i = 0; i < 17; i++) {
const sphere = new THREE.Mesh(jointGeo, jointMat);
const p = SKELETON_BASE[i];
sphere.position.set(p[0], p[1], p[2]);
sphere.userData.baseY = p[1];
sphere.userData.baseX = p[0];
sphere.userData.idx = i;
skeletonGroup.add(sphere);
joints.push(sphere);
}
const boneMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85 });
const bones = [];
for (const [a, b] of COCO_BONES) {
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3));
const line = new THREE.Line(geom, boneMat);
line.userData = { a, b };
skeletonGroup.add(line);
bones.push(line);
}
// ---------------------------------------------------------------------
// Face point cloud — synthetic ellipsoid attached to head keypoint
// ---------------------------------------------------------------------
const FACE_POINTS = 600;
const facePositions = new Float32Array(FACE_POINTS * 3);
const faceColors = new Float32Array(FACE_POINTS * 3);
const faceOffsets = new Float32Array(FACE_POINTS * 3); // canonical face shape, relative to nose
for (let i = 0; i < FACE_POINTS; i++) {
// Sample points roughly on a face-shaped ellipsoid (taller than wide).
const u = Math.random() * Math.PI * 2;
const v = (Math.random() - 0.5) * Math.PI;
const cu = Math.cos(u), su = Math.sin(u);
const cv = Math.cos(v), sv = Math.sin(v);
// ellipsoid radii (head-like proportions)
const rx = 0.085, ry = 0.105, rz = 0.075;
faceOffsets[i * 3 + 0] = rx * cv * cu;
faceOffsets[i * 3 + 1] = ry * sv;
faceOffsets[i * 3 + 2] = rz * cv * su;
// depth-encoded color: cyan at back, near-white at front (toward +Z = away from camera)
const depthT = (sv + 1) * 0.5;
faceColors[i * 3 + 0] = 0.30 + 0.70 * depthT; // R
faceColors[i * 3 + 1] = 0.80 + 0.20 * depthT; // G
faceColors[i * 3 + 2] = 1.00; // B
}
const faceGeom = new THREE.BufferGeometry();
faceGeom.setAttribute('position', new THREE.BufferAttribute(facePositions, 3));
faceGeom.setAttribute('color', new THREE.BufferAttribute(faceColors, 3));
const faceMat = new THREE.PointsMaterial({
size: 0.012,
vertexColors: true,
sizeAttenuation: true,
transparent: true,
opacity: 0.9,
});
const facePoints = new THREE.Points(faceGeom, faceMat);
skeletonGroup.add(facePoints);
document.getElementById('pc-count').textContent = FACE_POINTS + ' pts';
// ---------------------------------------------------------------------
// Multistatic sensor nodes — 4 ESP32 markers around the room
// ---------------------------------------------------------------------
const nodeGroup = new THREE.Group();
scene.add(nodeGroup);
const NODE_POSITIONS = [
[-1.9, 1.3, 1.9], // back-left high
[ 1.9, 1.3, 1.9], // back-right high
[-1.9, 1.3, -1.9], // front-left high
[ 1.9, 1.3, -1.9], // front-right high
];
const nodeBboxHelpers = [];
const nodeGeo = new THREE.BoxGeometry(0.12, 0.06, 0.18);
const nodeMat = new THREE.MeshBasicMaterial({ color: 0xe8a634 });
const nodeAntennaGeo = new THREE.ConeGeometry(0.018, 0.08, 8);
const nodeAntennaMat = new THREE.MeshBasicMaterial({ color: 0xffc04d });
NODE_POSITIONS.forEach((pos, i) => {
const group = new THREE.Group();
group.position.set(pos[0], pos[1], pos[2]);
const body = new THREE.Mesh(nodeGeo, nodeMat);
group.add(body);
// little antenna sticking up
const antenna = new THREE.Mesh(nodeAntennaGeo, nodeAntennaMat);
antenna.position.y = 0.07;
group.add(antenna);
// pulsing emissive ring (visualizes RX activity)
const ringGeo = new THREE.RingGeometry(0.10, 0.13, 32);
const ringMat = new THREE.MeshBasicMaterial({ color: 0xe8a634, side: THREE.DoubleSide, transparent: true, opacity: 0.4 });
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.y = -0.04;
ring.userData.phase = i * 0.5;
group.add(ring);
group.userData.ring = ring;
// sight-line from node to scene origin (visualizes multistatic geometry)
const sightGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(-pos[0], -pos[1], -pos[2]),
]);
const sightMat = new THREE.LineDashedMaterial({
color: 0xe8a634, transparent: true, opacity: 0.18,
dashSize: 0.1, gapSize: 0.06,
});
const sightLine = new THREE.Line(sightGeo, sightMat);
sightLine.computeLineDistances();
group.add(sightLine);
nodeGroup.add(group);
// ADR-097 §3.3 — per-node BoxHelper. Demonstrates that helpers
// compose naturally: one box per detected object.
const bbox = new THREE.BoxHelper(group, 0x4cf);
scene.add(bbox);
nodeBboxHelpers.push(bbox);
});
// ---------------------------------------------------------------------
// Animation — synthetic motion model
// ---------------------------------------------------------------------
let frameStart = performance.now();
let frameCount = 0;
let fpsAvg = 0;
function applyPose(t) {
// Body sway (slow), breathing (chest expansion), arm/leg swing (walking).
const swayX = Math.sin(t * 0.35) * 0.05;
const swayZ = Math.cos(t * 0.27) * 0.04;
const breathe = Math.sin(t * 1.4) * 0.012; // chest in/out
const walkPhase = t * 1.9; // walk cycle
skeletonGroup.position.set(swayX, 0, swayZ);
skeletonGroup.rotation.y = Math.sin(t * 0.22) * 0.18;
for (let i = 0; i < 17; i++) {
const base = SKELETON_BASE[i];
let dx = 0, dy = 0, dz = 0;
// breathing — shoulders + nose rise a little
if (i === 0 || i === 1 || i === 2) dy = breathe * 0.6;
if (i === 5 || i === 6) dy = breathe;
// arm swing (opposite of legs)
if (i === 7) { dz = Math.sin(walkPhase) * 0.10; dy += Math.cos(walkPhase) * 0.04; }
if (i === 9) { dz = Math.sin(walkPhase) * 0.18; dy += Math.cos(walkPhase) * 0.06; }
if (i === 8) { dz = -Math.sin(walkPhase) * 0.10; dy += Math.cos(walkPhase) * 0.04; }
if (i === 10){ dz = -Math.sin(walkPhase) * 0.18; dy += Math.cos(walkPhase) * 0.06; }
// leg swing
if (i === 13){ dz = -Math.sin(walkPhase) * 0.08; }
if (i === 15){ dz = -Math.sin(walkPhase) * 0.15; dy = Math.max(0, Math.cos(walkPhase)) * 0.04; }
if (i === 14){ dz = Math.sin(walkPhase) * 0.08; }
if (i === 16){ dz = Math.sin(walkPhase) * 0.15; dy = Math.max(0, -Math.cos(walkPhase)) * 0.04; }
joints[i].position.set(base[0] + dx, base[1] + dy, base[2] + dz);
}
// update bone line vertices from current joint positions
for (const line of bones) {
const { a, b } = line.userData;
const pa = joints[a].position;
const pb = joints[b].position;
const pos = line.geometry.attributes.position;
pos.array[0] = pa.x; pos.array[1] = pa.y; pos.array[2] = pa.z;
pos.array[3] = pb.x; pos.array[4] = pb.y; pos.array[5] = pb.z;
pos.needsUpdate = true;
}
// attach face point cloud to the nose keypoint (kpt 0)
const nose = joints[0].position;
const positions = faceGeom.attributes.position;
const headTurn = Math.sin(t * 0.6) * 0.35; // y-axis nod
const cosH = Math.cos(headTurn), sinH = Math.sin(headTurn);
for (let i = 0; i < FACE_POINTS; i++) {
const ox = faceOffsets[i * 3 + 0];
const oy = faceOffsets[i * 3 + 1];
const oz = faceOffsets[i * 3 + 2];
// rotate offset around Y axis by headTurn
const rx = cosH * ox + sinH * oz;
const rz = -sinH * ox + cosH * oz;
positions.array[i * 3 + 0] = nose.x + rx;
positions.array[i * 3 + 1] = nose.y + oy;
positions.array[i * 3 + 2] = nose.z + rz;
}
positions.needsUpdate = true;
}
function updateNodes(t) {
nodeGroup.children.forEach((node, i) => {
const ring = node.userData.ring;
const phase = (t * 1.8 + ring.userData.phase) % (Math.PI * 2);
ring.material.opacity = 0.18 + 0.42 * Math.max(0, Math.cos(phase));
ring.scale.setScalar(1 + 0.18 * Math.max(0, Math.cos(phase)));
});
}
function updateBboxHelper() {
const want = document.getElementById('t-bbox').checked;
if (!want) {
if (bboxHelper) { scene.remove(bboxHelper); bboxHelper = null; }
return;
}
skeletonGroup.updateMatrixWorld(true);
if (!bboxHelper) {
bboxHelper = new THREE.BoxHelper(skeletonGroup, 0xe8a634);
scene.add(bboxHelper);
} else {
bboxHelper.setFromObject(skeletonGroup);
}
// compute volume for the HUD
const box = new THREE.Box3().setFromObject(skeletonGroup);
const size = box.getSize(new THREE.Vector3());
document.getElementById('bbox-vol').textContent =
(size.x * size.y * size.z).toFixed(3) + ' m³';
}
function tick() {
const now = performance.now();
const t = now * 0.001;
const dt = now - frameStart;
frameStart = now;
frameCount++;
if (frameCount % 30 === 0) {
fpsAvg = 1000 / dt;
document.getElementById('fps').textContent = fpsAvg.toFixed(0) + ' fps';
}
applyPose(t);
updateNodes(t);
updateBboxHelper();
controls.update();
renderer.render(scene, camera);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
// ---------------------------------------------------------------------
// Controls wiring — checkbox toggles attach/detach helpers from scene
// ---------------------------------------------------------------------
function bindToggle(id, obj) {
const el = document.getElementById(id);
el.addEventListener('change', () => {
if (el.checked) {
if (!scene.children.includes(obj)) scene.add(obj);
} else {
scene.remove(obj);
}
});
}
bindToggle('t-grid', gridHelper);
bindToggle('t-polar', polarHelper);
bindToggle('t-axes', axesHelper);
// per-node bbox toggle (group of 4)
document.getElementById('t-nodebox').addEventListener('change', (e) => {
for (const bb of nodeBboxHelpers) {
if (e.target.checked) {
if (!scene.children.includes(bb)) scene.add(bb);
} else {
scene.remove(bb);
}
}
});
// ---------------------------------------------------------------------
// Resize
// ---------------------------------------------------------------------
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>