/** * 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(0xccccdd, this.settings.ambient * 5.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();