fuzzyworld

tower / files / test / pipe-hull.test.ts

pipe-hull.test.ts

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

6368edcf 2026-09-28 Pavel Safronov1import { describe, expect, it } from 'vitest'
2import { BufferGeometry, InstancedMesh, Matrix4, Mesh, Vector3 } from 'three'
3import { HULL_DEPTH_M, LENGTH_M, RADIUS_M } from '../src/canon/constants.js'
4import { Bursts, buildPipeHull } from '../src/views/pipe-hull.js'
5import { mulberry32 } from '../src/terrain/noise.js'
6
7const SKIN = (RADIUS_M + HULL_DEPTH_M) / 1000
8/** The hull's ends, collars included: past them there is no tube to see into. */
9const Z0 = -HULL_DEPTH_M / 1000
10const 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 */
16function 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
33describe('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 Safronov60describe('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 Safronov105describe('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})