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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CLAUDE.md +87 -0
  2. package/README.md +84 -2
  3. package/dist/dist-manifest.json +218 -0
  4. package/dist/eyes/index.js +109 -0
  5. package/dist/fat/pkg-node/egpt_wasm.js +1277 -0
  6. package/dist/fat/pkg-node/egpt_wasm_bg.wasm +0 -0
  7. package/dist/fat/pkg-node/package.json +12 -0
  8. package/dist/frqtl/assets/vendor/chroma.mjs +64 -0
  9. package/dist/frqtl/compat/boot-node.js +34 -0
  10. package/dist/frqtl/compat/engine.js +127 -0
  11. package/dist/frqtl/compat/package-root.js +24 -0
  12. package/dist/frqtl/compat/providers/wasm-engine.js +2501 -0
  13. package/dist/frqtl/compat/providers/wasm-load-browser.js +134 -0
  14. package/dist/frqtl/compat/providers/wasm-load-node.js +59 -0
  15. package/dist/frqtl/compat/renderer/FieldBuilder.js +145 -0
  16. package/dist/frqtl/compat/renderer/Filters.js +163 -0
  17. package/dist/frqtl/compat/renderer/RenderFamily.js +85 -0
  18. package/dist/frqtl/compat/renderer/RenderMesh.js +100 -0
  19. package/dist/frqtl/compat/renderer/RenderShape.js +154 -0
  20. package/dist/frqtl/compat/renderer/camera2d-geometry.js +183 -0
  21. package/dist/frqtl/compat/renderer/captureStill-common.js +259 -0
  22. package/dist/frqtl/compat/renderer/display-slot.js +96 -0
  23. package/dist/frqtl/compat/renderer/lo-drawn-center.js +231 -0
  24. package/dist/frqtl/compat/renderer/orbit-bag.js +216 -0
  25. package/dist/frqtl/compat/renderer/providers/godrays-layer.js +213 -0
  26. package/dist/frqtl/compat/renderer/providers/index.js +22 -0
  27. package/dist/frqtl/compat/renderer/providers/p5-provider.js +861 -0
  28. package/dist/frqtl/compat/renderer/providers/three-camera-contract.js +160 -0
  29. package/dist/frqtl/compat/renderer/providers/three-camera-geometry.js +571 -0
  30. package/dist/frqtl/compat/renderer/providers/three-provider.js +2474 -0
  31. package/dist/frqtl/compat/renderer/renderCapabilities-common.js +186 -0
  32. package/dist/frqtl/compat/renderer/renderer-provider-contract.js +238 -0
  33. package/dist/frqtl/compat/renderer/unprojectStill.js +172 -0
  34. package/dist/frqtl/compat/vendor.js +196 -0
  35. package/dist/frqtl/node-entry.js +17 -0
  36. package/dist/frqtl/objects/FrqtlApp.js +2922 -0
  37. package/dist/frqtl/objects/ObjectFactory.js +297 -0
  38. package/dist/frqtl/objects/RecipeContext.js +322 -0
  39. package/dist/frqtl/objects/collectors/DataCollector.js +116 -0
  40. package/dist/frqtl/objects/constants/WallBehaviors.js +178 -0
  41. package/dist/frqtl/objects/geometry/SlabFace.js +258 -0
  42. package/dist/frqtl/objects/instruments/capture-host.js +453 -0
  43. package/dist/frqtl/objects/instruments/capture.js +711 -0
  44. package/dist/frqtl/objects/instruments/hosted-eyes.js +985 -0
  45. package/dist/frqtl/objects/kinds-entry.js +14 -0
  46. package/dist/frqtl/objects/kinds.js +190 -0
  47. package/dist/frqtl/objects/objects/Box.js +273 -0
  48. package/dist/frqtl/objects/objects/Bullet.js +155 -0
  49. package/dist/frqtl/objects/objects/CircuitGate.js +380 -0
  50. package/dist/frqtl/objects/objects/CircuitProbe.js +214 -0
  51. package/dist/frqtl/objects/objects/DirectedTunnel.js +412 -0
  52. package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
  53. package/dist/frqtl/objects/objects/LightSource.js +354 -0
  54. package/dist/frqtl/objects/objects/QuantumEmitter.js +760 -0
  55. package/dist/frqtl/objects/objects/Tube.js +471 -0
  56. package/dist/frqtl/objects/objects/absorbRadiation.js +310 -0
  57. package/dist/frqtl/objects/objects/actionCompiler.js +1747 -0
  58. package/dist/frqtl/objects/objects/controlAction.js +89 -0
  59. package/dist/frqtl/objects/objects/controlBind.js +278 -0
  60. package/dist/frqtl/objects/objects/directedRedirectProgram.js +120 -0
  61. package/dist/frqtl/objects/objects/emitterProgram.js +209 -0
  62. package/dist/frqtl/objects/objects/frqtl-control-slider.js +197 -0
  63. package/dist/frqtl/objects/objects/frqtl-control-sync.js +272 -0
  64. package/dist/frqtl/objects/objects/frqtl-control-widgets.js +219 -0
  65. package/dist/frqtl/objects/objects/frqtl-control-write-queue.js +174 -0
  66. package/dist/frqtl/objects/objects/makeControlsForObject.js +824 -0
  67. package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
  68. package/dist/frqtl/objects/objects/slitLinerScatter.js +263 -0
  69. package/dist/frqtl/objects/objects/sustainedSource.js +299 -0
  70. package/dist/frqtl/objects/objects/tagger.js +228 -0
  71. package/dist/frqtl/objects/schema/engine-scopes.js +469 -0
  72. package/dist/frqtl/objects/schema/index.js +256 -0
  73. package/dist/frqtl/objects/schema/scopes.js +457 -0
  74. package/dist/frqtl/objects/schema/validate-descriptor.js +49 -0
  75. package/dist/frqtl/objects/schema/valueNormalizer.js +366 -0
  76. package/dist/frqtl/objects/setup/_collisionMatrixLanes.js +66 -0
  77. package/dist/frqtl/objects/setup/_setupOptions.js +118 -0
  78. package/dist/frqtl/objects/setup/_worldInit.js +162 -0
  79. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +242 -0
  80. package/dist/frqtl/objects/setup/setupCircuitBasic.js +447 -0
  81. package/dist/frqtl/objects/setup/setupCollisionMatrix.js +188 -0
  82. package/dist/frqtl/objects/setup/setupConeEmitter3D.js +251 -0
  83. package/dist/frqtl/objects/setup/setupDoubleSlit.js +327 -0
  84. package/dist/frqtl/objects/setup/setupDoubleSlit_Laser.js +229 -0
  85. package/dist/frqtl/objects/setup/setupEmpty.js +59 -0
  86. package/dist/frqtl/objects/setup/setupFxSmoke.js +209 -0
  87. package/dist/frqtl/objects/setup/setupG1Race.js +80 -0
  88. package/dist/frqtl/objects/setup/setupG2Sweep.js +89 -0
  89. package/dist/frqtl/objects/setup/setupG3Collide.js +177 -0
  90. package/dist/frqtl/objects/setup/setupLightSourceLab.js +280 -0
  91. package/dist/frqtl/objects/setup/setupOscillator.js +68 -0
  92. package/dist/frqtl/objects/setup/setupParticleWalk.js +93 -0
  93. package/dist/frqtl/objects/setup/setupParticleWalk3D.js +133 -0
  94. package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +1259 -0
  95. package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
  96. package/dist/frqtl/objects/setup/setupTunnelLab.js +242 -0
  97. package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
  98. package/dist/frqtl/objects/setup/setupWaveInterference.js +178 -0
  99. package/dist/frqtl/objects/v1-replacements.js +30 -0
  100. package/dist/frqtl/package-core.js +23 -0
  101. package/dist/frqtl/parts/CameraControls.js +643 -0
  102. package/dist/frqtl/parts/CameraPath.js +575 -0
  103. package/dist/frqtl/parts/CollisionActions.js +35 -0
  104. package/dist/frqtl/parts/CollisionTypes.js +31 -0
  105. package/dist/frqtl/parts/DetectorWall.js +496 -0
  106. package/dist/frqtl/parts/FrqtlControlOverlay.js +227 -0
  107. package/dist/frqtl/parts/FrqtlControlsPanel.js +415 -0
  108. package/dist/frqtl/parts/FrqtlVision.js +818 -0
  109. package/dist/frqtl/parts/GraphingDetectorWall.js +490 -0
  110. package/dist/frqtl/parts/LargeObject.js +406 -0
  111. package/dist/frqtl/parts/LightColor.js +255 -0
  112. package/dist/frqtl/parts/LiveChart.js +285 -0
  113. package/dist/frqtl/parts/LogicGate.js +720 -0
  114. package/dist/frqtl/parts/OvenBox.js +612 -0
  115. package/dist/frqtl/parts/PointSource.js +763 -0
  116. package/dist/frqtl/parts/Rectangle.js +200 -0
  117. package/dist/frqtl/parts/SlitLightSource.js +234 -0
  118. package/dist/frqtl/parts/SlitScreen.js +628 -0
  119. package/dist/frqtl/parts/SphereEmitter.js +946 -0
  120. package/dist/frqtl/parts/Trace.js +508 -0
  121. package/dist/frqtl/parts/Tunnel.js +1057 -0
  122. package/dist/frqtl/parts/Wall.js +1211 -0
  123. package/dist/frqtl/parts/index.js +33 -0
  124. package/dist/frqtl/scenes/absorbing-wall-3d.js +33 -0
  125. package/dist/frqtl/scenes/absorbing-wall.js +32 -0
  126. package/dist/frqtl/scenes/atomic-model.js +88 -0
  127. package/dist/frqtl/scenes/big-bang.js +46 -0
  128. package/dist/frqtl/scenes/blackbody.js +81 -0
  129. package/dist/frqtl/scenes/circuit-sat.js +127 -0
  130. package/dist/frqtl/scenes/collision-matrix-3d.js +107 -0
  131. package/dist/frqtl/scenes/counting-wall-3d.js +36 -0
  132. package/dist/frqtl/scenes/counting-wall.js +38 -0
  133. package/dist/frqtl/scenes/double-slit.js +31 -0
  134. package/dist/frqtl/scenes/dse-one-emitter.js +196 -0
  135. package/dist/frqtl/scenes/dse-one-slit.js +114 -0
  136. package/dist/frqtl/scenes/dse-photoelectric.js +70 -0
  137. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +244 -0
  138. package/dist/frqtl/scenes/dse-two-emitters.js +196 -0
  139. package/dist/frqtl/scenes/dse-two-slits.js +132 -0
  140. package/dist/frqtl/scenes/empty.js +14 -0
  141. package/dist/frqtl/scenes/laser-buildup.js +49 -0
  142. package/dist/frqtl/scenes/mirror-3d.js +56 -0
  143. package/dist/frqtl/scenes/mirror-route.js +140 -0
  144. package/dist/frqtl/scenes/mirror.js +38 -0
  145. package/dist/frqtl/scenes/omni-3d.js +27 -0
  146. package/dist/frqtl/scenes/omni-source.js +22 -0
  147. package/dist/frqtl/scenes/self-interference.js +39 -0
  148. package/dist/frqtl/scenes/smoking-gun.js +122 -0
  149. package/dist/frqtl/scenes/straight-walk.js +22 -0
  150. package/dist/frqtl/scenes/sun.js +39 -0
  151. package/dist/frqtl/scenes/two-source.js +37 -0
  152. package/dist/frqtl/scenes/wave.js +26 -0
  153. package/dist/shim/bundle/frqnb.js +112 -0
  154. package/dist/shim/bundle/index.js +121 -0
  155. package/dist/shim/bundle/indexHtml.js +107 -0
  156. package/dist/shim/bundle/manifest.js +95 -0
  157. package/dist/shim/bundle/zip.js +243 -0
  158. package/dist/shim/bus.js +67 -0
  159. package/dist/shim/controls/index.js +627 -0
  160. package/dist/shim/controls/registry.js +274 -0
  161. package/dist/shim/engine/artifact-shell.js +212 -0
  162. package/dist/shim/engine/boot-phases.js +44 -0
  163. package/dist/shim/engine/engine-stack-boot.js +59 -0
  164. package/dist/shim/engine/router.js +692 -0
  165. package/dist/shim/experiment-frame/index.js +296 -0
  166. package/dist/shim/expr/ast-schema.js +369 -0
  167. package/dist/shim/field-descriptor/index.js +326 -0
  168. package/dist/shim/frame-data/index.js +224 -0
  169. package/dist/shim/index.js +67 -0
  170. package/dist/shim/notebook/canvas-guard.js +82 -0
  171. package/dist/shim/notebook/catalog.js +35 -0
  172. package/dist/shim/notebook/format.js +736 -0
  173. package/dist/shim/notebook/frqtl-setups-importmap.js +81 -0
  174. package/dist/shim/notebook/kernel.js +672 -0
  175. package/dist/shim/notebook/knob.js +151 -0
  176. package/dist/shim/notebook/module-resolver.js +260 -0
  177. package/dist/shim/notebook/notebook-knobs.js +65 -0
  178. package/dist/shim/notebook/produced-bindings.js +67 -0
  179. package/dist/shim/notebook/realm-registry.js +121 -0
  180. package/dist/shim/notebook/realm-transport.js +73 -0
  181. package/dist/shim/notebook/setup-builder.js +510 -0
  182. package/dist/shim/notebook/store.js +159 -0
  183. package/dist/shim/notebook/vendor-importmap.js +134 -0
  184. package/dist/shim/notebook/view-builder.js +189 -0
  185. package/dist/shim/notebook/view-ctx.js +100 -0
  186. package/dist/shim/notebook/view-handle.js +202 -0
  187. package/dist/shim/notebook/warmup.js +102 -0
  188. package/dist/shim/package.json +4 -0
  189. package/dist/shim/page-params/index.js +214 -0
  190. package/dist/shim/project/identity.js +112 -0
  191. package/dist/shim/project/index.js +19 -0
  192. package/dist/shim/project/link.js +402 -0
  193. package/dist/shim/project/node-specifier-hook.mjs +35 -0
  194. package/dist/shim/project/register-node.mjs +17 -0
  195. package/dist/shim/project/specifiers.js +106 -0
  196. package/dist/shim/registry/copy-gate.js +110 -0
  197. package/dist/shim/registry/exhibits.js +455 -0
  198. package/dist/shim/registry/experiments.js +215 -0
  199. package/dist/shim/registry/index.js +19 -0
  200. package/dist/shim/registry/scene-source.js +157 -0
  201. package/dist/shim/registry/walkthrough.js +807 -0
  202. package/dist/shim/render/capture-envelope.js +279 -0
  203. package/dist/shim/render/heat-colormap.js +92 -0
  204. package/dist/shim/render/render-stages.js +855 -0
  205. package/dist/shim/render/sun-look.js +33 -0
  206. package/dist/shim/render-state/apply.js +262 -0
  207. package/dist/shim/render-state/index.js +74 -0
  208. package/dist/shim/rpc/index.js +207 -0
  209. package/dist/shim/runtime/index.js +1023 -0
  210. package/dist/shim/storage/boot.js +69 -0
  211. package/dist/shim/storage/index.js +169 -0
  212. package/dist/shim/storage/local-storage-provider.js +230 -0
  213. package/dist/shim/storage/make-namespace-store.js +132 -0
  214. package/dist/shim/storage/modules.js +249 -0
  215. package/dist/shim/storage/projects.js +150 -0
  216. package/llms.txt +12 -0
  217. package/package.json +62 -4
