tower / files / test / pipe-hull.test.ts
pipe-hull.test.ts
6368edcf 2026-09-28 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { BufferGeometry, InstancedMesh, Matrix4, Mesh, Vector3 } from 'three' | |
| 3 | import { HULL_DEPTH_M, LENGTH_M, RADIUS_M } from '../src/canon/constants.js' | |
| 4 | import { Bursts, buildPipeHull } from '../src/views/pipe-hull.js' | |
| 5 | import { mulberry32 } from '../src/terrain/noise.js' | |
| 6 | ||
| 7 | const SKIN = (RADIUS_M + HULL_DEPTH_M) / 1000 | |
| 8 | /** The hull's ends, collars included: past them there is no tube to see into. */ | |
| 9 | const Z0 = -HULL_DEPTH_M / 1000 | |
| 10 | const Z1 = (LENGTH_M + HULL_DEPTH_M) / 1000 | |
| 11 | ||
| 12 | /** | |
| 13 | * The smallest distance from the axis of any vertex the group draws alongside the hull. | |
| 14 | * A thruster's flame fanning out past an end is not beside the tube, so it is not counted. | |
| 15 | */ | |
| 16 | function innermost(mesh: Mesh): number { | |
| 17 | const pos = (mesh.geometry as BufferGeometry).attributes['position']! | |
| 18 | const count = mesh instanceof InstancedMesh ? mesh.count : 1 | |
| 19 | const m = new Matrix4() | |
| 20 | const v = new Vector3() | |
| 21 | let min = Infinity | |
| 22 | for (let k = 0; k < count; k++) { | |
| 23 | if (mesh instanceof InstancedMesh) mesh.getMatrixAt(k, m) | |
| 24 | else m.identity() | |
| 25 | for (let i = 0; i < pos.count; i++) { | |
| 26 | v.fromBufferAttribute(pos, i).applyMatrix4(m) | |
| 27 | if (v.z >= Z0 && v.z <= Z1) min = Math.min(min, Math.hypot(v.x, v.y)) | |
| 28 | } | |
| 29 | } | |
| 30 | return min | |
| 31 | } | |
| 32 | ||
| 33 | describe('the piping skin (D-077)', () => { | |
| 34 | const { group } = buildPipeHull() | |
| 35 | const meshes = group.children.filter((c): c is Mesh => c instanceof Mesh) | |
| 36 | ||
| 37 | it('puts nothing inside the skin, where the windows would see it', () => { | |
| 38 | for (const mesh of meshes.filter((m) => m instanceof Mesh)) { | |
| 39 | expect(innermost(mesh)).toBeGreaterThan(SKIN - 1e-3) | |
| 40 | } | |
| 41 | }) | |
| 42 | ||
| 43 | it('has every kind of part: bands, pipes, blocks, vents, thrusters', () => { | |
| 44 | const instanced = meshes.filter((m): m is InstancedMesh => m instanceof InstancedMesh) | |
| 45 | expect(instanced.length).toBe(9) | |
| 46 | expect(instanced.every((m) => m.count > 0)).toBe(true) | |
| 47 | expect(meshes.length - instanced.length).toBeGreaterThan(20) | |
| 48 | }) | |
| 49 | ||
| 50 | it('is the same plant on every load', () => { | |
| 51 | const again = buildPipeHull().group.children.filter((c): c is Mesh => c instanceof Mesh) | |
| 52 | const a = new Matrix4() | |
| 53 | const b = new Matrix4() | |
| 54 | ;(meshes.find((m) => m instanceof InstancedMesh) as InstancedMesh).getMatrixAt(7, a) | |
| 55 | ;(again.find((m) => m instanceof InstancedMesh) as InstancedMesh).getMatrixAt(7, b) | |
| 56 | expect(a.equals(b)).toBe(true) | |
| 57 | }) | |
| 58 | }) | |
| 59 | ||
ab6ae94f 2026-09-28 Pavel Safronov | 60 | describe('the plant under rock (D-081)', () => { |
| 61 | // Bare only on one half of the hull, by angle: easy to check a part against. | |
| 62 | const bare = (theta: number) => Math.cos(theta) > 0 | |
| 63 | const hull = buildPipeHull((theta) => bare(theta)) | |
| 64 | const instanced = hull.group.children.filter((c): c is InstancedMesh => c instanceof InstancedMesh) | |
| 65 | ||
| 66 | it('puts the plant rooms and vents only on bare ground', () => { | |
| 67 | // Blocks and vent housings. The blocks include the pipes' brackets, which follow the | |
| 68 | // pipes under the rock and are told apart by their length along the axis, 0.35 km. | |
| 69 | const [, boxes, housings] = instanced | |
| 70 | const m = new Matrix4() | |
| 71 | const v = new Vector3() | |
| 72 | const scale = new Vector3() | |
| 73 | let rooms = 0 | |
| 74 | for (let i = 0; i < boxes!.count; i++) { | |
| 75 | boxes!.getMatrixAt(i, m) | |
| 76 | v.setFromMatrixPosition(m) | |
| 77 | scale.setFromMatrixScale(m) | |
| 78 | if (Math.abs(scale.z - 0.35) < 1e-6) continue | |
| 79 | rooms++ | |
| 80 | expect(bare(Math.atan2(v.y, v.x))).toBe(true) | |
| 81 | } | |
| 82 | expect(rooms).toBeGreaterThan(500) | |
| 83 | for (let i = 0; i < housings!.count; i++) { | |
| 84 | housings!.getMatrixAt(i, m) | |
| 85 | v.setFromMatrixPosition(m) | |
| 86 | expect(bare(Math.atan2(v.y, v.x))).toBe(true) | |
| 87 | } | |
| 88 | }) | |
| 89 | ||
| 90 | it('says where its thruster pods stand, so the rock can be dug clear of them', () => { | |
| 91 | expect(hull.pods.length).toBe(12) | |
| 92 | }) | |
| 93 | ||
| 94 | it('is the same plant as ever when nothing is under rock', () => { | |
| 95 | const a = new Matrix4() | |
| 96 | const b = new Matrix4() | |
| 97 | const plain = buildPipeHull().group.children.filter((c): c is InstancedMesh => c instanceof InstancedMesh) | |
| 98 | ;(plain[1]!).getMatrixAt(plain[1]!.count - 1, a) | |
| 99 | ;(buildPipeHull(() => true).group.children.filter((c): c is InstancedMesh => c instanceof InstancedMesh)[1]!) | |
| 100 | .getMatrixAt(plain[1]!.count - 1, b) | |
| 101 | expect(a.equals(b)).toBe(true) | |
| 102 | }) | |
| 103 | }) | |
| 104 | ||
6368edcf 2026-09-28 Pavel Safronov | 105 | describe('emitters firing at random', () => { |
| 106 | const timing = { on: [1, 2] as [number, number], off: [3, 6] as [number, number], attack: 0.5, release: 1 } | |
| 107 | ||
| 108 | it('goes on and off, rising and falling rather than snapping', () => { | |
| 109 | const b = new Bursts(1, timing, mulberry32(3)) | |
| 110 | let wasOn = false | |
| 111 | let ons = 0 | |
| 112 | let prev = 0 | |
| 113 | for (let t = 0; t < 120; t += 0.05) { | |
| 114 | b.step(t) | |
| 115 | const l = b.level[0]! | |
| 116 | expect(l).toBeGreaterThanOrEqual(0) | |
| 117 | expect(l).toBeLessThanOrEqual(1) | |
| 118 | // At 0.05 s a frame, the level moves by at most 0.05 / attack (levels are float32). | |
| 119 | expect(Math.abs(l - prev)).toBeLessThanOrEqual(0.1 + 1e-6) | |
| 120 | prev = l | |
| 121 | if (l > 0.99 && !wasOn) ons++ | |
| 122 | wasOn = l > 0.99 ? true : l === 0 ? false : wasOn | |
| 123 | } | |
| 124 | // One cycle is 4 to 8 s, so two minutes holds 15 to 30 of them. | |
| 125 | expect(ons).toBeGreaterThan(12) | |
| 126 | expect(ons).toBeLessThan(32) | |
| 127 | }) | |
| 128 | ||
| 129 | it('keeps each emitter on its own clock', () => { | |
| 130 | const b = new Bursts(20, timing, mulberry32(5)) | |
| 131 | b.step(0) | |
| 132 | b.step(4) | |
| 133 | const lit = Array.from(b.level).filter((l) => l > 0).length | |
| 134 | expect(lit).toBeGreaterThan(0) | |
| 135 | expect(lit).toBeLessThan(20) | |
| 136 | }) | |
| 137 | ||
| 138 | it('survives a long pause, as when the tab was hidden', () => { | |
| 139 | const b = new Bursts(3, timing, mulberry32(9)) | |
| 140 | b.step(0) | |
| 141 | b.step(3600) | |
| 142 | for (const l of b.level) expect(l >= 0 && l <= 1).toBe(true) | |
| 143 | }) | |
| 144 | }) |