/** * Shanghai International Circuit loader + start/grid poses. */ import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; const MAP_URL = 'assets/maps/shanghai/track.glb'; export class Track { constructor(scene) { this.scene = scene; this.root = new THREE.Group(); scene.add(this.root); this._loader = new GLTFLoader(); this.mapId = null; this.ready = false; this.start = { x: 0, y: 0.4, z: 0, yaw: 0 }; this._bbox = null; // Temporary ground until map loads const ground = new THREE.Mesh( new THREE.PlaneGeometry(200, 200), new THREE.MeshStandardMaterial({ color: 0x2a2622, roughness: 0.95 }), ); ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; ground.name = '__placeholder'; this.root.add(ground); } async loadShanghai() { try { const gltf = await this._loader.loadAsync(MAP_URL); while (this.root.children.length) { this.root.remove(this.root.children[0]); } const model = gltf.scene; model.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } }); this.root.add(model); this.mapId = 'shanghai'; this.ready = true; this._deriveStartFromMap(model); return true; } catch (err) { console.warn('map load failed', err); this.ready = false; return false; } } /** * Find start/finish / pit / grid cues in node names; else use asphalt AABB heuristic. * Shanghai SIC start/finish sits near the pit straight — we prefer named nodes, * then fall back to the longest thin asphalt-ish bounds. */ _deriveStartFromMap(model) { const nameHits = []; const meshes = []; model.updateWorldMatrix(true, true); model.traverse((o) => { if (!o.isMesh) return; meshes.push(o); const n = `${o.name || ''} ${o.parent?.name || ''}`.toLowerCase(); if ( n.includes('start') || n.includes('finish') || n.includes('grid') || n.includes('pit') || n.includes('s/f') || n.includes('sf_') ) { nameHits.push(o); } }); let anchor = null; let yaw = 0; if (nameHits.length) { const box = new THREE.Box3(); for (const m of nameHits) box.expandByObject(m); const c = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); anchor = c; // Face along the longer horizontal axis of the start strip. yaw = size.x >= size.z ? 0 : Math.PI / 2; } else { // Fallback: whole-map center shifted toward a long strip (pit straight guess). const box = new THREE.Box3().setFromObject(model); this._bbox = box; const c = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); // Shanghai-like: start straight often near one end of the long axis. if (size.x >= size.z) { anchor = new THREE.Vector3(c.x + size.x * 0.12, box.min.y, c.z); yaw = 0; } else { anchor = new THREE.Vector3(c.x, box.min.y, c.z + size.z * 0.12); yaw = Math.PI / 2; } } const groundY = (() => { const box = new THREE.Box3().setFromObject(model); return box.min.y; })(); this.start = { x: anchor.x, y: groundY + 0.35, z: anchor.z, yaw, }; } /** Grid slot relative to start line (GTA-style staggered pairs). */ getGridPose(index = 0) { const i = Math.max(0, index | 0); const row = Math.floor(i / 2); const side = i % 2 === 0 ? -1 : 1; const lateral = side * 3.2; const back = -row * 7.5; const yaw = this.start.yaw; const fx = Math.sin(yaw); const fz = Math.cos(yaw); const rx = Math.cos(yaw); const rz = -Math.sin(yaw); return { x: this.start.x + fx * back + rx * lateral, y: this.start.y, z: this.start.z + fz * back + rz * lateral, yaw, }; } }