fellwing / world.js
CyberMax-tools's picture
Deploy fellwing
8f3ff28 verified
Raw History Blame Contribute Delete
26.4 kB
// Fellwing world pieces: sky dome with sun and stars, the sea, cloud layers, runways with real markings, route gates,
// the aircraft's ground shadow and the four aircraft models (lofted fuselages and airfoil wings, built in code).
// (c) 2026 CyberMax. All rights reserved.
import * as THREE from 'three';
import { ATMOS_GLSL } from './atmos.js';
const LOGV = '\n#include <common>\n#include <logdepthbuf_pars_vertex>\n', LOGV2 = '\n#include <logdepthbuf_vertex>\n';
const LOGF = '\n#include <logdepthbuf_pars_fragment>\n', LOGF2 = '\n#include <logdepthbuf_fragment>\n';
const NOISE = /* glsl */`
float h21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float vnoise(vec2 p) { vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(h21(i), h21(i + vec2(1, 0)), u.x), mix(h21(i + vec2(0, 1)), h21(i + vec2(1, 1)), u.x), u.y); }
float fbm(vec2 p) { float a = 0.5, s = 0.0; for (int i = 0; i < 5; i++) { s += a * vnoise(p); p = p * 2.02 + 11.7; a *= 0.5; } return s; }`;
// ---------------------------------------------------------------- sky
export function makeSky(uniforms) {
const m = new THREE.ShaderMaterial({
uniforms, depthWrite: false, depthTest: false, side: THREE.BackSide,
vertexShader: `varying vec3 vD; void main(){ vD = position; vec4 p = projectionMatrix * modelViewMatrix * vec4(position,1.0); gl_Position = p.xyww; }`,
fragmentShader: `varying vec3 vD; ${ATMOS_GLSL} ${NOISE}
void main(){ vec3 d = normalize(vD); vec3 c = skyColor(d);
float mu = dot(d, uSun);
float disc = smoothstep(0.99985, 0.99993, mu);
c += uSunCol * disc * 18.0 * (1.0 - uOvercast) * (1.0 - smoothstep(-0.02, 0.02, -uSun.y));
if (uNight > 0.0 && d.y > 0.0) { vec2 sp = vec2(atan(d.z, d.x) * 180.0, d.y * 260.0); float s = h21(floor(sp)); c += vec3(pow(s, 260.0) * 2.5) * uNight * (1.0 - uOvercast); }
if (d.y < 0.0) c = mix(c, c * 0.55, smoothstep(0.0, -0.25, d.y));
gl_FragColor = vec4(c, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`,
});
const mesh = new THREE.Mesh(new THREE.SphereGeometry(1000, 48, 24), m);
mesh.frustumCulled = false; mesh.renderOrder = -10;
return mesh;
}
// ---------------------------------------------------------------- sea
export function makeSea(uniforms) {
// polar grid around the camera: rings spaced exponentially, out to 260 km
const R = 48, S = 96, pos = [], idx = [];
for (let r = 0; r <= R; r++) { const rad = r === 0 ? 0 : 20 * Math.pow(1.215, r); for (let s = 0; s < S; s++) { const a = (s / S) * Math.PI * 2; pos.push(Math.cos(a) * rad, 0, Math.sin(a) * rad); } }
// wound to face up (the old a-c-b order faced down, so back-face culling hid the sea from above)
for (let r = 0; r < R; r++) for (let s = 0; s < S; s++) { const a = r * S + s, b = r * S + ((s + 1) % S), c = a + S, d = b + S; idx.push(a, b, c, b, d, c); }
const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setIndex(idx);
const m = new THREE.ShaderMaterial({
uniforms,
vertexShader: `uniform vec3 uCam; varying vec3 vW; ${ATMOS_GLSL.split('vec3 skyColor')[0]} ${LOGV}
void main(){ vec4 w = modelMatrix * vec4(position,1.0); vW = w.xyz; vec2 d = w.xz - uCam.xz; w.y -= dot(d,d)/(2.0*R_EARTH); gl_Position = projectionMatrix * viewMatrix * w; ${LOGV2} }`,
fragmentShader: `uniform vec3 uCam; uniform float uTime; uniform vec3 uAmbSky; varying vec3 vW; ${ATMOS_GLSL} ${NOISE} ${LOGF}
void main(){ ${LOGF2}
vec3 v = vW - uCam; float dist = length(v); v /= dist;
float fd = 1.0 - smoothstep(2000.0, 30000.0, dist);
vec2 p = vW.xz;
float e = 1.2;
float n1 = fbm(p / 60.0 + vec2(uTime * 0.05, uTime * 0.03)), n2 = fbm(p / 23.0 - vec2(uTime * 0.07, -uTime * 0.04));
float nx = fbm((p + vec2(e, 0.0)) / 60.0 + vec2(uTime * 0.05, uTime * 0.03)), nz = fbm((p + vec2(0.0, e)) / 60.0 + vec2(uTime * 0.05, uTime * 0.03));
vec3 n = normalize(vec3((n1 - nx) * 2.2 + (n2 - 0.5) * 0.08, 1.0, (n1 - nz) * 2.2 + (n2 - 0.5) * 0.08) * vec3(fd, 1.0, fd) + vec3(0.0, 1.0 - fd, 0.0));
vec3 r = reflect(v, n); r.y = abs(r.y);
float fres = 0.02 + 0.98 * pow(1.0 - max(dot(-v, n), 0.0), 5.0);
vec3 deep = vec3(0.004, 0.018, 0.03) * (uAmbSky + uSunCol * max(uSun.y, 0.0) * 0.3);
vec3 c = mix(deep, skyColor(r), fres);
c += uSunCol * pow(max(dot(r, uSun), 0.0), 380.0) * 6.0 * (1.0 - uOvercast);
float fa; c = aerial(c, uCam, vW, fa);
gl_FragColor = vec4(c, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`,
});
const mesh = new THREE.Mesh(g, m); mesh.frustumCulled = false;
return mesh;
}
// ---------------------------------------------------------------- clouds (two thin layers of fbm cumulus)
export function makeClouds(uniforms) {
const g = new THREE.PlaneGeometry(240000, 240000, 64, 64); g.rotateX(-Math.PI / 2);
const mk = (off) => new THREE.ShaderMaterial({
uniforms: { ...uniforms, uOff: { value: off } }, transparent: true, depthWrite: false, side: THREE.DoubleSide,
vertexShader: `uniform vec3 uCam; uniform float uCloudAlt; uniform float uOff; varying vec3 vW; ${ATMOS_GLSL.split('vec3 skyColor')[0]} ${LOGV}
void main(){ vec4 w = modelMatrix * vec4(position,1.0); w.y = uCloudAlt + uOff; vW = w.xyz; vec2 d = w.xz - uCam.xz; w.y -= dot(d,d)/(2.0*R_EARTH); gl_Position = projectionMatrix * viewMatrix * w; ${LOGV2} }`,
fragmentShader: `uniform vec3 uCam; uniform float uTime; uniform float uCover; uniform vec2 uWind; uniform float uOff; uniform vec3 uAmbSky; varying vec3 vW; ${ATMOS_GLSL} ${NOISE} ${LOGF}
void main(){ ${LOGF2}
vec2 p = (vW.xz + uWind * uTime) / 2600.0 + uOff * 0.002;
float n = fbm(p) * 0.75 + fbm(p * 3.7 + 5.0) * 0.25;
float cov = 1.0 - uCover;
float a = smoothstep(cov - 0.02, cov + 0.22, n);
if (a < 0.01) discard;
float lit = 0.75 + 0.35 * smoothstep(cov, cov + 0.3, fbm(p * 1.3 + uSun.xz * 0.2));
vec3 base = mix(uAmbSky * 0.9, vec3(1.0), 0.55) * (uSunCol * 0.32 + uAmbSky * 0.55) * lit;
base = mix(base, base * 0.62, uOvercast * smoothstep(cov, cov + 0.5, n));
float fa; vec3 c = aerial(base, uCam, vW, fa);
float edge = 1.0 - smoothstep(60000.0, 110000.0, length(vW.xz - uCam.xz));
gl_FragColor = vec4(c, a * 0.92 * edge);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`,
});
const grp = new THREE.Group();
for (const off of [0, 180]) { const m = new THREE.Mesh(g, mk(off)); m.frustumCulled = false; m.renderOrder = 5; grp.add(m); }
return grp;
}
// ---------------------------------------------------------------- runways
function runwayTexture(id, len, w) {
const cv = document.createElement('canvas'); cv.width = 128; cv.height = 2048;
const c = cv.getContext('2d');
c.fillStyle = '#585b5f'; c.fillRect(0, 0, 128, 2048);
for (let i = 0; i < 2600; i++) { c.fillStyle = `rgba(${Math.random() > 0.5 ? '255,255,255' : '0,0,0'},${Math.random() * 0.06})`; c.fillRect(Math.random() * 128, Math.random() * 2048, 2 + Math.random() * 4, 2 + Math.random() * 10); }
const m = 2048 / len; // px per metre along
c.fillStyle = '#e8e8e2';
c.fillRect(3, 0, 3, 2048); c.fillRect(122, 0, 3, 2048); // edge lines
for (let y = 60 * m; y < 2048 - 60 * m; y += 60 * m) c.fillRect(62, y, 4, 36 * m); // centreline: 36 m dashes, 24 m gaps
const keys = (y0, dir) => { for (let k = 0; k < 8; k++) { const x = 12 + k * 13 + (k >= 4 ? 4 : 0); c.fillRect(x, y0, 7, dir * 30 * m); } };
keys(2048 - 6 * m, -1); keys(6 * m, 1); // threshold piano keys
// runway numbers at each end (near end reads from the approach)
c.save(); c.font = `700 ${Math.max(28, 18 * m)}px Arial, sans-serif`; c.textAlign = 'center'; c.textBaseline = 'middle';
c.translate(64, 2048 - 52 * m); c.fillText(id, 0, 0);
const n = parseInt(id, 10), other = String(((n + 17) % 36) + 1).padStart(2, '0') + (id.endsWith('L') ? 'R' : id.endsWith('R') ? 'L' : '');
c.restore(); c.save(); c.font = `700 ${Math.max(28, 18 * m)}px Arial, sans-serif`; c.textAlign = 'center'; c.textBaseline = 'middle'; c.translate(64, 52 * m); c.rotate(Math.PI); c.fillText(other, 0, 0); c.restore();
// touchdown zone bars
for (const y of [150, 300, 450]) { c.fillRect(30, 2048 - y * m, 10, -22 * m); c.fillRect(88, 2048 - y * m, 10, -22 * m); c.fillRect(30, y * m, 10, 22 * m); c.fillRect(88, y * m, 10, 22 * m); }
const t = new THREE.CanvasTexture(cv); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 8; t.generateMipmaps = true; t.minFilter = THREE.LinearMipmapLinearFilter;
return t;
}
export function makeRunway(r, uniforms) {
const len = r.L + 0, w = r.a.w;
const segs = 40;
const g = new THREE.PlaneGeometry(w, len, 1, segs); g.rotateX(-Math.PI / 2);
const p = g.attributes.position;
for (let i = 0; i < p.count; i++) {
const along = -p.getZ(i) + len / 2; // 0 at threshold a
const t = along / len;
p.setY(i, r.ea + (r.eb - r.ea) * t + 0.15);
}
const ang = Math.atan2(r.dx, -r.dz);
g.rotateY(-ang);
g.translate((r.ax + r.bx) / 2, 0, (r.az + r.bz) / 2);
g.computeVertexNormals();
const tex = runwayTexture(r.a.rwy.padStart(2, '0'), len, w);
const m = new THREE.ShaderMaterial({
uniforms: { ...uniforms, uMap: { value: tex } },
polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -4,
vertexShader: `uniform vec3 uCam; varying vec3 vW; varying vec2 vUv; ${ATMOS_GLSL.split('vec3 skyColor')[0]} ${LOGV}
void main(){ vUv = uv; vec4 w = modelMatrix * vec4(position,1.0); vW = w.xyz; vec2 d = w.xz - uCam.xz; w.y -= dot(d,d)/(2.0*R_EARTH); gl_Position = projectionMatrix * viewMatrix * w; ${LOGV2} }`,
fragmentShader: `uniform vec3 uCam; uniform sampler2D uMap; uniform vec3 uAmbSky; uniform vec3 uAmbGnd; varying vec3 vW; varying vec2 vUv; ${ATMOS_GLSL} ${LOGF}
void main(){ ${LOGF2} vec3 c = texture2D(uMap, vUv).rgb; c = pow(c, vec3(2.2));
vec3 l = uSunCol * max(uSun.y, 0.0) * 0.44 + uAmbSky * 0.6 + uAmbGnd * 0.2; float fa; vec3 o = aerial(c * l, uCam, vW, fa);
gl_FragColor = vec4(o, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`,
});
const mesh = new THREE.Mesh(g, m); mesh.renderOrder = 1;
return mesh;
}
// ---------------------------------------------------------------- route gates
export function makeGate() {
const grp = new THREE.Group();
const ring = new THREE.Mesh(new THREE.TorusGeometry(42, 2.2, 10, 64), new THREE.MeshBasicMaterial({ color: 0xff8a3d, transparent: true, opacity: 0.95 }));
const glow = new THREE.Mesh(new THREE.TorusGeometry(42, 6.5, 8, 64), new THREE.MeshBasicMaterial({ color: 0xff8a3d, transparent: true, opacity: 0.18, blending: THREE.AdditiveBlending, depthWrite: false }));
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.6, 0.6, 2400, 6, 1, true), new THREE.MeshBasicMaterial({ color: 0xff8a3d, transparent: true, opacity: 0.22, blending: THREE.AdditiveBlending, depthWrite: false }));
pole.position.y = -1242;
grp.add(ring, glow, pole);
grp.userData = { ring, glow, pole };
return grp;
}
// ---------------------------------------------------------------- shadow under the aircraft
export function makeShadow() {
const cv = document.createElement('canvas'); cv.width = cv.height = 128;
const c = cv.getContext('2d'); const gr = c.createRadialGradient(64, 64, 4, 64, 64, 62);
gr.addColorStop(0, 'rgba(0,0,0,0.55)'); gr.addColorStop(0.55, 'rgba(0,0,0,0.3)'); gr.addColorStop(1, 'rgba(0,0,0,0)');
c.fillStyle = gr; c.fillRect(0, 0, 128, 128);
const t = new THREE.CanvasTexture(cv);
const m = new THREE.Mesh(new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ map: t, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -6, polygonOffsetUnits: -6 }));
m.renderOrder = 2;
return m;
}
// ---------------------------------------------------------------- aircraft models
// loft: rings of points (same count) -> a closed skin; colourFn(ringIndex, pointIndex) gives vertex colours (livery)
function loft(rings, colourFn, capEnds = true) {
const n = rings[0].length, pos = [], col = [], idx = [];
rings.forEach((r, i) => r.forEach((p, j) => { pos.push(p[0], p[1], p[2]); const c = colourFn ? colourFn(i, j, p) : [1, 1, 1]; col.push(c[0], c[1], c[2]); }));
for (let i = 0; i < rings.length - 1; i++) for (let j = 0; j < n; j++) { const a = i * n + j, b = i * n + ((j + 1) % n), c = a + n, d = b + n; idx.push(a, b, c, b, d, c); }
if (capEnds) for (const [ri, flip] of [[0, true], [rings.length - 1, false]]) {
const r = rings[ri]; const cx = r.reduce((s, p) => s + p[0], 0) / n, cy = r.reduce((s, p) => s + p[1], 0) / n, cz = r.reduce((s, p) => s + p[2], 0) / n;
const ci = pos.length / 3; pos.push(cx, cy, cz); const cc = colourFn ? colourFn(ri, 0, [cx, cy, cz]) : [1, 1, 1]; col.push(...cc);
for (let j = 0; j < n; j++) { const a = ri * n + j, b = ri * n + ((j + 1) % n); if (flip) idx.push(ci, b, a); else idx.push(ci, a, b); }
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute('color', new THREE.Float32BufferAttribute(col, 3)); g.setIndex(idx);
g.computeVertexNormals();
return g;
}
// fuselage: stations [z, width, height, yCentre]; superellipse sections
function fuselage(stations, liv, k = 2.4, n = 28) {
const rings = stations.map(([z, w, h, yc]) => Array.from({ length: n }, (_, j) => {
const a = (j / n) * Math.PI * 2, c = Math.cos(a), s = Math.sin(a);
const x = Math.sign(c) * Math.abs(c) ** (2 / k) * w / 2, y = Math.sign(s) * Math.abs(s) ** (2 / k) * h / 2;
return [x, yc + y, z];
}));
return loft(rings, (i, j, p) => liv(p, stations[i]));
}
// NACA 4-digit-like airfoil loop (unit chord, leading edge at 0, trailing edge at 1), m = camber, t = thickness
function airfoil(t = 0.12, m = 0.02, n = 18) {
const up = [], lo = [];
for (let i = 0; i <= n; i++) {
const x = (1 - Math.cos((i / n) * Math.PI)) / 2;
const yt = 5 * t * (0.2969 * Math.sqrt(x) - 0.126 * x - 0.3516 * x * x + 0.2843 * x ** 3 - 0.1036 * x ** 4);
const yc = x < 0.4 ? (m / 0.16) * (0.8 * x - x * x) : (m / 0.36) * (0.2 + 0.8 * x - x * x);
up.push([x, yc + yt]); lo.push([x, yc - yt]);
}
return [...up.reverse(), ...lo.slice(1, -1)];
}
// wing panel from root to tip: chords, positions (leading edge x/y/z), returns geometry; vertical = fin
function panel({ root, tip, span, sweep = 0, dihedral = 0, x0 = 0, y0 = 0, z0 = 0, t = 0.12, camber = 0.02, vertical = false, mirror = false, liv }) {
const af = airfoil(t, camber), steps = 6, rings = [];
for (let s = 0; s <= steps; s++) {
const f = s / steps, ch = root + (tip - root) * f, sp = span * f;
rings.push(af.map(([ax, ay]) => {
const zc = z0 + sweep * f + ax * ch; // chord runs back along +z
if (vertical) return [x0 + ay * ch, y0 + sp, zc];
return [(mirror ? -1 : 1) * (x0 + sp), y0 + Math.tan(dihedral) * sp + ay * ch, zc];
}));
}
const g = loft(mirror ? rings.map((r) => r.slice().reverse()) : rings, (i, j, p) => liv ? liv(p, i / steps) : [1, 1, 1]);
return g;
}
const MAT = {};
function mats(env) {
if (MAT.paint) { MAT.paint.envMap = env; MAT.glass.envMap = env; MAT.metal.envMap = env; return MAT; }
MAT.paint = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.32, metalness: 0.05, envMap: env, envMapIntensity: 0.9 });
MAT.glass = new THREE.MeshStandardMaterial({ color: 0x0d1418, roughness: 0.05, metalness: 0.6, envMap: env, envMapIntensity: 1.4, transparent: true, opacity: 0.88 });
MAT.metal = new THREE.MeshStandardMaterial({ color: 0x55585e, roughness: 0.45, metalness: 0.8, envMap: env });
MAT.dark = new THREE.MeshStandardMaterial({ color: 0x1b1d20, roughness: 0.7, metalness: 0.2 });
MAT.tyre = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.9 });
MAT.prop = new THREE.MeshBasicMaterial({ color: 0x222222, transparent: true, opacity: 0.25, side: THREE.DoubleSide, depthWrite: false });
MAT.nav = { r: new THREE.MeshBasicMaterial({ color: 0xff2020 }), g: new THREE.MeshBasicMaterial({ color: 0x20ff40 }), w: new THREE.MeshBasicMaterial({ color: 0xffffff }) };
return MAT;
}
const hex = (h) => { const c = new THREE.Color(h); c.convertSRGBToLinear(); return [c.r, c.g, c.b]; };
export const LIVERIES = {
kestrel: { base: '#F2F3F1', a: '#163A6B', b: '#E8742A' },
rook: { base: '#E9B421', a: '#16181B', b: '#F2F0E8' },
strix: { base: '#F4F2EE', a: '#C3202E', b: '#16181B' },
vesper: { base: '#F5F6F7', a: '#2A2F38', b: '#9AA3AE' },
};
export function makeAircraft(id, env) {
const M = mats(env), L = LIVERIES[id] || LIVERIES.kestrel;
const base = hex(L.base), A = hex(L.a), B = hex(L.b);
const g = new THREE.Group(); const parts = { prop: null, gear: [], flaps: [], ail: [], elev: null, rud: null };
const add = (geo, mat, cast = true) => { const m = new THREE.Mesh(geo, mat); m.castShadow = cast; g.add(m); return m; };
const stripe = (p, yc, h) => { const y = p[1] - yc; return y > -h * 0.12 && y < h * 0.02 ? A : y > h * 0.02 && y < h * 0.09 ? B : base; };
const wheel = (x, y, z, r = 0.3, w = 0.16, fair = true) => {
const wh = new THREE.Mesh(new THREE.CylinderGeometry(r, r, w, 18).rotateZ(Math.PI / 2), M.tyre); wh.position.set(x, y, z); g.add(wh);
if (fair) { const f = new THREE.Mesh(new THREE.SphereGeometry(1, 14, 10).scale(w * 1.6, r * 1.15, r * 2.3), M.paint); f.geometry.setAttribute('color', new THREE.Float32BufferAttribute(new Array(f.geometry.attributes.position.count).fill(base).flat(), 3)); f.position.set(x, y + r * 0.25, z); g.add(f); parts.gear.push(f); }
parts.gear.push(wh); return wh;
};
const strut = (a, b, r = 0.05) => { const v = new THREE.Vector3(...b).sub(new THREE.Vector3(...a)); const m = new THREE.Mesh(new THREE.CylinderGeometry(r, r, v.length(), 6), M.metal); m.position.set(...a).addScaledVector(v, 0.5); m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), v.normalize()); g.add(m); parts.gear.push(m); return m; };
const propeller = (z, y, r, blades = 2) => {
const hub = new THREE.Group(); hub.position.set(0, y, z);
const spin = new THREE.Mesh(new THREE.ConeGeometry(0.17, 0.42, 16).rotateX(-Math.PI / 2), M.paint);
spin.geometry.setAttribute('color', new THREE.Float32BufferAttribute(new Array(spin.geometry.attributes.position.count).fill(A).flat(), 3)); spin.position.z = -0.2; hub.add(spin);
const bl = new THREE.Group();
for (let i = 0; i < blades; i++) { const b = new THREE.Mesh(new THREE.BoxGeometry(0.13, r, 0.03), M.dark); b.position.y = r / 2; const w = new THREE.Group(); w.rotation.z = (i / blades) * Math.PI * 2; w.add(b); bl.add(w); }
hub.add(bl);
const disc = new THREE.Mesh(new THREE.CircleGeometry(r, 40), M.prop); disc.position.z = 0.02; hub.add(disc);
g.add(hub); parts.prop = { hub, bl, disc };
};
const navLights = (span, y, z) => { for (const [x, m] of [[-span / 2, M.nav.r], [span / 2, M.nav.g]]) { const s = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 6), m); s.position.set(x, y, z); g.add(s); } };
const wingLiv = (tipCol) => (p, f) => (f > 0.88 ? tipCol : base);
if (id === 'kestrel' || id === 'rook') {
const big = id === 'rook' ? 1.2 : 1;
const st = id === 'rook'
? [[-3.6, 0.5, 0.55, 0.1], [-3.2, 0.95, 0.95, 0.12], [-2.4, 1.25, 1.3, 0.15], [-1.2, 1.4, 1.65, 0.3], [0.4, 1.42, 1.75, 0.35], [2.0, 1.25, 1.45, 0.42], [4.0, 0.7, 0.85, 0.6], [6.4, 0.3, 0.45, 0.85], [7.4, 0.12, 0.3, 0.95]]
: [[-2.75, 0.42, 0.42, 0.0], [-2.45, 0.85, 0.9, 0.05], [-1.7, 1.1, 1.15, 0.1], [-0.7, 1.15, 1.45, 0.3], [0.6, 1.12, 1.45, 0.3], [1.9, 0.86, 1.05, 0.38], [3.6, 0.48, 0.62, 0.5], [5.6, 0.24, 0.36, 0.62], [6.3, 0.1, 0.24, 0.66]];
add(fuselage(st, (p, s) => stripe(p, s[3], s[2])), M.paint);
// cabin glass: a slanted windscreen + side windows (dark glossy blister)
const cab = new THREE.Mesh(new THREE.SphereGeometry(1, 20, 14, 0, Math.PI * 2, 0, Math.PI / 2).scale(0.56 * big, 0.5 * big, 1.25 * big), M.glass); cab.position.set(0, 0.62 * big, -0.75 * big); g.add(cab);
const span = id === 'rook' ? 15.9 : 11, wy = id === 'rook' ? 1.2 : 0.98, chord = id === 'rook' ? 1.7 : 1.55;
for (const mir of [false, true]) {
add(panel({ root: chord, tip: chord * 0.72, span: span / 2, dihedral: 0.03, x0: 0.25, y0: wy, z0: -0.55 * big, t: 0.14, camber: 0.03, mirror: mir, liv: wingLiv(B) }), M.paint);
strut([mir ? -0.55 : 0.55, -0.15, 0.05], [(mir ? -1 : 1) * span * 0.27, wy - 0.05, 0.1], 0.045);
}
add(panel({ root: 1.1 * big, tip: 0.75 * big, span: 1.85 * big, x0: 0.05, y0: 0.62 * big, z0: 5.0 * big, t: 0.1, camber: 0, liv: () => base }), M.paint);
add(panel({ root: 1.1 * big, tip: 0.75 * big, span: 1.85 * big, x0: 0.05, y0: 0.62 * big, z0: 5.0 * big, t: 0.1, camber: 0, mirror: true, liv: () => base }), M.paint);
add(panel({ root: 1.5 * big, tip: 0.7 * big, span: 1.55 * big, sweep: 0.8 * big, x0: 0, y0: 0.7 * big, z0: 4.75 * big, t: 0.1, camber: 0, vertical: true, liv: (p, f) => (f > 0.55 ? A : f > 0.45 ? B : base) }), M.paint);
propeller(-2.85 * big - (id === 'rook' ? 0.7 : 0), 0.0, id === 'rook' ? 1.3 : 0.95, id === 'rook' ? 3 : 2);
const gr = id === 'rook' ? 0.42 : 0.3;
wheel(0, -1.25 * big + gr - 0.3 + 0.0, -1.75 * big, gr * 0.85, 0.14, id !== 'rook'); strut([0, -0.35, -1.75 * big], [0, -1.25 * big + gr * 0.6, -1.75 * big]);
for (const s of [-1, 1]) { wheel(s * 1.25 * big, -1.3 * big + gr - 0.3, 0.35, gr, 0.18, id !== 'rook'); strut([s * 0.4, -0.3, 0.3], [s * 1.25 * big, -1.3 * big + gr * 0.7, 0.35], 0.05); }
navLights(span, wy + 0.05, -0.1);
} else if (id === 'strix') {
const st = [[-2.35, 0.36, 0.36, 0.05], [-2.1, 0.78, 0.8, 0.08], [-1.4, 0.95, 0.95, 0.12], [-0.3, 0.98, 1.0, 0.14], [0.8, 0.86, 0.9, 0.2], [2.4, 0.5, 0.58, 0.3], [4.2, 0.22, 0.32, 0.38], [4.9, 0.1, 0.2, 0.42]];
add(fuselage(st, (p, s) => { const y = p[1] - s[3]; return y > 0.05 ? A : (Math.abs(p[0]) > 0.3 && y > -0.2 ? B : base); }), M.paint);
const can = new THREE.Mesh(new THREE.SphereGeometry(1, 22, 14, 0, Math.PI * 2, 0, Math.PI / 2).scale(0.42, 0.5, 1.15), M.glass); can.position.set(0, 0.42, 0.15); g.add(can);
const sun = (p, f) => { const a = Math.atan2(p[2] + 0.2, Math.abs(p[0])); return Math.floor(a * 5 + f * 3) % 2 ? A : base; };
for (const mir of [false, true]) add(panel({ root: 1.7, tip: 1.0, span: 3.7, dihedral: 0, x0: 0.3, y0: -0.25, z0: -0.85, t: 0.15, camber: 0, mirror: mir, liv: sun }), M.paint);
for (const mir of [false, true]) add(panel({ root: 0.9, tip: 0.6, span: 1.35, x0: 0.05, y0: 0.32, z0: 3.75, t: 0.1, camber: 0, mirror: mir, liv: () => A }), M.paint);
add(panel({ root: 1.2, tip: 0.6, span: 1.1, sweep: 0.55, x0: 0, y0: 0.4, z0: 3.6, t: 0.1, camber: 0, vertical: true, liv: () => A }), M.paint);
propeller(-2.5, 0.05, 0.95, 3);
wheel(0, -0.72, -1.4, 0.24, 0.12); strut([0, -0.3, -1.4], [0, -0.75, -1.4]);
for (const s of [-1, 1]) { wheel(s * 1.1, -0.77, 0.2, 0.28, 0.14); strut([s * 0.35, -0.35, 0.1], [s * 1.1, -0.8, 0.2], 0.05); }
navLights(7.4, -0.2, -0.3);
} else { // vesper: low swept wing, T-tail, rear engines
const st = [[-7.9, 0.2, 0.2, 0.25], [-7.5, 0.9, 0.85, 0.3], [-6.6, 1.55, 1.5, 0.4], [-5.2, 1.85, 1.85, 0.5], [-2.0, 1.9, 1.95, 0.5], [2.5, 1.85, 1.9, 0.5], [5.0, 1.45, 1.55, 0.7], [7.3, 0.75, 0.95, 1.1], [8.6, 0.25, 0.45, 1.4]];
add(fuselage(st, (p, s) => stripe(p, s[3], s[2])), M.paint);
const wsc = new THREE.Mesh(new THREE.SphereGeometry(1, 20, 12, 0, Math.PI * 2, 0, Math.PI / 2.4).scale(0.85, 0.45, 1.1), M.glass); wsc.position.set(0, 0.95, -5.9); wsc.rotation.x = 0.12; g.add(wsc);
for (let i = 0; i < 5; i++) for (const s of [-1, 1]) { const w = new THREE.Mesh(new THREE.SphereGeometry(0.17, 10, 8).scale(0.3, 1, 1.25), M.glass); w.position.set(s * 0.94, 0.75, -3.6 + i * 1.05); g.add(w); }
for (const mir of [false, true]) add(panel({ root: 3.3, tip: 1.2, span: 7.6, sweep: 2.4, dihedral: 0.06, x0: 0.5, y0: -0.35, z0: -0.6, t: 0.12, camber: 0.02, mirror: mir, liv: wingLiv(A) }), M.paint);
add(panel({ root: 2.6, tip: 1.3, span: 2.6, sweep: 2.1, x0: 0, y0: 1.5, z0: 5.6, t: 0.1, camber: 0, vertical: true, liv: (p, f) => (f > 0.3 ? A : base) }), M.paint);
for (const mir of [false, true]) add(panel({ root: 1.4, tip: 0.8, span: 2.9, sweep: 0.9, x0: 0.05, y0: 4.05, z0: 7.0, t: 0.1, camber: 0, mirror: mir, liv: () => base }), M.paint);
for (const s of [-1, 1]) {
const n = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.44, 2.8, 20, 1, true).rotateX(Math.PI / 2), M.paint); n.geometry.setAttribute('color', new THREE.Float32BufferAttribute(new Array(n.geometry.attributes.position.count).fill(B).flat(), 3)); n.position.set(s * 1.55, 1.15, 5.0); g.add(n);
const fan = new THREE.Mesh(new THREE.CircleGeometry(0.46, 20), M.dark); fan.position.set(s * 1.55, 1.15, 3.62); fan.rotation.y = Math.PI; g.add(fan);
const py = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.2, 1.2), M.paint); py.geometry.setAttribute('color', new THREE.Float32BufferAttribute(new Array(py.geometry.attributes.position.count).fill(base).flat(), 3)); py.position.set(s * 1.05, 1.1, 5.0); g.add(py);
}
wheel(0, -1.3, -5.2, 0.3, 0.16, false); strut([0, -0.5, -5.2], [0, -1.35, -5.2], 0.07);
for (const s of [-1, 1]) { wheel(s * 1.9, -1.25, 0.9, 0.36, 0.22, false); strut([s * 1.6, -0.4, 0.9], [s * 1.9, -1.3, 0.9], 0.08); }
navLights(15.8, 0.15, 1.9);
}
// beacon on top
const bc = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), M.nav.r); bc.position.set(0, id === 'vesper' ? 1.5 : id === 'rook' ? 1.35 : 1.0, 1.0); g.add(bc); parts.beacon = bc;
g.userData = parts;
return g;
}
/** Spin the prop, show/hide the gear, blink the beacon. */
export function animateAircraft(model, P, t) {
const pr = model.userData;
if (pr.prop) {
const rpm = 0.25 + P.surf.thr * 0.75;
pr.prop.bl.rotation.z += rpm * 0.9;
pr.prop.disc.material.opacity = 0.08 + rpm * 0.22;
pr.prop.bl.visible = rpm < 0.5 || Math.floor(t * 30) % 2 === 0;
}
for (const m of pr.gear) m.visible = P.surf.gear > 0.5;
if (pr.beacon) pr.beacon.visible = Math.floor(t * 1.2) % 2 === 0 && (t * 1.2) % 1 < 0.15;
}