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