@descix/frqtl-node 0.6.0-p0.2 → 0.6.0-p1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CLAUDE.md +2 -2
  2. package/README.md +7 -3
  3. package/dist/dist-manifest.json +54 -48
  4. package/dist/frqtl/assets/vendor/acorn.LICENSE +21 -0
  5. package/dist/frqtl/assets/vendor/acorn.PINNED.json +9 -0
  6. package/dist/frqtl/assets/vendor/acorn.mjs +6266 -0
  7. package/dist/frqtl/compat/renderer/lo-drawn-center.js +41 -0
  8. package/dist/frqtl/experiments/_shared/boot-failure.js +60 -0
  9. package/dist/frqtl/objects/FrqtlApp.js +109 -30
  10. package/dist/frqtl/objects/ObjectFactory.js +4 -5
  11. package/dist/frqtl/objects/RecipeContext.js +451 -9
  12. package/dist/frqtl/objects/instruments/hosted-eyes.js +68 -28
  13. package/dist/frqtl/objects/objects/QuantumEmitter.js +1 -1
  14. package/dist/frqtl/objects/schema/index.js +72 -0
  15. package/dist/frqtl/parts/DetectorWall.js +1 -1
  16. package/dist/frqtl/parts/FrqtlControlsPanel.js +4 -2
  17. package/dist/frqtl/parts/Wall.js +2 -0
  18. package/dist/frqtl/scenes/absorbing-wall-3d.js +18 -14
  19. package/dist/frqtl/scenes/absorbing-wall.js +18 -14
  20. package/dist/frqtl/scenes/atomic-model.js +3 -0
  21. package/dist/frqtl/scenes/big-bang.js +3 -0
  22. package/dist/frqtl/scenes/blackbody.js +14 -11
  23. package/dist/frqtl/scenes/circuit-sat.js +7 -4
  24. package/dist/frqtl/scenes/collision-matrix-3d.js +3 -0
  25. package/dist/frqtl/scenes/counting-wall-3d.js +19 -15
  26. package/dist/frqtl/scenes/counting-wall.js +19 -15
  27. package/dist/frqtl/scenes/double-slit.js +3 -0
  28. package/dist/frqtl/scenes/dse-one-emitter.js +64 -58
  29. package/dist/frqtl/scenes/dse-one-slit.js +59 -55
  30. package/dist/frqtl/scenes/dse-photoelectric.js +28 -24
  31. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +124 -99
  32. package/dist/frqtl/scenes/dse-two-emitters.js +64 -58
  33. package/dist/frqtl/scenes/dse-two-slits.js +69 -65
  34. package/dist/frqtl/scenes/empty.js +3 -0
  35. package/dist/frqtl/scenes/laser-buildup.js +23 -16
  36. package/dist/frqtl/scenes/mirror-3d.js +31 -27
  37. package/dist/frqtl/scenes/mirror-route.js +4 -1
  38. package/dist/frqtl/scenes/mirror.js +24 -20
  39. package/dist/frqtl/scenes/omni-3d.js +7 -4
  40. package/dist/frqtl/scenes/omni-source.js +7 -4
  41. package/dist/frqtl/scenes/self-interference.js +13 -10
  42. package/dist/frqtl/scenes/smoking-gun.js +15 -11
  43. package/dist/frqtl/scenes/straight-walk.js +7 -4
  44. package/dist/frqtl/scenes/sun.js +11 -8
  45. package/dist/frqtl/scenes/two-source.js +22 -17
  46. package/dist/frqtl/scenes/wave.js +10 -7
  47. package/dist/shim/bundle/manifest.js +1 -1
  48. package/dist/shim/engine/router.js +2 -2
  49. package/dist/shim/page-params/index.js +2 -2
  50. package/dist/shim/project/index.js +7 -5
  51. package/dist/shim/project/link.js +12 -5
  52. package/dist/shim/project/parts-module.js +67 -0
  53. package/dist/shim/project/parts-writer.js +453 -0
  54. package/dist/shim/project/specifiers.js +41 -13
  55. package/dist/shim/registry/walkthrough.js +9 -9
  56. package/dist/shim/render-state/apply.js +11 -14
  57. package/llms.txt +2 -2
  58. package/package.json +1 -1
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * A sketch file: the WHOLE scene as one ES module, `export default function buildExperiment(exp)`. The world
7
7
  * (its co-init call, `exp.createCanvas` / `exp.createScene` — the one place its size is written),
8
- * the parts (`exp.addObject`) and the render intent are all here; the sugar
8
+ * the parts (`export const parts` + `exp.declare`, or `exp.addObject`) and the render intent are all here; the sugar
9
9
  * objects, renderer and engine are imports. Registered by lib/shim/registry/walkthrough.js
10
10
  * (rung dse-tunnel-rebuild).
11
11
  */
@@ -13,7 +13,7 @@
13
13
  // The double slit built on the TUNNEL OBJECT at physical DSE proportions. The two
14
14
  // slit channels are Tunnels — TWO parallel MIRROR walls (guarded bounce, zero leak) — so
15
15
  // the slit reflects the beam; the barriers + screen are v2 Walls (destroy / detector-screen
16
- // preset) so the scene round-trips through codegen on Restart. GEOMETRY: SYMMETRIC —
16
+ // preset), declared in `parts` so a hand edit is a code edit. GEOMETRY: SYMMETRIC —
17
17
  // emitter->front of the slit wall equals back of the slit wall->detector. The near-field
