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