deploy: Observatory demo for GitHub Pages
Static demo of the Psychohistory Observatory visualization (ADR-047). Runs in demo mode with synthetic CSI data — cycles through 4 scenarios (empty room, breathing, walking, fall event) every 30 seconds. Live at: https://ruvnet.github.io/RuView/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
e4e6c1e600
|
|
@ -0,0 +1,340 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RuView Observatory — WiFi DensePose</title>
|
||||
<link rel="stylesheet" href="observatory/css/observatory.css">
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="observatory-canvas"></canvas>
|
||||
|
||||
<!-- ======= HUD Overlay ======= -->
|
||||
<div id="hud">
|
||||
|
||||
<!-- Top-left: Branding -->
|
||||
<div id="brand">
|
||||
<div id="brand-logo"><span class="pi">π</span> RuView</div>
|
||||
<div id="brand-tagline">WiFi DensePose Sensing Observatory</div>
|
||||
</div>
|
||||
|
||||
<!-- Top-right: Connection + status + gear -->
|
||||
<div id="status-bar">
|
||||
<div id="data-source-badge">
|
||||
<span class="dot dot--demo"></span>
|
||||
<span id="data-source-label">DEMO</span>
|
||||
</div>
|
||||
<div id="scenario-area">
|
||||
<span id="autoplay-icon" title="Auto-cycling">▶</span>
|
||||
<select id="scenario-quick-select" title="Change scenario">
|
||||
<option value="auto">Auto-Cycle</option>
|
||||
<option value="empty_room">Empty Room</option>
|
||||
<option value="single_breathing">Vital Signs</option>
|
||||
<option value="two_walking">Multi-Person</option>
|
||||
<option value="fall_event">Fall Detect</option>
|
||||
<option value="sleep_monitoring">Sleep Monitor</option>
|
||||
<option value="intrusion_detect">Intrusion</option>
|
||||
<option value="gesture_control">Gesture Ctrl</option>
|
||||
<option value="crowd_occupancy">Crowd (4 ppl)</option>
|
||||
<option value="search_rescue">Search Rescue</option>
|
||||
<option value="elderly_care">Elderly Care</option>
|
||||
<option value="fitness_tracking">Fitness</option>
|
||||
<option value="security_patrol">Security Patrol</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="scenario-description"></div>
|
||||
<div id="fps-counter" style="display:none">60 FPS</div>
|
||||
<button id="settings-btn" title="Settings">⚙</button>
|
||||
</div>
|
||||
|
||||
<!-- Left panel: Vital Signs -->
|
||||
<div id="panel-vitals" class="data-panel">
|
||||
<div class="panel-header">Vital Signs</div>
|
||||
<div class="vital-row">
|
||||
<div class="vital-icon">♡</div>
|
||||
<div class="vital-data">
|
||||
<div class="vital-label">Heart Rate</div>
|
||||
<div class="vital-value"><span id="hr-value">--</span> <span class="vital-unit">BPM</span></div>
|
||||
<div class="vital-bar"><div id="hr-bar" class="vital-bar-fill vital-bar--hr"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vital-row">
|
||||
<div class="vital-icon">☼</div>
|
||||
<div class="vital-data">
|
||||
<div class="vital-label">Respiration</div>
|
||||
<div class="vital-value"><span id="br-value">--</span> <span class="vital-unit">RPM</span></div>
|
||||
<div class="vital-bar"><div id="br-bar" class="vital-bar-fill vital-bar--br"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vital-row">
|
||||
<div class="vital-icon">⚖</div>
|
||||
<div class="vital-data">
|
||||
<div class="vital-label">Confidence</div>
|
||||
<div class="vital-value"><span id="conf-value">--</span><span class="vital-unit">%</span></div>
|
||||
<div class="vital-bar"><div id="conf-bar" class="vital-bar-fill vital-bar--conf"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right panel: Signal & Presence -->
|
||||
<div id="panel-signal" class="data-panel">
|
||||
<div class="panel-header">WiFi Signal</div>
|
||||
<div class="signal-row">
|
||||
<span class="signal-label">RSSI</span>
|
||||
<span class="signal-value" id="rssi-value">-- dBm</span>
|
||||
</div>
|
||||
<div class="signal-row">
|
||||
<span class="signal-label">Variance</span>
|
||||
<span class="signal-value" id="var-value">--</span>
|
||||
</div>
|
||||
<div class="signal-row">
|
||||
<span class="signal-label">Motion</span>
|
||||
<span class="signal-value" id="motion-value">--</span>
|
||||
</div>
|
||||
<div class="signal-row">
|
||||
<span class="signal-label">Persons</span>
|
||||
<span class="signal-value" id="persons-value">0</span>
|
||||
<span id="persons-dots" class="persons-dots"></span>
|
||||
</div>
|
||||
<canvas id="rssi-sparkline" width="200" height="48"></canvas>
|
||||
|
||||
<div class="panel-header" style="margin-top:12px">Presence</div>
|
||||
<div id="presence-indicator" class="presence-state presence--absent">
|
||||
<span id="presence-label">ABSENT</span>
|
||||
</div>
|
||||
<div id="fall-alert" class="fall-alert" style="display:none">FALL DETECTED</div>
|
||||
</div>
|
||||
|
||||
<!-- Edge module badges (populated dynamically by HudController) -->
|
||||
<div id="edge-modules-bar"></div>
|
||||
|
||||
<!-- Bottom bar: capabilities -->
|
||||
<div id="capabilities-bar">
|
||||
<div class="cap-item"><span class="cap-icon">⚪</span><span>Human Pose Estimation</span></div>
|
||||
<div class="cap-divider"></div>
|
||||
<div class="cap-item"><span class="cap-icon">♥</span><span>Vital Sign Monitoring</span></div>
|
||||
<div class="cap-divider"></div>
|
||||
<div class="cap-item"><span class="cap-icon">☸</span><span>Presence Detection</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom-right: keyboard hints -->
|
||||
<div id="key-hints">
|
||||
<span class="key-hint">[A] Orbit</span>
|
||||
<span class="key-hint">[D] Scenario</span>
|
||||
<span class="key-hint">[F] FPS</span>
|
||||
<span class="key-hint">[S] Settings</span>
|
||||
<span class="key-hint">[Space] Pause</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======= Settings Dialog ======= -->
|
||||
<div id="settings-overlay" class="settings-overlay" style="display:none">
|
||||
<div class="settings-dialog">
|
||||
<div class="settings-header">
|
||||
<span>Settings</span>
|
||||
<button id="settings-close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-tabs">
|
||||
<button class="stab active" data-stab="rendering">Rendering</button>
|
||||
<button class="stab" data-stab="wireframe">Wireframe</button>
|
||||
<button class="stab" data-stab="scene">Scene</button>
|
||||
<button class="stab" data-stab="data">Data</button>
|
||||
</div>
|
||||
|
||||
<!-- Rendering tab -->
|
||||
<div class="stab-content active" id="stab-rendering">
|
||||
<label class="setting-row">
|
||||
<span>Bloom Strength</span>
|
||||
<input type="range" id="opt-bloom" min="0" max="3" step="0.1" value="1.0">
|
||||
<span class="range-val" id="opt-bloom-val">1.0</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Bloom Radius</span>
|
||||
<input type="range" id="opt-bloom-radius" min="0" max="1" step="0.05" value="0.5">
|
||||
<span class="range-val" id="opt-bloom-radius-val">0.5</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Bloom Threshold</span>
|
||||
<input type="range" id="opt-bloom-thresh" min="0" max="1" step="0.05" value="0.25">
|
||||
<span class="range-val" id="opt-bloom-thresh-val">0.25</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Exposure</span>
|
||||
<input type="range" id="opt-exposure" min="0.3" max="2" step="0.05" value="0.9">
|
||||
<span class="range-val" id="opt-exposure-val">0.9</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Vignette</span>
|
||||
<input type="range" id="opt-vignette" min="0" max="1" step="0.05" value="0.5">
|
||||
<span class="range-val" id="opt-vignette-val">0.5</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Film Grain</span>
|
||||
<input type="range" id="opt-grain" min="0" max="0.15" step="0.005" value="0.03">
|
||||
<span class="range-val" id="opt-grain-val">0.03</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Chromatic Aberration</span>
|
||||
<input type="range" id="opt-chromatic" min="0" max="0.008" step="0.0005" value="0.0015">
|
||||
<span class="range-val" id="opt-chromatic-val">0.0015</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Wireframe tab -->
|
||||
<div class="stab-content" id="stab-wireframe">
|
||||
<label class="setting-row">
|
||||
<span>Bone Thickness</span>
|
||||
<input type="range" id="opt-bone-thick" min="0.005" max="0.06" step="0.002" value="0.02">
|
||||
<span class="range-val" id="opt-bone-thick-val">0.02</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Joint Size</span>
|
||||
<input type="range" id="opt-joint-size" min="0.02" max="0.12" step="0.005" value="0.05">
|
||||
<span class="range-val" id="opt-joint-size-val">0.05</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Glow Intensity</span>
|
||||
<input type="range" id="opt-glow" min="0" max="2" step="0.1" value="0.8">
|
||||
<span class="range-val" id="opt-glow-val">0.8</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Particle Trail</span>
|
||||
<input type="range" id="opt-trail" min="0" max="1" step="0.05" value="0.6">
|
||||
<span class="range-val" id="opt-trail-val">0.6</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Wireframe Color</span>
|
||||
<input type="color" id="opt-wire-color" value="#00d878">
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Joint Color</span>
|
||||
<input type="color" id="opt-joint-color" value="#ff4060">
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Aura Opacity</span>
|
||||
<input type="range" id="opt-aura" min="0" max="0.2" step="0.01" value="0.06">
|
||||
<span class="range-val" id="opt-aura-val">0.06</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Scene tab -->
|
||||
<div class="stab-content" id="stab-scene">
|
||||
<label class="setting-row">
|
||||
<span>Signal Field</span>
|
||||
<input type="range" id="opt-field" min="0" max="1" step="0.05" value="0.5">
|
||||
<span class="range-val" id="opt-field-val">0.5</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>WiFi Waves</span>
|
||||
<input type="range" id="opt-waves" min="0" max="1" step="0.05" value="0.6">
|
||||
<span class="range-val" id="opt-waves-val">0.6</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Room Brightness</span>
|
||||
<input type="range" id="opt-ambient" min="0" max="1" step="0.05" value="0.4">
|
||||
<span class="range-val" id="opt-ambient-val">0.4</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Floor Reflection</span>
|
||||
<input type="range" id="opt-reflect" min="0" max="1" step="0.05" value="0.3">
|
||||
<span class="range-val" id="opt-reflect-val">0.3</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>FOV</span>
|
||||
<input type="range" id="opt-fov" min="30" max="90" step="1" value="50">
|
||||
<span class="range-val" id="opt-fov-val">50</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Orbit Speed</span>
|
||||
<input type="range" id="opt-orbit-speed" min="0.02" max="0.5" step="0.02" value="0.15">
|
||||
<span class="range-val" id="opt-orbit-speed-val">0.15</span>
|
||||
</label>
|
||||
<label class="setting-row check-row">
|
||||
<span>Show Grid</span>
|
||||
<input type="checkbox" id="opt-grid" checked>
|
||||
</label>
|
||||
<label class="setting-row check-row">
|
||||
<span>Show Room Boundary</span>
|
||||
<input type="checkbox" id="opt-room" checked>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Data tab -->
|
||||
<div class="stab-content" id="stab-data">
|
||||
<label class="setting-row">
|
||||
<span>Scenario</span>
|
||||
<select id="opt-scenario">
|
||||
<option value="auto">Auto-Cycle (30s)</option>
|
||||
<optgroup label="Core Sensing">
|
||||
<option value="empty_room">Empty Room</option>
|
||||
<option value="single_breathing">Vital Signs (Breathing)</option>
|
||||
<option value="two_walking">Multi-Person Tracking</option>
|
||||
<option value="fall_event">Fall Detection</option>
|
||||
</optgroup>
|
||||
<optgroup label="Medical / Health">
|
||||
<option value="sleep_monitoring">Sleep Monitoring (Apnea)</option>
|
||||
<option value="elderly_care">Elderly Care (Gait)</option>
|
||||
<option value="fitness_tracking">Fitness Tracking</option>
|
||||
</optgroup>
|
||||
<optgroup label="Security">
|
||||
<option value="intrusion_detect">Intrusion Detection</option>
|
||||
<option value="security_patrol">Security Patrol</option>
|
||||
</optgroup>
|
||||
<optgroup label="Building / Retail">
|
||||
<option value="crowd_occupancy">Crowd Occupancy (4 ppl)</option>
|
||||
<option value="gesture_control">Gesture Control (DTW)</option>
|
||||
</optgroup>
|
||||
<optgroup label="Disaster / Tactical">
|
||||
<option value="search_rescue">Search & Rescue (WiFi-Mat)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Cycle Speed (s)</span>
|
||||
<input type="range" id="opt-cycle" min="10" max="120" step="5" value="30">
|
||||
<span class="range-val" id="opt-cycle-val">30</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Style Preset</span>
|
||||
<select id="opt-preset">
|
||||
<option value="custom">Custom</option>
|
||||
<option value="foundation">Foundation (Default)</option>
|
||||
<option value="cinematic">Cinematic</option>
|
||||
<option value="minimal">Minimal / Clean</option>
|
||||
<option value="neon">Neon Glow</option>
|
||||
<option value="tactical">Tactical / Military</option>
|
||||
<option value="medical">Medical Monitor</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Data Source</span>
|
||||
<select id="opt-data-source">
|
||||
<option value="demo" selected>Demo Generator</option>
|
||||
<option value="ws">Live WebSocket</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="setting-row" id="ws-url-row" style="display:none">
|
||||
<span>WS URL</span>
|
||||
<input type="text" id="opt-ws-url" value="" placeholder="ws://localhost:3000/ws/sensing">
|
||||
</label>
|
||||
<button id="btn-reset-camera" class="settings-btn">Reset Camera</button>
|
||||
<button id="btn-reset-settings" class="settings-btn">Reset to Defaults</button>
|
||||
<button id="btn-export-settings" class="settings-btn">Export Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Three.js r160 + addons from CDN -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module" src="observatory/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,698 @@
|
|||
/* ============================================================
|
||||
RuView Observatory — Foundation Color Scheme
|
||||
Warm dark background, electric green wireframe, amber data
|
||||
============================================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=JetBrains+Mono:wght@400;600&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-deep: #080c14;
|
||||
--bg-panel: rgba(8, 16, 28, 0.85);
|
||||
--bg-panel-border: rgba(0, 210, 120, 0.2);
|
||||
--green-glow: #00d878;
|
||||
--green-bright:#3eff8a;
|
||||
--green-dim: #0a6b3a;
|
||||
--amber: #ffb020;
|
||||
--amber-dim: #a06800;
|
||||
--blue-signal: #2090ff;
|
||||
--blue-dim: #0a3060;
|
||||
--red-alert: #ff3040;
|
||||
--red-heart: #ff4060;
|
||||
--text-primary: #e8ece0;
|
||||
--text-secondary: rgba(232,236,224, 0.55);
|
||||
--text-label: rgba(232,236,224, 0.4);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--bg-deep);
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', -apple-system, sans-serif;
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#observatory-canvas {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100vw; height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- HUD Overlay ---- */
|
||||
#hud {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ---- Brand ---- */
|
||||
#brand {
|
||||
position: absolute;
|
||||
top: 24px; left: 28px;
|
||||
}
|
||||
|
||||
#brand-logo {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 32px;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.5px;
|
||||
text-shadow: 0 0 30px rgba(0, 216, 120, 0.3);
|
||||
}
|
||||
|
||||
.pi {
|
||||
color: var(--green-glow);
|
||||
font-style: italic;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
#brand-tagline {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---- Status bar (top right) ---- */
|
||||
#status-bar {
|
||||
position: absolute;
|
||||
top: 24px; right: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#data-source-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(0, 216, 120, 0.1);
|
||||
border: 1px solid rgba(0, 216, 120, 0.25);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.dot--demo { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
|
||||
.dot--live { background: var(--green-glow); box-shadow: 0 0 6px var(--green-glow); animation: pulse-dot 2s infinite; }
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
#scenario-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 14px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 176, 32, 0.1);
|
||||
border: 1px solid rgba(255, 176, 32, 0.25);
|
||||
pointer-events: auto;
|
||||
}
|
||||
#autoplay-icon {
|
||||
font-size: 10px;
|
||||
color: var(--green-glow);
|
||||
animation: pulse-dot 2s infinite;
|
||||
}
|
||||
#autoplay-icon.hidden { display: none; }
|
||||
#scenario-quick-select {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--amber);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
#scenario-quick-select:hover,
|
||||
#scenario-quick-select:focus { color: var(--green-glow); }
|
||||
#scenario-quick-select option {
|
||||
background: #0c1420;
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
#fps-counter {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ---- Data Panels ---- */
|
||||
.data-panel {
|
||||
position: absolute;
|
||||
width: 220px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--bg-panel-border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-label);
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
/* ---- Vitals Panel (left) ---- */
|
||||
#panel-vitals {
|
||||
left: 28px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.vital-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.vital-row:last-child { margin-bottom: 0; }
|
||||
|
||||
.vital-icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vital-row:nth-child(2) .vital-icon { color: var(--red-heart); }
|
||||
.vital-row:nth-child(3) .vital-icon { color: var(--green-glow); }
|
||||
.vital-row:nth-child(4) .vital-icon { color: var(--amber); }
|
||||
|
||||
.vital-data { flex: 1; }
|
||||
|
||||
.vital-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-label);
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vital-unit {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.vital-bar {
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 2px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vital-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.vital-bar--hr { background: var(--red-heart); width: 0%; }
|
||||
.vital-bar--br { background: var(--green-glow); width: 0%; }
|
||||
.vital-bar--conf { background: var(--amber); width: 0%; }
|
||||
|
||||
/* ---- Signal Panel (right) ---- */
|
||||
#panel-signal {
|
||||
right: 28px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.signal-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.signal-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-label);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.signal-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--blue-signal);
|
||||
}
|
||||
|
||||
#rssi-sparkline {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
margin-top: 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* Presence */
|
||||
.presence-state {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.presence--absent {
|
||||
background: rgba(255,255,255,0.03);
|
||||
color: var(--text-label);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.presence--present {
|
||||
background: rgba(0, 216, 120, 0.1);
|
||||
color: var(--green-glow);
|
||||
border: 1px solid rgba(0, 216, 120, 0.3);
|
||||
box-shadow: 0 0 20px rgba(0, 216, 120, 0.1);
|
||||
}
|
||||
|
||||
.presence--active {
|
||||
background: rgba(255, 176, 32, 0.1);
|
||||
color: var(--amber);
|
||||
border: 1px solid rgba(255, 176, 32, 0.3);
|
||||
box-shadow: 0 0 20px rgba(255, 176, 32, 0.1);
|
||||
}
|
||||
|
||||
.fall-alert {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
background: rgba(255, 48, 64, 0.15);
|
||||
color: var(--red-alert);
|
||||
border: 1px solid rgba(255, 48, 64, 0.4);
|
||||
animation: pulse-alert 0.8s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-alert {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ---- Capabilities Bar (bottom center) ---- */
|
||||
#capabilities-bar {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--bg-panel-border);
|
||||
border-radius: 30px;
|
||||
padding: 8px 24px;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.cap-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.cap-icon {
|
||||
font-size: 16px;
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
.cap-item:nth-child(3) .cap-icon { color: var(--red-heart); }
|
||||
.cap-item:nth-child(5) .cap-icon { color: var(--blue-signal); }
|
||||
|
||||
.cap-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
/* ---- Key hints ---- */
|
||||
#key-hints {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 28px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.key-hint {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.2);
|
||||
letter-spacing: 0.5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
/* ---- Settings button ---- */
|
||||
#settings-btn {
|
||||
pointer-events: auto;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
#settings-btn:hover {
|
||||
background: rgba(0, 216, 120, 0.15);
|
||||
border-color: var(--green-glow);
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
/* ---- Settings Dialog ---- */
|
||||
.settings-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
z-index: 100;
|
||||
background: rgba(0,0,0,0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.settings-dialog {
|
||||
background: rgba(10, 16, 28, 0.96);
|
||||
border: 1px solid rgba(0, 216, 120, 0.2);
|
||||
border-radius: 16px;
|
||||
width: 440px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 40px rgba(0,216,120,0.05);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-header button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.settings-header button:hover { color: var(--red-alert); }
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.stab {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-label);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.stab:hover { color: var(--text-secondary); }
|
||||
.stab.active {
|
||||
color: var(--green-glow);
|
||||
border-bottom-color: var(--green-glow);
|
||||
}
|
||||
|
||||
.stab-content {
|
||||
display: none;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.stab-content.active { display: block; }
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.setting-row span:first-child {
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.setting-row input[type="range"] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
.setting-row input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--green-glow);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 6px rgba(0,216,120,0.4);
|
||||
}
|
||||
|
||||
.setting-row input[type="color"] {
|
||||
-webkit-appearance: none;
|
||||
width: 36px; height: 24px;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.setting-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 2px; }
|
||||
.setting-row input[type="color"]::-webkit-color-swatch { border-radius: 2px; border: none; }
|
||||
|
||||
.setting-row select,
|
||||
.setting-row input[type="text"] {
|
||||
flex: 1;
|
||||
background: #0c1420;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
}
|
||||
.setting-row select:focus,
|
||||
.setting-row input[type="text"]:focus {
|
||||
border-color: var(--green-glow);
|
||||
}
|
||||
.setting-row select option {
|
||||
background: #0c1420;
|
||||
color: var(--text-primary);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.setting-row select optgroup {
|
||||
background: #0a1018;
|
||||
color: var(--green-glow);
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.setting-row input[type="checkbox"] {
|
||||
width: 18px; height: 18px;
|
||||
accent-color: var(--green-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.range-val {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--green-glow);
|
||||
min-width: 44px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-top: 6px;
|
||||
background: rgba(0, 216, 120, 0.08);
|
||||
border: 1px solid rgba(0, 216, 120, 0.2);
|
||||
color: var(--green-glow);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.settings-btn:hover {
|
||||
background: rgba(0, 216, 120, 0.15);
|
||||
border-color: var(--green-glow);
|
||||
}
|
||||
|
||||
/* ---- Scenario Description ---- */
|
||||
#scenario-description {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: 28px;
|
||||
max-width: 340px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
letter-spacing: 0.3px;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
/* ---- Edge Module Badges ---- */
|
||||
#edge-modules-bar {
|
||||
position: absolute;
|
||||
bottom: 58px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.edge-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
color: var(--badge-color, var(--text-secondary));
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid var(--badge-color, rgba(255,255,255,0.1));
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--badge-color, transparent) 30%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Person Count Dots ---- */
|
||||
.persons-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.person-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease;
|
||||
}
|
||||
|
||||
.person-dot--active {
|
||||
background: var(--green-glow);
|
||||
border-color: var(--green-glow);
|
||||
box-shadow: 0 0 4px rgba(0, 216, 120, 0.4);
|
||||
}
|
||||
|
||||
/* ---- Vital Value Color Transitions ---- */
|
||||
.vital-value span:first-child {
|
||||
transition: color 0.6s ease;
|
||||
}
|
||||
|
||||
.vital-bar-fill {
|
||||
transition: width 0.5s ease, background 0.6s ease;
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 1200px) {
|
||||
.data-panel { width: 190px; padding: 12px; }
|
||||
.vital-value { font-size: 22px; }
|
||||
#capabilities-bar { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.data-panel { display: none; }
|
||||
#key-hints { display: none; }
|
||||
.settings-dialog { width: 95vw; }
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
/**
|
||||
* Module E — "Statistical Convergence Engine"
|
||||
* RSSI waveform, person orbs, classification, fall alert, metric bars
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const WAVEFORM_POINTS = 120;
|
||||
|
||||
export class ConvergenceEngine {
|
||||
constructor(scene, panelGroup) {
|
||||
this.group = new THREE.Group();
|
||||
if (panelGroup) panelGroup.add(this.group);
|
||||
else scene.add(this.group);
|
||||
|
||||
// --- RSSI Waveform (scrolling line) ---
|
||||
this._rssiHistory = new Float32Array(WAVEFORM_POINTS);
|
||||
const waveGeo = new THREE.BufferGeometry();
|
||||
this._wavePositions = new Float32Array(WAVEFORM_POINTS * 3);
|
||||
for (let i = 0; i < WAVEFORM_POINTS; i++) {
|
||||
this._wavePositions[i * 3] = (i / WAVEFORM_POINTS) * 6 - 3; // x: -3 to 3
|
||||
this._wavePositions[i * 3 + 1] = 0;
|
||||
this._wavePositions[i * 3 + 2] = 0;
|
||||
}
|
||||
waveGeo.setAttribute('position', new THREE.BufferAttribute(this._wavePositions, 3));
|
||||
const waveMat = new THREE.LineBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this._waveform = new THREE.Line(waveGeo, waveMat);
|
||||
this._waveform.position.y = 1.5;
|
||||
this.group.add(this._waveform);
|
||||
|
||||
// Waveform glow (thicker, dimmer duplicate)
|
||||
const glowMat = new THREE.LineBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.2,
|
||||
linewidth: 2,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this._waveGlow = new THREE.Line(waveGeo.clone(), glowMat);
|
||||
this._waveGlow.position.y = 1.5;
|
||||
this._waveGlow.scale.set(1, 1.3, 1);
|
||||
this.group.add(this._waveGlow);
|
||||
|
||||
// --- Person orbs (up to 4) ---
|
||||
this._personOrbs = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const orbGeo = new THREE.SphereGeometry(0.2, 16, 16);
|
||||
const orbMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff8800,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const orb = new THREE.Mesh(orbGeo, orbMat);
|
||||
orb.position.set(-2 + i * 1.2, -0.5, 0);
|
||||
this.group.add(orb);
|
||||
|
||||
const light = new THREE.PointLight(0xff8800, 0, 3);
|
||||
orb.add(light);
|
||||
|
||||
this._personOrbs.push({ mesh: orb, light, mat: orbMat });
|
||||
}
|
||||
|
||||
// --- Classification text sprite ---
|
||||
this._classCanvas = document.createElement('canvas');
|
||||
this._classCanvas.width = 256;
|
||||
this._classCanvas.height = 48;
|
||||
this._classCtx = this._classCanvas.getContext('2d');
|
||||
this._classTex = new THREE.CanvasTexture(this._classCanvas);
|
||||
const classMat = new THREE.SpriteMaterial({
|
||||
map: this._classTex,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
this._classSprite = new THREE.Sprite(classMat);
|
||||
this._classSprite.scale.set(3, 0.6, 1);
|
||||
this._classSprite.position.y = 0.3;
|
||||
this.group.add(this._classSprite);
|
||||
|
||||
// --- Fall alert ring ---
|
||||
const alertGeo = new THREE.TorusGeometry(2.5, 0.05, 8, 48);
|
||||
this._alertMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff2244,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
this._alertRing = new THREE.Mesh(alertGeo, this._alertMat);
|
||||
this._alertRing.rotation.x = Math.PI / 2;
|
||||
this._alertRing.position.y = -1;
|
||||
this.group.add(this._alertRing);
|
||||
|
||||
// --- Metric bars (3: frame rate, confidence, variance) ---
|
||||
this._metricBars = [];
|
||||
const barLabels = ['CONF', 'VAR', 'SPEC'];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const barGeo = new THREE.PlaneGeometry(0.15, 1.5);
|
||||
const barMat = new THREE.MeshBasicMaterial({
|
||||
color: [0x00d4ff, 0x8844ff, 0xff8800][i],
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const bar = new THREE.Mesh(barGeo, barMat);
|
||||
bar.position.set(2 + i * 0.4, -1.2, 0);
|
||||
this.group.add(bar);
|
||||
this._metricBars.push({ mesh: bar, mat: barMat });
|
||||
}
|
||||
|
||||
this._rssiHead = 0;
|
||||
this._lastClassification = '';
|
||||
}
|
||||
|
||||
update(dt, elapsed, data) {
|
||||
const features = data?.features || {};
|
||||
const classification = data?.classification || {};
|
||||
const persons = data?.persons || [];
|
||||
const estPersons = data?.estimated_persons || 0;
|
||||
|
||||
// --- Update RSSI waveform ---
|
||||
const rssi = features.mean_rssi || -50;
|
||||
this._rssiHistory[this._rssiHead] = rssi;
|
||||
this._rssiHead = (this._rssiHead + 1) % WAVEFORM_POINTS;
|
||||
|
||||
for (let i = 0; i < WAVEFORM_POINTS; i++) {
|
||||
const histIdx = (this._rssiHead + i) % WAVEFORM_POINTS;
|
||||
const val = this._rssiHistory[histIdx];
|
||||
// Normalize RSSI (-80 to -20 range) to -1.5 to 1.5
|
||||
this._wavePositions[i * 3 + 1] = ((val + 50) / 30) * 1.5;
|
||||
}
|
||||
this._waveform.geometry.attributes.position.needsUpdate = true;
|
||||
|
||||
// Copy to glow
|
||||
const glowPos = this._waveGlow.geometry.attributes.position;
|
||||
glowPos.array.set(this._wavePositions);
|
||||
glowPos.needsUpdate = true;
|
||||
|
||||
// --- Person orbs ---
|
||||
for (let i = 0; i < this._personOrbs.length; i++) {
|
||||
const { mesh, light, mat } = this._personOrbs[i];
|
||||
if (i < estPersons) {
|
||||
mat.opacity = 0.7;
|
||||
light.intensity = 1.0 + Math.sin(elapsed * 3 + i * 1.5) * 0.5;
|
||||
const pulse = 1.0 + Math.sin(elapsed * 2 + i) * 0.15;
|
||||
mesh.scale.set(pulse, pulse, pulse);
|
||||
} else {
|
||||
mat.opacity = 0.05;
|
||||
light.intensity = 0;
|
||||
mesh.scale.set(0.5, 0.5, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Classification text ---
|
||||
const motionLevel = classification.motion_level || 'absent';
|
||||
const label = motionLevel.toUpperCase().replace('_', ' ');
|
||||
if (label !== this._lastClassification) {
|
||||
this._lastClassification = label;
|
||||
const ctx = this._classCtx;
|
||||
ctx.clearRect(0, 0, 256, 48);
|
||||
ctx.font = '600 24px "Courier New", monospace';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
if (motionLevel === 'active') ctx.fillStyle = '#ff8800';
|
||||
else if (motionLevel.includes('present')) ctx.fillStyle = '#00d4ff';
|
||||
else ctx.fillStyle = '#445566';
|
||||
|
||||
ctx.fillText(label, 128, 32);
|
||||
this._classTex.needsUpdate = true;
|
||||
}
|
||||
|
||||
// --- Fall alert ---
|
||||
const fallDetected = classification.fall_detected || false;
|
||||
if (fallDetected) {
|
||||
this._alertMat.opacity = 0.3 + Math.abs(Math.sin(elapsed * 6)) * 0.5;
|
||||
const scale = 1.0 + Math.sin(elapsed * 4) * 0.1;
|
||||
this._alertRing.scale.set(scale, scale, 1);
|
||||
} else {
|
||||
this._alertMat.opacity = 0;
|
||||
}
|
||||
|
||||
// --- Metric bars ---
|
||||
const confidence = classification.confidence || 0;
|
||||
const variance = Math.min(1, (features.variance || 0) / 5);
|
||||
const spectral = Math.min(1, (features.spectral_power || 0) / 0.5);
|
||||
const values = [confidence, variance, spectral];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const bar = this._metricBars[i];
|
||||
const v = values[i];
|
||||
bar.mesh.scale.y = Math.max(0.05, v);
|
||||
bar.mesh.position.y = -1.2 + v * 0.75;
|
||||
bar.mat.opacity = 0.3 + v * 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._waveform.geometry.dispose();
|
||||
this._waveform.material.dispose();
|
||||
this._waveGlow.geometry.dispose();
|
||||
this._waveGlow.material.dispose();
|
||||
this._alertRing.geometry.dispose();
|
||||
this._alertMat.dispose();
|
||||
this._classTex.dispose();
|
||||
for (const { mesh, mat } of this._personOrbs) {
|
||||
mesh.geometry.dispose();
|
||||
mat.dispose();
|
||||
}
|
||||
for (const { mesh, mat } of this._metricBars) {
|
||||
mesh.geometry.dispose();
|
||||
mat.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,513 @@
|
|||
/**
|
||||
* FigurePool — Manages a pool of wireframe human figures for multi-person rendering.
|
||||
*
|
||||
* Extracted from main.js Observatory class. Owns the lifecycle of up to MAX_FIGURES
|
||||
* Three.js figure groups, each containing joints, bones, body segments, and aura.
|
||||
*
|
||||
* Improvements over the original inline implementation:
|
||||
* - Smooth joint interpolation (lerp toward target instead of snapping)
|
||||
* - Joint pulsation synced with breathing
|
||||
* - Natural bone thickness taper (thicker at shoulder/hip, thinner at extremities)
|
||||
* - Secondary motion with slight delay/overshoot for organic feel
|
||||
* - Pose-adaptive aura shape (wider for exercise, narrower for crouching)
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
// 17-keypoint COCO skeleton connectivity
|
||||
export const SKELETON_PAIRS = [
|
||||
[0, 1], [0, 2], [1, 3], [2, 4],
|
||||
[5, 6], [5, 7], [7, 9], [6, 8], [8, 10],
|
||||
[5, 11], [6, 12], [11, 12],
|
||||
[11, 13], [13, 15], [12, 14], [14, 16],
|
||||
];
|
||||
|
||||
// Body segment cylinders that give volume to the wireframe
|
||||
export const BODY_SEGMENT_DEFS = [
|
||||
{ joints: [5, 11], radius: 0.12 }, // left torso
|
||||
{ joints: [6, 12], radius: 0.12 }, // right torso
|
||||
{ joints: [5, 6], radius: 0.1 }, // shoulder bar
|
||||
{ joints: [11, 12], radius: 0.1 }, // hip bar
|
||||
{ joints: [5, 7], radius: 0.05 }, // left upper arm
|
||||
{ joints: [6, 8], radius: 0.05 }, // right upper arm
|
||||
{ joints: [7, 9], radius: 0.04 }, // left forearm
|
||||
{ joints: [8, 10], radius: 0.04 }, // right forearm
|
||||
{ joints: [11, 13], radius: 0.07 }, // left thigh
|
||||
{ joints: [12, 14], radius: 0.07 }, // right thigh
|
||||
{ joints: [13, 15], radius: 0.05 }, // left shin
|
||||
{ joints: [14, 16], radius: 0.05 }, // right shin
|
||||
{ joints: [0, 0], radius: 0.1, isHead: true },
|
||||
];
|
||||
|
||||
// Bone thickness multipliers — thicker at torso, thinner at extremities
|
||||
const BONE_TAPER = (() => {
|
||||
const tapers = new Map();
|
||||
// Torso and shoulder/hip connections are thickest
|
||||
tapers.set('5-6', 1.4); // shoulder bar
|
||||
tapers.set('11-12', 1.3); // hip bar
|
||||
tapers.set('5-11', 1.3); // left torso
|
||||
tapers.set('6-12', 1.3); // right torso
|
||||
// Upper limbs
|
||||
tapers.set('5-7', 1.0); // left upper arm
|
||||
tapers.set('6-8', 1.0); // right upper arm
|
||||
tapers.set('11-13', 1.1); // left thigh
|
||||
tapers.set('12-14', 1.1); // right thigh
|
||||
// Lower limbs / extremities — thinnest
|
||||
tapers.set('7-9', 0.7); // left forearm
|
||||
tapers.set('8-10', 0.7); // right forearm
|
||||
tapers.set('13-15', 0.8); // left shin
|
||||
tapers.set('14-16', 0.8); // right shin
|
||||
// Head connections
|
||||
tapers.set('0-1', 0.5);
|
||||
tapers.set('0-2', 0.5);
|
||||
tapers.set('1-3', 0.4);
|
||||
tapers.set('2-4', 0.4);
|
||||
return tapers;
|
||||
})();
|
||||
|
||||
// Secondary motion delay factors per joint — extremities lag more
|
||||
const SECONDARY_DELAY = [
|
||||
0.12, // 0 nose
|
||||
0.10, // 1 left eye
|
||||
0.10, // 2 right eye
|
||||
0.08, // 3 left ear
|
||||
0.08, // 4 right ear
|
||||
0.18, // 5 left shoulder
|
||||
0.18, // 6 right shoulder
|
||||
0.14, // 7 left elbow
|
||||
0.14, // 8 right elbow
|
||||
0.10, // 9 left wrist (most lag)
|
||||
0.10, // 10 right wrist
|
||||
0.20, // 11 left hip (anchored, fast follow)
|
||||
0.20, // 12 right hip
|
||||
0.15, // 13 left knee
|
||||
0.15, // 14 right knee
|
||||
0.10, // 15 left ankle
|
||||
0.10, // 16 right ankle
|
||||
];
|
||||
|
||||
// Overshoot factors — extremities overshoot more for organic feel
|
||||
const OVERSHOOT = [
|
||||
0.02, // 0 nose
|
||||
0.01, // 1 left eye
|
||||
0.01, // 2 right eye
|
||||
0.01, // 3 left ear
|
||||
0.01, // 4 right ear
|
||||
0.03, // 5 left shoulder
|
||||
0.03, // 6 right shoulder
|
||||
0.05, // 7 left elbow
|
||||
0.05, // 8 right elbow
|
||||
0.08, // 9 left wrist
|
||||
0.08, // 10 right wrist
|
||||
0.02, // 11 left hip
|
||||
0.02, // 12 right hip
|
||||
0.04, // 13 left knee
|
||||
0.04, // 14 right knee
|
||||
0.06, // 15 left ankle
|
||||
0.06, // 16 right ankle
|
||||
];
|
||||
|
||||
const MAX_FIGURES = 4;
|
||||
|
||||
// Reusable vectors to avoid per-frame allocation
|
||||
const _vecFrom = new THREE.Vector3();
|
||||
const _vecTo = new THREE.Vector3();
|
||||
const _vecTarget = new THREE.Vector3();
|
||||
|
||||
export class FigurePool {
|
||||
/**
|
||||
* @param {THREE.Scene} scene - The Three.js scene to add figures to
|
||||
* @param {object} settings - Shared settings object (boneThick, jointSize, glow, etc.)
|
||||
* @param {object} poseSystem - PoseSystem instance with generateKeypoints(person, elapsed, breathPulse)
|
||||
*/
|
||||
constructor(scene, settings, poseSystem) {
|
||||
this._scene = scene;
|
||||
this._settings = settings;
|
||||
this._poseSystem = poseSystem;
|
||||
this._figures = [];
|
||||
this._maxFigures = MAX_FIGURES;
|
||||
this._build();
|
||||
}
|
||||
|
||||
/** @returns {Array} The array of figure objects */
|
||||
get figures() { return this._figures; }
|
||||
|
||||
// ---- Construction ----
|
||||
|
||||
_build() {
|
||||
for (let f = 0; f < this._maxFigures; f++) {
|
||||
this._figures.push(this._createFigure());
|
||||
}
|
||||
}
|
||||
|
||||
_createFigure() {
|
||||
const group = new THREE.Group();
|
||||
this._scene.add(group);
|
||||
const wireColor = new THREE.Color(this._settings.wireColor);
|
||||
const jointColor = new THREE.Color(this._settings.jointColor);
|
||||
|
||||
// Joints (17 COCO keypoints)
|
||||
const joints = [];
|
||||
for (let i = 0; i < 17; i++) {
|
||||
const isNose = i === 0;
|
||||
const size = isNose ? this._settings.jointSize * 0.7 : this._settings.jointSize;
|
||||
const geo = new THREE.SphereGeometry(size, 12, 12);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: isNose ? wireColor : jointColor,
|
||||
emissive: isNose ? wireColor : jointColor,
|
||||
emissiveIntensity: 0.35,
|
||||
transparent: true, opacity: 0,
|
||||
roughness: 0.3, metalness: 0.2,
|
||||
});
|
||||
const sphere = new THREE.Mesh(geo, mat);
|
||||
sphere.castShadow = true;
|
||||
group.add(sphere);
|
||||
joints.push(sphere);
|
||||
|
||||
// Halo glow on key joints
|
||||
if ([5, 6, 9, 10, 11, 12, 15, 16].includes(i)) {
|
||||
const haloGeo = new THREE.SphereGeometry(size * 1.3, 8, 8);
|
||||
const haloMat = new THREE.MeshBasicMaterial({
|
||||
color: jointColor,
|
||||
transparent: true, opacity: 0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
const halo = new THREE.Mesh(haloGeo, haloMat);
|
||||
sphere.add(halo);
|
||||
sphere._halo = halo;
|
||||
sphere._haloMat = haloMat;
|
||||
|
||||
const glow = new THREE.PointLight(jointColor, 0, 0.8);
|
||||
sphere.add(glow);
|
||||
sphere._glow = glow;
|
||||
}
|
||||
}
|
||||
|
||||
// Bones — tapered thickness
|
||||
const bones = [];
|
||||
for (const [a, b] of SKELETON_PAIRS) {
|
||||
const taperKey = `${Math.min(a, b)}-${Math.max(a, b)}`;
|
||||
const taper = BONE_TAPER.get(taperKey) || 1.0;
|
||||
const thick = this._settings.boneThick * taper;
|
||||
// Top radius thicker than bottom for natural taper along bone length
|
||||
const topRadius = thick;
|
||||
const botRadius = thick * 0.65;
|
||||
const geo = new THREE.CylinderGeometry(topRadius, botRadius, 1, 8, 1);
|
||||
geo.translate(0, 0.5, 0);
|
||||
geo.rotateX(Math.PI / 2);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: wireColor, emissive: wireColor, emissiveIntensity: 0.3,
|
||||
transparent: true, opacity: 0, roughness: 0.4, metalness: 0.1,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.castShadow = true;
|
||||
group.add(mesh);
|
||||
bones.push({ mesh, a, b, taper });
|
||||
}
|
||||
|
||||
// Body segments (volume cylinders and head sphere)
|
||||
const bodySegments = [];
|
||||
for (const seg of BODY_SEGMENT_DEFS) {
|
||||
const geo = seg.isHead
|
||||
? new THREE.SphereGeometry(seg.radius, 12, 12)
|
||||
: new THREE.CylinderGeometry(seg.radius, seg.radius * 0.85, 1, 8, 1);
|
||||
if (!seg.isHead) {
|
||||
geo.translate(0, 0.5, 0);
|
||||
geo.rotateX(Math.PI / 2);
|
||||
}
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: wireColor, emissive: wireColor, emissiveIntensity: 0.12,
|
||||
transparent: true, opacity: 0, roughness: 0.5, metalness: 0.1,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
group.add(mesh);
|
||||
bodySegments.push({ mesh, mat, a: seg.joints[0], b: seg.joints[1], isHead: seg.isHead });
|
||||
}
|
||||
|
||||
// Aura cylinder
|
||||
const auraGeo = new THREE.CylinderGeometry(0.4, 0.3, 1.7, 16, 1, true);
|
||||
const auraMat = new THREE.MeshBasicMaterial({
|
||||
color: wireColor, transparent: true, opacity: 0,
|
||||
side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||
});
|
||||
const aura = new THREE.Mesh(auraGeo, auraMat);
|
||||
aura.position.y = 1;
|
||||
group.add(aura);
|
||||
|
||||
// Per-figure point light
|
||||
const personLight = new THREE.PointLight(wireColor, 0, 6);
|
||||
personLight.position.y = 1;
|
||||
group.add(personLight);
|
||||
|
||||
// Interpolation state: previous positions for smooth lerp and secondary motion
|
||||
const prevPositions = [];
|
||||
const velocities = [];
|
||||
for (let i = 0; i < 17; i++) {
|
||||
prevPositions.push(new THREE.Vector3(0, 0, 0));
|
||||
velocities.push(new THREE.Vector3(0, 0, 0));
|
||||
}
|
||||
|
||||
return {
|
||||
group, joints, bones, bodySegments, aura, auraMat, personLight,
|
||||
visible: false,
|
||||
prevPositions,
|
||||
velocities,
|
||||
_initialized: false,
|
||||
_lastPose: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Per-frame update ----
|
||||
|
||||
/**
|
||||
* Update all figures based on current data frame.
|
||||
* @param {object} data - Current sensing data with persons[], vital_signs, classification
|
||||
* @param {number} elapsed - Elapsed time in seconds
|
||||
*/
|
||||
update(data, elapsed) {
|
||||
const persons = data?.persons || [];
|
||||
const vs = data?.vital_signs || {};
|
||||
const isPresent = data?.classification?.presence || false;
|
||||
const breathBpm = vs.breathing_rate_bpm || 0;
|
||||
const breathPulse = breathBpm > 0
|
||||
? Math.sin(elapsed * Math.PI * 2 * (breathBpm / 60)) * 0.012
|
||||
: 0;
|
||||
|
||||
for (let f = 0; f < this._figures.length; f++) {
|
||||
const fig = this._figures[f];
|
||||
if (f < persons.length && isPresent) {
|
||||
const p = persons[f];
|
||||
const kps = this._poseSystem.generateKeypoints(p, elapsed, breathPulse);
|
||||
this.applyKeypoints(fig, kps, breathPulse, p.position || [0, 0, 0], elapsed, p.pose);
|
||||
fig.visible = true;
|
||||
} else {
|
||||
if (fig.visible) {
|
||||
this.hide(fig);
|
||||
fig.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply keypoints to a figure with smooth interpolation, pulsation, and secondary motion.
|
||||
* @param {object} fig - Figure object from the pool
|
||||
* @param {Array} kps - 17-element array of [x,y,z] keypoint positions
|
||||
* @param {number} breathPulse - Current breathing pulse value
|
||||
* @param {Array} pos - Person world position [x,y,z]
|
||||
* @param {number} elapsed - Elapsed time for pulsation effects
|
||||
* @param {string} pose - Current pose name for aura adaptation
|
||||
*/
|
||||
applyKeypoints(fig, kps, breathPulse, pos, elapsed = 0, pose = 'standing') {
|
||||
const lerpFactor = fig._initialized ? 0.18 : 1.0;
|
||||
|
||||
// Joints with smooth interpolation and secondary motion
|
||||
for (let i = 0; i < 17 && i < kps.length; i++) {
|
||||
const j = fig.joints[i];
|
||||
_vecTarget.set(kps[i][0], kps[i][1], kps[i][2]);
|
||||
|
||||
if (fig._initialized) {
|
||||
// Compute velocity for overshoot
|
||||
const prev = fig.prevPositions[i];
|
||||
const vel = fig.velocities[i];
|
||||
|
||||
// Smooth lerp with per-joint delay
|
||||
const delay = SECONDARY_DELAY[i];
|
||||
const jointLerp = lerpFactor + delay;
|
||||
j.position.lerp(_vecTarget, Math.min(jointLerp, 0.95));
|
||||
|
||||
// Apply subtle overshoot based on velocity change
|
||||
const overshoot = OVERSHOOT[i];
|
||||
vel.subVectors(j.position, prev).multiplyScalar(overshoot);
|
||||
j.position.add(vel);
|
||||
|
||||
prev.copy(j.position);
|
||||
} else {
|
||||
// First frame: snap to position
|
||||
j.position.copy(_vecTarget);
|
||||
fig.prevPositions[i].copy(_vecTarget);
|
||||
fig.velocities[i].set(0, 0, 0);
|
||||
}
|
||||
|
||||
j.material.opacity = 0.95;
|
||||
|
||||
// Joint pulsation synced with breathing
|
||||
const pulseFactor = 1.0 + Math.abs(breathPulse) * 8.0;
|
||||
j.material.emissiveIntensity = 0.35 * pulseFactor;
|
||||
|
||||
const baseScale = this._settings.jointSize / 0.04;
|
||||
// Subtle size pulsation on breathing
|
||||
const pulseScale = baseScale * (1.0 + Math.abs(breathPulse) * 3.0);
|
||||
j.scale.setScalar(pulseScale);
|
||||
|
||||
if (j._haloMat) {
|
||||
j._haloMat.opacity = 0.04 * this._settings.glow * pulseFactor;
|
||||
}
|
||||
if (j._glow) {
|
||||
j._glow.intensity = this._settings.glow * 0.12 * pulseFactor;
|
||||
}
|
||||
}
|
||||
|
||||
fig._initialized = true;
|
||||
|
||||
// Bones with tapered thickness
|
||||
for (const bone of fig.bones) {
|
||||
const pA = kps[bone.a], pB = kps[bone.b];
|
||||
if (pA && pB) {
|
||||
_vecFrom.set(pA[0], pA[1], pA[2]);
|
||||
_vecTo.set(pB[0], pB[1], pB[2]);
|
||||
const len = _vecFrom.distanceTo(_vecTo);
|
||||
|
||||
// Use interpolated joint positions for smooth bone movement
|
||||
if (fig._initialized) {
|
||||
const jA = fig.joints[bone.a];
|
||||
const jB = fig.joints[bone.b];
|
||||
bone.mesh.position.copy(jA.position);
|
||||
bone.mesh.scale.set(1, 1, jA.position.distanceTo(jB.position));
|
||||
bone.mesh.lookAt(jB.position);
|
||||
} else {
|
||||
bone.mesh.position.copy(_vecFrom);
|
||||
bone.mesh.scale.set(1, 1, len);
|
||||
bone.mesh.lookAt(_vecTo);
|
||||
}
|
||||
|
||||
bone.mesh.material.opacity = 0.85;
|
||||
bone.mesh.material.emissiveIntensity = 0.3 + Math.abs(breathPulse) * 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Body segments
|
||||
for (const seg of fig.bodySegments) {
|
||||
if (seg.isHead) {
|
||||
const headJoint = fig.joints[seg.a];
|
||||
seg.mesh.position.set(headJoint.position.x, headJoint.position.y + 0.05, headJoint.position.z);
|
||||
seg.mat.opacity = 0.15;
|
||||
} else {
|
||||
const jA = fig.joints[seg.a];
|
||||
const jB = fig.joints[seg.b];
|
||||
if (jA && jB) {
|
||||
const len = jA.position.distanceTo(jB.position);
|
||||
seg.mesh.position.copy(jA.position);
|
||||
seg.mesh.scale.set(1, 1, len);
|
||||
seg.mesh.lookAt(jB.position);
|
||||
seg.mat.opacity = 0.12;
|
||||
}
|
||||
}
|
||||
seg.mat.emissiveIntensity = 0.1 + Math.abs(breathPulse) * 0.4;
|
||||
}
|
||||
|
||||
// Aura — adapt shape to pose
|
||||
const hipY = (fig.joints[11].position.y + fig.joints[12].position.y) / 2;
|
||||
const cx = (fig.joints[11].position.x + fig.joints[12].position.x) / 2;
|
||||
const cz = (fig.joints[11].position.z + fig.joints[12].position.z) / 2;
|
||||
fig.aura.position.set(cx, hipY, cz);
|
||||
fig.auraMat.opacity = this._settings.aura + Math.abs(breathPulse) * 0.8;
|
||||
|
||||
// Pose-adaptive aura: compute from actual keypoint spread
|
||||
const auraShape = this._computeAuraShape(fig, pose, breathPulse);
|
||||
fig.aura.scale.set(auraShape.scaleX, auraShape.scaleY, auraShape.scaleZ);
|
||||
|
||||
// Person light
|
||||
fig.personLight.position.set(pos[0], 1.2, pos[2]);
|
||||
fig.personLight.intensity = this._settings.glow * 0.4;
|
||||
|
||||
fig._lastPose = pose;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute pose-adaptive aura shape based on actual keypoint spread.
|
||||
* Wider for exercise/spread poses, narrower for crouching/compact poses.
|
||||
*/
|
||||
_computeAuraShape(fig, pose, breathPulse) {
|
||||
// Measure horizontal spread from shoulders and hips
|
||||
const lShoulder = fig.joints[5].position;
|
||||
const rShoulder = fig.joints[6].position;
|
||||
const lHip = fig.joints[11].position;
|
||||
const rHip = fig.joints[12].position;
|
||||
const nose = fig.joints[0].position;
|
||||
const lAnkle = fig.joints[15].position;
|
||||
const rAnkle = fig.joints[16].position;
|
||||
|
||||
// Horizontal spread (X-Z plane)
|
||||
const shoulderWidth = Math.sqrt(
|
||||
(rShoulder.x - lShoulder.x) ** 2 +
|
||||
(rShoulder.z - lShoulder.z) ** 2
|
||||
);
|
||||
const ankleWidth = Math.sqrt(
|
||||
(rAnkle.x - lAnkle.x) ** 2 +
|
||||
(rAnkle.z - lAnkle.z) ** 2
|
||||
);
|
||||
const maxWidth = Math.max(shoulderWidth, ankleWidth);
|
||||
|
||||
// Vertical extent
|
||||
const headY = nose.y;
|
||||
const footY = Math.min(lAnkle.y, rAnkle.y);
|
||||
const height = headY - footY;
|
||||
|
||||
// Normalize to base aura dimensions
|
||||
const baseWidth = 0.44; // default shoulder width
|
||||
const baseHeight = 1.7; // default standing height
|
||||
|
||||
const widthRatio = Math.max(0.6, Math.min(2.0, maxWidth / baseWidth));
|
||||
const heightRatio = Math.max(0.4, Math.min(1.3, height / baseHeight));
|
||||
|
||||
// Breathing modulation
|
||||
const breathMod = 1 + breathPulse * 2;
|
||||
|
||||
return {
|
||||
scaleX: widthRatio * breathMod,
|
||||
scaleY: heightRatio * breathMod,
|
||||
scaleZ: widthRatio * breathMod,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a figure by fading all materials to invisible.
|
||||
* @param {object} fig - Figure object to hide
|
||||
*/
|
||||
hide(fig) {
|
||||
for (const j of fig.joints) {
|
||||
j.material.opacity = 0;
|
||||
if (j._haloMat) j._haloMat.opacity = 0;
|
||||
if (j._glow) j._glow.intensity = 0;
|
||||
}
|
||||
for (const b of fig.bones) b.mesh.material.opacity = 0;
|
||||
for (const seg of fig.bodySegments) seg.mat.opacity = 0;
|
||||
fig.auraMat.opacity = 0;
|
||||
fig.personLight.intensity = 0;
|
||||
fig._initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply wire and joint colors to all figures in the pool.
|
||||
* @param {THREE.Color} wireColor
|
||||
* @param {THREE.Color} jointColor
|
||||
*/
|
||||
applyColors(wireColor, jointColor) {
|
||||
for (const fig of this._figures) {
|
||||
for (let i = 0; i < fig.joints.length; i++) {
|
||||
const j = fig.joints[i];
|
||||
if (i === 0) {
|
||||
j.material.color.copy(wireColor);
|
||||
j.material.emissive.copy(wireColor);
|
||||
} else {
|
||||
j.material.color.copy(jointColor);
|
||||
j.material.emissive.copy(jointColor);
|
||||
}
|
||||
if (j._haloMat) j._haloMat.color.copy(jointColor);
|
||||
if (j._glow) j._glow.color.copy(jointColor);
|
||||
}
|
||||
for (const b of fig.bones) {
|
||||
b.mesh.material.color.copy(wireColor);
|
||||
b.mesh.material.emissive.copy(wireColor);
|
||||
}
|
||||
for (const seg of fig.bodySegments) {
|
||||
seg.mat.color.copy(wireColor);
|
||||
seg.mat.emissive.copy(wireColor);
|
||||
}
|
||||
fig.auraMat.color.copy(wireColor);
|
||||
fig.personLight.color.copy(wireColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Holographic Panel — Reusable frame with border shader, scan line, title
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const BORDER_VERTEX = `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const BORDER_FRAGMENT = `
|
||||
uniform float uTime;
|
||||
uniform vec3 uColor;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
// Thin border
|
||||
float bx = step(vUv.x, 0.015) + step(1.0 - 0.015, vUv.x);
|
||||
float by = step(vUv.y, 0.02) + step(1.0 - 0.02, vUv.y);
|
||||
float border = clamp(bx + by, 0.0, 1.0);
|
||||
|
||||
// Scan line moving upward
|
||||
float scan = smoothstep(0.0, 0.02, abs(vUv.y - fract(uTime * 0.15))) ;
|
||||
scan = 1.0 - (1.0 - scan) * 0.4;
|
||||
|
||||
// Corner accents
|
||||
float corner = 0.0;
|
||||
float cx = min(vUv.x, 1.0 - vUv.x);
|
||||
float cy = min(vUv.y, 1.0 - vUv.y);
|
||||
if (cx < 0.06 && cy < 0.08) corner = 0.6;
|
||||
|
||||
// Subtle fill
|
||||
float fill = 0.03 + corner * 0.05;
|
||||
|
||||
float alpha = max(border * 0.7, fill) * scan;
|
||||
gl_FragColor = vec4(uColor, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
export class HolographicPanel {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {number[]} opts.position - [x, y, z]
|
||||
* @param {number} opts.width
|
||||
* @param {number} opts.height
|
||||
* @param {string} opts.title
|
||||
* @param {number} [opts.color=0x00d4ff]
|
||||
*/
|
||||
constructor(opts) {
|
||||
this.group = new THREE.Group();
|
||||
this.group.position.set(...opts.position);
|
||||
|
||||
const color = new THREE.Color(opts.color || 0x00d4ff);
|
||||
|
||||
// Border plane
|
||||
this._uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: color },
|
||||
};
|
||||
|
||||
const borderGeo = new THREE.PlaneGeometry(opts.width, opts.height);
|
||||
const borderMat = new THREE.ShaderMaterial({
|
||||
vertexShader: BORDER_VERTEX,
|
||||
fragmentShader: BORDER_FRAGMENT,
|
||||
uniforms: this._uniforms,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this._border = new THREE.Mesh(borderGeo, borderMat);
|
||||
this.group.add(this._border);
|
||||
|
||||
// Title sprite
|
||||
if (opts.title) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 512;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = 'transparent';
|
||||
ctx.fillRect(0, 0, 512, 64);
|
||||
ctx.font = '600 28px "Courier New", monospace';
|
||||
ctx.fillStyle = `#${color.getHexString()}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(opts.title.toUpperCase(), 256, 42);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const spriteMat = new THREE.SpriteMaterial({
|
||||
map: tex,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sprite = new THREE.Sprite(spriteMat);
|
||||
sprite.scale.set(opts.width * 0.8, opts.width * 0.1, 1);
|
||||
sprite.position.y = opts.height / 2 + 0.3;
|
||||
this.group.add(sprite);
|
||||
this._titleSprite = sprite;
|
||||
this._titleTex = tex;
|
||||
}
|
||||
}
|
||||
|
||||
update(dt, elapsed) {
|
||||
this._uniforms.uTime.value = elapsed;
|
||||
}
|
||||
|
||||
/** Make panel face camera */
|
||||
lookAt(cameraPos) {
|
||||
this.group.lookAt(cameraPos);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._border.geometry.dispose();
|
||||
this._border.material.dispose();
|
||||
if (this._titleTex) this._titleTex.dispose();
|
||||
if (this._titleSprite) this._titleSprite.material.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,567 @@
|
|||
/**
|
||||
* HudController — Extracted HUD update, settings dialog, and scenario UI
|
||||
*
|
||||
* Manages all DOM-based HUD elements:
|
||||
* - Vital sign display with smooth lerp transitions and color coding
|
||||
* - Signal metrics, sparkline, and presence indicator
|
||||
* - Scenario description and edge module badges
|
||||
* - Mini person-count dot visualization
|
||||
* - Settings dialog (tabs, ranges, presets, data source)
|
||||
* - Quick-select scenario dropdown
|
||||
*/
|
||||
|
||||
// ---- Constants ----
|
||||
|
||||
export const SCENARIO_NAMES = [
|
||||
'EMPTY ROOM','VITAL SIGNS','MULTI-PERSON','FALL DETECT',
|
||||
'SLEEP MONITOR','INTRUSION','GESTURE CTRL','CROWD OCCUPANCY',
|
||||
'SEARCH RESCUE','ELDERLY CARE','FITNESS','SECURITY PATROL',
|
||||
];
|
||||
|
||||
export const DEFAULTS = {
|
||||
bloom: 0.2, bloomRadius: 0.25, bloomThresh: 0.5,
|
||||
exposure: 1.3, vignette: 0.25, grain: 0.01, chromatic: 0.0005,
|
||||
boneThick: 0.018, jointSize: 0.035, glow: 0.3, trail: 0.35,
|
||||
wireColor: '#00d878', jointColor: '#ff4060', aura: 0.02,
|
||||
field: 0.45, waves: 0.4, ambient: 0.7, reflect: 0.2,
|
||||
fov: 50, orbitSpeed: 0.15, grid: true, room: true,
|
||||
scenario: 'auto', cycle: 30, dataSource: 'demo', wsUrl: '',
|
||||
};
|
||||
|
||||
export const SETTINGS_VERSION = '4';
|
||||
|
||||
export const PRESETS = {
|
||||
foundation: {},
|
||||
cinematic: {
|
||||
bloom: 1.2, bloomRadius: 0.5, bloomThresh: 0.2,
|
||||
exposure: 0.8, vignette: 0.7, grain: 0.04, chromatic: 0.002,
|
||||
glow: 0.6, trail: 0.8, aura: 0.06, field: 0.4,
|
||||
waves: 0.7, ambient: 0.25, reflect: 0.5, fov: 40, orbitSpeed: 0.08,
|
||||
},
|
||||
minimal: {
|
||||
bloom: 0.3, bloomRadius: 0.2, bloomThresh: 0.5,
|
||||
exposure: 1.1, vignette: 0.2, grain: 0, chromatic: 0,
|
||||
glow: 0.3, trail: 0.2, aura: 0.02, field: 0.7,
|
||||
waves: 0.3, ambient: 0.6, reflect: 0.1, wireColor: '#40ff90', jointColor: '#4080ff',
|
||||
},
|
||||
neon: {
|
||||
bloom: 2.5, bloomRadius: 0.8, bloomThresh: 0.1,
|
||||
exposure: 0.6, vignette: 0.6, grain: 0.02, chromatic: 0.004,
|
||||
glow: 2.0, trail: 1.0, aura: 0.15, field: 0.6,
|
||||
waves: 1.0, ambient: 0.15, reflect: 0.7, wireColor: '#00ffaa', jointColor: '#ff00ff',
|
||||
},
|
||||
tactical: {
|
||||
bloom: 0.5, bloomRadius: 0.3, bloomThresh: 0.4,
|
||||
exposure: 0.85, vignette: 0.4, grain: 0.04, chromatic: 0.001,
|
||||
glow: 0.5, trail: 0.4, aura: 0.03, field: 0.8,
|
||||
waves: 0.4, ambient: 0.3, reflect: 0.15, wireColor: '#30ff60', jointColor: '#ff8800',
|
||||
},
|
||||
medical: {
|
||||
bloom: 0.6, bloomRadius: 0.4, bloomThresh: 0.35,
|
||||
exposure: 1.0, vignette: 0.3, grain: 0.01, chromatic: 0.0005,
|
||||
glow: 0.6, trail: 0.3, aura: 0.04, field: 0.5,
|
||||
waves: 0.3, ambient: 0.5, reflect: 0.2, wireColor: '#00ccff', jointColor: '#ff3355',
|
||||
},
|
||||
};
|
||||
|
||||
// Scenario descriptions shown below the dropdown
|
||||
const SCENARIO_DESCRIPTIONS = {
|
||||
auto: 'Auto-cycling through all sensing scenarios.',
|
||||
empty_room: 'Baseline calibration with no human presence in the monitored zone.',
|
||||
single_breathing: 'Detecting vital signs through WiFi signal micro-variations.',
|
||||
two_walking: 'Tracking multiple people simultaneously via CSI multiplex separation.',
|
||||
fall_event: 'Sudden posture-change detection using acceleration feature analysis.',
|
||||
sleep_monitoring: 'Monitoring breathing patterns and apnea events during sleep.',
|
||||
intrusion_detect: 'Passive perimeter monitoring -- no cameras, pure RF sensing.',
|
||||
gesture_control: 'DTW-based gesture recognition from hand/arm motion signatures.',
|
||||
crowd_occupancy: 'Estimating room occupancy count from aggregate CSI variance.',
|
||||
search_rescue: 'Through-wall survivor detection using WiFi-MAT multistatic mode.',
|
||||
elderly_care: 'Continuous gait analysis for early mobility-decline detection.',
|
||||
fitness_tracking: 'Rep counting and exercise classification from body kinematics.',
|
||||
security_patrol: 'Multi-zone presence patrol with camera-free motion heatmaps.',
|
||||
};
|
||||
|
||||
// Edge modules active per scenario
|
||||
const SCENARIO_EDGE_MODULES = {
|
||||
auto: [],
|
||||
empty_room: [],
|
||||
single_breathing: ['VITALS'],
|
||||
two_walking: ['GAIT', 'TRACKING'],
|
||||
fall_event: ['FALL', 'VITALS'],
|
||||
sleep_monitoring: ['VITALS', 'APNEA'],
|
||||
intrusion_detect: ['PRESENCE', 'ALERT'],
|
||||
gesture_control: ['GESTURE', 'DTW'],
|
||||
crowd_occupancy: ['OCCUPANCY'],
|
||||
search_rescue: ['MAT', 'VITALS', 'PRESENCE'],
|
||||
elderly_care: ['GAIT', 'VITALS', 'FALL'],
|
||||
fitness_tracking: ['GESTURE', 'GAIT'],
|
||||
security_patrol: ['PRESENCE', 'ALERT', 'TRACKING'],
|
||||
};
|
||||
|
||||
// Edge-module badge colors
|
||||
const MODULE_COLORS = {
|
||||
VITALS: 'var(--red-heart)',
|
||||
GAIT: 'var(--green-glow)',
|
||||
FALL: 'var(--red-alert)',
|
||||
GESTURE: 'var(--amber)',
|
||||
PRESENCE: 'var(--blue-signal)',
|
||||
TRACKING: 'var(--green-bright)',
|
||||
OCCUPANCY: 'var(--amber)',
|
||||
ALERT: 'var(--red-alert)',
|
||||
DTW: 'var(--amber)',
|
||||
APNEA: 'var(--red-heart)',
|
||||
MAT: 'var(--blue-signal)',
|
||||
};
|
||||
|
||||
// Vital-sign color-coding thresholds
|
||||
function vitalColor(type, value) {
|
||||
if (value <= 0) return 'var(--text-secondary)';
|
||||
if (type === 'hr') {
|
||||
if (value < 50 || value > 130) return 'var(--red-alert)';
|
||||
if (value < 60 || value > 100) return 'var(--amber)';
|
||||
return 'var(--green-glow)';
|
||||
}
|
||||
if (type === 'br') {
|
||||
if (value < 8 || value > 28) return 'var(--red-alert)';
|
||||
if (value < 12 || value > 20) return 'var(--amber)';
|
||||
return 'var(--green-glow)';
|
||||
}
|
||||
if (type === 'conf') {
|
||||
if (value < 40) return 'var(--red-alert)';
|
||||
if (value < 70) return 'var(--amber)';
|
||||
return 'var(--green-glow)';
|
||||
}
|
||||
return 'var(--text-primary)';
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
// ---- HudController class ----
|
||||
|
||||
export class HudController {
|
||||
constructor(observatory) {
|
||||
this._obs = observatory;
|
||||
this._settingsOpen = false;
|
||||
this._rssiHistory = [];
|
||||
this._sparklineCtx = document.getElementById('rssi-sparkline')?.getContext('2d');
|
||||
|
||||
// Lerp state for smooth vital-sign transitions
|
||||
this._lerpHr = 0;
|
||||
this._lerpBr = 0;
|
||||
this._lerpConf = 0;
|
||||
|
||||
// Track current scenario for description/edge updates
|
||||
this._currentScenarioKey = null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Settings dialog
|
||||
// ============================================================
|
||||
|
||||
initSettings() {
|
||||
const overlay = document.getElementById('settings-overlay');
|
||||
const btn = document.getElementById('settings-btn');
|
||||
const closeBtn = document.getElementById('settings-close');
|
||||
btn.addEventListener('click', () => this.toggleSettings());
|
||||
closeBtn.addEventListener('click', () => this.toggleSettings());
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.toggleSettings(); });
|
||||
|
||||
// Tab switching
|
||||
document.querySelectorAll('.stab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.stab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.stab-content').forEach(c => c.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
document.getElementById(`stab-${tab.dataset.stab}`).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
const obs = this._obs;
|
||||
const s = obs.settings;
|
||||
|
||||
// Bind ranges
|
||||
this._bindRange('opt-bloom', 'bloom', v => { obs._postProcessing._bloomPass.strength = v; });
|
||||
this._bindRange('opt-bloom-radius', 'bloomRadius', v => { obs._postProcessing._bloomPass.radius = v; });
|
||||
this._bindRange('opt-bloom-thresh', 'bloomThresh', v => { obs._postProcessing._bloomPass.threshold = v; });
|
||||
this._bindRange('opt-exposure', 'exposure', v => { obs._renderer.toneMappingExposure = v; });
|
||||
this._bindRange('opt-vignette', 'vignette', v => { obs._postProcessing._vignettePass.uniforms.uVignetteStrength.value = v; });
|
||||
this._bindRange('opt-grain', 'grain', v => { obs._postProcessing._vignettePass.uniforms.uGrainStrength.value = v; });
|
||||
this._bindRange('opt-chromatic', 'chromatic', v => { obs._postProcessing._vignettePass.uniforms.uChromaticStrength.value = v; });
|
||||
this._bindRange('opt-bone-thick', 'boneThick');
|
||||
this._bindRange('opt-joint-size', 'jointSize');
|
||||
this._bindRange('opt-glow', 'glow');
|
||||
this._bindRange('opt-trail', 'trail');
|
||||
this._bindRange('opt-aura', 'aura');
|
||||
this._bindRange('opt-field', 'field', v => { obs._fieldMat.opacity = v; });
|
||||
this._bindRange('opt-waves', 'waves');
|
||||
this._bindRange('opt-ambient', 'ambient', v => { obs._ambient.intensity = v; });
|
||||
this._bindRange('opt-reflect', 'reflect', v => {
|
||||
obs._floorMat.roughness = 1.0 - v * 0.7;
|
||||
obs._floorMat.metalness = v * 0.5;
|
||||
});
|
||||
this._bindRange('opt-fov', 'fov', v => {
|
||||
obs._camera.fov = v;
|
||||
obs._camera.updateProjectionMatrix();
|
||||
});
|
||||
this._bindRange('opt-orbit-speed', 'orbitSpeed');
|
||||
this._bindRange('opt-cycle', 'cycle', v => { obs._demoData.setCycleDuration(v); });
|
||||
|
||||
// Color pickers
|
||||
document.getElementById('opt-wire-color').value = s.wireColor;
|
||||
document.getElementById('opt-wire-color').addEventListener('input', (e) => {
|
||||
s.wireColor = e.target.value; obs._applyColors(); this.saveSettings();
|
||||
});
|
||||
document.getElementById('opt-joint-color').value = s.jointColor;
|
||||
document.getElementById('opt-joint-color').addEventListener('input', (e) => {
|
||||
s.jointColor = e.target.value; obs._applyColors(); this.saveSettings();
|
||||
});
|
||||
|
||||
// Checkboxes
|
||||
document.getElementById('opt-grid').checked = s.grid;
|
||||
document.getElementById('opt-grid').addEventListener('change', (e) => {
|
||||
s.grid = e.target.checked; obs._grid.visible = e.target.checked; this.saveSettings();
|
||||
});
|
||||
document.getElementById('opt-room').checked = s.room;
|
||||
document.getElementById('opt-room').addEventListener('change', (e) => {
|
||||
s.room = e.target.checked; obs._roomWire.visible = e.target.checked; this.saveSettings();
|
||||
});
|
||||
|
||||
// Scenario select
|
||||
const scenarioSel = document.getElementById('opt-scenario');
|
||||
scenarioSel.value = s.scenario;
|
||||
scenarioSel.addEventListener('change', (e) => {
|
||||
s.scenario = e.target.value;
|
||||
obs._demoData.setScenario(e.target.value);
|
||||
this.saveSettings();
|
||||
});
|
||||
|
||||
// Data source
|
||||
const dsSel = document.getElementById('opt-data-source');
|
||||
dsSel.value = s.dataSource;
|
||||
dsSel.addEventListener('change', (e) => {
|
||||
s.dataSource = e.target.value;
|
||||
document.getElementById('ws-url-row').style.display = e.target.value === 'ws' ? 'flex' : 'none';
|
||||
if (e.target.value === 'ws' && s.wsUrl) obs._connectWS(s.wsUrl);
|
||||
else obs._disconnectWS();
|
||||
this.updateSourceBadge(s.dataSource, obs._ws);
|
||||
this.saveSettings();
|
||||
});
|
||||
document.getElementById('ws-url-row').style.display = s.dataSource === 'ws' ? 'flex' : 'none';
|
||||
|
||||
const wsInput = document.getElementById('opt-ws-url');
|
||||
wsInput.value = s.wsUrl;
|
||||
wsInput.addEventListener('change', (e) => {
|
||||
s.wsUrl = e.target.value;
|
||||
if (s.dataSource === 'ws') obs._connectWS(e.target.value);
|
||||
this.saveSettings();
|
||||
});
|
||||
|
||||
// Buttons
|
||||
document.getElementById('btn-reset-camera').addEventListener('click', () => {
|
||||
obs._camera.position.set(6, 5, 8);
|
||||
obs._controls.target.set(0, 1.2, 0);
|
||||
obs._controls.update();
|
||||
});
|
||||
document.getElementById('btn-export-settings').addEventListener('click', () => {
|
||||
const blob = new Blob([JSON.stringify(s, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'ruview-observatory-settings.json';
|
||||
a.click();
|
||||
});
|
||||
document.getElementById('btn-reset-settings').addEventListener('click', () => {
|
||||
this.applyPreset(DEFAULTS);
|
||||
});
|
||||
|
||||
const presetSel = document.getElementById('opt-preset');
|
||||
presetSel.addEventListener('change', (e) => {
|
||||
const p = PRESETS[e.target.value];
|
||||
if (p) this.applyPreset({ ...DEFAULTS, ...p });
|
||||
});
|
||||
|
||||
obs._grid.visible = s.grid;
|
||||
obs._roomWire.visible = s.room;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Quick-select (top bar scenario dropdown)
|
||||
// ============================================================
|
||||
|
||||
initQuickSelect() {
|
||||
const sel = document.getElementById('scenario-quick-select');
|
||||
if (!sel) return;
|
||||
sel.addEventListener('change', (e) => {
|
||||
this._obs._demoData.setScenario(e.target.value);
|
||||
const settingsSel = document.getElementById('opt-scenario');
|
||||
if (settingsSel) settingsSel.value = e.target.value;
|
||||
this._obs.settings.scenario = e.target.value;
|
||||
this.saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Toggle / save / preset
|
||||
// ============================================================
|
||||
|
||||
toggleSettings() {
|
||||
this._settingsOpen = !this._settingsOpen;
|
||||
document.getElementById('settings-overlay').style.display = this._settingsOpen ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
get settingsOpen() {
|
||||
return this._settingsOpen;
|
||||
}
|
||||
|
||||
saveSettings() {
|
||||
try {
|
||||
localStorage.setItem('ruview-observatory-settings', JSON.stringify(this._obs.settings));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
applyPreset(preset) {
|
||||
const obs = this._obs;
|
||||
Object.assign(obs.settings, preset);
|
||||
this.saveSettings();
|
||||
const rangeMap = {
|
||||
'opt-bloom': 'bloom', 'opt-bloom-radius': 'bloomRadius', 'opt-bloom-thresh': 'bloomThresh',
|
||||
'opt-exposure': 'exposure', 'opt-vignette': 'vignette', 'opt-grain': 'grain', 'opt-chromatic': 'chromatic',
|
||||
'opt-bone-thick': 'boneThick', 'opt-joint-size': 'jointSize', 'opt-glow': 'glow', 'opt-trail': 'trail', 'opt-aura': 'aura',
|
||||
'opt-field': 'field', 'opt-waves': 'waves', 'opt-ambient': 'ambient', 'opt-reflect': 'reflect',
|
||||
'opt-fov': 'fov', 'opt-orbit-speed': 'orbitSpeed', 'opt-cycle': 'cycle',
|
||||
};
|
||||
for (const [id, key] of Object.entries(rangeMap)) {
|
||||
const el = document.getElementById(id);
|
||||
const valEl = document.getElementById(`${id}-val`);
|
||||
if (el) el.value = obs.settings[key];
|
||||
if (valEl) valEl.textContent = obs.settings[key];
|
||||
}
|
||||
const gridEl = document.getElementById('opt-grid');
|
||||
if (gridEl) { gridEl.checked = obs.settings.grid; obs._grid.visible = obs.settings.grid; }
|
||||
const roomEl = document.getElementById('opt-room');
|
||||
if (roomEl) { roomEl.checked = obs.settings.room; obs._roomWire.visible = obs.settings.room; }
|
||||
document.getElementById('opt-wire-color').value = obs.settings.wireColor;
|
||||
document.getElementById('opt-joint-color').value = obs.settings.jointColor;
|
||||
obs._applyPostSettings();
|
||||
obs._renderer.toneMappingExposure = obs.settings.exposure;
|
||||
obs._fieldMat.opacity = obs.settings.field;
|
||||
obs._ambient.intensity = obs.settings.ambient;
|
||||
obs._floorMat.roughness = 1.0 - obs.settings.reflect * 0.7;
|
||||
obs._floorMat.metalness = obs.settings.reflect * 0.5;
|
||||
obs._camera.fov = obs.settings.fov;
|
||||
obs._camera.updateProjectionMatrix();
|
||||
obs._demoData.setCycleDuration(obs.settings.cycle);
|
||||
obs._applyColors();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Source badge
|
||||
// ============================================================
|
||||
|
||||
updateSourceBadge(dataSource, ws) {
|
||||
const dot = document.querySelector('#data-source-badge .dot');
|
||||
const label = document.getElementById('data-source-label');
|
||||
if (dataSource === 'ws' && ws?.readyState === WebSocket.OPEN) {
|
||||
dot.className = 'dot dot--live'; label.textContent = 'LIVE';
|
||||
} else {
|
||||
dot.className = 'dot dot--demo'; label.textContent = 'DEMO';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HUD update (called every frame)
|
||||
// ============================================================
|
||||
|
||||
updateHUD(data, demoData) {
|
||||
if (!data) return;
|
||||
const vs = data.vital_signs || {};
|
||||
const feat = data.features || {};
|
||||
const cls = data.classification || {};
|
||||
|
||||
// Sync scenario dropdown
|
||||
const quickSel = document.getElementById('scenario-quick-select');
|
||||
const cur = demoData._autoMode ? 'auto' : demoData.currentScenario;
|
||||
if (quickSel && quickSel.value !== cur) quickSel.value = cur;
|
||||
const autoIcon = document.getElementById('autoplay-icon');
|
||||
if (autoIcon) autoIcon.className = demoData._autoMode ? '' : 'hidden';
|
||||
|
||||
const targetHr = vs.heart_rate_bpm || 0;
|
||||
const targetBr = vs.breathing_rate_bpm || 0;
|
||||
const targetConf = Math.round((cls.confidence || 0) * 100);
|
||||
|
||||
// Smooth lerp transitions (blend 4% per frame toward target — very stable)
|
||||
const lerpFactor = 0.04;
|
||||
this._lerpHr = targetHr > 0 ? lerp(this._lerpHr, targetHr, lerpFactor) : 0;
|
||||
this._lerpBr = targetBr > 0 ? lerp(this._lerpBr, targetBr, lerpFactor) : 0;
|
||||
this._lerpConf = targetConf > 0 ? lerp(this._lerpConf, targetConf, lerpFactor) : 0;
|
||||
|
||||
const dispHr = this._lerpHr > 1 ? Math.round(this._lerpHr) : '--';
|
||||
const dispBr = this._lerpBr > 1 ? Math.round(this._lerpBr) : '--';
|
||||
const dispConf = this._lerpConf > 1 ? Math.round(this._lerpConf) : '--';
|
||||
|
||||
this._setText('hr-value', dispHr);
|
||||
this._setText('br-value', dispBr);
|
||||
this._setText('conf-value', dispConf);
|
||||
this._setWidth('hr-bar', Math.min(100, this._lerpHr / 120 * 100));
|
||||
this._setWidth('br-bar', Math.min(100, this._lerpBr / 30 * 100));
|
||||
this._setWidth('conf-bar', this._lerpConf);
|
||||
|
||||
// Color-code vital values
|
||||
this._setColor('hr-value', vitalColor('hr', this._lerpHr));
|
||||
this._setColor('br-value', vitalColor('br', this._lerpBr));
|
||||
this._setColor('conf-value', vitalColor('conf', this._lerpConf));
|
||||
|
||||
// Color-code bar fills to match
|
||||
this._setBarColor('hr-bar', vitalColor('hr', this._lerpHr));
|
||||
this._setBarColor('br-bar', vitalColor('br', this._lerpBr));
|
||||
this._setBarColor('conf-bar', vitalColor('conf', this._lerpConf));
|
||||
|
||||
this._setText('rssi-value', `${Math.round(feat.mean_rssi || 0)} dBm`);
|
||||
this._setText('var-value', (feat.variance || 0).toFixed(2));
|
||||
this._setText('motion-value', (feat.motion_band_power || 0).toFixed(3));
|
||||
|
||||
// Mini person-count dots
|
||||
const personCount = data.estimated_persons || 0;
|
||||
this._updatePersonDots(personCount);
|
||||
|
||||
const presEl = document.getElementById('presence-indicator');
|
||||
const presLabel = document.getElementById('presence-label');
|
||||
if (presEl) {
|
||||
const ml = cls.motion_level || 'absent';
|
||||
presEl.className = 'presence-state';
|
||||
if (ml === 'active') { presEl.classList.add('presence--active'); presLabel.textContent = 'ACTIVE'; }
|
||||
else if (cls.presence) { presEl.classList.add('presence--present'); presLabel.textContent = 'PRESENT'; }
|
||||
else { presEl.classList.add('presence--absent'); presLabel.textContent = 'ABSENT'; }
|
||||
}
|
||||
|
||||
const fallEl = document.getElementById('fall-alert');
|
||||
if (fallEl) fallEl.style.display = cls.fall_detected ? 'block' : 'none';
|
||||
|
||||
// Scenario description and edge modules
|
||||
const scenarioKey = demoData._autoMode ? (demoData.currentScenario || 'auto') : (demoData.currentScenario || 'auto');
|
||||
if (scenarioKey !== this._currentScenarioKey) {
|
||||
this._currentScenarioKey = scenarioKey;
|
||||
this._updateScenarioDescription(scenarioKey);
|
||||
this._updateEdgeModules(scenarioKey);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Sparkline
|
||||
// ============================================================
|
||||
|
||||
updateSparkline(data) {
|
||||
const rssi = data?.features?.mean_rssi;
|
||||
if (rssi == null || !this._sparklineCtx) return;
|
||||
this._rssiHistory.push(rssi);
|
||||
if (this._rssiHistory.length > 60) this._rssiHistory.shift();
|
||||
|
||||
const ctx = this._sparklineCtx;
|
||||
const w = ctx.canvas.width, h = ctx.canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (this._rssiHistory.length < 2) return;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#2090ff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowColor = '#2090ff';
|
||||
ctx.shadowBlur = 4;
|
||||
for (let i = 0; i < this._rssiHistory.length; i++) {
|
||||
const x = (i / (this._rssiHistory.length - 1)) * w;
|
||||
const norm = Math.max(0, Math.min(1, (this._rssiHistory[i] + 80) / 60));
|
||||
const y = h - norm * h;
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.lineTo(w, h);
|
||||
ctx.lineTo(0, h);
|
||||
ctx.closePath();
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
||||
grad.addColorStop(0, 'rgba(32,144,255,0.15)');
|
||||
grad.addColorStop(1, 'rgba(32,144,255,0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Private helpers
|
||||
// ============================================================
|
||||
|
||||
_setText(id, val) {
|
||||
const e = document.getElementById(id);
|
||||
if (e) e.textContent = val;
|
||||
}
|
||||
|
||||
_setWidth(id, pct) {
|
||||
const e = document.getElementById(id);
|
||||
if (e) e.style.width = `${pct}%`;
|
||||
}
|
||||
|
||||
_setColor(id, color) {
|
||||
const e = document.getElementById(id);
|
||||
if (e) e.style.color = color;
|
||||
}
|
||||
|
||||
_setBarColor(id, color) {
|
||||
const e = document.getElementById(id);
|
||||
if (e) e.style.background = color;
|
||||
}
|
||||
|
||||
_bindRange(id, key, applyFn) {
|
||||
const el = document.getElementById(id);
|
||||
const valEl = document.getElementById(`${id}-val`);
|
||||
if (!el) return;
|
||||
el.value = this._obs.settings[key];
|
||||
if (valEl) valEl.textContent = this._obs.settings[key];
|
||||
el.addEventListener('input', (e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
this._obs.settings[key] = v;
|
||||
if (valEl) valEl.textContent = v;
|
||||
if (applyFn) applyFn(v);
|
||||
this.saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
_updatePersonDots(count) {
|
||||
const container = document.getElementById('persons-dots');
|
||||
if (!container) {
|
||||
// Fall back to text-only display
|
||||
this._setText('persons-value', count);
|
||||
return;
|
||||
}
|
||||
// Build dot icons: filled for detected persons, dim for empty slots (max 8)
|
||||
const maxDots = 8;
|
||||
const clamped = Math.min(count, maxDots);
|
||||
let html = '';
|
||||
for (let i = 0; i < maxDots; i++) {
|
||||
const active = i < clamped;
|
||||
html += `<span class="person-dot${active ? ' person-dot--active' : ''}"></span>`;
|
||||
}
|
||||
container.innerHTML = html;
|
||||
this._setText('persons-value', count);
|
||||
}
|
||||
|
||||
_updateScenarioDescription(scenarioKey) {
|
||||
const el = document.getElementById('scenario-description');
|
||||
if (!el) return;
|
||||
el.textContent = SCENARIO_DESCRIPTIONS[scenarioKey] || '';
|
||||
}
|
||||
|
||||
_updateEdgeModules(scenarioKey) {
|
||||
const bar = document.getElementById('edge-modules-bar');
|
||||
if (!bar) return;
|
||||
const modules = SCENARIO_EDGE_MODULES[scenarioKey] || [];
|
||||
if (modules.length === 0) {
|
||||
bar.innerHTML = '';
|
||||
bar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
bar.style.display = 'flex';
|
||||
bar.innerHTML = modules.map(m => {
|
||||
const color = MODULE_COLORS[m] || 'var(--text-secondary)';
|
||||
return `<span class="edge-badge" style="--badge-color:${color}">${m}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,715 @@
|
|||
/**
|
||||
* RuView Observatory — Main Scene Orchestrator
|
||||
*
|
||||
* Room-based WiFi sensing visualization with:
|
||||
* - Pool of 4 human wireframe figures (multi-person scenarios)
|
||||
* - 7 pose types (standing, walking, lying, sitting, fallen, exercising, gesturing, crouching)
|
||||
* - Scenario-specific room props (chair, exercise mat, door, rubble wall, screen, desk)
|
||||
* - Dot-matrix mist body mass, particle trails, WiFi waves, signal field
|
||||
* - Reflective floor, settings dialog, and practical data HUD
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
import { DemoDataGenerator } from './demo-data.js';
|
||||
import { NebulaBackground } from './nebula-background.js';
|
||||
import { PostProcessing } from './post-processing.js';
|
||||
import { FigurePool, SKELETON_PAIRS } from './figure-pool.js';
|
||||
import { PoseSystem } from './pose-system.js';
|
||||
import { ScenarioProps } from './scenario-props.js';
|
||||
import { HudController, DEFAULTS, SETTINGS_VERSION, PRESETS, SCENARIO_NAMES } from './hud-controller.js';
|
||||
|
||||
// ---- Palette ----
|
||||
const C = {
|
||||
greenGlow: 0x00d878,
|
||||
greenBright:0x3eff8a,
|
||||
greenDim: 0x0a6b3a,
|
||||
amber: 0xffb020,
|
||||
blueSignal: 0x2090ff,
|
||||
redAlert: 0xff3040,
|
||||
redHeart: 0xff4060,
|
||||
bgDeep: 0x080c14,
|
||||
};
|
||||
|
||||
// SCENARIO_NAMES, DEFAULTS, SETTINGS_VERSION, PRESETS imported from hud-controller.js
|
||||
|
||||
// ---- Main Class ----
|
||||
|
||||
class Observatory {
|
||||
constructor() {
|
||||
this._canvas = document.getElementById('observatory-canvas');
|
||||
this.settings = { ...DEFAULTS };
|
||||
|
||||
// Load saved settings
|
||||
try {
|
||||
const ver = localStorage.getItem('ruview-settings-version');
|
||||
if (ver === SETTINGS_VERSION) {
|
||||
const saved = localStorage.getItem('ruview-observatory-settings');
|
||||
if (saved) Object.assign(this.settings, JSON.parse(saved));
|
||||
} else {
|
||||
localStorage.removeItem('ruview-observatory-settings');
|
||||
localStorage.setItem('ruview-settings-version', SETTINGS_VERSION);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Renderer
|
||||
this._renderer = new THREE.WebGLRenderer({
|
||||
canvas: this._canvas,
|
||||
antialias: true,
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this._renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
this._renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
this._renderer.toneMappingExposure = this.settings.exposure;
|
||||
this._renderer.shadowMap.enabled = true;
|
||||
this._renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
|
||||
// Scene
|
||||
this._scene = new THREE.Scene();
|
||||
this._scene.background = new THREE.Color(C.bgDeep);
|
||||
this._scene.fog = new THREE.FogExp2(C.bgDeep, 0.005);
|
||||
|
||||
// Camera
|
||||
this._camera = new THREE.PerspectiveCamera(
|
||||
this.settings.fov, window.innerWidth / window.innerHeight, 0.1, 300
|
||||
);
|
||||
this._camera.position.set(6, 5, 8);
|
||||
this._camera.lookAt(0, 1.2, 0);
|
||||
|
||||
// Controls
|
||||
this._controls = new OrbitControls(this._camera, this._canvas);
|
||||
this._controls.enableDamping = true;
|
||||
this._controls.dampingFactor = 0.08;
|
||||
this._controls.minDistance = 2;
|
||||
this._controls.maxDistance = 25;
|
||||
this._controls.maxPolarAngle = Math.PI * 0.88;
|
||||
this._controls.target.set(0, 1.2, 0);
|
||||
this._controls.update();
|
||||
|
||||
this._clock = new THREE.Clock();
|
||||
|
||||
// Data
|
||||
this._demoData = new DemoDataGenerator();
|
||||
this._demoData.setCycleDuration(this.settings.cycle || 30);
|
||||
if (this.settings.scenario && this.settings.scenario !== 'auto') {
|
||||
this._demoData.setScenario(this.settings.scenario);
|
||||
}
|
||||
this._currentData = null;
|
||||
this._currentScenario = null;
|
||||
|
||||
// Build scene
|
||||
this._setupLighting();
|
||||
this._nebula = new NebulaBackground(this._scene);
|
||||
this._buildRoom();
|
||||
this._buildRouter();
|
||||
this._poseSystem = new PoseSystem();
|
||||
this._figurePool = new FigurePool(this._scene, this.settings, this._poseSystem);
|
||||
this._scenarioProps = new ScenarioProps(this._scene);
|
||||
this._buildDotMatrixMist();
|
||||
this._buildParticleTrail();
|
||||
this._buildWifiWaves();
|
||||
this._buildSignalField();
|
||||
|
||||
// Post-processing
|
||||
this._postProcessing = new PostProcessing(this._renderer, this._scene, this._camera);
|
||||
this._applyPostSettings();
|
||||
|
||||
// HUD controller (settings dialog, sparkline, vital displays)
|
||||
this._hud = new HudController(this);
|
||||
|
||||
// State
|
||||
this._autopilot = false;
|
||||
this._autoAngle = 0;
|
||||
this._fpsFrames = 0;
|
||||
this._fpsTime = 0;
|
||||
this._fpsValue = 60;
|
||||
this._showFps = false;
|
||||
this._qualityLevel = 2;
|
||||
|
||||
// WebSocket for live data — always try auto-detect on startup
|
||||
this._ws = null;
|
||||
this._liveData = null;
|
||||
this._autoDetectLive();
|
||||
|
||||
// Input
|
||||
this._initKeyboard();
|
||||
this._hud.initSettings();
|
||||
this._hud.initQuickSelect();
|
||||
window.addEventListener('resize', () => this._onResize());
|
||||
|
||||
// Start
|
||||
this._animate();
|
||||
}
|
||||
|
||||
// ---- Lighting ----
|
||||
|
||||
_setupLighting() {
|
||||
this._ambient = new THREE.AmbientLight(0x446688, this.settings.ambient * 3.0);
|
||||
this._scene.add(this._ambient);
|
||||
|
||||
const hemi = new THREE.HemisphereLight(0x6688bb, 0x203040, 1.2);
|
||||
this._scene.add(hemi);
|
||||
|
||||
const key = new THREE.DirectionalLight(0xffeedd, 1.2);
|
||||
key.position.set(4, 8, 3);
|
||||
key.castShadow = true;
|
||||
key.shadow.mapSize.set(1024, 1024);
|
||||
key.shadow.camera.near = 0.5;
|
||||
key.shadow.camera.far = 20;
|
||||
key.shadow.camera.left = -8;
|
||||
key.shadow.camera.right = 8;
|
||||
key.shadow.camera.top = 8;
|
||||
key.shadow.camera.bottom = -8;
|
||||
this._scene.add(key);
|
||||
|
||||
// Fill light from opposite side
|
||||
const fill = new THREE.DirectionalLight(0x8899bb, 0.7);
|
||||
fill.position.set(-4, 5, -2);
|
||||
this._scene.add(fill);
|
||||
|
||||
// Rim light from above/behind for edge definition
|
||||
const rim = new THREE.DirectionalLight(0x6699cc, 0.5);
|
||||
rim.position.set(0, 6, -5);
|
||||
this._scene.add(rim);
|
||||
|
||||
// Overhead room light — general illumination
|
||||
const overhead = new THREE.PointLight(0x8899aa, 1.0, 20, 1.0);
|
||||
overhead.position.set(0, 3.8, 0);
|
||||
this._scene.add(overhead);
|
||||
}
|
||||
|
||||
// ---- Room ----
|
||||
|
||||
_buildRoom() {
|
||||
this._grid = new THREE.GridHelper(12, 24, 0x1a4830, 0x0c2818);
|
||||
this._grid.material.opacity = 0.5;
|
||||
this._grid.material.transparent = true;
|
||||
this._scene.add(this._grid);
|
||||
|
||||
const boxGeo = new THREE.BoxGeometry(12, 4, 10);
|
||||
const edges = new THREE.EdgesGeometry(boxGeo);
|
||||
this._roomWire = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({
|
||||
color: C.greenDim, opacity: 0.3, transparent: true,
|
||||
}));
|
||||
this._roomWire.position.y = 2;
|
||||
this._scene.add(this._roomWire);
|
||||
|
||||
// Reflective floor
|
||||
const floorGeo = new THREE.PlaneGeometry(12, 10);
|
||||
this._floorMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x101810,
|
||||
roughness: 1.0 - this.settings.reflect * 0.7,
|
||||
metalness: this.settings.reflect * 0.5,
|
||||
emissive: 0x020404,
|
||||
emissiveIntensity: 0.08,
|
||||
});
|
||||
const floor = new THREE.Mesh(floorGeo, this._floorMat);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.receiveShadow = true;
|
||||
this._scene.add(floor);
|
||||
|
||||
// Table under router
|
||||
const tableGeo = new THREE.BoxGeometry(0.8, 0.6, 0.5);
|
||||
const tableMat = new THREE.MeshStandardMaterial({ color: 0x6b5840, roughness: 0.55, emissive: 0x1a1408, emissiveIntensity: 0.25 });
|
||||
const table = new THREE.Mesh(tableGeo, tableMat);
|
||||
table.position.set(-4, 0.3, -3);
|
||||
table.castShadow = true;
|
||||
this._scene.add(table);
|
||||
}
|
||||
|
||||
// ---- Router ----
|
||||
|
||||
_buildRouter() {
|
||||
this._routerGroup = new THREE.Group();
|
||||
this._routerGroup.position.set(-4, 0.92, -3);
|
||||
|
||||
const bodyGeo = new THREE.BoxGeometry(0.6, 0.12, 0.35);
|
||||
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x505060, roughness: 0.2, metalness: 0.7, emissive: 0x101018, emissiveIntensity: 0.2 });
|
||||
this._routerGroup.add(new THREE.Mesh(bodyGeo, bodyMat));
|
||||
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
const antGeo = new THREE.CylinderGeometry(0.015, 0.015, 0.35);
|
||||
const antMat = new THREE.MeshStandardMaterial({ color: 0x606068, roughness: 0.3, metalness: 0.6, emissive: 0x101018, emissiveIntensity: 0.15 });
|
||||
const ant = new THREE.Mesh(antGeo, antMat);
|
||||
ant.position.set(i * 0.2, 0.24, 0);
|
||||
ant.rotation.z = i * 0.15;
|
||||
this._routerGroup.add(ant);
|
||||
}
|
||||
|
||||
const ledGeo = new THREE.SphereGeometry(0.025);
|
||||
this._routerLed = new THREE.Mesh(ledGeo, new THREE.MeshBasicMaterial({ color: C.greenGlow }));
|
||||
this._routerLed.position.set(0.22, 0.07, 0.18);
|
||||
this._routerGroup.add(this._routerLed);
|
||||
|
||||
this._routerLight = new THREE.PointLight(C.blueSignal, 1.2, 8);
|
||||
this._routerLight.position.set(0, 0.3, 0);
|
||||
this._routerGroup.add(this._routerLight);
|
||||
|
||||
this._scene.add(this._routerGroup);
|
||||
}
|
||||
|
||||
// ---- WiFi Waves ----
|
||||
|
||||
_buildWifiWaves() {
|
||||
this._wifiWaves = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const radius = 0.8 + i * 1.0;
|
||||
const geo = new THREE.SphereGeometry(radius, 24, 16, 0, Math.PI * 2, 0, Math.PI * 0.6);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: C.blueSignal,
|
||||
transparent: true, opacity: 0,
|
||||
side: THREE.DoubleSide,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false, wireframe: true,
|
||||
});
|
||||
const shell = new THREE.Mesh(geo, mat);
|
||||
shell.position.copy(this._routerGroup.position);
|
||||
shell.position.y += 0.5;
|
||||
this._scene.add(shell);
|
||||
this._wifiWaves.push({ mesh: shell, mat, phase: i * 0.7 });
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// DOT MATRIX MIST
|
||||
// ========================================
|
||||
|
||||
_buildDotMatrixMist() {
|
||||
const COUNT = 800;
|
||||
const positions = new Float32Array(COUNT * 3);
|
||||
const alphas = new Float32Array(COUNT);
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const r = Math.random() * 0.5;
|
||||
positions[i * 3] = Math.cos(angle) * r;
|
||||
positions[i * 3 + 1] = Math.random() * 1.8;
|
||||
positions[i * 3 + 2] = Math.sin(angle) * r;
|
||||
alphas[i] = 0;
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('alpha', new THREE.BufferAttribute(alphas, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: `
|
||||
attribute float alpha;
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
vAlpha = alpha;
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_PointSize = 3.0 * (200.0 / -mv.z);
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
float d = length(gl_PointCoord - 0.5);
|
||||
if (d > 0.5) discard;
|
||||
float edge = smoothstep(0.5, 0.2, d);
|
||||
gl_FragColor = vec4(uColor, edge * vAlpha);
|
||||
}
|
||||
`,
|
||||
uniforms: { uColor: { value: new THREE.Color(this.settings.wireColor) } },
|
||||
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||
});
|
||||
this._mistPoints = new THREE.Points(geo, mat);
|
||||
this._scene.add(this._mistPoints);
|
||||
this._mistCount = COUNT;
|
||||
}
|
||||
|
||||
// ---- Particle Trail ----
|
||||
|
||||
_buildParticleTrail() {
|
||||
const COUNT = 200;
|
||||
const positions = new Float32Array(COUNT * 3);
|
||||
const ages = new Float32Array(COUNT);
|
||||
for (let i = 0; i < COUNT; i++) ages[i] = 1;
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('age', new THREE.BufferAttribute(ages, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: `
|
||||
attribute float age;
|
||||
varying float vAge;
|
||||
void main() {
|
||||
vAge = age;
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_PointSize = max(1.0, (1.0 - age) * 5.0 * (150.0 / -mv.z));
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
varying float vAge;
|
||||
void main() {
|
||||
float d = length(gl_PointCoord - 0.5);
|
||||
if (d > 0.5) discard;
|
||||
float alpha = (1.0 - vAge) * 0.6 * smoothstep(0.5, 0.1, d);
|
||||
gl_FragColor = vec4(uColor, alpha);
|
||||
}
|
||||
`,
|
||||
uniforms: { uColor: { value: new THREE.Color(C.greenGlow) } },
|
||||
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||
});
|
||||
this._trail = new THREE.Points(geo, mat);
|
||||
this._scene.add(this._trail);
|
||||
this._trailHead = 0;
|
||||
this._trailCount = COUNT;
|
||||
this._trailTimer = 0;
|
||||
}
|
||||
|
||||
// ---- Signal Field ----
|
||||
|
||||
_buildSignalField() {
|
||||
const gridSize = 20;
|
||||
const count = gridSize * gridSize;
|
||||
const positions = new Float32Array(count * 3);
|
||||
this._fieldColors = new Float32Array(count * 3);
|
||||
this._fieldSizes = new Float32Array(count);
|
||||
for (let iz = 0; iz < gridSize; iz++) {
|
||||
for (let ix = 0; ix < gridSize; ix++) {
|
||||
const idx = iz * gridSize + ix;
|
||||
positions[idx * 3] = (ix - gridSize / 2) * 0.6;
|
||||
positions[idx * 3 + 1] = 0.02;
|
||||
positions[idx * 3 + 2] = (iz - gridSize / 2) * 0.5;
|
||||
this._fieldSizes[idx] = 8;
|
||||
}
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(this._fieldColors, 3));
|
||||
geo.setAttribute('size', new THREE.BufferAttribute(this._fieldSizes, 1));
|
||||
this._fieldMat = new THREE.PointsMaterial({
|
||||
size: 0.35, vertexColors: true, transparent: true,
|
||||
opacity: this.settings.field, blending: THREE.AdditiveBlending,
|
||||
depthWrite: false, sizeAttenuation: true,
|
||||
});
|
||||
this._fieldPoints = new THREE.Points(geo, this._fieldMat);
|
||||
this._scene.add(this._fieldPoints);
|
||||
}
|
||||
|
||||
// ---- Keyboard ----
|
||||
|
||||
_initKeyboard() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (this._hud.settingsOpen) return;
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'a':
|
||||
this._autopilot = !this._autopilot;
|
||||
this._controls.enabled = !this._autopilot;
|
||||
break;
|
||||
case 'd': this._demoData.cycleScenario(); break;
|
||||
case 'f':
|
||||
this._showFps = !this._showFps;
|
||||
document.getElementById('fps-counter').style.display = this._showFps ? 'block' : 'none';
|
||||
break;
|
||||
case 's': this._hud.toggleSettings(); break;
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
this._demoData.paused = !this._demoData.paused;
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Settings / HUD methods delegated to HudController ----
|
||||
|
||||
_applyPostSettings() {
|
||||
const pp = this._postProcessing;
|
||||
pp._bloomPass.strength = this.settings.bloom;
|
||||
pp._bloomPass.radius = this.settings.bloomRadius;
|
||||
pp._bloomPass.threshold = this.settings.bloomThresh;
|
||||
pp._vignettePass.uniforms.uVignetteStrength.value = this.settings.vignette;
|
||||
pp._vignettePass.uniforms.uGrainStrength.value = this.settings.grain;
|
||||
pp._vignettePass.uniforms.uChromaticStrength.value = this.settings.chromatic;
|
||||
}
|
||||
|
||||
_applyColors() {
|
||||
const wc = new THREE.Color(this.settings.wireColor);
|
||||
const jc = new THREE.Color(this.settings.jointColor);
|
||||
this._figurePool.applyColors(wc, jc);
|
||||
this._mistPoints.material.uniforms.uColor.value.copy(wc);
|
||||
}
|
||||
|
||||
// ---- WebSocket live data ----
|
||||
|
||||
_autoDetectLive() {
|
||||
// Probe sensing server health on same origin, then common ports
|
||||
const host = window.location.hostname || 'localhost';
|
||||
const candidates = [
|
||||
window.location.origin, // same origin (e.g. :3000)
|
||||
`http://${host}:8765`, // default WS port
|
||||
`http://${host}:3000`, // default HTTP port
|
||||
];
|
||||
// Deduplicate
|
||||
const unique = [...new Set(candidates)];
|
||||
|
||||
const tryNext = (i) => {
|
||||
if (i >= unique.length) {
|
||||
console.log('[Observatory] No sensing server detected, using demo mode');
|
||||
return;
|
||||
}
|
||||
const base = unique[i];
|
||||
fetch(`${base}/health`, { signal: AbortSignal.timeout(1500) })
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(data => {
|
||||
if (data && data.status === 'ok') {
|
||||
const wsProto = base.startsWith('https') ? 'wss:' : 'ws:';
|
||||
const urlObj = new URL(base);
|
||||
const wsUrl = `${wsProto}//${urlObj.host}/ws/sensing`;
|
||||
console.log('[Observatory] Sensing server detected at', base, '→', wsUrl);
|
||||
this.settings.dataSource = 'ws';
|
||||
this.settings.wsUrl = wsUrl;
|
||||
this._connectWS(wsUrl);
|
||||
} else {
|
||||
tryNext(i + 1);
|
||||
}
|
||||
})
|
||||
.catch(() => tryNext(i + 1));
|
||||
};
|
||||
tryNext(0);
|
||||
}
|
||||
|
||||
_connectWS(url) {
|
||||
this._disconnectWS();
|
||||
try {
|
||||
this._ws = new WebSocket(url);
|
||||
this._ws.onopen = () => {
|
||||
console.log('[Observatory] WebSocket connected');
|
||||
this._hud.updateSourceBadge('ws', this._ws);
|
||||
};
|
||||
this._ws.onmessage = (evt) => { try { this._liveData = JSON.parse(evt.data); } catch {} };
|
||||
this._ws.onclose = () => {
|
||||
console.log('[Observatory] WebSocket closed, falling back to demo');
|
||||
this._ws = null;
|
||||
this.settings.dataSource = 'demo';
|
||||
this._hud.updateSourceBadge('demo', null);
|
||||
};
|
||||
this._ws.onerror = () => {};
|
||||
} catch {}
|
||||
}
|
||||
|
||||
_disconnectWS() {
|
||||
if (this._ws) { this._ws.close(); this._ws = null; }
|
||||
this._liveData = null;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ANIMATION LOOP
|
||||
// ========================================
|
||||
|
||||
_animate() {
|
||||
requestAnimationFrame(() => this._animate());
|
||||
const dt = Math.min(this._clock.getDelta(), 0.1);
|
||||
const elapsed = this._clock.getElapsedTime();
|
||||
|
||||
// Data source
|
||||
if (this.settings.dataSource === 'ws' && this._liveData) {
|
||||
this._currentData = this._liveData;
|
||||
} else {
|
||||
this._currentData = this._demoData.update(dt);
|
||||
}
|
||||
const data = this._currentData;
|
||||
|
||||
// Updates
|
||||
this._nebula.update(dt, elapsed);
|
||||
this._figurePool.update(data, elapsed);
|
||||
this._scenarioProps.update(data, this._demoData.currentScenario);
|
||||
this._updateDotMatrixMist(data, elapsed);
|
||||
this._updateParticleTrail(data, dt, elapsed);
|
||||
this._updateWifiWaves(elapsed);
|
||||
this._updateSignalField(data);
|
||||
this._hud.updateHUD(data, this._demoData);
|
||||
this._hud.updateSparkline(data);
|
||||
|
||||
// Router LED
|
||||
this._routerLed.material.opacity = 0.5 + 0.5 * Math.sin(elapsed * 8);
|
||||
this._routerLight.intensity = 0.3 + 0.2 * Math.sin(elapsed * 3);
|
||||
|
||||
// Autopilot orbit
|
||||
if (this._autopilot) {
|
||||
this._autoAngle += dt * this.settings.orbitSpeed;
|
||||
const r = 10;
|
||||
this._camera.position.set(
|
||||
Math.sin(this._autoAngle) * r,
|
||||
4.5 + Math.sin(this._autoAngle * 0.5),
|
||||
Math.cos(this._autoAngle) * r
|
||||
);
|
||||
this._controls.target.set(0, 1.2, 0);
|
||||
this._controls.update();
|
||||
}
|
||||
this._controls.update();
|
||||
this._postProcessing.update(elapsed);
|
||||
this._postProcessing.render();
|
||||
this._updateFPS(dt);
|
||||
}
|
||||
|
||||
|
||||
// ========================================
|
||||
// MIST & TRAIL
|
||||
// ========================================
|
||||
|
||||
_updateDotMatrixMist(data, elapsed) {
|
||||
const persons = data?.persons || [];
|
||||
const isPresent = data?.classification?.presence || false;
|
||||
const pos = this._mistPoints.geometry.attributes.position;
|
||||
const alpha = this._mistPoints.geometry.attributes.alpha;
|
||||
|
||||
if (!isPresent || persons.length === 0) {
|
||||
for (let i = 0; i < this._mistCount; i++) {
|
||||
alpha.array[i] = Math.max(0, alpha.array[i] - 0.02);
|
||||
}
|
||||
alpha.needsUpdate = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Follow primary person
|
||||
const pp = persons[0].position || [0, 0, 0];
|
||||
const px = pp[0] || 0, pz = pp[2] || 0;
|
||||
const ms = persons[0].motion_score || 0;
|
||||
const pose = persons[0].pose || 'standing';
|
||||
const isLying = pose === 'lying' || pose === 'fallen';
|
||||
const bodyH = isLying ? 0.4 : 1.7;
|
||||
const bodyBaseY = isLying ? (pp[1] || 0) + 0.05 : 0.05;
|
||||
const spread = ms > 50 ? 0.6 : 0.4;
|
||||
|
||||
for (let i = 0; i < this._mistCount; i++) {
|
||||
const drift = Math.sin(elapsed * 0.5 + i * 0.1) * 0.003;
|
||||
const angle = (i / this._mistCount) * Math.PI * 2 + elapsed * 0.1;
|
||||
const layerT = (i % 20) / 20;
|
||||
const layerY = bodyBaseY + layerT * bodyH;
|
||||
|
||||
let bodyWidth;
|
||||
if (isLying) {
|
||||
bodyWidth = 0.25;
|
||||
} else {
|
||||
bodyWidth = layerT > 0.75 ? 0.15 : (layerT > 0.45 ? 0.25 : 0.18);
|
||||
}
|
||||
const r = bodyWidth * (0.5 + 0.5 * Math.sin(i * 1.7 + elapsed * 0.3)) * spread;
|
||||
|
||||
const tx = px + Math.cos(angle + i * 0.3) * r + drift;
|
||||
const tz = pz + Math.sin(angle + i * 0.5) * r * 0.6;
|
||||
|
||||
pos.array[i * 3] += (tx - pos.array[i * 3]) * 0.05;
|
||||
pos.array[i * 3 + 1] += (layerY - pos.array[i * 3 + 1]) * 0.05;
|
||||
pos.array[i * 3 + 2] += (tz - pos.array[i * 3 + 2]) * 0.05;
|
||||
|
||||
const targetAlpha = 0.15 + Math.sin(elapsed * 2 + i * 0.5) * 0.08;
|
||||
alpha.array[i] += (targetAlpha - alpha.array[i]) * 0.08;
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
alpha.needsUpdate = true;
|
||||
}
|
||||
|
||||
_updateParticleTrail(data, dt, elapsed) {
|
||||
if (this.settings.trail <= 0) return;
|
||||
const persons = data?.persons || [];
|
||||
const isPresent = data?.classification?.presence || false;
|
||||
const pos = this._trail.geometry.attributes.position;
|
||||
const ages = this._trail.geometry.attributes.age;
|
||||
|
||||
for (let i = 0; i < this._trailCount; i++) {
|
||||
ages.array[i] = Math.min(1, ages.array[i] + dt * 0.8);
|
||||
}
|
||||
|
||||
// Emit from all active persons
|
||||
if (isPresent && persons.length > 0) {
|
||||
this._trailTimer += dt;
|
||||
const ms = persons[0].motion_score || 0;
|
||||
const emitRate = ms > 50 ? 0.02 : 0.08;
|
||||
|
||||
if (this._trailTimer >= emitRate) {
|
||||
this._trailTimer = 0;
|
||||
for (const p of persons) {
|
||||
const pp = p.position || [0, 0, 0];
|
||||
const idx = this._trailHead;
|
||||
pos.array[idx * 3] = (pp[0] || 0) + (Math.random() - 0.5) * 0.15;
|
||||
pos.array[idx * 3 + 1] = Math.random() * 1.5 + 0.1;
|
||||
pos.array[idx * 3 + 2] = (pp[2] || 0) + (Math.random() - 0.5) * 0.15;
|
||||
ages.array[idx] = 0;
|
||||
this._trailHead = (this._trailHead + 1) % this._trailCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
ages.needsUpdate = true;
|
||||
}
|
||||
|
||||
// ---- WiFi Waves ----
|
||||
|
||||
_updateWifiWaves(elapsed) {
|
||||
for (const w of this._wifiWaves) {
|
||||
const t = (elapsed * 0.8 + w.phase) % 4.5;
|
||||
const life = t / 4.5;
|
||||
w.mat.opacity = Math.max(0, this.settings.waves * 0.25 * (1 - life));
|
||||
const scale = 1 + life * 0.6;
|
||||
w.mesh.scale.set(scale, scale, scale);
|
||||
w.mesh.rotation.y = elapsed * 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Signal Field ----
|
||||
|
||||
_updateSignalField(data) {
|
||||
const field = data?.signal_field?.values;
|
||||
if (!field) return;
|
||||
const count = Math.min(field.length, 400);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const v = field[i] || 0;
|
||||
let r, g, b;
|
||||
if (v < 0.3) { r = 0; g = v * 1.5; b = v * 0.3; }
|
||||
else if (v < 0.6) {
|
||||
const t = (v - 0.3) / 0.3;
|
||||
r = t * 0.3; g = 0.45 + t * 0.4; b = 0.09 - t * 0.05;
|
||||
} else {
|
||||
const t = (v - 0.6) / 0.4;
|
||||
r = 0.3 + t * 0.7; g = 0.85 - t * 0.2; b = 0.04;
|
||||
}
|
||||
this._fieldColors[i * 3] = r;
|
||||
this._fieldColors[i * 3 + 1] = g;
|
||||
this._fieldColors[i * 3 + 2] = b;
|
||||
this._fieldSizes[i] = 5 + v * 15;
|
||||
}
|
||||
this._fieldPoints.geometry.attributes.color.needsUpdate = true;
|
||||
this._fieldPoints.geometry.attributes.size.needsUpdate = true;
|
||||
}
|
||||
|
||||
// ---- FPS ----
|
||||
|
||||
_updateFPS(dt) {
|
||||
this._fpsFrames++;
|
||||
this._fpsTime += dt;
|
||||
if (this._fpsTime >= 1) {
|
||||
this._fpsValue = Math.round(this._fpsFrames / this._fpsTime);
|
||||
this._fpsFrames = 0;
|
||||
this._fpsTime = 0;
|
||||
if (this._showFps) {
|
||||
document.getElementById('fps-counter').textContent = `${this._fpsValue} FPS`;
|
||||
}
|
||||
this._adaptQuality();
|
||||
}
|
||||
}
|
||||
|
||||
_adaptQuality() {
|
||||
let nl = this._qualityLevel;
|
||||
if (this._fpsValue < 25 && nl > 0) nl--;
|
||||
else if (this._fpsValue > 55 && nl < 2) nl++;
|
||||
if (nl !== this._qualityLevel) {
|
||||
this._qualityLevel = nl;
|
||||
this._nebula.setQuality(nl);
|
||||
this._postProcessing.setQuality(nl);
|
||||
}
|
||||
}
|
||||
|
||||
_onResize() {
|
||||
const w = window.innerWidth, h = window.innerHeight;
|
||||
this._camera.aspect = w / h;
|
||||
this._camera.updateProjectionMatrix();
|
||||
this._renderer.setSize(w, h);
|
||||
this._postProcessing.resize(w, h);
|
||||
}
|
||||
}
|
||||
|
||||
new Observatory();
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Room Atmosphere Background — Warm dark gradient with subtle particles
|
||||
* Matches RuView Foundation aesthetic: deep blue-black with warm undertones
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const BG_VERTEX = `
|
||||
varying vec3 vWorldPos;
|
||||
void main() {
|
||||
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const BG_FRAGMENT = `
|
||||
uniform float uTime;
|
||||
uniform float uOctaves;
|
||||
varying vec3 vWorldPos;
|
||||
|
||||
vec3 hash33(vec3 p) {
|
||||
p = fract(p * vec3(443.8975, 397.2973, 491.1871));
|
||||
p += dot(p, p.yxz + 19.19);
|
||||
return fract(vec3(p.x * p.y, p.y * p.z, p.z * p.x));
|
||||
}
|
||||
|
||||
float noise3d(vec3 p) {
|
||||
vec3 i = floor(p);
|
||||
vec3 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
float n = mix(
|
||||
mix(mix(dot(hash33(i), f), dot(hash33(i + vec3(1,0,0)), f - vec3(1,0,0)), f.x),
|
||||
mix(dot(hash33(i + vec3(0,1,0)), f - vec3(0,1,0)), dot(hash33(i + vec3(1,1,0)), f - vec3(1,1,0)), f.x), f.y),
|
||||
mix(mix(dot(hash33(i + vec3(0,0,1)), f - vec3(0,0,1)), dot(hash33(i + vec3(1,0,1)), f - vec3(1,0,1)), f.x),
|
||||
mix(dot(hash33(i + vec3(0,1,1)), f - vec3(0,1,1)), dot(hash33(i + vec3(1,1,1)), f - vec3(1,1,1)), f.x), f.y),
|
||||
f.z);
|
||||
return n * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
float fbm(vec3 p, float octaves) {
|
||||
float v = 0.0, a = 0.5;
|
||||
for (float i = 0.0; i < 5.0; i++) {
|
||||
if (i >= octaves) break;
|
||||
v += a * noise3d(p);
|
||||
p *= 2.0;
|
||||
a *= 0.5;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 dir = normalize(vWorldPos);
|
||||
|
||||
// Warm dark atmosphere with subtle color variation
|
||||
float n1 = fbm(dir * 2.5 + uTime * 0.008, uOctaves);
|
||||
float n2 = fbm(dir * 4.0 - uTime * 0.005, max(1.0, uOctaves - 1.0));
|
||||
|
||||
// Foundation palette: deep blue-black with warm undertones
|
||||
vec3 deepBlack = vec3(0.03, 0.04, 0.06);
|
||||
vec3 warmNavy = vec3(0.04, 0.05, 0.10);
|
||||
vec3 greenTint = vec3(0.01, 0.06, 0.04);
|
||||
|
||||
vec3 bg = mix(deepBlack, warmNavy, n1 * 0.5);
|
||||
bg = mix(bg, greenTint, n2 * 0.15);
|
||||
|
||||
// Subtle top-down gradient (lighter ceiling)
|
||||
float upFactor = max(0.0, dir.y) * 0.08;
|
||||
bg += vec3(0.02, 0.03, 0.05) * upFactor;
|
||||
|
||||
// Very subtle dim stars (distant)
|
||||
vec3 c = floor(dir * 200.0);
|
||||
vec3 h = hash33(c);
|
||||
float star = step(0.998, h.x) * h.y * 0.15;
|
||||
star *= 0.7 + 0.3 * sin(uTime * 1.5 + h.z * 80.0);
|
||||
bg += vec3(0.6, 0.7, 0.8) * star;
|
||||
|
||||
gl_FragColor = vec4(bg, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export class NebulaBackground {
|
||||
constructor(scene) {
|
||||
this._octaves = 4;
|
||||
|
||||
this.uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uOctaves: { value: this._octaves },
|
||||
};
|
||||
|
||||
const geo = new THREE.SphereGeometry(150, 32, 32);
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: BG_VERTEX,
|
||||
fragmentShader: BG_FRAGMENT,
|
||||
uniforms: this.uniforms,
|
||||
side: THREE.BackSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
this.mesh = new THREE.Mesh(geo, mat);
|
||||
scene.add(this.mesh);
|
||||
}
|
||||
|
||||
update(dt, elapsed) {
|
||||
this.uniforms.uTime.value = elapsed;
|
||||
}
|
||||
|
||||
setQuality(level) {
|
||||
this._octaves = [2, 3, 4][level] || 4;
|
||||
this.uniforms.uOctaves.value = this._octaves;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.mesh.geometry.dispose();
|
||||
this.mesh.material.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Module D — "The Phase Constellation"
|
||||
* I/Q star map with constellation lines and rotating temporal view
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const NUM_SUBCARRIERS = 64;
|
||||
|
||||
export class PhaseConstellation {
|
||||
constructor(scene, panelGroup) {
|
||||
this.group = new THREE.Group();
|
||||
if (panelGroup) panelGroup.add(this.group);
|
||||
else scene.add(this.group);
|
||||
|
||||
// Star points (current frame)
|
||||
const starGeo = new THREE.BufferGeometry();
|
||||
this._positions = new Float32Array(NUM_SUBCARRIERS * 3);
|
||||
this._colors = new Float32Array(NUM_SUBCARRIERS * 3);
|
||||
this._sizes = new Float32Array(NUM_SUBCARRIERS);
|
||||
|
||||
starGeo.setAttribute('position', new THREE.BufferAttribute(this._positions, 3));
|
||||
starGeo.setAttribute('color', new THREE.BufferAttribute(this._colors, 3));
|
||||
starGeo.setAttribute('size', new THREE.BufferAttribute(this._sizes, 1));
|
||||
|
||||
const starMat = new THREE.PointsMaterial({
|
||||
size: 0.12,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
this._stars = new THREE.Points(starGeo, starMat);
|
||||
this.group.add(this._stars);
|
||||
|
||||
// Ghost layer (previous frame)
|
||||
const ghostGeo = new THREE.BufferGeometry();
|
||||
this._ghostPos = new Float32Array(NUM_SUBCARRIERS * 3);
|
||||
ghostGeo.setAttribute('position', new THREE.BufferAttribute(this._ghostPos, 3));
|
||||
|
||||
const ghostMat = new THREE.PointsMaterial({
|
||||
color: 0x00d4ff,
|
||||
size: 0.06,
|
||||
transparent: true,
|
||||
opacity: 0.2,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
this._ghosts = new THREE.Points(ghostGeo, ghostMat);
|
||||
this.group.add(this._ghosts);
|
||||
|
||||
// Constellation lines (connecting adjacent subcarriers)
|
||||
const lineGeo = new THREE.BufferGeometry();
|
||||
this._linePos = new Float32Array(NUM_SUBCARRIERS * 2 * 3); // pairs
|
||||
lineGeo.setAttribute('position', new THREE.BufferAttribute(this._linePos, 3));
|
||||
|
||||
const lineMat = new THREE.LineBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.15,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
this._lines = new THREE.LineSegments(lineGeo, lineMat);
|
||||
this.group.add(this._lines);
|
||||
|
||||
// Axes
|
||||
this._addAxes();
|
||||
|
||||
this._prevIQ = null;
|
||||
}
|
||||
|
||||
_addAxes() {
|
||||
const axesMat = new THREE.LineBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.1,
|
||||
});
|
||||
|
||||
// I axis
|
||||
const iGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-2.5, 0, 0),
|
||||
new THREE.Vector3(2.5, 0, 0),
|
||||
]);
|
||||
this.group.add(new THREE.Line(iGeo, axesMat));
|
||||
|
||||
// Q axis
|
||||
const qGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(0, -2.5, 0),
|
||||
new THREE.Vector3(0, 2.5, 0),
|
||||
]);
|
||||
this.group.add(new THREE.Line(qGeo, axesMat));
|
||||
}
|
||||
|
||||
update(dt, elapsed, data) {
|
||||
const iq = data?._observatory?.subcarrier_iq;
|
||||
const variance = data?._observatory?.per_subcarrier_variance;
|
||||
const amplitude = data?.nodes?.[0]?.amplitude;
|
||||
|
||||
// Slow Y rotation for temporal evolution
|
||||
this.group.rotation.y = elapsed * 0.05;
|
||||
|
||||
if (!iq || iq.length < NUM_SUBCARRIERS) return;
|
||||
|
||||
// Copy current to ghost
|
||||
this._ghostPos.set(this._positions);
|
||||
this._ghosts.geometry.attributes.position.needsUpdate = true;
|
||||
|
||||
// Update current positions from I/Q
|
||||
for (let s = 0; s < NUM_SUBCARRIERS; s++) {
|
||||
const i3 = s * 3;
|
||||
const iVal = (iq[s]?.i || 0) * 4; // scale for visibility
|
||||
const qVal = (iq[s]?.q || 0) * 4;
|
||||
|
||||
this._positions[i3] = iVal;
|
||||
this._positions[i3 + 1] = qVal;
|
||||
this._positions[i3 + 2] = 0;
|
||||
|
||||
// Size from amplitude
|
||||
const amp = amplitude ? (amplitude[s % amplitude.length] || 0.1) : 0.1;
|
||||
this._sizes[s] = 0.06 + amp * 0.15;
|
||||
|
||||
// Color from variance: blue(low) -> amber(high)
|
||||
const v = variance ? Math.min(1, (variance[s] || 0) * 2) : 0;
|
||||
this._colors[i3] = v * 1.0; // R
|
||||
this._colors[i3 + 1] = 0.5 + v * 0.3; // G
|
||||
this._colors[i3 + 2] = 1.0 - v * 0.7; // B
|
||||
}
|
||||
|
||||
this._stars.geometry.attributes.position.needsUpdate = true;
|
||||
this._stars.geometry.attributes.color.needsUpdate = true;
|
||||
this._stars.geometry.attributes.size.needsUpdate = true;
|
||||
|
||||
// Update constellation lines
|
||||
for (let s = 0; s < NUM_SUBCARRIERS - 1; s++) {
|
||||
const li = s * 6;
|
||||
const i3a = s * 3;
|
||||
const i3b = (s + 1) * 3;
|
||||
|
||||
this._linePos[li] = this._positions[i3a];
|
||||
this._linePos[li + 1] = this._positions[i3a + 1];
|
||||
this._linePos[li + 2] = this._positions[i3a + 2];
|
||||
this._linePos[li + 3] = this._positions[i3b];
|
||||
this._linePos[li + 4] = this._positions[i3b + 1];
|
||||
this._linePos[li + 5] = this._positions[i3b + 2];
|
||||
}
|
||||
// Last pair: wrap around
|
||||
const lastLi = (NUM_SUBCARRIERS - 1) * 6;
|
||||
const lastI3 = (NUM_SUBCARRIERS - 1) * 3;
|
||||
this._linePos[lastLi] = this._positions[lastI3];
|
||||
this._linePos[lastLi + 1] = this._positions[lastI3 + 1];
|
||||
this._linePos[lastLi + 2] = this._positions[lastI3 + 2];
|
||||
this._linePos[lastLi + 3] = this._positions[0];
|
||||
this._linePos[lastLi + 4] = this._positions[1];
|
||||
this._linePos[lastLi + 5] = this._positions[2];
|
||||
|
||||
this._lines.geometry.attributes.position.needsUpdate = true;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._stars.geometry.dispose();
|
||||
this._stars.material.dispose();
|
||||
this._ghosts.geometry.dispose();
|
||||
this._ghosts.material.dispose();
|
||||
this._lines.geometry.dispose();
|
||||
this._lines.material.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,567 @@
|
|||
/**
|
||||
* PoseSystem -- Stateless pose keypoint generator for COCO 17-keypoint format.
|
||||
*
|
||||
* Keypoint indices:
|
||||
* 0:nose 1:left_eye 2:right_eye 3:left_ear 4:right_ear
|
||||
* 5:left_shoulder 6:right_shoulder 7:left_elbow 8:right_elbow
|
||||
* 9:left_wrist 10:right_wrist 11:left_hip 12:right_hip
|
||||
* 13:left_knee 14:right_knee 15:left_ankle 16:right_ankle
|
||||
*
|
||||
* Every public method is a pure function: parameters in, keypoint array out.
|
||||
*/
|
||||
|
||||
export class PoseSystem {
|
||||
|
||||
// ---- Entry point -------------------------------------------------------
|
||||
|
||||
generateKeypoints(person, elapsed, breathPulse) {
|
||||
const pose = person.pose || 'standing';
|
||||
const pos = person.position || [0, 0, 0];
|
||||
const facing = person.facing || 0;
|
||||
const px = pos[0], pz = pos[2];
|
||||
const ms = person.motion_score || 0;
|
||||
const bp = breathPulse;
|
||||
|
||||
let kps;
|
||||
switch (pose) {
|
||||
case 'lying': kps = this.poseLying(px, pos[1] || 0, pz, elapsed, bp); break;
|
||||
case 'sitting': kps = this.poseSitting(px, pz, elapsed, bp); break;
|
||||
case 'fallen': kps = this.poseFallen(px, pz, elapsed); break;
|
||||
case 'falling': kps = this.poseFalling(px, pz, elapsed, person.fallProgress || 0); break;
|
||||
case 'exercising': kps = this.poseExercising(px, pz, elapsed, person.exerciseType, person.exerciseTime); break;
|
||||
case 'gesturing': kps = this.poseGesturing(px, pz, elapsed, person.gestureType, person.gestureIntensity || 0); break;
|
||||
case 'crouching': kps = this.poseCrouching(px, pz, elapsed, bp); break;
|
||||
case 'walking': kps = this.poseWalking(px, pz, elapsed, ms, bp); break;
|
||||
case 'standing':
|
||||
default: kps = this.poseStanding(px, pz, elapsed, ms, bp); break;
|
||||
}
|
||||
|
||||
// Apply facing rotation
|
||||
if (Math.abs(facing) > 0.01) {
|
||||
this.rotateKps(kps, px, pz, facing);
|
||||
}
|
||||
return kps;
|
||||
}
|
||||
|
||||
// ---- Rotation utility --------------------------------------------------
|
||||
|
||||
rotateKps(kps, cx, cz, angle) {
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle);
|
||||
for (const kp of kps) {
|
||||
const dx = kp[0] - cx, dz = kp[2] - cz;
|
||||
kp[0] = cx + dx * cos - dz * sin;
|
||||
kp[2] = cz + dx * sin + dz * cos;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Standing ----------------------------------------------------------
|
||||
// Weight shift between feet, idle head look-around, breathing
|
||||
|
||||
poseStanding(px, pz, elapsed, ms, bp) {
|
||||
// Slow weight shift side to side
|
||||
const weightShift = Math.sin(elapsed * 0.6) * 0.012;
|
||||
// Idle head look around
|
||||
const headTurn = Math.sin(elapsed * 0.3) * 0.015;
|
||||
const headTilt = Math.cos(elapsed * 0.25) * 0.008;
|
||||
// Slight sway from micro-balance adjustments
|
||||
const sway = Math.sin(elapsed * 0.8) * 0.005 + weightShift;
|
||||
// Knee bend alternation with weight shift
|
||||
const leftKneeBend = Math.max(0, Math.sin(elapsed * 0.6)) * 0.015;
|
||||
const rightKneeBend = Math.max(0, -Math.sin(elapsed * 0.6)) * 0.015;
|
||||
|
||||
return [
|
||||
[px + sway + headTurn, 1.72 + bp + headTilt, pz], // 0 nose
|
||||
[px - 0.03 + sway + headTurn, 1.74 + bp + headTilt, pz - 0.02], // 1 left eye
|
||||
[px + 0.03 + sway + headTurn, 1.74 + bp + headTilt, pz - 0.02], // 2 right eye
|
||||
[px - 0.07 + headTurn * 0.5, 1.72 + bp, pz], // 3 left ear
|
||||
[px + 0.07 + headTurn * 0.5, 1.72 + bp, pz], // 4 right ear
|
||||
[px - 0.22 + weightShift * 0.3, 1.48 + bp, pz], // 5 left shoulder
|
||||
[px + 0.22 + weightShift * 0.3, 1.48 + bp, pz], // 6 right shoulder
|
||||
[px - 0.24 + weightShift * 0.2, 1.18 + bp, pz + 0.02], // 7 left elbow
|
||||
[px + 0.24 + weightShift * 0.2, 1.18 + bp, pz - 0.02], // 8 right elbow
|
||||
[px - 0.22 + weightShift * 0.15, 0.92 + bp, pz + 0.05], // 9 left wrist
|
||||
[px + 0.22 + weightShift * 0.15, 0.92 + bp, pz - 0.05], // 10 right wrist
|
||||
[px - 0.11 + weightShift * 0.5, 0.98 + bp, pz], // 11 left hip
|
||||
[px + 0.11 + weightShift * 0.5, 0.98 + bp, pz], // 12 right hip
|
||||
[px - 0.12 + weightShift * 0.3, 0.52 + leftKneeBend, pz], // 13 left knee
|
||||
[px + 0.12 + weightShift * 0.3, 0.52 + rightKneeBend, pz], // 14 right knee
|
||||
[px - 0.12 + weightShift * 0.4, 0.04, pz], // 15 left ankle
|
||||
[px + 0.12 + weightShift * 0.4, 0.04, pz], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Walking -----------------------------------------------------------
|
||||
// Torso rotation, head bob, natural arm pendulum with elbow bend
|
||||
|
||||
poseWalking(px, pz, elapsed, ms, bp) {
|
||||
const speed = Math.min(ms / 100, 2.5);
|
||||
const wp = elapsed * speed * 1.8;
|
||||
const sFactor = Math.min(speed, 1);
|
||||
|
||||
// Leg stride
|
||||
const legStride = Math.sin(wp) * 0.25 * sFactor;
|
||||
const legBack = Math.sin(wp + Math.PI) * 0.25 * sFactor;
|
||||
const kneeAmt = Math.abs(Math.sin(wp)) * 0.08;
|
||||
|
||||
// Natural arm pendulum -- opposite to legs, with elbow bend
|
||||
const armPhase = Math.sin(wp);
|
||||
const armSwingL = -armPhase * 0.3 * sFactor; // left arm opposite right leg
|
||||
const armSwingR = armPhase * 0.3 * sFactor;
|
||||
const elbowBendL = Math.max(0, -armPhase) * 0.12 * sFactor; // bend on backswing
|
||||
const elbowBendR = Math.max(0, armPhase) * 0.12 * sFactor;
|
||||
|
||||
// Torso twist (shoulders rotate opposite to hips)
|
||||
const torsoTwist = Math.sin(wp) * 0.03 * sFactor;
|
||||
|
||||
// Vertical bob (double frequency -- peak at mid-stance)
|
||||
const bob = Math.abs(Math.sin(wp)) * 0.025;
|
||||
|
||||
// Head bob -- slight lag behind body
|
||||
const headBob = Math.abs(Math.sin(wp - 0.2)) * 0.015;
|
||||
const headLean = Math.sin(wp) * 0.008;
|
||||
|
||||
return [
|
||||
[px + headLean, 1.72 + bp + bob + headBob, pz], // 0 nose
|
||||
[px - 0.03 + headLean, 1.74 + bp + bob + headBob, pz - 0.02], // 1 left eye
|
||||
[px + 0.03 + headLean, 1.74 + bp + bob + headBob, pz - 0.02], // 2 right eye
|
||||
[px - 0.07, 1.72 + bp + bob + headBob, pz], // 3 left ear
|
||||
[px + 0.07, 1.72 + bp + bob + headBob, pz], // 4 right ear
|
||||
[px - 0.22 - torsoTwist, 1.48 + bp + bob, pz], // 5 left shoulder (twist)
|
||||
[px + 0.22 - torsoTwist, 1.48 + bp + bob, pz], // 6 right shoulder
|
||||
[px - 0.28 + armSwingL * 0.3, 1.18 + bp + bob - elbowBendL, pz + armSwingL * 0.3], // 7 left elbow
|
||||
[px + 0.28 + armSwingR * 0.3, 1.18 + bp + bob - elbowBendR, pz + armSwingR * 0.3], // 8 right elbow
|
||||
[px - 0.26 + armSwingL * 0.6, 0.92 + bp + bob - elbowBendL * 1.5, pz + armSwingL * 0.5], // 9 left wrist
|
||||
[px + 0.26 + armSwingR * 0.6, 0.92 + bp + bob - elbowBendR * 1.5, pz + armSwingR * 0.5], // 10 right wrist
|
||||
[px - 0.11 + torsoTwist * 0.5, 0.98 + bp + bob, pz], // 11 left hip (counter-twist)
|
||||
[px + 0.11 + torsoTwist * 0.5, 0.98 + bp + bob, pz], // 12 right hip
|
||||
[px - 0.12 + legStride * 0.3, 0.52 + kneeAmt, pz + legStride], // 13 left knee
|
||||
[px + 0.12 + legBack * 0.3, 0.52 + kneeAmt, pz + legBack], // 14 right knee
|
||||
[px - 0.12 + legStride * 0.6, 0.04, pz + legStride * 1.5], // 15 left ankle
|
||||
[px + 0.12 + legBack * 0.6, 0.04, pz + legBack * 1.5], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Lying -------------------------------------------------------------
|
||||
// Subtle micro-movements, differentiate supine vs side-lying via elapsed hash
|
||||
|
||||
poseLying(px, surfaceY, pz, elapsed, bp) {
|
||||
const y = (surfaceY || 0) + 0.2;
|
||||
const chest = bp * 0.015;
|
||||
|
||||
// Micro-movements -- tiny random-feeling shifts (deterministic from elapsed)
|
||||
const microX = Math.sin(elapsed * 0.17) * 0.004;
|
||||
const microZ = Math.cos(elapsed * 0.13) * 0.003;
|
||||
const fingerTwitch = Math.sin(elapsed * 0.7) * 0.008;
|
||||
|
||||
// Determine supine vs side-lying from a slow oscillation (stays one way for ~20s)
|
||||
const lyingMode = Math.sin(elapsed * 0.05);
|
||||
|
||||
if (lyingMode > 0.3) {
|
||||
// Side-lying (on left side)
|
||||
const curl = Math.sin(elapsed * 0.1) * 0.02; // slight fetal curl
|
||||
return [
|
||||
[px - 0.72 + microX, y + 0.12, pz - 0.08], // 0 nose (turned)
|
||||
[px - 0.70, y + 0.14, pz - 0.10], // 1 left eye
|
||||
[px - 0.70, y + 0.16, pz - 0.06], // 2 right eye (up)
|
||||
[px - 0.76, y + 0.11, pz - 0.12], // 3 left ear (down)
|
||||
[px - 0.76, y + 0.14, pz - 0.04], // 4 right ear
|
||||
[px - 0.45, y + chest + 0.05, pz - 0.12], // 5 left shoulder (down)
|
||||
[px - 0.45, y + chest + 0.2, pz + 0.04], // 6 right shoulder (up)
|
||||
[px - 0.38, y + 0.02, pz - 0.28 + curl], // 7 left elbow
|
||||
[px - 0.35, y + 0.18, pz + 0.15 + fingerTwitch], // 8 right elbow
|
||||
[px - 0.20, y - 0.01, pz - 0.30 + curl], // 9 left wrist
|
||||
[px - 0.18, y + 0.12, pz + 0.25 + fingerTwitch], // 10 right wrist
|
||||
[px + 0.05 + microX, y + chest * 0.4 + 0.03, pz - 0.08], // 11 left hip
|
||||
[px + 0.05 + microX, y + chest * 0.4 + 0.12, pz + 0.06], // 12 right hip
|
||||
[px + 0.40 + curl * 2, y + 0.02, pz - 0.14 + curl], // 13 left knee
|
||||
[px + 0.38 + curl * 2, y + 0.10, pz + 0.10 + curl], // 14 right knee
|
||||
[px + 0.75, y - 0.01, pz - 0.12], // 15 left ankle
|
||||
[px + 0.72, y + 0.04, pz + 0.08], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// Supine (face up) -- default
|
||||
return [
|
||||
[px - 0.75 + microX, y + 0.08, pz + microZ], // 0 nose
|
||||
[px - 0.72, y + 0.1, pz - 0.02 + microZ], // 1 left eye
|
||||
[px - 0.72, y + 0.1, pz + 0.02 + microZ], // 2 right eye
|
||||
[px - 0.78, y + 0.08, pz - 0.05], // 3 left ear
|
||||
[px - 0.78, y + 0.08, pz + 0.05], // 4 right ear
|
||||
[px - 0.45, y + chest, pz - 0.18], // 5 left shoulder
|
||||
[px - 0.45, y + chest, pz + 0.18], // 6 right shoulder
|
||||
[px - 0.42, y, pz - 0.35 + fingerTwitch], // 7 left elbow
|
||||
[px - 0.42, y, pz + 0.35 - fingerTwitch], // 8 right elbow
|
||||
[px - 0.2, y - 0.02, pz - 0.38 + fingerTwitch], // 9 left wrist
|
||||
[px - 0.2, y - 0.02, pz + 0.38 - fingerTwitch], // 10 right wrist
|
||||
[px + 0.05 + microX, y + chest * 0.5, pz - 0.1], // 11 left hip
|
||||
[px + 0.05 + microX, y + chest * 0.5, pz + 0.1], // 12 right hip
|
||||
[px + 0.45, y, pz - 0.11], // 13 left knee
|
||||
[px + 0.45, y, pz + 0.11], // 14 right knee
|
||||
[px + 0.82, y - 0.02, pz - 0.1], // 15 left ankle
|
||||
[px + 0.82, y - 0.02, pz + 0.1], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Sitting -----------------------------------------------------------
|
||||
// Occasional fidget, breathing chest expansion, weight shift
|
||||
|
||||
poseSitting(px, pz, elapsed, bp) {
|
||||
const sway = Math.sin(elapsed * 0.5) * 0.003;
|
||||
|
||||
// Fidget: occasional hand movement (every ~6s a small gesture)
|
||||
const fidgetCycle = elapsed % 6.0;
|
||||
const fidgetActive = fidgetCycle > 5.2 && fidgetCycle < 5.8;
|
||||
const fidgetAmt = fidgetActive ? Math.sin((fidgetCycle - 5.2) * Math.PI / 0.6) * 0.06 : 0;
|
||||
|
||||
// Weight shift side to side (slow)
|
||||
const weightShift = Math.sin(elapsed * 0.25) * 0.008;
|
||||
|
||||
// Chest expansion from breathing
|
||||
const chestExpand = bp * 0.008;
|
||||
|
||||
return [
|
||||
[px + sway + weightShift, 1.15 + bp, pz], // 0 nose
|
||||
[px - 0.03 + sway + weightShift, 1.17 + bp, pz - 0.02], // 1 left eye
|
||||
[px + 0.03 + sway + weightShift, 1.17 + bp, pz - 0.02], // 2 right eye
|
||||
[px - 0.07 + weightShift, 1.15 + bp, pz], // 3 left ear
|
||||
[px + 0.07 + weightShift, 1.15 + bp, pz], // 4 right ear
|
||||
[px - 0.20 - chestExpand + weightShift, 0.95 + bp, pz], // 5 left shoulder
|
||||
[px + 0.20 + chestExpand + weightShift, 0.95 + bp, pz], // 6 right shoulder
|
||||
[px - 0.25 + weightShift, 0.72 + bp, pz + 0.08], // 7 left elbow
|
||||
[px + 0.25 + weightShift, 0.72 + bp, pz + 0.08], // 8 right elbow
|
||||
[px - 0.18 + fidgetAmt, 0.55 + fidgetAmt * 0.3, pz + 0.15], // 9 left wrist (fidgets)
|
||||
[px + 0.18, 0.55, pz + 0.15], // 10 right wrist
|
||||
[px - 0.11 + weightShift * 0.5, 0.48, pz + 0.02], // 11 left hip
|
||||
[px + 0.11 + weightShift * 0.5, 0.48, pz + 0.02], // 12 right hip
|
||||
[px - 0.12, 0.48, pz + 0.4], // 13 left knee
|
||||
[px + 0.12, 0.48, pz + 0.4], // 14 right knee
|
||||
[px - 0.12, 0.04, pz + 0.4], // 15 left ankle
|
||||
[px + 0.12, 0.04, pz + 0.4], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Fallen ------------------------------------------------------------
|
||||
// Occasional twitch/attempt to move, asymmetric breathing
|
||||
|
||||
poseFallen(px, pz, elapsed) {
|
||||
// Irregular twitch -- sharper, less periodic
|
||||
const twitchArm = Math.sin(elapsed * 0.3) * 0.003 +
|
||||
Math.sin(elapsed * 1.7) * 0.008 * Math.max(0, Math.sin(elapsed * 0.15));
|
||||
const twitchLeg = Math.cos(elapsed * 0.4) * 0.005 *
|
||||
Math.max(0, Math.sin(elapsed * 0.2 + 1.0));
|
||||
|
||||
// Asymmetric breathing (one side of chest rises more)
|
||||
const breathL = Math.sin(elapsed * 0.8) * 0.006;
|
||||
const breathR = Math.sin(elapsed * 0.8 + 0.3) * 0.004;
|
||||
|
||||
// Attempt to move (slow reach every ~10s)
|
||||
const attemptCycle = elapsed % 10.0;
|
||||
const attempting = attemptCycle > 8.0 && attemptCycle < 9.5;
|
||||
const attemptAmt = attempting ? Math.sin((attemptCycle - 8.0) * Math.PI / 1.5) * 0.05 : 0;
|
||||
|
||||
return [
|
||||
[px + 0.35, 0.12, pz + 0.15 + twitchArm], // 0 nose
|
||||
[px + 0.33, 0.14, pz + 0.13], // 1 left eye
|
||||
[px + 0.37, 0.14, pz + 0.17], // 2 right eye
|
||||
[px + 0.38, 0.11, pz + 0.1], // 3 left ear
|
||||
[px + 0.38, 0.11, pz + 0.2], // 4 right ear
|
||||
[px + 0.15, 0.15 + breathL, pz - 0.1], // 5 left shoulder
|
||||
[px + 0.15, 0.2 + breathR, pz + 0.25], // 6 right shoulder
|
||||
[px - 0.05, 0.08, pz - 0.25 + twitchArm], // 7 left elbow
|
||||
[px + 0.3, 0.22 + attemptAmt * 0.5, pz + 0.45 + attemptAmt], // 8 right elbow (reaching)
|
||||
[px - 0.15, 0.05, pz - 0.3 + twitchArm * 1.5], // 9 left wrist
|
||||
[px + 0.4, 0.15 + attemptAmt, pz + 0.5 + attemptAmt * 1.5], // 10 right wrist (reaching)
|
||||
[px - 0.05, 0.12, pz - 0.05], // 11 left hip
|
||||
[px - 0.05, 0.12, pz + 0.15], // 12 right hip
|
||||
[px - 0.2, 0.08 + twitchLeg, pz - 0.3], // 13 left knee
|
||||
[px - 0.15, 0.15, pz + 0.35 + twitchLeg], // 14 right knee
|
||||
[px - 0.35, 0.04, pz - 0.2], // 15 left ankle
|
||||
[px - 0.3, 0.04, pz + 0.5], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Falling -----------------------------------------------------------
|
||||
// Flailing arms, head snap, non-linear easing (cubic ease-in)
|
||||
|
||||
poseFalling(px, pz, elapsed, progress) {
|
||||
const standing = this.poseStanding(px, pz, elapsed, 0, 0);
|
||||
const fallen = this.poseFallen(px, pz, elapsed);
|
||||
|
||||
// Cubic ease-in for realistic acceleration
|
||||
const t = progress * progress * progress;
|
||||
|
||||
// Arm flailing -- sinusoidal perturbation that peaks mid-fall then diminishes
|
||||
const flailIntensity = Math.sin(progress * Math.PI) * 0.15;
|
||||
const flailL = Math.sin(elapsed * 8 + progress * 5) * flailIntensity;
|
||||
const flailR = Math.cos(elapsed * 8 + progress * 5) * flailIntensity;
|
||||
|
||||
// Head snaps back early in the fall
|
||||
const headSnap = progress < 0.4 ? Math.sin(progress * Math.PI / 0.4) * 0.06 : 0;
|
||||
|
||||
const kps = [];
|
||||
for (let i = 0; i < 17; i++) {
|
||||
kps.push([
|
||||
standing[i][0] * (1 - t) + fallen[i][0] * t,
|
||||
standing[i][1] * (1 - t) + fallen[i][1] * t,
|
||||
standing[i][2] * (1 - t) + fallen[i][2] * t,
|
||||
]);
|
||||
}
|
||||
|
||||
// Apply head snap (tilt backward)
|
||||
kps[0][1] += headSnap;
|
||||
kps[1][1] += headSnap * 0.9;
|
||||
kps[2][1] += headSnap * 0.9;
|
||||
|
||||
// Apply arm flailing
|
||||
kps[7][0] += flailL; kps[7][2] += flailL * 0.5; // left elbow
|
||||
kps[8][0] += flailR; kps[8][2] -= flailR * 0.5; // right elbow
|
||||
kps[9][0] += flailL * 1.5; kps[9][2] += flailL; // left wrist
|
||||
kps[10][0] += flailR * 1.5; kps[10][2] -= flailR; // right wrist
|
||||
|
||||
return kps;
|
||||
}
|
||||
|
||||
// ---- Exercising --------------------------------------------------------
|
||||
|
||||
poseExercising(px, pz, elapsed, exerciseType, exerciseTime) {
|
||||
const et = exerciseTime || elapsed;
|
||||
|
||||
if (exerciseType === 'squats') {
|
||||
return this._poseSquats(px, pz, et);
|
||||
}
|
||||
return this._poseJumpingJacks(px, pz, et);
|
||||
}
|
||||
|
||||
// Squats: forward lean, hip hinge, arm counterbalance, depth variation
|
||||
|
||||
_poseSquats(px, pz, et) {
|
||||
const rawPhase = (Math.sin(et * 2.5) + 1) / 2; // 0=up, 1=down
|
||||
// Depth variation -- every other rep is shallower
|
||||
const repIndex = Math.floor(et * 2.5 / Math.PI);
|
||||
const depthMod = (repIndex % 2 === 0) ? 1.0 : 0.7;
|
||||
const phase = rawPhase * depthMod;
|
||||
|
||||
const squat = phase * 0.5;
|
||||
const armFwd = phase * 0.4;
|
||||
// Forward lean increases with squat depth
|
||||
const forwardLean = phase * 0.08;
|
||||
// Hip hinge -- hips push back
|
||||
const hipBack = phase * 0.12;
|
||||
|
||||
return [
|
||||
[px + forwardLean * 0.3, 1.72 - squat, pz + forwardLean], // 0 nose
|
||||
[px - 0.03 + forwardLean * 0.3, 1.74 - squat, pz - 0.02 + forwardLean], // 1 left eye
|
||||
[px + 0.03 + forwardLean * 0.3, 1.74 - squat, pz - 0.02 + forwardLean], // 2 right eye
|
||||
[px - 0.07, 1.72 - squat, pz + forwardLean * 0.8], // 3 left ear
|
||||
[px + 0.07, 1.72 - squat, pz + forwardLean * 0.8], // 4 right ear
|
||||
[px - 0.22, 1.48 - squat + forwardLean * 0.2, pz + forwardLean * 0.5], // 5 left shoulder
|
||||
[px + 0.22, 1.48 - squat + forwardLean * 0.2, pz + forwardLean * 0.5], // 6 right shoulder
|
||||
[px - 0.22, 1.25 - squat * 0.7, pz + armFwd], // 7 left elbow
|
||||
[px + 0.22, 1.25 - squat * 0.7, pz + armFwd], // 8 right elbow
|
||||
[px - 0.22, 1.05 - squat * 0.5, pz + armFwd * 1.5], // 9 left wrist (counterbalance)
|
||||
[px + 0.22, 1.05 - squat * 0.5, pz + armFwd * 1.5], // 10 right wrist
|
||||
[px - 0.11, 0.98 - squat * 0.3, pz - hipBack], // 11 left hip (pushed back)
|
||||
[px + 0.11, 0.98 - squat * 0.3, pz - hipBack], // 12 right hip
|
||||
[px - 0.15, 0.52 - squat * 0.1, pz + squat * 0.3], // 13 left knee
|
||||
[px + 0.15, 0.52 - squat * 0.1, pz + squat * 0.3], // 14 right knee
|
||||
[px - 0.13, 0.04, pz + 0.05], // 15 left ankle
|
||||
[px + 0.13, 0.04, pz + 0.05], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// Jumping jacks: full arm arc, hip sway, landing impact
|
||||
|
||||
_poseJumpingJacks(px, pz, et) {
|
||||
const rawPhase = (Math.sin(et * 3) + 1) / 2; // 0=closed, 1=open
|
||||
const phase = rawPhase;
|
||||
|
||||
// Full arm arc -- from sides to overhead in a smooth arc
|
||||
const armAngle = phase * Math.PI * 0.85; // 0 to ~153 degrees
|
||||
const armX = Math.sin(armAngle) * 0.55; // lateral spread
|
||||
const armY = Math.cos(armAngle) * 0.55; // vertical component
|
||||
|
||||
const legSpread = phase * 0.25;
|
||||
// Landing impact -- brief compression at bottom of cycle
|
||||
const impact = Math.max(0, -Math.sin(et * 3)) * 0.03;
|
||||
const jump = Math.max(0, Math.sin(et * 3)) * 0.06;
|
||||
// Hip sway at apex
|
||||
const hipSway = Math.sin(et * 3) * 0.015;
|
||||
|
||||
return [
|
||||
[px, 1.72 + jump - impact, pz], // 0 nose
|
||||
[px - 0.03, 1.74 + jump - impact, pz - 0.02], // 1 left eye
|
||||
[px + 0.03, 1.74 + jump - impact, pz - 0.02], // 2 right eye
|
||||
[px - 0.07, 1.72 + jump - impact, pz], // 3 left ear
|
||||
[px + 0.07, 1.72 + jump - impact, pz], // 4 right ear
|
||||
[px - 0.22, 1.48 + jump - impact, pz], // 5 left shoulder
|
||||
[px + 0.22, 1.48 + jump - impact, pz], // 6 right shoulder
|
||||
[px - 0.22 - armX * 0.6, 1.48 - armY * 0.3 + jump, pz], // 7 left elbow (arc)
|
||||
[px + 0.22 + armX * 0.6, 1.48 - armY * 0.3 + jump, pz], // 8 right elbow
|
||||
[px - 0.22 - armX, 1.48 - armY + 0.55 + jump, pz], // 9 left wrist (arc)
|
||||
[px + 0.22 + armX, 1.48 - armY + 0.55 + jump, pz], // 10 right wrist
|
||||
[px - 0.11 + hipSway, 0.98 + jump - impact, pz], // 11 left hip
|
||||
[px + 0.11 + hipSway, 0.98 + jump - impact, pz], // 12 right hip
|
||||
[px - 0.12 - legSpread, 0.52 + jump * 0.5 - impact * 0.5, pz], // 13 left knee
|
||||
[px + 0.12 + legSpread, 0.52 + jump * 0.5 - impact * 0.5, pz], // 14 right knee
|
||||
[px - 0.13 - legSpread * 1.3, 0.04 - impact * 0.3, pz], // 15 left ankle
|
||||
[px + 0.13 + legSpread * 1.3, 0.04 - impact * 0.3, pz], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Gesturing ---------------------------------------------------------
|
||||
|
||||
poseGesturing(px, pz, elapsed, gestureType, intensity) {
|
||||
const base = this.poseStanding(px, pz, elapsed, 0, 0);
|
||||
if (intensity <= 0) return base;
|
||||
const gt = elapsed;
|
||||
|
||||
switch (gestureType) {
|
||||
case 'wave':
|
||||
return this._gestureWave(base, px, pz, gt, intensity);
|
||||
case 'swipe_left':
|
||||
return this._gestureSwipe(base, px, pz, gt, intensity);
|
||||
case 'circle':
|
||||
return this._gestureCircle(base, px, pz, gt, intensity);
|
||||
case 'point':
|
||||
return this._gesturePoint(base, px, pz, gt, intensity);
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
// Wave: fluid hand oscillation, elbow pivot, slight shoulder raise
|
||||
|
||||
_gestureWave(base, px, pz, gt, intensity) {
|
||||
const wave = Math.sin(gt * 6) * 0.15 * intensity;
|
||||
const waveSmooth = Math.sin(gt * 6 + 0.3) * 0.08 * intensity; // secondary harmonic
|
||||
const shoulderRaise = 0.04 * intensity;
|
||||
const elbowPivot = Math.sin(gt * 3) * 0.03 * intensity;
|
||||
|
||||
// Shoulder rises slightly during wave
|
||||
base[6][1] += shoulderRaise;
|
||||
// Elbow raised and pivoting
|
||||
base[8] = [
|
||||
px + 0.32 + elbowPivot,
|
||||
1.55 * intensity + 1.18 * (1 - intensity) + shoulderRaise,
|
||||
pz + 0.05,
|
||||
];
|
||||
// Wrist oscillates fluidly
|
||||
base[10] = [
|
||||
px + 0.32 + wave + waveSmooth * 0.3,
|
||||
1.7 * intensity + 0.92 * (1 - intensity) + shoulderRaise,
|
||||
pz + 0.08 + waveSmooth,
|
||||
];
|
||||
// Slight body lean away from waving arm
|
||||
base[0][0] -= 0.01 * intensity;
|
||||
base[5][0] -= 0.008 * intensity;
|
||||
return base;
|
||||
}
|
||||
|
||||
// Swipe: full body rotation follow-through, arm extension
|
||||
|
||||
_gestureSwipe(base, px, pz, gt, intensity) {
|
||||
const sweep = Math.sin(gt * 2) * intensity;
|
||||
// Body rotation follows the arm
|
||||
const bodyRotation = sweep * 0.04;
|
||||
const shoulderTwist = sweep * 0.025;
|
||||
|
||||
// Upper body rotates
|
||||
for (let i = 0; i <= 4; i++) base[i][0] += bodyRotation * 0.5;
|
||||
base[5][0] -= shoulderTwist;
|
||||
base[6][0] += shoulderTwist;
|
||||
|
||||
// Arm extends fully during swipe
|
||||
base[8] = [px + 0.15 + sweep * 0.4, 1.3, pz + 0.3];
|
||||
base[10] = [px - 0.1 + sweep * 0.6, 1.3, pz + 0.55];
|
||||
|
||||
// Hip counter-rotation
|
||||
base[11][0] += bodyRotation * -0.2;
|
||||
base[12][0] += bodyRotation * -0.2;
|
||||
return base;
|
||||
}
|
||||
|
||||
// Circle: smooth circular motion with forearm rotation
|
||||
|
||||
_gestureCircle(base, px, pz, gt, intensity) {
|
||||
const angle = gt * 2.5;
|
||||
const radius = 0.25 * intensity;
|
||||
const cx = Math.cos(angle) * radius;
|
||||
const cy = Math.sin(angle) * radius;
|
||||
// Forearm rotation -- wrist traces a smaller secondary circle
|
||||
const forearmAngle = angle * 1.5;
|
||||
const forearmR = 0.06 * intensity;
|
||||
|
||||
base[8] = [
|
||||
px + 0.3 + cx * 0.5,
|
||||
1.3 + cy * 0.5,
|
||||
pz + 0.2 + Math.sin(angle) * 0.05,
|
||||
];
|
||||
base[10] = [
|
||||
px + 0.3 + cx + Math.cos(forearmAngle) * forearmR,
|
||||
1.3 + cy + Math.sin(forearmAngle) * forearmR,
|
||||
pz + 0.35 + Math.sin(angle) * 0.08,
|
||||
];
|
||||
// Slight shoulder movement following arm
|
||||
base[6][0] += cx * 0.08;
|
||||
base[6][1] += cy * 0.04;
|
||||
return base;
|
||||
}
|
||||
|
||||
// Point: extended index finger simulation with arm sway
|
||||
|
||||
_gesturePoint(base, px, pz, gt, intensity) {
|
||||
const point = intensity;
|
||||
// Slight arm sway -- breathing/holding still
|
||||
const sway = Math.sin(gt * 1.5) * 0.01 * intensity;
|
||||
const vertSway = Math.cos(gt * 1.2) * 0.008 * intensity;
|
||||
|
||||
base[8] = [px + 0.15 + sway, 1.35 + vertSway, pz + 0.35 * point];
|
||||
base[10] = [px + 0.08 + sway * 0.5, 1.38 + vertSway * 0.5, pz + 0.70 * point];
|
||||
|
||||
// Lean slightly toward point direction
|
||||
base[0][2] += 0.02 * point;
|
||||
base[5][2] += 0.01 * point;
|
||||
base[6][2] += 0.01 * point;
|
||||
return base;
|
||||
}
|
||||
|
||||
// ---- Crouching ---------------------------------------------------------
|
||||
// Stealth-crawl option, weight transfer between legs
|
||||
|
||||
poseCrouching(px, pz, elapsed, bp) {
|
||||
const sway = Math.sin(elapsed * 1.5) * 0.005;
|
||||
|
||||
// Weight transfer between legs (slow rocking)
|
||||
const weightTransfer = Math.sin(elapsed * 0.8) * 0.025;
|
||||
const leftDown = Math.max(0, weightTransfer) * 0.03;
|
||||
const rightDown = Math.max(0, -weightTransfer) * 0.03;
|
||||
|
||||
// Stealth-crawl micro-movement (slow forward creep every ~4s)
|
||||
const crawlCycle = elapsed % 4.0;
|
||||
const crawlActive = crawlCycle > 3.0;
|
||||
const crawlAmt = crawlActive ? Math.sin((crawlCycle - 3.0) * Math.PI) * 0.02 : 0;
|
||||
|
||||
// Arms adjust for balance during weight transfer
|
||||
const armBalance = weightTransfer * 0.3;
|
||||
|
||||
return [
|
||||
[px + sway, 1.05 + bp, pz + 0.15 + crawlAmt], // 0 nose
|
||||
[px - 0.03, 1.07 + bp, pz + 0.13 + crawlAmt], // 1 left eye
|
||||
[px + 0.03, 1.07 + bp, pz + 0.13 + crawlAmt], // 2 right eye
|
||||
[px - 0.07, 1.05 + bp, pz + 0.12 + crawlAmt], // 3 left ear
|
||||
[px + 0.07, 1.05 + bp, pz + 0.12 + crawlAmt], // 4 right ear
|
||||
[px - 0.22, 0.88 + bp, pz + 0.05], // 5 left shoulder
|
||||
[px + 0.22, 0.88 + bp, pz + 0.05], // 6 right shoulder
|
||||
[px - 0.28 - armBalance, 0.65 + bp, pz + 0.15 + crawlAmt * 0.5], // 7 left elbow
|
||||
[px + 0.28 + armBalance, 0.65 + bp, pz + 0.15 + crawlAmt * 0.5], // 8 right elbow
|
||||
[px - 0.22 - armBalance * 0.5, 0.48, pz + 0.2 + crawlAmt], // 9 left wrist
|
||||
[px + 0.22 + armBalance * 0.5, 0.48, pz + 0.2 + crawlAmt], // 10 right wrist
|
||||
[px - 0.12 + weightTransfer, 0.42, pz - 0.05], // 11 left hip
|
||||
[px + 0.12 + weightTransfer, 0.42, pz - 0.05], // 12 right hip
|
||||
[px - 0.15 + weightTransfer * 0.5, 0.35 - leftDown, pz + 0.25], // 13 left knee
|
||||
[px + 0.15 + weightTransfer * 0.5, 0.35 - rightDown, pz + 0.25], // 14 right knee
|
||||
[px - 0.13, 0.04, pz + 0.1], // 15 left ankle
|
||||
[px + 0.13, 0.04, pz + 0.1], // 16 right ankle
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* Post-Processing — Subtle bloom for green glow wireframe,
|
||||
* warm vignette, minimal grain. Foundation-style.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
||||
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
||||
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
||||
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
|
||||
|
||||
const VignetteShader = {
|
||||
uniforms: {
|
||||
tDiffuse: { value: null },
|
||||
uTime: { value: 0 },
|
||||
uVignetteStrength: { value: 0.5 },
|
||||
uChromaticStrength: { value: 0.0015 },
|
||||
uGrainStrength: { value: 0.03 },
|
||||
uWarmth: { value: 0.08 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform sampler2D tDiffuse;
|
||||
uniform float uTime;
|
||||
uniform float uVignetteStrength;
|
||||
uniform float uChromaticStrength;
|
||||
uniform float uGrainStrength;
|
||||
uniform float uWarmth;
|
||||
varying vec2 vUv;
|
||||
|
||||
float rand(vec2 co) {
|
||||
return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv;
|
||||
vec2 center = uv - 0.5;
|
||||
float dist = length(center);
|
||||
|
||||
// Subtle chromatic aberration at edges only
|
||||
vec2 offset = center * dist * uChromaticStrength;
|
||||
float r = texture2D(tDiffuse, uv + offset).r;
|
||||
float g = texture2D(tDiffuse, uv).g;
|
||||
float b = texture2D(tDiffuse, uv - offset * 0.5).b;
|
||||
vec3 color = vec3(r, g, b);
|
||||
|
||||
// Warm vignette
|
||||
float vignette = 1.0 - dist * dist * uVignetteStrength * 1.8;
|
||||
color *= vignette;
|
||||
|
||||
// Very subtle warm shift in shadows
|
||||
float luma = dot(color, vec3(0.299, 0.587, 0.114));
|
||||
color.r += (1.0 - luma) * uWarmth * 0.5;
|
||||
color.g += (1.0 - luma) * uWarmth * 0.2;
|
||||
|
||||
// Minimal grain
|
||||
float grain = (rand(uv * uTime * 0.01) - 0.5) * uGrainStrength;
|
||||
color += grain;
|
||||
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export class PostProcessing {
|
||||
constructor(renderer, scene, camera) {
|
||||
const size = renderer.getSize(new THREE.Vector2());
|
||||
|
||||
this.composer = new EffectComposer(renderer);
|
||||
this.composer.addPass(new RenderPass(scene, camera));
|
||||
|
||||
// Bloom — tuned for green wireframe glow
|
||||
this._bloomPass = new UnrealBloomPass(
|
||||
new THREE.Vector2(size.x, size.y),
|
||||
1.0, // strength (less aggressive than before)
|
||||
0.5, // radius
|
||||
0.25 // threshold
|
||||
);
|
||||
this.composer.addPass(this._bloomPass);
|
||||
|
||||
// Vignette + warmth
|
||||
this._vignettePass = new ShaderPass(VignetteShader);
|
||||
this.composer.addPass(this._vignettePass);
|
||||
|
||||
this._bloomEnabled = true;
|
||||
}
|
||||
|
||||
update(elapsed) {
|
||||
this._vignettePass.uniforms.uTime.value = elapsed;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.composer.render();
|
||||
}
|
||||
|
||||
resize(width, height) {
|
||||
this.composer.setSize(width, height);
|
||||
this._bloomPass.resolution.set(width, height);
|
||||
}
|
||||
|
||||
setQuality(level) {
|
||||
if (level === 0) {
|
||||
this._bloomPass.strength = 0;
|
||||
this._vignettePass.uniforms.uChromaticStrength.value = 0;
|
||||
this._vignettePass.uniforms.uGrainStrength.value = 0;
|
||||
} else if (level === 1) {
|
||||
this._bloomPass.strength = 0.6;
|
||||
this._vignettePass.uniforms.uChromaticStrength.value = 0.001;
|
||||
this._vignettePass.uniforms.uGrainStrength.value = 0.02;
|
||||
} else {
|
||||
this._bloomPass.strength = 1.0;
|
||||
this._vignettePass.uniforms.uChromaticStrength.value = 0.0015;
|
||||
this._vignettePass.uniforms.uGrainStrength.value = 0.03;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.composer.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* Module C — "Presence Cartography"
|
||||
* InstancedMesh 20x4x20 voxel heatmap with person lights
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const GRID_X = 20;
|
||||
const GRID_Y = 4;
|
||||
const GRID_Z = 20;
|
||||
const TOTAL_VOXELS = GRID_X * GRID_Y * GRID_Z;
|
||||
const VOXEL_SIZE = 0.22;
|
||||
|
||||
export class PresenceCartography {
|
||||
constructor(scene, panelGroup) {
|
||||
this.group = new THREE.Group();
|
||||
if (panelGroup) panelGroup.add(this.group);
|
||||
else scene.add(this.group);
|
||||
|
||||
// Instanced cubes
|
||||
const cubeGeo = new THREE.BoxGeometry(VOXEL_SIZE, VOXEL_SIZE, VOXEL_SIZE);
|
||||
const cubeMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
this._mesh = new THREE.InstancedMesh(cubeGeo, cubeMat, TOTAL_VOXELS);
|
||||
this._mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
|
||||
// Color attribute
|
||||
this._colors = new Float32Array(TOTAL_VOXELS * 3);
|
||||
this._mesh.instanceColor = new THREE.InstancedBufferAttribute(this._colors, 3);
|
||||
|
||||
// Initialize positions
|
||||
const dummy = new THREE.Object3D();
|
||||
const halfX = (GRID_X * VOXEL_SIZE * 1.1) / 2;
|
||||
const halfZ = (GRID_Z * VOXEL_SIZE * 1.1) / 2;
|
||||
|
||||
for (let y = 0; y < GRID_Y; y++) {
|
||||
for (let z = 0; z < GRID_Z; z++) {
|
||||
for (let x = 0; x < GRID_X; x++) {
|
||||
const idx = y * GRID_Z * GRID_X + z * GRID_X + x;
|
||||
dummy.position.set(
|
||||
x * VOXEL_SIZE * 1.1 - halfX,
|
||||
y * VOXEL_SIZE * 1.1,
|
||||
z * VOXEL_SIZE * 1.1 - halfZ
|
||||
);
|
||||
dummy.scale.set(0.01, 0.01, 0.01); // start invisible
|
||||
dummy.updateMatrix();
|
||||
this._mesh.setMatrixAt(idx, dummy.matrix);
|
||||
|
||||
this._colors[idx * 3] = 0;
|
||||
this._colors[idx * 3 + 1] = 0.2;
|
||||
this._colors[idx * 3 + 2] = 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
this._mesh.instanceMatrix.needsUpdate = true;
|
||||
this._mesh.instanceColor.needsUpdate = true;
|
||||
this.group.add(this._mesh);
|
||||
|
||||
// Room wireframe
|
||||
const roomW = GRID_X * VOXEL_SIZE * 1.1;
|
||||
const roomH = GRID_Y * VOXEL_SIZE * 1.1;
|
||||
const roomD = GRID_Z * VOXEL_SIZE * 1.1;
|
||||
const boxGeo = new THREE.BoxGeometry(roomW, roomH, roomD);
|
||||
const edges = new THREE.EdgesGeometry(boxGeo);
|
||||
const lineMat = new THREE.LineBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.15,
|
||||
});
|
||||
const wireframe = new THREE.LineSegments(edges, lineMat);
|
||||
wireframe.position.y = roomH / 2;
|
||||
this.group.add(wireframe);
|
||||
|
||||
// Person lights (up to 4)
|
||||
this._personLights = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const light = new THREE.PointLight(0xff8800, 0, 3);
|
||||
this.group.add(light);
|
||||
this._personLights.push(light);
|
||||
}
|
||||
|
||||
this._dummy = new THREE.Object3D();
|
||||
this._halfX = halfX;
|
||||
this._halfZ = halfZ;
|
||||
}
|
||||
|
||||
update(dt, elapsed, data) {
|
||||
const field = data?.signal_field?.values;
|
||||
const persons = data?.persons || [];
|
||||
|
||||
const dummy = this._dummy;
|
||||
|
||||
if (field && field.length >= GRID_X * GRID_Z) {
|
||||
for (let y = 0; y < GRID_Y; y++) {
|
||||
for (let z = 0; z < GRID_Z; z++) {
|
||||
for (let x = 0; x < GRID_X; x++) {
|
||||
const idx = y * GRID_Z * GRID_X + z * GRID_X + x;
|
||||
const fieldIdx = z * GRID_X + x;
|
||||
const val = field[fieldIdx] || 0;
|
||||
|
||||
// Extrude vertically: layer 0 = full val, higher layers diminish
|
||||
const layerFactor = Math.max(0, 1 - y / GRID_Y);
|
||||
const v = val * layerFactor;
|
||||
|
||||
// Scale voxel by value
|
||||
const s = v > 0.05 ? 0.3 + v * 0.7 : 0.01;
|
||||
dummy.position.set(
|
||||
x * VOXEL_SIZE * 1.1 - this._halfX,
|
||||
y * VOXEL_SIZE * 1.1,
|
||||
z * VOXEL_SIZE * 1.1 - this._halfZ
|
||||
);
|
||||
dummy.scale.set(s, s, s);
|
||||
dummy.updateMatrix();
|
||||
this._mesh.setMatrixAt(idx, dummy.matrix);
|
||||
|
||||
// Color: blue(low) -> cyan(mid) -> amber(high)
|
||||
let r, g, b;
|
||||
if (v < 0.3) {
|
||||
const t = v / 0.3;
|
||||
r = 0.02;
|
||||
g = 0.06 + t * 0.6;
|
||||
b = 0.2 + t * 0.6;
|
||||
} else if (v < 0.6) {
|
||||
const t = (v - 0.3) / 0.3;
|
||||
r = t * 0.8;
|
||||
g = 0.66 + t * 0.2;
|
||||
b = 0.8 - t * 0.5;
|
||||
} else {
|
||||
const t = (v - 0.6) / 0.4;
|
||||
r = 0.8 + t * 0.2;
|
||||
g = 0.86 - t * 0.5;
|
||||
b = 0.3 - t * 0.3;
|
||||
}
|
||||
this._colors[idx * 3] = r;
|
||||
this._colors[idx * 3 + 1] = g;
|
||||
this._colors[idx * 3 + 2] = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
this._mesh.instanceMatrix.needsUpdate = true;
|
||||
this._mesh.instanceColor.needsUpdate = true;
|
||||
}
|
||||
|
||||
// Person lights
|
||||
for (let i = 0; i < this._personLights.length; i++) {
|
||||
const light = this._personLights[i];
|
||||
if (i < persons.length) {
|
||||
const p = persons[i].position || [0, 0, 0];
|
||||
light.position.set(p[0] * 2, 1.5, p[2] * 2);
|
||||
light.intensity = 1.5 + Math.sin(elapsed * 3 + i) * 0.5;
|
||||
light.color.setHex(0xff8800);
|
||||
} else {
|
||||
light.intensity = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reduce voxel count for performance */
|
||||
setQuality(level) {
|
||||
// For now just toggle visibility of upper layers
|
||||
// level 0 = show only ground, 2 = show all
|
||||
this._mesh.count = level === 0
|
||||
? GRID_X * GRID_Z
|
||||
: level === 1
|
||||
? GRID_X * GRID_Z * 2
|
||||
: TOTAL_VOXELS;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._mesh.geometry.dispose();
|
||||
this._mesh.material.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,739 @@
|
|||
/**
|
||||
* ScenarioProps — Scenario-specific room furniture and props
|
||||
*
|
||||
* Extracted from main.js. Builds and manages visibility of all physical
|
||||
* objects that appear/disappear based on the active scenario: bed, chair,
|
||||
* exercise mat, door, rubble wall, screen/TV, desks, security cameras,
|
||||
* and the alert light system.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
// Scenario-to-prop-name mapping
|
||||
const SCENARIO_PROPS = {
|
||||
empty_room: [],
|
||||
single_breathing: [],
|
||||
two_walking: [],
|
||||
fall_event: [],
|
||||
sleep_monitoring: ['bed'],
|
||||
intrusion_detect: ['door'],
|
||||
gesture_control: ['screen'],
|
||||
crowd_occupancy: ['desk', 'desk2'],
|
||||
search_rescue: ['rubbleWall'],
|
||||
elderly_care: ['chair'],
|
||||
fitness_tracking: ['exerciseMat'],
|
||||
security_patrol: ['camera1', 'camera2'],
|
||||
};
|
||||
|
||||
export class ScenarioProps {
|
||||
constructor(scene) {
|
||||
this._scene = scene;
|
||||
this._props = {};
|
||||
this._currentScenario = null;
|
||||
this._alertLight = null;
|
||||
this._alertIntensity = 0;
|
||||
|
||||
// Animatable references
|
||||
this._screenGlow = null;
|
||||
this._camera1Group = null;
|
||||
this._camera2Group = null;
|
||||
this._cam1Cone = null;
|
||||
this._cam2Cone = null;
|
||||
this._cam1Led = null;
|
||||
this._cam2Led = null;
|
||||
this._dustParticles = null;
|
||||
this._doorSpotlight = null;
|
||||
this._alarmHousing = null;
|
||||
this._powerLed = null;
|
||||
|
||||
this._build();
|
||||
}
|
||||
|
||||
// ---- helper: positioned box with shadow ----
|
||||
_box(x, y, z, w, h, d, mat) {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat);
|
||||
m.position.set(x, y, z);
|
||||
m.castShadow = true;
|
||||
m.receiveShadow = true;
|
||||
return m;
|
||||
}
|
||||
|
||||
// ---- helper: positioned cylinder with shadow ----
|
||||
_cyl(x, y, z, rTop, rBot, h, segs, mat) {
|
||||
const m = new THREE.Mesh(new THREE.CylinderGeometry(rTop, rBot, h, segs), mat);
|
||||
m.position.set(x, y, z);
|
||||
m.castShadow = true;
|
||||
m.receiveShadow = true;
|
||||
return m;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// BUILD ALL PROPS
|
||||
// ========================================
|
||||
|
||||
_build() {
|
||||
const darkMat = new THREE.MeshStandardMaterial({ color: 0x6b5840, roughness: 0.6, emissive: 0x1a1408, emissiveIntensity: 0.25 });
|
||||
const metalMat = new THREE.MeshStandardMaterial({ color: 0x808088, roughness: 0.3, metalness: 0.7, emissive: 0x1a1a20, emissiveIntensity: 0.2 });
|
||||
const accentMat = new THREE.MeshStandardMaterial({ color: 0x606070, roughness: 0.4, metalness: 0.4, emissive: 0x101018, emissiveIntensity: 0.15 });
|
||||
|
||||
this._buildBed(darkMat);
|
||||
this._buildChair(darkMat, accentMat);
|
||||
this._buildExerciseMat();
|
||||
this._buildDoor();
|
||||
this._buildRubbleWall();
|
||||
this._buildScreen(metalMat);
|
||||
this._buildDesks(darkMat, metalMat, accentMat);
|
||||
this._buildCameras(metalMat);
|
||||
this._buildAlertSystem();
|
||||
}
|
||||
|
||||
// ---- BED (sleep monitoring) ----
|
||||
_buildBed(darkMat) {
|
||||
const bedGroup = new THREE.Group();
|
||||
|
||||
// Bed frame with legs
|
||||
const frameMat = new THREE.MeshStandardMaterial({ color: 0x7a6448, roughness: 0.55, metalness: 0.25, emissive: 0x181008, emissiveIntensity: 0.25 });
|
||||
const bedFrame = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.12, 1.2), frameMat);
|
||||
bedFrame.position.set(3.5, 0.32, -3.5);
|
||||
bedFrame.castShadow = true;
|
||||
bedGroup.add(bedFrame);
|
||||
|
||||
// Frame legs (4 short posts)
|
||||
for (const [lx, lz] of [[2.5, -4.0], [4.5, -4.0], [2.5, -3.0], [4.5, -3.0]]) {
|
||||
bedGroup.add(this._cyl(lx, 0.13, lz, 0.04, 0.04, 0.26, 6, frameMat));
|
||||
}
|
||||
|
||||
// Headboard — tall panel at head of bed
|
||||
const headboardMat = new THREE.MeshStandardMaterial({ color: 0x6a5440, roughness: 0.65, emissive: 0x140e08, emissiveIntensity: 0.2 });
|
||||
const headboard = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.7, 1.2), headboardMat);
|
||||
headboard.position.set(2.38, 0.65, -3.5);
|
||||
headboard.castShadow = true;
|
||||
bedGroup.add(headboard);
|
||||
|
||||
// Mattress
|
||||
const mattressMat = new THREE.MeshStandardMaterial({ color: 0x484860, roughness: 0.75, emissive: 0x0c0c1a, emissiveIntensity: 0.2 });
|
||||
const mattress = new THREE.Mesh(new THREE.BoxGeometry(2.0, 0.15, 1.1), mattressMat);
|
||||
mattress.position.set(3.5, 0.455, -3.5);
|
||||
mattress.castShadow = true;
|
||||
bedGroup.add(mattress);
|
||||
|
||||
// Wrinkled sheet — wave-displaced plane
|
||||
const sheetGeo = new THREE.PlaneGeometry(1.4, 1.0, 20, 20);
|
||||
const posAttr = sheetGeo.getAttribute('position');
|
||||
for (let i = 0; i < posAttr.count; i++) {
|
||||
const px = posAttr.getX(i);
|
||||
const py = posAttr.getY(i);
|
||||
posAttr.setZ(i, Math.sin(px * 4) * 0.015 + Math.cos(py * 5) * 0.01 + Math.sin(px * py * 3) * 0.008);
|
||||
}
|
||||
posAttr.needsUpdate = true;
|
||||
sheetGeo.computeVertexNormals();
|
||||
const sheetMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x506880, roughness: 0.75, side: THREE.DoubleSide, emissive: 0x0c1018, emissiveIntensity: 0.2,
|
||||
});
|
||||
const sheet = new THREE.Mesh(sheetGeo, sheetMat);
|
||||
sheet.rotation.x = -Math.PI / 2;
|
||||
sheet.position.set(3.7, 0.54, -3.5);
|
||||
sheet.castShadow = true;
|
||||
bedGroup.add(sheet);
|
||||
|
||||
// Pillow — soft shape using scaled sphere
|
||||
const pillowGeo = new THREE.SphereGeometry(0.18, 12, 8);
|
||||
pillowGeo.scale(1, 0.35, 1.4);
|
||||
const pillowMat = new THREE.MeshStandardMaterial({ color: 0x706868, roughness: 0.7, emissive: 0x141010, emissiveIntensity: 0.2 });
|
||||
const pillow = new THREE.Mesh(pillowGeo, pillowMat);
|
||||
pillow.position.set(2.65, 0.52, -3.5);
|
||||
pillow.castShadow = true;
|
||||
bedGroup.add(pillow);
|
||||
|
||||
// Bedside lamp — small cylinder + sphere shade on a tiny table
|
||||
const lampBaseMat = new THREE.MeshStandardMaterial({ color: 0x686870, roughness: 0.3, metalness: 0.7, emissive: 0x101018, emissiveIntensity: 0.15 });
|
||||
// Nightstand
|
||||
bedGroup.add(this._box(2.15, 0.25, -3.5, 0.35, 0.5, 0.35, darkMat));
|
||||
// Lamp base
|
||||
bedGroup.add(this._cyl(2.15, 0.55, -3.5, 0.04, 0.05, 0.1, 8, lampBaseMat));
|
||||
// Lamp stem
|
||||
bedGroup.add(this._cyl(2.15, 0.68, -3.5, 0.015, 0.015, 0.2, 6, lampBaseMat));
|
||||
// Lamp shade (emissive warm glow)
|
||||
const shadeMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x705830, emissive: 0x604018, emissiveIntensity: 1.0, roughness: 0.6,
|
||||
side: THREE.DoubleSide, transparent: true, opacity: 0.9,
|
||||
});
|
||||
const shade = new THREE.Mesh(new THREE.ConeGeometry(0.08, 0.1, 8, 1, true), shadeMat);
|
||||
shade.position.set(2.15, 0.78, -3.5);
|
||||
shade.rotation.x = Math.PI;
|
||||
bedGroup.add(shade);
|
||||
|
||||
// Warm lamp light
|
||||
const lampLight = new THREE.PointLight(0xffcc88, 2.0, 6, 1.2);
|
||||
lampLight.position.set(2.15, 0.78, -3.5);
|
||||
bedGroup.add(lampLight);
|
||||
|
||||
this._props.bed = bedGroup;
|
||||
bedGroup.visible = false;
|
||||
this._scene.add(bedGroup);
|
||||
}
|
||||
|
||||
// ---- CHAIR (elderly care) ----
|
||||
_buildChair(darkMat, accentMat) {
|
||||
const chairGroup = new THREE.Group();
|
||||
chairGroup.position.set(1, 0, -1.5);
|
||||
|
||||
const cushionMat = new THREE.MeshStandardMaterial({ color: 0x5a5078, roughness: 0.7, emissive: 0x10101a, emissiveIntensity: 0.2 });
|
||||
|
||||
// Seat
|
||||
chairGroup.add(this._box(0, 0.45, 0, 0.5, 0.04, 0.45, darkMat));
|
||||
// Seat cushion — slightly puffy
|
||||
const cushionGeo = new THREE.BoxGeometry(0.46, 0.06, 0.42);
|
||||
// Gentle puff on top vertices
|
||||
const cPos = cushionGeo.getAttribute('position');
|
||||
for (let i = 0; i < cPos.count; i++) {
|
||||
if (cPos.getY(i) > 0) {
|
||||
const dx = cPos.getX(i) / 0.23;
|
||||
const dz = cPos.getZ(i) / 0.21;
|
||||
cPos.setY(i, cPos.getY(i) + 0.015 * (1 - dx * dx) * (1 - dz * dz));
|
||||
}
|
||||
}
|
||||
cPos.needsUpdate = true;
|
||||
cushionGeo.computeVertexNormals();
|
||||
const cushion = new THREE.Mesh(cushionGeo, cushionMat);
|
||||
cushion.position.set(0, 0.50, 0);
|
||||
cushion.castShadow = true;
|
||||
chairGroup.add(cushion);
|
||||
|
||||
// Back
|
||||
chairGroup.add(this._box(0, 0.72, -0.22, 0.5, 0.5, 0.04, darkMat));
|
||||
// Legs
|
||||
for (const [lx, lz] of [[-0.22, -0.2], [0.22, -0.2], [-0.22, 0.2], [0.22, 0.2]]) {
|
||||
chairGroup.add(this._box(lx, 0.22, lz, 0.04, 0.44, 0.04, darkMat));
|
||||
}
|
||||
// Armrests
|
||||
chairGroup.add(this._box(-0.28, 0.6, 0, 0.04, 0.04, 0.4, accentMat));
|
||||
chairGroup.add(this._box(0.28, 0.6, 0, 0.04, 0.04, 0.4, accentMat));
|
||||
// Armrest supports
|
||||
chairGroup.add(this._box(-0.28, 0.52, -0.18, 0.04, 0.12, 0.04, accentMat));
|
||||
chairGroup.add(this._box(0.28, 0.52, -0.18, 0.04, 0.12, 0.04, accentMat));
|
||||
|
||||
// Small side table
|
||||
const tableMat = new THREE.MeshStandardMaterial({ color: 0x685840, roughness: 0.55, emissive: 0x14100a, emissiveIntensity: 0.2 });
|
||||
chairGroup.add(this._box(0.65, 0.3, 0, 0.35, 0.03, 0.35, tableMat));
|
||||
// Table legs
|
||||
for (const [tx, tz] of [[0.5, -0.14], [0.8, -0.14], [0.5, 0.14], [0.8, 0.14]]) {
|
||||
chairGroup.add(this._cyl(tx, 0.15, tz, 0.015, 0.015, 0.28, 6, tableMat));
|
||||
}
|
||||
|
||||
this._props.chair = chairGroup;
|
||||
chairGroup.visible = false;
|
||||
this._scene.add(chairGroup);
|
||||
}
|
||||
|
||||
// ---- EXERCISE MAT (fitness tracking) ----
|
||||
_buildExerciseMat() {
|
||||
const matGroup = new THREE.Group();
|
||||
const matMat = new THREE.MeshStandardMaterial({ color: 0x408858, roughness: 0.75, emissive: 0x0c2010, emissiveIntensity: 0.25 });
|
||||
|
||||
// Mat body
|
||||
const exerciseMat = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.015, 0.8), matMat);
|
||||
exerciseMat.position.set(0, 0.008, 0);
|
||||
exerciseMat.receiveShadow = true;
|
||||
matGroup.add(exerciseMat);
|
||||
|
||||
// Boundary lines on the mat (thin strips)
|
||||
const lineMat = new THREE.MeshStandardMaterial({ color: 0x50a068, roughness: 0.7, emissive: 0x102818, emissiveIntensity: 0.3 });
|
||||
// Longitudinal borders
|
||||
matGroup.add(this._box(0, 0.017, -0.37, 1.7, 0.003, 0.02, lineMat));
|
||||
matGroup.add(this._box(0, 0.017, 0.37, 1.7, 0.003, 0.02, lineMat));
|
||||
// Cross lines (exercise area markers)
|
||||
for (const xOff of [-0.6, 0, 0.6]) {
|
||||
matGroup.add(this._box(xOff, 0.017, 0, 0.02, 0.003, 0.74, lineMat));
|
||||
}
|
||||
|
||||
// Water bottle (cylinder body + hemisphere cap)
|
||||
const bottleMat = new THREE.MeshStandardMaterial({ color: 0x4878a8, roughness: 0.2, metalness: 0.7, emissive: 0x0c1828, emissiveIntensity: 0.25 });
|
||||
const bottleBody = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.18, 10), bottleMat);
|
||||
bottleBody.position.set(1.1, 0.09, 0.25);
|
||||
bottleBody.castShadow = true;
|
||||
matGroup.add(bottleBody);
|
||||
const bottleCap = new THREE.Mesh(new THREE.SphereGeometry(0.035, 8, 6, 0, Math.PI * 2, 0, Math.PI / 2), bottleMat);
|
||||
bottleCap.position.set(1.1, 0.18, 0.25);
|
||||
matGroup.add(bottleCap);
|
||||
// Bottle neck
|
||||
const neckMat = new THREE.MeshStandardMaterial({ color: 0x587088, roughness: 0.3, metalness: 0.6, emissive: 0x0c1420, emissiveIntensity: 0.2 });
|
||||
matGroup.add(this._cyl(1.1, 0.21, 0.25, 0.018, 0.025, 0.04, 8, neckMat));
|
||||
|
||||
// Small towel (flat draped box)
|
||||
const towelMat = new THREE.MeshStandardMaterial({ color: 0x686890, roughness: 0.75, emissive: 0x101020, emissiveIntensity: 0.2 });
|
||||
const towel = this._box(1.1, 0.01, -0.25, 0.3, 0.008, 0.15, towelMat);
|
||||
towel.rotation.y = 0.15;
|
||||
matGroup.add(towel);
|
||||
|
||||
this._props.exerciseMat = matGroup;
|
||||
matGroup.visible = false;
|
||||
this._scene.add(matGroup);
|
||||
}
|
||||
|
||||
// ---- DOOR (intrusion detection) ----
|
||||
_buildDoor() {
|
||||
const doorGroup = new THREE.Group();
|
||||
doorGroup.position.set(-5.5, 0, -1);
|
||||
const doorMat = new THREE.MeshStandardMaterial({ color: 0x7a6040, roughness: 0.5, emissive: 0x18140a, emissiveIntensity: 0.25 });
|
||||
const hingeMat = new THREE.MeshStandardMaterial({ color: 0x909098, roughness: 0.2, metalness: 0.85, emissive: 0x181820, emissiveIntensity: 0.15 });
|
||||
|
||||
// Left jamb
|
||||
doorGroup.add(this._box(-0.45, 1.1, 0, 0.08, 2.2, 0.15, doorMat));
|
||||
// Right jamb
|
||||
doorGroup.add(this._box(0.45, 1.1, 0, 0.08, 2.2, 0.15, doorMat));
|
||||
// Top
|
||||
doorGroup.add(this._box(0, 2.2, 0, 0.98, 0.08, 0.15, doorMat));
|
||||
// Door panel (partially open)
|
||||
const doorPanel = new THREE.Mesh(new THREE.BoxGeometry(0.85, 2.1, 0.04), doorMat);
|
||||
doorPanel.position.set(0.2, 1.05, -0.2);
|
||||
doorPanel.rotation.y = -0.7;
|
||||
doorPanel.castShadow = true;
|
||||
doorGroup.add(doorPanel);
|
||||
|
||||
// Door handle (torus)
|
||||
const handleMat = new THREE.MeshStandardMaterial({ color: 0xaaaaB0, roughness: 0.1, metalness: 0.9, emissive: 0x1a1a20, emissiveIntensity: 0.2 });
|
||||
const handle = new THREE.Mesh(new THREE.TorusGeometry(0.035, 0.008, 6, 12), handleMat);
|
||||
// Position on the door panel (relative to panel pivot)
|
||||
handle.position.set(0.48, 1.05, -0.22);
|
||||
handle.rotation.y = -0.7;
|
||||
handle.rotation.x = Math.PI / 2;
|
||||
doorGroup.add(handle);
|
||||
|
||||
// Hinge details — small cylinders at jamb
|
||||
for (const hy of [0.4, 1.1, 1.8]) {
|
||||
const hinge = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.015, 0.06, 6), hingeMat);
|
||||
hinge.position.set(-0.42, hy, 0.06);
|
||||
doorGroup.add(hinge);
|
||||
}
|
||||
|
||||
// Light spill through the gap — spotlight from outside
|
||||
const doorSpot = new THREE.SpotLight(0x88aacc, 3.0, 10, Math.PI / 4, 0.3, 0.6);
|
||||
doorSpot.position.set(-0.8, 1.2, -0.5);
|
||||
doorSpot.target.position.set(0.5, 0, 0.5);
|
||||
doorGroup.add(doorSpot);
|
||||
doorGroup.add(doorSpot.target);
|
||||
this._doorSpotlight = doorSpot;
|
||||
|
||||
// Window next to door — simple frame with translucent pane
|
||||
const windowFrame = new THREE.MeshStandardMaterial({ color: 0x686878, roughness: 0.35, metalness: 0.6, emissive: 0x101018, emissiveIntensity: 0.15 });
|
||||
// Frame
|
||||
doorGroup.add(this._box(1.2, 1.5, 0, 0.04, 0.8, 0.06, windowFrame));
|
||||
doorGroup.add(this._box(1.2, 1.5, 0, 0.6, 0.04, 0.06, windowFrame));
|
||||
doorGroup.add(this._box(0.92, 1.5, 0, 0.04, 0.8, 0.06, windowFrame));
|
||||
doorGroup.add(this._box(1.48, 1.5, 0, 0.04, 0.8, 0.06, windowFrame));
|
||||
doorGroup.add(this._box(1.2, 1.1, 0, 0.6, 0.04, 0.06, windowFrame));
|
||||
doorGroup.add(this._box(1.2, 1.9, 0, 0.6, 0.04, 0.06, windowFrame));
|
||||
// Glass pane
|
||||
const glassMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x305880, transparent: true, opacity: 0.4, roughness: 0.05, metalness: 0.3, emissive: 0x0c1830, emissiveIntensity: 0.35,
|
||||
});
|
||||
const glass = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.72, 0.01), glassMat);
|
||||
glass.position.set(1.2, 1.5, 0);
|
||||
doorGroup.add(glass);
|
||||
|
||||
this._props.door = doorGroup;
|
||||
doorGroup.visible = false;
|
||||
this._scene.add(doorGroup);
|
||||
}
|
||||
|
||||
// ---- RUBBLE WALL (search & rescue) ----
|
||||
_buildRubbleWall() {
|
||||
const rubbleGroup = new THREE.Group();
|
||||
const rubbleMat = new THREE.MeshStandardMaterial({ color: 0x807868, roughness: 0.75, emissive: 0x181610, emissiveIntensity: 0.25 });
|
||||
const rebarMat = new THREE.MeshStandardMaterial({ color: 0x8a7858, roughness: 0.4, metalness: 0.7, emissive: 0x1a1408, emissiveIntensity: 0.2 });
|
||||
|
||||
// Broken wall — main slab
|
||||
rubbleGroup.add(this._box(2, 1, 0, 0.4, 2, 3, rubbleMat));
|
||||
|
||||
// Wall crack lines (thin dark boxes embedded in wall surface)
|
||||
const crackMat = new THREE.MeshStandardMaterial({ color: 0x403828, roughness: 0.9 });
|
||||
const cracks = [
|
||||
[1.82, 1.4, -0.3, 0.01, 0.6, 0.02, 0.3],
|
||||
[1.82, 0.8, 0.5, 0.01, 0.5, 0.02, -0.2],
|
||||
[1.82, 1.6, 0.8, 0.01, 0.4, 0.02, 0.15],
|
||||
[1.82, 0.5, -0.7, 0.01, 0.35, 0.02, -0.25],
|
||||
];
|
||||
for (const [cx, cy, cz, cw, ch, cd, rot] of cracks) {
|
||||
const crack = this._box(cx, cy, cz, cw, ch, cd, crackMat);
|
||||
crack.rotation.z = rot;
|
||||
rubbleGroup.add(crack);
|
||||
}
|
||||
|
||||
// Rebar — thin metal cylinders protruding from the wall
|
||||
for (const [rx, ry, rz, rLen, rRot] of [
|
||||
[1.6, 1.7, -0.4, 0.8, 0.3],
|
||||
[1.5, 1.2, 0.6, 0.6, -0.2],
|
||||
[1.7, 0.9, -0.8, 0.5, 0.5],
|
||||
[1.55, 1.5, 1.0, 0.7, -0.4],
|
||||
]) {
|
||||
const rebar = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, rLen, 6), rebarMat);
|
||||
rebar.position.set(rx, ry, rz);
|
||||
rebar.rotation.z = Math.PI / 2 + rRot;
|
||||
rebar.rotation.y = rRot * 0.5;
|
||||
rebar.castShadow = true;
|
||||
rubbleGroup.add(rebar);
|
||||
}
|
||||
|
||||
// Rubble pieces — more varied with random rotations
|
||||
const rubbleColors = [0x807868, 0x706860, 0x908878, 0x686058];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const s = 0.12 + Math.random() * 0.3;
|
||||
const rMat = new THREE.MeshStandardMaterial({
|
||||
color: rubbleColors[i % rubbleColors.length], roughness: 0.7 + Math.random() * 0.15,
|
||||
emissive: 0x141210, emissiveIntensity: 0.2,
|
||||
});
|
||||
const piece = this._box(
|
||||
1.3 + Math.random() * 1.4, s / 2, -1.5 + Math.random() * 3,
|
||||
s, s * (0.4 + Math.random() * 0.5), s * (0.6 + Math.random() * 0.4), rMat
|
||||
);
|
||||
piece.rotation.x = (Math.random() - 0.5) * 0.6;
|
||||
piece.rotation.y = (Math.random() - 0.5) * 1.2;
|
||||
piece.rotation.z = (Math.random() - 0.5) * 0.4;
|
||||
rubbleGroup.add(piece);
|
||||
}
|
||||
|
||||
// Dust particles near rubble
|
||||
const dustCount = 60;
|
||||
const dustGeo = new THREE.BufferGeometry();
|
||||
const dustPositions = new Float32Array(dustCount * 3);
|
||||
for (let i = 0; i < dustCount; i++) {
|
||||
dustPositions[i * 3] = 1.0 + Math.random() * 2.0;
|
||||
dustPositions[i * 3 + 1] = Math.random() * 2.5;
|
||||
dustPositions[i * 3 + 2] = -1.5 + Math.random() * 3.0;
|
||||
}
|
||||
dustGeo.setAttribute('position', new THREE.BufferAttribute(dustPositions, 3));
|
||||
const dustMaterial = new THREE.PointsMaterial({
|
||||
color: 0xaa9988, size: 0.03, transparent: true, opacity: 0.5,
|
||||
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||
});
|
||||
this._dustParticles = new THREE.Points(dustGeo, dustMaterial);
|
||||
rubbleGroup.add(this._dustParticles);
|
||||
|
||||
this._props.rubbleWall = rubbleGroup;
|
||||
rubbleGroup.visible = false;
|
||||
this._scene.add(rubbleGroup);
|
||||
}
|
||||
|
||||
// ---- SCREEN / TV (gesture control) ----
|
||||
_buildScreen(metalMat) {
|
||||
const screenGroup = new THREE.Group();
|
||||
const screenFrame = new THREE.MeshStandardMaterial({ color: 0x484850, roughness: 0.2, metalness: 0.7, emissive: 0x0c0c14, emissiveIntensity: 0.15 });
|
||||
|
||||
// Frame
|
||||
screenGroup.add(this._box(0, 1.5, -4.7, 1.8, 1.1, 0.06, screenFrame));
|
||||
// Screen surface (emissive, color shifts in update())
|
||||
const screenSurfMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x1a3868, emissive: 0x1a3868, emissiveIntensity: 1.2, roughness: 0.1,
|
||||
});
|
||||
const screenSurf = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.9, 0.02), screenSurfMat);
|
||||
screenSurf.position.set(0, 1.5, -4.66);
|
||||
screenGroup.add(screenSurf);
|
||||
this._screenGlow = screenSurfMat;
|
||||
|
||||
// Stand / mount — neck + base
|
||||
screenGroup.add(this._box(0, 0.88, -4.7, 0.08, 0.16, 0.08, screenFrame));
|
||||
screenGroup.add(this._box(0, 0.78, -4.7, 0.4, 0.03, 0.2, metalMat));
|
||||
|
||||
// Power LED indicator
|
||||
const ledMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x00ff40, emissive: 0x00ff40, emissiveIntensity: 1.0,
|
||||
});
|
||||
const powerLed = new THREE.Mesh(new THREE.SphereGeometry(0.012, 6, 4), ledMat);
|
||||
powerLed.position.set(0.82, 0.96, -4.66);
|
||||
screenGroup.add(powerLed);
|
||||
this._powerLed = ledMat;
|
||||
|
||||
// Subtle screen glow (point light)
|
||||
const screenLight = new THREE.PointLight(0x4080e0, 1.5, 6);
|
||||
screenLight.position.set(0, 1.5, -4.5);
|
||||
screenGroup.add(screenLight);
|
||||
|
||||
// Media console below the screen
|
||||
const consoleMat = new THREE.MeshStandardMaterial({ color: 0x484858, roughness: 0.45, metalness: 0.5, emissive: 0x0c0c14, emissiveIntensity: 0.15 });
|
||||
screenGroup.add(this._box(0, 0.55, -4.7, 1.2, 0.35, 0.35, consoleMat));
|
||||
// Console shelf divider
|
||||
screenGroup.add(this._box(0, 0.55, -4.54, 1.1, 0.02, 0.01, metalMat));
|
||||
|
||||
this._props.screen = screenGroup;
|
||||
screenGroup.visible = false;
|
||||
this._scene.add(screenGroup);
|
||||
}
|
||||
|
||||
// ---- DESKS (crowd / office) ----
|
||||
_buildDesks(darkMat, metalMat, accentMat) {
|
||||
// Desk 1 (left)
|
||||
const deskGroup = new THREE.Group();
|
||||
deskGroup.add(this._box(-2, 0.38, -1, 1.2, 0.04, 0.6, darkMat));
|
||||
for (const [lx, lz] of [[-2.55, -1.25], [-1.45, -1.25], [-2.55, -0.75], [-1.45, -0.75]]) {
|
||||
deskGroup.add(this._box(lx, 0.19, lz, 0.04, 0.38, 0.04, darkMat));
|
||||
}
|
||||
// Monitor on desk 1
|
||||
const monitorMat = new THREE.MeshStandardMaterial({ color: 0x484850, roughness: 0.2, metalness: 0.7, emissive: 0x0c0c14, emissiveIntensity: 0.15 });
|
||||
const monScreenMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x183858, emissive: 0x183858, emissiveIntensity: 1.0, roughness: 0.1,
|
||||
});
|
||||
deskGroup.add(this._box(-2, 0.62, -1.15, 0.5, 0.35, 0.03, monitorMat));
|
||||
deskGroup.add(this._box(-2, 0.62, -1.13, 0.44, 0.29, 0.01, monScreenMat));
|
||||
deskGroup.add(this._box(-2, 0.42, -1.1, 0.06, 0.04, 0.06, metalMat)); // stand neck
|
||||
deskGroup.add(this._box(-2, 0.40, -1.05, 0.18, 0.01, 0.12, metalMat)); // stand base
|
||||
// Keyboard outline
|
||||
deskGroup.add(this._box(-2, 0.405, -0.85, 0.35, 0.008, 0.12, accentMat));
|
||||
// Office chair at desk 1
|
||||
this._buildOfficeChair(deskGroup, -2, -0.55, darkMat, metalMat);
|
||||
|
||||
// Monitor glow light
|
||||
const monLight = new THREE.PointLight(0x4080e0, 1.2, 4);
|
||||
monLight.position.set(-2, 0.7, -1.0);
|
||||
deskGroup.add(monLight);
|
||||
|
||||
this._props.desk = deskGroup;
|
||||
deskGroup.visible = false;
|
||||
this._scene.add(deskGroup);
|
||||
|
||||
// Desk 2 (right)
|
||||
const desk2Group = new THREE.Group();
|
||||
desk2Group.add(this._box(2, 0.38, 1, 1.0, 0.04, 0.6, darkMat));
|
||||
for (const [lx, lz] of [[1.45, 0.75], [2.55, 0.75], [1.45, 1.25], [2.55, 1.25]]) {
|
||||
desk2Group.add(this._box(lx, 0.19, lz, 0.04, 0.38, 0.04, darkMat));
|
||||
}
|
||||
// Monitor on desk 2
|
||||
desk2Group.add(this._box(2, 0.62, 1.15, 0.5, 0.35, 0.03, monitorMat));
|
||||
desk2Group.add(this._box(2, 0.62, 1.17, 0.44, 0.29, 0.01, monScreenMat));
|
||||
desk2Group.add(this._box(2, 0.42, 1.1, 0.06, 0.04, 0.06, metalMat));
|
||||
desk2Group.add(this._box(2, 0.40, 1.05, 0.18, 0.01, 0.12, metalMat));
|
||||
// Keyboard
|
||||
desk2Group.add(this._box(2, 0.405, 0.85, 0.35, 0.008, 0.12, accentMat));
|
||||
// Office chair at desk 2
|
||||
this._buildOfficeChair(desk2Group, 2, 0.55, darkMat, metalMat);
|
||||
|
||||
// Water cooler / plant between desks area
|
||||
const plantMat = new THREE.MeshStandardMaterial({ color: 0x2a7838, roughness: 0.7, emissive: 0x0c2810, emissiveIntensity: 0.3 });
|
||||
const potMat = new THREE.MeshStandardMaterial({ color: 0x706858, roughness: 0.6, emissive: 0x14120c, emissiveIntensity: 0.15 });
|
||||
desk2Group.add(this._cyl(3.2, 0.15, 0, 0.12, 0.1, 0.3, 8, potMat));
|
||||
// Foliage — cluster of small spheres
|
||||
for (const [fx, fy, fz] of [[3.2, 0.45, 0], [3.15, 0.4, 0.06], [3.25, 0.42, -0.05]]) {
|
||||
const leaf = new THREE.Mesh(new THREE.SphereGeometry(0.08, 6, 5), plantMat);
|
||||
leaf.position.set(fx, fy, fz);
|
||||
desk2Group.add(leaf);
|
||||
}
|
||||
|
||||
// Monitor glow light
|
||||
const monLight2 = new THREE.PointLight(0x4080e0, 1.2, 4);
|
||||
monLight2.position.set(2, 0.7, 1.0);
|
||||
desk2Group.add(monLight2);
|
||||
|
||||
this._props.desk2 = desk2Group;
|
||||
desk2Group.visible = false;
|
||||
this._scene.add(desk2Group);
|
||||
}
|
||||
|
||||
// Helper: small office chair
|
||||
_buildOfficeChair(parent, x, z, darkMat, metalMat) {
|
||||
// Seat
|
||||
parent.add(this._box(x, 0.38, z, 0.35, 0.03, 0.35, darkMat));
|
||||
// Backrest
|
||||
parent.add(this._box(x, 0.55, z - 0.16, 0.32, 0.3, 0.03, darkMat));
|
||||
// Central post
|
||||
parent.add(this._cyl(x, 0.22, z, 0.025, 0.025, 0.28, 6, metalMat));
|
||||
// Base star (5 legs)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const angle = (i / 5) * Math.PI * 2;
|
||||
const legLen = 0.16;
|
||||
const leg = this._box(
|
||||
x + Math.cos(angle) * legLen * 0.5, 0.04, z + Math.sin(angle) * legLen * 0.5,
|
||||
legLen, 0.015, 0.025, metalMat
|
||||
);
|
||||
leg.rotation.y = -angle;
|
||||
parent.add(leg);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SECURITY CAMERAS (patrol) ----
|
||||
_buildCameras(metalMat) {
|
||||
const camData = [
|
||||
['camera1', [5, 3.5, -4.5]],
|
||||
['camera2', [-5, 3.5, 4.5]],
|
||||
];
|
||||
|
||||
for (const [name, pos] of camData) {
|
||||
const camGroup = new THREE.Group();
|
||||
camGroup.position.set(...pos);
|
||||
|
||||
// Camera body
|
||||
camGroup.add(this._box(0, 0, 0, 0.15, 0.1, 0.2, metalMat));
|
||||
|
||||
// Lens
|
||||
const lens = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.08, 8), metalMat);
|
||||
lens.rotation.x = Math.PI / 2;
|
||||
lens.position.z = 0.14;
|
||||
camGroup.add(lens);
|
||||
|
||||
// Bracket / mount arm
|
||||
camGroup.add(this._box(0, 0.1, -0.08, 0.04, 0.2, 0.04, metalMat));
|
||||
|
||||
// Rotating motor housing (visible joint)
|
||||
const motorMat = new THREE.MeshStandardMaterial({ color: 0x686870, roughness: 0.35, metalness: 0.8, emissive: 0x141418, emissiveIntensity: 0.15 });
|
||||
const motor = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.04, 8), motorMat);
|
||||
motor.position.set(0, 0.05, -0.08);
|
||||
camGroup.add(motor);
|
||||
|
||||
// FOV cone (semi-transparent)
|
||||
const coneMat = new THREE.MeshStandardMaterial({
|
||||
color: 0xff3040, transparent: true, opacity: 0.15,
|
||||
side: THREE.DoubleSide, depthWrite: false,
|
||||
emissive: 0xff2020, emissiveIntensity: 0.3,
|
||||
});
|
||||
const cone = new THREE.Mesh(new THREE.ConeGeometry(1.5, 3, 16, 1, true), coneMat);
|
||||
cone.rotation.x = Math.PI / 2;
|
||||
cone.position.z = 1.7;
|
||||
camGroup.add(cone);
|
||||
|
||||
// Status LED (blinks in update)
|
||||
const ledMat = new THREE.MeshStandardMaterial({
|
||||
color: 0xff2020, emissive: 0xff2020, emissiveIntensity: 1.0,
|
||||
});
|
||||
const led = new THREE.Mesh(new THREE.SphereGeometry(0.015, 6, 4), ledMat);
|
||||
led.position.set(0.08, 0.04, 0.08);
|
||||
camGroup.add(led);
|
||||
|
||||
this._props[name] = camGroup;
|
||||
camGroup.visible = false;
|
||||
this._scene.add(camGroup);
|
||||
|
||||
// Store references for animation
|
||||
if (name === 'camera1') {
|
||||
this._camera1Group = camGroup;
|
||||
this._cam1Cone = cone;
|
||||
this._cam1Led = ledMat;
|
||||
} else {
|
||||
this._camera2Group = camGroup;
|
||||
this._cam2Cone = cone;
|
||||
this._cam2Led = ledMat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ALERT SYSTEM ----
|
||||
_buildAlertSystem() {
|
||||
// Main alert point light
|
||||
this._alertLight = new THREE.PointLight(0xff3040, 0, 10);
|
||||
this._alertLight.position.set(0, 3.5, 0);
|
||||
this._scene.add(this._alertLight);
|
||||
|
||||
// Ceiling-mounted alarm housing
|
||||
const housingMat = new THREE.MeshStandardMaterial({ color: 0x686878, roughness: 0.35, metalness: 0.6, emissive: 0x101018, emissiveIntensity: 0.15 });
|
||||
const housing = new THREE.Group();
|
||||
// Base plate
|
||||
housing.add(this._box(0, 3.95, 0, 0.2, 0.02, 0.2, housingMat));
|
||||
// Housing body
|
||||
housing.add(this._cyl(0, 3.85, 0, 0.08, 0.1, 0.16, 8, housingMat));
|
||||
// Alarm lens (red when active, dark when inactive)
|
||||
const lensMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x330808, emissive: 0x000000, emissiveIntensity: 0, roughness: 0.2,
|
||||
transparent: true, opacity: 0.8,
|
||||
});
|
||||
const alarmLens = new THREE.Mesh(new THREE.SphereGeometry(0.06, 10, 8, 0, Math.PI * 2, 0, Math.PI / 2), lensMat);
|
||||
alarmLens.position.set(0, 3.76, 0);
|
||||
alarmLens.rotation.x = Math.PI;
|
||||
housing.add(alarmLens);
|
||||
|
||||
this._alarmHousing = housing;
|
||||
this._alarmLensMat = lensMat;
|
||||
this._scene.add(housing);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// UPDATE (called every frame)
|
||||
// ========================================
|
||||
|
||||
update(data, currentScenario) {
|
||||
const scenario = data?.scenario || currentScenario;
|
||||
const elapsed = Date.now() * 0.001;
|
||||
|
||||
// Switch visible props when scenario changes
|
||||
if (scenario !== this._currentScenario) {
|
||||
this._currentScenario = scenario;
|
||||
for (const prop of Object.values(this._props)) prop.visible = false;
|
||||
const propsToShow = SCENARIO_PROPS[scenario] || [];
|
||||
for (const name of propsToShow) {
|
||||
if (this._props[name]) this._props[name].visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Alert light (fall / intrusion) ---
|
||||
const cls = data?.classification || {};
|
||||
if (cls.fall_detected || cls.intrusion) {
|
||||
this._alertIntensity = Math.min(2, this._alertIntensity + 0.1);
|
||||
} else {
|
||||
this._alertIntensity = Math.max(0, this._alertIntensity - 0.05);
|
||||
}
|
||||
// Sawtooth pattern for urgency instead of smooth sine
|
||||
const alertPhase = (elapsed * 3) % 1.0;
|
||||
const sawtooth = alertPhase < 0.5 ? alertPhase * 2 : 2 - alertPhase * 2;
|
||||
this._alertLight.intensity = this._alertIntensity * sawtooth;
|
||||
|
||||
// Alarm housing lens glow tracks alert
|
||||
if (this._alarmLensMat) {
|
||||
const alertFrac = Math.min(this._alertIntensity / 2, 1);
|
||||
this._alarmLensMat.emissive.setHex(alertFrac > 0.05 ? 0xff2020 : 0x000000);
|
||||
this._alarmLensMat.emissiveIntensity = alertFrac * sawtooth;
|
||||
}
|
||||
|
||||
// Subtle ambient color shift during alerts
|
||||
if (this._alertIntensity > 0.1 && this._alertLight) {
|
||||
const r = 0.08 + 0.04 * sawtooth * this._alertIntensity;
|
||||
const g = 0.05 - 0.02 * this._alertIntensity;
|
||||
const b = 0.10 - 0.04 * this._alertIntensity;
|
||||
// Shift the alert light color slightly over time
|
||||
this._alertLight.color.setRGB(
|
||||
Math.max(0, Math.min(1, 1.0)),
|
||||
Math.max(0, Math.min(1, 0.15 - 0.1 * sawtooth)),
|
||||
Math.max(0, Math.min(1, 0.2 - 0.15 * sawtooth))
|
||||
);
|
||||
} else if (this._alertLight) {
|
||||
this._alertLight.color.setHex(0xff3040);
|
||||
}
|
||||
|
||||
// --- Camera rotation animation ---
|
||||
if (this._camera1Group && this._camera1Group.visible) {
|
||||
this._camera1Group.rotation.y = Math.sin(elapsed * 0.4) * 0.5;
|
||||
}
|
||||
if (this._camera2Group && this._camera2Group.visible) {
|
||||
this._camera2Group.rotation.y = Math.sin(elapsed * 0.4 + Math.PI) * 0.5;
|
||||
}
|
||||
|
||||
// Camera LED blink
|
||||
if (this._cam1Led && this._camera1Group?.visible) {
|
||||
this._cam1Led.emissiveIntensity = (Math.sin(elapsed * 4) > 0.3) ? 1.0 : 0.1;
|
||||
}
|
||||
if (this._cam2Led && this._camera2Group?.visible) {
|
||||
this._cam2Led.emissiveIntensity = (Math.sin(elapsed * 4 + 1) > 0.3) ? 1.0 : 0.1;
|
||||
}
|
||||
|
||||
// --- Screen glow color shift ---
|
||||
if (this._screenGlow && this._props.screen?.visible) {
|
||||
const hue = (elapsed * 0.03) % 1;
|
||||
const r = 0.10 + 0.06 * Math.sin(hue * Math.PI * 2);
|
||||
const g = 0.16 + 0.08 * Math.sin(hue * Math.PI * 2 + 2.1);
|
||||
const b = 0.28 + 0.12 * Math.sin(hue * Math.PI * 2 + 4.2);
|
||||
this._screenGlow.emissive.setRGB(r, g, b);
|
||||
}
|
||||
|
||||
// Power LED gentle pulse
|
||||
if (this._powerLed && this._props.screen?.visible) {
|
||||
this._powerLed.emissiveIntensity = 0.5 + 0.5 * Math.sin(elapsed * 2);
|
||||
}
|
||||
|
||||
// --- Dust particle drift near rubble ---
|
||||
if (this._dustParticles && this._props.rubbleWall?.visible) {
|
||||
const dPos = this._dustParticles.geometry.getAttribute('position');
|
||||
for (let i = 0; i < dPos.count; i++) {
|
||||
let y = dPos.getY(i) + 0.002 * Math.sin(elapsed + i);
|
||||
if (y > 2.5) y = 0;
|
||||
dPos.setY(i, y);
|
||||
dPos.setX(i, dPos.getX(i) + Math.sin(elapsed * 0.5 + i * 0.3) * 0.0005);
|
||||
}
|
||||
dPos.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Module A — "The Subcarrier Manifold"
|
||||
* 3D scrolling surface: 64 subcarriers x 60 time slots
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const MANIFOLD_VERTEX = `
|
||||
attribute float aHeight;
|
||||
attribute float aAge; // 0 = newest, 1 = oldest
|
||||
varying float vHeight;
|
||||
varying float vAge;
|
||||
void main() {
|
||||
vec3 pos = position;
|
||||
pos.y += aHeight * 2.0;
|
||||
vHeight = aHeight;
|
||||
vAge = aAge;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const MANIFOLD_FRAGMENT = `
|
||||
uniform float uTime;
|
||||
varying float vHeight;
|
||||
varying float vAge;
|
||||
void main() {
|
||||
// Color map: low=deep blue, mid=cyan, high=amber
|
||||
vec3 lo = vec3(0.02, 0.06, 0.2);
|
||||
vec3 mid = vec3(0.0, 0.83, 1.0);
|
||||
vec3 hi = vec3(1.0, 0.53, 0.0);
|
||||
|
||||
float h = clamp(vHeight, 0.0, 1.0);
|
||||
vec3 col = h < 0.5
|
||||
? mix(lo, mid, h * 2.0)
|
||||
: mix(mid, hi, (h - 0.5) * 2.0);
|
||||
|
||||
// Fade older rows
|
||||
float alpha = 0.3 + 0.7 * (1.0 - vAge);
|
||||
gl_FragColor = vec4(col, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
const SUBS = 64;
|
||||
const TIME_SLOTS = 60;
|
||||
|
||||
export class SubcarrierManifold {
|
||||
constructor(scene, panelGroup) {
|
||||
this.group = new THREE.Group();
|
||||
if (panelGroup) panelGroup.add(this.group);
|
||||
else scene.add(this.group);
|
||||
|
||||
this._history = []; // ring buffer of Float32Array[64]
|
||||
for (let i = 0; i < TIME_SLOTS; i++) {
|
||||
this._history.push(new Float32Array(SUBS));
|
||||
}
|
||||
this._head = 0;
|
||||
|
||||
// Build surface geometry
|
||||
const geo = new THREE.PlaneGeometry(8, 5, SUBS - 1, TIME_SLOTS - 1);
|
||||
const vertCount = SUBS * TIME_SLOTS;
|
||||
|
||||
this._heights = new Float32Array(vertCount);
|
||||
this._ages = new Float32Array(vertCount);
|
||||
for (let t = 0; t < TIME_SLOTS; t++) {
|
||||
for (let s = 0; s < SUBS; s++) {
|
||||
this._ages[t * SUBS + s] = t / TIME_SLOTS;
|
||||
}
|
||||
}
|
||||
|
||||
geo.setAttribute('aHeight', new THREE.BufferAttribute(this._heights, 1));
|
||||
geo.setAttribute('aAge', new THREE.BufferAttribute(this._ages, 1));
|
||||
|
||||
// Solid surface
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: MANIFOLD_VERTEX,
|
||||
fragmentShader: MANIFOLD_FRAGMENT,
|
||||
uniforms: { uTime: { value: 0 } },
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this._mesh = new THREE.Mesh(geo, mat);
|
||||
this._mesh.rotation.x = -Math.PI * 0.35;
|
||||
this.group.add(this._mesh);
|
||||
|
||||
// Wireframe overlay
|
||||
const wireGeo = geo.clone();
|
||||
wireGeo.setAttribute('aHeight', new THREE.BufferAttribute(this._heights, 1));
|
||||
wireGeo.setAttribute('aAge', new THREE.BufferAttribute(this._ages, 1));
|
||||
const wireMat = new THREE.ShaderMaterial({
|
||||
vertexShader: MANIFOLD_VERTEX,
|
||||
fragmentShader: `
|
||||
varying float vHeight;
|
||||
varying float vAge;
|
||||
void main() {
|
||||
float alpha = 0.15 * (1.0 - vAge);
|
||||
gl_FragColor = vec4(0.0, 0.83, 1.0, alpha);
|
||||
}
|
||||
`,
|
||||
uniforms: { uTime: { value: 0 } },
|
||||
transparent: true,
|
||||
wireframe: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this._wire = new THREE.Mesh(wireGeo, wireMat);
|
||||
this._wire.rotation.x = -Math.PI * 0.35;
|
||||
this.group.add(this._wire);
|
||||
|
||||
this._frameAccum = 0;
|
||||
this._pushInterval = 1 / 15; // push ~15 rows/sec
|
||||
}
|
||||
|
||||
update(dt, elapsed, data) {
|
||||
this._mesh.material.uniforms.uTime.value = elapsed;
|
||||
|
||||
// Push new amplitude data at regular intervals
|
||||
this._frameAccum += dt;
|
||||
if (this._frameAccum >= this._pushInterval && data) {
|
||||
this._frameAccum = 0;
|
||||
|
||||
const amp = data.nodes?.[0]?.amplitude;
|
||||
const row = new Float32Array(SUBS);
|
||||
if (amp && amp.length > 0) {
|
||||
for (let i = 0; i < SUBS; i++) {
|
||||
row[i] = amp[i % amp.length] || 0;
|
||||
}
|
||||
}
|
||||
|
||||
this._history[this._head] = row;
|
||||
this._head = (this._head + 1) % TIME_SLOTS;
|
||||
|
||||
this._rebuildHeights();
|
||||
}
|
||||
}
|
||||
|
||||
_rebuildHeights() {
|
||||
for (let t = 0; t < TIME_SLOTS; t++) {
|
||||
const histIdx = (this._head + t) % TIME_SLOTS;
|
||||
const row = this._history[histIdx];
|
||||
for (let s = 0; s < SUBS; s++) {
|
||||
const idx = t * SUBS + s;
|
||||
this._heights[idx] = row[s];
|
||||
this._ages[idx] = t / TIME_SLOTS;
|
||||
}
|
||||
}
|
||||
|
||||
const geo = this._mesh.geometry;
|
||||
geo.attributes.aHeight.needsUpdate = true;
|
||||
geo.attributes.aAge.needsUpdate = true;
|
||||
|
||||
const wGeo = this._wire.geometry;
|
||||
wGeo.attributes.aHeight.needsUpdate = true;
|
||||
wGeo.attributes.aAge.needsUpdate = true;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._mesh.geometry.dispose();
|
||||
this._mesh.material.dispose();
|
||||
this._wire.geometry.dispose();
|
||||
this._wire.material.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Module B — "Vital Signs Oracle"
|
||||
* Breathing/HR as orbital torus rings with beat markers + trail particles
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class VitalsOracle {
|
||||
constructor(scene, panelGroup) {
|
||||
this.group = new THREE.Group();
|
||||
if (panelGroup) panelGroup.add(this.group);
|
||||
else scene.add(this.group);
|
||||
|
||||
// Outer torus — breathing (violet)
|
||||
const breathGeo = new THREE.TorusGeometry(1.8, 0.06, 16, 64);
|
||||
this._breathMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x8844ff,
|
||||
transparent: true,
|
||||
opacity: 0.7,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
this._breathRing = new THREE.Mesh(breathGeo, this._breathMat);
|
||||
this._breathRing.rotation.x = Math.PI * 0.4;
|
||||
this.group.add(this._breathRing);
|
||||
|
||||
// Inner torus — heart rate (crimson)
|
||||
const hrGeo = new THREE.TorusGeometry(1.2, 0.04, 16, 64);
|
||||
this._hrMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff2244,
|
||||
transparent: true,
|
||||
opacity: 0.6,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
this._hrRing = new THREE.Mesh(hrGeo, this._hrMat);
|
||||
this._hrRing.rotation.x = Math.PI * 0.5;
|
||||
this._hrRing.rotation.z = Math.PI * 0.15;
|
||||
this.group.add(this._hrRing);
|
||||
|
||||
// Center orb
|
||||
const orbGeo = new THREE.SphereGeometry(0.35, 24, 24);
|
||||
this._orbMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this._orb = new THREE.Mesh(orbGeo, this._orbMat);
|
||||
this.group.add(this._orb);
|
||||
|
||||
// Bloom point light
|
||||
this._light = new THREE.PointLight(0x00d4ff, 1.5, 8);
|
||||
this.group.add(this._light);
|
||||
|
||||
// Trail particles along breathing ring
|
||||
const trailCount = 120;
|
||||
const trailGeo = new THREE.BufferGeometry();
|
||||
const trailPos = new Float32Array(trailCount * 3);
|
||||
const trailSizes = new Float32Array(trailCount);
|
||||
for (let i = 0; i < trailCount; i++) {
|
||||
const angle = (i / trailCount) * Math.PI * 2;
|
||||
trailPos[i * 3] = Math.cos(angle) * 1.8;
|
||||
trailPos[i * 3 + 1] = 0;
|
||||
trailPos[i * 3 + 2] = Math.sin(angle) * 1.8;
|
||||
trailSizes[i] = 3;
|
||||
}
|
||||
trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPos, 3));
|
||||
trailGeo.setAttribute('size', new THREE.BufferAttribute(trailSizes, 1));
|
||||
|
||||
const trailMat = new THREE.PointsMaterial({
|
||||
color: 0x8844ff,
|
||||
size: 0.08,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
this._trails = new THREE.Points(trailGeo, trailMat);
|
||||
this._trails.rotation.x = Math.PI * 0.4;
|
||||
this.group.add(this._trails);
|
||||
|
||||
// Beat flash sprites
|
||||
this._beatFlash = this._createBeatSprite(0xff2244);
|
||||
this.group.add(this._beatFlash);
|
||||
this._beatTimer = 0;
|
||||
this._lastBeatTime = 0;
|
||||
|
||||
// State
|
||||
this._breathBpm = 0;
|
||||
this._hrBpm = 0;
|
||||
this._breathConf = 0;
|
||||
this._hrConf = 0;
|
||||
}
|
||||
|
||||
_createBeatSprite(color) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
|
||||
gradient.addColorStop(0, `rgba(255, 34, 68, 1)`);
|
||||
gradient.addColorStop(0.3, `rgba(255, 34, 68, 0.5)`);
|
||||
gradient.addColorStop(1, `rgba(255, 34, 68, 0)`);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, 64, 64);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const mat = new THREE.SpriteMaterial({
|
||||
map: tex,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sprite = new THREE.Sprite(mat);
|
||||
sprite.scale.set(0, 0, 0);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
update(dt, elapsed, data) {
|
||||
const vs = data?.vital_signs || {};
|
||||
this._breathBpm = vs.breathing_rate_bpm || 0;
|
||||
this._hrBpm = vs.heart_rate_bpm || 0;
|
||||
this._breathConf = vs.breathing_confidence || 0;
|
||||
this._hrConf = vs.heart_rate_confidence || 0;
|
||||
|
||||
// Breathing ring pulsation
|
||||
const breathFreq = this._breathBpm / 60;
|
||||
const breathPulse = breathFreq > 0 ? Math.sin(elapsed * Math.PI * 2 * breathFreq) : 0;
|
||||
const breathScale = 1.0 + breathPulse * 0.08 * this._breathConf;
|
||||
this._breathRing.scale.set(breathScale, breathScale, 1);
|
||||
this._breathMat.opacity = 0.3 + this._breathConf * 0.5;
|
||||
|
||||
// HR ring pulsation (faster)
|
||||
const hrFreq = this._hrBpm / 60;
|
||||
const hrPulse = hrFreq > 0 ? Math.sin(elapsed * Math.PI * 2 * hrFreq) : 0;
|
||||
const hrScale = 1.0 + hrPulse * 0.06 * this._hrConf;
|
||||
this._hrRing.scale.set(hrScale, hrScale, 1);
|
||||
this._hrMat.opacity = 0.2 + this._hrConf * 0.5;
|
||||
|
||||
// Slow rotation
|
||||
this._breathRing.rotation.z = elapsed * 0.1;
|
||||
this._hrRing.rotation.z = -elapsed * 0.15;
|
||||
this._trails.rotation.z = elapsed * 0.1;
|
||||
|
||||
// Center orb pulse
|
||||
const orbPulse = 1.0 + breathPulse * 0.1;
|
||||
this._orb.scale.set(orbPulse, orbPulse, orbPulse);
|
||||
this._light.intensity = 0.8 + Math.abs(breathPulse) * 1.0;
|
||||
|
||||
// Beat flash on HR cycle
|
||||
if (hrFreq > 0) {
|
||||
this._beatTimer += dt;
|
||||
const beatInterval = 1 / hrFreq;
|
||||
if (this._beatTimer >= beatInterval) {
|
||||
this._beatTimer -= beatInterval;
|
||||
this._lastBeatTime = elapsed;
|
||||
}
|
||||
const beatAge = elapsed - this._lastBeatTime;
|
||||
const flashSize = Math.max(0, 1.2 - beatAge * 4) * this._hrConf;
|
||||
this._beatFlash.scale.set(flashSize, flashSize, 1);
|
||||
} else {
|
||||
this._beatFlash.scale.set(0, 0, 0);
|
||||
}
|
||||
|
||||
// Update trail particle sizes based on breathing
|
||||
const sizes = this._trails.geometry.attributes.size;
|
||||
if (sizes) {
|
||||
for (let i = 0; i < sizes.count; i++) {
|
||||
const phase = (i / sizes.count) * Math.PI * 2 + elapsed * breathFreq * Math.PI * 2;
|
||||
sizes.array[i] = 0.04 + Math.abs(Math.sin(phase)) * 0.06 * this._breathConf;
|
||||
}
|
||||
sizes.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._breathRing.geometry.dispose();
|
||||
this._breathMat.dispose();
|
||||
this._hrRing.geometry.dispose();
|
||||
this._hrMat.dispose();
|
||||
this._orb.geometry.dispose();
|
||||
this._orbMat.dispose();
|
||||
this._trails.geometry.dispose();
|
||||
this._trails.material.dispose();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue