@fundamental-engine/core 0.4.0 → 0.5.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 (62) hide show
  1. package/README.md +2 -2
  2. package/dist/agents/event-agent.js +1 -1
  3. package/dist/agents/event-agent.js.map +1 -1
  4. package/dist/config/manual.d.ts +1 -1
  5. package/dist/config/manual.d.ts.map +1 -1
  6. package/dist/conformance/expectations.d.ts.map +1 -1
  7. package/dist/conformance/expectations.js +3 -1
  8. package/dist/conformance/expectations.js.map +1 -1
  9. package/dist/contracts/guards.js +1 -1
  10. package/dist/contracts/guards.js.map +1 -1
  11. package/dist/contracts/index.d.ts +1 -1
  12. package/dist/contracts/types.d.ts +1 -1
  13. package/dist/contracts/types.js +1 -1
  14. package/dist/core/events.d.ts +15 -0
  15. package/dist/core/events.d.ts.map +1 -1
  16. package/dist/core/events.js.map +1 -1
  17. package/dist/core/feedback-sink.d.ts +16 -3
  18. package/dist/core/feedback-sink.d.ts.map +1 -1
  19. package/dist/core/feedback-sink.js +11 -0
  20. package/dist/core/feedback-sink.js.map +1 -1
  21. package/dist/core/field.d.ts.map +1 -1
  22. package/dist/core/field.js +138 -34
  23. package/dist/core/field.js.map +1 -1
  24. package/dist/core/flow.d.ts +6 -0
  25. package/dist/core/flow.d.ts.map +1 -1
  26. package/dist/core/flow.js +16 -3
  27. package/dist/core/flow.js.map +1 -1
  28. package/dist/core/heatmap.d.ts +6 -1
  29. package/dist/core/heatmap.d.ts.map +1 -1
  30. package/dist/core/heatmap.js +10 -0
  31. package/dist/core/heatmap.js.map +1 -1
  32. package/dist/core/integrator.d.ts.map +1 -1
  33. package/dist/core/integrator.js +83 -29
  34. package/dist/core/integrator.js.map +1 -1
  35. package/dist/core/math.d.ts +4 -0
  36. package/dist/core/math.d.ts.map +1 -1
  37. package/dist/core/math.js +10 -1
  38. package/dist/core/math.js.map +1 -1
  39. package/dist/core/scalar-grid.d.ts +3 -0
  40. package/dist/core/scalar-grid.d.ts.map +1 -1
  41. package/dist/core/scalar-grid.js +9 -0
  42. package/dist/core/scalar-grid.js.map +1 -1
  43. package/dist/core/scanner.d.ts +1 -2
  44. package/dist/core/scanner.d.ts.map +1 -1
  45. package/dist/core/scanner.js +58 -1
  46. package/dist/core/scanner.js.map +1 -1
  47. package/dist/core/temporal.d.ts +1 -1
  48. package/dist/core/temporal.js +1 -1
  49. package/dist/core/types.d.ts +127 -0
  50. package/dist/core/types.d.ts.map +1 -1
  51. package/dist/forces/extended.d.ts.map +1 -1
  52. package/dist/forces/extended.js +6 -1
  53. package/dist/forces/extended.js.map +1 -1
  54. package/dist/index.d.ts +2 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +4 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/inspect/report.js +2 -2
  59. package/dist/inspect/report.js.map +1 -1
  60. package/dist/recipes/catalog.js +1 -1
  61. package/dist/recipes/catalog.js.map +1 -1
  62. package/package.json +1 -1
@@ -24,7 +24,7 @@ import { buildWaves, buildBound, waveYat, } from "./currents.js";
24
24
  import { healWaves, tearBoundNear, tearBoundByForces, induceCharges } from "./reservoir.js";
25
25
  import { FORMATION_BY, PALETTE } from "../config/forces.config.js";
26
26
  import { resolvePalette } from "../config/palettes.js";
27
- import { clamp, hexToRgb, particleRGB, rgbToHex, sampleStops } from "./math.js";
27
+ import { clamp, hexToRgb, particleRGBInto, rgbToHex, sampleStops } from "./math.js";
28
28
  import { feedbackTarget, feedbackWeight } from "./feedback.js";
29
29
  import { defaultFeedbackSink } from "./feedback-sink.js";
30
30
  import { thermoMetrics } from "./thermo.js";
