@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.
- 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 +246 -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 +231 -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,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('
|
|
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,
|
|
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
|
-
|
|
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
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
if (heatmap
|
|
1069
|
+
// The heatmap is a continuous ambient layer — NOT 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
|
-
|
|
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
|
-
//
|
|
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})`;
|
|
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
|
|
1128
|
+
ctx.arc(p.x, p.y, size + 1.2, 0, 6.28318);
|
|
1072
1129
|
ctx.fill();
|
|
1073
|
-
ctx.fillStyle = `rgba(${
|
|
1130
|
+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${alpha})`;
|
|
1074
1131
|
ctx.beginPath();
|
|
1075
|
-
ctx.arc(p.x, p.y,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(`
|
|
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
|
|
1881
|
-
|
|
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
|
-
|
|
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
|
|
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
|