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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CLAUDE.md +87 -0
  2. package/README.md +84 -2
  3. package/dist/dist-manifest.json +218 -0
  4. package/dist/eyes/index.js +109 -0
  5. package/dist/fat/pkg-node/egpt_wasm.js +1277 -0
  6. package/dist/fat/pkg-node/egpt_wasm_bg.wasm +0 -0
  7. package/dist/fat/pkg-node/package.json +12 -0
  8. package/dist/frqtl/assets/vendor/chroma.mjs +64 -0
  9. package/dist/frqtl/compat/boot-node.js +34 -0
  10. package/dist/frqtl/compat/engine.js +127 -0
  11. package/dist/frqtl/compat/package-root.js +24 -0
  12. package/dist/frqtl/compat/providers/wasm-engine.js +2501 -0
  13. package/dist/frqtl/compat/providers/wasm-load-browser.js +134 -0
  14. package/dist/frqtl/compat/providers/wasm-load-node.js +59 -0
  15. package/dist/frqtl/compat/renderer/FieldBuilder.js +145 -0
  16. package/dist/frqtl/compat/renderer/Filters.js +163 -0
  17. package/dist/frqtl/compat/renderer/RenderFamily.js +85 -0
  18. package/dist/frqtl/compat/renderer/RenderMesh.js +100 -0
  19. package/dist/frqtl/compat/renderer/RenderShape.js +154 -0
  20. package/dist/frqtl/compat/renderer/camera2d-geometry.js +183 -0
  21. package/dist/frqtl/compat/renderer/captureStill-common.js +259 -0
  22. package/dist/frqtl/compat/renderer/display-slot.js +96 -0
  23. package/dist/frqtl/compat/renderer/lo-drawn-center.js +231 -0
  24. package/dist/frqtl/compat/renderer/orbit-bag.js +216 -0
  25. package/dist/frqtl/compat/renderer/providers/godrays-layer.js +213 -0
  26. package/dist/frqtl/compat/renderer/providers/index.js +22 -0
  27. package/dist/frqtl/compat/renderer/providers/p5-provider.js +861 -0
  28. package/dist/frqtl/compat/renderer/providers/three-camera-contract.js +160 -0
  29. package/dist/frqtl/compat/renderer/providers/three-camera-geometry.js +571 -0
  30. package/dist/frqtl/compat/renderer/providers/three-provider.js +2474 -0
  31. package/dist/frqtl/compat/renderer/renderCapabilities-common.js +186 -0
  32. package/dist/frqtl/compat/renderer/renderer-provider-contract.js +238 -0
  33. package/dist/frqtl/compat/renderer/unprojectStill.js +172 -0
  34. package/dist/frqtl/compat/vendor.js +196 -0
  35. package/dist/frqtl/node-entry.js +17 -0
  36. package/dist/frqtl/objects/FrqtlApp.js +2922 -0
  37. package/dist/frqtl/objects/ObjectFactory.js +297 -0
  38. package/dist/frqtl/objects/RecipeContext.js +322 -0
  39. package/dist/frqtl/objects/collectors/DataCollector.js +116 -0
  40. package/dist/frqtl/objects/constants/WallBehaviors.js +178 -0
  41. package/dist/frqtl/objects/geometry/SlabFace.js +258 -0
  42. package/dist/frqtl/objects/instruments/capture-host.js +453 -0
  43. package/dist/frqtl/objects/instruments/capture.js +711 -0
  44. package/dist/frqtl/objects/instruments/hosted-eyes.js +985 -0
  45. package/dist/frqtl/objects/kinds-entry.js +14 -0
  46. package/dist/frqtl/objects/kinds.js +190 -0
  47. package/dist/frqtl/objects/objects/Box.js +273 -0
  48. package/dist/frqtl/objects/objects/Bullet.js +155 -0
  49. package/dist/frqtl/objects/objects/CircuitGate.js +380 -0
  50. package/dist/frqtl/objects/objects/CircuitProbe.js +214 -0
  51. package/dist/frqtl/objects/objects/DirectedTunnel.js +412 -0
  52. package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
  53. package/dist/frqtl/objects/objects/LightSource.js +354 -0
  54. package/dist/frqtl/objects/objects/QuantumEmitter.js +760 -0
  55. package/dist/frqtl/objects/objects/Tube.js +471 -0
  56. package/dist/frqtl/objects/objects/absorbRadiation.js +310 -0
  57. package/dist/frqtl/objects/objects/actionCompiler.js +1747 -0
  58. package/dist/frqtl/objects/objects/controlAction.js +89 -0
  59. package/dist/frqtl/objects/objects/controlBind.js +278 -0
  60. package/dist/frqtl/objects/objects/directedRedirectProgram.js +120 -0
  61. package/dist/frqtl/objects/objects/emitterProgram.js +209 -0
  62. package/dist/frqtl/objects/objects/frqtl-control-slider.js +197 -0
  63. package/dist/frqtl/objects/objects/frqtl-control-sync.js +272 -0
  64. package/dist/frqtl/objects/objects/frqtl-control-widgets.js +219 -0
  65. package/dist/frqtl/objects/objects/frqtl-control-write-queue.js +174 -0
  66. package/dist/frqtl/objects/objects/makeControlsForObject.js +824 -0
  67. package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
  68. package/dist/frqtl/objects/objects/slitLinerScatter.js +263 -0
  69. package/dist/frqtl/objects/objects/sustainedSource.js +299 -0
  70. package/dist/frqtl/objects/objects/tagger.js +228 -0
  71. package/dist/frqtl/objects/schema/engine-scopes.js +469 -0
  72. package/dist/frqtl/objects/schema/index.js +256 -0
  73. package/dist/frqtl/objects/schema/scopes.js +457 -0
  74. package/dist/frqtl/objects/schema/validate-descriptor.js +49 -0
  75. package/dist/frqtl/objects/schema/valueNormalizer.js +366 -0
  76. package/dist/frqtl/objects/setup/_collisionMatrixLanes.js +66 -0
  77. package/dist/frqtl/objects/setup/_setupOptions.js +118 -0
  78. package/dist/frqtl/objects/setup/_worldInit.js +162 -0
  79. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +242 -0
  80. package/dist/frqtl/objects/setup/setupCircuitBasic.js +447 -0
  81. package/dist/frqtl/objects/setup/setupCollisionMatrix.js +188 -0
  82. package/dist/frqtl/objects/setup/setupConeEmitter3D.js +251 -0
  83. package/dist/frqtl/objects/setup/setupDoubleSlit.js +327 -0
  84. package/dist/frqtl/objects/setup/setupDoubleSlit_Laser.js +229 -0
  85. package/dist/frqtl/objects/setup/setupEmpty.js +59 -0
  86. package/dist/frqtl/objects/setup/setupFxSmoke.js +209 -0
  87. package/dist/frqtl/objects/setup/setupG1Race.js +80 -0
  88. package/dist/frqtl/objects/setup/setupG2Sweep.js +89 -0
  89. package/dist/frqtl/objects/setup/setupG3Collide.js +177 -0
  90. package/dist/frqtl/objects/setup/setupLightSourceLab.js +280 -0
  91. package/dist/frqtl/objects/setup/setupOscillator.js +68 -0
  92. package/dist/frqtl/objects/setup/setupParticleWalk.js +93 -0
  93. package/dist/frqtl/objects/setup/setupParticleWalk3D.js +133 -0
  94. package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +1259 -0
  95. package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
  96. package/dist/frqtl/objects/setup/setupTunnelLab.js +242 -0
  97. package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
  98. package/dist/frqtl/objects/setup/setupWaveInterference.js +178 -0
  99. package/dist/frqtl/objects/v1-replacements.js +30 -0
  100. package/dist/frqtl/package-core.js +23 -0
  101. package/dist/frqtl/parts/CameraControls.js +643 -0
  102. package/dist/frqtl/parts/CameraPath.js +575 -0
  103. package/dist/frqtl/parts/CollisionActions.js +35 -0
  104. package/dist/frqtl/parts/CollisionTypes.js +31 -0
  105. package/dist/frqtl/parts/DetectorWall.js +496 -0
  106. package/dist/frqtl/parts/FrqtlControlOverlay.js +227 -0
  107. package/dist/frqtl/parts/FrqtlControlsPanel.js +415 -0
  108. package/dist/frqtl/parts/FrqtlVision.js +818 -0
  109. package/dist/frqtl/parts/GraphingDetectorWall.js +490 -0
  110. package/dist/frqtl/parts/LargeObject.js +406 -0
  111. package/dist/frqtl/parts/LightColor.js +255 -0
  112. package/dist/frqtl/parts/LiveChart.js +285 -0
  113. package/dist/frqtl/parts/LogicGate.js +720 -0
  114. package/dist/frqtl/parts/OvenBox.js +612 -0
  115. package/dist/frqtl/parts/PointSource.js +763 -0
  116. package/dist/frqtl/parts/Rectangle.js +200 -0
  117. package/dist/frqtl/parts/SlitLightSource.js +234 -0
  118. package/dist/frqtl/parts/SlitScreen.js +628 -0
  119. package/dist/frqtl/parts/SphereEmitter.js +946 -0
  120. package/dist/frqtl/parts/Trace.js +508 -0
  121. package/dist/frqtl/parts/Tunnel.js +1057 -0
  122. package/dist/frqtl/parts/Wall.js +1211 -0
  123. package/dist/frqtl/parts/index.js +33 -0
  124. package/dist/frqtl/scenes/absorbing-wall-3d.js +33 -0
  125. package/dist/frqtl/scenes/absorbing-wall.js +32 -0
  126. package/dist/frqtl/scenes/atomic-model.js +88 -0
  127. package/dist/frqtl/scenes/big-bang.js +46 -0
  128. package/dist/frqtl/scenes/blackbody.js +81 -0
  129. package/dist/frqtl/scenes/circuit-sat.js +127 -0
  130. package/dist/frqtl/scenes/collision-matrix-3d.js +107 -0
  131. package/dist/frqtl/scenes/counting-wall-3d.js +36 -0
  132. package/dist/frqtl/scenes/counting-wall.js +38 -0
  133. package/dist/frqtl/scenes/double-slit.js +31 -0
  134. package/dist/frqtl/scenes/dse-one-emitter.js +196 -0
  135. package/dist/frqtl/scenes/dse-one-slit.js +114 -0
  136. package/dist/frqtl/scenes/dse-photoelectric.js +70 -0
  137. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +244 -0
  138. package/dist/frqtl/scenes/dse-two-emitters.js +196 -0
  139. package/dist/frqtl/scenes/dse-two-slits.js +132 -0
  140. package/dist/frqtl/scenes/empty.js +14 -0
  141. package/dist/frqtl/scenes/laser-buildup.js +49 -0
  142. package/dist/frqtl/scenes/mirror-3d.js +56 -0
  143. package/dist/frqtl/scenes/mirror-route.js +140 -0
  144. package/dist/frqtl/scenes/mirror.js +38 -0
  145. package/dist/frqtl/scenes/omni-3d.js +27 -0
  146. package/dist/frqtl/scenes/omni-source.js +22 -0
  147. package/dist/frqtl/scenes/self-interference.js +39 -0
  148. package/dist/frqtl/scenes/smoking-gun.js +122 -0
  149. package/dist/frqtl/scenes/straight-walk.js +22 -0
  150. package/dist/frqtl/scenes/sun.js +39 -0
  151. package/dist/frqtl/scenes/two-source.js +37 -0
  152. package/dist/frqtl/scenes/wave.js +26 -0
  153. package/dist/shim/bundle/frqnb.js +112 -0
  154. package/dist/shim/bundle/index.js +121 -0
  155. package/dist/shim/bundle/indexHtml.js +107 -0
  156. package/dist/shim/bundle/manifest.js +95 -0
  157. package/dist/shim/bundle/zip.js +243 -0
  158. package/dist/shim/bus.js +67 -0
  159. package/dist/shim/controls/index.js +627 -0
  160. package/dist/shim/controls/registry.js +274 -0
  161. package/dist/shim/engine/artifact-shell.js +212 -0
  162. package/dist/shim/engine/boot-phases.js +44 -0
  163. package/dist/shim/engine/engine-stack-boot.js +59 -0
  164. package/dist/shim/engine/router.js +692 -0
  165. package/dist/shim/experiment-frame/index.js +296 -0
  166. package/dist/shim/expr/ast-schema.js +369 -0
  167. package/dist/shim/field-descriptor/index.js +326 -0
  168. package/dist/shim/frame-data/index.js +224 -0
  169. package/dist/shim/index.js +67 -0
  170. package/dist/shim/notebook/canvas-guard.js +82 -0
  171. package/dist/shim/notebook/catalog.js +35 -0
  172. package/dist/shim/notebook/format.js +736 -0
  173. package/dist/shim/notebook/frqtl-setups-importmap.js +81 -0
  174. package/dist/shim/notebook/kernel.js +672 -0
  175. package/dist/shim/notebook/knob.js +151 -0
  176. package/dist/shim/notebook/module-resolver.js +260 -0
  177. package/dist/shim/notebook/notebook-knobs.js +65 -0
  178. package/dist/shim/notebook/produced-bindings.js +67 -0
  179. package/dist/shim/notebook/realm-registry.js +121 -0
  180. package/dist/shim/notebook/realm-transport.js +73 -0
  181. package/dist/shim/notebook/setup-builder.js +510 -0
  182. package/dist/shim/notebook/store.js +159 -0
  183. package/dist/shim/notebook/vendor-importmap.js +134 -0
  184. package/dist/shim/notebook/view-builder.js +189 -0
  185. package/dist/shim/notebook/view-ctx.js +100 -0
  186. package/dist/shim/notebook/view-handle.js +202 -0
  187. package/dist/shim/notebook/warmup.js +102 -0
  188. package/dist/shim/package.json +4 -0
  189. package/dist/shim/page-params/index.js +214 -0
  190. package/dist/shim/project/identity.js +112 -0
  191. package/dist/shim/project/index.js +19 -0
  192. package/dist/shim/project/link.js +402 -0
  193. package/dist/shim/project/node-specifier-hook.mjs +35 -0
  194. package/dist/shim/project/register-node.mjs +17 -0
  195. package/dist/shim/project/specifiers.js +106 -0
  196. package/dist/shim/registry/copy-gate.js +110 -0
  197. package/dist/shim/registry/exhibits.js +455 -0
  198. package/dist/shim/registry/experiments.js +215 -0
  199. package/dist/shim/registry/index.js +19 -0
  200. package/dist/shim/registry/scene-source.js +157 -0
  201. package/dist/shim/registry/walkthrough.js +807 -0
  202. package/dist/shim/render/capture-envelope.js +279 -0
  203. package/dist/shim/render/heat-colormap.js +92 -0
  204. package/dist/shim/render/render-stages.js +855 -0
  205. package/dist/shim/render/sun-look.js +33 -0
  206. package/dist/shim/render-state/apply.js +262 -0
  207. package/dist/shim/render-state/index.js +74 -0
  208. package/dist/shim/rpc/index.js +207 -0
  209. package/dist/shim/runtime/index.js +1023 -0
  210. package/dist/shim/storage/boot.js +69 -0
  211. package/dist/shim/storage/index.js +169 -0
  212. package/dist/shim/storage/local-storage-provider.js +230 -0
  213. package/dist/shim/storage/make-namespace-store.js +132 -0
  214. package/dist/shim/storage/modules.js +249 -0
  215. package/dist/shim/storage/projects.js +150 -0
  216. package/llms.txt +12 -0
  217. package/package.json +62 -4
