@fundamental-engine/core 0.4.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/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/agents/element-agent.d.ts +38 -0
- package/dist/agents/element-agent.d.ts.map +1 -0
- package/dist/agents/element-agent.js +70 -0
- package/dist/agents/element-agent.js.map +1 -0
- package/dist/agents/event-agent.d.ts +47 -0
- package/dist/agents/event-agent.d.ts.map +1 -0
- package/dist/agents/event-agent.js +82 -0
- package/dist/agents/event-agent.js.map +1 -0
- package/dist/agents/index.d.ts +17 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/index.js +57 -0
- package/dist/agents/index.js.map +1 -0
- package/dist/agents/region-agents.d.ts +40 -0
- package/dist/agents/region-agents.d.ts.map +1 -0
- package/dist/agents/region-agents.js +22 -0
- package/dist/agents/region-agents.js.map +1 -0
- package/dist/agents/relationship.d.ts +55 -0
- package/dist/agents/relationship.d.ts.map +1 -0
- package/dist/agents/relationship.js +40 -0
- package/dist/agents/relationship.js.map +1 -0
- package/dist/agents/user-agent.d.ts +57 -0
- package/dist/agents/user-agent.d.ts.map +1 -0
- package/dist/agents/user-agent.js +45 -0
- package/dist/agents/user-agent.js.map +1 -0
- package/dist/config/forces.config.d.ts +101 -0
- package/dist/config/forces.config.d.ts.map +1 -0
- package/dist/config/forces.config.js +239 -0
- package/dist/config/forces.config.js.map +1 -0
- package/dist/config/manual.d.ts +134 -0
- package/dist/config/manual.d.ts.map +1 -0
- package/dist/config/manual.js +604 -0
- package/dist/config/manual.js.map +1 -0
- package/dist/config/palettes.d.ts +18 -0
- package/dist/config/palettes.d.ts.map +1 -0
- package/dist/config/palettes.js +34 -0
- package/dist/config/palettes.js.map +1 -0
- package/dist/config/presets.d.ts +48 -0
- package/dist/config/presets.d.ts.map +1 -0
- package/dist/config/presets.js +87 -0
- package/dist/config/presets.js.map +1 -0
- package/dist/config/tokens.d.ts +3 -0
- package/dist/config/tokens.d.ts.map +1 -0
- package/dist/config/tokens.js +16 -0
- package/dist/config/tokens.js.map +1 -0
- package/dist/conformance/expectations.d.ts +40 -0
- package/dist/conformance/expectations.d.ts.map +1 -0
- package/dist/conformance/expectations.js +347 -0
- package/dist/conformance/expectations.js.map +1 -0
- package/dist/conformance/experiments.d.ts +17 -0
- package/dist/conformance/experiments.d.ts.map +1 -0
- package/dist/conformance/experiments.js +875 -0
- package/dist/conformance/experiments.js.map +1 -0
- package/dist/conformance/run.d.ts +18 -0
- package/dist/conformance/run.d.ts.map +1 -0
- package/dist/conformance/run.js +240 -0
- package/dist/conformance/run.js.map +1 -0
- package/dist/conformance/types.d.ts +100 -0
- package/dist/conformance/types.d.ts.map +1 -0
- package/dist/conformance/types.js +2 -0
- package/dist/conformance/types.js.map +1 -0
- package/dist/contracts/guards.d.ts +51 -0
- package/dist/contracts/guards.d.ts.map +1 -0
- package/dist/contracts/guards.js +100 -0
- package/dist/contracts/guards.js.map +1 -0
- package/dist/contracts/index.d.ts +18 -0
- package/dist/contracts/index.d.ts.map +1 -0
- package/dist/contracts/index.js +107 -0
- package/dist/contracts/index.js.map +1 -0
- package/dist/contracts/passport.d.ts +88 -0
- package/dist/contracts/passport.d.ts.map +1 -0
- package/dist/contracts/passport.js +135 -0
- package/dist/contracts/passport.js.map +1 -0
- package/dist/contracts/types.d.ts +120 -0
- package/dist/contracts/types.d.ts.map +1 -0
- package/dist/contracts/types.js +24 -0
- package/dist/contracts/types.js.map +1 -0
- package/dist/core/accretion.d.ts +50 -0
- package/dist/core/accretion.d.ts.map +1 -0
- package/dist/core/accretion.js +98 -0
- package/dist/core/accretion.js.map +1 -0
- package/dist/core/agents.d.ts +31 -0
- package/dist/core/agents.d.ts.map +1 -0
- package/dist/core/agents.js +51 -0
- package/dist/core/agents.js.map +1 -0
- package/dist/core/attention.d.ts +72 -0
- package/dist/core/attention.d.ts.map +1 -0
- package/dist/core/attention.js +122 -0
- package/dist/core/attention.js.map +1 -0
- package/dist/core/causality.d.ts +38 -0
- package/dist/core/causality.d.ts.map +1 -0
- package/dist/core/causality.js +64 -0
- package/dist/core/causality.js.map +1 -0
- package/dist/core/conditions.d.ts +10 -0
- package/dist/core/conditions.d.ts.map +1 -0
- package/dist/core/conditions.js +22 -0
- package/dist/core/conditions.js.map +1 -0
- package/dist/core/currents.d.ts +53 -0
- package/dist/core/currents.d.ts.map +1 -0
- package/dist/core/currents.js +65 -0
- package/dist/core/currents.js.map +1 -0
- package/dist/core/dock.d.ts +35 -0
- package/dist/core/dock.d.ts.map +1 -0
- package/dist/core/dock.js +39 -0
- package/dist/core/dock.js.map +1 -0
- package/dist/core/events.d.ts +23 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/events.js +34 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/feedback-sink.d.ts +32 -0
- package/dist/core/feedback-sink.d.ts.map +1 -0
- package/dist/core/feedback-sink.js +53 -0
- package/dist/core/feedback-sink.js.map +1 -0
- package/dist/core/feedback.d.ts +11 -0
- package/dist/core/feedback.d.ts.map +1 -0
- package/dist/core/feedback.js +16 -0
- package/dist/core/feedback.js.map +1 -0
- package/dist/core/field-store.d.ts +26 -0
- package/dist/core/field-store.d.ts.map +1 -0
- package/dist/core/field-store.js +54 -0
- package/dist/core/field-store.js.map +1 -0
- package/dist/core/field.d.ts +18 -0
- package/dist/core/field.d.ts.map +1 -0
- package/dist/core/field.js +1943 -0
- package/dist/core/field.js.map +1 -0
- package/dist/core/fieldline-seeds.d.ts +25 -0
- package/dist/core/fieldline-seeds.d.ts.map +1 -0
- package/dist/core/fieldline-seeds.js +32 -0
- package/dist/core/fieldline-seeds.js.map +1 -0
- package/dist/core/fieldlines.d.ts +75 -0
- package/dist/core/fieldlines.d.ts.map +1 -0
- package/dist/core/fieldlines.js +111 -0
- package/dist/core/fieldlines.js.map +1 -0
- package/dist/core/flow.d.ts +38 -0
- package/dist/core/flow.d.ts.map +1 -0
- package/dist/core/flow.js +27 -0
- package/dist/core/flow.js.map +1 -0
- package/dist/core/formations.d.ts +11 -0
- package/dist/core/formations.d.ts.map +1 -0
- package/dist/core/formations.js +22 -0
- package/dist/core/formations.js.map +1 -0
- package/dist/core/geometry.d.ts +67 -0
- package/dist/core/geometry.d.ts.map +1 -0
- package/dist/core/geometry.js +68 -0
- package/dist/core/geometry.js.map +1 -0
- package/dist/core/heatmap.d.ts +22 -0
- package/dist/core/heatmap.d.ts.map +1 -0
- package/dist/core/heatmap.js +55 -0
- package/dist/core/heatmap.js.map +1 -0
- package/dist/core/host.d.ts +46 -0
- package/dist/core/host.d.ts.map +1 -0
- package/dist/core/host.js +11 -0
- package/dist/core/host.js.map +1 -0
- package/dist/core/integrator.d.ts +24 -0
- package/dist/core/integrator.d.ts.map +1 -0
- package/dist/core/integrator.js +375 -0
- package/dist/core/integrator.js.map +1 -0
- package/dist/core/math.d.ts +37 -0
- package/dist/core/math.d.ts.map +1 -0
- package/dist/core/math.js +77 -0
- package/dist/core/math.js.map +1 -0
- package/dist/core/reactions.d.ts +32 -0
- package/dist/core/reactions.d.ts.map +1 -0
- package/dist/core/reactions.js +45 -0
- package/dist/core/reactions.js.map +1 -0
- package/dist/core/registry.d.ts +13 -0
- package/dist/core/registry.d.ts.map +1 -0
- package/dist/core/registry.js +20 -0
- package/dist/core/registry.js.map +1 -0
- package/dist/core/render-backend.d.ts +46 -0
- package/dist/core/render-backend.d.ts.map +1 -0
- package/dist/core/render-backend.js +75 -0
- package/dist/core/render-backend.js.map +1 -0
- package/dist/core/render-modes.d.ts +42 -0
- package/dist/core/render-modes.d.ts.map +1 -0
- package/dist/core/render-modes.js +141 -0
- package/dist/core/render-modes.js.map +1 -0
- package/dist/core/reservoir.d.ts +43 -0
- package/dist/core/reservoir.d.ts.map +1 -0
- package/dist/core/reservoir.js +207 -0
- package/dist/core/reservoir.js.map +1 -0
- package/dist/core/scalar-grid.d.ts +51 -0
- package/dist/core/scalar-grid.d.ts.map +1 -0
- package/dist/core/scalar-grid.js +146 -0
- package/dist/core/scalar-grid.js.map +1 -0
- package/dist/core/scanner.d.ts +59 -0
- package/dist/core/scanner.d.ts.map +1 -0
- package/dist/core/scanner.js +260 -0
- package/dist/core/scanner.js.map +1 -0
- package/dist/core/shadow.d.ts +69 -0
- package/dist/core/shadow.d.ts.map +1 -0
- package/dist/core/shadow.js +84 -0
- package/dist/core/shadow.js.map +1 -0
- package/dist/core/spatial-hash.d.ts +30 -0
- package/dist/core/spatial-hash.d.ts.map +1 -0
- package/dist/core/spatial-hash.js +64 -0
- package/dist/core/spatial-hash.js.map +1 -0
- package/dist/core/streamlines.d.ts +29 -0
- package/dist/core/streamlines.d.ts.map +1 -0
- package/dist/core/streamlines.js +70 -0
- package/dist/core/streamlines.js.map +1 -0
- package/dist/core/surface.d.ts +19 -0
- package/dist/core/surface.d.ts.map +1 -0
- package/dist/core/surface.js +21 -0
- package/dist/core/surface.js.map +1 -0
- package/dist/core/temporal.d.ts +110 -0
- package/dist/core/temporal.d.ts.map +1 -0
- package/dist/core/temporal.js +139 -0
- package/dist/core/temporal.js.map +1 -0
- package/dist/core/thermo.d.ts +48 -0
- package/dist/core/thermo.d.ts.map +1 -0
- package/dist/core/thermo.js +48 -0
- package/dist/core/thermo.js.map +1 -0
- package/dist/core/types.d.ts +610 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/weights.d.ts +111 -0
- package/dist/core/weights.d.ts.map +1 -0
- package/dist/core/weights.js +128 -0
- package/dist/core/weights.js.map +1 -0
- package/dist/diagnostics/energy.d.ts +21 -0
- package/dist/diagnostics/energy.d.ts.map +1 -0
- package/dist/diagnostics/energy.js +27 -0
- package/dist/diagnostics/energy.js.map +1 -0
- package/dist/diagnostics/fields.d.ts +23 -0
- package/dist/diagnostics/fields.d.ts.map +1 -0
- package/dist/diagnostics/fields.js +30 -0
- package/dist/diagnostics/fields.js.map +1 -0
- package/dist/diagnostics/index.d.ts +46 -0
- package/dist/diagnostics/index.d.ts.map +1 -0
- package/dist/diagnostics/index.js +23 -0
- package/dist/diagnostics/index.js.map +1 -0
- package/dist/diagnostics/modes.d.ts +108 -0
- package/dist/diagnostics/modes.d.ts.map +1 -0
- package/dist/diagnostics/modes.js +181 -0
- package/dist/diagnostics/modes.js.map +1 -0
- package/dist/diagnostics/potential.d.ts +30 -0
- package/dist/diagnostics/potential.d.ts.map +1 -0
- package/dist/diagnostics/potential.js +43 -0
- package/dist/diagnostics/potential.js.map +1 -0
- package/dist/diagnostics/probes.d.ts +31 -0
- package/dist/diagnostics/probes.d.ts.map +1 -0
- package/dist/diagnostics/probes.js +61 -0
- package/dist/diagnostics/probes.js.map +1 -0
- package/dist/diagnostics/render.d.ts +49 -0
- package/dist/diagnostics/render.d.ts.map +1 -0
- package/dist/diagnostics/render.js +132 -0
- package/dist/diagnostics/render.js.map +1 -0
- package/dist/export.d.ts +18 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +17 -0
- package/dist/export.js.map +1 -0
- package/dist/forces/extended.d.ts +121 -0
- package/dist/forces/extended.d.ts.map +1 -0
- package/dist/forces/extended.js +674 -0
- package/dist/forces/extended.js.map +1 -0
- package/dist/forces/index.d.ts +33 -0
- package/dist/forces/index.d.ts.map +1 -0
- package/dist/forces/index.js +237 -0
- package/dist/forces/index.js.map +1 -0
- package/dist/forces/natural.d.ts +106 -0
- package/dist/forces/natural.d.ts.map +1 -0
- package/dist/forces/natural.js +385 -0
- package/dist/forces/natural.js.map +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +71 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect/budget.d.ts +17 -0
- package/dist/inspect/budget.d.ts.map +1 -0
- package/dist/inspect/budget.js +19 -0
- package/dist/inspect/budget.js.map +1 -0
- package/dist/inspect/index.d.ts +10 -0
- package/dist/inspect/index.d.ts.map +1 -0
- package/dist/inspect/index.js +10 -0
- package/dist/inspect/index.js.map +1 -0
- package/dist/inspect/report.d.ts +17 -0
- package/dist/inspect/report.d.ts.map +1 -0
- package/dist/inspect/report.js +44 -0
- package/dist/inspect/report.js.map +1 -0
- package/dist/inspect/snapshot.d.ts +21 -0
- package/dist/inspect/snapshot.d.ts.map +1 -0
- package/dist/inspect/snapshot.js +30 -0
- package/dist/inspect/snapshot.js.map +1 -0
- package/dist/recipes/catalog.d.ts +51 -0
- package/dist/recipes/catalog.d.ts.map +1 -0
- package/dist/recipes/catalog.js +1496 -0
- package/dist/recipes/catalog.js.map +1 -0
- package/dist/recipes/charge.d.ts +18 -0
- package/dist/recipes/charge.d.ts.map +1 -0
- package/dist/recipes/charge.js +27 -0
- package/dist/recipes/charge.js.map +1 -0
- package/dist/recipes/compile.d.ts +93 -0
- package/dist/recipes/compile.d.ts.map +1 -0
- package/dist/recipes/compile.js +113 -0
- package/dist/recipes/compile.js.map +1 -0
- package/dist/recipes/explain.d.ts +8 -0
- package/dist/recipes/explain.d.ts.map +1 -0
- package/dist/recipes/explain.js +46 -0
- package/dist/recipes/explain.js.map +1 -0
- package/dist/recipes/gallery.d.ts +6 -0
- package/dist/recipes/gallery.d.ts.map +1 -0
- package/dist/recipes/gallery.js +6 -0
- package/dist/recipes/gallery.js.map +1 -0
- package/dist/recipes/gravity.d.ts +16 -0
- package/dist/recipes/gravity.d.ts.map +1 -0
- package/dist/recipes/gravity.js +27 -0
- package/dist/recipes/gravity.js.map +1 -0
- package/dist/recipes/index.d.ts +18 -0
- package/dist/recipes/index.d.ts.map +1 -0
- package/dist/recipes/index.js +36 -0
- package/dist/recipes/index.js.map +1 -0
- package/dist/recipes/intent.d.ts +44 -0
- package/dist/recipes/intent.d.ts.map +1 -0
- package/dist/recipes/intent.js +46 -0
- package/dist/recipes/intent.js.map +1 -0
- package/dist/recipes/schema.d.ts +103 -0
- package/dist/recipes/schema.d.ts.map +1 -0
- package/dist/recipes/schema.js +123 -0
- package/dist/recipes/schema.js.map +1 -0
- package/dist/recipes/wayfinding.d.ts +39 -0
- package/dist/recipes/wayfinding.d.ts.map +1 -0
- package/dist/recipes/wayfinding.js +77 -0
- package/dist/recipes/wayfinding.js.map +1 -0
- package/dist/semantic/index.d.ts +13 -0
- package/dist/semantic/index.d.ts.map +1 -0
- package/dist/semantic/index.js +31 -0
- package/dist/semantic/index.js.map +1 -0
- package/dist/semantic/layers.d.ts +24 -0
- package/dist/semantic/layers.d.ts.map +1 -0
- package/dist/semantic/layers.js +27 -0
- package/dist/semantic/layers.js.map +1 -0
- package/dist/semantic/materials.d.ts +20 -0
- package/dist/semantic/materials.d.ts.map +1 -0
- package/dist/semantic/materials.js +17 -0
- package/dist/semantic/materials.js.map +1 -0
- package/dist/semantic/states.d.ts +11 -0
- package/dist/semantic/states.d.ts.map +1 -0
- package/dist/semantic/states.js +26 -0
- package/dist/semantic/states.js.map +1 -0
- package/dist/visual/channels.d.ts +71 -0
- package/dist/visual/channels.d.ts.map +1 -0
- package/dist/visual/channels.js +70 -0
- package/dist/visual/channels.js.map +1 -0
- package/dist/visual/index.d.ts +39 -0
- package/dist/visual/index.d.ts.map +1 -0
- package/dist/visual/index.js +30 -0
- package/dist/visual/index.js.map +1 -0
- package/dist/visual/lint.d.ts +41 -0
- package/dist/visual/lint.d.ts.map +1 -0
- package/dist/visual/lint.js +58 -0
- package/dist/visual/lint.js.map +1 -0
- package/dist/visual/mapping.d.ts +13 -0
- package/dist/visual/mapping.d.ts.map +1 -0
- package/dist/visual/mapping.js +43 -0
- package/dist/visual/mapping.js.map +1 -0
- package/dist/visual/semantic-text.d.ts +28 -0
- package/dist/visual/semantic-text.d.ts.map +1 -0
- package/dist/visual/semantic-text.js +36 -0
- package/dist/visual/semantic-text.js.map +1 -0
- package/dist/visual/tokens.d.ts +23 -0
- package/dist/visual/tokens.d.ts.map +1 -0
- package/dist/visual/tokens.js +54 -0
- package/dist/visual/tokens.js.map +1 -0
- package/dist/visual/visualization.d.ts +31 -0
- package/dist/visual/visualization.d.ts.map +1 -0
- package/dist/visual/visualization.js +47 -0
- package/dist/visual/visualization.js.map +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Designed extended forces (§20.3, implementation class [A]).
|
|
3
|
+
*
|
|
4
|
+
* Like the canonical nine (§6) these are *designed* — finite range, soft falloff,
|
|
5
|
+
* tuned for legibility — but they live outside the core nine as opt-in enrichments.
|
|
6
|
+
* Class [A] means each acts on a single particle from the shared per-frame `env`,
|
|
7
|
+
* needing no neighbour or grid services, so they register and test exactly like the
|
|
8
|
+
* nine. Opt-in via `data-body="lens"` etc.; a page that doesn't ask is unaffected.
|
|
9
|
+
*/
|
|
10
|
+
import { mixHex } from "../core/math.js";
|
|
11
|
+
/**
|
|
12
|
+
* §20.3 — `lens`: rotate the velocity, preserving its magnitude. A gravitational
|
|
13
|
+
* lens bends a path without adding energy, so this is a pure rotation by an angle
|
|
14
|
+
* that grows as a particle nears the body: `θ = θ_max·(1 − d/d_max)·sign`, then
|
|
15
|
+
* `v ← rotate(v, θ)`. `strength` is θ_max (radians), `spin` the sign of the bend.
|
|
16
|
+
*/
|
|
17
|
+
export const lens = {
|
|
18
|
+
token: 'lens',
|
|
19
|
+
label: 'Lens',
|
|
20
|
+
kinematic: true, // a pure rotation of velocity — bends the path, not the speed, mass-free
|
|
21
|
+
apply(b, p, e) {
|
|
22
|
+
if (e.dist >= b.range)
|
|
23
|
+
return;
|
|
24
|
+
const theta = b.strength * (1 - e.dist / b.range) * b.spin;
|
|
25
|
+
const cs = Math.cos(theta);
|
|
26
|
+
const sn = Math.sin(theta);
|
|
27
|
+
const vx = p.vx;
|
|
28
|
+
const vy = p.vy;
|
|
29
|
+
p.vx = vx * cs - vy * sn; // rotate(v, θ) — speed is conserved exactly
|
|
30
|
+
p.vy = vx * sn + vy * cs;
|
|
31
|
+
},
|
|
32
|
+
meta: { desc: 'rotates velocity, preserving speed — bends paths without adding energy' },
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* §20.3 — `gate`: a one-way membrane. Along its heading `n = (cosθ, sinθ)` matter
|
|
36
|
+
* passes freely; matter crossing the *wrong* way (`v·n < 0`) is reflected across the
|
|
37
|
+
* membrane, `v −= 2(v·n)·n`, so its normal component flips to travel with `n`. Sized
|
|
38
|
+
* by the element box (like `wall`, §6.4); `data-angle` sets `n`.
|
|
39
|
+
*/
|
|
40
|
+
export const gate = {
|
|
41
|
+
token: 'gate',
|
|
42
|
+
label: 'Gate',
|
|
43
|
+
kinematic: true, // reflects wrong-way crossers — a constraint, not an acceleration
|
|
44
|
+
apply(b, p, e) {
|
|
45
|
+
const pad = 6; // act on matter within the element box (the membrane's extent)
|
|
46
|
+
if (Math.abs(p.x - b.cx) >= b.hw + pad || Math.abs(p.y - b.cy) >= b.hh + pad)
|
|
47
|
+
return;
|
|
48
|
+
const vn = p.vx * b.ux + p.vy * b.uy; // velocity along the heading n
|
|
49
|
+
if (vn < 0) {
|
|
50
|
+
p.vx -= 2 * vn * b.ux; // reflect the wrong-way crosser back through n
|
|
51
|
+
p.vy -= 2 * vn * b.uy;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
meta: { desc: 'a one-way membrane — passes matter along its heading, reflects the reverse' },
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* §20.3 — `buoyancy`: a constant lift/sink set by a density difference. A particle's
|
|
58
|
+
* density `ρ_p = base / (size · (1 + heat))` falls as it grows or heats, so hot/large
|
|
59
|
+
* matter is lighter than the medium and rises while denser matter settles
|
|
60
|
+
* (sedimentation). `strength` is `g`; `data-range = 0` makes it global. Both `base`
|
|
61
|
+
* and the medium density are 1, so a unit-size, cool particle is neutrally buoyant.
|
|
62
|
+
*
|
|
63
|
+
* The spec writes `v_y += (ρ_med − ρ_p)·g`; the engine's `+y` points *down*, so we
|
|
64
|
+
* apply that quantity as a lift (subtract from `v_y`) — lighter matter rises (`−y`),
|
|
65
|
+
* denser sinks (`+y`).
|
|
66
|
+
*/
|
|
67
|
+
const BUOY_BASE = 1;
|
|
68
|
+
const BUOY_MEDIUM = 1;
|
|
69
|
+
export const buoyancy = {
|
|
70
|
+
token: 'buoyancy',
|
|
71
|
+
label: 'Buoyancy',
|
|
72
|
+
apply(b, p, e) {
|
|
73
|
+
if (b.range > 0 && e.dist >= b.range)
|
|
74
|
+
return; // range 0 ⇒ global field
|
|
75
|
+
const rhoP = BUOY_BASE / (p.size * (1 + p.heat)); // hotter / bigger → lighter
|
|
76
|
+
p.vy -= (BUOY_MEDIUM - rhoP) * b.strength; // lift up (−y) when lighter than the medium
|
|
77
|
+
},
|
|
78
|
+
meta: { desc: 'a constant lift/sink by density difference — light matter rises, dense settles' },
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* §20.3 — `shear`: a laminar velocity gradient (Couette flow). Speed along the flow
|
|
82
|
+
* axis `n = (cosθ, sinθ)` grows with a particle's *perpendicular* offset from the
|
|
83
|
+
* body: `v_∥ += S·(offset_⊥/d_max)·(1 − d/d_max)`. Matter on one side of the axis is
|
|
84
|
+
* dragged forward, the other side back — laminae sliding past each other.
|
|
85
|
+
* `data-angle` sets the flow axis; `strength` is S.
|
|
86
|
+
*/
|
|
87
|
+
export const shear = {
|
|
88
|
+
token: 'shear',
|
|
89
|
+
label: 'Shear',
|
|
90
|
+
apply(b, p, e) {
|
|
91
|
+
if (e.dist >= b.range)
|
|
92
|
+
return;
|
|
93
|
+
// perpendicular axis is (−uy, ux); offset_⊥ = (p − centre) · perp
|
|
94
|
+
const offsetPerp = (p.x - b.cx) * -b.uy + (p.y - b.cy) * b.ux;
|
|
95
|
+
const f = b.strength * (offsetPerp / b.range) * (1 - e.dist / b.range);
|
|
96
|
+
p.vx += b.ux * f; // accelerate along the flow axis n
|
|
97
|
+
p.vy += b.uy * f;
|
|
98
|
+
},
|
|
99
|
+
meta: { desc: 'a laminar shear gradient — flow speed grows with perpendicular offset' },
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* §20.3 — `crystallize`: a phase change. While a particle is cool (`heat < FREEZE`)
|
|
103
|
+
* it snaps toward the nearest node of a lattice anchored at the body, `v += (node −
|
|
104
|
+
* p)·k_snap`, then damps (`v *= 0.9`) so it settles into a solid; once hot it melts
|
|
105
|
+
* and moves freely. `strength` is `k_snap`; pairs naturally with `data-when="cool"`.
|
|
106
|
+
*/
|
|
107
|
+
const LATTICE = 32; // lattice cell, px
|
|
108
|
+
const FREEZE = 0.5; // heat below which matter solidifies
|
|
109
|
+
export const crystallize = {
|
|
110
|
+
token: 'crystallize',
|
|
111
|
+
label: 'Crystallize',
|
|
112
|
+
apply(b, p, e) {
|
|
113
|
+
if (e.dist >= b.range || p.heat >= FREEZE)
|
|
114
|
+
return; // out of range or melted → free
|
|
115
|
+
const nodeX = b.cx + Math.round((p.x - b.cx) / LATTICE) * LATTICE;
|
|
116
|
+
const nodeY = b.cy + Math.round((p.y - b.cy) / LATTICE) * LATTICE;
|
|
117
|
+
p.vx += (nodeX - p.x) * b.strength; // pull toward the lattice node
|
|
118
|
+
p.vy += (nodeY - p.y) * b.strength;
|
|
119
|
+
p.vx *= 0.9; // damp → settle into the solid
|
|
120
|
+
p.vy *= 0.9;
|
|
121
|
+
},
|
|
122
|
+
meta: { desc: 'snaps cool matter onto a lattice; melts and frees it when hot' },
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* §20.3 — `align`: steer velocity toward a target heading `ĥ` while preserving speed,
|
|
126
|
+
* `v += (ĥ·|v| − v)·k_align`. Unifies both spec variants: `[B]` uses the **mean of
|
|
127
|
+
* neighbours' headings** when `p` has any (boids alignment), and falls back to `[A]`,
|
|
128
|
+
* the body's own `data-angle` heading, when it's alone. `strength` is `k_align`.
|
|
129
|
+
*/
|
|
130
|
+
export const align = {
|
|
131
|
+
token: 'align',
|
|
132
|
+
label: 'Align',
|
|
133
|
+
apply(b, p, e) {
|
|
134
|
+
if (e.dist >= b.range)
|
|
135
|
+
return;
|
|
136
|
+
const pvz = p.vz ?? 0;
|
|
137
|
+
const speed = Math.hypot(p.vx, p.vy, pvz); // steer toward ĥ·|v| → turns without speeding up
|
|
138
|
+
const k = b.strength;
|
|
139
|
+
let hx = b.ux; // [A] default: the body heading (planar — data-angle is in-plane)
|
|
140
|
+
let hy = b.uy;
|
|
141
|
+
let hz = 0;
|
|
142
|
+
let sx = 0;
|
|
143
|
+
let sy = 0;
|
|
144
|
+
let sz = 0;
|
|
145
|
+
for (const n of e.neighbors(p, b.range)) {
|
|
146
|
+
const nvz = n.vz ?? 0;
|
|
147
|
+
const ns = Math.hypot(n.vx, n.vy, nvz); // sum the neighbours' unit velocities (v̂)
|
|
148
|
+
if (ns > 1e-6) {
|
|
149
|
+
sx += n.vx / ns;
|
|
150
|
+
sy += n.vy / ns;
|
|
151
|
+
sz += nvz / ns;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const sm = Math.hypot(sx, sy, sz);
|
|
155
|
+
if (sm > 1e-6) {
|
|
156
|
+
hx = sx / sm; // [B]: the mean neighbour heading
|
|
157
|
+
hy = sy / sm;
|
|
158
|
+
hz = sz / sm;
|
|
159
|
+
}
|
|
160
|
+
p.vx += (hx * speed - p.vx) * k;
|
|
161
|
+
p.vy += (hy * speed - p.vy) * k;
|
|
162
|
+
if (hz || pvz)
|
|
163
|
+
p.vz = pvz + (hz * speed - pvz) * k;
|
|
164
|
+
},
|
|
165
|
+
meta: { desc: 'steers toward the neighbour-mean heading (or the body heading when alone)' },
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* A smooth divergence-free flow field (§20.3) — the curl of a sinusoidal stream-
|
|
169
|
+
* function `ψ = sin(a)·cos(b)`, with `a = x·s + 0.2t`, `b = y·s − 0.2t`. The velocity
|
|
170
|
+
* `(∂ψ/∂y, −∂ψ/∂x)` is divergence-free by construction (`∇·curl ≡ 0`), so it stirs
|
|
171
|
+
* without compressing. Closed-form (no RNG) → deterministic and exactly testable.
|
|
172
|
+
* `s` is the spatial scale of the eddies.
|
|
173
|
+
*/
|
|
174
|
+
export function curlNoise(x, y, t, s) {
|
|
175
|
+
const a = x * s + t * 0.2;
|
|
176
|
+
const b = y * s - t * 0.2;
|
|
177
|
+
// ∂ψ/∂x = s·cos(a)cos(b), ∂ψ/∂y = −s·sin(a)sin(b); curl = (∂ψ/∂y, −∂ψ/∂x)
|
|
178
|
+
return { x: -s * Math.sin(a) * Math.sin(b), y: -s * Math.cos(a) * Math.cos(b) };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* §20.3 — `wind`: divergence-free turbulence, `v += curl(noise(x·s, y·s, t))·S`.
|
|
182
|
+
* `strength` is the amplitude S; `data-range = 0` makes it a global gust. (The
|
|
183
|
+
* spatial scale is a fixed constant for now — wiring `data-scale` would need a new
|
|
184
|
+
* Body field.)
|
|
185
|
+
*/
|
|
186
|
+
const WIND_SCALE = 0.01;
|
|
187
|
+
export const wind = {
|
|
188
|
+
token: 'wind',
|
|
189
|
+
label: 'Wind',
|
|
190
|
+
apply(b, p, e) {
|
|
191
|
+
if (b.range > 0 && e.dist >= b.range)
|
|
192
|
+
return; // range 0 ⇒ global
|
|
193
|
+
const c = curlNoise(p.x, p.y, e.t, WIND_SCALE);
|
|
194
|
+
p.vx += c.x * b.strength;
|
|
195
|
+
p.vy += c.y * b.strength;
|
|
196
|
+
},
|
|
197
|
+
meta: { desc: 'divergence-free curl-noise turbulence' },
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* §20.3 — `cohesion` (class [B], over `env.neighbors`): short-range pressure + mid-range
|
|
201
|
+
* pull, i.e. surface tension. Around a rest distance `r₀` each neighbour pushes `p` away
|
|
202
|
+
* when closer than `r₀` and draws it in when between `r₀` and the neighbour radius `r₁`.
|
|
203
|
+
* The spec's raw `k·(r₀ − d)` is normalized to a unit interval here so velocities stay
|
|
204
|
+
* UI-sane over ~100px distances. `strength` is the stiffness; `r₀ = r₁·0.5` (a fraction
|
|
205
|
+
* of the range, since `data-r0` would need a new Body field); `range` is `r₁`.
|
|
206
|
+
*/
|
|
207
|
+
const COHESION_REST = 0.5; // r₀ as a fraction of r₁
|
|
208
|
+
export const cohesion = {
|
|
209
|
+
token: 'cohesion',
|
|
210
|
+
label: 'Cohesion',
|
|
211
|
+
apply(b, p, e) {
|
|
212
|
+
if (e.dist >= b.range)
|
|
213
|
+
return;
|
|
214
|
+
const r1 = b.range;
|
|
215
|
+
const r0 = r1 * COHESION_REST;
|
|
216
|
+
const k = b.strength;
|
|
217
|
+
for (const n of e.neighbors(p, r1)) {
|
|
218
|
+
const dx = n.x - p.x;
|
|
219
|
+
const dy = n.y - p.y;
|
|
220
|
+
const dzn = (n.z ?? 0) - (p.z ?? 0); // 3D separation in a volume (z-axis.md)
|
|
221
|
+
const dn = Math.hypot(dx, dy, dzn);
|
|
222
|
+
if (dn < 1e-6)
|
|
223
|
+
continue;
|
|
224
|
+
const ux = dx / dn;
|
|
225
|
+
const uy = dy / dn;
|
|
226
|
+
const uz = dzn / dn;
|
|
227
|
+
if (dn < r0) {
|
|
228
|
+
const f = (k * (r0 - dn)) / r0; // pressure: push apart (no overlap)
|
|
229
|
+
p.vx -= f * ux;
|
|
230
|
+
p.vy -= f * uy;
|
|
231
|
+
if (dzn)
|
|
232
|
+
p.vz = (p.vz ?? 0) - f * uz;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
const f = (k * (dn - r0)) / (r1 - r0); // cohesion: pull toward the skin
|
|
236
|
+
p.vx += f * ux;
|
|
237
|
+
p.vy += f * uy;
|
|
238
|
+
if (dzn)
|
|
239
|
+
p.vz = (p.vz ?? 0) + f * uz;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
meta: { desc: 'short-range pressure + mid-range cohesion — surface tension over neighbours' },
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* §20.3 — `pressure` (class [B], over `env.neighbors`): SPH-style density relaxation
|
|
247
|
+
* → an incompressible even-fill. Each particle estimates the local matter density by
|
|
248
|
+
* summing a smooth kernel `W(d, h) = (1 − d/h)²` over its neighbours, then pushes *down*
|
|
249
|
+
* the density gradient whenever it sits above a rest density `ρ₀` — so crowded matter
|
|
250
|
+
* spreads out and settles to an even spacing instead of overlapping. Unlike `cohesion`
|
|
251
|
+
* (which has a mid-range *pull*), pressure only ever pushes apart; the rest density sets
|
|
252
|
+
* the equilibrium spacing. Momentum-conserving for a symmetric pair (each member pushes
|
|
253
|
+
* the other equally and oppositely). `range` is the smoothing radius `h`; `strength` is
|
|
254
|
+
* the stiffness `k`; `ρ₀` is a fixed fraction (a new `data-rho0` would need a Body field).
|
|
255
|
+
*/
|
|
256
|
+
const PRESSURE_REST = 0.5; // ρ₀ — the rest density that sets the equilibrium spacing
|
|
257
|
+
export const pressure = {
|
|
258
|
+
token: 'pressure',
|
|
259
|
+
label: 'Pressure',
|
|
260
|
+
apply(b, p, e) {
|
|
261
|
+
if (e.dist >= b.range)
|
|
262
|
+
return;
|
|
263
|
+
const h = b.range;
|
|
264
|
+
const k = b.strength;
|
|
265
|
+
// first pass: local density ρ_p = Σ W(d, h), W = (1 − d/h)² (a smooth, cheap kernel)
|
|
266
|
+
let rho = 0;
|
|
267
|
+
const ns = e.neighbors(p, h);
|
|
268
|
+
for (const n of ns) {
|
|
269
|
+
const d = Math.hypot(n.x - p.x, n.y - p.y, (n.z ?? 0) - (p.z ?? 0));
|
|
270
|
+
if (d < h)
|
|
271
|
+
rho += (1 - d / h) ** 2;
|
|
272
|
+
}
|
|
273
|
+
const over = rho - PRESSURE_REST; // pressure scalar P = k·(ρ − ρ₀)
|
|
274
|
+
if (over <= 0)
|
|
275
|
+
return; // under-dense → no push (an even fill only relaxes crowding)
|
|
276
|
+
// second pass: push away from each neighbour along the (spiky) density gradient,
|
|
277
|
+
// weighted by how crowded the spot is — strongest at close range (no overlap).
|
|
278
|
+
for (const n of ns) {
|
|
279
|
+
const dx = p.x - n.x; // neighbour → p, i.e. the away-from-crowd direction
|
|
280
|
+
const dy = p.y - n.y;
|
|
281
|
+
const dzn = (p.z ?? 0) - (n.z ?? 0);
|
|
282
|
+
const d = Math.hypot(dx, dy, dzn);
|
|
283
|
+
if (d < 1e-6 || d >= h)
|
|
284
|
+
continue;
|
|
285
|
+
const f = (k * over * (1 - d / h)) / d; // ∝ P · ∇W, normalized by d to use the deltas
|
|
286
|
+
p.vx += f * dx;
|
|
287
|
+
p.vy += f * dy;
|
|
288
|
+
if (dzn)
|
|
289
|
+
p.vz = (p.vz ?? 0) + f * dzn;
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
meta: { desc: 'SPH density relaxation — incompressible even-fill via mutual repulsion' },
|
|
293
|
+
};
|
|
294
|
+
/**
|
|
295
|
+
* §20.3 — `hunt` (class [B], over `env.neighbors`): a two-species pursuit. A particle's
|
|
296
|
+
* `species` sets its role: predators (species `0`) accelerate toward the nearest particle
|
|
297
|
+
* of another species; prey (species ≠ `0`) accelerate directly away from the nearest
|
|
298
|
+
* predator. `range` is the perception radius; `strength` the seek/flee gain. So a field of
|
|
299
|
+
* two species chases and scatters — schooling/fleeing motion. The Lotka–Volterra
|
|
300
|
+
* *population* cycle (births and deaths) is an emergent simulation concern, not this
|
|
301
|
+
* per-particle motion law; `hunt` is the chase itself, honestly.
|
|
302
|
+
*/
|
|
303
|
+
export const hunt = {
|
|
304
|
+
token: 'hunt',
|
|
305
|
+
label: 'Hunt',
|
|
306
|
+
apply(b, p, e) {
|
|
307
|
+
if (e.dist >= b.range)
|
|
308
|
+
return;
|
|
309
|
+
const me = p.species ?? 0;
|
|
310
|
+
// the nearest neighbour of a *different* species — the target to chase or escape
|
|
311
|
+
let target = null;
|
|
312
|
+
let bestD2 = Infinity;
|
|
313
|
+
for (const n of e.neighbors(p, b.range)) {
|
|
314
|
+
if ((n.species ?? 0) === me)
|
|
315
|
+
continue;
|
|
316
|
+
const dx = n.x - p.x;
|
|
317
|
+
const dy = n.y - p.y;
|
|
318
|
+
const dzn = (n.z ?? 0) - (p.z ?? 0);
|
|
319
|
+
const d2 = dx * dx + dy * dy + dzn * dzn;
|
|
320
|
+
if (d2 < bestD2) {
|
|
321
|
+
bestD2 = d2;
|
|
322
|
+
target = n;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (!target)
|
|
326
|
+
return; // nothing of the other species in reach
|
|
327
|
+
const dx = target.x - p.x;
|
|
328
|
+
const dy = target.y - p.y;
|
|
329
|
+
const dzn = (target.z ?? 0) - (p.z ?? 0);
|
|
330
|
+
const d = Math.hypot(dx, dy, dzn) || 1;
|
|
331
|
+
const dir = me === 0 ? 1 : -1; // predator seeks (toward), prey flees (away)
|
|
332
|
+
p.vx += (dx / d) * b.strength * dir;
|
|
333
|
+
p.vy += (dy / d) * b.strength * dir;
|
|
334
|
+
if (dzn)
|
|
335
|
+
p.vz = (p.vz ?? 0) + (dzn / d) * b.strength * dir;
|
|
336
|
+
},
|
|
337
|
+
meta: { desc: 'two-species pursuit — predators seek prey, prey flee predators' },
|
|
338
|
+
};
|
|
339
|
+
/**
|
|
340
|
+
* §20.3 — `link` (class [B], over `env.neighbors`): a Verlet distance constraint that
|
|
341
|
+
* holds matter at a rest length, so a dense blob behaves as rope / chain / cloth — a soft
|
|
342
|
+
* structure rather than a gas. The spec declares explicit pairs (`data-link="a b"`), but
|
|
343
|
+
* this engine's particles are an anonymous pool, so `link` bonds to *every* neighbour
|
|
344
|
+
* inside a bond radius (`range`) and pulls each toward the rest length `L = range·0.35`:
|
|
345
|
+
* too far → pull in, too close → push out, stiffness `k = strength`. Each particle applies
|
|
346
|
+
* only *half* the correction toward each partner; the partner applies its half on its own
|
|
347
|
+
* turn, so the pair satisfies the constraint symmetrically and momentum is conserved.
|
|
348
|
+
*/
|
|
349
|
+
const LINK_REST = 0.35; // rest length L as a fraction of the bond radius (range)
|
|
350
|
+
export const link = {
|
|
351
|
+
token: 'link',
|
|
352
|
+
label: 'Link',
|
|
353
|
+
apply(b, p, e) {
|
|
354
|
+
if (e.dist >= b.range)
|
|
355
|
+
return;
|
|
356
|
+
const r = b.range;
|
|
357
|
+
const L = r * LINK_REST;
|
|
358
|
+
const k = b.strength;
|
|
359
|
+
for (const n of e.neighbors(p, r)) {
|
|
360
|
+
const dx = n.x - p.x;
|
|
361
|
+
const dy = n.y - p.y;
|
|
362
|
+
const dzn = (n.z ?? 0) - (p.z ?? 0); // 3D bond length in a volume (z-axis.md)
|
|
363
|
+
const d = Math.hypot(dx, dy, dzn);
|
|
364
|
+
if (d < 1e-6)
|
|
365
|
+
continue;
|
|
366
|
+
const err = d - L; // +ve → too far (pull together); −ve → too close (push apart)
|
|
367
|
+
const f = 0.5 * k * (err / L); // half the Verlet correction; the partner does its half
|
|
368
|
+
p.vx += f * (dx / d);
|
|
369
|
+
p.vy += f * (dy / d);
|
|
370
|
+
if (dzn)
|
|
371
|
+
p.vz = (p.vz ?? 0) + f * (dzn / d);
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
meta: { desc: 'a Verlet distance constraint — holds a rest length, so matter ropes and drapes' },
|
|
375
|
+
};
|
|
376
|
+
/**
|
|
377
|
+
* §20.3 — `morph` (class [D]): matter assembles into a shape. Each particle is assigned
|
|
378
|
+
* a stable target point from the body's `targets` set (hashed from its fixed scatter
|
|
379
|
+
* fraction `gx`, so the assignment never flickers frame to frame), springs toward it, and
|
|
380
|
+
* the random jitter fades as it arrives — so the swarm settles into the form. `strength`
|
|
381
|
+
* is the spring gain.
|
|
382
|
+
*
|
|
383
|
+
* **DESIGN LAW (§11):** targets are *marks* — a logo, an icon, a chart, a map,
|
|
384
|
+
* punctuation — **never words or letterforms**. Text is rendered as text and made to
|
|
385
|
+
* react (glow/grow via `--d`, §8); particles never spell. The `targets` set must come
|
|
386
|
+
* from a non-word source.
|
|
387
|
+
*
|
|
388
|
+
* **Reach:** like every ranged body, the engine only applies morph to matter within ~1.6×
|
|
389
|
+
* the body's `range` of its centre (the integrator's cull radius). So `range` is morph's
|
|
390
|
+
* *recruitment radius* — distant matter is not pulled into the form. To assemble from the
|
|
391
|
+
* whole field, give the body a large range, or `data-range="0"` (global, never culled).
|
|
392
|
+
*/
|
|
393
|
+
const MORPH_ARRIVE = 40; // px within which a particle counts as "arrived" (jitter fades)
|
|
394
|
+
export const morph = {
|
|
395
|
+
token: 'morph',
|
|
396
|
+
label: 'Morph',
|
|
397
|
+
apply(b, p, e) {
|
|
398
|
+
const ts = b.targets;
|
|
399
|
+
if (!ts || ts.length === 0)
|
|
400
|
+
return; // no shape assigned → inert
|
|
401
|
+
// stable assignment: hash the particle's fixed scatter fraction to a target index, so
|
|
402
|
+
// a given particle always aims at the same point (no flicker as the pool reorders).
|
|
403
|
+
const i = Math.min(ts.length - 1, Math.floor((p.gx ?? 0) * ts.length));
|
|
404
|
+
const t = ts[i];
|
|
405
|
+
const dx = t.x - p.x;
|
|
406
|
+
const dy = t.y - p.y;
|
|
407
|
+
const d = Math.hypot(dx, dy);
|
|
408
|
+
const k = b.strength;
|
|
409
|
+
p.vx += dx * k * 0.02; // spring toward the target point
|
|
410
|
+
p.vy += dy * k * 0.02;
|
|
411
|
+
// targets are marks on the page plane (z-axis.md): the same spring returns matter to z = 0.
|
|
412
|
+
if (p.z)
|
|
413
|
+
p.vz = (p.vz ?? 0) - p.z * k * 0.02;
|
|
414
|
+
const arrived = d < MORPH_ARRIVE ? 1 - d / MORPH_ARRIVE : 0;
|
|
415
|
+
const jit = (1 - arrived) * k * 0.3; // jitter that fades to zero on arrival
|
|
416
|
+
if (jit > 0) {
|
|
417
|
+
p.vx += ((e.rng ?? Math.random)() - 0.5) * jit;
|
|
418
|
+
p.vy += ((e.rng ?? Math.random)() - 0.5) * jit;
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
meta: { desc: 'matter assembles into a mark/chart/logo — never words (§11)' },
|
|
422
|
+
};
|
|
423
|
+
/**
|
|
424
|
+
* §20.1/§20.2 — `spawn` (class [S], the source *atom*): the one force that *creates*
|
|
425
|
+
* matter rather than moving it. While its body is engaged it emits particles each frame
|
|
426
|
+
* at the body centre, launched along the heading `(ux, uy)` within a soft cone. This
|
|
427
|
+
* deliberately breaks conservation (§2.4), so every spawned particle is **mortal**: it
|
|
428
|
+
* carries a finite `age` and despawns when it expires (the integrator's [S] sink), and
|
|
429
|
+
* the engine caps the pool besides — a budgeted source, per the §20.1 conservation note.
|
|
430
|
+
* `strength` sets the emission rate; `angle` the direction. `fountain` is the preset that
|
|
431
|
+
* names a continuous upward spawn; `supernova` is its one-shot cousin (the conserved
|
|
432
|
+
* absorb→release event is the everyday path — reach for [S] only when creation is the
|
|
433
|
+
* point). A pure source: `apply` is a no-op, the work is in `source()`.
|
|
434
|
+
*/
|
|
435
|
+
export const SPAWN_LIFE = 90; // default lifespan (frames) when the body declares no data-life
|
|
436
|
+
export const spawn = {
|
|
437
|
+
token: 'spawn',
|
|
438
|
+
label: 'Spawn',
|
|
439
|
+
apply() { }, // a source, not a per-particle force — the work is in source()
|
|
440
|
+
source(b, e) {
|
|
441
|
+
// emit continuously (a fountain flows while on-screen); the integrator's source pass
|
|
442
|
+
// already skips non-visible bodies, so an off-screen source is silent.
|
|
443
|
+
//
|
|
444
|
+
// The source budget (workover v0.3 §"Source and sink rules"): each emission carries the
|
|
445
|
+
// body's `data-life` (default 90 frames), and `data-cap` clamps the emission rate to
|
|
446
|
+
// `cap / life` per frame — so the body's live spawned population is bounded at ~cap,
|
|
447
|
+
// independent of the engine's pool ceiling. A fractional rate accumulates on the body
|
|
448
|
+
// (b.emitAcc) so sub-1/frame budgets still flow.
|
|
449
|
+
const life = b.life ?? SPAWN_LIFE;
|
|
450
|
+
let rate = Math.max(1, Math.round(b.strength * 2)); // particles per frame
|
|
451
|
+
if (b.cap != null && b.cap > 0 && life > 0)
|
|
452
|
+
rate = Math.min(rate, b.cap / life);
|
|
453
|
+
b.emitAcc = (b.emitAcc ?? 0) + rate;
|
|
454
|
+
let n = Math.floor(b.emitAcc);
|
|
455
|
+
b.emitAcc -= n;
|
|
456
|
+
for (; n > 0; n--) {
|
|
457
|
+
// rotate the heading by a small random angle → a soft emission cone
|
|
458
|
+
const j = ((e.rng ?? Math.random)() - 0.5) * 0.6;
|
|
459
|
+
const c = Math.cos(j);
|
|
460
|
+
const s = Math.sin(j);
|
|
461
|
+
const hx = b.ux * c - b.uy * s;
|
|
462
|
+
const hy = b.ux * s + b.uy * c;
|
|
463
|
+
const speed = 2 + (e.rng ?? Math.random)() * 2;
|
|
464
|
+
e.spawn({ x: b.cx, y: b.cy, vx: hx * speed, vy: hy * speed, age: life, heat: 0.6 });
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
meta: { desc: 'a source — emits matter along the heading, budgeted by a lifespan' },
|
|
468
|
+
};
|
|
469
|
+
/**
|
|
470
|
+
* §20.3 — `resonate`: a *modifier* that pulses its sibling forces. It contributes no
|
|
471
|
+
* force of its own; instead `modify` returns a time-varying strength multiplier
|
|
472
|
+
* `1 + sin(ω·t)` (the spec's `S(t) = S₀·(1 + sin(ωt + φ))`), so e.g. `resonate attract`
|
|
473
|
+
* is a well that breathes. `spin` tunes the rate (`data-omega` not yet a Body field).
|
|
474
|
+
*/
|
|
475
|
+
const RESONATE_OMEGA = 3;
|
|
476
|
+
export const resonate = {
|
|
477
|
+
token: 'resonate',
|
|
478
|
+
label: 'Resonate',
|
|
479
|
+
apply() { }, // pure modifier — the work is in modify()
|
|
480
|
+
modify(b, _p, e) {
|
|
481
|
+
return { strength: 1 + Math.sin(e.t * RESONATE_OMEGA * b.spin) };
|
|
482
|
+
},
|
|
483
|
+
meta: { desc: 'pulses sibling forces with a time-varying strength S(t)=S₀(1+sin ωt)' },
|
|
484
|
+
};
|
|
485
|
+
/**
|
|
486
|
+
* §20.3 — `spotlight`: a *modifier* that gates its sibling forces to an angular cone of
|
|
487
|
+
* the heading `(ux, uy)`. A particle outside the cone is skipped for *every* token on
|
|
488
|
+
* the body this frame; inside, the siblings act normally — so `spotlight stream` is a
|
|
489
|
+
* directed beam. Cone half-angle is fixed (~60°; `data-fov` not yet a Body field).
|
|
490
|
+
*/
|
|
491
|
+
const SPOTLIGHT_COS = 0.5;
|
|
492
|
+
export const spotlight = {
|
|
493
|
+
token: 'spotlight',
|
|
494
|
+
label: 'Spotlight',
|
|
495
|
+
apply() { }, // pure modifier — the work is in modify()
|
|
496
|
+
modify(b, _p, e) {
|
|
497
|
+
const dirx = -e.dx / e.dist; // body → particle (e.dx points particle → body)
|
|
498
|
+
const diry = -e.dy / e.dist;
|
|
499
|
+
return { gate: dirx * b.ux + diry * b.uy < SPOTLIGHT_COS };
|
|
500
|
+
},
|
|
501
|
+
meta: { desc: 'gates sibling forces to an angular cone of the heading' },
|
|
502
|
+
};
|
|
503
|
+
/**
|
|
504
|
+
* Workover v0.3 — `screen`: a quiet zone / shield (truth mode: designed). A body carrying
|
|
505
|
+
* `screen` damps the magnitude of OTHER bodies' forces on matter inside its `data-range`,
|
|
506
|
+
* by `screenFactor` (`core/math.ts`) — text shielded from a noisy field, a calm pocket in a
|
|
507
|
+
* busy page.
|
|
508
|
+
* Cross-body by definition, so the work happens in the integrator's force pass (the only
|
|
509
|
+
* place per-particle, per-body forces compose); this module is the registered token, the
|
|
510
|
+
* passported identity, and the pure math. `apply` is a no-op — like `spotlight`/`resonate`
|
|
511
|
+
* it is a modifier, but one that modifies its *neighbors*, not its own siblings. Initial
|
|
512
|
+
* mode is `local` (the only shipped mode; `data-screen-mode` inside/outside/behind are
|
|
513
|
+
* future work). Probe-style diagnostic samplers (`forceAt`, the Lab's frame-0 delta) read
|
|
514
|
+
* raw forces and do not apply screen attenuation — the integrator is the contract.
|
|
515
|
+
*/
|
|
516
|
+
export const screen = {
|
|
517
|
+
token: 'screen',
|
|
518
|
+
label: 'Screen',
|
|
519
|
+
apply() { }, // a cross-body modifier — the attenuation lives in the integrator's force pass
|
|
520
|
+
meta: { desc: "a quiet zone — attenuates other bodies' forces on matter inside its radius" },
|
|
521
|
+
};
|
|
522
|
+
/**
|
|
523
|
+
* §20.8 — `pigment` (class [E], particle attribute `color`): conserved color
|
|
524
|
+
* transport. A particle that overlaps a pigment body takes on the body's tint
|
|
525
|
+
* (`data-color`) and carries it away — the section *stains* the field, and the
|
|
526
|
+
* color advects with the matter instead of being re-tinted globally. Opt-in and
|
|
527
|
+
* inert without a tint, so a normal field is untouched.
|
|
528
|
+
*/
|
|
529
|
+
export const pigment = {
|
|
530
|
+
token: 'pigment',
|
|
531
|
+
label: 'Pigment',
|
|
532
|
+
apply(b, p, e) {
|
|
533
|
+
const tint = b.tint;
|
|
534
|
+
if (!tint || e.dist >= b.range * 0.6)
|
|
535
|
+
return; // only stains on overlap
|
|
536
|
+
p.color = p.color ? mixHex(p.color, tint, 0.08) : tint; // adopt, then advect toward
|
|
537
|
+
},
|
|
538
|
+
meta: { desc: 'conserved color transport — matter takes on and carries a tint' },
|
|
539
|
+
};
|
|
540
|
+
/**
|
|
541
|
+
* §20.3 — `fieldflow`: follow the field lines. Where `magnetism` curls a moving charge
|
|
542
|
+
* *across* the field (the perpendicular Lorentz force, no work) and `charge` pushes only
|
|
543
|
+
* *charged* matter along its own radial field, `fieldflow` advects ALL matter ALONG the net
|
|
544
|
+
* structure field every body radiates — the superposition of every `field()` hook, read
|
|
545
|
+
* through `env.fieldAt`. It both **steers** velocity onto the local field line (speed-
|
|
546
|
+
* preserving, like `align`) and **accelerates** matter down it (does work), so a swarm
|
|
547
|
+
* threads the dipole loops of a magnet or streams off a charge like plasma along a solar
|
|
548
|
+
* prominence. The line *direction* is used scale-free (normalized), so a weak dipole channels
|
|
549
|
+
* matter as surely as a strong monopole — the look no longer depends on the field's absolute
|
|
550
|
+
* magnitude. Because it follows the *net* field (not just this body's), matter routes along
|
|
551
|
+
* the lines that link two poles, so the channelling *between* bodies emerges from the geometry.
|
|
552
|
+
* `strength` is the gain; the `(1 − d/r)` falloff localizes it (range 0 ⇒ a global field-follow,
|
|
553
|
+
* the `magnetic` formation). Acts on neutral matter too — it is field transport of the medium,
|
|
554
|
+
* not the charge-gated Lorentz force.
|
|
555
|
+
*/
|
|
556
|
+
const FIELDFLOW_STEER = 0.5; // fraction of velocity turned onto the line per frame (× gain)
|
|
557
|
+
const FIELDFLOW_ACCEL = 0.12; // streaming acceleration along the line (× gain)
|
|
558
|
+
export const fieldflow = {
|
|
559
|
+
token: 'fieldflow',
|
|
560
|
+
label: 'Field Flow',
|
|
561
|
+
apply(b, p, e) {
|
|
562
|
+
if (b.range > 0 && e.dist >= b.range)
|
|
563
|
+
return; // range 0 ⇒ global
|
|
564
|
+
const F = e.fieldAt?.(p.x, p.y); // the net field every body's field() radiates here
|
|
565
|
+
if (!F)
|
|
566
|
+
return;
|
|
567
|
+
const mag = Math.hypot(F.x, F.y);
|
|
568
|
+
if (!(mag > 1e-9))
|
|
569
|
+
return; // a true null point (or NaN) — no line to follow
|
|
570
|
+
const ux = F.x / mag; // the field-line tangent (direction only — scale-free, so a faint
|
|
571
|
+
const uy = F.y / mag; // dipole reads as clearly as a strong monopole)
|
|
572
|
+
const falloff = b.range > 0 ? 1 - e.dist / b.range : 1;
|
|
573
|
+
const gain = b.strength * falloff;
|
|
574
|
+
// 1) STEER onto the line — turn velocity toward the tangent without spending it (like `align`).
|
|
575
|
+
// The structure field is planar (bodies radiate in the page plane), so the steer also
|
|
576
|
+
// turns any z velocity onto the in-plane line — matter funnels back toward the plane.
|
|
577
|
+
const pvz = p.vz ?? 0;
|
|
578
|
+
const sp = Math.hypot(p.vx, p.vy, pvz);
|
|
579
|
+
if (sp > 1e-6) {
|
|
580
|
+
const k = Math.min(1, gain * FIELDFLOW_STEER);
|
|
581
|
+
p.vx += (ux * sp - p.vx) * k;
|
|
582
|
+
p.vy += (uy * sp - p.vy) * k;
|
|
583
|
+
if (pvz)
|
|
584
|
+
p.vz = pvz + (0 - pvz) * k; // the line's z tangent is 0
|
|
585
|
+
}
|
|
586
|
+
// 2) STREAM down the line — accelerate along it (the flare ejection; does work).
|
|
587
|
+
p.vx += ux * gain * FIELDFLOW_ACCEL;
|
|
588
|
+
p.vy += uy * gain * FIELDFLOW_ACCEL;
|
|
589
|
+
// bound by the unit system's speed of light (§20.10), as gravity/thermal do.
|
|
590
|
+
const vz2 = p.vz ?? 0;
|
|
591
|
+
const s2 = p.vx * p.vx + p.vy * p.vy + vz2 * vz2;
|
|
592
|
+
if (s2 > e.c * e.c) {
|
|
593
|
+
const inv = e.c / Math.sqrt(s2);
|
|
594
|
+
p.vx *= inv;
|
|
595
|
+
p.vy *= inv;
|
|
596
|
+
if (vz2)
|
|
597
|
+
p.vz = vz2 * inv;
|
|
598
|
+
}
|
|
599
|
+
if (b.on)
|
|
600
|
+
p.heat = Math.max(p.heat, falloff * 0.4);
|
|
601
|
+
},
|
|
602
|
+
meta: { desc: 'follow the field lines — steer onto and stream down the net field a body radiates' },
|
|
603
|
+
};
|
|
604
|
+
/** The designed extended forces, in spec order (§20.3). */
|
|
605
|
+
/**
|
|
606
|
+
* §22.3 — `warp`: a wormhole throat. Matter that enters the throat (within `absorbR`) is
|
|
607
|
+
* *relocated* — conserved, not created or destroyed — to the paired body's throat, emerging just
|
|
608
|
+
* outside it moving outward, with its local offset and velocity rotated by `data-twist` and scaled by
|
|
609
|
+
* `data-scale`. The pairing (`data-pair="#other"`) and the live target centre are resolved by the
|
|
610
|
+
* engine into `b.warpHas` / `b.warpX` / `b.warpY`; the force no-ops with no resolved target. Marked
|
|
611
|
+
* `kinematic` so it sets position/velocity outright (a teleport), unscaled by inertia.
|
|
612
|
+
*/
|
|
613
|
+
export const warp = {
|
|
614
|
+
token: 'warp',
|
|
615
|
+
label: 'Warp',
|
|
616
|
+
kinematic: true,
|
|
617
|
+
apply(b, p, e) {
|
|
618
|
+
if (!b.warpHas || p.cap)
|
|
619
|
+
return;
|
|
620
|
+
const throat = b.absorbR;
|
|
621
|
+
if (e.dist >= throat)
|
|
622
|
+
return;
|
|
623
|
+
const cs = Math.cos(b.twist ?? 0);
|
|
624
|
+
const sn = Math.sin(b.twist ?? 0);
|
|
625
|
+
const k = b.warpScale ?? 1;
|
|
626
|
+
// entry direction (unit local offset from this throat, e.dx/e.dy point *toward* the body), twisted
|
|
627
|
+
const ux = -e.dx / e.dist;
|
|
628
|
+
const uy = -e.dy / e.dist;
|
|
629
|
+
const rux = ux * cs - uy * sn;
|
|
630
|
+
const ruy = ux * sn + uy * cs;
|
|
631
|
+
// emerge just outside the paired throat so it does not immediately re-enter (no ping-pong)
|
|
632
|
+
const outR = throat * k + 6;
|
|
633
|
+
p.x = b.warpX + rux * outR;
|
|
634
|
+
p.y = b.warpY + ruy * outR;
|
|
635
|
+
// the paired throat sits on the page plane: the z offset passes through unscaled
|
|
636
|
+
// (the twist is about the z axis, so z is its rotation invariant).
|
|
637
|
+
if (p.z)
|
|
638
|
+
p.z = (-e.dz / e.dist) * outR;
|
|
639
|
+
// carry momentum through, rotated by the same twist (speed conserved; vz invariant)
|
|
640
|
+
const vx = p.vx;
|
|
641
|
+
const vy = p.vy;
|
|
642
|
+
p.vx = vx * cs - vy * sn;
|
|
643
|
+
p.vy = vx * sn + vy * cs;
|
|
644
|
+
p.heat = Math.max(p.heat, 0.6);
|
|
645
|
+
},
|
|
646
|
+
meta: { desc: 'a wormhole throat — relocates matter to its paired body, conserved' },
|
|
647
|
+
};
|
|
648
|
+
export const extendedForces = [
|
|
649
|
+
lens,
|
|
650
|
+
gate,
|
|
651
|
+
buoyancy,
|
|
652
|
+
shear,
|
|
653
|
+
crystallize,
|
|
654
|
+
align,
|
|
655
|
+
wind,
|
|
656
|
+
cohesion,
|
|
657
|
+
pressure,
|
|
658
|
+
link,
|
|
659
|
+
hunt,
|
|
660
|
+
morph,
|
|
661
|
+
spawn,
|
|
662
|
+
resonate,
|
|
663
|
+
spotlight,
|
|
664
|
+
screen,
|
|
665
|
+
pigment,
|
|
666
|
+
fieldflow,
|
|
667
|
+
warp,
|
|
668
|
+
];
|
|
669
|
+
/** Register the designed extended forces on a registry (§4) — opt-in, alongside the nine. */
|
|
670
|
+
export function registerExtendedForces(reg) {
|
|
671
|
+
for (const f of extendedForces)
|
|
672
|
+
reg.force(f);
|
|
673
|
+
}
|
|
674
|
+
//# sourceMappingURL=extended.js.map
|