@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,223 @@
1
+ /**
2
+ * The substances a world knows about, and the enthalpy curve each one resolves through.
3
+ *
4
+ * A curve is expensive to build and constant for the life of a substance, so it is built once here
5
+ * rather than per parcel. Two hundred oak logs share one curve, which is what makes the whole
6
+ * approach affordable: the per-parcel cost of a temperature is a binary search over 256 knots and
7
+ * nothing else.
8
+ *
9
+ * A substance is an `i32` index after registration, the convention CH-0 established for a species
10
+ * and `drift/physics` established for a body.
11
+ */
12
+ import { type ConductionModel } from '../parcel/conduction.ts';
13
+ import { SHAPE_CYLINDER, shellGeometry } from '../parcel/shells.ts';
14
+ import type { SpeciesRegistry } from '../species/registry.ts';
15
+ import type { AppearanceModel, SubstanceThermal } from './substance.ts';
16
+ import type { IgnitionModel } from '../ignition/criteria.ts';
17
+ import type { ReactionRegistry } from '../reaction/registry.ts';
18
+ import { MAX_SUBSTANCE_SPECIES, type SubstanceReactions } from '../reaction/solver.ts';
19
+ import { localiseReactions } from '../reaction/localise.ts';
20
+
21
+ export class SubstanceRegistry {
22
+ private readonly ids: string[] = [];
23
+ private readonly byId = new Map<string, number>();
24
+ private readonly definitions: SubstanceThermal[] = [];
25
+ private readonly conduction: ConductionModel[] = [];
26
+ /**
27
+ * One conduction model per shell count a tier can reduce this substance to, indexed by that count.
28
+ *
29
+ * **Built at registration, because `shellGeometry` calls `Math.cbrt`** and `§15` keeps that off the
30
+ * simulation path entirely — its result is not pinned down by ECMAScript. A level-of-detail tier
31
+ * that computed geometry when it changed would have put it back there. Four shapes and at most
32
+ * sixteen counts is nothing to precompute and the alternative is a determinism hole.
33
+ */
34
+ private readonly byShellCount: (ConductionModel | null)[][] = [];
35
+ private readonly reactionModels: SubstanceReactions[] = [];
36
+ /** Global species indices this substance's parcels track, ascending. */
37
+ private readonly speciesSets: Int32Array[] = [];
38
+ /** Global species index to local slot, or -1. One row per substance. */
39
+ private readonly localSlots: Int32Array[] = [];
40
+ private readonly densities: number[] = [];
41
+ private readonly porosities: number[] = [];
42
+ private readonly emissivities: number[] = [];
43
+ private readonly ignitions: (IgnitionModel | null)[] = [];
44
+ private readonly appearances: (AppearanceModel | null)[] = [];
45
+
46
+ constructor(
47
+ readonly species: SpeciesRegistry,
48
+ private readonly reactions?: ReactionRegistry,
49
+ ) {}
50
+
51
+ get count(): number {
52
+ return this.ids.length;
53
+ }
54
+
55
+ define(substance: SubstanceThermal): number {
56
+ if (this.byId.has(substance.id)) {
57
+ throw new Error(`substance "${substance.id}" is already registered`);
58
+ }
59
+ if (!(substance.density > 0)) {
60
+ throw new Error(`substance "${substance.id}" has a density of ${substance.density}`);
61
+ }
62
+ /* The shell geometry is a pure function of a shape and a count, so it is resolved here once and
63
+ multiplied by a parcel's own depth at use — which keeps `Math.cbrt`, whose result ECMAScript
64
+ does not pin down, off the simulation path entirely. */
65
+ const geometry = shellGeometry(substance.shape ?? SHAPE_CYLINDER, substance.shells ?? 4);
66
+
67
+ const { set, local, model } = this.buildReactions(substance);
68
+
69
+ const index = this.ids.length;
70
+ this.speciesSets.push(set);
71
+ this.localSlots.push(local);
72
+ this.reactionModels.push(model);
73
+ const conductivity = substance.conductivity ?? null;
74
+ this.conduction.push({ geometry, conductivity });
75
+ const shape = substance.shape ?? SHAPE_CYLINDER;
76
+ const perCount: (ConductionModel | null)[] = [null];
77
+ for (let count = 1; count <= geometry.count; count++) {
78
+ perCount.push({ geometry: shellGeometry(shape, count), conductivity });
79
+ }
80
+ this.byShellCount.push(perCount);
81
+ this.ids.push(substance.id);
82
+ this.byId.set(substance.id, index);
83
+ this.definitions.push(substance);
84
+ this.densities.push(substance.density);
85
+ this.porosities.push(substance.porosity ?? 0);
86
+ this.emissivities.push(substance.emissivity ?? 0.9);
87
+ this.ignitions.push(substance.ignition ?? null);
88
+ this.appearances.push(substance.appearance ?? null);
89
+ return index;
90
+ }
91
+
92
+ indexOf(id: string): number {
93
+ return this.byId.get(id) ?? -1;
94
+ }
95
+
96
+ idOf(substance: number): string {
97
+ return this.ids[this.check(substance)] as string;
98
+ }
99
+
100
+ conductionOf(substance: number): ConductionModel {
101
+ return this.conduction[this.check(substance)] as ConductionModel;
102
+ }
103
+
104
+ /**
105
+ * The conduction model for a substance resolved to `shells`, which a tier may have reduced it to.
106
+ *
107
+ * Precomputed at registration; see `byShellCount` for why it is not derived on demand.
108
+ */
109
+ conductionAt(substance: number, shells: number): ConductionModel {
110
+ const row = this.byShellCount[this.check(substance)] as (ConductionModel | null)[];
111
+ const model = shells >= 1 && shells < row.length ? row[shells] : null;
112
+ return model ?? (this.conduction[substance] as ConductionModel);
113
+ }
114
+
115
+ densityOf(substance: number): number {
116
+ return this.densities[this.check(substance)] as number;
117
+ }
118
+
119
+ /** Void fraction, 0..1. Zero means it exchanges no gas with the air at all. */
120
+ porosityOf(substance: number): number {
121
+ return this.porosities[this.check(substance)] as number;
122
+ }
123
+
124
+ /** How readily it radiates and absorbs, 0..1. */
125
+ emissivityOf(substance: number): number {
126
+ return this.emissivities[this.check(substance)] as number;
127
+ }
128
+
129
+ /** When this material catches, or `null` where nobody said it does. */
130
+ ignitionOf(substance: number): IgnitionModel | null {
131
+ return this.ignitions[this.check(substance)] as IgnitionModel | null;
132
+ }
133
+
134
+ /**
135
+ * What it looks like, or `null` where nobody said.
136
+ *
137
+ * `null` rather than a grey default: `present/` writes nothing for a substance with no appearance
138
+ * rather than inventing one, so a consumer sees their own material unchanged instead of finding
139
+ * the engine has quietly recoloured it.
140
+ */
141
+ appearanceOf(substance: number): AppearanceModel | null {
142
+ return this.appearances[this.check(substance)] as AppearanceModel | null;
143
+ }
144
+
145
+ definitionOf(substance: number): SubstanceThermal {
146
+ return this.definitions[this.check(substance)] as SubstanceThermal;
147
+ }
148
+
149
+ /** Global species indices this substance's parcels carry, ascending. */
150
+ speciesSetOf(substance: number): Int32Array {
151
+ return this.speciesSets[this.check(substance)] as Int32Array;
152
+ }
153
+
154
+ /** The local slot a global species occupies for this substance, or -1. */
155
+ localSlotOf(substance: number, species: number): number {
156
+ const row = this.localSlots[this.check(substance)] as Int32Array;
157
+ return species >= 0 && species < row.length ? (row[species] as number) : -1;
158
+ }
159
+
160
+ reactionsOf(substance: number): SubstanceReactions {
161
+ return this.reactionModels[this.check(substance)] as SubstanceReactions;
162
+ }
163
+
164
+ /**
165
+ * The species set and the localised, packed reaction network.
166
+ *
167
+ * The set is the composition's species plus everything its reactions touch, so a shell's
168
+ * composition is a dense run of the few species it can hold rather than a row over all
169
+ * sixty-eight. Built here, once, because it is a property of the substance.
170
+ */
171
+ private buildReactions(substance: SubstanceThermal): {
172
+ set: Int32Array;
173
+ local: Int32Array;
174
+ model: SubstanceReactions;
175
+ } {
176
+ const ids = substance.reactions ?? [];
177
+
178
+ const present = new Set<number>();
179
+ for (const s of substance.composition.species) present.add(s);
180
+ if (this.reactions !== undefined) {
181
+ for (const id of ids) {
182
+ const r = this.reactions.indexOf(id);
183
+ if (r < 0)
184
+ throw new Error(
185
+ `substance "${substance.id}" declares reaction "${id}", which is not registered`,
186
+ );
187
+ for (const sp of this.reactions.reactantSpecies(r)) present.add(sp);
188
+ for (const sp of this.reactions.productSpecies(r)) present.add(sp);
189
+ }
190
+ } else if (ids.length > 0) {
191
+ throw new Error(
192
+ `substance "${substance.id}" declares reactions, and this registry was built with none`,
193
+ );
194
+ }
195
+ const set = Int32Array.from([...present].sort((a, b) => a - b));
196
+ if (set.length > MAX_SUBSTANCE_SPECIES) {
197
+ throw new Error(
198
+ `substance "${substance.id}" would track ${set.length} species; the limit is ${MAX_SUBSTANCE_SPECIES}`,
199
+ );
200
+ }
201
+ const local = new Int32Array(this.species.count).fill(-1);
202
+ for (let i = 0; i < set.length; i++) local[set[i] as number] = i;
203
+
204
+ return {
205
+ set,
206
+ local,
207
+ model: localiseReactions(
208
+ this.species,
209
+ this.reactions,
210
+ ids,
211
+ set,
212
+ `substance "${substance.id}"`,
213
+ ),
214
+ };
215
+ }
216
+
217
+ private check(substance: number): number {
218
+ if (!Number.isInteger(substance) || substance < 0 || substance >= this.ids.length) {
219
+ throw new Error(`substance ${substance} is outside a registry of ${this.ids.length}`);
220
+ }
221
+ return substance;
222
+ }
223
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * What a material is, thermally: a composition, a density, and the transitions it passes through.
3
+ *
4
+ * CH-1's half of `Substance`. The design's `§5` describes the whole of it — conductivity,
5
+ * emissivity, porosity, shells, reactions, ignition, appearance — and each field arrives with the
6
+ * phase that reads it. Declaring them all here with nothing reading them would be the speculative
7
+ * abstraction `AGENTS.md` §Module rules forbids.
8
+ */
9
+ import { ELEMENT_COUNT } from '../element/elements.ts';
10
+ import type { SpeciesRegistry } from '../species/registry.ts';
11
+ import { type Composition, massFractions } from './composition.ts';
12
+ import type { Conductivity } from '../parcel/conduction.ts';
13
+ import type { Shape } from '../parcel/shells.ts';
14
+ import type { IgnitionModel } from '../ignition/criteria.ts';
15
+
16
+ /*
17
+ * **`PhaseTransition` is gone, and `phaseChange` in `reaction/reaction.ts` is where it went.**
18
+ *
19
+ * It described melting and boiling as shape in a per-substance enthalpy curve, and CH-6 retired
20
+ * that curve: the latent heat is already in the formation enthalpies CH-0 stores, so a phase change
21
+ * is a reaction with a temperature gate and its plateau emerges from the solver everything else
22
+ * already uses. A substance declares it in `reactions` like anything else it can do.
23
+ */
24
+
25
+ export interface SubstanceThermal {
26
+ readonly id: string;
27
+ /**
28
+ * What it is made of **at the bottom of the curve**, as mass fractions summing to one.
29
+ *
30
+ * The rule matters and is not obvious: transitions are applied in ascending temperature order, so
31
+ * a substance is authored as its coldest form. Water is `H2O(s)` with two transitions, not
32
+ * `H2O(l)` with one in each direction. A transition whose source species is absent when it is
33
+ * reached is refused, naming both, rather than silently doing nothing.
34
+ */
35
+ readonly composition: Composition;
36
+ /** kg/m³ of the dry matter. */
37
+ readonly density: number;
38
+ /**
39
+ * W/(m·K), rising with temperature. **Optional, and conduction refuses a substance without one**,
40
+ * naming it — a thermal conductivity nobody stated is a number somebody invented, and it decides
41
+ * whether a log takes twenty seconds or twenty minutes.
42
+ */
43
+ readonly conductivity?: Conductivity;
44
+ /** How many nested shells of equal mass. Four by default; see `§4` for why not one and why not sixteen. */
45
+ readonly shells?: number;
46
+ /** What it is shaped like, which decides how those shells divide. A cylinder by default: a log. */
47
+ readonly shape?: Shape;
48
+ /**
49
+ * Void fraction, 0..1. What decides how freely gas moves between this material and the air.
50
+ *
51
+ * A charcoal briquette breathes and a pane of glass does not, and that is most of the difference
52
+ * between something that smoulders and something that does not. Zero by default, which means a
53
+ * substance exchanges no gas at all until somebody says how open it is.
54
+ */
55
+ readonly porosity?: number;
56
+ /** How readily it radiates and absorbs, 0..1. Most non-metals sit near 0.9, which is the default. */
57
+ readonly emissivity?: number;
58
+ /**
59
+ * Reactions this material can undergo, by id.
60
+ *
61
+ * A substance's parcels track masses for its composition species **plus every species these
62
+ * reactions touch**, so declaring one is what makes room for its products. Wood that pyrolyses
63
+ * holds char and steam it did not start with.
64
+ */
65
+ readonly reactions?: readonly string[];
66
+ /**
67
+ * When this material catches and when it stops.
68
+ *
69
+ * Absent means it never catches at all, which is the right default: a substance nobody described
70
+ * as flammable should not be, and inventing an ignition temperature for glass is worse than
71
+ * declining to have one.
72
+ */
73
+ readonly ignition?: IgnitionModel;
74
+ /**
75
+ * What it looks like, and every field is a measurable property of the material rather than a
76
+ * choice about how to draw it.
77
+ *
78
+ * Absent means a consumer supplies their own colours, which is the right default: a substance
79
+ * nobody described the appearance of should not have one invented for it. `present/` refuses to
80
+ * write an albedo it was never given rather than writing grey.
81
+ */
82
+ readonly appearance?: AppearanceModel;
83
+ }
84
+
85
+ /**
86
+ * `§17`'s three numbers: what a material looks like before it burns, after it burns, and how dirty
87
+ * its smoke is on the way.
88
+ *
89
+ * **Soot yield belongs on the substance and `§8.4` says why**: polystyrene sooting heavily and
90
+ * methanol burning invisibly are the two ends of a scale, and which end a fuel sits at is a property
91
+ * of the fuel. It is the one number that decides whether a fire is seen at all in daylight.
92
+ */
93
+ export interface AppearanceModel {
94
+ /** Linear RGB before anything happens to it. */
95
+ readonly albedo: readonly [number, number, number];
96
+ /** Linear RGB when it is entirely char. Near-black for anything that chars at all. */
97
+ readonly charAlbedo: readonly [number, number, number];
98
+ /** kg of soot per kg of volatiles burnt. Wood is about 0.015; polystyrene is 0.16. */
99
+ readonly sootYield: number;
100
+ }
101
+
102
+ /**
103
+ * A composition with dry-basis moisture folded into it.
104
+ *
105
+ * **"12% moisture content" means 12 kg of water per 100 kg of *dry* matter**, so water is
106
+ * `m / (1 + m)` of the whole and every dry component is scaled by `1 / (1 + m)`. Treating the
107
+ * figure as a wet-basis fraction is wrong by 1.4 points at 12% and by 33 points for green wood at
108
+ * 60%, which is exactly the range where it decides whether a log catches fire at all.
109
+ *
110
+ * Water the composition already held is added to rather than replaced, because a substance can be
111
+ * authored wet: a stew is mostly water before anybody adds moisture to it.
112
+ */
113
+ export function wetComposition(
114
+ registry: SpeciesRegistry,
115
+ dry: Composition,
116
+ moisture: number,
117
+ ): Composition {
118
+ if (!Number.isFinite(moisture) || moisture < 0) {
119
+ throw new Error(`moisture content is ${moisture}, which is not a non-negative number`);
120
+ }
121
+ if (moisture === 0) return dry;
122
+
123
+ const scale = 1 / (1 + moisture);
124
+ const entries: Record<string, number> = {};
125
+ for (let i = 0; i < dry.species.length; i++) {
126
+ entries[registry.idOf(dry.species[i] as number)] = (dry.fraction[i] as number) * scale;
127
+ }
128
+ const water = 'H2O(l)';
129
+ entries[water] = (entries[water] ?? 0) + moisture * scale;
130
+ return massFractions(registry, entries);
131
+ }
132
+
133
+ /** Moles of each element in one kilogram of a composition. Writes into `out`, allocates nothing. */
134
+ export function compositionElements(
135
+ registry: SpeciesRegistry,
136
+ composition: Composition,
137
+ out: Float64Array,
138
+ ): void {
139
+ if (out.length !== ELEMENT_COUNT) {
140
+ throw new Error(`a totals vector is ${ELEMENT_COUNT} wide, not ${out.length}`);
141
+ }
142
+ out.fill(0);
143
+ for (let i = 0; i < composition.species.length; i++) {
144
+ const species = composition.species[i] as number;
145
+ const kilograms = composition.fraction[i] as number;
146
+ registry.elements.addScaledRow(species, kilograms / registry.molarMass(species), out);
147
+ }
148
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * How far two element vectors have drifted apart, for the conservation harness.
3
+ *
4
+ * Relative rather than absolute, because the elements in a burning world span many orders of
5
+ * magnitude at once: a scene may hold a kilomole of nitrogen and a micromole of sulphur, and an
6
+ * absolute tolerance either passes on a broken sulphur balance or fails on a correct nitrogen one.
7
+ *
8
+ * **With one exception that matters.** Where both sides are effectively zero, a relative measure
9
+ * has nothing to divide by — and an element that appeared from nowhere would then report as no
10
+ * drift at all, which is the single failure this harness exists to catch. So the denominator has a
11
+ * floor of one, making the measure absolute in that regime.
12
+ *
13
+ * Test-side rather than runtime: nothing in the simulation calls it.
14
+ */
15
+ export function maxElementDrift(a: Float64Array, b: Float64Array): number {
16
+ if (a.length !== b.length) {
17
+ throw new Error(`element vectors are ${a.length} and ${b.length} wide`);
18
+ }
19
+ let worst = 0;
20
+ for (let i = 0; i < a.length; i++) {
21
+ const left = a[i] as number;
22
+ const right = b[i] as number;
23
+ const scale = Math.max(1, Math.abs(left), Math.abs(right));
24
+ const drift = Math.abs(left - right) / scale;
25
+ if (drift > worst) worst = drift;
26
+ }
27
+ return worst;
28
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Which parcels are touching, this tick.
3
+ *
4
+ * Refilled by the caller each tick rather than held, the same shape `RadiativeSources` takes and
5
+ * for the same reason: contact is a property of the world's arrangement, which this package does
6
+ * not know. In practice it is fed from Track B's contact event buffer, which already reports pairs
7
+ * by feature id — and feeding it is the whole of what a consumer does to make a pan heat a steak.
8
+ */
9
+ export class ContactSet {
10
+ private a = new Int32Array(32);
11
+ private b = new Int32Array(32);
12
+ private area = new Float64Array(32);
13
+ private used = 0;
14
+
15
+ get count(): number {
16
+ return this.used;
17
+ }
18
+
19
+ clear(): void {
20
+ this.used = 0;
21
+ }
22
+
23
+ /** @param area m² of shared surface, which is what sets how fast heat crosses. */
24
+ add(a: number, b: number, area: number): void {
25
+ if (this.used === this.a.length) this.grow();
26
+ const at = this.used++;
27
+ this.a[at] = a;
28
+ this.b[at] = b;
29
+ this.area[at] = area;
30
+ }
31
+
32
+ firstOf(contact: number): number {
33
+ return this.a[contact] as number;
34
+ }
35
+
36
+ secondOf(contact: number): number {
37
+ return this.b[contact] as number;
38
+ }
39
+
40
+ areaOf(contact: number): number {
41
+ return this.area[contact] as number;
42
+ }
43
+
44
+ private grow(): void {
45
+ const size = this.a.length * 2;
46
+ const a = new Int32Array(size);
47
+ a.set(this.a);
48
+ this.a = a;
49
+ const b = new Int32Array(size);
50
+ b.set(this.b);
51
+ this.b = b;
52
+ const area = new Float64Array(size);
53
+ area.set(this.area);
54
+ this.area = area;
55
+ }
56
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Heat crossing empty space, which is how a fire reaches anything it is not touching.
3
+ *
4
+ * **Radiation dominates every fire beyond a few centimetres, and `§10` is the argument.** A flame
5
+ * at 1200 K radiates about 118 kW/m², and the fourth power means halving its temperature cuts that
6
+ * sixteenfold. A model without it cannot make a log ignite from across a hearth — which is the
7
+ * scenario the whole track exists for.
8
+ *
9
+ * **The view factor is a point source: `cos θ · A / (π r²)`.** It is wrong close in, where a flame
10
+ * is not a point, and it is right where it matters. What it buys is the single relationship this
11
+ * model is built on: **flux falls as `1/r²`**, so moving a log twice as far from a fire makes it
12
+ * take four times as long to catch. One line of arithmetic, and without it "leading a piece of wood
13
+ * near the fire" has no right answer at all.
14
+ */
15
+
16
+ /** W/(m²·K⁴). */
17
+ export const STEFAN_BOLTZMANN = 5.670374419e-8;
18
+
19
+ /**
20
+ * How close a point source may be approached before the approximation is capped.
21
+ *
22
+ * A point source's flux is unbounded at zero distance, and the honest failure there is a large
23
+ * number rather than an infinite one. The clamp is the scale at which a real source stops looking
24
+ * like a point, which is its own size.
25
+ */
26
+ const MIN_DISTANCE = 0.05;
27
+
28
+ /**
29
+ * Radiant flux onto a surface, W/m².
30
+ *
31
+ * @param sourceTemperature K
32
+ * @param sourceArea m² of radiating surface
33
+ * @param surfaceTemperature K of the receiver
34
+ * @param emissivity of the receiver, 0..1
35
+ * @param distance m
36
+ * @param cosine of the angle between the receiver's normal and the line to the source
37
+ */
38
+ export function radiantFlux(
39
+ sourceTemperature: number,
40
+ sourceArea: number,
41
+ surfaceTemperature: number,
42
+ emissivity: number,
43
+ distance: number,
44
+ cosine: number,
45
+ ): number {
46
+ if (!(cosine > 0)) return 0;
47
+ const range = Math.max(distance, MIN_DISTANCE);
48
+ const viewFactor = (cosine * sourceArea) / (Math.PI * range * range);
49
+ const source = sourceTemperature * sourceTemperature * sourceTemperature * sourceTemperature;
50
+ const surface = surfaceTemperature * surfaceTemperature * surfaceTemperature * surfaceTemperature;
51
+ return emissivity * STEFAN_BOLTZMANN * viewFactor * (source - surface);
52
+ }
53
+
54
+ /**
55
+ * How far a source of a given power is worth considering, m.
56
+ *
57
+ * From `P / (4π r²) = q_min`, so a **big fire reaches further than a small one** rather than every
58
+ * source sharing a constant radius. That is what stops a candle being tested against every parcel
59
+ * in a scene and a bonfire being cut off at the same distance.
60
+ */
61
+ export function sourceRange(power: number, minimumFlux: number): number {
62
+ if (!(power > 0) || !(minimumFlux > 0)) return 0;
63
+ return Math.sqrt(power / (4 * Math.PI * minimumFlux));
64
+ }
65
+
66
+ /**
67
+ * Convective heat transfer coefficient from the local gas speed, W/(m²·K).
68
+ *
69
+ * Two to twenty-five in still air, and up to about two hundred and fifty in a gale. **Above a fire
70
+ * convection dominates radiation and beside one radiation dominates convection**, and the crossover
71
+ * is at the plume edge — which is why standing beside a bonfire is pleasant and standing in the
72
+ * smoke is not.
73
+ */
74
+ export function convectiveCoefficient(gasSpeed: number): number {
75
+ const speed = gasSpeed > 0 ? gasSpeed : 0;
76
+ const h = 5 + 12 * Math.sqrt(speed);
77
+ return h > 250 ? 250 : h;
78
+ }
79
+
80
+ /** Whether anything solid stands between two points. */
81
+ export type OcclusionTest = (
82
+ ax: number,
83
+ ay: number,
84
+ az: number,
85
+ bx: number,
86
+ by: number,
87
+ bz: number,
88
+ ) => boolean;
89
+
90
+ /** Below this a source is not worth testing against anything, W/m². */
91
+ const MINIMUM_USEFUL_FLUX = 200;
92
+
93
+ /**
94
+ * The radiating things in a scene this tick: flames and hot surfaces both.
95
+ *
96
+ * Gathered into columns once per tick and read many times, which is why it is a structure rather
97
+ * than a callback. Cleared and refilled rather than reallocated.
98
+ */
99
+ export class RadiativeSources {
100
+ private x = new Float64Array(32);
101
+ private y = new Float64Array(32);
102
+ private z = new Float64Array(32);
103
+ private temperature = new Float64Array(32);
104
+ private area = new Float64Array(32);
105
+ private range = new Float64Array(32);
106
+ private used = 0;
107
+
108
+ get count(): number {
109
+ return this.used;
110
+ }
111
+
112
+ clear(): void {
113
+ this.used = 0;
114
+ }
115
+
116
+ /** @param power W, which decides how far this source is worth testing against. */
117
+ add(x: number, y: number, z: number, temperature: number, area: number, power: number): void {
118
+ if (this.used === this.x.length) this.grow();
119
+ const at = this.used++;
120
+ this.x[at] = x;
121
+ this.y[at] = y;
122
+ this.z[at] = z;
123
+ this.temperature[at] = temperature;
124
+ this.area[at] = area;
125
+ this.range[at] = sourceRange(power, MINIMUM_USEFUL_FLUX);
126
+ }
127
+
128
+ temperatureOf(source: number): number {
129
+ return this.temperature[source] as number;
130
+ }
131
+
132
+ areaOf(source: number): number {
133
+ return this.area[source] as number;
134
+ }
135
+
136
+ /** Whether a point is inside this source's own reach. */
137
+ reaches(source: number, x: number, y: number, z: number): boolean {
138
+ const dx = x - (this.x[source] as number);
139
+ const dy = y - (this.y[source] as number);
140
+ const dz = z - (this.z[source] as number);
141
+ const reach = this.range[source] as number;
142
+ return dx * dx + dy * dy + dz * dz <= reach * reach;
143
+ }
144
+
145
+ /**
146
+ * Total flux onto a point from every source that can see it, W/m².
147
+ *
148
+ * The receiver is treated as facing each source — a parcel is a lump rather than a plane, so
149
+ * there is no one normal to take a cosine against. What that costs is that a flat plate edge-on
150
+ * to a fire receives as much as one facing it; what it buys is not asking a consumer for a normal
151
+ * per parcel, which is a number most of them do not have.
152
+ */
153
+ fluxAt(
154
+ x: number,
155
+ y: number,
156
+ z: number,
157
+ surfaceTemperature: number,
158
+ emissivity: number,
159
+ occluded: OcclusionTest | null,
160
+ ): number {
161
+ let total = 0;
162
+ for (let source = 0; source < this.used; source++) {
163
+ if (!this.reaches(source, x, y, z)) continue;
164
+ const sx = this.x[source] as number;
165
+ const sy = this.y[source] as number;
166
+ const sz = this.z[source] as number;
167
+ if (occluded !== null && occluded(sx, sy, sz, x, y, z)) continue;
168
+ const dx = x - sx;
169
+ const dy = y - sy;
170
+ const dz = z - sz;
171
+ total += radiantFlux(
172
+ this.temperature[source] as number,
173
+ this.area[source] as number,
174
+ surfaceTemperature,
175
+ emissivity,
176
+ Math.sqrt(dx * dx + dy * dy + dz * dz),
177
+ 1,
178
+ );
179
+ }
180
+ return total;
181
+ }
182
+
183
+ /**
184
+ * The temperature of the hottest source that reaches a point, or zero where none does.
185
+ *
186
+ * **What a radiative exchange is bounded by**, and the reason it has to be bounded at all: the
187
+ * radiative term is explicit, so a parcel whose surface shell has a small heat capacity — a coin,
188
+ * a nail, a leaf — can absorb more in one step than would take it past the source that is heating
189
+ * it, and then radiate the excess back as `T⁴` and ring itself to pieces. It was found by putting
190
+ * a copper coin on a hearth in `demo/dev/chemistry.ts`, where it reached 4,000 K on the first step
191
+ * and 100 K a minute later.
192
+ *
193
+ * Nothing can be heated past the thing heating it, so this is the equilibrium the step may reach
194
+ * and not exceed — which is what an implicit solve would give and costs one comparison per source.
195
+ */
196
+ hottestAt(x: number, y: number, z: number, occluded: OcclusionTest | null): number {
197
+ let hottest = 0;
198
+ for (let source = 0; source < this.used; source++) {
199
+ if (!this.reaches(source, x, y, z)) continue;
200
+ const sx = this.x[source] as number;
201
+ const sy = this.y[source] as number;
202
+ const sz = this.z[source] as number;
203
+ if (occluded !== null && occluded(sx, sy, sz, x, y, z)) continue;
204
+ const temperature = this.temperature[source] as number;
205
+ if (temperature > hottest) hottest = temperature;
206
+ }
207
+ return hottest;
208
+ }
209
+
210
+ /**
211
+ * Whether **any** source reaches a point at all, which is the whole of the wake test.
212
+ *
213
+ * `§14`'s scan has to be coarse or sleeping saves nothing: this is a range comparison per source
214
+ * and no view factor, no temperature and no occlusion ray. A source's range already comes from its
215
+ * own power, so a bonfire wakes a parcel a candle would not.
216
+ */
217
+ reachesPoint(x: number, y: number, z: number): boolean {
218
+ for (let source = 0; source < this.used; source++) {
219
+ if (this.reaches(source, x, y, z)) return true;
220
+ }
221
+ return false;
222
+ }
223
+
224
+ private grow(): void {
225
+ const size = this.x.length * 2;
226
+ const grow = (from: Float64Array<ArrayBuffer>): Float64Array<ArrayBuffer> => {
227
+ const to = new Float64Array(size);
228
+ to.set(from);
229
+ return to;
230
+ };
231
+ this.x = grow(this.x);
232
+ this.y = grow(this.y);
233
+ this.z = grow(this.z);
234
+ this.temperature = grow(this.temperature);
235
+ this.area = grow(this.area);
236
+ this.range = grow(this.range);
237
+ }
238
+ }