import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { HDRLoader } from 'three/examples/jsm/loaders/HDRLoader.js'; import { RobotModel } from './robot-model'; import { VoxelMap } from './voxel-map'; import { theme } from '../theme'; export class Scene3D { renderer: THREE.WebGLRenderer; scene: THREE.Scene; camera: THREE.PerspectiveCamera; controls: OrbitControls; robotModel: RobotModel; voxelMap: VoxelMap; private animationId: number = 0; private grid: THREE.GridHelper | null = null; private unsubTheme: () => void = () => {}; // View toggle state (double-tap). Default 'follow' mirrors the Go2 // APK (viewType=0): the camera sits just behind the robot looking // forward, with fog and orbit locked. The alternate 'overview' // (viewType=1 in the APK) zooms out for a holistic top-down look // with orbit enabled. private viewType: 'overview' | 'follow' = 'follow'; private lastTapTime = 0; private lastTapX = 0; private savedCameraPos = new THREE.Vector3(-1.2, 0, 1); private savedTarget = new THREE.Vector3(4, 0, 0); /** Fires when the view mode toggles (APK shows toastMsg_4 / _5). The * host App subscribes to render a toast — we don't take a DOM ref * here because the scene shouldn't own UI chrome. */ onViewChange: ((view: 'overview' | 'follow') => void) | null = null; // APK camera offsets (Go2 frontend 1.11.4). // Robot-local frame: X is forward, Y is left, Z is up. private static readonly FOLLOW_CAMERA_OFFSET = new THREE.Vector3(-1.2, 0, 1); private static readonly FOLLOW_LOOKAT_OFFSET = new THREE.Vector3(4, 0, 0); private static readonly OVERVIEW_CAMERA_OFFSET = new THREE.Vector3(-3, 0, 3); // Fog tuned to the APK: dark background colour, near=0.015, far=20. private static readonly FOLLOW_FOG = new THREE.Fog(0x282828, 0.015, 20); // Robot spawns 0.3 m below world origin (BODY_HEIGHT_OFFSET in // robot-model.ts). Hard-coded here so the initial camera frames // correctly before the GLB finishes loading and getPosition() // returns a real value. private static readonly INITIAL_ROBOT_POS = new THREE.Vector3(0, 0, -0.3); constructor(canvas: HTMLCanvasElement) { this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); this.renderer.setPixelRatio(window.devicePixelRatio); this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFShadowMap; this.renderer.toneMapping = THREE.ACESFilmicToneMapping; this.renderer.toneMappingExposure = 1.0; this.scene = new THREE.Scene(); // Theme-aware background (APK dark = 0x282828; light = near-white) this.scene.background = new THREE.Color(theme().colors.background); // Follow is the default view, so the APK fog is on from the start. this.scene.fog = Scene3D.FOLLOW_FOG; // FOV matches the APK (PerspectiveCamera(70)). The previous 50° // truncated the dog from the bottom of the frame when the camera // sat 1 m above and 1.2 m behind a dog at z = -0.3 — 29° below // the view ray fell outside a 25° half-FOV. this.camera = new THREE.PerspectiveCamera(70, 1, 0.1, 500); // Initial follow-view placement — anchor on the robot's spawn // pose (z = -0.3 from BODY_HEIGHT_OFFSET) plus the APK offsets. this.camera.position.copy(Scene3D.INITIAL_ROBOT_POS).add(Scene3D.FOLLOW_CAMERA_OFFSET); this.camera.up.set(0, 0, 1); this.controls = new OrbitControls(this.camera, canvas); this.controls.target.copy(Scene3D.INITIAL_ROBOT_POS).add(Scene3D.FOLLOW_LOOKAT_OFFSET); this.controls.enableDamping = true; this.controls.dampingFactor = 0.1; this.controls.minDistance = 0.5; this.controls.maxDistance = 20; // Orbit is disabled while in follow mode — the camera tracks the // dog's yaw and the user can't break that with a finger drag. this.controls.enabled = false; this.controls.update(); this.setupLights(); this.setupGrid(); this.loadEnvironment(); this.robotModel = new RobotModel(this.scene); this.voxelMap = new VoxelMap(this.scene); // Double-tap detection on canvas canvas.addEventListener('pointerdown', (e) => this.handleDoubleTap(e)); // Re-apply background + grid on theme change this.unsubTheme = theme().onChange((_t, colors) => { this.scene.background = new THREE.Color(colors.background); this.rebuildGrid(colors.grid); }); this.resize(); window.addEventListener('resize', () => this.resize()); this.animate(); } private setupLights(): void { const ambient = new THREE.AmbientLight(0xffffff, 0.6); this.scene.add(ambient); const dir = new THREE.DirectionalLight(0xffffff, 1.2); dir.position.set(5, -5, 8); dir.castShadow = true; dir.shadow.mapSize.set(2048, 2048); dir.shadow.camera.near = 0.5; dir.shadow.camera.far = 30; dir.shadow.camera.left = -5; dir.shadow.camera.right = 5; dir.shadow.camera.top = 5; dir.shadow.camera.bottom = -5; this.scene.add(dir); const hemi = new THREE.HemisphereLight(0x8888ff, 0x444422, 0.4); this.scene.add(hemi); } private setupGrid(): void { this.rebuildGrid(theme().colors.grid); } private rebuildGrid(color: number): void { if (this.grid) { this.scene.remove(this.grid); (this.grid.material as THREE.Material).dispose?.(); this.grid.geometry.dispose(); } const g = new THREE.GridHelper(40, 40, color, color); g.rotateX(Math.PI / 2); this.scene.add(g); this.grid = g; } private loadEnvironment(): void { new HDRLoader().load('/models/venice_sunset_1k.hdr', (texture) => { texture.mapping = THREE.EquirectangularReflectionMapping; this.scene.environment = texture; }); } private handleDoubleTap(e: PointerEvent): void { const now = performance.now(); if (now - this.lastTapTime < 300 && Math.abs(e.clientX - this.lastTapX) <= 60) { this.toggleView(); } this.lastTapTime = now; this.lastTapX = e.clientX; } private toggleView(): void { const robotPos = this.robotModel.getPosition(); if (this.viewType === 'follow') { // Switch to overview: pull back to (-3, 0, 3) (rotated by the // robot's current yaw so we end up behind it), look at the robot, // drop the fog, re-enable orbit. Matches the APK's viewType=1. this.savedCameraPos.copy(this.camera.position); this.savedTarget.copy(this.controls.target); const yawOffset = this.yawRotated(Scene3D.OVERVIEW_CAMERA_OFFSET); this.animateCamera( robotPos.clone().add(yawOffset), robotPos.clone(), ); this.scene.fog = null; this.controls.enabled = true; this.viewType = 'overview'; } else { // Switch to follow view: snap behind the robot (yaw-rotated // -1.2, 0, 1) looking 4 m forward, with fog, orbit locked. // Matches the APK's viewType=0. const camOffset = this.yawRotated(Scene3D.FOLLOW_CAMERA_OFFSET); const lookOffset = this.yawRotated(Scene3D.FOLLOW_LOOKAT_OFFSET); this.animateCamera( robotPos.clone().add(camOffset), robotPos.clone().add(lookOffset), ); this.scene.fog = Scene3D.FOLLOW_FOG; this.controls.enabled = false; this.viewType = 'follow'; } this.onViewChange?.(this.viewType); } /** Apply only the robot's yaw (rotation around Z) to a local-frame * offset, leaving the Z component untouched. Mirrors the APK's * rotatePointInPlane usage in calFirstViewCameraPosition. */ private yawRotated(localOffset: THREE.Vector3): THREE.Vector3 { const yaw = new THREE.Euler().setFromQuaternion(this.robotModel.getQuaternion(), 'ZYX').z; const cos = Math.cos(yaw), sin = Math.sin(yaw); return new THREE.Vector3( localOffset.x * cos - localOffset.y * sin, localOffset.x * sin + localOffset.y * cos, localOffset.z, ); } /** Per-frame anchor for follow mode: keep the camera glued to the * robot at the yaw-rotated offset. Skipped while a toggle animation * is in progress so the tween isn't fought. */ private updateFollowCamera(): void { if (this.viewType !== 'follow' || this.tweenInProgress) return; const robotPos = this.robotModel.getPosition(); const camOffset = this.yawRotated(Scene3D.FOLLOW_CAMERA_OFFSET); const lookOffset = this.yawRotated(Scene3D.FOLLOW_LOOKAT_OFFSET); this.camera.position.copy(robotPos).add(camOffset); this.controls.target.copy(robotPos).add(lookOffset); } private tweenInProgress = false; private animateCamera(targetPos: THREE.Vector3, targetLookAt: THREE.Vector3): void { const startPos = this.camera.position.clone(); const startTarget = this.controls.target.clone(); const duration = 500; const startTime = performance.now(); this.tweenInProgress = true; const step = (now: number) => { const t = Math.min((now - startTime) / duration, 1); const eased = t * (2 - t); // ease-out this.camera.position.lerpVectors(startPos, targetPos, eased); this.controls.target.lerpVectors(startTarget, targetLookAt, eased); this.controls.update(); if (t < 1) requestAnimationFrame(step); else this.tweenInProgress = false; }; requestAnimationFrame(step); } resize(): void { const parent = this.renderer.domElement.parentElement; if (!parent) return; const w = parent.clientWidth; const h = parent.clientHeight; this.renderer.setSize(w, h); this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); } private animate(): void { this.animationId = requestAnimationFrame(() => this.animate()); this.updateFollowCamera(); this.controls.update(); this.renderer.render(this.scene, this.camera); } destroy(): void { cancelAnimationFrame(this.animationId); this.unsubTheme(); this.renderer.dispose(); } }