@@ -0,0 +1,2474 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * threeProvider — RendererProvider backed by three.js (WebGL2).
5
+ *
6
+ * Implements the RendererProvider interface (renderer-provider-contract.js):
7
+ * init({ parentEl, width, height }) (parentEl REQUIRED: requireMountHost)
8
+ * renderTick(tickData, largeObjects)
9
+ * filter(filterSpec) / removeFilter(name)
10
+ * setSize(width, height)
11
+ * start() / stop()
12
+ * destroy()
13
+ * raw()
14
+ * captureStill() — OPTIONAL (the `composite` stage tap; same-task GL readback)
15
+ *
16
+ * Design (§3 from WS_RENDERER_SHIM_DESIGN.md):
17
+ * - Retained-mode: geometry/material/mesh created ONCE in init(), buffers mutated
18
+ * each tick. NEVER recreates geometry per tick.
19
+ * - Quanta → Points geometry (MAX pre-allocated; draw range adjusted per tick).
20
+ * - Large objects → Box/Circle geometries created once, visibility toggled.
21
+ * - Orthographic camera maps physics-pixel space exactly (top-left origin, y-down).
22
+ * - Field → DataTexture for water filter (§7).
23
+ * - Filter passes → three.js WebGLRenderTarget + ShaderMaterial full-screen quad.
24
+ *
25
+ * Dependencies:
26
+ * - three.js (the vendored r128 UMD), reached ONLY through compat/vendor.js `three()`.
27
+ *
28
+ * FAIL-LOUD contract:
29
+ * makeThreeProvider() throws (and the boot module paints the overlay) if three is not
30
+ * loaded. Never silently falls back to the p5 provider.
31
+ */
32
+
33
+ import { three, meshLoadersBase } from '../../vendor.js';
34
+ import * as CaptureStillCommon from '../captureStill-common.js';
35
+ import { FieldBuilder } from '../FieldBuilder.js';
36
+ import * as FieldDescriptorSchema from '../../../../shim/field-descriptor/index.js';
37
+ import { Filters } from '../Filters.js';
38
+ import * as FrameDataSchema from '../../../../shim/frame-data/index.js';
39
+ import * as GodraysLayer from './godrays-layer.js';
40
+ import { LightColor } from '../../../parts/LightColor.js';
41
+ import * as LoDrawnCenter from '../lo-drawn-center.js';
42
+ import * as OrbitBag from '../orbit-bag.js';
43
+ import * as RenderCapabilitiesCommon from '../renderCapabilities-common.js';
44
+ import { RenderFamily } from '../RenderFamily.js';
45
+ import { RenderMesh } from '../RenderMesh.js';
46
+ import { RenderShape } from '../RenderShape.js';
47
+ import * as RendererProviderContract from '../renderer-provider-contract.js';
48
+ import * as ThreeCameraContract from './three-camera-contract.js';
49
+ import * as ThreeCameraGeometry from './three-camera-geometry.js';
50
+
51
+
52
+ // Shader: vertex — passes position + CPU-computed color/alpha to fragment.
53
+ // Color comes from LightColor (canonical single source) via per-particle aColor/aAlpha
54
+ // attributes, filled on the CPU each tick. No color spectrum in any shader.
55
+ var QUANTA_VERT = [
56
+ 'attribute float aFullness;',
57
+ 'attribute vec3 aColor;',
58
+ 'attribute float aAlpha;',
59
+ 'attribute float aSize;', // voxel dimension (frame rect.w) — R4-C smoke puff size
60
+ 'attribute float aSmoke;', // per-frame smoke flag (1 = this frame renders as a puff)
61
+ 'uniform float uPersp;', // 1 in 3D (perspective): attenuate point size by depth
62
+ 'uniform float uSmoke;', // 0 = off; >0 = puff size scale per voxel-dimension px (R4-C)
63
+ 'varying vec3 vColor;',
64
+ 'varying float vAlpha;',
65
+ 'varying float vFull;',
66
+ 'varying float vSmoke;',
67
+ 'void main() {',
68
+ ' vColor = aColor;',
69
+ ' vAlpha = aAlpha;',
70
+ ' vFull = aFullness;',
71
+ ' vSmoke = aSmoke;',
72
+ ' vec4 mv = modelViewMatrix * vec4(position, 1.0);',
73
+ ' float size;',
74
+ ' if (uSmoke > 0.0 && aSmoke > 0.5) {',
75
+ // Smoke render-half: size the sprite by the voxel dimension (emergent frame size
76
+ // from Thread B physics) — bigger parented frames draw as bigger puffs. World-px
77
+ // diameter in 2D (ortho 1:1); perspective-attenuated by depth in 3D.
78
+ ' float world = max(2.0, aSize * uSmoke);',
79
+ ' size = mix(world, world * (800.0 / max(1.0, -mv.z)), uPersp);',
80
+ ' } else {',
81
+ // DEFAULT render: the VOXEL SIZE (aSize = frame rect.w) drives the render size —
82
+ // matching the 2D p5/Chroma renderer (color + alpha come from fullness on the CPU).
83
+ // A 1px quantum → ~2px point; a Dim-6 64px voxel → 64px. (2px floor for visibility.)
84
+ ' float base = max(2.0, aSize);',
85
+ ' size = mix(base, base * (800.0 / max(1.0, -mv.z)), uPersp);',
86
+ ' }',
87
+ ' gl_PointSize = size;',
88
+ ' gl_Position = projectionMatrix * mv;',
89
+ '}'
90
+ ].join('\n');
91
+
92
+ // Shader: fragment — glow envelope ONLY; color read from vColor/vAlpha (CPU LightColor).
93
+ // No cold/hot spectrum; no fullness→color calculation here. Single-source rule.
94
+ var QUANTA_FRAG = [
95
+ 'uniform float uIntensity;', // glow gain (additive bloom knob; R4-A)
96
+ 'uniform float uHeat;', // 0..1 heat ramp: shift dense quanta warmer
97
+ 'uniform float uSmoke;', // >0 = soft translucent puff falloff (R4-C)
98
+ 'uniform float uShape;', // 0 = ball (round glow); 1 = box (filled square @ voxel size)
99
+ 'varying vec3 vColor;',
100
+ 'varying float vAlpha;',
101
+ 'varying float vFull;',
102
+ 'varying float vSmoke;',
103
+ 'void main() {',
104
+ ' vec2 c = gl_PointCoord - 0.5;',
105
+ // BOX shader (uShape=1): the point sprite IS a quad, so a Chebyshev-distance fill draws
106
+ // a solid square at the voxel size (1 Dim-0 = 1 px). A bright rim shows the box edge so
107
+ // the boxes-of-balls and their overlap/abutment read clearly. Same frame data, diff shader.
108
+ ' if (uShape > 0.5) {',
109
+ ' float e = max(abs(c.x), abs(c.y)) * 2.0;', // 0 at centre → 1 at the edge
110
+ ' if (e > 1.0) discard;',
111
+ ' float rim = smoothstep(0.80, 1.0, e);',
112
+ ' vec3 bcol = mix(vColor, vColor + vec3(0.55, 0.18, -0.25) * vFull, uHeat);',
113
+ ' bcol = mix(bcol, vec3(1.0), rim * 0.6);', // bright outline
114
+ ' float ba = clamp(vAlpha, 0.15, 1.0) * uIntensity;', // fullness → alpha (min for visibility)
115
+ ' gl_FragColor = vec4(bcol * ba, ba);',
116
+ ' return;',
117
+ ' }',
118
+ ' float d = length(c) * 2.0;',
119
+ ' float glow = smoothstep(1.0, 0.0, d);',
120
+ // Smoke puff: softer, dimmer edge so overlapping puffs build up cloudily.
121
+ ' if (uSmoke > 0.0 && vSmoke > 0.5) { glow = pow(glow, 0.6) * 0.5; }',
122
+ ' float a = glow * vAlpha * uIntensity;',
123
+ ' vec3 col = mix(vColor, vColor + vec3(0.55, 0.18, -0.25) * vFull, uHeat);',
124
+ ' gl_FragColor = vec4(col * a, a);',
125
+ '}'
126
+ ].join('\n');
127
+
128
+ // Full-screen quad vertex for post-process passes
129
+ var QUAD_VERT = [
130
+ 'varying vec2 vTexCoord;',
131
+ 'void main() {',
132
+ ' vTexCoord = uv;',
133
+ ' gl_Position = vec4(position, 1.0);',
134
+ '}'
135
+ ].join('\n');
136
+
137
+ // ── LO SKIN — the ONE derivation of "what colour/opacity an LO is painted" ──
138
+ //
139
+ // Per-face brightness fakes directional shading (the scene carries no lights) so a
140
+ // box reads as a solid 3D object. Face order +x,-x,+y,-y,+z,-z. ONE table: the box
141
+ // branch, the liveCanvas-slab branch and the per-frame refresh all index THIS array
142
+ // (three hand-copies of the same six numbers was a mirror waiting to drift).
143
+ var LO_FACE_MUL = [0.92, 0.72, 1.0, 0.6, 0.86, 0.78];
144
+
145
+ /**
146
+ * The LO's LIVE skin — colour (0-255 triple) + normalized opacity.
147
+ * `lo.color` / `lo.alpha` are the canonical render-only fields the property
148
+ * accessor writes (ObjectSchema `engineVisible:false`); the renderer DERIVES
149
+ * what it paints from them rather than baking them once at mesh build.
150
+ * @param {Object} lo
151
+ * @returns {{color:Array<number>, alpha:number}}
152
+ */
153
+ function loSkin(lo) {
154
+ return {
155
+ color: (lo && lo.color) || [200, 200, 200],
156
+ alpha: (lo && lo.alpha !== undefined) ? lo.alpha / 255 : 1.0
157
+ };
158
+ }
159
+
160
+ /** A change-detection signature for `loSkin` — the per-frame refresh below only
161
+ * touches materials when this moves (a colour edit is rare; the scan is not). */
162
+ function loSkinSig(lo) {
163
+ var s = loSkin(lo);
164
+ return s.color.join(',') + '|' + s.alpha;
165
+ }
166
+
167
+ /**
168
+ * Re-paint an ALREADY-BUILT mesh's material(s) from the LO's live skin.
169
+ *
170
+ * ── Layer 1: API contract ────────────────────────────────────────────────
171
+ * `materials` is a single material or the per-face array (index → LO_FACE_MUL,
172
+ * the same order `_buildLoMesh` builds with). Pure with respect to THREE: it
173
+ * only calls `material.color.setRGB` / sets `opacity`/`transparent`/`depthWrite`,
174
+ * so it is unit-testable with plain fake materials.
175
+ *
176
+ * TWO rules it must not break:
177
+ * - a material carrying a `map` (a ferried liveCanvas / sprite surface) is
178
+ * NEVER tinted — only its opacity follows (`_buildLoMesh` forces white on
179
+ * those for the same reason);
180
+ * - translucency keeps the "read-not-write" depth convention (`depthWrite`
181
+ * only for a fully opaque LO), so a transparent wall stops occluding the
182
+ * quanta behind it instead of punching a hole in them.
183
+ *
184
+ * @param {Object|Array} materials
185
+ * @param {Object} lo
186
+ * @returns {{color:Array<number>, alpha:number}} the applied skin.
187
+ */
188
+ function applyLoSkin(materials, lo) {
189
+ var s = loSkin(lo);
190
+ var list = Array.isArray(materials) ? materials : [materials];
191
+ var perFace = (list.length === LO_FACE_MUL.length);
192
+ for (var i = 0; i < list.length; i++) {
193
+ var m = list[i];
194
+ if (!m) { continue; }
195
+ var mul = perFace ? LO_FACE_MUL[i] : 1;
196
+ if (!m.map && m.color && typeof m.color.setRGB === 'function') {
197
+ m.color.setRGB((s.color[0] / 255) * mul, (s.color[1] / 255) * mul, (s.color[2] / 255) * mul);
198
+ }
199
+ var wantTransparent = m.map ? true : (s.alpha < 1.0);
200
+ if (m.transparent !== wantTransparent) { m.transparent = wantTransparent; m.needsUpdate = true; }
201
+ m.opacity = s.alpha;
202
+ m.depthWrite = (s.alpha >= 1.0);
203
+ }
204
+ return s;
205
+ }
206
+
207
+
208
+
209
+
210
+
211
+
212
+
213
+ // THE one field source (FieldBuilder.buildHeightField). An absent FieldBuilder is a load-order
214
+ // fault, never a skipped field update — the same rule capture.js's `field` tap throws on.
215
+
216
+ // ── renderCapabilities() (OPTIONAL contract method — S5a) ────────────────
217
+ //
218
+ // WS Studio Controls Realignment §7 r2 item 2/4 — see p5-provider.js's
219
+ // identical-purpose block for the full rationale (getCameraControl()
220
+ // optional-capability precedent; per-provider truth; module-level free
221
+ // function so no live WebGL/THREE context is required to call it —
222
+ // capability DESCRIPTORS are static metadata, live VALUES are read via
223
+ // FrqtlApp.getRenderOption/isFilterActive). three.js additionally
224
+ // declares the R4/R5 effect-layer knobs (godrays/godrayDensity/
225
+ // glowIntensity/heat/smoke) — p5 has no such render path (grep confirms;
226
+ // see p5-provider.js's own capability list, which omits them).
227
+ // S5b DRY tidy (architect's S5a gate "minor DRY recommendation" — the 3
228
+ // universal descriptors + the filter loop were hand-copied between this
229
+ // file and p5-provider.js): the ONE shared owner is now
230
+ // `renderCapabilities-common.js` — see that file's own docstring.
231
+
232
+
233
+ function _renderCapabilitiesThree() {
234
+ var normalizeFieldDescriptor = FieldDescriptorSchema.normalizeFieldDescriptor;
235
+ var RenderShapeRef = RenderShape;
236
+ var FrameDataSchemaRef = FrameDataSchema;
237
+ var FiltersRef = Filters;
238
+ var common = RenderCapabilitiesCommon;
239
+
240
+ var caps = common.universalRenderCapabilities(normalizeFieldDescriptor, RenderShapeRef, FrameDataSchemaRef).concat([
241
+ // ── three-only R4/R5 effect-layer knobs (the fx-* pages' ──────────────
242
+ // renderDefaults keys — bounds
243
+ // match the values already hardcoded per-page today, verified against
244
+ // _renderTick's opts.godrays/godrayDensity/glowIntensity/heat/smoke reads).
245
+ normalizeFieldDescriptor({
246
+ name: 'godrays', type: 'number', unit: 'intensity', default: 0,
247
+ constraint: { min: 0, max: 3, integer: false },
248
+ description: 'God-rays post-process composite intensity (three-only final-composite layer).'
249
+ }),
250
+ normalizeFieldDescriptor({
251
+ name: 'godrayDensity', type: 'number', unit: 'density', default: 0.8,
252
+ constraint: { min: 0.2, max: 1.5, integer: false },
253
+ description: 'God-rays radial-blur density/spread (only consulted when godrays > 0).'
254
+ }),
255
+ normalizeFieldDescriptor({
256
+ name: 'godraySource', type: 'enum', default: GodraysLayer.GODRAY_SOURCE_DEFAULT,
257
+ constraint: { values: GodraysLayer.GODRAY_SOURCES.reduce(function (m, v) { m[v] = v; return m; }, {}) },
258
+ description: "What the god-rays are computed from: 'leaf' = the fundamental-layer frames " +
259
+ "(LOs only occlude), 'all' = the whole lit scene (every frame of every layer and every LO)."
260
+ }),
261
+ normalizeFieldDescriptor({
262
+ name: 'glowIntensity', type: 'number', unit: 'intensity', default: 0,
263
+ constraint: { min: 0, max: 3, integer: false },
264
+ description: 'Per-dim glow-layer intensity multiplier (R4-GEO additive glow shading).'
265
+ }),
266
+ normalizeFieldDescriptor({
267
+ name: 'heat', type: 'number', unit: '0-1', default: 0,
268
+ constraint: { min: 0, max: 1, integer: false },
269
+ description: 'Heat-ramp shift: warms dense-quanta color toward the hot end of the scheme.'
270
+ }),
271
+ normalizeFieldDescriptor({
272
+ name: 'smoke', type: 'number', unit: 'intensity', default: 0,
273
+ constraint: { min: 0, max: 16, integer: false },
274
+ description: 'Smoke-puff layer intensity (per-dim greyscale+soft paint gate).'
275
+ })
276
+ ]).concat(common.meshRenderCapabilities(normalizeFieldDescriptor))
277
+ .concat(common.filterRenderCapabilities(normalizeFieldDescriptor, FiltersRef));
278
+
279
+ return caps;
280
+ }
281
+
282
+ // ── Factory ─────────────────────────────────────────────────────────────
283
+
284
+ /**
285
+ * @param {{is3D?: boolean}} [cfg] — the 3D-ness the provider starts with (default false). A host
286
+ * normally states it at init(opts) (FrqtlApp.createCanvas → {is3D, depth}); a harness that
287
+ * reads the camera handle without a WebGL init states it here.
288
+ */
289
+ function makeThreeProvider(cfg) {
290
+ var THREE = three(); // the vendor's one reader (compat/vendor.js) — throws until three is loaded
291
+
292
+ // The runner's physics rect, stated by init({width, height}) / setSize(w, h). null until
293
+ // then: every read goes through _physW()/_physH(), which THROW before it is stated — a
294
+ // provider has no size of its own (RENDERER_CONTRACT §1 init row).
295
+ var _width = null;
296
+ var _height = null;
297
+ function _unsized() {
298
+ return new Error('threeProvider: the physics rect was read before init({width, height}) or ' +
299
+ 'setSize(w, h) stated it. FrqtlApp.createCanvas hands the runner\'s canvas_rect to ' +
300
+ 'provider.init — the provider has no default size.');
301
+ }
302
+ function _physW() { if (_width === null) { throw _unsized(); } return _width; }
303
+ function _physH() { if (_height === null) { throw _unsized(); } return _height; }
304
+
305
+ // three.js core objects (created once in init)
306
+ var _renderer = null;
307
+ var _scene = null;
308
+ var _camera = null;
309
+ var _canvas = null; // the HTMLCanvasElement inside the renderer
310
+
311
+ // ── 3D camera mode (opt-in) ──────────────────────────────────────────
312
+ // 2D (default): the top-down OrthographicCamera below — every existing
313
+ // experiment is byte-unchanged.
314
+ // 3D: an orbit PerspectiveCamera, opted into by the host's init opts (`is3D`, from
315
+ // exp.createScene or a page's `new FrqtlApp({is3D:true})`). The orbit state is
316
+ // driven by getCameraControl().setOrbit() — the sugar handle the experiment wires
317
+ // a widget to (mirrors temperatureControl).
318
+ var _is3D = !!(cfg && cfg.is3D); // (re)stated by _init(opts) — 3D only when told so
319
+ var _depth = null; // the world z-extent the host declared (createScene's d)
320
+ // Render SHAPE: resolved per-tick by RenderShape.resolve(frame, renderOpts) from the
321
+ // priority chain: per-frame render.shape string_map key > setRenderOption('shape',...)
322
+ // default > 'ball'. The construction-time _egptRenderShape global is DELETED — it was
323
+ // construction-time only and had no p5 parity. RenderShape.js is the one shape authority.
324
+ // _currentShapeUniform is set in _renderTick from the experiment's render options.
325
+ var _currentShapeUniform = 0.0; // 0 = ball (default), 1 = box
326
+ // Default orientation convention (STAGE 2, CEO-ruled): x/y = the vertical screen plane,
327
+ // z toward the viewer (right-handed). The scene initially orients with the camera looking
328
+ // STRAIGHT AT the x/y plane from the +z edge of the canvas cube — that is az=0, el=0
329
+ // (pos = (cx, cy, cz+d), forward = (0,0,-1) into the screen). Scene setup = moving the
330
+ // camera RELATIVE to this start. (Was {35,22} = a 3/4 view; now straight-on-z.) Distance
331
+ // is still derived from canvas size at _init (Math.max(w,h)*1.6 — "the edge of the cube").
332
+ // Explicit-camera experiment pages (initialOrbit/setPreset/setOrbit) are UNAFFECTED; only
333
+ // bare-default 3D pages re-base to straight-on-z (build report enumerates them).
334
+ // `target` is the point the orbit revolves around: null = the scene centre
335
+ // (every existing scene), or a walked {x,y,z} once `move()` is used.
336
+ // `roll` is the rotation about the VIEW AXIS (the horizon tilt), degrees,
337
+ // default 0 — the fifth pose coordinate; it WRAPS and is never clamped.
338
+ var _orbit = { azimuth: 0, elevation: 0, distance: 0, roll: 0, target: null }; // distance set in _init from canvas size
339
+
340
+ // Point the live camera at whatever the orbit bag currently says. This
341
+ // function POSITIONS a camera — it derives NOTHING: the eye, the look-at
342
+ // centre and the up-vector all come from the ONE camera authority
343
+ // (three-camera-geometry.js `_orbitCameraBasis` / `_orbitCenter`), which is
344
+ // the SAME basis MOVE-strafe, screenToWorld, the carry pin and the picking
345
+ // ray consume. Two mirrors used to live here — a hand-copied az/el position
346
+ // formula and a hardcoded physics-y-down up-vector literal — and a roll
347
+ // applied to either of them would have rolled the PICTURE while every other
348
+ // consumer kept an unrolled basis. Both are DELETED; this is now a four-line
349
+ // ferry, and a source gate asserts no up-vector literal ever comes back
350
+ // (camera-roll-geometry.test.mjs).
351
+ //
352
+ // It is also the ONE place a pose write lands (setOrbit, look, move, setPreset, a
353
+ // resize), so it is where the POSE-CHANGE NOTICE fires: every write reaches every
354
+ // `addPoseListener` subscriber, whichever surface made it.
355
+ function _applyOrbit() {
356
+ if (!_is3D) { return; }
357
+ if (_camera) {
358
+ var _g = ThreeCameraGeometry;
359
+ var c = _g._orbitCenter(_orbit, _physW(), _physH());
360
+ var b = _g._orbitCameraBasis(_orbit, _physW(), _physH());
361
+ _camera.position.set(b.pos.x, b.pos.y, b.pos.z);
362
+ _camera.up.set(b.up.x, b.up.y, b.up.z); // DERIVED (rolled), never a literal
363
+ _camera.lookAt(c.x, c.y, c.z);
364
+ _camera.updateProjectionMatrix();
365
+ }
366
+ if (_poseNotice) { _poseNotice.notify(_orbitBag()); }
367
+ }
368
+
369
+ // The whole pose bag, COPIED (a captured pose must not keep tracking the camera): what
370
+ // getOrbit() returns and what every pose-change listener receives.
371
+ function _orbitBag() {
372
+ return {
373
+ azimuth: _orbit.azimuth, elevation: _orbit.elevation, distance: _orbit.distance,
374
+ // ROLL is a DECLARED field: a roll-capable provider always returns it as a
375
+ // number, so a consumer's capability check is `typeof cam.getOrbit().roll ===
376
+ // 'number'` — field presence, the peer of move()'s method presence. Absent ⇒
377
+ // hide the affordance, never substitute a different rotation.
378
+ roll: _orbit.roll,
379
+ // Carried so a captured pose ROUND-TRIPS through setOrbit (a saved project
380
+ // restores the walk, not just the look).
381
+ target: _orbit.target
382
+ ? { x: _orbit.target.x, y: _orbit.target.y, z: _orbit.target.z } : null
383
+ };
384
+ }
385
+
386
+ // The pose-change listener list (OrbitBag.makePoseNotice), provider-scoped so every
387
+ // handle getCameraControl() returns shares it. Created on the first subscription.
388
+ var _poseNotice = null;
389
+
390
+ // ── Per-dim render layers (R4-GEO, 2026-07-02 CEO geometry-aware effects design,
391
+ // the "smoke design SETTLED" ruling) ────────────────────────────────────────
392
+ // Replaces the old single _quantaMesh (all non-flash dims merged) + _flashMesh
393
+ // (all flash-eligible dims merged) pair with ONE THREE.Points object per
394
+ // POPULATED (dim, family) bucket — family 'A' (additive, the flash/hot family)
395
+ // or 'N' (normal-blend, everything else). "Dim decides APPEARANCE, position
396
+ // decides OCCLUSION" (CEO ruling): a frame's dim and the glow/smoke options pick
397
+ // its color/alpha/blend/paint; every bucket depth-READS (so walls occlude quanta
398
+ // for real — the root-cause fix) but does NOT depth-write (translucent layers
399
+ // never occlude each other via the z-buffer alone — that ordering is the
400
+ // explicit camera-distance renderOrder sort in `_renderTick`, painter's
401
+ // algorithm). This is what lets smoke in front of the flash dim it, and the
402
+ // existing 3-pass god-ray march (UNCHANGED, zero new shader math) becomes
403
+ // geometry-correct for free once the image it reads is honest. No parent/leaf
404
+ // distinction: "Dim 4 is smoke and Dim 5 is smoke — just different sizes."
405
+ // Buckets are created lazily (retained-mode: create once, mutate buffers,
406
+ // grow-on-demand — same discipline as the LO mesh Map below) and never
407
+ // disposed (an idle, hidden bucket is cheap; mirrors the LO-by-reference Map).
408
+ var _dimLayers = {}; // key "<dim>|A"/"<dim>|N" -> entry (see _ensureDimLayer)
409
+ var _DIM_LAYER_INITIAL_CAP = 2048;
410
+
411
+ function _makeDimLayerBuffers(cap) {
412
+ return {
413
+ cap: cap,
414
+ posArr: new Float32Array(cap * 3),
415
+ fullArr: new Float32Array(cap),
416
+ colArr: new Float32Array(cap * 3), // CPU-computed LightColor (0-1 range)
417
+ alphaArr: new Float32Array(cap),
418
+ sizeArr: new Float32Array(cap), // voxel dimension (frame rect.w; R4-C)
419
+ smokeArr: new Float32Array(cap) // paint==='soft' puff-size gate (R4-C, reused verbatim)
420
+ };
421
+ }
422
+
423
+ // One THREE object per (dim, family) bucket. `isAdditive` picks blending +
424
+ // whether the material carries a `uShape` uniform (mirrors the pre-existing
425
+ // _quantaMat-vs-_flashMat asymmetry: flash quanta never switch ball/box shape).
426
+ // `paint` picks the MESH KIND: 'sphere' -> a THREE.InstancedMesh (R6, 2026-07-03,
427
+ // this bucket's row-purity-locked paint never changes mid-run — see the
428
+ // row-purity structural test — so deciding the mesh kind ONCE at creation is safe);
429
+ // 'crisp'/'soft' -> the existing ShaderMaterial Points bucket, unchanged below.
430
+ function _ensureDimLayer(key, isAdditive, paint) {
431
+ var entry = _dimLayers[key];
432
+ if (entry) { return entry; }
433
+
434
+ if (paint === 'sphere') {
435
+ var sphereMesh = _buildSphereInstancedMesh(_DIM_LAYER_INITIAL_CAP);
436
+ sphereMesh.visible = false;
437
+ _scene.add(sphereMesh);
438
+ entry = { isSphere: true, mesh: sphereMesh, capacity: _DIM_LAYER_INITIAL_CAP, centroid: { x: 0, y: 0, z: 0 } };
439
+ _dimLayers[key] = entry;
440
+ return entry;
441
+ }
442
+
443
+ var bufs = _makeDimLayerBuffers(_DIM_LAYER_INITIAL_CAP);
444
+ var geo = new THREE.BufferGeometry();
445
+ geo.setAttribute('position', new THREE.BufferAttribute(bufs.posArr, 3));
446
+ geo.setAttribute('aFullness', new THREE.BufferAttribute(bufs.fullArr, 1));
447
+ geo.setAttribute('aColor', new THREE.BufferAttribute(bufs.colArr, 3));
448
+ geo.setAttribute('aAlpha', new THREE.BufferAttribute(bufs.alphaArr, 1));
449
+ geo.setAttribute('aSize', new THREE.BufferAttribute(bufs.sizeArr, 1));
450
+ geo.setAttribute('aSmoke', new THREE.BufferAttribute(bufs.smokeArr, 1));
451
+ geo.setDrawRange(0, 0);
452
+
453
+ var uniforms = {
454
+ uIntensity: { value: 1.0 },
455
+ uHeat: { value: 0.0 },
456
+ uSmoke: { value: 0.0 },
457
+ uPersp: { value: _is3D ? 1.0 : 0.0 }
458
+ };
459
+ if (!isAdditive) { uniforms.uShape = { value: _currentShapeUniform }; }
460
+
461
+ var mat = new THREE.ShaderMaterial({
462
+ vertexShader: QUANTA_VERT,
463
+ fragmentShader: QUANTA_FRAG,
464
+ transparent: true,
465
+ // depth-READ (walls/any depth-writing geometry occludes quanta for
466
+ // real); depth-WRITE stays OFF ("read not write" — CEO ruling). Layer
467
+ // vs layer visual ordering is the explicit renderOrder sort below.
468
+ depthTest: true,
469
+ depthWrite: false,
470
+ uniforms: uniforms
471
+ });
472
+ if (isAdditive) { mat.blending = THREE.AdditiveBlending; }
473
+
474
+ var mesh = new THREE.Points(geo, mat);
475
+ mesh.visible = false;
476
+ _scene.add(mesh);
477
+
478
+ entry = { geo: geo, mat: mat, mesh: mesh, isAdditive: isAdditive, bufs: bufs, centroid: { x: 0, y: 0, z: 0 } };
479
+ _dimLayers[key] = entry;
480
+ return entry;
481
+ }
482
+
483
+ function _growDimLayerIfNeeded(entry, needed) {
484
+ if (entry.isSphere) {
485
+ if (needed <= entry.capacity) { return; }
486
+ var newSphereCap = entry.capacity;
487
+ while (newSphereCap < needed) { newSphereCap = newSphereCap * 2; }
488
+ // InstancedMesh capacity is fixed at construction (no growable buffer like
489
+ // BufferGeometry attributes) — grow-by-recreate, mirroring _ensureCubeMesh's
490
+ // own grow path exactly (dispose the old mesh, build a fresh bigger one,
491
+ // preserve visibility/renderOrder across the swap).
492
+ var oldSphereMesh = entry.mesh;
493
+ var newSphereMesh = _buildSphereInstancedMesh(newSphereCap);
494
+ newSphereMesh.visible = oldSphereMesh.visible;
495
+ newSphereMesh.renderOrder = oldSphereMesh.renderOrder;
496
+ _scene.remove(oldSphereMesh);
497
+ oldSphereMesh.dispose();
498
+ _scene.add(newSphereMesh);
499
+ entry.mesh = newSphereMesh;
500
+ entry.capacity = newSphereCap;
501
+ return;
502
+ }
503
+ if (needed <= entry.bufs.cap) { return; }
504
+ var newCap = entry.bufs.cap;
505
+ while (newCap < needed) { newCap = newCap * 2; }
506
+ entry.bufs = _makeDimLayerBuffers(newCap);
507
+ entry.geo.setAttribute('position', new THREE.BufferAttribute(entry.bufs.posArr, 3));
508
+ entry.geo.setAttribute('aFullness', new THREE.BufferAttribute(entry.bufs.fullArr, 1));
509
+ entry.geo.setAttribute('aColor', new THREE.BufferAttribute(entry.bufs.colArr, 3));
510
+ entry.geo.setAttribute('aAlpha', new THREE.BufferAttribute(entry.bufs.alphaArr, 1));
511
+ entry.geo.setAttribute('aSize', new THREE.BufferAttribute(entry.bufs.sizeArr, 1));
512
+ entry.geo.setAttribute('aSmoke', new THREE.BufferAttribute(entry.bufs.smokeArr, 1));
513
+ }
514
+
515
+ function _hideAllDimLayers() {
516
+ for (var k in _dimLayers) {
517
+ if (_dimLayers.hasOwnProperty(k)) { _dimLayers[k].mesh.visible = false; }
518
+ }
519
+ }
520
+
521
+ // Squared distance from the active camera to a dim-layer's centroid — plain
522
+ // arithmetic (no THREE.Vector3 allocation) for the per-tick sort below.
523
+ function _camDistSq(c) {
524
+ var dx = c.x - _camera.position.x, dy = c.y - _camera.position.y, dz = c.z - _camera.position.z;
525
+ return dx * dx + dy * dy + dz * dz;
526
+ }
527
+
528
+ // Large objects: Map keyed by the LO OBJECT REFERENCE → { mesh, sig }. Keying by
529
+ // identity (not position) lets a MOVING LO (e.g. a Bullet) reuse its single mesh and
530
+ // just update its position each frame — no mesh-per-tick leak. The mesh is rebuilt only
531
+ // when the LO's SHAPE/skin signature (size, circle-vs-rect, renderHint/sprite) changes.
532
+ var _loMeshes = new Map();
533
+
534
+ // ── Cube instanced-mesh layer (3D + shape='cube' only) ──────────────────
535
+ // When the experiment opts in via setRenderOption('shape','cube') AND _is3D, the
536
+ // quanta render as TRUE shaded 3D cube meshes (THREE.InstancedMesh + BoxGeometry +
537
+ // MeshLambertMaterial) instead of flat point-sprites. The existing Points mesh
538
+ // becomes invisible; the cube mesh takes over. Both paths share the same CPU color
539
+ // computation (LightColor). The cube mesh is lazily created on first cube-tick.
540
+ //
541
+ // Gating: RenderShape.isCubeMesh(resolvedShape) && _is3D
542
+ // → true: hide Points, show cube InstancedMesh.
543
+ // → false: show Points (all existing experiments unchanged), hide cube.
544
+ //
545
+ // The AmbientLight + DirectionalLight added in _init are invisible to ShaderMaterial
546
+ // (the Points shader is custom GLSL that ignores the three.js lighting system), so
547
+ // existing experiments are byte-identical to before this change.
548
+ var _cubeMesh = null; // THREE.InstancedMesh — created lazily
549
+ var _cubeGeo = null; // THREE.BoxGeometry — created lazily (voxel-sized)
550
+ var _cubeCapacity = 0; // current max-instance count (grows in powers of 2)
551
+ var _cubeMat4 = null; // THREE.Matrix4 scratch (reused per-instance, no alloc)
552
+ var _cubeColor = null; // THREE.Color scratch (reused per-instance, no alloc)
553
+
554
+ // ── Aim feedback layer (opts.aimFeedback — see _updateAimFeedback): the universe cube,
555
+ // the screen-centre reticle, the shadow-surrounds, the lock glyph and the heading ring.
556
+ // Lazily created, toggled .visible + transformed per frame — never rebuilt per tick.
557
+ var _aimCube = null; // THREE.LineSegments — universe-bounds wireframe
558
+ var _aimCubeSig = null; // last bounds signature (rebuild only on a bounds change)
559
+ var _aimReticle = null; // THREE.LineSegments — the screen-centre reticle
560
+ var _aimRing = null; // THREE.LineSegments — passive heading ring + spoke
561
+ var _aimLock = null; // THREE.LineSegments — the grabbed lock glyph
562
+ var _aimHoverSur = null; // shadow-surround for the aimed / grabbed object
563
+ var _aimSelSur = null; // shadow-surround for the host selection
564
+ var _feedbackScene = null; // SEPARATE scene drawn as a final overlay pass AFTER all FX
565
+ // composites so bloom/god-rays can never wash it out (G2)
566
+
567
+ // ── Sphere InstancedMesh — per-dim baseline (R6, 2026-07-03 CEO in-page ────
568
+ // per-dim matrix drawing: Effect=None -> paint:'sphere', "the 2D coloring
569
+ // extended to 3D"). Mirrors the cube InstancedMesh precedent immediately above
570
+ // (SAME pre-created-white-instanceColor / frustumCulled=false / MeshLambertMaterial-
571
+ // reads-instanceColor-for-free gotchas) with ONE difference: cubes share ONE
572
+ // voxel-size for the whole scene (`_ensureCubeMesh(size,...)`), but spheres are
573
+ // PER-(dim,family)-BUCKET (see _dimLayers below) and size PER-INSTANCE via the
574
+ // instance matrix's scale component (frames within one dim can have varying box
575
+ // sizes — "Dim 4 is smoke and Dim 5 is smoke, just different sizes," the same
576
+ // per-dim-not-per-parent-leaf principle applies to spheres) — so ONE shared UNIT
577
+ // sphere geometry (radius 0.5) suffices for every bucket; only the material +
578
+ // InstancedMesh are per-bucket (built in `_buildSphereInstancedMesh`, called
579
+ // from `_ensureDimLayer` when `bucket.paint === 'sphere'`).
580
+ var _sphereGeo = null; // THREE.SphereGeometry — SHARED unit sphere (radius 0.5), created once
581
+ var _sphereMat4 = null; // THREE.Matrix4 scratch (reused per-instance, no alloc)
582
+ var _spherePos = null; // THREE.Vector3 scratch (position component of the composed matrix)
583
+ var _sphereQuat = null; // THREE.Quaternion scratch — identity, spheres never rotate
584
+ var _sphereScale = null; // THREE.Vector3 scratch (scale component — per-instance sizing)
585
+ var _sphereColor = null; // THREE.Color scratch (reused per-instance, no alloc)
586
+
587
+ // ── render.mesh external-geometry path (3D + render.mesh asset id) ───────
588
+ // When a frame/experiment routes to an external glTF (RenderMesh.resolve), the quanta
589
+ // render as CLONES of the loaded mesh instead of cube/sprite. State:
590
+ var _meshLoader = null; // THREE.GLTFLoader (lazy; DRACOLoader attached if present)
591
+ var _meshCache = {}; // assetId → { status:'loading'|'ready'|'error', scene, norm, error }
592
+ var _meshGroup = null; // THREE.Group holding all clones (added to _scene once, lazily)
593
+ // Per-ASSET clone sub-pools (spec §2 clone-pool fix): `assetId -> [Object3D]`. A single
594
+ // flat pool indexed by frame index would let TWO concurrent glTF assets (e.g. a per-frame
595
+ // bullet mesh + a scene-wide default) fight over the same clone slots. Keying the pool by
596
+ // asset id keeps each asset's clones independent, so a per-frame render.mesh (a
597
+ // projectile body) coexists with any other mesh in the same scene.
598
+ var _meshClonesByAsset = {}; // assetId -> pool of cloned Object3D (reused across ticks; extras hidden)
599
+
600
+ // Field DataTexture for filter passes (smooth heightfield from FieldBuilder)
601
+ var _fieldFw = 0;
602
+ var _fieldFh = 0;
603
+ var _fieldTex = null;
604
+ var _fieldData = null; // Uint8Array(FIELD_OPTS.w * FIELD_OPTS.h)
605
+
606
+ // Render targets for post-process
607
+ var _rtA = null;
608
+ var _rtB = null;
609
+ var _quadScene = null;
610
+ var _quadCamera = null;
611
+ var _quadMeshes = {}; // per-filter name → Mesh
612
+
613
+ var _filters = []; // ordered list of active filterSpec objects
614
+
615
+ // ── R4-B god-rays layer (GodraysLayer.makeGodrayPasses; created lazily on first activation) ──
616
+ var _godrays = null;
617
+ var _cubeSourceCount = 0; // cube instances [0, n) are god-rays source frames
618
+
619
+ var _initialized = false;
620
+ var _lastTickData = null;
621
+ // What the LAST composite actually ran with — recorded AT compositing time, never
622
+ // re-read from the runner later: this provider is the authority for what it
623
+ // composited (RENDERER_CONTRACT §1a.4, no caller-supplied provenance), and
624
+ // captureStill() replays _composeFinal with THIS bag so the readback is the same
625
+ // picture. null until the first renderTick = "nothing composited yet".
626
+ var _lastOpts = null;
627
+ var _lastRenderBaseCanvas = null;
628
+ // The `uTime` the LAST final composite fed its filter pass (the contract's filterTime of
629
+ // the rendered tick); null when no filter ran.
630
+ var _lastFilterTime = null;
631
+
632
+ // ── Init ─────────────────────────────────────────────────────────────
633
+
634
+ function _init(opts) {
635
+ RendererProviderContract.requireProviderSize('threeProvider.init',
636
+ opts && opts.width, opts && opts.height);
637
+ // The mount host is resolved BEFORE any GL resource exists: a missing or unknown
638
+ // canvasParentId throws the contract's sentence, never a body-append (no default mount).
639
+ var host = RendererProviderContract.requireMountHost('threeProvider.init', opts.parentEl);
640
+ _width = opts.width;
641
+ _height = opts.height;
642
+ _is3D = !!(opts && opts.is3D);
643
+ _depth = (opts && opts.depth !== undefined) ? opts.depth : null;
644
+
645
+ // Renderer
646
+ _renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false });
647
+ _renderer.setSize(_physW(), _physH());
648
+ _renderer.setPixelRatio(1);
649
+ _canvas = _renderer.domElement;
650
+
651
+ host.appendChild(_canvas);
652
+
653
+ if (_is3D) {
654
+ // 3D: orbit PerspectiveCamera around the scene centre. The fixed-pixel
655
+ // point shader keeps quanta visible at any depth. near/far span the
656
+ // physics-pixel volume generously.
657
+ // CAMERA_FOV_DEG is the ONE vertical-FOV constant, owned by
658
+ // three-camera-geometry.js — the analytic picking ray and this live
659
+ // frustum share it by construction, never a second literal 50.
660
+ _camera = new THREE.PerspectiveCamera(
661
+ ThreeCameraGeometry.CAMERA_FOV_DEG, _physW() / _physH(), 0.1, 100000);
662
+ _orbit.distance = Math.max(_physW(), _physH()) * 1.6;
663
+ _applyOrbit();
664
+ } else {
665
+ // 2D (default): top-down OrthographicCamera, physics-pixel space, y-down.
666
+ // THREE is y-up by default, so we invert y via the camera bounds
667
+ // (top=0, bottom=_physH()). Unchanged from the pre-3D provider.
668
+ _camera = new THREE.OrthographicCamera(0, _physW(), _physH(), 0, -1, 1);
669
+ }
670
+
671
+ _scene = new THREE.Scene();
672
+ _scene.background = new THREE.Color(0x000000);
673
+
674
+ // ── Lights for the cube InstancedMesh path ────────────────────────
675
+ // MeshLambertMaterial requires real lights. These have ZERO effect on the
676
+ // existing ShaderMaterial Points mesh (custom GLSL ignores the three.js
677
+ // lighting system). Cost is negligible (two light uniforms uploaded once).
678
+ // Present always (not gated on shape) because shape can change at runtime.
679
+ var _ambient = new THREE.AmbientLight(0xffffff, 0.45);
680
+ _scene.add(_ambient);
681
+ // Key light: angled from upper-right in world space — when the camera orbits,
682
+ // the top/front faces catch the key and the side/back faces go darker, giving
683
+ // depth shading that makes cube faces unambiguous from every orbit angle.
684
+ var _dirLight = new THREE.DirectionalLight(0xffffff, 0.9);
685
+ _dirLight.position.set(1.2, -2.0, 2.5).normalize(); // y is physics-down → -y = "up"
686
+ _scene.add(_dirLight);
687
+ // Scratch objects for the cube path (reused per-frame — zero GC).
688
+ _cubeMat4 = new THREE.Matrix4();
689
+ _cubeColor = new THREE.Color();
690
+ // Scratch objects for the sphere InstancedMesh path (reused per-instance —
691
+ // zero GC, same discipline as the cube scratch objects above).
692
+ _sphereMat4 = new THREE.Matrix4();
693
+ _spherePos = new THREE.Vector3();
694
+ _sphereQuat = new THREE.Quaternion(); // stays identity — spheres never rotate
695
+ _sphereScale = new THREE.Vector3();
696
+ _sphereColor = new THREE.Color();
697
+
698
+ // Per-dim render layers (_dimLayers) are created LAZILY on first population
699
+ // in `_renderTick` (`_ensureDimLayer`) — there is nothing to build here at
700
+ // init time; unlike the old fixed _quantaMesh/_flashMesh pair, the set of
701
+ // populated dims isn't known until the first tick's frameData arrives.
702
+
703
+ // ── Field DataTexture (smooth heightfield from FieldBuilder) ──────
704
+ var fOpts = FieldBuilder.FIELD_OPTS;
705
+ _fieldFw = fOpts.w;
706
+ _fieldFh = fOpts.h;
707
+ _fieldData = new Uint8Array(_fieldFw * _fieldFh);
708
+ _fieldTex = new THREE.DataTexture(
709
+ _fieldData, _fieldFw, _fieldFh,
710
+ THREE.RedFormat, THREE.UnsignedByteType
711
+ );
712
+ _fieldTex.minFilter = THREE.LinearFilter;
713
+ _fieldTex.magFilter = THREE.LinearFilter;
714
+ _fieldTex.needsUpdate = true;
715
+
716
+ // ── Render targets for post-process ───────────────────────────────
717
+ _rtA = new THREE.WebGLRenderTarget(_physW(), _physH());
718
+ _rtB = new THREE.WebGLRenderTarget(_physW(), _physH());
719
+
720
+ // Quad scene for full-screen post-process passes
721
+ _quadScene = new THREE.Scene();
722
+ _quadCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
723
+
724
+ _initialized = true;
725
+ }
726
+
727
+ // ── Large objects ─────────────────────────────────────────────────────
728
+
729
+ // Shape/skin signature (NOT position): a change here rebuilds the mesh; movement does not.
730
+ // Includes the render.mesh asset id AND its load status, so the async glTF load flipping
731
+ // loading→ready changes the signature and rebuilds the placeholder into the real geometry.
732
+ function _loSig(lo, opts) {
733
+ var meshAsset = RenderMesh.resolve(lo, opts);
734
+ var meshStatus = (meshAsset && _meshCache[meshAsset]) ? _meshCache[meshAsset].status : '';
735
+ // lo.rect.d included (CEO drive #2 structural fix, 2026-07-14): box-vs-
736
+ // plane can now be DERIVED from rect.d alone (see _buildLoMesh below) —
737
+ // an LO that never sets the explicit lo.shape/lo.depth hint pair still
738
+ // needs its signature sensitive to rect.d so a depth change rebuilds it.
739
+ return [lo.shape || (lo.drawAsCircle ? 'c' : 'r'), lo.rect.w, lo.rect.h, lo.depth || '', lo.rect.d || '',
740
+ lo.shellShape || '', // R3-5: a live shell-shape change rebuilds the mesh
741
+ lo.renderHint || '', lo.sprite || '',
742
+ (lo.liveCanvas ? 'L' : ''), // LiveChart: presence flips to the live-texture plane branch
743
+ // A renderHint texture is BAKED with the LO's colour (_hintTexture caches by
744
+ // hint+colour), so for THAT branch alone the colour belongs in the signature.
745
+ // Every other branch re-derives its colour per frame (applyLoSkin) — colour is
746
+ // deliberately NOT a general rebuild trigger.
747
+ (lo.renderHint ? (lo.color || []).join('-') : ''),
748
+ meshAsset || '', meshStatus, lo.meshScale || '',
749
+ (lo.meshRotation || []).join('|')].join(',');
750
+ }
751
+
752
+ // Resolve a LiveChart LO's CURRENT chart canvas. lo.liveCanvas is an accessor
753
+ // (a function returning the current HTMLCanvasElement — makeChartEnv recreates the
754
+ // canvas each redraw) OR a direct canvas element. Returns the element or null.
755
+ function _resolveLiveCanvas(lo) {
756
+ var lc = lo && lo.liveCanvas;
757
+ if (!lc) { return null; }
758
+ try {
759
+ var cv = (typeof lc === 'function') ? lc() : lc;
760
+ return cv || null;
761
+ } catch (e) { return null; }
762
+ }
763
+
764
+ // ── liveCanvas texturing — ONE construction site, orientation derived in WORLD SPACE ──
765
+ //
766
+ // THE FACE→WORLD TABLE. Derived from THREE.BoxGeometry's own six buildPlane()
767
+ // calls (u,v,w axes + udir/vdir signs) and PlaneGeometry's vertex/uv order:
768
+ // for each face, as UV.x runs 0→1 the world coordinate on `uAxis` moves in
769
+ // direction `uDir`; as UV.y runs 0→1 the world coordinate on `vAxis` moves in
770
+ // direction `vDir`. (buildPlane pushes `uv.x = ix/gridX` with world[u] = x·udir,
771
+ // and `uv.y = 1 - iy/gridY` with world[v] = y·vdir — hence vDir = −vdir.)
772
+ //
773
+ // idx 0 (+x): u=z,uDir=−1 v=y,vDir=+1 idx 3 (−y): u=x,uDir=+1 v=z,vDir=+1
774
+ // idx 1 (−x): u=z,uDir=+1 v=y,vDir=+1 idx 4 (+z): u=x,uDir=+1 v=y,vDir=+1
775
+ // idx 2 (+y): u=x,uDir=+1 v=z,vDir=−1 idx 5 (−z): u=x,uDir=−1 v=y,vDir=+1
776
+ // plane : u=x,uDir=+1 v=y,vDir=+1
777
+ //
778
+ // The ferried canvas is painted in WORLD-AXIS-INCREASING order (canvas x → the
779
+ // face's horizontal world axis increasing, canvas y → its vertical world axis
780
+ // increasing). So the texture transform is a DERIVATION, never a hand-tuned sign:
781
+ // flipY = false — canvas row 0 must sample at UV.y = 0 (the mapping above is
782
+ // stated in raw UV, and THREE's default flipY would invert it);
783
+ // mirrorU/mirrorV — set exactly when that face's uDir/vDir is −1.
784
+ // This is why the same painted canvas reads CORRECT from the front and as the
785
+ // through-the-window MIRROR from behind: both faces show the texel at its true
786
+ // world position, which is what "spatially registered" means. Hand-mirrored signs
787
+ // tuned to one camera are the banned class (see _applyOrbit's own note: the world
788
+ // holds RAW y-down physics values; only camera.up is flipped).
789
+ var LIVE_FACE_UV = [
790
+ { uAxis: 'z', uDir: -1, vAxis: 'y', vDir: 1 }, // 0 +x
791
+ { uAxis: 'z', uDir: 1, vAxis: 'y', vDir: 1 }, // 1 −x
792
+ { uAxis: 'x', uDir: 1, vAxis: 'z', vDir: -1 }, // 2 +y
793
+ { uAxis: 'x', uDir: 1, vAxis: 'z', vDir: 1 }, // 3 −y
794
+ { uAxis: 'x', uDir: 1, vAxis: 'y', vDir: 1 }, // 4 +z
795
+ { uAxis: 'x', uDir: -1, vAxis: 'y', vDir: 1 } // 5 −z
796
+ ];
797
+
798
+ /**
799
+ * The wall's THIN axis (the one its broad faces are normal to), derived from
800
+ * the drawn extents — generic, no per-object hint and no scene special-case.
801
+ * A flat LO (no z-slab) is thin along z by construction.
802
+ * @returns {{axis:'x'|'y'|'z', faces:number[]}} the two face indices to texture.
803
+ */
804
+ function _thinAxisFaces(w, h, d) {
805
+ if (!(d > 0)) { return { axis: 'z', faces: [4, 5] }; }
806
+ if (w <= h && w <= d) { return { axis: 'x', faces: [0, 1] }; }
807
+ if (h <= w && h <= d) { return { axis: 'y', faces: [2, 3] }; }
808
+ return { axis: 'z', faces: [4, 5] };
809
+ }
810
+
811
+ /**
812
+ * THE ONE world-texture constructor: every texture an LO carries — a ferried
813
+ * liveCanvas (mesh build + per-frame refresh), a renderHint paint, an lo.sprite
814
+ * image — is built here, so the orientation derivation has exactly one home.
815
+ * @param {HTMLCanvasElement|string} src — a canvas, or an image URL (lo.sprite)
816
+ * @param {{mirrorU:boolean, mirrorV:boolean}} [xf] — from LIVE_FACE_UV.
817
+ */
818
+ function _makeWorldTexture(src, xf) {
819
+ var t = (typeof src === 'string') ? new THREE.TextureLoader().load(src) : new THREE.CanvasTexture(src);
820
+ t.minFilter = THREE.LinearFilter;
821
+ t.flipY = false;
822
+ if (xf && xf.mirrorU) { t.wrapS = THREE.RepeatWrapping; t.repeat.x = -1; t.offset.x = 1; }
823
+ if (xf && xf.mirrorV) { t.wrapT = THREE.RepeatWrapping; t.repeat.y = -1; t.offset.y = 1; }
824
+ return t;
825
+ }
826
+
827
+ /**
828
+ * THE ONE material shape for a world-textured flat plane (a LiveChart plane, a
829
+ * renderHint paint, an lo.sprite). DoubleSide travels with the world registration:
830
+ * the texel sits at its true world position, so the plane reads from wherever the
831
+ * camera stands — from behind as the through-the-window mirror — and an orbit past
832
+ * 90 degrees never silently blanks it.
833
+ */
834
+ function _worldPlaneMaterial(map, alpha, depthWrite) {
835
+ return new THREE.MeshBasicMaterial({
836
+ map: map, transparent: true, opacity: alpha, depthWrite: depthWrite,
837
+ side: THREE.DoubleSide
838
+ });
839
+ }
840
+
841
+ /** The texture transform for a face index (or the flat-plane case, index 4). */
842
+ function _liveFaceTransform(faceIdx) {
843
+ var f = LIVE_FACE_UV[faceIdx] || LIVE_FACE_UV[4];
844
+ return { mirrorU: f.uDir < 0, mirrorV: f.vDir < 0 };
845
+ }
846
+
847
+ // Build the THREE.Mesh for one LO (geometry + flat/textured material). Position is set
848
+ // by the caller every frame so a moving LO follows its rect.
849
+ //
850
+ // 3D SUGAR OBJECTS: an LO may declare lo.shape='box' (+ optional lo.depth) — the renderer
851
+ // then WRAPS the conceptual LargeObject with a 3D BoxGeometry (a solid cube of w×h×depth)
852
+ // instead of a flat plane, so it reads as a real 3D object from every angle. This is the
853
+ // first of a family of 3D render shapes the renderer can wrap around an LO (the gun will
854
+ // become one too). The engine/physics never see this — it is a renderer-only field.
855
+ function _buildLoMesh(lo, opts) {
856
+ var alpha = (lo.alpha !== undefined) ? lo.alpha / 255 : 1.0;
857
+ // Deterministic translucency (FRQTL-MAINT 2026-07-02, CEO assessment
858
+ // prerequisite): "translucent LOs read-not-write, opaque LOs write" — the
859
+ // SAME depthTest:true/depthWrite:false convention the per-dim quanta layers
860
+ // already follow (see _ensureDimLayer above). Previously EVERY LO material
861
+ // left depthWrite at its THREE default (true) regardless of opacity, so a
862
+ // translucent LO (e.g. a Tube wall at wallOpacity<1) WROTE depth — whether an
863
+ // interior sprite then read as occluded or visible depended on draw/insertion
864
+ // order, not true 3D position (the wall-occlusion inconsistency: CEO saw
865
+ // see-through where the tester saw near-black — both real, nondeterministic).
866
+ // Generalized here for ANY LO (not a Tube special-case): opaque (alpha>=1, the
867
+ // default when lo.alpha is unset) still writes depth as before — zero change
868
+ // for every existing opaque LO (detector walls, source bodies, etc).
869
+ var loDepthWrite = alpha >= 1.0;
870
+
871
+ // ── render.mesh: wrap the LO with an external glTF geometry (highest priority) ──
872
+ // A source-body glTF routes here (lo.mesh = '/frqtl/assets/models/<asset>.glb').
873
+ // Reuses the same async load/cache/normalize helpers as the per-quantum mesh path.
874
+ // The unit-normalized scene is scaled so its max dimension fills the LO's largest
875
+ // rect extent; an optional lo.meshScale / lo.meshRotation tunes fit + orientation.
876
+ var meshAsset = RenderMesh.resolve(lo, opts);
877
+ if (meshAsset) {
878
+ _ensureMeshLoaded(meshAsset);
879
+ var mEntry = _meshCache[meshAsset];
880
+ if (mEntry && mEntry.status === 'ready' && mEntry.scene) {
881
+ var g = mEntry.scene.clone(true);
882
+ g.frustumCulled = false;
883
+ var fit = Math.max(lo.rect.w, lo.rect.h, lo.depth || 0) || lo.rect.w;
884
+ var mul = (typeof lo.meshScale === 'number' && lo.meshScale > 0) ? lo.meshScale : 1;
885
+ g.scale.setScalar(fit * mEntry.norm * mul);
886
+ if (lo.meshRotation) {
887
+ g.rotation.set(lo.meshRotation[0] || 0, lo.meshRotation[1] || 0, lo.meshRotation[2] || 0);
888
+ }
889
+ return g;
890
+ }
891
+ // loading (or errored → _meshFailLoud already fired): empty placeholder. _loSig
892
+ // carries the cache status, so the ready flip re-enters here with real geometry.
893
+ return new THREE.Group();
894
+ }
895
+
896
+ // ── liveCanvas: a ferried in-scene surface (LiveChart panel / a detector
897
+ // wall's own registered heatmap) ───────────────────────────────────────
898
+ // The LO carries an OFFSCREEN canvas on lo.liveCanvas (the R5 ferry contract,
899
+ // peer of sprite/renderHint) — a Chart.js panel canvas, or the bin rectangles
900
+ // a GraphingDetectorWall paints itself. The provider only wraps it as a native
901
+ // THREE.CanvasTexture; nothing above the shim enters the renderer, and there is
902
+ // no new provider capability method (declare-data / provider-derives, exactly
903
+ // like box-vs-plane below). The canvas element can churn per redraw, so
904
+ // _syncLargeObjects re-resolves + refreshes per frame.
905
+ //
906
+ // TWO SHAPES, ONE BEHAVIOR:
907
+ // • a real z-slab (rect.d>0) → the BOX branch with the canvas on the TWO faces
908
+ // normal to the wall's THIN axis (derived from the extents — generic) and the
909
+ // flat wall material on the other four. Both textured faces are opaque, so at
910
+ // every orbit the camera sees exactly one of them: the map can never disappear.
911
+ // • no z-slab → the flat plane, kept (free-floating chart planes stay supported)
912
+ // and now DoubleSide, so it is equally visible from behind.
913
+ // Orientation for BOTH comes from the same world-space derivation
914
+ // (_liveFaceTransform + _makeWorldTexture) — front reads correct, back reads
915
+ // as the through-the-window mirror, because each texel sits at its true world
916
+ // position on the face.
917
+ if (lo.liveCanvas) {
918
+ var liveCv = _resolveLiveCanvas(lo);
919
+ var liveD = (lo.depth !== undefined && lo.depth > 0) ? lo.depth
920
+ : ((lo.rect.d !== undefined && lo.rect.d > 0) ? lo.rect.d : 0);
921
+ var pc = lo.color || [30, 40, 55];
922
+ var liveMesh, liveFaces;
923
+ if (liveD > 0) {
924
+ var thin = _thinAxisFaces(lo.rect.w, lo.rect.h, liveD);
925
+ var liveBox = new THREE.BoxGeometry(lo.rect.w, lo.rect.h, liveD);
926
+ // Same per-face brightness as the plain box branch (the ONE
927
+ // LO_FACE_MUL table), so the four untextured faces read as a solid slab.
928
+ liveFaces = [];
929
+ var liveMats = LO_FACE_MUL.map(function (m, idx) {
930
+ var isFace = thin.faces.indexOf(idx) !== -1;
931
+ if (isFace && liveCv) {
932
+ var xf = _liveFaceTransform(idx);
933
+ liveFaces.push({ index: idx, mirrorU: xf.mirrorU, mirrorV: xf.mirrorV });
934
+ return new THREE.MeshBasicMaterial({
935
+ map: _makeWorldTexture(liveCv, xf),
936
+ transparent: true, opacity: alpha, depthWrite: loDepthWrite
937
+ });
938
+ }
939
+ return new THREE.MeshBasicMaterial({
940
+ color: new THREE.Color((pc[0] / 255) * m, (pc[1] / 255) * m, (pc[2] / 255) * m),
941
+ transparent: alpha < 1.0, opacity: alpha, depthWrite: loDepthWrite
942
+ });
943
+ });
944
+ if (!liveCv) {
945
+ // Placeholder until the first paint lands; the refresh pass installs
946
+ // the textures on the SAME two faces once the canvas appears.
947
+ liveFaces = thin.faces.map(function (idx) {
948
+ var xf = _liveFaceTransform(idx);
949
+ return { index: idx, mirrorU: xf.mirrorU, mirrorV: xf.mirrorV };
950
+ });
951
+ }
952
+ liveMesh = new THREE.Mesh(liveBox, liveMats);
953
+ liveMesh.add(new THREE.LineSegments(
954
+ new THREE.EdgesGeometry(liveBox),
955
+ new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.35 })
956
+ ));
957
+ } else {
958
+ var planeGeo = new THREE.PlaneGeometry(lo.rect.w, lo.rect.h);
959
+ var planeXf = _liveFaceTransform(4); // the plane's own u=x,v=y mapping
960
+ liveFaces = [{ index: -1, mirrorU: planeXf.mirrorU, mirrorV: planeXf.mirrorV }];
961
+ var liveMat;
962
+ if (liveCv) {
963
+ liveMat = _worldPlaneMaterial(_makeWorldTexture(liveCv, planeXf), alpha, loDepthWrite);
964
+ } else {
965
+ // Panel shell placeholder until the first chart render lands.
966
+ liveMat = new THREE.MeshBasicMaterial({
967
+ color: new THREE.Color(pc[0] / 255, pc[1] / 255, pc[2] / 255),
968
+ transparent: alpha < 1.0, opacity: alpha, depthWrite: loDepthWrite,
969
+ side: THREE.DoubleSide
970
+ });
971
+ }
972
+ liveMesh = new THREE.Mesh(planeGeo, liveMat);
973
+ }
974
+ liveMesh.userData = liveMesh.userData || {};
975
+ liveMesh.userData.liveCanvasSrc = liveCv || null;
976
+ liveMesh.userData.liveCanvasFaces = liveFaces;
977
+ return liveMesh;
978
+ }
979
+
980
+ // BOX-VS-PLANE DERIVATION (CEO drive #2 structural fix, "wrong_setup_in
981
+ // _DSE_lab.png", 2026-07-14): "engine emits primitives; renderer
982
+ // DERIVES" — a 6-element LO rect (rect.d present and >0) is a real
983
+ // z-slab at the ENGINE level; the renderer should not require a
984
+ // SEPARATE, easy-to-forget lo.shape='box' hint to reflect that (the DSE
985
+ // slabs shipped with correct 6-element rects but no hint, rendering as
986
+ // flat planes while the tunnels next to them correctly rendered as
987
+ // boxes — the SECOND instance of the exact bug b617c029 fixed for
988
+ // SlitScreen/DetectorWall/setupDoubleSlit's walls). Ground-truthed: EVERY
989
+ // current rect.d-bearing LO in this tree (Tube, SlitScreen, DetectorWall,
990
+ // setupDoubleSlit's startWall, the pierced-wall slabs) already explicitly sets
991
+ // shape='box' — none wants a flat render of a deep rect — so derivation
992
+ // is a safe, non-breaking ADDITION: `lo.shape==='box'` (explicit, unchanged)
993
+ // OR `lo.shape===undefined && rect.d>0` (derived, NEW — makes the explicit
994
+ // hint redundant going forward; the 5 existing hint-setting call sites
995
+ // become dead weight, left in place — a later sweep may remove them). A
996
+ // caller that genuinely wants a flat plane despite a real z-slab can still
997
+ // opt out by setting `lo.shape` to any OTHER explicit value.
998
+ // R3-5: shell-shape parity with p5 (_layerBodies). A per-LO `shellShape` (the ferried
999
+ // render.shape vocab, set by the r2 Shell section) picks the drawn primitive on the 3D
1000
+ // provider too: box/cube → BoxGeometry, sphere → SphereGeometry, ball/circle → the flat
1001
+ // disc. Absent → the prior lo.shape/rect.d/drawAsCircle behavior (byte-identical).
1002
+ var _shellShape = (typeof lo.shellShape === 'string' && lo.shellShape) ? lo.shellShape : null;
1003
+ var wantsBox = (lo.shape === 'box') ||
1004
+ (_shellShape === 'box' || _shellShape === 'cube') ||
1005
+ (lo.shape === undefined && _shellShape === null && lo.rect.d !== undefined && lo.rect.d > 0);
1006
+ if (wantsBox) {
1007
+ var d = (lo.depth !== undefined) ? lo.depth : ((lo.rect.d !== undefined) ? lo.rect.d : lo.rect.w); // cube z-extent
1008
+ var box = new THREE.BoxGeometry(lo.rect.w, lo.rect.h, d);
1009
+ var c = lo.color || [200, 200, 200];
1010
+ // Per-face brightness: the ONE LO_FACE_MUL table (see its docstring).
1011
+ var mats = LO_FACE_MUL.map(function (m) {
1012
+ return new THREE.MeshBasicMaterial({
1013
+ color: new THREE.Color((c[0]/255)*m, (c[1]/255)*m, (c[2]/255)*m),
1014
+ transparent: alpha < 1.0, opacity: alpha, depthWrite: loDepthWrite
1015
+ });
1016
+ });
1017
+ var boxMesh = new THREE.Mesh(box, mats);
1018
+ // Crisp edge lines so the cube silhouette/faces are unmistakable from any angle.
1019
+ boxMesh.add(new THREE.LineSegments(
1020
+ new THREE.EdgesGeometry(box),
1021
+ new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.35 })
1022
+ ));
1023
+ return boxMesh;
1024
+ }
1025
+
1026
+ // R3-5: a shell shape of 'sphere' → a TRUE 3D sphere (three is 3D; symmetric with the
1027
+ // box branch). ball/circle → the flat disc; no shellShape → the legacy drawAsCircle path.
1028
+ var _asCircle = _shellShape
1029
+ ? (_shellShape !== 'box' && _shellShape !== 'cube' && _shellShape !== 'sphere')
1030
+ : !!lo.drawAsCircle;
1031
+ var geo = (_shellShape === 'sphere')
1032
+ ? new THREE.SphereGeometry(lo.rect.w / 2, 24, 16)
1033
+ : (_asCircle
1034
+ ? new THREE.CircleGeometry(lo.rect.w / 2, 32)
1035
+ : new THREE.PlaneGeometry(lo.rect.w, lo.rect.h));
1036
+ var mat;
1037
+ if (lo.sprite || lo.renderHint) {
1038
+ // R5: custom textured plane (image URL or named built-in graphic) vs the flat rect.
1039
+ var tex = lo.sprite ? _makeWorldTexture(lo.sprite, _liveFaceTransform(4))
1040
+ : _hintTexture(lo.renderHint, lo.color);
1041
+ mat = _worldPlaneMaterial(tex, alpha, loDepthWrite);
1042
+ } else {
1043
+ mat = new THREE.MeshBasicMaterial({
1044
+ color: new THREE.Color(lo.color[0]/255, lo.color[1]/255, lo.color[2]/255),
1045
+ transparent: alpha < 1.0, opacity: alpha, depthWrite: loDepthWrite
1046
+ });
1047
+ }
1048
+ return new THREE.Mesh(geo, mat);
1049
+ }
1050
+
1051
+ // ── R5 gun-paint — custom textured graphic for a hinted LargeObject ─────
1052
+ // An LO may carry OPTIONAL additive fields the renderer reads (not interpreted
1053
+ // by the engine): `sprite` (image URL) or `renderHint` (a named built-in graphic,
1054
+ // e.g. 'gun'). Both paint a textured plane instead of the flat colored rect.
1055
+ // Procedural CanvasTextures are cached by hint+color so we build each once.
1056
+ var _hintTexCache = {};
1057
+
1058
+ function _hintTexture(hint, color) {
1059
+ var key = hint + '|' + (color ? color.join(',') : '');
1060
+ if (_hintTexCache[key]) { return _hintTexCache[key]; }
1061
+ var cv = document.createElement('canvas');
1062
+ cv.width = 256; cv.height = 256;
1063
+ var g = cv.getContext('2d');
1064
+ if (hint === 'gun') {
1065
+ // Stylized side-view gun pointing +x (down-barrel of a smoking gun).
1066
+ var body = 'rgb(' + (color ? color.join(',') : '46,46,52') + ')';
1067
+ g.clearRect(0, 0, 256, 256);
1068
+ g.fillStyle = body;
1069
+ // receiver / body
1070
+ g.fillRect(40, 96, 120, 56);
1071
+ // barrel
1072
+ g.fillRect(150, 110, 86, 26);
1073
+ // grip (angled)
1074
+ g.save(); g.translate(70, 150); g.rotate(0.35);
1075
+ g.fillRect(0, 0, 34, 80); g.restore();
1076
+ // metallic top highlight
1077
+ g.fillStyle = 'rgba(150,160,180,0.55)';
1078
+ g.fillRect(40, 96, 196, 8);
1079
+ // muzzle flash hint at the tip
1080
+ var grd = g.createRadialGradient(236, 123, 2, 236, 123, 26);
1081
+ grd.addColorStop(0, 'rgba(255,230,150,0.95)');
1082
+ grd.addColorStop(1, 'rgba(255,180,60,0)');
1083
+ g.fillStyle = grd; g.beginPath(); g.arc(236, 123, 26, 0, Math.PI * 2); g.fill();
1084
+ } else {
1085
+ // Generic hinted placeholder: a bordered tile labelled with the hint.
1086
+ g.fillStyle = 'rgb(' + (color ? color.join(',') : '60,60,70') + ')';
1087
+ g.fillRect(0, 0, 256, 256);
1088
+ g.strokeStyle = 'rgba(180,200,220,0.7)'; g.lineWidth = 6;
1089
+ g.strokeRect(8, 8, 240, 240);
1090
+ }
1091
+ var tex = _makeWorldTexture(cv, _liveFaceTransform(4)); // the plane's own u=x,v=y mapping
1092
+ _hintTexCache[key] = tex;
1093
+ return tex;
1094
+ }
1095
+
1096
+ // ── Cube InstancedMesh — lazy creation + capacity growth ──────────────
1097
+ //
1098
+ // Called at the start of the cube render path each tick. Creates the mesh
1099
+ // the first time (or grows it when active count exceeds current capacity).
1100
+ // Geometry is a cube of `size` px on each side (the voxel dimension). In the
1101
+ // collision-matrix-3d experiment, every frame is a Dim-6 body (64 px voxel),
1102
+ // so size is constant — the geometry is created once and reused every tick.
1103
+ //
1104
+ // MeshLambertMaterial + vertexColors: the instance color (set via setColorAt)
1105
+ // IS the base diffuse color; Lambert shading (AmbientLight + DirectionalLight)
1106
+ // then modulates it per-face. Result: physics-derived color with face-depth shading.
1107
+ //
1108
+ // The InstancedMesh is added to _scene once; its count is updated each tick via
1109
+ // the drawRange-equivalent `mesh.count = activeCount`. This is the retained-mode
1110
+ // pattern (same philosophy as the Points mesh — create once, update buffers).
1111
+ function _ensureCubeMesh(size, needed) {
1112
+ var recreateGeo = !_cubeGeo;
1113
+ if (!_cubeMesh || needed > _cubeCapacity) {
1114
+ // Grow to next power of 2 at or above needed.
1115
+ var newCap = _cubeCapacity || 4096;
1116
+ while (newCap < needed) { newCap = newCap * 2; }
1117
+
1118
+ if (_cubeMesh) { _scene.remove(_cubeMesh); _cubeMesh.dispose(); }
1119
+ if (recreateGeo || !_cubeGeo) {
1120
+ if (_cubeGeo) { _cubeGeo.dispose(); }
1121
+ // BoxGeometry: size×size×size voxel. The y-down physics convention means
1122
+ // the box sits centred on the frame's (x,y,z) position — no offset needed.
1123
+ _cubeGeo = new THREE.BoxGeometry(size, size, size);
1124
+ }
1125
+ // Do NOT set vertexColors: true here. BoxGeometry has no 'color' vertex
1126
+ // attribute, so vertexColors:true makes the GLSL vColor read (0,0,0) and
1127
+ // multiplies every face to black. For InstancedMesh colored per-instance via
1128
+ // setColorAt(), three.js automatically uploads instanceColor as a second
1129
+ // instanced attribute that the built-in Lambert shader reads WITHOUT
1130
+ // vertexColors being set — the instanceColor IS the per-cube diffuse color.
1131
+ var mat = new THREE.MeshLambertMaterial({});
1132
+ _cubeMesh = new THREE.InstancedMesh(_cubeGeo, mat, newCap);
1133
+ _cubeMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
1134
+ // Pre-create instanceColor at construction (initialized WHITE), NOT lazily on the
1135
+ // first setColorAt(). If instanceColor is added AFTER the material program first
1136
+ // compiles, r128 binds the new attribute but the per-instance colors read as zero
1137
+ // -> diffuse * 0 = BLACK cubes (the bug this thread chased). Allocating it up front
1138
+ // makes USE_INSTANCING_COLOR part of the FIRST compile; white init keeps any
1139
+ // not-yet-written instance visible rather than black.
1140
+ _cubeMesh.instanceColor = new THREE.InstancedBufferAttribute(
1141
+ new Float32Array(newCap * 3).fill(1), 3);
1142
+ _cubeMesh.instanceColor.setUsage(THREE.DynamicDrawUsage);
1143
+ _cubeMesh.count = 0;
1144
+ // Disable frustum culling on the InstancedMesh. three.js frustum-culls using
1145
+ // the BASE geometry's bounding sphere (a ~64px sphere at the world origin).
1146
+ // All instances are translated to physics coords (x~300, y~300, z varies) via
1147
+ // instanceMatrix, which the cull test does NOT see — so if the origin is outside
1148
+ // the frustum, three.js culls the ENTIRE mesh (black canvas, no error).
1149
+ // frustumCulled=false is the standard fix; cost is negligible for one mesh.
1150
+ _cubeMesh.frustumCulled = false;
1151
+ _scene.add(_cubeMesh);
1152
+ _cubeCapacity = newCap;
1153
+ }
1154
+ }
1155
+
1156
+ // ── Sphere InstancedMesh factory (R6, 2026-07-03 — Effect=None baseline) ──
1157
+ // Builds a NEW InstancedMesh at the given capacity, sharing the ONE global unit
1158
+ // sphere geometry (created lazily on first call) across every (dim,family)
1159
+ // bucket that opts into `paint:'sphere'` — unlike the cube path, no per-scene
1160
+ // voxel-size geometry rebuild is needed since PER-INSTANCE scale (set in the
1161
+ // fill loop below) does the sizing. Mirrors `_ensureCubeMesh`'s exact material/
1162
+ // instanceColor/frustumCulled discipline (see its own comments for the "why"):
1163
+ // - MeshLambertMaterial({}) reads instanceColor as the per-instance diffuse
1164
+ // color automatically, using the SAME _ambient/_dirLight already in _scene
1165
+ // (zero new lighting setup).
1166
+ // - instanceColor is PRE-CREATED (white) at construction, not lazily on first
1167
+ // setColorAt() — adding it after first shader compile reads zero -> BLACK
1168
+ // spheres (the exact bug class the cube path's own comment documents).
1169
+ // - frustumCulled=false — instances translate far from the shared geometry's
1170
+ // origin-centered bounding sphere; three.js's frustum cull test doesn't see
1171
+ // per-instance transforms and would otherwise cull the WHOLE mesh.
1172
+ function _buildSphereInstancedMesh(capacity) {
1173
+ if (!_sphereGeo) {
1174
+ // Low-poly (CEO spec) unit sphere — radius 0.5 so a per-instance scale of
1175
+ // (size,size,size) gives an exact `size`-px diameter, matching the crisp/
1176
+ // soft Points path's `aSize` (frame.rect.w) convention.
1177
+ _sphereGeo = new THREE.SphereGeometry(0.5, 8, 6);
1178
+ }
1179
+ var mat = new THREE.MeshLambertMaterial({});
1180
+ var mesh = new THREE.InstancedMesh(_sphereGeo, mat, capacity);
1181
+ mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
1182
+ mesh.instanceColor = new THREE.InstancedBufferAttribute(
1183
+ new Float32Array(capacity * 3).fill(1), 3);
1184
+ mesh.instanceColor.setUsage(THREE.DynamicDrawUsage);
1185
+ mesh.count = 0;
1186
+ mesh.frustumCulled = false;
1187
+ return mesh;
1188
+ }
1189
+
1190
+ // ── render.mesh external-geometry helpers ────────────────────────────
1191
+ // FAIL LOUD: a requested mesh that can't load is a broken experiment, not a silent
1192
+ // fallback to sprites (Invariant 2 / truth-in-labeling). Inject an overlay + console.
1193
+ function _meshFailLoud(msg) {
1194
+ if (typeof document === 'undefined') { throw new Error(msg); }
1195
+ if (!document.getElementById('egpt-renderer-error')) {
1196
+ var d = document.createElement('div');
1197
+ d.id = 'egpt-renderer-error';
1198
+ d.style.cssText = 'position:fixed;inset:0;background:#300;color:#fdd;font:14px monospace;' +
1199
+ 'padding:18px;white-space:pre-wrap;z-index:99999;overflow:auto;';
1200
+ d.textContent = 'render.mesh FAIL-LOUD\n\n' + msg;
1201
+ document.body.appendChild(d);
1202
+ }
1203
+ // eslint-disable-next-line no-console
1204
+ if (typeof console !== 'undefined') { console.error('[three-provider] render.mesh: ' + msg); }
1205
+ }
1206
+
1207
+ // Kick off (once) the async glTF load for an asset id; cache the result. The GLTFLoader
1208
+ // is lazy-constructed with DRACOLoader attached (Objaverse assets are often Draco), the
1209
+ // decoder pointed at the vendored draco dir (compat/vendor.js `meshLoadersBase()`, the one
1210
+ // owner of where the loaders were loaded from). On load, the scene is normalized to unit size so per-frame
1211
+ // scaling is `frame.w` directly. FAIL LOUD if the loaders aren't present or the load errors.
1212
+ function _ensureMeshLoaded(assetId) {
1213
+ if (_meshCache[assetId]) { return; }
1214
+ if (typeof THREE.GLTFLoader === 'undefined') {
1215
+ _meshCache[assetId] = { status: 'error', scene: null, norm: 1, error: 'loaders-missing' };
1216
+ _meshFailLoud('THREE.GLTFLoader is undefined — the mesh experiment must boot with ' +
1217
+ 'bootRealm({ meshLoaders: true }) so the UMD loaders load after three.');
1218
+ return;
1219
+ }
1220
+ if (!_meshLoader) {
1221
+ var _loader = new THREE.GLTFLoader();
1222
+ if (typeof THREE.DRACOLoader !== 'undefined') {
1223
+ // ONE base for one fact: compat/vendor.js loaded the loaders and owns where from.
1224
+ var base = meshLoadersBase();
1225
+ var draco = new THREE.DRACOLoader();
1226
+ draco.setDecoderPath(base + '/draco/');
1227
+ _loader.setDRACOLoader(draco);
1228
+ }
1229
+ _meshLoader = _loader;
1230
+ }
1231
+ _meshCache[assetId] = { status: 'loading', scene: null, norm: 1, error: null };
1232
+ _meshLoader.load(
1233
+ assetId,
1234
+ function (gltf) {
1235
+ var scene = gltf.scene || (gltf.scenes && gltf.scenes[0]);
1236
+ if (!scene) { _meshCache[assetId] = { status: 'error', scene: null, norm: 1, error: 'no-scene' };
1237
+ _meshFailLoud('glTF loaded but has no scene: ' + assetId); return; }
1238
+ // Normalize to unit size: scale so the model's max bbox dimension = 1, so a
1239
+ // clone scaled by frame.w (quanta) or the LO rect (large objects) fills the target.
1240
+ var box = new THREE.Box3().setFromObject(scene);
1241
+ var size = new THREE.Vector3(); box.getSize(size);
1242
+ var center = new THREE.Vector3(); box.getCenter(center);
1243
+ var maxDim = Math.max(size.x, size.y, size.z) || 1;
1244
+ // RECENTER at the local origin via a pivot wrapper: shift the model inside a
1245
+ // parent group by -center, and cache the WRAPPER. A clone of the wrapper has its
1246
+ // origin at the model's bbox center, so setting the clone's position places the
1247
+ // model CENTERED there — not offset by the asset's authored pivot. Without this a
1248
+ // single large LO mesh (e.g. the gun body) lands off-frame; the per-quantum cloud
1249
+ // masked it because many clones spread the offset out. Both paths clone the wrapper.
1250
+ scene.position.sub(center);
1251
+ var pivot = new THREE.Group();
1252
+ pivot.add(scene);
1253
+ _meshCache[assetId] = { status: 'ready', scene: pivot, norm: 1 / maxDim, error: null };
1254
+ },
1255
+ undefined,
1256
+ function (err) {
1257
+ _meshCache[assetId] = { status: 'error', scene: null, norm: 1, error: String(err) };
1258
+ _meshFailLoud('failed to load render.mesh asset: ' + assetId + '\n' + String(err));
1259
+ }
1260
+ );
1261
+ }
1262
+
1263
+ // Position one clone of the loaded mesh per frame (reusing a pool). The loaded scene is
1264
+ // unit-normalized; each clone is scaled to the frame's voxel size (w) and placed at the
1265
+ // frame's (x,y,z) — the SAME physics coords the cube/points paths use. Extra clones hide.
1266
+ function _layoutMeshClones(entry, frameData, drawParticles, scaleMul, assetId) {
1267
+ if (!_meshGroup) { _meshGroup = new THREE.Group(); _meshGroup.frustumCulled = false; _scene.add(_meshGroup); }
1268
+ // Per-ASSET clone sub-pool (spec §2): one pool per asset id so concurrent assets never
1269
+ // fight over slots. Callers: the scene-wide useMesh branch (assetId = resolvedMesh) and
1270
+ // the per-frame mesh siphon in the Points path (one call per stamped asset).
1271
+ var pool = _meshClonesByAsset[assetId] || (_meshClonesByAsset[assetId] = []);
1272
+ var mul = (typeof scaleMul === 'number' && scaleMul > 0) ? scaleMul : 1;
1273
+ var n = (drawParticles && frameData) ? frameData.length : 0;
1274
+ for (var i = 0; i < n; i++) {
1275
+ var f = frameData[i];
1276
+ var clone = pool[i];
1277
+ if (!clone) {
1278
+ clone = entry.scene.clone(true);
1279
+ clone.frustumCulled = false;
1280
+ pool[i] = clone;
1281
+ _meshGroup.add(clone);
1282
+ }
1283
+ // R3-3: per-frame render.meshScale / render.meshRotation overrides (local > global),
1284
+ // read mechanically from the frame's engine-opaque string_map — the same local-over-
1285
+ // global precedence render.color/render.shape use. render.meshScale replaces the
1286
+ // experiment-wide `mul` (setRenderOption('meshScale',…)); render.meshRotation is a CSV
1287
+ // of THREE euler radians (same unit as lo.meshRotation). Absent → the prior behavior
1288
+ // exactly (mul, identity rotation) → byte-identical for every mesh caller today.
1289
+ var _sm = f.data && f.data.string_map;
1290
+ var frameMul = mul;
1291
+ if (_sm && _sm['render.meshScale'] != null) {
1292
+ var _fms = Number(_sm['render.meshScale']);
1293
+ if (!isNaN(_fms) && _fms > 0) { frameMul = _fms; }
1294
+ }
1295
+ var s = (f.rect && f.rect.w ? f.rect.w : 1) * entry.norm * frameMul;
1296
+ clone.scale.setScalar(s);
1297
+ clone.position.set(f.rect.x, f.rect.y, f.z || 0);
1298
+ clone.userData.frameLayer = f.layer; // the god-rays source pass reads it (isGodraySource)
1299
+ if (_sm && _sm['render.meshRotation'] != null) {
1300
+ var _rp = String(_sm['render.meshRotation']).split(',').map(Number);
1301
+ if (_rp.length >= 3 && _rp.every(function (n) { return !isNaN(n); })) {
1302
+ clone.rotation.set(_rp[0], _rp[1], _rp[2]);
1303
+ } else { clone.rotation.set(0, 0, 0); }
1304
+ } else {
1305
+ clone.rotation.set(0, 0, 0);
1306
+ }
1307
+ clone.visible = true;
1308
+ }
1309
+ for (var j = n; j < pool.length; j++) { if (pool[j]) { pool[j].visible = false; } }
1310
+ if (n > 0 && _meshGroup) { _meshGroup.visible = true; } // group hidden by the caller when NO asset drew
1311
+ return n;
1312
+ }
1313
+
1314
+ // Hide every clone in every asset pool NOT in `keep` (an array of active asset ids). Lets
1315
+ // a tick that stops using an asset (asset swap, or mesh→points transition) drop its clones
1316
+ // without a per-asset call. The active pools' own _layoutMeshClones manages their tails.
1317
+ function _hideMeshClonesExcept(keep) {
1318
+ for (var aid in _meshClonesByAsset) {
1319
+ if (!_meshClonesByAsset.hasOwnProperty(aid)) { continue; }
1320
+ if (keep && keep.indexOf(aid) !== -1) { continue; }
1321
+ var pool = _meshClonesByAsset[aid];
1322
+ for (var i = 0; i < pool.length; i++) { if (pool[i]) { pool[i].visible = false; } }
1323
+ }
1324
+ }
1325
+
1326
+ function _syncLargeObjects(largeObjects, opts) {
1327
+ if (!largeObjects) return;
1328
+ var seen = new Set();
1329
+ for (var i = 0; i < largeObjects.length; i++) {
1330
+ var lo = largeObjects[i];
1331
+ seen.add(lo);
1332
+ var sig = _loSig(lo, opts);
1333
+ var rec = _loMeshes.get(lo);
1334
+ if (!rec || rec.sig !== sig) {
1335
+ // New LO, or its shape/skin/mesh-status changed → (re)build the mesh.
1336
+ if (rec && rec.mesh) { _scene.remove(rec.mesh); }
1337
+ var mesh = _buildLoMesh(lo, opts);
1338
+ rec = { mesh: mesh, sig: sig, skinSig: null };
1339
+ _loMeshes.set(lo, rec);
1340
+ _scene.add(mesh);
1341
+ }
1342
+ // Colour + opacity are DERIVED every frame, never baked once at build:
1343
+ // `lo.color` / `lo.alpha` are live render-only fields the property accessor
1344
+ // (Inspector, a page's own code) writes between ticks, exactly like `visible`
1345
+ // and the rect below. Guarded by a skin signature so the material walk runs
1346
+ // only when a value actually moved.
1347
+ var skinSig = loSkinSig(lo);
1348
+ if (rec.skinSig !== skinSig) {
1349
+ if (rec.mesh && rec.mesh.material) { applyLoSkin(rec.mesh.material, lo); }
1350
+ rec.skinSig = skinSig;
1351
+ }
1352
+ // Update position EVERY frame (cheap) — this is what moves a moving LO. The mesh
1353
+ // sits at the rect's drawn center, owned by lo-drawn-center.js (the runner's pick
1354
+ // aims at the same point through it).
1355
+ var loCenter = LoDrawnCenter.drawnCenter(lo.rect);
1356
+ rec.mesh.position.set(loCenter[0], loCenter[1], loCenter[2]);
1357
+ // R2 — VISIBLE ROTATION: rotate the shell about z by the ferried orientationDeg.
1358
+ // Cheap per-frame update (not a geometry rebuild). orientation 0 ⇒ no rotation,
1359
+ // byte-identical to every existing LO (SU1). Skipped for a billboard LO
1360
+ // (its quaternion is camera-driven below). The drawn face is the wall: a rotated
1361
+ // wall draws NO outline of its un-rotated engine rect.
1362
+ var _deg = (typeof lo.orientationDeg === 'number') ? (((lo.orientationDeg % 360) + 360) % 360) : 0;
1363
+ // R2AX — PITCH (two-axis mirror, CEO ruling 2026-07-26): tip the plate about
1364
+ // its OWN long axis (the geometry's local Y after the z-rotation) by the
1365
+ // ferried pitchDeg. Euler order 'ZYX' = intrinsic z-then-local-y — exactly
1366
+ // "yaw about world z, then pitch about the plate's own long axis" (the same
1367
+ // decomposition SlabFace uses for the physics normal; drawn face is truth).
1368
+ // pitch 0/absent leaves rotation.y at 0 — byte-identical to every existing LO.
1369
+ var _pitch = (typeof lo.pitchDeg === 'number') ? lo.pitchDeg : 0;
1370
+ if (!lo.billboard) {
1371
+ rec.mesh.rotation.order = 'ZYX';
1372
+ // SIGN (3D-loop orientation-inversion fix, CEO drive 2026-07-26 — drawn face
1373
+ // is truth IN WORLD SPACE): the THREE world holds RAW physics coordinates
1374
+ // (y-down VALUES; only camera.up is flipped, see _applyOrbit) — the world is
1375
+ // NOT y-flipped, so yaw draws as +deg, exactly the p5/SlabFace convention
1376
+ // (face line at 90+o in the engine frame). The previous −deg was a hand-
1377
+ // mirrored sign (the SlabFace-banned class): it survived single-plate LOOKs
1378
+ // because the az≈0 camera mirrors screen-x, and for an UNDIRECTED in-plane
1379
+ // plate line an x-mirror is indistinguishable from a y-mirror — but the BEAM
1380
+ // lives in world space, so a ring of tangent mirrors drew as a pinwheel fan
1381
+ // (per-mirror error 2·ori) with the beam disagreeing with every plate.
1382
+ // Probe: scratchpad loopm1probe.cjs — physics exit exact 2D-law on the 2D
1383
+ // wall AND the 3D slab (byte-identical states); only the drawn angle lied.
1384
+ rec.mesh.rotation.z = _deg * Math.PI / 180;
1385
+ // Pitch sign (re-derived against the TRUE normal, not a y-flipped one):
1386
+ // Rz(+yaw)·Ry(θy)·x̂ = (cosθy·cos o, cosθy·sin o, −sinθy) must align with
1387
+ // −n3 = (cos o·cos p, sin o·cos p, −sin p) ⇒ θy = +pitch for ALL yaw
1388
+ // (plate normal sign is conventional; the z-tip direction is unchanged
1389
+ // from the verified R2AX realm sweep).
1390
+ rec.mesh.rotation.y = _pitch * Math.PI / 180;
1391
+ }
1392
+ // Billboard: opt-in LOs (e.g. a Bullet) always face the camera, so a flat plane
1393
+ // reads as a disc head-on (down-barrel) instead of an edge-on sliver. No-op in the
1394
+ // 2D ortho view (camera faces -z, so the plane is already front-on).
1395
+ if (lo.billboard && _camera) { rec.mesh.quaternion.copy(_camera.quaternion); }
1396
+ // liveCanvas refresh: keep the LiveChart texture current. The offscreen
1397
+ // Chart.js canvas churns each redraw (new element) AND updates in place, so
1398
+ // re-resolve; (re)build the CanvasTexture when the element changed; always flag
1399
+ // needsUpdate so a same-element mid-redraw shows. Cheap per-frame (throttled to
1400
+ // ~10fps upstream); zero cost for a non-chart LO (lo.liveCanvas absent).
1401
+ if (lo.liveCanvas && rec.mesh && rec.mesh.material) {
1402
+ var curCv = _resolveLiveCanvas(lo);
1403
+ var ud = rec.mesh.userData || (rec.mesh.userData = {});
1404
+ // One or two target materials: the flat plane's single material
1405
+ // (face index −1), or the two thin-axis faces of a slab. Each keeps
1406
+ // its OWN world-space UV transform, built by the SAME one texture
1407
+ // factory the mesh-build path uses.
1408
+ var faces = ud.liveCanvasFaces || [{ index: -1, mirrorU: false, mirrorV: false }];
1409
+ for (var fi = 0; fi < faces.length; fi++) {
1410
+ var face = faces[fi];
1411
+ var lmat = (face.index >= 0 && Array.isArray(rec.mesh.material))
1412
+ ? rec.mesh.material[face.index] : rec.mesh.material;
1413
+ if (!lmat) { continue; }
1414
+ if (curCv && (ud.liveCanvasSrc !== curCv || !lmat.map)) {
1415
+ if (lmat.map) { try { lmat.map.dispose(); } catch (e) {} }
1416
+ lmat.map = _makeWorldTexture(curCv, face);
1417
+ if (lmat.color) { lmat.color.setRGB(1, 1, 1); } // never tint the ferried surface
1418
+ lmat.transparent = true;
1419
+ lmat.needsUpdate = true;
1420
+ } else if (curCv && lmat.map) {
1421
+ lmat.map.needsUpdate = true;
1422
+ }
1423
+ }
1424
+ if (curCv) { ud.liveCanvasSrc = curCv; }
1425
+ }
1426
+ rec.mesh.visible = (lo.visible !== false);
1427
+ }
1428
+ // Hide meshes whose LO is absent this frame.
1429
+ _loMeshes.forEach(function (rec, lo) { if (!seen.has(lo)) { rec.mesh.visible = false; } });
1430
+ }
1431
+
1432
+ // ── Field texture update — smooth heightfield via shared FieldBuilder ─
1433
+
1434
+ function _updateFieldTex(tickData) {
1435
+ if (!tickData || !tickData.frameData || !_fieldData) return;
1436
+
1437
+ // FieldBuilder builds a smooth splatted heightfield (shared spec, identical
1438
+ // semantics as p5 provider) and returns a fresh Uint8Array each tick.
1439
+ var built = FieldBuilder.buildHeightField(tickData.frameData,
1440
+ Object.assign({}, FieldBuilder.FIELD_OPTS, { canvasW: _physW(), canvasH: _physH() }));
1441
+ // Copy into the pre-allocated _fieldData buffer (same size)
1442
+ _fieldData.set(built);
1443
+ _fieldTex.needsUpdate = true;
1444
+ }
1445
+
1446
+ // ── Filter post-process ────────────────────────────────────────────────
1447
+
1448
+ function _ensureQuadMesh(spec) {
1449
+ var name = spec.name || 'filter';
1450
+ if (_quadMeshes[name]) return _quadMeshes[name];
1451
+
1452
+ var fragSrc = spec.glsl;
1453
+ // three.js ShaderMaterial uses GLSL 1.0-style by default (no version directive needed)
1454
+
1455
+ var mat = new THREE.ShaderMaterial({
1456
+ uniforms: {
1457
+ uScene: { value: _rtA.texture },
1458
+ uField: { value: _fieldTex },
1459
+ uResolution: { value: new THREE.Vector2(_physW(), _physH()) },
1460
+ uTime: { value: 0.0 }
1461
+ },
1462
+ vertexShader: QUAD_VERT,
1463
+ fragmentShader: fragSrc,
1464
+ transparent: false,
1465
+ depthWrite: false
1466
+ });
1467
+
1468
+ var mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat);
1469
+ _quadScene.add(mesh);
1470
+ _quadMeshes[name] = mesh;
1471
+ return mesh;
1472
+ }
1473
+
1474
+ function _applyFilter(spec, time) {
1475
+ var name = spec.name || 'filter';
1476
+ var mesh = _ensureQuadMesh(spec);
1477
+
1478
+ // uTime = simulation seconds at the rendered tick (the contract's filterTime).
1479
+ mesh.material.uniforms.uTime.value = time;
1480
+ mesh.material.uniforms.uResolution.value.set(_physW(), _physH());
1481
+
1482
+ // Render scene → rtA, then apply filter pass → screen
1483
+ _renderer.setRenderTarget(_rtA);
1484
+ _renderer.render(_scene, _camera);
1485
+ _renderer.setRenderTarget(null);
1486
+
1487
+ // Now render the quad (reads rtA) to screen
1488
+ mesh.material.uniforms.uScene.value = _rtA.texture;
1489
+ mesh.material.uniforms.uField.value = _fieldTex;
1490
+ _renderer.render(_quadScene, _quadCamera);
1491
+ }
1492
+
1493
+ // ── R4-B god-rays ──────────────────────────────────────────────────────
1494
+
1495
+ // The god-rays SOURCE image → _godrays.rtSource: same scene/camera/paint, ONLY the fundamental-layer
1496
+ // frames drawn. LOs write depth but no colour (they shadow, never shine). All flags restored.
1497
+ function _renderGodraySource(tickData) {
1498
+ var gs = GodraysLayer, fund = gs.fundamentalLayerOf(tickData);
1499
+ var hidden = [], cubeCount = null, colourOff = [];
1500
+ function hide(o) { if (o && o.visible) { o.visible = false; hidden.push(o); } }
1501
+ for (var k in _dimLayers) {
1502
+ if (_dimLayers.hasOwnProperty(k) && !gs.isGodraySource(_dimLayers[k].layer, fund)) { hide(_dimLayers[k].mesh); }
1503
+ }
1504
+ if (_cubeMesh && _cubeMesh.visible) { cubeCount = _cubeMesh.count; _cubeMesh.count = _cubeSourceCount; }
1505
+ for (var aid in _meshClonesByAsset) {
1506
+ if (!_meshClonesByAsset.hasOwnProperty(aid)) { continue; }
1507
+ var pool = _meshClonesByAsset[aid];
1508
+ for (var i = 0; i < pool.length; i++) {
1509
+ if (pool[i] && !gs.isGodraySource(pool[i].userData.frameLayer, fund)) { hide(pool[i]); }
1510
+ }
1511
+ }
1512
+ _loMeshes.forEach(function (rec) {
1513
+ if (!rec.mesh) { return; }
1514
+ rec.mesh.traverse(function (o) {
1515
+ var mats = o.material ? (Array.isArray(o.material) ? o.material : [o.material]) : [];
1516
+ for (var m = 0; m < mats.length; m++) {
1517
+ if (mats[m] && mats[m].colorWrite !== false) { mats[m].colorWrite = false; colourOff.push(mats[m]); }
1518
+ }
1519
+ });
1520
+ });
1521
+
1522
+ _renderer.setRenderTarget(_godrays.rtSource);
1523
+ _renderer.render(_scene, _camera);
1524
+
1525
+ for (var h = 0; h < hidden.length; h++) { hidden[h].visible = true; }
1526
+ if (cubeCount !== null) { _cubeMesh.count = cubeCount; }
1527
+ for (var c = 0; c < colourOff.length; c++) { colourOff[c].colorWrite = true; }
1528
+ }
1529
+
1530
+ // God-rays to screen: the march reads the SOURCE image (fundamental-layer frames, LOs
1531
+ // depth-only) and adds its rays over the full lit scene. Cost paid ONLY when on.
1532
+ // `godraySource` 'all' marches the lit scene itself (every frame, every LO).
1533
+ function _renderGodrays(opts, tickData) {
1534
+ var gl = GodraysLayer, leaf = gl.resolveGodraySource(opts.godraySource) === 'leaf';
1535
+ if (!_godrays) { _godrays = gl.makeGodrayPasses(THREE, QUAD_VERT, _physW(), _physH()); }
1536
+ _renderer.setRenderTarget(_rtA); // Pass 1: lit scene → _rtA
1537
+ _renderer.render(_scene, _camera);
1538
+ if (leaf) { _renderGodraySource(tickData); } // Pass 1b: source → rtSource
1539
+ _godrays.march(_renderer, _quadCamera, _rtA, leaf ? _godrays.rtSource : _rtA, _rtB,
1540
+ _godrays.lightUV(tickData, _camera, opts.godraySource), opts.godrayDensity, opts.godrays);
1541
+ }
1542
+
1543
+ // ── Aim feedback layer update (docs/internal/engine/AIM_GRAB_FEEDBACK_CONTRACT.md) ──
1544
+ // Gated on opts.aimFeedback (runner-fed, live per frame). 3D-only. Draws, in a SEPARATE
1545
+ // _feedbackScene rendered as a FINAL overlay pass (see _renderAimFeedbackPass) AFTER every
1546
+ // FX composite so bloom/god-rays cannot wash it out:
1547
+ // • the universe-cube wireframe from `bounds`;
1548
+ // • the RETICLE — a screen-centre glyph with no position of its own: it is placed each
1549
+ // frame at the orbit centre (the point the camera looks at, on its centre ray) facing
1550
+ // the camera, so it sits at screen centre by construction;
1551
+ // • the SHADOW-SURROUND around `hover` (grab-ready / grabbed) and `selected` (the host
1552
+ // selection) — painted FROM the volume in the bag, the same lo-drawn-center volume the
1553
+ // runner's ray tested: a dark translucent shell plus amber edges;
1554
+ // • the LOCK glyph on the grabbed object's shadow;
1555
+ // • the passive heading ring (amber, the highlight's colour) at `orientation.pivot`,
1556
+ // turned by `orientation.deg`. Magenta is the reticle's alone.
1557
+ // Reticle, surround and lock are framing-normalized (scaled by orbit-distance / default
1558
+ // framing) so their screen weight holds at telephoto; depthTest:false keeps them on top.
1559
+ var AIM_RETICLE_COLOR = 0xff33ff; // opaque magenta — the aim instrument
1560
+ var AIM_SURROUND_EDGE = 0xffaa00; // amber — the shadow-surround's rim
1561
+ var AIM_LOCK_COLOR = 0x33ff99; // green — grabbed
1562
+ function _lineGeo(pts) {
1563
+ var g = new THREE.BufferGeometry();
1564
+ g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pts), 3));
1565
+ return g;
1566
+ }
1567
+ // `transparent:true` at full opacity puts the lines in three's TRANSPARENT list with the dark
1568
+ // surround shells, so renderOrder (lines 9999 over shells 9997) decides — an opaque line
1569
+ // would be drawn first and then darkened by the shell over it.
1570
+ function _overlayLines(geo, color) {
1571
+ var l = new THREE.LineSegments(geo, new THREE.LineBasicMaterial({ color: color, depthTest: false,
1572
+ transparent: true, opacity: 1 }));
1573
+ l.renderOrder = 9999; l.frustumCulled = false;
1574
+ return l;
1575
+ }
1576
+ function _circlePts(r, seg, z) {
1577
+ var p = [];
1578
+ for (var i = 0; i < seg; i++) {
1579
+ var a0 = i / seg * Math.PI * 2, a1 = (i + 1) / seg * Math.PI * 2;
1580
+ p.push(r * Math.cos(a0), r * Math.sin(a0), z || 0, r * Math.cos(a1), r * Math.sin(a1), z || 0);
1581
+ }
1582
+ return p;
1583
+ }
1584
+ /** One shadow-surround: a unit dark shell + amber rim for a box volume, a camera-facing
1585
+ * dark disc + amber ring for a sphere; transformed per frame from the bag's volume. */
1586
+ function _makeSurround() {
1587
+ var unitBox = new THREE.BoxGeometry(1, 1, 1);
1588
+ var dark = function () {
1589
+ return new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.38,
1590
+ depthTest: false, depthWrite: false, side: THREE.DoubleSide });
1591
+ };
1592
+ var box = new THREE.Mesh(unitBox, dark());
1593
+ box.add(_overlayLines(new THREE.EdgesGeometry(unitBox), AIM_SURROUND_EDGE));
1594
+ var disc = new THREE.Mesh(new THREE.CircleGeometry(1, 48), dark());
1595
+ disc.add(_overlayLines(_lineGeo(_circlePts(1, 48)), AIM_SURROUND_EDGE));
1596
+ [box, disc].forEach(function (m) { m.renderOrder = 9997; m.frustumCulled = false; m.visible = false; _feedbackScene.add(m); });
1597
+ return { box: box, disc: disc };
1598
+ }
1599
+ /** Place `sur` around `vol` (padded by `pad` world px); returns the volume's outer radius. */
1600
+ function _placeSurround(sur, vol, pad) {
1601
+ var c = vol.center;
1602
+ if (vol.kind === 'sphere') {
1603
+ var r = vol.radius + pad;
1604
+ sur.disc.position.set(c[0], c[1], c[2]);
1605
+ sur.disc.quaternion.copy(_camera.quaternion);
1606
+ sur.disc.scale.setScalar(r);
1607
+ sur.disc.visible = true; sur.box.visible = false;
1608
+ return r;
1609
+ }
1610
+ sur.box.position.set(c[0], c[1], c[2]);
1611
+ sur.box.rotation.order = 'ZYX'; // the shell's own order
1612
+ sur.box.rotation.z = (vol.yawDeg || 0) * Math.PI / 180;
1613
+ sur.box.rotation.y = (vol.pitchDeg || 0) * Math.PI / 180;
1614
+ sur.box.scale.set(2 * (vol.half[0] + pad), 2 * (vol.half[1] + pad), 2 * (vol.half[2] + pad));
1615
+ sur.box.visible = true; sur.disc.visible = false;
1616
+ return Math.hypot(vol.half[0], vol.half[1], vol.half[2]) + pad;
1617
+ }
1618
+ function _hideSurround(sur) { if (sur) { sur.box.visible = false; sur.disc.visible = false; } }
1619
+
1620
+ function _updateAimFeedback(opts) {
1621
+ var fb = opts && opts.aimFeedback;
1622
+ if (!_is3D || !fb || !fb.bounds) {
1623
+ if (_aimCube) { _aimCube.visible = false; }
1624
+ if (_aimReticle) { _aimReticle.visible = false; }
1625
+ if (_aimRing) { _aimRing.visible = false; }
1626
+ if (_aimLock) { _aimLock.visible = false; }
1627
+ _hideSurround(_aimHoverSur); _hideSurround(_aimSelSur);
1628
+ return;
1629
+ }
1630
+ if (!_feedbackScene) { _feedbackScene = new THREE.Scene(); }
1631
+ var w = fb.bounds.width, h = fb.bounds.height, zHalf = fb.bounds.zHalf;
1632
+ var sig = w + 'x' + h + 'x' + zHalf;
1633
+ var s = Math.max(4, Math.min(w, h) * 0.06); // glyph half-length (world px, base)
1634
+ if (!_aimCube || _aimCubeSig !== sig) {
1635
+ // (Re)build the bounds-sized geometry only when the universe bounds change.
1636
+ [_aimCube, _aimReticle, _aimRing, _aimLock].forEach(function (o) {
1637
+ if (o) { _feedbackScene.remove(o); o.geometry.dispose(); }
1638
+ });
1639
+ var cube = new THREE.BoxGeometry(w, h, Math.max(1, 2 * zHalf));
1640
+ _aimCube = new THREE.LineSegments(new THREE.EdgesGeometry(cube),
1641
+ new THREE.LineBasicMaterial({ color: 0x66ccff, transparent: true, opacity: 0.5, depthTest: false }));
1642
+ _aimCube.renderOrder = 9998; _aimCube.frustumCulled = false;
1643
+ _feedbackScene.add(_aimCube);
1644
+ cube.dispose();
1645
+ // Reticle: four ticks around an open centre plus a small ring (camera plane).
1646
+ var g0 = s * 0.25;
1647
+ _aimReticle = _overlayLines(_lineGeo([-s, 0, 0, -g0, 0, 0, g0, 0, 0, s, 0, 0,
1648
+ 0, -s, 0, 0, -g0, 0, 0, g0, 0, 0, s, 0].concat(_circlePts(s * 0.55, 32))), AIM_RETICLE_COLOR);
1649
+ _feedbackScene.add(_aimReticle);
1650
+ // Heading ring: a ring in world xy (orientation is a yaw about z) + a spoke along
1651
+ // local +x with an arrowhead, turned with the object it describes.
1652
+ var rr = s * 1.6, tip = rr * 1.35, ah = rr * 0.22;
1653
+ _aimRing = _overlayLines(_lineGeo(_circlePts(rr, 48).concat(
1654
+ [0, 0, 0, tip, 0, 0, tip, 0, 0, tip - ah, ah * 0.6, 0, tip, 0, 0, tip - ah, -ah * 0.6, 0])),
1655
+ AIM_SURROUND_EDGE); // the highlight's colour: magenta is the reticle alone
1656
+ _feedbackScene.add(_aimRing);
1657
+ // Lock glyph: a padlock (body rectangle + shackle arch), camera-facing.
1658
+ var bw = s * 0.45, bh = s * 0.4, lk = [-bw, -bh, 0, bw, -bh, 0, bw, -bh, 0, bw, bh * 0.4, 0,
1659
+ bw, bh * 0.4, 0, -bw, bh * 0.4, 0, -bw, bh * 0.4, 0, -bw, -bh, 0];
1660
+ var sr = bw * 0.62;
1661
+ for (var ai = 0; ai < 12; ai++) {
1662
+ var q0 = ai / 12 * Math.PI, q1 = (ai + 1) / 12 * Math.PI;
1663
+ lk.push(sr * Math.cos(q0), bh * 0.4 + sr * 0.3 + sr * Math.sin(q0), 0,
1664
+ sr * Math.cos(q1), bh * 0.4 + sr * 0.3 + sr * Math.sin(q1), 0);
1665
+ }
1666
+ lk.push(sr, bh * 0.4, 0, sr, bh * 0.4 + sr * 0.3, 0, -sr, bh * 0.4, 0, -sr, bh * 0.4 + sr * 0.3, 0);
1667
+ _aimLock = _overlayLines(_lineGeo(lk), AIM_LOCK_COLOR);
1668
+ _feedbackScene.add(_aimLock);
1669
+ if (!_aimHoverSur) { _aimHoverSur = _makeSurround(); _aimSelSur = _makeSurround(); }
1670
+ _aimCubeSig = sig;
1671
+ }
1672
+ _aimCube.position.set(w / 2, h / 2, 0); // BoxGeometry is origin-centred → universe centre
1673
+ _aimCube.visible = true;
1674
+ var ref = Math.max(_physW(), _physH()) * 1.6;
1675
+ var fscale = (ref > 0 && _orbit.distance > 0) ? (_orbit.distance / ref) : 1;
1676
+ // Reticle at the orbit centre, facing the camera: screen centre by construction.
1677
+ var oc = ThreeCameraGeometry._orbitCenter(_orbit, _physW(), _physH());
1678
+ _aimReticle.position.set(oc.x, oc.y, oc.z);
1679
+ _aimReticle.quaternion.copy(_camera.quaternion);
1680
+ _aimReticle.scale.setScalar(fscale);
1681
+ _aimReticle.visible = true;
1682
+ var pad = 4 * fscale;
1683
+ var hoverR = fb.hover ? _placeSurround(_aimHoverSur, fb.hover.volume, pad) : 0;
1684
+ if (!fb.hover) { _hideSurround(_aimHoverSur); }
1685
+ if (fb.selected && !(fb.hover && fb.hover.name === fb.selected.name)) {
1686
+ _placeSurround(_aimSelSur, fb.selected.volume, pad);
1687
+ } else { _hideSurround(_aimSelSur); }
1688
+ if (fb.grabbed && fb.hover) {
1689
+ // The lock rides the top of the grabbed object's shadow (screen-up of its centre).
1690
+ var gc = fb.hover.volume.center, up = _camera.up, lift = hoverR + s * 0.8 * fscale;
1691
+ _aimLock.position.set(gc[0] + up.x * lift, gc[1] + up.y * lift, gc[2] + up.z * lift);
1692
+ _aimLock.quaternion.copy(_camera.quaternion);
1693
+ _aimLock.scale.setScalar(fscale);
1694
+ _aimLock.visible = true;
1695
+ } else { _aimLock.visible = false; }
1696
+ var ori = fb.orientation;
1697
+ if (ori && ori.pivot) {
1698
+ _aimRing.position.set(ori.pivot[0], ori.pivot[1], (ori.pivot[2] != null) ? ori.pivot[2] : 0);
1699
+ // Same yaw sign as the wall shell (R2: rotation.z = +deg — the world holds raw
1700
+ // physics coordinates), so the spoke turns WITH the drawn object.
1701
+ _aimRing.rotation.z = (ori.deg || 0) * Math.PI / 180;
1702
+ _aimRing.scale.setScalar(fscale);
1703
+ }
1704
+ _aimRing.visible = !!(ori && ori.pivot);
1705
+ }
1706
+
1707
+ // G2 final overlay pass: render the feedback scene OVER the composited screen with
1708
+ // autoClear disabled, so it survives bloom/god-ray/water composites untouched. Called last
1709
+ // in _renderTick (after the FX dispatch has drawn to the default framebuffer).
1710
+ function _renderAimFeedbackPass() {
1711
+ if (!_feedbackScene || !_aimCube || !_aimCube.visible) { return; }
1712
+ var prevAuto = _renderer.autoClear;
1713
+ _renderer.autoClear = false;
1714
+ _renderer.setRenderTarget(null);
1715
+ _renderer.render(_feedbackScene, _camera);
1716
+ _renderer.autoClear = prevAuto;
1717
+ }
1718
+
1719
+ // ── renderTick ─────────────────────────────────────────────────────────
1720
+
1721
+ function _renderTick(tickData, largeObjects, options) {
1722
+ if (!_initialized) return;
1723
+ _lastTickData = tickData;
1724
+ _updateAimFeedback(options || {});
1725
+
1726
+ // Sync large objects (pass render options so LO render.mesh resolves + rebuilds on load)
1727
+ _syncLargeObjects(largeObjects, options || {});
1728
+
1729
+ // ── Layer gating — each layer does per-frame work ONLY when active ──
1730
+ // Modular separation: no layer's per-frame cost is paid unless that layer
1731
+ // is on AND something consumes it. The Field heightfield in particular is
1732
+ // built ONLY when a consumer (a queued filter) is active — never unconditionally (that was one sugar object hijacking the
1733
+ // render loop). Each flag comes from the render OPTIONS bag (setRenderOption).
1734
+ var opts = options || {};
1735
+ var drawParticles = opts.renderBaseCanvas !== false; // Particles layer
1736
+ // SNAPSHOT, not the reference: _renderOptsWithFeedback() returns
1737
+ // this._renderOptions ITSELF when there is no aim-feedback bag, so retaining
1738
+ // `opts` would let a setRenderOption between ticks silently change what
1739
+ // captureStill() replays (§1a.4's "as of the last completed renderTick").
1740
+ _lastOpts = Object.assign({}, opts);
1741
+ _lastRenderBaseCanvas = drawParticles;
1742
+ var fieldConsumed = _filters.length > 0; // build field iff consumed
1743
+
1744
+ // R4-GEO / per-dim layers (2026-07-02): glow/heat/smoke + additive-vs-normal
1745
+ // blend are resolved PER (dim,family) BUCKET inside the points-path loop below
1746
+ // (each bucket gets its own material/uniforms).
1747
+ var glow = opts.glowIntensity;
1748
+
1749
+ // ── Cube InstancedMesh path (shape='cube' + 3D only) ─────────────────────────
1750
+ // When the experiment opts in with setRenderOption('shape','cube') AND the scene
1751
+ // is 3D (_is3D), quanta render as TRUE shaded 3D cube meshes instead of flat
1752
+ // point-sprites. The Points mesh (+ flash) becomes invisible; the cube InstancedMesh
1753
+ // takes over. The physics data (position, fullness, color scheme) flows through the
1754
+ // SAME LightColor path as the Points path — only the draw call changes.
1755
+ //
1756
+ // Gate: RenderShape.isCubeMesh(resolvedShape) && _is3D.
1757
+ // true → cube InstancedMesh path (Points hidden).
1758
+ // false → existing Points path (cube InstancedMesh hidden). ALL EXISTING
1759
+ // EXPERIMENTS use false → ZERO change to their render path.
1760
+ // LightColor + RenderShape are MANDATORY canonical contracts (the single color/shape
1761
+ // sources). A missing one is a hard load-order/packaging error — FAIL LOUD, never a
1762
+ // silent fallback to a default color/shape (Invariant 2; architect ruling 2026-06-30).
1763
+ var lc2 = LightColor;
1764
+ var rs2 = RenderShape;
1765
+ // SCENE-DEFAULT shape (opts only, no frame): the scene-wide cube gate below + the default
1766
+ // uShape for glow-sprite buckets (glow/smoke) that carry no per-frame shape. The
1767
+ // PER-FRAME shape/mesh routing bug (Red-Team §6: the old `resolve(null,opts)` ignored a
1768
+ // frame's own render.shape/render.mesh) is fixed in the Points loop, which resolves EACH
1769
+ // frame's family via RenderFamily.resolveRenderFamily(f, opts, _is3D) — never null.
1770
+ var resolvedShape = rs2.resolve(null, opts);
1771
+ // render.mesh (external glTF) takes priority over cube/points when an experiment
1772
+ // routes to it. resolvedMesh = experiment-level setRenderOption('mesh',id).
1773
+ var resolvedMesh = _is3D ? RenderMesh.resolve(null, opts) : null;
1774
+ var useMesh = !!resolvedMesh;
1775
+ var useCubeMesh = !useMesh && _is3D && rs2.isCubeMesh(resolvedShape);
1776
+ if (!useMesh && _meshGroup) { _meshGroup.visible = false; }
1777
+
1778
+ if (useMesh) {
1779
+ // ── MESH PATH — clones of a loaded glTF (render.mesh) ─────────
1780
+ // Points/flash/cube hidden; one clone of the external mesh per frame at its
1781
+ // (x,y,z). The asset loads async (FAIL-LOUD on error); while loading, nothing
1782
+ // draws (no silent sprite). WHICH mesh = renderer/sugar choice from FrameData.
1783
+ _hideAllDimLayers();
1784
+ if (_cubeMesh) { _cubeMesh.visible = false; }
1785
+ _ensureMeshLoaded(resolvedMesh);
1786
+ var _me = _meshCache[resolvedMesh];
1787
+ var _sceneLaid = 0;
1788
+ if (_me && _me.status === 'ready') {
1789
+ _sceneLaid = _layoutMeshClones(_me, tickData.frameData, opts.renderBaseCanvas !== false, opts.meshScale, resolvedMesh);
1790
+ }
1791
+ _hideMeshClonesExcept([resolvedMesh]); // drop any stale per-frame/other-asset clones
1792
+ if (_meshGroup) { _meshGroup.visible = _sceneLaid > 0; } // loading/empty → hidden (error already overlaid)
1793
+ } else if (useCubeMesh) {
1794
+ // ── CUBE PATH — InstancedMesh of BoxGeometry ──────────────────
1795
+ // Points + flash are invisible; cube InstancedMesh fills from frameData.
1796
+ // MeshLambertMaterial uses the scene's AmbientLight + DirectionalLight
1797
+ // to shade each face differently — front/top catch the key light and are
1798
+ // bright; back/bottom are dark — so the cube reads as a solid 3D object.
1799
+ _hideAllDimLayers();
1800
+
1801
+ if (drawParticles && tickData && tickData.frameData) {
1802
+ var fdC = tickData.frameData;
1803
+ var totalC = fdC.length;
1804
+ var defaultColorSchemeC = opts.colorScheme || 'warmcool';
1805
+
1806
+ // Voxel size: the frame rect.w for the first active frame, or a sensible
1807
+ // default. BoxGeometry is sized from the first frame that arrives, then
1808
+ // reused if the voxel size is stable (which it always is — one Dim-level
1809
+ // per setup call). If it changes (unusual), a new geometry is lazy-created.
1810
+ var cubeSize = 64; // fallback
1811
+ for (var ci0 = 0; ci0 < totalC; ci0++) {
1812
+ if (fdC[ci0].rect && fdC[ci0].rect.w) { cubeSize = fdC[ci0].rect.w; break; }
1813
+ }
1814
+ // Grow the InstancedMesh if needed (same power-of-2 grow philosophy as
1815
+ // _growDimLayerIfNeeded above).
1816
+ _ensureCubeMesh(cubeSize, totalC);
1817
+
1818
+ // God-rays source frames FIRST: the source pass draws instances [0, _cubeSourceCount).
1819
+ var gsC = GodraysLayer, fundC = gsC.fundamentalLayerOf(tickData), orderC = [];
1820
+ for (var oc = 0; oc < totalC; oc++) { if (gsC.isGodraySource(fdC[oc].layer, fundC)) { orderC.push(oc); } }
1821
+ _cubeSourceCount = orderC.length;
1822
+ for (var oc2 = 0; oc2 < totalC; oc2++) { if (!gsC.isGodraySource(fdC[oc2].layer, fundC)) { orderC.push(oc2); } }
1823
+
1824
+ var cubeCount = 0;
1825
+ for (var ci = 0; ci < totalC; ci++) {
1826
+ var fc = fdC[orderC[ci]];
1827
+ var fullnessC = fc.fullness || 0;
1828
+ var _smC = fc.data && fc.data.string_map;
1829
+ var renderColorC = null;
1830
+ if (_smC && _smC['render.color']) {
1831
+ var _cpC = String(_smC['render.color']).split(',').map(Number);
1832
+ if (_cpC.length >= 3 && _cpC.every(function (n) { return !isNaN(n); })) renderColorC = _cpC;
1833
+ }
1834
+ var frameSchemeC = (_smC && _smC['render.colorScheme']) ? _smC['render.colorScheme'] : defaultColorSchemeC;
1835
+ var rgbC = renderColorC ? renderColorC
1836
+ : lc2.getRgbBySchemeName(frameSchemeC, fullnessC);
1837
+
1838
+ // Position the cube at the frame's physics (x, y, z).
1839
+ var px = fc.rect.x, py = fc.rect.y, pz = fc.z || 0;
1840
+ _cubeMat4.makeTranslation(px, py, pz);
1841
+ _cubeMesh.setMatrixAt(cubeCount, _cubeMat4);
1842
+
1843
+ // Instance color: fullness-driven physics color (LightColor authority).
1844
+ // MeshLambertMaterial multiplies this with the diffuse shading from the
1845
+ // scene lights, so bright faces = key light, dark faces = shadow.
1846
+ _cubeColor.setRGB((rgbC[0] || 0) / 255, (rgbC[1] || 0) / 255, (rgbC[2] || 0) / 255);
1847
+ _cubeMesh.setColorAt(cubeCount, _cubeColor);
1848
+ cubeCount++;
1849
+ }
1850
+ _cubeMesh.count = cubeCount;
1851
+ _cubeMesh.instanceMatrix.needsUpdate = true;
1852
+ if (_cubeMesh.instanceColor) { _cubeMesh.instanceColor.needsUpdate = true; }
1853
+ _cubeMesh.visible = drawParticles && cubeCount > 0;
1854
+ } else {
1855
+ if (_cubeMesh) { _cubeMesh.visible = false; }
1856
+ }
1857
+ } else {
1858
+ // ── POINTS PATH — per-dim layers; every non-cube/non-mesh experiment ──
1859
+ // runs here (R4-GEO, 2026-07-02). The cube InstancedMesh (if it was ever
1860
+ // created from a prior cube-tick) is simply hidden; no disposal needed.
1861
+ if (_cubeMesh) { _cubeMesh.visible = false; }
1862
+ _hideAllDimLayers();
1863
+
1864
+ if (drawParticles && tickData && tickData.frameData) {
1865
+ var fd = tickData.frameData;
1866
+ var total = fd.length;
1867
+
1868
+ var lc = lc2; // alias — same LightColor authority
1869
+ var rs = rs2;
1870
+ var smokeOn = (typeof opts.smoke === 'number') && opts.smoke > 0;
1871
+ var smokeIntensity = smokeOn ? opts.smoke : 0.0;
1872
+ var defaultColorScheme = opts.colorScheme || 'warmcool';
1873
+
1874
+ // _currentShapeUniform = the SCENE-DEFAULT uShape (glow-sprite buckets that carry
1875
+ // no per-frame shape — glow/smoke — use it). The crisp 'N' shape bucket now
1876
+ // carries its OWN per-frame uShape (see the family fold below), so ball vs box can
1877
+ // COEXIST per-frame instead of sharing this one scene-wide uniform.
1878
+ _currentShapeUniform = rs.toUniform(resolvedShape);
1879
+
1880
+ // Scene-wide additive-vs-normal choice: glow > 0 makes the WHOLE scene one
1881
+ // additive blend mode.
1882
+ var plainWantAdditive = (typeof glow === 'number' && glow > 0);
1883
+
1884
+ // Per-frame render FAMILY (docs/internal/engine/RENDERER_CONTRACT.md,
1885
+ // §per-frame render-intent resolution): each frame's
1886
+ // shape/mesh is resolved PER-FRAME via RenderFamily.resolveRenderFamily(f, opts,
1887
+ // _is3D) inside the loop below (never the old scene-wide `resolve(null,opts)`).
1888
+ // The resolved family is folded into the bucket KEY so per-frame ball / box /
1889
+ // sphere COEXIST in one scene (each family draws with its own THREE object):
1890
+ // 'sphere' → paint:'sphere' (the L2e instanced-sphere fold, per-instance color)
1891
+ // 'pts:<v>' → the crisp Points sprite with per-bucket uShape v (0=ball, 1=box)
1892
+ // 'mesh:<id>' → siphoned OUT of the sprite buckets into a per-asset glTF clone
1893
+ // layout (meshByAsset below) — the per-frame projectile case.
1894
+ // PHASE 1 limitation (stated): a per-frame render.shape='cube' in a NON-cube-default
1895
+ // scene renders here as the box SPRITE (uShape 1), not a lit 3D cube — the lit cube
1896
+ // InstancedMesh is still the scene-wide `useCubeMesh` branch above (folding cube into
1897
+ // a per-bucket family is Phase 2; deferred pending realm verification).
1898
+ var meshByAsset = {}; // assetId -> [frame] (per-frame render.mesh siphon)
1899
+
1900
+ // ── Bucket rows into (dim, family) groups — ONE THREE object per bucket.
1901
+ // "Dim decides APPEARANCE, position decides OCCLUSION" (CEO ruling): no
1902
+ // parent/leaf distinction — a frame is classified by its OWN dim using its
1903
+ // OWN fullness/box size.
1904
+ var buckets = {}; // key -> { additive, paint, uShape?, list:[{x,y,z,full,r,g,b,a,size}] }
1905
+ function _pushTo(layer, key, additive, paint, x, y, z, full, rgb, a, size, uShapeVariant) {
1906
+ var b = buckets[key];
1907
+ if (!b) { b = buckets[key] = { layer: layer, additive: additive, paint: paint, uShape: uShapeVariant, list: [] }; }
1908
+ b.list.push({ x: x, y: y, z: z, full: full, r: rgb[0] || 0, g: rgb[1] || 0, b2: rgb[2] || 0, a: a, size: size });
1909
+ }
1910
+
1911
+ for (var i = 0; i < total; i++) {
1912
+ var f = fd[i];
1913
+ var layer = f.layer || 0;
1914
+
1915
+ // Per-frame render FAMILY (spec §2/§3): resolve mesh/shape from THIS frame.
1916
+ // A per-frame render.mesh stamp OUTRANKS shape AND color — it renders as a
1917
+ // glTF clone regardless (a per-frame projectile). Siphon it out of the sprite
1918
+ // buckets and continue.
1919
+ var famF = RenderFamily.resolveRenderFamily(f, opts, _is3D);
1920
+ if (famF.family === 'mesh') {
1921
+ (meshByAsset[famF.assetId] || (meshByAsset[famF.assetId] = [])).push(f);
1922
+ continue;
1923
+ }
1924
+
1925
+ var fullness = f.fullness || 0;
1926
+ // render.color: read from string_map (the cross-engine path), NOT f.data.render.color
1927
+ // (the retired typed render section — f.data.render.color is the old shape that was
1928
+ // never written by the engine and caused a silent null for most frames).
1929
+ var _sm = f.data && f.data.string_map;
1930
+ var renderColor = null;
1931
+ if (_sm && _sm['render.color']) {
1932
+ var _cp = String(_sm['render.color']).split(',').map(Number);
1933
+ if (_cp.length >= 3 && _cp.every(function (n) { return !isNaN(n); })) renderColor = _cp;
1934
+ }
1935
+ // Per-frame color scheme override.
1936
+ var frameScheme = (_sm && _sm['render.colorScheme']) ? _sm['render.colorScheme'] : defaultColorScheme;
1937
+ var z = f.z || 0, px = f.rect.x, py = f.rect.y;
1938
+ var voxelSize = (f.rect && f.rect.w) ? f.rect.w : 1;
1939
+
1940
+ var alpha01 = lc.getAlphaFromIntensity(fullness);
1941
+ var rgbN = renderColor ? renderColor : lc.getRgbBySchemeName(frameScheme, fullness);
1942
+ // Destination bucket, in priority order: the whole scene additive (glow > 0);
1943
+ // soft puffs (smoke > 0); else the per-frame render FAMILY (famF) folded into
1944
+ // the KEY so ball / box / sphere coexist per-frame.
1945
+ var nSize = plainWantAdditive ? (4.0 * fullness + 1.0) : voxelSize;
1946
+ if (plainWantAdditive) {
1947
+ // additive glow sprite — shape N/A (additive materials carry no uShape uniform).
1948
+ _pushTo(layer, layer + '|A', true, 'crisp', px, py, z, fullness, rgbN, alpha01, nSize);
1949
+ } else if (smokeOn) {
1950
+ // soft puff — shape N/A.
1951
+ _pushTo(layer, layer + '|N|soft', false, 'soft', px, py, z, fullness, rgbN, alpha01, nSize);
1952
+ } else if (famF.family === 'sphere') {
1953
+ // per-frame render.shape='sphere' → the instanced-sphere family (per-instance color).
1954
+ _pushTo(layer, layer + '|N|sphere', false, 'sphere', px, py, z, fullness, rgbN, alpha01, nSize, 0);
1955
+ } else {
1956
+ // per-frame Points sprite: ball (uShape 0) / box (uShape 1). PHASE 1: a
1957
+ // per-frame render.shape='cube' renders as the box sprite (uv 1) — the lit
1958
+ // 3D cube is the scene-wide useCubeMesh branch only (cube-fold = Phase 2).
1959
+ var uv = (famF.family === 'cube') ? 1 : (famF.uShapeVariant != null ? famF.uShapeVariant : 0);
1960
+ _pushTo(layer, layer + '|N|pts:' + uv, false, 'crisp', px, py, z, fullness, rgbN, alpha01, nSize, uv);
1961
+ }
1962
+ }
1963
+
1964
+ // ── Fill/grow/paint each populated bucket's THREE object ──
1965
+ var seenKeys = [];
1966
+ for (var key2 in buckets) {
1967
+ if (!buckets.hasOwnProperty(key2)) { continue; }
1968
+ var bucket = buckets[key2];
1969
+ var n = bucket.list.length;
1970
+ if (n === 0) { continue; }
1971
+ seenKeys.push(key2);
1972
+
1973
+ var entry = _ensureDimLayer(key2, bucket.additive, bucket.paint);
1974
+ _growDimLayerIfNeeded(entry, n);
1975
+ entry.layer = bucket.layer; // the god-rays source pass reads it (isGodraySource)
1976
+
1977
+ var cx = 0, cy = 0, cz = 0;
1978
+
1979
+ if (entry.isSphere) {
1980
+ // ── Sphere InstancedMesh branch (R6, Effect=None baseline) ──
1981
+ // Per-instance TRANSLATE + SCALE (unlike the cube path's fixed
1982
+ // per-scene voxel size): matrix.compose(position, identity
1983
+ // rotation, scale) sizes each sphere by its OWN frame.rect.w
1984
+ // (already carried in `it.size` — the SAME value the crisp/soft
1985
+ // Points path uses for `aSize`), matching "Dim decides
1986
+ // APPEARANCE... a frame is painted per its OWN box size."
1987
+ for (var js = 0; js < n; js++) {
1988
+ var its = bucket.list[js];
1989
+ _spherePos.set(its.x, its.y, its.z);
1990
+ _sphereScale.set(its.size, its.size, its.size);
1991
+ _sphereMat4.compose(_spherePos, _sphereQuat, _sphereScale);
1992
+ entry.mesh.setMatrixAt(js, _sphereMat4);
1993
+ _sphereColor.setRGB(its.r / 255, its.g / 255, its.b2 / 255);
1994
+ entry.mesh.setColorAt(js, _sphereColor);
1995
+ cx += its.x; cy += its.y; cz += its.z;
1996
+ }
1997
+ entry.mesh.count = n;
1998
+ entry.mesh.instanceMatrix.needsUpdate = true;
1999
+ if (entry.mesh.instanceColor) { entry.mesh.instanceColor.needsUpdate = true; }
2000
+ } else {
2001
+ // ── existing crisp/soft Points branch — UNCHANGED ──
2002
+ for (var j = 0; j < n; j++) {
2003
+ var it = bucket.list[j];
2004
+ entry.bufs.posArr[j*3]=it.x; entry.bufs.posArr[j*3+1]=it.y; entry.bufs.posArr[j*3+2]=it.z;
2005
+ entry.bufs.fullArr[j]=it.full;
2006
+ entry.bufs.colArr[j*3]=it.r/255; entry.bufs.colArr[j*3+1]=it.g/255; entry.bufs.colArr[j*3+2]=it.b2/255;
2007
+ entry.bufs.alphaArr[j]=it.a;
2008
+ entry.bufs.sizeArr[j]=it.size;
2009
+ // Puff-size gate (aSmoke/uSmoke — the pre-existing `smoke`
2010
+ // render option, independent of color/alpha), driven by
2011
+ // `paint` — a bucket-level constant (the key carries it).
2012
+ entry.bufs.smokeArr[j] = (bucket.paint === 'soft') ? 1 : 0;
2013
+ cx += it.x; cy += it.y; cz += it.z;
2014
+ }
2015
+ entry.geo.setDrawRange(0, n);
2016
+ entry.geo.attributes.position.needsUpdate = true;
2017
+ entry.geo.attributes.aFullness.needsUpdate = true;
2018
+ entry.geo.attributes.aColor.needsUpdate = true;
2019
+ entry.geo.attributes.aAlpha.needsUpdate = true;
2020
+ entry.geo.attributes.aSize.needsUpdate = true;
2021
+ entry.geo.attributes.aSmoke.needsUpdate = true;
2022
+
2023
+ entry.mat.uniforms.uPersp.value = _is3D ? 1.0 : 0.0;
2024
+ // uShape is now PER-BUCKET (the per-frame render.shape fold): the crisp
2025
+ // family bucket carries its own ball(0)/box(1) uShape; glow/smoke buckets
2026
+ // (no per-frame shape) fall back to the scene default. So ball and box
2027
+ // coexist per-frame, each in its own bucket with its own uniform.
2028
+ if (entry.mat.uniforms.uShape) {
2029
+ entry.mat.uniforms.uShape.value = (bucket.uShape != null) ? bucket.uShape : _currentShapeUniform;
2030
+ }
2031
+ if (bucket.additive) {
2032
+ // glow controls flash gain DIRECTLY: glow:0 -> flash OFF
2033
+ // (intensity 0), not full (the fix that stopped glow:0
2034
+ // drowning white smoke in gold).
2035
+ entry.mat.uniforms.uIntensity.value = (typeof glow === 'number') ? glow : 1.0;
2036
+ entry.mat.uniforms.uHeat.value = (typeof opts.heat === 'number') ? opts.heat : 0.0;
2037
+ entry.mat.uniforms.uSmoke.value = 0.0; // additive/flash quanta never puff
2038
+ } else {
2039
+ entry.mat.uniforms.uIntensity.value = 1.0;
2040
+ entry.mat.uniforms.uHeat.value = (typeof opts.heat === 'number') ? opts.heat : 0.0;
2041
+ entry.mat.uniforms.uSmoke.value = (bucket.paint === 'soft') ? smokeIntensity : 0.0;
2042
+ }
2043
+ }
2044
+
2045
+ entry.centroid.x = cx / n; entry.centroid.y = cy / n; entry.centroid.z = cz / n;
2046
+ entry.mesh.visible = drawParticles;
2047
+ }
2048
+
2049
+ // ── Camera-distance sort (painter's algorithm, CEO ruling) — farthest
2050
+ // drawn FIRST so a nearer translucent bucket visually occludes (dims) a
2051
+ // farther one, exactly like real depth would (only meaningful in 3D; the
2052
+ // 2D ortho camera has no orbit/depth axis quanta actually vary along). ──
2053
+ if (_is3D && seenKeys.length > 1) {
2054
+ seenKeys.sort(function (ka, kb) {
2055
+ return _camDistSq(_dimLayers[kb].centroid) - _camDistSq(_dimLayers[ka].centroid);
2056
+ });
2057
+ for (var oi = 0; oi < seenKeys.length; oi++) {
2058
+ _dimLayers[seenKeys[oi]].mesh.renderOrder = oi;
2059
+ }
2060
+ }
2061
+
2062
+ // ── Per-frame render.mesh (spec §2 — the per-frame projectile case) ───────
2063
+ // Lay out glTF clones for each per-frame-stamped asset (siphoned above into
2064
+ // meshByAsset), per-asset sub-pool. This runs INSIDE the Points path so mesh
2065
+ // frames coexist with sprite/points frames in ONE scene — the bullet renders as
2066
+ // its glTF while the propellant renders as points, with NO experiment-level
2067
+ // setRenderOption('mesh',…). A referenced asset that is still loading / failed
2068
+ // draws nothing (FAIL-LOUD overlay via _ensureMeshLoaded), never a silent sprite.
2069
+ var _pfMeshAssets = [];
2070
+ var _pfMeshLaid = 0;
2071
+ for (var _aid in meshByAsset) {
2072
+ if (!meshByAsset.hasOwnProperty(_aid)) { continue; }
2073
+ _pfMeshAssets.push(_aid);
2074
+ _ensureMeshLoaded(_aid);
2075
+ var _pfMe = _meshCache[_aid];
2076
+ if (_pfMe && _pfMe.status === 'ready') {
2077
+ _pfMeshLaid += _layoutMeshClones(_pfMe, meshByAsset[_aid], drawParticles, opts.meshScale, _aid);
2078
+ }
2079
+ }
2080
+ _hideMeshClonesExcept(_pfMeshAssets);
2081
+ if (_meshGroup) { _meshGroup.visible = _pfMeshLaid > 0; }
2082
+ }
2083
+ }
2084
+
2085
+ // ── Field layer — build the heightfield ONLY when consumed ──
2086
+ if (fieldConsumed) { _updateFieldTex(tickData); }
2087
+
2088
+ // ── Render ── (the FINAL composite dispatch — one implementation, two callers)
2089
+ _composeFinal(opts, tickData);
2090
+ }
2091
+
2092
+ /**
2093
+ * The FINAL composite dispatch — the ONE implementation, two callers (`_renderTick`
2094
+ * and `captureStill`). Extracted so a capture replays the IDENTICAL composite in the
2095
+ * same JS task; the full readback guarantee (and why `preserveDrawingBuffer:true` is
2096
+ * the WRONG fix) is RENDERER_CONTRACT §1a.4. A second copy here would be the
2097
+ * two-paths bug: the capture would silently stop matching the picture.
2098
+ * @param {object} opts — the render options bag in effect
2099
+ * @param {object} tickData — the TickData the composite draws from
2100
+ */
2101
+ function _composeFinal(opts, tickData) {
2102
+ opts = opts || {};
2103
+ // godrays is a final-composite layer (a declared render option). It owns the final pass when on; it and the 2D water-fx
2104
+ // filter are alternative final composites (concurrent use is a phase-2 item).
2105
+ var wantGodrays = opts.godrays > 0;
2106
+ var time = _filters.length > 0
2107
+ ? RendererProviderContract.filterTime(tickData) : null;
2108
+ _lastFilterTime = time; // capture provenance (§1a.4)
2109
+
2110
+ if (wantGodrays) {
2111
+ _renderGodrays(opts, tickData);
2112
+ } else if (_filters.length > 0) {
2113
+ // 2D water-fx path: scene → rtA, then the full-screen quad shader.
2114
+ // (Particle visibility already set above via the Particles layer flag.)
2115
+ _applyFilter(_filters[0], time);
2116
+ // Additional filters would use rtB ping-pong — deferred to phase 2
2117
+ } else {
2118
+ _renderer.setRenderTarget(null);
2119
+ _renderer.render(_scene, _camera);
2120
+ }
2121
+
2122
+ // G2 — draw the aim feedback (cube, reticle, surrounds) LAST, over whatever the FX dispatch just
2123
+ // composited to the screen, so it can never be washed by bloom/god-rays.
2124
+ _renderAimFeedbackPass();
2125
+ }
2126
+
2127
+ // ── Public interface ─────────────────────────────────────────────────
2128
+
2129
+ var provider = {
2130
+
2131
+ // RETURNS the drawing surface (RENDERER_CONTRACT §1 init row): the WebGL canvas.
2132
+ init: function (opts) {
2133
+ _init(opts);
2134
+ return _canvas;
2135
+ },
2136
+
2137
+ renderTick: function (tickData, largeObjects, options) {
2138
+ _renderTick(tickData, largeObjects, options);
2139
+ },
2140
+
2141
+ filter: function (filterSpec, perFilterOptions) {
2142
+ // R-6: perFilterOptions (optional) merges over the runner options for this
2143
+ // specific filter call. Stored on the spec entry for _applyFilter.
2144
+ if (!filterSpec) return;
2145
+ if (filterSpec.name) {
2146
+ for (var i = 0; i < _filters.length; i++) {
2147
+ if (_filters[i].name === filterSpec.name) {
2148
+ if (perFilterOptions) {
2149
+ _filters[i]._perFilterOptions = perFilterOptions;
2150
+ }
2151
+ return;
2152
+ }
2153
+ }
2154
+ }
2155
+ var entry = filterSpec;
2156
+ if (perFilterOptions) {
2157
+ entry = Object.assign({}, filterSpec);
2158
+ entry._perFilterOptions = perFilterOptions;
2159
+ }
2160
+ _filters.push(entry);
2161
+ },
2162
+
2163
+ removeFilter: function (nameOrSpec) {
2164
+ var name = (typeof nameOrSpec === 'string') ? nameOrSpec
2165
+ : (nameOrSpec && nameOrSpec.name);
2166
+ _filters = _filters.filter(function (f) { return f.name !== name; });
2167
+ if (name && _quadMeshes[name]) {
2168
+ _quadScene.remove(_quadMeshes[name]);
2169
+ delete _quadMeshes[name];
2170
+ }
2171
+ },
2172
+
2173
+ setSize: function (w, h) {
2174
+ RendererProviderContract.requireProviderSize('threeProvider.setSize', w, h);
2175
+ _width = w; _height = h;
2176
+ if (_renderer) {
2177
+ _renderer.setSize(w, h);
2178
+ if (_is3D) {
2179
+ _camera.aspect = w / h;
2180
+ _applyOrbit(); // re-centre + updateProjectionMatrix
2181
+ } else {
2182
+ _camera.right = w;
2183
+ _camera.bottom = h;
2184
+ _camera.updateProjectionMatrix();
2185
+ }
2186
+ if (_rtA) { _rtA.setSize(w, h); }
2187
+ if (_rtB) { _rtB.setSize(w, h); }
2188
+ if (_godrays) { _godrays.setSize(w, h); }
2189
+ } else if (_is3D) {
2190
+ // No camera yet, but the pose basis still moved with the canvas centre:
2191
+ // _applyOrbit is the one pose-applied site, so it carries the notice.
2192
+ _applyOrbit();
2193
+ }
2194
+ // Field DataTexture uses FieldBuilder.FIELD_OPTS dimensions (fixed).
2195
+ // No rebuild needed for canvas resize — field resolution is independent.
2196
+ },
2197
+
2198
+ // Loop owned by FrqtlApp — no-ops here.
2199
+ start: function () {},
2200
+ stop: function () {},
2201
+
2202
+ destroy: function () {
2203
+ if (_renderer) { _renderer.dispose(); }
2204
+ if (_canvas && _canvas.parentNode) { _canvas.parentNode.removeChild(_canvas); }
2205
+ _initialized = false;
2206
+ _filters = [];
2207
+ },
2208
+
2209
+ raw: function () {
2210
+ // Escape hatch: return { renderer, scene, camera }
2211
+ return { renderer: _renderer, scene: _scene, camera: _camera };
2212
+ },
2213
+
2214
+ // ── projection (OPTIONAL — the scene's camera intrinsics, §1a.5) ──
2215
+ // `fovY` is FERRIED from the camera-geometry authority, never a literal typed
2216
+ // here (a second FOV copy is the split-basis bug in miniature). three's own 2D
2217
+ // ortho mode reports the same `affine2d` kind as p5 — one vocabulary.
2218
+ projection: function () {
2219
+ if (!_is3D) { return { kind: 'affine2d' }; }
2220
+ return {
2221
+ kind: 'perspective',
2222
+ fovY: ThreeCameraGeometry.CAMERA_FOV_DEG
2223
+ };
2224
+ },
2225
+
2226
+ // ── captureStill (OPTIONAL — the `composite` tap executor, §1a.4) ──
2227
+ // The compliant GL readback, in ONE JS task: (1) replay the identical final
2228
+ // composite against the RETAINED scene + LAST options bag (no
2229
+ // preserveDrawingBuffer ⇒ an earlier task's buffer is already cleared), then
2230
+ // (2) readPixels + bottom-left→top-left flip through the shared owner. Promise
2231
+ // per the frozen signature (WebGPU readback is async); resolves immediately.
2232
+ // FAIL-LOUD before the first renderTick — never a blank still a golden would
2233
+ // silently absorb.
2234
+ captureStill: function () {
2235
+ if (!_initialized || !_renderer) {
2236
+ throw new Error('[three-provider.captureStill] no live renderer — init() has ' +
2237
+ 'not run. (FAIL-LOUD — never a null or blank still.)');
2238
+ }
2239
+ if (!_lastTickData) {
2240
+ throw new Error('[three-provider.captureStill] nothing has been composited yet ' +
2241
+ '(no renderTick/repaint since init). A capture before the first draw would be ' +
2242
+ 'a cleared framebuffer, i.e. a black still. (FAIL-LOUD.)');
2243
+ }
2244
+ var common = CaptureStillCommon;
2245
+ // 1. Same-task re-render of the retained scene through the ONE dispatch.
2246
+ _composeFinal(_lastOpts, _lastTickData);
2247
+ // 2. Same-task readback.
2248
+ var gl = _renderer.getContext();
2249
+ var el = _renderer.domElement;
2250
+ var bag = common.captureFromGl(gl, el.width, el.height, {
2251
+ cssWidth: el.clientWidth || _physW(),
2252
+ cssHeight: el.clientHeight || _physH(),
2253
+ physicsW: _physW(),
2254
+ physicsH: _physH(),
2255
+ // This provider's OWN ordered _filters list is the one authority on
2256
+ // which passes ran and in what order (it also sees a direct BYO
2257
+ // prov.filter() call, which the runner's map does not).
2258
+ filtersApplied: _filters.map(function (f) { return f.name; }),
2259
+ renderBaseCanvas: _lastRenderBaseCanvas,
2260
+ filterTime: _lastFilterTime,
2261
+ // The TickData the replayed composite was drawn from → `renderedTick`.
2262
+ composited: _lastTickData
2263
+ });
2264
+ return Promise.resolve(bag);
2265
+ },
2266
+
2267
+ // ── Camera control (OPTIONAL convenience capability) ───────────────
2268
+ // Returns a sugar handle { setOrbit, getOrbit } the experiment wires a
2269
+ // widget to — the camera analogue of the blackbody oven's live setTemperature.
2270
+ // Returns null in 2D mode (no orbit camera): consumers capability-CHECK
2271
+ // the result (runner.cameraControl == null → hide the camera UI), they
2272
+ // never sniff the renderer. A custom BYO provider may implement the same
2273
+ // method to get the same UI for free.
2274
+ getCameraControl: function () {
2275
+ if (!_is3D) { return null; }
2276
+ // The preset table and the pose-axis descriptors are DECLARED in
2277
+ // three-camera-contract.js (the ONE source for both). The handle
2278
+ // PUBLISHES them, so every host renders whatever THIS camera
2279
+ // declares — and a BYO provider's own lists show up for free.
2280
+ var _contract = ThreeCameraContract;
2281
+ function _presets() { return _contract.cameraPresets(_physW(), _physH()); }
2282
+ /**
2283
+ * THROW-OR-APPLY input guard (the property-accessor discipline).
2284
+ * A pose write is either applied or it FAILS LOUD — a silent
2285
+ * clamp-and-continue on a NaN would leave the camera in a
2286
+ * plausible-looking wrong place with no diagnostic. Absent /
2287
+ * `undefined` still means "not mentioned" (a partial spec is the
2288
+ * whole point of setOrbit), so only a PRESENT non-finite value
2289
+ * throws. There is deliberately no fail-loud at the orbit POLE:
2290
+ * a chart singularity is not a contract violation, and a throw
2291
+ * mid-drag is a grenade (see _decomposeOrbitBasis).
2292
+ */
2293
+ function _finite(v, name, where) {
2294
+ if (typeof v === 'number' && isFinite(v)) { return v; }
2295
+ throw new Error('three-provider camera ' + where + ': ' + name +
2296
+ ' must be a finite number, got ' + String(v) +
2297
+ ' — throw-or-apply, never a silent clamp.');
2298
+ }
2299
+ var ctl = {
2300
+ presets: Object.keys(_presets()),
2301
+ // POSE AXES: name = the getOrbit()/setOrbit() key,
2302
+ // constraint/widgetConfig = the slider range + step. A newly
2303
+ // declared axis gets a slider in every host with no host edit.
2304
+ poseFields: _contract.cameraPoseFields(_physW(), _physH()),
2305
+ setOrbit: function (spec) {
2306
+ if (!spec) { return; }
2307
+ // POINT SHAPE FIRST, at the ONE owner: both spellings arrive here as
2308
+ // the canonical object form, and a malformed point has already thrown
2309
+ // the same message the 2D handle throws — never a silent re-centre.
2310
+ spec = OrbitBag.normalizeOrbitPatch(spec);
2311
+ if (spec.azimuth !== undefined) { _orbit.azimuth = _finite(spec.azimuth, 'azimuth', 'setOrbit'); }
2312
+ if (spec.elevation !== undefined) { _orbit.elevation = _finite(spec.elevation, 'elevation', 'setOrbit'); }
2313
+ if (spec.distance !== undefined) { _orbit.distance = _finite(spec.distance, 'distance', 'setOrbit'); }
2314
+ // ROLL — the fifth pose coordinate (rotation about the view
2315
+ // axis). It WRAPS (mod 360) and is NEVER clamped: roll has no
2316
+ // pole, so a clamp would make the picture stop rolling part-way.
2317
+ if (spec.roll !== undefined) {
2318
+ var _r = _finite(spec.roll, 'roll', 'setOrbit') % 360;
2319
+ _orbit.roll = (_r > 180) ? _r - 360 : ((_r <= -180) ? _r + 360 : _r);
2320
+ }
2321
+ // `target` is only touched when the spec MENTIONS it, so a
2322
+ // look/zoom write never silently un-walks the camera. An
2323
+ // explicit `target:null` re-centres on the scene.
2324
+ if ('target' in spec) {
2325
+ var t = spec.target;
2326
+ _orbit.target = t ? { x: t.x, y: t.y, z: t.z } : null;
2327
+ }
2328
+ _applyOrbit();
2329
+ },
2330
+ getOrbit: function () { return _orbitBag(); },
2331
+ // addPoseListener(fn) → unsubscribe — the POSE-CHANGE NOTICE: `fn(bag)` runs
2332
+ // after EVERY pose write (setOrbit, look, move, setPreset, a resize), with
2333
+ // the getOrbit() bag, whichever surface wrote it. A dependent that must
2334
+ // follow the camera (the runner's aim + carry) subscribes here instead of
2335
+ // being forwarded to by each writer.
2336
+ addPoseListener: function (fn) {
2337
+ if (!_poseNotice) { _poseNotice = OrbitBag.makePoseNotice(); }
2338
+ return _poseNotice.add(fn);
2339
+ },
2340
+ setPreset: function (name) {
2341
+ var all = _presets(), p = all[name];
2342
+ if (!p) {
2343
+ throw new Error('[three-provider camera] setPreset("' + name + '"): this camera offers ' +
2344
+ 'only [' + Object.keys(all).join(', ') + '] (ctl.presets). (FAIL-LOUD.)');
2345
+ }
2346
+ // A preset is a FULL framing reset — it re-centres the walk AND
2347
+ // levels the horizon, which is what makes the preset buttons the
2348
+ // reliable way back. A stale roll surviving a preset click would
2349
+ // break that promise, so roll:0 is part of the reset.
2350
+ ctl.setOrbit({ azimuth: p.azimuth, elevation: p.elevation,
2351
+ distance: p.distance, roll: 0, target: null });
2352
+ },
2353
+ // ── look — ROTATE the camera, screen-relative (the LOOK drag) ───────
2354
+ // The INTERACTIVE peer of `move()`: `{spin, pitch}` in DEGREES,
2355
+ // INCREMENTAL, both applied in the camera's OWN frame.
2356
+ // spin → rotate about the view axis → the picture ROLLS.
2357
+ // pitch → rotate about screen-right → the view tilts up/down,
2358
+ // orbiting the centre at a FIXED radius.
2359
+ // Successive screen-relative rotations do NOT compose the way three
2360
+ // Euler angles applied in a fixed order do, so this goes through the
2361
+ // ONE rotation authority (three-camera-geometry `_rotateOrbit`):
2362
+ // compose the bag into an orthonormal frame, rotate the FRAME,
2363
+ // decompose back. `azimuth` is therefore DERIVED on this path (an
2364
+ // output of the decomposition) while staying a writable declarative
2365
+ // coordinate through `setOrbit` — the same coordinate, two directions
2366
+ // through the same adjoint pair. `distance`/`target` are untouched:
2367
+ // a LOOK is never a translation.
2368
+ //
2369
+ // A LOOK widget therefore composes NOTHING — it forwards its stick
2370
+ // deflection as a delta and all the geometry stays here. A BYO
2371
+ // provider that omits `look` is capability-checked by its absence
2372
+ // (LOOK degrades to a defined no-op, never a substituted rotation),
2373
+ // exactly like `move`.
2374
+ look: function (spec) {
2375
+ if (!spec) { return; }
2376
+ var spin = (spec.spin === undefined) ? 0 : _finite(spec.spin, 'spin', 'look');
2377
+ var pitch = (spec.pitch === undefined) ? 0 : _finite(spec.pitch, 'pitch', 'look');
2378
+ if (!spin && !pitch) { return; }
2379
+ var next = ThreeCameraGeometry
2380
+ ._rotateOrbit(_orbit, { spin: spin, pitch: pitch }, _physW(), _physH());
2381
+ _orbit.azimuth = next.azimuth;
2382
+ _orbit.elevation = next.elevation;
2383
+ _orbit.roll = next.roll;
2384
+ _applyOrbit();
2385
+ },
2386
+ // ── move — TRANSLATE the camera, screen-relative (the FPS walk) ─────
2387
+ // The translation peer of `look()`. `{forward, strafe}` in ENGINE
2388
+ // PIXELS: `forward` walks along the FULL view direction (positive =
2389
+ // into the picture, so objects grow), `strafe` slides along
2390
+ // screen-right. NEITHER changes azimuth, elevation, roll or distance —
2391
+ // a walk is never a rotation.
2392
+ //
2393
+ // MOVE is ALWAYS screen-relative (CEO 2026-07-28): the walk is NOT
2394
+ // pinned to a ground plane, so walking forward while pitched down
2395
+ // DESCENDS. This is a fly control, not an FPS ground walk — and it is
2396
+ // what keeps "positive strafe reads as rightward on screen" true at
2397
+ // roll ≠ 0, where screen-right is no longer world-horizontal.
2398
+ //
2399
+ // It exists so a MOVE joystick (CameraControls.js) can be pure
2400
+ // translation without re-deriving any orbit math in the widget: ALL the
2401
+ // geometry stays at the one camera authority (the same split
2402
+ // cameraOffsetFromWorld/screenToWorld already use). A BYO provider that
2403
+ // does not implement it is capability-checked by its absence, exactly
2404
+ // like getCameraControl itself.
2405
+ move: function (spec) {
2406
+ if (!spec) { return; }
2407
+ var f = (spec.forward === undefined) ? 0 : _finite(spec.forward, 'forward', 'move');
2408
+ var s = (spec.strafe === undefined) ? 0 : _finite(spec.strafe, 'strafe', 'move');
2409
+ if (!f && !s) { return; }
2410
+ _orbit.target = ThreeCameraGeometry
2411
+ ._orbitTranslate(_orbit, _physW(), _physH(), f, s);
2412
+ _applyOrbit();
2413
+ },
2414
+ // ── Camera-relative carry transform (OPTIONAL capability, Q-A5-CAP) ──
2415
+ // The world↔camera-space transform PAIR that lets a feature carry a
2416
+ // selected object PINNED to the camera view (A5): capture the object's
2417
+ // fixed camera-local offset once, then re-place it as the orbit pose
2418
+ // changes. ALL orbit math stays at the ONE camera authority
2419
+ // (three-camera-geometry `_orbitCameraBasis`, which screenToWorld also
2420
+ // reuses) — the feature never reads _camera.position, never re-derives
2421
+ // the basis, never sniffs the renderer. Because that basis is the
2422
+ // ROLLED one, a carried object stays pinned through a roll exactly as
2423
+ // it does through an azimuth change.
2424
+ // Units = ENGINE PIXELS throughout (directly feeds
2425
+ // runner.updateObjectProperty(h,'center',…)). Only reachable when _is3D
2426
+ // (getCameraControl returns null in 2D), so both are inherently 2D-null.
2427
+ //
2428
+ // cameraOffsetFromWorld([x,y,z]) → {right,up,forward}: the world point in
2429
+ // the camera's orthonormal frame (capture a selection's FIXED offset once).
2430
+ cameraOffsetFromWorld: function (worldPoint) {
2431
+ return ThreeCameraGeometry
2432
+ ._cameraOffsetFromWorld(_orbit, _physW(), _physH(), worldPoint);
2433
+ },
2434
+ // worldFromCameraOffset({right,up,forward}) → [x,y,z]: the exact inverse off
2435
+ // the LIVE pose (re-place the object as az/el/dist change → pinned in view).
2436
+ worldFromCameraOffset: function (off) {
2437
+ return ThreeCameraGeometry
2438
+ ._worldFromCameraOffset(_orbit, _physW(), _physH(), off);
2439
+ }
2440
+ };
2441
+ return ctl;
2442
+ },
2443
+
2444
+ // renderCapabilities — OPTIONAL (see the module-level docstring above
2445
+ // _renderCapabilitiesThree). Static per-provider metadata; no live
2446
+ // WebGL/THREE instance state needed, so the free function above is
2447
+ // called unmodified.
2448
+ renderCapabilities: _renderCapabilitiesThree,
2449
+
2450
+ // ── screenToWorld (OPTIONAL convenience capability, S5b) ────────────
2451
+ // WS Studio Controls Realignment §7 r2 item 4/§2.5.7 "3D drag":
2452
+ // "three: raycast against the object's plane [at its z]". Returns
2453
+ // `null` in 2D mode (no orbit camera to raycast from — mirrors
2454
+ // `getCameraControl`'s own 2D-mode null) or when the ray is parallel
2455
+ // to / behind the requested z-plane. `_screenToWorldThree`
2456
+ // (three-camera-geometry.js, THREE-instance-free — see its own
2457
+ // docstring) does the actual pinhole-camera math off the SAME rolled
2458
+ // basis the live camera uses; this is a thin live-state wrapper.
2459
+ screenToWorld: function (canvasCssX, canvasCssY, canvasCssWidth, canvasCssHeight, worldZ) {
2460
+ if (!_is3D) { return null; }
2461
+ return ThreeCameraGeometry._screenToWorldThree(_orbit, _physW(), _physH(),
2462
+ canvasCssX, canvasCssY, canvasCssWidth, canvasCssHeight, worldZ);
2463
+ },
2464
+
2465
+ get initialized() { return _initialized; }
2466
+ };
2467
+
2468
+ return provider;
2469
+ }
2470
+
2471
+ // _renderCapabilitiesThree is exported beside the factory so the capability LIST is testable in
2472
+ // node WITHOUT a live THREE/WebGL provider. The camera GEOMETRY is NOT re-exported: its one
2473
+ // import path is three-camera-geometry.js.
2474
+ export { makeThreeProvider, _renderCapabilitiesThree, loSkin, loSkinSig, applyLoSkin, LO_FACE_MUL };