@descix/frqtl-node 0.0.0-stage → 0.6.0-p0.2

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 (218) hide show
  1. package/CLAUDE.md +87 -0
  2. package/LICENSE.md +664 -0
  3. package/README.md +84 -2
  4. package/dist/dist-manifest.json +218 -0
  5. package/dist/eyes/index.js +109 -0
  6. package/dist/fat/pkg-node/egpt_wasm.js +1277 -0
  7. package/dist/fat/pkg-node/egpt_wasm_bg.wasm +0 -0
  8. package/dist/fat/pkg-node/package.json +12 -0
  9. package/dist/frqtl/assets/vendor/chroma.mjs +64 -0
  10. package/dist/frqtl/compat/boot-node.js +34 -0
  11. package/dist/frqtl/compat/engine.js +127 -0
  12. package/dist/frqtl/compat/package-root.js +24 -0
  13. package/dist/frqtl/compat/providers/wasm-engine.js +2499 -0
  14. package/dist/frqtl/compat/providers/wasm-load-browser.js +134 -0
  15. package/dist/frqtl/compat/providers/wasm-load-node.js +59 -0
  16. package/dist/frqtl/compat/renderer/FieldBuilder.js +145 -0
  17. package/dist/frqtl/compat/renderer/Filters.js +163 -0
  18. package/dist/frqtl/compat/renderer/RenderFamily.js +84 -0
  19. package/dist/frqtl/compat/renderer/RenderMesh.js +99 -0
  20. package/dist/frqtl/compat/renderer/RenderShape.js +154 -0
  21. package/dist/frqtl/compat/renderer/camera2d-geometry.js +183 -0
  22. package/dist/frqtl/compat/renderer/captureStill-common.js +258 -0
  23. package/dist/frqtl/compat/renderer/display-slot.js +96 -0
  24. package/dist/frqtl/compat/renderer/lo-drawn-center.js +231 -0
  25. package/dist/frqtl/compat/renderer/orbit-bag.js +216 -0
  26. package/dist/frqtl/compat/renderer/providers/godrays-layer.js +213 -0
  27. package/dist/frqtl/compat/renderer/providers/index.js +22 -0
  28. package/dist/frqtl/compat/renderer/providers/p5-provider.js +861 -0
  29. package/dist/frqtl/compat/renderer/providers/three-camera-contract.js +160 -0
  30. package/dist/frqtl/compat/renderer/providers/three-camera-geometry.js +571 -0
  31. package/dist/frqtl/compat/renderer/providers/three-provider.js +2470 -0
  32. package/dist/frqtl/compat/renderer/renderCapabilities-common.js +186 -0
  33. package/dist/frqtl/compat/renderer/renderer-provider-contract.js +238 -0
  34. package/dist/frqtl/compat/renderer/unprojectStill.js +172 -0
  35. package/dist/frqtl/compat/vendor.js +196 -0
  36. package/dist/frqtl/node-entry.js +17 -0
  37. package/dist/frqtl/objects/FrqtlApp.js +2920 -0
  38. package/dist/frqtl/objects/ObjectFactory.js +296 -0
  39. package/dist/frqtl/objects/RecipeContext.js +322 -0
  40. package/dist/frqtl/objects/collectors/DataCollector.js +116 -0
  41. package/dist/frqtl/objects/constants/WallBehaviors.js +178 -0
  42. package/dist/frqtl/objects/geometry/SlabFace.js +258 -0
  43. package/dist/frqtl/objects/instruments/capture-host.js +453 -0
  44. package/dist/frqtl/objects/instruments/capture.js +710 -0
  45. package/dist/frqtl/objects/instruments/hosted-eyes.js +984 -0
  46. package/dist/frqtl/objects/kinds-entry.js +14 -0
  47. package/dist/frqtl/objects/kinds.js +190 -0
  48. package/dist/frqtl/objects/objects/Box.js +273 -0
  49. package/dist/frqtl/objects/objects/Bullet.js +155 -0
  50. package/dist/frqtl/objects/objects/CircuitGate.js +380 -0
  51. package/dist/frqtl/objects/objects/CircuitProbe.js +214 -0
  52. package/dist/frqtl/objects/objects/DirectedTunnel.js +411 -0
  53. package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
  54. package/dist/frqtl/objects/objects/LightSource.js +351 -0
  55. package/dist/frqtl/objects/objects/QuantumEmitter.js +760 -0
  56. package/dist/frqtl/objects/objects/Tube.js +471 -0
  57. package/dist/frqtl/objects/objects/absorbRadiation.js +309 -0
  58. package/dist/frqtl/objects/objects/actionCompiler.js +1747 -0
  59. package/dist/frqtl/objects/objects/controlAction.js +89 -0
  60. package/dist/frqtl/objects/objects/controlBind.js +278 -0
  61. package/dist/frqtl/objects/objects/directedRedirectProgram.js +120 -0
  62. package/dist/frqtl/objects/objects/emitterProgram.js +209 -0
  63. package/dist/frqtl/objects/objects/frqtl-control-slider.js +197 -0
  64. package/dist/frqtl/objects/objects/frqtl-control-sync.js +272 -0
  65. package/dist/frqtl/objects/objects/frqtl-control-widgets.js +219 -0
  66. package/dist/frqtl/objects/objects/frqtl-control-write-queue.js +174 -0
  67. package/dist/frqtl/objects/objects/makeControlsForObject.js +820 -0
  68. package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
  69. package/dist/frqtl/objects/objects/slitLinerScatter.js +261 -0
  70. package/dist/frqtl/objects/objects/sustainedSource.js +298 -0
  71. package/dist/frqtl/objects/objects/tagger.js +228 -0
  72. package/dist/frqtl/objects/schema/engine-scopes.js +468 -0
  73. package/dist/frqtl/objects/schema/index.js +256 -0
  74. package/dist/frqtl/objects/schema/scopes.js +457 -0
  75. package/dist/frqtl/objects/schema/validate-descriptor.js +49 -0
  76. package/dist/frqtl/objects/schema/valueNormalizer.js +366 -0
  77. package/dist/frqtl/objects/setup/_collisionMatrixLanes.js +66 -0
  78. package/dist/frqtl/objects/setup/_setupOptions.js +118 -0
  79. package/dist/frqtl/objects/setup/_worldInit.js +162 -0
  80. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +242 -0
  81. package/dist/frqtl/objects/setup/setupCircuitBasic.js +447 -0
  82. package/dist/frqtl/objects/setup/setupCollisionMatrix.js +188 -0
  83. package/dist/frqtl/objects/setup/setupConeEmitter3D.js +250 -0
  84. package/dist/frqtl/objects/setup/setupDoubleSlit.js +326 -0
  85. package/dist/frqtl/objects/setup/setupDoubleSlit_Laser.js +229 -0
  86. package/dist/frqtl/objects/setup/setupEmpty.js +59 -0
  87. package/dist/frqtl/objects/setup/setupFxSmoke.js +209 -0
  88. package/dist/frqtl/objects/setup/setupG1Race.js +80 -0
  89. package/dist/frqtl/objects/setup/setupG2Sweep.js +89 -0
  90. package/dist/frqtl/objects/setup/setupG3Collide.js +177 -0
  91. package/dist/frqtl/objects/setup/setupLightSourceLab.js +277 -0
  92. package/dist/frqtl/objects/setup/setupOscillator.js +68 -0
  93. package/dist/frqtl/objects/setup/setupParticleWalk.js +93 -0
  94. package/dist/frqtl/objects/setup/setupParticleWalk3D.js +133 -0
  95. package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +1258 -0
  96. package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
  97. package/dist/frqtl/objects/setup/setupTunnelLab.js +240 -0
  98. package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
  99. package/dist/frqtl/objects/setup/setupWaveInterference.js +177 -0
  100. package/dist/frqtl/objects/v1-replacements.js +29 -0
  101. package/dist/frqtl/package-core.js +23 -0
  102. package/dist/frqtl/parts/CameraControls.js +643 -0
  103. package/dist/frqtl/parts/CameraPath.js +575 -0
  104. package/dist/frqtl/parts/CollisionActions.js +34 -0
  105. package/dist/frqtl/parts/CollisionTypes.js +30 -0
  106. package/dist/frqtl/parts/DetectorWall.js +496 -0
  107. package/dist/frqtl/parts/FrqtlControlOverlay.js +227 -0
  108. package/dist/frqtl/parts/FrqtlControlsPanel.js +415 -0
  109. package/dist/frqtl/parts/FrqtlVision.js +818 -0
  110. package/dist/frqtl/parts/GraphingDetectorWall.js +489 -0
  111. package/dist/frqtl/parts/LargeObject.js +405 -0
  112. package/dist/frqtl/parts/LightColor.js +255 -0
  113. package/dist/frqtl/parts/LiveChart.js +285 -0
  114. package/dist/frqtl/parts/LogicGate.js +717 -0
  115. package/dist/frqtl/parts/OvenBox.js +611 -0
  116. package/dist/frqtl/parts/PointSource.js +762 -0
  117. package/dist/frqtl/parts/Rectangle.js +200 -0
  118. package/dist/frqtl/parts/SlitLightSource.js +234 -0
  119. package/dist/frqtl/parts/SlitScreen.js +628 -0
  120. package/dist/frqtl/parts/SphereEmitter.js +946 -0
  121. package/dist/frqtl/parts/Trace.js +508 -0
  122. package/dist/frqtl/parts/Tunnel.js +1057 -0
  123. package/dist/frqtl/parts/Wall.js +1211 -0
  124. package/dist/frqtl/parts/index.js +33 -0
  125. package/dist/frqtl/scenes/absorbing-wall-3d.js +33 -0
  126. package/dist/frqtl/scenes/absorbing-wall.js +32 -0
  127. package/dist/frqtl/scenes/atomic-model.js +88 -0
  128. package/dist/frqtl/scenes/big-bang.js +46 -0
  129. package/dist/frqtl/scenes/blackbody.js +81 -0
  130. package/dist/frqtl/scenes/circuit-sat.js +127 -0
  131. package/dist/frqtl/scenes/collision-matrix-3d.js +107 -0
  132. package/dist/frqtl/scenes/counting-wall-3d.js +36 -0
  133. package/dist/frqtl/scenes/counting-wall.js +38 -0
  134. package/dist/frqtl/scenes/double-slit.js +31 -0
  135. package/dist/frqtl/scenes/dse-one-emitter.js +196 -0
  136. package/dist/frqtl/scenes/dse-one-slit.js +114 -0
  137. package/dist/frqtl/scenes/dse-photoelectric.js +70 -0
  138. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +243 -0
  139. package/dist/frqtl/scenes/dse-two-emitters.js +196 -0
  140. package/dist/frqtl/scenes/dse-two-slits.js +132 -0
  141. package/dist/frqtl/scenes/empty.js +14 -0
  142. package/dist/frqtl/scenes/laser-buildup.js +49 -0
  143. package/dist/frqtl/scenes/mirror-3d.js +56 -0
  144. package/dist/frqtl/scenes/mirror-route.js +140 -0
  145. package/dist/frqtl/scenes/mirror.js +38 -0
  146. package/dist/frqtl/scenes/omni-3d.js +27 -0
  147. package/dist/frqtl/scenes/omni-source.js +22 -0
  148. package/dist/frqtl/scenes/self-interference.js +39 -0
  149. package/dist/frqtl/scenes/smoking-gun.js +122 -0
  150. package/dist/frqtl/scenes/straight-walk.js +22 -0
  151. package/dist/frqtl/scenes/sun.js +39 -0
  152. package/dist/frqtl/scenes/two-source.js +37 -0
  153. package/dist/frqtl/scenes/wave.js +26 -0
  154. package/dist/shim/bundle/frqnb.js +112 -0
  155. package/dist/shim/bundle/index.js +121 -0
  156. package/dist/shim/bundle/indexHtml.js +107 -0
  157. package/dist/shim/bundle/manifest.js +95 -0
  158. package/dist/shim/bundle/zip.js +243 -0
  159. package/dist/shim/bus.js +67 -0
  160. package/dist/shim/controls/index.js +627 -0
  161. package/dist/shim/controls/registry.js +274 -0
  162. package/dist/shim/engine/artifact-shell.js +211 -0
  163. package/dist/shim/engine/boot-phases.js +44 -0
  164. package/dist/shim/engine/engine-stack-boot.js +59 -0
  165. package/dist/shim/engine/router.js +690 -0
  166. package/dist/shim/experiment-frame/index.js +296 -0
  167. package/dist/shim/expr/ast-schema.js +369 -0
  168. package/dist/shim/field-descriptor/index.js +325 -0
  169. package/dist/shim/frame-data/index.js +224 -0
  170. package/dist/shim/index.js +67 -0
  171. package/dist/shim/notebook/canvas-guard.js +82 -0
  172. package/dist/shim/notebook/catalog.js +35 -0
  173. package/dist/shim/notebook/format.js +736 -0
  174. package/dist/shim/notebook/frqtl-setups-importmap.js +81 -0
  175. package/dist/shim/notebook/kernel.js +671 -0
  176. package/dist/shim/notebook/knob.js +150 -0
  177. package/dist/shim/notebook/module-resolver.js +260 -0
  178. package/dist/shim/notebook/notebook-knobs.js +65 -0
  179. package/dist/shim/notebook/produced-bindings.js +67 -0
  180. package/dist/shim/notebook/realm-registry.js +121 -0
  181. package/dist/shim/notebook/realm-transport.js +73 -0
  182. package/dist/shim/notebook/setup-builder.js +507 -0
  183. package/dist/shim/notebook/store.js +159 -0
  184. package/dist/shim/notebook/vendor-importmap.js +134 -0
  185. package/dist/shim/notebook/view-builder.js +189 -0
  186. package/dist/shim/notebook/view-ctx.js +100 -0
  187. package/dist/shim/notebook/view-handle.js +202 -0
  188. package/dist/shim/notebook/warmup.js +102 -0
  189. package/dist/shim/package.json +4 -0
  190. package/dist/shim/page-params/index.js +213 -0
  191. package/dist/shim/project/identity.js +112 -0
  192. package/dist/shim/project/index.js +19 -0
  193. package/dist/shim/project/link.js +402 -0
  194. package/dist/shim/project/node-specifier-hook.mjs +35 -0
  195. package/dist/shim/project/register-node.mjs +17 -0
  196. package/dist/shim/project/specifiers.js +106 -0
  197. package/dist/shim/registry/copy-gate.js +110 -0
  198. package/dist/shim/registry/exhibits.js +455 -0
  199. package/dist/shim/registry/experiments.js +215 -0
  200. package/dist/shim/registry/index.js +19 -0
  201. package/dist/shim/registry/scene-source.js +157 -0
  202. package/dist/shim/registry/walkthrough.js +804 -0
  203. package/dist/shim/render/capture-envelope.js +279 -0
  204. package/dist/shim/render/heat-colormap.js +92 -0
  205. package/dist/shim/render/render-stages.js +855 -0
  206. package/dist/shim/render/sun-look.js +33 -0
  207. package/dist/shim/render-state/apply.js +262 -0
  208. package/dist/shim/render-state/index.js +74 -0
  209. package/dist/shim/rpc/index.js +207 -0
  210. package/dist/shim/runtime/index.js +1023 -0
  211. package/dist/shim/storage/boot.js +69 -0
  212. package/dist/shim/storage/index.js +169 -0
  213. package/dist/shim/storage/local-storage-provider.js +230 -0
  214. package/dist/shim/storage/make-namespace-store.js +132 -0
  215. package/dist/shim/storage/modules.js +249 -0
  216. package/dist/shim/storage/projects.js +150 -0
  217. package/llms.txt +12 -0
  218. package/package.json +63 -4
