tower / files / src / views / craft.ts
craft.ts
| 1 | /** |
| 2 | * The flyers of the scenic tour, drawn: paragliders, gliders, light aircraft and seaplanes, |
| 3 | * airliners, airships and the giants that live near the axis, trains, and spacecraft. See |
| 4 | * world-generation.md §19, D-085. |
| 5 | * |
| 6 | * Each is a handful of plain shapes, modelled in metres with its nose along +x and its top |
| 7 | * along +y, which is how the tour hands them over (tour/interior-legs.ts `Craft`). They are |
| 8 | * seen for fifteen seconds at a time, mostly from a chase camera tens of metres back, so the |
| 9 | * silhouette is what matters: the paraglider's arched wing, the airliner's swept one, the |
| 10 | * airship's envelope and fins. |
| 11 | * |
| 12 | * The material is the caller's: the interior hazes and lights them like everything else in |
| 13 | * there, and the exterior lights its spacecraft with its own lights. |
| 14 | */ |
| 15 | import { |
| 16 | BoxGeometry, type BufferGeometry, ConeGeometry, CylinderGeometry, Group, type Material, Matrix4, Mesh, |
| 17 | SphereGeometry, Vector3, |
| 18 | } from 'three' |
| 19 | import type { CraftKind } from '../tour/interior-legs.js' |
| 20 | |
| 21 | export type MaterialFor = (colour: number) => Material |
| 22 | |
| 23 | interface Part { g: BufferGeometry; colour: number } |
| 24 | |
| 25 | const box = (w: number, h: number, d: number, x: number, y: number, z: number, colour: number): Part => |
| 26 | ({ g: new BoxGeometry(w, h, d).translate(x, y, z), colour }) |
| 27 | |
| 28 | /** A cylinder along x. */ |
| 29 | const tube = (r: number, length: number, x: number, y: number, z: number, colour: number, r2 = r): Part => |
| 30 | ({ g: new CylinderGeometry(r2, r, length, 16).rotateZ(-Math.PI / 2).translate(x, y, z), colour }) |
| 31 | |
| 32 | /** A cone along +x, its point forward. */ |
| 33 | const nose = (r: number, length: number, x: number, y: number, z: number, colour: number): Part => |
| 34 | ({ g: new ConeGeometry(r, length, 16).rotateZ(-Math.PI / 2).translate(x, y, z), colour }) |
| 35 | |
| 36 | const blob = (rx: number, ry: number, rz: number, x: number, y: number, z: number, colour: number): Part => |
| 37 | ({ g: new SphereGeometry(1, 24, 16).scale(rx, ry, rz).translate(x, y, z), colour }) |
| 38 | |
| 39 | /** A wing: a flat box `span` across (z), `chord` deep, swept back by `sweep` metres at the tips. */ |
| 40 | function wing(span: number, chord: number, thick: number, x: number, y: number, sweep: number, colour: number): Part { |
| 41 | const g = new BoxGeometry(chord, thick, span).translate(x, y, 0) |
| 42 | const p = g.attributes['position']! |
| 43 | for (let i = 0; i < p.count; i++) p.setX(i, p.getX(i) - (Math.abs(p.getZ(i)) / (span / 2)) * sweep) |
| 44 | g.computeVertexNormals() |
| 45 | return { g, colour } |
| 46 | } |
| 47 | |
| 48 | const WHITE = 0xe9e9ec, GREY = 0x8b9099, DARK = 0x2a2d33 |
| 49 | |
| 50 | function parts(kind: CraftKind): Part[] { |
| 51 | switch (kind) { |
| 52 | case 'paraglider': { |
| 53 | // An arched wing overhead on its lines, and the pilot hanging under it. |
| 54 | const out: Part[] = [box(0.6, 1.1, 0.5, 0, 0, 0, DARK)] |
| 55 | const cells = 9, R = 6.5, arc = 1.6 |
| 56 | for (let i = 0; i < cells; i++) { |
| 57 | const a = -arc / 2 + (arc * (i + 0.5)) / cells |
| 58 | const g = new BoxGeometry(2.6, 0.3, (R * arc) / cells + 0.05) |
| 59 | .rotateX(-a).translate(0, 7 + R * (Math.cos(a) - 1) + 0.5, R * Math.sin(a)) |
| 60 | out.push({ g, colour: i === 0 || i === cells - 1 ? 0xf2c14e : 0xe0662a }) |
| 61 | } |
| 62 | for (const s of [-1, 1]) out.push(box(0.05, 6.3, 0.05, 0, 3.4, s * 2.2, GREY)) |
| 63 | return out |
| 64 | } |
| 65 | case 'glider': |
| 66 | return [ |
| 67 | tube(0.45, 7, 0, 0, 0, WHITE, 0.25), nose(0.45, 1.2, 4.1, 0, 0, WHITE), |
| 68 | blob(0.8, 0.4, 0.4, 2.2, 0.35, 0, 0x5e7690), |
| 69 | wing(17, 0.95, 0.12, 0.6, 0.25, 0.3, WHITE), |
| 70 | box(0.9, 1.4, 0.1, -3.4, 0.7, 0, WHITE), wing(2.8, 0.6, 0.08, -3.6, 1.4, 0.1, WHITE), |
| 71 | ] |
| 72 | case 'light-plane': |
| 73 | case 'seaplane': { |
| 74 | const body = kind === 'seaplane' ? 0xd9a32e : 0xb8322e |
| 75 | const out = [ |
| 76 | tube(0.65, 7.5, 0, 0, 0, body, 0.35), nose(0.65, 1, 4.2, 0, 0, WHITE), |
| 77 | blob(1.1, 0.55, 0.6, 1.2, 0.55, 0, 0x5e7690), |
| 78 | wing(11, 1.5, 0.18, 0.8, 0.85, 0, WHITE), |
| 79 | box(1.3, 1.6, 0.12, -3.3, 0.8, 0, body), wing(3.4, 0.9, 0.1, -3.4, 0.2, 0.1, WHITE), |
| 80 | ] |
| 81 | if (kind === 'seaplane') for (const s of [-1, 1]) out.push(tube(0.3, 5.5, 0.4, -1.6, s * 1.3, WHITE), box(0.1, 1.2, 0.1, 0.9, -0.9, s * 1.3, GREY)) |
| 82 | return out |
| 83 | } |
| 84 | case 'helicopter': |
| 85 | return [ |
| 86 | blob(2.6, 1.4, 1.3, 0, 0, 0, 0x2f5f9e), tube(0.25, 6, -4.2, 0.4, 0, 0x2f5f9e, 0.15), |
| 87 | box(0.1, 0.08, 11, 0, 1.6, 0, DARK), box(11, 0.08, 0.1, 0, 1.6, 0, DARK), |
| 88 | ] |
| 89 | case 'airliner': |
| 90 | return [ |
| 91 | tube(2, 38, 0, 0, 0, WHITE, 1.4), nose(2, 4, 21, 0, 0, WHITE), |
| 92 | wing(36, 6, 0.4, -1, -0.8, 7, WHITE), |
| 93 | tube(1.1, 4, 2, -2, 6.5, GREY), tube(1.1, 4, 2, -2, -6.5, GREY), |
| 94 | box(4.5, 7, 0.4, -17.5, 4, 0, 0x2f5f9e), wing(12, 3, 0.3, -18, 1.2, 2, WHITE), |
| 95 | ] |
| 96 | case 'airship': |
| 97 | case 'giant-airship': { |
| 98 | // The giants are the same shape, three and a half times over: 420 m, for air a |
| 99 | // twentieth as dense near the axis (D-085). |
| 100 | const k = kind === 'giant-airship' ? 3.5 : 1 |
| 101 | const hull = kind === 'giant-airship' ? 0xb9b3a4 : 0xc9ccd2 |
| 102 | const out: Part[] = [blob(60 * k, 14 * k, 14 * k, 0, 0, 0, hull), box(16 * k, 4 * k, 5 * k, 6 * k, -15 * k, 0, GREY)] |
| 103 | for (const r of [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2]) { |
| 104 | const g = new BoxGeometry(14 * k, 0.6 * k, 12 * k).translate(-48 * k, 0, 16 * k).rotateX(r) |
| 105 | out.push({ g, colour: 0x7a1f1f }) |
| 106 | } |
| 107 | return out |
| 108 | } |
| 109 | case 'locomotive': |
| 110 | return [box(22, 3.9, 3.1, 0, 2.3, 0, 0x7a2320), box(3, 1, 2.6, 9, 4.5, 0, DARK)] |
| 111 | case 'carriage': |
| 112 | return [box(25, 3.8, 3, 0, 2.3, 0, 0x2e4a6b), box(24.6, 0.35, 3.1, 0, 4.3, 0, GREY)] |
| 113 | case 'train': |
| 114 | case 'ship': |
| 115 | // Drawn car by car (above), and by the fleet: never asked for whole. |
| 116 | return [] |
| 117 | case 'spacecraft': |
| 118 | return [ |
| 119 | tube(7, 60, 0, 0, 0, 0xb3b8c2), nose(7, 12, 36, 0, 0, 0xd6d8dc), |
| 120 | nose(9, 10, -34, 0, 0, 0x3a3d44), blob(4, 4, 4, 30, 5, 0, 0x5e7690), |
| 121 | box(28, 0.6, 46, -8, 0, 0, 0x6b5a2e), box(12, 2.5, 2.5, 10, 7, 0, GREY), |
| 122 | ] |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | /** A craft, built fresh: a group of meshes in metres. */ |
| 127 | export function buildCraft(kind: CraftKind, material: MaterialFor): Group { |
| 128 | const group = new Group() |
| 129 | group.name = kind |
| 130 | const mats = new Map<number, Material>() |
| 131 | for (const p of parts(kind)) { |
| 132 | let m = mats.get(p.colour) |
| 133 | if (!m) mats.set(p.colour, (m = material(p.colour))) |
| 134 | const mesh = new Mesh(p.g, m) |
| 135 | mesh.frustumCulled = false |
| 136 | group.add(mesh) |
| 137 | } |
| 138 | return group |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * A pool of craft, placed each frame from the tour's list. Positions arrive in metres in |
| 143 | * the tower's frame and are drawn in the view's kilometres, `metresPerUnit` to the unit. |
| 144 | */ |
| 145 | export class CraftView { |
| 146 | readonly group = new Group() |
| 147 | private pool = new Map<CraftKind, Group[]>() |
| 148 | private basis = new Matrix4() |
| 149 | private fwd = new Vector3() |
| 150 | private up = new Vector3() |
| 151 | private side = new Vector3() |
| 152 | |
| 153 | constructor(private material: MaterialFor, private metresPerUnit = 1000) { |
| 154 | this.group.name = 'craft' |
| 155 | } |
| 156 | |
| 157 | show(crafts: readonly { kind: CraftKind; pos: { x: number; y: number; z: number }; fwd: { x: number; y: number; z: number }; up: { x: number; y: number; z: number } }[]): void { |
| 158 | for (const list of this.pool.values()) for (const g of list) g.visible = false |
| 159 | const used = new Map<CraftKind, number>() |
| 160 | for (const c of crafts) { |
| 161 | const n = used.get(c.kind) ?? 0 |
| 162 | used.set(c.kind, n + 1) |
| 163 | let list = this.pool.get(c.kind) |
| 164 | if (!list) this.pool.set(c.kind, (list = [])) |
| 165 | let g = list[n] |
| 166 | if (!g) { |
| 167 | g = buildCraft(c.kind, this.material) |
| 168 | list.push(g) |
| 169 | this.group.add(g) |
| 170 | } |
| 171 | const k = this.metresPerUnit |
| 172 | this.fwd.set(c.fwd.x, c.fwd.y, c.fwd.z).normalize() |
| 173 | this.up.set(c.up.x, c.up.y, c.up.z).normalize() |
| 174 | this.side.crossVectors(this.fwd, this.up).normalize() |
| 175 | this.up.crossVectors(this.side, this.fwd) |
| 176 | this.basis.makeBasis(this.fwd, this.up, this.side) |
| 177 | g.quaternion.setFromRotationMatrix(this.basis) |
| 178 | g.position.set(c.pos.x / k, c.pos.y / k, c.pos.z / k) |
| 179 | g.scale.setScalar(1 / k) |
| 180 | g.visible = true |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | clear(): void { |
| 185 | for (const list of this.pool.values()) for (const g of list) g.visible = false |
| 186 | } |
| 187 | } |