@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.
- package/README.md +2 -2
- package/dist/agents/event-agent.js +1 -1
- package/dist/agents/event-agent.js.map +1 -1
- package/dist/config/manual.d.ts +1 -1
- package/dist/config/manual.d.ts.map +1 -1
- package/dist/conformance/expectations.d.ts.map +1 -1
- package/dist/conformance/expectations.js +3 -1
- package/dist/conformance/expectations.js.map +1 -1
- package/dist/contracts/guards.js +1 -1
- package/dist/contracts/guards.js.map +1 -1
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/types.d.ts +1 -1
- package/dist/contracts/types.js +1 -1
- package/dist/core/events.d.ts +15 -0
- package/dist/core/events.d.ts.map +1 -1
- package/dist/core/events.js.map +1 -1
- package/dist/core/feedback-sink.d.ts +16 -3
- package/dist/core/feedback-sink.d.ts.map +1 -1
- package/dist/core/feedback-sink.js +11 -0
- package/dist/core/feedback-sink.js.map +1 -1
- package/dist/core/field.d.ts.map +1 -1
- package/dist/core/field.js +138 -34
- package/dist/core/field.js.map +1 -1
- package/dist/core/flow.d.ts +6 -0
- package/dist/core/flow.d.ts.map +1 -1
- package/dist/core/flow.js +16 -3
- package/dist/core/flow.js.map +1 -1
- package/dist/core/heatmap.d.ts +6 -1
- package/dist/core/heatmap.d.ts.map +1 -1
- package/dist/core/heatmap.js +10 -0
- package/dist/core/heatmap.js.map +1 -1
- package/dist/core/integrator.d.ts.map +1 -1
- package/dist/core/integrator.js +83 -29
- package/dist/core/integrator.js.map +1 -1
- package/dist/core/math.d.ts +4 -0
- package/dist/core/math.d.ts.map +1 -1
- package/dist/core/math.js +10 -1
- package/dist/core/math.js.map +1 -1
- package/dist/core/scalar-grid.d.ts +3 -0
- package/dist/core/scalar-grid.d.ts.map +1 -1
- package/dist/core/scalar-grid.js +9 -0
- package/dist/core/scalar-grid.js.map +1 -1
- package/dist/core/scanner.d.ts +1 -2
- package/dist/core/scanner.d.ts.map +1 -1
- package/dist/core/scanner.js +58 -1
- package/dist/core/scanner.js.map +1 -1
- package/dist/core/temporal.d.ts +1 -1
- package/dist/core/temporal.js +1 -1
- package/dist/core/types.d.ts +127 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/forces/extended.d.ts.map +1 -1
- package/dist/forces/extended.js +6 -1
- package/dist/forces/extended.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/inspect/report.js +2 -2
- package/dist/inspect/report.js.map +1 -1
- package/dist/recipes/catalog.js +1 -1
- package/dist/recipes/catalog.js.map +1 -1
- package/package.json +1 -1
package/dist/core/field.js
CHANGED
|
@@ -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,
|
|
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 {
|
|
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('
|
|
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('
|
|
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
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
if (heatmap
|
|
1059
|
+
// The heatmap is a continuous ambient layer — NOT 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
|
-
|
|
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
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
1063
|
-
// the
|
|
1064
|
-
|
|
1065
|
-
ctx.fillStyle = `rgba(${
|
|
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,
|
|
1118
|
+
ctx.arc(p.x, p.y, size + 1.2, 0, 6.28318);
|
|
1068
1119
|
ctx.fill();
|
|
1069
|
-
ctx.fillStyle = `rgba(${
|
|
1120
|
+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${alpha})`;
|
|
1070
1121
|
ctx.beginPath();
|
|
1071
|
-
ctx.arc(p.x, p.y, size
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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(`
|
|
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
|
|
1881
|
-
|
|
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
|
-
|
|
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
|
|
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;
|