Spaces:
Running
Running
Download world.js from CyberMax-tools/fellwing: direct link, hf CLI and curl.
- Browser
- Download file 26.4 kB
-
https://huggingface.co/spaces/CyberMax-tools/fellwing/resolve/main/world.js
- Command line
-
hf download hf://spaces/CyberMax-tools/fellwing/world.js
-
curl -L -o world.js https://huggingface.co/spaces/CyberMax-tools/fellwing/resolve/main/world.js
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; | |
| } | |