@@ -0,0 +1,2920 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * FrqtlApp — the HOST ADAPTER a page/IDE/notebook talks to for one FRQTL scene.
5
+ *
6
+ * This is an `Application` in the PixiJS sense: loop, scene registry, renderer facade,
7
+ * canvas, instrument taps, editor plumbing and world-property proxy all hang off ONE
8
+ * handle the host drives. It is NOT a loop driver — an object called "Runner" reads as
9
+ * the loop alone (Matter.js `Runner` drives `Engine`; `Render` and `Composite` are
10
+ * separate objects), so a caller carrying that muscle memory reaches for a `scene` or
11
+ * `renderer` that does not exist here and starts guessing at the interface. Named to
12
+ * match the surface it actually is, consistent with `FrqtlVision`.
13
+ *
14
+ * Handles universe construction, experiment setup via the frqtl/objects/setup factories,
15
+ * and the doTick → renderer.renderTick loop, over the Rust/WASM engine reached through
16
+ * the engine door (frqtl/compat/engine.js) — engine-agnostic by contract.
17
+ *
18
+ * Host hook: `start()` makes the instance the page's runner (setPageRunner; read by a realm that
19
+ * cannot import this module — a parent frame, a Playwright probe — as the page door's
20
+ * `FrqtlPage.runner`, experiments/_shared/page-door.js). There is no second hook and no alias.
21
+ *
22
+ * Design (§6, §6.1 from WS_RENDERER_SHIM_DESIGN.md):
23
+ * - The app OWNS the requestAnimationFrame loop. p5's loop()/noLoop() are
24
+ * NO LONGER used. window.draw is no longer set.
25
+ * - The renderer is obtained from THE DISPLAY SLOT (compat/renderer/display-slot.js, set by
26
+ * the boot module, experiments/_shared/boot.js). If the slot is empty when the draw loop first fires, the app
27
+ * FAILS LOUD (paintBootFailure) — no silent P5Renderer fallback (Phase 1).
28
+ * - Fixed-timestep accumulator decouples physics ticks from draw frames:
29
+ * tickHz: physics ticks per second (default: lock to drawHz → 1:1 as today)
30
+ * drawHz: draw frames per second cap (default: display refresh)
31
+ * Default (tickHz == drawHz) reproduces today's exact 1:1 loop.
32
+ * - onTick fires per PHYSICS TICK; renderTick fires per DRAW FRAME.
33
+ * - stepsPerFrame is kept as a back-compat alias: sets tickHz = drawHz * stepsPerFrame.
34
+ * - Spiral-of-death guard: accumulated time clamped + ticks-per-frame capped.
35
+ * - Determinism preserved: physics only ever advances in whole doTick() steps;
36
+ * the headless run.js drivers (no RAF, no rendering) are unaffected.
37
+ *
38
+ * Usage (unchanged from before):
39
+ *
40
+ * var runner = new FrqtlApp({
41
+ * canvasParentId: 'canvas-wrapper',
42
+ * canvasW: 600,
43
+ * canvasH: 600,
44
+ * seed: 42,
45
+ * onTick: function(tickData, setupResult) { ... }
46
+ * });
47
+ *
48
+ * runner.createCanvas();
49
+ * runner.setup(setupDoubleSlit, { wavelength: 64, ... });
50
+ * runner.start();
51
+ * runner.stop();
52
+ * runner.toggle();
53
+ * runner.step(); // single tick + render (step-by-step debugging)
54
+ *
55
+ * New (§6.1):
56
+ * new FrqtlApp({ ..., tickHz: 120, drawHz: 30 })
57
+ * // → 4 physics ticks per drawn frame
58
+ *
59
+ * Back-compat alias (§6.1):
60
+ * new FrqtlApp({ ..., stepsPerFrame: 4 })
61
+ * // → sets tickHz = drawHz * 4 (same as above if drawHz == display refresh)
62
+ */
63
+
64
+ import { ObjectSchema } from './schema/index.js';
65
+ import { FrqtlCapture } from './instruments/capture.js';
66
+ import * as FrqtlDisplay from '../compat/renderer/display-slot.js';
67
+ import * as RendererProviderContract from '../compat/renderer/renderer-provider-contract.js';
68
+ import * as OrbitBag from '../compat/renderer/orbit-bag.js';
69
+ import * as LoDrawnCenter from '../compat/renderer/lo-drawn-center.js';
70
+ import { engine } from '../compat/engine.js';
71
+ import { Filters } from '../compat/renderer/Filters.js';
72
+ import { LargeObject } from '../parts/LargeObject.js';
73
+ import { CollisionTypes } from '../parts/CollisionTypes.js';
74
+ import { suppressP5Draw, resumeP5Loop } from '../compat/vendor.js';
75
+ import { paintBootFailure } from '../experiments/_shared/boot-failure.js';
76
+
77
+
78
+ 'use strict';
79
+
80
+ // ── Fixed-timestep constants (§6.1) ─────────────────────────────────────
81
+ var MAX_FRAME_MS = 100; // clamp wall-clock delta (spiral-of-death guard)
82
+ var MAX_TICKS_PER_FRAME = 10; // hard cap for normal (bounded) operation
83
+ var MAX_TICKS_UNBOUNDED = 200; // hard cap for unbounded TPS (Infinity tickHz)
84
+
85
+ // The keys setRateLimits() accepts — any other key is a FAIL-LOUD error there.
86
+ var RATE_LIMIT_KEYS = Object.freeze(['fpsLimit', 'tpsLimit']);
87
+ // The factory statics setup() consumes (see FrqtlApp.prototype.setup) — the ONE list
88
+ // carryFactoryStatics() copies, so a wrapped factory boots the same scene as the bare one.
89
+ var FACTORY_STATICS = Object.freeze(['initWorld']);
90
+ // A factory static that was deleted: setup() and carryFactoryStatics() THROW on it,
91
+ // naming what replaces it, so a factory still carrying one never loses it silently.
92
+ function _refuseDeletedStatics(fn, where) {
93
+ if (fn.RATE_LIMITS !== undefined) {
94
+ throw new Error('[FrqtlApp.' + where + '] the RATE_LIMITS factory static is DELETED — pacing is ' +
95
+ 'declared where the scene is: exp.runner.setRateLimits({ fpsLimit?, tpsLimit? }) in a scene ' +
96
+ 'file, runner.setRateLimits(...) on a page. (FAIL-LOUD.)');
97
+ }
98
+ }
99
+
100
+ // ── FrqtlApp ─────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * Resolve an engine class the host needs (`EGPTUniverse`, `Rectangle`): the handle a headless
104
+ * host injects (`new FrqtlApp({ engine: handle, … })`), else the realm's booted engine
105
+ * (`lib/frqtl/compat/engine.js` engine(), which THROWS naming the door before the boot).
106
+ * There is NO alternative construction path: `EGPTUniverse.create(config)` remains the ONE
107
+ * construction authority.
108
+ * @private
109
+ */
110
+ function _resolveEngineClass(name, engineHandle) {
111
+ if (engineHandle && engineHandle[name]) { return engineHandle[name]; }
112
+ return engine()[name];
113
+ }
114
+
115
+ /** Whether `prop` is a WALL field — declared by the Wall descriptor every Wall-family kind
116
+ * extends (read from the schema owner), as opposed to a subclass's own field. */
117
+ function _isWallField(schema, prop) {
118
+ var d = schema.get('Wall');
119
+ for (var i = 0; i < d.fields.length; i++) { if (d.fields[i].name === prop) { return true; } }
120
+ return false;
121
+ }
122
+ /**
123
+ * `x` itself when it is a Wall-family FACADE (Wall, GraphingDetectorWall, …), else null.
124
+ * Structural, never by kind: the Wall constructor stamps its LO's ONE up-link
125
+ * (`this.lo._owner = this`), so a facade is `x.lo._owner === x`. Facade-only — no consumer
126
+ * holds an inner LO (every pick and aim resolves up to the declared object).
127
+ */
128
+ function _wallFacadeOf(x) {
129
+ return (x && x.lo && x.lo._owner === x) ? x : null;
130
+ }
131
+ FrqtlApp._wallFacadeOf = _wallFacadeOf;
132
+
133
+
134
+ /**
135
+ * The wall-clock loop (`start()`, `toggle()` resume) runs on `requestAnimationFrame`.
136
+ * A host without one (node) gets a NAMED door here instead of a raw ReferenceError, and
137
+ * gets it before the runner's loop state is touched.
138
+ * @private
139
+ */
140
+ function _requireAnimationFrame(method) {
141
+ if (typeof requestAnimationFrame === 'function') { return; }
142
+ throw new Error('[FrqtlApp.' + method + '] no animation frame in this host — the wall-clock ' +
143
+ 'loop needs requestAnimationFrame. Drive the clock with `runner.step()` (one tick + ' +
144
+ 'render per call, any host), or boot in a browser through `@descix/frqtl-web/boot`. ' +
145
+ '(FAIL-LOUD.)');
146
+ }
147
+
148
+ /**
149
+ * @param {Object} opts
150
+ * @param {string} [opts.canvasParentId] DOM element ID for the canvas.
151
+ * @param {Object} [opts.engine] Engine handle (compat/engine.js engine()) carrying
152
+ * EGPTUniverse + Rectangle. Omit in a page — the
153
+ * boot-published globals are read instead.
154
+ * @param {number} opts.canvasW Physics canvas width in pixels — REQUIRED (no default:
155
+ * a default size is a second declaration of the world).
156
+ * @param {number} opts.canvasH Physics canvas height in pixels — REQUIRED.
157
+ * @param {boolean} [opts.is3D=false] The display is 3D (perspective + depth) — handed to the
158
+ * provider's init. A scene states it through
159
+ * exp.createScene; a page passes it here.
160
+ * @param {number} [opts.canvasD] World depth for a 3D display (handed to provider init).
161
+ * @param {number} [opts.seed=42] RNG seed for deterministic runs.
162
+ * @param {number} [opts.tickHz] Physics ticks/s (default: lock to drawHz).
163
+ * @param {number} [opts.drawHz] Draw frames/s cap (default: display refresh).
164
+ * @param {number} [opts.stepsPerFrame=1] Back-compat alias: sets tickHz = drawHz * N.
165
+ * @param {Function} [opts.onTick] Called per PHYSICS TICK with (tickData, setupResult).
166
+ * @param {Function} [opts.onReady] Called once the canvas is ready.
167
+ */
168
+ function FrqtlApp(opts) {
169
+ opts = opts || {};
170
+ var canvasW = opts.canvasW, canvasH = opts.canvasH;
171
+ if (typeof canvasW !== 'number' || !(canvasW > 0) || typeof canvasH !== 'number' || !(canvasH > 0)) {
172
+ throw new Error('[FrqtlApp] canvasW and canvasH are REQUIRED positive numbers (got ' +
173
+ JSON.stringify(canvasW) + ' x ' + JSON.stringify(canvasH) + '). There is no default world ' +
174
+ 'size: a scene states its world with exp.createCanvas(w, h, cfg) / exp.createScene(w, h, d, cfg); ' +
175
+ 'a page passes it here. (FAIL-LOUD.)');
176
+ }
177
+ this._is3D = !!opts.is3D;
178
+ this._depth = (opts.canvasD !== undefined && opts.canvasD !== null) ? opts.canvasD : null;
179
+ var seed = opts.seed !== undefined ? opts.seed : 42;
180
+
181
+ this._canvasParentId = opts.canvasParentId || null;
182
+ this._onTick = opts.onTick || null;
183
+ this._onReady = opts.onReady || null;
184
+ // Multi-subscriber per-tick fan-out (peer of the single `_onTick` slot the
185
+ // experiment page owns). `addTickListener(fn)` lets a host-side consumer (the
186
+ // LiveChart wiring) subscribe to the tick stream WITHOUT stealing `_onTick`.
187
+ // Empty by default → zero behavior change (SU1); the fan-out fires AFTER
188
+ // `_onTick` at both tick sites (the RAF loop and step()).
189
+ this._tickListeners = [];
190
+
191
+ // tickHz / drawHz — independent clocks (§6.1)
192
+ // drawHz=0 means "lock to display refresh" (the default)
193
+ this._drawHz = (opts.drawHz !== undefined && opts.drawHz > 0) ? opts.drawHz : 0;
194
+ // stepsPerFrame back-compat: treated as tickHz multiplier relative to drawHz
195
+ var spf = (opts.stepsPerFrame !== undefined) ? Math.max(1, opts.stepsPerFrame) : 1;
196
+ if (opts.tickHz !== undefined && opts.tickHz > 0) {
197
+ this._tickHz = opts.tickHz;
198
+ } else if (spf > 1) {
199
+ // stepsPerFrame > 1: tickHz = drawHz * spf (if drawHz known at start, else approximate)
200
+ this._tickHz = (this._drawHz > 0) ? this._drawHz * spf : 60 * spf;
201
+ } else {
202
+ // Default 1:1: tickHz locked to drawHz at runtime (set in the frame loop)
203
+ this._tickHz = 0; // 0 = "lock to drawHz" — resolved inside the frame loop
204
+ }
205
+
206
+ // Back-compat accessor
207
+ this.stepsPerFrame = spf;
208
+
209
+ // The engine classes, resolved through the named order (never a bare global).
210
+ var _Rectangle = _resolveEngineClass('Rectangle', opts.engine);
211
+
212
+ // Physics canvas rect
213
+ this.canvas_rect = new _Rectangle(0, 0, canvasW, canvasH, null, false);
214
+
215
+ // The INIT SEED, retained. This runner is the party that hands the seed to the
216
+ // universe, so it is the seed authority — and `seed` is half of the regeneration
217
+ // triple (code, seed, tick) a capture's SceneRecord must carry. It is FERRIED into
218
+ // the capture instrument's identity bag below, never re-derived there.
219
+ this._seed = seed;
220
+ // Tick SUSPENSION depth (see suspendTicks). Distinct from `_isLooping`, which is the
221
+ // USER's play/pause: a suspend must not lie to the play button, and a resume must not
222
+ // secretly start a stopped sim.
223
+ this._tickSuspend = 0;
224
+
225
+ // Universe — built through the ONE construction authority, `EGPTUniverse.create`
226
+ // (EGPTUniverse.CONFIG is the exported key/default/unit/effect table). This host
227
+ // names only the two settings it actually owns; every other key takes its published
228
+ // default (fps 60, iframeIntervalSeconds 1, no IQC, no bonding, groundUp,
229
+ // minMBFillRate 0.1, wavelengthScale 1 — the setup factory / sketch's
230
+ // `universe.init()` sets the base dimension and overrides wavelengthScale per
231
+ // experiment anyway).
232
+ var _Universe = _resolveEngineClass('EGPTUniverse', opts.engine);
233
+ this.universe = _Universe.create({
234
+ rect: this.canvas_rect,
235
+ seed: seed,
236
+ });
237
+
238
+ // The DECLARED-object registry (see `declared`): empty until a declaring call
239
+ // (ObjectFactory.create) records a part.
240
+ this._declared = [];
241
+ this._declaredMembers = new Map();
242
+ this._declaredTicks = new Map();
243
+ this._setupResult = null;
244
+ this._sceneReplay = null; // retainSceneReplay(fn) — what restart() replays
245
+ // Design-mode signal — DEFAULT false (Run / standalone bundles /
246
+ // experiment pages). When true (STUDIO authoring), the renderer DRAWS the
247
+ // Design-only markers (a manipulable emitter's visual circle, `lo._designMarker`).
248
+ // A render toggle at the output boundary (zero physics): it filters ONLY the
249
+ // renderTick view; the pick, the aim, Layers and codegen read the census and the
250
+ // declared registry directly, so an emitter stays selectable even when its marker
251
+ // circle is suppressed.
252
+ this._designMode = false;
253
+ // The aim surface (setAimArmed): null while disarmed, else the stationary-reticle state —
254
+ // the centre-ray hover and, while grabbed, the held camera-relative pose. `_selected` is
255
+ // the host's selection, handed down for its shadow-surround (setSelectHighlight).
256
+ this._aim = null;
257
+ this._selected = null;
258
+ // F5: optional host callback fired on a position write (center/rect) so an overlay can
259
+ // REPAINT event-driven (Studio sets it; a headless game leaves it null → no-op).
260
+ this._onObjectMoved = null;
261
+ this._isLooping = false;
262
+ this._rafId = null;
263
+ this._lastTickData = null; // repaint()'s source (§Studio Controls Realignment S4)
264
+
265
+ // Fixed-timestep state
266
+ this._accumulator = 0;
267
+ this._lastFrame = 0;
268
+ this._lastDraw = 0;
269
+
270
+ // Budget-exhaustion warning throttle (see the drive loop). Default-muted;
271
+ // only emits under FrqtlApp.verbose === true, at most once per interval.
272
+ this._lastBudgetWarn = 0;
273
+ this._BUDGET_WARN_INTERVAL_MS = 5000;
274
+
275
+ // Rate measurement (§fps/tps readout) — integers only, no per-frame allocation.
276
+ // Counts reset each ~RATE_WINDOW_MS; getRates() returns last computed snapshot.
277
+ this._rateWindowMs = 1000; // recompute window length
278
+ this._rateWindowEnd = 0; // performance.now() when the current window ends
279
+ this._tickCount = 0; // physics ticks accumulated in this window
280
+ this._drawCount = 0; // draw frames accumulated in this window
281
+ this._lastTps = 0; // last computed ticks/sec
282
+ this._lastFps = 0; // last computed frames/sec
283
+
284
+ // Render options — threaded into every renderTick(tickData, largeObjects, options) call.
285
+ // Default: renderBaseCanvas true = draw particle layer.
286
+ // Written ONLY through setRenderOption/setRenderOptions (the declared-capability gate).
287
+ this._renderOptions = { renderBaseCanvas: true };
288
+ // Writes made before a provider attached: validated against THAT provider's
289
+ // renderCapabilities() the moment it attaches (see _getRenderer). Null when empty.
290
+ this._stagedRenderOptions = null;
291
+
292
+ // Renderer (resolved lazily in start() — the boot may not have selected it yet)
293
+ this._renderer = null;
294
+
295
+ // ── The render-pipeline TAP — DELEGATED, not implemented here ─────────
296
+ // The mechanism lives at lib/frqtl/objects/instruments/capture.js (THE_MINIMAL_SIM
297
+ // §4): a tap is an instrument on the primitive surface, not an editor concern. This
298
+ // host keeps only the `capture`/`captureStill`/`sceneRecord`/`setSourceRef`
299
+ // conveniences below, each a thin forward. The binding hands the instrument LIVE
300
+ // reads — the retained TickData, the active provider, the physics rect and the ONE
301
+ // render-options bag — so nothing is copied and no second authority is minted.
302
+ var self = this;
303
+ this._capture = FrqtlCapture.create(
304
+ { get lastTickData() { return self._lastTickData; } },
305
+ function () { return self._getRenderer(); },
306
+ { seed: this._seed, get rect() { return self.canvas_rect; } },
307
+ function () { return self._renderOptions; }
308
+ );
309
+ }
310
+
311
+ /**
312
+ * runner.largeObjects — THE OBJECT CENSUS, DERIVED FROM THE WORLD.
313
+ *
314
+ * ── Layer 1: API contract ────────────────────────────────────────────────
315
+ * The live array of `LargeObject` wrappers constructed on this runner's universe, in
316
+ * construction order (which is z-order for the renderer). It is the WORLD's array,
317
+ * not a copy: `new LargeObject(u, …)` self-registers into it and `dispose()` leaves
318
+ * it, so this host maintains no list of its own.
319
+ *
320
+ * This host used to keep `_largeObjects`, a SECOND, render-only registration list fed
321
+ * by `addLargeObject(lo)`. Its divergence from the engine's registry is unrepresentable
322
+ * in the model the primitive table teaches — "construct it and it is in the world" —
323
+ * and when it diverged, the object collided and counted correctly while being
324
+ * completely invisible in every picture, with no error. Both that list and the
325
+ * `addLargeObject`/`removeLargeObject` pair are DELETED: constructing IS registering,
326
+ * and `removeObject(obj)` (which disposes) IS removing.
327
+ *
328
+ * ── Layer 2: physics behaviour ───────────────────────────────────────────
329
+ * ZERO. A census read touches no frame, no dimension and no tick; the render VIEW
330
+ * (`_visibleLargeObjects`) filters it without mutating it.
331
+ * @returns {Array}
332
+ */
333
+ Object.defineProperty(FrqtlApp.prototype, 'largeObjects', {
334
+ configurable: true,
335
+ get: function () {
336
+ var u = this.universe;
337
+ return (u && Array.isArray(u.largeObjects)) ? u.largeObjects : [];
338
+ }
339
+ });
340
+
341
+ /**
342
+ * runner.controlHost — THE door a scene's on-canvas chrome mounts through (host-owned
343
+ * control mount through the runner's one door; scenes do not name DOM
344
+ * ids). Read: the element a HOSTING PANE set, else the element the scene canvas is mounted
345
+ * in (its wrapper), else null (no canvas yet — a notebook pane before its mount: the chrome
346
+ * stays in the exposed registry only). Write: a hosting pane (STUDIO) hands an element it
347
+ * owns ABOVE its own overlays; every chrome already mounted through the door (a knob overlay
348
+ * registered with addControlChrome) MOVES there, so a click on a scene knob reaches the knob,
349
+ * never STUDIO's selection overlay.
350
+ */
351
+ Object.defineProperty(FrqtlApp.prototype, 'controlHost', {
352
+ configurable: true,
353
+ get: function () {
354
+ if (this._controlHost) { return this._controlHost; }
355
+ var s = this._sceneSurface;
356
+ return (s && s.parentNode) ? s.parentNode : null;
357
+ },
358
+ set: function (el) {
359
+ if (!el || typeof el.appendChild !== 'function' || !el.ownerDocument) {
360
+ throw new TypeError('[FrqtlApp.controlHost] a control host is a DOM element (it carries ' +
361
+ 'appendChild + ownerDocument); got ' + String(el) + '. (FAIL-LOUD.)');
362
+ }
363
+ this._controlHost = el;
364
+ var cs = (this._controlChrome || []).slice();
365
+ for (var i = 0; i < cs.length; i++) { cs[i].rehost(el); }
366
+ }
367
+ });
368
+
369
+ /**
370
+ * addControlChrome(chrome) → unsubscribe. On-canvas chrome mounted through `controlHost`
371
+ * registers here (`chrome.rehost(el)` moves it) so a host set later moves it too.
372
+ */
373
+ FrqtlApp.prototype.addControlChrome = function (chrome) {
374
+ if (!chrome || typeof chrome.rehost !== 'function') {
375
+ throw new TypeError('[FrqtlApp.addControlChrome] chrome must expose rehost(el). (FAIL-LOUD.)');
376
+ }
377
+ var list = this._controlChrome || (this._controlChrome = []);
378
+ list.push(chrome);
379
+ return function () { var i = list.indexOf(chrome); if (i !== -1) { list.splice(i, 1); } };
380
+ };
381
+
382
+ /**
383
+ * Toggle Design mode. STUDIO calls setDesignMode(true) while authoring
384
+ * (MODE_DESIGN) so the manipulable emitter markers (`lo._designMarker`) render as
385
+ * visible circles, and false during Run / Pause so they are suppressed (you see
386
+ * the emission, not the source marker). Standalone bundles + experiment pages
387
+ * never call this → the default false suppresses the marker for pure output. Zero
388
+ * physics — this only gates the renderTick view (see _visibleLargeObjects).
389
+ * @param {boolean} v
390
+ */
391
+ FrqtlApp.prototype.setDesignMode = function (v) {
392
+ this._designMode = !!v;
393
+ };
394
+
395
+ /**
396
+ * Effective shellRender mode for a LargeObject — the SINGLE
397
+ * suppression authority. Precedence:
398
+ * 1. per-instance `lo.shellRender` (a valid 'design'|'run'|'never' string) —
399
+ * the live override the Inspector / a game sets via updateObjectProperty
400
+ * (the "red light bulb" flips a marker to 'run').
401
+ * 2. kind default: a manipulable marker (`lo._designMarker === true`) defaults
402
+ * 'design' (invisible during Run); every ordinary LO defaults
403
+ * 'run' (always visible). This keeps every pre-existing scene byte-identical.
404
+ * @returns {'design'|'run'|'never'}
405
+ */
406
+ FrqtlApp.prototype._shellRenderOf = function (lo) {
407
+ var sr = lo && lo.shellRender;
408
+ if (sr === 'design' || sr === 'run' || sr === 'never') { return sr; }
409
+ return (lo && lo._designMarker === true) ? 'design' : 'run';
410
+ };
411
+
412
+ /**
413
+ * The largeObjects VIEW passed to the renderer each frame: each LO
414
+ * is shown per its effective `shellRender` (see _shellRenderOf) — 'run' always,
415
+ * 'design' only while `_designMode` is on, 'never' hidden.
416
+ *
417
+ * It is a VIEW over `this.largeObjects` (the world's census), which is the whole of
418
+ * this method's remaining job: the host filters, it does not register. A new array
419
+ * (never mutates the census); returns the SAME array reference when nothing is
420
+ * filtered (the common case — all LOs 'run', or design mode with no 'never') to
421
+ * avoid per-frame churn.
422
+ * @returns {Array}
423
+ */
424
+ FrqtlApp.prototype._visibleLargeObjects = function () {
425
+ var los = this.largeObjects;
426
+ if (!los || los.length === 0) { return los; }
427
+ var self = this;
428
+ var anyHidden = false;
429
+ for (var i = 0; i < los.length; i++) {
430
+ if (los[i] && !self._isShellVisible(los[i])) { anyHidden = true; break; }
431
+ }
432
+ if (!anyHidden) { return los; }
433
+ return los.filter(function (lo) { return self._isShellVisible(lo); });
434
+ };
435
+
436
+ /** Whether a LO's shell paints in the CURRENT mode. */
437
+ FrqtlApp.prototype._isShellVisible = function (lo) {
438
+ var sr = this._shellRenderOf(lo);
439
+ if (sr === 'run') { return true; }
440
+ if (sr === 'never') { return false; }
441
+ return !!this._designMode; // 'design'
442
+ };
443
+
444
+ var FILTER_KEY_PREFIX = 'filter.';
445
+
446
+ /** The provider's label for an error message: the display slot's boot name when it is that provider. */
447
+ function _providerLabel(prov) {
448
+ var slot = FrqtlDisplay;
449
+ var name = (slot.get() === prov) ? slot.name() : null;
450
+ return name ? ('renderer "' + name + '"') : 'the active renderer';
451
+ }
452
+
453
+ /**
454
+ * Validate render-option writes against `prov`'s declared capabilities.
455
+ * THROW-OR-RETURN: returns the normalized `[key, value]` rows; nothing is applied here.
456
+ * @private
457
+ */
458
+ function _validateRenderWrites(prov, rows) {
459
+ if (typeof prov.renderCapabilities !== 'function') {
460
+ throw new TypeError('[FrqtlApp.setRenderOption] ' + _providerLabel(prov) + ' declares no ' +
461
+ 'renderCapabilities(), so it accepts no render option (tried "' + rows.map(function (r) {
462
+ return r[0]; }).join('", "') + '"). A provider declares its knobs by implementing ' +
463
+ 'renderCapabilities().');
464
+ }
465
+ var caps = prov.renderCapabilities();
466
+ var byName = {};
467
+ for (var i = 0; i < caps.length; i++) { byName[caps[i].name] = caps[i]; }
468
+ var normalizeValue = ObjectSchema.normalizeValue;
469
+ var out = [];
470
+ for (var r = 0; r < rows.length; r++) {
471
+ var key = rows[r][0];
472
+ if (FrqtlApp.RUNNER_INJECTED_RENDER_KEYS.indexOf(key) !== -1) {
473
+ throw new TypeError('[FrqtlApp.setRenderOption] "' + key + '" is merged into the render ' +
474
+ 'bag by the runner itself (FrqtlApp.RUNNER_INJECTED_RENDER_KEYS); it is not a writable ' +
475
+ 'render option.');
476
+ }
477
+ var cap = Object.prototype.hasOwnProperty.call(byName, key) ? byName[key] : null;
478
+ if (!cap) {
479
+ throw new TypeError('[FrqtlApp.setRenderOption] "' + key + '" is not a render option ' +
480
+ _providerLabel(prov) + ' declares. Declared by renderCapabilities(): [' +
481
+ caps.map(function (c) { return c.name; }).join(', ') + ']. (FAIL-LOUD: an ' +
482
+ 'undeclared key is read by nothing, so the picture would silently differ from the recipe.)');
483
+ }
484
+ out.push([key, normalizeValue(cap, rows[r][1], 'render option')]);
485
+ }
486
+ return out;
487
+ }
488
+
489
+ /**
490
+ * Apply already-validated rows: a `filter.<name>` toggle routes to setFilterActive (the
491
+ * provider's filter()/removeFilter() slots); every other key lands in the ONE options bag.
492
+ * THE ONE apply site, so it is also where a write is ANNOUNCED (addRenderOptionsListener — a
493
+ * panel reads the live values back through its own factory, whoever wrote them) and where a
494
+ * PAUSED picture is redrawn (a running loop redraws anyway; before the first tick repaint is
495
+ * a no-op by contract).
496
+ * @private
497
+ */
498
+ FrqtlApp.prototype._applyRenderWrites = function (rows) {
499
+ for (var i = 0; i < rows.length; i++) {
500
+ var key = rows[i][0];
501
+ if (key.indexOf(FILTER_KEY_PREFIX) === 0) {
502
+ this.setFilterActive(key.slice(FILTER_KEY_PREFIX.length), !!rows[i][1]);
503
+ } else {
504
+ this._renderOptions[key] = rows[i][1];
505
+ }
506
+ }
507
+ var ls = (this._renderOptionsListeners || []).slice();
508
+ for (var j = 0; j < ls.length; j++) { ls[j](rows.map(function (r) { return [r[0], r[1]]; })); }
509
+ if (!this._isLooping) { this.repaint(); }
510
+ };
511
+
512
+ /**
513
+ * addRenderOptionsListener(fn) → unsubscribe. `fn(rows)` runs once per APPLIED batch of render
514
+ * option writes (`rows` = the applied `[key, value]` pairs), from every writer — a scene knob,
515
+ * `exp.render(...)`, a STUDIO panel, staged writes applied when the provider attaches. The
516
+ * pose-listener / aim-listener precedent: the runner announces, a view reads back.
517
+ * @param {Function} fn @returns {Function} unsubscribe
518
+ */
519
+ FrqtlApp.prototype.addRenderOptionsListener = function (fn) {
520
+ var ls = this._renderOptionsListeners || (this._renderOptionsListeners = []);
521
+ if (ls.indexOf(fn) === -1) { ls.push(fn); }
522
+ return function () { var i = ls.indexOf(fn); if (i !== -1) { ls.splice(i, 1); } };
523
+ };
524
+
525
+ /**
526
+ * runner.setRenderOptions(bag) — write render options as ONE atomic batch.
527
+ *
528
+ * THROW-OR-APPLY. Every key must be a capability the display provider declares through
529
+ * `renderCapabilities()`, and every value must satisfy that descriptor through the SAME
530
+ * declared-type gate a part's field passes (`ObjectSchema.normalizeValue`: an enum member,
531
+ * a number inside its range, a 0/1 coerced to boolean). Every key validates before any
532
+ * applies, so a bad bag applies nothing. `'filter.<name>': 0|1` toggles that post-process
533
+ * filter.
534
+ *
535
+ * Before a provider attaches, the writes are STAGED and validated at attach (the first
536
+ * `_getRenderer()` that finds the display slot filled): a staged key that provider does
537
+ * not declare throws THEN, naming the provider.
538
+ *
539
+ * @param {Object<string, *>} bag — e.g. `{ shape: 'sphere', godrays: 0.8, glowIntensity: 1.6 }`.
540
+ * @throws {TypeError} on a non-object bag, an undeclared key, or an invalid value.
541
+ */
542
+ FrqtlApp.prototype.setRenderOptions = function (bag) {
543
+ if (!bag || typeof bag !== 'object' || Array.isArray(bag)) {
544
+ throw new TypeError('[FrqtlApp.setRenderOptions] expects a plain object of ' +
545
+ '{ capabilityName: value }; got ' + (Array.isArray(bag) ? 'an array' : String(bag)) + '.');
546
+ }
547
+ var rows = Object.keys(bag).map(function (k) { return [k, bag[k]]; });
548
+ var prov = this._getRenderer();
549
+ if (!prov) {
550
+ this._stagedRenderOptions = (this._stagedRenderOptions || []).concat(rows);
551
+ return;
552
+ }
553
+ this._applyRenderWrites(_validateRenderWrites(prov, rows));
554
+ };
555
+
556
+ /**
557
+ * runner.setRenderOption(key, value) — one render option; see setRenderOptions.
558
+ * @param {string} key — a capability name the display provider declares.
559
+ * @param {*} value — a value its descriptor accepts.
560
+ * @throws {TypeError} on an undeclared key or an invalid value.
561
+ */
562
+ FrqtlApp.prototype.setRenderOption = function (key, value) {
563
+ var bag = {};
564
+ bag[key] = value;
565
+ this.setRenderOptions(bag);
566
+ };
567
+
568
+ /**
569
+ * runner.renderCapabilities() — the active provider's `renderCapabilities()` list, the
570
+ * render-option vocabulary. No provider yet, or a provider implementing no
571
+ * `renderCapabilities()`, declares no options: `[]` (the controls factory then emits no
572
+ * render controls, and every render-option write to that provider throws).
573
+ *
574
+ * @returns {Array<object>} field-descriptor-shaped capability descriptors
575
+ * (`lib/shim/field-descriptor`'s `{name,type,constraint,unit,default,
576
+ * description}` shape), or `[]`.
577
+ */
578
+ FrqtlApp.prototype.renderCapabilities = function () {
579
+ var prov = this._getRenderer();
580
+ if (!prov || typeof prov.renderCapabilities !== 'function') { return []; }
581
+ return prov.renderCapabilities();
582
+ };
583
+
584
+ /**
585
+ * runner.getRenderOption(key) — the live value of a declared render option;
586
+ * `filter.<name>` reads isFilterActive (a boolean, matching its descriptor).
587
+ *
588
+ * @param {string} key
589
+ * @returns {*} the live value (undefined when never written; the descriptor's `default`
590
+ * states what the provider draws then).
591
+ * @throws {TypeError} when no provider has attached, or `key` is not declared by it.
592
+ */
593
+ FrqtlApp.prototype.getRenderOption = function (key) {
594
+ var prov = this._getRenderer();
595
+ if (!prov) {
596
+ throw new TypeError('[FrqtlApp.getRenderOption] no display provider has attached yet, so ' +
597
+ 'no render option is declared. Read after the renderer boots.');
598
+ }
599
+ if (typeof prov.renderCapabilities !== 'function') {
600
+ throw new TypeError('[FrqtlApp.getRenderOption] ' + _providerLabel(prov) + ' declares no ' +
601
+ 'renderCapabilities(), so "' + key + '" is not a render option it has.');
602
+ }
603
+ var caps = prov.renderCapabilities();
604
+ var found = false;
605
+ for (var i = 0; i < caps.length; i++) { if (caps[i].name === key) { found = true; break; } }
606
+ if (!found) {
607
+ throw new TypeError('[FrqtlApp.getRenderOption] "' + key + '" is not declared by ' +
608
+ _providerLabel(prov) + ' (declared: [' +
609
+ caps.map(function (c) { return c.name; }).join(', ') + ']).');
610
+ }
611
+ if (key.indexOf(FILTER_KEY_PREFIX) === 0) {
612
+ return this.isFilterActive(key.slice(FILTER_KEY_PREFIX.length));
613
+ }
614
+ return this._renderOptions[key];
615
+ };
616
+
617
+ /**
618
+ * runner.setFilterActive(name, active) / runner.isFilterActive(name) — the
619
+ * canonical write/read pair for a `filter.<name>` render capability (WS
620
+ * Studio Controls Realignment §7 r2 item 2, the ratified filter re-model).
621
+ * `filter()`/`removeFilter()` are ALREADY-REQUIRED `RendererProvider`
622
+ * contract methods and the canonical MULTI-SLOT name-keyed filter primitive
623
+ * (`Filters.water`/`Filters.bloom` = `{name, glsl, uniforms}`) — this pair
624
+ * is NOT a second write path, it is the uniform accessor `controlBind.js`'s
625
+ * render branch calls for a `filter.` capability instead of tunneling
626
+ * through `setRenderOption` (which has no multi-slot/toggle semantics and
627
+ * cannot express "2 filters concurrently active").
628
+ *
629
+ * @param {string} name — a `Filters` catalog key (e.g. 'water', 'bloom').
630
+ * @param {boolean} active
631
+ * @throws {Error} if no renderer is active, or `name` is not a real
632
+ * `Filters` catalog entry (FAIL-LOUD — never a silent no-op filter call).
633
+ */
634
+ FrqtlApp.prototype.setFilterActive = function (name, active) {
635
+ var prov = this._getRenderer();
636
+ if (!prov || typeof prov.filter !== 'function' || typeof prov.removeFilter !== 'function') {
637
+ throw new Error('[FrqtlApp.setFilterActive] no active renderer with filter()/' +
638
+ 'removeFilter() (RendererProvider REQUIRED methods). (FAIL-LOUD.)');
639
+ }
640
+ var FiltersRef = Filters;
641
+ if (!Object.prototype.hasOwnProperty.call(FiltersRef, name)) {
642
+ throw new Error('[FrqtlApp.setFilterActive] unknown filter "' + name + '" — not a ' +
643
+ 'Filters.js catalog entry. (FAIL-LOUD.)');
644
+ }
645
+ if (!this._activeFilters) { this._activeFilters = {}; }
646
+ if (active) {
647
+ prov.filter(FiltersRef[name]);
648
+ this._activeFilters[name] = true;
649
+ } else {
650
+ prov.removeFilter(name);
651
+ delete this._activeFilters[name];
652
+ }
653
+ };
654
+
655
+ /** @param {string} name — a `Filters` catalog key. @returns {boolean} */
656
+ FrqtlApp.prototype.isFilterActive = function (name) {
657
+ return !!(this._activeFilters && this._activeFilters[name]);
658
+ };
659
+
660
+ // ── Render-pipeline TAPS (the RENDER_STAGES contract) — DELEGATED ──────
661
+ //
662
+ // The MECHANISM lives at `lib/frqtl/objects/instruments/capture.js` (THE_MINIMAL_SIM
663
+ // §4): the tap is an INSTRUMENT on the primitive surface — bound to the loop, the
664
+ // provider, the scene identity and the live render-options bag — not an editor concern,
665
+ // and it needs no host and no DOM. This host keeps only the conveniences below, and
666
+ // each is a THIN FORWARD: zero dispatch, zero throw rules, zero provenance stamping
667
+ // here. The reject-not-throw wrapper lives in exactly ONE place (the mechanism), so
668
+ // these forwards deliberately add no try/catch of their own.
669
+ //
670
+ // The stage vocabulary stays with its owner, `lib/shim/render/render-stages` (the shim
671
+ // owns cross-layer vocabularies — peer of frame-data's RENDER_INTENT_KEYS); the
672
+ // instrument ferries it.
673
+
674
+ /**
675
+ * runner.setSourceRef(ref) — register the experiment/rung identity for the SceneRecord.
676
+ * Forwards to the instrument's identity. See instruments/capture.js.
677
+ * @param {string|null} ref
678
+ */
679
+ FrqtlApp.prototype.setSourceRef = function (ref) {
680
+ return this._capture.setSourceRef(ref);
681
+ };
682
+
683
+ /**
684
+ * runner.setSourceSha256(sha) — register the project identity (the code half) for the
685
+ * SceneRecord. Forwards to the instrument. See instruments/capture.js.
686
+ * @param {string|null} sha
687
+ */
688
+ FrqtlApp.prototype.setSourceSha256 = function (sha) {
689
+ return this._capture.setSourceSha256(sha);
690
+ };
691
+
692
+ /**
693
+ * runner.sceneRecord() → Promise<SceneRecord + its own content id>. Forwards.
694
+ * @returns {Promise<object>}
695
+ */
696
+ FrqtlApp.prototype.sceneRecord = function () {
697
+ return this._capture.sceneRecord();
698
+ };
699
+
700
+ /**
701
+ * THE DIVERGENCE GATE — a physics-registered object that no renderer can see is a
702
+ * THROW, never an invisible body.
703
+ *
704
+ * ── Layer 1: API contract ────────────────────────────────────────────────
705
+ * Called before every draw and before every capture. The world answers which of its
706
+ * LIVE engine registrations carry no JS wrapper (`unwrappedLargeObjectHandles` — the
707
+ * only ledger that can tell a live object from a torn-down one); any survivor means
708
+ * the draw list DERIVED from the world's wrapper census is missing something the
709
+ * physics knows about, and this refuses to draw rather than paint a lie.
710
+ *
711
+ * With the census at the world handle the ordinary case cannot arise — constructing a
712
+ * `LargeObject` registers it for physics AND render in one act. What remains is the
713
+ * RAW `universe.registerLargeObject(spec)` path, which is legitimate in a headless
714
+ * conformance harness (nothing draws) and is exactly the silent-invisible case in a
715
+ * rendered world. So this is the enforcement tooth on the preferred shape, not an
716
+ * alternative to it.
717
+ *
718
+ * The capability check is METHOD PRESENCE (the `move()`/`cameraControl` idiom): a
719
+ * world that publishes no ledger is not a world this host can interrogate, and this
720
+ * host does not invent one.
721
+ *
722
+ * ── Layer 2: physics behaviour ───────────────────────────────────────────
723
+ * ZERO. It reads two ledgers and throws or returns; no frame, dimension, tick or
724
+ * op-program is touched.
725
+ * @private
726
+ */
727
+ FrqtlApp.prototype._assertRenderCensus = function () {
728
+ var u = this.universe;
729
+ if (!u || typeof u.unwrappedLargeObjectHandles !== 'function') { return; }
730
+ var orphans = u.unwrappedLargeObjectHandles();
731
+ if (orphans.length === 0) { return; }
732
+ throw new Error('[FrqtlApp] ' + orphans.length + ' object(s) are registered with the ' +
733
+ 'PHYSICS of this world but absent from its render census — handle(s) [' +
734
+ orphans.join(', ') + ']. They collide and count correctly and CANNOT BE SEEN, ' +
735
+ 'which is the one outcome this surface refuses: a silently wrong picture is worse ' +
736
+ 'than an error, because what the eye is taught from it is wrong. Construct large ' +
737
+ 'objects through `new LargeObject(universe, …)` (or a part that does — Wall, ' +
738
+ 'GraphingDetectorWall, Tunnel …), which registers them for physics AND render in ' +
739
+ 'one act. The raw `universe.registerLargeObject(spec)` path carries no wrapper and ' +
740
+ 'belongs to headless harnesses only. (FAIL-LOUD.)');
741
+ };
742
+
743
+ /**
744
+ * runner.capture(stage) — the generic render-pipeline tap. Forwards.
745
+ * @param {string} stage — a member of `RenderStages.RENDER_STAGES`.
746
+ * @returns {Promise<object>}
747
+ */
748
+ FrqtlApp.prototype.capture = function (stage) {
749
+ // A Promise-returning entry point never throws SYNCHRONOUSLY: a mixed error channel
750
+ // guarantees an uncaught exception at some call site that only wrote `.catch()`.
751
+ try { this._assertRenderCensus(); } catch (e) { return Promise.reject(e); }
752
+ return this._capture.capture(stage);
753
+ };
754
+
755
+ /**
756
+ * runner.captureStill() — the LAST-STAGE CONVENIENCE. Forwards.
757
+ * @returns {Promise<object>}
758
+ */
759
+ FrqtlApp.prototype.captureStill = function () {
760
+ try { this._assertRenderCensus(); } catch (e) { return Promise.reject(e); }
761
+ return this._capture.captureStill();
762
+ };
763
+
764
+ /**
765
+ * Add a background-image under-layer (shared backdrop helper — Phase 2).
766
+ *
767
+ * Creates and registers a NON-COLLIDING LargeObject that the renderer draws as the
768
+ * bottom-most layer via _layerBackdrop (under particles and large objects).
769
+ *
770
+ * This is the canonical way to add a backdrop in ANY experiment — no per-page
771
+ * LargeObject construction needed. The backdop's .visible is false by default;
772
+ * the caller toggles it on when desired.
773
+ *
774
+ * @param {Object} image - a p5.Graphics, HTMLCanvasElement, or any renderable
775
+ * object the active renderer can draw as a backdrop image.
776
+ * @param {Object} [rect] - optional {left, top, w, h} in physics pixels.
777
+ * Defaults to the full canvas rect.
778
+ * @param {Object} [opts] - optional overrides: { visible (default false),
779
+ * color (default [0,0,0]) }
780
+ * @returns {Object} - the LargeObject created, for further manipulation.
781
+ */
782
+ FrqtlApp.prototype.addBackdrop = function (image, rect, opts) {
783
+ opts = opts || {};
784
+ var r = rect || {
785
+ left: 0, top: 0,
786
+ w: this.canvas_rect.w,
787
+ h: this.canvas_rect.h
788
+ };
789
+ var color = opts.color || [0, 0, 0];
790
+ var visible = (opts.visible !== undefined) ? opts.visible : false;
791
+
792
+ var _LO = LargeObject;
793
+ var _CT = CollisionTypes;
794
+
795
+ var backdrop = new _LO(
796
+ this.universe,
797
+ [r.left, r.top, r.w, r.h],
798
+ _CT.NONE, [], 'backdrop', 'closest', null, 1, color
799
+ );
800
+ backdrop.backdrop = true;
801
+ backdrop.image = image;
802
+ backdrop.visible = visible;
803
+
804
+ // No render-list call: constructing a LargeObject on this world IS registering it,
805
+ // for physics AND for the renderer (CollisionTypes.NONE makes it draw-only).
806
+ return backdrop;
807
+ };
808
+
809
+ // ── THE DECLARED-OBJECT REGISTRY ─────────────────────────────────────────────────────────
810
+ //
811
+ // From STUDIO's point of view only the declared sugar object in code (not its sub objects)
812
+ // is manipulable, so only what the user proactively declares is registered.
813
+ //
814
+ // `declared` is the ordered list of the handles the user's DECLARING calls produced
815
+ // (`exp.addObject`, a palette drop, a codegen replay — all three funnel through
816
+ // `ObjectFactory.create`, the registry's ONE writer). It is a different fact from the world
817
+ // census (`largeObjects` — everything the world draws and collides): a part's composition is
818
+ // in the census but never declared. "Which declared object does this drawn thing belong to"
819
+ // is answered by walking the ONE up-link `_owner` each composer stamps on what it composed
820
+ // (`declaredOf`). A `new Wall(universe, …)` written directly is the PRIMITIVE surface: in the
821
+ // world, drawn, colliding — not declared, not manipulable.
822
+
823
+ /** A declaration's census LOs: the LOs `ObjectFactory.create` saw it put in the world. */
824
+ function _declaredMembersMap(app) {
825
+ return app._declaredMembers || (app._declaredMembers = new Map());
826
+ }
827
+ /** A declaration's host tick-functions: the ones `ObjectFactory.create` saw its build push. */
828
+ function _declaredTicksMap(app) {
829
+ return app._declaredTicks || (app._declaredTicks = new Map());
830
+ }
831
+
832
+ /**
833
+ * runner.declared — the declared objects, in declaration order. Read-only by contract
834
+ * (written only by `declare`, emptied only by `clearDeclared`). THROWS when an entry's census
835
+ * LOs are no longer in the world: the world was re-inited by a path that did not clear the
836
+ * registry, and a dead handle would otherwise sit in a Layers row silently.
837
+ * @returns {Array}
838
+ */
839
+ Object.defineProperty(FrqtlApp.prototype, 'declared', {
840
+ configurable: true,
841
+ get: function () {
842
+ var list = this._declared || (this._declared = []);
843
+ if (!list.length) { return list; }
844
+ var census = new Set(this.largeObjects);
845
+ var members = _declaredMembersMap(this);
846
+ for (var i = 0; i < list.length; i++) {
847
+ var ms = members.get(list[i]) || [];
848
+ for (var j = 0; j < ms.length; j++) {
849
+ if (!census.has(ms[j])) {
850
+ throw new Error('[FrqtlApp.declared] stale declaration — "' +
851
+ (list[i].name || list[i]._schemaKind) + '" lost its census LO "' + (ms[j].name || '?') +
852
+ '": the world was re-inited without clearing the registry (clearDeclared runs at ' +
853
+ 'FrqtlApp.setup and the RecipeContext co-init exp.createCanvas / exp.createScene — the ' +
854
+ 'world-init paths). (FAIL-LOUD.)');
855
+ }
856
+ }
857
+ }
858
+ return list;
859
+ }
860
+ });
861
+
862
+ /**
863
+ * declare(handle, members, tickFns) — record a declared object. Called ONLY by
864
+ * `ObjectFactory.create` (the one funnel of every declaring call; a static gate holds it to one
865
+ * call site). `members` are the census LOs the declaration put in the world; each must resolve
866
+ * (`declaredOf`) to `handle` — a part that forgot to stamp `_owner` on what it composed
867
+ * THROWS here instead of leaking an unresolvable LO into the scene. `tickFns` are the host
868
+ * tick-functions the build pushed (an emitter's emit fn, an absorb radiator): what the
869
+ * declaration owns besides what it draws. THROWS on a re-declaration.
870
+ * @param {Object} handle @param {Array} members @param {Array} [tickFns] @returns {Object} handle
871
+ */
872
+ FrqtlApp.prototype.declare = function (handle, members, tickFns) {
873
+ if (!handle || typeof handle !== 'object') {
874
+ throw new TypeError('[FrqtlApp.declare] a declared object is a handle, got ' + typeof handle + '. (FAIL-LOUD.)');
875
+ }
876
+ var list = this._declared || (this._declared = []);
877
+ if (list.indexOf(handle) !== -1) {
878
+ throw new Error('[FrqtlApp.declare] "' + (handle.name || handle._schemaKind) + '" is already declared. ' +
879
+ '(FAIL-LOUD.)');
880
+ }
881
+ list.push(handle);
882
+ var ms = (members || []).slice();
883
+ _declaredMembersMap(this).set(handle, ms);
884
+ _declaredTicksMap(this).set(handle, (tickFns || []).slice());
885
+ for (var i = 0; i < ms.length; i++) {
886
+ if (this.declaredOf(ms[i]) !== handle) {
887
+ list.pop();
888
+ _declaredMembersMap(this).delete(handle);
889
+ _declaredTicksMap(this).delete(handle);
890
+ throw new Error('[FrqtlApp.declare] "' + (handle.name || handle._schemaKind) + '" put the LO "' +
891
+ (ms[i].name || '?') + '" in the world without an `_owner` chain ending at it — a composer ' +
892
+ 'stamps ONE up-link on what it composed, so every drawn thing resolves to its declared ' +
893
+ 'object. (FAIL-LOUD.)');
894
+ }
895
+ }
896
+ return handle;
897
+ };
898
+
899
+ /** clearDeclared() — empty the registry. Called where the world re-inits (FrqtlApp.setup, and the
900
+ * RecipeContext co-init exp.createCanvas / exp.createScene): a fresh world has no declared objects. */
901
+ FrqtlApp.prototype.clearDeclared = function () {
902
+ this._declared = [];
903
+ this._declaredMembers = new Map();
904
+ this._declaredTicks = new Map();
905
+ };
906
+
907
+ /**
908
+ * declaredOf(x) → the declared object `x` IS or belongs to, else null. Walks the ONE up-link
909
+ * `_owner` (stamped by the part that composed x) until it reaches a registry member; null when
910
+ * the chain ends outside the registry (an undeclared, primitive-surface object).
911
+ */
912
+ FrqtlApp.prototype.declaredOf = function (x) {
913
+ var list = this._declared || [];
914
+ for (var cur = x, hops = 0; cur && hops < 64; cur = cur._owner, hops++) {
915
+ if (list.indexOf(cur) !== -1) { return cur; }
916
+ }
917
+ return null;
918
+ };
919
+
920
+ /** membersOf(declared) → the census LOs that declaration put in the world (what draws it). */
921
+ FrqtlApp.prototype.membersOf = function (d) {
922
+ return (_declaredMembersMap(this).get(d) || []).slice();
923
+ };
924
+
925
+ /**
926
+ * undeclaredTickFunctions() → the host tick-functions running in the world that NO declared
927
+ * object owns: a setup factory's emitter, a page's own regulation fn, a sketch's
928
+ * exp.addTickFunction. They are behaviour the registry cannot write down, so whatever writes the
929
+ * world as declarations (codegen) refuses while any exists.
930
+ * @returns {Function[]}
931
+ */
932
+ FrqtlApp.prototype.undeclaredTickFunctions = function () {
933
+ var fns = (this.universe && Array.isArray(this.universe.experimentTickFunctions))
934
+ ? this.universe.experimentTickFunctions : [];
935
+ var owned = new Set();
936
+ _declaredTicksMap(this).forEach(function (ts) { for (var i = 0; i < ts.length; i++) { owned.add(ts[i]); } });
937
+ return fns.filter(function (fn) { return !owned.has(fn); });
938
+ };
939
+
940
+ /**
941
+ * removeObject(declared) — delete a DECLARED object: drop it from the registry and call its own
942
+ * `dispose()` (each part tears down what it composed — a Tunnel unsubscribes its redirect and
943
+ * disposes its plates and gate, a Wall drops its absorb radiator, a SphereEmitter its emit
944
+ * tick-fn and its marker). A sub-object THROWS naming its declared owner; an undeclared object
945
+ * THROWS naming the primitive surface. DISPOSE IS THE REMOVAL: an LO's dispose leaves the world
946
+ * census the renderer draws from, and any census LO the declaration put in the world that is
947
+ * still there afterwards THROWS.
948
+ * @param {Object} obj — a member of `runner.declared`
949
+ */
950
+ FrqtlApp.prototype.removeObject = function (obj) {
951
+ var list = this.declared;
952
+ var i = list.indexOf(obj);
953
+ if (i === -1) {
954
+ var owner = this.declaredOf(obj);
955
+ if (owner) {
956
+ throw new Error('[FrqtlApp.removeObject] "' + (obj && obj.name) + '" is part of the declared ' +
957
+ (owner._schemaKind || 'object') + ' "' + owner.name + '" — delete "' + owner.name +
958
+ '", not its part. (FAIL-LOUD.)');
959
+ }
960
+ throw new Error('[FrqtlApp.removeObject] "' + (obj && (obj.name || obj._schemaKind)) + '" is not a ' +
961
+ 'declared object (runner.declared) — only what a declaring call (exp.addObject, a palette drop) ' +
962
+ 'produced is removed here; a primitive-surface object is disposed by whoever built it ' +
963
+ '(obj.dispose()). (FAIL-LOUD.)');
964
+ }
965
+ if (typeof obj.dispose !== 'function') {
966
+ throw new Error('[FrqtlApp.removeObject] the declared ' + (obj._schemaKind || 'object') + ' "' + obj.name +
967
+ '" has no dispose() — a part tears down what it composed. (FAIL-LOUD.)');
968
+ }
969
+ var members = this.membersOf(obj);
970
+ var ticks = (_declaredTicksMap(this).get(obj) || []).slice();
971
+ list.splice(i, 1);
972
+ _declaredMembersMap(this).delete(obj);
973
+ _declaredTicksMap(this).delete(obj);
974
+ obj.dispose();
975
+ var running = (this.universe && Array.isArray(this.universe.experimentTickFunctions))
976
+ ? this.universe.experimentTickFunctions : [];
977
+ var leftTicks = ticks.filter(function (fn) { return running.indexOf(fn) !== -1; });
978
+ if (leftTicks.length) {
979
+ throw new Error('[FrqtlApp.removeObject] disposing "' + obj.name + '" left ' + leftTicks.length +
980
+ ' of its tick-functions running — its dispose() must tear down everything it composed. (FAIL-LOUD.)');
981
+ }
982
+ var census = new Set(this.largeObjects);
983
+ var left = members.filter(function (m) { return census.has(m); });
984
+ if (left.length) {
985
+ throw new Error('[FrqtlApp.removeObject] disposing "' + obj.name + '" left ' + left.length +
986
+ ' of its LOs in the world (' + left.map(function (m) { return m.name; }).join(', ') +
987
+ ') — its dispose() must tear down everything it composed. (FAIL-LOUD.)');
988
+ }
989
+ };
990
+
991
+ /**
992
+ * getObjectByName(name, predicate?) → the DECLARED object of that name (passing `predicate` when
993
+ * given), else null — the lazy by-name reference every cross-object link that must survive a
994
+ * Restart uses (an emitter's gateCounter, a LiveChart source). Handles are fresh at registration
995
+ * and never survive codegen, so the reference is a NAME resolved against the registry at use
996
+ * time. The CALLER decides whether null is FAIL-LOUD (the emission gate throws; a LiveChart
997
+ * retries next scan).
998
+ * @param {string} name @param {Function} [predicate] @returns {Object|null}
999
+ */
1000
+ FrqtlApp.prototype.getObjectByName = function (name, predicate) {
1001
+ if (typeof name !== 'string' || !name) { return null; }
1002
+ var list = this.declared;
1003
+ for (var i = 0; i < list.length; i++) {
1004
+ var d = list[i];
1005
+ if (d && d.name === name && (!predicate || predicate(d))) { return d; }
1006
+ }
1007
+ return null;
1008
+ };
1009
+
1010
+ /**
1011
+ * Fan a RENDER-ONLY property write out to the parts a composite composed.
1012
+ *
1013
+ * ── Layer 1: API contract ────────────────────────────────────────────────
1014
+ * `owner` is a composite (Tunnel / LogicGate / Box / OvenBox …). Its DIRECT children are the
1015
+ * things whose ONE up-link `_owner` is `owner` — found by walking each census LO up its
1016
+ * `_owner` chain to the node just below `owner` (one hop below the composite). Each child
1017
+ * receives the SAME write through the SAME accessor, so its own kind branch (a Wall facade
1018
+ * delegating to its LO, a nested composite fanning out again) applies unchanged. A child whose
1019
+ * descriptor does not declare the prop is skipped (a heterogeneous composite is legal); a
1020
+ * non-composite owns nothing and this is a no-op.
1021
+ *
1022
+ * ── Layer 2: physics behavior ────────────────────────────────────────────
1023
+ * None. Only `engineVisible:false` (render-only) props reach here — colour,
1024
+ * opacity, visibility. Zero engine writes, zero op-program recompiles, so a
1025
+ * bench golden cannot move (SU1).
1026
+ *
1027
+ * @param {Object} runner — the FrqtlApp owning the object list.
1028
+ * @param {Object} owner — the wrapper the write was addressed to.
1029
+ * @param {string} prop — a render-only field name.
1030
+ * @param {*} value — the new value.
1031
+ */
1032
+ function _fanOutRenderPropToGroup(runner, owner, prop, value) {
1033
+ var list = runner.largeObjects;
1034
+ if (!owner || !list || list.length === 0) { return; }
1035
+ var schema = ObjectSchema;
1036
+ var children = [];
1037
+ for (var i = 0; i < list.length; i++) {
1038
+ for (var cur = list[i], hops = 0; cur && cur._owner && hops < 64; cur = cur._owner, hops++) {
1039
+ if (cur._owner === owner) {
1040
+ if (children.indexOf(cur) === -1) { children.push(cur); }
1041
+ break;
1042
+ }
1043
+ }
1044
+ }
1045
+ for (var c = 0; c < children.length; c++) {
1046
+ var child = children[c];
1047
+ if (schema) {
1048
+ var childDesc = schema.get(child._schemaKind || child.kind || 'LargeObject');
1049
+ var declared = false;
1050
+ if (childDesc) {
1051
+ for (var f = 0; f < childDesc.fields.length; f++) {
1052
+ if (childDesc.fields[f].name === prop) { declared = true; break; }
1053
+ }
1054
+ }
1055
+ if (!declared) { continue; }
1056
+ }
1057
+ runner.updateObjectProperty(child, prop, value);
1058
+ }
1059
+ }
1060
+
1061
+ /**
1062
+ * Uniform schema-driven mutation entry point (Phase 4 — runner level).
1063
+ *
1064
+ * ONE public method the IDE uses to edit ANY property of ANY object.
1065
+ * Routing:
1066
+ * engineVisible:false (color, visible, alpha, name, ...) →
1067
+ * mutate the JS-side wrapper directly (render layer; no engine ABI).
1068
+ * engineVisible:true (rect, collision_type, source_rects, ...) →
1069
+ * forward to universe.updateObjectProperty (engine-concurrence path).
1070
+ *
1071
+ * Validation: prop MUST be in the schema for kind → TypeError (FAIL LOUD).
1072
+ *
1073
+ * @param {Object} lo - LargeObject / QuantumEmitter wrapper with .handle and .kind
1074
+ * @param {string} prop - Field name declared in ObjectSchema for lo's kind
1075
+ * @param {*} value - New value
1076
+ * @throws {TypeError} - If prop is unknown / readonly for the object's kind
1077
+ */
1078
+ FrqtlApp.prototype.updateObjectProperty = function (lo, prop, value) {
1079
+ if (!lo) return;
1080
+
1081
+ // Every shipped object class STAMPS `_schemaKind` (LargeObject, QuantumEmitter,
1082
+ // PointSource, Wall, DetectorWall, SphereEmitter, …), so the
1083
+ // old `|| 'LargeObject'` fallback never described a real object — it only fired for
1084
+ // a handle that is not one of them. Defaulting there turned "I cannot identify this
1085
+ // object" into "Property X is not in the schema for kind LargeObject", which blames
1086
+ // the SCHEMA for what is actually an unidentified caller argument and sends the
1087
+ // reader to the wrong owner entirely. Identify the failure honestly instead.
1088
+ var kind = lo._schemaKind || lo.kind;
1089
+ if (!kind) {
1090
+ throw new TypeError(
1091
+ '[FrqtlApp.updateObjectProperty] this object carries no `_schemaKind` ' +
1092
+ '(nor `kind`), so its descriptor cannot be resolved — property "' + prop + '" was ' +
1093
+ 'not rejected by any schema. Every shipped object class stamps `_schemaKind`; a ' +
1094
+ 'handle without one is usually the wrong handle (e.g. a raw emitter handle rather ' +
1095
+ 'than the palette object that wraps it).'
1096
+ );
1097
+ }
1098
+
1099
+ // ── Schema lookup ─────────────────────────────────────────────────────
1100
+ var schema = ObjectSchema;
1101
+
1102
+ var fieldDef = null;
1103
+ if (schema) {
1104
+ var descriptor = schema.get(kind);
1105
+ if (!descriptor) {
1106
+ throw new TypeError(
1107
+ '[FrqtlApp.updateObjectProperty] Unknown object kind "' + kind + '"'
1108
+ );
1109
+ }
1110
+ for (var i = 0; i < descriptor.fields.length; i++) {
1111
+ if (descriptor.fields[i].name === prop) {
1112
+ fieldDef = descriptor.fields[i];
1113
+ break;
1114
+ }
1115
+ }
1116
+ if (!fieldDef) {
1117
+ throw new TypeError(
1118
+ '[FrqtlApp.updateObjectProperty] Property "' + prop +
1119
+ '" is not in the schema for kind "' + kind + '"'
1120
+ );
1121
+ }
1122
+ if (fieldDef.type === 'readonly') {
1123
+ throw new TypeError(
1124
+ '[FrqtlApp.updateObjectProperty] Property "' + prop +
1125
+ '" is readonly for kind "' + kind + '"'
1126
+ );
1127
+ }
1128
+ // ── Declared-type normalization (THE type gate) ───────────────────
1129
+ // The descriptor declares the TYPE; the accessor is the one validating gate,
1130
+ // so this is where the written value is made to match it — or REJECTED. The
1131
+ // semantics live with the declarations, at the descriptor owner
1132
+ // (lib/frqtl/objects/schema `normalizeValue`), and are FERRIED here: a
1133
+ // boolean 0/1 becomes a real boolean (every consumer tests IDENTITY — a
1134
+ // stored `0` reads as "still visible" and the write silently does nothing);
1135
+ // a wrong-typed value (a `'42'` string, a non-array `color`, a non-array
1136
+ // `rect` that would reach `lo.rect.w = r[2]` as NaN geometry, an undeclared
1137
+ // enum member) THROWS here instead of being stored and poisoning a reader
1138
+ // far away. Throw-or-apply, one call, every caller.
1139
+ value = schema.normalizeValue(fieldDef, value, kind);
1140
+
1141
+ // ── SphereEmitter — sugar; no engine handle ───────────────────────
1142
+ // A SphereEmitter's real state lives in the setupSphereEmitter3D control
1143
+ // closure (a host emit tick-fn), not on any engine object. Route EVERY
1144
+ // editable/adapter prop to the matching control setter via the facade's
1145
+ // own write-router (applyProp) — mechanism-A: the Inspector and the visual
1146
+ // marker's coupled-move call THIS, never resolveBind (which is why the
1147
+ // SphereEmitter kind sits outside controlBind's bind-executable allowlist).
1148
+ // `rect` is the input adapter (rect-centre → control.setCenter).
1149
+ // Return BEFORE the generic engineVisible mirror-write below (which would
1150
+ // assign to a getter-only prop and throw). No universe delegation — the
1151
+ // sugar IS the engine-facing write.
1152
+ // ── A WALL's rect — the wall's ONE position write (Wall.setRect) ─────────
1153
+ // A Wall-family facade (Wall, GraphingDetectorWall — `_wallFacadeOf`, structural): the
1154
+ // rect IS the wall's rect, so the write lands on setRect, which keeps the true slab, the
1155
+ // drawn rect, the engine broad rect + backing frame and an absorber's exit in step.
1156
+ var wallFacade = _wallFacadeOf(lo);
1157
+ if (prop === 'rect' && wallFacade) {
1158
+ wallFacade.setRect(value);
1159
+ if (typeof this._onObjectMoved === 'function') { this._onObjectMoved(wallFacade, prop); }
1160
+ return;
1161
+ }
1162
+
1163
+ var V2_WALL_PROPS = ['collisionBehavior', 'bounceDirection', 'decoherence',
1164
+ 'hooks', 'markTag', 'countFilter', 'capacity', 'orientationDeg', 'pitchDeg'];
1165
+
1166
+ // ── Wall (W1) — sugar facade over ONE LargeObject (lo.lo) ─────────
1167
+ // Any Wall-family facade (_wallFacadeOf), for the fields the Wall descriptor declares. A
1168
+ // subclass's OWN declared fields (a GraphingDetectorWall's binPx/refreshMs/heatBase)
1169
+ // fall through to the generic path, as before.
1170
+ // A lexicon edit RECOMPILES the running op-program (the "flip a wall into a
1171
+ // detector mid-run" live path — Wall.setV2Prop). Every other
1172
+ // prop (rect/color/alpha/visible/layer/shellShape) is DELEGATED DOWN to the
1173
+ // inner LargeObject via the SAME accessor, so its engine-visible rect/layer
1174
+ // push + render-only mirror logic applies UNCHANGED (the inner LO keeps its
1175
+ // own _schemaKind 'LargeObject'). Return before the generic paths below
1176
+ // (which would hit the facade's read-only rect getter and throw).
1177
+ if (wallFacade === lo && _isWallField(schema, prop)) {
1178
+ // Collision LEXICON v2: the nested behavior/direction/modifier/hook props
1179
+ // live on the FACADE (updated in place so the Inspector's visibleWhen re-read
1180
+ // reveals dependent fields immediately) and recompile the running op-program
1181
+ // via setV2Prop. It is the ONE wall write path.
1182
+ // R2AX (two-axis mirror, 2026-07-26): pitchDeg is a FACADE v2 prop,
1183
+ // exactly like orientationDeg — its live edit re-bakes the 3-space
1184
+ // reflection via the SAME setV2Prop recompile path (Wall.setPitch). It
1185
+ // MUST be listed here or it falls to the `lo.lo` delegate below and
1186
+ // recurses into the inner LargeObject, whose schema has no pitchDeg →
1187
+ // a spurious "not in the schema for kind LargeObject" throw on a live
1188
+ // edit that would otherwise land. One facade path, no schema mirror.
1189
+ if (V2_WALL_PROPS.indexOf(prop) !== -1) {
1190
+ if (typeof lo.setV2Prop === 'function') { lo.setV2Prop(prop, value); }
1191
+ } else if (lo.lo) {
1192
+ this.updateObjectProperty(lo.lo, prop, value);
1193
+ }
1194
+ if (prop === 'rect' && typeof this._onObjectMoved === 'function') {
1195
+ this._onObjectMoved(lo, prop);
1196
+ }
1197
+ return;
1198
+ }
1199
+
1200
+ // ── Tunnel (D5) — composite sugar over two Walls + the exit gate ──
1201
+ // Its live SUGAR props (the exit-gate flags) recompile the gate's op-program via
1202
+ // the facade's own write-router. Without this hop the write fell through to the
1203
+ // generic mirror-write below, setting a DEAD property on the wrapper while the
1204
+ // gate program stayed as it was — the Inspector flag flipped and nothing happened.
1205
+ // The router returns FALSE for props it does not own (`color`), which deliberately
1206
+ // falls through to the group fan-out that paints both mirror walls.
1207
+ if (kind === 'Tunnel' && typeof lo.applyProp === 'function') {
1208
+ if (lo.applyProp(prop, value)) {
1209
+ // A position write (the envelope, or the bore origin) moved the whole corridor:
1210
+ // the same object-moved event a Wall's rect write fires (overlay repaint).
1211
+ if ((prop === 'rect' || prop === 'left' || prop === 'top') &&
1212
+ typeof this._onObjectMoved === 'function') { this._onObjectMoved(lo, prop); }
1213
+ return;
1214
+ }
1215
+ }
1216
+
1217
+ if (kind === 'SphereEmitter') {
1218
+ if (typeof lo.applyProp === 'function') { lo.applyProp(prop, value); }
1219
+ // F5: a position write (a grab carry / programmatic center or rect-adapter)
1220
+ // fires the object-moved event so a host (Studio's overlay) can REPAINT — PR-1
1221
+ // made the bbox DATA fresh, but the flat overlay only redraws on discrete events;
1222
+ // this EXTENDS the event set (never a poll). rect/center are the position props.
1223
+ if ((prop === 'center' || prop === 'rect') && typeof this._onObjectMoved === 'function') {
1224
+ this._onObjectMoved(lo, prop);
1225
+ }
1226
+ return;
1227
+ }
1228
+
1229
+ // ── Render-only (engineVisible:false) ─────────────────────────────
1230
+ // Mutate the JS-side wrapper object directly. The renderer reads from it
1231
+ // on the next draw frame. No engine ABI — physics is not affected.
1232
+ if (!fieldDef.engineVisible) {
1233
+ // Studio Controls Realignment S4:
1234
+ // collision_type/actions are SUGAR-INPUT fields compiled to an
1235
+ // op-program at REGISTRATION (actionCompiler.js) — a bare mirror-write
1236
+ // (what every OTHER engineVisible:false prop gets) would only update
1237
+ // this wrapper's DISPLAY value, never retarget the RUNNING op-program
1238
+ // (a wall flipped to a detector mid-run would keep acting like a
1239
+ // wall). Route these two through the recompile-and-push helper
1240
+ // instead — "flipping a wall into a detector mid-run" made live.
1241
+ if ((kind === 'LargeObject' || kind === 'backdrop') &&
1242
+ (prop === 'collision_type' || prop === 'actions') &&
1243
+ typeof lo.recompileActions === 'function') {
1244
+ var recompileOpts = {};
1245
+ recompileOpts[prop === 'collision_type' ? 'collisionType' : 'actions'] = value;
1246
+ lo.recompileActions(recompileOpts);
1247
+ return;
1248
+ }
1249
+ lo[prop] = value;
1250
+ // ── GROUP FAN-OUT (throw-or-apply, never a silent no-op) ───────
1251
+ // A composite wrapper (Box / Tunnel / OvenBox / SlitScreen) owns no
1252
+ // drawn body of its own: it DECLARES a render-only prop ("Render
1253
+ // color for both mirror walls") whose effect lives on the walls it
1254
+ // built. The bare mirror-write above would set a dead property on
1255
+ // the wrapper and change nothing on screen — the exact silent no-op
1256
+ // the accessor contract forbids. So a render-only write on a wrapper
1257
+ // ALSO fans out to the parts it composed, via the SAME accessor.
1258
+ // Ownership is the ONE up-link `_owner` every composer stamps on what
1259
+ // it composed (schema design rule 6) — never a per-kind child list
1260
+ // here (that would be the schema mirror). A non-composite owns nothing.
1261
+ _fanOutRenderPropToGroup(this, lo, prop, value);
1262
+ return;
1263
+ }
1264
+ }
1265
+
1266
+ // ── Engine-visible (engineVisible:true) ───────────────────────────────
1267
+ // A 'rect' write on an object that carries a drawn rect lands on the object's OWN
1268
+ // `setRect` — the ONE writer of its drawn rect AND its engine rect (LargeObject.setRect:
1269
+ // the form check, the w/h-then-left/top order, the engine push). The accessor does not
1270
+ // re-implement it. (Kinds whose rect is an adapter with no drawn rect of their own —
1271
+ // PointSource, QuantumEmitter — carry no `.rect` and take their own branches below.)
1272
+ if (prop === 'rect' && lo.rect) {
1273
+ if (typeof lo.setRect !== 'function') {
1274
+ throw new TypeError('[FrqtlApp.updateObjectProperty] "' + (lo.name || kind) + '" (' + kind +
1275
+ ') carries a rect but no setRect — its rect has no writer, so a rect write cannot land. ' +
1276
+ '(FAIL-LOUD.)');
1277
+ }
1278
+ lo.setRect(value);
1279
+ return;
1280
+ }
1281
+
1282
+ // ── Mirror engine-visible scalar props onto the JS wrapper (Bug #1/#2 fix) ─
1283
+ // The JS wrapper (lo) is the read/serialize source of truth for the inspector
1284
+ // and serializeDesign. On WASM the engine state lives in Rust; the JS wrapper
1285
+ // is a SHADOW. Without this mirror, lo.collision_type / lo.actions / lo.layer /
1286
+ // lo.reposition all stay at CONSTRUCTOR values after a call,
1287
+ // so Save→Restart silently drops every edit to those fields.
1288
+ //
1289
+ // Rule: for every engineVisible scalar prop (not 'rect' — handled via Rectangle
1290
+ // setters above; not 'source_rects' — handled by the emitter path), write
1291
+ // lo[prop] = value NOW so inspector and serializeDesign always read live state.
1292
+ // This mirrors what the render-only path already does for engineVisible:false props.
1293
+ if (prop !== 'rect' && prop !== 'source_rects') {
1294
+ if (prop === 'actions' && (kind === 'LargeObject' || kind === 'backdrop')) {
1295
+ // 'actions' maps to arr_collision_actions on the JS LargeObject wrapper.
1296
+ var actArr = Array.isArray(value) ? value.slice() : [value];
1297
+ lo.arr_collision_actions = actArr;
1298
+ lo.actions = actArr; // keep schema name 'actions' in sync too
1299
+ } else if (prop === 'layer' && (kind === 'LargeObject' || kind === 'backdrop')) {
1300
+ // 'layer' → observedDimensions in the engine. null = all dimensions.
1301
+ lo.layer = (value !== null && value !== undefined) ? value : null;
1302
+ lo.observedDimensions = (value !== null && value !== undefined) ? [value] : [];
1303
+ } else {
1304
+ lo[prop] = value;
1305
+ }
1306
+ }
1307
+
1308
+ // ── PointSource rect: reposition all angle emitters ─────────────────
1309
+ // PointSource is a user-space compound object (N engine emitters, one per angle).
1310
+ // Engine updateObjectProperty does not know about PointSource — handle it here.
1311
+ if (kind === 'PointSource' && prop === 'rect') {
1312
+ if (typeof lo.repositionToRect === 'function') {
1313
+ lo.repositionToRect(value[0], value[1], value[2], value[3], this.universe);
1314
+ }
1315
+ // PointSource has no single engine handle — skip the universe delegation below.
1316
+ return;
1317
+ }
1318
+
1319
+ // ── QuantumEmitter rect: reposition the single canonical source rect ────
1320
+ // QuantumEmitter is engine-agnostic sugar (G-EMIT-2) — its real state is
1321
+ // emState.sourceRects, mutated directly (no provider ABI to delegate to;
1322
+ // the RETIRED updateEmitterSourceRects no longer exists). Mirrors the
1323
+ // PointSource branch above — this is the "composite emitter move" fix
1324
+ // (design §2.2/§2.5.5): before this branch existed, a rect write on a
1325
+ // QuantumEmitter passed schema validation then silently no-oped (the
1326
+ // universe-delegation below has no LargeObject/backdrop branch for it).
1327
+ if (kind === 'QuantumEmitter' && prop === 'rect') {
1328
+ if (typeof lo.setSourceRect === 'function') {
1329
+ lo.setSourceRect(value);
1330
+ }
1331
+ return; // no universe delegation — the sugar IS the engine-facing write.
1332
+ }
1333
+
1334
+ // Delegate to universe for the engine-side update.
1335
+ if (this.universe && typeof this.universe.updateObjectProperty === 'function') {
1336
+ this.universe.updateObjectProperty(lo.handle, kind, prop, value);
1337
+ }
1338
+ };
1339
+
1340
+ // ── Declared-object CAPABILITIES (what the pick, the aim and the carry ask of it) ────────
1341
+ //
1342
+ // A declared object's position authority is asked of the object itself, in ONE order that
1343
+ // holds for every part: centre-bearing (`getCenter`/`setCenter` — an emitter), else
1344
+ // rect-positioned (`rect` + `setRect` — a wall), else none (the device REFUSES loudly).
1345
+ // Its heading authority is its own `setOrientation`, when it exposes one.
1346
+
1347
+ /** The centre of a centre-bearing declared object, else null. */
1348
+ FrqtlApp.prototype._centerOf = function (d) {
1349
+ if (!d || typeof d.getCenter !== 'function' || typeof d.setCenter !== 'function') { return null; }
1350
+ var c = d.getCenter();
1351
+ return Array.isArray(c) ? c : null;
1352
+ };
1353
+ /** The declared object itself when its rect is its position authority (rect + setRect), else null. */
1354
+ FrqtlApp.prototype._rectAuthorityOf = function (d) {
1355
+ return (d && d.rect && typeof d.setRect === 'function') ? d : null;
1356
+ };
1357
+ /**
1358
+ * positionAuthorityOf(d) → 'center' | 'rect' | null — how a declared object is placed, in the ONE
1359
+ * capability order every device uses (the SPACE carry, the 2D drag). null means no device can
1360
+ * move it, and the device refuses loudly with `noPositionAuthorityMessage(d)`.
1361
+ */
1362
+ FrqtlApp.prototype.positionAuthorityOf = function (d) {
1363
+ if (this._centerOf(d)) { return 'center'; }
1364
+ if (this._rectAuthorityOf(d)) { return 'rect'; }
1365
+ return null;
1366
+ };
1367
+ /** The refusal text for a declared object with no position authority (one wording, every device). */
1368
+ FrqtlApp.prototype.noPositionAuthorityMessage = function (d) {
1369
+ return '"' + (d && d.name) + '" (' + ((d && d._schemaKind) || 'object') + ') has no position ' +
1370
+ 'authority — neither a centre (getCenter/setCenter) nor a rect (rect + setRect) — so it cannot ' +
1371
+ 'be moved as a unit.';
1372
+ };
1373
+ /**
1374
+ * The refusal for anything that is not a member of the declared registry: what a scene declared
1375
+ * (ObjectFactory.create — exp.addObject / a palette drop) is the ONLY manipulable set, for every
1376
+ * device (the camera carry, the mouse, a name).
1377
+ * @private
1378
+ */
1379
+ FrqtlApp.prototype._requireDeclared = function (d, where) {
1380
+ if (d && this.declaredOf(d) === d) { return d; }
1381
+ var owner = d ? this.declaredOf(d) : null;
1382
+ throw new Error('[FrqtlApp.' + where + '] ' + (d ? '"' + (d.name || d._schemaKind || 'object') + '"' : String(d)) +
1383
+ ' is not a declared object — only what the scene declared (runner.declared) is placed or read here' +
1384
+ (owner ? '; it belongs to the declared ' + (owner._schemaKind || 'object') + ' "' + owner.name +
1385
+ '" — pass that handle (runner.declaredOf(x))' : '; a primitive-surface object is not manipulable') +
1386
+ '. (FAIL-LOUD.)');
1387
+ };
1388
+
1389
+ /**
1390
+ * centerOf(d) → [x, y, z] — the placement point of DECLARED object `d`, by its position authority
1391
+ * in the ONE capability order: a centre-bearing object's centre, else a rect-positioned object's
1392
+ * DRAWN centre (lo-drawn-center drawnCenter — the point the renderer draws its box at).
1393
+ * THROWS for an undeclared object and for one with no position authority — never null.
1394
+ */
1395
+ FrqtlApp.prototype.centerOf = function (d) {
1396
+ this._requireDeclared(d, 'centerOf');
1397
+ var c = this._centerOf(d);
1398
+ if (c) { return c; }
1399
+ var rt = this._rectAuthorityOf(d);
1400
+ if (rt) { return LoDrawnCenter.drawnCenter(rt.rect); }
1401
+ throw new Error(this.noPositionAuthorityMessage(d));
1402
+ };
1403
+
1404
+ /** kindOf(d) → the declared object's kind (the descriptor name ObjectFactory.create stamped). THROWS for an undeclared object. */
1405
+ FrqtlApp.prototype.kindOf = function (d) {
1406
+ this._requireDeclared(d, 'kindOf');
1407
+ if (typeof d._schemaKind !== 'string' || !d._schemaKind) {
1408
+ throw new Error('[FrqtlApp.kindOf] the declared object "' + d.name + '" carries no kind stamp — ' +
1409
+ 'ObjectFactory.create stamps every declaration. (FAIL-LOUD.)');
1410
+ }
1411
+ return d._schemaKind;
1412
+ };
1413
+
1414
+ /**
1415
+ * The placement authority of declared `d` — 'center' | 'rect' — REFUSING what no device may
1416
+ * place: no position authority (`noPositionAuthorityMessage`), or a wall whose op-program another
1417
+ * part pushed (`assertOwnsProgram` — re-baking it would silently replace that part's physics).
1418
+ * The one refusal both the grab (at SPACE) and the placement write ask.
1419
+ * @private
1420
+ */
1421
+ FrqtlApp.prototype._placementAuthority = function (d) {
1422
+ if (this._centerOf(d)) { return 'center'; }
1423
+ var rt = this._rectAuthorityOf(d);
1424
+ if (!rt) { throw new Error(this.noPositionAuthorityMessage(d)); }
1425
+ if (typeof rt.assertOwnsProgram === 'function') { rt.assertOwnsProgram(); }
1426
+ return 'rect';
1427
+ };
1428
+
1429
+ /**
1430
+ * placeDeclaredAt(d, [x, y, z?]) → { center: [x,y,z], zHeld: boolean } — THE ONE placement write
1431
+ * for a declared object, whatever the device: the camera carry calls it (after clamping — its
1432
+ * device can fly out of the world), a host addressing the object by NAME calls it directly.
1433
+ *
1434
+ * Refuses (THROWS): an undeclared object; a malformed point (TypeError); a point outside the
1435
+ * universe cube `getUniverseBounds()` (RangeError — x ∈ [0, width], y ∈ [0, height],
1436
+ * z ∈ [−zHalf, zHalf]); no position authority (`noPositionAuthorityMessage`); a wall whose
1437
+ * op-program another part pushed (`assertOwnsProgram`). A refusal writes nothing.
1438
+ * An omitted z is 0 — the same reading the cube clamp and rectAtCenter give it.
1439
+ * Centre authority: written as `center`. Rect authority: the rect placed at that centre by
1440
+ * lo-drawn-center `rectAtCenter`, written as `rect`; a z-less (flat) rect cannot leave z = 0,
1441
+ * so it is placed there and `zHeld` says so.
1442
+ * Every write goes through `updateObjectProperty` (throw-or-apply); `center` is the re-read of
1443
+ * where the object now IS (`centerOf`).
1444
+ */
1445
+ FrqtlApp.prototype.placeDeclaredAt = function (d, point) {
1446
+ this._requireDeclared(d, 'placeDeclaredAt');
1447
+ if (!Array.isArray(point) || (point.length !== 2 && point.length !== 3) ||
1448
+ !point.every(function (v) { return typeof v === 'number' && isFinite(v); })) {
1449
+ throw new TypeError('[FrqtlApp.placeDeclaredAt] the point is [x, y, z?] of finite numbers; got ' +
1450
+ JSON.stringify(point) + '. (FAIL-LOUD.)');
1451
+ }
1452
+ var k = [point[0], point[1], point.length === 3 ? point[2] : 0];
1453
+ var b = this.getUniverseBounds();
1454
+ if (k[0] < 0 || k[0] > b.width || k[1] < 0 || k[1] > b.height || k[2] < -b.zHalf || k[2] > b.zHalf) {
1455
+ throw new RangeError('[FrqtlApp.placeDeclaredAt] ' + JSON.stringify(k) + ' is outside the universe cube ' +
1456
+ '(getUniverseBounds(): width ' + b.width + ', height ' + b.height + ', zHalf ' + b.zHalf +
1457
+ ' — x in [0, width], y in [0, height], z in [-zHalf, zHalf]). (FAIL-LOUD.)');
1458
+ }
1459
+ var zHeld = false;
1460
+ if (this._placementAuthority(d) === 'rect') {
1461
+ var L = LoDrawnCenter;
1462
+ if (!L.hasZ(d.rect)) {
1463
+ zHeld = Math.abs(k[2]) > FrqtlApp.Z_HOLD_EPS;
1464
+ k = [k[0], k[1], 0];
1465
+ }
1466
+ this.updateObjectProperty(d, 'rect', L.rectAtCenter(d.rect, k));
1467
+ } else {
1468
+ this.updateObjectProperty(d, 'center', k);
1469
+ }
1470
+ return { center: this.centerOf(d), zHeld: zHeld };
1471
+ };
1472
+ /** The declared object itself when it carries a heading (setOrientation), else null. */
1473
+ FrqtlApp.prototype._orientationOf = function (d) {
1474
+ return (d && typeof d.setOrientation === 'function') ? d : null;
1475
+ };
1476
+ /**
1477
+ * envelopeOf(d) → `{left, top, w, h[, z, d]}` | null — the box a DECLARED object occupies: its own
1478
+ * `rect` when it has one (a Wall's slab, a Tunnel's members' envelope, an emitter's marker box),
1479
+ * else the union of the census LOs it put in the world. The ONE envelope the 2D overlay box, the
1480
+ * 2D drag and the aim surround read.
1481
+ */
1482
+ FrqtlApp.prototype.envelopeOf = function (d) {
1483
+ if (d && d.rect && typeof d.rect.w === 'number') { return d.rect; }
1484
+ var ms = this.membersOf(d);
1485
+ var l = Infinity, t = Infinity, r = -Infinity, b = -Infinity, z0 = Infinity, z1 = -Infinity, hasZ = false;
1486
+ for (var i = 0; i < ms.length; i++) {
1487
+ var m = ms[i] && ms[i].rect;
1488
+ if (!m) { continue; }
1489
+ l = Math.min(l, m.left); t = Math.min(t, m.top);
1490
+ r = Math.max(r, m.left + m.w); b = Math.max(b, m.top + m.h);
1491
+ if (LoDrawnCenter.hasZ(m)) {
1492
+ hasZ = true;
1493
+ z0 = Math.min(z0, m.z); z1 = Math.max(z1, m.z + ((typeof m.d === 'number') ? m.d : m.w));
1494
+ }
1495
+ }
1496
+ if (!(l < r) || !(t < b)) { return null; }
1497
+ var env = { left: l, top: t, w: r - l, h: b - t };
1498
+ if (hasZ) { env.z = z0; env.d = z1 - z0; }
1499
+ return env;
1500
+ };
1501
+ /**
1502
+ * _objectCentroid(lo) → [x,y,z] | null — where a drawn census LO SITS, for the mouse pick: the
1503
+ * centre of its centre-bearing declared owner, else its rect's drawn centre (lo-drawn-center.js
1504
+ * — the point the renderer draws its box at).
1505
+ */
1506
+ FrqtlApp.prototype._objectCentroid = function (lo) {
1507
+ var c = this._centerOf(this.declaredOf(lo));
1508
+ if (c) { return c; }
1509
+ var r = lo && lo.rect;
1510
+ if (!r) { return null; }
1511
+ return LoDrawnCenter.drawnCenter(r);
1512
+ };
1513
+
1514
+ /**
1515
+ * getUniverseBounds() → { width, height, zHalf } (engine px). The 2D canvas is
1516
+ * width×height; the "canvas cube" adds a z-extent — a named convention parameter
1517
+ * (spec §2c): zHalf = min(width,height)/2 (a cube-ish volume centered on the z=0
1518
+ * target). Used by the grab-carry clamp; a game may read it too.
1519
+ */
1520
+ FrqtlApp.prototype.getUniverseBounds = function () {
1521
+ // canvas_rect is written once, at construction, from the REQUIRED canvasW/canvasH; read
1522
+ // through the ONE normalizer, so a missing dimension throws rather than clamping to 0.
1523
+ var r = ObjectSchema.worldRect(this.canvas_rect, 'FrqtlApp.getUniverseBounds canvas_rect');
1524
+ var w = r.w, h = r.h;
1525
+ return { width: w, height: h, zHalf: Math.min(w, h) / 2 };
1526
+ };
1527
+ /** Clamp a carried center to the canvas cube (spec §2c — confinement in the CARRY path,
1528
+ * NEVER in the updateObjectProperty accessor, which stays throw-or-apply against the
1529
+ * schema; the engine/emitter/programmatic placement set `center` freely). */
1530
+ FrqtlApp.prototype._clampToUniverseCube = function (c) {
1531
+ var b = this.getUniverseBounds();
1532
+ var x = Math.max(0, Math.min(b.width, c[0]));
1533
+ var y = Math.max(0, Math.min(b.height, c[1]));
1534
+ var z = Math.max(-b.zHalf, Math.min(b.zHalf, (c[2] != null) ? c[2] : 0));
1535
+ return [x, y, z];
1536
+ };
1537
+
1538
+ // ── THE AIM: a stationary reticle, a center-ray hover, a SPACEBAR grab ──────────────────
1539
+ // The reticle sits at screen centre
1540
+ // and the sticks fly the CAMERA in every state. The first visible object on the camera's
1541
+ // centre ray is GRAB-READY; SPACE grabs it; a grabbed object HOLDS its camera-relative pose
1542
+ // (position + heading) under every camera motion, so it is placed by moving the world and
1543
+ // re-oriented by turning it; SPACE places it. The runner owns the state machine and the
1544
+ // carry; the camera owns motion (and says when it moved: addPoseListener); the provider
1545
+ // owns the paint. No stick ever routes to an object.
1546
+
1547
+ /** World px added on every side of a volume before the centre-ray test, so a thin wall
1548
+ * (a plate of depth ~0) is aimable. */
1549
+ FrqtlApp.HOVER_MARGIN = 6;
1550
+ /** Below this world-xy length of a carried unit heading, the heading is not expressible
1551
+ * in the 1-DOF orientation model (it points along world z) and HOLDS its last value. */
1552
+ FrqtlApp.HEADING_HOLD_EPS = 0.05;
1553
+ /** A carried heading within this many degrees of the object's current one is unchanged (the
1554
+ * camera-basis round trip leaves float noise on a pure MOVE) — no heading write is made. */
1555
+ FrqtlApp.HEADING_WRITE_EPS_DEG = 1e-6;
1556
+ /** Beyond this |z| (world px) a carried centre is not expressible by a z-LESS rect (it sits
1557
+ * at z-centre 0 — lo-drawn-center): the object stays on the z = 0 plane and says Z HELD. */
1558
+ FrqtlApp.Z_HOLD_EPS = 1e-6;
1559
+
1560
+ var AIM_OFF = 'off', AIM_ROAMING = 'roaming', AIM_READY = 'grab-ready', AIM_GRABBED = 'grabbed';
1561
+
1562
+ /**
1563
+ * setAimArmed(on) — arm/disarm the aim surface. The host arms it in STUDIO Design with a
1564
+ * tool active; Run, the no-tool view and a scene reset disarm it (a disarm RELEASES a grab
1565
+ * where the object stands). Arming needs an ANGULAR camera (`OrbitBag.hasAngularPose`) —
1566
+ * a flat camera has no centre ray to aim with, so arming there leaves the aim off (the same
1567
+ * predicate that keeps the joysticks out of 2D). An angular camera whose handle lacks the
1568
+ * pose notice or the offset pair breaks RENDERER_CONTRACT §1a.2 and THROWS.
1569
+ */
1570
+ FrqtlApp.prototype.setAimArmed = function (on) {
1571
+ if (!on) {
1572
+ if (!this._aim) { return; }
1573
+ this._aim.unsubscribePose();
1574
+ this._aim = null;
1575
+ this._notifyAim();
1576
+ return;
1577
+ }
1578
+ if (this._aim) { return; }
1579
+ var cam = this.cameraControl;
1580
+ if (!OrbitBag.hasAngularPose(cam)) { return; }
1581
+ if (typeof cam.addPoseListener !== 'function' || typeof cam.cameraOffsetFromWorld !== 'function' ||
1582
+ typeof cam.worldFromCameraOffset !== 'function') {
1583
+ throw new Error('[FrqtlApp.setAimArmed] the angular camera handle lacks addPoseListener / ' +
1584
+ 'cameraOffsetFromWorld / worldFromCameraOffset — the aim follows the camera through its ' +
1585
+ 'pose notice and reads the ray off the offset pair (RENDERER_CONTRACT §1a.2). (FAIL-LOUD.)');
1586
+ }
1587
+ var self = this;
1588
+ this._aim = { cam: cam, hover: null, grab: null, atEdge: false, headingHeld: false, zHeld: false, error: null,
1589
+ last: null, unsubscribePose: null };
1590
+ this._aim.unsubscribePose = cam.addPoseListener(function () { self._onAimPose(); });
1591
+ this._refreshHover();
1592
+ this._notifyAim();
1593
+ };
1594
+ FrqtlApp.prototype.isAimArmed = function () { return !!this._aim; };
1595
+
1596
+ /**
1597
+ * getAimState() → `{ state, hover, grabbed, atEdge, headingHeld, zHeld, error }` — `state` ∈
1598
+ * off|roaming|grab-ready|grabbed; `hover`/`grabbed` are object NAMES (or null); `atEdge` is
1599
+ * true while the carried pose is clamped to the universe cube; `headingHeld` while the carried
1600
+ * heading points along world z (not expressible, held); `zHeld` while a z-less (flat) rect is
1601
+ * carried off the z = 0 plane it is pinned to (held there); `error` is the message of a carry
1602
+ * write that threw (the grab was released) or of a refused grab — the property of ONE attempt:
1603
+ * it clears when the aim moves to another target, on the next SPACE, on a selection that
1604
+ * releases the grab, and on disarm.
1605
+ */
1606
+ FrqtlApp.prototype.getAimState = function () {
1607
+ var a = this._aim;
1608
+ if (!a) { return { state: AIM_OFF, hover: null, grabbed: null, atEdge: false, headingHeld: false, zHeld: false, error: null }; }
1609
+ return {
1610
+ state: a.grab ? AIM_GRABBED : (a.hover ? AIM_READY : AIM_ROAMING),
1611
+ hover: a.hover ? (a.hover.name || '') : null,
1612
+ grabbed: a.grab ? (a.grab.object.name || '') : null,
1613
+ atEdge: !!(a.grab && a.atEdge),
1614
+ headingHeld: !!(a.grab && a.headingHeld),
1615
+ zHeld: !!(a.grab && a.zHeld),
1616
+ error: a.error
1617
+ };
1618
+ };
1619
+ /** The grabbed DECLARED object or null — the host selects it. */
1620
+ FrqtlApp.prototype.getGrabbedObject = function () {
1621
+ return (this._aim && this._aim.grab) ? this._aim.grab.object : null;
1622
+ };
1623
+
1624
+ /**
1625
+ * addAimListener(fn) → unsubscribe. `fn(state)` runs after every change of the aim state
1626
+ * (arm, disarm, hover, grab, release, clamp, heading hold, a carry error) with getAimState().
1627
+ */
1628
+ FrqtlApp.prototype.addAimListener = function (fn) {
1629
+ var ls = this._aimListeners || (this._aimListeners = []);
1630
+ if (ls.indexOf(fn) === -1) { ls.push(fn); }
1631
+ return function () { var i = ls.indexOf(fn); if (i !== -1) { ls.splice(i, 1); } };
1632
+ };
1633
+ FrqtlApp.prototype._notifyAim = function () {
1634
+ var st = this.getAimState();
1635
+ if (this._aim) { this._aim.last = JSON.stringify(st); }
1636
+ var ls = (this._aimListeners || []).slice();
1637
+ for (var i = 0; i < ls.length; i++) { ls[i](st); }
1638
+ };
1639
+ /** Notify only when the published state actually changed (a carry step usually does not). */
1640
+ FrqtlApp.prototype._notifyAimIfChanged = function () {
1641
+ if (this._aim && JSON.stringify(this.getAimState()) === this._aim.last) { return; }
1642
+ this._notifyAim();
1643
+ };
1644
+
1645
+ /**
1646
+ * grabToggle() — THE spacebar. At GRAB-READY it grabs the hovered object, capturing its full
1647
+ * camera-relative pose; while GRABBED it places the object where it stands; at ROAMING (or
1648
+ * disarmed) it is a no-op. An object that refuses the grab (no position authority, or a wall
1649
+ * carrying a program it did not bake) stays un-grabbed and the refusal is published as `error`.
1650
+ * Returns the new state name.
1651
+ */
1652
+ FrqtlApp.prototype.grabToggle = function () {
1653
+ var a = this._aim;
1654
+ if (!a) { return AIM_OFF; }
1655
+ a.error = null; // the error is the property of ONE attempt: a new SPACE starts clean
1656
+ if (a.grab) {
1657
+ this._releaseGrab();
1658
+ } else {
1659
+ this._refreshHover();
1660
+ if (a.hover) {
1661
+ try {
1662
+ a.grab = this._captureGrab(a.hover); a.atEdge = false; a.headingHeld = false; a.zHeld = false;
1663
+ } catch (e) {
1664
+ a.grab = null; a.error = 'the object cannot be grabbed: ' + (e && e.message ? e.message : e);
1665
+ }
1666
+ }
1667
+ }
1668
+ this._notifyAim();
1669
+ return this.getAimState().state;
1670
+ };
1671
+ FrqtlApp.prototype._releaseGrab = function () {
1672
+ if (this._aim) { this._aim.grab = null; this._aim.atEdge = false; this._aim.headingHeld = false; this._aim.zHeld = false; }
1673
+ };
1674
+
1675
+ /**
1676
+ * setSelectHighlight(object | null) — the host's selection, handed down so its shadow-surround
1677
+ * paints (the same highlight geometry as the aim's). Selection never grabs; a selection that
1678
+ * is not the grabbed object (a deselect, another pick) RELEASES the grab.
1679
+ */
1680
+ FrqtlApp.prototype.setSelectHighlight = function (object) {
1681
+ this._selected = object || null;
1682
+ var a = this._aim;
1683
+ if (a && a.grab && a.grab.object !== this._selected) {
1684
+ this._releaseGrab();
1685
+ a.error = null;
1686
+ this._notifyAim();
1687
+ }
1688
+ };
1689
+
1690
+ /**
1691
+ * The full camera-relative pose of declared object `d` at grab, by its position authority in the
1692
+ * ONE capability order: a centre-bearing object's centre, else a rect-positioned object's DRAWN
1693
+ * centre (lo-drawn-center), else REFUSED (`noPositionAuthorityMessage`) — a part that cannot be
1694
+ * placed is never silently carried by one of its members. The heading's camera-frame components
1695
+ * ride along when `d` carries one (setOrientation).
1696
+ */
1697
+ FrqtlApp.prototype._captureGrab = function (d) {
1698
+ var cam = this._aim.cam;
1699
+ // The grab refuses at SPACE what the placement would refuse on the first carry.
1700
+ this._placementAuthority(d);
1701
+ var center = this.centerOf(d);
1702
+ var oriTarget = this._orientationOf(d);
1703
+ var g = { object: d, off: cam.cameraOffsetFromWorld(center), oriTarget: oriTarget, hCam: null };
1704
+ if (oriTarget && typeof oriTarget.getOrientation === 'function') {
1705
+ var r = (oriTarget.getOrientation() || 0) * Math.PI / 180;
1706
+ var o0 = cam.cameraOffsetFromWorld([0, 0, 0]);
1707
+ var o1 = cam.cameraOffsetFromWorld([Math.cos(r), Math.sin(r), 0]);
1708
+ g.hCam = { right: o1.right - o0.right, up: o1.up - o0.up, forward: o1.forward - o0.forward };
1709
+ }
1710
+ return g;
1711
+ };
1712
+
1713
+ /** The pose notice: carry the grabbed object, then re-aim the centre ray. */
1714
+ FrqtlApp.prototype._onAimPose = function () {
1715
+ if (this._aim && this._aim.grab) { this._carryGrab(); }
1716
+ this._refreshHover();
1717
+ this._notifyAimIfChanged();
1718
+ };
1719
+
1720
+ /**
1721
+ * Re-place the grabbed object at its held camera-relative pose in the LIVE basis: the centre
1722
+ * through worldFromCameraOffset → the universe-cube clamp (the camera device's own pre-write
1723
+ * policy) → `placeDeclaredAt`, the ONE placement write (its `zHeld` copied into the aim state),
1724
+ * then the offset re-captured from the clamped point it placed (z 0 when held) — glued at the
1725
+ * wall, no invisible over-travel. The heading is re-expressed in the live basis, projected onto world-xy, written through setOrientation.
1726
+ * A write that throws RELEASES the grab and publishes the error.
1727
+ */
1728
+ FrqtlApp.prototype._carryGrab = function () {
1729
+ var a = this._aim, g = a.grab, cam = a.cam;
1730
+ try {
1731
+ // The camera device CLAMPS (it can fly out of the world), then places through the ONE write.
1732
+ var c = cam.worldFromCameraOffset(g.off);
1733
+ var k = this._clampToUniverseCube(c);
1734
+ a.atEdge = (Math.abs(k[0] - c[0]) + Math.abs(k[1] - c[1]) + Math.abs(k[2] - c[2])) > 1e-6;
1735
+ var placed = this.placeDeclaredAt(g.object, k);
1736
+ a.zHeld = placed.zHeld;
1737
+ // Re-capture from the point the carry ASKED to place (z held to 0 when it was), never
1738
+ // from the re-read: a centre authority quantizes its write (an emitter's setCenter rounds
1739
+ // to whole px), and re-capturing a quantized read drifts the held pose on every notice.
1740
+ g.off = cam.cameraOffsetFromWorld(placed.zHeld ? [k[0], k[1], 0] : k);
1741
+ if (g.hCam) {
1742
+ var w0 = cam.worldFromCameraOffset({ right: 0, up: 0, forward: 0 });
1743
+ var w1 = cam.worldFromCameraOffset(g.hCam);
1744
+ var hx = w1[0] - w0[0], hy = w1[1] - w0[1];
1745
+ a.headingHeld = Math.hypot(hx, hy) < FrqtlApp.HEADING_HOLD_EPS;
1746
+ if (!a.headingHeld) {
1747
+ // A pose notice that did not turn the heading (a MOVE) writes no heading: a
1748
+ // re-aim to the same degree would re-bake and push the program, region and broad
1749
+ // rect again — the second engine write per notice for the SAME placement.
1750
+ var deg = Math.atan2(hy, hx) * 180 / Math.PI;
1751
+ var cur = (typeof g.oriTarget.getOrientation === 'function') ? g.oriTarget.getOrientation() : null;
1752
+ var dd = (cur === null) ? Infinity : Math.abs((((deg - cur) % 360) + 540) % 360 - 180);
1753
+ if (dd > FrqtlApp.HEADING_WRITE_EPS_DEG) { g.oriTarget.setOrientation(deg); }
1754
+ }
1755
+ }
1756
+ } catch (e) {
1757
+ this._releaseGrab();
1758
+ a.error = 'the grabbed object could not follow the camera: ' + (e && e.message ? e.message : e);
1759
+ this._notifyAim();
1760
+ }
1761
+ };
1762
+
1763
+ /**
1764
+ * The volume a drawn census LO is aimed at by (compat/renderer/lo-drawn-center.js — the SAME
1765
+ * volume family the shadow-surround paints): when its declared owner is centre-bearing, the
1766
+ * owner's centre as a sphere the size of the drawn marker; else the LO's drawn box turned by its
1767
+ * shell rotation. null when it has neither.
1768
+ */
1769
+ FrqtlApp.prototype._aimVolume = function (lo, owner) {
1770
+ var L = LoDrawnCenter;
1771
+ var c = this._centerOf(owner === undefined ? this.declaredOf(lo) : owner);
1772
+ if (c) {
1773
+ var r = lo.rect ? Math.max(lo.rect.w || 0, lo.rect.h || 0) / 2 : 0;
1774
+ return L.drawnVolume({ center: c, radius: r });
1775
+ }
1776
+ return lo.rect ? L.drawnVolume(lo) : null;
1777
+ };
1778
+
1779
+ /**
1780
+ * The surround volume of a DECLARED object — ONE shell around the whole object (one object, one
1781
+ * cue): a centre-bearing object's sphere; a single-member object's own drawn volume (its shell
1782
+ * rotation kept); a composite's ENVELOPE box (`envelopeOf` — a Tunnel's own rect).
1783
+ */
1784
+ FrqtlApp.prototype._declaredVolume = function (d) {
1785
+ var members = this.membersOf(d);
1786
+ if (members.length === 1 || this._centerOf(d)) {
1787
+ return members.length ? this._aimVolume(members[0], d) : null;
1788
+ }
1789
+ var env = this.envelopeOf(d);
1790
+ return env ? LoDrawnCenter.drawnVolume({ rect: env }) : null;
1791
+ };
1792
+ /** Whether the frame DRAWS any part of declared object `d` (one of its members is aimable). */
1793
+ FrqtlApp.prototype._isDeclaredAimable = function (d) {
1794
+ var ms = this.membersOf(d);
1795
+ for (var i = 0; i < ms.length; i++) { if (this._isAimable(ms[i])) { return true; } }
1796
+ return false;
1797
+ };
1798
+
1799
+ /** Whether the frame DRAWS `lo` — the runner's own render view, never the provider's:
1800
+ * not a backdrop, `visible` not false, and its shell shown in the current mode. */
1801
+ FrqtlApp.prototype._isAimable = function (lo) {
1802
+ return !!lo && !lo.backdrop && lo.visible !== false && this._isShellVisible(lo);
1803
+ };
1804
+
1805
+ /**
1806
+ * _centerRay() → `{ origin, dir }` — the camera's screen-centre ray, read off the OFFSET pair:
1807
+ * the eye is the offset origin, the direction the unit forward offset. (The screen pair —
1808
+ * screenToWorld / screenPointOf — answers point-on-plane questions and is not used for rays.)
1809
+ */
1810
+ FrqtlApp.prototype._centerRay = function () {
1811
+ var cam = this._aim.cam;
1812
+ var o = cam.worldFromCameraOffset({ right: 0, up: 0, forward: 0 });
1813
+ var f = cam.worldFromCameraOffset({ right: 0, up: 0, forward: 1 });
1814
+ return { origin: o, dir: [f[0] - o[0], f[1] - o[1], f[2] - o[2]] };
1815
+ };
1816
+
1817
+ /**
1818
+ * Recompute which DECLARED object the centre ray hits first. The hit set is what the frame DRAWS
1819
+ * (the census's aimable LOs — a composite draws nothing of its own, so its hit volume is the union
1820
+ * of its members' by construction of the set); the result is the declared object the hit LO
1821
+ * belongs to (`declaredOf`). An LO whose `_owner` chain ends outside the registry (a
1822
+ * primitive-surface object) is not a candidate — it is never hovered.
1823
+ */
1824
+ FrqtlApp.prototype._refreshHover = function () {
1825
+ var a = this._aim;
1826
+ if (!a) { return; }
1827
+ var los = this.largeObjects, owners = [], vols = [];
1828
+ for (var i = 0; i < los.length; i++) {
1829
+ if (!this._isAimable(los[i])) { continue; }
1830
+ var d = this.declaredOf(los[i]);
1831
+ if (!d) { continue; }
1832
+ var v = this._aimVolume(los[i], d);
1833
+ if (v) { owners.push(d); vols.push(v); }
1834
+ }
1835
+ var ray = this._centerRay();
1836
+ var hit = LoDrawnCenter.firstHit(ray.origin, ray.dir, vols, FrqtlApp.HOVER_MARGIN);
1837
+ var next = hit ? owners[hit.index] : null;
1838
+ // Aiming at something else ends the refused/failed attempt: its error goes with it.
1839
+ if (next !== a.hover) { a.error = null; }
1840
+ a.hover = next;
1841
+ };
1842
+
1843
+ // ── The aim feedback bag, threaded to the provider via the renderTick opts channel ──────
1844
+ // LIVE per-frame state (never setRenderOption), under ONE runner-injected key. A provider
1845
+ // that ignores it draws nothing (capability-checked).
1846
+ var AIM_FEEDBACK_KEY = 'aimFeedback';
1847
+
1848
+ /**
1849
+ * The render-option keys this RUNNER merges into the bag per render — never a provider
1850
+ * capability, never user-writable. The read-set conformance test FERRIES this list as its
1851
+ * one exemption.
1852
+ */
1853
+ FrqtlApp.RUNNER_INJECTED_RENDER_KEYS = Object.freeze([AIM_FEEDBACK_KEY]);
1854
+
1855
+ /**
1856
+ * The live aim feedback, or null when the aim is disarmed:
1857
+ * `{ bounds, state, hover?: {name, volume}, selected?: {name, volume}, grabbed?: {name, volume},
1858
+ * orientation?: {pivot, deg} }`
1859
+ * `hover` is the grab-ready (or grabbed) object's shadow-surround, `selected` the host
1860
+ * selection's, `grabbed` marks the grabbed one (its lock glyph), and `orientation` the passive
1861
+ * heading of the grabbed (else selected) orientation-bearing object. Every volume is the
1862
+ * lo-drawn-center volume the ray tested.
1863
+ */
1864
+ FrqtlApp.prototype._computeAimFeedback = function () {
1865
+ var a = this._aim;
1866
+ if (!a) { return null; }
1867
+ this._refreshHover();
1868
+ this._notifyAimIfChanged();
1869
+ var self = this;
1870
+ function mark(d) {
1871
+ var v = d && self._declaredVolume(d);
1872
+ return v ? { name: d.name || '', volume: v } : null;
1873
+ }
1874
+ var fb = { bounds: this.getUniverseBounds(), state: this.getAimState().state };
1875
+ var hov = mark(a.grab ? a.grab.object : a.hover);
1876
+ if (hov) { fb.hover = hov; }
1877
+ var sel = (this._selected && this._isDeclaredAimable(this._selected)) ? mark(this._selected) : null;
1878
+ if (sel) { fb.selected = sel; }
1879
+ if (a.grab && hov) { fb.grabbed = hov; }
1880
+ var headed = a.grab ? a.grab.object : this._selected;
1881
+ var oriT = this._orientationOf(headed);
1882
+ var headVol = headed ? this._declaredVolume(headed) : null;
1883
+ if (oriT && headVol && typeof oriT.getOrientation === 'function') {
1884
+ fb.orientation = { pivot: headVol.center, deg: oriT.getOrientation() || 0 };
1885
+ }
1886
+ return fb;
1887
+ };
1888
+ /** The render options for a frame: the persistent user options plus the live aim feedback (if any). */
1889
+ FrqtlApp.prototype._renderOptsWithFeedback = function () {
1890
+ var fb = this._computeAimFeedback();
1891
+ if (!fb) { return this._renderOptions; }
1892
+ var bag = Object.assign({}, this._renderOptions);
1893
+ bag[AIM_FEEDBACK_KEY] = fb;
1894
+ return bag;
1895
+ };
1896
+
1897
+ /**
1898
+ * screenPointOf(world, cssW, cssH, seed?) → [x,y] | null — THE world→screen map: the point, in
1899
+ * a cssW×cssH screen surface, at which the renderer DRAWS world point `world` ([x,y] or
1900
+ * [x,y,z]; z defaults to 0). It is the ADJOINT of the provider's own screenToWorld (the one
1901
+ * camera authority, in both directions — no second projection is derived here): Newton steps
1902
+ * on the unprojection onto world's z-plane, from `seed` ([x,y]; default the surface centre),
1903
+ * using the local Jacobian (world delta per +1 screen px). The map on a plane is projective
1904
+ * (affine for the 2D pan/zoom camera), so a few steps converge; the answer is accepted only if
1905
+ * it unprojects back onto `world`. null is the contracted "not on screen from this view"
1906
+ * answer — never a cue to try another mapping. THROWS when there is no renderer or it lacks
1907
+ * the screenToWorld capability (RENDERER_CONTRACT: screenToWorld).
1908
+ */
1909
+ FrqtlApp.prototype.screenPointOf = function (world, cssW, cssH, seed) {
1910
+ var r = this.renderer;
1911
+ if (!r || typeof r.screenToWorld !== 'function') {
1912
+ throw new Error('[FrqtlApp.screenPointOf] the active renderer provider has no ' +
1913
+ 'screenToWorld capability (RENDERER_CONTRACT) — world→screen is its adjoint and ' +
1914
+ 'has no other source. (FAIL-LOUD.)');
1915
+ }
1916
+ var z = (typeof world[2] === 'number') ? world[2] : 0;
1917
+ var x = seed ? seed[0] : cssW / 2, y = seed ? seed[1] : cssH / 2;
1918
+ for (var k = 0; k < 8; k++) {
1919
+ var p0 = r.screenToWorld(x, y, cssW, cssH, z);
1920
+ var px = r.screenToWorld(x + 1, y, cssW, cssH, z);
1921
+ var py = r.screenToWorld(x, y + 1, cssW, cssH, z);
1922
+ if (!p0 || !px || !py) { return null; }
1923
+ var a = px.x - p0.x, b = py.x - p0.x, e = px.y - p0.y, f = py.y - p0.y;
1924
+ var det = a * f - b * e;
1925
+ if (!det) { return null; } // the plane is edge-on to the view
1926
+ var wx = world[0] - p0.x, wy = world[1] - p0.y;
1927
+ var dx = (f * wx - b * wy) / det, dy = (a * wy - e * wx) / det;
1928
+ x += dx; y += dy;
1929
+ if (Math.abs(dx) + Math.abs(dy) < 0.05) { break; }
1930
+ }
1931
+ var q = r.screenToWorld(x, y, cssW, cssH, z);
1932
+ if (!q || Math.abs(q.x - world[0]) + Math.abs(q.y - world[1]) > 0.5) { return null; }
1933
+ return [x, y];
1934
+ };
1935
+
1936
+ /** Screen radius (in the caller's screen units) inside which a click picks an object. */
1937
+ FrqtlApp.PICK_RADIUS_PX = 32;
1938
+ /** A center-bearing (drivable) object's pick distance is divided by this, so in a dense scene
1939
+ * the thing the sticks can MOVE wins a near-tie against a wall behind it. */
1940
+ FrqtlApp.PICK_DRIVE_PRIORITY = 2;
1941
+
1942
+ /**
1943
+ * pickObjectAtScreen(cssX, cssY, cssW, cssH) → the DECLARED object owning the census LO whose
1944
+ * centroid is DRAWN nearest the screen point, or null (spec §2). The distance is in SCREEN px (each centroid is located on
1945
+ * screen through the provider's screenToWorld — screenPointOf), so the pick radius is what the
1946
+ * eye sees at any zoom, axis and depth; a world-px radius on each object's own plane was
1947
+ * anisotropic on screen and different per plane, which let a wall behind the emitter win inside
1948
+ * the emitter's own footprint. A drivable object's distance is divided by PICK_DRIVE_PRIORITY.
1949
+ * Objects-layer; consumed downward by AuthorView. Null (fail-soft) with no screenToWorld
1950
+ * capability — the host falls back to the Layers panel.
1951
+ */
1952
+ FrqtlApp.prototype.pickObjectAtScreen = function (cssX, cssY, cssW, cssH) {
1953
+ var r = this.renderer;
1954
+ if (!r || typeof r.screenToWorld !== 'function') { return null; }
1955
+ var los = this.largeObjects;
1956
+ var best = null, bestScore = Infinity;
1957
+ for (var i = 0; i < los.length; i++) {
1958
+ var lo = los[i];
1959
+ var owner = this.declaredOf(lo);
1960
+ if (!owner) { continue; } // a primitive-surface object is never picked
1961
+ var c = this._objectCentroid(lo);
1962
+ if (!c) { continue; }
1963
+ var sp = this.screenPointOf(c, cssW, cssH, [cssX, cssY]);
1964
+ if (!sp) { continue; }
1965
+ var drivable = typeof owner.getCenter === 'function';
1966
+ var score = Math.hypot(sp[0] - cssX, sp[1] - cssY) / (drivable ? FrqtlApp.PICK_DRIVE_PRIORITY : 1);
1967
+ if (score < bestScore) { bestScore = score; best = owner; }
1968
+ }
1969
+ return (bestScore <= FrqtlApp.PICK_RADIUS_PX) ? best : null;
1970
+ };
1971
+
1972
+ /**
1973
+ * Uniform schema-driven mutation entry point for the live UNIVERSE singleton
1974
+ * (Studio Controls Realignment S1, design §2.3.2 / gap-census #10).
1975
+ *
1976
+ * Mirrors `updateObjectProperty`'s throw-or-apply discipline for the ONE other
1977
+ * object every experiment page has exactly one of: the universe itself. Unlike
1978
+ * a Frame/LargeObject prop there is no separate engine-ABI branch to dispatch
1979
+ * to — `wavelengthScale`'s own JS-side property setter
1980
+ * (`EGPTUniverse.prototype.wavelengthScale`, `wasm-engine.js`) already calls
1981
+ * through to the WASM ABI (`_wasm.set_wavelength_scale`), so once schema
1982
+ * validation has passed, `this.universe[prop] = value` IS the engine write —
1983
+ * never a bare, unvalidated assignment (that's exactly the bind-local-property-
1984
+ * list anti-pattern this accessor exists to close off).
1985
+ *
1986
+ * @param {string} prop - Field name declared in ObjectSchema.get('Universe')
1987
+ * @param {*} value - New value
1988
+ * @throws {TypeError} - If prop is unknown or readonly for the 'Universe' kind
1989
+ */
1990
+ FrqtlApp.prototype.setUniverseProperty = function (prop, value) {
1991
+ var schema = ObjectSchema;
1992
+ if (!schema) {
1993
+ throw new TypeError(
1994
+ '[FrqtlApp.setUniverseProperty] ObjectSchema is unavailable — cannot ' +
1995
+ 'validate "' + prop + '" (FAIL-LOUD: never write to the universe unvalidated).'
1996
+ );
1997
+ }
1998
+ var descriptor = schema.get('Universe');
1999
+ if (!descriptor) {
2000
+ throw new TypeError('[FrqtlApp.setUniverseProperty] Unknown object kind "Universe"');
2001
+ }
2002
+ var fieldDef = null;
2003
+ for (var i = 0; i < descriptor.fields.length; i++) {
2004
+ if (descriptor.fields[i].name === prop) { fieldDef = descriptor.fields[i]; break; }
2005
+ }
2006
+ if (!fieldDef) {
2007
+ throw new TypeError(
2008
+ '[FrqtlApp.setUniverseProperty] Property "' + prop +
2009
+ '" is not in the schema for kind "Universe". Valid properties: ' +
2010
+ descriptor.fields.map(function (f) { return f.name; }).join(', ')
2011
+ );
2012
+ }
2013
+ if (fieldDef.type === 'readonly') {
2014
+ throw new TypeError(
2015
+ '[FrqtlApp.setUniverseProperty] Property "' + prop + '" is readonly for kind "Universe"'
2016
+ );
2017
+ }
2018
+ this.universe[prop] = value;
2019
+ };
2020
+
2021
+ /**
2022
+ * Read counterpart to setUniverseProperty — same schema validation, live read
2023
+ * (no tick dependency, unlike the LargeObject snapshot path).
2024
+ *
2025
+ * @param {string} prop - Field name declared in ObjectSchema.get('Universe')
2026
+ * @returns {*} the current value of `this.universe[prop]`
2027
+ * @throws {TypeError} - If prop is unknown for the 'Universe' kind
2028
+ */
2029
+ FrqtlApp.prototype.getUniverseProperty = function (prop) {
2030
+ var schema = ObjectSchema;
2031
+ if (schema) {
2032
+ var descriptor = schema.get('Universe');
2033
+ if (descriptor) {
2034
+ var fieldDef = null;
2035
+ for (var i = 0; i < descriptor.fields.length; i++) {
2036
+ if (descriptor.fields[i].name === prop) { fieldDef = descriptor.fields[i]; break; }
2037
+ }
2038
+ if (!fieldDef) {
2039
+ throw new TypeError(
2040
+ '[FrqtlApp.getUniverseProperty] Property "' + prop +
2041
+ '" is not in the schema for kind "Universe". Valid properties: ' +
2042
+ descriptor.fields.map(function (f) { return f.name; }).join(', ')
2043
+ );
2044
+ }
2045
+ }
2046
+ }
2047
+ return this.universe[prop];
2048
+ };
2049
+
2050
+ /**
2051
+ * Move a LargeObject to a new position.
2052
+ * Private dispatch target — kept so internal callers don't break.
2053
+ * The public entry point is updateObjectProperty(lo, 'rect', [left, top, w, h]).
2054
+ *
2055
+ * @param {Object} lo LargeObject wrapper with .handle and .rect
2056
+ * @param {number} left New left edge (physics pixels)
2057
+ * @param {number} top New top edge (physics pixels)
2058
+ * @param {number} [w] New width (default: unchanged)
2059
+ * @param {number} [h] New height (default: unchanged)
2060
+ */
2061
+ FrqtlApp.prototype.moveLargeObject = function (lo, left, top, w, h) {
2062
+ if (!lo || !lo.rect) return;
2063
+ var newW = (w !== undefined) ? w : lo.rect.w;
2064
+ var newH = (h !== undefined) ? h : lo.rect.h;
2065
+ // Route through the uniform API so engine-visible mutation goes through one path.
2066
+ this.updateObjectProperty(lo, 'rect', [left, top, newW, newH]);
2067
+ };
2068
+
2069
+ // ── Canvas creation ────────────────────────────────────────────────────
2070
+
2071
+ /**
2072
+ * Create the canvas and attach it to the configured parent element.
2073
+ *
2074
+ * When the p5 renderer is active: MUST be called from p5's setup() context
2075
+ * (delegates to the p5 provider's init(), which calls createCanvas()).
2076
+ *
2077
+ * FAIL-LOUD: with no renderer provider resolved this THROWS naming
2078
+ * the boot module. There is no bare-p5 fallback — a page that draws through
2079
+ * an unresolved renderer is the silent-degradation this file's own doctrine
2080
+ * forbids.
2081
+ *
2082
+ * THE SCENE SURFACE IS DECLARED HERE. `provider.init` returns its drawing surface
2083
+ * (RENDERER_CONTRACT §1); this — the one mount site, so every provider, built-in or BYO,
2084
+ * passes through it — stamps `SCENE_SURFACE_ATTR` on it, so a reader finds the scene's
2085
+ * pixels by the marker instead of guessing among the document's canvases.
2086
+ *
2087
+ * @returns {HTMLCanvasElement} The provider's drawing surface, marked.
2088
+ * @throws {Error} when no RendererProvider is active, or `init` returned no canvas element.
2089
+ */
2090
+ FrqtlApp.prototype.createCanvas = function () {
2091
+ this._createCanvasCalled = true; // from here init states the rect (see _getRenderer)
2092
+ var prov = this._getRenderer();
2093
+ if (prov && prov.init) {
2094
+ // New path: delegate to provider
2095
+ var initOpts = {
2096
+ parentEl: this._canvasParentId,
2097
+ width: this.canvas_rect.w,
2098
+ height: this.canvas_rect.h,
2099
+ is3D: this._is3D
2100
+ };
2101
+ if (this._depth !== null) { initOpts.depth = this._depth; }
2102
+ var contract = RendererProviderContract;
2103
+ var el = contract.requireSceneSurface('FrqtlApp.createCanvas', prov.init(initOpts));
2104
+ el.setAttribute(contract.SCENE_SURFACE_ATTR, '');
2105
+ this._sceneSurface = el; // the canvas; its parent is the default control host
2106
+ if (this._onReady) { this._onReady(this); }
2107
+ return el;
2108
+ }
2109
+
2110
+ throw new Error('[FrqtlApp.createCanvas] no RendererProvider is active — ' +
2111
+ 'one is selected by the boot module (lib/frqtl/experiments/_shared/boot.js) — a page boots ' +
2112
+ 'through bootPage(), a programmatic host awaits bootRealm() — before calling createCanvas().');
2113
+ };
2114
+
2115
+ // ── Experiment setup ───────────────────────────────────────────────────
2116
+
2117
+ /**
2118
+ * Run a setup factory (e.g. setupDoubleSlit) on this runner's universe: its world
2119
+ * step (`setupFn.initWorld`, when the factory exports one), then the factory itself.
2120
+ *
2121
+ * FACTORY STATICS — the declarations a factory exports for its runner, listed ONCE in
2122
+ * `FrqtlApp.FACTORY_STATICS`; a wrapper around a factory carries them with
2123
+ * `FrqtlApp.carryFactoryStatics(wrapper, factory)`:
2124
+ * - `initWorld(universe, opts)` — the world the factory's part implies, run FIRST.
2125
+ * A factory carrying the deleted `RATE_LIMITS` static THROWS here; pacing is
2126
+ * `runner.setRateLimits(...)` (a scene: `exp.runner.setRateLimits(...)`).
2127
+ *
2128
+ * CANONICAL TEARDOWN (the S4 dispose discipline,
2129
+ * generalized to setup-level reinit): `universe.init()` (called by the world step
2130
+ * below) resets the WASM instance's physics state but does NOT
2131
+ * clear the JS-side `universe.experimentTickFunctions` array — a fact
2132
+ * `fx-experiment.js`'s own loop-control docstring already names explicitly
2133
+ * ("NOT cleared by init() and would duplicate on every reset"), which is
2134
+ * why THAT mechanism never pushes a recurring tick-fn in the first place.
2135
+ * A setup factory that DOES register a recurring host tick-fn (e.g. a
2136
+ * continuous burst injector) for a LIVE, re-runnable experiment page must
2137
+ * therefore tear its OWN registration down before the next `setup()` call
2138
+ * installs a new one, or the OLD tick-fn keeps firing into the SAME array
2139
+ * forever, compounding the population across every reinit (the exact
2140
+ * "5730 → 8890 → 10160 → 10720" runaway the tester measured). Rather than
2141
+ * require every such page to remember a bespoke unhook, this ONE canonical
2142
+ * check-and-call — mirroring `removeObject`'s own `_dispose()` idiom
2143
+ * EXACTLY (any object with a `.dispose()` method gets it called,
2144
+ * idempotently, no kind-sniffing) — runs here, generically, for every
2145
+ * experiment page: a setup factory that returns `{dispose(){...}}` on its
2146
+ * `setupResult` gets automatic teardown-before-reinit for free. Every
2147
+ * EXISTING setup factory (none of which currently returns `.dispose`) is
2148
+ * byte-identical — this is a pure ADDITION, not a behavior change, for
2149
+ * anything that doesn't opt in.
2150
+ */
2151
+ FrqtlApp.prototype.setup = function (setupFn, opts) {
2152
+ opts = opts || {};
2153
+ // A scene reset disarms the aim: a grabbed or selected object is about to be disposed.
2154
+ if (this._aim) { this.setAimArmed(false); }
2155
+ this._selected = null;
2156
+ if (this._setupResult && typeof this._setupResult.dispose === 'function') {
2157
+ this._setupResult.dispose();
2158
+ }
2159
+ // RETAINED so `restart()` can replay the SAME scene in-place — through the ONE retainer
2160
+ // (retainSceneReplay), which a sketch build sets the same way.
2161
+ var self = this;
2162
+ this.retainSceneReplay(function () { return self.setup(setupFn, opts); });
2163
+ // A fresh world has no declared objects (the factory's own parts are primitives).
2164
+ this.clearDeclared();
2165
+ // ONE BOOT SEQUENCE (#81): a factory that constructs a PART exports its implied
2166
+ // world as `setupFn.initWorld(universe, opts)`; the runner runs it FIRST, then the
2167
+ // part step. A factory that owns its world (setupDoubleSlit, the composites) exports
2168
+ // none and inits inside. Either way `restart()` — which re-enters here — replays the
2169
+ // whole scene from a fresh world, never the part alone on a populated one.
2170
+ _refuseDeletedStatics(setupFn, 'setup');
2171
+ if (typeof setupFn.initWorld === 'function') {
2172
+ setupFn.initWorld(this.universe, opts); // 1. WORLD
2173
+ }
2174
+ this._setupResult = setupFn(this.universe, opts); // 2. PART(S)
2175
+ // NO render-list assignment: a factory's LargeObjects registered themselves with
2176
+ // the world as they were CONSTRUCTED. The old line replaced the host's shadow list
2177
+ // with `setupResult.largeObjects`, which both required every factory to remember to
2178
+ // return the list AND silently dropped anything built outside it.
2179
+ return this._setupResult;
2180
+ };
2181
+
2182
+ /**
2183
+ * retainSceneReplay(fn) — the ONE retainer of "how this runner's scene was built", so
2184
+ * `restart()` can replay it. Whichever path built the scene sets it: `setup(factory, opts)`
2185
+ * retains its factory replay; `RecipeContext.build(sketch)` retains `exp.build(sketch)`. A later
2186
+ * build replaces the earlier retention (the runner holds ONE scene).
2187
+ * @param {Function} fn — replays the scene; its return value is restart()'s.
2188
+ */
2189
+ FrqtlApp.prototype.retainSceneReplay = function (fn) {
2190
+ if (typeof fn !== 'function') {
2191
+ throw new TypeError('[FrqtlApp] retainSceneReplay(fn) takes the scene\'s replay function; got ' +
2192
+ (fn === null ? 'null' : typeof fn) + '. (FAIL-LOUD.)');
2193
+ }
2194
+ this._sceneReplay = fn;
2195
+ };
2196
+
2197
+ /**
2198
+ * restart() — the PAGE-LEVEL restart primitive (presentation pass Item 2,
2199
+ * E-P2b): stop → replay the RETAINED scene (`retainSceneReplay` — a setup
2200
+ * factory's world + part steps with its ORIGINAL opts, or a sketch's `exp.build(sketch)`
2201
+ * on the same runner and canvas) → start. The scene returns to tick 0 IN-PAGE: no reload,
2202
+ * so the camera orbit, render options and any host wiring survive. The declared `restart`
2203
+ * control (controlAction.js) routes here for every host.
2204
+ *
2205
+ * Teardown runs EXACTLY ONCE — the replay owns it (`setup()` calls the previous
2206
+ * setupResult's `dispose()`; a sketch build tears the previous scene down first —
2207
+ * RecipeContext.build), so this method must NOT dispose again (double teardown was the
2208
+ * stacking-regression this seam was built to avoid).
2209
+ *
2210
+ * DISTINCT BY DESIGN from STUDIO's codegen Restart (AuthorView), which rebuilds
2211
+ * a persisted DESIGN and reloads the page: that one is STUDIO chrome and stays
2212
+ * where it is. This one replays the already-loaded module.
2213
+ *
2214
+ * @returns {*} the replay's result (a setup result, or the sketch's facade).
2215
+ * @throws {Error} FAIL-LOUD when nothing built a scene on this runner (there is nothing
2216
+ * to replay) — never a silent no-op.
2217
+ */
2218
+ FrqtlApp.prototype.restart = function () {
2219
+ if (typeof this._sceneReplay !== 'function') {
2220
+ throw new Error('[FrqtlApp] restart(): nothing to replay — no scene has been built on this ' +
2221
+ 'runner. Build one first with runner.setup(factory, opts) or RecipeContext.build(sketch). ' +
2222
+ '(FAIL-LOUD — a restart with nothing to replay would silently do nothing.)');
2223
+ }
2224
+ this.stop();
2225
+ var result = this._sceneReplay();
2226
+ this._lastTickData = null;
2227
+ // Always resume: "restart" means run the scene again from tick 0 — a restart
2228
+ // that left the loop paused would read as a dead button.
2229
+ this.start();
2230
+ return result;
2231
+ };
2232
+
2233
+ // ── Renderer resolution ────────────────────────────────────────────────
2234
+
2235
+ /**
2236
+ * Resolve the DISPLAY renderer.
2237
+ * 1. this._renderer (already resolved this session, or set directly by a host/test)
2238
+ * 2. the DISPLAY SLOT (`compat/renderer/display-slot.js`) — the ONE answer to
2239
+ * "which provider owns the display", written by whichever boot ran the selection.
2240
+ *
2241
+ * The bare `window._egptActiveRenderer` global this used to read is DELETED: a slot
2242
+ * plus a global would be two truths about one fact (RENDERER_CONTRACT §9).
2243
+ *
2244
+ * FAIL-LOUD: if neither is set when rendering is requested, this is a boot
2245
+ * configuration error — the boot must select a renderer before the draw loop starts
2246
+ * (see `_failLoudMissingRenderer`). No silent P5Renderer fallback (P5Renderer was
2247
+ * deleted in Phase 1). An EMPTY slot is a legitimate state, not an error: the `tick`
2248
+ * and `field` taps are backend-independent and work with no display at all.
2249
+ */
2250
+ FrqtlApp.prototype._getRenderer = function () {
2251
+ if (!this._renderer) {
2252
+ var prov = FrqtlDisplay.get();
2253
+ if (prov) {
2254
+ // ATTACH. Render options written before this point validate against THIS
2255
+ // provider now. A refusal throws and leaves the provider unattached (and untouched),
2256
+ // so every later draw raises it again instead of rendering without the knob.
2257
+ var validated = this._stagedRenderOptions
2258
+ ? _validateRenderWrites(prov, this._stagedRenderOptions) : null;
2259
+ // ATTACH BEFORE createCanvas (a host that defers the canvas — the notebook realm's
2260
+ // mount seam owns createCanvas, so a recipe builds first): the provider has no size of
2261
+ // its own, and a scene may read it while building (runner.cameraControl frames the
2262
+ // view). The runner states ITS rect — the scene's co-init w/h — now; createCanvas's
2263
+ // init restates the same rect. After createCanvas, init alone states it.
2264
+ if (!this._createCanvasCalled) {
2265
+ prov.setSize(this.canvas_rect.w, this.canvas_rect.h);
2266
+ }
2267
+ this._renderer = prov;
2268
+ if (validated) {
2269
+ this._stagedRenderOptions = null;
2270
+ this._applyRenderWrites(validated);
2271
+ }
2272
+ }
2273
+ }
2274
+ return this._renderer || null;
2275
+ };
2276
+
2277
+ /**
2278
+ * runner.cameraControl — the OPTIONAL camera sugar handle from the active
2279
+ * renderer provider, or null. The camera analogue of a setup factory's
2280
+ * temperatureControl: an experiment page does
2281
+ * `var cam = runner.cameraControl; if (cam) wireWidget(cam);`
2282
+ * exactly as blackbody does `if (result.temperatureControl) …`. The presence
2283
+ * of the handle is the CAPABILITY CHECK — the page never sniffs the renderer.
2284
+ * Resolves lazily (after start(), when the provider is live) and caches the
2285
+ * non-null handle (a provider with no pose control re-checks cheaply; a custom provider
2286
+ * that implements getCameraControl() gets the same UI for free). The 2D p5 camera is a
2287
+ * handle too — whether a scene is 3D is `OrbitBag.hasAngularPose(runner.cameraControl)`,
2288
+ * never the handle's presence.
2289
+ * @returns {{ setOrbit(spec):void, getOrbit():Object }|null}
2290
+ */
2291
+ Object.defineProperty(FrqtlApp.prototype, 'cameraControl', {
2292
+ configurable: true,
2293
+ get: function () {
2294
+ if (!this._cameraControl) {
2295
+ var r = this._getRenderer();
2296
+ if (r && typeof r.getCameraControl === 'function') {
2297
+ this._cameraControl = r.getCameraControl(); // null only with no pose control
2298
+ }
2299
+ }
2300
+ return this._cameraControl || null;
2301
+ }
2302
+ });
2303
+
2304
+ /**
2305
+ * Report a missing renderer loudly; called from the draw loop when
2306
+ * _getRenderer() returns null on the first render attempt.
2307
+ */
2308
+ FrqtlApp.prototype._failLoudMissingRenderer = function () {
2309
+ var msg = 'FrqtlApp: the DISPLAY SLOT is empty (FrqtlDisplay.get() === null).\n\n' +
2310
+ 'The boot module (lib/frqtl/experiments/_shared/boot.js) must select a renderer\n' +
2311
+ 'BEFORE the draw loop fires. This is a page configuration error.\n\n' +
2312
+ 'P5Renderer has been removed (WS-RENDERER Phase 1). All experiment pages\n' +
2313
+ 'boot the canonical RendererProvider stack through bootPage().';
2314
+ if (typeof document === 'undefined') {
2315
+ // No DOM to paint the overlay into — the message is still LOUD, on the console
2316
+ // this host does have. Never silent.
2317
+ if (typeof console !== 'undefined' && console.error) { console.error('[FrqtlApp] ' + msg); }
2318
+ } else {
2319
+ paintBootFailure('renderer', msg); // THE one boot-failure painter (console-loud too)
2320
+ }
2321
+ /* Stop the loop to prevent repeated error overlays */
2322
+ this._isLooping = false;
2323
+ if (this._rafId !== null) {
2324
+ cancelAnimationFrame(this._rafId);
2325
+ this._rafId = null;
2326
+ }
2327
+ };
2328
+
2329
+ // ── RAF loop (§6, §6.1) ───────────────────────────────────────────────
2330
+
2331
+ /**
2332
+ * Build and return the inner RAF frame callback for this runner.
2333
+ * Shared by start() and toggle() — one definition, no drift.
2334
+ *
2335
+ * When tickHz === Infinity the loop runs up to MAX_TICKS_UNBOUNDED ticks
2336
+ * per animation frame (unbounded TPS mode). In all other cases the normal
2337
+ * MAX_TICKS_PER_FRAME cap applies and a warning fires if it is hit.
2338
+ */
2339
+ FrqtlApp.prototype._makeFrame = function () {
2340
+ var self = this;
2341
+ function frame(now) {
2342
+ if (!self._isLooping) { return; }
2343
+ self._rafId = requestAnimationFrame(frame);
2344
+
2345
+ // ── Fixed-timestep accumulator (§6.1) ──────────────────────────
2346
+ if (self._lastFrame === 0) { self._lastFrame = now; }
2347
+ var delta = Math.min(now - self._lastFrame, MAX_FRAME_MS);
2348
+ self._lastFrame = now;
2349
+ self._accumulator += delta;
2350
+
2351
+ // Resolve effective tickDt and drawDt for this frame
2352
+ var tickHz = self._tickHz;
2353
+ var drawHz = self._drawHz;
2354
+ var tickDt, drawDt;
2355
+
2356
+ if (tickHz === Infinity) {
2357
+ // Unbounded TPS: run as many ticks per frame as the safety budget allows.
2358
+ // tickDt = 0 keeps the while-condition always true until budget runs out.
2359
+ tickDt = 0;
2360
+ } else if (tickHz > 0) {
2361
+ tickDt = 1000 / tickHz;
2362
+ } else {
2363
+ // Lock to drawHz (1:1 default): advance exactly one tick per draw.
2364
+ tickDt = delta;
2365
+ self._accumulator = delta; // override accumulator to guarantee 1 tick/frame
2366
+ }
2367
+ drawDt = (drawHz > 0) ? (1000 / drawHz) : 0;
2368
+
2369
+ // ── Physics ticks ───────────────────────────────────────────────
2370
+ var lastTickData = null;
2371
+ var budget = (tickHz === Infinity) ? MAX_TICKS_UNBOUNDED : MAX_TICKS_PER_FRAME;
2372
+ // `_tickSuspend` is checked PER TICK, not once per frame: at unbounded TPS this
2373
+ // loop runs a whole batch of ticks inside ONE rAF frame, so a suspend raised from
2374
+ // inside a tick listener must break the batch IMMEDIATELY. Checking it only at the
2375
+ // top of the frame is exactly what let ~120 ticks elapse between a waypoint and an
2376
+ // awaited capture.
2377
+ while ((tickDt === 0 || self._accumulator >= tickDt) && budget-- > 0 && self._tickSuspend === 0 && self._isLooping) {
2378
+ var td = self.universe.doTick();
2379
+ lastTickData = td;
2380
+ self._lastTickData = td; // repaint()'s source — see that method's docstring
2381
+ if (tickDt > 0) { self._accumulator -= tickDt; }
2382
+ self._countTick(now);
2383
+ if (self._onTick) { self._onTick(td, self._setupResult); }
2384
+ self._notifyTickListeners(td);
2385
+ }
2386
+
2387
+ // Budget exhaustion (persistent slow physics). This fires on nearly every
2388
+ // frame whenever the render/tick budget can't keep up, so the warning is
2389
+ // MUTED BY DEFAULT — it was flooding the console once per frame. It surfaces
2390
+ // ONLY when the caller opts into verbose logging (FrqtlApp.verbose = true)
2391
+ // AND at most once per _BUDGET_WARN_INTERVAL_MS so even verbose runs stay
2392
+ // readable. The accumulator reset (the actual correctness action) is unchanged.
2393
+ if (budget <= 0 && tickDt > 0 && self._accumulator >= tickDt) {
2394
+ if (FrqtlApp.verbose === true && typeof console !== 'undefined' && console.warn) {
2395
+ var _lastWarn = self._lastBudgetWarn || 0;
2396
+ if (now - _lastWarn >= self._BUDGET_WARN_INTERVAL_MS) {
2397
+ self._lastBudgetWarn = now;
2398
+ console.warn('[FrqtlApp] tick budget exhausted — physics may be falling behind');
2399
+ }
2400
+ }
2401
+ self._accumulator = 0; // reset to avoid unbounded catch-up
2402
+ }
2403
+ // In unbounded mode, always drain the accumulator so it does not compound.
2404
+ if (tickHz === Infinity) { self._accumulator = 0; }
2405
+
2406
+ // ── Draw frame ──────────────────────────────────────────────────
2407
+ var shouldDraw = drawDt > 0
2408
+ ? (now - self._lastDraw) >= drawDt
2409
+ : true; // no drawHz cap → draw every frame
2410
+
2411
+ if (lastTickData && shouldDraw) {
2412
+ var prov = self._getRenderer();
2413
+ if (prov && prov.renderTick) {
2414
+ self._assertRenderCensus();
2415
+ prov.renderTick(lastTickData, self._visibleLargeObjects(), self._renderOptsWithFeedback());
2416
+ } else if (!self._rendererMissingReported) {
2417
+ self._rendererMissingReported = true;
2418
+ self._failLoudMissingRenderer();
2419
+ return;
2420
+ }
2421
+ self._countDraw(now);
2422
+ self._lastDraw = now;
2423
+ }
2424
+ }
2425
+ return frame;
2426
+ };
2427
+
2428
+ /**
2429
+ * Start the simulation loop.
2430
+ * Idempotent — calling start() while already running does nothing.
2431
+ * Owns requestAnimationFrame — never calls p5 loop(). The runner's loop co-exists with a
2432
+ * p5 global-mode page: p5's own draw() is made a no-op (compat/vendor.js suppressP5Draw)
2433
+ * so p5 does not double-render. The started runner becomes the page's runner (the page
2434
+ * door's `FrqtlPage.runner`, read through `pageRunner()`).
2435
+ */
2436
+ FrqtlApp.prototype.start = function () {
2437
+ // Idempotency guard: if a RAF loop is already running, do nothing.
2438
+ // Without this guard, a second start() call spawns a second concurrent loop,
2439
+ // doubling the tick rate (observable as TPS × 2 on the readout).
2440
+ if (this._isLooping || this._rafId !== null) { return; }
2441
+ _requireAnimationFrame('start');
2442
+
2443
+ // Suppress p5's own draw loop so it doesn't double-render while our RAF loop runs.
2444
+ suppressP5Draw();
2445
+ // The live runner is the PAGE's runner, so any experiment page (and same-origin
2446
+ // embedders such as the IDE author surface, through the page door) drives it
2447
+ // identically — Layers toggles, backdrop controls, inspector edits — without per-page wiring.
2448
+ setPageRunner(this);
2449
+
2450
+ this._isLooping = true;
2451
+ this._accumulator = 0;
2452
+ this._lastFrame = 0;
2453
+ this._lastDraw = 0;
2454
+ this._rateWindowEnd = 0;
2455
+ this._tickCount = 0;
2456
+ this._drawCount = 0;
2457
+ this._lastTps = 0;
2458
+ this._lastFps = 0;
2459
+
2460
+ this._rafId = requestAnimationFrame(this._makeFrame());
2461
+
2462
+ // Also call p5's loop() for pages that still rely on p5 being "running"
2463
+ // (e.g. mousePressed etc. — p5 must be in a running state for event handling).
2464
+ resumeP5Loop();
2465
+ };
2466
+
2467
+ /**
2468
+ * runner.suspendTicks() / resumeTicks() — hold the TICK CLOCK across an async operation
2469
+ * that must observe one specific tick, without touching the user's play/pause state.
2470
+ *
2471
+ * A counted pair (nested suspends are safe; the clock resumes when the LAST one releases).
2472
+ * The batch loop checks the depth PER TICK, so a suspend raised from inside a tick
2473
+ * listener breaks the current batch immediately rather than at the next frame — which is
2474
+ * the whole point: at unbounded TPS a single rAF frame runs a hundred-plus ticks, so
2475
+ * "stop at the next frame" is far too late for anything that means "at THIS tick".
2476
+ *
2477
+ * This is deliberately NOT `stop()`: `stop()` is the user's pause, and a consumer that
2478
+ * borrowed it would leave the play button lying about the run state and could
2479
+ * accidentally restart a sim the user had paused. Physics determinism is untouched — the
2480
+ * tick SEQUENCE is identical either way; only wall-clock pacing changes.
2481
+ *
2482
+ * The canonical consumer is a capture that must correspond to a known tick
2483
+ * (`CameraPath`'s waypoint handlers). Always release in a `finally`.
2484
+ */
2485
+ FrqtlApp.prototype.suspendTicks = function () {
2486
+ this._tickSuspend += 1;
2487
+ return this._tickSuspend;
2488
+ };
2489
+
2490
+ /** Release one suspend. Never goes below zero (an unbalanced resume is a no-op, not a debt). */
2491
+ FrqtlApp.prototype.resumeTicks = function () {
2492
+ if (this._tickSuspend > 0) { this._tickSuspend -= 1; }
2493
+ return this._tickSuspend;
2494
+ };
2495
+
2496
+ /** @returns {boolean} whether the tick clock is currently held. */
2497
+ Object.defineProperty(FrqtlApp.prototype, 'ticksSuspended', {
2498
+ configurable: true,
2499
+ get: function () { return this._tickSuspend > 0; }
2500
+ });
2501
+
2502
+ /**
2503
+ * Pause the simulation.
2504
+ * Honoured PER TICK: a stop() issued from a tick-fn or tick listener ends the batch after
2505
+ * the current tick — that tick completes and the frame's post-batch draw paints it.
2506
+ */
2507
+ FrqtlApp.prototype.stop = function () {
2508
+ this._isLooping = false;
2509
+ if (this._rafId !== null) {
2510
+ cancelAnimationFrame(this._rafId);
2511
+ this._rafId = null;
2512
+ }
2513
+ // Keep p5's loop running for event handling but suppress its draw.
2514
+ // (window.draw is already a no-op from start(); keep it that way.)
2515
+ };
2516
+
2517
+ /**
2518
+ * dispose() — the runner is done: stop its loop and, if it is the page's runner, the page has none
2519
+ * any more (FrqtlPage.runner reads null). Only THIS runner is cleared: a newer runner that already
2520
+ * started stays the page's runner.
2521
+ */
2522
+ FrqtlApp.prototype.dispose = function () {
2523
+ this.stop();
2524
+ if (_pageRunner === this) { _pageRunner = null; }
2525
+ };
2526
+
2527
+ /**
2528
+ * Toggle play/pause.
2529
+ * Resume path re-uses _makeFrame() — same loop body as start(), no drift.
2530
+ */
2531
+ FrqtlApp.prototype.toggle = function () {
2532
+ if (this._isLooping) {
2533
+ this.stop();
2534
+ } else {
2535
+ _requireAnimationFrame('toggle');
2536
+ // Guard: if somehow a stale _rafId is present, cancel it first.
2537
+ if (this._rafId !== null) {
2538
+ cancelAnimationFrame(this._rafId);
2539
+ this._rafId = null;
2540
+ }
2541
+ this._isLooping = true;
2542
+ this._lastFrame = 0; // reset timer on resume to avoid catch-up spike
2543
+ this._rafId = requestAnimationFrame(this._makeFrame());
2544
+ }
2545
+ };
2546
+
2547
+ /**
2548
+ * Advance exactly one tick + render. Useful for step-by-step debugging.
2549
+ */
2550
+ FrqtlApp.prototype.step = function () {
2551
+ // THE SECOND TICK DRIVER. `doTick()` is called in exactly two places — the RAF batch
2552
+ // loop and here — and a suspension that only the loop honoured would be a half-closed
2553
+ // guarantee: the notebook realm, hosted eyes and the Studio Design path drive ticks
2554
+ // through step(), and any of them could advance the world out from under an in-flight
2555
+ // capture-at-a-known-tick. Suspended ⇒ THROW. A step that silently did nothing would
2556
+ // let a driver count ticks it never ran (a warm-up of N steps landing short, a drive
2557
+ // "to tick n" stamped with n-1's world) — the caller waits for `ticksSuspended` to
2558
+ // clear, then steps.
2559
+ if (this._tickSuspend > 0) {
2560
+ throw new Error('[FrqtlApp.step] the tick clock is HELD (runner.ticksSuspended is ' +
2561
+ 'true, depth ' + this._tickSuspend + '): a capture or waypoint handler is ' +
2562
+ 'observing the current tick, and stepping now would advance the world out from ' +
2563
+ 'under it. Wait until runner.ticksSuspended is false, then step. (FAIL-LOUD — a ' +
2564
+ 'step that silently did nothing would be a tick the driver believes it ran.)');
2565
+ }
2566
+ var tickData = this.universe.doTick();
2567
+ this._lastTickData = tickData; // repaint()'s source — see that method's docstring
2568
+ if (tickData) {
2569
+ var prov = this._getRenderer();
2570
+ if (prov && prov.renderTick) {
2571
+ this._assertRenderCensus();
2572
+ prov.renderTick(tickData, this._visibleLargeObjects(), this._renderOptsWithFeedback());
2573
+ } else if (!this._rendererMissingReported) {
2574
+ this._rendererMissingReported = true;
2575
+ this._failLoudMissingRenderer();
2576
+ }
2577
+ }
2578
+ if (this._onTick) { this._onTick(tickData, this._setupResult); }
2579
+ this._notifyTickListeners(tickData);
2580
+ return tickData;
2581
+ };
2582
+
2583
+ /**
2584
+ * addTickListener(fn) — subscribe to the per-tick stream WITHOUT clobbering the
2585
+ * page's single `_onTick` slot. `fn(tickData, setupResult)` runs after `_onTick`
2586
+ * at every tick (RAF loop + step()). Returns an unsubscribe function.
2587
+ *
2588
+ * Host-side sugar for the LiveChart wiring (lib/components/live-chart-wiring):
2589
+ * the wiring accumulates its source wall's collided frames once per tick here.
2590
+ * Idempotent (same fn added once); safe to call before start().
2591
+ * @param {Function} fn
2592
+ * @returns {Function} unsubscribe
2593
+ */
2594
+ FrqtlApp.prototype.addTickListener = function (fn) {
2595
+ if (typeof fn !== 'function') { return function () {}; }
2596
+ if (this._tickListeners.indexOf(fn) === -1) { this._tickListeners.push(fn); }
2597
+ var self = this;
2598
+ return function () { self.removeTickListener(fn); };
2599
+ };
2600
+
2601
+ /**
2602
+ * firstTick({ deadlineMs }) — a promise for the FIRST tick this runner DELIVERS after the
2603
+ * call, bounded by a liveness deadline measured on the runner's OWN clock.
2604
+ *
2605
+ * READINESS IS THE RUNNER'S. A host (the SDK's bootScene/bootRecipe sketch path, a hosted-eyes
2606
+ * driver, a recipe bundle) that wants to know "is this scene live" awaits this instead of
2607
+ * deriving its own answer from `start()` having returned — `start()` schedules a frame; it
2608
+ * delivers nothing. The promise resolves from `_notifyTickListeners` (a tick that RAN), so
2609
+ * a universe that throws in `doTick()` never resolves it.
2610
+ *
2611
+ * THE DEADLINE RUNS ONLY WHILE THE DOCUMENT IS VISIBLE. The tick clock is
2612
+ * requestAnimationFrame (`_makeFrame`), which browsers suspend in a hidden document — a
2613
+ * background tab, a minimised window, an offscreen cross-origin iframe (the claude.ai
2614
+ * artifact case). A wall-clock bound would expire on a HEALTHY runner that was merely
2615
+ * hidden, and a gate that fires on correct behaviour trains everyone to ignore it. So the
2616
+ * countdown PAUSES on `visibilitychange` → hidden (remaining time kept) and resumes when
2617
+ * visible; a runner started hidden waits, however long, for its first visible frame.
2618
+ *
2619
+ * On expiry — `deadlineMs` of VISIBLE time with no delivered tick — the runner STOPS itself
2620
+ * (a loop whose ticks throw would otherwise raise an uncaught error on every frame forever)
2621
+ * and the promise REJECTS naming the runner, its seed, its canvas and the deadline. Never a
2622
+ * resolve: a scene that never ticked is not live, and no caller may be told it is.
2623
+ *
2624
+ * @param {{deadlineMs:number}} opts — `deadlineMs` is REQUIRED (> 0): a promise with no bound
2625
+ * hangs forever on a dead clock, and a hang is silence. FAIL-LOUD on an absent/invalid value.
2626
+ * @returns {Promise<object>} resolves with the first delivered TickData.
2627
+ */
2628
+ FrqtlApp.prototype.firstTick = function (opts) {
2629
+ var deadlineMs = opts && opts.deadlineMs;
2630
+ if (typeof deadlineMs !== 'number' || !isFinite(deadlineMs) || deadlineMs <= 0) {
2631
+ throw new Error('[FrqtlApp.firstTick] opts.deadlineMs must be a positive number of ms (got ' +
2632
+ String(deadlineMs) + '). A first-tick wait with no bound hangs forever on a dead tick clock.');
2633
+ }
2634
+ var self = this;
2635
+ var doc = (typeof document !== 'undefined') ? document : null;
2636
+ return new Promise(function (resolve, reject) {
2637
+ var settled = false;
2638
+ var timer = null;
2639
+ var remainingMs = deadlineMs;
2640
+ var armedAt = 0;
2641
+ var unsubscribe = null;
2642
+ function isVisible() { return !doc || doc.visibilityState === 'visible'; }
2643
+ function disarm() {
2644
+ if (timer === null) { return; }
2645
+ clearTimeout(timer);
2646
+ timer = null;
2647
+ remainingMs = Math.max(0, remainingMs - (Date.now() - armedAt));
2648
+ }
2649
+ function settle() {
2650
+ settled = true;
2651
+ disarm();
2652
+ if (unsubscribe) { unsubscribe(); }
2653
+ if (doc) { doc.removeEventListener('visibilitychange', onVisibility); }
2654
+ }
2655
+ function expire() {
2656
+ timer = null;
2657
+ if (settled) { return; }
2658
+ settle();
2659
+ self.stop();
2660
+ reject(new Error('[FrqtlApp.firstTick] the runner (FrqtlPage.runner, FrqtlApp seed ' +
2661
+ self._seed + ', canvas ' + self.canvas_rect.w + 'x' + self.canvas_rect.h + ') was started ' +
2662
+ 'but delivered no tick within ' + deadlineMs + ' ms of visible time — the scene is NOT ' +
2663
+ 'live; the runner has been stopped. A per-frame error in the tick loop (see the page ' +
2664
+ 'console) or a held tick clock (runner.ticksSuspended) is the usual cause.'));
2665
+ }
2666
+ function arm() {
2667
+ if (settled || timer !== null || !isVisible()) { return; }
2668
+ armedAt = Date.now();
2669
+ timer = setTimeout(expire, remainingMs);
2670
+ }
2671
+ function onVisibility() { if (isVisible()) { arm(); } else { disarm(); } }
2672
+ unsubscribe = self.addTickListener(function (tickData) {
2673
+ if (settled) { return; }
2674
+ settle();
2675
+ resolve(tickData);
2676
+ });
2677
+ if (doc) { doc.addEventListener('visibilitychange', onVisibility); }
2678
+ arm();
2679
+ });
2680
+ };
2681
+
2682
+ /**
2683
+ * removeTickListener(fn) — drop a previously-added tick listener. No-op if absent.
2684
+ * @param {Function} fn
2685
+ */
2686
+ FrqtlApp.prototype.removeTickListener = function (fn) {
2687
+ var i = this._tickListeners.indexOf(fn);
2688
+ if (i !== -1) { this._tickListeners.splice(i, 1); }
2689
+ };
2690
+
2691
+ /**
2692
+ * Fan out one tick to every registered listener. A listener throwing does NOT
2693
+ * abort the loop or the other listeners (its error is surfaced to the console,
2694
+ * never swallowed silently) — the tick clock is never held hostage by a consumer.
2695
+ *
2696
+ * The set dispatched is the set AT TICK START (a snapshot): an unsubscribe during
2697
+ * dispatch takes effect from the next tick, and a subscribe during dispatch fires
2698
+ * from the next tick. No listener's coverage depends on a sibling's bookkeeping.
2699
+ * @private
2700
+ */
2701
+ FrqtlApp.prototype._notifyTickListeners = function (tickData) {
2702
+ if (!this._tickListeners || !this._tickListeners.length) { return; }
2703
+ var ls = this._tickListeners.slice();
2704
+ for (var i = 0; i < ls.length; i++) {
2705
+ try { ls[i](tickData, this._setupResult); }
2706
+ catch (e) {
2707
+ if (typeof console !== 'undefined' && console.error) {
2708
+ console.error('[FrqtlApp] tick listener threw:', e);
2709
+ }
2710
+ }
2711
+ }
2712
+ };
2713
+
2714
+ /**
2715
+ * Repaint the CURRENT physics state WITHOUT advancing the tick clock (WS
2716
+ * Studio Controls Realignment S4, design §2.5.4 — "drag must NEVER call
2717
+ * runner.step() — the tick loop is the only clock"). Re-invokes the active
2718
+ * renderer's `renderTick` against the LAST computed TickData (tracked by
2719
+ * `step()` and the RAF loop) and the CURRENT world census — so a
2720
+ * Design-mode drag (sim stopped, no RAF loop running) still gives immediate
2721
+ * visual feedback for a moved wall/emitter rect (the wrapper's `.rect` was
2722
+ * already mutated in place by `updateObjectProperty` BEFORE this is called)
2723
+ * without ticking physics or drawing a new frame's worth of particle motion.
2724
+ * A no-op before the first tick has ever run (`_lastTickData` is null).
2725
+ */
2726
+ FrqtlApp.prototype.repaint = function () {
2727
+ if (!this._lastTickData) { return; }
2728
+ var prov = this._getRenderer();
2729
+ if (prov && prov.renderTick) {
2730
+ this._assertRenderCensus();
2731
+ prov.renderTick(this._lastTickData, this._visibleLargeObjects(), this._renderOptsWithFeedback());
2732
+ }
2733
+ };
2734
+
2735
+ // ── Accessors ──────────────────────────────────────────────────────────
2736
+
2737
+ Object.defineProperty(FrqtlApp.prototype, 'isRunning', {
2738
+ get: function () { return this._isLooping; }
2739
+ });
2740
+
2741
+ Object.defineProperty(FrqtlApp.prototype, 'setupResult', {
2742
+ get: function () { return this._setupResult; }
2743
+ });
2744
+
2745
+ /**
2746
+ * The world bounds, on all three axes — the ONE read consumers use for "how big /
2747
+ * how deep is this scene" (e.g. the palette Wall drop's full-scene-depth default,
2748
+ * R#101). A pure two-source FERRY — both bags are built by wasm-engine.js's
2749
+ * `_wrapBoundaryRect` (THE ONE engine-bounds → JS bag normalizer,
2750
+ * {x,y,z,w,h,d,left,right,top,bottom,near,far,r}); this getter builds nothing:
2751
+ * 1. the engine-owned TickData ferry once a tick has run — the authority, since
2752
+ * it tracks live resizes;
2753
+ * 2. before the first tick (design-mode drops happen at tick 0), the wrapper's
2754
+ * init-time declaration `universe.initBounds` (truth-in-labeling read-back at
2755
+ * the init site, gated by init-bounds-conformance.test.mjs).
2756
+ * `null` when the wrapper published no init bounds and no tick has run.
2757
+ */
2758
+ Object.defineProperty(FrqtlApp.prototype, 'worldBounds', {
2759
+ get: function () {
2760
+ if (this._lastTickData) { return this._lastTickData.universeData.boundaryRect; }
2761
+ var u = this.universe;
2762
+ return (u && u.initBounds) || null;
2763
+ }
2764
+ });
2765
+
2766
+ /**
2767
+ * The active renderer provider (RendererProvider | null).
2768
+ * Available after start() has been called (or after the display slot is written).
2769
+ */
2770
+ Object.defineProperty(FrqtlApp.prototype, 'renderer', {
2771
+ get: function () { return this._getRenderer(); }
2772
+ });
2773
+
2774
+ /**
2775
+ * Return the most recently computed rate snapshot.
2776
+ *
2777
+ * Rates are recomputed once per ~1 s rolling window (no per-frame allocation;
2778
+ * integers only). Returns { fps: 0, tps: 0 } until the first window expires.
2779
+ *
2780
+ * @returns {{ fps: number, tps: number }}
2781
+ */
2782
+ FrqtlApp.prototype.getRates = function () {
2783
+ return { fps: this._lastFps, tps: this._lastTps };
2784
+ };
2785
+
2786
+ /**
2787
+ * Read the current rate limits.
2788
+ *
2789
+ * fpsLimit — draw frames per second cap (0 = lock to display refresh, i.e. no cap).
2790
+ * tpsLimit — physics ticks per second cap:
2791
+ * 0 = 1:1 locked to drawHz (one tick per draw frame, the default)
2792
+ * > 0 = tick at that Hz (e.g. 240)
2793
+ * Infinity = unbounded — run up to MAX_TICKS_UNBOUNDED ticks per frame
2794
+ *
2795
+ * @returns {{ fpsLimit: number, tpsLimit: number }}
2796
+ */
2797
+ FrqtlApp.prototype.getRateLimits = function () {
2798
+ return { fpsLimit: this._drawHz, tpsLimit: this._tickHz };
2799
+ };
2800
+
2801
+ /**
2802
+ * Set rate limits at runtime. Changes take effect on the next RAF frame
2803
+ * (no restart required).
2804
+ *
2805
+ * @param {Object} limits
2806
+ * @param {number} [limits.fpsLimit] Draw frames/s cap. 0 = display refresh.
2807
+ * Must be ≥ 0.
2808
+ * @param {number} [limits.tpsLimit] Physics ticks/s cap.
2809
+ * 0 = 1:1 locked (default).
2810
+ * > 0 = tick at that Hz.
2811
+ * Infinity = unbounded (up to safety budget).
2812
+ * Must be ≥ 0 (or Infinity).
2813
+ *
2814
+ * Throws a RangeError on invalid inputs and a TypeError on a key outside
2815
+ * `FrqtlApp.RATE_LIMIT_KEYS` (FAIL-LOUD).
2816
+ */
2817
+ FrqtlApp.prototype.setRateLimits = function (limits) {
2818
+ if (!limits || typeof limits !== 'object') {
2819
+ throw new TypeError('[FrqtlApp.setRateLimits] limits must be an object');
2820
+ }
2821
+ Object.keys(limits).forEach(function (k) {
2822
+ if (RATE_LIMIT_KEYS.indexOf(k) === -1) {
2823
+ throw new TypeError('[FrqtlApp.setRateLimits] unknown rate key "' + k +
2824
+ '" — accepted keys: ' + RATE_LIMIT_KEYS.join(', ') + ' (FAIL-LOUD).');
2825
+ }
2826
+ });
2827
+ if (limits.fpsLimit !== undefined) {
2828
+ var fps = limits.fpsLimit;
2829
+ if (typeof fps !== 'number' || fps < 0 || isNaN(fps)) {
2830
+ throw new RangeError('[FrqtlApp.setRateLimits] fpsLimit must be a non-negative number, got: ' + fps);
2831
+ }
2832
+ this._drawHz = fps;
2833
+ }
2834
+ if (limits.tpsLimit !== undefined) {
2835
+ var tps = limits.tpsLimit;
2836
+ if (typeof tps !== 'number' || (tps < 0 && tps !== -Infinity) || isNaN(tps)) {
2837
+ throw new RangeError('[FrqtlApp.setRateLimits] tpsLimit must be ≥ 0 or Infinity, got: ' + tps);
2838
+ }
2839
+ // Normalise -Infinity or negative finite to 0 (1:1 locked).
2840
+ this._tickHz = (tps < 0) ? 0 : tps;
2841
+ }
2842
+ };
2843
+
2844
+ /**
2845
+ * Internal helper: tick the rate counters. Call once per physics tick.
2846
+ * @param {number} now — performance.now() value for this frame
2847
+ */
2848
+ FrqtlApp.prototype._countTick = function (now) {
2849
+ this._tickCount += 1;
2850
+ if (this._rateWindowEnd === 0) {
2851
+ // Initialise window on first call
2852
+ this._rateWindowEnd = now + this._rateWindowMs;
2853
+ }
2854
+ };
2855
+
2856
+ /**
2857
+ * Internal helper: tick the draw counters. Call once per rendered frame.
2858
+ * Checks whether the window has expired and flushes a new rate snapshot.
2859
+ * @param {number} now — performance.now() value for this frame
2860
+ */
2861
+ FrqtlApp.prototype._countDraw = function (now) {
2862
+ this._drawCount += 1;
2863
+ if (now >= this._rateWindowEnd && this._rateWindowEnd > 0) {
2864
+ // Compute elapsed — clamp to at least 1 ms to guard against division by zero.
2865
+ var elapsed = Math.max(now - (this._rateWindowEnd - this._rateWindowMs), 1);
2866
+ this._lastFps = Math.round(this._drawCount * 1000 / elapsed);
2867
+ this._lastTps = Math.round(this._tickCount * 1000 / elapsed);
2868
+ this._drawCount = 0;
2869
+ this._tickCount = 0;
2870
+ this._rateWindowEnd = now + this._rateWindowMs;
2871
+ }
2872
+ };
2873
+
2874
+ FrqtlApp.RATE_LIMIT_KEYS = RATE_LIMIT_KEYS;
2875
+ FrqtlApp.FACTORY_STATICS = FACTORY_STATICS;
2876
+
2877
+ /**
2878
+ * Copy the factory statics setup() consumes (`FrqtlApp.FACTORY_STATICS`) from a
2879
+ * factory onto a wrapper around it — only the ones the factory has — so
2880
+ * `runner.setup(wrapper)` boots the same scene as `runner.setup(factory)`.
2881
+ *
2882
+ * @param {Function} wrapper the function handed to setup() in the factory's place.
2883
+ * @param {Function} factory the setup factory it wraps.
2884
+ * @returns {Function} the wrapper.
2885
+ * @throws {TypeError} FAIL-LOUD when either argument is not a function.
2886
+ */
2887
+ FrqtlApp.carryFactoryStatics = function (wrapper, factory) {
2888
+ if (typeof wrapper !== 'function' || typeof factory !== 'function') {
2889
+ throw new TypeError('[FrqtlApp.carryFactoryStatics] wrapper and factory must both be functions ' +
2890
+ '(got ' + typeof wrapper + ', ' + typeof factory + ') (FAIL-LOUD).');
2891
+ }
2892
+ _refuseDeletedStatics(factory, 'carryFactoryStatics');
2893
+ FACTORY_STATICS.forEach(function (k) {
2894
+ if (factory[k] !== undefined) { wrapper[k] = factory[k]; }
2895
+ });
2896
+ return wrapper;
2897
+ };
2898
+
2899
+ /** Opt-in console diagnostics (the tick-budget warning). Off by default. */
2900
+ FrqtlApp.verbose = false;
2901
+
2902
+ // ── The page's runner ────────────────────────────────────────────────────
2903
+ // The runner a page drives: the one its loop last started, or the one a host page published
2904
+ // after applying a scene (the empty page leaves its runner STOPPED). The page door
2905
+ // (experiments/_shared/page-door.js) reads it as FrqtlPage.runner — this is its ONE store.
2906
+ var _pageRunner = null;
2907
+ /** @param {FrqtlApp} runner */
2908
+ function setPageRunner(runner) {
2909
+ if (!runner || typeof runner.step !== 'function') {
2910
+ throw new TypeError('[FrqtlApp] setPageRunner(runner): expected an FrqtlApp. (FAIL-LOUD.)');
2911
+ }
2912
+ _pageRunner = runner;
2913
+ }
2914
+ /** @returns {FrqtlApp|null} the page's CURRENT live runner — null before a scene ran and after that runner's dispose(). */
2915
+ function pageRunner() { return _pageRunner; }
2916
+
2917
+ // ── Export ─────────────────────────────────────────────────────────────
2918
+
2919
+ export { FrqtlApp, setPageRunner, pageRunner };
2920
+