@@ -0,0 +1,1259 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * setupSphereEmitter3D — a spherical "sun" emitter: uniform-DIRECTION emission
5
+ * via ball-rejected integer velocity sampling (CEO-ratified model, 2026-07-17,
6
+ * corrected same day: speed is ALWAYS 1px/tick (bench M1) — (vx,vy,vz) is
7
+ * DIRECTION ONLY, never a speed knob; only the ratio vx:vy:vz matters,
8
+ * (3,4,0)≡(6,8,0)). Models on `setupConeEmitter3D.js` (same live-knob,
9
+ * host-tick-fn, thin-sugar pattern).
10
+ *
11
+ * ── THE LIFECYCLE CONTRACT (read this first) ─────────────────────────────────
12
+ *
13
+ * world.init() FIRST → CONSTRUCT parts → WIRE (render, ticks) → RUN
14
+ *
15
+ * **The world is init'd by the SKETCH. A part constructor never inits.** This
16
+ * factory constructs a PART (an emitter = a host tick-fn that calls
17
+ * `makeLeafFrame`+`injectFrame` N times per tick with ball-rejected directions),
18
+ * so it takes an ALREADY-INIT'D universe and registers itself on it. Construction
19
+ * order among parts is therefore irrelevant, and a second emitter sharing one
20
+ * field is just a second call — no flag. (`skipInit` is DELETED and throws: it was
21
+ * the packaging demanding a flag to TOLERATE the pattern being followed correctly,
22
+ * and the root cause of the boot-order crash class.)
23
+ *
24
+ * The sketch step, when the sketch wants THIS emitter's implied world:
25
+ *
26
+ * setupSphereEmitter3D.initWorld(universe, opts); // 1. WORLD (once)
27
+ * setupSphereEmitter3D(universe, opts); // 2. PART
28
+ * setupSphereEmitter3D(universe, opts2); // 2. another PART, same field
29
+ *
30
+ * `setupSphereEmitter3D.worldCfg(opts)` is the SINGLE SOURCE of that world's physics, as DATA
31
+ * (a world cfg bag) — the ONE authority for the lambda_eff chain. A sketch states the world
32
+ * with it in its co-init call (`exp.createCanvas(w, h, worldCfg(opts))`, or `exp.createScene`
33
+ * for 3D — the METHOD owns the axes and the size). `initWorld` is that bag through
34
+ * `FrqtlWorldInit.initArgs`, the one cfg→init mapping, plus the axes the part's own `is2D`
35
+ * declares, so both spellings build the same world. `FrqtlApp.setup(setupSphereEmitter3D, opts)` runs both steps in order.
36
+ *
37
+ * ── Layer 1: API contract ────────────────────────────────────────────────────
38
+ *
39
+ * @param {Object} universe
40
+ * The engine universe instance (WASM compat shim), ALREADY INIT'D by the sketch.
41
+ * Obtained from `window.EGPTUniverse` via FrqtlApp.
42
+ *
43
+ * @param {Object} [options]
44
+ * @param {number} [options.fundamentalWaveLength=64] - Same wavelengthScale
45
+ * derivation every other setup factory in this directory uses. A WORLD knob:
46
+ * it is read by `worldCfg`/`initWorld` (the sketch step), not by the part —
47
+ * an emitter constructed on a world the sketch init'd differently inherits
48
+ * THAT world's wavelengthScale.
49
+ * @param {string} [options.stepping] - 'fixed' | 'average'. The wave-walk STEPPING
50
+ * SAMPLER, a WORLD knob like fundamentalWaveLength: read by `worldCfg`/`initWorld`
51
+ * and ferried VERBATIM into `universe.init()` (the one validator), never
52
+ * interpreted or defaulted here. Omitted ⇒ the ENGINE's default sampler.
53
+ * @param {boolean} [options.is2D=false] - Fixed at construction (activeAxes.z
54
+ * is set at `universe.init()` time and cannot change without a full
55
+ * restart-in-place — see the experiment page's dimension toggle, which calls
56
+ * `runner.setup()` again with the opposite flag, the documented
57
+ * restart-in-place idiom). `is2D=true` ⇒ activeAxes {x:true,y:true,z:false},
58
+ * velocities sampled as (vx,vy) disk-rejected — the 2D analogue ("spherical"
59
+ * becomes a uniform DISK).
60
+ * @param {number} [options.R=24] - THE virtual-parent-frame side / velocity
61
+ * RANGE each of vx,vy,vz is sampled from ([-R,R], integer) AND the
62
+ * blockiness/resolution knob: R also caps the rejection ball's radius
63
+ * (|v|<=R). Direction fidelity (angular uniformity, measured via CV) is
64
+ * ROBUST across R (engine-realized CV stays ~0.04-0.11 for R in [2,40] after
65
+ * ~10+ ticks of engine motion — see the canonical-bench S4/S5 entries and
66
+ * the bench README physics note) because the per-tick base-walk step
67
+ * probability is the RATIO |v_axis|/|v| (frame.rs `l2_walk_probs`), scale-
68
+ * invariant in R. What R actually controls is the NUMBER OF DISTINCT
69
+ * INTEGER-LATTICE DIRECTIONS available inside the ball (R=2 -> 26 rays,
70
+ * R=20 -> ~27800 rays, R=40 -> ~223000 rays) — a real "sun" wants R large
71
+ * enough that a SINGLE BURST (before engine-tick temporal averaging kicks
72
+ * in) doesn't itself read lumpy: a static (no-tick) per-burst octant CV is
73
+ * ~0.16 at R=9, ~0.09 at R=20, ~0.07 at R=24-25, flattening to ~0.05-0.06 by
74
+ * R=30-40 (CEO-directed re-check, 2026-07-18, host-RNG measurement) — R=24
75
+ * is the new DEFAULT, comfortably past the R=20 knee with headroom below the
76
+ * R=40 slider ceiling (so the R=9-ish "lumpy" end of the range stays
77
+ * explorable too).
78
+ * @param {number} [options.emissionBoxSide=0] - Side (px) of the CUBE emission
79
+ * region leaves are placed within (uniform per-axis offset from center),
80
+ * CLAMPED to <= R (a box bigger than the direction-sampling range would be
81
+ * an incoherent "sun"). 0 (default) ⇒ true point source (a sun); values up
82
+ * to R ⇒ an extended "light bulb" glow volume. Independent knob from R (a
83
+ * point-source sun with wide angular spread, or an extended bulb with a
84
+ * narrow beam, are both valid).
85
+ * @param {'ball'|'shell'} [options.mode='ball'] - 'ball': reject-to-|v|<=R,
86
+ * the DEFAULT — a filled sphere of directions. 'shell': reject to
87
+ * 0.9R<|v|<=R — a THIN direction band (NOT a speed constraint; speed is
88
+ * always 1px/tick, M1). Its only effect is marginally BETTER angular
89
+ * uniformity at small sample counts (measured pure-math octant CV 0.066 vs
90
+ * ball's 0.082 at R=20,N=4000) because it drops the coarse near-origin
91
+ * lattice points; optional refinement, not the default.
92
+ * @param {'spherical'|'hemisphere'|'laser'|'drum'|'particle-walk'} [options.preset='spherical'] - Sets the
93
+ * per-axis velocity RANGES the sampler draws from — the unification the CEO
94
+ * asked for: sun/laser/drum are the SAME sampler with different ranges.
95
+ * 'spherical' (DEFAULT, the sun): vx,vy,vz each in [-R,R] -> full ball ->
96
+ * uniform spherical direction distribution.
97
+ * 'hemisphere' (FORWARD 180° emission — the directed sun): the SAME ball-
98
+ * rejected sampler with the FORWARD AXIS' range HALVED — vx in [0,R],
99
+ * vy,vz in [-R,R]. Conditioning a uniform ball on vx>=0 leaves a uniform
100
+ * HALF-ball, whose direction marginal is EXACTLY the restriction of
101
+ * 'spherical''s to the forward hemisphere: same physics, pointed one way
102
+ * (theorist advisory 2026-07-28, measured over 2x10^6 MC samples). BALL
103
+ * REJECTION IS RETAINED and that is load-bearing — a raw box draw
104
+ * (vx in [0,R], vy in [-R,R], no magnitude clip) measures 1.83:1
105
+ * anisotropy with 1.45x peaks at +/-45deg, which is exactly the diagonal
106
+ * over-weighting the ball correction exists to remove (S4/S5). The
107
+ * forward axis is +X in BOTH 2D and 3D (like 'particle-walk', unlike
108
+ * 'laser''s is2D branch), so the aim is the emitter's OWN
109
+ * `orientationDeg` yaw (`rotateVelocity2D`) — rotation is measure-
110
+ * preserving and applied AFTER sampling, so there is exactly ONE
111
+ * rotation path and no second aim knob. `is2D` forces vz=0, giving the
112
+ * uniform half-DISK (the 2D analogue), per bench S1 (2D === 3D minus an
113
+ * axis). Not a magnitude/speed knob: speed is always 1px/tick (M1) —
114
+ * only the direction ratio changes.
115
+ * 'laser': collimated beam, forward axis chosen by `is2D` so this preset
116
+ * reproduces the historical `particle-walk`/`particle-walk-3d` recipe
117
+ * (a fixed rightward drift + emergent transverse wave-walk) through the
118
+ * SAME sampler, not a separate mechanism:
119
+ * - 3D (`is2D=false`): vx,vy narrowed to [-0.15R,0.15R], vz forward-
120
+ * biased to [0.6R,R] -> mostly +z (view with the 'down-barrel'
121
+ * camera preset to see it propagate on screen).
122
+ * - 2D (`is2D=true`): vx forward-biased to [0.6R,R] (the historical
123
+ * rightward convention), vy narrowed to [-0.15R,0.15R] (vz is
124
+ * already forced 0 by `is2D` regardless). At small R (2-3) the
125
+ * narrow band rounds to exactly 0, giving the OLD Particle Walk's
126
+ * deterministic vx-only drift with pure y-axis wave-walk
127
+ * oscillation — R alone selects "exact old recipe" vs "spread beam".
128
+ * 'drum' (the old cylinder/disk emitter): vz PINNED to [0,0] -> vx,vy
129
+ * ball-rejected in the z=0 plane -> a 2D disk of directions, no forward
130
+ * component (this is the historical concentric-ring-emitter-vs-cone
131
+ * construction's "collimated cylinder" case generalized: here the
132
+ * DISK is of DIRECTIONS at the source, not ring positions).
133
+ * 'particle-walk' (CEO Phase 1 iteration, 2026-07-18, two rounds — a
134
+ * FAITHFUL reproduction, not just the primitive, GROUND-TRUTHED against
135
+ * the actual `setupParticleWalk.js`/`setupParticleWalk3D.js` source):
136
+ * vx PINNED to `[R,R]` (deterministic +X drift, ALWAYS the forward
137
+ * axis regardless of `is2D` — unlike 'laser'), vy=vz PINNED to `[0,0]`.
138
+ * The waviness comes ENTIRELY from the transverse wave-walk (a
139
+ * SEPARATE mechanism from this base-walk velocity — CLAUDE.md
140
+ * Invariant 1) firing on y (2D) or y+z (3D), never from momentum
141
+ * divergence — this is the "golden CHARACTER" gap fix vs. 'laser' (a
142
+ * diverging cone), matching both historical experiments' exact
143
+ * `vx=1,vy=0` convention. ALSO auto-selects a matching SOURCE SHAPE
144
+ * (see `options.emissionBox`/`sourceShape` below): a flat vertical LINE
145
+ * in Y, height `10` — IDENTICAL in 2D and 3D (ground-truthed: BOTH
146
+ * historical files construct the SAME `[cx,cy,1,10]` sourceRect; there
147
+ * is no disk anywhere in the historical code — the 3D version's
148
+ * Z-spread is 100% EMERGENT from the wave-walk over ticks, not from an
149
+ * initial disk placement — a prior round of this preset wrongly
150
+ * defaulted 3D to a 'disk' shape; corrected here). Also
151
+ * ground-truthed: `burstSize` defaults to `20` and `emitEveryTicks` to
152
+ * `1` (both historical experiments fire `burst_size=20` EVERY tick,
153
+ * `delay=0`) — this reproduces the measured historical steady-state
154
+ * density (~5960 alive quanta on a 600x600 canvas, reached by
155
+ * tick~300 and held thereafter — little's-law: 20/tick x ~298-tick
156
+ * transit time). A true 'disk' shape and any other burst/rate/size
157
+ * stay available as explicit opt-ins (`options.sourceShape`,
158
+ * `options.burstSize`, `options.emitEveryTicks`, `options.
159
+ * emissionBoxSide`) — these are just no longer this preset's SILENT
160
+ * defaults.
161
+ * Any preset can be overridden per-axis after construction via
162
+ * `control.setAxisRange('vx', lo, hi)` etc. for custom directional shapes.
163
+ * @param {{x:number,y:number,z:number}} [options.emissionBox] - OPT-IN per-axis
164
+ * emission-position box (extends the scalar `emissionBoxSide` cube into an
165
+ * independent size per axis — CEO Phase 1 iteration). Omitted (default) ->
166
+ * `emissionBox` is DERIVED from `emissionBoxSide` + the current preset
167
+ * (uniform cube for spherical/laser/drum — BYTE-IDENTICAL to every
168
+ * pre-existing caller; the flat line shape above for 'particle-walk',
169
+ * IDENTICAL in 2D and 3D — ground-truthed, see the preset docs above).
170
+ * When explicitly provided, it is FIXED (frozen at construction, ignoring
171
+ * preset/emissionBoxSide changes) — any omitted axis defaults to `0`.
172
+ * @param {'box'|'disk'} [options.sourceShape] - OPT-IN emission-position
173
+ * SHAPE for the (y,z) pair (x always independent, matching a beam's
174
+ * forward axis having no positional spread). Omitted (default) -> derived
175
+ * from preset — `'box'` for EVERY preset including 'particle-walk' (a line
176
+ * IS a degenerate box, y=size,z=0 — ground-truthed as the historical
177
+ * convention, both 2D and 3D). `'disk'` is available as an explicit
178
+ * opt-in (not any preset's silent default): circular-rejects (y,z) to a
179
+ * radius of `max(emissionBox.y,emissionBox.z)/2` (mirrors `sampleVelocity`'s
180
+ * own ball-rejection idiom, applied to POSITION instead of direction).
181
+ * @param {number} [options.burstSize=40] - Leaves emitted per burst.
182
+ * @param {number} [options.emitEveryTicks=6] - Ticks between bursts (sustained
183
+ * emission — a host tick-fn, NOT a native emit-spec; mirrors
184
+ * `setupConeEmitter3D.js` / Blackbody's thermostat).
185
+ * @param {number} [options.fundamentalDimension] - REINIT-CLASS (like `is2D`
186
+ * — a WORLD knob, fixed at the sketch's `universe.init()` time via
187
+ * `worldCfg().dimension`, which reads it as 0 when undeclared; changing it needs
188
+ * a restart-in-place). The PART step, when neither this nor `leafDimension` is
189
+ * declared, takes the universe's live `fundamentalDimension`; when only
190
+ * `leafDimension` is declared it is 0. A positive value (e.g. `2`, matching `setupTunnelLab`/
191
+ * `setupLightSourceLab`'s own `FUNDAMENTAL_DIMENSION` — "MATCHED CONTROLS ->
192
+ * MATCHED PHYSICS") raises each emitted leaf's CAPACITY
193
+ * (`SustainedSource.capacityAtDimension(dim)` = `(2^dim)^2`) so `options.
194
+ * wavelength` below has room to produce a mass strictly less than capacity —
195
+ * this is the r0/r1 "unique rational wavelength" demo knob. NOTE: raising
196
+ * this also raises the leaf's rendered PIXEL FOOTPRINT (width=2^dim per
197
+ * axis) — an intentional, visible trade for the demo, not a bug.
198
+ * @param {'volume'|'surface'} [options.emissionShell='volume'] - Emission
199
+ * START-POSITION topology. 'volume' (DEFAULT) seeds WITHIN the emissionBox
200
+ * area/cube — BYTE-IDENTICAL to every pre-existing caller (no extra draw when
201
+ * box=0). 'surface' = the old PointSource "ring launch": seed the start pixel ON
202
+ * a shell of radius `options.innerR`, AT THE ANGLE OF THE LEAF'S OWN VELOCITY —
203
+ * `start = center + round(innerR × unit(v))` — so the start pixel is COLLINEAR
204
+ * with the outward trajectory. This gives a single source an EMPTY CENTER and
205
+ * razor-sharp bands (every particle projects radially outward; none crosses the
206
+ * inner disk). The position is a DETERMINISTIC function of the already-sampled
207
+ * velocity — it draws NOTHING and does NOT decouple position from direction (an
208
+ * independent random start angle would let them diverge and blur the bands).
209
+ * Single-occupancy forbids many quanta starting on the same pixel, so the ring is
210
+ * the correct single-source topology. LIVE via `control.setEmissionShell`.
211
+ * @param {number} [options.innerR=0] - Shell radius (px, non-negative integer) for
212
+ * `emissionShell:'surface'`. 0 collapses the shell to the center point. Ignored
213
+ * (no draw) in 'volume' mode. For the single-source-interference golden this is a
214
+ * small integer (~16, matching PointSource's diameter/2 ring radius) — large
215
+ * enough that a per-tick burst finds distinct start pixels around the ring.
216
+ * @param {'positive'|'negative'|'random'} [options.chargeMode='positive'] -
217
+ * Per-emitted-leaf CHARGE. 'positive' (DEFAULT) stamps charge 0 on every leaf —
218
+ * BYTE-IDENTICAL to the pre-existing hardcoded value (no extra RNG draw, so
219
+ * every sun/laser/drum/particle-walk golden is untouched). 'negative' stamps
220
+ * charge 1. 'random' draws a per-leaf ± (`universe.random()<0.5?0:1`, the ONE
221
+ * opt-in extra draw) EXACTLY as PointSource/`emitterProgram.js` does — this is
222
+ * what makes opposite-charge leaves braid (charge shifts the transverse-spin
223
+ * quadrant), reproducing the Wave-Interference self-interference look from a
224
+ * single omni source. LIVE via `control.setChargeMode` (affects the NEXT leaf,
225
+ * M5). NOTE: charge cannot be delegated as `null` to makeLeafFrame — null
226
+ * coerces to 0 at the wasm-bindgen ABI, not a random-draw sentinel.
227
+ * @param {number} [options.wavelength] - Per-leaf wavelength arg forwarded
228
+ * verbatim to `makeLeafFrame`'s 4th positional (mirrors `setupLightSourceLab`/
229
+ * `setupTunnelLab`'s identical `wavelength` field). Omitted (default) ->
230
+ * `null` -> engine default `mass=capacity` (fullness=1, shortest/violet
231
+ * oscillation) — BYTE-IDENTICAL to every pre-existing caller. A number ->
232
+ * `mass=max(1,capacity/wavelength)`, LIVE (affects the NEXT emitted leaf;
233
+ * already-emitted leaves keep whatever mass they were stamped with, M5).
234
+ * Clamped to `[1, MAX_USEFUL_WAVELENGTH]` (=capacity at the CURRENT
235
+ * `fundamentalDimension`) — beyond that ceiling `mass` saturates at 1 and
236
+ * the slider's upper half goes dead (`feedback_mass_ceiling_saturation_
237
+ * wavelength_param.md`). At `fundamentalDimension=0` (capacity=1) ANY
238
+ * wavelength>=1 forces mass=1 regardless — this knob is a documented NO-OP
239
+ * until `fundamentalDimension` is raised, exactly the SU1 opt-in-safety
240
+ * property that keeps the sun/laser/drum presets' default look untouched.
241
+ *
242
+ * @param {[number,number]|[number,number,number]} [options.center] - Emission
243
+ * SOURCE POSITION in ABSOLUTE canvas px — WHERE this emitter seeds its leaves
244
+ * (the center `cx0/cy0/cz0` that BOTH the volume box AND the surface ring
245
+ * launch are measured relative to). Wins over `options.offset` when both are
246
+ * present. z (3rd element) defaults to 0. Pure sugar (Bite 3, 2026-07-20): it
247
+ * ONLY moves the `makeLeafFrame` seed pixel — the velocity-aligned ring launch
248
+ * (`start = center + innerR×unit(v)`) stays relative to THIS center; zero
249
+ * engine, no golden moves (SU1). Needed for TWO-SOURCE interference (two
250
+ * emitters at ±separation/2 in x cannot both sit at the shared center).
251
+ * @param {[number,number]|[number,number,number]} [options.offset] - Emission
252
+ * SOURCE POSITION RELATIVE to the canvas center (`[dx,dy(,dz)]` px). Used only
253
+ * when `options.center` is absent. z defaults to 0.
254
+ * Both OMITTED (default) ⇒ canvas center (`floor(w/2), floor(h/2), 0`) —
255
+ * BYTE-IDENTICAL to every pre-existing caller. LIVE via `control.setCenter`.
256
+ *
257
+ * @returns {{
258
+ * sphereEmitterControl: {
259
+ * setR(v), getR(), setEmissionBoxSide(v), getEmissionBoxSide(),
260
+ * setMode(v), getMode(), setPreset(v), getPreset(),
261
+ * setAxisRange(axis, lo, hi), getAxisRange(axis),
262
+ * setBurstSize(v), setEmitEveryTicks(v), setActive(bool),
263
+ * setCenter(x,y,z?), getCenter(),
264
+ * setWavelength(v), getWavelength(), getMaxWavelength(),
265
+ * getEmissionBox(), getSourceShape(), dispose(),
266
+ * controls: Array<{id,label,min,max,step,value,fmt,onChange}>
267
+ * },
268
+ * largeObjects: []
269
+ * }}
270
+ * `sphereEmitterControl` is a live sugar handle (the canonical knob pattern
271
+ * — the blackbody oven's live `setTemperature`, `setupConeEmitter3D`'s
272
+ * `coneEmitterControl`). `sphereEmitterControl.controls` is the SAME
273
+ * standardized `runner.experimentControls`-compatible array — declared
274
+ * ONCE, consumed by both the page's own sliders and the IDE GYM strip.
275
+ *
276
+ * ── Layer 2: physics behavior ────────────────────────────────────────────────
277
+ *
278
+ * Zero new physics (canonical-bench SU1). This composes THREE already-
279
+ * goldened engine behaviors:
280
+ * - M1/M5: a free leaf's speed is fixed (fundamental leaf, always steps
281
+ * P=1 per axis when its per-axis base-walk probability fires) and its
282
+ * velocity is conserved absent collision — a straight-line drift once
283
+ * emitted.
284
+ * - `frame.rs::l2_walk_probs` (the S2/S3 cone-emitter's own load-bearing
285
+ * mechanism): the per-tick base-walk step on each axis fires with
286
+ * probability |v_axis|/|v| (L2-normalized RATIO) — this is EXACTLY the
287
+ * standard "sample uniform in a ball, normalize" recipe for generating
288
+ * uniform-direction unit vectors, realized natively by the engine's own
289
+ * stepping rule with ZERO extra engine code: an integer lattice point
290
+ * drawn uniformly inside a ball of radius R, handed to the engine as
291
+ * (vx,vy,vz), produces a leaf whose stochastic per-tick walk direction
292
+ * is (in expectation, and empirically after ~10+ ticks) uniform on the
293
+ * sphere — this IS the physics justification for why ball-rejection is
294
+ * mandatory (a raw cube sample, uncorrected, is diagonal-biased; bench's
295
+ * own Monte-Carlo pre-check + this file's S4/S5 canonical-bench entries
296
+ * confirm the ball correction, not a cube, gives isotropy).
297
+ * - S1: x, y, z axes run the IDENTICAL update rule — 2D (a disk of
298
+ * directions) is the same physics as 3D (a ball of directions) with one
299
+ * fewer axis; `is2D` simply narrows `activeAxes` at init and drops vz
300
+ * from the sampler.
301
+ *
302
+ * MEASURED engine-realized finding (canonical-bench S4/S5,
303
+ * `docs/internal/physics-lab/canonical-bench/bench.cjs`, pkg-node WASM, R=20 N=2000 T=30
304
+ * seed=42, bit-reproducible across runs): angular uniformity (equal-solid-
305
+ * angle octant CV + cos(polar) marginal-flatness CV) is NOT distorted toward
306
+ * axes or diagonals by the ±1px/tick per-axis speed cap — S4 measures
307
+ * octantCV=0.108, cosThetaFlatCV=0.046 (3D ball) and S5 measures
308
+ * sectorCV=0.077 (2D disk), all comfortably isotropic. An independent
309
+ * exploratory sweep (host-side RNG, not the bench's own `un.random()` stream
310
+ * — different draw realization, same qualitative shape) found: a single-tick
311
+ * snapshot (T=1) reads a misleadingly high CV (~0.8) because most leaves
312
+ * haven't taken their first step yet (a sampling-resolution artifact, not a
313
+ * physics bias) — by T=10 the CV has already collapsed to ~0.10, and by
314
+ * T=30+ it is ~0.04-0.11 essentially FLAT across R=2..40, actually TIGHTER
315
+ * than the pure-math ball-sample CV (~0.08 at the same R/N) because the
316
+ * walk's per-tick Bernoulli draws add temporal averaging on top of the
317
+ * initial spatial sample. This confirms R is a "how many distinct rays does
318
+ * a single burst show" (visual/resolution) knob (R=2 -> only 26 distinct
319
+ * integer-lattice directions; R=20 -> ~27800; R=40 -> ~223000), NOT an
320
+ * isotropy-fidelity knob — isotropy is robust even at small R once the
321
+ * emitter has run a few ticks.
322
+ *
323
+ * `fundamentalDimension`/`wavelength` compose Invariant #6's fork (CLAUDE.md
324
+ * §6): a leaf constructed AT the universe's fundamental dimension (which is
325
+ * exactly what happens here — `emitOneLeaf` passes `level=null`, deferring to
326
+ * `universe.init`'s 2nd arg) is "light" and always steps P=1 regardless of its
327
+ * mass (6A) — so raising capacity and setting a sub-capacity mass changes
328
+ * ONLY the transverse oscillation period `wavelength=floor(capacity/mass *
329
+ * WAVELENGTH_CONSTANT)` (6B), never the beam's forward speed. This decoupling
330
+ * is a property of the ALREADY-GOLDENED move-gate rule, not new sugar logic —
331
+ * composing it here (dial mass via the `wavelength` arg, read the resulting
332
+ * oscillation) is exactly the r0/r1 "unique rational wavelength, unchanged
333
+ * speed" demo the intuition guide (`docs/internal/physics-lab/intuition/README.md`) names.
334
+ *
335
+ * Sustained emission is a HOST TICK-FN pushed to
336
+ * `universe.experimentTickFunctions` (architect guardrail: no new engine
337
+ * hook, no native emit-spec). Fast-path regime: no
338
+ * `withInterQuantumCollisions`, no `brownianMotion`, no top-down frame-gen
339
+ * auto-spawn ladder. No sink — leaves drift forever (M5) until they exit
340
+ * `universe_rect` (population bounded by the universe edge, per
341
+ * `reference_population_bounded_by_universe_edge_not_only_sink.md`).
342
+ */
343
+
344
+ import { assertSetupOptions as _assertSetupOptions, requireWorldRect as _requireWorldRect } from './_setupOptions.js';
345
+ import * as SustainedSource from '../objects/sustainedSource.js';
346
+ import { FrqtlWorldInit } from './_worldInit.js';
347
+
348
+ // The ONE unknown-option-key validator (objects/setup/_setupOptions.js). Resolved
349
+ // LAZILY in the browser: the game layer defines window.assertSetupOptions before any
350
+ // page script runs, but reading it at eval time would bake in a load-order dependency.
351
+
352
+ // ── Imports ─────────────────────────────────────────────────────────────
353
+ // SustainedSource.capacityAtDimension is the ONE authority for "capacity of
354
+ // a leaf at fundamental dimension N" (setupTunnelLab.js / setupLightSourceLab.js's
355
+ // own MAX_USEFUL_WAVELENGTH derivation — reused here, not re-derived by hand).
356
+
357
+ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
358
+
359
+ /**
360
+ * worldCfg — the WORLD this emitter's options imply, as DATA.
361
+ *
362
+ * The one authority for the lambda_eff chain; the sketch decides whether and when to
363
+ * apply it. Returns a world cfg bag (the `cfg` of the co-init call) —
364
+ * NO axes: a sketch's co-init METHOD owns those; `FrqtlWorldInit.initArgs` (objects/setup/_worldInit.js)
365
+ * is the ONE mapping of that bag to the raw `universe.init(fundamentalDimension, options)` call, and
366
+ * `initWorld` below applies it.
367
+ *
368
+ * ── THE lambda_eff CHAIN (the one place it is derived; cite THIS, not a constant) ──
369
+ * fundamentalWaveLength (author-facing knob, default 64)
370
+ * -> wavelengthScale = max(1, floor(fWL / 4)) [this function]
371
+ * -> universe.init({ wavelengthScale: <that> }) [FrqtlWorldInit.initArgs]
372
+ * — the universe STORES that as its `wavelengthScale`
373
+ * -> lambda_eff = (capacity / mass) * wavelengthScale
374
+ * = 16 * floor(fWL / 4) [fundamentalDimension 2, mass 1]
375
+ * = 4 * fundamentalWaveLength
376
+ * Three names for three layers of one quantity — author knob, universe field,
377
+ * emergent wavelength — so a reader who sees only one of them can find the others.
378
+ * Confirmed by measurement at both sweep points: fWL 64 -> 256, fWL 32 -> 128
379
+ * (docs/internal/physics-lab/intuition/starter.jsonl, r3-radius-stall-shells).
380
+ *
381
+ * ── `stepping` (ws-walk-unification Bite 1) — a WORLD field, PASSED THROUGH ──
382
+ * The wave-walk STEPPING SAMPLER ('fixed' | 'average') is a per-universe init
383
+ * option, reinit-class exactly like `is2D`/`fundamentalDimension`, so it belongs
384
+ * to the world step rather than to the part. This factory NEITHER validates NOR
385
+ * defaults it: the value is ferried VERBATIM to `universe.init()`, which is the
386
+ * one validator (it throws naming both legal samplers), and the ENGINE's serde
387
+ * default (`Fixed`) is the one default. The key is therefore attached to the
388
+ * returned bag ONLY when the caller declared it — omission keeps every existing
389
+ * caller's cfg bag (and so its `universe.init` options) byte-identical. Stepping is a
390
+ * property of the wave-walk branch (brownianMotion false, frameGenMode 'groundUp').
391
+ *
392
+ * @param {Object} [options] — the SAME options bag `setupSphereEmitter3D` takes.
393
+ * @param {string} [options.stepping] — 'fixed' | 'average'; omitted ⇒ engine default.
394
+ * The AXES are not in the bag: a scene's co-init call owns them (exp.createCanvas ⇒ 2D,
395
+ * exp.createScene ⇒ 3D); `initWorld` (the factory/page path) derives them from `is2D`.
396
+ *
397
+ * @returns {{dimension:number, wavelengthScale:number, stepping?:string}}
398
+ */
399
+ function worldCfg(options) {
400
+ // worldCfg/initWorld are EXPORTED entry points, so a typo in the sketch step
401
+ // (`initWorld(u, {fundamentalWavelength: 64})`) must fail here rather than run
402
+ // silently. They gate on SKETCH_OPTIONS, not OPTIONS: the sketch bag is SHARED
403
+ // with the part step and legitimately carries the facade's own keys (see above).
404
+ options = _assertSetupOptions('setupSphereEmitter3D.worldCfg', options, SKETCH_OPTIONS);
405
+ var desiredWaveLength = options.fundamentalWaveLength !== undefined
406
+ ? options.fundamentalWaveLength : 64;
407
+ var cfg = {
408
+ dimension: options.fundamentalDimension !== undefined ? options.fundamentalDimension : 0,
409
+ wavelengthScale: Math.max(1, Math.floor(desiredWaveLength / 4)),
410
+ };
411
+ // Omit-when-undeclared (the activeAxes/depth discipline one layer down): an
412
+ // undeclared sampler must not even appear as a key, so the bag a pre-existing
413
+ // sketch builds is unchanged, key-for-key.
414
+ if (options.stepping !== undefined && options.stepping !== null) {
415
+ cfg.stepping = options.stepping;
416
+ }
417
+ return cfg;
418
+ }
419
+
420
+ /**
421
+ * The ONE cfg→universe.init mapping is `FrqtlWorldInit.initArgs`, owned by the leaf
422
+ * `objects/setup/_worldInit.js` — never a local copy.
423
+ */
424
+ function _worldInitArgs() {
425
+ return FrqtlWorldInit.initArgs;
426
+ }
427
+
428
+ /**
429
+ * initWorld — THE SKETCH STEP. Init the world this emitter implies, on a RAW
430
+ * universe, BEFORE constructing any part:
431
+ *
432
+ * universe.init(...FrqtlWorldInit.initArgs(worldCfg(opts)))
433
+ *
434
+ * i.e. exactly the world a sketch's co-init call with `worldCfg(opts)` builds (2D:
435
+ * createCanvas, 3D: createScene) — one factory, one world,
436
+ * whichever surface the author used (conformance: one-world-per-factory.test.mjs).
437
+ *
438
+ * `universe.init()` is a universe-GLOBAL reset (it clears every frame and fixes the
439
+ * base fundamental, wavelengthScale and activeAxes), which is exactly why it belongs
440
+ * to the sketch and runs ONCE. `FrqtlApp.setup(setupSphereEmitter3D, opts)` runs it
441
+ * before the part step (and `restart()` replays both); a second emitter on the same
442
+ * field just skips this call — that is what deleted `skipInit`.
443
+ *
444
+ * @param {Object} universe — a constructed universe (init'd or not; this re-inits it).
445
+ * @param {Object} [options] — the SAME options bag `setupSphereEmitter3D` takes.
446
+ */
447
+ function initWorld(universe, options) {
448
+ var args = _worldInitArgs()(worldCfg(options));
449
+ // The factory/page path has no co-init call to own the axes, so the part's own
450
+ // `is2D` declares them here (the RAW universe.init options — not a world-cfg key).
451
+ args[1].activeAxes = { x: true, y: true, z: !(options && options.is2D) };
452
+ universe.init.apply(universe, args);
453
+ }
454
+ /**
455
+ * OPTIONS — this factory's own option vocabulary: EXACTLY the keys it reads.
456
+ * Exported as `setupSphereEmitter3D.OPTIONS` so a composing caller consumes it instead of
457
+ * retyping it. `setup-factory-option-vocabulary.test.mjs` re-derives the
458
+ * read-set by static trace and fails if this list drifts from the code.
459
+ */
460
+ var OPTIONS = [
461
+ 'R', 'burstSize', 'center', 'chargeMode',
462
+ 'emissionBox', 'emissionBoxSide', 'emissionShell', 'emitEveryTicks',
463
+ 'fundamentalDimension', 'fundamentalWaveLength', 'innerR', 'is2D',
464
+ 'leafDimension', 'mode', 'offset', 'orientationDeg',
465
+ 'preset', 'renderColor', 'renderShape', 'sourceShape',
466
+ 'stepping', 'wavelength',
467
+ ];
468
+
469
+ /**
470
+ * SKETCH_OPTIONS — what `worldCfg`/`initWorld` tolerate, which is a SUPERSET of the
471
+ * part's own vocabulary.
472
+ *
473
+ * The canonical sketch idiom shares ONE bag across both steps:
474
+ * exp.createCanvas(W, H, setupSphereEmitter3D.worldCfg(SRC)); // WORLD (and its canvas)
475
+ * exp.addObject('SphereEmitter', SRC); // PART
476
+ * (walkthrough.js cl1d + its 3D twin). So the bag reaching `worldCfg` legitimately
477
+ * carries the keys the SphereEmitter FACADE owns and strips before forwarding
478
+ * (`emissionCap`/`gateCounter`) — `worldCfg` projects out the four world knobs and
479
+ * ignores the rest BY DESIGN. Gating it on OPTIONS alone would reject the recipe the
480
+ * STUDIO first-contact rung is built from.
481
+ *
482
+ * `SphereEmitter.FACADE_OPTIONS` is the owner of that extra list, but the arrow runs
483
+ * object → setup, so requiring it here would be a cycle. Instead
484
+ * `setup-factory-option-vocabulary.test.mjs` §4 asserts this list equals
485
+ * `OPTIONS ∪ SphereEmitter.FACADE_OPTIONS` — a drift is a CI failure, not a mirror
486
+ * someone has to remember to update.
487
+ */
488
+ var SKETCH_OPTIONS = OPTIONS.concat(['emissionCap', 'gateCounter']);
489
+
490
+ /**
491
+ * PRESETS — the emission presets `applyPreset` knows, exported so the preset control
492
+ * and every conformance test consume ONE list instead of retyping it.
493
+ */
494
+ var PRESETS = ['spherical', 'hemisphere', 'laser', 'drum', 'particle-walk'];
495
+
496
+ /**
497
+ * RETIRED_OPTIONS — names this factory claims with its OWN migration message.
498
+ *
499
+ * Exported because a gate now sits ABOVE this call: `ObjectFactory.create` validates
500
+ * prop keys before the builder forwards them here, and a generic "unknown prop key
501
+ * 'skipInit'" would REPLACE the sentence that tells the author what to do instead.
502
+ * ObjectFactory consumes this list so a retired name reaches its owner; it is not
503
+ * a licence to accept the key — the throw below is what happens next.
504
+ */
505
+ var RETIRED_OPTIONS = ['skipInit'];
506
+
507
+ /** The world's live base layer, asked of its owner. A universe that cannot answer throws. */
508
+ function _worldFundamentalDimension(universe) {
509
+ var dim = universe.fundamentalDimension;
510
+ if (typeof dim !== 'number' || !isFinite(dim) || dim < 0 || Math.floor(dim) !== dim) {
511
+ throw new Error('setupSphereEmitter3D: the universe did not report its fundamental ' +
512
+ 'dimension (universe.fundamentalDimension = ' + String(dim) + '). An emitter that ' +
513
+ 'declares no fundamentalDimension/leafDimension mints at the world\'s own layer, read ' +
514
+ 'from EGPTUniverse.fundamentalDimension on an init\'d world.');
515
+ }
516
+ return dim;
517
+ }
518
+
519
+ function setupSphereEmitter3D(universe, options) {
520
+ options = options || {};
521
+ // ORDER IS LOAD-BEARING (the lesson `a1f1b324` control D pinned one layer down):
522
+ // the RETIRED name is claimed FIRST, so it keeps its specific migration message.
523
+ // Demoted to the generic unknown-key error it would lose the instruction that
524
+ // makes the hand-migration possible. The same reason RETIRED_OPTIONS is EXPORTED:
525
+ // ObjectFactory's prop-key gate sits ABOVE this call and would otherwise reject
526
+ // `skipInit` first, so it lets the retired names through to this message.
527
+ if (RETIRED_OPTIONS.indexOf('skipInit') !== -1 && 'skipInit' in options) {
528
+ throw new Error(
529
+ "setupSphereEmitter3D: 'skipInit' is DELETED. This factory constructs a PART and " +
530
+ 'never inits the world — the SKETCH does, once, before any part: ' +
531
+ 'setupSphereEmitter3D.initWorld(universe, opts). Remove skipInit; a second emitter ' +
532
+ 'on an already-init\'d universe needs no flag.'
533
+ );
534
+ }
535
+ options = _assertSetupOptions('setupSphereEmitter3D', options, OPTIONS);
536
+
537
+ // The world rect the co-init call wrote (EGPTUniverse.create({rect})) — the ONE size.
538
+ var canvas_rect = universe.universe_rect;
539
+ _requireWorldRect('setupSphereEmitter3D universe.universe_rect', canvas_rect);
540
+
541
+ var is2D = !!options.is2D;
542
+
543
+ // `fundamentalDimension` = the universe's BASE (lowest) dimension the SKETCH
544
+ // init'd the world at (`worldCfg().dimension`). Read here only to resolve
545
+ // `leafDimension` and the capacity clamp — never to init. The WORLD owns this fact:
546
+ // a caller that declares NEITHER dimension gets the universe's live fundamental, so a
547
+ // part dropped into a dimension-2 world mints its leaves at layer 2. A caller that
548
+ // declares either keeps its declaration exactly (an undeclared base beside a declared
549
+ // `leafDimension` stays 0).
550
+ var declaresDimension = options.fundamentalDimension !== undefined ||
551
+ options.leafDimension !== undefined;
552
+ var fundamentalDimension = options.fundamentalDimension !== undefined
553
+ ? options.fundamentalDimension
554
+ : (declaresDimension ? 0 : _worldFundamentalDimension(universe));
555
+
556
+ // `leafDimension` = the dimension THIS emitter births its leaves at (per-emitter
557
+ // — the CEO's corrected 2026-07-19 model: dimension is an ADDABLE scale layer, and
558
+ // an emitter creates leaves AT a chosen dimension via makeLeafFrame's explicit
559
+ // `level` + `addDimension` (the SAME pattern scenes/blackbody.js uses: init at a base
560
+ // fundamental, then addDimension for the higher scale layer). Default =
561
+ // fundamentalDimension → BYTE-IDENTICAL to every pre-existing caller (birthing at
562
+ // level=fundamentalDimension is identical to the old level=null, which defers to the
563
+ // universe fundamental; and leafDimension==fundamentalDimension adds no dimension).
564
+ // A leaf born ABOVE the base fundamental is move-gated (Invariant 6A: P=fullness →
565
+ // heavier/slower at fixed mass) AND has larger capacity (→ longer wavelength → redder)
566
+ // — that fullness-driven difference IS the intended per-dimension observable, NOT a
567
+ // bug. Multiple emitters at DIFFERENT leafDimensions coexist on one universe (each its
568
+ // own scale layer). Capacity clamp uses leafDimension (the leaves' ACTUAL capacity).
569
+ var leafDimension = options.leafDimension !== undefined
570
+ ? options.leafDimension : fundamentalDimension;
571
+ var maxWavelength = SustainedSource.capacityAtDimension(leafDimension);
572
+
573
+ // ── THE WORLD IS ALREADY INIT'D ─────────────────────────────────────────
574
+ // This factory CONSTRUCTS A PART. It does not init the world — the SKETCH does,
575
+ // BEFORE any part is constructed (world.init() FIRST → CONSTRUCT parts → WIRE →
576
+ // RUN). `setupSphereEmitter3D.initWorld(universe, options)` is the sketch step
577
+ // that derives this emitter's implied world from these same options; a sketch
578
+ // that owns its own world simply never calls it. There is no flag: an author who
579
+ // followed the pattern correctly does nothing extra, and a second emitter on a
580
+ // shared field is just a second constructor call.
581
+ // Declare this emitter's scale layer via addDimension (the Blackbody pattern —
582
+ // per-emitter dimension goes THROUGH addDimension and the ENGINE derives the
583
+ // fundamental). Under dynamic dimensions (merged 2026-07-19) the engine SELF-LOWERS
584
+ // the fundamental to min(allocated) on add_dimension — so declaring a LOWER layer
585
+ // (leafDimension < the init seed) self-lowers the fundamental to it, making those
586
+ // leaves AT-fundamental (light/fast) and re-basing previously-lower-fullness leaves
587
+ // to above-fundamental (slower). We therefore addDimension whenever leafDimension
588
+ // differs from the init seed (idempotent; safe for shared/re-declared layers). Equal
589
+ // to the seed adds nothing (the seed is already the fundamental at init) → BYTE-
590
+ // IDENTICAL to every pre-existing caller (which never passes leafDimension). NEVER
591
+ // re-set the fundamental manually — let add_dimension self-lower it.
592
+ if (leafDimension !== fundamentalDimension) {
593
+ universe.addDimension(leafDimension);
594
+ }
595
+
596
+ // ── Emission SOURCE POSITION (per-emitter — Bite 3, 2026-07-20) ──────────
597
+ // cx0/cy0/cz0 = the center this emitter seeds its leaves around (BOTH the
598
+ // volume box AND the surface ring launch are relative to it). Resolved from
599
+ // options.center (ABSOLUTE canvas px, wins) / options.offset (RELATIVE to
600
+ // canvas center); both omitted ⇒ canvas center → BYTE-IDENTICAL to every
601
+ // pre-existing caller (old cx0=floor(w/2), cy0=floor(h/2), cz0=0). Mutable
602
+ // (function-scoped) so control.setCenter can reposition LIVE (M5 — affects
603
+ // the NEXT emitted leaf). Pure sugar: only the makeLeafFrame seed pixel moves.
604
+ var _defCx = Math.floor(canvas_rect.w / 2);
605
+ var _defCy = Math.floor(canvas_rect.h / 2);
606
+ var cx0, cy0, cz0;
607
+ (function resolveCenter(center, offset) {
608
+ if (Array.isArray(center)) {
609
+ cx0 = Math.round(center[0]);
610
+ cy0 = Math.round(center[1]);
611
+ cz0 = (center.length > 2 && center[2] != null) ? Math.round(center[2]) : 0;
612
+ } else if (Array.isArray(offset)) {
613
+ cx0 = _defCx + Math.round(offset[0]);
614
+ cy0 = _defCy + Math.round(offset[1]);
615
+ cz0 = (offset.length > 2 && offset[2] != null) ? Math.round(offset[2]) : 0;
616
+ } else {
617
+ cx0 = _defCx; cy0 = _defCy; cz0 = 0;
618
+ }
619
+ })(options.center, options.offset);
620
+
621
+ // ── Live-mutable emission state (canonical live-knob pattern: closure
622
+ // state read fresh on every emitted leaf — a slider drag changes
623
+ // the NEXT burst; already-emitted leaves keep drifting on whatever
624
+ // velocity they were stamped with, M5). ──────────────────────────
625
+ var state = {
626
+ R: options.R !== undefined ? options.R : 24,
627
+ emissionBoxSide: 0,
628
+ mode: options.mode !== undefined ? options.mode : 'ball',
629
+ preset: options.preset !== undefined ? options.preset : 'spherical',
630
+ burstSize: 0, // derived below (preset-aware default) — placeholder here
631
+ // Per-leaf wavelength (mass=capacity/wavelength) — null = engine
632
+ // default mass=capacity, BYTE-IDENTICAL to every pre-existing
633
+ // caller. Clamped to [1,maxWavelength] whenever a number is set
634
+ // (feedback_mass_ceiling_saturation_wavelength_param.md).
635
+ wavelength: (options.wavelength !== undefined && options.wavelength !== null)
636
+ ? clamp(options.wavelength, 1, maxWavelength) : null,
637
+ // Per-leaf CHARGE mode (drives the transverse oscillation quadrant —
638
+ // opposite charges braid, which IS the self-interference structure).
639
+ // 'positive' (DEFAULT) = charge 0, BYTE-IDENTICAL to the pre-existing
640
+ // hardcoded 0 (no extra RNG draw, so every sun/laser/drum/particle-walk
641
+ // golden is untouched); 'negative' = charge 1 (also no draw); 'random'
642
+ // = per-leaf ±, exactly reproducing PointSource/emitterProgram.js's
643
+ // `chargeInt = universe.random() < 0.5 ? 0 : 1` (the ONE opt-in extra
644
+ // draw). Required to reproduce the Wave-Interference look from a single
645
+ // omni source (CEO 2026-07-18).
646
+ chargeMode: (options.chargeMode === 'random' || options.chargeMode === 'negative'
647
+ || options.chargeMode === 'positive') ? options.chargeMode : 'positive',
648
+ // Emission START-POSITION topology (WHERE a leaf is seeded, independent
649
+ // of its velocity direction). 'volume' (DEFAULT) = seed WITHIN the
650
+ // emissionBox area/cube (the pre-existing behavior — BYTE-IDENTICAL);
651
+ // 'surface' = seed ON THE SURFACE of a shell at radius `innerR` (a
652
+ // CIRCLE in 2D / a SPHERE SHELL in 3D). Surface mode reproduces the old
653
+ // PointSource "ring launch": single-occupancy forbids many quanta from
654
+ // starting on the EXACT same pixel, so PointSource seeds them around a
655
+ // ring — the correct single-source start topology (CEO 2026-07-18).
656
+ emissionShell: (options.emissionShell === 'surface') ? 'surface' : 'volume',
657
+ // Shell radius (px) for emissionShell:'surface'. Non-negative integer;
658
+ // 0 collapses the shell back to the center point (so 'surface' with
659
+ // innerR 0 == 'volume' with box 0 — a single pixel). Ignored entirely
660
+ // in 'volume' mode (no draw).
661
+ innerR: (options.innerR !== undefined) ? Math.max(0, Math.round(options.innerR)) : 0,
662
+ // Per-leaf RENDER COLOR OVERRIDE (render-intent DATA, NOT physics — CEO 2026-07-19).
663
+ // null (DEFAULT) → leaves are emitted COLORLESS (byte-identical to every pre-existing
664
+ // caller) so the renderer's colorScheme derives color from fullness (LightColor). An
665
+ // [r,g,b] / [r,g,b,a] array → FORCES that color: it is passed to makeLeafFrame's `color`
666
+ // arg, which the compat layer stamps as `string_map['render.color']` = CSV 'r,g,b,a' on
667
+ // the engine-OPAQUE frame-data bubble (owned by lib/shim/frame-data). The engine carries
668
+ // it verbatim and interprets NOTHING; the renderer deserializes render.color (which takes
669
+ // priority over colorScheme). So this forces the particle color INDEPENDENT of the
670
+ // wavelength→LightColor mapping with ZERO engine field touched. LIVE via
671
+ // control.setRenderColor (affects the NEXT emitted leaf, M5).
672
+ renderColor: Array.isArray(options.renderColor) ? options.renderColor.slice() : null,
673
+ // Per-leaf RENDER SHAPE OVERRIDE (render-intent DATA, NOT physics — Bite 5b A3).
674
+ // null (DEFAULT) → leaves emit with NO render.shape key (byte-identical to every
675
+ // pre-existing caller); the renderer falls back to the render-level "Default Shape"
676
+ // (setRenderOption) then 'ball'. A ball|box|circle|cube|sphere string is stamped as
677
+ // string_map['render.shape'] via makeLeafFrame's render-intent MERGE seam (peer of
678
+ // render.color) — the engine carries it opaque, the renderer deserializes (priority
679
+ // render.shape > opts.shape > default). LIVE via control.setRenderShape (next leaf, M5).
680
+ renderShape: (typeof options.renderShape === 'string' && options.renderShape) ? options.renderShape : null,
681
+ // EMITTER ORIENTATION (B8 pulsar, Collision Lab WAVE 3) — degrees, default 0.
682
+ // Unlike a WALL (whose cone is BAKED at the INITIAL orientation, immune to spin),
683
+ // the emitter tracks its CURRENT orientation: sampleVelocity rotates each sampled
684
+ // (vx,vy) about the z-axis by this angle, so LIVE-spinning a CONSTRAINED (laser)
685
+ // emitter SWEEPS its beam (a pulsar). Omni ('spherical') is rotationally invariant,
686
+ // so its output DISTRIBUTION is unchanged under rotation (isotropy). This is ALSO
687
+ // the AIM knob for the 'hemisphere' preset: that preset's forward axis is +X by
688
+ // construction, and this rotation — measure-preserving, applied AFTER sampling — is
689
+ // the ONE path that points the 180° fan anywhere else. SU1: at 0 the
690
+ // rotation is the identity (round(vx·1−vy·0)=vx), so every pre-existing golden is
691
+ // BYTE-IDENTICAL and NO extra RNG draw is made. LIVE via control.setOrientation.
692
+ orientationDeg: (typeof options.orientationDeg === 'number') ? options.orientationDeg : 0,
693
+ active: true
694
+ };
695
+ // GROUND-TRUTHED against setupParticleWalk.js / setupParticleWalk3D.js
696
+ // (CEO fullness-tuning pass, 2026-07-18 — read the actual source, not a
697
+ // paraphrase): both historical experiments use IDENTICAL construction —
698
+ // `sourceRect=[cx,cy,1,10]` (a height-10 vertical LINE, x-width=1, in
699
+ // BOTH 2D and 3D — there is no disk anywhere in the historical code;
700
+ // the earlier iteration's 3D 'disk' default was an unverified
701
+ // elaboration, corrected here), `burst_size=20`, `delay=0` (fires
702
+ // EVERY tick, continuously) — every other preset keeps its byte-
703
+ // identical defaults (box=0 point source, burst=40, rate=6). Measured
704
+ // steady-state density at these EXACT settings (pkg-node, 600x600
705
+ // canvas, wrap=false so quanta die at the far wall): alive count
706
+ // climbs 1000/2000/4000 at ticks 50/100/200 then PLATEAUS at exactly
707
+ // 5960 by tick~300 (little's-law steady state: 20/tick x ~298-tick
708
+ // transit time from x~302 to the x=599 wall) — this IS the CEO's
709
+ // "~5960 alive, the fuller sheet/beam" density target, reproduced
710
+ // here by matching the SAME three numbers, not by tuning against the
711
+ // number directly.
712
+ var PARTICLE_WALK_DEFAULT_SOURCE_SIZE = 10; // = old sourceRect height (ground-truthed)
713
+ var PARTICLE_WALK_DEFAULT_BURST_SIZE = 20; // = old burst_size (ground-truthed)
714
+ var PARTICLE_WALK_DEFAULT_RATE = 1; // = old delay=0 -> fires every tick (ground-truthed)
715
+ state.emissionBoxSide = clamp(
716
+ options.emissionBoxSide !== undefined ? options.emissionBoxSide
717
+ : (state.preset === 'particle-walk' ? PARTICLE_WALK_DEFAULT_SOURCE_SIZE : 0),
718
+ 0, state.R);
719
+ state.burstSize = options.burstSize !== undefined ? options.burstSize
720
+ : (state.preset === 'particle-walk' ? PARTICLE_WALK_DEFAULT_BURST_SIZE : 40);
721
+
722
+ // ── Per-axis emission SOURCE SHAPE (CEO Phase 1 iteration, 2026-07-18
723
+ // — the "drum emitter" shape: a flat line source, extending the
724
+ // scalar emissionBoxSide cube into an opt-in per-axis box).
725
+ // `explicitEmissionBox`/`explicitSourceShape` (options, FROZEN at
726
+ // construction) take priority over the preset-derived default —
727
+ // the common/default path (neither given) reduces EXACTLY to
728
+ // today's uniform cube for every pre-existing preset. ────────────
729
+ var explicitEmissionBox = options.emissionBox || null;
730
+ var explicitSourceShape = options.sourceShape !== undefined ? options.sourceShape : null;
731
+ function clampBox(v) { return clamp(v, 0, state.R); }
732
+ function deriveSourceShape() {
733
+ if (explicitEmissionBox) {
734
+ state.emissionBox = {
735
+ x: clampBox(explicitEmissionBox.x !== undefined ? explicitEmissionBox.x : 0),
736
+ y: clampBox(explicitEmissionBox.y !== undefined ? explicitEmissionBox.y : 0),
737
+ z: clampBox(explicitEmissionBox.z !== undefined ? explicitEmissionBox.z : 0)
738
+ };
739
+ state.sourceShape = explicitSourceShape || 'box';
740
+ return;
741
+ }
742
+ if (state.preset === 'particle-walk') {
743
+ // Flat vertical LINE in Y — x=z=0, IDENTICAL in 2D and 3D
744
+ // (ground-truthed: setupParticleWalk3D.js uses the SAME
745
+ // [cx,cy,1,10] sourceRect as the 2D version — the z-spread it
746
+ // shows is 100% EMERGENT from the wave-walk over ticks, never
747
+ // from an initial disk placement; is2D only toggles whether
748
+ // z's wave-walk fires at all, via activeAxes). A true 'disk'
749
+ // shape stays available as an explicit opt-in
750
+ // (`options.sourceShape:'disk'`) for anyone who wants one —
751
+ // it's just no longer this preset's SILENT default.
752
+ state.emissionBox = { x: 0, y: clampBox(state.emissionBoxSide), z: 0 };
753
+ state.sourceShape = explicitSourceShape || 'box';
754
+ } else {
755
+ // Byte-identical to every pre-existing preset: a uniform cube.
756
+ var b = clampBox(state.emissionBoxSide);
757
+ state.emissionBox = { x: b, y: b, z: b };
758
+ state.sourceShape = explicitSourceShape || 'box';
759
+ }
760
+ }
761
+ deriveSourceShape();
762
+
763
+ var emitEveryTicks = options.emitEveryTicks !== undefined ? options.emitEveryTicks
764
+ : (state.preset === 'particle-walk' ? PARTICLE_WALK_DEFAULT_RATE : 6);
765
+
766
+ // Per-axis velocity RANGES — the unification: 'spherical'/'laser'/'drum'
767
+ // are the SAME sampler over different ranges, derived from R + preset.
768
+ // A custom range set via control.setAxisRange survives until the next
769
+ // setR()/setPreset() call (which re-derives all three from scratch).
770
+ var ranges = { vx: [0, 0], vy: [0, 0], vz: [0, 0] };
771
+ function applyPreset(name, R) {
772
+ if (name === 'laser') {
773
+ var narrow = Math.max(1, Math.round(R * 0.15));
774
+ var fwd = [Math.round(R * 0.6), R];
775
+ if (is2D) {
776
+ // 2D: forward axis is +x (the historical particle-walk
777
+ // rightward convention) — is2D already forces vz=0
778
+ // unconditionally in sampleVelocity, so vz's range here is
779
+ // never sampled; set to [0,0] only for internal consistency.
780
+ ranges.vx = fwd;
781
+ ranges.vy = [-narrow, narrow];
782
+ ranges.vz = [0, 0];
783
+ } else {
784
+ // 3D (unchanged): forward axis is +z (view with the
785
+ // 'down-barrel' camera preset).
786
+ ranges.vx = [-narrow, narrow];
787
+ ranges.vy = [-narrow, narrow];
788
+ ranges.vz = fwd;
789
+ }
790
+ } else if (name === 'hemisphere') {
791
+ // FORWARD 180° emission (D1, CEO 2026-07-28) — the ONE line that
792
+ // differs from 'spherical': the forward axis' range is HALVED to
793
+ // [0,R]. Everything else (the ball rejection below, the isotropy
794
+ // correction) is shared, which is the whole point: a uniform ball
795
+ // conditioned on vx>=0 IS a uniform half-ball, so this is the
796
+ // spherical distribution restricted to the forward hemisphere, not
797
+ // a different sampler. Forward axis is +X in 2D AND 3D; aim it with
798
+ // `orientationDeg` (the existing, measure-preserving post-sample
799
+ // rotation) — never a second rotation path.
800
+ ranges.vx = [0, R];
801
+ ranges.vy = [-R, R];
802
+ ranges.vz = [-R, R];
803
+ } else if (name === 'drum') {
804
+ ranges.vx = [-R, R];
805
+ ranges.vy = [-R, R];
806
+ ranges.vz = [0, 0];
807
+ } else if (name === 'particle-walk') {
808
+ // Faithful reproduction (CEO Phase 1 iteration): PURE-FORWARD
809
+ // +X momentum, ALWAYS (not is2D-branched like 'laser' — both
810
+ // historical experiments, 2D and 3D, used vx=1,vy=0 literally).
811
+ // vy=vz PINNED to 0 — the waviness comes entirely from the
812
+ // transverse wave-walk (a separate mechanism), never from
813
+ // momentum divergence (the 'laser' preset's diverging-cone gap
814
+ // this preset exists to close).
815
+ ranges.vx = [R, R];
816
+ ranges.vy = [0, 0];
817
+ ranges.vz = [0, 0];
818
+ } else { // 'spherical' (default sun)
819
+ ranges.vx = [-R, R];
820
+ ranges.vy = [-R, R];
821
+ ranges.vz = [-R, R];
822
+ }
823
+ }
824
+ applyPreset(state.preset, state.R);
825
+
826
+ // Integer velocity sample from the current per-axis ranges + R (frame.rs's
827
+ // l2_walk_probs normalizes by |v| — the RATIO is all that matters; speed is
828
+ // always 1px/tick, M1). universe.random() is the engine's own deterministic
829
+ // Mulberry32 stream (G-EMIT primitive) — same stream physics draws from, so a
830
+ // fixed seed is fully reproducible.
831
+ //
832
+ // REJECTION IS PRESET-AWARE (CEO fix, 2026-07-18) — the rejection criterion
833
+ // must match each preset's INTENDED SHAPE, not a single one-size ball:
834
+ // 'spherical': reject to the BALL |v|<=R (rounds the sampled cube into a
835
+ // sphere of directions — the mandatory isotropy correction, S4/S5).
836
+ // 'hemisphere': the SAME ball rejection, applied to the half-range box
837
+ // (vx in [0,R]) — a uniform ball conditioned on vx>=0 is a uniform
838
+ // HALF-ball, so the isotropy correction carries over UNCHANGED. Keeping
839
+ // it here is load-bearing: dropping the clip (i.e. treating this as a
840
+ // 'directional' preset) turns the forward fan into the measured 1.83:1
841
+ // diagonal-weighted box draw. It is therefore deliberately NOT in the
842
+ // `isDirectional` set below.
843
+ // 'drum': vz is pinned to [0,0] by applyPreset, so the SAME ball-rejection
844
+ // test collapses to a DISK rejection in the z=0 plane for free (no
845
+ // separate branch needed — z contributes 0 to m2 unconditionally).
846
+ // 'laser': its shape IS the narrow forward-biased range itself — vz alone
847
+ // already sits near R (0.6R..R), so ANY nonzero vx/vy pushes |v| over R
848
+ // and a ball-rejection test against that SAME R starves the sampler
849
+ // (near-100% rejection -> the maxTries safety net firing on every draw,
850
+ // producing a messy, non-uniform "X" instead of a clean beam). A
851
+ // directional preset's own range bounds are ALREADY its shape — applying
852
+ // a further ball clip on top is a category error (double-constraining the
853
+ // same shape with an incompatible radius). FIX: skip magnitude rejection
854
+ // entirely for 'laser' (and any future purely-directional preset) — only
855
+ // the "no exact zero vector" resample guard applies. Any custom range set
856
+ // via setAxisRange while preset stays 'spherical'/'drum' still gets
857
+ // ball/disk-rejected; switch preset to 'laser' (or add a future directional
858
+ // preset) to opt a custom shape out of magnitude rejection.
859
+ // 'particle-walk': same category as 'laser' — vx=[R,R] alone already sits
860
+ // at exactly R, so a ball-rejection test against that SAME R starves the
861
+ // sampler identically. Directional, no magnitude clip.
862
+ // B8 pulsar: rotate a sampled (vx,vy) about the z-axis by the emitter's CURRENT
863
+ // orientation (2D yaw). Integer-round after rotation (the sampler yields integer
864
+ // components; frame.rs normalizes by |v| so the RATIO is what matters — M1). At
865
+ // orientation 0 this is the IDENTITY (cos0=1, sin0=0 → round(vx)=vx), so the default
866
+ // path is BYTE-IDENTICAL and draws no RNG. vz (3D forward for the 'laser' 3D preset)
867
+ // is untouched — the 2D pulsar sweeps the in-plane beam; a 3D beam yaws about vertical.
868
+ function rotateVelocity2D(v) {
869
+ var deg = state.orientationDeg || 0;
870
+ if (!deg) { return v; } // identity fast-path (default) — byte-identical
871
+ var rad = deg * Math.PI / 180;
872
+ var c = Math.cos(rad), s = Math.sin(rad);
873
+ return [Math.round(v[0] * c - v[1] * s), Math.round(v[0] * s + v[1] * c), v[2]];
874
+ }
875
+ function sampleVelocity() { return rotateVelocity2D(sampleVelocityCore()); }
876
+ function sampleVelocityCore() {
877
+ var R = state.R;
878
+ var isDirectional = (state.preset === 'laser' || state.preset === 'particle-walk'); // shape = the range itself, not a ball
879
+ var maxTries = 500;
880
+ var vx = 0, vy = 0, vz = 0;
881
+ for (var t = 0; t < maxTries; t++) {
882
+ vx = Math.round(ranges.vx[0] + universe.random() * (ranges.vx[1] - ranges.vx[0]));
883
+ vy = Math.round(ranges.vy[0] + universe.random() * (ranges.vy[1] - ranges.vy[0]));
884
+ vz = is2D ? 0 : Math.round(ranges.vz[0] + universe.random() * (ranges.vz[1] - ranges.vz[0]));
885
+ var m2 = vx * vx + vy * vy + vz * vz;
886
+ if (m2 === 0) continue; // no direction — resample (applies to every preset)
887
+ if (isDirectional) return [vx, vy, vz]; // range IS the shape — no magnitude clip
888
+ if (state.mode === 'shell') {
889
+ var lo = 0.9 * R;
890
+ if (m2 <= R * R && m2 >= lo * lo) return [vx, vy, vz];
891
+ } else {
892
+ if (m2 <= R * R) return [vx, vy, vz];
893
+ }
894
+ }
895
+ return [vx, vy, vz]; // safety net (misconfigured ranges) — best available sample
896
+ }
897
+
898
+ // Per-leaf CHARGE (0=+, 1=-) — drives the transverse-oscillation quadrant
899
+ // (compute_spin: `(remainder/(wl/4) + charge) % 4`), so opposite charges
900
+ // braid, which IS the emergent self-interference structure. Reproduces
901
+ // emitterProgram.js/PointSource EXACTLY: 'random' draws ONE universe.random()
902
+ // per leaf (the same G-EMIT draw the composite emitter makes); 'positive'/
903
+ // 'negative' are deterministic (NO draw) so the default 'positive' path is
904
+ // byte-identical to the pre-existing hardcoded 0.
905
+ // MUST draw in JS and pass an integer 0/1 — passing null coerces to 0 at the
906
+ // wasm-bindgen ABI, NOT a random-draw sentinel (reference_wasm_null_charge_
907
+ // coercion_bug.md), so `chargeMode:'random'` cannot be delegated to makeLeafFrame.
908
+ function sampleCharge() {
909
+ if (state.chargeMode === 'random') { return (universe.random() < 0.5) ? 0 : 1; }
910
+ return (state.chargeMode === 'negative') ? 1 : 0;
911
+ }
912
+
913
+ function sampleOffsetAxis(boxSide) {
914
+ if (boxSide <= 0) return 0;
915
+ return -boxSide / 2 + universe.random() * boxSide;
916
+ }
917
+
918
+ // Circular rejection in the Y-Z plane (the transverse cross-section
919
+ // perpendicular to 'particle-walk''s +X forward axis — the CEO's
920
+ // "circular disk source (3D)"). Mirrors sampleVelocity's own
921
+ // ball-rejection idiom, applied to POSITION instead of direction.
922
+ // Only ever called when state.sourceShape==='disk' (opt-in — never
923
+ // the default path for spherical/laser/drum).
924
+ function sampleDiskOffsetYZ(diameter) {
925
+ var r = diameter / 2;
926
+ if (r <= 0) return [0, 0];
927
+ var maxTries = 200;
928
+ for (var t = 0; t < maxTries; t++) {
929
+ var dy = -r + universe.random() * 2 * r;
930
+ var dz = -r + universe.random() * 2 * r;
931
+ if (dy * dy + dz * dz <= r * r) return [dy, dz];
932
+ }
933
+ return [0, 0]; // safety net (misconfigured box)
934
+ }
935
+
936
+ // Emission SHELL SURFACE start-position — VELOCITY-ALIGNED (CEO 2026-07-18).
937
+ // The old-PointSource "ring launch" seeds each particle ON the ring AT THE
938
+ // ANGLE OF ITS OWN VELOCITY, so its start pixel is COLLINEAR with its outward
939
+ // trajectory (the CEO's "pixel position is the integer-scaled momentum
940
+ // vector"). This is what gives a single source an EMPTY CENTER and razor-
941
+ // sharp bands: every particle projects radially outward and NONE cuts across
942
+ // the inner disk. Draws NOTHING itself — it is a deterministic function of
943
+ // the ALREADY-SAMPLED velocity `v` (which is why emitOneLeaf samples velocity
944
+ // FIRST in surface mode): start = round(innerR × unit(v)). 2D uses (vx,vy);
945
+ // 3D uses the full (vx,vy,vz) on the sphere shell. Degenerate |v|=0 → center
946
+ // (sampleVelocity never returns the zero vector — it resamples m2===0 — so
947
+ // this is only a safety fallback). NOT the old independent-random-angle seed,
948
+ // which let position and velocity diverge and blurred the bands.
949
+ function shellOffsetFromVelocity(v, radius) {
950
+ if (radius <= 0) return [0, 0, 0];
951
+ var vx = v[0], vy = v[1], vz = is2D ? 0 : v[2];
952
+ var mag = Math.sqrt(vx * vx + vy * vy + vz * vz);
953
+ if (mag < 1e-9) return [0, 0, 0]; // |v|=0 → center (safety; never hit)
954
+ var s = radius / mag;
955
+ return [Math.round(vx * s), Math.round(vy * s), is2D ? 0 : Math.round(vz * s)];
956
+ }
957
+
958
+ // Emission POSITION sampler for the VOLUME modes only ('box' default, per-axis
959
+ // independent, byte-identical to the uniform-cube behavior when all three axes
960
+ // are equal; or 'disk', 'particle-walk' 3D only). Draw order (x, then y/z)
961
+ // matches the pre-per-axis code — same RNG stream. (SURFACE mode does NOT go
962
+ // through here — its position is derived from velocity in emitOneLeaf.)
963
+ function sampleSourcePosition() {
964
+ var box = state.emissionBox;
965
+ var ox = sampleOffsetAxis(box.x);
966
+ if (state.sourceShape === 'disk') {
967
+ var yz = sampleDiskOffsetYZ(Math.max(box.y, box.z));
968
+ return [ox, yz[0], is2D ? 0 : yz[1]];
969
+ }
970
+ var oy = sampleOffsetAxis(box.y);
971
+ var oz = is2D ? 0 : sampleOffsetAxis(box.z);
972
+ return [ox, oy, oz];
973
+ }
974
+
975
+ function emitOneLeaf() {
976
+ var v, ox, oy, oz;
977
+ if (state.emissionShell === 'surface') {
978
+ // VELOCITY-ALIGNED ring launch: sample velocity FIRST, then place the
979
+ // start pixel on the shell AT the velocity's own angle (position is a
980
+ // deterministic function of v, drawing nothing). This makes the start
981
+ // pixel collinear with the outward trajectory — empty center, sharp
982
+ // bands. The velocity-first order applies ONLY to surface mode, so the
983
+ // 'volume' path below keeps its exact position-then-velocity draw
984
+ // stream (byte-identical for every pre-existing box>0 caller).
985
+ v = sampleVelocity();
986
+ var spos = shellOffsetFromVelocity(v, state.innerR);
987
+ ox = spos[0]; oy = spos[1]; oz = spos[2];
988
+ } else {
989
+ var pos = sampleSourcePosition();
990
+ ox = pos[0]; oy = pos[1]; oz = pos[2];
991
+ v = sampleVelocity();
992
+ }
993
+ var chg = sampleCharge();
994
+ var lx = cx0 + ox, ly = cy0 + oy, lz = cz0 + oz;
995
+ // level=leafDimension -> birth this leaf AT this emitter's chosen dimension
996
+ // directly (capacity=(2^leafDimension)², confirmed birth-at-dimension, NOT
997
+ // birth-at-fundamental-then-promote). BYTE-IDENTICAL to the old level=null when
998
+ // leafDimension === the universe fundamental (both resolve to the fundamental).
999
+ // wavelength=state.wavelength -- null by default (mass=capacity, byte-identical);
1000
+ // a number sets mass=max(1,capacity/wavelength) for the NEXT emitted leaf (M5:
1001
+ // already-emitted leaves keep whatever mass they were stamped with).
1002
+ // 10th arg (Bite 5b A3) = per-leaf render.shape override (null default → no key →
1003
+ // byte-identical). Rides the SAME provider render-intent MERGE seam as color.
1004
+ var h = universe.makeLeafFrame(lx, ly, leafDimension, state.wavelength, v[0], v[1], false, chg, state.renderColor, state.renderShape);
1005
+ if (h === null) { return; } // liveQuantaCap refused the add: no leaf, nothing to stamp
1006
+ universe.injectFrame(h);
1007
+ var q = universe.getFrameQuantumId(h);
1008
+ universe.setFrameProperty(q, 'z', lz);
1009
+ universe.setFrameProperty(q, 'vz', v[2]);
1010
+ universe.setFrameProperty(q, 'vx', v[0]);
1011
+ universe.setFrameProperty(q, 'vy', v[1]);
1012
+ }
1013
+
1014
+ function emitBurst() {
1015
+ for (var i = 0; i < state.burstSize; i++) { emitOneLeaf(); }
1016
+ }
1017
+
1018
+ var _tick = 0;
1019
+ var _tickFn = function () {
1020
+ if (state.active && (_tick % emitEveryTicks === 0)) { emitBurst(); }
1021
+ _tick++;
1022
+ };
1023
+ universe.experimentTickFunctions.push(_tickFn);
1024
+
1025
+ // ── Live sugar control handle (the canonical knob pattern) ──────────────
1026
+ // The Design marker this emitter's facade owns (attachMarker), or null; and the ONE place
1027
+ // that derives where it is drawn from the emission centre.
1028
+ var marker = null;
1029
+ function placeMarker() {
1030
+ var D = marker.rect.w;
1031
+ marker.setRect([cx0 - D / 2, cy0 - D / 2, cz0 - D / 2, D, D, D]);
1032
+ }
1033
+ var control = {
1034
+ // INVARIANT (CEO fix, 2026-07-18 — "the clamp bug"): emissionBoxSide is
1035
+ // NEVER larger than the CURRENT R. Construction clamps it once; setR()
1036
+ // is the ONLY runtime path that can widen the gap (R shrinking below an
1037
+ // already-set box), so it MUST re-clamp every time — this is the single
1038
+ // authority both `sampleOffset` (physics) and the page's box slider (UI,
1039
+ // via each control descriptor's `get()`, see below) read back from.
1040
+ setR: function (v) {
1041
+ state.R = v;
1042
+ state.emissionBoxSide = clamp(state.emissionBoxSide, 0, state.R);
1043
+ applyPreset(state.preset, state.R);
1044
+ deriveSourceShape();
1045
+ },
1046
+ getR: function () { return state.R; },
1047
+ setEmissionBoxSide: function (v) {
1048
+ state.emissionBoxSide = clamp(v, 0, state.R);
1049
+ deriveSourceShape();
1050
+ },
1051
+ getEmissionBoxSide: function () { return state.emissionBoxSide; },
1052
+ getEmissionBox: function () { return { x: state.emissionBox.x, y: state.emissionBox.y, z: state.emissionBox.z }; },
1053
+ getSourceShape: function () { return state.sourceShape; },
1054
+ setMode: function (v) { state.mode = (v === 'shell') ? 'shell' : 'ball'; },
1055
+ getMode: function () { return state.mode; },
1056
+ setPreset: function (v) {
1057
+ state.preset = v;
1058
+ applyPreset(v, state.R);
1059
+ deriveSourceShape();
1060
+ },
1061
+ getPreset: function () { return state.preset; },
1062
+ setAxisRange: function (axis, lo, hi) { ranges[axis] = [lo, hi]; },
1063
+ getAxisRange: function (axis) { return ranges[axis].slice(); },
1064
+ setBurstSize: function (v) { state.burstSize = Math.max(1, Math.round(v)); },
1065
+ getBurstSize: function () { return state.burstSize; },
1066
+ setEmitEveryTicks: function (v) { emitEveryTicks = Math.max(1, Math.round(v)); },
1067
+ getEmitEveryTicks: function () { return emitEveryTicks; },
1068
+ setActive: function (v) { state.active = !!v; },
1069
+ getIs2D: function () { return is2D; },
1070
+ // Emission SOURCE POSITION (Bite 3, 2026-07-20) — reposition this emitter's
1071
+ // center LIVE (affects the NEXT emitted leaf, M5). Absolute canvas px. z
1072
+ // optional (retains the current z when omitted). Pure sugar — only the
1073
+ // makeLeafFrame seed pixel moves; the ring launch stays relative to it.
1074
+ setCenter: function (x, y, z) {
1075
+ cx0 = Math.round(x); cy0 = Math.round(y);
1076
+ if (z !== undefined && z !== null) { cz0 = Math.round(z); }
1077
+ // THE follow: every centre write ends here, so the Design marker (when this
1078
+ // emitter owns one) is re-placed here — explicit and z-capable, never by a caller.
1079
+ if (marker) { placeMarker(); }
1080
+ },
1081
+ /**
1082
+ * attachMarker(lo) — hand this emitter's Design marker (a z-capable D×D×D NONE-collision
1083
+ * LO, D = its own width) to the centre write: from now on every setCenter re-places it
1084
+ * at [cx−D/2, cy−D/2, cz−D/2, D, D, D] through its own LargeObject.setRect.
1085
+ */
1086
+ attachMarker: function (lo) {
1087
+ if (!lo || !lo.rect || typeof lo.rect.z !== 'number') {
1088
+ throw new TypeError('[setupSphereEmitter3D.attachMarker] the marker must be a z-capable LO ' +
1089
+ '(constructed with a 6-element rect) — it follows the centre in x, y AND z. (FAIL-LOUD.)');
1090
+ }
1091
+ marker = lo;
1092
+ placeMarker();
1093
+ },
1094
+ getCenter: function () { return [cx0, cy0, cz0]; },
1095
+ // Per-leaf charge mode (LIVE — affects the NEXT emitted leaf, M5).
1096
+ // 'positive'|'negative'|'random'; 'random' = per-leaf ± (braiding).
1097
+ setChargeMode: function (v) {
1098
+ state.chargeMode = (v === 'random' || v === 'negative' || v === 'positive') ? v : state.chargeMode;
1099
+ },
1100
+ getChargeMode: function () { return state.chargeMode; },
1101
+ // Emission START-POSITION topology (LIVE — affects the NEXT leaf, M5).
1102
+ setEmissionShell: function (v) { state.emissionShell = (v === 'surface') ? 'surface' : 'volume'; },
1103
+ getEmissionShell: function () { return state.emissionShell; },
1104
+ setInnerR: function (v) { state.innerR = Math.max(0, Math.round(v)); },
1105
+ getInnerR: function () { return state.innerR; },
1106
+ // r0/r1 unique-rational-wavelength demo knob (CLAUDE.md §6 fork —
1107
+ // see Layer 2 physics note above). v=1 is physically IDENTICAL to
1108
+ // the null default (mass=max(1,capacity/1)=capacity either way) —
1109
+ // this control operates entirely in explicit-numeric space; there
1110
+ // is no semantic loss vs. the true null-default path a caller who
1111
+ // never touches this control takes.
1112
+ setWavelength: function (v) { state.wavelength = clamp(Math.round(v), 1, maxWavelength); },
1113
+ getWavelength: function () { return state.wavelength !== null ? state.wavelength : 1; },
1114
+ getMaxWavelength: function () { return maxWavelength; },
1115
+ getFundamentalDimension: function () { return fundamentalDimension; },
1116
+ // The dimension THIS emitter births leaves at (per-emitter — CEO 2026-07-19).
1117
+ getLeafDimension: function () { return leafDimension; },
1118
+ // LIVE "Emit Dimension" (Bite 5b A2). Host-side addDimension (idempotent) +
1119
+ // closure-var flip: the NEXT emitted leaf is born at the new dimension (M5, exactly
1120
+ // like wavelength/charge). Theorist CLEARED mid-run addDimension as SAFE (2026-07-22):
1121
+ // do_tick snapshots the dim key set fresh each tick, so a between-ticks add is atomic;
1122
+ // promotion routes live. SIDE EFFECT (S1): a value BELOW the current fundamental
1123
+ // self-lowers the floor UNIVERSE-GLOBALLY and is a ONE-WAY RATCHET (no removeDimension)
1124
+ // — the engine re-bases previously-lower-fullness leaves M1→M2 (alive count conserved).
1125
+ // maxWavelength (the wavelength clamp) tracks the leaves' ACTUAL capacity, so recompute.
1126
+ setLeafDimension: function (n) {
1127
+ var v = Math.max(0, Math.round(n));
1128
+ if (v === leafDimension) { return leafDimension; }
1129
+ // addDimension whenever it differs from the current fundamental (idempotent; the
1130
+ // engine self-lowers the fundamental to min(allocated) — the ratchet). Equal to
1131
+ // the fundamental adds nothing (already the base).
1132
+ if (v !== fundamentalDimension && typeof universe.addDimension === 'function') {
1133
+ universe.addDimension(v);
1134
+ }
1135
+ leafDimension = v;
1136
+ maxWavelength = SustainedSource.capacityAtDimension(leafDimension);
1137
+ // Re-clamp any live wavelength to the new capacity ceiling.
1138
+ if (state.wavelength !== null) { state.wavelength = clamp(state.wavelength, 1, maxWavelength); }
1139
+ return leafDimension;
1140
+ },
1141
+ // Per-leaf RENDER COLOR OVERRIDE (render-intent, LIVE — affects the NEXT leaf, M5).
1142
+ // [r,g,b]/[r,g,b,a] forces string_map['render.color']; null = colorless (scheme-driven).
1143
+ setRenderColor: function (rgb) { state.renderColor = Array.isArray(rgb) ? rgb.slice() : null; },
1144
+ getRenderColor: function () { return state.renderColor ? state.renderColor.slice() : null; },
1145
+ // Per-leaf RENDER SHAPE OVERRIDE (render-intent, LIVE — affects the NEXT leaf, M5).
1146
+ // A ball|box|circle|cube|sphere string forces string_map['render.shape']; null =
1147
+ // fall back to the render-level "Default Shape" (setRenderOption). Zero physics.
1148
+ setRenderShape: function (shape) {
1149
+ state.renderShape = (typeof shape === 'string' && shape) ? shape : null;
1150
+ },
1151
+ getRenderShape: function () { return state.renderShape || null; },
1152
+ // B8 pulsar orientation — LIVE (affects the NEXT sampled leaf, M5). Normalized to
1153
+ // [0,360). Spinning a constrained (laser) emitter sweeps the beam; omni unaffected.
1154
+ setOrientation: function (deg) {
1155
+ var d = (typeof deg === 'number' && isFinite(deg)) ? deg : 0;
1156
+ state.orientationDeg = ((d % 360) + 360) % 360;
1157
+ },
1158
+ getOrientation: function () { return state.orientationDeg || 0; },
1159
+ dispose: function () {
1160
+ var i = universe.experimentTickFunctions.indexOf(_tickFn);
1161
+ if (i !== -1) { universe.experimentTickFunctions.splice(i, 1); }
1162
+ }
1163
+ };
1164
+
1165
+ // Standardized live knobs (runner.experimentControls-compatible — the SAME
1166
+ // shape coneEmitterControl.controls uses). Every
1167
+ // descriptor carries a `get()` accessor (CEO fix, 2026-07-18) so a page can
1168
+ // generically RE-SYNC every slider's displayed value/label from authoritative
1169
+ // state after ANY control fires — not just the one the user dragged. This is
1170
+ // what makes the emissionBoxSide<=R clamp VISIBLE live: `emissionBoxSide`
1171
+ // additionally carries `maxRef:'R'`, telling the page which OTHER control's
1172
+ // current value must drive its slider's `max` attribute (so the box slider
1173
+ // can never be dragged past the live R in the UI, not just internally).
1174
+ control.controls = [
1175
+ {
1176
+ id: 'R', label: 'R (radius / blockiness)', min: 2, max: 40, step: 1, value: state.R,
1177
+ fmt: function (v) { return String(v); },
1178
+ get: function () { return control.getR(); },
1179
+ onChange: function (v) { control.setR(v); }
1180
+ },
1181
+ {
1182
+ id: 'emissionBoxSide', label: 'emission box', min: 0, max: 40, step: 1, value: state.emissionBoxSide,
1183
+ maxRef: 'R', // this slider's live max = the CURRENT value of the 'R' control
1184
+ fmt: function (v) { return String(v); },
1185
+ get: function () { return control.getEmissionBoxSide(); },
1186
+ onChange: function (v) { control.setEmissionBoxSide(v); }
1187
+ },
1188
+ {
1189
+ id: 'preset', label: 'preset', kind: 'select', options: PRESETS.slice(),
1190
+ value: state.preset,
1191
+ fmt: function (v) { return String(v); },
1192
+ get: function () { return control.getPreset(); },
1193
+ onChange: function (v) { control.setPreset(v); }
1194
+ },
1195
+ {
1196
+ id: 'mode', label: 'mode', kind: 'select', options: ['ball', 'shell'], value: state.mode,
1197
+ fmt: function (v) { return String(v); },
1198
+ get: function () { return control.getMode(); },
1199
+ onChange: function (v) { control.setMode(v); }
1200
+ },
1201
+ {
1202
+ id: 'burstSize', label: 'burst size', min: 1, max: 200, step: 1, value: state.burstSize,
1203
+ fmt: function (v) { return String(v); },
1204
+ get: function () { return control.getBurstSize(); },
1205
+ onChange: function (v) { control.setBurstSize(v); }
1206
+ },
1207
+ {
1208
+ id: 'emitEveryTicks', label: 'rate (ticks/burst)', min: 1, max: 30, step: 1, value: emitEveryTicks,
1209
+ fmt: function (v) { return String(v); },
1210
+ get: function () { return control.getEmitEveryTicks(); },
1211
+ onChange: function (v) { control.setEmitEveryTicks(v); }
1212
+ },
1213
+ {
1214
+ // r0/r1 demo: dials the NEXT emitted leaf's mass (=capacity/wavelength),
1215
+ // hence its transverse oscillation period — SU1 no-op (max=1) until
1216
+ // `fundamentalDimension` is raised (see Layer 1/2 docs above).
1217
+ id: 'wavelength', label: 'wavelength/mass (λ, capacity=' + maxWavelength + ')',
1218
+ min: 1, max: maxWavelength, step: 1, value: control.getWavelength(),
1219
+ fmt: function (v) { return String(v); },
1220
+ get: function () { return control.getWavelength(); },
1221
+ onChange: function (v) { control.setWavelength(v); }
1222
+ }
1223
+ ];
1224
+
1225
+ return {
1226
+ sphereEmitterControl: control,
1227
+ largeObjects: [],
1228
+ // TOP-LEVEL dispose (STACKING-BUG FIX, CEO 2026-07-19): FrqtlApp.setup()
1229
+ // tears down the PREVIOUS setupResult by calling `setupResult.dispose()` IF it
1230
+ // exists (canonical teardown, FrqtlApp.js) — but this factory previously
1231
+ // exposed dispose ONLY on `sphereEmitterControl`, so the runner's teardown check
1232
+ // found no top-level dispose and the old emitter's host tick-fn was NEVER removed
1233
+ // → a dimension change / re-setup STACKED emitters (multiple tick-fns emitting at
1234
+ // once). Delegating a top-level dispose to the control's dispose makes the runner's
1235
+ // canonical teardown remove this emitter's tick-fn before the next setup registers
1236
+ // the new one — no stacking. Idempotent (control.dispose splices by indexOf; a
1237
+ // second call no-ops), so removeObject()/double-dispose is safe.
1238
+ dispose: function () { control.dispose(); }
1239
+ };
1240
+ }
1241
+
1242
+ // ONE authority for "which fundamental dimension the r0/r1 wavelength demo
1243
+ // uses" — matches setupTunnelLab.FUNDAMENTAL_DIMENSION / setupLightSourceLab
1244
+ // .FUNDAMENTAL_DIMENSION's own value (2) exactly ("MATCHED CONTROLS ->
1245
+ // MATCHED PHYSICS"). The page reads THIS instead of hand-typing "2".
1246
+ setupSphereEmitter3D.WAVELENGTH_DEMO_DIMENSION = 2;
1247
+
1248
+ // The SKETCH step (see the functions above): the world this emitter's options imply,
1249
+ // as data (`worldCfg`) and as the init call (`initWorld`). A part never inits.
1250
+ setupSphereEmitter3D.worldCfg = worldCfg;
1251
+ setupSphereEmitter3D.initWorld = initWorld;
1252
+
1253
+ setupSphereEmitter3D.OPTIONS = OPTIONS;
1254
+ setupSphereEmitter3D.PRESETS = PRESETS;
1255
+ setupSphereEmitter3D.SKETCH_OPTIONS = SKETCH_OPTIONS;
1256
+ setupSphereEmitter3D.RETIRED_OPTIONS = RETIRED_OPTIONS;
1257
+
1258
+ export { setupSphereEmitter3D };
1259
+