@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.
- package/CLAUDE.md +87 -0
- package/README.md +84 -2
- package/dist/dist-manifest.json +218 -0
- package/dist/eyes/index.js +109 -0
- package/dist/fat/pkg-node/egpt_wasm.js +1277 -0
- package/dist/fat/pkg-node/egpt_wasm_bg.wasm +0 -0
- package/dist/fat/pkg-node/package.json +12 -0
- package/dist/frqtl/assets/vendor/chroma.mjs +64 -0
- package/dist/frqtl/compat/boot-node.js +34 -0
- package/dist/frqtl/compat/engine.js +127 -0
- package/dist/frqtl/compat/package-root.js +24 -0
- package/dist/frqtl/compat/providers/wasm-engine.js +2501 -0
- package/dist/frqtl/compat/providers/wasm-load-browser.js +134 -0
- package/dist/frqtl/compat/providers/wasm-load-node.js +59 -0
- package/dist/frqtl/compat/renderer/FieldBuilder.js +145 -0
- package/dist/frqtl/compat/renderer/Filters.js +163 -0
- package/dist/frqtl/compat/renderer/RenderFamily.js +85 -0
- package/dist/frqtl/compat/renderer/RenderMesh.js +100 -0
- package/dist/frqtl/compat/renderer/RenderShape.js +154 -0
- package/dist/frqtl/compat/renderer/camera2d-geometry.js +183 -0
- package/dist/frqtl/compat/renderer/captureStill-common.js +259 -0
- package/dist/frqtl/compat/renderer/display-slot.js +96 -0
- package/dist/frqtl/compat/renderer/lo-drawn-center.js +231 -0
- package/dist/frqtl/compat/renderer/orbit-bag.js +216 -0
- package/dist/frqtl/compat/renderer/providers/godrays-layer.js +213 -0
- package/dist/frqtl/compat/renderer/providers/index.js +22 -0
- package/dist/frqtl/compat/renderer/providers/p5-provider.js +861 -0
- package/dist/frqtl/compat/renderer/providers/three-camera-contract.js +160 -0
- package/dist/frqtl/compat/renderer/providers/three-camera-geometry.js +571 -0
- package/dist/frqtl/compat/renderer/providers/three-provider.js +2474 -0
- package/dist/frqtl/compat/renderer/renderCapabilities-common.js +186 -0
- package/dist/frqtl/compat/renderer/renderer-provider-contract.js +238 -0
- package/dist/frqtl/compat/renderer/unprojectStill.js +172 -0
- package/dist/frqtl/compat/vendor.js +196 -0
- package/dist/frqtl/node-entry.js +17 -0
- package/dist/frqtl/objects/FrqtlApp.js +2922 -0
- package/dist/frqtl/objects/ObjectFactory.js +297 -0
- package/dist/frqtl/objects/RecipeContext.js +322 -0
- package/dist/frqtl/objects/collectors/DataCollector.js +116 -0
- package/dist/frqtl/objects/constants/WallBehaviors.js +178 -0
- package/dist/frqtl/objects/geometry/SlabFace.js +258 -0
- package/dist/frqtl/objects/instruments/capture-host.js +453 -0
- package/dist/frqtl/objects/instruments/capture.js +711 -0
- package/dist/frqtl/objects/instruments/hosted-eyes.js +985 -0
- package/dist/frqtl/objects/kinds-entry.js +14 -0
- package/dist/frqtl/objects/kinds.js +190 -0
- package/dist/frqtl/objects/objects/Box.js +273 -0
- package/dist/frqtl/objects/objects/Bullet.js +155 -0
- package/dist/frqtl/objects/objects/CircuitGate.js +380 -0
- package/dist/frqtl/objects/objects/CircuitProbe.js +214 -0
- package/dist/frqtl/objects/objects/DirectedTunnel.js +412 -0
- package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
- package/dist/frqtl/objects/objects/LightSource.js +354 -0
- package/dist/frqtl/objects/objects/QuantumEmitter.js +760 -0
- package/dist/frqtl/objects/objects/Tube.js +471 -0
- package/dist/frqtl/objects/objects/absorbRadiation.js +310 -0
- package/dist/frqtl/objects/objects/actionCompiler.js +1747 -0
- package/dist/frqtl/objects/objects/controlAction.js +89 -0
- package/dist/frqtl/objects/objects/controlBind.js +278 -0
- package/dist/frqtl/objects/objects/directedRedirectProgram.js +120 -0
- package/dist/frqtl/objects/objects/emitterProgram.js +209 -0
- package/dist/frqtl/objects/objects/frqtl-control-slider.js +197 -0
- package/dist/frqtl/objects/objects/frqtl-control-sync.js +272 -0
- package/dist/frqtl/objects/objects/frqtl-control-widgets.js +219 -0
- package/dist/frqtl/objects/objects/frqtl-control-write-queue.js +174 -0
- package/dist/frqtl/objects/objects/makeControlsForObject.js +824 -0
- package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
- package/dist/frqtl/objects/objects/slitLinerScatter.js +263 -0
- package/dist/frqtl/objects/objects/sustainedSource.js +299 -0
- package/dist/frqtl/objects/objects/tagger.js +228 -0
- package/dist/frqtl/objects/schema/engine-scopes.js +469 -0
- package/dist/frqtl/objects/schema/index.js +256 -0
- package/dist/frqtl/objects/schema/scopes.js +457 -0
- package/dist/frqtl/objects/schema/validate-descriptor.js +49 -0
- package/dist/frqtl/objects/schema/valueNormalizer.js +366 -0
- package/dist/frqtl/objects/setup/_collisionMatrixLanes.js +66 -0
- package/dist/frqtl/objects/setup/_setupOptions.js +118 -0
- package/dist/frqtl/objects/setup/_worldInit.js +162 -0
- package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +242 -0
- package/dist/frqtl/objects/setup/setupCircuitBasic.js +447 -0
- package/dist/frqtl/objects/setup/setupCollisionMatrix.js +188 -0
- package/dist/frqtl/objects/setup/setupConeEmitter3D.js +251 -0
- package/dist/frqtl/objects/setup/setupDoubleSlit.js +327 -0
- package/dist/frqtl/objects/setup/setupDoubleSlit_Laser.js +229 -0
- package/dist/frqtl/objects/setup/setupEmpty.js +59 -0
- package/dist/frqtl/objects/setup/setupFxSmoke.js +209 -0
- package/dist/frqtl/objects/setup/setupG1Race.js +80 -0
- package/dist/frqtl/objects/setup/setupG2Sweep.js +89 -0
- package/dist/frqtl/objects/setup/setupG3Collide.js +177 -0
- package/dist/frqtl/objects/setup/setupLightSourceLab.js +280 -0
- package/dist/frqtl/objects/setup/setupOscillator.js +68 -0
- package/dist/frqtl/objects/setup/setupParticleWalk.js +93 -0
- package/dist/frqtl/objects/setup/setupParticleWalk3D.js +133 -0
- package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +1259 -0
- package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
- package/dist/frqtl/objects/setup/setupTunnelLab.js +242 -0
- package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
- package/dist/frqtl/objects/setup/setupWaveInterference.js +178 -0
- package/dist/frqtl/objects/v1-replacements.js +30 -0
- package/dist/frqtl/package-core.js +23 -0
- package/dist/frqtl/parts/CameraControls.js +643 -0
- package/dist/frqtl/parts/CameraPath.js +575 -0
- package/dist/frqtl/parts/CollisionActions.js +35 -0
- package/dist/frqtl/parts/CollisionTypes.js +31 -0
- package/dist/frqtl/parts/DetectorWall.js +496 -0
- package/dist/frqtl/parts/FrqtlControlOverlay.js +227 -0
- package/dist/frqtl/parts/FrqtlControlsPanel.js +415 -0
- package/dist/frqtl/parts/FrqtlVision.js +818 -0
- package/dist/frqtl/parts/GraphingDetectorWall.js +490 -0
- package/dist/frqtl/parts/LargeObject.js +406 -0
- package/dist/frqtl/parts/LightColor.js +255 -0
- package/dist/frqtl/parts/LiveChart.js +285 -0
- package/dist/frqtl/parts/LogicGate.js +720 -0
- package/dist/frqtl/parts/OvenBox.js +612 -0
- package/dist/frqtl/parts/PointSource.js +763 -0
- package/dist/frqtl/parts/Rectangle.js +200 -0
- package/dist/frqtl/parts/SlitLightSource.js +234 -0
- package/dist/frqtl/parts/SlitScreen.js +628 -0
- package/dist/frqtl/parts/SphereEmitter.js +946 -0
- package/dist/frqtl/parts/Trace.js +508 -0
- package/dist/frqtl/parts/Tunnel.js +1057 -0
- package/dist/frqtl/parts/Wall.js +1211 -0
- package/dist/frqtl/parts/index.js +33 -0
- package/dist/frqtl/scenes/absorbing-wall-3d.js +33 -0
- package/dist/frqtl/scenes/absorbing-wall.js +32 -0
- package/dist/frqtl/scenes/atomic-model.js +88 -0
- package/dist/frqtl/scenes/big-bang.js +46 -0
- package/dist/frqtl/scenes/blackbody.js +81 -0
- package/dist/frqtl/scenes/circuit-sat.js +127 -0
- package/dist/frqtl/scenes/collision-matrix-3d.js +107 -0
- package/dist/frqtl/scenes/counting-wall-3d.js +36 -0
- package/dist/frqtl/scenes/counting-wall.js +38 -0
- package/dist/frqtl/scenes/double-slit.js +31 -0
- package/dist/frqtl/scenes/dse-one-emitter.js +196 -0
- package/dist/frqtl/scenes/dse-one-slit.js +114 -0
- package/dist/frqtl/scenes/dse-photoelectric.js +70 -0
- package/dist/frqtl/scenes/dse-tunnel-rebuild.js +244 -0
- package/dist/frqtl/scenes/dse-two-emitters.js +196 -0
- package/dist/frqtl/scenes/dse-two-slits.js +132 -0
- package/dist/frqtl/scenes/empty.js +14 -0
- package/dist/frqtl/scenes/laser-buildup.js +49 -0
- package/dist/frqtl/scenes/mirror-3d.js +56 -0
- package/dist/frqtl/scenes/mirror-route.js +140 -0
- package/dist/frqtl/scenes/mirror.js +38 -0
- package/dist/frqtl/scenes/omni-3d.js +27 -0
- package/dist/frqtl/scenes/omni-source.js +22 -0
- package/dist/frqtl/scenes/self-interference.js +39 -0
- package/dist/frqtl/scenes/smoking-gun.js +122 -0
- package/dist/frqtl/scenes/straight-walk.js +22 -0
- package/dist/frqtl/scenes/sun.js +39 -0
- package/dist/frqtl/scenes/two-source.js +37 -0
- package/dist/frqtl/scenes/wave.js +26 -0
- package/dist/shim/bundle/frqnb.js +112 -0
- package/dist/shim/bundle/index.js +121 -0
- package/dist/shim/bundle/indexHtml.js +107 -0
- package/dist/shim/bundle/manifest.js +95 -0
- package/dist/shim/bundle/zip.js +243 -0
- package/dist/shim/bus.js +67 -0
- package/dist/shim/controls/index.js +627 -0
- package/dist/shim/controls/registry.js +274 -0
- package/dist/shim/engine/artifact-shell.js +212 -0
- package/dist/shim/engine/boot-phases.js +44 -0
- package/dist/shim/engine/engine-stack-boot.js +59 -0
- package/dist/shim/engine/router.js +692 -0
- package/dist/shim/experiment-frame/index.js +296 -0
- package/dist/shim/expr/ast-schema.js +369 -0
- package/dist/shim/field-descriptor/index.js +326 -0
- package/dist/shim/frame-data/index.js +224 -0
- package/dist/shim/index.js +67 -0
- package/dist/shim/notebook/canvas-guard.js +82 -0
- package/dist/shim/notebook/catalog.js +35 -0
- package/dist/shim/notebook/format.js +736 -0
- package/dist/shim/notebook/frqtl-setups-importmap.js +81 -0
- package/dist/shim/notebook/kernel.js +672 -0
- package/dist/shim/notebook/knob.js +151 -0
- package/dist/shim/notebook/module-resolver.js +260 -0
- package/dist/shim/notebook/notebook-knobs.js +65 -0
- package/dist/shim/notebook/produced-bindings.js +67 -0
- package/dist/shim/notebook/realm-registry.js +121 -0
- package/dist/shim/notebook/realm-transport.js +73 -0
- package/dist/shim/notebook/setup-builder.js +510 -0
- package/dist/shim/notebook/store.js +159 -0
- package/dist/shim/notebook/vendor-importmap.js +134 -0
- package/dist/shim/notebook/view-builder.js +189 -0
- package/dist/shim/notebook/view-ctx.js +100 -0
- package/dist/shim/notebook/view-handle.js +202 -0
- package/dist/shim/notebook/warmup.js +102 -0
- package/dist/shim/package.json +4 -0
- package/dist/shim/page-params/index.js +214 -0
- package/dist/shim/project/identity.js +112 -0
- package/dist/shim/project/index.js +19 -0
- package/dist/shim/project/link.js +402 -0
- package/dist/shim/project/node-specifier-hook.mjs +35 -0
- package/dist/shim/project/register-node.mjs +17 -0
- package/dist/shim/project/specifiers.js +106 -0
- package/dist/shim/registry/copy-gate.js +110 -0
- package/dist/shim/registry/exhibits.js +455 -0
- package/dist/shim/registry/experiments.js +215 -0
- package/dist/shim/registry/index.js +19 -0
- package/dist/shim/registry/scene-source.js +157 -0
- package/dist/shim/registry/walkthrough.js +807 -0
- package/dist/shim/render/capture-envelope.js +279 -0
- package/dist/shim/render/heat-colormap.js +92 -0
- package/dist/shim/render/render-stages.js +855 -0
- package/dist/shim/render/sun-look.js +33 -0
- package/dist/shim/render-state/apply.js +262 -0
- package/dist/shim/render-state/index.js +74 -0
- package/dist/shim/rpc/index.js +207 -0
- package/dist/shim/runtime/index.js +1023 -0
- package/dist/shim/storage/boot.js +69 -0
- package/dist/shim/storage/index.js +169 -0
- package/dist/shim/storage/local-storage-provider.js +230 -0
- package/dist/shim/storage/make-namespace-store.js +132 -0
- package/dist/shim/storage/modules.js +249 -0
- package/dist/shim/storage/projects.js +150 -0
- package/llms.txt +12 -0
- 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
|
+
|