fuzzyworld

tower / files / src / views / craft.ts

craft.ts

Blame: the commit that last changed each line · file · history

99a2821b 2026-09-29 Pavel Safronov1/**
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 */
15import {
16 BoxGeometry, type BufferGeometry, ConeGeometry, CylinderGeometry, Group, type Material, Matrix4, Mesh,
17 SphereGeometry, Vector3,
18} from 'three'
19import type { CraftKind } from '../tour/interior-legs.js'
20
21export type MaterialFor = (colour: number) => Material
22
23interface Part { g: BufferGeometry; colour: number }
24
25const 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. */
29const 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. */
33const 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
36const 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. */
40function 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
48const WHITE = 0xe9e9ec, GREY = 0x8b9099, DARK = 0x2a2d33
49
50function 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. */
127export 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 */
145export 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}