From 18778b3d26f5d88c2e97dc91b19ee8fa09357b12 Mon Sep 17 00:00:00 2001 From: Marco Costa Date: Sat, 17 Oct 2026 22:25:53 -0300 Subject: [PATCH] fix(city): correct vehicle yaw calculation and expose orientation API - Extracts yawFromDelta helper to standardize rotation math - Removes incorrect PI/2 offset causing reverse-facing vehicles - Accounts for direction sign in shuttle vehicle rotation - Adds vehicleOrientations() for test validation --- public/js/city/app.js | 52 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/public/js/city/app.js b/public/js/city/app.js index 041edb0..5f80aa9 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -980,6 +980,15 @@ function probeAhead(pts, s, L) { return { distToCorner: 999, axis: 'x', corner: pts[0] }; } +/** + * Yaw so a mesh whose nose is local +X faces the world direction (dx, dz). + * + * Three.js: local +X after rotation.y=θ becomes (cos θ, 0, -sin θ). + */ +function yawFromDelta(dx, dz) { + return Math.atan2(-dz, dx); +} + function updateLoopVehicle(v, dt) { const probe = probeAhead(v.pts, v.s, v.L); const distToCorner = probe.distToCorner; @@ -1008,7 +1017,7 @@ function updateLoopVehicle(v, dt) { posOnPath(v.pts, v.s + 0.6, v.L, tmp); v.mesh.position.copy(pos); if (tmp.distanceToSquared(pos) > 0.0001) { - v.mesh.rotation.y = Math.atan2(tmp.x - pos.x, tmp.z - pos.z) + Math.PI / 2; + v.mesh.rotation.y = yawFromDelta(tmp.x - pos.x, tmp.z - pos.z); } const wheelSpin = v.speed * dt * 3; for (const w of v.mesh.userData.wheels || []) w.rotation.z += wheelSpin; @@ -1049,7 +1058,7 @@ function updateShuttleVehicle(v, dt) { v.mesh.position.copy(pos); const look = new THREE.Vector3().lerpVectors(v.a, v.b, Math.max(0, Math.min(1, v.t + v.dir * 0.05))); if (look.distanceToSquared(pos) > 0.0001) { - v.mesh.rotation.y = Math.atan2(look.x - pos.x, look.z - pos.z) + Math.PI / 2; + v.mesh.rotation.y = yawFromDelta(look.x - pos.x, look.z - pos.z); } for (const w of v.mesh.userData.wheels || []) w.rotation.z += v.speed * dt * 3; } @@ -2632,10 +2641,11 @@ function vehicleBoxes() { posOnPath(v.pts, v.s, v.L, p); const ahead = new THREE.Vector3(); posOnPath(v.pts, v.s + 0.6, v.L, ahead); - rot = Math.atan2(ahead.x - p.x, ahead.z - p.z); + rot = yawFromDelta(ahead.x - p.x, ahead.z - p.z); } else { p.lerpVectors(v.a, v.b, v.t); - rot = Math.atan2(v.b.x - v.a.x, v.b.z - v.a.z) + Math.PI / 2; + const dirSign = v.dir >= 0 ? 1 : -1; + rot = yawFromDelta((v.b.x - v.a.x) * dirSign, (v.b.z - v.a.z) * dirSign); } const halfL = v.mesh?.userData?.halfL ?? (v.pts ? 2.1 : 2.0); const halfW = v.mesh?.userData?.halfW ?? 1.0; @@ -3459,6 +3469,40 @@ window.__city10 = { }; }, vehiclePositions() { return vehicleSystem ? vehicleSystem.positions() : []; }, + /** Sample vehicle heading vs velocity so tests can detect reverse-facing models. */ + vehicleOrientations() { + if (!vehicleSystem) return []; + const out = []; + for (const v of vehicleSystem.vehicles) { + const p = new THREE.Vector3(); + const ahead = new THREE.Vector3(); + if (v.pts) { + posOnPath(v.pts, v.s, v.L, p); + posOnPath(v.pts, v.s + 0.8, v.L, ahead); + } else { + p.lerpVectors(v.a, v.b, v.t); + const lookT = Math.max(0, Math.min(1, v.t + v.dir * 0.05)); + ahead.lerpVectors(v.a, v.b, lookT); + } + const dx = ahead.x - p.x; + const dz = ahead.z - p.z; + const len = Math.hypot(dx, dz) || 1; + const vx = dx / len; + const vz = dz / len; + // Mesh nose is local +X → world forward = (cos yaw, -sin yaw). + const yaw = v.mesh.rotation.y; + const fx = Math.cos(yaw); + const fz = -Math.sin(yaw); + const dot = fx * vx + fz * vz; + out.push({ + kind: v.mesh?.userData?.kind || 'car', + speed: +(v.speed || 0).toFixed(2), + dot: +dot.toFixed(3), + aligned: dot > 0.5, + }); + } + return out; + }, pedestrianPositions() { return pedestrianSystem ? pedestrianSystem.positions() : []; }, get me() { return meCharacter; }, refreshMe: async function () {