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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/CLAUDE.md +87 -0
  2. package/LICENSE.md +664 -0
  3. package/README.md +84 -2
  4. package/dist/dist-manifest.json +218 -0
  5. package/dist/eyes/index.js +109 -0
  6. package/dist/fat/pkg-node/egpt_wasm.js +1277 -0
  7. package/dist/fat/pkg-node/egpt_wasm_bg.wasm +0 -0
  8. package/dist/fat/pkg-node/package.json +12 -0
  9. package/dist/frqtl/assets/vendor/chroma.mjs +64 -0
  10. package/dist/frqtl/compat/boot-node.js +34 -0
  11. package/dist/frqtl/compat/engine.js +127 -0
  12. package/dist/frqtl/compat/package-root.js +24 -0
  13. package/dist/frqtl/compat/providers/wasm-engine.js +2499 -0
  14. package/dist/frqtl/compat/providers/wasm-load-browser.js +134 -0
  15. package/dist/frqtl/compat/providers/wasm-load-node.js +59 -0
  16. package/dist/frqtl/compat/renderer/FieldBuilder.js +145 -0
  17. package/dist/frqtl/compat/renderer/Filters.js +163 -0
  18. package/dist/frqtl/compat/renderer/RenderFamily.js +84 -0
  19. package/dist/frqtl/compat/renderer/RenderMesh.js +99 -0
  20. package/dist/frqtl/compat/renderer/RenderShape.js +154 -0
  21. package/dist/frqtl/compat/renderer/camera2d-geometry.js +183 -0
  22. package/dist/frqtl/compat/renderer/captureStill-common.js +258 -0
  23. package/dist/frqtl/compat/renderer/display-slot.js +96 -0
  24. package/dist/frqtl/compat/renderer/lo-drawn-center.js +231 -0
  25. package/dist/frqtl/compat/renderer/orbit-bag.js +216 -0
  26. package/dist/frqtl/compat/renderer/providers/godrays-layer.js +213 -0
  27. package/dist/frqtl/compat/renderer/providers/index.js +22 -0
  28. package/dist/frqtl/compat/renderer/providers/p5-provider.js +861 -0
  29. package/dist/frqtl/compat/renderer/providers/three-camera-contract.js +160 -0
  30. package/dist/frqtl/compat/renderer/providers/three-camera-geometry.js +571 -0
  31. package/dist/frqtl/compat/renderer/providers/three-provider.js +2470 -0
  32. package/dist/frqtl/compat/renderer/renderCapabilities-common.js +186 -0
  33. package/dist/frqtl/compat/renderer/renderer-provider-contract.js +238 -0
  34. package/dist/frqtl/compat/renderer/unprojectStill.js +172 -0
  35. package/dist/frqtl/compat/vendor.js +196 -0
  36. package/dist/frqtl/node-entry.js +17 -0
  37. package/dist/frqtl/objects/FrqtlApp.js +2920 -0
  38. package/dist/frqtl/objects/ObjectFactory.js +296 -0
  39. package/dist/frqtl/objects/RecipeContext.js +322 -0
  40. package/dist/frqtl/objects/collectors/DataCollector.js +116 -0
  41. package/dist/frqtl/objects/constants/WallBehaviors.js +178 -0
  42. package/dist/frqtl/objects/geometry/SlabFace.js +258 -0
  43. package/dist/frqtl/objects/instruments/capture-host.js +453 -0
  44. package/dist/frqtl/objects/instruments/capture.js +710 -0
  45. package/dist/frqtl/objects/instruments/hosted-eyes.js +984 -0
  46. package/dist/frqtl/objects/kinds-entry.js +14 -0
  47. package/dist/frqtl/objects/kinds.js +190 -0
  48. package/dist/frqtl/objects/objects/Box.js +273 -0
  49. package/dist/frqtl/objects/objects/Bullet.js +155 -0
  50. package/dist/frqtl/objects/objects/CircuitGate.js +380 -0
  51. package/dist/frqtl/objects/objects/CircuitProbe.js +214 -0
  52. package/dist/frqtl/objects/objects/DirectedTunnel.js +411 -0
  53. package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
  54. package/dist/frqtl/objects/objects/LightSource.js +351 -0
  55. package/dist/frqtl/objects/objects/QuantumEmitter.js +760 -0
  56. package/dist/frqtl/objects/objects/Tube.js +471 -0
  57. package/dist/frqtl/objects/objects/absorbRadiation.js +309 -0
  58. package/dist/frqtl/objects/objects/actionCompiler.js +1747 -0
  59. package/dist/frqtl/objects/objects/controlAction.js +89 -0
  60. package/dist/frqtl/objects/objects/controlBind.js +278 -0
  61. package/dist/frqtl/objects/objects/directedRedirectProgram.js +120 -0
  62. package/dist/frqtl/objects/objects/emitterProgram.js +209 -0
  63. package/dist/frqtl/objects/objects/frqtl-control-slider.js +197 -0
  64. package/dist/frqtl/objects/objects/frqtl-control-sync.js +272 -0
  65. package/dist/frqtl/objects/objects/frqtl-control-widgets.js +219 -0
  66. package/dist/frqtl/objects/objects/frqtl-control-write-queue.js +174 -0
  67. package/dist/frqtl/objects/objects/makeControlsForObject.js +820 -0
  68. package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
  69. package/dist/frqtl/objects/objects/slitLinerScatter.js +261 -0
  70. package/dist/frqtl/objects/objects/sustainedSource.js +298 -0
  71. package/dist/frqtl/objects/objects/tagger.js +228 -0
  72. package/dist/frqtl/objects/schema/engine-scopes.js +468 -0
  73. package/dist/frqtl/objects/schema/index.js +256 -0
  74. package/dist/frqtl/objects/schema/scopes.js +457 -0
  75. package/dist/frqtl/objects/schema/validate-descriptor.js +49 -0
  76. package/dist/frqtl/objects/schema/valueNormalizer.js +366 -0
  77. package/dist/frqtl/objects/setup/_collisionMatrixLanes.js +66 -0
  78. package/dist/frqtl/objects/setup/_setupOptions.js +118 -0
  79. package/dist/frqtl/objects/setup/_worldInit.js +162 -0
  80. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +242 -0
  81. package/dist/frqtl/objects/setup/setupCircuitBasic.js +447 -0
  82. package/dist/frqtl/objects/setup/setupCollisionMatrix.js +188 -0
  83. package/dist/frqtl/objects/setup/setupConeEmitter3D.js +250 -0
  84. package/dist/frqtl/objects/setup/setupDoubleSlit.js +326 -0
  85. package/dist/frqtl/objects/setup/setupDoubleSlit_Laser.js +229 -0
  86. package/dist/frqtl/objects/setup/setupEmpty.js +59 -0
  87. package/dist/frqtl/objects/setup/setupFxSmoke.js +209 -0
  88. package/dist/frqtl/objects/setup/setupG1Race.js +80 -0
  89. package/dist/frqtl/objects/setup/setupG2Sweep.js +89 -0
  90. package/dist/frqtl/objects/setup/setupG3Collide.js +177 -0
  91. package/dist/frqtl/objects/setup/setupLightSourceLab.js +277 -0
  92. package/dist/frqtl/objects/setup/setupOscillator.js +68 -0
  93. package/dist/frqtl/objects/setup/setupParticleWalk.js +93 -0
  94. package/dist/frqtl/objects/setup/setupParticleWalk3D.js +133 -0
  95. package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +1258 -0
  96. package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
  97. package/dist/frqtl/objects/setup/setupTunnelLab.js +240 -0
  98. package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
  99. package/dist/frqtl/objects/setup/setupWaveInterference.js +177 -0
  100. package/dist/frqtl/objects/v1-replacements.js +29 -0
  101. package/dist/frqtl/package-core.js +23 -0
  102. package/dist/frqtl/parts/CameraControls.js +643 -0
  103. package/dist/frqtl/parts/CameraPath.js +575 -0
  104. package/dist/frqtl/parts/CollisionActions.js +34 -0
  105. package/dist/frqtl/parts/CollisionTypes.js +30 -0
  106. package/dist/frqtl/parts/DetectorWall.js +496 -0
  107. package/dist/frqtl/parts/FrqtlControlOverlay.js +227 -0
  108. package/dist/frqtl/parts/FrqtlControlsPanel.js +415 -0
  109. package/dist/frqtl/parts/FrqtlVision.js +818 -0
  110. package/dist/frqtl/parts/GraphingDetectorWall.js +489 -0
  111. package/dist/frqtl/parts/LargeObject.js +405 -0
  112. package/dist/frqtl/parts/LightColor.js +255 -0
  113. package/dist/frqtl/parts/LiveChart.js +285 -0
  114. package/dist/frqtl/parts/LogicGate.js +717 -0
  115. package/dist/frqtl/parts/OvenBox.js +611 -0
  116. package/dist/frqtl/parts/PointSource.js +762 -0
  117. package/dist/frqtl/parts/Rectangle.js +200 -0
  118. package/dist/frqtl/parts/SlitLightSource.js +234 -0
  119. package/dist/frqtl/parts/SlitScreen.js +628 -0
  120. package/dist/frqtl/parts/SphereEmitter.js +946 -0
  121. package/dist/frqtl/parts/Trace.js +508 -0
  122. package/dist/frqtl/parts/Tunnel.js +1057 -0
  123. package/dist/frqtl/parts/Wall.js +1211 -0
  124. package/dist/frqtl/parts/index.js +33 -0
  125. package/dist/frqtl/scenes/absorbing-wall-3d.js +33 -0
  126. package/dist/frqtl/scenes/absorbing-wall.js +32 -0
  127. package/dist/frqtl/scenes/atomic-model.js +88 -0
  128. package/dist/frqtl/scenes/big-bang.js +46 -0
  129. package/dist/frqtl/scenes/blackbody.js +81 -0
  130. package/dist/frqtl/scenes/circuit-sat.js +127 -0
  131. package/dist/frqtl/scenes/collision-matrix-3d.js +107 -0
  132. package/dist/frqtl/scenes/counting-wall-3d.js +36 -0
  133. package/dist/frqtl/scenes/counting-wall.js +38 -0
  134. package/dist/frqtl/scenes/double-slit.js +31 -0
  135. package/dist/frqtl/scenes/dse-one-emitter.js +196 -0
  136. package/dist/frqtl/scenes/dse-one-slit.js +114 -0
  137. package/dist/frqtl/scenes/dse-photoelectric.js +70 -0
  138. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +243 -0
  139. package/dist/frqtl/scenes/dse-two-emitters.js +196 -0
  140. package/dist/frqtl/scenes/dse-two-slits.js +132 -0
  141. package/dist/frqtl/scenes/empty.js +14 -0
  142. package/dist/frqtl/scenes/laser-buildup.js +49 -0
  143. package/dist/frqtl/scenes/mirror-3d.js +56 -0
  144. package/dist/frqtl/scenes/mirror-route.js +140 -0
  145. package/dist/frqtl/scenes/mirror.js +38 -0
  146. package/dist/frqtl/scenes/omni-3d.js +27 -0
  147. package/dist/frqtl/scenes/omni-source.js +22 -0
  148. package/dist/frqtl/scenes/self-interference.js +39 -0
  149. package/dist/frqtl/scenes/smoking-gun.js +122 -0
  150. package/dist/frqtl/scenes/straight-walk.js +22 -0
  151. package/dist/frqtl/scenes/sun.js +39 -0
  152. package/dist/frqtl/scenes/two-source.js +37 -0
  153. package/dist/frqtl/scenes/wave.js +26 -0
  154. package/dist/shim/bundle/frqnb.js +112 -0
  155. package/dist/shim/bundle/index.js +121 -0
  156. package/dist/shim/bundle/indexHtml.js +107 -0
  157. package/dist/shim/bundle/manifest.js +95 -0
  158. package/dist/shim/bundle/zip.js +243 -0
  159. package/dist/shim/bus.js +67 -0
  160. package/dist/shim/controls/index.js +627 -0
  161. package/dist/shim/controls/registry.js +274 -0
  162. package/dist/shim/engine/artifact-shell.js +211 -0
  163. package/dist/shim/engine/boot-phases.js +44 -0
  164. package/dist/shim/engine/engine-stack-boot.js +59 -0
  165. package/dist/shim/engine/router.js +690 -0
  166. package/dist/shim/experiment-frame/index.js +296 -0
  167. package/dist/shim/expr/ast-schema.js +369 -0
  168. package/dist/shim/field-descriptor/index.js +325 -0
  169. package/dist/shim/frame-data/index.js +224 -0
  170. package/dist/shim/index.js +67 -0
  171. package/dist/shim/notebook/canvas-guard.js +82 -0
  172. package/dist/shim/notebook/catalog.js +35 -0
  173. package/dist/shim/notebook/format.js +736 -0
  174. package/dist/shim/notebook/frqtl-setups-importmap.js +81 -0
  175. package/dist/shim/notebook/kernel.js +671 -0
  176. package/dist/shim/notebook/knob.js +150 -0
  177. package/dist/shim/notebook/module-resolver.js +260 -0
  178. package/dist/shim/notebook/notebook-knobs.js +65 -0
  179. package/dist/shim/notebook/produced-bindings.js +67 -0
  180. package/dist/shim/notebook/realm-registry.js +121 -0
  181. package/dist/shim/notebook/realm-transport.js +73 -0
  182. package/dist/shim/notebook/setup-builder.js +507 -0
  183. package/dist/shim/notebook/store.js +159 -0
  184. package/dist/shim/notebook/vendor-importmap.js +134 -0
  185. package/dist/shim/notebook/view-builder.js +189 -0
  186. package/dist/shim/notebook/view-ctx.js +100 -0
  187. package/dist/shim/notebook/view-handle.js +202 -0
  188. package/dist/shim/notebook/warmup.js +102 -0
  189. package/dist/shim/package.json +4 -0
  190. package/dist/shim/page-params/index.js +213 -0
  191. package/dist/shim/project/identity.js +112 -0
  192. package/dist/shim/project/index.js +19 -0
  193. package/dist/shim/project/link.js +402 -0
  194. package/dist/shim/project/node-specifier-hook.mjs +35 -0
  195. package/dist/shim/project/register-node.mjs +17 -0
  196. package/dist/shim/project/specifiers.js +106 -0
  197. package/dist/shim/registry/copy-gate.js +110 -0
  198. package/dist/shim/registry/exhibits.js +455 -0
  199. package/dist/shim/registry/experiments.js +215 -0
  200. package/dist/shim/registry/index.js +19 -0
  201. package/dist/shim/registry/scene-source.js +157 -0
  202. package/dist/shim/registry/walkthrough.js +804 -0
  203. package/dist/shim/render/capture-envelope.js +279 -0
  204. package/dist/shim/render/heat-colormap.js +92 -0
  205. package/dist/shim/render/render-stages.js +855 -0
  206. package/dist/shim/render/sun-look.js +33 -0
  207. package/dist/shim/render-state/apply.js +262 -0
  208. package/dist/shim/render-state/index.js +74 -0
  209. package/dist/shim/rpc/index.js +207 -0
  210. package/dist/shim/runtime/index.js +1023 -0
  211. package/dist/shim/storage/boot.js +69 -0
  212. package/dist/shim/storage/index.js +169 -0
  213. package/dist/shim/storage/local-storage-provider.js +230 -0
  214. package/dist/shim/storage/make-namespace-store.js +132 -0
  215. package/dist/shim/storage/modules.js +249 -0
  216. package/dist/shim/storage/projects.js +150 -0
  217. package/llms.txt +12 -0
  218. package/package.json +63 -4
