@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,157 @@
1
+ /**
2
+ * `installChemistry` — three lines, and a consumer's oak is oak.
3
+ *
4
+ * **`§16` is honest about what "free" means and this file is where it lands.** Free of *authoring*:
5
+ * a consumer never writes a reaction, never tunes an ignition temperature, never writes a state
6
+ * machine for fire. Not free of *importing*: core does not depend on chemistry and a game with no
7
+ * fire in it pays zero bytes, which is `ARCHITECTURE.md` §5's payload rule and this track does not
8
+ * get an exception to it.
9
+ *
10
+ * So the work goes into making the wiring free, and this is it.
11
+ */
12
+ import { ReactionRegistry } from '../reaction/registry.js';
13
+ import { KIND_COMBUSTION, KIND_PHASE_CHANGE } from '../reaction/reaction.js';
14
+ import { SpeciesRegistry } from '../species/registry.js';
15
+ import { registerStandardSpecies } from '../species/standard.js';
16
+ import { SubstanceRegistry } from '../substance/registry.js';
17
+ import { ParcelStore } from '../parcel/store.js';
18
+ import { AtmosphereField } from '../field/atmosphere.js';
19
+ import { STANDARD_AIR, STANDARD_FIELD_SPECIES } from '../field/ambient.js';
20
+ import { ChemistryWorld } from '../transport/world.js';
21
+ import { installLibrary } from '../library/library.js';
22
+ /**
23
+ * A world with the given families in it, and everything wired to everything else.
24
+ *
25
+ * **Every option after `libraries` removes a capability when omitted rather than breaking.** No
26
+ * ambient: standard air. No cell size: one metre. That is `§16`'s arrangement, and the reason none
27
+ * of them is required is that a consumer who has not decided yet should still get a fire.
28
+ *
29
+ * **The gas-phase reactions come from the libraries rather than from the caller.** A family knows
30
+ * what its own volatiles are, and a consumer who forgot to pass `burn-tar` would have a fire that
31
+ * made smoke and no flame — which is a silent wrong answer rather than an error.
32
+ */
33
+ export function installChemistry(options) {
34
+ const species = new SpeciesRegistry();
35
+ registerStandardSpecies(species);
36
+ const reactions = new ReactionRegistry(species);
37
+ const substances = new SubstanceRegistry(species, reactions);
38
+ for (const library of options.libraries) {
39
+ installLibrary(library, species, reactions, substances);
40
+ }
41
+ /*
42
+ * Every combustion step any installed family declared, which is what a flame is made of — and the
43
+ * species those steps touch, which is what the air has to be able to carry.
44
+ *
45
+ * **Both are derived rather than asked for.** A consumer who installed `organic` and then forgot
46
+ * to name `burn-tar` would have a fire that made smoke and no flame, and one who named the
47
+ * reaction but not the `tar` species would be refused at init with a message about a field they
48
+ * never configured. Neither is a decision worth exposing.
49
+ */
50
+ const gasReactions = [];
51
+ const carried = new Set(STANDARD_FIELD_SPECIES);
52
+ for (let r = 0; r < reactions.count; r++) {
53
+ if (reactions.kindOf(r) !== KIND_COMBUSTION)
54
+ continue;
55
+ gasReactions.push(reactions.idOf(r));
56
+ for (const s of reactions.reactantSpecies(r))
57
+ carried.add(species.idOf(s));
58
+ for (const s of reactions.productSpecies(r))
59
+ carried.add(species.idOf(s));
60
+ }
61
+ /*
62
+ * **And every phase change whose reactants the air already holds, which is what condenses smoke.**
63
+ *
64
+ * Combustion alone left a hole: `library/organic` condenses tar to droplets below its dew point,
65
+ * which is a `KIND_PHASE_CHANGE` and is the only thing that happens to the volatiles off a
66
+ * *smouldering* surface — no flame over it, so nothing burns and nothing cracks. Without this the
67
+ * pale smoke a smoulder makes has no source and the tar drifts away invisible.
68
+ *
69
+ * **Derived by the same rule as the line above rather than declared**, and the rule is one
70
+ * sentence: a reaction the air can run is one whose inputs are already in the air. That admits
71
+ * `condense-tar`, because a combustion step above put `tar` in the set, and then `evaporate-tar`,
72
+ * because the first one put `tar(l)` there — which is why this is a fixpoint and not a pass. It
73
+ * declines `melt-ice` and `boil-water` and every `vaporise-*` in `library/fuel`, because a lump of
74
+ * ice and a puddle of diesel are not things the air holds, and the combustion steps burn the
75
+ * *vapours* rather than the liquids they come from. And it would admit `boil-water` the day
76
+ * something put liquid water into a cell, which is right: a droplet suspended in warm air really
77
+ * does evaporate.
78
+ *
79
+ * The alternative was a `gasReactions` list on `SubstanceLibrary`, which this file already argues
80
+ * against for combustion: a library author who forgot to add one would ship a fire whose smoke
81
+ * never whitened, and nothing would say so. The cost of deriving is that the set is a closure a
82
+ * consumer cannot read off their own library — so `gasReactions` is returned, and the demo panel
83
+ * prints it.
84
+ */
85
+ for (let grew = true; grew;) {
86
+ grew = false;
87
+ for (let r = 0; r < reactions.count; r++) {
88
+ if (reactions.kindOf(r) !== KIND_PHASE_CHANGE)
89
+ continue;
90
+ const id = reactions.idOf(r);
91
+ if (gasReactions.includes(id))
92
+ continue;
93
+ let inTheAir = true;
94
+ for (const s of reactions.reactantSpecies(r)) {
95
+ if (!carried.has(species.idOf(s)))
96
+ inTheAir = false;
97
+ }
98
+ if (!inTheAir)
99
+ continue;
100
+ gasReactions.push(id);
101
+ for (const s of reactions.productSpecies(r))
102
+ carried.add(species.idOf(s));
103
+ grew = true;
104
+ }
105
+ }
106
+ const parcels = new ParcelStore(substances);
107
+ const air = new AtmosphereField(species, {
108
+ cellSize: options.cellSize ?? 1,
109
+ ambient: options.ambient ?? STANDARD_AIR,
110
+ maxChunks: options.maxChunks,
111
+ species: [...carried],
112
+ reactions,
113
+ reactionIds: gasReactions,
114
+ });
115
+ const world = new ChemistryWorld(parcels, air);
116
+ return {
117
+ species,
118
+ reactions,
119
+ substances,
120
+ parcels,
121
+ air,
122
+ world,
123
+ gasReactions,
124
+ match(names) {
125
+ const resolved = [];
126
+ const unmatched = [];
127
+ let matched = 0;
128
+ for (const name of names) {
129
+ const index = substances.indexOf(name);
130
+ resolved.push(index);
131
+ if (index >= 0)
132
+ matched++;
133
+ else
134
+ unmatched.push(name);
135
+ }
136
+ return { substances: resolved, matched, unmatched };
137
+ },
138
+ };
139
+ }
140
+ /**
141
+ * Sensible parcel numbers from a bounding box and a density.
142
+ *
143
+ * **`§16`'s third mechanism**: a consumer with meshes gets parcels without measuring anything, and a
144
+ * consumer who cares supplies real numbers instead. The area is all six faces, which is right for
145
+ * something in the open and generous for something lying on a hearth — and being generous is the
146
+ * safe direction, because it makes things heat slightly faster rather than mysteriously refusing to.
147
+ */
148
+ export function parcelFromBounds(width, height, depth, density) {
149
+ const volume = width * height * depth;
150
+ const area = 2 * (width * height + height * depth + depth * width);
151
+ return {
152
+ mass: volume * density,
153
+ volume,
154
+ area,
155
+ thickness: area > 0 ? volume / area : 0,
156
+ };
157
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The field made visible, written into somebody else's particle arrays.
3
+ *
4
+ * **This module names no core type and imports nothing.** The target is described by what it must
5
+ * have, which is the `ragdollFromBones` arrangement: `@driftengine/core`'s `ParticleInstances`
6
+ * satisfies `SmokeTarget` exactly, and neither package knows the other exists. That matters more
7
+ * here than it did there, because this package imports **no** engine package at all and `present/`
8
+ * must not be the thing that breaks it.
9
+ */
10
+ import type { AtmosphereField } from '../field/atmosphere.ts';
11
+ /**
12
+ * Anything shaped like a pool of particles.
13
+ *
14
+ * Declared structurally, per the header. `spins` and `capacity` are core's and are not required
15
+ * here — a target that has them is fine, and one that does not still works, which is what
16
+ * structural means.
17
+ */
18
+ export interface SmokeTarget {
19
+ positions: Float32Array;
20
+ velocities: Float32Array;
21
+ colors: Float32Array;
22
+ alphas: Float32Array;
23
+ sizes: Float32Array;
24
+ ages: Float32Array;
25
+ seeds: Float32Array;
26
+ count: number;
27
+ readonly capacity: number;
28
+ }
29
+ export interface SmokeOptions {
30
+ /** kg of soot plus aerosol in a cell below which nothing is drawn. */
31
+ readonly threshold: number;
32
+ /** Metres across, which is also the path length opacity is computed over. */
33
+ readonly size: number;
34
+ /** The horizontal component a particle inherits, m/s. The vertical is the field's own. */
35
+ readonly wind: number;
36
+ }
37
+ /**
38
+ * The colour of a mixture of soot and aerosol, by mass.
39
+ *
40
+ * **Nothing chose it.** Soot is near-black at an albedo around 0.03 and water aerosol is near-white,
41
+ * so the ratio *is* the colour: a green log steams white, a rich fire smokes black, and a fire doing
42
+ * both does both in the right places. Grey where they are equal, which is what wood smoke is.
43
+ *
44
+ * Writes into `out`, allocates nothing.
45
+ */
46
+ export declare function smokeColour(soot: number, aerosol: number, out: Float32Array): void;
47
+ /**
48
+ * Write one particle per smoky cell in a box, and return how many were written.
49
+ *
50
+ * The box is `cells` cells on a side from `(x, y, z)` in cell coordinates, which is what a consumer
51
+ * has: they know where their fire is, and scanning the whole live set to find it would cost the same
52
+ * whether there was one fire or a hundred.
53
+ *
54
+ * **Every value is a reading rather than a parameter.** Position is the cell centre, velocity is the
55
+ * caller's wind plus the field's own buoyant rise — the same function transport uses, so a particle
56
+ * goes up at the speed the gas carrying it goes up — colour is the soot-to-aerosol ratio, and
57
+ * opacity is extinction over the particle's own path length. `size` and `threshold` are the two
58
+ * things a consumer genuinely decides.
59
+ *
60
+ * `count` is set rather than added to: a pool is refilled each frame, which is what makes this
61
+ * allocation-free.
62
+ */
63
+ export declare function emitSmoke(field: AtmosphereField, x: number, y: number, z: number, cells: number, out: SmokeTarget, options: SmokeOptions): number;
@@ -0,0 +1,85 @@
1
+ /** Linear albedo of soot, and of condensed water aerosol. `§17`. */
2
+ const SOOT_ALBEDO = 0.03;
3
+ const AEROSOL_ALBEDO = 0.92;
4
+ /** The extinction the field's own visibility is derived from, m²/kg. */
5
+ const SOOT_EXTINCTION = 8700;
6
+ /**
7
+ * The colour of a mixture of soot and aerosol, by mass.
8
+ *
9
+ * **Nothing chose it.** Soot is near-black at an albedo around 0.03 and water aerosol is near-white,
10
+ * so the ratio *is* the colour: a green log steams white, a rich fire smokes black, and a fire doing
11
+ * both does both in the right places. Grey where they are equal, which is what wood smoke is.
12
+ *
13
+ * Writes into `out`, allocates nothing.
14
+ */
15
+ export function smokeColour(soot, aerosol, out) {
16
+ const total = soot + aerosol;
17
+ const albedo = total > 0 ? (soot * SOOT_ALBEDO + aerosol * AEROSOL_ALBEDO) / total : AEROSOL_ALBEDO;
18
+ out[0] = albedo;
19
+ out[1] = albedo;
20
+ out[2] = albedo;
21
+ }
22
+ /* Scratch, module-level and reused, because this runs per cell per frame. */
23
+ const colour = new Float32Array(3);
24
+ /**
25
+ * Write one particle per smoky cell in a box, and return how many were written.
26
+ *
27
+ * The box is `cells` cells on a side from `(x, y, z)` in cell coordinates, which is what a consumer
28
+ * has: they know where their fire is, and scanning the whole live set to find it would cost the same
29
+ * whether there was one fire or a hundred.
30
+ *
31
+ * **Every value is a reading rather than a parameter.** Position is the cell centre, velocity is the
32
+ * caller's wind plus the field's own buoyant rise — the same function transport uses, so a particle
33
+ * goes up at the speed the gas carrying it goes up — colour is the soot-to-aerosol ratio, and
34
+ * opacity is extinction over the particle's own path length. `size` and `threshold` are the two
35
+ * things a consumer genuinely decides.
36
+ *
37
+ * `count` is set rather than added to: a pool is refilled each frame, which is what makes this
38
+ * allocation-free.
39
+ */
40
+ export function emitSmoke(field, x, y, z, cells, out, options) {
41
+ let written = 0;
42
+ const limit = out.alphas.length < out.capacity ? out.alphas.length : out.capacity;
43
+ for (let ix = 0; ix < cells && written < limit; ix++) {
44
+ for (let iy = 0; iy < cells && written < limit; iy++) {
45
+ for (let iz = 0; iz < cells && written < limit; iz++) {
46
+ const cx = x + ix + 0.5;
47
+ const cy = y + iy + 0.5;
48
+ const cz = z + iz + 0.5;
49
+ const soot = field.sootAt(cx, cy, cz);
50
+ const aerosol = field.aerosolAt(cx, cy, cz);
51
+ if (soot + aerosol < options.threshold)
52
+ continue;
53
+ const at = written * 3;
54
+ out.positions[at] = cx;
55
+ out.positions[at + 1] = cy;
56
+ out.positions[at + 2] = cz;
57
+ out.velocities[at] = options.wind;
58
+ out.velocities[at + 1] = field.riseAt(cx, cy, cz);
59
+ out.velocities[at + 2] = 0;
60
+ smokeColour(soot, aerosol, colour);
61
+ out.colors[at] = colour[0];
62
+ out.colors[at + 1] = colour[1];
63
+ out.colors[at + 2] = colour[2];
64
+ /*
65
+ * Opacity from extinction over the particle's own size, rather than from a curve.
66
+ *
67
+ * `1 − exp(−σd)` is the honest form and `exp` may not run here, so this is the first two
68
+ * terms of it arranged as `σd / (1 + σd)` — which agrees to within a few percent over the
69
+ * range that matters and saturates at one the way the exponential does, instead of
70
+ * overshooting the way a truncated series would.
71
+ */
72
+ const extinction = field.smokeDensityAt(cx, cy, cz) * options.size;
73
+ out.alphas[written] = extinction / (1 + extinction);
74
+ out.sizes[written] = options.size;
75
+ out.ages[written] = 0;
76
+ /* Per-particle randomness with no entropy: the cell's own index, so neighbours differ and
77
+ two runs agree. `§15`'s determinism rule reaches presentation too. */
78
+ out.seeds[written] = (((ix * 73856093) ^ (iy * 19349663) ^ (iz * 83492791)) % 1024) / 1024;
79
+ written++;
80
+ }
81
+ }
82
+ }
83
+ out.count = written;
84
+ return written;
85
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What a burning thing looks like, written into somebody else's per-instance arrays.
3
+ *
4
+ * **Structural, like `smoke.ts`, and for the same reason**: this package imports no engine package
5
+ * and `present/` must not be the thing that breaks it. A target is described by what it must have.
6
+ *
7
+ * Four of `§17`'s six mappings live here — char, glow, wetness and shrinkage — because all four are
8
+ * per-parcel and per-frame and reading a parcel four times would be four passes over the same
9
+ * columns. Smoke and flame are per-cell and per-fire, which is why they are elsewhere.
10
+ */
11
+ import type { ParcelStore } from '../parcel/store.ts';
12
+ /** Anything shaped like a block of per-instance material overrides. */
13
+ export interface SurfaceTarget {
14
+ /** Linear RGB, three floats each. */
15
+ albedos: Float32Array;
16
+ /** Linear RGB, three floats each, already multiplied by intensity. */
17
+ emissives: Float32Array;
18
+ /** 0 to 1, where wet is smoother. */
19
+ roughness: Float32Array;
20
+ /** A uniform scale, 1 at the size it was spawned. */
21
+ scales: Float32Array;
22
+ count: number;
23
+ }
24
+ /**
25
+ * Write one entry per parcel, in the order given.
26
+ *
27
+ * **Every value is a reading.** Albedo lerps from the substance's own toward its own char colour by
28
+ * char fraction; the emissive is Planck at the surface temperature times Stefan-Boltzmann;
29
+ * roughness is the surface water film; scale is the cube root of what is left of the volume.
30
+ *
31
+ * **A substance with no `appearance` gets no albedo and no roughness written**, and the entry is
32
+ * left exactly as the caller had it. Quietly recolouring a material the consumer described would be
33
+ * the engine overwriting an artist. Scale and emissive are still written, because both are physics
34
+ * rather than art.
35
+ *
36
+ * **`AGENTS.md`'s standing trap applies to `emissives`.** Emissive in this engine is gated on
37
+ * `nightFactor`, so embers written here look right at dusk and dead at noon. A consumer who wants
38
+ * them visible in daylight raises that gate; nothing in this function can do it for them.
39
+ */
40
+ export declare function writeSurface(parcels: ParcelStore, handles: readonly number[], out: SurfaceTarget): number;
@@ -0,0 +1,76 @@
1
+ import { GLOW_MIN_TEMPERATURE, blackbodyRGB, glowIntensity } from './blackbody.js';
2
+ /**
3
+ * Roughness a dry surface sits at, and how far a soaked one is pulled toward a mirror.
4
+ *
5
+ * `WET_FILM_FULL` is kilograms of water per square metre at which the film is continuous — about a
6
+ * tenth of a millimetre of water, which is what a surface holds before it starts running off.
7
+ */
8
+ const DRY_ROUGHNESS = 0.85;
9
+ const WET_ROUGHNESS = 0.15;
10
+ const WET_FILM_FULL = 0.1;
11
+ /* Scratch, module-level and reused: this runs per parcel per frame. */
12
+ const glow = new Float32Array(3);
13
+ /**
14
+ * Write one entry per parcel, in the order given.
15
+ *
16
+ * **Every value is a reading.** Albedo lerps from the substance's own toward its own char colour by
17
+ * char fraction; the emissive is Planck at the surface temperature times Stefan-Boltzmann;
18
+ * roughness is the surface water film; scale is the cube root of what is left of the volume.
19
+ *
20
+ * **A substance with no `appearance` gets no albedo and no roughness written**, and the entry is
21
+ * left exactly as the caller had it. Quietly recolouring a material the consumer described would be
22
+ * the engine overwriting an artist. Scale and emissive are still written, because both are physics
23
+ * rather than art.
24
+ *
25
+ * **`AGENTS.md`'s standing trap applies to `emissives`.** Emissive in this engine is gated on
26
+ * `nightFactor`, so embers written here look right at dusk and dead at noon. A consumer who wants
27
+ * them visible in daylight raises that gate; nothing in this function can do it for them.
28
+ */
29
+ export function writeSurface(parcels, handles, out) {
30
+ let written = 0;
31
+ for (let i = 0; i < handles.length; i++) {
32
+ const parcel = handles[i];
33
+ if (!parcels.alive(parcel))
34
+ continue;
35
+ const at = written * 3;
36
+ const appearance = parcels.appearanceOf(parcel);
37
+ if (appearance !== null) {
38
+ const char = parcels.charFractionOf(parcel);
39
+ const solid = char > 1 ? 1 : char;
40
+ for (let c = 0; c < 3; c++) {
41
+ out.albedos[at + c] =
42
+ appearance.albedo[c] * (1 - solid) +
43
+ appearance.charAlbedo[c] * solid;
44
+ }
45
+ const film = parcels.wetnessOf(parcel) / WET_FILM_FULL;
46
+ const wet = film > 1 ? 1 : film;
47
+ out.roughness[written] = DRY_ROUGHNESS * (1 - wet) + WET_ROUGHNESS * wet;
48
+ }
49
+ const surface = parcels.surfaceTemperatureOf(parcel);
50
+ if (surface >= GLOW_MIN_TEMPERATURE) {
51
+ blackbodyRGB(surface, glow);
52
+ const intensity = glowIntensity(surface);
53
+ for (let c = 0; c < 3; c++)
54
+ out.emissives[at + c] = glow[c] * intensity;
55
+ }
56
+ else {
57
+ out.emissives[at] = 0;
58
+ out.emissives[at + 1] = 0;
59
+ out.emissives[at + 2] = 0;
60
+ }
61
+ /*
62
+ * The **cube root** of the volume ratio, because a scale is linear and a volume is not. Half the
63
+ * mass is 0.794 of the size, which is a log visibly smaller and not half a log.
64
+ *
65
+ * `Math.cbrt`'s result is not pinned down by ECMAScript, which is why it is kept off the
66
+ * simulation path entirely — `shellGeometry` says so. Here it is presentation, which does not
67
+ * feed back into the state, so a last-bit difference between platforms is a pixel rather than a
68
+ * divergence.
69
+ */
70
+ // determinism: build-time — presentation: a parcel's drawn scale, not its state
71
+ out.scales[written] = Math.cbrt(parcels.volumeShareOf(parcel));
72
+ written++;
73
+ }
74
+ out.count = written;
75
+ return written;
76
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Packing a reaction network against one owner's species set.
3
+ *
4
+ * Both a substance and the atmosphere run reactions, and both need the same thing: the network
5
+ * rewritten so its species are **local slots** into the few species that owner actually holds,
6
+ * rather than indices into all sixty-eight. A shell's composition is then a dense run and the
7
+ * solver's inner loop is a walk rather than a lookup.
8
+ *
9
+ * One implementation, used by both, because two would be two things to keep in step — and the
10
+ * second would be discovered wrong on the day a reaction behaved differently in a cell than in the
11
+ * shell beside it.
12
+ */
13
+ import type { SpeciesRegistry } from '../species/registry.ts';
14
+ import type { ReactionRegistry } from './registry.ts';
15
+ import { type SubstanceReactions } from './solver.ts';
16
+ export declare function localiseReactions(species: SpeciesRegistry, reactions: ReactionRegistry | undefined, ids: readonly string[], set: Int32Array, owner: string): SubstanceReactions;
@@ -0,0 +1,95 @@
1
+ import { MAX_SUBSTANCE_REACTIONS } from './solver.js';
2
+ export function localiseReactions(species, reactions, ids, set, owner) {
3
+ if (ids.length > MAX_SUBSTANCE_REACTIONS) {
4
+ throw new Error(`${owner} declares ${ids.length} reactions; the limit is ${MAX_SUBSTANCE_REACTIONS}`);
5
+ }
6
+ const local = new Int32Array(species.count).fill(-1);
7
+ for (let i = 0; i < set.length; i++)
8
+ local[set[i]] = i;
9
+ const molarMass = new Float64Array(set.length);
10
+ const cpA = new Float64Array(set.length);
11
+ const cpB = new Float64Array(set.length);
12
+ for (let i = 0; i < set.length; i++) {
13
+ const global = set[i];
14
+ molarMass[i] = species.molarMass(global);
15
+ /* `cp(T) = cpA + cpB·(T − 298.15)`, recovered from the registry's own linear model. */
16
+ cpA[i] = species.heatCapacityOf(global, 298.15);
17
+ cpB[i] = species.heatCapacityOf(global, 299.15) - cpA[i];
18
+ }
19
+ const indices = [];
20
+ for (const id of ids) {
21
+ if (reactions === undefined) {
22
+ throw new Error(`${owner} declares reaction "${id}", and no reaction registry was supplied`);
23
+ }
24
+ const at = reactions.indexOf(id);
25
+ if (at < 0)
26
+ throw new Error(`${owner} declares reaction "${id}", which is not registered`);
27
+ indices.push(at);
28
+ }
29
+ const reactantStart = new Int32Array(indices.length + 1);
30
+ const productStart = new Int32Array(indices.length + 1);
31
+ const reactantLocal = [];
32
+ const reactantMoles = [];
33
+ const productLocal = [];
34
+ const productMoles = [];
35
+ const enthalpy = new Float64Array(indices.length);
36
+ const minTemperature = new Float64Array(indices.length);
37
+ const maxTemperature = new Float64Array(indices.length);
38
+ const gateWidth = new Float64Array(indices.length);
39
+ const transportLimit = new Float64Array(indices.length);
40
+ const table = [];
41
+ const slot = (global, id) => {
42
+ const at = local[global];
43
+ if (at < 0) {
44
+ throw new Error(`${owner} declares reaction "${id}", which touches "${species.idOf(global)}" — ` +
45
+ 'a species it does not carry');
46
+ }
47
+ return at;
48
+ };
49
+ for (let r = 0; r < indices.length; r++) {
50
+ const registry = reactions;
51
+ const global = indices[r];
52
+ const id = ids[r];
53
+ reactantStart[r] = reactantLocal.length;
54
+ const rs = registry.reactantSpecies(global);
55
+ const rm = registry.reactantMoles(global);
56
+ for (let i = 0; i < rs.length; i++) {
57
+ reactantLocal.push(slot(rs[i], id));
58
+ reactantMoles.push(rm[i]);
59
+ }
60
+ productStart[r] = productLocal.length;
61
+ const ps = registry.productSpecies(global);
62
+ const pm = registry.productMoles(global);
63
+ for (let i = 0; i < ps.length; i++) {
64
+ productLocal.push(slot(ps[i], id));
65
+ productMoles.push(pm[i]);
66
+ }
67
+ enthalpy[r] = registry.enthalpyOf(global);
68
+ minTemperature[r] = registry.minTemperatureOf(global);
69
+ maxTemperature[r] = registry.maxTemperatureOf(global);
70
+ gateWidth[r] = registry.gateWidthOf(global);
71
+ transportLimit[r] = registry.transportLimitOf(global);
72
+ table.push(registry.rateTableOrNull(global));
73
+ }
74
+ reactantStart[indices.length] = reactantLocal.length;
75
+ productStart[indices.length] = productLocal.length;
76
+ return {
77
+ count: indices.length,
78
+ speciesCount: set.length,
79
+ molarMass,
80
+ cpA,
81
+ cpB,
82
+ minTemperature,
83
+ maxTemperature,
84
+ gateWidth,
85
+ transportLimit,
86
+ reactantStart,
87
+ reactantLocal: Int32Array.from(reactantLocal),
88
+ reactantMoles: Float64Array.from(reactantMoles),
89
+ productStart,
90
+ productLocal: Int32Array.from(productLocal),
91
+ productMoles: Float64Array.from(productMoles),
92
+ enthalpy,
93
+ table,
94
+ };
95
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Reaction rates as tables, because `exp` may not run on a tick.
3
+ *
4
+ * **The reasoning is `§7`'s and it is a hard rule rather than a preference.** `AGENTS.md` already
5
+ * made this call once, for the tyre curve: Pacejka is `sin(atan(…))` and both were banned from the
6
+ * tick, so the curve became a table and the API came out better than the closed form. The same
7
+ * applies here with more force. `exp(−Ea/RT)` would be evaluated once per reaction per shell per
8
+ * parcel per substep — a hundred times the tyre call count — and it varies by twenty orders of
9
+ * magnitude over the range of interest, which makes it the single easiest place in the engine to
10
+ * lose determinism to one platform's `exp` differing in the last bit.
11
+ *
12
+ * **Knots are uniform in `1/T`, and that is a departure from the design worth recording.** `§7`
13
+ * says "512 logarithmically-spaced knots, log-`k` stored so the interpolation is in the variable
14
+ * that is nearly linear" — and `ln k = ln A + n·ln T − Ea/(R·T)` is not nearly linear in `ln T`, it
15
+ * is *exactly* linear in `1/T` for the `n = 0` form that almost every rate in the library uses. So
16
+ * the knots are uniform in `1/T`, which puts the interpolation error where the design wanted it.
17
+ *
18
+ * **But `k` is stored rather than `ln k`, and that is the other half of the correction.** The
19
+ * design's own sentence would need an `exp` at every lookup, which is the thing the paragraph above
20
+ * it forbids — the two halves of `§7` contradict each other, and this is the resolution. Storing
21
+ * `k` and interpolating linearly costs a real interpolation error, bounded by about `δ²/8` where
22
+ * `δ` is the change in `ln k` across one knot. At 1,024 knots over 200–3500 K that is under half a
23
+ * percent for every activation energy in the library, **which is comfortably inside the uncertainty
24
+ * in the activation energies themselves** — literature values for cellulose pyrolysis differ by
25
+ * several percent between studies. `rates.test.ts` measures it rather than trusting this paragraph.
26
+ */
27
+ /** J/(mol·K). */
28
+ export declare const GAS_CONSTANT = 8.314462618;
29
+ export interface RateTable {
30
+ readonly minTemperature: number;
31
+ readonly maxTemperature: number;
32
+ /** `1 / maxTemperature`: the low end of the knot axis. */
33
+ readonly inverseMin: number;
34
+ /** `1 / minTemperature`: the high end of it. */
35
+ readonly inverseMax: number;
36
+ readonly knots: number;
37
+ /** Rate at each knot, ascending in `1/T` — so descending in temperature. */
38
+ readonly rate: Float64Array;
39
+ }
40
+ /** `k(T) = A · T^n · exp(−Ea / R·T)`, sampled once so the tick never evaluates it. */
41
+ export declare function tabulateArrhenius(A: number, activationEnergy: number, n?: number, min?: number, max?: number, knots?: number): RateTable;
42
+ /**
43
+ * The peaked curve an enzyme-catalysed rate actually follows, normalised to one at the optimum.
44
+ *
45
+ * Zero at both cardinal ends, one at `optimum`, and **asymmetric in the right direction**: a slow
46
+ * climb and a sharp fall, because an enzyme denatures far faster than it warms up. Modelling decay
47
+ * or fermentation with Arrhenius instead gives compost that gets hotter forever, which is the most
48
+ * common mistake in this area and is refused here structurally rather than by remembering.
49
+ */
50
+ export declare function cardinalRate(temperature: number, min: number, optimum: number, max: number): number;
51
+ /** A cardinal curve scaled to a peak rate, tabulated the same way an Arrhenius one is. */
52
+ export declare function tabulateCardinal(peak: number, min: number, optimum: number, max: number, low?: number, high?: number, knots?: number): RateTable;
53
+ /**
54
+ * The rate at a temperature.
55
+ *
56
+ * Two multiplications, a floor and a lerp. Clamped at both ends rather than extrapolated: linearly
57
+ * extrapolating an exponential is how a rate table produces a number with nothing behind it.
58
+ */
59
+ export declare function rateAt(table: RateTable, temperature: number): number;