fix(pfd): correct 3D aircraft orientation and heading indicator layout

Aircraft model rebuilt with proper Three.js coordinate system:
- Wings along X axis (horizontal), fuselage along Z (forward=-Z), Y=up
- Pitch rotates around X, bank around Z
- Camera positioned for front-left view

Heading indicator fixed to match Airbus PFD style:
- Yellow heading readout box at top with pointer triangle below
- Ticks grow upward from bottom edge
- Labels positioned above ticks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-02-20 23:38:42 +01:00
parent 0d1dae87e7
commit a3056491d9
2 changed files with 74 additions and 68 deletions

View File

@@ -29,56 +29,62 @@ let targetPitchRad = 0
let targetBankRad = 0
function buildAircraft(): THREE.Group {
// Three.js coords: X=right, Y=up, Z=toward viewer
// Aircraft: nose toward -Z, wings along X, Y is up
const group = new THREE.Group()
// Fuselage - cylinder rotated to lie along X axis
const bodyColor = 0xdcdcdc
const darkColor = 0x2a2a2a
const engineColor = 0x555555
// Fuselage — cylinder along Z axis (default cylinder is along Y, rotate to Z)
const fuselageGeo = new THREE.CylinderGeometry(0.4, 0.35, 5, 12)
const fuselageMat = new THREE.MeshStandardMaterial({ color: 0xdcdcdc })
const fuselageMat = new THREE.MeshStandardMaterial({ color: bodyColor })
const fuselage = new THREE.Mesh(fuselageGeo, fuselageMat)
fuselage.rotation.z = Math.PI / 2
fuselage.rotation.x = Math.PI / 2 // rotate Y-cylinder to lie along Z
group.add(fuselage)
// Nose cone
// Nose cone — pointing toward -Z (forward)
const noseGeo = new THREE.ConeGeometry(0.35, 1, 12)
const noseMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2a })
const noseMat = new THREE.MeshStandardMaterial({ color: darkColor })
const nose = new THREE.Mesh(noseGeo, noseMat)
nose.rotation.z = -Math.PI / 2
nose.position.set(3, 0, 0)
nose.rotation.x = Math.PI / 2 // cone tip points along -Z
nose.position.set(0, 0, -3)
group.add(nose)
// Main wings
const wingGeo = new THREE.BoxGeometry(0.6, 4.5, 0.08)
const wingMat = new THREE.MeshStandardMaterial({ color: 0xdcdcdc })
// Main wings — wide along X (wingspan), thin along Z (chord), very thin along Y
const wingGeo = new THREE.BoxGeometry(4.5, 0.08, 0.6) // X=span, Y=thickness, Z=chord
const wingMat = new THREE.MeshStandardMaterial({ color: bodyColor })
const wings = new THREE.Mesh(wingGeo, wingMat)
wings.position.set(-0.2, 0, 0)
wings.position.set(0, 0, 0.2)
group.add(wings)
// Horizontal stabilizer
const hStabGeo = new THREE.BoxGeometry(0.3, 1.8, 0.06)
const hStabMat = new THREE.MeshStandardMaterial({ color: 0xdcdcdc })
// Horizontal stabilizer — same orientation as wings, smaller
const hStabGeo = new THREE.BoxGeometry(1.8, 0.06, 0.3) // X=span, Y=thick, Z=chord
const hStabMat = new THREE.MeshStandardMaterial({ color: bodyColor })
const hStab = new THREE.Mesh(hStabGeo, hStabMat)
hStab.position.set(-2.3, 0, 0)
hStab.position.set(0, 0, 2.3)
group.add(hStab)
// Vertical stabilizer
const vStabGeo = new THREE.BoxGeometry(0.6, 0.06, 1.2)
const vStabMat = new THREE.MeshStandardMaterial({ color: 0xdcdcdc })
// Vertical stabilizer — tall along Y, along Z (chord)
const vStabGeo = new THREE.BoxGeometry(0.06, 1.2, 0.6) // X=thin, Y=height, Z=chord
const vStabMat = new THREE.MeshStandardMaterial({ color: bodyColor })
const vStab = new THREE.Mesh(vStabGeo, vStabMat)
vStab.position.set(-2.1, 0, 0.6)
vStab.position.set(0, 0.6, 2.1)
group.add(vStab)
// Engines (two, under each wing)
// Engines (two, under each wing) — cylinders along Z
const engineGeo = new THREE.CylinderGeometry(0.18, 0.22, 0.8, 8)
const engineMat = new THREE.MeshStandardMaterial({ color: 0x555555 })
const engineMat = new THREE.MeshStandardMaterial({ color: engineColor })
const engineLeft = new THREE.Mesh(engineGeo, engineMat)
engineLeft.rotation.z = Math.PI / 2
engineLeft.position.set(0.2, -1.5, -0.35)
engineLeft.rotation.x = Math.PI / 2 // align along Z
engineLeft.position.set(-1.5, -0.35, -0.2)
group.add(engineLeft)
const engineRight = new THREE.Mesh(engineGeo, engineMat)
engineRight.rotation.z = Math.PI / 2
engineRight.position.set(0.2, 1.5, -0.35)
engineRight.rotation.x = Math.PI / 2 // align along Z
engineRight.position.set(1.5, -0.35, -0.2)
group.add(engineRight)
return group
@@ -96,10 +102,10 @@ function initScene() {
scene.background = new THREE.Color(0x1a5fb4)
scene.fog = new THREE.Fog(0x62a0ea, 30, 80)
// Camera
// Camera — view from front-left, slightly above
// Aircraft nose points -Z, so camera at +Z looks at front
camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 200)
// View from front-right, slightly above — shows pitch and bank clearly
camera.position.set(6, 2, 4)
camera.position.set(-4, 3, -6)
camera.lookAt(0, 0, 0)
// Lights
@@ -156,11 +162,11 @@ function animate() {
animFrameId = requestAnimationFrame(animate)
if (aircraft) {
// Aircraft flies along +X, wings along Y, Z is up
// Pitch: rotate around Y axis (wing axis) — positive pitch = nose up
aircraft.rotation.y = lerp(aircraft.rotation.y, targetPitchRad, 0.1)
// Bank: rotate around X axis (longitudinal axis) — positive bank = right wing down
aircraft.rotation.x = lerp(aircraft.rotation.x, targetBankRad, 0.1)
// Aircraft: nose toward -Z, wings along X, Y is up
// Pitch: rotate around X axis — positive pitch = nose up (rotates nose away from -Z toward +Y)
aircraft.rotation.x = lerp(aircraft.rotation.x, targetPitchRad, 0.1)
// Bank: rotate around Z axis — positive bank = right wing down
aircraft.rotation.z = lerp(aircraft.rotation.z, -targetBankRad, 0.1)
}
if (renderer && scene && camera) {

View File

@@ -93,23 +93,52 @@ const readoutBoxHeight = 18
rx="2"
/>
<!-- Scrolling compass tape (clipped) -->
<!-- Readout box at top center (yellow heading value) -->
<rect
:x="centerX - readoutBoxWidth / 2"
y="2"
:width="readoutBoxWidth"
:height="readoutBoxHeight"
fill="rgba(10,10,30,0.95)"
stroke="#facc15"
stroke-width="1"
rx="2"
/>
<text
:x="centerX"
:y="16"
fill="#facc15"
font-size="12"
font-weight="bold"
text-anchor="middle"
font-family="monospace"
>
{{ readoutText }}
</text>
<!-- Center pointer triangle (yellow, pointing down from readout box) -->
<polygon
:points="`${centerX},${readoutBoxHeight + 6} ${centerX - 4},${readoutBoxHeight + 2} ${centerX + 4},${readoutBoxHeight + 2}`"
fill="#facc15"
/>
<!-- Scrolling compass tape (clipped) ticks grow upward from bottom -->
<g :clip-path="`url(#${clipId})`">
<g v-for="mark in marks" :key="mark.deg">
<!-- Tick mark -->
<!-- Tick mark (from bottom upward) -->
<line
:x1="mark.x"
y1="0"
:y1="height"
:x2="mark.x"
:y2="mark.isMajor ? 12 : 7"
:y2="mark.isMajor ? height - 12 : height - 7"
stroke="white"
:stroke-width="mark.isMajor ? 1.5 : 1"
/>
<!-- Label -->
<!-- Label (above tick) -->
<text
v-if="mark.isMajor"
:x="mark.x"
y="24"
:y="height - 15"
fill="white"
font-size="11"
text-anchor="middle"
@@ -119,34 +148,5 @@ const readoutBoxHeight = 18
</text>
</g>
</g>
<!-- Center pointer triangle (cyan) -->
<polygon
:points="`${centerX},2 ${centerX - 5},0 ${centerX + 5},0`"
fill="#22d3ee"
/>
<!-- Readout box at bottom -->
<rect
:x="centerX - readoutBoxWidth / 2"
:y="height - readoutBoxHeight - 2"
:width="readoutBoxWidth"
:height="readoutBoxHeight"
fill="rgba(10,10,30,0.95)"
stroke="#22d3ee"
stroke-width="1"
rx="2"
/>
<text
:x="centerX"
:y="height - 6"
fill="#22d3ee"
font-size="12"
font-weight="bold"
text-anchor="middle"
font-family="monospace"
>
{{ readoutText }}
</text>
</svg>
</template>