@fundamental-engine/core 0.6.0 → 0.8.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 +7 -7
- package/dist/config/themes.d.ts +22 -0
- package/dist/config/themes.d.ts.map +1 -0
- package/dist/config/themes.js +9 -0
- package/dist/config/themes.js.map +1 -0
- package/dist/contracts/guards.d.ts +14 -0
- package/dist/contracts/guards.d.ts.map +1 -1
- package/dist/contracts/guards.js +21 -0
- package/dist/contracts/guards.js.map +1 -1
- package/dist/core/agents.d.ts +1 -1
- package/dist/core/agents.d.ts.map +1 -1
- package/dist/core/agents.js +5 -2
- package/dist/core/agents.js.map +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/field.d.ts.map +1 -1
- package/dist/core/field.js +295 -99
- package/dist/core/field.js.map +1 -1
- package/dist/core/host.d.ts +8 -1
- package/dist/core/host.d.ts.map +1 -1
- package/dist/core/host.js +1 -1
- package/dist/core/math.d.ts +4 -3
- package/dist/core/math.d.ts.map +1 -1
- package/dist/core/math.js +10 -9
- package/dist/core/math.js.map +1 -1
- package/dist/core/scanner.d.ts +1 -1
- package/dist/core/scanner.d.ts.map +1 -1
- package/dist/core/scanner.js +8 -4
- package/dist/core/scanner.js.map +1 -1
- package/dist/core/temporal.d.ts +2 -2
- package/dist/core/temporal.js +2 -2
- package/dist/core/types.d.ts +81 -43
- package/dist/core/types.d.ts.map +1 -1
- package/dist/export.d.ts +1 -1
- package/dist/forces/index.d.ts.map +1 -1
- package/dist/forces/index.js +3 -1
- package/dist/forces/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/recipes/compile.d.ts +1 -1
- package/dist/recipes/index.js +1 -1
- package/dist/recipes/index.js.map +1 -1
- package/dist/version.d.ts +8 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/package.json +1 -1
package/dist/core/field.js
CHANGED
|
@@ -24,6 +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 { THEMES, DEFAULT_THEME } from "../config/themes.js";
|
|
27
28
|
import { clamp, hexToRgb, particleRGBInto, rgbToHex, sampleStops } from "./math.js";
|
|
28
29
|
import { feedbackTarget, feedbackWeight } from "./feedback.js";
|
|
29
30
|
import { defaultFeedbackSink } from "./feedback-sink.js";
|
|
@@ -45,9 +46,9 @@ import { forceAt, netField } from "./streamlines.js";
|
|
|
45
46
|
import { traceFieldLines } from "./fieldlines.js";
|
|
46
47
|
import { fieldLineSeeds } from "./fieldline-seeds.js";
|
|
47
48
|
import { flowBiasInto, makeFlowFocus } from "./flow.js";
|
|
49
|
+
import { devWarnNoOp } from "../contracts/guards.js";
|
|
50
|
+
import { FIELD_VERSION } from "../version.js";
|
|
48
51
|
import { energyReport } from "../diagnostics/energy.js";
|
|
49
|
-
// the Currents' cool baseline palette — a subset of the force palette (§24.4).
|
|
50
|
-
const WAVE_RGB = ['#4da3ff', '#2dd4bf', '#a78bfa'].map(hexToRgb);
|
|
51
52
|
// Shared draw/integrate scratch — reused across the per-particle and per-cell hot loops so an
|
|
52
53
|
// active flow focus and the particle draw don't allocate a `{x,y}` / `[r,g,b]` each iteration.
|
|
53
54
|
// Safe to share module-wide: each field's frame runs synchronously, and every read consumes the
|
|
@@ -62,7 +63,7 @@ export function createField(canvas, opts = {}) {
|
|
|
62
63
|
// acquires it lazily (and sizes the store then) — so a field created with 'none' allocates no
|
|
63
64
|
// render surface at all unless asked to draw.
|
|
64
65
|
let ctx = null;
|
|
65
|
-
if ((opts.render ?? '
|
|
66
|
+
if ((opts.render ?? 'none') !== 'none') {
|
|
66
67
|
ctx = canvas.getContext('2d');
|
|
67
68
|
if (!ctx)
|
|
68
69
|
throw new Error('Fundamental: 2D canvas context unavailable');
|
|
@@ -79,8 +80,6 @@ export function createField(canvas, opts = {}) {
|
|
|
79
80
|
let overlayBackend = opts.overlayBackend ?? (overlayCanvas && overlayCtx ? canvas2dBackend(overlayCanvas, overlayCtx) : null);
|
|
80
81
|
const store = new FieldStore();
|
|
81
82
|
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
|
|
84
83
|
const grids = new Map(); // §20.1 class [C] field buffers, lazy
|
|
85
84
|
const reg = createRegistry();
|
|
86
85
|
// host-agnostic discrete event bus (the read side): plain-data push for occurrences a non-DOM
|
|
@@ -95,24 +94,86 @@ export function createField(canvas, opts = {}) {
|
|
|
95
94
|
cb(payload);
|
|
96
95
|
}
|
|
97
96
|
const sinkPeak = new WeakMap(); // matter held at the rising edge, for the release count
|
|
97
|
+
// #441 body-proximity events. Object-identity membership (survives rescan; removed bodies pruned).
|
|
98
|
+
const insideOf = new Map(); // enter/exit: bodies currently within a body's range
|
|
99
|
+
const metWith = new Map(); // met: bodies currently in box-contact (symmetric, deduped a<b)
|
|
100
|
+
function detectProximityEvents(bs) {
|
|
101
|
+
const wantEE = busHas('enter') || busHas('exit');
|
|
102
|
+
const wantMet = busHas('met');
|
|
103
|
+
if (!wantEE && !wantMet)
|
|
104
|
+
return; // lazy: zero listeners → zero cost
|
|
105
|
+
for (let i = 0; i < bs.length; i++) {
|
|
106
|
+
const a = bs[i];
|
|
107
|
+
if (wantEE) {
|
|
108
|
+
let inside = insideOf.get(a);
|
|
109
|
+
if (!inside)
|
|
110
|
+
insideOf.set(a, (inside = new Set()));
|
|
111
|
+
const r2 = a.range * a.range;
|
|
112
|
+
for (let j = 0; j < bs.length; j++) {
|
|
113
|
+
if (i === j)
|
|
114
|
+
continue;
|
|
115
|
+
const o = bs[j];
|
|
116
|
+
const dx = o.cx - a.cx, dy = o.cy - a.cy;
|
|
117
|
+
const isIn = dx * dx + dy * dy < r2;
|
|
118
|
+
if (isIn && !inside.has(o)) {
|
|
119
|
+
inside.add(o);
|
|
120
|
+
if (busHas('enter'))
|
|
121
|
+
busEmit('enter', { body: a, other: o });
|
|
122
|
+
}
|
|
123
|
+
else if (!isIn && inside.has(o)) {
|
|
124
|
+
inside.delete(o);
|
|
125
|
+
if (busHas('exit'))
|
|
126
|
+
busEmit('exit', { body: a, other: o });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (wantMet) {
|
|
131
|
+
let met = metWith.get(a);
|
|
132
|
+
if (!met)
|
|
133
|
+
metWith.set(a, (met = new Set()));
|
|
134
|
+
for (let j = i + 1; j < bs.length; j++) {
|
|
135
|
+
const b = bs[j];
|
|
136
|
+
const touch = Math.abs(b.cx - a.cx) < a.hw + b.hw && Math.abs(b.cy - a.cy) < a.hh + b.hh;
|
|
137
|
+
if (touch && !met.has(b)) {
|
|
138
|
+
met.add(b);
|
|
139
|
+
busEmit('met', { a, b });
|
|
140
|
+
}
|
|
141
|
+
else if (!touch && met.has(b))
|
|
142
|
+
met.delete(b);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// prune tracking for bodies no longer present (rescan removed them) so they neither leak nor re-fire
|
|
147
|
+
const present = new Set(bs);
|
|
148
|
+
for (const m of [insideOf, metWith])
|
|
149
|
+
for (const k of m.keys())
|
|
150
|
+
if (!present.has(k))
|
|
151
|
+
m.delete(k);
|
|
152
|
+
}
|
|
98
153
|
const programmaticBodies = []; // bodies added via addBody(); merged into `bodies` each scan
|
|
154
|
+
const fieldChannels = new Map(); // addField() input channels
|
|
155
|
+
// All 36 forces are registered on every field — there is no opt-in. Any of them activates per-body
|
|
156
|
+
// through its `data-body` token (e.g. `data-body="lens crystallize"`); an unused force costs nothing.
|
|
99
157
|
registerCoreForces(reg); // the canonical nine (§6)
|
|
100
|
-
registerNaturalForces(reg); // natural primitives: gravity
|
|
101
|
-
registerExtendedForces(reg); // designed extended forces: lens, … (§20.3)
|
|
158
|
+
registerNaturalForces(reg); // 8 natural primitives: gravity, charge, magnetism, thermal, … (§20.10)
|
|
159
|
+
registerExtendedForces(reg); // 19 designed extended forces: lens, crystallize, link, morph, … (§20.3)
|
|
102
160
|
// the environment seam: all DOM access goes through this injected host — core imports zero DOM.
|
|
103
|
-
// In the browser, pass `browserHost()` from @fundamental-engine/
|
|
161
|
+
// In the browser, pass `browserHost()` from @fundamental-engine/dom (or use createBrowserField); the
|
|
104
162
|
// @fundamental-engine/{elements,react,vanilla} entry points wire it for you.
|
|
105
163
|
if (!opts.host) {
|
|
106
164
|
throw new Error('Fundamental: createField requires opts.host. Use @fundamental-engine/vanilla (createField/mountField) or ' +
|
|
107
|
-
'@fundamental-engine/elements / @fundamental-engine/react, or pass browserHost() from @fundamental-engine/
|
|
165
|
+
'@fundamental-engine/elements / @fundamental-engine/react, or pass browserHost() from @fundamental-engine/dom.');
|
|
108
166
|
}
|
|
109
167
|
const host = opts.host;
|
|
110
168
|
const teardowns = []; // host event unsubscribers, called on destroy
|
|
111
169
|
const reduceMotion = host.reducedMotion();
|
|
170
|
+
// ambient theme (#529): a named preset for the heat ramp + wave baseline; `warm` (default) reproduces
|
|
171
|
+
// the shipped palette. Individual lanes (gradientCool/gradientWarm/waveBaseline) override the preset.
|
|
172
|
+
const theme = THEMES[opts.theme ?? DEFAULT_THEME] ?? THEMES[DEFAULT_THEME];
|
|
112
173
|
const cfg = {
|
|
113
174
|
accent: opts.accent ?? resolvePalette(opts.palette)[0] ?? PALETTE[0] ?? '#4da3ff',
|
|
114
175
|
density: opts.density && opts.density > 0 ? opts.density : 1,
|
|
115
|
-
render: opts.render ?? 'dots'
|
|
176
|
+
render: opts.render ?? 'none', // signals-first default (#538): a bare field runs the sim + feedback but draws nothing until asked. Pass render:'dots' for the particle surface.
|
|
116
177
|
waves: opts.waves ?? true, // draw the background Currents (§24); opt-out for the bare field
|
|
117
178
|
background: opts.background ?? 'opaque', // 'transparent' → clear to transparent, underlay over light content
|
|
118
179
|
mass: opts.mass ?? false, // first-class mass (§21.3): m ∝ size when on
|
|
@@ -120,6 +181,15 @@ export function createField(canvas, opts = {}) {
|
|
|
120
181
|
causality: opts.causality ?? false, // cross-boundary causality (Concept 4), opt-in
|
|
121
182
|
heatmap: opts.heatmap ?? false, // density heatmap layer (field-systems H1), opt-in
|
|
122
183
|
overlay: opts.overlay ?? 'off', // Field Surfaces: overlay-surface visualization mode, opt-in
|
|
184
|
+
// distortion multiplier for the `grid` overlay's lattice deflection (1 = calibrated default; 0 flat).
|
|
185
|
+
gridWarp: opts.gridWarp != null && opts.gridWarp >= 0 ? opts.gridWarp : 1,
|
|
186
|
+
// stroke opacity for the `grid` overlay lines. 0.16 = the calibrated faint diagnostic (default,
|
|
187
|
+
// never overpowers content); raise it (≈0.5) to make the warped lattice a deliberate centerpiece.
|
|
188
|
+
gridIntensity: opts.gridIntensity != null && opts.gridIntensity >= 0 ? Math.min(opts.gridIntensity, 1) : 0.16,
|
|
189
|
+
// theme palette (#529): the heat-ramp ends + wave baseline, resolved from the preset + overrides.
|
|
190
|
+
gradientCool: opts.gradientCool ? hexToRgb(opts.gradientCool) : theme.cool,
|
|
191
|
+
gradientWarm: opts.gradientWarm ? hexToRgb(opts.gradientWarm) : theme.warm,
|
|
192
|
+
waveBaseline: (opts.waveBaseline ?? theme.wave).map(hexToRgb),
|
|
123
193
|
dprCap: opts.dprCap && opts.dprCap > 0 ? opts.dprCap : 2, // backing-store DPR ceiling (#410); the
|
|
124
194
|
// dominant fill-rate lever — the ambient field is soft, so ~1.5 buys ~1.8× headroom on retina.
|
|
125
195
|
// optional z volume (z-axis.md): 0 — the default — is the flat field, byte-identical
|
|
@@ -134,6 +204,14 @@ export function createField(canvas, opts = {}) {
|
|
|
134
204
|
let bodies = [];
|
|
135
205
|
let W = 0;
|
|
136
206
|
let H = 0;
|
|
207
|
+
// field-space origin from the host viewport (#540) — 0,0 for a window host, the container's
|
|
208
|
+
// left/top for a contained host. measureBodies + the move/thread readouts subtract it.
|
|
209
|
+
let originX = 0;
|
|
210
|
+
let originY = 0;
|
|
211
|
+
// a contained field (host viewport returns an origin): its bodies live in container-local space,
|
|
212
|
+
// which is INVARIANT under page scroll (the container + its contents move together). So it refreshes
|
|
213
|
+
// the origin each frame and skips the window-only per-frame scroll shift below.
|
|
214
|
+
let contained = false;
|
|
137
215
|
// cached page scroll extent — reading scrollHeight forces a synchronous reflow, so
|
|
138
216
|
// we cache it (refreshed on resize + sampled occasionally) rather than per frame.
|
|
139
217
|
let maxScroll = 1;
|
|
@@ -174,6 +252,15 @@ export function createField(canvas, opts = {}) {
|
|
|
174
252
|
// on a cadence and DRAW from this cache every frame (so the arrows never flicker or step).
|
|
175
253
|
let slSamples = null;
|
|
176
254
|
let slQuiescent = [];
|
|
255
|
+
// Same cadence cache for the OVERLAY arrows (drawOverlayArrows) — the in-front Field-Surfaces
|
|
256
|
+
// reading. Its grid is the same body-induced force field, so it had the same per-frame regrid
|
|
257
|
+
// waste the underlay shed in #406; resample on the cadence, draw from this cache every frame.
|
|
258
|
+
let olSamples = null;
|
|
259
|
+
// Tag-tint cache (#515): the parsed RGB + reach² of each coloured body, rebuilt only on the
|
|
260
|
+
// measure cadence (colour/range change there, not per frame). The body ref is kept so the
|
|
261
|
+
// per-particle loop reads cx/cy FRESH each frame — those are scroll-compensated every frame
|
|
262
|
+
// (#508), so caching positions would re-introduce the swarm-pause-on-scroll lag for the tint.
|
|
263
|
+
let tintCache = null;
|
|
177
264
|
// hard pool ceiling for class-[S] sources (§20.1) — generous above the ~130·density
|
|
178
265
|
// base field so emission is never starved, but bounded so the sim can't grow forever.
|
|
179
266
|
const spawnCeiling = Math.round(130 * cfg.density) * 4;
|
|
@@ -361,7 +448,7 @@ export function createField(canvas, opts = {}) {
|
|
|
361
448
|
store.add(newParticle());
|
|
362
449
|
applySeed();
|
|
363
450
|
// the Currents (§24) are opt-out: with waves off, the field is just the free particles.
|
|
364
|
-
waves = cfg.waves ? buildWaves(
|
|
451
|
+
waves = cfg.waves ? buildWaves(cfg.waveBaseline) : [];
|
|
365
452
|
bound = cfg.waves ? buildBound(waves.length, cfg.density, rng) : [];
|
|
366
453
|
boundTarget = bound.length;
|
|
367
454
|
}
|
|
@@ -380,7 +467,7 @@ export function createField(canvas, opts = {}) {
|
|
|
380
467
|
// programmatic bodies (addBody) aren't discoverable by the scan — carry them across the rebuild.
|
|
381
468
|
if (programmaticBodies.length > 0)
|
|
382
469
|
bodies = bodies.concat(programmaticBodies);
|
|
383
|
-
measureBodies(bodies, W, H);
|
|
470
|
+
measureBodies(bodies, W, H, originX, originY);
|
|
384
471
|
bindEngagement();
|
|
385
472
|
// Reconcile movers: carry forward offset + dock state for elements that persist across
|
|
386
473
|
// rescans (shadow-DOM re-register, Astro nav re-mounts, explicit rescan()). An element that
|
|
@@ -587,7 +674,7 @@ export function createField(canvas, opts = {}) {
|
|
|
587
674
|
// self-laying-out repulsion (Concept 3) sees where everything actually sits this frame.
|
|
588
675
|
const centers = movers.map((mv) => {
|
|
589
676
|
const r = mv.el.getBoundingClientRect();
|
|
590
|
-
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
677
|
+
return { x: r.left - originX + r.width / 2, y: r.top - originY + r.height / 2 }; // container-local (#540)
|
|
591
678
|
});
|
|
592
679
|
for (let i = 0; i < movers.length; i++) {
|
|
593
680
|
const mv = movers[i];
|
|
@@ -643,8 +730,9 @@ export function createField(canvas, opts = {}) {
|
|
|
643
730
|
// Concept 3 — self-laying-out: this element pushes off the others and drifts off
|
|
644
731
|
// dense field regions, so a cluster spreads and re-settles (e.g. on resize).
|
|
645
732
|
if (mv.layout) {
|
|
646
|
-
|
|
647
|
-
|
|
733
|
+
// #530: pass the shared centers + this index instead of allocating `centers.filter(j !== i)`
|
|
734
|
+
// every frame per mover (was O(movers²) arrays/frame on a self-laying-out cluster).
|
|
735
|
+
const rep = repelForce({ x: cx, y: cy }, centers, i);
|
|
648
736
|
const press = densityPush((sx, sy) => store.near(sx, sy, 40).length, cx, cy, 16, 6);
|
|
649
737
|
fx += rep.x + press.x;
|
|
650
738
|
fy += rep.y + press.y;
|
|
@@ -745,10 +833,10 @@ export function createField(canvas, opts = {}) {
|
|
|
745
833
|
for (const th of threadLinks) {
|
|
746
834
|
const ra = th.a.getBoundingClientRect();
|
|
747
835
|
const rb = th.b.getBoundingClientRect();
|
|
748
|
-
const ax = ra.left + ra.width / 2;
|
|
749
|
-
const ay = ra.top + ra.height / 2;
|
|
750
|
-
const bx = rb.left + rb.width / 2;
|
|
751
|
-
const by = rb.top + rb.height / 2;
|
|
836
|
+
const ax = ra.left - originX + ra.width / 2; // container-local (#540)
|
|
837
|
+
const ay = ra.top - originY + ra.height / 2;
|
|
838
|
+
const bx = rb.left - originX + rb.width / 2;
|
|
839
|
+
const by = rb.top - originY + rb.height / 2;
|
|
752
840
|
const [cr, cg, cb] = th.c;
|
|
753
841
|
ctx.strokeStyle = `rgba(${cr},${cg},${cb},0.22)`;
|
|
754
842
|
ctx.lineWidth = 1;
|
|
@@ -771,11 +859,17 @@ export function createField(canvas, opts = {}) {
|
|
|
771
859
|
// Size the drawing surfaces' backing stores to the current W×H (dpr-scaled). Split out of
|
|
772
860
|
// resize() so the lazy `setRender('none' → drawing)` path can run exactly this once. With no
|
|
773
861
|
// context (a field created with `render: 'none'`, §13.7 / #297) it is a no-op: the canvas
|
|
862
|
+
// adaptive quality tier (#413): the QualityGovernor's 0–3 signal, applied to the engine's own
|
|
863
|
+
// levers. A tier caps the backing-store DPR (the dominant fill lever) and, at 2+, skips the heaviest
|
|
864
|
+
// ambient layer (the heatmap glow). Reversible — tier 0 restores the configured quality. Driven by
|
|
865
|
+
// `setQualityTier`; the platform runtime forwards the governor's tier.
|
|
866
|
+
let qualityTier = 0;
|
|
867
|
+
const TIER_DPR = [Infinity, 1.5, 1.25, 1]; // effective DPR ceiling per tier, capping cfg.dprCap further
|
|
774
868
|
// backing store stays 0×0 while W/H — the simulation space — keep tracking the viewport.
|
|
775
869
|
function sizeSurfaces(dprRaw) {
|
|
776
870
|
if (!ctx)
|
|
777
871
|
return;
|
|
778
|
-
const dpr = Math.min(dprRaw || 1, cfg.dprCap);
|
|
872
|
+
const dpr = Math.min(dprRaw || 1, cfg.dprCap, TIER_DPR[qualityTier] ?? Infinity);
|
|
779
873
|
canvas.width = Math.floor(W * dpr);
|
|
780
874
|
canvas.height = Math.floor(H * dpr);
|
|
781
875
|
canvas.style.width = W + 'px';
|
|
@@ -788,6 +882,9 @@ export function createField(canvas, opts = {}) {
|
|
|
788
882
|
const vp = host.viewport();
|
|
789
883
|
W = vp.width;
|
|
790
884
|
H = vp.height;
|
|
885
|
+
originX = vp.originX ?? 0;
|
|
886
|
+
originY = vp.originY ?? 0;
|
|
887
|
+
contained = vp.originX != null || vp.originY != null;
|
|
791
888
|
sizeSurfaces(vp.dpr);
|
|
792
889
|
env.W = W;
|
|
793
890
|
env.H = H;
|
|
@@ -1002,6 +1099,14 @@ export function createField(canvas, opts = {}) {
|
|
|
1002
1099
|
function drawHeatmap() {
|
|
1003
1100
|
if (!heatmap)
|
|
1004
1101
|
return;
|
|
1102
|
+
// Fade the ambient glow out as the page scrolls past the hero (≈ the first viewport). Unlike the
|
|
1103
|
+
// earlier velocity-based suppression (which popped in/out and read as choppy), this is a smooth,
|
|
1104
|
+
// MONOTONIC function of scroll POSITION — full through the top of the page, gone by ~1.15 viewports
|
|
1105
|
+
// — so it never flickers; and below the hero the whole layer is skipped (the #409 at-rest upscale
|
|
1106
|
+
// cost the heatmap is otherwise paying every frame for a glow you can't focus on mid-page).
|
|
1107
|
+
const hmFade = H > 0 ? clamp((1.15 - lastScrollY / H) / 0.85, 0, 1) : 1;
|
|
1108
|
+
if (hmFade <= 0.01)
|
|
1109
|
+
return;
|
|
1005
1110
|
const cell = heatmap.cell;
|
|
1006
1111
|
const cols = Math.max(1, Math.ceil(W / cell));
|
|
1007
1112
|
const rows = Math.max(1, Math.ceil(H / cell));
|
|
@@ -1022,7 +1127,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1022
1127
|
if (hmImg === null || frameN % 3 === 0) {
|
|
1023
1128
|
if (hmImg === null)
|
|
1024
1129
|
hmImg = hmCtx.createImageData(cols, rows);
|
|
1025
|
-
const acc = hexToRgb(cfg.accent)
|
|
1130
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1026
1131
|
const data = hmImg.data;
|
|
1027
1132
|
for (let r = 0; r < rows; r++) {
|
|
1028
1133
|
for (let c = 0; c < cols; c++) {
|
|
@@ -1038,7 +1143,9 @@ export function createField(canvas, opts = {}) {
|
|
|
1038
1143
|
}
|
|
1039
1144
|
ctx.globalCompositeOperation = 'lighter';
|
|
1040
1145
|
ctx.imageSmoothingEnabled = true;
|
|
1146
|
+
ctx.globalAlpha = hmFade; // fade with scroll position (computed above)
|
|
1041
1147
|
ctx.drawImage(hmCanvas, 0, 0, W, H); // bilinear upscale → smooth glow
|
|
1148
|
+
ctx.globalAlpha = 1;
|
|
1042
1149
|
ctx.globalCompositeOperation = 'source-over';
|
|
1043
1150
|
}
|
|
1044
1151
|
function render() {
|
|
@@ -1071,8 +1178,8 @@ export function createField(canvas, opts = {}) {
|
|
|
1071
1178
|
// whenever enabled. (An earlier scroll-suppression made it pop/fade out while scrolling, which
|
|
1072
1179
|
// read as choppy; the perf intent is served instead by the compute throttle — the texel grid is
|
|
1073
1180
|
// recomputed only every 3rd frame — so the per-frame cost is just the cached bilinear upscale.)
|
|
1074
|
-
if (heatmap)
|
|
1075
|
-
drawHeatmap();
|
|
1181
|
+
if (heatmap && qualityTier < 2)
|
|
1182
|
+
drawHeatmap(); // #413: drop the heaviest ambient layer at tier 2+
|
|
1076
1183
|
drawBound();
|
|
1077
1184
|
// free particles — cool centre → warm edge, blended toward accent (§20.8).
|
|
1078
1185
|
// metaballs (a molten iso-surface skin) and streamlines (the bare force field) REPLACE
|
|
@@ -1080,10 +1187,27 @@ export function createField(canvas, opts = {}) {
|
|
|
1080
1187
|
// keep it (their overlays read against the particles).
|
|
1081
1188
|
const showMatter = cfg.render !== 'metaballs' && cfg.render !== 'streamlines';
|
|
1082
1189
|
ctx.globalCompositeOperation = 'lighter';
|
|
1083
|
-
const acc = hexToRgb(cfg.accent)
|
|
1190
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1084
1191
|
const cx = W / 2;
|
|
1085
1192
|
const cy = H * 0.4;
|
|
1086
1193
|
const maxD = Math.hypot(Math.max(cx, W - cx), Math.max(cy, H - cy)) || 1;
|
|
1194
|
+
// Tag-tint: every body carrying a colour stains the swarm toward its tint by proximity — a
|
|
1195
|
+
// pervasive, render-time companion to the overlap-only `pigment` force, so a particle near a
|
|
1196
|
+
// tagged body wears its tag's hue even on a sparse field. Few coloured bodies, precomputed once
|
|
1197
|
+
// here; nearest-strongest wins per particle so the hues don't muddy. Reach is a touch past the
|
|
1198
|
+
// force range so the colour reads before matter actually arrives.
|
|
1199
|
+
// Rebuild the RGB cache only on the measure cadence (every 6th frame) or first frame — hexToRgb is
|
|
1200
|
+
// the churn (#515). Positions are NOT cached: the loop below reads each body's live cx/cy.
|
|
1201
|
+
if (tintCache === null || frameN % 6 === 0) {
|
|
1202
|
+
tintCache = [];
|
|
1203
|
+
for (const tb of bodies) {
|
|
1204
|
+
if (!tb.tint)
|
|
1205
|
+
continue;
|
|
1206
|
+
const reach = (tb.range || 200) * 1.4;
|
|
1207
|
+
tintCache.push({ b: tb, r2: reach * reach, rgb: hexToRgb(tb.tint) });
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const tintBodies = tintCache;
|
|
1087
1211
|
if (showMatter)
|
|
1088
1212
|
for (const p of store.particles) {
|
|
1089
1213
|
// captured matter is held in orbit by the sink — drawn dim and small (the accretion's orbital
|
|
@@ -1099,10 +1223,32 @@ export function createField(canvas, opts = {}) {
|
|
|
1099
1223
|
const d = Math.min(1, Math.hypot(p.x - cx, p.y - cy) / maxD);
|
|
1100
1224
|
const rs = d * d;
|
|
1101
1225
|
const h = p.heat;
|
|
1102
|
-
particleRGBInto(_rgb, rs, h, acc);
|
|
1226
|
+
particleRGBInto(_rgb, rs, h, acc, cfg.gradientCool, cfg.gradientWarm);
|
|
1103
1227
|
let r = _rgb[0];
|
|
1104
1228
|
let g = _rgb[1];
|
|
1105
1229
|
let b = _rgb[2];
|
|
1230
|
+
if (tintBodies.length) {
|
|
1231
|
+
let bw = 0;
|
|
1232
|
+
let brgb = null;
|
|
1233
|
+
for (const tb of tintBodies) {
|
|
1234
|
+
const dx = p.x - tb.b.cx; // live position — scroll-compensated every frame (#508)
|
|
1235
|
+
const dy = p.y - tb.b.cy;
|
|
1236
|
+
const dd2 = dx * dx + dy * dy;
|
|
1237
|
+
if (dd2 >= tb.r2)
|
|
1238
|
+
continue;
|
|
1239
|
+
const w = 1 - Math.sqrt(dd2 / tb.r2); // linear falloff, 1 at centre → 0 at reach
|
|
1240
|
+
if (w > bw) {
|
|
1241
|
+
bw = w;
|
|
1242
|
+
brgb = tb.rgb;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
if (brgb) {
|
|
1246
|
+
const k = bw * 0.7; // tint strength at the body centre
|
|
1247
|
+
r += (brgb[0] - r) * k;
|
|
1248
|
+
g += (brgb[1] - g) * k;
|
|
1249
|
+
b += (brgb[2] - b) * k;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1106
1252
|
if (p.color) {
|
|
1107
1253
|
// carried pigment (§20.8): a stained particle reads mostly as its own tint.
|
|
1108
1254
|
const [pr, pg, pb] = hexToRgb(p.color);
|
|
@@ -1138,7 +1284,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1138
1284
|
ctx.globalCompositeOperation = 'source-over';
|
|
1139
1285
|
if (cfg.render === 'links') {
|
|
1140
1286
|
ctx.globalCompositeOperation = 'lighter';
|
|
1141
|
-
const acc = hexToRgb(cfg.accent)
|
|
1287
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1142
1288
|
const R = 90;
|
|
1143
1289
|
ctx.lineWidth = 0.6;
|
|
1144
1290
|
for (const p of store.particles) {
|
|
@@ -1178,7 +1324,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1178
1324
|
continue;
|
|
1179
1325
|
splatDensity(mball, cols, rows, STEP, p.x, p.y, RAD, 1);
|
|
1180
1326
|
}
|
|
1181
|
-
const acc = hexToRgb(cfg.accent)
|
|
1327
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1182
1328
|
ctx.globalCompositeOperation = 'lighter';
|
|
1183
1329
|
ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${0.5 * boot})`;
|
|
1184
1330
|
ctx.lineWidth = 1.4;
|
|
@@ -1232,7 +1378,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1232
1378
|
vor[gy * cols + gx] = owner;
|
|
1233
1379
|
}
|
|
1234
1380
|
}
|
|
1235
|
-
const acc = hexToRgb(cfg.accent)
|
|
1381
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1236
1382
|
ctx.globalCompositeOperation = 'lighter';
|
|
1237
1383
|
ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${0.32 * boot})`;
|
|
1238
1384
|
ctx.lineWidth = 1;
|
|
@@ -1250,7 +1396,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1250
1396
|
// particles plus the flow they ride, in this one underlay canvas (no second surface, no blend).
|
|
1251
1397
|
if (cfg.render === 'streamlines' || cfg.render === 'flow') {
|
|
1252
1398
|
const GRID = 46;
|
|
1253
|
-
const acc = hexToRgb(cfg.accent)
|
|
1399
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1254
1400
|
ctx.lineWidth = 1;
|
|
1255
1401
|
ctx.lineCap = 'round';
|
|
1256
1402
|
// RESAMPLE the field on a cadence, not every frame. The arrows trace the body-induced force
|
|
@@ -1342,41 +1488,51 @@ export function createField(canvas, opts = {}) {
|
|
|
1342
1488
|
// unused by the current dispatch (both arrow modes read the felt field).
|
|
1343
1489
|
function drawOverlayArrows(out, structure, absolute) {
|
|
1344
1490
|
const GRID = 44;
|
|
1345
|
-
const acc = hexToRgb(cfg.accent)
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1491
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1492
|
+
// RESAMPLE the field on a cadence, not every frame (mirrors the underlay slSamples cache, #406).
|
|
1493
|
+
// The overlay grid is the same body-induced force field — it only shifts on the measure cadence
|
|
1494
|
+
// (every 6th frame) or while a flow focus animates — so a per-frame regrid (≈grid×bodies force
|
|
1495
|
+
// evals) was the same wasted work the underlay shed, surfacing as scroll jank when a Field-
|
|
1496
|
+
// Surfaces reading is live. Resample every 3rd frame (or empty cache / live flow); the EMA scale
|
|
1497
|
+
// updates with it. DRAW from the cache every frame below, so the arrows never flicker or step.
|
|
1498
|
+
if (olSamples === null || flow || frameN % 3 === 0) {
|
|
1499
|
+
const samples = [];
|
|
1500
|
+
let frameMax = 0;
|
|
1501
|
+
for (let gx = GRID / 2; gx < W; gx += GRID) {
|
|
1502
|
+
for (let gy = GRID / 2; gy < H; gy += GRID) {
|
|
1503
|
+
let { fx, fy } = forceAt(bodies, reg.forces, env, gx, gy);
|
|
1504
|
+
if (flow) {
|
|
1505
|
+
const b = flowBiasInto(_flowB, gx, gy, flow, 0.04);
|
|
1506
|
+
fx += b.x;
|
|
1507
|
+
fy += b.y;
|
|
1508
|
+
}
|
|
1509
|
+
const mag = Math.hypot(fx, fy);
|
|
1510
|
+
if (!(mag > 1e-9))
|
|
1511
|
+
continue; // skip dead zones / NaN
|
|
1512
|
+
samples.push({ gx, gy, ux: fx / mag, uy: fy / mag, mag });
|
|
1513
|
+
if (mag > frameMax)
|
|
1514
|
+
frameMax = mag;
|
|
1355
1515
|
}
|
|
1356
|
-
const mag = Math.hypot(fx, fy);
|
|
1357
|
-
if (!(mag > 1e-9))
|
|
1358
|
-
continue; // skip dead zones / NaN
|
|
1359
|
-
samples.push({ gx, gy, ux: fx / mag, uy: fy / mag, mag });
|
|
1360
|
-
if (mag > frameMax)
|
|
1361
|
-
frameMax = mag;
|
|
1362
1516
|
}
|
|
1517
|
+
// Same EMA approach as the underlay streamlines (see slMaxSmoothed) — independent state so
|
|
1518
|
+
// the overlay scale never couples to the underlay's field strength. Updated with the resample.
|
|
1519
|
+
if (olMaxSmoothed === 0)
|
|
1520
|
+
olMaxSmoothed = frameMax;
|
|
1521
|
+
else
|
|
1522
|
+
olMaxSmoothed = frameMax > olMaxSmoothed
|
|
1523
|
+
? olMaxSmoothed * 0.7 + frameMax * 0.3 // track rises promptly
|
|
1524
|
+
: olMaxSmoothed * 0.9 + frameMax * 0.1; // decay slowly so quiet frames don't flash
|
|
1525
|
+
olSamples = samples;
|
|
1363
1526
|
}
|
|
1364
|
-
// Same EMA approach as the underlay streamlines (see slMaxSmoothed) — independent state so
|
|
1365
|
-
// the overlay scale never couples to the underlay's field strength.
|
|
1366
|
-
if (olMaxSmoothed === 0)
|
|
1367
|
-
olMaxSmoothed = frameMax;
|
|
1368
|
-
else
|
|
1369
|
-
olMaxSmoothed = frameMax > olMaxSmoothed
|
|
1370
|
-
? olMaxSmoothed * 0.7 + frameMax * 0.3 // track rises promptly
|
|
1371
|
-
: olMaxSmoothed * 0.9 + frameMax * 0.1; // decay slowly so quiet frames don't flash
|
|
1372
1527
|
if (olMaxSmoothed <= 0)
|
|
1373
1528
|
return;
|
|
1374
1529
|
// one backend call per arrow: shaft + two head strokes packed as three segments. Alpha varies
|
|
1375
1530
|
// per arrow (it encodes magnitude), so arrows can't share one batch without quantizing — the
|
|
1376
|
-
// call count matches the previous per-arrow beginPath/stroke exactly.
|
|
1531
|
+
// call count matches the previous per-arrow beginPath/stroke exactly. `acc` is read every frame
|
|
1532
|
+
// (outside the resample) so a live setAccent recolors the cached arrows immediately.
|
|
1377
1533
|
const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0, width: 1.2 };
|
|
1378
1534
|
const seg = new Float64Array(12);
|
|
1379
|
-
for (const s of
|
|
1535
|
+
for (const s of olSamples) {
|
|
1380
1536
|
const rel = absolute ? clamp(s.mag / olMaxSmoothed, 0, 1) : Math.sqrt(s.mag / olMaxSmoothed);
|
|
1381
1537
|
const len = GRID * 0.5 * (0.25 + 0.75 * rel);
|
|
1382
1538
|
const ex = s.gx + s.ux * len;
|
|
@@ -1414,7 +1570,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1414
1570
|
bounds: { w: W, h: H },
|
|
1415
1571
|
loopDist: 8,
|
|
1416
1572
|
});
|
|
1417
|
-
const acc = hexToRgb(cfg.accent)
|
|
1573
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1418
1574
|
// one polyline per traced curve through the backend seam (#373) — same shared stroke style.
|
|
1419
1575
|
const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0.42, width: 1.1 };
|
|
1420
1576
|
for (const line of lines) {
|
|
@@ -1432,7 +1588,8 @@ export function createField(canvas, opts = {}) {
|
|
|
1432
1588
|
// space itself made visible, bending where the field is strong. Reads deformation.
|
|
1433
1589
|
function drawOverlayGrid(out) {
|
|
1434
1590
|
const STEP = 56;
|
|
1435
|
-
const MAXD = 11; // px displacement at the strongest sample — legible, never
|
|
1591
|
+
const MAXD = 11 * cfg.gridWarp; // px displacement at the strongest sample — legible, never
|
|
1592
|
+
// chaotic at the default gridWarp=1; the multiplier exaggerates the deformation for demos.
|
|
1436
1593
|
const cols = Math.floor(W / STEP) + 2;
|
|
1437
1594
|
const rows = Math.floor(H / STEP) + 2;
|
|
1438
1595
|
const dx = new Float32Array(cols * rows);
|
|
@@ -1453,8 +1610,10 @@ export function createField(canvas, opts = {}) {
|
|
|
1453
1610
|
}
|
|
1454
1611
|
}
|
|
1455
1612
|
}
|
|
1456
|
-
const acc = hexToRgb(cfg.accent)
|
|
1457
|
-
|
|
1613
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1614
|
+
// a bolder lattice also wants a slightly heavier line so it reads as structure, not threads;
|
|
1615
|
+
// at the faint diagnostic default (0.16) this stays width 1 — byte-identical to before.
|
|
1616
|
+
const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: cfg.gridIntensity, width: cfg.gridIntensity > 0.3 ? 1.2 : 1 };
|
|
1458
1617
|
const px = (gx, gy) => {
|
|
1459
1618
|
const i = gy * cols + gx;
|
|
1460
1619
|
const rel = maxMag > 0 ? Math.sqrt(mags[i] / maxMag) : 0;
|
|
@@ -1505,7 +1664,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1505
1664
|
max = oscalar[i];
|
|
1506
1665
|
if (max <= 0)
|
|
1507
1666
|
return;
|
|
1508
|
-
const acc = hexToRgb(cfg.accent)
|
|
1667
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1509
1668
|
const LEVELS = [0.25, 0.5, 0.78]; // nested iso-rings: faint outer shell → bright core
|
|
1510
1669
|
const packed = [];
|
|
1511
1670
|
for (let li = 0; li < LEVELS.length; li++) {
|
|
@@ -1543,7 +1702,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1543
1702
|
const SEED = 104; // seed lattice spacing (px)
|
|
1544
1703
|
const STEPPX = 9; // integration step (px)
|
|
1545
1704
|
const STEPS = 24; // max steps per path
|
|
1546
|
-
const acc = hexToRgb(cfg.accent)
|
|
1705
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1547
1706
|
const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0, width: 1.1 };
|
|
1548
1707
|
const seg = new Float64Array(4);
|
|
1549
1708
|
for (let sx = SEED / 2; sx < W; sx += SEED) {
|
|
@@ -1581,7 +1740,7 @@ export function createField(canvas, opts = {}) {
|
|
|
1581
1740
|
// (§8, the same number the platform mirrors to `--d`) printed beside the body. Feedback bodies
|
|
1582
1741
|
// lead (they asked to be measured); non-feedback bodies are skipped — no reading, no chip.
|
|
1583
1742
|
function drawOverlayData(out) {
|
|
1584
|
-
const acc = hexToRgb(cfg.accent)
|
|
1743
|
+
const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
|
|
1585
1744
|
for (const b of bodies) {
|
|
1586
1745
|
if (!b.vis || !b.feedback)
|
|
1587
1746
|
continue;
|
|
@@ -1633,16 +1792,36 @@ export function createField(canvas, opts = {}) {
|
|
|
1633
1792
|
if (boot < 1)
|
|
1634
1793
|
boot = Math.min(1, boot + 0.012);
|
|
1635
1794
|
easeFormation(env.form, formTarget, 0.03); // glide between formations (§7)
|
|
1795
|
+
// a contained field re-reads its origin each frame (the container's viewport position shifts as the
|
|
1796
|
+
// page scrolls); cheap — one getBoundingClientRect for the container, only when contained.
|
|
1797
|
+
if (contained) {
|
|
1798
|
+
const vp = host.viewport();
|
|
1799
|
+
originX = vp.originX ?? 0;
|
|
1800
|
+
originY = vp.originY ?? 0;
|
|
1801
|
+
}
|
|
1636
1802
|
const scrollY = host.scrollY();
|
|
1803
|
+
const dScroll = scrollY - lastScrollY;
|
|
1637
1804
|
// eased page-scroll speed for the `scrolling` data-when gate (§5).
|
|
1638
|
-
env.scrollV = (env.scrollV ?? 0) * 0.7 + Math.abs(
|
|
1805
|
+
env.scrollV = (env.scrollV ?? 0) * 0.7 + Math.abs(dScroll) * 0.3;
|
|
1639
1806
|
lastScrollY = scrollY;
|
|
1807
|
+
// Scroll-compensate the cached body centres between the every-6th-frame re-measures. The page
|
|
1808
|
+
// scrolls continuously under the fixed field, so each body's viewport position shifts every frame
|
|
1809
|
+
// even though getBoundingClientRect only runs on the measure cadence — without this the attractors
|
|
1810
|
+
// snap in 6-frame steps during scroll and the swarm reads as "pausing". Cheap and drift-free: the
|
|
1811
|
+
// elements don't move in the document between measures, so the only delta is scroll, and
|
|
1812
|
+
// measureBodies refreshes from the real rects on its own cadence. cy carries the shaped box too
|
|
1813
|
+
// (it is centred on cy ± hh). Window fields only: a contained field's local positions are
|
|
1814
|
+
// scroll-invariant (container + contents move together), so this shift would corrupt them (#540).
|
|
1815
|
+
if (dScroll !== 0 && !contained)
|
|
1816
|
+
for (const b of bodies)
|
|
1817
|
+
b.cy -= dScroll;
|
|
1640
1818
|
for (const w of waves) {
|
|
1641
1819
|
const target = scrollY * (0.025 + w.depth * 0.08); // wave parallax (§24)
|
|
1642
1820
|
w.offsetY += (target - w.offsetY) * 0.04;
|
|
1643
1821
|
}
|
|
1644
1822
|
if (bodies.length && frameN % 6 === 0) {
|
|
1645
|
-
measureBodies(bodies, W, H);
|
|
1823
|
+
measureBodies(bodies, W, H, originX, originY);
|
|
1824
|
+
detectProximityEvents(bodies); // #441 enter/exit/met — on the measure cadence, lazy
|
|
1646
1825
|
// attention-gated discharge (#365): an engagement-gated sink releases on the falling
|
|
1647
1826
|
// edge of engagement — the same conserved supernova ritual as saturation.
|
|
1648
1827
|
dischargeDisengaged(bodies, env.supernova);
|
|
@@ -1874,20 +2053,19 @@ export function createField(canvas, opts = {}) {
|
|
|
1874
2053
|
}
|
|
1875
2054
|
}
|
|
1876
2055
|
},
|
|
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
2056
|
setDprCap: (cap) => {
|
|
1887
2057
|
cfg.dprCap = cap > 0 ? cap : 2;
|
|
1888
2058
|
if (ctx)
|
|
1889
2059
|
sizeSurfaces(host.viewport().dpr); // re-size the backing store to the new ceiling now
|
|
1890
2060
|
},
|
|
2061
|
+
setQualityTier: (tier) => {
|
|
2062
|
+
const next = Math.max(0, Math.min(3, Math.floor(tier || 0)));
|
|
2063
|
+
if (next === qualityTier)
|
|
2064
|
+
return;
|
|
2065
|
+
qualityTier = next;
|
|
2066
|
+
if (ctx)
|
|
2067
|
+
sizeSurfaces(host.viewport().dpr); // re-apply the tier's effective DPR ceiling now
|
|
2068
|
+
},
|
|
1891
2069
|
threads: setThreads,
|
|
1892
2070
|
burst: (x, y, hex) => {
|
|
1893
2071
|
// discrete one-shot: shove + heat nearby matter, optionally tint it (§11).
|
|
@@ -1993,29 +2171,6 @@ export function createField(canvas, opts = {}) {
|
|
|
1993
2171
|
}
|
|
1994
2172
|
return w;
|
|
1995
2173
|
},
|
|
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
2174
|
addAgent: (spec) => {
|
|
2020
2175
|
const p = newParticle({ x: spec.x, y: spec.y, z: spec.z, species: spec.species });
|
|
2021
2176
|
p.vx = 0;
|
|
@@ -2075,10 +2230,28 @@ export function createField(canvas, opts = {}) {
|
|
|
2075
2230
|
body.onFeedback = (ch) => { Object.assign(channels, ch); spec.onFeedback?.(ch); };
|
|
2076
2231
|
programmaticBodies.push(body);
|
|
2077
2232
|
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
|
|
2233
|
+
measureBodies([body], W, H, originX, originY); // init cx/cy so the first sample/force is correct
|
|
2079
2234
|
return {
|
|
2080
2235
|
data: spec.data,
|
|
2081
2236
|
get channels() { return channels; },
|
|
2237
|
+
set: (params) => {
|
|
2238
|
+
// mutate the attrs the synthetic element exposes; refreshBodyParams (measure cadence) picks
|
|
2239
|
+
// up strength/range/spin/angle next frame — the same reactive path DOM + three bodies use.
|
|
2240
|
+
if (params.strength != null)
|
|
2241
|
+
attrs['data-strength'] = String(params.strength);
|
|
2242
|
+
if (params.range != null)
|
|
2243
|
+
attrs['data-range'] = String(params.range);
|
|
2244
|
+
if (params.spin != null)
|
|
2245
|
+
attrs['data-spin'] = String(params.spin);
|
|
2246
|
+
if (params.angle != null)
|
|
2247
|
+
attrs['data-angle'] = String(params.angle);
|
|
2248
|
+
// color/tint isn't on the refresh path — set it directly (the scanner reads tint once).
|
|
2249
|
+
if (params.color != null) {
|
|
2250
|
+
attrs['data-color'] = params.color;
|
|
2251
|
+
el.dataset.color = params.color;
|
|
2252
|
+
body.tint = params.color;
|
|
2253
|
+
}
|
|
2254
|
+
},
|
|
2082
2255
|
remove: () => {
|
|
2083
2256
|
const i = programmaticBodies.indexOf(body);
|
|
2084
2257
|
if (i >= 0)
|
|
@@ -2087,13 +2260,35 @@ export function createField(canvas, opts = {}) {
|
|
|
2087
2260
|
},
|
|
2088
2261
|
};
|
|
2089
2262
|
},
|
|
2263
|
+
addField: (name, sampler) => {
|
|
2264
|
+
fieldChannels.set(name, sampler);
|
|
2265
|
+
return {
|
|
2266
|
+
name,
|
|
2267
|
+
set: (next) => { fieldChannels.set(name, next); },
|
|
2268
|
+
remove: () => { fieldChannels.delete(name); },
|
|
2269
|
+
};
|
|
2270
|
+
},
|
|
2271
|
+
sampleField: (name, x, y) => {
|
|
2272
|
+
const s = fieldChannels.get(name);
|
|
2273
|
+
return s ? s(x, y) : 0;
|
|
2274
|
+
},
|
|
2090
2275
|
energy: () => energyReport(store.particles),
|
|
2091
2276
|
sample: (x, y) => {
|
|
2092
2277
|
const { fx, fy } = forceAt(bodies, reg.forces, env, x, y);
|
|
2093
2278
|
return { x: fx, y: fy };
|
|
2094
2279
|
},
|
|
2095
|
-
sampleScalar: (x, y) =>
|
|
2096
|
-
|
|
2280
|
+
sampleScalar: (x, y) => {
|
|
2281
|
+
if (heatmap)
|
|
2282
|
+
return heatmap.norm(x, y);
|
|
2283
|
+
devWarnNoOp('NOOP_NO_HEATMAP', 'sampleScalar() returned 0 because the heatmap layer is off — construct with { heatmap: true } or call setHeatmap(true).');
|
|
2284
|
+
return 0;
|
|
2285
|
+
},
|
|
2286
|
+
sampleGradient: (x, y) => {
|
|
2287
|
+
if (heatmap)
|
|
2288
|
+
return heatmap.gradient(x, y);
|
|
2289
|
+
devWarnNoOp('NOOP_NO_HEATMAP', 'sampleGradient() returned { x: 0, y: 0 } because the heatmap layer is off — construct with { heatmap: true } or call setHeatmap(true).');
|
|
2290
|
+
return { x: 0, y: 0 };
|
|
2291
|
+
},
|
|
2097
2292
|
grid: (name) => env.grid(name),
|
|
2098
2293
|
on: (type, cb) => {
|
|
2099
2294
|
let set = busListeners.get(type);
|
|
@@ -2104,6 +2299,7 @@ export function createField(canvas, opts = {}) {
|
|
|
2104
2299
|
set.add(cb);
|
|
2105
2300
|
return () => void set.delete(cb);
|
|
2106
2301
|
},
|
|
2302
|
+
version: FIELD_VERSION,
|
|
2107
2303
|
scrollV: () => env.scrollV ?? 0,
|
|
2108
2304
|
setVisible: (on) => {
|
|
2109
2305
|
canvasVisible = on;
|