@driftengine/chemistry 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +194 -0
  4. package/dist/conservation.d.ts +9 -0
  5. package/dist/conservation.js +38 -0
  6. package/dist/element/elements.d.ts +31 -0
  7. package/dist/element/elements.js +73 -0
  8. package/dist/element/matrix.d.ts +27 -0
  9. package/dist/element/matrix.js +80 -0
  10. package/dist/field/ambient.d.ts +49 -0
  11. package/dist/field/ambient.js +82 -0
  12. package/dist/field/atmosphere.d.ts +308 -0
  13. package/dist/field/atmosphere.js +1033 -0
  14. package/dist/ignition/criteria.d.ts +97 -0
  15. package/dist/ignition/criteria.js +135 -0
  16. package/dist/ignition/events.d.ts +56 -0
  17. package/dist/ignition/events.js +90 -0
  18. package/dist/index.d.ts +84 -0
  19. package/dist/index.js +66 -0
  20. package/dist/library/biological.d.ts +6 -0
  21. package/dist/library/biological.js +229 -0
  22. package/dist/library/common.d.ts +26 -0
  23. package/dist/library/common.js +208 -0
  24. package/dist/library/food.d.ts +9 -0
  25. package/dist/library/food.js +272 -0
  26. package/dist/library/fuel.d.ts +19 -0
  27. package/dist/library/fuel.js +290 -0
  28. package/dist/library/library.d.ts +45 -0
  29. package/dist/library/library.js +29 -0
  30. package/dist/library/metal.d.ts +6 -0
  31. package/dist/library/metal.js +181 -0
  32. package/dist/library/mineral.d.ts +4 -0
  33. package/dist/library/mineral.js +177 -0
  34. package/dist/library/organic.d.ts +12 -0
  35. package/dist/library/organic.js +489 -0
  36. package/dist/library/polymer.d.ts +4 -0
  37. package/dist/library/polymer.js +237 -0
  38. package/dist/parcel/conduction.d.ts +20 -0
  39. package/dist/parcel/conduction.js +160 -0
  40. package/dist/parcel/shells.d.ts +49 -0
  41. package/dist/parcel/shells.js +73 -0
  42. package/dist/parcel/store.d.ts +395 -0
  43. package/dist/parcel/store.js +1152 -0
  44. package/dist/parcel/tier.d.ts +48 -0
  45. package/dist/parcel/tier.js +51 -0
  46. package/dist/present/audio.d.ts +41 -0
  47. package/dist/present/audio.js +50 -0
  48. package/dist/present/blackbody.d.ts +55 -0
  49. package/dist/present/blackbody.js +112 -0
  50. package/dist/present/flame.d.ts +34 -0
  51. package/dist/present/flame.js +103 -0
  52. package/dist/present/index.d.ts +25 -0
  53. package/dist/present/index.js +22 -0
  54. package/dist/present/install.d.ts +87 -0
  55. package/dist/present/install.js +157 -0
  56. package/dist/present/smoke.d.ts +63 -0
  57. package/dist/present/smoke.js +85 -0
  58. package/dist/present/surface.d.ts +40 -0
  59. package/dist/present/surface.js +76 -0
  60. package/dist/reaction/localise.d.ts +16 -0
  61. package/dist/reaction/localise.js +95 -0
  62. package/dist/reaction/rates.d.ts +59 -0
  63. package/dist/reaction/rates.js +101 -0
  64. package/dist/reaction/reaction.d.ts +178 -0
  65. package/dist/reaction/reaction.js +104 -0
  66. package/dist/reaction/registry.d.ts +47 -0
  67. package/dist/reaction/registry.js +215 -0
  68. package/dist/reaction/solver.d.ts +51 -0
  69. package/dist/reaction/solver.js +277 -0
  70. package/dist/species/registry.d.ts +26 -0
  71. package/dist/species/registry.js +97 -0
  72. package/dist/species/species.d.ts +48 -0
  73. package/dist/species/species.js +20 -0
  74. package/dist/species/standard.d.ts +30 -0
  75. package/dist/species/standard.js +436 -0
  76. package/dist/species/thermal.d.ts +18 -0
  77. package/dist/species/thermal.js +72 -0
  78. package/dist/substance/boiling.d.ts +29 -0
  79. package/dist/substance/boiling.js +54 -0
  80. package/dist/substance/composition.d.ts +34 -0
  81. package/dist/substance/composition.js +53 -0
  82. package/dist/substance/registry.d.ts +86 -0
  83. package/dist/substance/registry.js +187 -0
  84. package/dist/substance/substance.d.ts +94 -0
  85. package/dist/substance/substance.js +48 -0
  86. package/dist/testing/drift.d.ts +15 -0
  87. package/dist/testing/drift.js +29 -0
  88. package/dist/transport/contacts.d.ts +22 -0
  89. package/dist/transport/contacts.js +50 -0
  90. package/dist/transport/radiation.d.ts +101 -0
  91. package/dist/transport/radiation.js +199 -0
  92. package/dist/transport/world.d.ts +92 -0
  93. package/dist/transport/world.js +381 -0
  94. package/dist/world/fingerprint.d.ts +31 -0
  95. package/dist/world/fingerprint.js +70 -0
  96. package/package.json +100 -0
  97. package/src/conservation.ts +44 -0
  98. package/src/element/elements.ts +80 -0
  99. package/src/element/matrix.ts +89 -0
  100. package/src/field/ambient.ts +111 -0
  101. package/src/field/atmosphere.ts +1159 -0
  102. package/src/ignition/criteria.ts +177 -0
  103. package/src/ignition/events.ts +100 -0
  104. package/src/index.ts +213 -0
  105. package/src/library/biological.ts +252 -0
  106. package/src/library/common.ts +224 -0
  107. package/src/library/food.ts +302 -0
  108. package/src/library/fuel.ts +299 -0
  109. package/src/library/library.ts +62 -0
  110. package/src/library/metal.ts +186 -0
  111. package/src/library/mineral.ts +194 -0
  112. package/src/library/organic.ts +545 -0
  113. package/src/library/polymer.ts +243 -0
  114. package/src/parcel/conduction.ts +188 -0
  115. package/src/parcel/shells.ts +100 -0
  116. package/src/parcel/store.ts +1270 -0
  117. package/src/parcel/tier.ts +60 -0
  118. package/src/present/audio.ts +54 -0
  119. package/src/present/blackbody.ts +118 -0
  120. package/src/present/flame.ts +111 -0
  121. package/src/present/index.ts +25 -0
  122. package/src/present/install.ts +209 -0
  123. package/src/present/smoke.ts +139 -0
  124. package/src/present/surface.ts +107 -0
  125. package/src/reaction/localise.ts +126 -0
  126. package/src/reaction/rates.ts +153 -0
  127. package/src/reaction/reaction.ts +235 -0
  128. package/src/reaction/registry.ts +257 -0
  129. package/src/reaction/solver.ts +326 -0
  130. package/src/species/registry.ts +117 -0
  131. package/src/species/species.ts +53 -0
  132. package/src/species/standard.ts +449 -0
  133. package/src/species/thermal.ts +74 -0
  134. package/src/substance/boiling.ts +64 -0
  135. package/src/substance/composition.ts +81 -0
  136. package/src/substance/registry.ts +223 -0
  137. package/src/substance/substance.ts +148 -0
  138. package/src/testing/drift.ts +28 -0
  139. package/src/transport/contacts.ts +56 -0
  140. package/src/transport/radiation.ts +238 -0
  141. package/src/transport/world.ts +437 -0
  142. package/src/world/fingerprint.ts +106 -0
