@descix/frqtl-node 0.0.0-stage → 0.6.0-p0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +87 -0
- package/LICENSE.md +664 -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 +2499 -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 +84 -0
- package/dist/frqtl/compat/renderer/RenderMesh.js +99 -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 +258 -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 +2470 -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 +2920 -0
- package/dist/frqtl/objects/ObjectFactory.js +296 -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 +710 -0
- package/dist/frqtl/objects/instruments/hosted-eyes.js +984 -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 +411 -0
- package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
- package/dist/frqtl/objects/objects/LightSource.js +351 -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 +309 -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 +820 -0
- package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
- package/dist/frqtl/objects/objects/slitLinerScatter.js +261 -0
- package/dist/frqtl/objects/objects/sustainedSource.js +298 -0
- package/dist/frqtl/objects/objects/tagger.js +228 -0
- package/dist/frqtl/objects/schema/engine-scopes.js +468 -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 +250 -0
- package/dist/frqtl/objects/setup/setupDoubleSlit.js +326 -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 +277 -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 +1258 -0
- package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
- package/dist/frqtl/objects/setup/setupTunnelLab.js +240 -0
- package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
- package/dist/frqtl/objects/setup/setupWaveInterference.js +177 -0
- package/dist/frqtl/objects/v1-replacements.js +29 -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 +34 -0
- package/dist/frqtl/parts/CollisionTypes.js +30 -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 +489 -0
- package/dist/frqtl/parts/LargeObject.js +405 -0
- package/dist/frqtl/parts/LightColor.js +255 -0
- package/dist/frqtl/parts/LiveChart.js +285 -0
- package/dist/frqtl/parts/LogicGate.js +717 -0
- package/dist/frqtl/parts/OvenBox.js +611 -0
- package/dist/frqtl/parts/PointSource.js +762 -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 +243 -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 +211 -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 +690 -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 +325 -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 +671 -0
- package/dist/shim/notebook/knob.js +150 -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 +507 -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 +213 -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 +804 -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 +63 -4
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/* Copyright 2023-2026 by Essam Abadir */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* three-camera-geometry.js — the three.js camera's PURE GEOMETRY authority: the
|
|
5
|
+
* orbit bag ⇄ camera basis maps, the screen-relative rotation/translation
|
|
6
|
+
* primitives, and the analytic pinhole-camera ray math.
|
|
7
|
+
*
|
|
8
|
+
* ## Why this is its own file
|
|
9
|
+
*
|
|
10
|
+
* It is the pure half of the camera authority: plain functions of an orbit bag
|
|
11
|
+
* and a scene size, returning plain `{x,y,z}` data, with no THREE, no WebGL and
|
|
12
|
+
* no live provider state. `three-provider.js` (the live half) resolves it and
|
|
13
|
+
* every camera surface it exposes — `_applyOrbit`, `screenToWorld`,
|
|
14
|
+
* `getCameraControl()`'s `setOrbit`/`look`/`move`/carry methods — is a THIN
|
|
15
|
+
* wrapper over the functions below. **All camera geometry lives HERE**: the
|
|
16
|
+
* provider positions a camera, it does not re-derive a basis, and no widget or
|
|
17
|
+
* host above it does either.
|
|
18
|
+
*
|
|
19
|
+
* Its sibling `three-camera-contract.js` is the DECLARATIVE half (the preset
|
|
20
|
+
* table + the pose-axis field descriptors). Three files, one camera: declarative
|
|
21
|
+
* contract / pure geometry / live renderer.
|
|
22
|
+
*
|
|
23
|
+
* Keeping geometry separate also holds `three-provider.js` under the per-file
|
|
24
|
+
* size ceiling that `sdk/_build/verify-publish-surface.mjs` enforces
|
|
25
|
+
* on the copied-verbatim dist directories — that gate's own instruction is to
|
|
26
|
+
* SPLIT at a natural seam rather than raise the ceiling, and "pure orbit
|
|
27
|
+
* geometry vs live renderer" is that seam.
|
|
28
|
+
*
|
|
29
|
+
* ## The orbit bag
|
|
30
|
+
*
|
|
31
|
+
* `{ azimuth, elevation, distance, roll, target }`
|
|
32
|
+
*
|
|
33
|
+
* Generalized coordinates, all angles in DEGREES, `distance` in ENGINE PIXELS:
|
|
34
|
+
* - `azimuth` — orbit angle around the world vertical.
|
|
35
|
+
* - `elevation` — orbit angle above (+) / below (−) the horizontal, |el| ≤ 90.
|
|
36
|
+
* - `distance` — how far the eye sits from the orbit centre.
|
|
37
|
+
* - `roll` — rotation about the VIEW AXIS (the horizon tilt). WRAPS
|
|
38
|
+
* (mod 360); it has no pole, so it is never clamped.
|
|
39
|
+
* - `target` — the point the orbit revolves around, or `null` = the scene
|
|
40
|
+
* centre (`orbitCenter` is the ONE resolver).
|
|
41
|
+
*
|
|
42
|
+
* The bag is BOTH the declarative contract (a rung's stored camera, a saved
|
|
43
|
+
* project, a drone waypoint) and the provider's stored state. The camera's
|
|
44
|
+
* up-VECTOR is never stored — it is DERIVED as `orbitCameraBasis().up`. The bag
|
|
45
|
+
* carries the free coordinate; the geometry derives the vector.
|
|
46
|
+
*
|
|
47
|
+
* ## The two maps (compose ⇄ decompose)
|
|
48
|
+
*
|
|
49
|
+
* `orbitCameraBasis(bag, w, h)` → `{pos, forward, right, up}` — compose
|
|
50
|
+
* `decomposeOrbitBasis(basis)` → `{azimuth, elevation, roll}` — decompose
|
|
51
|
+
*
|
|
52
|
+
* They are exact adjoints (see `decomposeOrbitBasis`'s own docstring for the
|
|
53
|
+
* pole convention), which is what lets `rotateOrbit` express an INTERACTIVE,
|
|
54
|
+
* screen-relative rotation (a LOOK drag) as: compose → rotate the orthonormal
|
|
55
|
+
* frame → decompose back to the bag. Azimuth is therefore an OUTPUT on the
|
|
56
|
+
* interactive path while remaining a writable coordinate on the declarative one
|
|
57
|
+
* — the same coordinate, two directions through the same adjoint pair.
|
|
58
|
+
*
|
|
59
|
+
* ## Node
|
|
60
|
+
*
|
|
61
|
+
* Named ES-module exports, so every function here is testable in plain
|
|
62
|
+
* Node with no THREE instance — see `providers/test/camera-roll-geometry.test.mjs`,
|
|
63
|
+
* `camera-carry.test.mjs`, `screen-to-world.test.mjs`.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
// ── Constants ───────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/** The ONE vertical-FOV constant (degrees). `three-provider.js`'s `_init`
|
|
71
|
+
* constructs `new THREE.PerspectiveCamera(...)` with THIS — never a second
|
|
72
|
+
* literal `50` — so the analytic ray math below and the live camera share
|
|
73
|
+
* one frustum by construction. */
|
|
74
|
+
var CAMERA_FOV_DEG = 50;
|
|
75
|
+
|
|
76
|
+
/** Ray/plane ANGLE guard (`dir.z` IS a cosine for a unit `dir`): reject any
|
|
77
|
+
* ray within ~2.9° of parallel-to-the-plane, never just "exactly" parallel.
|
|
78
|
+
* See `intersectRayWithZPlane` for the regression this closes. */
|
|
79
|
+
var MIN_RAY_PLANE_COS = 0.05;
|
|
80
|
+
/** Reject an unprojected result beyond 20× the scene's own width/height. */
|
|
81
|
+
var SANITY_EXTENT_FACTOR = 20;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The FIXED world-up HINT the orbit basis is built from — physics y-DOWN, so
|
|
85
|
+
* it matches the render camera's own `up` before any roll. It appears in
|
|
86
|
+
* EXACTLY ONE place in the repo (here): `orbitCameraBasis` is the sole
|
|
87
|
+
* consumer, and every other camera surface (the live `_applyOrbit`, MOVE
|
|
88
|
+
* strafe, `screenToWorld`, the carry pin, `rayDirForNdc`) consumes the BASIS
|
|
89
|
+
* this produces. Three hand-copies of this hint used to exist — the roll
|
|
90
|
+
* capability is exactly what a fourth would have broken, since a rolled
|
|
91
|
+
* picture with an UNROLLED strafe/drag/carry basis is the schema-mirror
|
|
92
|
+
* failure signature (one datum, two enumerations, breaks in one consumer).
|
|
93
|
+
*/
|
|
94
|
+
var UP_HINT = { x: 0, y: -1, z: 0 };
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Pole band (dimensionless): |cos(elevation)| below this and the
|
|
98
|
+
* (azimuth, roll) split is a GAUGE — see `decomposeOrbitBasis`. Both the
|
|
99
|
+
* compose and the decompose branch on the SAME quantity with the SAME
|
|
100
|
+
* threshold, which is what makes the round trip exact at the pole.
|
|
101
|
+
*/
|
|
102
|
+
var POLE_EPS = 1e-9;
|
|
103
|
+
|
|
104
|
+
var DEG = Math.PI / 180;
|
|
105
|
+
var RAD = 180 / Math.PI;
|
|
106
|
+
|
|
107
|
+
// ── Small vector helpers (private) ──────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
function _cross(a, b) {
|
|
110
|
+
return {
|
|
111
|
+
x: a.y * b.z - a.z * b.y,
|
|
112
|
+
y: a.z * b.x - a.x * b.z,
|
|
113
|
+
z: a.x * b.y - a.y * b.x
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function _dot(a, b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
|
|
117
|
+
function _norm(v) {
|
|
118
|
+
var l = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) || 1;
|
|
119
|
+
return { x: v.x / l, y: v.y / l, z: v.z / l };
|
|
120
|
+
}
|
|
121
|
+
/** Rodrigues rotation of `v` about the UNIT axis `k` by `rad`. */
|
|
122
|
+
function _rotateAbout(v, k, rad) {
|
|
123
|
+
var c = Math.cos(rad), s = Math.sin(rad);
|
|
124
|
+
var kxv = _cross(k, v);
|
|
125
|
+
var kdv = _dot(k, v) * (1 - c);
|
|
126
|
+
return {
|
|
127
|
+
x: v.x * c + kxv.x * s + k.x * kdv,
|
|
128
|
+
y: v.y * c + kxv.y * s + k.y * kdv,
|
|
129
|
+
z: v.z * c + kxv.z * s + k.z * kdv
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Wrap an angle to (-180, 180] — roll and azimuth WRAP, they never clamp. */
|
|
133
|
+
function _wrapDeg(d) {
|
|
134
|
+
var w = ((d + 180) % 360 + 360) % 360 - 180;
|
|
135
|
+
return (w === -180) ? 180 : w;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── The orbit centre ────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The ONE resolver for the point the orbit revolves around (and the camera
|
|
142
|
+
* looks at). Default = the scene centre `(width/2, height/2, 0)`; an orbit
|
|
143
|
+
* carrying a `target:{x,y,z}` (set by `move()` — the FPS walk) revolves
|
|
144
|
+
* around THAT instead. Every consumer of the camera geometry —
|
|
145
|
+
* `orbitCameraBasis`, and through it `screenToWorld` and the carry
|
|
146
|
+
* transforms — resolves the centre HERE, so a walked camera keeps
|
|
147
|
+
* click-to-place and carry registered instead of silently desyncing.
|
|
148
|
+
*/
|
|
149
|
+
function _orbitCenter(orbit, width, height) {
|
|
150
|
+
var t = orbit && orbit.target;
|
|
151
|
+
if (t && typeof t.x === 'number' && typeof t.y === 'number' && typeof t.z === 'number') {
|
|
152
|
+
return { x: t.x, y: t.y, z: t.z };
|
|
153
|
+
}
|
|
154
|
+
return { x: width / 2, y: height / 2, z: 0 };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── COMPOSE: bag → camera position + orthonormal basis ──────────────────
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Camera position + orthonormal basis (forward/right/up) for the given orbit
|
|
161
|
+
* bag, around the orbit's resolved centre (`_orbitCenter` — the scene centre,
|
|
162
|
+
* or a walked `target`). This is the EXACT geometry `three-provider.js`'s
|
|
163
|
+
* `_applyOrbit` positions the live THREE camera with, and the ONE camera
|
|
164
|
+
* authority every other surface consumes.
|
|
165
|
+
*
|
|
166
|
+
* `pos = centre − distance · forward` exactly, so a ROLL (a rotation about
|
|
167
|
+
* `forward`) never moves the eye and never changes the radius — a LOOK is
|
|
168
|
+
* fixed-radius by construction, and only MOVE translates.
|
|
169
|
+
*
|
|
170
|
+
* Basis convention: `right`/`up` ARE screen-right/screen-up by construction,
|
|
171
|
+
* because they are derived from the SAME `UP_HINT` the render camera's own
|
|
172
|
+
* `up` comes from (see UP_HINT). At the default pose (az=0, el=0, roll=0):
|
|
173
|
+
* `forward = (0,0,-1)` INTO the screen (viewer on +z), `right = (-1,0,0)`
|
|
174
|
+
* = world −x = SCREEN-RIGHT, `up = (0,-1,0)` = world −y = physics-UP =
|
|
175
|
+
* SCREEN-UP. That is what makes `screenToWorld`, the MOVE strafe, the carry
|
|
176
|
+
* pin and the drive change-of-basis screen-consistent regardless of the
|
|
177
|
+
* world-axis SIGN convention.
|
|
178
|
+
*
|
|
179
|
+
* ROLL enters HERE and nowhere else: after `right`/`up` are built from the
|
|
180
|
+
* fixed hint, both are rotated about `forward` by `roll` (right-handed about
|
|
181
|
+
* `forward`), which on screen spins the PICTURE counter-clockwise for a
|
|
182
|
+
* positive roll. `forward` is untouched, so roll changes the horizon tilt and
|
|
183
|
+
* nothing else.
|
|
184
|
+
*
|
|
185
|
+
* TOTAL at the poles: at |elevation| = 90 the hint is parallel to `forward`
|
|
186
|
+
* and the cross product vanishes, so the unrolled `right` is taken as the
|
|
187
|
+
* analytic LIMIT of the off-pole formula, `(−cos az, 0, sin az)`. Pitching
|
|
188
|
+
* over the top is therefore legal and continuous — no clamp, no NaN basis.
|
|
189
|
+
*
|
|
190
|
+
* @param {{azimuth:number,elevation:number,distance:number,roll?:number,target?:object}} orbit
|
|
191
|
+
* @param {number} width scene width in engine pixels
|
|
192
|
+
* @param {number} height scene height in engine pixels
|
|
193
|
+
* @returns {{pos:{x,y,z}, forward:{x,y,z}, right:{x,y,z}, up:{x,y,z}}}
|
|
194
|
+
*/
|
|
195
|
+
function _orbitCameraBasis(orbit, width, height) {
|
|
196
|
+
var _c = _orbitCenter(orbit, width, height);
|
|
197
|
+
var cx = _c.x, cy = _c.y, cz = _c.z;
|
|
198
|
+
var az = orbit.azimuth * DEG;
|
|
199
|
+
var el = orbit.elevation * DEG;
|
|
200
|
+
var d = orbit.distance;
|
|
201
|
+
var cosEl = Math.cos(el), sinEl = Math.sin(el);
|
|
202
|
+
var pos = {
|
|
203
|
+
x: cx + d * cosEl * Math.sin(az),
|
|
204
|
+
y: cy + d * sinEl,
|
|
205
|
+
z: cz + d * cosEl * Math.cos(az)
|
|
206
|
+
};
|
|
207
|
+
// forward = normalize(centre - pos) = -(cosEl sinAz, sinEl, cosEl cosAz)
|
|
208
|
+
var forward = { x: -cosEl * Math.sin(az), y: -sinEl, z: -cosEl * Math.cos(az) };
|
|
209
|
+
|
|
210
|
+
// Unrolled screen-right = normalize(forward × UP_HINT). |forward × UP_HINT|
|
|
211
|
+
// is exactly |cosEl|, so the pole test below is the SAME quantity (and the
|
|
212
|
+
// SAME POLE_EPS) `decomposeOrbitBasis` branches on — that is what makes the
|
|
213
|
+
// two exact adjoints at the pole as well as away from it.
|
|
214
|
+
var right;
|
|
215
|
+
if (Math.abs(cosEl) < POLE_EPS) {
|
|
216
|
+
// POLE: the analytic limit of normalize(forward × UP_HINT) as el → ±90.
|
|
217
|
+
//
|
|
218
|
+
// DEFENSIVE GUARD, deliberately not a behavioural gate. `Math.cos` never
|
|
219
|
+
// returns exactly 0 for a double (cos(90°) is ~6.1e-17), so the generic
|
|
220
|
+
// branch below already normalizes cleanly at the pole and produces this
|
|
221
|
+
// very vector — verified by mutation: disabling this branch moves NO test.
|
|
222
|
+
// It exists so that a genuinely zero cross product could never reach
|
|
223
|
+
// `_norm`'s `|| 1` fallback and yield a SILENT zero basis vector, which is
|
|
224
|
+
// the one failure mode here that would not announce itself. If you are
|
|
225
|
+
// hunting for the test that covers this line: there isn't one, and that is
|
|
226
|
+
// recorded on purpose rather than papered over with a vacuous assertion.
|
|
227
|
+
right = { x: -Math.cos(az), y: 0, z: Math.sin(az) };
|
|
228
|
+
} else {
|
|
229
|
+
right = _norm(_cross(forward, UP_HINT));
|
|
230
|
+
}
|
|
231
|
+
var up = _cross(right, forward);
|
|
232
|
+
|
|
233
|
+
var rollDeg = (typeof orbit.roll === 'number') ? orbit.roll : 0;
|
|
234
|
+
if (rollDeg) {
|
|
235
|
+
var r = rollDeg * DEG;
|
|
236
|
+
right = _rotateAbout(right, forward, r);
|
|
237
|
+
up = _rotateAbout(up, forward, r);
|
|
238
|
+
}
|
|
239
|
+
return { pos: pos, forward: forward, right: right, up: up };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── DECOMPOSE: camera basis → bag angles (the exported adjoint) ──────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The adjoint of `_orbitCameraBasis`: recover `{azimuth, elevation, roll}`
|
|
246
|
+
* (degrees) from an orthonormal camera basis. Colocated with its inverse on
|
|
247
|
+
* purpose — a decomposition that drifts from the composition is the whole
|
|
248
|
+
* bug class this pair exists to prevent.
|
|
249
|
+
*
|
|
250
|
+
* `distance` and `target` are NOT recovered: a rotation about the eye or the
|
|
251
|
+
* centre changes neither, so `rotateOrbit` carries them through untouched.
|
|
252
|
+
*
|
|
253
|
+
* elevation = asin(−forward.y) (always in [−90, 90])
|
|
254
|
+
* azimuth = atan2(−forward.x, −forward.z)
|
|
255
|
+
* roll = the signed angle from the UNROLLED right/up frame to the
|
|
256
|
+
* basis's own right, measured about `forward`.
|
|
257
|
+
*
|
|
258
|
+
* Because elevation is always returned in [−90, 90], pitching OVER the pole
|
|
259
|
+
* re-charts automatically as the coordinated (azimuth+180, elevation
|
|
260
|
+
* reflected, roll+180) transition — the ORIENTATION is continuous; only its
|
|
261
|
+
* coordinates change chart. This is what makes "any view can be achieved"
|
|
262
|
+
* true without an elevation clamp anywhere.
|
|
263
|
+
*
|
|
264
|
+
* ## Pole convention (documented, STATELESS, total — and it never throws)
|
|
265
|
+
*
|
|
266
|
+
* Inside the band |cos(elevation)| < POLE_EPS the (azimuth, roll) split is
|
|
267
|
+
* ill-conditioned — but the ill-conditioned direction IS the gauge direction:
|
|
268
|
+
* `(az+δ, roll+δ)` composes to the SAME orientation. So the APPLIED VIEW is
|
|
269
|
+
* stable to floating-point epsilon straight through the pole; only the
|
|
270
|
+
* REPORTED azimuth/roll numbers may jump along the gauge.
|
|
271
|
+
*
|
|
272
|
+
* The gauge is fixed statelessly: **roll := 0, and azimuth absorbs the whole
|
|
273
|
+
* screen-spin**, read off `right`'s horizontal projection —
|
|
274
|
+
* `azimuth = atan2(right.z, −right.x)`. That is exactly the off-pole formula's
|
|
275
|
+
* own limit at roll = 0, so the convention is continuous with the generic
|
|
276
|
+
* branch rather than an arbitrary snap, and `_orbitCameraBasis` composes it
|
|
277
|
+
* back exactly (its pole branch uses the same limit).
|
|
278
|
+
*
|
|
279
|
+
* A chart singularity is not a contract violation and a throw mid-drag is a
|
|
280
|
+
* grenade, so this function is TOTAL and never throws. FAIL-LOUD lives at the
|
|
281
|
+
* INPUTS instead: `setOrbit` / `look` / `move` reject non-finite values.
|
|
282
|
+
*
|
|
283
|
+
* @param {{forward:{x,y,z}, right:{x,y,z}, up:{x,y,z}}} basis
|
|
284
|
+
* @returns {{azimuth:number, elevation:number, roll:number}} degrees
|
|
285
|
+
*/
|
|
286
|
+
function _decomposeOrbitBasis(basis) {
|
|
287
|
+
var f = basis.forward, rt = basis.right;
|
|
288
|
+
var sinEl = -f.y;
|
|
289
|
+
if (sinEl > 1) { sinEl = 1; }
|
|
290
|
+
if (sinEl < -1) { sinEl = -1; }
|
|
291
|
+
var elevation = Math.asin(sinEl) * RAD;
|
|
292
|
+
var horiz = Math.sqrt(f.x * f.x + f.z * f.z); // = |cos(elevation)|
|
|
293
|
+
|
|
294
|
+
if (horiz < POLE_EPS) {
|
|
295
|
+
// POLE: gauge-fixed — roll 0, azimuth carries the screen-spin.
|
|
296
|
+
return {
|
|
297
|
+
azimuth: _wrapDeg(Math.atan2(rt.z, -rt.x) * RAD),
|
|
298
|
+
elevation: elevation,
|
|
299
|
+
roll: 0
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
var azimuth = Math.atan2(-f.x, -f.z) * RAD;
|
|
304
|
+
// The UNROLLED reference frame at this (azimuth, elevation).
|
|
305
|
+
var right0 = _norm(_cross(f, UP_HINT));
|
|
306
|
+
var up0 = _cross(right0, f);
|
|
307
|
+
// right = cos(roll)·right0 − sin(roll)·up0 (right-handed about forward)
|
|
308
|
+
var roll = Math.atan2(-_dot(rt, up0), _dot(rt, right0)) * RAD;
|
|
309
|
+
return { azimuth: _wrapDeg(azimuth), elevation: elevation, roll: _wrapDeg(roll) };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── The interactive rotation: compose → rotate → decompose ──────────────
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* `rotateOrbit(bag, {spin, pitch}, w, h)` → a NEW bag (pure; mutates nothing).
|
|
316
|
+
*
|
|
317
|
+
* The SCREEN-RELATIVE, INCREMENTAL rotation a LOOK drag produces — the peer
|
|
318
|
+
* of `_orbitTranslate` (which is what a MOVE drag produces). Both deltas are
|
|
319
|
+
* in DEGREES and both are applied in the camera's OWN frame:
|
|
320
|
+
*
|
|
321
|
+
* `spin` — rotate about `forward` (the view axis): the picture rolls.
|
|
322
|
+
* `pitch` — rotate about `right` (screen-horizontal): the view tilts up
|
|
323
|
+
* /down, orbiting the centre at a FIXED radius.
|
|
324
|
+
*
|
|
325
|
+
* Successive screen-relative rotations do NOT compose the way three Euler
|
|
326
|
+
* angles applied in a fixed order do — which is exactly why this goes through
|
|
327
|
+
* the orthonormal frame: compose the bag into a basis, rotate the FRAME, then
|
|
328
|
+
* decompose back. `azimuth` is an OUTPUT of that decomposition (azimuth is
|
|
329
|
+
* computed as a function of the two), while remaining a
|
|
330
|
+
* writable declarative coordinate through `setOrbit`. `distance` and `target`
|
|
331
|
+
* ride through untouched — a LOOK is never a translation.
|
|
332
|
+
*
|
|
333
|
+
* Roll WRAPS (mod 360). It has no pole, so it is never clamped: a clamp would
|
|
334
|
+
* make the picture stop rolling part-way, which reads as "roll is broken at
|
|
335
|
+
* the extremes".
|
|
336
|
+
*
|
|
337
|
+
* @param {object} orbit the current bag
|
|
338
|
+
* @param {{spin?:number, pitch?:number}} delta degrees
|
|
339
|
+
* @param {number} width scene width in engine pixels
|
|
340
|
+
* @param {number} height scene height in engine pixels
|
|
341
|
+
* @returns {object} a new bag `{azimuth, elevation, distance, roll, target}`
|
|
342
|
+
*/
|
|
343
|
+
function _rotateOrbit(orbit, delta, width, height) {
|
|
344
|
+
var spin = (delta && typeof delta.spin === 'number') ? delta.spin : 0;
|
|
345
|
+
var pitch = (delta && typeof delta.pitch === 'number') ? delta.pitch : 0;
|
|
346
|
+
var b = _orbitCameraBasis(orbit, width, height);
|
|
347
|
+
var forward = b.forward, right = b.right, up = b.up;
|
|
348
|
+
|
|
349
|
+
if (spin) {
|
|
350
|
+
var s = spin * DEG;
|
|
351
|
+
right = _rotateAbout(right, forward, s);
|
|
352
|
+
up = _rotateAbout(up, forward, s);
|
|
353
|
+
}
|
|
354
|
+
if (pitch) {
|
|
355
|
+
var p = pitch * DEG;
|
|
356
|
+
forward = _rotateAbout(forward, right, p);
|
|
357
|
+
up = _rotateAbout(up, right, p);
|
|
358
|
+
}
|
|
359
|
+
// Re-orthonormalize: many small incremental rotations accumulate FP drift.
|
|
360
|
+
forward = _norm(forward);
|
|
361
|
+
right = _norm(right);
|
|
362
|
+
var ang = _decomposeOrbitBasis({ forward: forward, right: right, up: up });
|
|
363
|
+
return {
|
|
364
|
+
azimuth: ang.azimuth,
|
|
365
|
+
elevation: ang.elevation,
|
|
366
|
+
distance: orbit.distance,
|
|
367
|
+
roll: ang.roll,
|
|
368
|
+
target: orbit.target || null
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── The interactive translation (the FPS walk) ──────────────────────────
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* SCREEN-RELATIVE translation of the orbit TARGET — the FPS walk, in the
|
|
376
|
+
* camera's own frame. PURE (returns a new target; mutates nothing) so it is
|
|
377
|
+
* Node-testable with no live THREE instance, and so the one piece of orbit
|
|
378
|
+
* math a MOVE pad needs lives HERE at the camera authority rather than being
|
|
379
|
+
* re-derived inside a widget.
|
|
380
|
+
*
|
|
381
|
+
* `forward` walks along the FULL view direction (positive = into the picture);
|
|
382
|
+
* `strafe` slides along screen-right. Both in ENGINE PIXELS, both taken from
|
|
383
|
+
* the ONE basis, so a positive strafe reads as rightward on screen at every
|
|
384
|
+
* azimuth AND at every roll.
|
|
385
|
+
*
|
|
386
|
+
* **MOVE is ALWAYS screen-relative**. The walk is NOT pinned
|
|
387
|
+
* to a ground plane: walking forward while pitched down DESCENDS — this is a
|
|
388
|
+
* fly control, not an FPS ground walk. (The earlier `y: centre.y` pin was the
|
|
389
|
+
* algebraic residue of a retired ground-plane promise; keeping it would have
|
|
390
|
+
* silently zeroed the vertical motion, and at roll≠0 it would have made
|
|
391
|
+
* "screen-right" and "world-horizontal" contradict each other.)
|
|
392
|
+
*/
|
|
393
|
+
function _orbitTranslate(orbit, width, height, forward, strafe) {
|
|
394
|
+
var c = _orbitCenter(orbit, width, height);
|
|
395
|
+
var b = _orbitCameraBasis(orbit, width, height);
|
|
396
|
+
return {
|
|
397
|
+
x: c.x + forward * b.forward.x + strafe * b.right.x,
|
|
398
|
+
y: c.y + forward * b.forward.y + strafe * b.right.y,
|
|
399
|
+
z: c.z + forward * b.forward.z + strafe * b.right.z
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── Camera-relative CARRY transforms (Q-A5-CAP) ─────────────────────────
|
|
404
|
+
|
|
405
|
+
/** Camera-relative CARRY transforms (Q-A5-CAP) — the world↔camera-space pair, PURE
|
|
406
|
+
* free functions off _orbitCameraBasis (the ONE camera authority) so they are Node-
|
|
407
|
+
* testable with no live THREE/WebGL instance (mirrors _screenToWorldThree). The
|
|
408
|
+
* getCameraControl handle's cameraOffsetFromWorld/worldFromCameraOffset methods are
|
|
409
|
+
* thin closures over these with the live _orbit/_width/_height. Units = engine px.
|
|
410
|
+
* They read the ROLLED basis, so a carried object stays pinned in view through a
|
|
411
|
+
* roll exactly as it does through an azimuth change. */
|
|
412
|
+
function _cameraOffsetFromWorld(orbit, width, height, worldPoint) {
|
|
413
|
+
if (!worldPoint) { return null; }
|
|
414
|
+
var b = _orbitCameraBasis(orbit, width, height);
|
|
415
|
+
var dx = worldPoint[0] - b.pos.x, dy = worldPoint[1] - b.pos.y, dz = worldPoint[2] - b.pos.z;
|
|
416
|
+
return {
|
|
417
|
+
right: dx * b.right.x + dy * b.right.y + dz * b.right.z,
|
|
418
|
+
up: dx * b.up.x + dy * b.up.y + dz * b.up.z,
|
|
419
|
+
forward: dx * b.forward.x + dy * b.forward.y + dz * b.forward.z
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
function _worldFromCameraOffset(orbit, width, height, off) {
|
|
423
|
+
if (!off) { return null; }
|
|
424
|
+
var b = _orbitCameraBasis(orbit, width, height);
|
|
425
|
+
var r = off.right || 0, u = off.up || 0, f = off.forward || 0;
|
|
426
|
+
return [
|
|
427
|
+
b.pos.x + r * b.right.x + u * b.up.x + f * b.forward.x,
|
|
428
|
+
b.pos.y + r * b.right.y + u * b.up.y + f * b.forward.y,
|
|
429
|
+
b.pos.z + r * b.right.z + u * b.up.z + f * b.forward.z
|
|
430
|
+
];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── screenToWorld() analytic camera math (OPTIONAL capability, S5b) ──────
|
|
434
|
+
//
|
|
435
|
+
// Studio Controls Realignment §7 r2 item 4 / §2.5.7 "3D drag": "three:
|
|
436
|
+
// raycast against the object's plane [at its z]". Rather than reach for a
|
|
437
|
+
// LIVE `THREE.Raycaster`/`THREE.Plane` (which would make this untestable in
|
|
438
|
+
// plain Node — makeThreeProvider() itself throws fail-loud without a real
|
|
439
|
+
// WebGL/THREE global), this is a PURE re-derivation of the exact same
|
|
440
|
+
// pinhole-camera geometry `_applyOrbit` uses to POSITION the camera — the
|
|
441
|
+
// SAME basis function, the same fixed vertical FOV — so the two are
|
|
442
|
+
// geometrically identical by construction, while this half stays
|
|
443
|
+
// THREE-instance-free and unit-testable with plain `{x,y,z}` objects (see
|
|
444
|
+
// `test/screen-to-world.test.mjs`). The provider's own `screenToWorld`
|
|
445
|
+
// method is a THIN wrapper calling these free functions with its live
|
|
446
|
+
// `_orbit`/`_width`/`_height` — it does not re-derive the math.
|
|
447
|
+
|
|
448
|
+
/** World-space ray direction (unit vector) for a normalized-device-coordinate
|
|
449
|
+
* screen point `(ndcX, ndcY)` (both in [-1,1], y-up) through `basis`'s camera,
|
|
450
|
+
* given the fixed vertical FOV and the canvas aspect ratio. Standard pinhole-
|
|
451
|
+
* camera ray generation — the SAME math `THREE.Raycaster.setFromCamera` performs
|
|
452
|
+
* internally for a `PerspectiveCamera`, re-derived here without a THREE instance. */
|
|
453
|
+
function _rayDirForNdc(basis, ndcX, ndcY, fovDeg, aspect) {
|
|
454
|
+
var tanHalfFov = Math.tan((fovDeg * DEG) / 2);
|
|
455
|
+
var dx = ndcX * tanHalfFov * aspect;
|
|
456
|
+
var dy = ndcY * tanHalfFov;
|
|
457
|
+
var dirX = basis.forward.x + dx * basis.right.x + dy * basis.up.x;
|
|
458
|
+
var dirY = basis.forward.y + dx * basis.right.y + dy * basis.up.y;
|
|
459
|
+
var dirZ = basis.forward.z + dx * basis.right.z + dy * basis.up.z;
|
|
460
|
+
var len = Math.sqrt(dirX * dirX + dirY * dirY + dirZ * dirZ) || 1;
|
|
461
|
+
return { x: dirX / len, y: dirY / len, z: dirZ / len };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// S5b tester-found BLOCKING regression (fix-loop): a live down-barrel drag
|
|
465
|
+
// (the DEFAULT 3D camera preset — looks ~straight down +X) exploded a
|
|
466
|
+
// dragged LO's rect to ~1e15. Root cause: `dir` is a UNIT vector, so
|
|
467
|
+
// `dir.z` IS `cos(angle between the ray and the plane normal)`; the
|
|
468
|
+
// ORIGINAL `Math.abs(dir.z) < 1e-9` guard only caught the ray being
|
|
469
|
+
// EXACTLY parallel (dir.z ~ 1e-17, floating-point zero) — a genuinely
|
|
470
|
+
// common NEAR-parallel case (down-barrel's screen-centre column has
|
|
471
|
+
// dir.z passing smoothly through small-but-not-tiny values like 1e-4 as
|
|
472
|
+
// the mouse crosses the vertical centreline) produced a t = numerator/
|
|
473
|
+
// dir.z with dir.z just above that razor-thin threshold, giving a huge
|
|
474
|
+
// but FINITE (never NaN/Infinity, so never itself caught) t — and the
|
|
475
|
+
// resulting world point blew up proportionally. Two independent guards
|
|
476
|
+
// now close this, per the tester's own fix recipe:
|
|
477
|
+
// (1) MIN_RAY_PLANE_COS — an ANGLE threshold (dir.z is literally a
|
|
478
|
+
// cosine), not a near-zero-only threshold: reject any ray within
|
|
479
|
+
// ~3° of parallel-to-the-plane, never just "exactly" parallel.
|
|
480
|
+
// (2) A scene-scale-aware SANITY BOUND at the `_screenToWorldThree`
|
|
481
|
+
// orchestration layer (which alone knows width/height) — reject
|
|
482
|
+
// ANY result implausibly far beyond the scene extent, so a
|
|
483
|
+
// degenerate point can NEVER reach `updateObjectProperty` even if
|
|
484
|
+
// a future edge case slipped past guard (1).
|
|
485
|
+
|
|
486
|
+
/** Intersect a ray (`origin`,`dir`) with the world plane `z = planeZ`.
|
|
487
|
+
* Returns `{x,y}` (z === planeZ by construction) or `null` when the ray is
|
|
488
|
+
* NEAR-parallel to the plane (see MIN_RAY_PLANE_COS above — this is an
|
|
489
|
+
* ANGLE guard, not merely an exact-zero check) or the intersection is
|
|
490
|
+
* BEHIND the camera (t < 0). Pure math — no scene-scale awareness here
|
|
491
|
+
* (that lives in `_screenToWorldThree`'s own sanity-bound check below,
|
|
492
|
+
* which is the layer that actually knows width/height). */
|
|
493
|
+
function _intersectRayWithZPlane(origin, dir, planeZ) {
|
|
494
|
+
if (Math.abs(dir.z) < MIN_RAY_PLANE_COS) { return null; }
|
|
495
|
+
var t = (planeZ - origin.z) / dir.z;
|
|
496
|
+
// T_EPS tolerates floating-point noise right at t~0 (e.g. `origin.z`
|
|
497
|
+
// for a down-barrel orbit is `distance * cos(el) * cos(90°)`, and
|
|
498
|
+
// `Math.cos(Math.PI/2)` is NOT exactly 0 in IEEE-754 — it is
|
|
499
|
+
// ~6.12e-17 — so a worldZ=0 target from a down-barrel camera can
|
|
500
|
+
// resolve to a genuinely-in-front intersection with `t` a few
|
|
501
|
+
// e-13 NEGATIVE purely from that noise). Only a MEANINGFULLY
|
|
502
|
+
// negative t (the intersection is actually behind the camera) is
|
|
503
|
+
// rejected; a noise-scale negative t is treated as ~0.
|
|
504
|
+
if (t < -1e-6) { return null; }
|
|
505
|
+
return { x: origin.x + dir.x * t, y: origin.y + dir.y * t };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* screenToWorld — the full pure pipeline: canvas CSS pixel → NDC → world-ray
|
|
510
|
+
* → intersection with the z=worldZ plane → scene-scale sanity bound.
|
|
511
|
+
* Exported (alongside the three sub-steps above) so the LIVE provider
|
|
512
|
+
* method is a one-line call and the geometry is independently
|
|
513
|
+
* unit-testable without a THREE global. Returns `null` on EITHER a
|
|
514
|
+
* near-parallel/behind-camera ray (see `_intersectRayWithZPlane`) OR a
|
|
515
|
+
* result beyond `SANITY_EXTENT_FACTOR` × the scene's own width/height —
|
|
516
|
+
* the caller (AuthorView.jsx's overlay drag) treats a `null` result as
|
|
517
|
+
* "skip this frame's write" (never a fallback to a DIFFERENT, WRONG
|
|
518
|
+
* coordinate transform, and never a write of the degenerate point).
|
|
519
|
+
*
|
|
520
|
+
* It reads the ROLLED basis, so a drag under a rolled camera resolves along
|
|
521
|
+
* the screen axes the user is actually looking at.
|
|
522
|
+
*/
|
|
523
|
+
// OPT-IN EXTENSION (`fovYDeg`, trailing, defaulted to the module's own CAMERA_FOV_DEG):
|
|
524
|
+
// byte-identical for every existing caller — the live provider's camera IS at
|
|
525
|
+
// CAMERA_FOV_DEG, so omitting the argument reproduces the previous behaviour exactly.
|
|
526
|
+
// It exists so the OFFLINE unproject (`unprojectStill.js`) can pass a captured scene's
|
|
527
|
+
// `projection.fovY` as DATA through this SAME composition rather than re-deriving the
|
|
528
|
+
// pinhole. One mapping authority: a consumer's hand-written unproject drifting from the
|
|
529
|
+
// provider's is the schema-mirror bug applied to MATH.
|
|
530
|
+
function _screenToWorldThree(orbit, width, height, canvasCssX, canvasCssY, canvasCssWidth, canvasCssHeight, worldZ, fovYDeg) {
|
|
531
|
+
var cw = canvasCssWidth || width;
|
|
532
|
+
var ch = canvasCssHeight || height;
|
|
533
|
+
var fov = (typeof fovYDeg === 'number' && isFinite(fovYDeg) && fovYDeg > 0) ? fovYDeg : CAMERA_FOV_DEG;
|
|
534
|
+
var ndcX = (canvasCssX / cw) * 2 - 1;
|
|
535
|
+
var ndcY = -((canvasCssY / ch) * 2 - 1);
|
|
536
|
+
var basis = _orbitCameraBasis(orbit, width, height);
|
|
537
|
+
var dir = _rayDirForNdc(basis, ndcX, ndcY, fov, width / height);
|
|
538
|
+
var hit = _intersectRayWithZPlane(basis.pos, dir, (typeof worldZ === 'number') ? worldZ : 0);
|
|
539
|
+
if (!hit) { return null; }
|
|
540
|
+
var bound = SANITY_EXTENT_FACTOR * Math.max(width, height, 1);
|
|
541
|
+
if (!isFinite(hit.x) || !isFinite(hit.y) || Math.abs(hit.x) > bound || Math.abs(hit.y) > bound) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
return hit;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ── Export ──────────────────────────────────────────────────────────────
|
|
548
|
+
//
|
|
549
|
+
// The `_`-prefixed names are preserved verbatim from their previous home
|
|
550
|
+
// inside three-provider.js: this was a MOVE, not a rewrite, and renaming
|
|
551
|
+
// during a relocation makes the relocation unreviewable. They read as
|
|
552
|
+
// "renderer-internal geometry primitives", which is exactly what they are —
|
|
553
|
+
// the PUBLIC camera surface is the `getCameraControl()` handle.
|
|
554
|
+
|
|
555
|
+
export {
|
|
556
|
+
CAMERA_FOV_DEG,
|
|
557
|
+
MIN_RAY_PLANE_COS,
|
|
558
|
+
SANITY_EXTENT_FACTOR,
|
|
559
|
+
UP_HINT,
|
|
560
|
+
POLE_EPS,
|
|
561
|
+
_orbitCenter,
|
|
562
|
+
_orbitCameraBasis,
|
|
563
|
+
_decomposeOrbitBasis,
|
|
564
|
+
_rotateOrbit,
|
|
565
|
+
_orbitTranslate,
|
|
566
|
+
_cameraOffsetFromWorld,
|
|
567
|
+
_worldFromCameraOffset,
|
|
568
|
+
_rayDirForNdc,
|
|
569
|
+
_intersectRayWithZPlane,
|
|
570
|
+
_screenToWorldThree
|
|
571
|
+
};
|