@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.
- 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 +249 -38
- 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 +226 -1
- 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,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('
|
|
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,
|
|
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
|
-
|
|
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
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
if (heatmap
|
|
1070
|
+
// The heatmap is a continuous ambient layer — NOT 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
|
-
|
|
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
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
1063
|
-
// the
|
|
1064
|
-
|
|
1065
|
-
ctx.fillStyle = `rgba(${
|
|
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
|
|
1129
|
+
ctx.arc(p.x, p.y, size + 1.2, 0, 6.28318);
|
|
1072
1130
|
ctx.fill();
|
|
1073
|
-
ctx.fillStyle = `rgba(${
|
|
1131
|
+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${alpha})`;
|
|
1074
1132
|
ctx.beginPath();
|
|
1075
|
-
ctx.arc(p.x, p.y,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(`
|
|
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
|
|
1881
|
-
|
|
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
|
-
|
|
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
|
|
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
|