@@ -0,0 +1,1159 @@
1
+ /**
2
+ * One atmosphere, sampled by everything that breathes, burns or smokes.
3
+ *
4
+ * `AGENTS.md` has a section called *One wind, sampled once*, whose argument is that a second wind
5
+ * is a scene that does not cohere. **The same argument applies with more force to the air**, which
6
+ * is what carries oxygen to a fire and smoke away from it: two disagreeing airs give a fire that is
7
+ * starved in one system and rich in another, and nobody can see why.
8
+ *
9
+ * **Sparse chunks of 8³ cells, and empty space costs nothing.** A chunk exists only where something
10
+ * has put matter into it; everywhere else reads from the far field, which is a constant. A read of
11
+ * empty air creates nothing, so a world with one campfire in it holds one chunk.
12
+ *
13
+ * **Masses rather than moles**, which is the same choice a parcel makes and for a better reason
14
+ * than consistency: it lets the gas-phase network run through **the same solver a shell does**.
15
+ * `§8.4`'s combustion is not a second implementation of `§7`'s substep-and-clamp — it is that one,
16
+ * given a cell instead of a shell.
17
+ *
18
+ * **What this is not, and `§1` says it in writing:** not computational fluid dynamics. There is no
19
+ * pressure projection and no incompressibility constraint. A plume rises, bends and spreads, and it
20
+ * does not curl into the vortex rings a real one makes.
21
+ */
22
+ import { ELEMENT_COUNT } from '../element/elements.ts';
23
+ import type { SpeciesRegistry } from '../species/registry.ts';
24
+ import { PHASE_LIQUID } from '../species/species.ts';
25
+ import { localiseReactions } from '../reaction/localise.ts';
26
+ import type { ReactionRegistry } from '../reaction/registry.ts';
27
+ import { type SubstanceReactions, reactShell } from '../reaction/solver.ts';
28
+ import {
29
+ type AmbientState,
30
+ GAS_CONSTANT,
31
+ STANDARD_AIR,
32
+ STANDARD_FIELD_SPECIES,
33
+ ambientCellMass,
34
+ saturationVapourPressure,
35
+ } from './ambient.ts';
36
+
37
+ /** Cells along one edge of a chunk. */
38
+ export const CHUNK_CELLS = 8;
39
+
40
+ /** Metres along one edge of a chunk, at the default cell size. */
41
+ export const CHUNK_SIZE = 8;
42
+
43
+ /**
44
+ * Extinction per kilogram of soot per cubic metre, m²/kg.
45
+ *
46
+ * Smoke's mass-specific extinction coefficient, which is what turns a soot concentration into a
47
+ * visibility. The value is the one measured for flaming-fire smoke; smouldering smoke scatters
48
+ * rather than absorbs and runs lower.
49
+ */
50
+ const SOOT_EXTINCTION = 8700;
51
+
52
+ /** Koschmieder's constant: the contrast threshold that defines "can no longer see it". */
53
+ const VISIBILITY_CONSTANT = 3.912;
54
+
55
+ /**
56
+ * The saturation vapour pressure table: 256 knots over 200-500 K.
57
+ *
58
+ * Below 200 K there is no liquid water to be in equilibrium with and above 500 K saturation is well
59
+ * past any pressure this model runs at, so both ends clamp rather than extrapolate. Linear over a
60
+ * curve whose logarithm changes by 6% per kelvin costs under a tenth of a percent at this spacing,
61
+ * which is inside the spread of the Magnus coefficients themselves.
62
+ */
63
+ const SATURATION_MIN = 200;
64
+ const SATURATION_MAX = 500;
65
+ const SATURATION_KNOTS = 256;
66
+
67
+ const CELLS_PER_CHUNK = CHUNK_CELLS * CHUNK_CELLS * CHUNK_CELLS;
68
+
69
+ /** m/s². */
70
+ const GRAVITY = 9.80665;
71
+
72
+ /**
73
+ * How long a buoyant parcel accelerates before drag holds it, seconds.
74
+ *
75
+ * Half a second gives a cell at 800 K a rise of about 3 m/s, which is what a real fire plume does.
76
+ * A proper answer would come from an entrainment model; this is the one number standing in for one.
77
+ */
78
+ const BUOYANT_TIME = 0.5;
79
+
80
+ /** m/s. Past this a plume is moving faster than anything this model resolves. */
81
+ const MAX_BUOYANT_SPEED = 12;
82
+
83
+ /** How fast a pressure difference across a face equalises, per second. */
84
+ const PRESSURE_RELAX = 8;
85
+
86
+ /**
87
+ * The most of a cell one face may move in one sub-step.
88
+ *
89
+ * **A tenth, and the number is a conservation constraint rather than a taste.** Faces are computed
90
+ * independently and summed into the delta, so a cell has three faces that can drain it by bulk flow
91
+ * and six that can drain it by diffusion. At the 0.4 this started at, those together could take
92
+ * more than the cell held — and the non-negativity clamp in the apply pass would quietly absorb the
93
+ * difference, which is mass created from nothing.
94
+ *
95
+ * It was found by an element-conservation assertion sitting at 8.6e-9 instead of at zero, which is
96
+ * exactly the class of bug that assertion exists for: too small to see, and not going away.
97
+ * 3 × 0.1 by bulk plus 6 × 0.1 by diffusion is 0.9, so the clamp is now a backstop that never fires.
98
+ */
99
+ const MAX_FACE_FRACTION = 0.1;
100
+
101
+ const MAX_TRANSPORT_SUBSTEPS = 16;
102
+
103
+ /** kg. Below this a cell is the outdoors as far as spreading is concerned. */
104
+ const AMBIENT_EPSILON = 1e-9;
105
+
106
+ /** J. The same idea for heat: a tenth of a joule in a cubic metre of air is not a plume. */
107
+ const ENTHALPY_EPSILON = 0.1;
108
+
109
+ export interface AtmosphereOptions {
110
+ /** Metres per cell. One by default, which makes a chunk eight metres across. */
111
+ readonly cellSize?: number;
112
+ readonly ambient?: AmbientState;
113
+ /** Gas species to track. Twelve by default; a consumer may name fewer and pay less. */
114
+ readonly species?: readonly string[];
115
+ /**
116
+ * How many chunks the live set may grow to before its edge becomes far field.
117
+ *
118
+ * A plume growing the live set is correct — smoke really does reach new ground — and unbounded
119
+ * growth is not. Past the cap a boundary face vents to the ambient instead of creating a
120
+ * neighbour, which degrades a plume into a dissipating one rather than failing.
121
+ */
122
+ readonly maxChunks?: number;
123
+ /** How fast an open boundary equalises with the far field, per second. */
124
+ readonly openness?: number;
125
+ /** Diffusivity between neighbouring cells, m²/s. */
126
+ readonly diffusivity?: number;
127
+ /** Where `reactionIds` are looked up. */
128
+ readonly reactions?: ReactionRegistry;
129
+ /**
130
+ * Gas-phase reactions to run in every cell, by id.
131
+ *
132
+ * `§8.4`'s combustion, and it is **not a second implementation** of the solver a shell uses — it
133
+ * is that one, given a cell. Which is what makes a flame stand off a surface rather than sit on
134
+ * it: burning is something the *air* does, in the place where the mixture is right.
135
+ */
136
+ readonly reactionIds?: readonly string[];
137
+ }
138
+
139
+ export class AtmosphereField {
140
+ readonly cellSize: number;
141
+ readonly cellVolume: number;
142
+ readonly ambient: AmbientState;
143
+ /** Global species indices this field tracks, in storage order. */
144
+ readonly species: Int32Array;
145
+
146
+ /** kg per species per cell, then one enthalpy per cell, then soot and aerosol. */
147
+ private readonly chunks: Float64Array[] = [];
148
+ private readonly blocked: Uint8Array[] = [];
149
+ private readonly coords: Int32Array[] = [];
150
+ private readonly byKey = new Map<number, number>();
151
+ /** Each chunk's packed `key(cx, cy, cz)`, parallel to `chunks`. */
152
+ private readonly keys: number[] = [];
153
+ /**
154
+ * Chunk indices in **key order**, which is the order every sweep and every reduction walks.
155
+ *
156
+ * **`chunks` is in creation order and creation order is history.** Two runs of one world that
157
+ * realise their chunks in a different sequence — a consumer spawning two logs the other way round
158
+ * is enough — hold identical per-cell state in differently-ordered arrays, and every `+=` over
159
+ * them then lands in a different order. Floating-point addition is not associative, so the answer
160
+ * differs in the last bit, and `fingerprintChemistry` reports a divergence that did not happen.
161
+ *
162
+ * Worse than the report: the sweep accumulates into shared `deltas`, so the *state* diverges too
163
+ * and a stiff exponential rate law grows the difference. Measured before this existed: two logs
164
+ * eight metres apart, spawned in the other order, left **1,202 cells** differing after sixty
165
+ * ticks.
166
+ *
167
+ * `key` is a pure function of the chunk's coordinates, so this order is a fact about geometry and
168
+ * nothing else. Maintained by insertion at creation, which is rare; the sweeps pay one indirection
169
+ * per chunk, not per cell.
170
+ */
171
+ private readonly order: number[] = [];
172
+ /** Scratch for one sweep round, so the walk does not observe chunks created during it. */
173
+ private round: Int32Array;
174
+ /** Which sweep last visited each chunk, so a chunk created mid-sweep is swept exactly once. */
175
+ private readonly sweptAt: number[] = [];
176
+ private pass = 0;
177
+ private readonly stride: number;
178
+ private readonly enthalpySlot: number;
179
+ private readonly sootSlot: number;
180
+ private readonly aerosolSlot: number;
181
+ /** Where `C(soot)` sits among the tracked species, or -1 where this field does not carry it. */
182
+ private readonly sootSpecies: number;
183
+ /** Slots of every carried species in the liquid phase: droplets, which is what an aerosol is. */
184
+ private readonly aerosolSpecies: Int32Array;
185
+
186
+ /** One cell of untouched air, filled once and copied into every new cell. */
187
+ readonly maxChunks: number;
188
+ private readonly openness: number;
189
+ private readonly diffusivity: number;
190
+ /** Deltas for one step, so no cell sees a neighbour a previous cell already changed. */
191
+ private readonly deltas: Float64Array[] = [];
192
+ /** What has left through an open boundary: per species, then enthalpy, soot and aerosol. */
193
+ private readonly vented: Float64Array;
194
+ private ventedEnthalpyTotal = 0;
195
+
196
+ private readonly ambientCell: Float64Array;
197
+ private readonly ambientEnthalpy: number;
198
+ private readonly ambientMass: number;
199
+ /**
200
+ * The ambient temperature **as `cellTemperature` computes it**, not as it was authored.
201
+ *
202
+ * The same arithmetic over the same doubles in the same order, so a cell that has been realised
203
+ * and one that has not report the identical number. See `temperatureAt`.
204
+ */
205
+ private readonly ambientTemperatureExact: number;
206
+ /** `saturationVapourPressure` sampled once, so `exp` never runs on a query. */
207
+ private readonly saturationTable = new Float64Array(SATURATION_KNOTS);
208
+ private readonly network: SubstanceReactions | null;
209
+ private readonly combustible: Int32Array;
210
+ private readonly molarMass: Float64Array;
211
+ /** J/(kg·K) at the standard state, one per tracked species. Resolved once. */
212
+ private readonly heatCapacity: Float64Array;
213
+ private readonly localOf: Int32Array;
214
+
215
+ constructor(
216
+ private readonly registry: SpeciesRegistry,
217
+ options: AtmosphereOptions = {},
218
+ ) {
219
+ this.cellSize = options.cellSize ?? 1;
220
+ if (!(this.cellSize > 0)) throw new Error(`a cell is ${this.cellSize} m across`);
221
+ this.cellVolume = this.cellSize * this.cellSize * this.cellSize;
222
+ this.ambient = options.ambient ?? STANDARD_AIR;
223
+
224
+ const ids = options.species ?? STANDARD_FIELD_SPECIES;
225
+ this.species = new Int32Array(ids.length);
226
+ for (let i = 0; i < ids.length; i++) {
227
+ const at = registry.indexOf(ids[i] as string);
228
+ if (at < 0) throw new Error(`the field names "${ids[i]}", which is not a registered species`);
229
+ this.species[i] = at;
230
+ }
231
+ this.localOf = new Int32Array(registry.count).fill(-1);
232
+ for (let i = 0; i < this.species.length; i++) this.localOf[this.species[i] as number] = i;
233
+
234
+ this.molarMass = new Float64Array(this.species.length);
235
+ for (let i = 0; i < this.species.length; i++) {
236
+ this.molarMass[i] = registry.molarMass(this.species[i] as number);
237
+ }
238
+
239
+ this.heatCapacity = new Float64Array(this.species.length);
240
+ for (let i = 0; i < this.species.length; i++) {
241
+ this.heatCapacity[i] = registry.heatCapacityOf(this.species[i] as number, 298.15);
242
+ }
243
+
244
+ this.enthalpySlot = this.species.length;
245
+ this.sootSlot = this.enthalpySlot + 1;
246
+ this.aerosolSlot = this.sootSlot + 1;
247
+ /* `localOf` is the species-index-to-slot map built just above, and `-1` where absent. */
248
+ const soot = registry.indexOf('C(soot)');
249
+ this.sootSpecies = soot >= 0 ? (this.localOf[soot] as number) : -1;
250
+ this.stride = this.aerosolSlot + 1;
251
+
252
+ /*
253
+ * **A liquid in a gas cell is a droplet, and a cloud of droplets is an aerosol.** Resolved from
254
+ * the phase rather than from a list of ids, because the field would otherwise have to name
255
+ * `tar(l)` — a species `library/organic` registers and every other consumer has never heard of —
256
+ * and a core file naming a library's species is the direction this package does not import in.
257
+ *
258
+ * So the rule is the physical one and it costs nothing to state: any carried species whose phase
259
+ * is liquid is suspended matter. Wood tar condensing out of a smouldering plume is what this was
260
+ * built for; a consumer whose own library condenses something else gets the same treatment with
261
+ * no entry added anywhere.
262
+ *
263
+ * **Soot is not here and must not be**, though it is equally suspended: it is the *black* half
264
+ * of smoke and this is the pale half, and `§17` colours a plume from the ratio between them.
265
+ */
266
+ const aerosol: number[] = [];
267
+ for (let i = 0; i < this.species.length; i++) {
268
+ if (registry.phaseOf(this.species[i] as number) === PHASE_LIQUID) aerosol.push(i);
269
+ }
270
+ this.aerosolSpecies = Int32Array.from(aerosol);
271
+
272
+ this.maxChunks = options.maxChunks ?? 512;
273
+ this.openness = options.openness ?? 1.5;
274
+ this.diffusivity = options.diffusivity ?? 0.02;
275
+ this.vented = new Float64Array(this.species.length + 3);
276
+ this.round = new Int32Array(this.maxChunks);
277
+
278
+ /* The tracked species that can burn, resolved once. */
279
+ const combustible: number[] = [];
280
+ for (const id of ['CH4', 'CO', 'H2', 'C2H4', 'C2H6', 'NH3', 'HCN']) {
281
+ const at = registry.indexOf(id);
282
+ if (at >= 0 && this.slotOf(at) >= 0) combustible.push(at);
283
+ }
284
+ this.combustible = Int32Array.from(combustible);
285
+
286
+ this.ambientCell = new Float64Array(this.species.length);
287
+ ambientCellMass(registry, this.species, this.ambient, this.cellVolume, this.ambientCell);
288
+
289
+ /*
290
+ * Enthalpy is measured from the standard state, so ambient air at 293.15 K sits a little below
291
+ * zero. Computed once here rather than per cell.
292
+ *
293
+ * **`this.heatCapacity`, which is `cp` at 298.15 — the same array `cellTemperature` divides
294
+ * by — rather than `cp` at the ambient temperature.** The two disagree for any species whose
295
+ * `cp` rises with temperature, and the field's own model is a constant `cp` at the datum: a cell
296
+ * *is* an enthalpy divided by that array. Building the ambient with a different `cp` made a
297
+ * freshly filled cell read back 8e-5 K away from the temperature it was filled at — small, and
298
+ * an inconsistency between how a number is written and how it is read is the kind that grows.
299
+ *
300
+ * The linear-`cp` refinement is the *parcel's* model, where `sensibleEnthalpy` integrates it
301
+ * properly. The field trades that for a cell temperature that is one divide.
302
+ */
303
+ let enthalpy = 0;
304
+ for (let i = 0; i < this.species.length; i++) {
305
+ enthalpy +=
306
+ (this.ambientCell[i] as number) *
307
+ (this.heatCapacity[i] as number) *
308
+ (this.ambient.temperature - 298.15);
309
+ }
310
+ this.ambientEnthalpy = enthalpy;
311
+ let ambientMass = 0;
312
+ for (let i = 0; i < this.species.length; i++) ambientMass += this.ambientCell[i] as number;
313
+ this.ambientMass = ambientMass;
314
+ let ambientCapacity = 0;
315
+ for (let i = 0; i < this.species.length; i++) {
316
+ ambientCapacity += (this.ambientCell[i] as number) * (this.heatCapacity[i] as number);
317
+ }
318
+ this.ambientTemperatureExact =
319
+ ambientCapacity > 0
320
+ ? 298.15 + this.ambientEnthalpy / ambientCapacity
321
+ : this.ambient.temperature;
322
+ for (let i = 0; i < SATURATION_KNOTS; i++) {
323
+ const temperature =
324
+ SATURATION_MIN + ((SATURATION_MAX - SATURATION_MIN) * i) / (SATURATION_KNOTS - 1);
325
+ this.saturationTable[i] = saturationVapourPressure(temperature);
326
+ }
327
+
328
+ const reactionIds = options.reactionIds ?? [];
329
+ this.network =
330
+ reactionIds.length === 0
331
+ ? null
332
+ : localiseReactions(registry, options.reactions, reactionIds, this.species, 'the field');
333
+ }
334
+
335
+ get chunkCount(): number {
336
+ return this.chunks.length;
337
+ }
338
+
339
+ /** How many species this field tracks. */
340
+ get speciesCount(): number {
341
+ return this.species.length;
342
+ }
343
+
344
+ /** The local slot a global species occupies, or -1. */
345
+ slotOf(species: number): number {
346
+ return species >= 0 && species < this.localOf.length ? (this.localOf[species] as number) : -1;
347
+ }
348
+
349
+ private key(cx: number, cy: number, cz: number): number {
350
+ /* Offset so negatives pack, then base-2048 in three digits. Supports ±1024 chunks, which at
351
+ eight metres is ±8 km — past any scene this engine draws. */
352
+ return ((cx + 1024) * 2048 + (cy + 1024)) * 2048 + (cz + 1024);
353
+ }
354
+
355
+ /** The chunk holding a point, creating it if `create`. `-1` where none exists. */
356
+ private chunkAt(x: number, y: number, z: number, create: boolean): number {
357
+ const span = CHUNK_CELLS * this.cellSize;
358
+ const cx = Math.floor(x / span);
359
+ const cy = Math.floor(y / span);
360
+ const cz = Math.floor(z / span);
361
+ const key = this.key(cx, cy, cz);
362
+ const found = this.byKey.get(key);
363
+ if (found !== undefined) return found;
364
+ if (!create || this.chunks.length >= this.maxChunks) return -1;
365
+ return this.createChunk(cx, cy, cz);
366
+ }
367
+
368
+ private createChunk(cx: number, cy: number, cz: number): number {
369
+ const key = this.key(cx, cy, cz);
370
+ const cells = CELLS_PER_CHUNK;
371
+ const data = new Float64Array(cells * this.stride);
372
+ for (let cell = 0; cell < cells; cell++) {
373
+ const base = cell * this.stride;
374
+ for (let i = 0; i < this.species.length; i++) data[base + i] = this.ambientCell[i] as number;
375
+ data[base + this.enthalpySlot] = this.ambientEnthalpy;
376
+ }
377
+ /* A new chunk is air drawn in from the far field, so the ledger records it as having come
378
+ from outside. That is what makes `totalMass() + ventedTotalMass()` invariant under chunk
379
+ creation as well as under transport. */
380
+ for (let i = 0; i < this.species.length; i++) {
381
+ this.vented[i] = (this.vented[i] as number) - (this.ambientCell[i] as number) * cells;
382
+ }
383
+ this.ventedEnthalpyTotal -= this.ambientEnthalpy * cells;
384
+
385
+ const index = this.chunks.length;
386
+ this.chunks.push(data);
387
+ this.deltas.push(new Float64Array(cells * this.stride));
388
+ this.blocked.push(new Uint8Array(cells));
389
+ this.coords.push(Int32Array.from([cx, cy, cz]));
390
+ this.keys.push(key);
391
+ this.sweptAt.push(-1);
392
+ this.byKey.set(key, index);
393
+
394
+ /* Into `order` at its sorted position, so the walk is geometry rather than history. See the
395
+ field on the class. A binary search and a splice, on a path that runs once per chunk. */
396
+ let low = 0;
397
+ let high = this.order.length;
398
+ while (low < high) {
399
+ const mid = (low + high) >> 1;
400
+ if ((this.keys[this.order[mid] as number] as number) < key) low = mid + 1;
401
+ else high = mid;
402
+ }
403
+ this.order.splice(low, 0, index);
404
+ return index;
405
+ }
406
+
407
+ /** Index of the cell containing a point within its chunk. */
408
+ private cellIn(x: number, y: number, z: number): number {
409
+ const lx = ((Math.floor(x / this.cellSize) % CHUNK_CELLS) + CHUNK_CELLS) % CHUNK_CELLS;
410
+ const ly = ((Math.floor(y / this.cellSize) % CHUNK_CELLS) + CHUNK_CELLS) % CHUNK_CELLS;
411
+ const lz = ((Math.floor(z / this.cellSize) % CHUNK_CELLS) + CHUNK_CELLS) % CHUNK_CELLS;
412
+ return (lx * CHUNK_CELLS + ly) * CHUNK_CELLS + lz;
413
+ }
414
+
415
+ /** The base offset of the cell containing a point, or `-1` where no chunk exists. */
416
+ private baseAt(x: number, y: number, z: number, create: boolean): number {
417
+ const chunk = this.chunkAt(x, y, z, create);
418
+ if (chunk < 0) return -1;
419
+ return this.cellIn(x, y, z) * this.stride;
420
+ }
421
+
422
+ private dataAt(x: number, y: number, z: number, create: boolean): Float64Array | null {
423
+ const chunk = this.chunkAt(x, y, z, create);
424
+ return chunk < 0 ? null : (this.chunks[chunk] as Float64Array);
425
+ }
426
+
427
+ /** Add (or with a negative value, remove) mass of one species at a point. */
428
+ addSpecies(x: number, y: number, z: number, species: number, kilograms: number): void {
429
+ const slot = this.slotOf(species);
430
+ if (slot < 0) {
431
+ throw new Error(
432
+ `the field does not carry "${this.registry.idOf(species)}", so it cannot hold any`,
433
+ );
434
+ }
435
+ const data = this.dataAt(x, y, z, true);
436
+ if (data === null) return;
437
+ const base = this.baseAt(x, y, z, true);
438
+ const next = (data[base + slot] as number) + kilograms;
439
+ data[base + slot] = next > 0 ? next : 0;
440
+ }
441
+
442
+ addHeat(x: number, y: number, z: number, joules: number): void {
443
+ const data = this.dataAt(x, y, z, true);
444
+ if (data === null) return;
445
+ const base = this.baseAt(x, y, z, true);
446
+ data[base + this.enthalpySlot] = (data[base + this.enthalpySlot] as number) + joules;
447
+ }
448
+
449
+ addSoot(x: number, y: number, z: number, kilograms: number): void {
450
+ const data = this.dataAt(x, y, z, true);
451
+ if (data === null) return;
452
+ const base = this.baseAt(x, y, z, true);
453
+ const next = (data[base + this.sootSlot] as number) + kilograms;
454
+ data[base + this.sootSlot] = next > 0 ? next : 0;
455
+ }
456
+
457
+ addAerosol(x: number, y: number, z: number, kilograms: number): void {
458
+ const data = this.dataAt(x, y, z, true);
459
+ if (data === null) return;
460
+ const base = this.baseAt(x, y, z, true);
461
+ const next = (data[base + this.aerosolSlot] as number) + kilograms;
462
+ data[base + this.aerosolSlot] = next > 0 ? next : 0;
463
+ }
464
+
465
+ /** Mark a cell as occupied by geometry. A blocked face transports nothing. */
466
+ setBlocked(x: number, y: number, z: number, blocked: boolean): void {
467
+ const chunk = this.chunkAt(x, y, z, true);
468
+ if (chunk < 0) return;
469
+ (this.blocked[chunk] as Uint8Array)[this.cellIn(x, y, z)] = blocked ? 1 : 0;
470
+ }
471
+
472
+ isBlocked(x: number, y: number, z: number): boolean {
473
+ const chunk = this.chunkAt(x, y, z, false);
474
+ if (chunk < 0) return false;
475
+ return (this.blocked[chunk] as Uint8Array)[this.cellIn(x, y, z)] === 1;
476
+ }
477
+
478
+ speciesMassAt(x: number, y: number, z: number, species: number): number {
479
+ const slot = this.slotOf(species);
480
+ if (slot < 0) return 0;
481
+ const data = this.dataAt(x, y, z, false);
482
+ if (data === null) return this.ambientCell[slot] as number;
483
+ return data[this.cellIn(x, y, z) * this.stride + slot] as number;
484
+ }
485
+
486
+ /**
487
+ * Kilograms of soot in the cell: the `C(soot)` its own chemistry made, plus anything added.
488
+ *
489
+ * **Two sources on purpose.** Soot a fire produced is the `C(soot)` *species*, which keeps its
490
+ * carbon inside `elementTotals` — a channel carrying mass outside the ledger would make the most
491
+ * valuable assertion in the track quietly wrong every time something smoked. The channel is for a
492
+ * consumer who wants smoke without chemistry behind it: a smoke grenade, a chimney, a dust cloud.
493
+ */
494
+ sootAt(x: number, y: number, z: number): number {
495
+ const data = this.dataAt(x, y, z, false);
496
+ if (data === null) return 0;
497
+ const cell = this.cellIn(x, y, z) * this.stride;
498
+ const added = data[cell + this.sootSlot] as number;
499
+ const slot = this.sootSpecies;
500
+ return slot < 0 ? added : added + (data[cell + slot] as number);
501
+ }
502
+
503
+ /**
504
+ * Kilograms of suspended droplets in the cell — the pale half of smoke.
505
+ *
506
+ * Soot is near-black and a droplet is near-white, so the *ratio* between them is what colours a
507
+ * plume: `§17` reads it, and a wet fire steams white where a rich one smokes black with neither
508
+ * being chosen. Symmetric with `sootAt`, and for the same two reasons.
509
+ *
510
+ * **Two sources, exactly as soot has.** Anything the chemistry condensed is a *species* — wood tar
511
+ * coming out of a cooling plume is the case this was built for — so its mass stays inside
512
+ * `elementTotals` and the most valuable assertion in the track keeps covering it. The channel
513
+ * `addAerosol` writes is for a consumer who wants steam without a boiler behind it: a kettle
514
+ * dressed for a scene, a waterfall's spray, a breath on a cold morning.
515
+ */
516
+ aerosolAt(x: number, y: number, z: number): number {
517
+ const data = this.dataAt(x, y, z, false);
518
+ if (data === null) return 0;
519
+ const cell = this.cellIn(x, y, z) * this.stride;
520
+ let total = data[cell + this.aerosolSlot] as number;
521
+ for (let i = 0; i < this.aerosolSpecies.length; i++) {
522
+ total += data[cell + (this.aerosolSpecies[i] as number)] as number;
523
+ }
524
+ return total;
525
+ }
526
+
527
+ /**
528
+ * The upward speed this cell's own lightness gives it, m/s.
529
+ *
530
+ * **The field's own number rather than a second one.** `§17` gives a smoke particle its velocity
531
+ * from "the cell's advected plus buoyant velocity", and this is the buoyant half — the same
532
+ * function transport uses, so a particle rises at the speed the gas carrying it actually rises at
533
+ * rather than at a rate chosen to look right.
534
+ */
535
+ riseAt(x: number, y: number, z: number): number {
536
+ const data = this.dataAt(x, y, z, false);
537
+ if (data === null) return 0;
538
+ return this.buoyantSpeed(data, this.cellIn(x, y, z) * this.stride);
539
+ }
540
+
541
+ /** Total moles of gas in the cell containing a point. */
542
+ molesAt(x: number, y: number, z: number): number {
543
+ const data = this.dataAt(x, y, z, false);
544
+ let total = 0;
545
+ if (data === null) {
546
+ for (let i = 0; i < this.species.length; i++) {
547
+ total += (this.ambientCell[i] as number) / (this.molarMass[i] as number);
548
+ }
549
+ return total;
550
+ }
551
+ const base = this.cellIn(x, y, z) * this.stride;
552
+ for (let i = 0; i < this.species.length; i++) {
553
+ total += (data[base + i] as number) / (this.molarMass[i] as number);
554
+ }
555
+ return total;
556
+ }
557
+
558
+ /** Joules held by the cell containing a point. */
559
+ enthalpyAt(x: number, y: number, z: number): number {
560
+ const data = this.dataAt(x, y, z, false);
561
+ if (data === null) return this.ambientEnthalpy;
562
+ return data[this.cellIn(x, y, z) * this.stride + this.enthalpySlot] as number;
563
+ }
564
+
565
+ /** kg of gas in the cell containing a point. */
566
+ massAt(x: number, y: number, z: number): number {
567
+ const data = this.dataAt(x, y, z, false);
568
+ let total = 0;
569
+ if (data === null) {
570
+ for (let i = 0; i < this.species.length; i++) total += this.ambientCell[i] as number;
571
+ return total;
572
+ }
573
+ const base = this.cellIn(x, y, z) * this.stride;
574
+ for (let i = 0; i < this.species.length; i++) total += data[base + i] as number;
575
+ return total;
576
+ }
577
+
578
+ /**
579
+ * K, from the cell's enthalpy and the heat capacity of what is in it.
580
+ *
581
+ * A gas mixture has no phase plateau, so this is a division rather than the curve inversion a
582
+ * parcel needs — and it is evaluated at the standard state's heat capacity, which is where the
583
+ * mixture's `cp` is anchored.
584
+ */
585
+ /**
586
+ * K of the cell containing a point.
587
+ *
588
+ * **`ambientTemperatureExact` rather than `this.ambient.temperature`, and the difference is a
589
+ * determinism bug rather than a rounding nicety.** A cell nobody has touched is not stored; one a
590
+ * neighbour realised is. Returning the *authored* 293.15 for the first and the *derived*
591
+ * 293.15000000000003 for the second made a cell's reported temperature depend on whether some
592
+ * other parcel had happened to create the chunk first — which put an order dependence into
593
+ * convection, and from there into whether a log ignites on tick N or tick N+1.
594
+ *
595
+ * Found by `world/fingerprint.test.ts`: two logs spawned the other way round diverged in the
596
+ * eleventh digit on their very first tick, and nothing about them was different.
597
+ */
598
+ temperatureAt(x: number, y: number, z: number): number {
599
+ const data = this.dataAt(x, y, z, false);
600
+ if (data === null) return this.ambientTemperatureExact;
601
+ return this.cellTemperature(data, this.cellIn(x, y, z) * this.stride);
602
+ }
603
+
604
+ private cellTemperature(data: Float64Array, base: number): number {
605
+ let capacity = 0;
606
+ for (let i = 0; i < this.species.length; i++) {
607
+ capacity += (data[base + i] as number) * (this.heatCapacity[i] as number);
608
+ }
609
+ if (!(capacity > 0)) return this.ambientTemperatureExact;
610
+ return 298.15 + (data[base + this.enthalpySlot] as number) / capacity;
611
+ }
612
+
613
+ /** Volume fraction of one species, which for a gas is its mole fraction. */
614
+ volumeFractionAt(x: number, y: number, z: number, species: number): number {
615
+ const slot = this.slotOf(species);
616
+ if (slot < 0) return 0;
617
+ const total = this.molesAt(x, y, z);
618
+ if (!(total > 0)) return 0;
619
+ return this.speciesMassAt(x, y, z, species) / (this.molarMass[slot] as number) / total;
620
+ }
621
+
622
+ /**
623
+ * Volume fraction of the cell that is gas which can burn.
624
+ *
625
+ * What a flammability limit is checked against. Below the lower limit there is not enough fuel to
626
+ * carry a flame; above the upper there is not enough air, which is why a fuel-soaked rag smokes
627
+ * rather than flames until it thins out.
628
+ */
629
+ fuelFractionAt(x: number, y: number, z: number): number {
630
+ let total = 0;
631
+ for (const species of this.combustible) total += this.volumeFractionAt(x, y, z, species);
632
+ return total;
633
+ }
634
+
635
+ oxygenFractionAt(x: number, y: number, z: number): number {
636
+ return this.volumeFractionAt(x, y, z, this.registry.indexOf('O2'));
637
+ }
638
+
639
+ /** Parts per million by volume, which is the reading a game acts on. */
640
+ concentrationAt(x: number, y: number, z: number, species: number): number {
641
+ return this.volumeFractionAt(x, y, z, species) * 1e6;
642
+ }
643
+
644
+ /** kg/m³. */
645
+ densityAt(x: number, y: number, z: number): number {
646
+ return this.massAt(x, y, z) / this.cellVolume;
647
+ }
648
+
649
+ /** Pa, by the ideal gas law over what the cell holds at the temperature it is. */
650
+ pressureAt(x: number, y: number, z: number): number {
651
+ return (this.molesAt(x, y, z) * GAS_CONSTANT * this.temperatureAt(x, y, z)) / this.cellVolume;
652
+ }
653
+
654
+ /**
655
+ * How thick the smoke is, as an extinction coefficient in inverse metres.
656
+ *
657
+ * **The quantity `visibilityAt` is derived from**, and the one a consumer attenuating a light
658
+ * along a ray actually needs: metres of visibility is one answer to it and `exp(−σ·d)` is another.
659
+ * Exporting only the first would have every consumer inverting Koschmieder to get back here.
660
+ */
661
+ smokeDensityAt(x: number, y: number, z: number): number {
662
+ return (this.sootAt(x, y, z) / this.cellVolume) * SOOT_EXTINCTION;
663
+ }
664
+
665
+ /**
666
+ * How far you can see, metres.
667
+ *
668
+ * Koschmieder over the soot's extinction. Clamped at ten kilometres, past which the answer stops
669
+ * being about the smoke.
670
+ */
671
+ visibilityAt(x: number, y: number, z: number): number {
672
+ const extinction = this.smokeDensityAt(x, y, z);
673
+ if (!(extinction > 0)) return 10000;
674
+ return Math.min(10000, VISIBILITY_CONSTANT / extinction);
675
+ }
676
+
677
+ /**
678
+ * Relative humidity, 0 to 1: the water vapour present against what this air could hold.
679
+ *
680
+ * **The reading that decides whether anything dries.** `§8.1`'s evaporation is driven by the
681
+ * vapour pressure *deficit*, so washing dries in wind and nothing dries in fog — and the same
682
+ * number is why a fire dries a room out while removing no water at all: warm air could hold more,
683
+ * so the same vapour is a smaller fraction of saturation.
684
+ *
685
+ * **Saturation is tabulated**, per `§7`'s rule, because it is `exp` of a rational function of
686
+ * temperature and this is a query a script may make every frame. 256 knots over 200-500 K, where
687
+ * linear interpolation is under a tenth of a percent.
688
+ */
689
+ humidityAt(x: number, y: number, z: number): number {
690
+ const vapour = this.registry.indexOf('H2O(g)');
691
+ if (vapour < 0) return 0;
692
+ const partial = this.volumeFractionAt(x, y, z, vapour) * this.pressureAt(x, y, z);
693
+ const saturation = this.saturation(this.temperatureAt(x, y, z));
694
+ return saturation > 0 ? partial / saturation : 0;
695
+ }
696
+
697
+ /** Pa, off the table `SATURATION_KNOTS` describes. Clamped at both ends rather than extrapolated. */
698
+ private saturation(temperature: number): number {
699
+ if (temperature <= SATURATION_MIN) return this.saturationTable[0] as number;
700
+ if (temperature >= SATURATION_MAX) return this.saturationTable[SATURATION_KNOTS - 1] as number;
701
+ const position =
702
+ ((temperature - SATURATION_MIN) / (SATURATION_MAX - SATURATION_MIN)) * (SATURATION_KNOTS - 1);
703
+ const low = Math.floor(position);
704
+ const at = position - low;
705
+ return (
706
+ (this.saturationTable[low] as number) * (1 - at) +
707
+ (this.saturationTable[low + 1] as number) * at
708
+ );
709
+ }
710
+
711
+ /** Kilograms of one species above what the ambient would have held. Diagnostics and tests. */
712
+ totalAdded(species: number): number {
713
+ const slot = this.slotOf(species);
714
+ if (slot < 0) return 0;
715
+ const cells = CHUNK_CELLS * CHUNK_CELLS * CHUNK_CELLS;
716
+ let total = 0;
717
+ for (let o = 0; o < this.order.length; o++) {
718
+ const data = this.chunks[this.order[o] as number] as Float64Array;
719
+ for (let cell = 0; cell < cells; cell++) {
720
+ total += (data[cell * this.stride + slot] as number) - (this.ambientCell[slot] as number);
721
+ }
722
+ }
723
+ return total;
724
+ }
725
+
726
+ /**
727
+ * Advance the field one step: expansion, buoyancy, wind, diffusion and the far field.
728
+ *
729
+ * **Every transfer is flux-form and conservative** — computed once for a face and applied to both
730
+ * sides — which is a departure from `§9` worth recording. The design chose semi-Lagrangian
731
+ * advection for unconditional stability; semi-Lagrangian is **not conservative**, and
732
+ * conservation is the invariant this whole track is asserted on. Upwind flux has a CFL limit
733
+ * instead, and a CFL limit is handled here the same way stiffness is handled in the reaction
734
+ * solver: by sub-stepping. That is the trade taken deliberately.
735
+ *
736
+ * **Deltas are accumulated and applied afterwards**, so no cell sees a neighbour that an earlier
737
+ * cell in the sweep already changed. A Gauss-Seidel sweep would give an answer that depended on
738
+ * chunk allocation order, which is a determinism hole with no symptom until two runs allocate
739
+ * differently.
740
+ *
741
+ * `windX` and `windZ` come from the one `WindField`, sampled once per frame by the caller.
742
+ */
743
+ step(dt: number, windX = 0, windZ = 0): void {
744
+ if (!(dt > 0) || this.chunks.length === 0) return;
745
+
746
+ /* The CFL limit over wind and the fastest a buoyant cell can rise. */
747
+ const fastest = Math.max(Math.abs(windX), Math.abs(windZ), MAX_BUOYANT_SPEED);
748
+ const substeps = Math.min(
749
+ MAX_TRANSPORT_SUBSTEPS,
750
+ Math.max(1, Math.ceil((fastest * dt) / (this.cellSize * MAX_FACE_FRACTION))),
751
+ );
752
+ const step = dt / substeps;
753
+ for (let pass = 0; pass < substeps; pass++) this.sweep(step, windX, windZ);
754
+ this.react(dt);
755
+ }
756
+
757
+ /**
758
+ * Run the gas-phase network in every live cell.
759
+ *
760
+ * After transport rather than before, so a cell burns the mixture it actually ended the step
761
+ * with — which is what makes a flame follow the fuel rather than lag a step behind it.
762
+ */
763
+ private react(dt: number): void {
764
+ const network = this.network;
765
+ if (network === null) return;
766
+ /* In key order, like every other full walk. A cell's reaction reads and writes only itself, so
767
+ this one is order-independent already — but a second convention here is a second thing to get
768
+ wrong, and the cost is one indirection per chunk. */
769
+ for (let o = 0; o < this.order.length; o++) {
770
+ const chunk = this.order[o] as number;
771
+ const data = this.chunks[chunk] as Float64Array;
772
+ const blocked = this.blocked[chunk] as Uint8Array;
773
+ for (let cell = 0; cell < CELLS_PER_CHUNK; cell++) {
774
+ if (blocked[cell] === 1) continue;
775
+ const base = cell * this.stride;
776
+ let mass = 0;
777
+ for (let i = 0; i < this.species.length; i++) mass += data[base + i] as number;
778
+ if (!(mass > 0)) continue;
779
+ reactShell(data, base, data, base + this.enthalpySlot, network, mass, dt);
780
+ }
781
+ }
782
+ }
783
+
784
+ /** The upward speed a cell's own lightness gives it, m/s. */
785
+ private buoyantSpeed(data: Float64Array, base: number): number {
786
+ let mass = 0;
787
+ for (let i = 0; i < this.species.length; i++) mass += data[base + i] as number;
788
+ const density = mass / this.cellVolume;
789
+ const ambientDensity = this.ambientMass / this.cellVolume;
790
+ if (!(ambientDensity > 0)) return 0;
791
+ const speed = GRAVITY * ((ambientDensity - density) / ambientDensity) * BUOYANT_TIME;
792
+ return speed > MAX_BUOYANT_SPEED
793
+ ? MAX_BUOYANT_SPEED
794
+ : speed < -MAX_BUOYANT_SPEED
795
+ ? -MAX_BUOYANT_SPEED
796
+ : speed;
797
+ }
798
+
799
+ /** Resolve a world cell to a packed chunk-and-cell, or -1. */
800
+ private resolve(wx: number, wy: number, wz: number, create: boolean): number {
801
+ const cx = Math.floor(wx / CHUNK_CELLS);
802
+ const cy = Math.floor(wy / CHUNK_CELLS);
803
+ const cz = Math.floor(wz / CHUNK_CELLS);
804
+ const key = this.key(cx, cy, cz);
805
+ let chunk = this.byKey.get(key);
806
+ if (chunk === undefined) {
807
+ if (!create || this.chunks.length >= this.maxChunks) return -1;
808
+ chunk = this.createChunk(cx, cy, cz);
809
+ }
810
+ const lx = wx - cx * CHUNK_CELLS;
811
+ const ly = wy - cy * CHUNK_CELLS;
812
+ const lz = wz - cz * CHUNK_CELLS;
813
+ return chunk * CELLS_PER_CHUNK + (lx * CHUNK_CELLS + ly) * CHUNK_CELLS + lz;
814
+ }
815
+
816
+ private sweep(dt: number, windX: number, windZ: number): void {
817
+ for (const delta of this.deltas) delta.fill(0);
818
+
819
+ const diffusion = Math.min(
820
+ MAX_FACE_FRACTION,
821
+ (this.diffusivity * dt) / (this.cellSize * this.cellSize),
822
+ );
823
+ const vent = Math.min(0.5, this.openness * dt);
824
+ const expansion = Math.min(MAX_FACE_FRACTION, PRESSURE_RELAX * dt);
825
+
826
+ /*
827
+ * **In rounds over `order`, which is key order, rather than along `chunks`, which is creation
828
+ * order.** A face may create the chunk beyond it, and a chunk created this sweep is swept in the
829
+ * same pass — correct, because it starts as ambient and can only receive until it is. Walking
830
+ * `chunks.length` afresh gave that for free and paid for it in determinism: the visit order was
831
+ * then the allocation order, so two runs that realised their chunks differently accumulated into
832
+ * the shared `deltas` in a different sequence and diverged in the last bit. See `order`.
833
+ *
834
+ * A round is snapshotted into `round` before it is walked, so chunks created during it are not
835
+ * observed mid-walk; the loop repeats until a round creates nothing. Both halves are then facts
836
+ * about geometry: which chunks a round contains, and the order it visits them in.
837
+ */
838
+ this.pass++;
839
+ for (;;) {
840
+ let count = 0;
841
+ for (let o = 0; o < this.order.length; o++) {
842
+ const chunk = this.order[o] as number;
843
+ if (this.sweptAt[chunk] !== this.pass) this.round[count++] = chunk;
844
+ }
845
+ if (count === 0) break;
846
+ for (let r = 0; r < count; r++) {
847
+ const chunk = this.round[r] as number;
848
+ this.sweptAt[chunk] = this.pass;
849
+ this.sweepChunk(chunk, dt, windX, windZ, diffusion, vent, expansion);
850
+ }
851
+ }
852
+
853
+ /*
854
+ * The apply pass, per chunk and independent, so its order cannot matter: a cell's next value is
855
+ * its own plus its own delta. Along `chunks` rather than `order` for that reason, and because
856
+ * the whole array is one linear walk.
857
+ */
858
+ for (let chunk = 0; chunk < this.chunks.length; chunk++) {
859
+ const data = this.chunks[chunk] as Float64Array;
860
+ const delta = this.deltas[chunk] as Float64Array;
861
+ for (let i = 0; i < data.length; i++) {
862
+ const next = (data[i] as number) + (delta[i] as number);
863
+ data[i] = next;
864
+ }
865
+ /* Clamp only what cannot be negative; enthalpy legitimately can. */
866
+ for (let cell = 0; cell < CELLS_PER_CHUNK; cell++) {
867
+ const base = cell * this.stride;
868
+ for (let i = 0; i < this.species.length; i++) {
869
+ if ((data[base + i] as number) < 0) data[base + i] = 0;
870
+ }
871
+ if ((data[base + this.sootSlot] as number) < 0) data[base + this.sootSlot] = 0;
872
+ if ((data[base + this.aerosolSlot] as number) < 0) data[base + this.aerosolSlot] = 0;
873
+ }
874
+ }
875
+ }
876
+
877
+ /** One chunk's faces, for `sweep`. Every transfer lands in `deltas` and is applied afterwards. */
878
+ private sweepChunk(
879
+ chunk: number,
880
+ dt: number,
881
+ windX: number,
882
+ windZ: number,
883
+ diffusion: number,
884
+ vent: number,
885
+ expansion: number,
886
+ ): void {
887
+ {
888
+ const data = this.chunks[chunk] as Float64Array;
889
+ const blocked = this.blocked[chunk] as Uint8Array;
890
+ const coords = this.coords[chunk] as Int32Array;
891
+ for (let cell = 0; cell < CELLS_PER_CHUNK; cell++) {
892
+ if (blocked[cell] === 1) continue;
893
+ const base = cell * this.stride;
894
+ const lx = (cell / (CHUNK_CELLS * CHUNK_CELLS)) | 0;
895
+ const ly = ((cell / CHUNK_CELLS) | 0) % CHUNK_CELLS;
896
+ const lz = cell % CHUNK_CELLS;
897
+ const wx = (coords[0] as number) * CHUNK_CELLS + lx;
898
+ const wy = (coords[1] as number) * CHUNK_CELLS + ly;
899
+ const wz = (coords[2] as number) * CHUNK_CELLS + lz;
900
+
901
+ const rise = this.buoyantSpeed(data, base);
902
+ for (let axis = 0; axis < 3; axis++) {
903
+ const velocity = axis === 0 ? windX : axis === 1 ? rise : windZ;
904
+ const other = this.resolve(
905
+ wx + (axis === 0 ? 1 : 0),
906
+ wy + (axis === 1 ? 1 : 0),
907
+ wz + (axis === 2 ? 1 : 0),
908
+ /*
909
+ * New ground is claimed only where something is being pushed onto it — a positive
910
+ * velocity **and** a cell that has something to carry. Without the second half the
911
+ * `+y` face of every boundary cell reaches upward on every sweep, whether or not
912
+ * anything is rising, and the live set grows a tower of ambient air to its cap.
913
+ */
914
+ velocity > 0 && this.departsFromAmbient(data, base),
915
+ );
916
+ if (other < 0) {
917
+ this.ventFace(chunk, cell, base, vent);
918
+ continue;
919
+ }
920
+ const otherChunk = (other / CELLS_PER_CHUNK) | 0;
921
+ const otherCell = other - otherChunk * CELLS_PER_CHUNK;
922
+ if ((this.blocked[otherChunk] as Uint8Array)[otherCell] === 1) continue;
923
+ this.face(
924
+ chunk,
925
+ base,
926
+ otherChunk,
927
+ otherCell * this.stride,
928
+ velocity,
929
+ dt,
930
+ diffusion,
931
+ expansion,
932
+ );
933
+ }
934
+
935
+ /* The three negative faces are somebody else's positive face, except where that somebody
936
+ does not exist — in which case this cell is on the boundary and vents. */
937
+ for (let axis = 0; axis < 3; axis++) {
938
+ const back = this.resolve(
939
+ wx - (axis === 0 ? 1 : 0),
940
+ wy - (axis === 1 ? 1 : 0),
941
+ wz - (axis === 2 ? 1 : 0),
942
+ false,
943
+ );
944
+ if (back < 0) this.ventFace(chunk, cell, base, vent);
945
+ }
946
+ }
947
+ }
948
+ }
949
+
950
+ /**
951
+ * Whether this cell holds anything the far field would not.
952
+ *
953
+ * What bounds the live set. A cell indistinguishable from the outdoors has nothing to spread, so
954
+ * it never claims a neighbour — which is why a world with one campfire holds the chunks around
955
+ * that fire rather than a column of air to the sky.
956
+ */
957
+ private departsFromAmbient(data: Float64Array, base: number): boolean {
958
+ if ((data[base + this.sootSlot] as number) > AMBIENT_EPSILON) return true;
959
+ if ((data[base + this.aerosolSlot] as number) > AMBIENT_EPSILON) return true;
960
+ if (
961
+ Math.abs((data[base + this.enthalpySlot] as number) - this.ambientEnthalpy) > ENTHALPY_EPSILON
962
+ ) {
963
+ return true;
964
+ }
965
+ for (let i = 0; i < this.species.length; i++) {
966
+ if (
967
+ Math.abs((data[base + i] as number) - (this.ambientCell[i] as number)) > AMBIENT_EPSILON
968
+ ) {
969
+ return true;
970
+ }
971
+ }
972
+ return false;
973
+ }
974
+
975
+ /** One internal face: bulk transfer by wind, buoyancy and pressure, plus diffusion. */
976
+ private face(
977
+ chunkA: number,
978
+ baseA: number,
979
+ chunkB: number,
980
+ baseB: number,
981
+ velocity: number,
982
+ dt: number,
983
+ diffusion: number,
984
+ expansion: number,
985
+ ): void {
986
+ const dataA = this.chunks[chunkA] as Float64Array;
987
+ const dataB = this.chunks[chunkB] as Float64Array;
988
+ const deltaA = this.deltas[chunkA] as Float64Array;
989
+ const deltaB = this.deltas[chunkB] as Float64Array;
990
+
991
+ let molesA = 0;
992
+ let molesB = 0;
993
+ for (let i = 0; i < this.species.length; i++) {
994
+ molesA += (dataA[baseA + i] as number) / (this.molarMass[i] as number);
995
+ molesB += (dataB[baseB + i] as number) / (this.molarMass[i] as number);
996
+ }
997
+
998
+ /*
999
+ * **Pressure is `n·T`, not `n`**, and getting that wrong is what a first attempt at this did.
1000
+ * Heating a cell does not change how many moles it holds, so equalising *molar density* leaves
1001
+ * a hot cell exactly where it was: nothing expands, its density never falls, and buoyancy —
1002
+ * which reads density — never lifts it. The plume simply does not happen. Pressure is what
1003
+ * actually differs across that face, and it is what pushes.
1004
+ */
1005
+ const pressureA = molesA * this.cellTemperature(dataA, baseA);
1006
+ const pressureB = molesB * this.cellTemperature(dataB, baseB);
1007
+
1008
+ /* Wind and buoyancy as a Courant fraction, plus the share of its excess the higher-pressure
1009
+ cell pushes across. Both are "move a fraction of the donor's contents", so they add. */
1010
+ const largest = Math.max(pressureA, pressureB);
1011
+ const pressure = largest > 0 ? (expansion * (pressureA - pressureB)) / (2 * largest) : 0;
1012
+ let fraction = (velocity * dt) / this.cellSize + pressure;
1013
+ if (fraction > MAX_FACE_FRACTION) fraction = MAX_FACE_FRACTION;
1014
+ if (fraction < -MAX_FACE_FRACTION) fraction = -MAX_FACE_FRACTION;
1015
+
1016
+ if (fraction !== 0) {
1017
+ const fromA = fraction > 0;
1018
+ const source = fromA ? dataA : dataB;
1019
+ const sourceBase = fromA ? baseA : baseB;
1020
+ const share = fromA ? fraction : -fraction;
1021
+ for (let i = 0; i < this.stride; i++) {
1022
+ const moved = (source[sourceBase + i] as number) * share;
1023
+ if (moved === 0) continue;
1024
+ if (fromA) {
1025
+ deltaA[baseA + i] = (deltaA[baseA + i] as number) - moved;
1026
+ deltaB[baseB + i] = (deltaB[baseB + i] as number) + moved;
1027
+ } else {
1028
+ deltaB[baseB + i] = (deltaB[baseB + i] as number) - moved;
1029
+ deltaA[baseA + i] = (deltaA[baseA + i] as number) + moved;
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ if (diffusion > 0) {
1035
+ for (let i = 0; i < this.stride; i++) {
1036
+ const moved = ((dataA[baseA + i] as number) - (dataB[baseB + i] as number)) * diffusion;
1037
+ if (moved === 0) continue;
1038
+ deltaA[baseA + i] = (deltaA[baseA + i] as number) - moved;
1039
+ deltaB[baseB + i] = (deltaB[baseB + i] as number) + moved;
1040
+ }
1041
+ }
1042
+ }
1043
+
1044
+ /** A face onto the far field: relax toward ambient and book what crossed. */
1045
+ private ventFace(chunk: number, cell: number, base: number, vent: number): void {
1046
+ void cell;
1047
+ if (!(vent > 0)) return;
1048
+ const data = this.chunks[chunk] as Float64Array;
1049
+ const delta = this.deltas[chunk] as Float64Array;
1050
+ for (let i = 0; i < this.species.length; i++) {
1051
+ const moved = ((data[base + i] as number) - (this.ambientCell[i] as number)) * vent;
1052
+ delta[base + i] = (delta[base + i] as number) - moved;
1053
+ this.vented[i] = (this.vented[i] as number) + moved;
1054
+ }
1055
+ const heat = ((data[base + this.enthalpySlot] as number) - this.ambientEnthalpy) * vent;
1056
+ delta[base + this.enthalpySlot] = (delta[base + this.enthalpySlot] as number) - heat;
1057
+ this.ventedEnthalpyTotal += heat;
1058
+ for (const slot of [this.sootSlot, this.aerosolSlot]) {
1059
+ const moved = (data[base + slot] as number) * vent;
1060
+ delta[base + slot] = (delta[base + slot] as number) - moved;
1061
+ }
1062
+ }
1063
+
1064
+ /** Moles of each element the field holds, over every live chunk. Writes into `out`. */
1065
+ elementTotals(out: Float64Array): void {
1066
+ if (out.length !== ELEMENT_COUNT) {
1067
+ throw new Error(`a totals vector is ${ELEMENT_COUNT} wide, not ${out.length}`);
1068
+ }
1069
+ out.fill(0);
1070
+ const cells = CHUNK_CELLS * CHUNK_CELLS * CHUNK_CELLS;
1071
+ /* **In key order**, because this is a floating-point reduction and a reduction's answer depends
1072
+ on the order it adds. See `order`: along `chunks` it depended on which chunk was realised
1073
+ first, and `fingerprintChemistry` hashes exactly this vector. */
1074
+ for (let o = 0; o < this.order.length; o++) {
1075
+ const data = this.chunks[this.order[o] as number] as Float64Array;
1076
+ for (let cell = 0; cell < cells; cell++) {
1077
+ const base = cell * this.stride;
1078
+ for (let i = 0; i < this.species.length; i++) {
1079
+ const kilograms = data[base + i] as number;
1080
+ if (kilograms === 0) continue;
1081
+ this.registry.elements.addScaledRow(
1082
+ this.species[i] as number,
1083
+ kilograms / (this.molarMass[i] as number),
1084
+ out,
1085
+ );
1086
+ }
1087
+ }
1088
+ }
1089
+ }
1090
+
1091
+ /** Total gas mass across every live chunk, kg. */
1092
+ totalMass(): number {
1093
+ const cells = CHUNK_CELLS * CHUNK_CELLS * CHUNK_CELLS;
1094
+ let total = 0;
1095
+ for (let o = 0; o < this.order.length; o++) {
1096
+ const data = this.chunks[this.order[o] as number] as Float64Array;
1097
+ for (let cell = 0; cell < cells; cell++) {
1098
+ const base = cell * this.stride;
1099
+ for (let i = 0; i < this.species.length; i++) total += data[base + i] as number;
1100
+ }
1101
+ }
1102
+ return total;
1103
+ }
1104
+
1105
+ /** Kilograms of one species that have left through an open boundary. */
1106
+ ventedMass(species: number): number {
1107
+ const slot = this.slotOf(species);
1108
+ return slot < 0 ? 0 : (this.vented[slot] as number);
1109
+ }
1110
+
1111
+ /** Joules that have left through an open boundary. */
1112
+ ventedEnthalpy(): number {
1113
+ return this.ventedEnthalpyTotal;
1114
+ }
1115
+
1116
+ /** Kilograms that have crossed the far-field boundary in total, in either direction. */
1117
+ ventedTotalMass(): number {
1118
+ let total = 0;
1119
+ for (let i = 0; i < this.species.length; i++) total += this.vented[i] as number;
1120
+ return total;
1121
+ }
1122
+
1123
+ /**
1124
+ * Moles of each element that have crossed the far-field boundary. Writes into `out`.
1125
+ *
1126
+ * Live chunks alone do not balance, and the reason is not transport: **creating a chunk draws a
1127
+ * chunk of air in from outside**, which is matter appearing in the live set. The ledger records
1128
+ * it, so `elementTotals + ventedElementTotals` is invariant under chunk creation as well as under
1129
+ * everything else — and a conservation assertion over a growing plume is possible at all.
1130
+ */
1131
+ ventedElementTotals(out: Float64Array): void {
1132
+ if (out.length !== ELEMENT_COUNT) {
1133
+ throw new Error(`a totals vector is ${ELEMENT_COUNT} wide, not ${out.length}`);
1134
+ }
1135
+ out.fill(0);
1136
+ for (let i = 0; i < this.species.length; i++) {
1137
+ const kilograms = this.vented[i] as number;
1138
+ if (kilograms === 0) continue;
1139
+ this.registry.elements.addScaledRow(
1140
+ this.species[i] as number,
1141
+ kilograms / (this.molarMass[i] as number),
1142
+ out,
1143
+ );
1144
+ }
1145
+ }
1146
+
1147
+ /** Total enthalpy across every live chunk, J. */
1148
+ totalEnthalpy(): number {
1149
+ const cells = CHUNK_CELLS * CHUNK_CELLS * CHUNK_CELLS;
1150
+ let total = 0;
1151
+ for (let o = 0; o < this.order.length; o++) {
1152
+ const data = this.chunks[this.order[o] as number] as Float64Array;
1153
+ for (let cell = 0; cell < cells; cell++) {
1154
+ total += data[cell * this.stride + this.enthalpySlot] as number;
1155
+ }
1156
+ }
1157
+ return total;
1158
+ }
1159
+ }