@fundamental-engine/core 0.4.0 → 0.5.1

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 +246 -38
  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 +231 -1
  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,23 @@ 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
96
+ const programmaticBodies = []; // bodies added via addBody(); merged into `bodies` each scan
97
+ const fieldChannels = new Map(); // addField() input channels
77
98
  registerCoreForces(reg); // the canonical nine (§6)
78
99
  registerNaturalForces(reg); // natural primitives: gravity + charge (§20.10), opt-in
79
100
  registerExtendedForces(reg); // designed extended forces: lens, … (§20.3), opt-in
@@ -81,7 +102,7 @@ export function createField(canvas, opts = {}) {
81
102
  // In the browser, pass `browserHost()` from @fundamental-engine/platform (or use createBrowserField); the
82
103
  // @fundamental-engine/{elements,react,vanilla} entry points wire it for you.
83
104
  if (!opts.host) {
84
- throw new Error('field-ui: createField requires opts.host. Use @fundamental-engine/vanilla (createField/mountField) or ' +
105
+ throw new Error('Fundamental: createField requires opts.host. Use @fundamental-engine/vanilla (createField/mountField) or ' +
85
106
  '@fundamental-engine/elements / @fundamental-engine/react, or pass browserHost() from @fundamental-engine/platform.');
86
107
  }
87
108
  const host = opts.host;
@@ -98,6 +119,8 @@ export function createField(canvas, opts = {}) {
98
119
  causality: opts.causality ?? false, // cross-boundary causality (Concept 4), opt-in
99
120
  heatmap: opts.heatmap ?? false, // density heatmap layer (field-systems H1), opt-in
100
121
  overlay: opts.overlay ?? 'off', // Field Surfaces: overlay-surface visualization mode, opt-in
122
+ dprCap: opts.dprCap && opts.dprCap > 0 ? opts.dprCap : 2, // backing-store DPR ceiling (#410); the
123
+ // dominant fill-rate lever — the ambient field is soft, so ~1.5 buys ~1.8× headroom on retina.
101
124
  // optional z volume (z-axis.md): 0 — the default — is the flat field, byte-identical
102
125
  // to the 2D engine; > 0 opens a shallow depth the matter drifts through, opt-in.
103
126
  depth: opts.depth && opts.depth > 0 ? opts.depth : 0,
@@ -132,6 +155,7 @@ export function createField(canvas, opts = {}) {
132
155
  const rng = opts.rng ?? Math.random;
133
156
  const wallNow = opts.now ?? (() => performance.now());
134
157
  let boot = reduceMotion ? 1 : 0;
158
+ let lastNow = NaN; // previous frame timestamp — drives the frame-rate-independent dt (#434)
135
159
  let mball = null; // scratch density grid for the metaballs render mode
136
160
  let vor = null; // scratch owner grid for the voronoi render mode
137
161
  // EMA (exponential moving average) of the per-frame peak magnitude for each arrow renderer.
@@ -251,6 +275,9 @@ export function createField(canvas, opts = {}) {
251
275
  if (b.el.dataset.fxCap === '1') {
252
276
  b.el.dataset.fxCap = '0';
253
277
  fireCaptureEvent(b.el, 'released', { accreted: 0, load: 0 });
278
+ if (busHas('release'))
279
+ busEmit('release', { body: b, count: released.length });
280
+ sinkPeak.delete(b);
254
281
  }
255
282
  },
256
283
  // class-[S] sources emit through here, capped by a hard pool ceiling (the
@@ -290,6 +317,7 @@ export function createField(canvas, opts = {}) {
290
317
  function newParticle(seed = {}) {
291
318
  const size = seed.size ?? 0.7 + rng() * 1.8;
292
319
  return {
320
+ id: seed.id ?? nextParticleId++,
293
321
  x: seed.x ?? rng() * W,
294
322
  y: seed.y ?? rng() * H,
295
323
  vx: seed.vx ?? (rng() - 0.5) * 0.25,
@@ -307,6 +335,7 @@ export function createField(canvas, opts = {}) {
307
335
  cap: null,
308
336
  ...(seed.age != null ? { age: seed.age } : {}), // mortal matter (a [S] source)
309
337
  ...(seed.color != null ? { color: seed.color } : {}),
338
+ ...(seed.species != null ? { species: seed.species } : {}), // matter tagging (#444)
310
339
  };
311
340
  }
312
341
  // optional per-particle data records (FieldHandle.seed) — round-robined onto the base pool, with
@@ -347,6 +376,9 @@ export function createField(canvas, opts = {}) {
347
376
  else {
348
377
  bodies = scanned;
349
378
  }
379
+ // programmatic bodies (addBody) aren't discoverable by the scan — carry them across the rebuild.
380
+ if (programmaticBodies.length > 0)
381
+ bodies = bodies.concat(programmaticBodies);
350
382
  measureBodies(bodies, W, H);
351
383
  bindEngagement();
352
384
  // Reconcile movers: carry forward offset + dock state for elements that persist across
@@ -519,10 +551,16 @@ export function createField(canvas, opts = {}) {
519
551
  if (edge.fire === 'captured') {
520
552
  b.el.dataset.fxCap = '1';
521
553
  fireCaptureEvent(b.el, 'captured', { accreted: b.accreted, load: sinkLoad(b) });
554
+ if (busHas('absorb'))
555
+ busEmit('absorb', { body: b, count: b.accreted });
556
+ sinkPeak.set(b, b.accreted);
522
557
  }
523
558
  else if (edge.fire === 'released') {
524
559
  b.el.dataset.fxCap = '0';
525
560
  fireCaptureEvent(b.el, 'released', { accreted: 0, load: 0 });
561
+ if (busHas('release'))
562
+ busEmit('release', { body: b, count: sinkPeak.get(b) ?? 0 });
563
+ sinkPeak.delete(b);
526
564
  }
527
565
  }
528
566
  }
@@ -647,6 +685,23 @@ export function createField(canvas, opts = {}) {
647
685
  // engagement: hover/focus a [data-hot] element → it activates (b.on, lighting
648
686
  // the spine + on-state forces) and overrides the accent with its data-color (§9).
649
687
  function bindEngagement() {
688
+ // Reconcile across rescans (mirrors the emitter prune above): a persistent field outlives the
689
+ // [data-hot] elements swapped under it (Astro nav, dynamic content), so drop engagements whose
690
+ // element has left the DOM — release their listeners + the strong ref the `engaged` array holds,
691
+ // so a long-lived field can't accumulate detached nodes. New elements are bound below; the
692
+ // `fxEngaged` guard keeps live ones from double-binding.
693
+ if (engaged.length) {
694
+ engaged = engaged.filter((e) => {
695
+ if (e.el.isConnected)
696
+ return true;
697
+ e.el.removeEventListener('pointerenter', e.enter);
698
+ e.el.removeEventListener('pointerleave', e.leave);
699
+ e.el.removeEventListener('focus', e.enter);
700
+ e.el.removeEventListener('blur', e.leave);
701
+ delete e.el.dataset.fxEngaged;
702
+ return false;
703
+ });
704
+ }
650
705
  host.root.querySelectorAll('[data-hot]').forEach((node) => {
651
706
  const el = node;
652
707
  if (el.dataset.fxEngaged === '1')
@@ -719,7 +774,7 @@ export function createField(canvas, opts = {}) {
719
774
  function sizeSurfaces(dprRaw) {
720
775
  if (!ctx)
721
776
  return;
722
- const dpr = Math.min(dprRaw || 1, 2);
777
+ const dpr = Math.min(dprRaw || 1, cfg.dprCap);
723
778
  canvas.width = Math.floor(W * dpr);
724
779
  canvas.height = Math.floor(H * dpr);
725
780
  canvas.style.width = W + 'px';
@@ -894,14 +949,17 @@ export function createField(canvas, opts = {}) {
894
949
  // (feedback-sink.ts), which performs the same direct writes the engine always made:
895
950
  // `--d`/`--field-density`, `--field-heatmap-density`, `--load`/`--mass`,
896
951
  // plus the measured `--entropy`/`--coherence`/`--temperature`.
897
- cfg.feedbackSink(writeEl, {
952
+ const channels = {
898
953
  density: b.d,
899
954
  heatmapDensity,
900
955
  load,
901
956
  entropy: m.entropy,
902
957
  coherence: m.coherence,
903
958
  temperature: m.temperature,
904
- });
959
+ };
960
+ cfg.feedbackSink(writeEl, channels);
961
+ // per-body feedback (addBody): demux this body's channels to its own callback.
962
+ b.onFeedback?.(channels);
905
963
  }
906
964
  }
907
965
  function drawSparks() {
@@ -1008,11 +1066,11 @@ export function createField(canvas, opts = {}) {
1008
1066
  ctx.fillRect(0, 0, W, H);
1009
1067
  }
1010
1068
  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)
1069
+ // The heatmap is a continuous ambient layerNOT coupled to scroll. It draws every frame
1070
+ // whenever enabled. (An earlier scroll-suppression made it pop/fade out while scrolling, which
1071
+ // read as choppy; the perf intent is served instead by the compute throttle the texel grid is
1072
+ // recomputed only every 3rd frame so the per-frame cost is just the cached bilinear upscale.)
1073
+ if (heatmap)
1016
1074
  drawHeatmap();
1017
1075
  drawBound();
1018
1076
  // free particles — cool centre → warm edge, blended toward accent (§20.8).
@@ -1040,7 +1098,10 @@ export function createField(canvas, opts = {}) {
1040
1098
  const d = Math.min(1, Math.hypot(p.x - cx, p.y - cy) / maxD);
1041
1099
  const rs = d * d;
1042
1100
  const h = p.heat;
1043
- let [r, g, b] = particleRGB(rs, h, acc);
1101
+ particleRGBInto(_rgb, rs, h, acc);
1102
+ let r = _rgb[0];
1103
+ let g = _rgb[1];
1104
+ let b = _rgb[2];
1044
1105
  if (p.color) {
1045
1106
  // carried pigment (§20.8): a stained particle reads mostly as its own tint.
1046
1107
  const [pr, pg, pb] = hexToRgb(p.color);
@@ -1056,23 +1117,19 @@ export function createField(canvas, opts = {}) {
1056
1117
  const cr = r | 0;
1057
1118
  const cg = g | 0;
1058
1119
  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})`;
1066
- ctx.beginPath();
1067
- ctx.arc(p.x, p.y, (size + 1) * (2 + h * 1.6), 0, 6.28318);
1068
- ctx.fill();
1069
- ctx.fillStyle = `rgba(${col},${0.16 * alpha})`;
1120
+ // A single tight, faint bloom under the crisp core (additive, 'lighter' composite) just
1121
+ // enough to soften the point into a star. NOT the old wide, heat-scaled halo (`size+3+6*h`):
1122
+ // near an accretion sink every particle heats to h≈1, so that halo bloomed the whole cluster
1123
+ // into big overlapping rings (the repeatedly-flagged "glow" #434 follow-up). Heat now reads
1124
+ // only through the brighter, slightly larger core (the `+ h*2` size and `+ h*0.5` alpha above),
1125
+ // never a growing aura. Radius is fixed (size + ~1px), so points stay crisp at any heat.
1126
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.12 * alpha})`;
1070
1127
  ctx.beginPath();
1071
- ctx.arc(p.x, p.y, size * 1.25 + 0.6, 0, 6.28318);
1128
+ ctx.arc(p.x, p.y, size + 1.2, 0, 6.28318);
1072
1129
  ctx.fill();
1073
- ctx.fillStyle = `rgba(${col},${0.55 * alpha})`;
1130
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${alpha})`;
1074
1131
  ctx.beginPath();
1075
- ctx.arc(p.x, p.y, Math.max(0.4, size * 0.55), 0, 6.28318);
1132
+ ctx.arc(p.x, p.y, size, 0, 6.28318);
1076
1133
  ctx.fill();
1077
1134
  }
1078
1135
  drawSparks();
@@ -1212,7 +1269,7 @@ export function createField(canvas, opts = {}) {
1212
1269
  let { fx, fy } = forceAt(bodies, reg.forces, env, gx, gy);
1213
1270
  // a live flow focus bends the rendered field lines toward the target (field.flowTo).
1214
1271
  if (flow) {
1215
- const b = flowBias(gx, gy, flow, 0.04);
1272
+ const b = flowBiasInto(_flowB, gx, gy, flow, 0.04);
1216
1273
  fx += b.x;
1217
1274
  fy += b.y;
1218
1275
  }
@@ -1291,7 +1348,7 @@ export function createField(canvas, opts = {}) {
1291
1348
  for (let gy = GRID / 2; gy < H; gy += GRID) {
1292
1349
  let { fx, fy } = forceAt(bodies, reg.forces, env, gx, gy);
1293
1350
  if (flow) {
1294
- const b = flowBias(gx, gy, flow, 0.04);
1351
+ const b = flowBiasInto(_flowB, gx, gy, flow, 0.04);
1295
1352
  fx += b.x;
1296
1353
  fy += b.y;
1297
1354
  }
@@ -1495,7 +1552,7 @@ export function createField(canvas, opts = {}) {
1495
1552
  for (let i = 0; i < STEPS; i++) {
1496
1553
  let { fx, fy } = forceAt(bodies, reg.forces, env, x, y);
1497
1554
  if (flow) {
1498
- const b = flowBias(x, y, flow, 0.04);
1555
+ const b = flowBiasInto(_flowB, x, y, flow, 0.04);
1499
1556
  fx += b.x;
1500
1557
  fy += b.y;
1501
1558
  }
@@ -1561,7 +1618,17 @@ export function createField(canvas, opts = {}) {
1561
1618
  frameN++;
1562
1619
  env.t = (now - t0) / 1000;
1563
1620
  env.frameN = frameN;
1564
- env.dt = reduceMotion ? 0 : 1;
1621
+ // Frame-rate-independent timestep (#434): dt is the real frame interval normalized to a
1622
+ // 60fps baseline (≈1 at 60fps, ≈0.5 at 120fps, ≈2 at 30fps), clamped so a long stall
1623
+ // (tab switch, GC pause) can't teleport matter. Previously dt was a flat 1 regardless of
1624
+ // FPS, so when the perf work lifted the homepage to 60–120fps the same per-frame physics
1625
+ // ran 2–4× faster on screen. Position alone is dt-scaled (forces/friction are per-frame by
1626
+ // design, §applyForce) — that's enough to make displacement-per-second FPS-independent.
1627
+ // Still 0 under reduce-motion: the integrator and the `if (env.dt)` gates read it as the
1628
+ // "is the field animating" flag, so it must stay falsy when still and >0 when moving.
1629
+ const dtRaw = Number.isFinite(lastNow) ? (now - lastNow) / 16.6667 : 1;
1630
+ lastNow = now;
1631
+ env.dt = reduceMotion ? 0 : clamp(dtRaw, 0.2, 2);
1565
1632
  if (boot < 1)
1566
1633
  boot = Math.min(1, boot + 0.012);
1567
1634
  easeFormation(env.form, formTarget, 0.03); // glide between formations (§7)
@@ -1617,7 +1684,7 @@ export function createField(canvas, opts = {}) {
1617
1684
  for (const p of store.particles) {
1618
1685
  if (p.cap)
1619
1686
  continue;
1620
- const b = flowBias(p.x, p.y, flow, 0.6);
1687
+ const b = flowBiasInto(_flowB, p.x, p.y, flow, 0.6);
1621
1688
  p.vx += b.x;
1622
1689
  p.vy += b.y;
1623
1690
  }
@@ -1695,6 +1762,11 @@ export function createField(canvas, opts = {}) {
1695
1762
  setFormation('ambient');
1696
1763
  }
1697
1764
  }, 1200);
1765
+ // Never let this ambient idle-detection timer pin the host alive on its own: Node keeps the
1766
+ // process running while an un-unref'd interval is pending (browsers ignore unref). destroy()
1767
+ // still clears it for prompt teardown — this just guarantees a field that outlives its destroy()
1768
+ // (e.g. a headless test that forgets to tear down) can't hang process exit.
1769
+ idleTimer.unref?.();
1698
1770
  const onResize = () => resize();
1699
1771
  resize();
1700
1772
  // pause all work while the tab is backgrounded — stop the loop and the idle timer,
@@ -1720,7 +1792,7 @@ export function createField(canvas, opts = {}) {
1720
1792
  teardowns.push(host.onBodyEvent(UPDATE_BODY, onUpdateBody));
1721
1793
  onScroll();
1722
1794
  raf = host.raf(frame);
1723
- return {
1795
+ const handle = {
1724
1796
  scan,
1725
1797
  rescan: scan,
1726
1798
  setAccent: (hex) => {
@@ -1764,7 +1836,7 @@ export function createField(canvas, opts = {}) {
1764
1836
  if (!ctx) {
1765
1837
  // context acquisition can genuinely fail (lost GPU process, too many contexts) —
1766
1838
  // 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'`);
1839
+ console.warn(`Fundamental: setRender('${mode}') could not acquire a 2d context; staying in render 'none'`);
1768
1840
  return;
1769
1841
  }
1770
1842
  if (overlayCanvas && !overlayCtx) {
@@ -1801,6 +1873,11 @@ export function createField(canvas, opts = {}) {
1801
1873
  }
1802
1874
  }
1803
1875
  },
1876
+ setDprCap: (cap) => {
1877
+ cfg.dprCap = cap > 0 ? cap : 2;
1878
+ if (ctx)
1879
+ sizeSurfaces(host.viewport().dpr); // re-size the backing store to the new ceiling now
1880
+ },
1804
1881
  threads: setThreads,
1805
1882
  burst: (x, y, hex) => {
1806
1883
  // discrete one-shot: shove + heat nearby matter, optionally tint it (§11).
@@ -1877,23 +1954,153 @@ export function createField(canvas, opts = {}) {
1877
1954
  particleCount: () => store.size,
1878
1955
  readParticles: (out) => {
1879
1956
  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++) {
1957
+ const capN = Math.floor(out.length / 5); // stride 5
1958
+ let w = 0;
1959
+ for (let i = 0; i < ps.length && w < capN; i++) {
1882
1960
  const p = ps[i];
1883
- const o = i * 5;
1961
+ if (p.report !== undefined)
1962
+ continue; // agents draw as their own object, not a swarm dot
1963
+ const o = w * 5;
1884
1964
  out[o] = p.x;
1885
1965
  out[o + 1] = p.y;
1886
1966
  out[o + 2] = p.z ?? 0; // optional z lane (z-axis.md); 0 in a flat field
1887
1967
  out[o + 3] = p.heat;
1888
1968
  out[o + 4] = p.size;
1969
+ w++;
1889
1970
  }
1890
- return n;
1971
+ return w;
1972
+ },
1973
+ readParticleIds: (out) => {
1974
+ // parallel to readParticles (same pool order, same agent skip), so out[i] is the stable id of
1975
+ // the particle written at stride offset i*5 there — the host maps id → its own opaque payload.
1976
+ const ps = store.particles;
1977
+ let w = 0;
1978
+ for (let i = 0; i < ps.length && w < out.length; i++) {
1979
+ const p = ps[i];
1980
+ if (p.report !== undefined)
1981
+ continue; // agents excluded, exactly like readParticles
1982
+ out[w++] = p.id ?? 0;
1983
+ }
1984
+ return w;
1985
+ },
1986
+ addAgent: (spec) => {
1987
+ const p = newParticle({ x: spec.x, y: spec.y, z: spec.z, species: spec.species });
1988
+ p.vx = 0;
1989
+ p.vy = 0;
1990
+ if (spec.z === undefined && cfg.depth <= 0)
1991
+ p.z = 0;
1992
+ if (spec.mass !== undefined)
1993
+ p.m = spec.mass;
1994
+ p.maxSpeed = spec.maxSpeed;
1995
+ p.report = spec.report;
1996
+ store.add(p);
1997
+ return { particle: p, remove: () => store.remove(p) };
1998
+ },
1999
+ addBody: (spec) => {
2000
+ // A programmatic body has no DOM. A minimal rect-backed element answers the body contract the
2001
+ // measurer + reactive-param refresh read (getAttribute / dataset / getBoundingClientRect), so we
2002
+ // reuse the exact bodyFromElement path the scan uses — no fake document, no querySelectorAll root.
2003
+ const attrs = {
2004
+ 'data-body': Array.isArray(spec.tokens) ? spec.tokens.join(' ') : String(spec.tokens),
2005
+ };
2006
+ if (spec.strength != null)
2007
+ attrs['data-strength'] = String(spec.strength);
2008
+ if (spec.range != null)
2009
+ attrs['data-range'] = String(spec.range);
2010
+ if (spec.spin != null)
2011
+ attrs['data-spin'] = String(spec.spin);
2012
+ if (spec.angle != null)
2013
+ attrs['data-angle'] = String(spec.angle);
2014
+ if (spec.color != null)
2015
+ attrs['data-color'] = spec.color;
2016
+ const toRect = () => {
2017
+ const r = spec.rect();
2018
+ return {
2019
+ left: r.left, top: r.top, right: r.left + r.width, bottom: r.top + r.height,
2020
+ width: r.width, height: r.height, x: r.left, y: r.top, toJSON: () => ({}),
2021
+ };
2022
+ };
2023
+ const el = {
2024
+ tagName: 'DIV', id: '', className: '',
2025
+ dataset: spec.color != null ? { color: spec.color } : {},
2026
+ getAttribute: (n) => attrs[n] ?? null,
2027
+ hasAttribute: (n) => n in attrs,
2028
+ getBoundingClientRect: toRect,
2029
+ dispatchEvent: () => true,
2030
+ setAttribute: () => { },
2031
+ removeAttribute: () => { },
2032
+ // the feedback sink writes CSS vars here; a no-op style absorbs them (the value is delivered
2033
+ // to onFeedback / the handle's channels, not the DOM).
2034
+ style: { setProperty: () => { }, removeProperty: () => { }, getPropertyValue: () => '' },
2035
+ };
2036
+ const body = bodyFromElement(el);
2037
+ body.rect = toRect;
2038
+ body.data = spec.data;
2039
+ body.feedback = true; // programmatic bodies always compute channels (the CSS write hits the
2040
+ // harmless stub element; the value flows to onFeedback + the handle's live channels).
2041
+ const channels = {};
2042
+ body.onFeedback = (ch) => { Object.assign(channels, ch); spec.onFeedback?.(ch); };
2043
+ programmaticBodies.push(body);
2044
+ bodies = bodies.concat(body); // live this frame, before the next scan re-merges it
2045
+ measureBodies([body], W, H); // init cx/cy so the first sample/force is correct
2046
+ return {
2047
+ data: spec.data,
2048
+ get channels() { return channels; },
2049
+ set: (params) => {
2050
+ // mutate the attrs the synthetic element exposes; refreshBodyParams (measure cadence) picks
2051
+ // up strength/range/spin/angle next frame — the same reactive path DOM + three bodies use.
2052
+ if (params.strength != null)
2053
+ attrs['data-strength'] = String(params.strength);
2054
+ if (params.range != null)
2055
+ attrs['data-range'] = String(params.range);
2056
+ if (params.spin != null)
2057
+ attrs['data-spin'] = String(params.spin);
2058
+ if (params.angle != null)
2059
+ attrs['data-angle'] = String(params.angle);
2060
+ // color/tint isn't on the refresh path — set it directly (the scanner reads tint once).
2061
+ if (params.color != null) {
2062
+ attrs['data-color'] = params.color;
2063
+ el.dataset.color = params.color;
2064
+ body.tint = params.color;
2065
+ }
2066
+ },
2067
+ remove: () => {
2068
+ const i = programmaticBodies.indexOf(body);
2069
+ if (i >= 0)
2070
+ programmaticBodies.splice(i, 1);
2071
+ bodies = bodies.filter((x) => x !== body);
2072
+ },
2073
+ };
2074
+ },
2075
+ addField: (name, sampler) => {
2076
+ fieldChannels.set(name, sampler);
2077
+ return {
2078
+ name,
2079
+ set: (next) => { fieldChannels.set(name, next); },
2080
+ remove: () => { fieldChannels.delete(name); },
2081
+ };
2082
+ },
2083
+ sampleField: (name, x, y) => {
2084
+ const s = fieldChannels.get(name);
2085
+ return s ? s(x, y) : 0;
1891
2086
  },
1892
2087
  energy: () => energyReport(store.particles),
1893
2088
  sample: (x, y) => {
1894
2089
  const { fx, fy } = forceAt(bodies, reg.forces, env, x, y);
1895
2090
  return { x: fx, y: fy };
1896
2091
  },
2092
+ sampleScalar: (x, y) => (heatmap ? heatmap.norm(x, y) : 0),
2093
+ sampleGradient: (x, y) => (heatmap ? heatmap.gradient(x, y) : { x: 0, y: 0 }),
2094
+ grid: (name) => env.grid(name),
2095
+ on: (type, cb) => {
2096
+ let set = busListeners.get(type);
2097
+ if (!set) {
2098
+ set = new Set();
2099
+ busListeners.set(type, set);
2100
+ }
2101
+ set.add(cb);
2102
+ return () => void set.delete(cb);
2103
+ },
1897
2104
  scrollV: () => env.scrollV ?? 0,
1898
2105
  setVisible: (on) => {
1899
2106
  canvasVisible = on;
@@ -1939,5 +2146,6 @@ export function createField(canvas, opts = {}) {
1939
2146
  store.clear();
1940
2147
  },
1941
2148
  };
2149
+ return handle;
1942
2150
  }
1943
2151
  //# sourceMappingURL=field.js.map