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>
This commit is contained in:
parent
174e2365f0
commit
27a6edba8b
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Mixamo FBX downloads — too large + license boundary. Get your own from
|
||||||
|
# mixamo.com (FBX Binary + T-Pose / Without Skin), drop into assets/.
|
||||||
|
*.fbx
|
||||||
|
|
||||||
|
# Diagnostic / debug screenshots from a dev session. Official screenshots
|
||||||
|
# live in screenshots/ and are committed; these underscore-prefixed ones
|
||||||
|
# are scratch.
|
||||||
|
_diag-*.png
|
||||||
|
_demo-mode-shot*.png
|
||||||
|
_PROOF-*.png
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
# three.js demos
|
||||||
|
|
||||||
|
Five progressively richer browser demos of the ADR-097 sensing-helpers scene,
|
||||||
|
ending with a live MediaPipe-Pose → Mixamo X Bot retargeting pipeline driven
|
||||||
|
by a real ESP32 CSI feed.
|
||||||
|
|
||||||
|
## Run them
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python examples/three.js/server/serve-demo.py
|
||||||
|
# then open one of the URLs the script prints
|
||||||
|
```
|
||||||
|
|
||||||
|
`server/serve-demo.py` is a tiny `ThreadingHTTPServer` with aggressive
|
||||||
|
no-cache headers — the stdlib `http.server` is single-threaded and times out
|
||||||
|
on the parallel script + FBX fetches the demos make.
|
||||||
|
|
||||||
|
## Demos
|
||||||
|
|
||||||
|
| # | File | What it shows |
|
||||||
|
|---|------|---------------|
|
||||||
|
| 01 | [`demos/01-helpers.html`](demos/01-helpers.html) | Plain ADR-097 helpers in the point-cloud viewer |
|
||||||
|
| 02 | [`demos/02-cinematic.html`](demos/02-cinematic.html) | Cinematic camera + pseudo-CSI visualization on top of #01 |
|
||||||
|
| 03 | [`demos/03-skinned.html`](demos/03-skinned.html) | GLTF skinned mesh + additive animation blending |
|
||||||
|
| 04 | [`demos/04-skinned-fbx.html`](demos/04-skinned-fbx.html) | Mixamo X Bot loaded from FBX in the ADR-097 scene |
|
||||||
|
| 05 | [`demos/05-skinned-realtime.html`](demos/05-skinned-realtime.html) | Webcam → MediaPipe Pose Heavy → Mixamo IK retarget, live ESP32 CSI overlay |
|
||||||
|
|
||||||
|
| Screenshot | |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
|  |  |
|
||||||
|
|  | |
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
examples/three.js/
|
||||||
|
├── README.md
|
||||||
|
├── .gitignore
|
||||||
|
├── demos/ # 5 self-contained HTML demos
|
||||||
|
│ ├── 01-helpers.html
|
||||||
|
│ ├── 02-cinematic.html
|
||||||
|
│ ├── 03-skinned.html
|
||||||
|
│ ├── 04-skinned-fbx.html
|
||||||
|
│ └── 05-skinned-realtime.html
|
||||||
|
├── screenshots/ # one PNG per demo
|
||||||
|
│ └── 0N-*.png
|
||||||
|
├── server/
|
||||||
|
│ ├── serve-demo.py # local HTTP server with no-cache headers
|
||||||
|
│ └── ruvultra-csi-bridge.py # ESP32 CSI WebSocket bridge (ruvultra:8766)
|
||||||
|
└── assets/
|
||||||
|
└── X Bot.fbx # gitignored — get your own from mixamo.com
|
||||||
|
# (FBX Binary, T-Pose, Without Skin)
|
||||||
|
# used by demos 04 and 05
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mixamo X Bot
|
||||||
|
|
||||||
|
Demos 04 and 05 expect `assets/X Bot.fbx`. It's gitignored (size + license
|
||||||
|
boundary). Download yours from [mixamo.com](https://mixamo.com): pick the
|
||||||
|
"X Bot" character, export as **FBX Binary**, **T-Pose**, **Without Skin**,
|
||||||
|
and drop it into `assets/`.
|
||||||
|
|
||||||
|
## Live ESP32 CSI overlay (demo 05 only)
|
||||||
|
|
||||||
|
`server/ruvultra-csi-bridge.py` is the systemd-deployable bridge that runs on
|
||||||
|
the `ruvultra` host (over Tailscale). It listens for ESP32-S3 CSI on UDP and
|
||||||
|
re-broadcasts it as WebSocket frames at `ws://ruvultra:8766/csi`. Demo 05
|
||||||
|
auto-connects; if the socket is down, it falls back to the bundled idle clip
|
||||||
|
plus a synthetic CSI driver.
|
||||||
|
|
||||||
|
## Open issues
|
||||||
|
|
||||||
|
- [#583](https://github.com/ruvnet/RuView/issues/583) — head/face tracking
|
||||||
|
fidelity in `05-skinned-realtime.html`. Recommended fix: swap MediaPipe
|
||||||
|
Pose Heavy for MediaPipe Holistic (same API, adds 468-point face mesh +
|
||||||
|
hand landmarks for proper PnP head pose and finger curl tracking).
|
||||||
|
|
@ -0,0 +1,587 @@
|
||||||
|
<!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>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,854 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>RuView · Skinned · ADR-097 + GLTF skinned mesh + additive animation blending</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: #050507;
|
||||||
|
--bg-panel: rgba(8, 10, 14, 0.78);
|
||||||
|
--amber: #ffb840;
|
||||||
|
--amber-hot: #ffe09f;
|
||||||
|
--cyan: #4cf;
|
||||||
|
--magenta: #ff4cc8;
|
||||||
|
--text: #d8c69a;
|
||||||
|
--text-mute: #6b6155;
|
||||||
|
--border: rgba(255, 184, 64, 0.18);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: var(--bg); color: var(--text); overflow: hidden;
|
||||||
|
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||||
|
-webkit-font-smoothing: antialiased; font-size: 12px;
|
||||||
|
}
|
||||||
|
canvas { display: block; }
|
||||||
|
|
||||||
|
.overlay-frame {
|
||||||
|
position: fixed; inset: 0; pointer-events: none; z-index: 5;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
|
||||||
|
linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.38) 100%);
|
||||||
|
}
|
||||||
|
.scanlines {
|
||||||
|
position: fixed; inset: 0; pointer-events: none; z-index: 6;
|
||||||
|
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.04) 0px, rgba(0,0,0,0.04) 1px, transparent 1px, transparent 3px);
|
||||||
|
mix-blend-mode: overlay; opacity: 0.5;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
position: absolute; background: var(--bg-panel); border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; padding: 12px 14px; backdrop-filter: blur(8px);
|
||||||
|
box-shadow: 0 1px 0 rgba(255, 184, 64, 0.04), 0 8px 32px rgba(0,0,0,0.55); z-index: 10;
|
||||||
|
}
|
||||||
|
.panel h2 {
|
||||||
|
margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||||
|
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#info { top: 20px; left: 20px; min-width: 280px; }
|
||||||
|
#info h1 { margin: 0 0 1px 0; font-size: 13px; letter-spacing: 1px; color: var(--amber-hot); font-weight: 600; }
|
||||||
|
#info .sub { font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
||||||
|
#info .row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
|
||||||
|
#info .row .k { color: var(--text-mute); font-size: 11px; }
|
||||||
|
#info .row .v { color: var(--text); font-variant-numeric: tabular-nums; font-size: 11px; }
|
||||||
|
#info .row .v.amber { color: var(--amber); }
|
||||||
|
#info .row .v.cyan { color: var(--cyan); }
|
||||||
|
#info .row .v.mag { color: var(--magenta); }
|
||||||
|
|
||||||
|
#anim {
|
||||||
|
position: absolute; bottom: 20px; left: 20px; min-width: 280px;
|
||||||
|
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
|
||||||
|
}
|
||||||
|
#anim h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||||
|
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||||
|
#anim .group { padding: 6px 0; border-bottom: 1px solid rgba(255,184,64,0.08); }
|
||||||
|
#anim .group:last-child { border-bottom: none; }
|
||||||
|
#anim .group-label { font-size: 10px; color: var(--text-mute); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
|
||||||
|
#anim button {
|
||||||
|
background: rgba(255,184,64,0.06); border: 1px solid rgba(255,184,64,0.18);
|
||||||
|
color: var(--text); font-family: inherit; font-size: 10px; padding: 4px 8px;
|
||||||
|
margin: 2px 4px 2px 0; cursor: pointer; border-radius: 3px; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
#anim button:hover { background: rgba(255,184,64,0.14); color: var(--amber-hot); }
|
||||||
|
#anim button.active { background: var(--amber); color: var(--bg); border-color: var(--amber); font-weight: 600; }
|
||||||
|
#anim .slider-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
|
||||||
|
#anim .slider-row .label { width: 90px; color: var(--text-mute); }
|
||||||
|
#anim .slider-row input[type=range] { flex: 1; accent-color: var(--amber); }
|
||||||
|
#anim .slider-row .val { width: 38px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
#csi { top: 20px; right: 20px; min-width: 260px; }
|
||||||
|
#csi .bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
|
||||||
|
#csi .bar-row .label { width: 42px; color: var(--text-mute); }
|
||||||
|
#csi .bar-row .bar-track { flex: 1; height: 6px; background: rgba(255,184,64,0.08); border-radius: 2px; overflow: hidden; }
|
||||||
|
#csi .bar-row .bar-fill {
|
||||||
|
height: 100%; background: linear-gradient(90deg, var(--amber-hot), var(--amber));
|
||||||
|
box-shadow: 0 0 6px var(--amber); transition: width 0.08s linear;
|
||||||
|
}
|
||||||
|
#csi .bar-row .val { width: 36px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
#helpers {
|
||||||
|
position: absolute; bottom: 20px; right: 20px; min-width: 220px;
|
||||||
|
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
|
||||||
|
}
|
||||||
|
#helpers h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||||
|
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||||
|
#helpers label {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 3px 0; cursor: pointer; user-select: none; font-size: 11px;
|
||||||
|
}
|
||||||
|
#helpers label:hover { color: var(--amber-hot); }
|
||||||
|
#helpers input[type=checkbox] { accent-color: var(--amber); width: 13px; height: 13px; cursor: pointer; }
|
||||||
|
#helpers .swatch { width: 8px; height: 8px; border-radius: 50%; margin-left: auto; box-shadow: 0 0 6px currentColor; }
|
||||||
|
|
||||||
|
#loading {
|
||||||
|
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(5, 5, 7, 0.96); z-index: 20; font-size: 13px; color: var(--amber);
|
||||||
|
letter-spacing: 2px; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
#loading.hidden { display: none; }
|
||||||
|
#loading .text {
|
||||||
|
text-shadow: 0 0 12px var(--amber);
|
||||||
|
animation: loadPulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes loadPulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1.0; } }
|
||||||
|
|
||||||
|
@keyframes scanFlash {
|
||||||
|
0% { opacity: 0; } 10% { opacity: 0.12; } 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
.scan-flash {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--magenta), transparent);
|
||||||
|
mix-blend-mode: screen; pointer-events: none; opacity: 0; z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
#titlecard {
|
||||||
|
position: absolute; bottom: 76px; left: 50%; transform: translateX(-50%);
|
||||||
|
text-align: center; color: var(--amber-hot); letter-spacing: 6px; font-size: 11px;
|
||||||
|
text-transform: uppercase; opacity: 0.35; z-index: 10;
|
||||||
|
text-shadow: 0 0 12px var(--amber); pointer-events: none;
|
||||||
|
}
|
||||||
|
#titlecard .sub { font-size: 9px; color: var(--text-mute); letter-spacing: 4px; margin-top: 4px; }
|
||||||
|
|
||||||
|
#adr-badge {
|
||||||
|
position: absolute; top: 50%; right: 20px; transform: translateY(-50%);
|
||||||
|
padding: 6px 10px; background: var(--bg-panel); border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; font-size: 9px; color: var(--text-mute); z-index: 10;
|
||||||
|
backdrop-filter: blur(8px); letter-spacing: 0.5px; max-width: 70px; text-align: center; line-height: 1.5;
|
||||||
|
}
|
||||||
|
#adr-badge a { color: var(--amber); text-decoration: none; display: block; }
|
||||||
|
#adr-badge a:hover { color: var(--amber-hot); }
|
||||||
|
</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>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/GLTFLoader.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="overlay-frame"></div>
|
||||||
|
<div class="scanlines"></div>
|
||||||
|
<div class="scan-flash" id="scan-flash"></div>
|
||||||
|
|
||||||
|
<div id="loading"><div class="text">▸ Loading skinned subject · Xbot.glb · 2.9 MB</div></div>
|
||||||
|
|
||||||
|
<div class="panel" id="info">
|
||||||
|
<h1>RuView · Skinned</h1>
|
||||||
|
<div class="sub">ADR-097 · GLTF skinned mesh · additive animation blending</div>
|
||||||
|
<div class="row"><span class="k">Subject</span><span class="v amber">● Tracked</span></div>
|
||||||
|
<div class="row"><span class="k">Model</span><span class="v">Xbot.glb · 14k tris</span></div>
|
||||||
|
<div class="row"><span class="k">Base anim</span><span class="v amber" id="base-name">walk</span></div>
|
||||||
|
<div class="row"><span class="k">Additive</span><span class="v mag" id="add-name">headShake · 0.40</span></div>
|
||||||
|
<div class="row"><span class="k">Mesh nodes</span><span class="v cyan">4 · multistatic</span></div>
|
||||||
|
<div class="row"><span class="k">Coherence</span><span class="v" id="coh-val">— %</span></div>
|
||||||
|
<div class="row"><span class="k">Heart rate</span><span class="v amber" id="hr-val">— bpm</span></div>
|
||||||
|
<div class="row"><span class="k">Bbox vol</span><span class="v" id="bbox-vol">— m³</span></div>
|
||||||
|
<div class="row"><span class="k">Render</span><span class="v" id="fps-val">— fps</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="anim">
|
||||||
|
<h2>AnimationMixer</h2>
|
||||||
|
<div class="group">
|
||||||
|
<div class="group-label">Base · loops</div>
|
||||||
|
<button data-base="idle">idle</button>
|
||||||
|
<button data-base="walk" class="active">walk</button>
|
||||||
|
<button data-base="run">run</button>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<div class="group-label">Additive · layered</div>
|
||||||
|
<button data-add="agree">agree</button>
|
||||||
|
<button data-add="headShake" class="active">headShake</button>
|
||||||
|
<button data-add="sad_pose">sad</button>
|
||||||
|
<button data-add="sneak_pose">sneak</button>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<div class="slider-row">
|
||||||
|
<span class="label">add weight</span>
|
||||||
|
<input type="range" id="add-weight" min="0" max="1" step="0.01" value="0.40">
|
||||||
|
<span class="val" id="add-weight-val">0.40</span>
|
||||||
|
</div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<span class="label">time scale</span>
|
||||||
|
<input type="range" id="time-scale" min="0.1" max="2" step="0.05" value="1.0">
|
||||||
|
<span class="val" id="time-scale-val">1.00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" id="csi">
|
||||||
|
<h2>Per-node CSI</h2>
|
||||||
|
<div class="bar-row"><span class="label">N1·BL</span><div class="bar-track"><div class="bar-fill" id="bar-0" style="width:0"></div></div><span class="val" id="val-0">—</span></div>
|
||||||
|
<div class="bar-row"><span class="label">N2·BR</span><div class="bar-track"><div class="bar-fill" id="bar-1" style="width:0"></div></div><span class="val" id="val-1">—</span></div>
|
||||||
|
<div class="bar-row"><span class="label">N3·FL</span><div class="bar-track"><div class="bar-fill" id="bar-2" style="width:0"></div></div><span class="val" id="val-2">—</span></div>
|
||||||
|
<div class="bar-row"><span class="label">N4·FR</span><div class="bar-track"><div class="bar-fill" id="bar-3" style="width:0"></div></div><span class="val" id="val-3">—</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="helpers">
|
||||||
|
<h2>ADR-097 helpers</h2>
|
||||||
|
<label><input type="checkbox" id="t-grid" checked>GridHelper<span class="swatch" style="color:#666"></span></label>
|
||||||
|
<label><input type="checkbox" id="t-polar" checked>PolarGridHelper<span class="swatch" style="color:#ffb840"></span></label>
|
||||||
|
<label><input type="checkbox" id="t-bbox" checked>BoxHelper on mesh<span class="swatch" style="color:#ffe09f"></span></label>
|
||||||
|
<label><input type="checkbox" id="t-skel">SkeletonHelper<span class="swatch" style="color:#4cf"></span></label>
|
||||||
|
<label><input type="checkbox" id="t-nodebox" checked>Per-node BoxHelpers<span class="swatch" style="color:#4cf"></span></label>
|
||||||
|
<label><input type="checkbox" id="t-pings" checked>Sonar pings<span class="swatch" style="color:#4cf"></span></label>
|
||||||
|
<label><input type="checkbox" id="t-tomo" checked>Tomography sweep<span class="swatch" style="color:#ff4cc8"></span></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="titlecard">
|
||||||
|
RuView · Seldon Vault
|
||||||
|
<div class="sub">skinned · ADR-097 · CCDIKSolver next</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="adr-badge">
|
||||||
|
<a href="https://threejs.org/examples/#webgl_animation_skinning_additive_blending" target="_blank" rel="noopener">additive blend</a>
|
||||||
|
<a href="https://threejs.org/examples/#webgl_animation_skinning_ik" target="_blank" rel="noopener" style="margin-top:4px;">skinning IK</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// =====================================================================
|
||||||
|
// RuView · Skinned · ADR-097 + GLTF skinned mesh + additive animation
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Replaces the procedural sphere-skeleton of helpers-cinematic.html
|
||||||
|
// with a real rigged + skinned humanoid loaded from Xbot.glb. Plays
|
||||||
|
// a base loop (walk / run / idle) and layers an additive pose on
|
||||||
|
// top (headShake / agree / sneak / sad) — mirrors the upstream
|
||||||
|
// three.js webgl_animation_skinning_additive_blending example.
|
||||||
|
//
|
||||||
|
// All ADR-097 helpers still wrap the loaded mesh — BoxHelper picks
|
||||||
|
// up the live AABB of the SkinnedMesh, the polar grid sits under
|
||||||
|
// the rig, and per-node BoxHelpers wrap the four ESP32 markers.
|
||||||
|
//
|
||||||
|
// Production path (next): swap canned GLTF animations for live
|
||||||
|
// COCO-17 keypoint output → CCDIKSolver targets on hands/feet/head.
|
||||||
|
// Reference: three.js webgl_animation_skinning_ik example.
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
const MODEL_URL = 'https://threejs.org/examples/models/gltf/Xbot.glb';
|
||||||
|
|
||||||
|
const NODE_POSITIONS = [
|
||||||
|
[-1.9, 1.3, 1.9],[ 1.9, 1.3, 1.9],
|
||||||
|
[-1.9, 1.3, -1.9],[ 1.9, 1.3, -1.9],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Scene
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x050507);
|
||||||
|
scene.fog = new THREE.FogExp2(0x050507, 0.06);
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(48, window.innerWidth/window.innerHeight, 0.05, 100);
|
||||||
|
camera.position.set(3.2, 1.55, 4.0);
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
||||||
|
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
renderer.toneMappingExposure = 0.80;
|
||||||
|
renderer.outputEncoding = THREE.sRGBEncoding;
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
document.body.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.target.set(0, 0.9, 0);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.06;
|
||||||
|
controls.minDistance = 2; controls.maxDistance = 12;
|
||||||
|
controls.maxPolarAngle = Math.PI * 0.92;
|
||||||
|
controls.autoRotate = new URLSearchParams(location.search).get('orbit') === '1';
|
||||||
|
controls.autoRotateSpeed = 0.25;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Lights — the GLTF uses PBR materials so we actually need lighting
|
||||||
|
// here (unlike the all-emissive cinematic.html). Tuned to keep the
|
||||||
|
// amber/cyan mood: amber hemi + amber key + cyan rim lights from
|
||||||
|
// each node direction (visualizes "the nodes illuminate the subject").
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const hemiLight = new THREE.HemisphereLight(0x553a18, 0x080606, 0.7);
|
||||||
|
hemiLight.position.set(0, 4, 0);
|
||||||
|
scene.add(hemiLight);
|
||||||
|
|
||||||
|
const keyLight = new THREE.DirectionalLight(0xffc070, 0.95);
|
||||||
|
keyLight.position.set(2.5, 3.8, 2.5);
|
||||||
|
keyLight.castShadow = true;
|
||||||
|
keyLight.shadow.camera.top = 2; keyLight.shadow.camera.bottom = -2;
|
||||||
|
keyLight.shadow.camera.left = -2; keyLight.shadow.camera.right = 2;
|
||||||
|
keyLight.shadow.camera.near = 0.1; keyLight.shadow.camera.far = 12;
|
||||||
|
keyLight.shadow.mapSize.set(1024, 1024);
|
||||||
|
keyLight.shadow.bias = -0.0008;
|
||||||
|
scene.add(keyLight);
|
||||||
|
|
||||||
|
// cyan rim lights, one per ESP32 node — keeps the "sensed by the mesh" mood
|
||||||
|
const rimLights = [];
|
||||||
|
NODE_POSITIONS.forEach(pos => {
|
||||||
|
const rim = new THREE.PointLight(0x4cf, 0.55, 8, 1.8);
|
||||||
|
rim.position.set(pos[0] * 1.1, pos[1] * 0.7, pos[2] * 1.1);
|
||||||
|
scene.add(rim);
|
||||||
|
rimLights.push(rim);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Post-processing — same composer as cinematic.html
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const composer = new THREE.EffectComposer(renderer);
|
||||||
|
composer.addPass(new THREE.RenderPass(scene, camera));
|
||||||
|
const bloom = new THREE.UnrealBloomPass(
|
||||||
|
new THREE.Vector2(window.innerWidth, window.innerHeight),
|
||||||
|
0.45, 0.40, 0.78,
|
||||||
|
);
|
||||||
|
composer.addPass(bloom);
|
||||||
|
|
||||||
|
const filmShader = {
|
||||||
|
uniforms: {
|
||||||
|
tDiffuse: { value: null },
|
||||||
|
time: { value: 0 }, grain: { value: 0.04 },
|
||||||
|
vignette: { value: 0.32 }, aberration: { value: 0.0018 },
|
||||||
|
},
|
||||||
|
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform sampler2D tDiffuse; uniform float time, grain, vignette, aberration;
|
||||||
|
varying vec2 vUv;
|
||||||
|
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
|
||||||
|
void main() {
|
||||||
|
vec2 off = (vUv - 0.5) * aberration;
|
||||||
|
float r = texture2D(tDiffuse, vUv + off).r;
|
||||||
|
float g = texture2D(tDiffuse, vUv).g;
|
||||||
|
float b = texture2D(tDiffuse, vUv - off).b;
|
||||||
|
vec3 col = vec3(r, g, b);
|
||||||
|
col += (hash(vUv * 1024.0 + time) - 0.5) * grain;
|
||||||
|
float v = smoothstep(0.85, 0.20, length(vUv - 0.5));
|
||||||
|
col *= mix(1.0 - vignette, 1.0, v);
|
||||||
|
gl_FragColor = vec4(col, 1.0);
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
const filmPass = new THREE.ShaderPass(filmShader);
|
||||||
|
composer.addPass(filmPass);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Floor — same procedural cyber grid (toned down for skinned scene)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const floorMat = new THREE.ShaderMaterial({
|
||||||
|
uniforms: { time: { value: 0 }, baseColor: { value: new THREE.Color(0xffb840) } },
|
||||||
|
vertexShader: `varying vec3 vPos; void main() { vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform float time; uniform vec3 baseColor; varying vec3 vPos;
|
||||||
|
void main() {
|
||||||
|
vec2 g = abs(fract(vPos.xz * 0.5) - 0.5);
|
||||||
|
float line = smoothstep(0.48, 0.50, max(g.x, g.y));
|
||||||
|
float majorLine = smoothstep(0.96, 1.00, max(g.x, g.y) * 2.0);
|
||||||
|
float scan = 0.5 + 0.5 * sin((vPos.x + vPos.z) * 2.0 - time * 1.4);
|
||||||
|
scan = pow(scan, 14.0);
|
||||||
|
float falloff = smoothstep(5.0, 1.2, length(vPos.xz));
|
||||||
|
vec3 col = baseColor * (0.01 + 0.05 * line + 0.16 * majorLine + 0.08 * scan);
|
||||||
|
gl_FragColor = vec4(col * falloff, falloff * 0.55);
|
||||||
|
}`,
|
||||||
|
transparent: true, depthWrite: false,
|
||||||
|
});
|
||||||
|
const floor = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), floorMat);
|
||||||
|
floor.rotation.x = -Math.PI / 2;
|
||||||
|
floor.position.y = 0;
|
||||||
|
scene.add(floor);
|
||||||
|
|
||||||
|
// shadow-receiving ground (invisible, just catches the shadow)
|
||||||
|
const shadowGround = new THREE.Mesh(
|
||||||
|
new THREE.PlaneGeometry(20, 20),
|
||||||
|
new THREE.ShadowMaterial({ opacity: 0.55 })
|
||||||
|
);
|
||||||
|
shadowGround.rotation.x = -Math.PI / 2;
|
||||||
|
shadowGround.position.y = 0.001;
|
||||||
|
shadowGround.receiveShadow = true;
|
||||||
|
scene.add(shadowGround);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// ADR-097 helpers
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const gridHelper = new THREE.GridHelper(4, 20, 0x554a32, 0x2a2418);
|
||||||
|
gridHelper.material.transparent = true; gridHelper.material.opacity = 0.45;
|
||||||
|
scene.add(gridHelper);
|
||||||
|
|
||||||
|
const polarHelper = new THREE.PolarGridHelper(2.2, 16, 4, 64, 0xffb840, 0x4a3a1a);
|
||||||
|
polarHelper.position.y = 0.002;
|
||||||
|
polarHelper.material.transparent = true; polarHelper.material.opacity = 0.55;
|
||||||
|
scene.add(polarHelper);
|
||||||
|
|
||||||
|
let bboxHelper = null;
|
||||||
|
let skeletonHelper = null;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Multistatic sensor nodes — same as cinematic
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const nodeGroup = new THREE.Group();
|
||||||
|
scene.add(nodeGroup);
|
||||||
|
const nodeBboxHelpers = [];
|
||||||
|
const nodeRings = [];
|
||||||
|
const nodeAnchors = [];
|
||||||
|
const nodeBodyGeo = new THREE.BoxGeometry(0.14, 0.06, 0.20);
|
||||||
|
const nodeBodyMat = new THREE.MeshBasicMaterial({ color: 0xffb840 });
|
||||||
|
const antennaGeo = new THREE.ConeGeometry(0.018, 0.10, 8);
|
||||||
|
const antennaMat = new THREE.MeshBasicMaterial({ color: 0xffe09f });
|
||||||
|
|
||||||
|
NODE_POSITIONS.forEach((pos, i) => {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.position.set(pos[0], pos[1], pos[2]);
|
||||||
|
|
||||||
|
const body = new THREE.Mesh(nodeBodyGeo, nodeBodyMat);
|
||||||
|
group.add(body);
|
||||||
|
const antenna = new THREE.Mesh(antennaGeo, antennaMat);
|
||||||
|
antenna.position.y = 0.08; group.add(antenna);
|
||||||
|
|
||||||
|
const ring = new THREE.Mesh(
|
||||||
|
new THREE.RingGeometry(0.11, 0.14, 32),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0xffb840, side: THREE.DoubleSide, transparent: true,
|
||||||
|
opacity: 0.55, blending: THREE.AdditiveBlending, depthWrite: false })
|
||||||
|
);
|
||||||
|
ring.rotation.x = -Math.PI / 2; ring.position.y = -0.05;
|
||||||
|
ring.userData.phase = i * 0.7;
|
||||||
|
group.add(ring); nodeRings.push(ring);
|
||||||
|
|
||||||
|
const core = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.025, 12, 12),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0xffe09f })
|
||||||
|
);
|
||||||
|
core.position.y = 0.04; group.add(core);
|
||||||
|
|
||||||
|
nodeGroup.add(group); nodeAnchors.push(group);
|
||||||
|
|
||||||
|
const bbox = new THREE.BoxHelper(group, 0x4cf);
|
||||||
|
bbox.material.transparent = true; bbox.material.opacity = 0.45;
|
||||||
|
scene.add(bbox); nodeBboxHelpers.push(bbox);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// GLTF — load the rigged Xbot model
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
let model = null;
|
||||||
|
let mixer = null;
|
||||||
|
let headBone = null;
|
||||||
|
const baseActions = {}; // idle / walk / run
|
||||||
|
const additiveActions = {}; // sneak_pose / sad_pose / agree / headShake
|
||||||
|
let currentBase = 'walk';
|
||||||
|
let currentAddName = 'headShake';
|
||||||
|
let addWeight = 0.40;
|
||||||
|
|
||||||
|
const loader = new THREE.GLTFLoader();
|
||||||
|
loader.load(MODEL_URL, (gltf) => {
|
||||||
|
model = gltf.scene;
|
||||||
|
model.position.y = 0;
|
||||||
|
model.traverse(obj => {
|
||||||
|
if (obj.isMesh) { obj.castShadow = true; obj.receiveShadow = true; }
|
||||||
|
if (obj.isBone && /head/i.test(obj.name) && !headBone) headBone = obj;
|
||||||
|
});
|
||||||
|
scene.add(model);
|
||||||
|
|
||||||
|
skeletonHelper = new THREE.SkeletonHelper(model);
|
||||||
|
skeletonHelper.visible = false;
|
||||||
|
scene.add(skeletonHelper);
|
||||||
|
|
||||||
|
mixer = new THREE.AnimationMixer(model);
|
||||||
|
const baseNames = new Set(['idle', 'walk', 'run']);
|
||||||
|
const additiveNames = new Set(['sneak_pose', 'sad_pose', 'agree', 'headShake']);
|
||||||
|
|
||||||
|
for (let i = 0; i < gltf.animations.length; i++) {
|
||||||
|
let clip = gltf.animations[i];
|
||||||
|
const name = clip.name;
|
||||||
|
if (baseNames.has(name)) {
|
||||||
|
const action = mixer.clipAction(clip);
|
||||||
|
action.enabled = true;
|
||||||
|
action.setEffectiveTimeScale(1);
|
||||||
|
action.setEffectiveWeight(name === currentBase ? 1 : 0);
|
||||||
|
action.play();
|
||||||
|
baseActions[name] = action;
|
||||||
|
} else if (additiveNames.has(name)) {
|
||||||
|
THREE.AnimationUtils.makeClipAdditive(clip);
|
||||||
|
if (name.endsWith('_pose')) {
|
||||||
|
clip = THREE.AnimationUtils.subclip(clip, name, 2, 3, 30);
|
||||||
|
}
|
||||||
|
const action = mixer.clipAction(clip);
|
||||||
|
action.enabled = true;
|
||||||
|
action.setEffectiveTimeScale(1);
|
||||||
|
action.setEffectiveWeight(name === currentAddName ? addWeight : 0);
|
||||||
|
action.play();
|
||||||
|
additiveActions[name] = action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// build the face point cloud anchored to head bone
|
||||||
|
buildFacePointCloud();
|
||||||
|
|
||||||
|
document.getElementById('loading').classList.add('hidden');
|
||||||
|
}, (xhr) => {
|
||||||
|
const pct = xhr.loaded / (xhr.total || 2930032) * 100;
|
||||||
|
const txt = document.querySelector('#loading .text');
|
||||||
|
if (txt) txt.textContent = `▸ Loading skinned subject · Xbot.glb · ${pct.toFixed(0)} %`;
|
||||||
|
}, (err) => {
|
||||||
|
console.error('GLTF load failed', err);
|
||||||
|
document.querySelector('#loading .text').textContent = '⚠ Load failed — see console';
|
||||||
|
});
|
||||||
|
|
||||||
|
function setBase(name) {
|
||||||
|
if (!baseActions[name]) return;
|
||||||
|
for (const k in baseActions) {
|
||||||
|
const a = baseActions[k];
|
||||||
|
const target = (k === name) ? 1 : 0;
|
||||||
|
a.crossFadeTo ? null : null; // (no-op — using simple weight crossfade)
|
||||||
|
a.setEffectiveWeight(target);
|
||||||
|
}
|
||||||
|
currentBase = name;
|
||||||
|
document.getElementById('base-name').textContent = name;
|
||||||
|
for (const btn of document.querySelectorAll('#anim [data-base]')) {
|
||||||
|
btn.classList.toggle('active', btn.dataset.base === name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setAdditive(name) {
|
||||||
|
for (const k in additiveActions) {
|
||||||
|
additiveActions[k].setEffectiveWeight(k === name ? addWeight : 0);
|
||||||
|
}
|
||||||
|
currentAddName = name;
|
||||||
|
document.getElementById('add-name').textContent = name + ' · ' + addWeight.toFixed(2);
|
||||||
|
for (const btn of document.querySelectorAll('#anim [data-add]')) {
|
||||||
|
btn.classList.toggle('active', btn.dataset.add === name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Face point cloud — anchored to head bone via getWorldPosition each frame
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const FACE_POINTS = 480;
|
||||||
|
const facePositions = new Float32Array(FACE_POINTS * 3);
|
||||||
|
const faceOffsets = new Float32Array(FACE_POINTS * 3);
|
||||||
|
const facePhases = new Float32Array(FACE_POINTS);
|
||||||
|
let facePoints = null;
|
||||||
|
function buildFacePointCloud() {
|
||||||
|
for (let i = 0; i < FACE_POINTS; i++) {
|
||||||
|
const u = Math.random() * Math.PI * 2;
|
||||||
|
const v = (Math.random() - 0.5) * Math.PI * 0.95;
|
||||||
|
const cu = Math.cos(u), su = Math.sin(u);
|
||||||
|
const cv = Math.cos(v), sv = Math.sin(v);
|
||||||
|
faceOffsets[i*3+0] = 0.085 * cv * cu;
|
||||||
|
faceOffsets[i*3+1] = 0.108 * sv;
|
||||||
|
faceOffsets[i*3+2] = 0.072 * cv * su;
|
||||||
|
facePhases[i] = Math.random() * Math.PI * 2;
|
||||||
|
}
|
||||||
|
const geom = new THREE.BufferGeometry();
|
||||||
|
geom.setAttribute('position', new THREE.BufferAttribute(facePositions, 3));
|
||||||
|
geom.setAttribute('aPhase', new THREE.BufferAttribute(facePhases, 1));
|
||||||
|
const mat = new THREE.ShaderMaterial({
|
||||||
|
uniforms: { time: { value: 0 } },
|
||||||
|
vertexShader: `
|
||||||
|
attribute float aPhase; uniform float time;
|
||||||
|
varying float vAlpha;
|
||||||
|
void main() {
|
||||||
|
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||||
|
float shimmer = 0.5 + 0.5 * sin(time * 3.0 + aPhase);
|
||||||
|
vAlpha = 0.18 + 0.30 * shimmer;
|
||||||
|
gl_Position = projectionMatrix * mv;
|
||||||
|
gl_PointSize = (1.6 + shimmer * 1.0) * (200.0 / -mv.z);
|
||||||
|
}`,
|
||||||
|
fragmentShader: `
|
||||||
|
varying float vAlpha;
|
||||||
|
void main() {
|
||||||
|
vec2 c = gl_PointCoord - 0.5;
|
||||||
|
float d = length(c);
|
||||||
|
if (d > 0.5) discard;
|
||||||
|
float falloff = smoothstep(0.5, 0.0, d);
|
||||||
|
vec3 col = mix(vec3(0.18, 0.52, 0.72), vec3(0.55, 0.62, 0.72), 0.5);
|
||||||
|
gl_FragColor = vec4(col * (1.0 + falloff * 0.3), vAlpha * falloff);
|
||||||
|
}`,
|
||||||
|
transparent: true, depthWrite: false,
|
||||||
|
});
|
||||||
|
facePoints = new THREE.Points(geom, mat);
|
||||||
|
scene.add(facePoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Sonar pings + tomography sweep — same as cinematic.html
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const PING_POOL = 24;
|
||||||
|
const pings = [];
|
||||||
|
const pingGeo = new THREE.TorusGeometry(1, 0.012, 8, 48);
|
||||||
|
for (let i = 0; i < PING_POOL; i++) {
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ color: 0x4cf, transparent: true, opacity: 0, depthWrite: false });
|
||||||
|
const mesh = new THREE.Mesh(pingGeo, mat);
|
||||||
|
mesh.visible = false; scene.add(mesh);
|
||||||
|
pings.push({ mesh, active: false, t0: 0, duration: 0,
|
||||||
|
origin: new THREE.Vector3(), target: new THREE.Vector3() });
|
||||||
|
}
|
||||||
|
let pingIndex = 0;
|
||||||
|
function emitPing(origin, target) {
|
||||||
|
const p = pings[pingIndex]; pingIndex = (pingIndex + 1) % PING_POOL;
|
||||||
|
p.active = true; p.t0 = performance.now() * 0.001;
|
||||||
|
p.duration = 0.55 + Math.random() * 0.20;
|
||||||
|
p.origin.copy(origin); p.target.copy(target);
|
||||||
|
p.mesh.position.copy(origin); p.mesh.visible = true;
|
||||||
|
p.mesh.material.opacity = 0;
|
||||||
|
const dir = new THREE.Vector3().subVectors(target, origin).normalize();
|
||||||
|
p.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tomoMat = new THREE.ShaderMaterial({
|
||||||
|
uniforms: { time: { value: 0 }, intensity: { value: 0 } },
|
||||||
|
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform float time, intensity; varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
float band = exp(-pow((vUv.x - 0.5) * 14.0, 2.0));
|
||||||
|
float lines = 0.5 + 0.5 * sin(vUv.y * 90.0 + time * 4.0);
|
||||||
|
vec3 col = vec3(1.0, 0.3, 0.78) * band * (0.6 + 0.4 * lines);
|
||||||
|
gl_FragColor = vec4(col, intensity * band * 0.75);
|
||||||
|
}`,
|
||||||
|
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
const tomoPlane = new THREE.Mesh(new THREE.PlaneGeometry(8, 6), tomoMat);
|
||||||
|
tomoPlane.rotation.y = Math.PI / 2;
|
||||||
|
tomoPlane.position.set(-2, 1.0, 0);
|
||||||
|
tomoPlane.visible = false;
|
||||||
|
scene.add(tomoPlane);
|
||||||
|
let tomoActive = false, tomoT0 = 0, tomoNextAt = 4 + Math.random() * 4;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Pseudo-CSI driver — same as cinematic
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const csiAmp = [0, 0, 0, 0];
|
||||||
|
let csiCoherence = 0.5;
|
||||||
|
const csiNoise = [0, 0, 0, 0];
|
||||||
|
|
||||||
|
function tickCsi(t, targetWorld) {
|
||||||
|
for (let i = 0; i < 4; i++) csiNoise[i] = csiNoise[i] * 0.92 + (Math.random() - 0.5) * 0.08;
|
||||||
|
let mean = 0; const amps = [];
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const np = NODE_POSITIONS[i];
|
||||||
|
const dx = np[0] - targetWorld.x, dy = np[1] - targetWorld.y, dz = np[2] - targetWorld.z;
|
||||||
|
const r2 = dx*dx + dy*dy + dz*dz;
|
||||||
|
const fall = 1.0 / (1.0 + r2 * 0.18);
|
||||||
|
const breath = Math.sin(t * 0.27 * Math.PI * 2) * 0.10;
|
||||||
|
const heart = Math.sin(t * 1.18 * Math.PI * 2) * 0.04;
|
||||||
|
const walk = Math.sin(t * 1.9 + i * 0.5) * 0.12;
|
||||||
|
const a = Math.max(0, Math.min(1, fall + breath + heart + walk + csiNoise[i] * 0.30));
|
||||||
|
amps.push(a);
|
||||||
|
csiAmp[i] = csiAmp[i] * 0.7 + a * 0.3;
|
||||||
|
mean += a;
|
||||||
|
}
|
||||||
|
mean /= 4;
|
||||||
|
let v = 0; for (let i = 0; i < 4; i++) v += (amps[i] - mean) ** 2;
|
||||||
|
v = Math.sqrt(v / 4);
|
||||||
|
csiCoherence = csiCoherence * 0.85 + Math.max(0, Math.min(1, 1.0 - v * 2.5)) * 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Per-frame updates
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const tmpVec = new THREE.Vector3();
|
||||||
|
let lastPingT = [0, 0, 0, 0];
|
||||||
|
|
||||||
|
function updateNodes() {
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const ring = nodeRings[i];
|
||||||
|
const amp = csiAmp[i];
|
||||||
|
ring.material.opacity = 0.32 + 0.55 * amp;
|
||||||
|
ring.scale.setScalar(1 + 0.30 * amp);
|
||||||
|
rimLights[i].intensity = 0.30 + 0.60 * amp * csiCoherence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function maybeEmitPings(t, modelCenter) {
|
||||||
|
if (!document.getElementById('t-pings').checked || !model) return;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const interval = 1.2 / (0.25 + csiAmp[i]);
|
||||||
|
if (t - lastPingT[i] > interval) {
|
||||||
|
lastPingT[i] = t;
|
||||||
|
const target = modelCenter.clone();
|
||||||
|
target.y += (Math.random() - 0.3) * 0.8;
|
||||||
|
target.x += (Math.random() - 0.5) * 0.2;
|
||||||
|
const origin = nodeAnchors[i].getWorldPosition(new THREE.Vector3());
|
||||||
|
emitPing(origin, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updatePings(t) {
|
||||||
|
for (const p of pings) {
|
||||||
|
if (!p.active) continue;
|
||||||
|
const u = (t - p.t0) / p.duration;
|
||||||
|
if (u >= 1) { p.active = false; p.mesh.visible = false; continue; }
|
||||||
|
p.mesh.position.lerpVectors(p.origin, p.target, u);
|
||||||
|
p.mesh.scale.setScalar(0.03 + u * 0.18);
|
||||||
|
p.mesh.material.opacity = (1.0 - u) * 0.40 * csiCoherence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateTomography(t) {
|
||||||
|
if (!document.getElementById('t-tomo').checked) { tomoActive = false; tomoPlane.visible = false; return; }
|
||||||
|
if (!tomoActive && t > tomoNextAt) {
|
||||||
|
tomoActive = true; tomoT0 = t; tomoPlane.visible = true;
|
||||||
|
const sf = document.getElementById('scan-flash');
|
||||||
|
sf.style.animation = 'none';
|
||||||
|
requestAnimationFrame(() => { sf.style.animation = 'scanFlash 1.6s ease-out'; });
|
||||||
|
}
|
||||||
|
if (tomoActive) {
|
||||||
|
const dur = 2.4;
|
||||||
|
const e = (t - tomoT0) / dur;
|
||||||
|
if (e >= 1) {
|
||||||
|
tomoActive = false; tomoPlane.visible = false;
|
||||||
|
tomoNextAt = t + 4 + Math.random() * 5;
|
||||||
|
} else {
|
||||||
|
tomoPlane.position.x = -3 + e * 6;
|
||||||
|
tomoMat.uniforms.intensity.value = Math.sin(e * Math.PI);
|
||||||
|
tomoMat.uniforms.time.value = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateBbox() {
|
||||||
|
const want = document.getElementById('t-bbox').checked && model;
|
||||||
|
if (!want) {
|
||||||
|
if (bboxHelper) { scene.remove(bboxHelper); bboxHelper = null; }
|
||||||
|
document.getElementById('bbox-vol').textContent = '—';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bboxHelper) {
|
||||||
|
bboxHelper = new THREE.BoxHelper(model, 0xffe09f);
|
||||||
|
bboxHelper.material.transparent = true; bboxHelper.material.opacity = 0.55;
|
||||||
|
scene.add(bboxHelper);
|
||||||
|
} else bboxHelper.setFromObject(model);
|
||||||
|
const box = new THREE.Box3().setFromObject(model);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
document.getElementById('bbox-vol').textContent = (size.x * size.y * size.z).toFixed(3) + ' m³';
|
||||||
|
}
|
||||||
|
function updateFaceCloud(t) {
|
||||||
|
if (!facePoints || !headBone) return;
|
||||||
|
const headWorld = new THREE.Vector3();
|
||||||
|
headBone.getWorldPosition(headWorld);
|
||||||
|
const pos = facePoints.geometry.attributes.position;
|
||||||
|
for (let i = 0; i < FACE_POINTS; i++) {
|
||||||
|
pos.array[i*3+0] = headWorld.x + faceOffsets[i*3+0];
|
||||||
|
pos.array[i*3+1] = headWorld.y + faceOffsets[i*3+1] + 0.06;
|
||||||
|
pos.array[i*3+2] = headWorld.z + faceOffsets[i*3+2];
|
||||||
|
}
|
||||||
|
pos.needsUpdate = true;
|
||||||
|
facePoints.material.uniforms.time.value = t;
|
||||||
|
}
|
||||||
|
let hudT = 0;
|
||||||
|
function updateHud(t, fps) {
|
||||||
|
if (t - hudT < 0.1) return;
|
||||||
|
hudT = t;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const pct = Math.round(csiAmp[i] * 100);
|
||||||
|
document.getElementById('bar-' + i).style.width = pct + '%';
|
||||||
|
document.getElementById('val-' + i).textContent = pct + '%';
|
||||||
|
}
|
||||||
|
document.getElementById('coh-val').textContent = (csiCoherence * 100).toFixed(0) + ' %';
|
||||||
|
document.getElementById('hr-val').textContent = (68 + Math.sin(t * 0.3) * 4).toFixed(0) + ' bpm';
|
||||||
|
document.getElementById('fps-val').textContent = fps.toFixed(0) + ' fps';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// UI wiring
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
for (const btn of document.querySelectorAll('#anim [data-base]')) {
|
||||||
|
btn.addEventListener('click', () => setBase(btn.dataset.base));
|
||||||
|
}
|
||||||
|
for (const btn of document.querySelectorAll('#anim [data-add]')) {
|
||||||
|
btn.addEventListener('click', () => setAdditive(btn.dataset.add));
|
||||||
|
}
|
||||||
|
document.getElementById('add-weight').addEventListener('input', (e) => {
|
||||||
|
addWeight = parseFloat(e.target.value);
|
||||||
|
document.getElementById('add-weight-val').textContent = addWeight.toFixed(2);
|
||||||
|
if (additiveActions[currentAddName]) additiveActions[currentAddName].setEffectiveWeight(addWeight);
|
||||||
|
document.getElementById('add-name').textContent = currentAddName + ' · ' + addWeight.toFixed(2);
|
||||||
|
});
|
||||||
|
document.getElementById('time-scale').addEventListener('input', (e) => {
|
||||||
|
const ts = parseFloat(e.target.value);
|
||||||
|
document.getElementById('time-scale-val').textContent = ts.toFixed(2);
|
||||||
|
if (mixer) mixer.timeScale = ts;
|
||||||
|
});
|
||||||
|
function bindToggle(id, obj) {
|
||||||
|
document.getElementById(id).addEventListener('change', e => {
|
||||||
|
if (e.target.checked && !scene.children.includes(obj)) scene.add(obj);
|
||||||
|
else if (!e.target.checked) scene.remove(obj);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bindToggle('t-grid', gridHelper);
|
||||||
|
bindToggle('t-polar', polarHelper);
|
||||||
|
document.getElementById('t-skel').addEventListener('change', e => {
|
||||||
|
if (skeletonHelper) skeletonHelper.visible = e.target.checked;
|
||||||
|
});
|
||||||
|
document.getElementById('t-nodebox').addEventListener('change', e => {
|
||||||
|
for (const bb of nodeBboxHelpers) {
|
||||||
|
if (e.target.checked && !scene.children.includes(bb)) scene.add(bb);
|
||||||
|
else if (!e.target.checked) scene.remove(bb);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Main loop
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const clock = new THREE.Clock();
|
||||||
|
let lastMs = performance.now();
|
||||||
|
let fpsEma = 60;
|
||||||
|
function tick() {
|
||||||
|
const nowMs = performance.now();
|
||||||
|
const dt = nowMs - lastMs;
|
||||||
|
lastMs = nowMs;
|
||||||
|
fpsEma = fpsEma * 0.92 + (1000 / Math.max(dt, 1)) * 0.08;
|
||||||
|
const t = nowMs * 0.001;
|
||||||
|
const delta = clock.getDelta();
|
||||||
|
|
||||||
|
if (mixer) mixer.update(delta);
|
||||||
|
floorMat.uniforms.time.value = t;
|
||||||
|
filmShader.uniforms.time.value = t;
|
||||||
|
|
||||||
|
// get model center for CSI / ping targeting
|
||||||
|
const center = new THREE.Vector3();
|
||||||
|
if (model) {
|
||||||
|
const box = new THREE.Box3().setFromObject(model);
|
||||||
|
box.getCenter(center);
|
||||||
|
} else center.set(0, 0.9, 0);
|
||||||
|
|
||||||
|
tickCsi(t, center);
|
||||||
|
updateNodes();
|
||||||
|
maybeEmitPings(t, center);
|
||||||
|
updatePings(t);
|
||||||
|
updateTomography(t);
|
||||||
|
updateBbox();
|
||||||
|
updateFaceCloud(t);
|
||||||
|
|
||||||
|
controls.update();
|
||||||
|
composer.render();
|
||||||
|
|
||||||
|
updateHud(t, fpsEma);
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
camera.aspect = window.innerWidth / window.innerHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
composer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
bloom.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,961 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>RuView · Skinned (FBX) · Mixamo X Bot in the ADR-097 helpers scene</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: #050507; --bg-panel: rgba(8,10,14,0.78);
|
||||||
|
--amber: #ffb840; --amber-hot: #ffe09f;
|
||||||
|
--cyan: #4cf; --magenta: #ff4cc8;
|
||||||
|
--text: #d8c69a; --text-mute: #6b6155;
|
||||||
|
--border: rgba(255,184,64,0.18);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: var(--bg); color: var(--text); overflow: hidden;
|
||||||
|
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||||
|
-webkit-font-smoothing: antialiased; font-size: 12px;
|
||||||
|
}
|
||||||
|
canvas { display: block; }
|
||||||
|
.overlay-frame {
|
||||||
|
position: fixed; inset: 0; pointer-events: none; z-index: 5;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
|
||||||
|
linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.38) 100%);
|
||||||
|
}
|
||||||
|
.scanlines {
|
||||||
|
position: fixed; inset: 0; pointer-events: none; z-index: 6;
|
||||||
|
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.04) 0px, rgba(0,0,0,0.04) 1px, transparent 1px, transparent 3px);
|
||||||
|
mix-blend-mode: overlay; opacity: 0.5;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
position: absolute; background: var(--bg-panel); border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; padding: 12px 14px; backdrop-filter: blur(8px);
|
||||||
|
box-shadow: 0 1px 0 rgba(255,184,64,0.04), 0 8px 32px rgba(0,0,0,0.55); z-index: 10;
|
||||||
|
}
|
||||||
|
.panel h2 {
|
||||||
|
margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||||
|
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
#info { top: 20px; left: 20px; min-width: 280px; }
|
||||||
|
#info h1 { margin: 0 0 1px 0; font-size: 13px; letter-spacing: 1px; color: var(--amber-hot); font-weight: 600; }
|
||||||
|
#info .sub { font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
||||||
|
#info .row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
|
||||||
|
#info .row .k { color: var(--text-mute); font-size: 11px; }
|
||||||
|
#info .row .v { color: var(--text); font-variant-numeric: tabular-nums; font-size: 11px; }
|
||||||
|
#info .row .v.amber { color: var(--amber); }
|
||||||
|
#info .row .v.cyan { color: var(--cyan); }
|
||||||
|
#info .row .v.mag { color: var(--magenta); }
|
||||||
|
|
||||||
|
#anim {
|
||||||
|
position: absolute; bottom: 20px; left: 20px; min-width: 280px;
|
||||||
|
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
|
||||||
|
}
|
||||||
|
#anim h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||||
|
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||||
|
#anim .row { padding: 6px 0; font-size: 10px; }
|
||||||
|
#anim .row .label { color: var(--text-mute); margin-right: 8px; }
|
||||||
|
#anim button {
|
||||||
|
background: rgba(255,184,64,0.06); border: 1px solid rgba(255,184,64,0.18);
|
||||||
|
color: var(--text); font-family: inherit; font-size: 10px; padding: 4px 8px;
|
||||||
|
margin: 2px 4px 2px 0; cursor: pointer; border-radius: 3px; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
#anim button:hover { background: rgba(255,184,64,0.14); color: var(--amber-hot); }
|
||||||
|
#anim button.active { background: var(--amber); color: var(--bg); border-color: var(--amber); font-weight: 600; }
|
||||||
|
#anim .slider-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; margin-top: 6px; border-top: 1px solid rgba(255,184,64,0.08); padding-top: 8px; }
|
||||||
|
#anim .slider-row .label { width: 90px; }
|
||||||
|
#anim .slider-row input[type=range] { flex: 1; accent-color: var(--amber); }
|
||||||
|
#anim .slider-row .val { width: 38px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||||
|
#anim .empty-hint {
|
||||||
|
font-size: 10px; color: var(--text-mute); line-height: 1.5; margin-top: 4px;
|
||||||
|
padding: 8px; background: rgba(255,184,64,0.04); border-radius: 3px;
|
||||||
|
border-left: 2px solid var(--amber);
|
||||||
|
}
|
||||||
|
#anim .empty-hint a { color: var(--amber); text-decoration: none; }
|
||||||
|
#anim .empty-hint a:hover { color: var(--amber-hot); text-decoration: underline; }
|
||||||
|
|
||||||
|
#helpers {
|
||||||
|
position: absolute; bottom: 20px; right: 20px; min-width: 220px;
|
||||||
|
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
|
||||||
|
}
|
||||||
|
#helpers h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
||||||
|
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||||
|
#helpers label {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 3px 0; cursor: pointer; user-select: none; font-size: 11px;
|
||||||
|
}
|
||||||
|
#helpers label:hover { color: var(--amber-hot); }
|
||||||
|
#helpers input[type=checkbox] { accent-color: var(--amber); width: 13px; height: 13px; cursor: pointer; }
|
||||||
|
#helpers .swatch { width: 8px; height: 8px; border-radius: 50%; margin-left: auto; box-shadow: 0 0 6px currentColor; }
|
||||||
|
|
||||||
|
#csi { top: 20px; right: 20px; min-width: 260px; }
|
||||||
|
#csi .bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
|
||||||
|
#csi .bar-row .label { width: 42px; color: var(--text-mute); }
|
||||||
|
#csi .bar-row .bar-track { flex: 1; height: 6px; background: rgba(255,184,64,0.08); border-radius: 2px; overflow: hidden; }
|
||||||
|
#csi .bar-row .bar-fill {
|
||||||
|
height: 100%; background: linear-gradient(90deg, var(--amber-hot), var(--amber));
|
||||||
|
box-shadow: 0 0 6px var(--amber); transition: width 0.08s linear;
|
||||||
|
}
|
||||||
|
#csi .bar-row .val { width: 36px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
#loading {
|
||||||
|
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(5,5,7,0.96); z-index: 20; font-size: 13px; color: var(--amber);
|
||||||
|
letter-spacing: 2px; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
#loading.hidden { display: none; }
|
||||||
|
#loading .text { text-shadow: 0 0 12px var(--amber); animation: loadPulse 1.4s ease-in-out infinite; }
|
||||||
|
@keyframes loadPulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1.0; } }
|
||||||
|
|
||||||
|
@keyframes scanFlash { 0% { opacity: 0; } 10% { opacity: 0.12; } 100% { opacity: 0; } }
|
||||||
|
.scan-flash {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--magenta), transparent);
|
||||||
|
mix-blend-mode: screen; pointer-events: none; opacity: 0; z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
#titlecard {
|
||||||
|
position: absolute; bottom: 76px; left: 50%; transform: translateX(-50%);
|
||||||
|
text-align: center; color: var(--amber-hot); letter-spacing: 6px; font-size: 11px;
|
||||||
|
text-transform: uppercase; opacity: 0.35; z-index: 10;
|
||||||
|
text-shadow: 0 0 12px var(--amber); pointer-events: none;
|
||||||
|
}
|
||||||
|
#titlecard .sub { font-size: 9px; color: var(--text-mute); letter-spacing: 4px; margin-top: 4px; }
|
||||||
|
</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>
|
||||||
|
<script src="https://unpkg.com/fflate@0.7.4/umd/index.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/curves/NURBSCurve.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/FBXLoader.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="overlay-frame"></div>
|
||||||
|
<div class="scanlines"></div>
|
||||||
|
<div class="scan-flash" id="scan-flash"></div>
|
||||||
|
<div id="loading"><div class="text">▸ Loading skinned subject · X Bot.fbx</div></div>
|
||||||
|
|
||||||
|
<div class="panel" id="info">
|
||||||
|
<h1>RuView · Skinned (FBX)</h1>
|
||||||
|
<div class="sub">ADR-097 · Mixamo X Bot · loaded via FBXLoader</div>
|
||||||
|
<div class="row"><span class="k">Subject</span><span class="v amber">● Tracked</span></div>
|
||||||
|
<div class="row"><span class="k">Source</span><span class="v" id="src-name">X Bot.fbx</span></div>
|
||||||
|
<div class="row"><span class="k">Format</span><span class="v">FBX 7700 · 1.75 MB</span></div>
|
||||||
|
<div class="row"><span class="k">Bones</span><span class="v" id="bone-count">—</span></div>
|
||||||
|
<div class="row"><span class="k">Animation</span><span class="v amber" id="anim-name">—</span></div>
|
||||||
|
<div class="row"><span class="k">Mesh nodes</span><span class="v cyan">4 · multistatic</span></div>
|
||||||
|
<div class="row"><span class="k">Coherence</span><span class="v" id="coh-val">— %</span></div>
|
||||||
|
<div class="row"><span class="k">Heart rate</span><span class="v amber" id="hr-val">— bpm</span></div>
|
||||||
|
<div class="row"><span class="k">Bbox vol</span><span class="v" id="bbox-vol">— m³</span></div>
|
||||||
|
<div class="row"><span class="k">Render</span><span class="v" id="fps-val">— fps</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="anim">
|
||||||
|
<h2>AnimationMixer</h2>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">clips</span>
|
||||||
|
<span id="clip-buttons"></span>
|
||||||
|
</div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<span class="label">time scale</span>
|
||||||
|
<input type="range" id="time-scale" min="0.1" max="2" step="0.05" value="1.0">
|
||||||
|
<span class="val" id="time-scale-val">1.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="empty-hint" id="empty-hint" style="display:none;">
|
||||||
|
<strong>No animations in this FBX.</strong><br>
|
||||||
|
Mixamo's "T-Pose / Without Skin" export rigs the model but has no clips.
|
||||||
|
Re-download with <em>"Original Pose"</em> + an animation selected
|
||||||
|
(e.g. <a href="https://www.mixamo.com/#/?page=1&query=walking&type=Motion%2CMotionPack" target="_blank" rel="noopener">Walking</a>) to get a clip, or drop another FBX with anim and reload.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" id="csi">
|
||||||
|
<h2>Per-node CSI</h2>
|
||||||
|
<div class="bar-row"><span class="label">N1·BL</span><div class="bar-track"><div class="bar-fill" id="bar-0"></div></div><span class="val" id="val-0">—</span></div>
|
||||||
|
<div class="bar-row"><span class="label">N2·BR</span><div class="bar-track"><div class="bar-fill" id="bar-1"></div></div><span class="val" id="val-1">—</span></div>
|
||||||
|
<div class="bar-row"><span class="label">N3·FL</span><div class="bar-track"><div class="bar-fill" id="bar-2"></div></div><span class="val" id="val-2">—</span></div>
|
||||||
|
<div class="bar-row"><span class="label">N4·FR</span><div class="bar-track"><div class="bar-fill" id="bar-3"></div></div><span class="val" id="val-3">—</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="helpers">
|
||||||
|
<h2>ADR-097 helpers</h2>
|
||||||
|
<label><input type="checkbox" id="t-grid" checked>GridHelper<span class="swatch" style="color:#666"></span></label>
|
||||||
|
<label><input type="checkbox" id="t-polar" checked>PolarGridHelper<span class="swatch" style="color:#ffb840"></span></label>
|
||||||
|
<label><input type="checkbox" id="t-bbox" checked>BoxHelper on mesh<span class="swatch" style="color:#ffe09f"></span></label>
|
||||||
|
<label><input type="checkbox" id="t-skel">SkeletonHelper<span class="swatch" style="color:#4cf"></span></label>
|
||||||
|
<label><input type="checkbox" id="t-nodebox" checked>Per-node BoxHelpers<span class="swatch" style="color:#4cf"></span></label>
|
||||||
|
<label><input type="checkbox" id="t-pings" checked>Sonar pings<span class="swatch" style="color:#4cf"></span></label>
|
||||||
|
<label><input type="checkbox" id="t-tomo" checked>Tomography sweep<span class="swatch" style="color:#ff4cc8"></span></label>
|
||||||
|
<label><input type="checkbox" id="t-rays" checked>RF illumination cones<span class="swatch" style="color:#ffb840"></span></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="titlecard">
|
||||||
|
RuView · Seldon Vault
|
||||||
|
<div class="sub">FBXLoader · Mixamo · ADR-097</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// =====================================================================
|
||||||
|
// RuView · Skinned (FBX) · Mixamo X Bot loaded via FBXLoader
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Sibling of helpers-skinned.html that loads a local .fbx file
|
||||||
|
// rather than the canonical GLB. Same cinematic atmosphere
|
||||||
|
// (UnrealBloomPass, sonar pings, tomography sweep, pseudo-CSI),
|
||||||
|
// same ADR-097 helpers wrapping the rigged mesh.
|
||||||
|
//
|
||||||
|
// Mixamo FBX caveats handled here:
|
||||||
|
// 1. Mixamo exports in cm (100 = 1 m). We auto-detect by the
|
||||||
|
// loaded model's bbox height and rescale to ~1.7 m human size.
|
||||||
|
// 2. PhongMaterial → StandardMaterial swap for cleaner shading
|
||||||
|
// under our amber key + cyan rim lights.
|
||||||
|
// 3. Bone name probing for the head (Mixamo: "mixamorigHead",
|
||||||
|
// legacy: "Bip01_Head", or any bone with /head/i match).
|
||||||
|
// 4. Graceful no-animations case — many Mixamo exports are
|
||||||
|
// rig-only.
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
const MODEL_URL = '../assets/X%20Bot.fbx';
|
||||||
|
|
||||||
|
const NODE_POSITIONS = [
|
||||||
|
[-1.9, 1.3, 1.9],[ 1.9, 1.3, 1.9],
|
||||||
|
[-1.9, 1.3, -1.9],[ 1.9, 1.3, -1.9],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Scene / camera / renderer
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x050507);
|
||||||
|
scene.fog = new THREE.FogExp2(0x050507, 0.06);
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(48, window.innerWidth/window.innerHeight, 0.05, 100);
|
||||||
|
camera.position.set(3.2, 1.55, 4.0);
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
||||||
|
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
renderer.toneMappingExposure = 0.80;
|
||||||
|
renderer.outputEncoding = THREE.sRGBEncoding;
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
document.body.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.target.set(0, 0.9, 0);
|
||||||
|
controls.enableDamping = true; controls.dampingFactor = 0.06;
|
||||||
|
controls.minDistance = 2; controls.maxDistance = 12;
|
||||||
|
controls.maxPolarAngle = Math.PI * 0.92;
|
||||||
|
controls.autoRotate = new URLSearchParams(location.search).get('orbit') === '1';
|
||||||
|
controls.autoRotateSpeed = 0.25;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Lights — amber key + cyan rim from each ESP32 direction
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
scene.add(new THREE.HemisphereLight(0x553a18, 0x080606, 0.7));
|
||||||
|
|
||||||
|
const keyLight = new THREE.DirectionalLight(0xffc070, 1.05);
|
||||||
|
keyLight.position.set(2.5, 3.8, 2.5);
|
||||||
|
keyLight.castShadow = true;
|
||||||
|
keyLight.shadow.camera.top = 2; keyLight.shadow.camera.bottom = -2;
|
||||||
|
keyLight.shadow.camera.left = -2; keyLight.shadow.camera.right = 2;
|
||||||
|
keyLight.shadow.camera.near = 0.1; keyLight.shadow.camera.far = 12;
|
||||||
|
keyLight.shadow.mapSize.set(1024, 1024);
|
||||||
|
keyLight.shadow.bias = -0.0008;
|
||||||
|
scene.add(keyLight);
|
||||||
|
|
||||||
|
const rimLights = [];
|
||||||
|
NODE_POSITIONS.forEach(pos => {
|
||||||
|
const rim = new THREE.PointLight(0x4cf, 0.55, 8, 1.8);
|
||||||
|
rim.position.set(pos[0] * 1.1, pos[1] * 0.7, pos[2] * 1.1);
|
||||||
|
scene.add(rim); rimLights.push(rim);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Post-processing
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const composer = new THREE.EffectComposer(renderer);
|
||||||
|
composer.addPass(new THREE.RenderPass(scene, camera));
|
||||||
|
const bloom = new THREE.UnrealBloomPass(
|
||||||
|
new THREE.Vector2(window.innerWidth, window.innerHeight),
|
||||||
|
0.45, 0.40, 0.78,
|
||||||
|
);
|
||||||
|
composer.addPass(bloom);
|
||||||
|
const filmShader = {
|
||||||
|
uniforms: { tDiffuse: { value: null }, time: { value: 0 }, grain: { value: 0.04 },
|
||||||
|
vignette: { value: 0.32 }, aberration: { value: 0.0018 } },
|
||||||
|
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform sampler2D tDiffuse; uniform float time, grain, vignette, aberration;
|
||||||
|
varying vec2 vUv;
|
||||||
|
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
|
||||||
|
void main() {
|
||||||
|
vec2 off = (vUv - 0.5) * aberration;
|
||||||
|
float r = texture2D(tDiffuse, vUv + off).r;
|
||||||
|
float g = texture2D(tDiffuse, vUv).g;
|
||||||
|
float b = texture2D(tDiffuse, vUv - off).b;
|
||||||
|
vec3 col = vec3(r, g, b);
|
||||||
|
col += (hash(vUv * 1024.0 + time) - 0.5) * grain;
|
||||||
|
float v = smoothstep(0.85, 0.20, length(vUv - 0.5));
|
||||||
|
col *= mix(1.0 - vignette, 1.0, v);
|
||||||
|
gl_FragColor = vec4(col, 1.0);
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
const filmPass = new THREE.ShaderPass(filmShader);
|
||||||
|
composer.addPass(filmPass);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Floor (same procedural shader as cinematic / skinned-glb)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const floorMat = new THREE.ShaderMaterial({
|
||||||
|
uniforms: { time: { value: 0 }, baseColor: { value: new THREE.Color(0xffb840) } },
|
||||||
|
vertexShader: `varying vec3 vPos; void main() { vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform float time; uniform vec3 baseColor; varying vec3 vPos;
|
||||||
|
void main() {
|
||||||
|
vec2 g = abs(fract(vPos.xz * 0.5) - 0.5);
|
||||||
|
float line = smoothstep(0.48, 0.50, max(g.x, g.y));
|
||||||
|
float majorLine = smoothstep(0.96, 1.00, max(g.x, g.y) * 2.0);
|
||||||
|
float scan = 0.5 + 0.5 * sin((vPos.x + vPos.z) * 2.0 - time * 1.4);
|
||||||
|
scan = pow(scan, 14.0);
|
||||||
|
float falloff = smoothstep(5.0, 1.2, length(vPos.xz));
|
||||||
|
vec3 col = baseColor * (0.01 + 0.05 * line + 0.16 * majorLine + 0.08 * scan);
|
||||||
|
gl_FragColor = vec4(col * falloff, falloff * 0.55);
|
||||||
|
}`,
|
||||||
|
transparent: true, depthWrite: false,
|
||||||
|
});
|
||||||
|
const floor = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), floorMat);
|
||||||
|
floor.rotation.x = -Math.PI / 2;
|
||||||
|
scene.add(floor);
|
||||||
|
|
||||||
|
const shadowGround = new THREE.Mesh(
|
||||||
|
new THREE.PlaneGeometry(20, 20),
|
||||||
|
new THREE.ShadowMaterial({ opacity: 0.55 })
|
||||||
|
);
|
||||||
|
shadowGround.rotation.x = -Math.PI / 2;
|
||||||
|
shadowGround.position.y = 0.001;
|
||||||
|
shadowGround.receiveShadow = true;
|
||||||
|
scene.add(shadowGround);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// ADR-097 helpers + sensor nodes (same as helpers-skinned.html)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const gridHelper = new THREE.GridHelper(4, 20, 0x554a32, 0x2a2418);
|
||||||
|
gridHelper.material.transparent = true; gridHelper.material.opacity = 0.45;
|
||||||
|
scene.add(gridHelper);
|
||||||
|
|
||||||
|
const polarHelper = new THREE.PolarGridHelper(2.2, 16, 4, 64, 0xffb840, 0x4a3a1a);
|
||||||
|
polarHelper.position.y = 0.002;
|
||||||
|
polarHelper.material.transparent = true; polarHelper.material.opacity = 0.55;
|
||||||
|
scene.add(polarHelper);
|
||||||
|
|
||||||
|
let bboxHelper = null;
|
||||||
|
let skeletonHelper = null;
|
||||||
|
|
||||||
|
const nodeBboxHelpers = [];
|
||||||
|
const nodeRings = [];
|
||||||
|
const nodeAnchors = [];
|
||||||
|
NODE_POSITIONS.forEach((pos, i) => {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.position.set(pos[0], pos[1], pos[2]);
|
||||||
|
|
||||||
|
group.add(new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(0.14, 0.06, 0.20),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0xffb840 })
|
||||||
|
));
|
||||||
|
const antenna = new THREE.Mesh(
|
||||||
|
new THREE.ConeGeometry(0.018, 0.10, 8),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0xffe09f })
|
||||||
|
);
|
||||||
|
antenna.position.y = 0.08; group.add(antenna);
|
||||||
|
|
||||||
|
const ring = new THREE.Mesh(
|
||||||
|
new THREE.RingGeometry(0.11, 0.14, 32),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0xffb840, side: THREE.DoubleSide,
|
||||||
|
transparent: true, opacity: 0.55, blending: THREE.AdditiveBlending, depthWrite: false })
|
||||||
|
);
|
||||||
|
ring.rotation.x = -Math.PI / 2; ring.position.y = -0.05;
|
||||||
|
group.add(ring); nodeRings.push(ring);
|
||||||
|
|
||||||
|
const core = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.025, 12, 12),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0xffe09f })
|
||||||
|
);
|
||||||
|
core.position.y = 0.04; group.add(core);
|
||||||
|
|
||||||
|
scene.add(group); nodeAnchors.push(group);
|
||||||
|
|
||||||
|
const bbox = new THREE.BoxHelper(group, 0x4cf);
|
||||||
|
bbox.material.transparent = true; bbox.material.opacity = 0.45;
|
||||||
|
scene.add(bbox); nodeBboxHelpers.push(bbox);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// God-ray cones — one per node, pointed at the subject. Visualizes
|
||||||
|
// "the four ESP32s are jointly illuminating the body with RF". Each
|
||||||
|
// cone has a volumetric-feeling gradient shader and is opacity-
|
||||||
|
// modulated by that node's csiAmp × csiCoherence (so when a node's
|
||||||
|
// signal degrades, its ray dims).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const godRayMat = (color, idx) => new THREE.ShaderMaterial({
|
||||||
|
uniforms: {
|
||||||
|
time: { value: 0 },
|
||||||
|
intensity: { value: 0.0 },
|
||||||
|
color: { value: new THREE.Color(color) },
|
||||||
|
seed: { value: idx * 17.3 },
|
||||||
|
},
|
||||||
|
vertexShader: `
|
||||||
|
varying vec2 vUv;
|
||||||
|
varying float vY;
|
||||||
|
void main() {
|
||||||
|
vUv = uv;
|
||||||
|
vY = position.y;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform float time, intensity, seed;
|
||||||
|
uniform vec3 color;
|
||||||
|
varying vec2 vUv;
|
||||||
|
varying float vY;
|
||||||
|
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
|
||||||
|
void main() {
|
||||||
|
// along the cone (uv.y goes 0=tip → 1=base), fade out at the base
|
||||||
|
float edgeFade = smoothstep(0.0, 0.18, vUv.y) * smoothstep(1.0, 0.65, vUv.y);
|
||||||
|
// soft radial falloff (cone-edge transparency)
|
||||||
|
float radial = sin(vUv.y * 3.14159);
|
||||||
|
radial = pow(radial, 2.0);
|
||||||
|
// volumetric noise (slow scrolling)
|
||||||
|
float n = hash(floor(vUv * vec2(40.0, 60.0)) + vec2(seed, time * 0.4));
|
||||||
|
float scroll = 0.85 + 0.30 * sin(vUv.y * 32.0 - time * 1.4 + seed);
|
||||||
|
float a = edgeFade * radial * scroll * (0.55 + 0.45 * n);
|
||||||
|
gl_FragColor = vec4(color, a * intensity * 0.25);
|
||||||
|
}`,
|
||||||
|
transparent: true,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
const godRays = [];
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
// cone with apex at node, expanding toward the subject
|
||||||
|
// height 4 m (more than enough to reach subject), radius 0.45 m at base
|
||||||
|
const geom = new THREE.ConeGeometry(0.45, 4.0, 28, 1, true);
|
||||||
|
// ConeGeometry tip is at +Y, base at -Y — rotate so tip is along -Y
|
||||||
|
// (we'll later orient each cone so its tip touches the node).
|
||||||
|
geom.translate(0, -2.0, 0); // shift so apex is at origin
|
||||||
|
const mat = godRayMat(0xffb840, i);
|
||||||
|
const cone = new THREE.Mesh(geom, mat);
|
||||||
|
scene.add(cone);
|
||||||
|
godRays.push({ mesh: cone, mat });
|
||||||
|
}
|
||||||
|
function updateGodRays(t) {
|
||||||
|
if (!model) return;
|
||||||
|
const want = document.getElementById('t-rays').checked;
|
||||||
|
const center = new THREE.Vector3();
|
||||||
|
const box = new THREE.Box3().setFromObject(model);
|
||||||
|
box.getCenter(center);
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
godRays[i].mesh.visible = want;
|
||||||
|
if (!want) continue;
|
||||||
|
const node = nodeAnchors[i];
|
||||||
|
const np = node.getWorldPosition(new THREE.Vector3());
|
||||||
|
const dir = new THREE.Vector3().subVectors(center, np);
|
||||||
|
const len = dir.length();
|
||||||
|
dir.normalize();
|
||||||
|
const ray = godRays[i];
|
||||||
|
ray.mesh.position.copy(np);
|
||||||
|
// align cone's -Y axis (apex direction after the geometry shift)
|
||||||
|
// to point along `dir`
|
||||||
|
ray.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, -1, 0), dir);
|
||||||
|
// stretch cone length to actual distance, keep base width reasonable
|
||||||
|
ray.mesh.scale.set(1, len / 4.0, 1);
|
||||||
|
ray.mat.uniforms.time.value = t;
|
||||||
|
// intensity follows that node's CSI amplitude * global coherence
|
||||||
|
const target = csiAmp[i] * csiCoherence * 1.4;
|
||||||
|
ray.mat.uniforms.intensity.value =
|
||||||
|
ray.mat.uniforms.intensity.value * 0.85 + target * 0.15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// FBX load with Mixamo-aware fixups
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
let model = null;
|
||||||
|
let mixer = null;
|
||||||
|
let headBone = null;
|
||||||
|
let boneCount = 0;
|
||||||
|
const clipActions = {}; // by clip name
|
||||||
|
let currentClip = null;
|
||||||
|
|
||||||
|
const loader = new THREE.FBXLoader();
|
||||||
|
loader.load(MODEL_URL, (object) => {
|
||||||
|
model = object;
|
||||||
|
|
||||||
|
// 1. Scale fix — Mixamo defaults to cm; detect by bbox height and
|
||||||
|
// rescale so the rig reads as ~1.7 m human size.
|
||||||
|
const raw = new THREE.Box3().setFromObject(model);
|
||||||
|
const height = raw.max.y - raw.min.y;
|
||||||
|
if (height > 10) {
|
||||||
|
model.scale.setScalar(1 / 100); // cm → m
|
||||||
|
} else if (height > 5) {
|
||||||
|
model.scale.setScalar(1 / 50); // catch in-between rigs
|
||||||
|
}
|
||||||
|
// recenter on origin at floor
|
||||||
|
const b2 = new THREE.Box3().setFromObject(model);
|
||||||
|
model.position.y -= b2.min.y;
|
||||||
|
|
||||||
|
// 2. Material upgrade + shadow casting + head/bone scan
|
||||||
|
model.traverse((obj) => {
|
||||||
|
if (obj.isMesh) {
|
||||||
|
obj.castShadow = true;
|
||||||
|
obj.receiveShadow = true;
|
||||||
|
// Phong → Standard for cleaner shading under our PBR lights.
|
||||||
|
// Keep diffuse map + skinning intact.
|
||||||
|
if (obj.material && obj.material.isMeshPhongMaterial) {
|
||||||
|
const m = obj.material;
|
||||||
|
const upgraded = new THREE.MeshStandardMaterial({
|
||||||
|
map: m.map, normalMap: m.normalMap, color: m.color,
|
||||||
|
skinning: !!obj.isSkinnedMesh,
|
||||||
|
metalness: 0.0, roughness: 0.85,
|
||||||
|
});
|
||||||
|
obj.material = upgraded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (obj.isBone) {
|
||||||
|
boneCount++;
|
||||||
|
if (!headBone && /head/i.test(obj.name)) headBone = obj;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('bone-count').textContent = boneCount;
|
||||||
|
|
||||||
|
scene.add(model);
|
||||||
|
|
||||||
|
skeletonHelper = new THREE.SkeletonHelper(model);
|
||||||
|
skeletonHelper.visible = false;
|
||||||
|
scene.add(skeletonHelper);
|
||||||
|
|
||||||
|
// 3. Animations — Mixamo exports one clip per FBX (sometimes none)
|
||||||
|
const clips = object.animations || [];
|
||||||
|
if (clips.length === 0) {
|
||||||
|
document.getElementById('anim-name').textContent = 'none (rig-only)';
|
||||||
|
document.getElementById('empty-hint').style.display = 'block';
|
||||||
|
} else {
|
||||||
|
mixer = new THREE.AnimationMixer(model);
|
||||||
|
const btnHost = document.getElementById('clip-buttons');
|
||||||
|
for (const clip of clips) {
|
||||||
|
const action = mixer.clipAction(clip);
|
||||||
|
clipActions[clip.name] = action;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = clip.name || 'clip-' + Object.keys(clipActions).length;
|
||||||
|
btn.addEventListener('click', () => playClip(clip.name));
|
||||||
|
btnHost.appendChild(btn);
|
||||||
|
}
|
||||||
|
playClip(clips[0].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Face point cloud
|
||||||
|
if (headBone) buildFacePointCloud();
|
||||||
|
|
||||||
|
document.getElementById('loading').classList.add('hidden');
|
||||||
|
}, (xhr) => {
|
||||||
|
const total = xhr.total || 1750032;
|
||||||
|
const pct = (xhr.loaded / total * 100).toFixed(0);
|
||||||
|
const txt = document.querySelector('#loading .text');
|
||||||
|
if (txt) txt.textContent = `▸ Loading skinned subject · X Bot.fbx · ${pct} %`;
|
||||||
|
}, (err) => {
|
||||||
|
console.error('FBX load failed', err);
|
||||||
|
const txt = document.querySelector('#loading .text');
|
||||||
|
if (txt) txt.textContent = '⚠ Load failed — see console';
|
||||||
|
});
|
||||||
|
|
||||||
|
function playClip(name) {
|
||||||
|
for (const k in clipActions) {
|
||||||
|
const a = clipActions[k];
|
||||||
|
if (k === name) {
|
||||||
|
a.reset(); a.play(); currentClip = name;
|
||||||
|
document.getElementById('anim-name').textContent = name;
|
||||||
|
for (const btn of document.querySelectorAll('#anim button[data-base], #anim button')) {
|
||||||
|
if (btn.dataset.base !== undefined || !btn.textContent) continue;
|
||||||
|
btn.classList.toggle('active', btn.textContent === name);
|
||||||
|
}
|
||||||
|
} else a.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Face point cloud — anchored to head bone, same shimmer shader
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const FACE_POINTS = 220; // fewer points so each dot is visible as a tracked landmark
|
||||||
|
const facePositions = new Float32Array(FACE_POINTS * 3);
|
||||||
|
const faceOffsets = new Float32Array(FACE_POINTS * 3);
|
||||||
|
const facePhases = new Float32Array(FACE_POINTS);
|
||||||
|
let facePoints = null;
|
||||||
|
function buildFacePointCloud() {
|
||||||
|
// Front-hemisphere only — points scattered on the +Z half of an
|
||||||
|
// ellipsoid so the cloud reads as a FACE projection forward from
|
||||||
|
// the head bone, not a halo wrapping the skull. Local coords:
|
||||||
|
// +Z = forward (face direction), +Y = up, +X = right.
|
||||||
|
for (let i = 0; i < FACE_POINTS; i++) {
|
||||||
|
// theta in [0, 2π) around the local Z axis, phi in [0, π/2]
|
||||||
|
// (front hemisphere only — no points behind the head)
|
||||||
|
const theta = Math.random() * Math.PI * 2;
|
||||||
|
const phi = Math.acos(1 - Math.random() * 0.95); // dense near face front
|
||||||
|
const sinPhi = Math.sin(phi), cosPhi = Math.cos(phi);
|
||||||
|
// ellipsoid radii (taller than wide, slightly squashed F-B)
|
||||||
|
const rx = 0.085, ry = 0.108, rz = 0.075;
|
||||||
|
// local coords with +Z = face forward
|
||||||
|
faceOffsets[i*3+0] = rx * sinPhi * Math.cos(theta);
|
||||||
|
faceOffsets[i*3+1] = ry * sinPhi * Math.sin(theta) * 1.05; // taller
|
||||||
|
faceOffsets[i*3+2] = rz * cosPhi; // forward
|
||||||
|
facePhases[i] = Math.random() * Math.PI * 2;
|
||||||
|
}
|
||||||
|
const geom = new THREE.BufferGeometry();
|
||||||
|
geom.setAttribute('position', new THREE.BufferAttribute(facePositions, 3));
|
||||||
|
geom.setAttribute('aPhase', new THREE.BufferAttribute(facePhases, 1));
|
||||||
|
const mat = new THREE.ShaderMaterial({
|
||||||
|
uniforms: { time: { value: 0 } },
|
||||||
|
vertexShader: `
|
||||||
|
attribute float aPhase; uniform float time;
|
||||||
|
varying float vAlpha;
|
||||||
|
void main() {
|
||||||
|
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||||
|
// Slow per-point shimmer + occasional "scan-lit" spike
|
||||||
|
// so the cloud reads as discrete tracked landmarks
|
||||||
|
// rather than a fluffy halo.
|
||||||
|
float shimmer = 0.5 + 0.5 * sin(time * 3.0 + aPhase);
|
||||||
|
float spark = step(0.95, fract(sin(aPhase * 17.0 + time * 0.5) * 43.0));
|
||||||
|
vAlpha = 0.10 + 0.25 * shimmer + 0.55 * spark;
|
||||||
|
gl_Position = projectionMatrix * mv;
|
||||||
|
// 6× smaller — tracked dots, not a cloud
|
||||||
|
gl_PointSize = (1.0 + shimmer * 0.6 + spark * 1.5) * (32.0 / -mv.z);
|
||||||
|
}`,
|
||||||
|
fragmentShader: `
|
||||||
|
varying float vAlpha;
|
||||||
|
void main() {
|
||||||
|
vec2 c = gl_PointCoord - 0.5;
|
||||||
|
float d = length(c);
|
||||||
|
if (d > 0.5) discard;
|
||||||
|
float falloff = smoothstep(0.5, 0.0, d);
|
||||||
|
vec3 col = vec3(0.40, 0.78, 1.00);
|
||||||
|
gl_FragColor = vec4(col, vAlpha * falloff);
|
||||||
|
}`,
|
||||||
|
transparent: true, depthWrite: false,
|
||||||
|
});
|
||||||
|
facePoints = new THREE.Points(geom, mat);
|
||||||
|
scene.add(facePoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Pings + tomography + CSI driver — copied wholesale from skinned-glb
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const PING_POOL = 24;
|
||||||
|
const pings = [];
|
||||||
|
const pingGeo = new THREE.TorusGeometry(1, 0.012, 8, 48);
|
||||||
|
for (let i = 0; i < PING_POOL; i++) {
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ color: 0x4cf, transparent: true, opacity: 0, depthWrite: false });
|
||||||
|
const mesh = new THREE.Mesh(pingGeo, mat); mesh.visible = false; scene.add(mesh);
|
||||||
|
pings.push({ mesh, active: false, t0: 0, duration: 0,
|
||||||
|
origin: new THREE.Vector3(), target: new THREE.Vector3() });
|
||||||
|
}
|
||||||
|
let pingIndex = 0;
|
||||||
|
function emitPing(origin, target) {
|
||||||
|
const p = pings[pingIndex]; pingIndex = (pingIndex + 1) % PING_POOL;
|
||||||
|
p.active = true; p.t0 = performance.now() * 0.001;
|
||||||
|
p.duration = 0.55 + Math.random() * 0.20;
|
||||||
|
p.origin.copy(origin); p.target.copy(target);
|
||||||
|
p.mesh.position.copy(origin); p.mesh.visible = true; p.mesh.material.opacity = 0;
|
||||||
|
const dir = new THREE.Vector3().subVectors(target, origin).normalize();
|
||||||
|
p.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tomoMat = new THREE.ShaderMaterial({
|
||||||
|
uniforms: { time: { value: 0 }, intensity: { value: 0 } },
|
||||||
|
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform float time, intensity; varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
float band = exp(-pow((vUv.x - 0.5) * 14.0, 2.0));
|
||||||
|
float lines = 0.5 + 0.5 * sin(vUv.y * 90.0 + time * 4.0);
|
||||||
|
vec3 col = vec3(1.0, 0.3, 0.78) * band * (0.6 + 0.4 * lines);
|
||||||
|
gl_FragColor = vec4(col, intensity * band * 0.75);
|
||||||
|
}`,
|
||||||
|
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
const tomoPlane = new THREE.Mesh(new THREE.PlaneGeometry(8, 6), tomoMat);
|
||||||
|
tomoPlane.rotation.y = Math.PI / 2;
|
||||||
|
tomoPlane.position.set(-2, 1.0, 0); tomoPlane.visible = false;
|
||||||
|
scene.add(tomoPlane);
|
||||||
|
let tomoActive = false, tomoT0 = 0, tomoNextAt = 4 + Math.random() * 4;
|
||||||
|
|
||||||
|
const csiAmp = [0, 0, 0, 0];
|
||||||
|
let csiCoherence = 0.5;
|
||||||
|
const csiNoise = [0, 0, 0, 0];
|
||||||
|
function tickCsi(t, targetWorld) {
|
||||||
|
for (let i = 0; i < 4; i++) csiNoise[i] = csiNoise[i] * 0.92 + (Math.random() - 0.5) * 0.08;
|
||||||
|
let mean = 0; const amps = [];
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const np = NODE_POSITIONS[i];
|
||||||
|
const dx = np[0] - targetWorld.x, dy = np[1] - targetWorld.y, dz = np[2] - targetWorld.z;
|
||||||
|
const r2 = dx*dx + dy*dy + dz*dz;
|
||||||
|
const fall = 1.0 / (1.0 + r2 * 0.18);
|
||||||
|
const breath = Math.sin(t * 0.27 * Math.PI * 2) * 0.10;
|
||||||
|
const heart = Math.sin(t * 1.18 * Math.PI * 2) * 0.04;
|
||||||
|
const walk = Math.sin(t * 1.9 + i * 0.5) * 0.12;
|
||||||
|
const a = Math.max(0, Math.min(1, fall + breath + heart + walk + csiNoise[i] * 0.30));
|
||||||
|
amps.push(a);
|
||||||
|
csiAmp[i] = csiAmp[i] * 0.7 + a * 0.3;
|
||||||
|
mean += a;
|
||||||
|
}
|
||||||
|
mean /= 4;
|
||||||
|
let v = 0; for (let i = 0; i < 4; i++) v += (amps[i] - mean) ** 2;
|
||||||
|
v = Math.sqrt(v / 4);
|
||||||
|
csiCoherence = csiCoherence * 0.85 + Math.max(0, Math.min(1, 1.0 - v * 2.5)) * 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastPingT = [0, 0, 0, 0];
|
||||||
|
// Subject hit-flash: when a sonar ping lands, briefly raise the
|
||||||
|
// emissive on every mesh in the model. Decays each frame.
|
||||||
|
let subjectFlash = 0;
|
||||||
|
const modelMeshes = [];
|
||||||
|
function collectModelMeshes() {
|
||||||
|
if (!model || modelMeshes.length) return;
|
||||||
|
model.traverse(o => {
|
||||||
|
if (o.isMesh && o.material && o.material.isMeshStandardMaterial) {
|
||||||
|
o.material.emissive = new THREE.Color(0xffb840);
|
||||||
|
o.material.emissiveIntensity = 0;
|
||||||
|
modelMeshes.push(o);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function updateSubjectFlash() {
|
||||||
|
collectModelMeshes();
|
||||||
|
subjectFlash *= 0.86;
|
||||||
|
for (const m of modelMeshes) {
|
||||||
|
m.material.emissiveIntensity = subjectFlash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtle root motion — even with a stationary Idle clip, give the
|
||||||
|
// figure a gentle drift + look-around so it doesn't feel pinned.
|
||||||
|
function updateRootMotion(t) {
|
||||||
|
if (!model) return;
|
||||||
|
model.position.x = Math.sin(t * 0.18) * 0.06;
|
||||||
|
model.position.z = Math.cos(t * 0.13) * 0.05;
|
||||||
|
model.rotation.y = Math.sin(t * 0.11) * 0.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNodes() {
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const ring = nodeRings[i];
|
||||||
|
const amp = csiAmp[i];
|
||||||
|
ring.material.opacity = 0.32 + 0.55 * amp;
|
||||||
|
ring.scale.setScalar(1 + 0.30 * amp);
|
||||||
|
rimLights[i].intensity = 0.30 + 0.60 * amp * csiCoherence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function maybeEmitPings(t, modelCenter) {
|
||||||
|
if (!document.getElementById('t-pings').checked || !model) return;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const interval = 1.2 / (0.25 + csiAmp[i]);
|
||||||
|
if (t - lastPingT[i] > interval) {
|
||||||
|
lastPingT[i] = t;
|
||||||
|
const target = modelCenter.clone();
|
||||||
|
target.y += (Math.random() - 0.3) * 0.8;
|
||||||
|
target.x += (Math.random() - 0.5) * 0.2;
|
||||||
|
const origin = nodeAnchors[i].getWorldPosition(new THREE.Vector3());
|
||||||
|
emitPing(origin, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updatePings(t) {
|
||||||
|
for (const p of pings) {
|
||||||
|
if (!p.active) continue;
|
||||||
|
const u = (t - p.t0) / p.duration;
|
||||||
|
if (u >= 1) {
|
||||||
|
p.active = false; p.mesh.visible = false;
|
||||||
|
// ping landed — flash the subject (drives emissiveIntensity)
|
||||||
|
subjectFlash = Math.min(0.42, subjectFlash + 0.18);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
p.mesh.position.lerpVectors(p.origin, p.target, u);
|
||||||
|
p.mesh.scale.setScalar(0.03 + u * 0.18);
|
||||||
|
p.mesh.material.opacity = (1.0 - u) * 0.40 * csiCoherence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateTomography(t) {
|
||||||
|
if (!document.getElementById('t-tomo').checked) { tomoActive = false; tomoPlane.visible = false; return; }
|
||||||
|
if (!tomoActive && t > tomoNextAt) {
|
||||||
|
tomoActive = true; tomoT0 = t; tomoPlane.visible = true;
|
||||||
|
const sf = document.getElementById('scan-flash');
|
||||||
|
sf.style.animation = 'none';
|
||||||
|
requestAnimationFrame(() => { sf.style.animation = 'scanFlash 1.6s ease-out'; });
|
||||||
|
}
|
||||||
|
if (tomoActive) {
|
||||||
|
const dur = 2.4;
|
||||||
|
const e = (t - tomoT0) / dur;
|
||||||
|
if (e >= 1) {
|
||||||
|
tomoActive = false; tomoPlane.visible = false;
|
||||||
|
tomoNextAt = t + 4 + Math.random() * 5;
|
||||||
|
} else {
|
||||||
|
tomoPlane.position.x = -3 + e * 6;
|
||||||
|
tomoMat.uniforms.intensity.value = Math.sin(e * Math.PI);
|
||||||
|
tomoMat.uniforms.time.value = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateBbox() {
|
||||||
|
const want = document.getElementById('t-bbox').checked && model;
|
||||||
|
if (!want) {
|
||||||
|
if (bboxHelper) { scene.remove(bboxHelper); bboxHelper = null; }
|
||||||
|
document.getElementById('bbox-vol').textContent = '—';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bboxHelper) {
|
||||||
|
bboxHelper = new THREE.BoxHelper(model, 0xffe09f);
|
||||||
|
bboxHelper.material.transparent = true; bboxHelper.material.opacity = 0.55;
|
||||||
|
scene.add(bboxHelper);
|
||||||
|
} else bboxHelper.setFromObject(model);
|
||||||
|
const box = new THREE.Box3().setFromObject(model);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
document.getElementById('bbox-vol').textContent = (size.x * size.y * size.z).toFixed(3) + ' m³';
|
||||||
|
}
|
||||||
|
const tmpHeadPos = new THREE.Vector3();
|
||||||
|
const tmpHeadQuat = new THREE.Quaternion();
|
||||||
|
const tmpHeadScl = new THREE.Vector3();
|
||||||
|
const tmpOffset = new THREE.Vector3();
|
||||||
|
function updateFaceCloud(t) {
|
||||||
|
if (!facePoints || !headBone) return;
|
||||||
|
// Decompose the head bone's world matrix so we can apply its
|
||||||
|
// orientation (face direction) to each local offset. This way
|
||||||
|
// the cloud rotates with the head — turn left/right and the
|
||||||
|
// face points stay in front of the face.
|
||||||
|
headBone.updateMatrixWorld(true);
|
||||||
|
headBone.matrixWorld.decompose(tmpHeadPos, tmpHeadQuat, tmpHeadScl);
|
||||||
|
// Mixamo head bone forward is along +Y in some rigs (head looks up the
|
||||||
|
// bone chain) — project the cloud along the model's actual forward
|
||||||
|
// vector, which for Mixamo X Bot facing camera is world +Z.
|
||||||
|
// Use the model's root rotation as the source of "forward".
|
||||||
|
const forward = new THREE.Vector3(0, 0, 1);
|
||||||
|
if (model) forward.applyQuaternion(model.getWorldQuaternion(new THREE.Quaternion()));
|
||||||
|
const up = new THREE.Vector3(0, 1, 0);
|
||||||
|
const right = new THREE.Vector3().crossVectors(up, forward).normalize();
|
||||||
|
const facingUp = up.clone();
|
||||||
|
// anchor the cloud just in front of the head
|
||||||
|
const anchor = tmpHeadPos.clone().addScaledVector(forward, 0.04);
|
||||||
|
anchor.y += 0.04; // nudge up so cloud sits over the face, not the chin
|
||||||
|
const pos = facePoints.geometry.attributes.position;
|
||||||
|
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];
|
||||||
|
// map local (ox, oy, oz) into world via (right, up, forward)
|
||||||
|
tmpOffset.copy(right).multiplyScalar(ox)
|
||||||
|
.addScaledVector(facingUp, oy)
|
||||||
|
.addScaledVector(forward, oz);
|
||||||
|
pos.array[i*3+0] = anchor.x + tmpOffset.x;
|
||||||
|
pos.array[i*3+1] = anchor.y + tmpOffset.y;
|
||||||
|
pos.array[i*3+2] = anchor.z + tmpOffset.z;
|
||||||
|
}
|
||||||
|
pos.needsUpdate = true;
|
||||||
|
facePoints.material.uniforms.time.value = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hudT = 0;
|
||||||
|
function updateHud(t, fps) {
|
||||||
|
if (t - hudT < 0.1) return;
|
||||||
|
hudT = t;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const pct = Math.round(csiAmp[i] * 100);
|
||||||
|
document.getElementById('bar-' + i).style.width = pct + '%';
|
||||||
|
document.getElementById('val-' + i).textContent = pct + '%';
|
||||||
|
}
|
||||||
|
document.getElementById('coh-val').textContent = (csiCoherence * 100).toFixed(0) + ' %';
|
||||||
|
document.getElementById('hr-val').textContent = (68 + Math.sin(t * 0.3) * 4).toFixed(0) + ' bpm';
|
||||||
|
document.getElementById('fps-val').textContent = fps.toFixed(0) + ' fps';
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI wiring
|
||||||
|
document.getElementById('time-scale').addEventListener('input', (e) => {
|
||||||
|
const ts = parseFloat(e.target.value);
|
||||||
|
document.getElementById('time-scale-val').textContent = ts.toFixed(2);
|
||||||
|
if (mixer) mixer.timeScale = ts;
|
||||||
|
});
|
||||||
|
function bindToggle(id, obj) {
|
||||||
|
document.getElementById(id).addEventListener('change', e => {
|
||||||
|
if (e.target.checked && !scene.children.includes(obj)) scene.add(obj);
|
||||||
|
else if (!e.target.checked) scene.remove(obj);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bindToggle('t-grid', gridHelper);
|
||||||
|
bindToggle('t-polar', polarHelper);
|
||||||
|
document.getElementById('t-skel').addEventListener('change', e => {
|
||||||
|
if (skeletonHelper) skeletonHelper.visible = e.target.checked;
|
||||||
|
});
|
||||||
|
document.getElementById('t-nodebox').addEventListener('change', e => {
|
||||||
|
for (const bb of nodeBboxHelpers) {
|
||||||
|
if (e.target.checked && !scene.children.includes(bb)) scene.add(bb);
|
||||||
|
else if (!e.target.checked) scene.remove(bb);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Main loop
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const clock = new THREE.Clock();
|
||||||
|
let lastMs = performance.now();
|
||||||
|
let fpsEma = 60;
|
||||||
|
function tick() {
|
||||||
|
const nowMs = performance.now();
|
||||||
|
const dt = nowMs - lastMs;
|
||||||
|
lastMs = nowMs;
|
||||||
|
fpsEma = fpsEma * 0.92 + (1000 / Math.max(dt, 1)) * 0.08;
|
||||||
|
const t = nowMs * 0.001;
|
||||||
|
const delta = clock.getDelta();
|
||||||
|
|
||||||
|
if (mixer) mixer.update(delta);
|
||||||
|
floorMat.uniforms.time.value = t;
|
||||||
|
filmShader.uniforms.time.value = t;
|
||||||
|
|
||||||
|
const center = new THREE.Vector3();
|
||||||
|
if (model) {
|
||||||
|
const box = new THREE.Box3().setFromObject(model);
|
||||||
|
box.getCenter(center);
|
||||||
|
} else center.set(0, 0.9, 0);
|
||||||
|
|
||||||
|
tickCsi(t, center);
|
||||||
|
updateRootMotion(t);
|
||||||
|
updateNodes();
|
||||||
|
updateGodRays(t);
|
||||||
|
maybeEmitPings(t, center);
|
||||||
|
updatePings(t);
|
||||||
|
updateSubjectFlash();
|
||||||
|
updateTomography(t);
|
||||||
|
updateBbox();
|
||||||
|
updateFaceCloud(t);
|
||||||
|
|
||||||
|
controls.update();
|
||||||
|
composer.render();
|
||||||
|
updateHud(t, fpsEma);
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
camera.aspect = window.innerWidth / window.innerHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
composer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
bloom.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 598 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 632 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 682 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 596 KiB |
|
|
@ -0,0 +1,153 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ruvultra → browser CSI bridge.
|
||||||
|
|
||||||
|
Reads adaptive_ctrl tick lines from the ESP32-S3 RuView firmware on
|
||||||
|
/dev/ttyACM0 and forwards normalized per-node metrics over a WebSocket
|
||||||
|
that the helpers-skinned-realtime demo can subscribe to via Tailscale.
|
||||||
|
|
||||||
|
Sample serial line (1 Hz cadence from firmware):
|
||||||
|
I (22890561) adaptive_ctrl: medium tick: state=6 yield=15pps motion=1.00 presence=5.35 rssi=-33
|
||||||
|
|
||||||
|
Output JSON (per tick):
|
||||||
|
{
|
||||||
|
"ts": 1716830400.123,
|
||||||
|
"node": 0, # always 0 (single node), client expands to 4
|
||||||
|
"motion": 1.00, # raw firmware metric
|
||||||
|
"presence": 5.35,
|
||||||
|
"rssi": -33,
|
||||||
|
"yield_pps": 15,
|
||||||
|
"amp": 0.78 # synthesized CSI amplitude in [0..1] for the bar
|
||||||
|
}
|
||||||
|
|
||||||
|
Run on ruvultra:
|
||||||
|
python3 -u ruvultra-csi-bridge.py
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import builtins
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
# Force every print to flush — we're often piped to a log file
|
||||||
|
_orig_print = builtins.print
|
||||||
|
def _print(*a, **kw):
|
||||||
|
kw.setdefault("flush", True)
|
||||||
|
return _orig_print(*a, **kw)
|
||||||
|
builtins.print = _print
|
||||||
|
|
||||||
|
import serial
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
PORT = "/dev/ttyACM0"
|
||||||
|
BAUD = 115200
|
||||||
|
WS_HOST = "0.0.0.0"
|
||||||
|
WS_PORT = 8766
|
||||||
|
|
||||||
|
TICK_RE = re.compile(
|
||||||
|
r"adaptive_ctrl:\s*\w+\s+tick:\s*"
|
||||||
|
r"state=(?P<state>\d+)\s+"
|
||||||
|
r"yield=(?P<yield>\d+)pps\s+"
|
||||||
|
r"motion=(?P<motion>[\d.]+)\s+"
|
||||||
|
r"presence=(?P<presence>[\d.]+)\s+"
|
||||||
|
r"rssi=(?P<rssi>-?\d+)"
|
||||||
|
)
|
||||||
|
|
||||||
|
clients = set()
|
||||||
|
last_payload = None
|
||||||
|
|
||||||
|
|
||||||
|
def amp_from_metrics(motion, presence, rssi):
|
||||||
|
"""Map firmware metrics to a [0..1] CSI-style amplitude."""
|
||||||
|
rssi_norm = max(0.0, min(1.0, (rssi + 80) / 50)) # -80..-30 → 0..1
|
||||||
|
presence_norm = max(0.0, min(1.0, presence / 8.0)) # cap at 8
|
||||||
|
motion_norm = max(0.0, min(1.0, motion)) # already 0..1ish
|
||||||
|
return 0.40 * rssi_norm + 0.35 * presence_norm + 0.25 * motion_norm
|
||||||
|
|
||||||
|
|
||||||
|
async def serial_reader_loop():
|
||||||
|
global last_payload
|
||||||
|
print(f"[bridge] opening {PORT} @ {BAUD}…")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
ser = serial.Serial(PORT, BAUD, timeout=1)
|
||||||
|
except (serial.SerialException, OSError) as e:
|
||||||
|
print(f"[bridge] serial open failed ({e}); retry in 3s")
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"[bridge] connected to {PORT}")
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = await loop.run_in_executor(None, ser.readline)
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
text = line.decode(errors="replace").strip()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
m = TICK_RE.search(text)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
motion = float(m["motion"])
|
||||||
|
presence = float(m["presence"])
|
||||||
|
rssi = int(m["rssi"])
|
||||||
|
payload = {
|
||||||
|
"ts": time.time(),
|
||||||
|
"node": 0,
|
||||||
|
"state": int(m["state"]),
|
||||||
|
"yield_pps": int(m["yield"]),
|
||||||
|
"motion": motion,
|
||||||
|
"presence": presence,
|
||||||
|
"rssi": rssi,
|
||||||
|
"amp": amp_from_metrics(motion, presence, rssi),
|
||||||
|
}
|
||||||
|
last_payload = payload
|
||||||
|
msg = json.dumps(payload)
|
||||||
|
if clients:
|
||||||
|
dead = []
|
||||||
|
for ws in list(clients):
|
||||||
|
try:
|
||||||
|
await ws.send(msg)
|
||||||
|
except websockets.ConnectionClosed:
|
||||||
|
dead.append(ws)
|
||||||
|
for d in dead:
|
||||||
|
clients.discard(d)
|
||||||
|
print(
|
||||||
|
f"[tick] motion={motion:.2f} presence={presence:5.2f} "
|
||||||
|
f"rssi={rssi:+d} yield={int(m['yield']):3d}pps "
|
||||||
|
f"amp={payload['amp']:.2f} clients={len(clients)}"
|
||||||
|
)
|
||||||
|
except (serial.SerialException, OSError) as e:
|
||||||
|
print(f"[bridge] serial error ({e}); reopen in 1s")
|
||||||
|
with suppress(Exception):
|
||||||
|
ser.close()
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def ws_handler(ws):
|
||||||
|
addr = ws.remote_address
|
||||||
|
clients.add(ws)
|
||||||
|
print(f"[ws] client connected: {addr} total={len(clients)}")
|
||||||
|
try:
|
||||||
|
if last_payload is not None:
|
||||||
|
await ws.send(json.dumps(last_payload))
|
||||||
|
await ws.wait_closed()
|
||||||
|
finally:
|
||||||
|
clients.discard(ws)
|
||||||
|
print(f"[ws] client gone: {addr} total={len(clients)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print(f"[bridge] websocket on ws://{WS_HOST}:{WS_PORT}")
|
||||||
|
async with websockets.serve(ws_handler, WS_HOST, WS_PORT):
|
||||||
|
await serial_reader_loop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""Tiny threaded HTTP server for the three.js demos that fetch local files.
|
||||||
|
|
||||||
|
Why a sibling helper script instead of `python -m http.server`?
|
||||||
|
The stdlib SimpleHTTPServer is single-threaded; Chrome opens many parallel
|
||||||
|
connections (HTML + 9 script tags + FBX), the first eats the worker, the
|
||||||
|
rest time out with net::ERR_EMPTY_RESPONSE. ThreadingHTTPServer fixes it.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python examples/three.js/server/serve-demo.py
|
||||||
|
open http://localhost:8765/examples/three.js/demos/05-skinned-realtime.html
|
||||||
|
"""
|
||||||
|
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
||||||
|
import os, sys
|
||||||
|
|
||||||
|
PORT = int(os.environ.get("PORT", 8765))
|
||||||
|
# Always serve from the repo root regardless of where the script is launched.
|
||||||
|
# This file lives at examples/three.js/server/serve-demo.py — three levels deep.
|
||||||
|
os.chdir(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
|
||||||
|
|
||||||
|
class NoCacheHandler(SimpleHTTPRequestHandler):
|
||||||
|
def end_headers(self):
|
||||||
|
# Aggressive no-cache so browser ALWAYS fetches the latest .html
|
||||||
|
# after we edit it. Otherwise stale code sticks around even on hard
|
||||||
|
# refresh and you debug a phantom.
|
||||||
|
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||||
|
self.send_header("Pragma", "no-cache")
|
||||||
|
self.send_header("Expires", "0")
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
DEMOS = [
|
||||||
|
"01-helpers.html",
|
||||||
|
"02-cinematic.html",
|
||||||
|
"03-skinned.html",
|
||||||
|
"04-skinned-fbx.html",
|
||||||
|
"05-skinned-realtime.html",
|
||||||
|
]
|
||||||
|
|
||||||
|
with ThreadingHTTPServer(("127.0.0.1", PORT), NoCacheHandler) as srv:
|
||||||
|
print(f"serving {os.getcwd()} on http://127.0.0.1:{PORT}/")
|
||||||
|
print("demos:")
|
||||||
|
for d in DEMOS:
|
||||||
|
print(f" http://127.0.0.1:{PORT}/examples/three.js/demos/{d}")
|
||||||
|
try:
|
||||||
|
srv.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(0)
|
||||||
Loading…
Reference in New Issue