@@ -0,0 +1,643 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * CameraControls.js — a REUSABLE first-person-game-style camera-controls sugar
5
+ * object. ANY 3D experiment page drops
6
+ * it in with ONE call; it drives the existing `runner.cameraControl` capability
7
+ * (the camera-as-sugar / BYO-renderer pattern — `getCameraControl()` →
8
+ * `look({spin,pitch})` ROTATES, `move({forward,strafe})` TRANSLATES) with two
9
+ * on-screen joysticks + arrow/WASD keys.
10
+ *
11
+ * ── Layer 1: API contract ────────────────────────────────────────────────────
12
+ *
13
+ * `CameraControls.attach(runner, mountEl, opts) → handle`
14
+ *
15
+ * @param {object} runner — an FrqtlApp. The ONLY coupling: it reads
16
+ * `runner.cameraControl` (the OPTIONAL camera sugar handle the active
17
+ * renderer provider exposes via `getCameraControl()`). The pads mount only
18
+ * for a camera that DECLARES an angular axis — `OrbitBag.hasAngularPose(cam)`
19
+ * (compat/renderer/orbit-bag.js), asked of the handle's own `poseFields`. The
20
+ * 2D p5 camera is a real handle too (`{distance, target}`, no look/move), so
21
+ * handle presence is never the test. NO renderer-sniffing, NO engine, NO
22
+ * per-page coupling — the object knows nothing about which experiment mounts it.
23
+ * @param {HTMLElement} mountEl — the container the two joystick pads are
24
+ * absolutely positioned within (typically the experiment's canvas wrapper).
25
+ * Its `position` is set to `relative` while attached if it is `static`
26
+ * (restored on dispose) so the pads anchor to it.
27
+ * @param {object} [opts]
28
+ * @param {{azimuth?:number,elevation?:number,distance?:number}} [opts.initialOrbit]
29
+ * — applied to the camera at attach (via setOrbit) so the page keeps its
30
+ * default framing. Omitted ⇒ the camera's CURRENT `getOrbit()` is kept.
31
+ * CALLER CONTRACT: name ONLY axes the TARGET camera declares. Every camera
32
+ * publishes its own axes as `cameraControl.poseFields` (the CameraPath idiom:
33
+ * ask what axes EXIST, never what mode the camera is in); an axis the camera
34
+ * does not declare FAILS LOUD inside `setOrbit` (by design, at the provider
35
+ * that owns the axes). A flat camera never gets this far — attach() mounts
36
+ * nothing for it (see the handle below). This
37
+ * widget deliberately does not re-validate or filter the bag: a second
38
+ * enumeration of the pose vocabulary here would be a schema mirror, and
39
+ * silently dropping an axis the caller asked for is the failure mode
40
+ * CameraPath's own check exists to prevent. Derive the bag from
41
+ * `poseFields`; do not hand the same literal to every camera.
42
+ * @param {number} [opts.lookSpeed=90] — deg/sec of screen-relative ROTATION
43
+ * at full LOOK-stick deflection (and full arrow-key hold): x spins (roll),
44
+ * y pitches. Forwarded to `cameraControl.look()`; this widget integrates no
45
+ * angles of its own.
46
+ * @param {number} [opts.moveRate=0.6] — TRANSLATION rate at full MOVE-stick
47
+ * deflection (and full WASD hold), on both the walk and strafe axes, as a
48
+ * FRACTION OF THE CURRENT ORBIT DISTANCE per second. Scale-invariant on
49
+ * purpose — the same pad has to feel right in a 600-px scene and a 3000-px
50
+ * one, exactly like the camera presets scale distance to the scene.
51
+ * @param {number} [opts.dollySpeed=900] — world-px/sec of distance change used
52
+ * ONLY as the fallback walk for a provider whose cameraControl exposes no
53
+ * `move()` translate primitive (see the mapping note below).
54
+ * @param {number} [opts.wheelStep=60] — world-px of distance per wheel notch
55
+ * (wheel/pinch zoom on mountEl). Set 0 to disable wheel zoom.
56
+ * @param {number} [opts.minDistance=20] — dolly/zoom clamp (near).
57
+ * @param {number} [opts.maxDistance=12000] — dolly/zoom clamp (far).
58
+ * @param {boolean} [opts.keyboard=true] — bind arrow + WASD keys. When on,
59
+ * `mountEl` is made focusable (tabindex -1) and focused on attach + on any
60
+ * pointer interaction with the canvas, so REAL key presses (which are only
61
+ * delivered to this iframe while it holds focus) reach the handler — the
62
+ * natural "click the canvas, then the keys drive it" behavior. (tabindex is
63
+ * restored on dispose.)
64
+ * @param {Window|HTMLElement} [opts.keyboardTarget=window] — where key
65
+ * listeners attach. Default `window` receives the bubbled keydown once the
66
+ * focused mount (inside the iframe) delivers it; also lets a headless test
67
+ * dispatch synthetic events straight at window.
68
+ * @param {boolean} [opts.joysticks=true] — render the two on-screen pads.
69
+ * The camera's WHOLE `getOrbit()` bag is published continuously: every NUMERIC field
70
+ * of it is stamped as `data-cam-<field>` on the control container
71
+ * (`data-role="camera-controls"`). The bag is FERRIED, never re-listed: a
72
+ * hand-written {azimuth,elevation,distance} triple here would have silently
73
+ * dropped `roll` the moment the camera declared it.
74
+ * @returns {{
75
+ * dispose():void, // remove pads + listeners + rAF, restore mountEl
76
+ * getState():object, // the camera's whole orbit bag
77
+ * setState(spec):void, // apply an orbit-bag PATCH to the camera + publish
78
+ * sync():void, // re-read the camera's orbit into the hook (e.g. after an external setOrbit)
79
+ * mounted:boolean // false when the camera declares no angular axis (FAIL-SOFT no-op)
80
+ * }} handle. When the camera declares no angular axis (the 2D p5 camera) or there
81
+ * is no camera handle at all, NOTHING mounts and a no-op handle (`mounted:false`,
82
+ * all methods safe) is returned — every caller, including an unconditional
83
+ * walkthrough-rung attach, is right in 2D by construction.
84
+ *
85
+ * ── Input mapping — the STANDARD FPS SPLIT ───────────────────────────────────
86
+ * The two pads are the two halves of a first-person camera and they do NOT
87
+ * overlap: LOOK rotates, MOVE translates. MOVE never rotates.
88
+ * LOOK pad (bottom-RIGHT) — ROTATION ONLY, and it is SCREEN-RELATIVE.
89
+ * x → SPIN about the view axis (the picture ROLLS — the horizon tilts).
90
+ * y → PITCH about screen-right (the view tilts up/down at a FIXED radius).
91
+ * Both are forwarded as DELTAS to `cameraControl.look({spin, pitch})`.
92
+ * Azimuth is no longer driven directly: spin + pitch generate the whole
93
+ * rotation group (roll 90° then pitch IS yaw), so azimuth is DERIVED by the
94
+ * provider's decomposition. There is no elevation clamp — pitching over the
95
+ * pole is legal, which is what makes any view reachable.
96
+ * MOVE pad (bottom-LEFT) — TRANSLATION ONLY, via `cameraControl.move()`.
97
+ * y → walk forward/back along the view direction (up = into the picture,
98
+ * so objects grow) · x → strafe left/right along screen-right. MOVE is
99
+ * ALWAYS screen-relative: walking forward while pitched down DESCENDS
100
+ * (fly, not FPS ground-walk).
101
+ * Arrow keys → mirror the LOOK pad (←/→ spin/roll, ↑/↓ pitch).
102
+ * W/A/S/D → mirror the MOVE pad (W/S walk, A/D strafe).
103
+ * Wheel/pinch → dolly (distance), independent of the pads.
104
+ * THE STICKS ONLY EVER FLY THE CAMERA — in every state, with nothing selected, an
105
+ * object aimed at, or an object grabbed. There is no object-drive branch: a
106
+ * grabbed object follows the camera because the runner carries it on the camera's
107
+ * own pose notice (`addPoseListener`), not because a stick is routed to it.
108
+ * Space → `runner.grabToggle()` — the aim's grab/place key (STUDIO's
109
+ * stationary reticle).
110
+ * Ignored while typing (INPUT/TEXTAREA/SELECT/contenteditable) and
111
+ * on key auto-repeat; a no-op while the runner's aim is off.
112
+ * A state label above the pads (`data-role="aim-state"`) reads the runner's aim
113
+ * state (roaming / grab-ready / grabbed) while the aim is armed.
114
+ * All the geometry lives at the ONE camera authority (the provider's
115
+ * `getCameraControl()` handle: `look` rotates, `move` translates) — this object
116
+ * re-derives NO orbit math and integrates NO angles. A BYO provider whose handle
117
+ * omits `look()` / `move()` is capability-checked by that absence: the affected
118
+ * axis becomes a defined NO-OP (walk falls back to a dolly), never a substituted
119
+ * different rotation.
120
+ * Drive is CONTINUOUS while a pad is held or a key is down (a single rAF loop
121
+ * integrates the current input each frame; it idles — no rAF scheduled — when
122
+ * no input is active, and is torn down on dispose).
123
+ *
124
+ * ── Layer 2: physics behavior ────────────────────────────────────────────────
125
+ * ZERO physics (canonical-bench SU1). This is an OUTPUT-BOUNDARY view control:
126
+ * it only re-points the camera through the renderer's `setOrbit` sugar; it never
127
+ * touches the engine, a frame, a dimension, or emission. Toggling / driving it
128
+ * leaves every physics golden byte-identical — the same guarantee the camera
129
+ * preset dropdown it replaces already had (rendering is downstream of doTick()).
130
+ */
131
+
132
+ import * as OrbitBag from '../compat/renderer/orbit-bag.js';
133
+
134
+
135
+ function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
136
+
137
+
138
+
139
+ // No-op handle returned when the camera declares no angular axis (the 2D p5
140
+ // camera) or there is no camera. All methods are safe to call.
141
+ function noopHandle() {
142
+ return {
143
+ mounted: false,
144
+ dispose: function () {},
145
+ getState: function () { return null; },
146
+ setState: function () {},
147
+ sync: function () {}
148
+ };
149
+ }
150
+
151
+ function attach(runner, mountEl, opts) {
152
+ opts = opts || {};
153
+
154
+ // ── CAPABILITY CHECK (FAIL-SOFT) ─────────────────────────────────────
155
+ // The pads are the ANGULAR affordance: they mount only for a camera whose own
156
+ // poseFields declare an angular axis (OrbitBag.hasAngularPose — the ONE 3D-ness
157
+ // predicate). A flat camera (2D p5: {distance, target}, no look/move) would get
158
+ // a LOOK pad with no live axis. Never a renderer sniff, never handle presence.
159
+ // That predicate is the ONLY no-op exit: an angular camera that cannot set or read
160
+ // its pose breaks the camera contract and throws.
161
+ var cam = (runner && runner.cameraControl) ? runner.cameraControl : null;
162
+ if (!OrbitBag.hasAngularPose(cam)) { return noopHandle(); }
163
+ if (typeof cam.setOrbit !== 'function' || typeof cam.getOrbit !== 'function') {
164
+ throw new Error('[CameraControls] the camera declares angular axes but its handle has no ' +
165
+ 'setOrbit()/getOrbit() — every getCameraControl() handle sets and reads its pose ' +
166
+ '(RENDERER_CONTRACT §1a.2). (FAIL-LOUD.)');
167
+ }
168
+
169
+ // ── IDEMPOTENT per mountEl (F4) ──────────────────────────────────────
170
+ // A second attach() on the SAME container returns the LIVE instance instead of
171
+ // stacking a second pad set + a second window keydown listener. This kills the
172
+ // double-mount race for EVERY caller — a page that wires its own joysticks
173
+ // (a walkthrough rung) AND STUDIO's host mount (F2) both target the same
174
+ // #canvas-wrapper, and whichever runs second must be a no-op. Ordering-
175
+ // independent: no reliance on the host's DOM-timing guard. The marker is cleared
176
+ // in dispose(), so a re-attach after dispose mounts fresh.
177
+ if (mountEl && mountEl.__frqtlCameraHandle && mountEl.__frqtlCameraHandle.mounted) {
178
+ return mountEl.__frqtlCameraHandle;
179
+ }
180
+
181
+ var doc = (mountEl && mountEl.ownerDocument) ? mountEl.ownerDocument
182
+ : (typeof document !== 'undefined' ? document : null);
183
+
184
+ var lookSpeed = (typeof opts.lookSpeed === 'number') ? opts.lookSpeed : 90;
185
+ var moveRate = (typeof opts.moveRate === 'number') ? opts.moveRate : 0.6;
186
+ var dollySpeed = (typeof opts.dollySpeed === 'number') ? opts.dollySpeed : 900;
187
+ // Capability check for TRANSLATION (the getCameraControl precedent — the
188
+ // presence of the method IS the check, never a renderer sniff). A provider
189
+ // without move() still gets forward/back as a dolly; its strafe axis is a
190
+ // no-op, because MOVE must never rotate.
191
+ var canTranslate = (typeof cam.move === 'function');
192
+ // Capability check for screen-relative ROTATION, the same idiom: the
193
+ // presence of the method IS the check. A provider without look() gets a
194
+ // defined NO-OP on the LOOK pad — never a substituted azimuth/elevation
195
+ // integration here, which would put orbit geometry back in the widget.
196
+ var canLook = (typeof cam.look === 'function');
197
+ var wheelStep = (typeof opts.wheelStep === 'number') ? opts.wheelStep : 60;
198
+ var minDist = (typeof opts.minDistance === 'number') ? opts.minDistance : 20;
199
+ var maxDist = (typeof opts.maxDistance === 'number') ? opts.maxDistance : 12000;
200
+ var useKeyboard = opts.keyboard !== false;
201
+ var kbTarget = opts.keyboardTarget || (doc ? doc.defaultView : null);
202
+ var useJoysticks = opts.joysticks !== false;
203
+
204
+
205
+ // ── Seed the camera framing ──────────────────────────────────────────
206
+ if (opts.initialOrbit) { cam.setOrbit(opts.initialOrbit); }
207
+
208
+ // ── Realm hook + data-attr publishing ────────────────────────────────
209
+ var container = null;
210
+ function publishState() {
211
+ // FERRY the whole bag — never a hand-listed {azimuth,elevation,distance}
212
+ // triple. The retired hand list would have silently omitted `roll` from
213
+ // both the realm hook and the data attrs the moment the camera declared
214
+ // it: the schema-mirror failure mode in miniature — the datum exists,
215
+ // one consumer sees it, another does not.
216
+ var o = cam.getOrbit();
217
+
218
+ if (container) {
219
+ for (var k in o) {
220
+ if (!Object.prototype.hasOwnProperty.call(o, k)) { continue; }
221
+ // Numeric pose axes become data-cam-<axis>; `target` is an object
222
+ // (the walked centre), read off the hook instead.
223
+ if (typeof o[k] !== 'number') { continue; }
224
+ container.setAttribute('data-cam-' + k.toLowerCase(),
225
+ String(Math.round(o[k] * 100) / 100));
226
+ }
227
+ }
228
+ return o;
229
+ }
230
+
231
+ // ── Live input state (joystick vectors + held keys), integrated by rAF ─
232
+ // Each vector component is in [-1, 1]; keyboard contributes ±1 additively.
233
+ var jMove = { x: 0, y: 0 }; // MOVE pad
234
+ var jLook = { x: 0, y: 0 }; // LOOK pad
235
+ var keys = {}; // keyCode → true while held
236
+
237
+ function keyMove() {
238
+ var x = 0, y = 0;
239
+ if (keys.KeyA) { x -= 1; } if (keys.KeyD) { x += 1; } // strafe left/right
240
+ if (keys.KeyW) { y += 1; } if (keys.KeyS) { y -= 1; } // walk forward/back
241
+ return { x: x, y: y };
242
+ }
243
+ function keyLook() {
244
+ var x = 0, y = 0;
245
+ if (keys.ArrowLeft) { x -= 1; } if (keys.ArrowRight) { x += 1; } // spin (roll)
246
+ if (keys.ArrowUp) { y += 1; } if (keys.ArrowDown) { y -= 1; } // pitch
247
+ return { x: x, y: y };
248
+ }
249
+
250
+ function anyInputActive() {
251
+ if (jMove.x || jMove.y || jLook.x || jLook.y) { return true; }
252
+ for (var k in keys) { if (keys[k]) { return true; } }
253
+ return false;
254
+ }
255
+
256
+ // ── The single rAF integration loop ──────────────────────────────────
257
+ var rafId = null;
258
+ var lastT = 0;
259
+ var nowFn = (typeof performance !== 'undefined' && performance.now)
260
+ ? function () { return performance.now(); } : function () { return Date.now(); };
261
+ var rAF = (typeof requestAnimationFrame === 'function')
262
+ ? requestAnimationFrame : function (cb) { return setTimeout(function () { cb(nowFn()); }, 16); };
263
+ var cAF = (typeof cancelAnimationFrame === 'function')
264
+ ? cancelAnimationFrame : clearTimeout;
265
+
266
+ // A stick/key/wheel step changes what the frame SHOWS — the camera pose, and with it
267
+ // a grabbed object the runner carries. A running loop paints every frame; a STOPPED one
268
+ // (STUDIO Design) paints only when asked, so ask the runner for one repaint of the
269
+ // CURRENT state. repaint() never advances the tick clock — no physics.
270
+ function paintIfStopped() {
271
+ if (runner && !runner.isRunning && typeof runner.repaint === 'function') { runner.repaint(); }
272
+ }
273
+
274
+ function frame(t) {
275
+ rafId = null;
276
+ var dt = (t - lastT) / 1000;
277
+ lastT = t;
278
+ if (dt <= 0) { dt = 0.016; }
279
+ if (dt > 0.1) { dt = 0.1; } // clamp long gaps (tab switch)
280
+
281
+ var km = keyMove(), kl = keyLook();
282
+ // Combine pad + keyboard, clamped to a unit deflection per channel.
283
+ var lookX = clamp(jLook.x + kl.x, -1, 1); // spin (roll, about the view axis)
284
+ var lookY = clamp(jLook.y + kl.y, -1, 1); // pitch (about screen-right)
285
+ var moveX = clamp(jMove.x + km.x, -1, 1); // strafe (translation, screen-right)
286
+ var moveY = clamp(jMove.y + km.y, -1, 1); // walk (translation, into the picture)
287
+
288
+ var o = cam.getOrbit();
289
+ // ── THE FPS SPLIT (screen-relative) ──────────────────────────────
290
+ // LOOK = rotation ONLY, and the widget COMPOSES NOTHING: it hands the
291
+ // provider a screen-relative DELTA (spin about the view axis,
292
+ // pitch about screen-right) and the ONE camera authority does
293
+ // the compose -> rotate -> decompose. The old widget-side
294
+ // `az/el += rate*dt` integration is DELETED: three Euler angles
295
+ // applied in a fixed order do NOT compose the way successive
296
+ // screen-relative rotations do, so integrating them here could
297
+ // not express a rolled pitch at all.
298
+ // MOVE = translation ONLY (y = walk along the view direction, x =
299
+ // strafe along screen-right). MOVE never contributes rotation —
300
+ // that was the mis-wire where BOTH pads orbited when grabbed
301
+ // sideways.
302
+ if (canLook && (lookX || lookY)) {
303
+ cam.look({ spin: lookX * lookSpeed * dt, pitch: lookY * lookSpeed * dt });
304
+ }
305
+ if (moveX || moveY) {
306
+ if (canTranslate) {
307
+ // SCALE-INVARIANT walk: a step is a FRACTION of how far away the
308
+ // camera currently is, the same way the presets scale distance to
309
+ // the scene. A fixed px/sec felt right in one scene and walked
310
+ // clean out of a smaller one in under two seconds.
311
+ var step = Math.max(o.distance, 1) * moveRate * dt;
312
+ cam.move({ forward: moveY * step, strafe: moveX * step });
313
+ } else {
314
+ // FAIL-SOFT for a provider with no translate primitive: forward/back
315
+ // still reads as into/out of the picture (a dolly); strafe is a
316
+ // defined NO-OP rather than a rotation. ONLY distance is written —
317
+ // the LOOK axes are look()'s business, never smuggled in here.
318
+ if (moveY) {
319
+ cam.setOrbit({ distance: clamp(o.distance - moveY * dollySpeed * dt,
320
+ minDist, maxDist) });
321
+ }
322
+ }
323
+ }
324
+ if (lookX || lookY || moveX || moveY) { paintIfStopped(); }
325
+ publishState();
326
+
327
+ if (anyInputActive()) { scheduleFrame(); }
328
+ }
329
+ function scheduleFrame() {
330
+ if (rafId === null) { lastT = nowFn(); rafId = rAF(frame); }
331
+ }
332
+
333
+ // ── Keyboard ─────────────────────────────────────────────────────────
334
+ var HANDLED_KEYS = {
335
+ ArrowLeft: 1, ArrowRight: 1, ArrowUp: 1, ArrowDown: 1,
336
+ KeyW: 1, KeyA: 1, KeyS: 1, KeyD: 1
337
+ };
338
+ function onKeyDown(e) {
339
+ var code = e.code || e.key;
340
+ // Typing never drives: no grab, no camera key, while an editable has the key.
341
+ if ((code === 'Space' || HANDLED_KEYS[code]) && isTypingTarget(e, doc)) { return; }
342
+ if (code === 'Space') {
343
+ if (!runner || typeof runner.grabToggle !== 'function' || !runner.isAimArmed()) { return; }
344
+ if (e.preventDefault) { e.preventDefault(); } // Space is the page-scroll key
345
+ if (e.repeat) { return; } // a held key toggles once
346
+ runner.grabToggle();
347
+ paintIfStopped();
348
+ return;
349
+ }
350
+ if (!HANDLED_KEYS[code]) { return; }
351
+ keys[code] = true;
352
+ if (e.preventDefault) { e.preventDefault(); } // stop arrow-key page scroll
353
+ scheduleFrame();
354
+ }
355
+ function onKeyUp(e) {
356
+ var code = e.code || e.key;
357
+ if (!HANDLED_KEYS[code]) { return; }
358
+ keys[code] = false;
359
+ }
360
+ if (useKeyboard && kbTarget && kbTarget.addEventListener) {
361
+ kbTarget.addEventListener('keydown', onKeyDown);
362
+ kbTarget.addEventListener('keyup', onKeyUp);
363
+ }
364
+
365
+ // ── Canvas focus — so REAL keyboard input reaches this iframe ─────────
366
+ // The experiment page runs INSIDE an iframe. A real, human keydown is only
367
+ // delivered to this window when the iframe holds focus; if the iframe body
368
+ // never receives focus, real arrow/WASD presses fire in the PARENT document
369
+ // and never reach onKeyDown. (The old smoke test passed only because it
370
+ // DISPATCHED synthetic KeyboardEvents straight at window, bypassing focus.)
371
+ // Fix: make the mount focusable and grab focus on attach + on any pointer
372
+ // interaction with the canvas — the natural "click the game canvas, then
373
+ // the keys drive it" behavior. Key delivery still routes through kbTarget
374
+ // (window) via event bubbling once the mount (inside the iframe) is focused.
375
+ var restoreTabIndex = null;
376
+ var onMountPointerDown = null;
377
+ function focusMount() {
378
+ if (mountEl && typeof mountEl.focus === 'function') {
379
+ try { mountEl.focus({ preventScroll: true }); }
380
+ catch (e) { try { mountEl.focus(); } catch (e2) {} }
381
+ }
382
+ }
383
+ if (useKeyboard && mountEl && mountEl.addEventListener) {
384
+ if (typeof mountEl.tabIndex !== 'number' || mountEl.tabIndex < 0) {
385
+ restoreTabIndex = (mountEl.getAttribute && mountEl.hasAttribute && mountEl.hasAttribute('tabindex'))
386
+ ? mountEl.getAttribute('tabindex') : false; // false ⇒ no attr to restore
387
+ mountEl.tabIndex = -1; // focusable, out of tab order
388
+ if (mountEl.style) { mountEl.style.outline = 'none'; }
389
+ }
390
+ // Capture phase so this fires even when a joystick pad handles the same down.
391
+ onMountPointerDown = function () { focusMount(); };
392
+ mountEl.addEventListener('pointerdown', onMountPointerDown, true);
393
+ focusMount(); // best-effort focus at attach
394
+ }
395
+
396
+ // ── Aim state label — what the reticle is doing, read off the runner ──────
397
+ // Rendered from runner.getAimState() on the runner's aim notice, so it cannot disagree
398
+ // with what SPACE does. Shown only while the aim is armed (STUDIO Design). Carries
399
+ // data-aim-* attributes so a non-human reader (the VLA loop) reads the same state.
400
+ var aimLabelEl = null;
401
+ var unsubscribeAim = null;
402
+ var AIM_TEXT = {
403
+ 'roaming': function () { return 'ROAMING \u2014 aim the reticle at an object'; },
404
+ 'grab-ready': function (st) { return 'GRAB-READY ' + (st.hover || 'object') + ' \u2014 press SPACE to grab'; },
405
+ 'grabbed': function (st) { return 'GRABBED ' + (st.grabbed || 'object') + ' \u2014 move the world to place it, SPACE to release'; }
406
+ };
407
+ function makeAimLabel() {
408
+ var el = doc.createElement('div');
409
+ el.setAttribute('data-role', 'aim-state');
410
+ el.style.cssText = 'position:absolute;left:50%;transform:translateX(-50%);bottom:118px;display:none;' +
411
+ 'max-width:70%;padding:4px 8px;border-radius:4px;background:rgba(20,30,38,0.8);' +
412
+ 'border:1px solid rgba(255,170,0,0.7);color:rgba(240,240,240,0.95);' +
413
+ 'pointer-events:none;z-index:40;font:11px sans-serif;line-height:1.45;text-align:center;';
414
+ return el;
415
+ }
416
+ function renderAimLabel() {
417
+ if (!aimLabelEl) { return; }
418
+ var st = runner.getAimState();
419
+ aimLabelEl.setAttribute('data-aim-state', st.state);
420
+ aimLabelEl.setAttribute('data-aim-target', st.grabbed || st.hover || '');
421
+ aimLabelEl.setAttribute('data-aim-edge', st.atEdge ? 'true' : 'false');
422
+ aimLabelEl.setAttribute('data-aim-heading-held', st.headingHeld ? 'true' : 'false');
423
+ aimLabelEl.setAttribute('data-aim-z-held', st.zHeld ? 'true' : 'false');
424
+ if (st.state === 'off') { aimLabelEl.style.display = 'none'; aimLabelEl.textContent = ''; return; }
425
+ var lines = [AIM_TEXT[st.state](st)];
426
+ if (st.atEdge) { lines.push('AT WORLD EDGE \u2014 held at the universe boundary'); }
427
+ if (st.headingHeld) { lines.push('HEADING HELD \u2014 it points along the depth axis'); }
428
+ if (st.zHeld) { lines.push('Z HELD \u2014 a flat (z-less) wall stays on the z = 0 plane'); }
429
+ // The runner's message names what happened: a carry write that failed (the grab was
430
+ // released) or a grab the object refused (it was never grabbed).
431
+ if (st.error) { lines.push('NOT HELD: ' + st.error); }
432
+ aimLabelEl.textContent = lines.join('\n');
433
+ aimLabelEl.style.whiteSpace = 'pre-line';
434
+ aimLabelEl.style.color = (st.state === 'grabbed') ? '#ffcc66' : 'rgba(240,240,240,0.95)';
435
+ aimLabelEl.style.display = 'block';
436
+ }
437
+
438
+ // ── On-screen joystick pads ──────────────────────────────────────────
439
+ var padEls = [];
440
+ var padCleanups = []; // per-pad force-release (used on dispose mid-drag)
441
+ var restoreMountPosition = null;
442
+ var onWheel = null;
443
+
444
+ function makePad(side, label, vec) {
445
+ var pad = doc.createElement('div');
446
+ pad.setAttribute('data-role', 'camera-joystick');
447
+ pad.setAttribute('data-stick', side === 'left' ? 'move' : 'look');
448
+ pad.style.cssText =
449
+ 'position:absolute;bottom:16px;' + (side === 'left' ? 'left:16px;' : 'right:16px;') +
450
+ 'width:96px;height:96px;border-radius:50%;' +
451
+ 'background:rgba(20,30,38,0.55);border:1px solid rgba(126,200,227,0.5);' +
452
+ 'touch-action:none;z-index:40;box-sizing:border-box;user-select:none;' +
453
+ '-webkit-user-select:none;cursor:grab;';
454
+ var cap = doc.createElement('div');
455
+ cap.textContent = label;
456
+ cap.style.cssText =
457
+ 'position:absolute;top:4px;left:0;right:0;text-align:center;' +
458
+ 'color:rgba(126,200,227,0.85);font:10px sans-serif;pointer-events:none;';
459
+ var knob = doc.createElement('div');
460
+ knob.setAttribute('data-role', 'camera-joystick-knob');
461
+ knob.style.cssText =
462
+ 'position:absolute;left:50%;top:50%;width:34px;height:34px;margin:-17px 0 0 -17px;' +
463
+ 'border-radius:50%;background:rgba(126,200,227,0.85);pointer-events:none;' +
464
+ 'transition:transform 0.06s linear;';
465
+ pad.appendChild(cap); pad.appendChild(knob);
466
+
467
+ var R = 48; // pad radius (px); knob travels within it
468
+ var activePointer = null;
469
+ function setFromEvent(e) {
470
+ var rect = pad.getBoundingClientRect();
471
+ var cx = rect.left + rect.width / 2;
472
+ var cy = rect.top + rect.height / 2;
473
+ var dx = (e.clientX - cx);
474
+ var dy = (e.clientY - cy);
475
+ // Normalize to [-1,1]; invert y so "up" is +1 (screen y grows down).
476
+ var nx = clamp(dx / R, -1, 1);
477
+ var ny = clamp(-dy / R, -1, 1);
478
+ vec.x = nx; vec.y = ny;
479
+ knob.style.transform = 'translate(' + (nx * (R - 17)) + 'px,' + (-ny * (R - 17)) + 'px)';
480
+ scheduleFrame();
481
+ }
482
+ // A REAL mouse/touch drag routinely leaves the 96 px pad. Relying on the
483
+ // pad's own pointermove (+ setPointerCapture) is fragile — capture can be
484
+ // silently lost, and without it a pointer outside the pad stops firing pad
485
+ // events, so the drag "dies" and the camera never moves. Instead we track
486
+ // the drag at the DOCUMENT level for its whole lifetime (attached on down,
487
+ // detached on release), which works regardless of where the pointer goes or
488
+ // whether capture is honored. setPointerCapture stays as a harmless bonus.
489
+ var onDocMove = null, onDocUp = null;
490
+ function detachDoc() {
491
+ if (doc && doc.removeEventListener) {
492
+ if (onDocMove) { doc.removeEventListener('pointermove', onDocMove); }
493
+ if (onDocUp) {
494
+ doc.removeEventListener('pointerup', onDocUp);
495
+ doc.removeEventListener('pointercancel', onDocUp);
496
+ }
497
+ }
498
+ onDocMove = null; onDocUp = null;
499
+ }
500
+ function release() {
501
+ if (pad.releasePointerCapture && activePointer !== null) {
502
+ try { pad.releasePointerCapture(activePointer); } catch (err) {}
503
+ }
504
+ activePointer = null;
505
+ vec.x = 0; vec.y = 0;
506
+ knob.style.transform = 'translate(0px,0px)';
507
+ pad.style.cursor = 'grab';
508
+ detachDoc();
509
+ }
510
+ pad.addEventListener('pointerdown', function (e) {
511
+ activePointer = e.pointerId;
512
+ pad.style.cursor = 'grabbing';
513
+ focusMount(); // clicking a joystick focuses the canvas ⇒ keys work too
514
+ if (pad.setPointerCapture) { try { pad.setPointerCapture(e.pointerId); } catch (err) {} }
515
+ // Document-level drag tracking for the life of this gesture.
516
+ onDocMove = function (ev) { if (activePointer === ev.pointerId) { setFromEvent(ev); } };
517
+ onDocUp = function (ev) { if (activePointer === ev.pointerId) { release(); } };
518
+ if (doc && doc.addEventListener) {
519
+ doc.addEventListener('pointermove', onDocMove);
520
+ doc.addEventListener('pointerup', onDocUp);
521
+ doc.addEventListener('pointercancel', onDocUp);
522
+ }
523
+ setFromEvent(e);
524
+ if (e.preventDefault) { e.preventDefault(); }
525
+ });
526
+ padCleanups.push(function () { release(); });
527
+ return pad;
528
+ }
529
+
530
+ if (useJoysticks && doc && mountEl) {
531
+ // Anchor pads to mountEl.
532
+ var computed = (doc.defaultView && doc.defaultView.getComputedStyle)
533
+ ? doc.defaultView.getComputedStyle(mountEl).position : mountEl.style.position;
534
+ if (computed === 'static' || !computed) {
535
+ restoreMountPosition = mountEl.style.position || '';
536
+ mountEl.style.position = 'relative';
537
+ }
538
+ container = doc.createElement('div');
539
+ container.setAttribute('data-role', 'camera-controls');
540
+ container.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:39;';
541
+ // Pads themselves re-enable pointer events (container passes clicks through
542
+ // to the canvas everywhere else).
543
+ var movePad = makePad('left', 'MOVE', jMove);
544
+ var lookPad = makePad('right', 'LOOK', jLook);
545
+ movePad.style.pointerEvents = 'auto';
546
+ lookPad.style.pointerEvents = 'auto';
547
+ container.appendChild(movePad);
548
+ container.appendChild(lookPad);
549
+ mountEl.appendChild(container);
550
+ padEls = [movePad, lookPad];
551
+ if (runner && typeof runner.addAimListener === 'function') {
552
+ aimLabelEl = makeAimLabel();
553
+ container.appendChild(aimLabelEl);
554
+ unsubscribeAim = runner.addAimListener(renderAimLabel);
555
+ renderAimLabel();
556
+ }
557
+
558
+ // Wheel/pinch zoom on the mount surface (dolly), independent of the pads.
559
+ if (wheelStep > 0) {
560
+ onWheel = function (e) {
561
+ var o = cam.getOrbit();
562
+ var di = clamp(o.distance + (e.deltaY > 0 ? 1 : -1) * wheelStep, minDist, maxDist);
563
+ cam.setOrbit({ distance: di });
564
+ paintIfStopped();
565
+ publishState();
566
+ if (e.preventDefault) { e.preventDefault(); }
567
+ };
568
+ mountEl.addEventListener('wheel', onWheel, { passive: false });
569
+ }
570
+ }
571
+
572
+ // Publish once so the hook is live immediately (headless can read it
573
+ // before any drive).
574
+ publishState();
575
+
576
+ // ── Handle ───────────────────────────────────────────────────────────
577
+ var liveHandle = {
578
+ mounted: true,
579
+ dispose: function () {
580
+ // F4: release the idempotency marker so a later attach() mounts fresh.
581
+ if (mountEl && mountEl.__frqtlCameraHandle === liveHandle) {
582
+ mountEl.__frqtlCameraHandle = null;
583
+ }
584
+ if (rafId !== null) { cAF(rafId); rafId = null; }
585
+ // Force-release any in-flight joystick drag (detaches its doc listeners).
586
+ for (var i = 0; i < padCleanups.length; i++) {
587
+ try { padCleanups[i](); } catch (e) {}
588
+ }
589
+ padCleanups = [];
590
+ if (useKeyboard && kbTarget && kbTarget.removeEventListener) {
591
+ kbTarget.removeEventListener('keydown', onKeyDown);
592
+ kbTarget.removeEventListener('keyup', onKeyUp);
593
+ }
594
+ if (onMountPointerDown && mountEl && mountEl.removeEventListener) {
595
+ mountEl.removeEventListener('pointerdown', onMountPointerDown, true);
596
+ }
597
+ if (restoreTabIndex !== null && mountEl) {
598
+ if (restoreTabIndex === false) {
599
+ if (mountEl.removeAttribute) { mountEl.removeAttribute('tabindex'); }
600
+ } else if (mountEl.setAttribute) {
601
+ mountEl.setAttribute('tabindex', restoreTabIndex);
602
+ }
603
+ }
604
+ if (onWheel && mountEl && mountEl.removeEventListener) {
605
+ mountEl.removeEventListener('wheel', onWheel);
606
+ }
607
+ if (unsubscribeAim) { unsubscribeAim(); unsubscribeAim = null; }
608
+ aimLabelEl = null;
609
+ if (container && container.parentNode) { container.parentNode.removeChild(container); }
610
+ if (restoreMountPosition !== null && mountEl) { mountEl.style.position = restoreMountPosition; }
611
+ padEls = [];
612
+ container = null;
613
+ },
614
+ getState: function () { return cam.getOrbit(); },
615
+ setState: function (spec) { cam.setOrbit(spec); publishState(); },
616
+ sync: function () { publishState(); }
617
+ };
618
+ // F4: mark the container as owning this live instance (idempotency guard above).
619
+ if (mountEl) { mountEl.__frqtlCameraHandle = liveHandle; }
620
+ return liveHandle;
621
+ }
622
+
623
+ // ── Export ──────────────────────────────────────────────────────────────
624
+ /**
625
+ * isTypingTarget(e, doc) — THE "is the user typing" predicate (one owner): true when the key
626
+ * event's target, or the document's focused element (Monaco types into a hidden textarea while
627
+ * the event lands on the body), is an INPUT / TEXTAREA / SELECT or contenteditable. Every key
628
+ * binding that must not fire while typing asks THIS — the camera's Space + WASD/arrows here,
629
+ * STUDIO's Delete/Backspace in the parent document.
630
+ * @param {{target?:Element}} e @param {Document|null} doc @returns {boolean}
631
+ */
632
+ function _isEditable(el) {
633
+ var tag = el && el.tagName;
634
+ return !!el && (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable === true);
635
+ }
636
+ function isTypingTarget(e, doc) {
637
+ return _isEditable(e && e.target) || !!(doc && _isEditable(doc.activeElement));
638
+ }
639
+
640
+ var CameraControls = { attach: attach, isTypingTarget: isTypingTarget };
641
+
642
+ export { CameraControls };
643
+