@@ -44,10 +44,16 @@ import { canvas2dBackend } from "./render-backend.js";
44
44
  import { forceAt, netField } from "./streamlines.js";
45
45
  import { traceFieldLines } from "./fieldlines.js";
46
46
  import { fieldLineSeeds } from "./fieldline-seeds.js";
47
- import { flowBias, makeFlowFocus } from "./flow.js";
47
+ import { flowBiasInto, makeFlowFocus } from "./flow.js";
48
48
  import { energyReport } from "../diagnostics/energy.js";
49
49
  // the Currents' cool baseline palette — a subset of the force palette (§24.4).
50
50
  const WAVE_RGB = ['#4da3ff', '#2dd4bf', '#a78bfa'].map(hexToRgb);
51
+ // Shared draw/integrate scratch — reused across the per-particle and per-cell hot loops so an
52
+ // active flow focus and the particle draw don't allocate a `{x,y}` / `[r,g,b]` each iteration.
53
+ // Safe to share module-wide: each field's frame runs synchronously, and every read consumes the
54
+ // scratch before the next write (no overlapping lifetimes, no cross-instance interleaving).
55
+ const _flowB = { x: 0, y: 0 };
56
+ const _rgb = [0, 0, 0];
51
57
  export function createField(canvas, opts = {}) {
52
58
  // Signals-only mode (`render: 'none'`, §13.7 / #297): the full simulation + feedback pipeline
53
59
  // runs, but the engine never acquires a 2d context, never sizes a canvas backing store (it stays
@@ -59,7 +65,7 @@ export function createField(canvas, opts = {}) {
59
65
  if ((opts.render ?? 'dots') !== 'none') {
60
66
  ctx = canvas.getContext('2d');
61
67
  if (!ctx)
62
- throw new Error('field-ui: 2D canvas context unavailable');
68
+ throw new Error('Fundamental: 2D canvas context unavailable');
63
69
  }
64
70
  // Field Surfaces: the optional OVERLAY surface, drawn in front of page content. Core only draws to
65
71
  // it (the caller owns the element + its fixed/pointer-events placement); its backing store is sized
@@ -72,8 +78,21 @@ export function createField(canvas, opts = {}) {
72
78
  // overlay's own 2d context.
73
79
  let overlayBackend = opts.overlayBackend ?? (overlayCanvas && overlayCtx ? canvas2dBackend(overlayCanvas, overlayCtx) : null);
74
80
  const store = new FieldStore();
81
+ let nextParticleId = 1; // monotonic stable particle identity (readParticleIds); never reused
75
82
  const grids = new Map(); // §20.1 class [C] field buffers, lazy
76
83
  const reg = createRegistry();
84
+ // host-agnostic discrete event bus (the read side): plain-data push for occurrences a non-DOM
85
+ // host reacts to (a sink caught/released matter, the swarm settled) — no DOM, no polling. Cheap
86
+ // when unused: detection passes guard on `busHas(type)` so zero listeners cost nothing.
87
+ const busListeners = new Map();
88
+ const busHas = (type) => (busListeners.get(type)?.size ?? 0) > 0;
89
+ function busEmit(type, payload) {
90
+ const set = busListeners.get(type);
91
+ if (set)
92
+ for (const cb of set)
93
+ cb(payload);
94
+ }
95
+ const sinkPeak = new WeakMap(); // matter held at the rising edge, for the release count
77
96
  registerCoreForces(reg); // the canonical nine (§6)
78
97
  registerNaturalForces(reg); // natural primitives: gravity + charge (§20.10), opt-in
79
98
  registerExtendedForces(reg); // designed extended forces: lens, … (§20.3), opt-in
@@ -81,7 +100,7 @@ export function createField(canvas, opts = {}) {
81
100
  // In the browser, pass `browserHost()` from @fundamental-engine/platform (or use createBrowserField); the
82
101
  // @fundamental-engine/{elements,react,vanilla} entry points wire it for you.
83
102
  if (!opts.host) {
84
- throw new Error('field-ui: createField requires opts.host. Use @fundamental-engine/vanilla (createField/mountField) or ' +
103
+ throw new Error('Fundamental: createField requires opts.host. Use @fundamental-engine/vanilla (createField/mountField) or ' +
85
104
  '@fundamental-engine/elements / @fundamental-engine/react, or pass browserHost() from @fundamental-engine/platform.');
86
105
  }
87
106
  const host = opts.host;
@@ -132,6 +151,7 @@ export function createField(canvas, opts = {}) {
132
151
  const rng = opts.rng ?? Math.random;
133
152
  const wallNow = opts.now ?? (() => performance.now());
134
153
  let boot = reduceMotion ? 1 : 0;
154
+ let lastNow = NaN; // previous frame timestamp — drives the frame-rate-independent dt (#434)
135
155
  let mball = null; // scratch density grid for the metaballs render mode
136
156
  let vor = null; // scratch owner grid for the voronoi render mode
137
157
  // EMA (exponential moving average) of the per-frame peak magnitude for each arrow renderer.
@@ -251,6 +271,9 @@ export function createField(canvas, opts = {}) {
251
271
  if (b.el.dataset.fxCap === '1') {
252
272
  b.el.dataset.fxCap = '0';
253
273
  fireCaptureEvent(b.el, 'released', { accreted: 0, load: 0 });
274
+ if (busHas('release'))
275
+ busEmit('release', { body: b, count: released.length });
276
+ sinkPeak.delete(b);
254
277
  }
255
278
  },
256
279
  // class-[S] sources emit through here, capped by a hard pool ceiling (the
@@ -290,6 +313,7 @@ export function createField(canvas, opts = {}) {
290
313
  function newParticle(seed = {}) {
291
314
  const size = seed.size ?? 0.7 + rng() * 1.8;
292
315
  return {
316
+ id: seed.id ?? nextParticleId++,
293
317
  x: seed.x ?? rng() * W,
294
318
  y: seed.y ?? rng() * H,
295
319
  vx: seed.vx ?? (rng() - 0.5) * 0.25,
@@ -307,6 +331,7 @@ export function createField(canvas, opts = {}) {
307
331
  cap: null,
308
332
  ...(seed.age != null ? { age: seed.age } : {}), // mortal matter (a [S] source)
309
333
  ...(seed.color != null ? { color: seed.color } : {}),
334
+ ...(seed.species != null ? { species: seed.species } : {}), // matter tagging (#444)
310
335
  };
311
336
  }
312
337
  // optional per-particle data records (FieldHandle.seed) — round-robined onto the base pool, with
@@ -519,10 +544,16 @@ export function createField(canvas, opts = {}) {
519
544
  if (edge.fire === 'captured') {
520
545
  b.el.dataset.fxCap = '1';
521
546
  fireCaptureEvent(b.el, 'captured', { accreted: b.accreted, load: sinkLoad(b) });
547
+ if (busHas('absorb'))
548
+ busEmit('absorb', { body: b, count: b.accreted });
549
+ sinkPeak.set(b, b.accreted);
522
550
  }
523
551
  else if (edge.fire === 'released') {
524
552
  b.el.dataset.fxCap = '0';
525
553
  fireCaptureEvent(b.el, 'released', { accreted: 0, load: 0 });
554
+ if (busHas('release'))
555
+ busEmit('release', { body: b, count: sinkPeak.get(b) ?? 0 });
556
+ sinkPeak.delete(b);
526
557
  }
527
558
  }
528
559
  }
@@ -647,6 +678,23 @@ export function createField(canvas, opts = {}) {
647
678
  // engagement: hover/focus a [data-hot] element → it activates (b.on, lighting
648
679
  // the spine + on-state forces) and overrides the accent with its data-color (§9).
649
680
  function bindEngagement() {
681
+ // Reconcile across rescans (mirrors the emitter prune above): a persistent field outlives the
682
+ // [data-hot] elements swapped under it (Astro nav, dynamic content), so drop engagements whose
683
+ // element has left the DOM — release their listeners + the strong ref the `engaged` array holds,
684
+ // so a long-lived field can't accumulate detached nodes. New elements are bound below; the
685
+ // `fxEngaged` guard keeps live ones from double-binding.
686
+ if (engaged.length) {
687
+ engaged = engaged.filter((e) => {
688
+ if (e.el.isConnected)
689
+ return true;
690
+ e.el.removeEventListener('pointerenter', e.enter);
691
+ e.el.removeEventListener('pointerleave', e.leave);
692
+ e.el.removeEventListener('focus', e.enter);
693
+ e.el.removeEventListener('blur', e.leave);
694
+ delete e.el.dataset.fxEngaged;
695
+ return false;
696
+ });
697
+ }
650
698
  host.root.querySelectorAll('[data-hot]').forEach((node) => {
651
699
  const el = node;
652
700
  if (el.dataset.fxEngaged === '1')
@@ -1008,11 +1056,11 @@ export function createField(canvas, opts = {}) {
1008
1056
  ctx.fillRect(0, 0, W, H);
1009
1057
  }
1010
1058
  drawWaves();
1011
- // The heatmap is a full-viewport bilinear-upscale glowthe heaviest per-frame layer. It's
1012
- // ambient density you read at rest, not detail you track mid-scroll, so suppress it while the
1013
- // page is scrolling fast (eased env.scrollV). Scrolling never pays the heatmap's fill cost; the
1014
- // glow returns the moment the page settles. (Body charge/glow is CSS --load, unaffected.)
1015
- if (heatmap && (env.scrollV ?? 0) < 6)
1059
+ // The heatmap is a continuous ambient layerNOT coupled to scroll. It draws every frame
1060
+ // whenever enabled. (An earlier scroll-suppression made it pop/fade out while scrolling, which
1061
+ // read as choppy; the perf intent is served instead by the compute throttle the texel grid is
1062
+ // recomputed only every 3rd frame so the per-frame cost is just the cached bilinear upscale.)
1063
+ if (heatmap)
1016
1064
  drawHeatmap();
1017
1065
  drawBound();
1018
1066
  // free particles — cool centre → warm edge, blended toward accent (§20.8).
@@ -1040,7 +1088,10 @@ export function createField(canvas, opts = {}) {
1040
1088
  const d = Math.min(1, Math.hypot(p.x - cx, p.y - cy) / maxD);
1041
1089
  const rs = d * d;
1042
1090
  const h = p.heat;
1043
- let [r, g, b] = particleRGB(rs, h, acc);
1091
+ particleRGBInto(_rgb, rs, h, acc);
1092
+ let r = _rgb[0];
1093
+ let g = _rgb[1];
1094
+ let b = _rgb[2];
1044
1095
  if (p.color) {
1045
1096
  // carried pigment (§20.8): a stained particle reads mostly as its own tint.
1046
1097
  const [pr, pg, pb] = hexToRgb(p.color);
@@ -1056,23 +1107,19 @@ export function createField(canvas, opts = {}) {
1056
1107
  const cr = r | 0;
1057
1108
  const cg = g | 0;
1058
1109
  const cb = b | 0;
1059
- // Soft additive glow, not a solid disc. Three concentric discs a wide faint aura, a mid
1060
- // body, a small bright core sum under the 'lighter' composite into a smooth radial falloff,
1061
- // so the particle reads as LIGHT rather than a hard filled circle. Cheap (a few small arcs,
1062
- // no per-particle gradient or shadowBlur the costs §20.8 deliberately avoids), and it keeps
1063
- // the per-particle tint. Hotter matter glows wider.
1064
- const col = `${cr},${cg},${cb}`;
1065
- ctx.fillStyle = `rgba(${col},${(0.05 + 0.08 * h) * boot * zk})`;
1110
+ // A single tight, faint bloom under the crisp core (additive, 'lighter' composite) just
1111
+ // enough to soften the point into a star. NOT the old wide, heat-scaled halo (`size+3+6*h`):
1112
+ // near an accretion sink every particle heats to h≈1, so that halo bloomed the whole cluster
1113
+ // into big overlapping rings (the repeatedly-flagged "glow" #434 follow-up). Heat now reads
1114
+ // only through the brighter, slightly larger core (the `+ h*2` size and `+ h*0.5` alpha above),
1115
+ // never a growing aura. Radius is fixed (size + ~1px), so points stay crisp at any heat.
1116
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.12 * alpha})`;
1066
1117
  ctx.beginPath();
1067
- ctx.arc(p.x, p.y, (size + 1) * (2 + h * 1.6), 0, 6.28318);
1118
+ ctx.arc(p.x, p.y, size + 1.2, 0, 6.28318);
1068
1119
  ctx.fill();
1069
- ctx.fillStyle = `rgba(${col},${0.16 * alpha})`;
1120
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${alpha})`;
1070
1121
  ctx.beginPath();
1071
- ctx.arc(p.x, p.y, size * 1.25 + 0.6, 0, 6.28318);
1072
- ctx.fill();
1073
- ctx.fillStyle = `rgba(${col},${0.55 * alpha})`;
1074
- ctx.beginPath();
1075
- ctx.arc(p.x, p.y, Math.max(0.4, size * 0.55), 0, 6.28318);
1122
+ ctx.arc(p.x, p.y, size, 0, 6.28318);
1076
1123
  ctx.fill();
1077
1124
  }
1078
1125
  drawSparks();
@@ -1212,7 +1259,7 @@ export function createField(canvas, opts = {}) {
1212
1259
  let { fx, fy } = forceAt(bodies, reg.forces, env, gx, gy);
1213
1260
  // a live flow focus bends the rendered field lines toward the target (field.flowTo).
1214
1261
  if (flow) {
1215
- const b = flowBias(gx, gy, flow, 0.04);
1262
+ const b = flowBiasInto(_flowB, gx, gy, flow, 0.04);
1216
1263
  fx += b.x;
1217
1264
  fy += b.y;
1218
1265
  }
@@ -1291,7 +1338,7 @@ export function createField(canvas, opts = {}) {
1291
1338
  for (let gy = GRID / 2; gy < H; gy += GRID) {
1292
1339
  let { fx, fy } = forceAt(bodies, reg.forces, env, gx, gy);
1293
1340
  if (flow) {
1294
- const b = flowBias(gx, gy, flow, 0.04);
1341
+ const b = flowBiasInto(_flowB, gx, gy, flow, 0.04);
1295
1342
  fx += b.x;
1296
1343
  fy += b.y;
1297
1344
  }
@@ -1495,7 +1542,7 @@ export function createField(canvas, opts = {}) {
1495
1542
  for (let i = 0; i < STEPS; i++) {
1496
1543
  let { fx, fy } = forceAt(bodies, reg.forces, env, x, y);
1497
1544
  if (flow) {
1498
- const b = flowBias(x, y, flow, 0.04);
1545
+ const b = flowBiasInto(_flowB, x, y, flow, 0.04);
1499
1546
  fx += b.x;
1500
1547
  fy += b.y;
1501
1548
  }
@@ -1561,7 +1608,17 @@ export function createField(canvas, opts = {}) {
1561
1608
  frameN++;
1562
1609
  env.t = (now - t0) / 1000;
1563
1610
  env.frameN = frameN;
1564
- env.dt = reduceMotion ? 0 : 1;
1611
+ // Frame-rate-independent timestep (#434): dt is the real frame interval normalized to a
1612
+ // 60fps baseline (≈1 at 60fps, ≈0.5 at 120fps, ≈2 at 30fps), clamped so a long stall
1613
+ // (tab switch, GC pause) can't teleport matter. Previously dt was a flat 1 regardless of
1614
+ // FPS, so when the perf work lifted the homepage to 60–120fps the same per-frame physics
1615
+ // ran 2–4× faster on screen. Position alone is dt-scaled (forces/friction are per-frame by
1616
+ // design, §applyForce) — that's enough to make displacement-per-second FPS-independent.
1617
+ // Still 0 under reduce-motion: the integrator and the `if (env.dt)` gates read it as the
1618
+ // "is the field animating" flag, so it must stay falsy when still and >0 when moving.
1619
+ const dtRaw = Number.isFinite(lastNow) ? (now - lastNow) / 16.6667 : 1;
1620
+ lastNow = now;
1621
+ env.dt = reduceMotion ? 0 : clamp(dtRaw, 0.2, 2);
1565
1622
  if (boot < 1)
1566
1623
  boot = Math.min(1, boot + 0.012);
1567
1624
  easeFormation(env.form, formTarget, 0.03); // glide between formations (§7)
@@ -1617,7 +1674,7 @@ export function createField(canvas, opts = {}) {
1617
1674
  for (const p of store.particles) {
1618
1675
  if (p.cap)
1619
1676
  continue;
1620
- const b = flowBias(p.x, p.y, flow, 0.6);
1677
+ const b = flowBiasInto(_flowB, p.x, p.y, flow, 0.6);
1621
1678
  p.vx += b.x;
1622
1679
  p.vy += b.y;
1623
1680
  }
@@ -1695,6 +1752,11 @@ export function createField(canvas, opts = {}) {
1695
1752
  setFormation('ambient');
1696
1753
  }
1697
1754
  }, 1200);
1755
+ // Never let this ambient idle-detection timer pin the host alive on its own: Node keeps the
1756
+ // process running while an un-unref'd interval is pending (browsers ignore unref). destroy()
1757
+ // still clears it for prompt teardown — this just guarantees a field that outlives its destroy()
1758
+ // (e.g. a headless test that forgets to tear down) can't hang process exit.
1759
+ idleTimer.unref?.();
1698
1760
  const onResize = () => resize();
1699
1761
  resize();
1700
1762
  // pause all work while the tab is backgrounded — stop the loop and the idle timer,
@@ -1764,7 +1826,7 @@ export function createField(canvas, opts = {}) {
1764
1826
  if (!ctx) {
1765
1827
  // context acquisition can genuinely fail (lost GPU process, too many contexts) —
1766
1828
  // warn and stay signals-only rather than crash the live simulation.
1767
- console.warn(`field-ui: setRender('${mode}') could not acquire a 2d context; staying in render 'none'`);
1829
+ console.warn(`Fundamental: setRender('${mode}') could not acquire a 2d context; staying in render 'none'`);
1768
1830
  return;
1769
1831
  }
1770
1832
  if (overlayCanvas && !overlayCtx) {
@@ -1877,23 +1939,65 @@ export function createField(canvas, opts = {}) {
1877
1939
  particleCount: () => store.size,
1878
1940
  readParticles: (out) => {
1879
1941
  const ps = store.particles;
1880
- const n = Math.min(store.size, Math.floor(out.length / 5)); // stride 5
1881
- for (let i = 0; i < n; i++) {
1942
+ const capN = Math.floor(out.length / 5); // stride 5
1943
+ let w = 0;
1944
+ for (let i = 0; i < ps.length && w < capN; i++) {
1882
1945
  const p = ps[i];
1883
- const o = i * 5;
1946
+ if (p.report !== undefined)
1947
+ continue; // agents draw as their own object, not a swarm dot
1948
+ const o = w * 5;
1884
1949
  out[o] = p.x;
1885
1950
  out[o + 1] = p.y;
1886
1951
  out[o + 2] = p.z ?? 0; // optional z lane (z-axis.md); 0 in a flat field
1887
1952
  out[o + 3] = p.heat;
1888
1953
  out[o + 4] = p.size;
1954
+ w++;
1955
+ }
1956
+ return w;
1957
+ },
1958
+ readParticleIds: (out) => {
1959
+ // parallel to readParticles (same pool order, same agent skip), so out[i] is the stable id of
1960
+ // the particle written at stride offset i*5 there — the host maps id → its own opaque payload.
1961
+ const ps = store.particles;
1962
+ let w = 0;
1963
+ for (let i = 0; i < ps.length && w < out.length; i++) {
1964
+ const p = ps[i];
1965
+ if (p.report !== undefined)
1966
+ continue; // agents excluded, exactly like readParticles
1967
+ out[w++] = p.id ?? 0;
1889
1968
  }
1890
- return n;
1969
+ return w;
1970
+ },
1971
+ addAgent: (spec) => {
1972
+ const p = newParticle({ x: spec.x, y: spec.y, z: spec.z, species: spec.species });
1973
+ p.vx = 0;
1974
+ p.vy = 0;
1975
+ if (spec.z === undefined && cfg.depth <= 0)
1976
+ p.z = 0;
1977
+ if (spec.mass !== undefined)
1978
+ p.m = spec.mass;
1979
+ p.maxSpeed = spec.maxSpeed;
1980
+ p.report = spec.report;
1981
+ store.add(p);
1982
+ return { particle: p, remove: () => store.remove(p) };
1891
1983
  },
1892
1984
  energy: () => energyReport(store.particles),
1893
1985
  sample: (x, y) => {
1894
1986
  const { fx, fy } = forceAt(bodies, reg.forces, env, x, y);
1895
1987
  return { x: fx, y: fy };
1896
1988
  },
1989
+ sampleScalar: (x, y) => (heatmap ? heatmap.norm(x, y) : 0),
1990
+ sampleGradient: (x, y) => (heatmap ? heatmap.gradient(x, y) : { x: 0, y: 0 }),
1991
+ grid: (name) => env.grid(name),
1992
+ on: (type, cb) => {
1993
+ let set = busListeners.get(type);
1994
+ if (!set) {
1995
+ set = new Set();
1996
+ busListeners.set(type, set);
1997
+ }
1998
+ set.add(cb);
1999
+ return () => void set.delete(cb);
2000
+ },
1897
2001
  scrollV: () => env.scrollV ?? 0,
1898
2002
  setVisible: (on) => {
1899
2003
  canvasVisible = on;