Compare commits
8 Commits
bf30844835
...
4698f54fa0
| Author | SHA1 | Date |
|---|---|---|
|
|
4698f54fa0 | |
|
|
ea62ec4667 | |
|
|
3685d16a49 | |
|
|
8a155e07ec | |
|
|
540ecb4538 | |
|
|
10684972d7 | |
|
|
27a6edba8b | |
|
|
174e2365f0 |
|
|
@ -21,6 +21,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
|||
- [Windows WiFi (RSSI Only)](#windows-wifi-rssi-only)
|
||||
- [ESP32-S3 (Full CSI)](#esp32-s3-full-csi)
|
||||
- [ESP32 Multistatic Mesh (Advanced)](#esp32-multistatic-mesh-advanced)
|
||||
- [Connect Mesh Data to the Dashboard and Observatory](#connect-mesh-data-to-the-dashboard-and-observatory)
|
||||
- [Cognitum Seed Integration (ADR-069)](#cognitum-seed-integration-adr-069)
|
||||
5. [REST API Reference](#rest-api-reference)
|
||||
6. [WebSocket Streaming](#websocket-streaming)
|
||||
|
|
@ -331,6 +332,46 @@ The mesh uses a **Time-Division Multiplexing (TDM)** protocol so nodes take turn
|
|||
|
||||
See [ADR-029](adr/ADR-029-ruvsense-multistatic-sensing-mode.md) and [ADR-032](adr/ADR-032-multistatic-mesh-security-hardening.md) for the full design.
|
||||
|
||||
### Connect Mesh Data to the Dashboard and Observatory
|
||||
|
||||
If a standalone `aggregator` command prints live packets, the ESP32 fleet is already reaching that host. To visualize the same data, stop the standalone aggregator and run `sensing-server` on that same host and UDP port. The sensing server is the aggregator used by the REST API, WebSocket stream, dashboard, and Observatory.
|
||||
|
||||
```bash
|
||||
# From a source build
|
||||
cd v2
|
||||
cargo run -p wifi-densepose-sensing-server -- \
|
||||
--source esp32 \
|
||||
--udp-port 5005 \
|
||||
--http-port 3000 \
|
||||
--ws-port 3001 \
|
||||
--ui-path ../../ui
|
||||
|
||||
# Docker
|
||||
docker run --rm \
|
||||
-e CSI_SOURCE=esp32 \
|
||||
-p 3000:3000 \
|
||||
-p 3001:3001 \
|
||||
-p 5005:5005/udp \
|
||||
ruvnet/wifi-densepose:latest
|
||||
```
|
||||
|
||||
Open the UI from the sensing server, not from a local file:
|
||||
|
||||
| View | URL |
|
||||
|------|-----|
|
||||
| Dashboard | `http://localhost:3000/ui/index.html` |
|
||||
| Observatory | `http://localhost:3000/ui/observatory.html` |
|
||||
|
||||
Use these checks before debugging the browser:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/health
|
||||
curl http://localhost:3000/api/v1/nodes
|
||||
curl http://localhost:3000/api/v1/sensing/latest
|
||||
```
|
||||
|
||||
If the ESP32 nodes are provisioned with `--target-ip <AGGREGATOR_HOST>`, that IP must be the machine running `sensing-server`. Only one process can receive UDP `:5005` at a time, so leave the standalone hardware `aggregator` off while the dashboard or Observatory is live.
|
||||
|
||||
### Cognitum Seed Integration (ADR-069)
|
||||
|
||||
Connect an ESP32-S3 to a [Cognitum Seed](https://cognitum.one) (Pi Zero 2 W, ~$15) for persistent vector storage, kNN similarity search, cryptographic witness chain, and AI-accessible sensing via MCP proxy.
|
||||
|
|
@ -1744,6 +1785,8 @@ The server applies a 3-stage smoothing pipeline (ADR-048). If readings are still
|
|||
|
||||
- Verify the sensing server is running: `curl http://localhost:3000/health`
|
||||
- Access Observatory via the server URL: `http://localhost:3000/ui/observatory.html` (not a file:// URL)
|
||||
- If a standalone `aggregator` command is already listening on UDP `:5005`, stop it and run `sensing-server --source esp32 --udp-port 5005` instead; the Observatory reads the server WebSocket, not the standalone aggregator output
|
||||
- Verify the ESP32 nodes are provisioned to the IP address of the machine running `sensing-server`
|
||||
- Hard refresh with Ctrl+Shift+R to clear cached settings
|
||||
- The auto-detect probes `/health` on the same origin — cross-origin won't work
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -15,7 +15,7 @@ This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 and
|
|||
> | **CSI streaming** | Per-subcarrier I/Q capture over UDP | ~20 Hz, ADR-018 binary format |
|
||||
> | **Breathing detection** | Bandpass 0.1-0.5 Hz, zero-crossing BPM | 6-30 BPM |
|
||||
> | **Heart rate** | Bandpass 0.8-2.0 Hz, zero-crossing BPM | 40-120 BPM |
|
||||
> | **Presence sensing** | Phase variance + adaptive calibration | < 1 ms latency |
|
||||
> | **Presence indicator** (heuristic) | Phase variance + adaptive threshold (60 s ambient learning) | < 1 ms latency, false-positives under strong RF interference — see [Tier 2 caveats](#what-this-firmware-does-not-do-tier-2-caveats) |
|
||||
> | **Fall detection** | Phase acceleration threshold | Configurable sensitivity |
|
||||
> | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash |
|
||||
|
||||
|
|
@ -37,18 +37,22 @@ MSYS_NO_PATHCONV=1 docker run --rm \
|
|||
|
||||
### 2. Flash
|
||||
|
||||
Offsets must match `partitions_display.csv` (8 MB) or `partitions_4mb.csv` (4 MB):
|
||||
`bootloader=0x0`, `partition-table=0x8000`, `otadata=0xf000`, `app (ota_0)=0x20000`.
|
||||
|
||||
```bash
|
||||
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
|
||||
write_flash --flash_mode dio --flash_size 8MB \
|
||||
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
|
||||
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
|
||||
0x10000 firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
|
||||
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
|
||||
0xf000 firmware/esp32-csi-node/build/ota_data_initial.bin \
|
||||
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
```
|
||||
|
||||
### 3. Provision WiFi credentials (no reflash needed)
|
||||
|
||||
```bash
|
||||
python scripts/provision.py --port COM7 \
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "YourSSID" --password "YourPass" --target-ip 192.168.1.20
|
||||
```
|
||||
|
||||
|
|
@ -129,11 +133,32 @@ Adds real-time health and safety monitoring.
|
|||
|
||||
- **Breathing rate** -- biquad IIR bandpass 0.1-0.5 Hz, zero-crossing BPM (6-30 BPM)
|
||||
- **Heart rate** -- biquad IIR bandpass 0.8-2.0 Hz, zero-crossing BPM (40-120 BPM)
|
||||
- **Presence detection** -- adaptive threshold calibration (60 s ambient learning)
|
||||
- **Presence indicator** -- phase variance vs an adaptively-calibrated threshold (60 s ambient learning at boot). Heuristic, not a learned classifier — strong RF interferers (fans, microwaves, transmit-power swings) can push variance above threshold without anyone in the room. See "What this firmware does NOT do" below.
|
||||
- **Fall detection** -- phase acceleration exceeds configurable threshold
|
||||
- **Multi-person estimation** -- subcarrier group clustering (up to 4 persons)
|
||||
- **Multi-person slot count** -- partitions the top-K subcarriers into `top_k / 2` groups (clamped to `[1, EDGE_MAX_PERSONS]`), computes per-group filtered breathing/heart-rate estimates, and reports the slot count as `pkt.n_persons`. This is a **slot-capacity heuristic**, not a learned counter — the reported count tracks subcarrier diversity, not actual occupancy. See [`edge_processing.c:481-548`](main/edge_processing.c#L481-L548).
|
||||
- **Vitals packet** -- 32-byte UDP packet at 1 Hz (magic `0xC5110002`)
|
||||
|
||||
### What this firmware does NOT do (Tier 2 caveats)
|
||||
|
||||
- It does **not** run a trained neural model. The "person count" is an
|
||||
arithmetic slot-capacity heuristic over the top-K subcarrier groups
|
||||
(`firmware/esp32-csi-node/main/edge_processing.c:481`). It tracks
|
||||
subcarrier diversity, not actual occupancy.
|
||||
- It does **not** run pose estimation. Pose-related features in the host
|
||||
UI come from the Rust `wifi-densepose-sensing-server` running a separate
|
||||
pipeline. When no `.rvf` model file is loaded via `--model`, the server
|
||||
drives the on-screen skeleton from signal-based heuristics (amplitude
|
||||
variance, motion-band power), not from learned keypoint inference. The
|
||||
repository does not ship pre-trained weights — see issues
|
||||
[#509](../../issues/509) and [#506](../../issues/506) for context, and
|
||||
[ADR-079](../../docs/adr/ADR-079-camera-supervised-pose-finetune.md) for
|
||||
the planned training path (phases P7-P9 are `Pending`).
|
||||
- The presence indicator is a calibrated variance threshold and **will
|
||||
false-positive** under strong RF interference from non-human sources
|
||||
(fans near the antenna, microwave duty cycles, neighbouring AP power
|
||||
swings) without re-running the 60-second ambient calibration. If you
|
||||
see ghost detections, re-calibrate by power-cycling in an empty room.
|
||||
|
||||
### Tier 3 -- WASM Programmable Sensing (Alpha)
|
||||
|
||||
Turns the ESP32 from a fixed-function sensor into a programmable sensing computer. Instead of reflashing firmware to change algorithms, you upload new sensing logic as small WASM modules -- compiled from Rust, packaged in signed RVF containers.
|
||||
|
|
@ -254,9 +279,10 @@ Find your serial port: `COM7` on Windows, `/dev/ttyUSB0` on Linux, `/dev/cu.SLAB
|
|||
```bash
|
||||
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
|
||||
write_flash --flash_mode dio --flash_size 8MB \
|
||||
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
|
||||
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
|
||||
0x10000 firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
0x0 firmware/esp32-csi-node/build/bootloader/bootloader.bin \
|
||||
0x8000 firmware/esp32-csi-node/build/partition_table/partition-table.bin \
|
||||
0xf000 firmware/esp32-csi-node/build/ota_data_initial.bin \
|
||||
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
```
|
||||
|
||||
### Serial Monitor
|
||||
|
|
@ -285,7 +311,7 @@ All settings can be changed at runtime via Non-Volatile Storage (NVS) without re
|
|||
The easiest way to write NVS settings:
|
||||
|
||||
```bash
|
||||
python scripts/provision.py --port COM7 \
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "MyWiFi" \
|
||||
--password "MyPassword" \
|
||||
--target-ip 192.168.1.20
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ def main():
|
|||
if args.ssid:
|
||||
print(f" WiFi SSID: {args.ssid}")
|
||||
if args.password is not None:
|
||||
print(f" WiFi Password: {'*' * len(args.password)}")
|
||||
print(f" WiFi Password: {'(set)' if args.password else '(empty)'}")
|
||||
if args.target_ip:
|
||||
print(f" Target IP: {args.target_ip}")
|
||||
if args.target_port:
|
||||
|
|
|
|||
|
|
@ -153,6 +153,13 @@ typedef struct {
|
|||
uint8_t primary;
|
||||
} wifi_ap_record_t;
|
||||
|
||||
typedef enum {
|
||||
WIFI_PS_NONE = 0,
|
||||
WIFI_PS_MIN_MODEM = 1,
|
||||
WIFI_PS_MAX_MODEM = 2,
|
||||
} wifi_ps_type_t;
|
||||
|
||||
static inline esp_err_t esp_wifi_set_ps(wifi_ps_type_t type) { (void)type; return ESP_OK; }
|
||||
static inline esp_err_t esp_wifi_set_promiscuous(bool en) { (void)en; return ESP_OK; }
|
||||
static inline esp_err_t esp_wifi_set_promiscuous_rx_cb(void *cb) { (void)cb; return ESP_OK; }
|
||||
static inline esp_err_t esp_wifi_set_promiscuous_filter(wifi_promiscuous_filter_t *f) { (void)f; return ESP_OK; }
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ def main():
|
|||
if args.ssid:
|
||||
print(f" WiFi SSID: {args.ssid}")
|
||||
if args.password is not None:
|
||||
print(f" WiFi Password: {'*' * len(args.password)}")
|
||||
print(f" WiFi Password: {'(set)' if args.password else '(empty)'}")
|
||||
if args.target_ip:
|
||||
print(f" Target IP: {args.target_ip}")
|
||||
if args.target_port:
|
||||
|
|
|
|||
|
|
@ -259,11 +259,16 @@ def provision_node(
|
|||
if stale.exists():
|
||||
stale.unlink()
|
||||
|
||||
# Build provision.py arguments
|
||||
# Build provision.py arguments.
|
||||
# --force-partial: this is a per-node TDM/channel overlay; WiFi
|
||||
# credentials live in the base flash image, not the per-node NVS slice.
|
||||
# Without --force-partial, provision.py rejects calls missing the
|
||||
# --ssid/--password/--target-ip trio (issue #391 guard).
|
||||
args = [
|
||||
sys.executable, str(PROVISION_SCRIPT),
|
||||
"--port", "/dev/null",
|
||||
"--dry-run",
|
||||
"--force-partial",
|
||||
"--node-id", str(node.node_id),
|
||||
"--tdm-slot", str(node.tdm_slot),
|
||||
"--tdm-total", str(n_total),
|
||||
|
|
|
|||
|
|
@ -9,11 +9,25 @@
|
|||
* emit simulated frames so the UI can clearly distinguish live vs. fallback data.
|
||||
*/
|
||||
|
||||
// Derive WebSocket URL from the page origin so it works on any port.
|
||||
// The /ws/sensing endpoint is available on the same HTTP port (3000).
|
||||
const _wsProto = (typeof window !== 'undefined' && window.location.protocol === 'https:') ? 'wss:' : 'ws:';
|
||||
const _wsHost = (typeof window !== 'undefined' && window.location.host) ? window.location.host : 'localhost:3000';
|
||||
const SENSING_WS_URL = `${_wsProto}//${_wsHost}/ws/sensing`;
|
||||
const SENSING_WS_PORT_BY_HTTP_PORT = {
|
||||
// Docker image: HTTP UI/API on 3000, sensing stream on 3001.
|
||||
'3000': '3001',
|
||||
// Python sensing stack: UI on 8080, sensing stream on 8765.
|
||||
'8080': '8765',
|
||||
};
|
||||
|
||||
export function buildSensingWsUrl(locationLike = (typeof window !== 'undefined' ? window.location : null)) {
|
||||
const protocol = locationLike && locationLike.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = locationLike && locationLike.host ? locationLike.host : 'localhost:3001';
|
||||
const hostname = locationLike && locationLike.hostname ? locationLike.hostname : host.split(':')[0];
|
||||
const port = locationLike && locationLike.port ? locationLike.port : '';
|
||||
const wsPort = SENSING_WS_PORT_BY_HTTP_PORT[port];
|
||||
const wsHost = wsPort ? `${hostname}:${wsPort}` : host;
|
||||
|
||||
return `${protocol}//${wsHost}/ws/sensing`;
|
||||
}
|
||||
|
||||
const SENSING_WS_URL = buildSensingWsUrl();
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];
|
||||
const MAX_RECONNECT_ATTEMPTS = 20;
|
||||
// Number of failed attempts that must occur before simulation starts.
|
||||
|
|
|
|||
|
|
@ -136,9 +136,22 @@ export class WebSocketService {
|
|||
|
||||
// Set up WebSocket event handlers
|
||||
setupEventHandlers(url, ws, handlers) {
|
||||
const connection = this.connections.get(url);
|
||||
const getConnection = (eventName) => {
|
||||
const connection = this.connections.get(url);
|
||||
if (!connection) {
|
||||
this.logger.warn(`Ignoring WebSocket ${eventName} for unregistered connection`, {
|
||||
url,
|
||||
readyState: ws.readyState
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return connection;
|
||||
};
|
||||
|
||||
ws.onopen = (event) => {
|
||||
const connection = getConnection('open');
|
||||
if (!connection) return;
|
||||
|
||||
const connectionTime = Date.now() - connection.connectionStartTime;
|
||||
this.logger.info(`WebSocket connected successfully`, { url, connectionTime });
|
||||
|
||||
|
|
@ -158,6 +171,9 @@ export class WebSocketService {
|
|||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const connection = getConnection('message');
|
||||
if (!connection) return;
|
||||
|
||||
connection.lastActivity = Date.now();
|
||||
connection.messageCount++;
|
||||
|
||||
|
|
@ -188,6 +204,9 @@ export class WebSocketService {
|
|||
};
|
||||
|
||||
ws.onerror = (event) => {
|
||||
const connection = getConnection('error');
|
||||
if (!connection) return;
|
||||
|
||||
connection.errorCount++;
|
||||
this.logger.error(`WebSocket error occurred`, {
|
||||
url,
|
||||
|
|
@ -208,6 +227,9 @@ export class WebSocketService {
|
|||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
const connection = getConnection('close');
|
||||
if (!connection) return;
|
||||
|
||||
const { code, reason, wasClean } = event;
|
||||
this.logger.info(`WebSocket closed`, { url, code, reason, wasClean });
|
||||
|
||||
|
|
@ -607,4 +629,4 @@ export class WebSocketService {
|
|||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const wsService = new WebSocketService();
|
||||
export const wsService = new WebSocketService();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { API_CONFIG, buildApiUrl, buildWsUrl } from '../config/api.config.js';
|
||||
import { apiService } from '../services/api.service.js';
|
||||
import { wsService } from '../services/websocket.service.js';
|
||||
import { buildSensingWsUrl } from '../services/sensing.service.js';
|
||||
import { poseService } from '../services/pose.service.js';
|
||||
import { healthService } from '../services/health.service.js';
|
||||
import { TabManager } from '../components/TabManager.js';
|
||||
|
|
@ -232,6 +233,17 @@ testRunner.test('buildWsUrl constructs WebSocket URLs', 'apiConfig', () => {
|
|||
testRunner.assert(url.includes('token=test-token'), 'URL should contain token parameter');
|
||||
});
|
||||
|
||||
testRunner.test('buildSensingWsUrl maps Docker UI port to sensing WebSocket port', 'apiConfig', () => {
|
||||
const url = buildSensingWsUrl({
|
||||
protocol: 'http:',
|
||||
host: '192.168.28.147:3000',
|
||||
hostname: '192.168.28.147',
|
||||
port: '3000',
|
||||
});
|
||||
|
||||
testRunner.assertEqual(url, 'ws://192.168.28.147:3001/ws/sensing');
|
||||
});
|
||||
|
||||
// API Service Tests
|
||||
testRunner.test('apiService has required methods', 'apiService', () => {
|
||||
testRunner.assert(typeof apiService.get === 'function', 'get method should exist');
|
||||
|
|
@ -473,4 +485,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
testRunner.updateSummary();
|
||||
});
|
||||
|
||||
export { testRunner };
|
||||
export { testRunner };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,440 @@
|
|||
//! Host-header allowlist for the sensing-server HTTP + WS surface.
|
||||
//!
|
||||
//! Defense against DNS rebinding: when the server is bound to loopback
|
||||
//! (default `127.0.0.1`), a foreign page (e.g. `evil.com`) can lower its DNS
|
||||
//! TTL and re-resolve to `127.0.0.1` after the browser has already accepted
|
||||
//! the origin. From the browser's point of view the request is same-origin
|
||||
//! against `evil.com`, so it reads the response — even though the bytes come
|
||||
//! from the local sensing-server. Without `Host`-header validation the server
|
||||
//! happily serves the request because every other axum layer treats it as a
|
||||
//! normal connection.
|
||||
//!
|
||||
//! For RuView this means any website the user visits can stream live pose,
|
||||
//! breathing rate, and heart-rate data out of the sensing-server (`/ws/sensing`,
|
||||
//! `/api/v1/pose/current`, `/api/v1/vital-signs`, …), and trigger state-mutating
|
||||
//! POSTs (`/api/v1/recording/start`, `/api/v1/models/load`, …) when bearer-auth
|
||||
//! is not configured (the default LAN-only deployment posture from #443).
|
||||
//!
|
||||
//! The middleware here rejects any request whose `Host` header is not in the
|
||||
//! configured allowlist with `421 Misdirected Request`. Defaults cover the
|
||||
//! common local-only deployment (`localhost`, `127.0.0.1`, `[::1]` with or
|
||||
//! without `:PORT`). Operators who bind to a routable address (`--bind-addr
|
||||
//! 0.0.0.0` or a LAN IP) extend the allowlist with `--allowed-host` flags or
|
||||
//! the `SENSING_ALLOWED_HOSTS` env var.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{header::HOST, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
/// Environment variable that supplies additional allowed hosts
|
||||
/// (comma-separated). Whitespace around each entry is trimmed; empty entries
|
||||
/// are ignored.
|
||||
pub const ALLOWED_HOSTS_ENV: &str = "SENSING_ALLOWED_HOSTS";
|
||||
|
||||
/// Built-in allowlist entries. Each entry is also accepted with an optional
|
||||
/// trailing `:PORT` (any port).
|
||||
const DEFAULT_LOOPBACK_HOSTS: &[&str] = &["localhost", "127.0.0.1", "[::1]"];
|
||||
|
||||
/// Cheap, cloneable handle to the configured Host allowlist.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HostAllowlist {
|
||||
/// Lower-cased exact-match hostnames (with or without `:PORT` already
|
||||
/// baked in). Empty set ⇒ middleware accepts everything and is a no-op,
|
||||
/// matching the historical behaviour for callers that want to opt out.
|
||||
entries: Arc<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl HostAllowlist {
|
||||
/// Build an allowlist with only the default loopback names (bare and
|
||||
/// with any `:PORT`). Use this when the server is bound to loopback and
|
||||
/// no operator overrides have been supplied.
|
||||
pub fn loopback_only() -> Self {
|
||||
let mut entries: HashSet<String> = HashSet::new();
|
||||
for h in DEFAULT_LOOPBACK_HOSTS {
|
||||
entries.insert((*h).to_string());
|
||||
}
|
||||
HostAllowlist {
|
||||
entries: Arc::new(entries),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an allowlist from an iterator of additional hostnames (each may
|
||||
/// optionally include a `:PORT` suffix). The default loopback set is
|
||||
/// always included so `--bind-addr 0.0.0.0` deployments do not lock out
|
||||
/// local browsers on `http://localhost:8080/…`.
|
||||
pub fn with_extra<I, S>(extras: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut entries: HashSet<String> = HashSet::new();
|
||||
for h in DEFAULT_LOOPBACK_HOSTS {
|
||||
entries.insert((*h).to_string());
|
||||
}
|
||||
for h in extras {
|
||||
let h = h.as_ref().trim();
|
||||
if !h.is_empty() {
|
||||
entries.insert(h.to_lowercase());
|
||||
}
|
||||
}
|
||||
HostAllowlist {
|
||||
entries: Arc::new(entries),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an allowlist by joining (a) the default loopback set, (b) any
|
||||
/// CLI-supplied extras, and (c) the comma-separated `SENSING_ALLOWED_HOSTS`
|
||||
/// env var. Order of precedence does not matter — the result is a set.
|
||||
pub fn from_cli_and_env<I, S>(cli_extras: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let env_extras: Vec<String> = std::env::var(ALLOWED_HOSTS_ENV)
|
||||
.ok()
|
||||
.map(|v| {
|
||||
v.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let cli_vec: Vec<String> = cli_extras
|
||||
.into_iter()
|
||||
.map(|s| s.as_ref().to_string())
|
||||
.collect();
|
||||
HostAllowlist::with_extra(cli_vec.into_iter().chain(env_extras.into_iter()))
|
||||
}
|
||||
|
||||
/// Disable host-header validation entirely. Provided as an explicit escape
|
||||
/// hatch for operators who deploy the server behind a reverse proxy that
|
||||
/// already canonicalises `Host`, or for unit tests that need to bypass
|
||||
/// the layer.
|
||||
pub fn disabled() -> Self {
|
||||
HostAllowlist::default()
|
||||
}
|
||||
|
||||
/// True if the middleware will enforce host validation. `false` ⇒ no-op.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
!self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Test-only accessor returning a sorted, lower-cased copy of the
|
||||
/// configured allowlist. Exposed via the `pub(crate)` boundary so we can
|
||||
/// unit-test the env-var parsing without reaching into the `Arc`.
|
||||
pub fn entries_for_test(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.entries.iter().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// Check whether `host` (the raw `Host` header value, e.g.
|
||||
/// `127.0.0.1:8080` or `[::1]`) is permitted. Comparison is case-insensitive
|
||||
/// on the host part; ports are matched verbatim if the allowlist entry
|
||||
/// pins one, otherwise the port is ignored.
|
||||
pub fn is_allowed(&self, host: &str) -> bool {
|
||||
if self.entries.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let host = host.trim().to_lowercase();
|
||||
if host.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exact match (e.g. allowlist contains `127.0.0.1:8080` and request
|
||||
// sent `Host: 127.0.0.1:8080`).
|
||||
if self.entries.contains(&host) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Match on host-only when the allowlist entry has no port and the
|
||||
// request includes a port. Handles `Host: 127.0.0.1:8080` against
|
||||
// `127.0.0.1` in the allowlist, and `Host: [::1]:8080` against
|
||||
// `[::1]`.
|
||||
let host_only = strip_port(&host);
|
||||
if self.entries.contains(host_only) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip a `:PORT` suffix from `host`, leaving the host portion. IPv6 literals
|
||||
/// are wrapped in brackets (`[::1]:PORT`) so the last `:` is the port
|
||||
/// separator; bracketed IPv6 without a port stays intact.
|
||||
fn strip_port(host: &str) -> &str {
|
||||
if let Some(close) = host.strip_prefix('[').and_then(|_| host.find(']')) {
|
||||
// Bracketed IPv6: `[::1]` or `[::1]:8080`.
|
||||
if let Some(after) = host.get(close + 1..) {
|
||||
if after.starts_with(':') {
|
||||
return &host[..=close];
|
||||
}
|
||||
}
|
||||
return host;
|
||||
}
|
||||
match host.rfind(':') {
|
||||
Some(idx) => &host[..idx],
|
||||
None => host,
|
||||
}
|
||||
}
|
||||
|
||||
/// Axum middleware: rejects any request whose `Host` header is not in the
|
||||
/// configured allowlist. Use with [`axum::middleware::from_fn_with_state`].
|
||||
///
|
||||
/// Behaviour:
|
||||
/// * No `Host` header → `400 Bad Request` (HTTP/1.1 requires one; HTTP/2
|
||||
/// synthesises it from `:authority`, so a missing value is a real protocol
|
||||
/// violation, not a rebinding signal).
|
||||
/// * `Host` header present but not in the allowlist → `421 Misdirected Request`.
|
||||
/// * Empty allowlist → no-op (the operator explicitly opted out).
|
||||
pub async fn require_allowed_host(
|
||||
State(allowlist): State<HostAllowlist>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
if !allowlist.is_enabled() {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let host_header = request
|
||||
.headers()
|
||||
.get(HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
let host_header = match host_header {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"missing Host header\n",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if allowlist.is_allowed(&host_header) {
|
||||
next.run(request).await
|
||||
} else {
|
||||
(
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
"Host header not in allowlist (DNS-rebinding defense). \
|
||||
Set --allowed-host <name[:port]> or SENSING_ALLOWED_HOSTS=<comma-list> \
|
||||
to permit this hostname.\n",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn router(allowlist: HostAllowlist) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.route("/api/v1/pose/current", get(|| async { "ok" }))
|
||||
.route("/ws/sensing", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
allowlist,
|
||||
require_allowed_host,
|
||||
))
|
||||
}
|
||||
|
||||
async fn status(router: Router, path: &str, host: Option<&str>) -> StatusCode {
|
||||
let mut req = Request::builder().method("GET").uri(path);
|
||||
if let Some(h) = host {
|
||||
req = req.header(HOST, h);
|
||||
}
|
||||
let req = req.body(Body::empty()).unwrap();
|
||||
router.oneshot(req).await.unwrap().status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loopback_only_allows_default_hosts_with_any_port() {
|
||||
let r = router(HostAllowlist::loopback_only());
|
||||
for h in [
|
||||
"localhost",
|
||||
"localhost:8080",
|
||||
"127.0.0.1",
|
||||
"127.0.0.1:8080",
|
||||
"127.0.0.1:65535",
|
||||
"[::1]",
|
||||
"[::1]:8080",
|
||||
] {
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some(h)).await,
|
||||
StatusCode::OK,
|
||||
"host {h} should be allowed under loopback_only()"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loopback_only_rejects_foreign_hosts() {
|
||||
let r = router(HostAllowlist::loopback_only());
|
||||
for h in [
|
||||
"evil.com",
|
||||
"evil.com:8080",
|
||||
"127.0.0.1.evil.com",
|
||||
"192.168.1.10",
|
||||
"192.168.1.10:8080",
|
||||
"sensing.local",
|
||||
] {
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some(h)).await,
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
"host {h} should be rejected under loopback_only()"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_missing_host_header() {
|
||||
let r = router(HostAllowlist::loopback_only());
|
||||
assert_eq!(
|
||||
status(r, "/api/v1/pose/current", None).await,
|
||||
StatusCode::BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_empty_host_header() {
|
||||
let r = router(HostAllowlist::loopback_only());
|
||||
assert_eq!(
|
||||
status(r, "/api/v1/pose/current", Some("")).await,
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejection_applies_to_health_and_ws_routes_too() {
|
||||
// The whole router is fronted by the middleware — there is no
|
||||
// bypass for `/health` or `/ws/*`, because rebinding doesn't care
|
||||
// which route it targets, it cares about what bytes flow back.
|
||||
let r = router(HostAllowlist::loopback_only());
|
||||
assert_eq!(
|
||||
status(r.clone(), "/health", Some("evil.com")).await,
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
);
|
||||
assert_eq!(
|
||||
status(r, "/ws/sensing", Some("evil.com")).await,
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extras_extend_loopback_set() {
|
||||
let r = router(HostAllowlist::with_extra(["sensing.local", "192.168.1.10"]));
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some("sensing.local")).await,
|
||||
StatusCode::OK,
|
||||
);
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some("sensing.local:8080")).await,
|
||||
StatusCode::OK,
|
||||
);
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some("192.168.1.10:8080")).await,
|
||||
StatusCode::OK,
|
||||
);
|
||||
// Loopback defaults are still in:
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some("127.0.0.1")).await,
|
||||
StatusCode::OK,
|
||||
);
|
||||
// Foreign hosts still rejected:
|
||||
assert_eq!(
|
||||
status(r, "/api/v1/pose/current", Some("evil.com")).await,
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_allowlist_is_no_op() {
|
||||
let r = router(HostAllowlist::disabled());
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some("evil.com")).await,
|
||||
StatusCode::OK,
|
||||
);
|
||||
assert_eq!(
|
||||
status(r, "/api/v1/pose/current", None).await,
|
||||
StatusCode::OK,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn case_insensitive_host_match() {
|
||||
let r = router(HostAllowlist::loopback_only());
|
||||
for h in ["LOCALHOST", "LocalHost:8080", "127.0.0.1"] {
|
||||
assert_eq!(
|
||||
status(r.clone(), "/api/v1/pose/current", Some(h)).await,
|
||||
StatusCode::OK,
|
||||
"host {h} should be allowed (case-insensitive)"
|
||||
);
|
||||
}
|
||||
let r2 = router(HostAllowlist::with_extra(["Sensing.Local"]));
|
||||
assert_eq!(
|
||||
status(r2, "/api/v1/pose/current", Some("sensing.local:8080")).await,
|
||||
StatusCode::OK,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_port_handles_ipv4_ipv6_and_bare_hostnames() {
|
||||
assert_eq!(strip_port("localhost"), "localhost");
|
||||
assert_eq!(strip_port("localhost:8080"), "localhost");
|
||||
assert_eq!(strip_port("127.0.0.1"), "127.0.0.1");
|
||||
assert_eq!(strip_port("127.0.0.1:8080"), "127.0.0.1");
|
||||
assert_eq!(strip_port("[::1]"), "[::1]");
|
||||
assert_eq!(strip_port("[::1]:8080"), "[::1]");
|
||||
// No `:` at all
|
||||
assert_eq!(strip_port("sensing.local"), "sensing.local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_extra_trims_whitespace_and_skips_empty() {
|
||||
let allowlist = HostAllowlist::with_extra([" sensing.local ", "", "192.168.1.10"]);
|
||||
let entries = allowlist.entries_for_test();
|
||||
assert!(entries.contains(&"sensing.local".to_string()));
|
||||
assert!(entries.contains(&"192.168.1.10".to_string()));
|
||||
assert!(!entries.iter().any(|s| s.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_only_includes_all_three_defaults() {
|
||||
let entries = HostAllowlist::loopback_only().entries_for_test();
|
||||
assert!(entries.contains(&"localhost".to_string()));
|
||||
assert!(entries.contains(&"127.0.0.1".to_string()));
|
||||
assert!(entries.contains(&"[::1]".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_to_with_extra_still_includes_loopback_defaults() {
|
||||
// Calling `with_extra` with no extras (e.g. operator passed no
|
||||
// `--allowed-host` flags) must keep the loopback defaults so a fresh
|
||||
// 127.0.0.1 deployment isn't bricked.
|
||||
let entries: Vec<String> = Vec::new();
|
||||
let allowlist = HostAllowlist::with_extra(entries);
|
||||
assert!(allowlist.is_allowed("127.0.0.1"));
|
||||
assert!(allowlist.is_allowed("127.0.0.1:8080"));
|
||||
assert!(allowlist.is_allowed("localhost"));
|
||||
assert!(!allowlist.is_allowed("evil.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_constants_are_stable() {
|
||||
assert_eq!(ALLOWED_HOSTS_ENV, "SENSING_ALLOWED_HOSTS");
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,11 @@
|
|||
//! - Vital sign detection from WiFi CSI amplitude data
|
||||
//! - RVF (RuVector Format) binary container for model weights
|
||||
//! - Opt-in bearer-token auth for the `/api/v1/*` HTTP surface (`bearer_auth`)
|
||||
//! - Host-header allowlist / DNS-rebinding defense (`host_validation`)
|
||||
//! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099)
|
||||
|
||||
pub mod bearer_auth;
|
||||
pub mod host_validation;
|
||||
pub mod introspection;
|
||||
pub mod vital_signs;
|
||||
pub mod rvf_container;
|
||||
|
|
|
|||
|
|
@ -95,6 +95,21 @@ struct Args {
|
|||
#[arg(long, default_value = "127.0.0.1", env = "SENSING_BIND_ADDR")]
|
||||
bind_addr: String,
|
||||
|
||||
/// Additional hostname (with or without `:PORT`) to permit in the `Host`
|
||||
/// header — defends loopback-bound deployments against DNS rebinding.
|
||||
/// Loopback names (`localhost`, `127.0.0.1`, `[::1]`) are always permitted
|
||||
/// implicitly. Pass multiple times to add several entries. Comma-separated
|
||||
/// values are also accepted via the `SENSING_ALLOWED_HOSTS` env var.
|
||||
#[arg(long = "allowed-host", value_name = "HOST")]
|
||||
allowed_hosts: Vec<String>,
|
||||
|
||||
/// Disable Host-header validation entirely. Use only when the server sits
|
||||
/// behind a reverse proxy that already canonicalises `Host` (e.g. nginx
|
||||
/// `proxy_set_header Host`) — bare deployments stay vulnerable to DNS
|
||||
/// rebinding without it.
|
||||
#[arg(long)]
|
||||
disable_host_validation: bool,
|
||||
|
||||
/// Data source: auto, wifi, esp32, simulate
|
||||
#[arg(long, default_value = "auto")]
|
||||
source: String,
|
||||
|
|
@ -4969,11 +4984,39 @@ async fn main() {
|
|||
);
|
||||
}
|
||||
|
||||
// DNS-rebinding defense: validate the `Host` header against an allowlist
|
||||
// before any handler runs. Default is loopback-only (`localhost`,
|
||||
// `127.0.0.1`, `[::1]`, each with or without a port). Operators extend
|
||||
// the set via `--allowed-host` flags or the `SENSING_ALLOWED_HOSTS` env
|
||||
// var; `--disable-host-validation` opts out entirely for reverse-proxy
|
||||
// setups that already canonicalise `Host`.
|
||||
let host_allowlist = if args.disable_host_validation {
|
||||
warn!(
|
||||
"Host-header validation DISABLED — server is reachable via any Host. \
|
||||
Only use this behind a reverse proxy that pins Host."
|
||||
);
|
||||
wifi_densepose_sensing_server::host_validation::HostAllowlist::disabled()
|
||||
} else {
|
||||
let allowlist =
|
||||
wifi_densepose_sensing_server::host_validation::HostAllowlist::from_cli_and_env(
|
||||
args.allowed_hosts.iter().cloned(),
|
||||
);
|
||||
info!(
|
||||
"Host-header validation ON ({} entries; loopback names always included)",
|
||||
allowlist.entries_for_test().len()
|
||||
);
|
||||
allowlist
|
||||
};
|
||||
|
||||
// WebSocket server on dedicated port (8765)
|
||||
let ws_state = state.clone();
|
||||
let ws_app = Router::new()
|
||||
.route("/ws/sensing", get(ws_sensing_handler))
|
||||
.route("/health", get(health))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
host_allowlist.clone(),
|
||||
wifi_densepose_sensing_server::host_validation::require_allowed_host,
|
||||
))
|
||||
.with_state(ws_state);
|
||||
|
||||
let ws_addr = SocketAddr::from((bind_ip, args.ws_port));
|
||||
|
|
@ -5066,6 +5109,14 @@ async fn main() {
|
|||
bearer_auth_state.clone(),
|
||||
wifi_densepose_sensing_server::bearer_auth::require_bearer,
|
||||
))
|
||||
// DNS-rebinding defense: applied last so it runs first on the request
|
||||
// path (axum layers run outermost-in). Rejects requests whose `Host`
|
||||
// header is not in the allowlist before any handler — including
|
||||
// `/health` and `/ws/*` — observes the body.
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
host_allowlist.clone(),
|
||||
wifi_densepose_sensing_server::host_validation::require_allowed_host,
|
||||
))
|
||||
.with_state(state.clone());
|
||||
|
||||
let http_addr = SocketAddr::from((bind_ip, args.http_port));
|
||||
|
|
|
|||
|
|
@ -139,13 +139,15 @@ impl VitalSignDetector {
|
|||
// Cardiac-induced body surface displacement is < 0.5 mm, producing
|
||||
// tiny phase changes. Cross-subcarrier phase variance captures this
|
||||
// more sensitively than amplitude alone.
|
||||
//
|
||||
// Phases come from atan2() and are wrapped to (-pi, pi]. Linear mean
|
||||
// and variance on wrapped values is wrong: two phases close across
|
||||
// the +/-pi discontinuity (e.g. pi-eps and -pi+eps) are physically
|
||||
// ~2*eps rad apart but produce arithmetic variance ~pi^2. Use the
|
||||
// standard circular variance (1 - mean resultant length), which is
|
||||
// stable across the wrap.
|
||||
let phase_var = if phase.len() > 1 {
|
||||
let mean_phase: f64 = phase.iter().sum::<f64>() / phase.len() as f64;
|
||||
phase
|
||||
.iter()
|
||||
.map(|p| (p - mean_phase).powi(2))
|
||||
.sum::<f64>()
|
||||
/ phase.len() as f64
|
||||
phase_circular_variance(phase)
|
||||
} else {
|
||||
// Fallback: use amplitude high-pass residual when phase is unavailable
|
||||
let half = amplitude.len() / 2;
|
||||
|
|
@ -367,6 +369,25 @@ impl VitalSignDetector {
|
|||
/// Constructs a bandpass by subtracting two lowpass filters (LPF_high - LPF_low)
|
||||
/// with a Hamming window. This is a zero-external-dependency implementation
|
||||
/// suitable for the buffer sizes we encounter (up to ~600 samples).
|
||||
/// Circular variance of wrapped phase samples in radians.
|
||||
///
|
||||
/// Returns `1 - R` where `R` is the mean resultant length of the unit-circle
|
||||
/// representation of the input. `0.0` means all samples point in the same
|
||||
/// direction; `1.0` means they are uniformly spread. Stable across the +/-pi
|
||||
/// discontinuity of `atan2`-derived phases. Returns `0.0` for inputs shorter
|
||||
/// than 2 samples.
|
||||
pub(crate) fn phase_circular_variance(phase: &[f64]) -> f64 {
|
||||
if phase.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let (sin_sum, cos_sum) = phase
|
||||
.iter()
|
||||
.fold((0.0_f64, 0.0_f64), |(s, c), &p| (s + p.sin(), c + p.cos()));
|
||||
let n = phase.len() as f64;
|
||||
let r = (sin_sum * sin_sum + cos_sum * cos_sum).sqrt() / n;
|
||||
(1.0 - r).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
pub fn bandpass_filter(data: &[f64], low_hz: f64, high_hz: f64, sample_rate: f64) -> Vec<f64> {
|
||||
if data.len() < 3 || sample_rate < f64::EPSILON {
|
||||
return data.to_vec();
|
||||
|
|
@ -605,6 +626,89 @@ pub fn run_benchmark(n_frames: usize) -> (std::time::Duration, std::time::Durati
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression test for the linear-vs-circular phase variance bug.
|
||||
///
|
||||
/// Two CSI subcarriers whose phases land just either side of the +/-pi
|
||||
/// wrap are physically ~2*eps rad apart, but the previous code computed
|
||||
/// arithmetic mean + arithmetic variance on the wrapped values, treating
|
||||
/// them as ~2*pi apart and producing variance ~pi^2 ~= 9.87. The corrected
|
||||
/// circular variance returns ~1e-6 for the same input.
|
||||
#[test]
|
||||
fn test_phase_variance_handles_wraparound() {
|
||||
// Two phases ~0.002 rad apart, straddling the +/-pi discontinuity.
|
||||
let phases = [PI - 0.001, -PI + 0.001];
|
||||
|
||||
let v = phase_circular_variance(&phases);
|
||||
assert!(
|
||||
v < 0.01,
|
||||
"circular variance of nearly-identical wrapped phases must be tiny, got {v}"
|
||||
);
|
||||
|
||||
// For reference, the *old* (buggy) linear formula on the same input
|
||||
// produced ~9.87. Document that gap here so the assertion above
|
||||
// explicitly fails on any regression to the linear computation.
|
||||
let mean_linear: f64 = phases.iter().sum::<f64>() / phases.len() as f64;
|
||||
let v_linear_buggy: f64 = phases
|
||||
.iter()
|
||||
.map(|p| (p - mean_linear).powi(2))
|
||||
.sum::<f64>()
|
||||
/ phases.len() as f64;
|
||||
assert!(
|
||||
v_linear_buggy > 9.0,
|
||||
"sanity: linear formula on wrapped input should be ~pi^2, got {v_linear_buggy}"
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end: `process_frame` must not blow up `heartbeat_buffer` when
|
||||
/// the input phases happen to straddle the +/-pi wrap. Anyone can run this
|
||||
/// against `main` (with the inline linear-formula bug) and observe the
|
||||
/// stored value of ~9.86; with the fix it is ~1e-6.
|
||||
#[test]
|
||||
fn test_process_frame_handles_wrapped_phases() {
|
||||
let mut detector = VitalSignDetector::new(20.0);
|
||||
let amp = vec![1.0_f64; 8];
|
||||
// 8 subcarriers, all physically ~aligned at ~+/-pi, alternating sign.
|
||||
let phase = vec![
|
||||
PI - 0.001, -PI + 0.001, PI - 0.001, -PI + 0.001,
|
||||
PI - 0.001, -PI + 0.001, PI - 0.001, -PI + 0.001,
|
||||
];
|
||||
|
||||
detector.process_frame(&, &phase);
|
||||
|
||||
let stored = *detector
|
||||
.heartbeat_buffer
|
||||
.back()
|
||||
.expect("process_frame should push exactly one phase_var");
|
||||
|
||||
assert!(
|
||||
stored < 0.1,
|
||||
"phase_var pushed into heartbeat_buffer should be near 0 for \
|
||||
physically-aligned wrapped phases, got {stored}. The linear \
|
||||
(buggy) formula produces ~pi^2 = 9.87 here; the circular \
|
||||
(fixed) formula produces ~1e-6."
|
||||
);
|
||||
}
|
||||
|
||||
/// Diametrically opposite phases must yield maximum circular variance.
|
||||
#[test]
|
||||
fn test_phase_variance_opposite_phases() {
|
||||
let v = phase_circular_variance(&[0.0, PI]);
|
||||
assert!(
|
||||
v > 0.99,
|
||||
"two opposite phases must give circular variance near 1.0, got {v}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Identical phases must yield zero circular variance, even far from zero.
|
||||
#[test]
|
||||
fn test_phase_variance_identical_phases() {
|
||||
let v = phase_circular_variance(&[2.5, 2.5, 2.5, 2.5]);
|
||||
assert!(
|
||||
v < 1e-9,
|
||||
"identical phases must give circular variance ~0, got {v}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fft_magnitude_dc() {
|
||||
let signal = vec![1.0; 8];
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 30fe5eb7a1f1494aa1ad00d54160088a565ec766
|
||||
Subproject commit 8f70d2bb9d64a8ddee7745bc18bc4ff9e151845e
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 050c3fe6f878981250cb62d4003f47b42d290973
|
||||
Subproject commit 53f0419782a472d0a8cc683673667e79f3c350bb
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit acd5689d9f995dc9952bc924268d42e52aae547f
|
||||
Subproject commit 72891d740f92903c78a8208a9069f6c82a4d1cc2
|
||||
4
verify
4
verify
|
|
@ -19,9 +19,9 @@
|
|||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROOF_DIR="${SCRIPT_DIR}/v1/data/proof"
|
||||
PROOF_DIR="${SCRIPT_DIR}/archive/v1/data/proof"
|
||||
VERIFY_PY="${PROOF_DIR}/verify.py"
|
||||
V1_SRC="${SCRIPT_DIR}/v1/src"
|
||||
V1_SRC="${SCRIPT_DIR}/archive/v1/src"
|
||||
|
||||
# Colors (disabled if not a terminal)
|
||||
if [ -t 1 ]; then
|
||||
|
|
|
|||
Loading…
Reference in New Issue