@classytic/stage 0.1.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.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/assets/index.d.mts +3 -0
- package/dist/assets/index.mjs +5 -0
- package/dist/assets/kit/glyphs.d.mts +61 -0
- package/dist/assets/kit/glyphs.mjs +256 -0
- package/dist/assets/kit/index.d.mts +2 -0
- package/dist/assets/kit/index.mjs +4 -0
- package/dist/assets/kit/svg-defs.d.mts +6 -0
- package/dist/assets/kit/svg-defs.mjs +137 -0
- package/dist/builder/Palette.d.mts +30 -0
- package/dist/builder/Palette.mjs +100 -0
- package/dist/builder/SceneBuilder.d.mts +26 -0
- package/dist/builder/SceneBuilder.mjs +340 -0
- package/dist/builder/controls.d.mts +18 -0
- package/dist/builder/controls.mjs +29 -0
- package/dist/builder/editor.d.mts +46 -0
- package/dist/builder/editor.mjs +130 -0
- package/dist/builder/icons.d.mts +14 -0
- package/dist/builder/icons.mjs +143 -0
- package/dist/builder/index.d.mts +6 -0
- package/dist/builder/index.mjs +7 -0
- package/dist/builder/tools.d.mts +47 -0
- package/dist/builder/tools.mjs +303 -0
- package/dist/chem/index.d.mts +118 -0
- package/dist/chem/index.mjs +131 -0
- package/dist/core/clock.d.mts +41 -0
- package/dist/core/clock.mjs +62 -0
- package/dist/core/context.d.mts +13 -0
- package/dist/core/context.mjs +29 -0
- package/dist/core/control.d.mts +58 -0
- package/dist/core/control.mjs +113 -0
- package/dist/core/coords.d.mts +51 -0
- package/dist/core/coords.mjs +44 -0
- package/dist/core/index.d.mts +8 -0
- package/dist/core/index.mjs +9 -0
- package/dist/core/learner.d.mts +44 -0
- package/dist/core/learner.mjs +23 -0
- package/dist/core/motion.d.mts +73 -0
- package/dist/core/motion.mjs +103 -0
- package/dist/core/richText.d.mts +28 -0
- package/dist/core/richText.mjs +52 -0
- package/dist/core/vec.d.mts +47 -0
- package/dist/core/vec.mjs +144 -0
- package/dist/field/index.d.mts +58 -0
- package/dist/field/index.mjs +156 -0
- package/dist/index.d.mts +54 -0
- package/dist/index.mjs +55 -0
- package/dist/interaction/MovableDot.d.mts +33 -0
- package/dist/interaction/MovableDot.mjs +66 -0
- package/dist/interaction/hitTest.d.mts +16 -0
- package/dist/interaction/hitTest.mjs +39 -0
- package/dist/interaction/index.d.mts +5 -0
- package/dist/interaction/index.mjs +6 -0
- package/dist/interaction/useDraggable.d.mts +51 -0
- package/dist/interaction/useDraggable.mjs +116 -0
- package/dist/interaction/useScreenToMath.d.mts +12 -0
- package/dist/interaction/useScreenToMath.mjs +25 -0
- package/dist/logic/ast.d.mts +29 -0
- package/dist/logic/ast.mjs +37 -0
- package/dist/logic/index.d.mts +25 -0
- package/dist/logic/index.mjs +36 -0
- package/dist/logic/latex.d.mts +6 -0
- package/dist/logic/latex.mjs +33 -0
- package/dist/logic/minimize.d.mts +37 -0
- package/dist/logic/minimize.mjs +175 -0
- package/dist/logic/parse.d.mts +6 -0
- package/dist/logic/parse.mjs +68 -0
- package/dist/logic/table.d.mts +24 -0
- package/dist/logic/table.mjs +65 -0
- package/dist/logic/tokenize.mjs +258 -0
- package/dist/math/ast.d.mts +35 -0
- package/dist/math/ast.mjs +94 -0
- package/dist/math/calculus.d.mts +9 -0
- package/dist/math/calculus.mjs +188 -0
- package/dist/math/defs.mjs +51 -0
- package/dist/math/index.d.mts +26 -0
- package/dist/math/index.mjs +42 -0
- package/dist/math/latex.d.mts +7 -0
- package/dist/math/latex.mjs +115 -0
- package/dist/math/parse.d.mts +6 -0
- package/dist/math/parse.mjs +91 -0
- package/dist/math/tokenize.mjs +112 -0
- package/dist/primitives/CanvasLayer.d.mts +26 -0
- package/dist/primitives/CanvasLayer.mjs +87 -0
- package/dist/primitives/Dot.d.mts +27 -0
- package/dist/primitives/Dot.mjs +40 -0
- package/dist/primitives/Grid.d.mts +30 -0
- package/dist/primitives/Grid.mjs +122 -0
- package/dist/primitives/Label.d.mts +33 -0
- package/dist/primitives/Label.mjs +45 -0
- package/dist/primitives/Lines.d.mts +50 -0
- package/dist/primitives/Lines.mjs +82 -0
- package/dist/primitives/Plot.d.mts +50 -0
- package/dist/primitives/Plot.mjs +87 -0
- package/dist/primitives/Shapes.d.mts +76 -0
- package/dist/primitives/Shapes.mjs +79 -0
- package/dist/primitives/Tex.d.mts +27 -0
- package/dist/primitives/Tex.mjs +54 -0
- package/dist/primitives/index.d.mts +10 -0
- package/dist/primitives/index.mjs +10 -0
- package/dist/primitives/props.d.mts +15 -0
- package/dist/scene/Scene.d.mts +31 -0
- package/dist/scene/Scene.mjs +168 -0
- package/dist/scene/assets.d.mts +37 -0
- package/dist/scene/assets.mjs +31 -0
- package/dist/scene/commands.d.mts +47 -0
- package/dist/scene/commands.mjs +162 -0
- package/dist/scene/evaluators.d.mts +17 -0
- package/dist/scene/evaluators.mjs +210 -0
- package/dist/scene/index.d.mts +13 -0
- package/dist/scene/index.mjs +13 -0
- package/dist/scene/migrate.d.mts +11 -0
- package/dist/scene/migrate.mjs +35 -0
- package/dist/scene/render.d.mts +14 -0
- package/dist/scene/render.mjs +126 -0
- package/dist/scene/resolve.d.mts +12 -0
- package/dist/scene/resolve.mjs +95 -0
- package/dist/scene/schema.d.mts +9 -0
- package/dist/scene/schema.mjs +51 -0
- package/dist/scene/sims.d.mts +18 -0
- package/dist/scene/sims.mjs +54 -0
- package/dist/scene/store.d.mts +22 -0
- package/dist/scene/store.mjs +37 -0
- package/dist/scene/types.d.mts +228 -0
- package/dist/scene/types.mjs +27 -0
- package/dist/sim/equilibrium.d.mts +36 -0
- package/dist/sim/equilibrium.mjs +54 -0
- package/dist/sim/index.d.mts +9 -0
- package/dist/sim/index.mjs +9 -0
- package/dist/sim/particles.d.mts +43 -0
- package/dist/sim/particles.mjs +136 -0
- package/dist/sim/rate.d.mts +36 -0
- package/dist/sim/rate.mjs +39 -0
- package/dist/sim/registry.d.mts +10 -0
- package/dist/sim/registry.mjs +27 -0
- package/dist/sim/sampler.d.mts +47 -0
- package/dist/sim/sampler.mjs +79 -0
- package/dist/sim/thermal.d.mts +78 -0
- package/dist/sim/thermal.mjs +172 -0
- package/dist/sim/types.d.mts +24 -0
- package/dist/sim/wave.d.mts +34 -0
- package/dist/sim/wave.mjs +61 -0
- package/dist/steps/index.d.mts +62 -0
- package/dist/steps/index.mjs +133 -0
- package/dist/thermo/index.d.mts +82 -0
- package/dist/thermo/index.mjs +133 -0
- package/dist/view/Stage.d.mts +39 -0
- package/dist/view/Stage.mjs +91 -0
- package/dist/view/index.d.mts +4 -0
- package/dist/view/index.mjs +5 -0
- package/dist/view/useElementSize.d.mts +13 -0
- package/dist/view/useElementSize.mjs +40 -0
- package/dist/view/useInView.d.mts +9 -0
- package/dist/view/useInView.mjs +33 -0
- package/package.json +141 -0
- package/styles.css +70 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { SimCore } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/sim/particles.d.ts
|
|
4
|
+
interface Box {
|
|
5
|
+
w: number;
|
|
6
|
+
h: number;
|
|
7
|
+
}
|
|
8
|
+
interface ParticlesParams {
|
|
9
|
+
/** Particle count. Default 80. */
|
|
10
|
+
n?: number;
|
|
11
|
+
box?: Box;
|
|
12
|
+
/** Initial speed (∝ √temperature). Default 2. */
|
|
13
|
+
speed?: number;
|
|
14
|
+
/** Particle radius (for collisions + drawing). Default 0.14. */
|
|
15
|
+
radius?: number;
|
|
16
|
+
/** Elastic pairwise collisions (thermalises the speed distribution). Default true. */
|
|
17
|
+
collide?: boolean;
|
|
18
|
+
/** Start group 0 in the LEFT half, group 1 in the RIGHT half (diffusion). Default false = mixed. */
|
|
19
|
+
split?: boolean;
|
|
20
|
+
seed?: number;
|
|
21
|
+
}
|
|
22
|
+
interface ParticlesState {
|
|
23
|
+
n: number;
|
|
24
|
+
box: Box;
|
|
25
|
+
radius: number;
|
|
26
|
+
collide: boolean;
|
|
27
|
+
px: number[];
|
|
28
|
+
py: number[];
|
|
29
|
+
vx: number[];
|
|
30
|
+
vy: number[];
|
|
31
|
+
/** 0 or 1 — two populations (for diffusion / colour). */
|
|
32
|
+
group: number[];
|
|
33
|
+
tSec: number;
|
|
34
|
+
/** Mean kinetic energy ∝ temperature. */
|
|
35
|
+
temp: number;
|
|
36
|
+
/** Smoothed pressure (wall-collision momentum per unit time per unit perimeter). */
|
|
37
|
+
pressure: number;
|
|
38
|
+
/** Mixing index 0 (separated) → 1 (uniform) — fraction-based, for diffusion. */
|
|
39
|
+
mixed: number;
|
|
40
|
+
}
|
|
41
|
+
declare const ParticlesCore: SimCore<ParticlesState, ParticlesParams>;
|
|
42
|
+
//#endregion
|
|
43
|
+
export { Box, ParticlesCore, ParticlesParams, ParticlesState };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
//#region src/sim/particles.ts
|
|
2
|
+
function rng(a) {
|
|
3
|
+
a = a + 1831565813 | 0;
|
|
4
|
+
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
5
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
6
|
+
return {
|
|
7
|
+
r: ((t ^ t >>> 14) >>> 0) / 4294967296,
|
|
8
|
+
a
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** Mixing: group-0's share of the RIGHT half, scaled so 0=separated, 1=uniform. */
|
|
12
|
+
function mixIndex(s) {
|
|
13
|
+
let g0 = 0, g0right = 0;
|
|
14
|
+
for (let i = 0; i < s.n; i++) if (s.group[i] === 0) {
|
|
15
|
+
g0++;
|
|
16
|
+
if (s.px[i] > s.box.w / 2) g0right++;
|
|
17
|
+
}
|
|
18
|
+
if (g0 === 0) return 1;
|
|
19
|
+
return Math.min(1, g0right / g0 * 2);
|
|
20
|
+
}
|
|
21
|
+
const ParticlesCore = {
|
|
22
|
+
reset(params = {}) {
|
|
23
|
+
const n = Math.max(1, Math.min(400, Math.round(params.n ?? 80)));
|
|
24
|
+
const box = params.box ?? {
|
|
25
|
+
w: 10,
|
|
26
|
+
h: 6
|
|
27
|
+
};
|
|
28
|
+
const radius = params.radius ?? .14;
|
|
29
|
+
const collide = params.collide ?? true;
|
|
30
|
+
const speed = params.speed ?? 2;
|
|
31
|
+
const split = params.split ?? false;
|
|
32
|
+
let a = (params.seed ?? 1) | 0;
|
|
33
|
+
const px = [], py = [], vx = [], vy = [], group = [];
|
|
34
|
+
for (let i = 0; i < n; i++) {
|
|
35
|
+
const g = i < n / 2 ? 0 : 1;
|
|
36
|
+
let rx = rng(a);
|
|
37
|
+
a = rx.a;
|
|
38
|
+
let ry = rng(a);
|
|
39
|
+
a = ry.a;
|
|
40
|
+
const ra = rng(a);
|
|
41
|
+
a = ra.a;
|
|
42
|
+
const xLo = split ? g === 0 ? radius : box.w / 2 + radius : radius;
|
|
43
|
+
const xHi = split ? g === 0 ? box.w / 2 - radius : box.w - radius : box.w - radius;
|
|
44
|
+
px.push(xLo + rx.r * (xHi - xLo));
|
|
45
|
+
py.push(radius + ry.r * (box.h - 2 * radius));
|
|
46
|
+
const ang = ra.r * Math.PI * 2;
|
|
47
|
+
vx.push(Math.cos(ang) * speed);
|
|
48
|
+
vy.push(Math.sin(ang) * speed);
|
|
49
|
+
group.push(g);
|
|
50
|
+
}
|
|
51
|
+
const base = {
|
|
52
|
+
n,
|
|
53
|
+
box,
|
|
54
|
+
radius,
|
|
55
|
+
collide,
|
|
56
|
+
px,
|
|
57
|
+
py,
|
|
58
|
+
vx,
|
|
59
|
+
vy,
|
|
60
|
+
group,
|
|
61
|
+
tSec: 0,
|
|
62
|
+
temp: .5 * speed * speed,
|
|
63
|
+
pressure: 0
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
...base,
|
|
67
|
+
mixed: mixIndex(base)
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
step(state, dtSec) {
|
|
71
|
+
const dt = Math.min(.04, Math.max(0, dtSec));
|
|
72
|
+
const { n, box, radius, px, py, vx, vy } = state;
|
|
73
|
+
let impulse = 0;
|
|
74
|
+
for (let i = 0; i < n; i++) {
|
|
75
|
+
px[i] += vx[i] * dt;
|
|
76
|
+
py[i] += vy[i] * dt;
|
|
77
|
+
if (px[i] < radius) {
|
|
78
|
+
px[i] = radius;
|
|
79
|
+
vx[i] = -vx[i];
|
|
80
|
+
impulse += 2 * Math.abs(vx[i]);
|
|
81
|
+
} else if (px[i] > box.w - radius) {
|
|
82
|
+
px[i] = box.w - radius;
|
|
83
|
+
vx[i] = -vx[i];
|
|
84
|
+
impulse += 2 * Math.abs(vx[i]);
|
|
85
|
+
}
|
|
86
|
+
if (py[i] < radius) {
|
|
87
|
+
py[i] = radius;
|
|
88
|
+
vy[i] = -vy[i];
|
|
89
|
+
impulse += 2 * Math.abs(vy[i]);
|
|
90
|
+
} else if (py[i] > box.h - radius) {
|
|
91
|
+
py[i] = box.h - radius;
|
|
92
|
+
vy[i] = -vy[i];
|
|
93
|
+
impulse += 2 * Math.abs(vy[i]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (state.collide) {
|
|
97
|
+
const d2 = 2 * radius * (2 * radius);
|
|
98
|
+
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) {
|
|
99
|
+
const dx = px[j] - px[i], dy = py[j] - py[i];
|
|
100
|
+
const dist2 = dx * dx + dy * dy;
|
|
101
|
+
if (dist2 > 1e-9 && dist2 < d2) {
|
|
102
|
+
const dist = Math.sqrt(dist2);
|
|
103
|
+
const nx = dx / dist, ny = dy / dist;
|
|
104
|
+
const dvn = (vx[j] - vx[i]) * nx + (vy[j] - vy[i]) * ny;
|
|
105
|
+
if (dvn < 0) {
|
|
106
|
+
vx[i] += dvn * nx;
|
|
107
|
+
vy[i] += dvn * ny;
|
|
108
|
+
vx[j] -= dvn * nx;
|
|
109
|
+
vy[j] -= dvn * ny;
|
|
110
|
+
}
|
|
111
|
+
const overlap = (2 * radius - dist) / 2;
|
|
112
|
+
px[i] -= nx * overlap;
|
|
113
|
+
py[i] -= ny * overlap;
|
|
114
|
+
px[j] += nx * overlap;
|
|
115
|
+
py[j] += ny * overlap;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
let ke = 0;
|
|
120
|
+
for (let i = 0; i < n; i++) ke += vx[i] * vx[i] + vy[i] * vy[i];
|
|
121
|
+
const temp = .5 * ke / n;
|
|
122
|
+
const perim = 2 * (box.w + box.h);
|
|
123
|
+
const instP = dt > 0 ? impulse / (dt * perim) : 0;
|
|
124
|
+
const pressure = state.pressure * .9 + instP * .1;
|
|
125
|
+
return {
|
|
126
|
+
...state,
|
|
127
|
+
tSec: state.tSec + dt,
|
|
128
|
+
temp,
|
|
129
|
+
pressure,
|
|
130
|
+
mixed: mixIndex(state)
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
//#endregion
|
|
136
|
+
export { ParticlesCore };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { SimCore } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/sim/rate.d.ts
|
|
4
|
+
interface RateParams {
|
|
5
|
+
/** Initial value. Named `value0`, NOT `value`: the runtime merges params onto
|
|
6
|
+
* state each frame, so an evolving field sharing a param name would be reset
|
|
7
|
+
* every step (the `value` would never relax). Keep them disjoint. */
|
|
8
|
+
value0?: number;
|
|
9
|
+
/** Asymptote the value relaxes toward. Default 0 (pure decay). */
|
|
10
|
+
target?: number;
|
|
11
|
+
/** Time constant τ (seconds) — the e-folding time. Default 1. */
|
|
12
|
+
tau?: number;
|
|
13
|
+
/** Stop after this many seconds (essentially relaxed) so it doesn't run forever. Default ∞. */
|
|
14
|
+
maxTime?: number;
|
|
15
|
+
/** Trace ring-buffer capacity. Default 240. */
|
|
16
|
+
trace?: number;
|
|
17
|
+
}
|
|
18
|
+
interface RateState {
|
|
19
|
+
value: number;
|
|
20
|
+
target: number;
|
|
21
|
+
tau: number;
|
|
22
|
+
/** Seconds elapsed on this core's timeline. */
|
|
23
|
+
tSec: number;
|
|
24
|
+
/** Rolling history of `value`, oldest-first — for the curve. */
|
|
25
|
+
samples: number[];
|
|
26
|
+
cap: number;
|
|
27
|
+
maxTime: number;
|
|
28
|
+
/** True once tSec ≥ maxTime — the runtime stops the loop. */
|
|
29
|
+
done: boolean;
|
|
30
|
+
}
|
|
31
|
+
declare const RateCore: SimCore<RateState, RateParams>;
|
|
32
|
+
/** τ from a half-life (and back) — t½ = τ·ln2. */
|
|
33
|
+
declare const halfLifeToTau: (halfLife: number) => number;
|
|
34
|
+
declare const tauToHalfLife: (tau: number) => number;
|
|
35
|
+
//#endregion
|
|
36
|
+
export { RateCore, RateParams, RateState, halfLifeToTau, tauToHalfLife };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
//#region src/sim/rate.ts
|
|
2
|
+
const RateCore = {
|
|
3
|
+
reset(params = {}) {
|
|
4
|
+
const value = params.value0 ?? 1;
|
|
5
|
+
return {
|
|
6
|
+
value,
|
|
7
|
+
target: params.target ?? 0,
|
|
8
|
+
tau: Math.max(.001, params.tau ?? 1),
|
|
9
|
+
tSec: 0,
|
|
10
|
+
samples: [value],
|
|
11
|
+
cap: Math.max(2, Math.round(params.trace ?? 240)),
|
|
12
|
+
maxTime: params.maxTime ?? Infinity,
|
|
13
|
+
done: false
|
|
14
|
+
};
|
|
15
|
+
},
|
|
16
|
+
step(state, dtSec) {
|
|
17
|
+
if (state.tSec >= state.maxTime) return state.done ? state : {
|
|
18
|
+
...state,
|
|
19
|
+
done: true
|
|
20
|
+
};
|
|
21
|
+
const dt = Math.max(0, dtSec);
|
|
22
|
+
const value = state.target + (state.value - state.target) * Math.exp(-dt / state.tau);
|
|
23
|
+
const tSec = state.tSec + dt;
|
|
24
|
+
const samples = state.samples.length >= state.cap ? [...state.samples.slice(state.samples.length - state.cap + 1), value] : [...state.samples, value];
|
|
25
|
+
return {
|
|
26
|
+
...state,
|
|
27
|
+
value,
|
|
28
|
+
tSec,
|
|
29
|
+
samples,
|
|
30
|
+
done: tSec >= state.maxTime
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
/** τ from a half-life (and back) — t½ = τ·ln2. */
|
|
35
|
+
const halfLifeToTau = (halfLife) => halfLife / Math.LN2;
|
|
36
|
+
const tauToHalfLife = (tau) => tau * Math.LN2;
|
|
37
|
+
|
|
38
|
+
//#endregion
|
|
39
|
+
export { RateCore, halfLifeToTau, tauToHalfLife };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { SimCore } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/sim/registry.d.ts
|
|
4
|
+
/** A core with its specifics erased — the runtime reads state fields by name. */
|
|
5
|
+
type AnySim = SimCore<Record<string, unknown>, Record<string, unknown>>;
|
|
6
|
+
declare function registerSim<S extends Record<string, unknown>, P>(name: string, core: SimCore<S, P>): void;
|
|
7
|
+
declare function getSim(name: string): AnySim | undefined;
|
|
8
|
+
declare function listSims(): string[];
|
|
9
|
+
//#endregion
|
|
10
|
+
export { getSim, listSims, registerSim };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { WaveCore } from "./wave.mjs";
|
|
2
|
+
import { SamplerCore } from "./sampler.mjs";
|
|
3
|
+
import { RateCore } from "./rate.mjs";
|
|
4
|
+
import { ParticlesCore } from "./particles.mjs";
|
|
5
|
+
import { ThermalCore } from "./thermal.mjs";
|
|
6
|
+
import { EquilibriumCore } from "./equilibrium.mjs";
|
|
7
|
+
|
|
8
|
+
//#region src/sim/registry.ts
|
|
9
|
+
const SIMS = /* @__PURE__ */ new Map();
|
|
10
|
+
SIMS.set("wave", WaveCore);
|
|
11
|
+
SIMS.set("sampler", SamplerCore);
|
|
12
|
+
SIMS.set("rate", RateCore);
|
|
13
|
+
SIMS.set("particles", ParticlesCore);
|
|
14
|
+
SIMS.set("thermal", ThermalCore);
|
|
15
|
+
SIMS.set("equilibrium", EquilibriumCore);
|
|
16
|
+
function registerSim(name, core) {
|
|
17
|
+
SIMS.set(name, core);
|
|
18
|
+
}
|
|
19
|
+
function getSim(name) {
|
|
20
|
+
return SIMS.get(name);
|
|
21
|
+
}
|
|
22
|
+
function listSims() {
|
|
23
|
+
return [...SIMS.keys()];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
export { getSim, listSims, registerSim };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { SimCore } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/sim/sampler.d.ts
|
|
4
|
+
interface SamplerParams {
|
|
5
|
+
/** Relative weights per outcome. Default [1, 1] (a fair coin). */
|
|
6
|
+
weights?: number[];
|
|
7
|
+
/** Draws per step. Default 1 (raise to converge faster). */
|
|
8
|
+
perStep?: number;
|
|
9
|
+
/** PRNG seed. Default 1. */
|
|
10
|
+
seed?: number;
|
|
11
|
+
/** Stop drawing once `n` reaches this — a converged sim shouldn't run forever. Default ∞. */
|
|
12
|
+
maxDraws?: number;
|
|
13
|
+
/** History ring-buffer capacity for the headline estimate p[0]. Default 240. */
|
|
14
|
+
trace?: number;
|
|
15
|
+
}
|
|
16
|
+
interface SamplerState {
|
|
17
|
+
weights: number[];
|
|
18
|
+
perStep: number;
|
|
19
|
+
/**
|
|
20
|
+
* Current PRNG state (advances every draw). Deliberately NOT named `seed`: the
|
|
21
|
+
* Scene runtime merges the SimDecl `params` (which carries the initial `seed`)
|
|
22
|
+
* onto the state each frame, so an evolving field sharing a param name would be
|
|
23
|
+
* clobbered back to its initial value — freezing the RNG. Keep evolving state
|
|
24
|
+
* fields disjoint from param names.
|
|
25
|
+
*/
|
|
26
|
+
rng: number;
|
|
27
|
+
/** Total draws so far. */
|
|
28
|
+
n: number;
|
|
29
|
+
/** Per-outcome counts. */
|
|
30
|
+
counts: number[];
|
|
31
|
+
/** Running frequency estimate per outcome (counts / n). */
|
|
32
|
+
p: number[];
|
|
33
|
+
/** True (normalized) probabilities — for convergence comparison. */
|
|
34
|
+
p0: number[];
|
|
35
|
+
/** Index of the most recent draw (-1 before the first). */
|
|
36
|
+
last: number;
|
|
37
|
+
/** Rolling history of p[0], oldest-first — for an LLN convergence plot. */
|
|
38
|
+
samples: number[];
|
|
39
|
+
cap: number;
|
|
40
|
+
/** Draw cap; step() becomes a no-op once n reaches it. */
|
|
41
|
+
maxDraws: number;
|
|
42
|
+
/** True once n ≥ maxDraws — the runtime stops the loop. */
|
|
43
|
+
done: boolean;
|
|
44
|
+
}
|
|
45
|
+
declare const SamplerCore: SimCore<SamplerState, SamplerParams>;
|
|
46
|
+
//#endregion
|
|
47
|
+
export { SamplerCore, SamplerParams, SamplerState };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//#region src/sim/sampler.ts
|
|
2
|
+
/** Pure mulberry32 step: returns a uniform r∈[0,1) and the next PRNG state. */
|
|
3
|
+
function rng(a) {
|
|
4
|
+
a = a + 1831565813 | 0;
|
|
5
|
+
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
6
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
7
|
+
return {
|
|
8
|
+
r: ((t ^ t >>> 14) >>> 0) / 4294967296,
|
|
9
|
+
a
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function pick(weights, total, r) {
|
|
13
|
+
let x = r * total;
|
|
14
|
+
for (let i = 0; i < weights.length - 1; i++) {
|
|
15
|
+
const w = weights[i] ?? 0;
|
|
16
|
+
if (x < w) return i;
|
|
17
|
+
x -= w;
|
|
18
|
+
}
|
|
19
|
+
return weights.length - 1;
|
|
20
|
+
}
|
|
21
|
+
function normalize(weights) {
|
|
22
|
+
const total = weights.reduce((a, b) => a + b, 0) || 1;
|
|
23
|
+
return weights.map((w) => w / total);
|
|
24
|
+
}
|
|
25
|
+
const SamplerCore = {
|
|
26
|
+
reset(params = {}) {
|
|
27
|
+
const weights = params.weights && params.weights.length >= 2 ? params.weights.slice() : [1, 1];
|
|
28
|
+
const k = weights.length;
|
|
29
|
+
return {
|
|
30
|
+
weights,
|
|
31
|
+
perStep: Math.max(1, Math.round(params.perStep ?? 1)),
|
|
32
|
+
rng: (params.seed ?? 1) | 0,
|
|
33
|
+
n: 0,
|
|
34
|
+
counts: new Array(k).fill(0),
|
|
35
|
+
p: new Array(k).fill(0),
|
|
36
|
+
p0: normalize(weights),
|
|
37
|
+
last: -1,
|
|
38
|
+
samples: [0],
|
|
39
|
+
cap: Math.max(2, Math.round(params.trace ?? 240)),
|
|
40
|
+
maxDraws: params.maxDraws ?? Infinity,
|
|
41
|
+
done: false
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
step(state) {
|
|
45
|
+
if (state.n >= state.maxDraws) return state.done ? state : {
|
|
46
|
+
...state,
|
|
47
|
+
done: true
|
|
48
|
+
};
|
|
49
|
+
state.weights.length;
|
|
50
|
+
const total = state.weights.reduce((a, b) => a + b, 0) || 1;
|
|
51
|
+
const counts = state.counts.slice();
|
|
52
|
+
let a = state.rng;
|
|
53
|
+
let last = state.last;
|
|
54
|
+
for (let d = 0; d < state.perStep; d++) {
|
|
55
|
+
const next = rng(a);
|
|
56
|
+
a = next.a;
|
|
57
|
+
last = pick(state.weights, total, next.r);
|
|
58
|
+
counts[last] = (counts[last] ?? 0) + 1;
|
|
59
|
+
}
|
|
60
|
+
const n = state.n + state.perStep;
|
|
61
|
+
const p = counts.map((c) => c / n);
|
|
62
|
+
const head = p[0] ?? 0;
|
|
63
|
+
const samples = state.samples.length >= state.cap ? [...state.samples.slice(state.samples.length - state.cap + 1), head] : [...state.samples, head];
|
|
64
|
+
return {
|
|
65
|
+
...state,
|
|
66
|
+
rng: a,
|
|
67
|
+
n,
|
|
68
|
+
counts,
|
|
69
|
+
p,
|
|
70
|
+
p0: normalize(state.weights),
|
|
71
|
+
last,
|
|
72
|
+
samples,
|
|
73
|
+
done: n >= state.maxDraws
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
//#endregion
|
|
79
|
+
export { SamplerCore };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { SimCore } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/sim/thermal.d.ts
|
|
4
|
+
/** A material: how much heat each gram needs to warm 1 °C in each phase, the
|
|
5
|
+
* latent heats of its two transitions, and where those transitions sit. */
|
|
6
|
+
interface Substance {
|
|
7
|
+
name: string;
|
|
8
|
+
solidName: string;
|
|
9
|
+
liquidName: string;
|
|
10
|
+
gasName: string;
|
|
11
|
+
/** Specific heat capacities, J/(g·°C), one per phase. */
|
|
12
|
+
cSolid: number;
|
|
13
|
+
cLiquid: number;
|
|
14
|
+
cGas: number;
|
|
15
|
+
/** Latent heat of fusion (melting) and vaporisation (boiling), J/g. */
|
|
16
|
+
lFusion: number;
|
|
17
|
+
lVapor: number;
|
|
18
|
+
/** Transition temperatures, °C. */
|
|
19
|
+
tMelt: number;
|
|
20
|
+
tBoil: number;
|
|
21
|
+
/** Liquid colour token (for the skin). */
|
|
22
|
+
color: string;
|
|
23
|
+
}
|
|
24
|
+
/** Real water — the canonical example (note: latent vaporisation ≫ everything,
|
|
25
|
+
* so the boiling plateau dwarfs the rest, which is exactly the lesson). */
|
|
26
|
+
declare const WATER: Substance;
|
|
27
|
+
/** Ethanol — lower boiling point, smaller latent heats; a contrasting curve. */
|
|
28
|
+
declare const ETHANOL: Substance;
|
|
29
|
+
type Phase = 'solid' | 'melting' | 'liquid' | 'boiling' | 'gas';
|
|
30
|
+
/** One run of the energy ladder. `kind:'heat'` is a sloped temperature rise
|
|
31
|
+
* (q = m·c·Δθ); `kind:'change'` is a flat phase transition (q = m·L). */
|
|
32
|
+
interface ThermalSeg {
|
|
33
|
+
phase: Phase;
|
|
34
|
+
kind: 'heat' | 'change';
|
|
35
|
+
which: 'melt' | 'boil' | null;
|
|
36
|
+
/** Heat this segment absorbs, J. */
|
|
37
|
+
q: number;
|
|
38
|
+
/** Cumulative heat at the START of this segment, J (x-position on the curve). */
|
|
39
|
+
qStart: number;
|
|
40
|
+
/** Temperature at the start / end of the segment, °C (equal for a change). */
|
|
41
|
+
t0: number;
|
|
42
|
+
t1: number;
|
|
43
|
+
}
|
|
44
|
+
interface ThermalParams {
|
|
45
|
+
substance?: Substance;
|
|
46
|
+
/** Mass, grams. Default 50. */
|
|
47
|
+
mass?: number;
|
|
48
|
+
/** Heating power, J/s. Negative cools. Default 100. */
|
|
49
|
+
powerW?: number;
|
|
50
|
+
/** Starting temperature, °C. Default −20 (cold ice). */
|
|
51
|
+
tStart?: number;
|
|
52
|
+
/** Top of the curve / highest temperature shown, °C. Default tBoil + 40. */
|
|
53
|
+
tMax?: number;
|
|
54
|
+
}
|
|
55
|
+
interface ThermalState {
|
|
56
|
+
sub: Substance;
|
|
57
|
+
mass: number;
|
|
58
|
+
powerW: number;
|
|
59
|
+
tStart: number;
|
|
60
|
+
tMax: number;
|
|
61
|
+
/** The full energy ladder — the heating curve, as data. */
|
|
62
|
+
segs: ThermalSeg[];
|
|
63
|
+
/** Total heat to walk the whole ladder, J. */
|
|
64
|
+
totalJ: number;
|
|
65
|
+
energyJ: number;
|
|
66
|
+
tempC: number;
|
|
67
|
+
phase: Phase;
|
|
68
|
+
/** Completion of the melting / boiling transitions, 0..1. */
|
|
69
|
+
fracMelt: number;
|
|
70
|
+
fracBoil: number;
|
|
71
|
+
tSec: number;
|
|
72
|
+
done: boolean;
|
|
73
|
+
}
|
|
74
|
+
declare const ThermalCore: SimCore<ThermalState, ThermalParams>;
|
|
75
|
+
/** Human label for a segment ("warming ice", "melting", "boiling"…). */
|
|
76
|
+
declare function segLabel(s: ThermalSeg, sub: Substance): string;
|
|
77
|
+
//#endregion
|
|
78
|
+
export { ETHANOL, Phase, Substance, ThermalCore, ThermalParams, ThermalSeg, ThermalState, WATER, segLabel };
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
//#region src/sim/thermal.ts
|
|
2
|
+
const clamp01 = (v) => Math.max(0, Math.min(1, v));
|
|
3
|
+
/** Real water — the canonical example (note: latent vaporisation ≫ everything,
|
|
4
|
+
* so the boiling plateau dwarfs the rest, which is exactly the lesson). */
|
|
5
|
+
const WATER = {
|
|
6
|
+
name: "Water",
|
|
7
|
+
solidName: "ice",
|
|
8
|
+
liquidName: "water",
|
|
9
|
+
gasName: "steam",
|
|
10
|
+
cSolid: 2.09,
|
|
11
|
+
cLiquid: 4.18,
|
|
12
|
+
cGas: 2.01,
|
|
13
|
+
lFusion: 334,
|
|
14
|
+
lVapor: 2260,
|
|
15
|
+
tMelt: 0,
|
|
16
|
+
tBoil: 100,
|
|
17
|
+
color: "var(--stage-accent, #3b82f6)"
|
|
18
|
+
};
|
|
19
|
+
/** Ethanol — lower boiling point, smaller latent heats; a contrasting curve. */
|
|
20
|
+
const ETHANOL = {
|
|
21
|
+
name: "Ethanol",
|
|
22
|
+
solidName: "solid",
|
|
23
|
+
liquidName: "ethanol",
|
|
24
|
+
gasName: "vapour",
|
|
25
|
+
cSolid: 1.2,
|
|
26
|
+
cLiquid: 2.44,
|
|
27
|
+
cGas: 1.42,
|
|
28
|
+
lFusion: 108,
|
|
29
|
+
lVapor: 855,
|
|
30
|
+
tMelt: -114,
|
|
31
|
+
tBoil: 78,
|
|
32
|
+
color: "var(--stage-accent-2, #8b5cf6)"
|
|
33
|
+
};
|
|
34
|
+
/** Build the energy ladder from `tStart` up to `tMax`, including only the phases
|
|
35
|
+
* reachable heating upward (a liquid start skips the solid+melting runs). */
|
|
36
|
+
function buildSegs(sub, mass, tStart, tMax) {
|
|
37
|
+
const segs = [];
|
|
38
|
+
let cum = 0;
|
|
39
|
+
const push = (s) => {
|
|
40
|
+
segs.push({
|
|
41
|
+
...s,
|
|
42
|
+
qStart: cum
|
|
43
|
+
});
|
|
44
|
+
cum += Math.max(0, s.q);
|
|
45
|
+
};
|
|
46
|
+
let t = tStart;
|
|
47
|
+
if (t < sub.tMelt) {
|
|
48
|
+
push({
|
|
49
|
+
phase: "solid",
|
|
50
|
+
kind: "heat",
|
|
51
|
+
which: null,
|
|
52
|
+
q: mass * sub.cSolid * (sub.tMelt - t),
|
|
53
|
+
t0: t,
|
|
54
|
+
t1: sub.tMelt
|
|
55
|
+
});
|
|
56
|
+
push({
|
|
57
|
+
phase: "melting",
|
|
58
|
+
kind: "change",
|
|
59
|
+
which: "melt",
|
|
60
|
+
q: mass * sub.lFusion,
|
|
61
|
+
t0: sub.tMelt,
|
|
62
|
+
t1: sub.tMelt
|
|
63
|
+
});
|
|
64
|
+
t = sub.tMelt;
|
|
65
|
+
}
|
|
66
|
+
if (t < sub.tBoil) {
|
|
67
|
+
push({
|
|
68
|
+
phase: "liquid",
|
|
69
|
+
kind: "heat",
|
|
70
|
+
which: null,
|
|
71
|
+
q: mass * sub.cLiquid * (sub.tBoil - t),
|
|
72
|
+
t0: t,
|
|
73
|
+
t1: sub.tBoil
|
|
74
|
+
});
|
|
75
|
+
push({
|
|
76
|
+
phase: "boiling",
|
|
77
|
+
kind: "change",
|
|
78
|
+
which: "boil",
|
|
79
|
+
q: mass * sub.lVapor,
|
|
80
|
+
t0: sub.tBoil,
|
|
81
|
+
t1: sub.tBoil
|
|
82
|
+
});
|
|
83
|
+
t = sub.tBoil;
|
|
84
|
+
}
|
|
85
|
+
push({
|
|
86
|
+
phase: "gas",
|
|
87
|
+
kind: "heat",
|
|
88
|
+
which: null,
|
|
89
|
+
q: Math.max(0, mass * sub.cGas * (tMax - t)),
|
|
90
|
+
t0: t,
|
|
91
|
+
t1: tMax
|
|
92
|
+
});
|
|
93
|
+
return segs;
|
|
94
|
+
}
|
|
95
|
+
/** Temperature / phase / transition-fractions at a given heat-added (pure). */
|
|
96
|
+
function derive(segs, totalJ, energyRaw) {
|
|
97
|
+
const e = Math.max(0, Math.min(totalJ, energyRaw));
|
|
98
|
+
let fracMelt = 0, fracBoil = 0;
|
|
99
|
+
for (const s of segs) {
|
|
100
|
+
if (s.which === "melt") fracMelt = s.q > 0 ? clamp01((e - s.qStart) / s.q) : 1;
|
|
101
|
+
if (s.which === "boil") fracBoil = s.q > 0 ? clamp01((e - s.qStart) / s.q) : 1;
|
|
102
|
+
}
|
|
103
|
+
let tempC = segs[0].t0, phase = segs[0].phase;
|
|
104
|
+
for (let i = 0; i < segs.length; i++) {
|
|
105
|
+
const s = segs[i];
|
|
106
|
+
const last = i === segs.length - 1;
|
|
107
|
+
if (e <= s.qStart + s.q || last) {
|
|
108
|
+
const f = s.q > 0 ? clamp01((e - s.qStart) / s.q) : 1;
|
|
109
|
+
tempC = s.kind === "change" ? s.t0 : s.t0 + f * (s.t1 - s.t0);
|
|
110
|
+
phase = s.phase;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
tempC,
|
|
116
|
+
phase,
|
|
117
|
+
fracMelt,
|
|
118
|
+
fracBoil
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const ThermalCore = {
|
|
122
|
+
reset(params = {}) {
|
|
123
|
+
const sub = params.substance ?? WATER;
|
|
124
|
+
const mass = Math.max(.001, params.mass ?? 50);
|
|
125
|
+
const tStart = params.tStart ?? -20;
|
|
126
|
+
const tMax = params.tMax ?? sub.tBoil + 40;
|
|
127
|
+
const segs = buildSegs(sub, mass, tStart, tMax);
|
|
128
|
+
const totalJ = segs.reduce((a, s) => a + s.q, 0);
|
|
129
|
+
const d = derive(segs, totalJ, 0);
|
|
130
|
+
return {
|
|
131
|
+
sub,
|
|
132
|
+
mass,
|
|
133
|
+
powerW: params.powerW ?? 100,
|
|
134
|
+
tStart,
|
|
135
|
+
tMax,
|
|
136
|
+
segs,
|
|
137
|
+
totalJ,
|
|
138
|
+
energyJ: 0,
|
|
139
|
+
tempC: d.tempC,
|
|
140
|
+
phase: d.phase,
|
|
141
|
+
fracMelt: 0,
|
|
142
|
+
fracBoil: 0,
|
|
143
|
+
tSec: 0,
|
|
144
|
+
done: false
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
step(state, dtSec) {
|
|
148
|
+
const dt = Math.max(0, dtSec);
|
|
149
|
+
const energyJ = Math.max(0, Math.min(state.totalJ, state.energyJ + state.powerW * dt));
|
|
150
|
+
const d = derive(state.segs, state.totalJ, energyJ);
|
|
151
|
+
const done = state.powerW >= 0 && energyJ >= state.totalJ || state.powerW < 0 && energyJ <= 0;
|
|
152
|
+
return {
|
|
153
|
+
...state,
|
|
154
|
+
energyJ,
|
|
155
|
+
tempC: d.tempC,
|
|
156
|
+
phase: d.phase,
|
|
157
|
+
fracMelt: d.fracMelt,
|
|
158
|
+
fracBoil: d.fracBoil,
|
|
159
|
+
tSec: state.tSec + dt,
|
|
160
|
+
done
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
/** Human label for a segment ("warming ice", "melting", "boiling"…). */
|
|
165
|
+
function segLabel(s, sub) {
|
|
166
|
+
if (s.which === "melt") return `melting ${sub.solidName}`;
|
|
167
|
+
if (s.which === "boil") return `boiling ${sub.liquidName}`;
|
|
168
|
+
return `warming ${s.phase === "solid" ? sub.solidName : s.phase === "gas" ? sub.gasName : sub.liquidName}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
//#endregion
|
|
172
|
+
export { ETHANOL, ThermalCore, WATER, segLabel };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/sim/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Sim cores — the "physics engine" half of a lab: pure, time-stepped simulators
|
|
4
|
+
* with NO React and NO pixels. A core is `reset(params) → state`, then
|
|
5
|
+
* `step(state, dt) → state` once per frame. Because a core is a pure function of
|
|
6
|
+
* (params, elapsed time), a lab built on one is deterministic: it replays
|
|
7
|
+
* identically under a Remotion fixed-frame clock (→ video) and is unit-testable
|
|
8
|
+
* with no DOM. Rendering (glyphs) READS the returned state; drawing never lives
|
|
9
|
+
* in here. Live controls write their target fields onto `state` between steps —
|
|
10
|
+
* so a core's mutable inputs (e.g. `volts`, `mode`) live IN its state, which is
|
|
11
|
+
* also why those fields are readable by name from a binding.
|
|
12
|
+
*/
|
|
13
|
+
interface SimCore<S, P> {
|
|
14
|
+
/** Build initial state from params. Pure. */
|
|
15
|
+
reset(params: P): S;
|
|
16
|
+
/**
|
|
17
|
+
* Advance `state` by `dtSec` and return the NEXT state. Treat the incoming
|
|
18
|
+
* `state` as immutable (return a fresh object) so the runtime can keep the
|
|
19
|
+
* previous snapshot for change detection.
|
|
20
|
+
*/
|
|
21
|
+
step(state: S, dtSec: number): S;
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { SimCore };
|