@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.
Files changed (371) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/dist/agents/element-agent.d.ts +38 -0
  4. package/dist/agents/element-agent.d.ts.map +1 -0
  5. package/dist/agents/element-agent.js +70 -0
  6. package/dist/agents/element-agent.js.map +1 -0
  7. package/dist/agents/event-agent.d.ts +47 -0
  8. package/dist/agents/event-agent.d.ts.map +1 -0
  9. package/dist/agents/event-agent.js +82 -0
  10. package/dist/agents/event-agent.js.map +1 -0
  11. package/dist/agents/index.d.ts +17 -0
  12. package/dist/agents/index.d.ts.map +1 -0
  13. package/dist/agents/index.js +57 -0
  14. package/dist/agents/index.js.map +1 -0
  15. package/dist/agents/region-agents.d.ts +40 -0
  16. package/dist/agents/region-agents.d.ts.map +1 -0
  17. package/dist/agents/region-agents.js +22 -0
  18. package/dist/agents/region-agents.js.map +1 -0
  19. package/dist/agents/relationship.d.ts +55 -0
  20. package/dist/agents/relationship.d.ts.map +1 -0
  21. package/dist/agents/relationship.js +40 -0
  22. package/dist/agents/relationship.js.map +1 -0
  23. package/dist/agents/user-agent.d.ts +57 -0
  24. package/dist/agents/user-agent.d.ts.map +1 -0
  25. package/dist/agents/user-agent.js +45 -0
  26. package/dist/agents/user-agent.js.map +1 -0
  27. package/dist/config/forces.config.d.ts +101 -0
  28. package/dist/config/forces.config.d.ts.map +1 -0
  29. package/dist/config/forces.config.js +239 -0
  30. package/dist/config/forces.config.js.map +1 -0
  31. package/dist/config/manual.d.ts +134 -0
  32. package/dist/config/manual.d.ts.map +1 -0
  33. package/dist/config/manual.js +604 -0
  34. package/dist/config/manual.js.map +1 -0
  35. package/dist/config/palettes.d.ts +18 -0
  36. package/dist/config/palettes.d.ts.map +1 -0
  37. package/dist/config/palettes.js +34 -0
  38. package/dist/config/palettes.js.map +1 -0
  39. package/dist/config/presets.d.ts +48 -0
  40. package/dist/config/presets.d.ts.map +1 -0
  41. package/dist/config/presets.js +87 -0
  42. package/dist/config/presets.js.map +1 -0
  43. package/dist/config/tokens.d.ts +3 -0
  44. package/dist/config/tokens.d.ts.map +1 -0
  45. package/dist/config/tokens.js +16 -0
  46. package/dist/config/tokens.js.map +1 -0
  47. package/dist/conformance/expectations.d.ts +40 -0
  48. package/dist/conformance/expectations.d.ts.map +1 -0
  49. package/dist/conformance/expectations.js +347 -0
  50. package/dist/conformance/expectations.js.map +1 -0
  51. package/dist/conformance/experiments.d.ts +17 -0
  52. package/dist/conformance/experiments.d.ts.map +1 -0
  53. package/dist/conformance/experiments.js +875 -0
  54. package/dist/conformance/experiments.js.map +1 -0
  55. package/dist/conformance/run.d.ts +18 -0
  56. package/dist/conformance/run.d.ts.map +1 -0
  57. package/dist/conformance/run.js +240 -0
  58. package/dist/conformance/run.js.map +1 -0
  59. package/dist/conformance/types.d.ts +100 -0
  60. package/dist/conformance/types.d.ts.map +1 -0
  61. package/dist/conformance/types.js +2 -0
  62. package/dist/conformance/types.js.map +1 -0
  63. package/dist/contracts/guards.d.ts +51 -0
  64. package/dist/contracts/guards.d.ts.map +1 -0
  65. package/dist/contracts/guards.js +100 -0
  66. package/dist/contracts/guards.js.map +1 -0
  67. package/dist/contracts/index.d.ts +18 -0
  68. package/dist/contracts/index.d.ts.map +1 -0
  69. package/dist/contracts/index.js +107 -0
  70. package/dist/contracts/index.js.map +1 -0
  71. package/dist/contracts/passport.d.ts +88 -0
  72. package/dist/contracts/passport.d.ts.map +1 -0
  73. package/dist/contracts/passport.js +135 -0
  74. package/dist/contracts/passport.js.map +1 -0
  75. package/dist/contracts/types.d.ts +120 -0
  76. package/dist/contracts/types.d.ts.map +1 -0
  77. package/dist/contracts/types.js +24 -0
  78. package/dist/contracts/types.js.map +1 -0
  79. package/dist/core/accretion.d.ts +50 -0
  80. package/dist/core/accretion.d.ts.map +1 -0
  81. package/dist/core/accretion.js +98 -0
  82. package/dist/core/accretion.js.map +1 -0
  83. package/dist/core/agents.d.ts +31 -0
  84. package/dist/core/agents.d.ts.map +1 -0
  85. package/dist/core/agents.js +51 -0
  86. package/dist/core/agents.js.map +1 -0
  87. package/dist/core/attention.d.ts +72 -0
  88. package/dist/core/attention.d.ts.map +1 -0
  89. package/dist/core/attention.js +122 -0
  90. package/dist/core/attention.js.map +1 -0
  91. package/dist/core/causality.d.ts +38 -0
  92. package/dist/core/causality.d.ts.map +1 -0
  93. package/dist/core/causality.js +64 -0
  94. package/dist/core/causality.js.map +1 -0
  95. package/dist/core/conditions.d.ts +10 -0
  96. package/dist/core/conditions.d.ts.map +1 -0
  97. package/dist/core/conditions.js +22 -0
  98. package/dist/core/conditions.js.map +1 -0
  99. package/dist/core/currents.d.ts +53 -0
  100. package/dist/core/currents.d.ts.map +1 -0
  101. package/dist/core/currents.js +65 -0
  102. package/dist/core/currents.js.map +1 -0
  103. package/dist/core/dock.d.ts +35 -0
  104. package/dist/core/dock.d.ts.map +1 -0
  105. package/dist/core/dock.js +39 -0
  106. package/dist/core/dock.js.map +1 -0
  107. package/dist/core/events.d.ts +23 -0
  108. package/dist/core/events.d.ts.map +1 -0
  109. package/dist/core/events.js +34 -0
  110. package/dist/core/events.js.map +1 -0
  111. package/dist/core/feedback-sink.d.ts +32 -0
  112. package/dist/core/feedback-sink.d.ts.map +1 -0
  113. package/dist/core/feedback-sink.js +53 -0
  114. package/dist/core/feedback-sink.js.map +1 -0
  115. package/dist/core/feedback.d.ts +11 -0
  116. package/dist/core/feedback.d.ts.map +1 -0
  117. package/dist/core/feedback.js +16 -0
  118. package/dist/core/feedback.js.map +1 -0
  119. package/dist/core/field-store.d.ts +26 -0
  120. package/dist/core/field-store.d.ts.map +1 -0
  121. package/dist/core/field-store.js +54 -0
  122. package/dist/core/field-store.js.map +1 -0
  123. package/dist/core/field.d.ts +18 -0
  124. package/dist/core/field.d.ts.map +1 -0
  125. package/dist/core/field.js +1943 -0
  126. package/dist/core/field.js.map +1 -0
  127. package/dist/core/fieldline-seeds.d.ts +25 -0
  128. package/dist/core/fieldline-seeds.d.ts.map +1 -0
  129. package/dist/core/fieldline-seeds.js +32 -0
  130. package/dist/core/fieldline-seeds.js.map +1 -0
  131. package/dist/core/fieldlines.d.ts +75 -0
  132. package/dist/core/fieldlines.d.ts.map +1 -0
  133. package/dist/core/fieldlines.js +111 -0
  134. package/dist/core/fieldlines.js.map +1 -0
  135. package/dist/core/flow.d.ts +38 -0
  136. package/dist/core/flow.d.ts.map +1 -0
  137. package/dist/core/flow.js +27 -0
  138. package/dist/core/flow.js.map +1 -0
  139. package/dist/core/formations.d.ts +11 -0
  140. package/dist/core/formations.d.ts.map +1 -0
  141. package/dist/core/formations.js +22 -0
  142. package/dist/core/formations.js.map +1 -0
  143. package/dist/core/geometry.d.ts +67 -0
  144. package/dist/core/geometry.d.ts.map +1 -0
  145. package/dist/core/geometry.js +68 -0
  146. package/dist/core/geometry.js.map +1 -0
  147. package/dist/core/heatmap.d.ts +22 -0
  148. package/dist/core/heatmap.d.ts.map +1 -0
  149. package/dist/core/heatmap.js +55 -0
  150. package/dist/core/heatmap.js.map +1 -0
  151. package/dist/core/host.d.ts +46 -0
  152. package/dist/core/host.d.ts.map +1 -0
  153. package/dist/core/host.js +11 -0
  154. package/dist/core/host.js.map +1 -0
  155. package/dist/core/integrator.d.ts +24 -0
  156. package/dist/core/integrator.d.ts.map +1 -0
  157. package/dist/core/integrator.js +375 -0
  158. package/dist/core/integrator.js.map +1 -0
  159. package/dist/core/math.d.ts +37 -0
  160. package/dist/core/math.d.ts.map +1 -0
  161. package/dist/core/math.js +77 -0
  162. package/dist/core/math.js.map +1 -0
  163. package/dist/core/reactions.d.ts +32 -0
  164. package/dist/core/reactions.d.ts.map +1 -0
  165. package/dist/core/reactions.js +45 -0
  166. package/dist/core/reactions.js.map +1 -0
  167. package/dist/core/registry.d.ts +13 -0
  168. package/dist/core/registry.d.ts.map +1 -0
  169. package/dist/core/registry.js +20 -0
  170. package/dist/core/registry.js.map +1 -0
  171. package/dist/core/render-backend.d.ts +46 -0
  172. package/dist/core/render-backend.d.ts.map +1 -0
  173. package/dist/core/render-backend.js +75 -0
  174. package/dist/core/render-backend.js.map +1 -0
  175. package/dist/core/render-modes.d.ts +42 -0
  176. package/dist/core/render-modes.d.ts.map +1 -0
  177. package/dist/core/render-modes.js +141 -0
  178. package/dist/core/render-modes.js.map +1 -0
  179. package/dist/core/reservoir.d.ts +43 -0
  180. package/dist/core/reservoir.d.ts.map +1 -0
  181. package/dist/core/reservoir.js +207 -0
  182. package/dist/core/reservoir.js.map +1 -0
  183. package/dist/core/scalar-grid.d.ts +51 -0
  184. package/dist/core/scalar-grid.d.ts.map +1 -0
  185. package/dist/core/scalar-grid.js +146 -0
  186. package/dist/core/scalar-grid.js.map +1 -0
  187. package/dist/core/scanner.d.ts +59 -0
  188. package/dist/core/scanner.d.ts.map +1 -0
  189. package/dist/core/scanner.js +260 -0
  190. package/dist/core/scanner.js.map +1 -0
  191. package/dist/core/shadow.d.ts +69 -0
  192. package/dist/core/shadow.d.ts.map +1 -0
  193. package/dist/core/shadow.js +84 -0
  194. package/dist/core/shadow.js.map +1 -0
  195. package/dist/core/spatial-hash.d.ts +30 -0
  196. package/dist/core/spatial-hash.d.ts.map +1 -0
  197. package/dist/core/spatial-hash.js +64 -0
  198. package/dist/core/spatial-hash.js.map +1 -0
  199. package/dist/core/streamlines.d.ts +29 -0
  200. package/dist/core/streamlines.d.ts.map +1 -0
  201. package/dist/core/streamlines.js +70 -0
  202. package/dist/core/streamlines.js.map +1 -0
  203. package/dist/core/surface.d.ts +19 -0
  204. package/dist/core/surface.d.ts.map +1 -0
  205. package/dist/core/surface.js +21 -0
  206. package/dist/core/surface.js.map +1 -0
  207. package/dist/core/temporal.d.ts +110 -0
  208. package/dist/core/temporal.d.ts.map +1 -0
  209. package/dist/core/temporal.js +139 -0
  210. package/dist/core/temporal.js.map +1 -0
  211. package/dist/core/thermo.d.ts +48 -0
  212. package/dist/core/thermo.d.ts.map +1 -0
  213. package/dist/core/thermo.js +48 -0
  214. package/dist/core/thermo.js.map +1 -0
  215. package/dist/core/types.d.ts +610 -0
  216. package/dist/core/types.d.ts.map +1 -0
  217. package/dist/core/types.js +2 -0
  218. package/dist/core/types.js.map +1 -0
  219. package/dist/core/weights.d.ts +111 -0
  220. package/dist/core/weights.d.ts.map +1 -0
  221. package/dist/core/weights.js +128 -0
  222. package/dist/core/weights.js.map +1 -0
  223. package/dist/diagnostics/energy.d.ts +21 -0
  224. package/dist/diagnostics/energy.d.ts.map +1 -0
  225. package/dist/diagnostics/energy.js +27 -0
  226. package/dist/diagnostics/energy.js.map +1 -0
  227. package/dist/diagnostics/fields.d.ts +23 -0
  228. package/dist/diagnostics/fields.d.ts.map +1 -0
  229. package/dist/diagnostics/fields.js +30 -0
  230. package/dist/diagnostics/fields.js.map +1 -0
  231. package/dist/diagnostics/index.d.ts +46 -0
  232. package/dist/diagnostics/index.d.ts.map +1 -0
  233. package/dist/diagnostics/index.js +23 -0
  234. package/dist/diagnostics/index.js.map +1 -0
  235. package/dist/diagnostics/modes.d.ts +108 -0
  236. package/dist/diagnostics/modes.d.ts.map +1 -0
  237. package/dist/diagnostics/modes.js +181 -0
  238. package/dist/diagnostics/modes.js.map +1 -0
  239. package/dist/diagnostics/potential.d.ts +30 -0
  240. package/dist/diagnostics/potential.d.ts.map +1 -0
  241. package/dist/diagnostics/potential.js +43 -0
  242. package/dist/diagnostics/potential.js.map +1 -0
  243. package/dist/diagnostics/probes.d.ts +31 -0
  244. package/dist/diagnostics/probes.d.ts.map +1 -0
  245. package/dist/diagnostics/probes.js +61 -0
  246. package/dist/diagnostics/probes.js.map +1 -0
  247. package/dist/diagnostics/render.d.ts +49 -0
  248. package/dist/diagnostics/render.d.ts.map +1 -0
  249. package/dist/diagnostics/render.js +132 -0
  250. package/dist/diagnostics/render.js.map +1 -0
  251. package/dist/export.d.ts +18 -0
  252. package/dist/export.d.ts.map +1 -0
  253. package/dist/export.js +17 -0
  254. package/dist/export.js.map +1 -0
  255. package/dist/forces/extended.d.ts +121 -0
  256. package/dist/forces/extended.d.ts.map +1 -0
  257. package/dist/forces/extended.js +674 -0
  258. package/dist/forces/extended.js.map +1 -0
  259. package/dist/forces/index.d.ts +33 -0
  260. package/dist/forces/index.d.ts.map +1 -0
  261. package/dist/forces/index.js +237 -0
  262. package/dist/forces/index.js.map +1 -0
  263. package/dist/forces/natural.d.ts +106 -0
  264. package/dist/forces/natural.d.ts.map +1 -0
  265. package/dist/forces/natural.js +385 -0
  266. package/dist/forces/natural.js.map +1 -0
  267. package/dist/index.d.ts +59 -0
  268. package/dist/index.d.ts.map +1 -0
  269. package/dist/index.js +71 -0
  270. package/dist/index.js.map +1 -0
  271. package/dist/inspect/budget.d.ts +17 -0
  272. package/dist/inspect/budget.d.ts.map +1 -0
  273. package/dist/inspect/budget.js +19 -0
  274. package/dist/inspect/budget.js.map +1 -0
  275. package/dist/inspect/index.d.ts +10 -0
  276. package/dist/inspect/index.d.ts.map +1 -0
  277. package/dist/inspect/index.js +10 -0
  278. package/dist/inspect/index.js.map +1 -0
  279. package/dist/inspect/report.d.ts +17 -0
  280. package/dist/inspect/report.d.ts.map +1 -0
  281. package/dist/inspect/report.js +44 -0
  282. package/dist/inspect/report.js.map +1 -0
  283. package/dist/inspect/snapshot.d.ts +21 -0
  284. package/dist/inspect/snapshot.d.ts.map +1 -0
  285. package/dist/inspect/snapshot.js +30 -0
  286. package/dist/inspect/snapshot.js.map +1 -0
  287. package/dist/recipes/catalog.d.ts +51 -0
  288. package/dist/recipes/catalog.d.ts.map +1 -0
  289. package/dist/recipes/catalog.js +1496 -0
  290. package/dist/recipes/catalog.js.map +1 -0
  291. package/dist/recipes/charge.d.ts +18 -0
  292. package/dist/recipes/charge.d.ts.map +1 -0
  293. package/dist/recipes/charge.js +27 -0
  294. package/dist/recipes/charge.js.map +1 -0
  295. package/dist/recipes/compile.d.ts +93 -0
  296. package/dist/recipes/compile.d.ts.map +1 -0
  297. package/dist/recipes/compile.js +113 -0
  298. package/dist/recipes/compile.js.map +1 -0
  299. package/dist/recipes/explain.d.ts +8 -0
  300. package/dist/recipes/explain.d.ts.map +1 -0
  301. package/dist/recipes/explain.js +46 -0
  302. package/dist/recipes/explain.js.map +1 -0
  303. package/dist/recipes/gallery.d.ts +6 -0
  304. package/dist/recipes/gallery.d.ts.map +1 -0
  305. package/dist/recipes/gallery.js +6 -0
  306. package/dist/recipes/gallery.js.map +1 -0
  307. package/dist/recipes/gravity.d.ts +16 -0
  308. package/dist/recipes/gravity.d.ts.map +1 -0
  309. package/dist/recipes/gravity.js +27 -0
  310. package/dist/recipes/gravity.js.map +1 -0
  311. package/dist/recipes/index.d.ts +18 -0
  312. package/dist/recipes/index.d.ts.map +1 -0
  313. package/dist/recipes/index.js +36 -0
  314. package/dist/recipes/index.js.map +1 -0
  315. package/dist/recipes/intent.d.ts +44 -0
  316. package/dist/recipes/intent.d.ts.map +1 -0
  317. package/dist/recipes/intent.js +46 -0
  318. package/dist/recipes/intent.js.map +1 -0
  319. package/dist/recipes/schema.d.ts +103 -0
  320. package/dist/recipes/schema.d.ts.map +1 -0
  321. package/dist/recipes/schema.js +123 -0
  322. package/dist/recipes/schema.js.map +1 -0
  323. package/dist/recipes/wayfinding.d.ts +39 -0
  324. package/dist/recipes/wayfinding.d.ts.map +1 -0
  325. package/dist/recipes/wayfinding.js +77 -0
  326. package/dist/recipes/wayfinding.js.map +1 -0
  327. package/dist/semantic/index.d.ts +13 -0
  328. package/dist/semantic/index.d.ts.map +1 -0
  329. package/dist/semantic/index.js +31 -0
  330. package/dist/semantic/index.js.map +1 -0
  331. package/dist/semantic/layers.d.ts +24 -0
  332. package/dist/semantic/layers.d.ts.map +1 -0
  333. package/dist/semantic/layers.js +27 -0
  334. package/dist/semantic/layers.js.map +1 -0
  335. package/dist/semantic/materials.d.ts +20 -0
  336. package/dist/semantic/materials.d.ts.map +1 -0
  337. package/dist/semantic/materials.js +17 -0
  338. package/dist/semantic/materials.js.map +1 -0
  339. package/dist/semantic/states.d.ts +11 -0
  340. package/dist/semantic/states.d.ts.map +1 -0
  341. package/dist/semantic/states.js +26 -0
  342. package/dist/semantic/states.js.map +1 -0
  343. package/dist/visual/channels.d.ts +71 -0
  344. package/dist/visual/channels.d.ts.map +1 -0
  345. package/dist/visual/channels.js +70 -0
  346. package/dist/visual/channels.js.map +1 -0
  347. package/dist/visual/index.d.ts +39 -0
  348. package/dist/visual/index.d.ts.map +1 -0
  349. package/dist/visual/index.js +30 -0
  350. package/dist/visual/index.js.map +1 -0
  351. package/dist/visual/lint.d.ts +41 -0
  352. package/dist/visual/lint.d.ts.map +1 -0
  353. package/dist/visual/lint.js +58 -0
  354. package/dist/visual/lint.js.map +1 -0
  355. package/dist/visual/mapping.d.ts +13 -0
  356. package/dist/visual/mapping.d.ts.map +1 -0
  357. package/dist/visual/mapping.js +43 -0
  358. package/dist/visual/mapping.js.map +1 -0
  359. package/dist/visual/semantic-text.d.ts +28 -0
  360. package/dist/visual/semantic-text.d.ts.map +1 -0
  361. package/dist/visual/semantic-text.js +36 -0
  362. package/dist/visual/semantic-text.js.map +1 -0
  363. package/dist/visual/tokens.d.ts +23 -0
  364. package/dist/visual/tokens.d.ts.map +1 -0
  365. package/dist/visual/tokens.js +54 -0
  366. package/dist/visual/tokens.js.map +1 -0
  367. package/dist/visual/visualization.d.ts +31 -0
  368. package/dist/visual/visualization.d.ts.map +1 -0
  369. package/dist/visual/visualization.js +47 -0
  370. package/dist/visual/visualization.js.map +1 -0
  371. 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