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

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