18
18
  // result is a balanced, central-dominant three-lobe double-slit interference pattern (a
19
19
  // stochastic near-field walk; the ray-optics λL/d Fraunhofer regime needs L >> d²/λ ≈ 580px,
@@ -21,8 +21,9 @@
21
21
  //
22
22
  // ONE MODE-AWARE SOURCE (S3/W2). The mode strip
23
23
  // selects the RENDERER the host boots (renderState.renderer), and this scene reads it ONCE,
24
- // before its world: `three` ⇒ exp.createScene (a 3D world), else exp.createCanvas (2D). `IS3D`
25
- // then guards the dimensional deltas — there is NO second (`-3d`) source file:
24
+ // before its world: `three` ⇒ exp.createScene (a 3D world), else exp.createCanvas (2D).
25
+ // `parts` is written in the DEFAULT mode, 3D; in 2D the scene declares flat2D(parts), the
26
+ // scene's own statement of its 2D delta — there is NO second (`-3d`) source file:
26
27
  // * the emitter opens the z-axis (is2D:!IS3D → activeAxes.z, a 3D ball of directions);
27
28
  // * every barrier / tunnel / detector gains the SAME z-slab (z/depth → a 6-element rect),
28
29
  // so the slit wall is a real SLOT-in-a-PLATE and what you SEE is what collides;
@@ -40,108 +41,98 @@
40
41
  // (= mass/capacity = 1/wavelength). See the emitter block in the source for the two
41
42
  // independent dials (band vs pixel wavelength) and why the fringe geometry is unchanged.
42
43
 
43
- import { CameraControls, FrqtlControlOverlay, FrqtlControlsPanel, SphereEmitter } from '@descix/frqtl-web/parts';
44
+ import { CameraControls, FrqtlControlOverlay, FrqtlControlsPanel, GraphingDetectorWall, SphereEmitter, Tunnel, Wall } from '@descix/frqtl-web/parts';
44
45
  import * as ControlRegistry from '@descix/frqtl-web/shim/controls/registry.js';
45
46
  import * as FrqtlDisplay from '@descix/frqtl-web/compat/renderer/display-slot.js';
46
47
  import * as SunLook from '@descix/frqtl-web/shim/render/sun-look.js';
47
48
 
48
- export default function buildExperiment(exp) {
49
- // Double Slit (Tunnel) — the two slit channels are MIRROR TUNNELS (guarded
50
- // bounce, zero-leak); the detector sits at the SYMMETRIC distance (emitter->front ==
51
- // back->detector). X placements are wl-derived (canvas-width-independent); Y is
52
- // centered on H (groupCenterY = H/2).
53
- // The booted display (the host's renderState.renderer) decides which world this mode makes.
54
- var IS3D = FrqtlDisplay.name() === 'three';
55
- var CW = 600, H = 400, WORLD_D = 600; // THE WORLD this scene states (its co-init call below)
56
- // GEOMETRY and WAVELENGTH are two INDEPENDENT dials, and they are kept apart on
57
- // purpose: `wl` sizes the PLATE (slit height, gap, wall placement) and nothing else,
58
- // while `lamPx` is the transverse wavelength in pixels. Doubling lambda must NOT move
59
- // the slit wall — if one variable drove both, the aperture would silently walk every
60
- // time the beam was re-tuned and no two runs would be comparable.
61
- var wl = 64, sh = Math.floor(wl * 0.8), gap = Math.floor(wl * 0.8); // 51, 51
62
- var lamPx = 128; // the TRANSVERSE wavelength, in pixels
63
- var groupH = sh + gap + sh; // 153
64
- var gcY = H / 2; // 300 @ H=600
65
- var s1Top = gcY - groupH / 2; // 223.5
66
- var gapTop = s1Top + sh; // 274.5
67
- var s2Bot = gcY + groupH / 2; // 376.5
68
- var s2Top = s2Bot - sh; // 325.5
69
- var srcX = 20; // source x (left, near the slit wall)
70
- var L = wl * 1.5, W = wl; // slit-group left 96, width 64 (wall front..back = [96,160])
71
- // THE DETECTOR LEG, DERIVED FROM THE RING LADDER (zero free parameters).
72
- // A phase-locked POINT source lays down density rings at r_m = (lambda/4)*sqrt((2m+1)^2+1)
73
- // measured from the emission point (guide row r3-radius-stall-shells). Each slit exit below
74
- // IS such a source, so the plate should be placed where
75
- // a NAMED ring crosses it rather than at a symmetric figure. Put the MIDLINE on ring m=2
76
- // of BOTH slits: with the exits at +/-halfSep from the midline, that needs a leg of
77
- // D = sqrt(r_2^2 - halfSep^2) = sqrt(163.2^2 - 51^2) = 155
78
- // so the two ring-2 circles intersect exactly on the axis and the centre is DOUBLY
79
- // reinforced. Rings inside D (m=0 at 45.3, m=1 at 101.2) never reach the plate and
80
- // contribute no band — a measured property of the ladder, not an assumption.
81
- // This REPLACES the old symmetric-leg figure, whose own comment made it conditional on
82
- // the slits becoming directed emitters; the exit gates below are that condition.
83
- var ringR = function (m) { return (lamPx / 4) * Math.sqrt((2 * m + 1) * (2 * m + 1) + 1); };
84
- var slitCY1 = s1Top + sh / 2, slitCY2 = s2Top + sh / 2; // 249, 351 — the bore midlines
85
- var halfSep = gcY - slitCY1; // 51
86
- var detW = 20; // the detector plate's own thickness
87
- var D = Math.round(Math.sqrt(ringR(2) * ringR(2) - halfSep * halfSep)); // 155
88
- var detX = (L + W) + D; // 315
89
- var linerH = Math.min(10, Math.floor(sh * 0.2)), bore = sh - 2 * linerH; // 10, 31
90
- // THE 3D DELTA, in ONE place: the plate z-band. Every barrier / tunnel liner / detector
91
- // carries the SAME z-slab, so the slit wall is a genuine SLOT IN A PLATE standing in
92
- // space (the slits are slots long in z — the 2D figure extruded). In 2D the bag is
93
- // EMPTY, so every Object.assign below is byte-identical to the flat scene.
94
- var zHalf = Math.round(groupH / 2); // 77 @ wl 64
95
- var Z3 = IS3D ? { z: -zHalf, depth: 2 * zHalf } : {};
96
- // THE BEAM'S COLOUR — its own frequency band, never a forced recolor. The emitter
97
- // emits COLOURLESS leaves (no renderColor arg ⇒ no string_map['render.color']), so the
98
- // renderer derives the colour from the ONE authority, LightColor, at the leaf's
99
- // fullness. TWO INDEPENDENT dials produce it, both on the emitter:
100
- // fullness = mass/capacity = 1 / wavelength -> the spectral BAND (LightColor)
101
- // lambda_px = wavelength x wavelengthScale -> the transverse wave, in PIXELS
102
- // (wavelengthScale = floor(fundamentalWaveLength / 4)). So the band is chosen by
103
- // scaling fundamentalWaveLength INVERSELY with the wavelength arg — lambda_px (the
104
- // fringe geometry) is untouched. 1/3.2 = 0.3125 sits in the YELLOW band; the rung's
105
- // renderState declares the spectrally correct 'roygbiv' ramp, so the beam renders
106
- // [255,243,0]. lambda_px = 3.2 x 40 = 128, INDEPENDENT of the 64-derived plate.
107
- // THIS BEAM IS LIGHT, so the band is free: these leaves are born AT the universe's
108
- // fundamental dimension (leafDimension == fundamentalDimension == 2), and a leaf at the
109
- // fundamental ALWAYS steps, P=1, whatever its fullness (bench M1) — the fullness
110
- // move-gate (bench M2) simply does not apply, and P=1 draws no RNG. Measured on the
111
- // live detector: the fringe histogram is bin-for-bin the SAME as the (64, 16) tuple
112
- // (t=30s, 16px profile: 11260 vs 11262 hits, every bin within 1, COM 201.7 both).
113
- var lamArg = 3.2; // fullness 1/3.2 = 0.3125 -> yellow
114
- var fwl = 4 * (lamPx / lamArg); // 160 -> wavelengthScale 40 -> lambda_px = 128
115
- // The light source — the current SphereEmitter sugar (braided walk), placed at the
116
- // original source pixel (srcX, gap-center = H/2).
117
- // is2D:!IS3D opens activeAxes.z — the ball-rejected sampler then draws a 3D BALL of
118
- // directions (bench S1/S5) instead of the 2D disk.
119
- // preset 'hemisphere' = 180 degrees of FORWARD emission (D1): the SAME ball-rejected
120
- // sampler with the forward axis' range halved to [0,R], i.e. the spherical distribution
121
- // RESTRICTED to the forward half — same physics, pointed one way, so nothing about the
122
- // walk changes. Forward is +X, which is where the slit wall stands (orientationDeg stays
123
- // 0), so the half of the burst that used to fly LEFT into the back-scatter sink now goes
124
- // toward the slits: the same burst size illuminates the aperture roughly twice as hard.
125
- var SRC = {
126
- preset: 'hemisphere', is2D: !IS3D, center: [srcX, Math.round(gcY)],
49
+ const CW = 600, H = 400, WORLD_D = 600; // THE WORLD this scene states (its co-init call below)
50
+ // GEOMETRY and WAVELENGTH are two INDEPENDENT dials, and they are kept apart on
51
+ // purpose: `wl` sizes the PLATE (slit height, gap, wall placement) and nothing else,
52
+ // while `lamPx` is the transverse wavelength in pixels. Doubling lambda must NOT move
53
+ // the slit wall — if one variable drove both, the aperture would silently walk every
54
+ // time the beam was re-tuned and no two runs would be comparable.
55
+ const wl = 64, sh = Math.floor(wl * 0.8), gap = Math.floor(wl * 0.8); // 51, 51
56
+ const lamPx = 128; // the TRANSVERSE wavelength, in pixels
57
+ const groupH = sh + gap + sh; // 153
58
+ const gcY = H / 2; // 300 @ H=600
59
+ const s1Top = gcY - groupH / 2; // 223.5
60
+ const gapTop = s1Top + sh; // 274.5
61
+ const s2Bot = gcY + groupH / 2; // 376.5
62
+ const s2Top = s2Bot - sh; // 325.5
63
+ const srcX = 20; // source x (left, near the slit wall)
64
+ const L = wl * 1.5, W = wl; // slit-group left 96, width 64 (wall front..back = [96,160])
65
+ // THE DETECTOR LEG, DERIVED FROM THE RING LADDER (zero free parameters).
66
+ // A phase-locked POINT source lays down density rings at r_m = (lambda/4)*sqrt((2m+1)^2+1)
67
+ // measured from the emission point (guide row r3-radius-stall-shells). Each slit exit below
68
+ // IS such a source, so the plate should be placed where
69
+ // a NAMED ring crosses it rather than at a symmetric figure. Put the MIDLINE on ring m=2
70
+ // of BOTH slits: with the exits at +/-halfSep from the midline, that needs a leg of
71
+ // D = sqrt(r_2^2 - halfSep^2) = sqrt(163.2^2 - 51^2) = 155
72
+ // so the two ring-2 circles intersect exactly on the axis and the centre is DOUBLY
73
+ // reinforced. Rings inside D (m=0 at 45.3, m=1 at 101.2) never reach the plate and
74
+ // contribute no band — a measured property of the ladder, not an assumption.
75
+ // This REPLACES the old symmetric-leg figure, whose own comment made it conditional on
76
+ // the slits becoming directed emitters; the exit gates below are that condition.
77
+ const ringR = function (m) { return (lamPx / 4) * Math.sqrt((2 * m + 1) * (2 * m + 1) + 1); };
78
+ const slitCY1 = s1Top + sh / 2, slitCY2 = s2Top + sh / 2; // 249, 351 — the bore midlines
79
+ const halfSep = gcY - slitCY1; // 51
80
+ const detW = 20; // the detector plate's own thickness
81
+ const D = Math.round(Math.sqrt(ringR(2) * ringR(2) - halfSep * halfSep)); // 155
82
+ const detX = (L + W) + D; // 315
83
+ const linerH = Math.min(10, Math.floor(sh * 0.2)), bore = sh - 2 * linerH; // 10, 31
84
+ // THE 3D DELTA: the plate z-band. Every barrier / tunnel liner / detector in `parts`
85
+ // carries the SAME z-slab (z: -zHalf, depth: 2 * zHalf), so the slit wall is a genuine SLOT
86
+ // IN A PLATE standing in space (the slits are slots long in z — the 2D figure extruded).
87
+ // flat2D (below) deletes the slab, so the 2D scene is the flat figure.
88
+ const zHalf = Math.round(groupH / 2); // 77 @ wl 64
89
+ // THE BEAM'S COLOUR — its own frequency band, never a forced recolor. The emitter
90
+ // emits COLOURLESS leaves (no renderColor arg ⇒ no string_map['render.color']), so the
91
+ // renderer derives the colour from the ONE authority, LightColor, at the leaf's
92
+ // fullness. TWO INDEPENDENT dials produce it, both on the emitter:
93
+ // fullness = mass/capacity = 1 / wavelength -> the spectral BAND (LightColor)
94
+ // lambda_px = wavelength x wavelengthScale -> the transverse wave, in PIXELS
95
+ // (wavelengthScale = floor(fundamentalWaveLength / 4)). So the band is chosen by
96
+ // scaling fundamentalWaveLength INVERSELY with the wavelength arg — lambda_px (the
97
+ // fringe geometry) is untouched. 1/3.2 = 0.3125 sits in the YELLOW band; the rung's
98
+ // renderState declares the spectrally correct 'roygbiv' ramp, so the beam renders
99
+ // [255,243,0]. lambda_px = 3.2 x 40 = 128, INDEPENDENT of the 64-derived plate.
100
+ // THIS BEAM IS LIGHT, so the band is free: these leaves are born AT the universe's
101
+ // fundamental dimension (leafDimension == fundamentalDimension == 2), and a leaf at the
102
+ // fundamental ALWAYS steps, P=1, whatever its fullness (bench M1) — the fullness
103
+ // move-gate (bench M2) simply does not apply, and P=1 draws no RNG. Measured on the
104
+ // live detector: the fringe histogram is bin-for-bin the SAME as the (64, 16) tuple
105
+ // (t=30s, 16px profile: 11260 vs 11262 hits, every bin within 1, COM 201.7 both).
106
+ const lamArg = 3.2; // fullness 1/3.2 = 0.3125 -> yellow
107
+ const fwl = 4 * (lamPx / lamArg); // 160 -> wavelengthScale 40 -> lambda_px = 128
108
+
109
+ // The light source — the current SphereEmitter sugar (braided walk), placed at the
110
+ // original source pixel (srcX, gap-center = H/2).
111
+ // is2D:!IS3D opens activeAxes.z — the ball-rejected sampler then draws a 3D BALL of
112
+ // directions (bench S1/S5) instead of the 2D disk.
113
+ // preset 'hemisphere' = 180 degrees of FORWARD emission (D1): the SAME ball-rejected
114
+ // sampler with the forward axis' range halved to [0,R], i.e. the spherical distribution
115
+ // RESTRICTED to the forward half — same physics, pointed one way, so nothing about the
116
+ // walk changes. Forward is +X, which is where the slit wall stands (orientationDeg stays
117
+ // 0), so the half of the burst that used to fly LEFT into the back-scatter sink now goes
118
+ // toward the slits: the same burst size illuminates the aperture roughly twice as hard.
119
+ export const parts = {
120
+ source: [SphereEmitter, {
121
+ preset: 'hemisphere', is2D: false, center: [srcX, Math.round(gcY)],
127
122
  chargeMode: 'random', wavelength: lamArg, burstSize: 8, emitEveryTicks: 1,
128
123
  fundamentalDimension: 2, fundamentalWaveLength: fwl
129
- };
130
- // THE WORLD — first, then parts: a 3D scene + universe (600x400x600) or a 2D canvas + universe.
131
- if (IS3D) { exp.createScene(CW, H, WORLD_D, SphereEmitter.worldCfg(SRC)); }
132
- else { exp.createCanvas(CW, H, SphereEmitter.worldCfg(SRC)); }
133
- exp.addObject('SphereEmitter', SRC);
124
+ }],
134
125
  // Start wall — a plain sink (collisionBehavior:'destroy', [set is_alive=0]) at the
135
126
  // left edge, catching back-scatter. It is TRANSPARENT (alpha 40 of 255): the default
136
127
  // camera stands BEHIND the emitter, so an opaque slab here would be the first thing in
137
- // frame and would hide the whole experiment. alpha is a DECLARED Wall field, so codegen
138
- // captures it and Restart rebuilds the wall just as sheer. Physics is untouched — the
128
+ // frame and would hide the whole experiment. alpha is a DECLARED Wall field, so the parts block
129
+ // carries it and Restart rebuilds the wall just as sheer. Physics is untouched — the
139
130
  // sink still destroys every back-scattered quantum (render-only, SU1).
140
- exp.addObject('Wall', Object.assign({ rect: [0, 0, 10, H] }, Z3, { collisionBehavior: 'destroy', name: 'start_wall', color: [120, 120, 120], alpha: 40 }));
131
+ start_wall: [Wall, { rect: [0, 0, 10, H], z: -zHalf, depth: 2 * zHalf, collisionBehavior: 'destroy', color: [120, 120, 120], alpha: 40 }],
141
132
  // Barrier segments — opaque plain sinks (destroy) leaving the two slit gaps.
142
- exp.addObject('Wall', Object.assign({ rect: [L, 0, W, s1Top - 1] }, Z3, { collisionBehavior: 'destroy', name: 'wall1', color: [200, 60, 60] }));
143
- exp.addObject('Wall', Object.assign({ rect: [L, gapTop, W, gap - 1] }, Z3, { collisionBehavior: 'destroy', name: 'wall2', color: [150, 40, 40] }));
144
- exp.addObject('Wall', Object.assign({ rect: [L, s2Bot, W, (H - s2Bot) - 1] }, Z3, { collisionBehavior: 'destroy', name: 'wall3', color: [200, 60, 60] }));
133
+ wall1: [Wall, { rect: [L, 0, W, s1Top - 1], z: -zHalf, depth: 2 * zHalf, collisionBehavior: 'destroy', color: [200, 60, 60] }],
134
+ wall2: [Wall, { rect: [L, gapTop, W, gap - 1], z: -zHalf, depth: 2 * zHalf, collisionBehavior: 'destroy', color: [150, 40, 40] }],
135
+ wall3: [Wall, { rect: [L, s2Bot, W, (H - s2Bot) - 1], z: -zHalf, depth: 2 * zHalf, collisionBehavior: 'destroy', color: [200, 60, 60] }],
145
136
  // The two slit channels as TUNNELS — walls land at the EXACT original liner positions
146
137
  // ([L,s1Top,W,10] & [L,s1Top+sh-10,W,10]); the bore is the clear aperture between them.
147
138
  // The Tunnel ferries z/depth onto BOTH walls, so a 3D channel is a real corridor.
@@ -168,8 +159,8 @@ export default function buildExperiment(exp) {
168
159
  // Unifying this with the SphereEmitter preset vocabulary (one owner, ferried — never a
169
160
  // second preset list) is an open contract design;
170
161
  // this rung uses the existing flags and documents the mapping.
171
- exp.addObject('Tunnel', Object.assign({ left: L, top: s1Top + linerH, length: W, bore: bore, wallThickness: linerH, axis: 'x' }, Z3, { mode: 'directed', exitCollapse: true, exitRecenter: true, exitPhaseReset: true, name: 'slit1_channel', color: [80, 140, 220] }));
172
- exp.addObject('Tunnel', Object.assign({ left: L, top: s2Top + linerH, length: W, bore: bore, wallThickness: linerH, axis: 'x' }, Z3, { mode: 'directed', exitCollapse: true, exitRecenter: true, exitPhaseReset: true, name: 'slit2_channel', color: [80, 140, 220] }));
162
+ slit1_channel: [Tunnel, { left: L, top: s1Top + linerH, length: W, bore: bore, wallThickness: linerH, axis: 'x', z: -zHalf, depth: 2 * zHalf, mode: 'directed', exitCollapse: true, exitRecenter: true, exitPhaseReset: true, color: [80, 140, 220] }],
163
+ slit2_channel: [Tunnel, { left: L, top: s2Top + linerH, length: W, bore: bore, wallThickness: linerH, axis: 'x', z: -zHalf, depth: 2 * zHalf, mode: 'directed', exitCollapse: true, exitRecenter: true, exitPhaseReset: true, color: [80, 140, 220] }],
173
164
  // The detector SCREEN — a NON-ABSORBING COUNTING wall ('transparent-probe':
174
165
  // collisionBehavior 'nothing' + the count hook) in BOTH modes. This is the
175
166
  // |psi|^2-faithful screen: an ABSORBING plate removes each quantum at first contact,
@@ -187,8 +178,42 @@ export default function buildExperiment(exp) {
187
178
  // FAR-EDGE SINK. The screen below COUNTS WITHOUT ABSORBING, so quanta fly on past it;
188
179
  // this destroy wall at the right edge is the experiment-boundary sink that keeps the
189
180
  // population bounded (Invariant 5/7 — only an experiment boundary may destroy).
190
- exp.addObject('Wall', Object.assign({ rect: [CW - 10, 0, 10, H] }, Z3, { collisionBehavior: 'destroy', name: 'far_sink', color: [120, 120, 120], alpha: 40 }));
191
- exp.addObject(IS3D ? 'GraphingDetectorWall' : 'Wall', Object.assign({ rect: [detX, 0, detW, H] }, Z3, { preset: 'transparent-probe', name: 'screen', color: [120, 200, 120] }, IS3D ? { alpha: 220, binPx: 12 } : {}));
181
+ far_sink: [Wall, { rect: [CW - 10, 0, 10, H], z: -zHalf, depth: 2 * zHalf, collisionBehavior: 'destroy', color: [120, 120, 120], alpha: 40 }],
182
+ screen: [GraphingDetectorWall, { rect: [detX, 0, detW, H], z: -zHalf, depth: 2 * zHalf, preset: 'transparent-probe', color: [120, 200, 120], alpha: 220, binPx: 12 }],
183
+ };
184
+
185
+ /**
186
+ * flat2D(parts) — THE 2D MODE of this scene, derived from its 3D `parts`: the same keys in the same order,
187
+ * each entry's props copied with the z-slab (z, depth) deleted, is2D set true (by assignment, so the key
188
+ * keeps its position), and the GraphingDetectorWall plate a plain counting Wall without its paint
189
+ * (alpha, binPx). Pure: it reads only its argument. A hand-added entry flattens by the same rule.
190
+ */
191
+ function flat2D(parts) {
192
+ var out = {};
193
+ Object.keys(parts).forEach(function (key) {
194
+ var K = parts[key][0];
195
+ var props = Object.assign({}, parts[key][1]);
196
+ delete props.z;
197
+ delete props.depth;
198
+ if ('is2D' in props) { props.is2D = true; }
199
+ if (K === GraphingDetectorWall) { K = Wall; delete props.alpha; delete props.binPx; }
200
+ out[key] = [K, props];
201
+ });
202
+ return out;
203
+ }
204
+
205
+ export default function buildExperiment(exp) {
206
+ // Double Slit (Tunnel) — the two slit channels are MIRROR TUNNELS (guarded
207
+ // bounce, zero-leak); the detector sits at the SYMMETRIC distance (emitter->front ==
208
+ // back->detector). X placements are wl-derived (canvas-width-independent); Y is
209
+ // centered on H (groupCenterY = H/2).
210
+ // The booted display (the host's renderState.renderer) decides which world this mode makes.
211
+ var IS3D = FrqtlDisplay.name() === 'three';
212
+ var P = IS3D ? parts : flat2D(parts);
213
+ // THE WORLD — first, then parts: a 3D scene + universe (600x400x600) or a 2D canvas + universe.
214
+ if (IS3D) { exp.createScene(CW, H, WORLD_D, SphereEmitter.worldCfg(P.source[1])); }
215
+ else { exp.createCanvas(CW, H, SphereEmitter.worldCfg(P.source[1])); }
216
+ exp.declare(P);
192
217
  if (IS3D) {
193
218
  // SCENE Z-COLLIMATORS — two INVISIBLE full-canvas destroy boundaries flush OUTSIDE
194
219
  // the plate z-band (the cl1a-mirror-3d pattern): the emitted ball's z-tail diffuses,
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * A sketch file: the WHOLE scene as one ES module, `export default function buildExperiment(exp)`. The world
7
7
  * (its co-init call, `exp.createCanvas` / `exp.createScene` — the one place its size is written),
8
- * the parts (`exp.addObject`) and the render intent are all here; the sugar
8
+ * the parts (`export const parts` + `exp.declare`, or `exp.addObject`) and the render intent are all here; the sugar
9
9
  * objects, renderer and engine are imports. Registered by lib/shim/registry/walkthrough.js
10
10
  * (rung dse-two-emitters).
11
11
  */
@@ -71,19 +71,70 @@
71
71
  // the title lines and the `var NSRC` line — lib/frqtl/scenes/test/scene-files.test.mjs
72
72
  // fails if any other line diverges, so the A/B the pair exists to make stays an A/B.
73
73
 
74
- import { SphereEmitter } from '@descix/frqtl-web/parts';
74
+ import { SphereEmitter, Wall } from '@descix/frqtl-web/parts';
75
+
76
+ const CW = 600, H = 400; // THE WORLD this scene states (its co-init call below)
77
+ // ── THE ONLY LENGTH ─────────────────────────────────────────────────────
78
+ // lamPx is lambda_eff in pixels. 320 is chosen for RESOLUTION, not for how many
79
+ // orders fit: the feature this rung exists to show (the minimum just inside the
80
+ // band and the peak riding outside it) is 0.025*lambda wide, which is 8 px at
81
+ // lambda 320 and 3 px at lambda 128. A stage that cannot separate them shows a
82
+ // smooth hump and leaves you unable to say whether the physics or the sampling
83
+ // was at fault. One well-resolved pair beats three mushy ones.
84
+ const lamPx = 320;
85
+ const sinkW = Math.round(lamPx / 16); // 20 — far-edge sink thickness
86
+
87
+ // ── THE DETECTOR LEG — ONE NAMED CONSTANT ───────────────────────────────
88
+ // D = legFrac * lambda, measured from the SOURCE (there is no barrier, so the
89
+ // emitter itself is the reference point of the ladder). legFrac = 1/4 is the
90
+ // leg the ring formula was written for; see the block comment above.
91
+ const legFrac = 1 / 4;
92
+ const D = Math.round(lamPx * legFrac); // 80
93
+ const srcX = Math.round(lamPx / 16); // 20 — launch room; the canvas edge is the back sink
94
+ const detX = srcX + D; // 100 — the plate's FRONT FACE
95
+ // PLATE THICKNESS IS AN OPTICAL CHOICE UNDER A FLUX DETECTOR, not trim. A flux
96
+ // probe counts every tick a quantum overlaps it, so it integrates the density
97
+ // ACROSS its thickness — and the ring crest is a CIRCLE about the source, not a
98
+ // plane. At the m=0 band the crest's slope is dy/dx = -D/y = -1, so a slab t px
99
+ // thick smears the band over ~t px of y. Against an 8 px feature a lambda/8 (40
100
+ // px) plate would guarantee mush. lambda/64 = 5 px keeps the smear sub-feature
101
+ // and is still unjumpable (a quantum steps at most 2 px/axis/tick). A 'hits'
102
+ // plate would not care — it counts at first contact, i.e. at the front face —
103
+ // which is exactly why the reference H1 runs could afford a lambda/8 slab.
104
+ const detW = Math.round(lamPx / 64); // 5 — plate thickness
105
+ // ── THE PLATE — A COUNTING WALL THAT DOES NOT ABSORB ────────────────────
106
+ // ONE NAMED CONSTANT, so the reading can be swapped in a single edit and the two
107
+ // instruments compared rather than argued about:
108
+ // 'transparent-probe' = collisionBehavior 'nothing' + mark/count. FLUX. The
109
+ // quantum is counted on every tick it overlaps the plate and then flies on,
110
+ // so the reading is the OCCUPANCY INTEGRAL at the plate plane — the density
111
+ // at that plane. This is the shipped choice.
112
+ // 'detector-screen' = destroy + count. HITS. Each quantum is counted once, at
113
+ // arrival, and removed: a transit census, the headcount a photographic plate
114
+ // would report. It is what the H1 reference runs used.
115
+ // NEITHER ABSORBS. 'absorb' stores the arriving mass and re-radiates it — that is
116
+ // the photoelectric experiment, and it saturates.
117
+ const detectorPreset = 'transparent-probe';
118
+
119
+ export const parts = {
120
+ // FAR-EDGE SINK. The plate below counts WITHOUT removing, so quanta fly on past
121
+ // it; this is the experiment-boundary sink downstream of the plate. It is not
122
+ // load-bearing (wrap is false, so the canvas edge would remove them anyway) — it
123
+ // is where the population bound is STATED rather than inferred.
124
+ far_sink: [Wall, { rect: [CW - sinkW, 0, sinkW, H], collisionBehavior: 'destroy',
125
+ color: [120, 120, 120], alpha: 40 }],
126
+ // markTag — the plate's PROVENANCE stamp. The mark hook fires ONLY when the
127
+ // collision trigger does (genuine overlap), whereas the engine buffers every
128
+ // frame inside the LO's mb_w-PADDED search rect whether it triggered or not. So a
129
+ // raw frame count off the buffer is a padded-occupancy reading and the
130
+ // mark-filtered count is the trigger-gated census: two different instruments, and
131
+ // an offline reader needs the tag to tell them apart.
132
+ screen: [Wall, { rect: [detX, 0, detW, H],
133
+ preset: detectorPreset, markTag: 'plate', color: [120, 200, 120] }],
134
+ };
75
135
 
76
136
  export default function buildExperiment(exp) {
77
137
  var NSRC = 2;
78
- var CW = 600, H = 400; // THE WORLD this scene states (its co-init call below)
79
- // ── THE ONLY LENGTH ─────────────────────────────────────────────────────
80
- // lamPx is lambda_eff in pixels. 320 is chosen for RESOLUTION, not for how many
81
- // orders fit: the feature this rung exists to show (the minimum just inside the
82
- // band and the peak riding outside it) is 0.025*lambda wide, which is 8 px at
83
- // lambda 320 and 3 px at lambda 128. A stage that cannot separate them shows a
84
- // smooth hump and leaves you unable to say whether the physics or the sampling
85
- // was at fault. One well-resolved pair beats three mushy ones.
86
- var lamPx = 320;
87
138
  var lamArg = 3.2; // capacity/mass -> fullness 1/3.2 = 0.3125 (yellow)
88
139
  var fwl = 4 * (lamPx / lamArg); // 400 -> wavelengthScale floor(400/4) = 100
89
140
  var wlScale = Math.floor(fwl / 4); // 100
@@ -91,26 +142,7 @@ export default function buildExperiment(exp) {
91
142
  throw new Error('[dse-h1] lamPx ' + lamPx + ' is not realisable as lambda_eff: the engine ' +
92
143
  'can only produce wavelengthArg x floor(fwl/4) = ' + (lamArg * wlScale) + '. (FAIL-LOUD.)');
93
144
  }
94
- // ── THE DETECTOR LEG — ONE NAMED CONSTANT ───────────────────────────────
95
- // D = legFrac * lambda, measured from the SOURCE (there is no barrier, so the
96
- // emitter itself is the reference point of the ladder). legFrac = 1/4 is the
97
- // leg the ring formula was written for; see the block comment above.
98
- var legFrac = 1 / 4;
99
- var D = Math.round(lamPx * legFrac); // 80
100
- var srcX = Math.round(lamPx / 16); // 20 — launch room; the canvas edge is the back sink
101
145
  var cy = Math.round(H / 2); // 200 — the source plane's midline
102
- var detX = srcX + D; // 100 — the plate's FRONT FACE
103
- // PLATE THICKNESS IS AN OPTICAL CHOICE UNDER A FLUX DETECTOR, not trim. A flux
104
- // probe counts every tick a quantum overlaps it, so it integrates the density
105
- // ACROSS its thickness — and the ring crest is a CIRCLE about the source, not a
106
- // plane. At the m=0 band the crest's slope is dy/dx = -D/y = -1, so a slab t px
107
- // thick smears the band over ~t px of y. Against an 8 px feature a lambda/8 (40
108
- // px) plate would guarantee mush. lambda/64 = 5 px keeps the smear sub-feature
109
- // and is still unjumpable (a quantum steps at most 2 px/axis/tick). A 'hits'
110
- // plate would not care — it counts at first contact, i.e. at the front face —
111
- // which is exactly why the reference H1 runs could afford a lambda/8 slab.
112
- var detW = Math.round(lamPx / 64); // 5 — plate thickness
113
- var sinkW = Math.round(lamPx / 16); // 20 — far-edge sink thickness
114
146
  // TWO-SOURCE SEPARATION. Two sources reinforce when their separation is a
115
147
  // multiple of lambda/2, and the coupling falls as (lambda/d)^2, so lambda/2 is
116
148
  // the strongest case. At this leg it also puts a band of EACH source on the
@@ -137,33 +169,7 @@ export default function buildExperiment(exp) {
137
169
  for (var si = 0; si < srcYs.length; si++) {
138
170
  exp.addObject('SphereEmitter', Object.assign({}, SRC, { center: [srcX, Math.round(srcYs[si])] }));
139
171
  }
140
- // FAR-EDGE SINK. The plate below counts WITHOUT removing, so quanta fly on past
141
- // it; this is the experiment-boundary sink downstream of the plate. It is not
142
- // load-bearing (wrap is false, so the canvas edge would remove them anyway) — it
143
- // is where the population bound is STATED rather than inferred.
144
- exp.addObject('Wall', { rect: [CW - sinkW, 0, sinkW, H], collisionBehavior: 'destroy',
145
- name: 'far_sink', color: [120, 120, 120], alpha: 40 });
146
- // ── THE PLATE — A COUNTING WALL THAT DOES NOT ABSORB ────────────────────
147
- // ONE NAMED CONSTANT, so the reading can be swapped in a single edit and the two
148
- // instruments compared rather than argued about:
149
- // 'transparent-probe' = collisionBehavior 'nothing' + mark/count. FLUX. The
150
- // quantum is counted on every tick it overlaps the plate and then flies on,
151
- // so the reading is the OCCUPANCY INTEGRAL at the plate plane — the density
152
- // at that plane. This is the shipped choice.
153
- // 'detector-screen' = destroy + count. HITS. Each quantum is counted once, at
154
- // arrival, and removed: a transit census, the headcount a photographic plate
155
- // would report. It is what the H1 reference runs used.
156
- // NEITHER ABSORBS. 'absorb' stores the arriving mass and re-radiates it — that is
157
- // the photoelectric experiment, and it saturates.
158
- var detectorPreset = 'transparent-probe';
159
- // markTag — the plate's PROVENANCE stamp. The mark hook fires ONLY when the
160
- // collision trigger does (genuine overlap), whereas the engine buffers every
161
- // frame inside the LO's mb_w-PADDED search rect whether it triggered or not. So a
162
- // raw frame count off the buffer is a padded-occupancy reading and the
163
- // mark-filtered count is the trigger-gated census: two different instruments, and
164
- // an offline reader needs the tag to tell them apart.
165
- var screen = exp.addObject('Wall', { rect: [detX, 0, detW, H],
166
- preset: detectorPreset, markTag: 'plate', name: 'screen', color: [120, 200, 120] });
172
+ const p = exp.declare(parts);
167
173
  // ── THE INSTRUMENT — the live count-by-y histogram, REGISTERED TO THE PLATE ──
168
174
  // The panel takes the plate's OWN rect y-origin (0) and height (H), so the two
169
175
  // are co-extensive on the bin axis at any canvas size, and it starts just past
@@ -189,7 +195,7 @@ export default function buildExperiment(exp) {
189
195
  // profile's floor rises the curve compresses toward the left edge. Read positions off
190
196
  // it, not magnitudes; magnitudes come from getCollisionHistogram().
191
197
  exp.addObject('LiveChart', {
192
- source: screen, orientation: 90,
198
+ source: p.screen, orientation: 90,
193
199
  rect: [detX + detW + 8, 0, Math.max(60, CW - (detX + detW + 8) - 8), H],
194
200
  series: 'line', label: 'count by y', refreshMs: 100, color: [24, 32, 46], alpha: 225
195
201
  });