@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,2920 @@
|
|
|
1
|
+
/* Copyright 2023-2026 by Essam Abadir */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FrqtlApp — the HOST ADAPTER a page/IDE/notebook talks to for one FRQTL scene.
|
|
5
|
+
*
|
|
6
|
+
* This is an `Application` in the PixiJS sense: loop, scene registry, renderer facade,
|
|
7
|
+
* canvas, instrument taps, editor plumbing and world-property proxy all hang off ONE
|
|
8
|
+
* handle the host drives. It is NOT a loop driver — an object called "Runner" reads as
|
|
9
|
+
* the loop alone (Matter.js `Runner` drives `Engine`; `Render` and `Composite` are
|
|
10
|
+
* separate objects), so a caller carrying that muscle memory reaches for a `scene` or
|
|
11
|
+
* `renderer` that does not exist here and starts guessing at the interface. Named to
|
|
12
|
+
* match the surface it actually is, consistent with `FrqtlVision`.
|
|
13
|
+
*
|
|
14
|
+
* Handles universe construction, experiment setup via the frqtl/objects/setup factories,
|
|
15
|
+
* and the doTick → renderer.renderTick loop, over the Rust/WASM engine reached through
|
|
16
|
+
* the engine door (frqtl/compat/engine.js) — engine-agnostic by contract.
|
|
17
|
+
*
|
|
18
|
+
* Host hook: `start()` makes the instance the page's runner (setPageRunner; read by a realm that
|
|
19
|
+
* cannot import this module — a parent frame, a Playwright probe — as the page door's
|
|
20
|
+
* `FrqtlPage.runner`, experiments/_shared/page-door.js). There is no second hook and no alias.
|
|
21
|
+
*
|
|
22
|
+
* Design (§6, §6.1 from WS_RENDERER_SHIM_DESIGN.md):
|
|
23
|
+
* - The app OWNS the requestAnimationFrame loop. p5's loop()/noLoop() are
|
|
24
|
+
* NO LONGER used. window.draw is no longer set.
|
|
25
|
+
* - The renderer is obtained from THE DISPLAY SLOT (compat/renderer/display-slot.js, set by
|
|
26
|
+
* the boot module, experiments/_shared/boot.js). If the slot is empty when the draw loop first fires, the app
|
|
27
|
+
* FAILS LOUD (paintBootFailure) — no silent P5Renderer fallback (Phase 1).
|
|
28
|
+
* - Fixed-timestep accumulator decouples physics ticks from draw frames:
|
|
29
|
+
* tickHz: physics ticks per second (default: lock to drawHz → 1:1 as today)
|
|
30
|
+
* drawHz: draw frames per second cap (default: display refresh)
|
|
31
|
+
* Default (tickHz == drawHz) reproduces today's exact 1:1 loop.
|
|
32
|
+
* - onTick fires per PHYSICS TICK; renderTick fires per DRAW FRAME.
|
|
33
|
+
* - stepsPerFrame is kept as a back-compat alias: sets tickHz = drawHz * stepsPerFrame.
|
|
34
|
+
* - Spiral-of-death guard: accumulated time clamped + ticks-per-frame capped.
|
|
35
|
+
* - Determinism preserved: physics only ever advances in whole doTick() steps;
|
|
36
|
+
* the headless run.js drivers (no RAF, no rendering) are unaffected.
|
|
37
|
+
*
|
|
38
|
+
* Usage (unchanged from before):
|
|
39
|
+
*
|
|
40
|
+
* var runner = new FrqtlApp({
|
|
41
|
+
* canvasParentId: 'canvas-wrapper',
|
|
42
|
+
* canvasW: 600,
|
|
43
|
+
* canvasH: 600,
|
|
44
|
+
* seed: 42,
|
|
45
|
+
* onTick: function(tickData, setupResult) { ... }
|
|
46
|
+
* });
|
|
47
|
+
*
|
|
48
|
+
* runner.createCanvas();
|
|
49
|
+
* runner.setup(setupDoubleSlit, { wavelength: 64, ... });
|
|
50
|
+
* runner.start();
|
|
51
|
+
* runner.stop();
|
|
52
|
+
* runner.toggle();
|
|
53
|
+
* runner.step(); // single tick + render (step-by-step debugging)
|
|
54
|
+
*
|
|
55
|
+
* New (§6.1):
|
|
56
|
+
* new FrqtlApp({ ..., tickHz: 120, drawHz: 30 })
|
|
57
|
+
* // → 4 physics ticks per drawn frame
|
|
58
|
+
*
|
|
59
|
+
* Back-compat alias (§6.1):
|
|
60
|
+
* new FrqtlApp({ ..., stepsPerFrame: 4 })
|
|
61
|
+
* // → sets tickHz = drawHz * 4 (same as above if drawHz == display refresh)
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import { ObjectSchema } from './schema/index.js';
|
|
65
|
+
import { FrqtlCapture } from './instruments/capture.js';
|
|
66
|
+
import * as FrqtlDisplay from '../compat/renderer/display-slot.js';
|
|
67
|
+
import * as RendererProviderContract from '../compat/renderer/renderer-provider-contract.js';
|
|
68
|
+
import * as OrbitBag from '../compat/renderer/orbit-bag.js';
|
|
69
|
+
import * as LoDrawnCenter from '../compat/renderer/lo-drawn-center.js';
|
|
70
|
+
import { engine } from '../compat/engine.js';
|
|
71
|
+
import { Filters } from '../compat/renderer/Filters.js';
|
|
72
|
+
import { LargeObject } from '../parts/LargeObject.js';
|
|
73
|
+
import { CollisionTypes } from '../parts/CollisionTypes.js';
|
|
74
|
+
import { suppressP5Draw, resumeP5Loop } from '../compat/vendor.js';
|
|
75
|
+
import { paintBootFailure } from '../experiments/_shared/boot-failure.js';
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
'use strict';
|
|
79
|
+
|
|
80
|
+
// ── Fixed-timestep constants (§6.1) ─────────────────────────────────────
|
|
81
|
+
var MAX_FRAME_MS = 100; // clamp wall-clock delta (spiral-of-death guard)
|
|
82
|
+
var MAX_TICKS_PER_FRAME = 10; // hard cap for normal (bounded) operation
|
|
83
|
+
var MAX_TICKS_UNBOUNDED = 200; // hard cap for unbounded TPS (Infinity tickHz)
|
|
84
|
+
|
|
85
|
+
// The keys setRateLimits() accepts — any other key is a FAIL-LOUD error there.
|
|
86
|
+
var RATE_LIMIT_KEYS = Object.freeze(['fpsLimit', 'tpsLimit']);
|
|
87
|
+
// The factory statics setup() consumes (see FrqtlApp.prototype.setup) — the ONE list
|
|
88
|
+
// carryFactoryStatics() copies, so a wrapped factory boots the same scene as the bare one.
|
|
89
|
+
var FACTORY_STATICS = Object.freeze(['initWorld']);
|
|
90
|
+
// A factory static that was deleted: setup() and carryFactoryStatics() THROW on it,
|
|
91
|
+
// naming what replaces it, so a factory still carrying one never loses it silently.
|
|
92
|
+
function _refuseDeletedStatics(fn, where) {
|
|
93
|
+
if (fn.RATE_LIMITS !== undefined) {
|
|
94
|
+
throw new Error('[FrqtlApp.' + where + '] the RATE_LIMITS factory static is DELETED — pacing is ' +
|
|
95
|
+
'declared where the scene is: exp.runner.setRateLimits({ fpsLimit?, tpsLimit? }) in a scene ' +
|
|
96
|
+
'file, runner.setRateLimits(...) on a page. (FAIL-LOUD.)');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── FrqtlApp ─────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve an engine class the host needs (`EGPTUniverse`, `Rectangle`): the handle a headless
|
|
104
|
+
* host injects (`new FrqtlApp({ engine: handle, … })`), else the realm's booted engine
|
|
105
|
+
* (`lib/frqtl/compat/engine.js` engine(), which THROWS naming the door before the boot).
|
|
106
|
+
* There is NO alternative construction path: `EGPTUniverse.create(config)` remains the ONE
|
|
107
|
+
* construction authority.
|
|
108
|
+
* @private
|
|
109
|
+
*/
|
|
110
|
+
function _resolveEngineClass(name, engineHandle) {
|
|
111
|
+
if (engineHandle && engineHandle[name]) { return engineHandle[name]; }
|
|
112
|
+
return engine()[name];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether `prop` is a WALL field — declared by the Wall descriptor every Wall-family kind
|
|
116
|
+
* extends (read from the schema owner), as opposed to a subclass's own field. */
|
|
117
|
+
function _isWallField(schema, prop) {
|
|
118
|
+
var d = schema.get('Wall');
|
|
119
|
+
for (var i = 0; i < d.fields.length; i++) { if (d.fields[i].name === prop) { return true; } }
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* `x` itself when it is a Wall-family FACADE (Wall, GraphingDetectorWall, …), else null.
|
|
124
|
+
* Structural, never by kind: the Wall constructor stamps its LO's ONE up-link
|
|
125
|
+
* (`this.lo._owner = this`), so a facade is `x.lo._owner === x`. Facade-only — no consumer
|
|
126
|
+
* holds an inner LO (every pick and aim resolves up to the declared object).
|
|
127
|
+
*/
|
|
128
|
+
function _wallFacadeOf(x) {
|
|
129
|
+
return (x && x.lo && x.lo._owner === x) ? x : null;
|
|
130
|
+
}
|
|
131
|
+
FrqtlApp._wallFacadeOf = _wallFacadeOf;
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The wall-clock loop (`start()`, `toggle()` resume) runs on `requestAnimationFrame`.
|
|
136
|
+
* A host without one (node) gets a NAMED door here instead of a raw ReferenceError, and
|
|
137
|
+
* gets it before the runner's loop state is touched.
|
|
138
|
+
* @private
|
|
139
|
+
*/
|
|
140
|
+
function _requireAnimationFrame(method) {
|
|
141
|
+
if (typeof requestAnimationFrame === 'function') { return; }
|
|
142
|
+
throw new Error('[FrqtlApp.' + method + '] no animation frame in this host — the wall-clock ' +
|
|
143
|
+
'loop needs requestAnimationFrame. Drive the clock with `runner.step()` (one tick + ' +
|
|
144
|
+
'render per call, any host), or boot in a browser through `@descix/frqtl-web/boot`. ' +
|
|
145
|
+
'(FAIL-LOUD.)');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {Object} opts
|
|
150
|
+
* @param {string} [opts.canvasParentId] DOM element ID for the canvas.
|
|
151
|
+
* @param {Object} [opts.engine] Engine handle (compat/engine.js engine()) carrying
|
|
152
|
+
* EGPTUniverse + Rectangle. Omit in a page — the
|
|
153
|
+
* boot-published globals are read instead.
|
|
154
|
+
* @param {number} opts.canvasW Physics canvas width in pixels — REQUIRED (no default:
|
|
155
|
+
* a default size is a second declaration of the world).
|
|
156
|
+
* @param {number} opts.canvasH Physics canvas height in pixels — REQUIRED.
|
|
157
|
+
* @param {boolean} [opts.is3D=false] The display is 3D (perspective + depth) — handed to the
|
|
158
|
+
* provider's init. A scene states it through
|
|
159
|
+
* exp.createScene; a page passes it here.
|
|
160
|
+
* @param {number} [opts.canvasD] World depth for a 3D display (handed to provider init).
|
|
161
|
+
* @param {number} [opts.seed=42] RNG seed for deterministic runs.
|
|
162
|
+
* @param {number} [opts.tickHz] Physics ticks/s (default: lock to drawHz).
|
|
163
|
+
* @param {number} [opts.drawHz] Draw frames/s cap (default: display refresh).
|
|
164
|
+
* @param {number} [opts.stepsPerFrame=1] Back-compat alias: sets tickHz = drawHz * N.
|
|
165
|
+
* @param {Function} [opts.onTick] Called per PHYSICS TICK with (tickData, setupResult).
|
|
166
|
+
* @param {Function} [opts.onReady] Called once the canvas is ready.
|
|
167
|
+
*/
|
|
168
|
+
function FrqtlApp(opts) {
|
|
169
|
+
opts = opts || {};
|
|
170
|
+
var canvasW = opts.canvasW, canvasH = opts.canvasH;
|
|
171
|
+
if (typeof canvasW !== 'number' || !(canvasW > 0) || typeof canvasH !== 'number' || !(canvasH > 0)) {
|
|
172
|
+
throw new Error('[FrqtlApp] canvasW and canvasH are REQUIRED positive numbers (got ' +
|
|
173
|
+
JSON.stringify(canvasW) + ' x ' + JSON.stringify(canvasH) + '). There is no default world ' +
|
|
174
|
+
'size: a scene states its world with exp.createCanvas(w, h, cfg) / exp.createScene(w, h, d, cfg); ' +
|
|
175
|
+
'a page passes it here. (FAIL-LOUD.)');
|
|
176
|
+
}
|
|
177
|
+
this._is3D = !!opts.is3D;
|
|
178
|
+
this._depth = (opts.canvasD !== undefined && opts.canvasD !== null) ? opts.canvasD : null;
|
|
179
|
+
var seed = opts.seed !== undefined ? opts.seed : 42;
|
|
180
|
+
|
|
181
|
+
this._canvasParentId = opts.canvasParentId || null;
|
|
182
|
+
this._onTick = opts.onTick || null;
|
|
183
|
+
this._onReady = opts.onReady || null;
|
|
184
|
+
// Multi-subscriber per-tick fan-out (peer of the single `_onTick` slot the
|
|
185
|
+
// experiment page owns). `addTickListener(fn)` lets a host-side consumer (the
|
|
186
|
+
// LiveChart wiring) subscribe to the tick stream WITHOUT stealing `_onTick`.
|
|
187
|
+
// Empty by default → zero behavior change (SU1); the fan-out fires AFTER
|
|
188
|
+
// `_onTick` at both tick sites (the RAF loop and step()).
|
|
189
|
+
this._tickListeners = [];
|
|
190
|
+
|
|
191
|
+
// tickHz / drawHz — independent clocks (§6.1)
|
|
192
|
+
// drawHz=0 means "lock to display refresh" (the default)
|
|
193
|
+
this._drawHz = (opts.drawHz !== undefined && opts.drawHz > 0) ? opts.drawHz : 0;
|
|
194
|
+
// stepsPerFrame back-compat: treated as tickHz multiplier relative to drawHz
|
|
195
|
+
var spf = (opts.stepsPerFrame !== undefined) ? Math.max(1, opts.stepsPerFrame) : 1;
|
|
196
|
+
if (opts.tickHz !== undefined && opts.tickHz > 0) {
|
|
197
|
+
this._tickHz = opts.tickHz;
|
|
198
|
+
} else if (spf > 1) {
|
|
199
|
+
// stepsPerFrame > 1: tickHz = drawHz * spf (if drawHz known at start, else approximate)
|
|
200
|
+
this._tickHz = (this._drawHz > 0) ? this._drawHz * spf : 60 * spf;
|
|
201
|
+
} else {
|
|
202
|
+
// Default 1:1: tickHz locked to drawHz at runtime (set in the frame loop)
|
|
203
|
+
this._tickHz = 0; // 0 = "lock to drawHz" — resolved inside the frame loop
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Back-compat accessor
|
|
207
|
+
this.stepsPerFrame = spf;
|
|
208
|
+
|
|
209
|
+
// The engine classes, resolved through the named order (never a bare global).
|
|
210
|
+
var _Rectangle = _resolveEngineClass('Rectangle', opts.engine);
|
|
211
|
+
|
|
212
|
+
// Physics canvas rect
|
|
213
|
+
this.canvas_rect = new _Rectangle(0, 0, canvasW, canvasH, null, false);
|
|
214
|
+
|
|
215
|
+
// The INIT SEED, retained. This runner is the party that hands the seed to the
|
|
216
|
+
// universe, so it is the seed authority — and `seed` is half of the regeneration
|
|
217
|
+
// triple (code, seed, tick) a capture's SceneRecord must carry. It is FERRIED into
|
|
218
|
+
// the capture instrument's identity bag below, never re-derived there.
|
|
219
|
+
this._seed = seed;
|
|
220
|
+
// Tick SUSPENSION depth (see suspendTicks). Distinct from `_isLooping`, which is the
|
|
221
|
+
// USER's play/pause: a suspend must not lie to the play button, and a resume must not
|
|
222
|
+
// secretly start a stopped sim.
|
|
223
|
+
this._tickSuspend = 0;
|
|
224
|
+
|
|
225
|
+
// Universe — built through the ONE construction authority, `EGPTUniverse.create`
|
|
226
|
+
// (EGPTUniverse.CONFIG is the exported key/default/unit/effect table). This host
|
|
227
|
+
// names only the two settings it actually owns; every other key takes its published
|
|
228
|
+
// default (fps 60, iframeIntervalSeconds 1, no IQC, no bonding, groundUp,
|
|
229
|
+
// minMBFillRate 0.1, wavelengthScale 1 — the setup factory / sketch's
|
|
230
|
+
// `universe.init()` sets the base dimension and overrides wavelengthScale per
|
|
231
|
+
// experiment anyway).
|
|
232
|
+
var _Universe = _resolveEngineClass('EGPTUniverse', opts.engine);
|
|
233
|
+
this.universe = _Universe.create({
|
|
234
|
+
rect: this.canvas_rect,
|
|
235
|
+
seed: seed,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// The DECLARED-object registry (see `declared`): empty until a declaring call
|
|
239
|
+
// (ObjectFactory.create) records a part.
|
|
240
|
+
this._declared = [];
|
|
241
|
+
this._declaredMembers = new Map();
|
|
242
|
+
this._declaredTicks = new Map();
|
|
243
|
+
this._setupResult = null;
|
|
244
|
+
this._sceneReplay = null; // retainSceneReplay(fn) — what restart() replays
|
|
245
|
+
// Design-mode signal — DEFAULT false (Run / standalone bundles /
|
|
246
|
+
// experiment pages). When true (STUDIO authoring), the renderer DRAWS the
|
|
247
|
+
// Design-only markers (a manipulable emitter's visual circle, `lo._designMarker`).
|
|
248
|
+
// A render toggle at the output boundary (zero physics): it filters ONLY the
|
|
249
|
+
// renderTick view; the pick, the aim, Layers and codegen read the census and the
|
|
250
|
+
// declared registry directly, so an emitter stays selectable even when its marker
|
|
251
|
+
// circle is suppressed.
|
|
252
|
+
this._designMode = false;
|
|
253
|
+
// The aim surface (setAimArmed): null while disarmed, else the stationary-reticle state —
|
|
254
|
+
// the centre-ray hover and, while grabbed, the held camera-relative pose. `_selected` is
|
|
255
|
+
// the host's selection, handed down for its shadow-surround (setSelectHighlight).
|
|
256
|
+
this._aim = null;
|
|
257
|
+
this._selected = null;
|
|
258
|
+
// F5: optional host callback fired on a position write (center/rect) so an overlay can
|
|
259
|
+
// REPAINT event-driven (Studio sets it; a headless game leaves it null → no-op).
|
|
260
|
+
this._onObjectMoved = null;
|
|
261
|
+
this._isLooping = false;
|
|
262
|
+
this._rafId = null;
|
|
263
|
+
this._lastTickData = null; // repaint()'s source (§Studio Controls Realignment S4)
|
|
264
|
+
|
|
265
|
+
// Fixed-timestep state
|
|
266
|
+
this._accumulator = 0;
|
|
267
|
+
this._lastFrame = 0;
|
|
268
|
+
this._lastDraw = 0;
|
|
269
|
+
|
|
270
|
+
// Budget-exhaustion warning throttle (see the drive loop). Default-muted;
|
|
271
|
+
// only emits under FrqtlApp.verbose === true, at most once per interval.
|
|
272
|
+
this._lastBudgetWarn = 0;
|
|
273
|
+
this._BUDGET_WARN_INTERVAL_MS = 5000;
|
|
274
|
+
|
|
275
|
+
// Rate measurement (§fps/tps readout) — integers only, no per-frame allocation.
|
|
276
|
+
// Counts reset each ~RATE_WINDOW_MS; getRates() returns last computed snapshot.
|
|
277
|
+
this._rateWindowMs = 1000; // recompute window length
|
|
278
|
+
this._rateWindowEnd = 0; // performance.now() when the current window ends
|
|
279
|
+
this._tickCount = 0; // physics ticks accumulated in this window
|
|
280
|
+
this._drawCount = 0; // draw frames accumulated in this window
|
|
281
|
+
this._lastTps = 0; // last computed ticks/sec
|
|
282
|
+
this._lastFps = 0; // last computed frames/sec
|
|
283
|
+
|
|
284
|
+
// Render options — threaded into every renderTick(tickData, largeObjects, options) call.
|
|
285
|
+
// Default: renderBaseCanvas true = draw particle layer.
|
|
286
|
+
// Written ONLY through setRenderOption/setRenderOptions (the declared-capability gate).
|
|
287
|
+
this._renderOptions = { renderBaseCanvas: true };
|
|
288
|
+
// Writes made before a provider attached: validated against THAT provider's
|
|
289
|
+
// renderCapabilities() the moment it attaches (see _getRenderer). Null when empty.
|
|
290
|
+
this._stagedRenderOptions = null;
|
|
291
|
+
|
|
292
|
+
// Renderer (resolved lazily in start() — the boot may not have selected it yet)
|
|
293
|
+
this._renderer = null;
|
|
294
|
+
|
|
295
|
+
// ── The render-pipeline TAP — DELEGATED, not implemented here ─────────
|
|
296
|
+
// The mechanism lives at lib/frqtl/objects/instruments/capture.js (THE_MINIMAL_SIM
|
|
297
|
+
// §4): a tap is an instrument on the primitive surface, not an editor concern. This
|
|
298
|
+
// host keeps only the `capture`/`captureStill`/`sceneRecord`/`setSourceRef`
|
|
299
|
+
// conveniences below, each a thin forward. The binding hands the instrument LIVE
|
|
300
|
+
// reads — the retained TickData, the active provider, the physics rect and the ONE
|
|
301
|
+
// render-options bag — so nothing is copied and no second authority is minted.
|
|
302
|
+
var self = this;
|
|
303
|
+
this._capture = FrqtlCapture.create(
|
|
304
|
+
{ get lastTickData() { return self._lastTickData; } },
|
|
305
|
+
function () { return self._getRenderer(); },
|
|
306
|
+
{ seed: this._seed, get rect() { return self.canvas_rect; } },
|
|
307
|
+
function () { return self._renderOptions; }
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* runner.largeObjects — THE OBJECT CENSUS, DERIVED FROM THE WORLD.
|
|
313
|
+
*
|
|
314
|
+
* ── Layer 1: API contract ────────────────────────────────────────────────
|
|
315
|
+
* The live array of `LargeObject` wrappers constructed on this runner's universe, in
|
|
316
|
+
* construction order (which is z-order for the renderer). It is the WORLD's array,
|
|
317
|
+
* not a copy: `new LargeObject(u, …)` self-registers into it and `dispose()` leaves
|
|
318
|
+
* it, so this host maintains no list of its own.
|
|
319
|
+
*
|
|
320
|
+
* This host used to keep `_largeObjects`, a SECOND, render-only registration list fed
|
|
321
|
+
* by `addLargeObject(lo)`. Its divergence from the engine's registry is unrepresentable
|
|
322
|
+
* in the model the primitive table teaches — "construct it and it is in the world" —
|
|
323
|
+
* and when it diverged, the object collided and counted correctly while being
|
|
324
|
+
* completely invisible in every picture, with no error. Both that list and the
|
|
325
|
+
* `addLargeObject`/`removeLargeObject` pair are DELETED: constructing IS registering,
|
|
326
|
+
* and `removeObject(obj)` (which disposes) IS removing.
|
|
327
|
+
*
|
|
328
|
+
* ── Layer 2: physics behaviour ───────────────────────────────────────────
|
|
329
|
+
* ZERO. A census read touches no frame, no dimension and no tick; the render VIEW
|
|
330
|
+
* (`_visibleLargeObjects`) filters it without mutating it.
|
|
331
|
+
* @returns {Array}
|
|
332
|
+
*/
|
|
333
|
+
Object.defineProperty(FrqtlApp.prototype, 'largeObjects', {
|
|
334
|
+
configurable: true,
|
|
335
|
+
get: function () {
|
|
336
|
+
var u = this.universe;
|
|
337
|
+
return (u && Array.isArray(u.largeObjects)) ? u.largeObjects : [];
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* runner.controlHost — THE door a scene's on-canvas chrome mounts through (host-owned
|
|
343
|
+
* control mount through the runner's one door; scenes do not name DOM
|
|
344
|
+
* ids). Read: the element a HOSTING PANE set, else the element the scene canvas is mounted
|
|
345
|
+
* in (its wrapper), else null (no canvas yet — a notebook pane before its mount: the chrome
|
|
346
|
+
* stays in the exposed registry only). Write: a hosting pane (STUDIO) hands an element it
|
|
347
|
+
* owns ABOVE its own overlays; every chrome already mounted through the door (a knob overlay
|
|
348
|
+
* registered with addControlChrome) MOVES there, so a click on a scene knob reaches the knob,
|
|
349
|
+
* never STUDIO's selection overlay.
|
|
350
|
+
*/
|
|
351
|
+
Object.defineProperty(FrqtlApp.prototype, 'controlHost', {
|
|
352
|
+
configurable: true,
|
|
353
|
+
get: function () {
|
|
354
|
+
if (this._controlHost) { return this._controlHost; }
|
|
355
|
+
var s = this._sceneSurface;
|
|
356
|
+
return (s && s.parentNode) ? s.parentNode : null;
|
|
357
|
+
},
|
|
358
|
+
set: function (el) {
|
|
359
|
+
if (!el || typeof el.appendChild !== 'function' || !el.ownerDocument) {
|
|
360
|
+
throw new TypeError('[FrqtlApp.controlHost] a control host is a DOM element (it carries ' +
|
|
361
|
+
'appendChild + ownerDocument); got ' + String(el) + '. (FAIL-LOUD.)');
|
|
362
|
+
}
|
|
363
|
+
this._controlHost = el;
|
|
364
|
+
var cs = (this._controlChrome || []).slice();
|
|
365
|
+
for (var i = 0; i < cs.length; i++) { cs[i].rehost(el); }
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* addControlChrome(chrome) → unsubscribe. On-canvas chrome mounted through `controlHost`
|
|
371
|
+
* registers here (`chrome.rehost(el)` moves it) so a host set later moves it too.
|
|
372
|
+
*/
|
|
373
|
+
FrqtlApp.prototype.addControlChrome = function (chrome) {
|
|
374
|
+
if (!chrome || typeof chrome.rehost !== 'function') {
|
|
375
|
+
throw new TypeError('[FrqtlApp.addControlChrome] chrome must expose rehost(el). (FAIL-LOUD.)');
|
|
376
|
+
}
|
|
377
|
+
var list = this._controlChrome || (this._controlChrome = []);
|
|
378
|
+
list.push(chrome);
|
|
379
|
+
return function () { var i = list.indexOf(chrome); if (i !== -1) { list.splice(i, 1); } };
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Toggle Design mode. STUDIO calls setDesignMode(true) while authoring
|
|
384
|
+
* (MODE_DESIGN) so the manipulable emitter markers (`lo._designMarker`) render as
|
|
385
|
+
* visible circles, and false during Run / Pause so they are suppressed (you see
|
|
386
|
+
* the emission, not the source marker). Standalone bundles + experiment pages
|
|
387
|
+
* never call this → the default false suppresses the marker for pure output. Zero
|
|
388
|
+
* physics — this only gates the renderTick view (see _visibleLargeObjects).
|
|
389
|
+
* @param {boolean} v
|
|
390
|
+
*/
|
|
391
|
+
FrqtlApp.prototype.setDesignMode = function (v) {
|
|
392
|
+
this._designMode = !!v;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Effective shellRender mode for a LargeObject — the SINGLE
|
|
397
|
+
* suppression authority. Precedence:
|
|
398
|
+
* 1. per-instance `lo.shellRender` (a valid 'design'|'run'|'never' string) —
|
|
399
|
+
* the live override the Inspector / a game sets via updateObjectProperty
|
|
400
|
+
* (the "red light bulb" flips a marker to 'run').
|
|
401
|
+
* 2. kind default: a manipulable marker (`lo._designMarker === true`) defaults
|
|
402
|
+
* 'design' (invisible during Run); every ordinary LO defaults
|
|
403
|
+
* 'run' (always visible). This keeps every pre-existing scene byte-identical.
|
|
404
|
+
* @returns {'design'|'run'|'never'}
|
|
405
|
+
*/
|
|
406
|
+
FrqtlApp.prototype._shellRenderOf = function (lo) {
|
|
407
|
+
var sr = lo && lo.shellRender;
|
|
408
|
+
if (sr === 'design' || sr === 'run' || sr === 'never') { return sr; }
|
|
409
|
+
return (lo && lo._designMarker === true) ? 'design' : 'run';
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The largeObjects VIEW passed to the renderer each frame: each LO
|
|
414
|
+
* is shown per its effective `shellRender` (see _shellRenderOf) — 'run' always,
|
|
415
|
+
* 'design' only while `_designMode` is on, 'never' hidden.
|
|
416
|
+
*
|
|
417
|
+
* It is a VIEW over `this.largeObjects` (the world's census), which is the whole of
|
|
418
|
+
* this method's remaining job: the host filters, it does not register. A new array
|
|
419
|
+
* (never mutates the census); returns the SAME array reference when nothing is
|
|
420
|
+
* filtered (the common case — all LOs 'run', or design mode with no 'never') to
|
|
421
|
+
* avoid per-frame churn.
|
|
422
|
+
* @returns {Array}
|
|
423
|
+
*/
|
|
424
|
+
FrqtlApp.prototype._visibleLargeObjects = function () {
|
|
425
|
+
var los = this.largeObjects;
|
|
426
|
+
if (!los || los.length === 0) { return los; }
|
|
427
|
+
var self = this;
|
|
428
|
+
var anyHidden = false;
|
|
429
|
+
for (var i = 0; i < los.length; i++) {
|
|
430
|
+
if (los[i] && !self._isShellVisible(los[i])) { anyHidden = true; break; }
|
|
431
|
+
}
|
|
432
|
+
if (!anyHidden) { return los; }
|
|
433
|
+
return los.filter(function (lo) { return self._isShellVisible(lo); });
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
/** Whether a LO's shell paints in the CURRENT mode. */
|
|
437
|
+
FrqtlApp.prototype._isShellVisible = function (lo) {
|
|
438
|
+
var sr = this._shellRenderOf(lo);
|
|
439
|
+
if (sr === 'run') { return true; }
|
|
440
|
+
if (sr === 'never') { return false; }
|
|
441
|
+
return !!this._designMode; // 'design'
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
var FILTER_KEY_PREFIX = 'filter.';
|
|
445
|
+
|
|
446
|
+
/** The provider's label for an error message: the display slot's boot name when it is that provider. */
|
|
447
|
+
function _providerLabel(prov) {
|
|
448
|
+
var slot = FrqtlDisplay;
|
|
449
|
+
var name = (slot.get() === prov) ? slot.name() : null;
|
|
450
|
+
return name ? ('renderer "' + name + '"') : 'the active renderer';
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Validate render-option writes against `prov`'s declared capabilities.
|
|
455
|
+
* THROW-OR-RETURN: returns the normalized `[key, value]` rows; nothing is applied here.
|
|
456
|
+
* @private
|
|
457
|
+
*/
|
|
458
|
+
function _validateRenderWrites(prov, rows) {
|
|
459
|
+
if (typeof prov.renderCapabilities !== 'function') {
|
|
460
|
+
throw new TypeError('[FrqtlApp.setRenderOption] ' + _providerLabel(prov) + ' declares no ' +
|
|
461
|
+
'renderCapabilities(), so it accepts no render option (tried "' + rows.map(function (r) {
|
|
462
|
+
return r[0]; }).join('", "') + '"). A provider declares its knobs by implementing ' +
|
|
463
|
+
'renderCapabilities().');
|
|
464
|
+
}
|
|
465
|
+
var caps = prov.renderCapabilities();
|
|
466
|
+
var byName = {};
|
|
467
|
+
for (var i = 0; i < caps.length; i++) { byName[caps[i].name] = caps[i]; }
|
|
468
|
+
var normalizeValue = ObjectSchema.normalizeValue;
|
|
469
|
+
var out = [];
|
|
470
|
+
for (var r = 0; r < rows.length; r++) {
|
|
471
|
+
var key = rows[r][0];
|
|
472
|
+
if (FrqtlApp.RUNNER_INJECTED_RENDER_KEYS.indexOf(key) !== -1) {
|
|
473
|
+
throw new TypeError('[FrqtlApp.setRenderOption] "' + key + '" is merged into the render ' +
|
|
474
|
+
'bag by the runner itself (FrqtlApp.RUNNER_INJECTED_RENDER_KEYS); it is not a writable ' +
|
|
475
|
+
'render option.');
|
|
476
|
+
}
|
|
477
|
+
var cap = Object.prototype.hasOwnProperty.call(byName, key) ? byName[key] : null;
|
|
478
|
+
if (!cap) {
|
|
479
|
+
throw new TypeError('[FrqtlApp.setRenderOption] "' + key + '" is not a render option ' +
|
|
480
|
+
_providerLabel(prov) + ' declares. Declared by renderCapabilities(): [' +
|
|
481
|
+
caps.map(function (c) { return c.name; }).join(', ') + ']. (FAIL-LOUD: an ' +
|
|
482
|
+
'undeclared key is read by nothing, so the picture would silently differ from the recipe.)');
|
|
483
|
+
}
|
|
484
|
+
out.push([key, normalizeValue(cap, rows[r][1], 'render option')]);
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Apply already-validated rows: a `filter.<name>` toggle routes to setFilterActive (the
|
|
491
|
+
* provider's filter()/removeFilter() slots); every other key lands in the ONE options bag.
|
|
492
|
+
* THE ONE apply site, so it is also where a write is ANNOUNCED (addRenderOptionsListener — a
|
|
493
|
+
* panel reads the live values back through its own factory, whoever wrote them) and where a
|
|
494
|
+
* PAUSED picture is redrawn (a running loop redraws anyway; before the first tick repaint is
|
|
495
|
+
* a no-op by contract).
|
|
496
|
+
* @private
|
|
497
|
+
*/
|
|
498
|
+
FrqtlApp.prototype._applyRenderWrites = function (rows) {
|
|
499
|
+
for (var i = 0; i < rows.length; i++) {
|
|
500
|
+
var key = rows[i][0];
|
|
501
|
+
if (key.indexOf(FILTER_KEY_PREFIX) === 0) {
|
|
502
|
+
this.setFilterActive(key.slice(FILTER_KEY_PREFIX.length), !!rows[i][1]);
|
|
503
|
+
} else {
|
|
504
|
+
this._renderOptions[key] = rows[i][1];
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
var ls = (this._renderOptionsListeners || []).slice();
|
|
508
|
+
for (var j = 0; j < ls.length; j++) { ls[j](rows.map(function (r) { return [r[0], r[1]]; })); }
|
|
509
|
+
if (!this._isLooping) { this.repaint(); }
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* addRenderOptionsListener(fn) → unsubscribe. `fn(rows)` runs once per APPLIED batch of render
|
|
514
|
+
* option writes (`rows` = the applied `[key, value]` pairs), from every writer — a scene knob,
|
|
515
|
+
* `exp.render(...)`, a STUDIO panel, staged writes applied when the provider attaches. The
|
|
516
|
+
* pose-listener / aim-listener precedent: the runner announces, a view reads back.
|
|
517
|
+
* @param {Function} fn @returns {Function} unsubscribe
|
|
518
|
+
*/
|
|
519
|
+
FrqtlApp.prototype.addRenderOptionsListener = function (fn) {
|
|
520
|
+
var ls = this._renderOptionsListeners || (this._renderOptionsListeners = []);
|
|
521
|
+
if (ls.indexOf(fn) === -1) { ls.push(fn); }
|
|
522
|
+
return function () { var i = ls.indexOf(fn); if (i !== -1) { ls.splice(i, 1); } };
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* runner.setRenderOptions(bag) — write render options as ONE atomic batch.
|
|
527
|
+
*
|
|
528
|
+
* THROW-OR-APPLY. Every key must be a capability the display provider declares through
|
|
529
|
+
* `renderCapabilities()`, and every value must satisfy that descriptor through the SAME
|
|
530
|
+
* declared-type gate a part's field passes (`ObjectSchema.normalizeValue`: an enum member,
|
|
531
|
+
* a number inside its range, a 0/1 coerced to boolean). Every key validates before any
|
|
532
|
+
* applies, so a bad bag applies nothing. `'filter.<name>': 0|1` toggles that post-process
|
|
533
|
+
* filter.
|
|
534
|
+
*
|
|
535
|
+
* Before a provider attaches, the writes are STAGED and validated at attach (the first
|
|
536
|
+
* `_getRenderer()` that finds the display slot filled): a staged key that provider does
|
|
537
|
+
* not declare throws THEN, naming the provider.
|
|
538
|
+
*
|
|
539
|
+
* @param {Object<string, *>} bag — e.g. `{ shape: 'sphere', godrays: 0.8, glowIntensity: 1.6 }`.
|
|
540
|
+
* @throws {TypeError} on a non-object bag, an undeclared key, or an invalid value.
|
|
541
|
+
*/
|
|
542
|
+
FrqtlApp.prototype.setRenderOptions = function (bag) {
|
|
543
|
+
if (!bag || typeof bag !== 'object' || Array.isArray(bag)) {
|
|
544
|
+
throw new TypeError('[FrqtlApp.setRenderOptions] expects a plain object of ' +
|
|
545
|
+
'{ capabilityName: value }; got ' + (Array.isArray(bag) ? 'an array' : String(bag)) + '.');
|
|
546
|
+
}
|
|
547
|
+
var rows = Object.keys(bag).map(function (k) { return [k, bag[k]]; });
|
|
548
|
+
var prov = this._getRenderer();
|
|
549
|
+
if (!prov) {
|
|
550
|
+
this._stagedRenderOptions = (this._stagedRenderOptions || []).concat(rows);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
this._applyRenderWrites(_validateRenderWrites(prov, rows));
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* runner.setRenderOption(key, value) — one render option; see setRenderOptions.
|
|
558
|
+
* @param {string} key — a capability name the display provider declares.
|
|
559
|
+
* @param {*} value — a value its descriptor accepts.
|
|
560
|
+
* @throws {TypeError} on an undeclared key or an invalid value.
|
|
561
|
+
*/
|
|
562
|
+
FrqtlApp.prototype.setRenderOption = function (key, value) {
|
|
563
|
+
var bag = {};
|
|
564
|
+
bag[key] = value;
|
|
565
|
+
this.setRenderOptions(bag);
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* runner.renderCapabilities() — the active provider's `renderCapabilities()` list, the
|
|
570
|
+
* render-option vocabulary. No provider yet, or a provider implementing no
|
|
571
|
+
* `renderCapabilities()`, declares no options: `[]` (the controls factory then emits no
|
|
572
|
+
* render controls, and every render-option write to that provider throws).
|
|
573
|
+
*
|
|
574
|
+
* @returns {Array<object>} field-descriptor-shaped capability descriptors
|
|
575
|
+
* (`lib/shim/field-descriptor`'s `{name,type,constraint,unit,default,
|
|
576
|
+
* description}` shape), or `[]`.
|
|
577
|
+
*/
|
|
578
|
+
FrqtlApp.prototype.renderCapabilities = function () {
|
|
579
|
+
var prov = this._getRenderer();
|
|
580
|
+
if (!prov || typeof prov.renderCapabilities !== 'function') { return []; }
|
|
581
|
+
return prov.renderCapabilities();
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* runner.getRenderOption(key) — the live value of a declared render option;
|
|
586
|
+
* `filter.<name>` reads isFilterActive (a boolean, matching its descriptor).
|
|
587
|
+
*
|
|
588
|
+
* @param {string} key
|
|
589
|
+
* @returns {*} the live value (undefined when never written; the descriptor's `default`
|
|
590
|
+
* states what the provider draws then).
|
|
591
|
+
* @throws {TypeError} when no provider has attached, or `key` is not declared by it.
|
|
592
|
+
*/
|
|
593
|
+
FrqtlApp.prototype.getRenderOption = function (key) {
|
|
594
|
+
var prov = this._getRenderer();
|
|
595
|
+
if (!prov) {
|
|
596
|
+
throw new TypeError('[FrqtlApp.getRenderOption] no display provider has attached yet, so ' +
|
|
597
|
+
'no render option is declared. Read after the renderer boots.');
|
|
598
|
+
}
|
|
599
|
+
if (typeof prov.renderCapabilities !== 'function') {
|
|
600
|
+
throw new TypeError('[FrqtlApp.getRenderOption] ' + _providerLabel(prov) + ' declares no ' +
|
|
601
|
+
'renderCapabilities(), so "' + key + '" is not a render option it has.');
|
|
602
|
+
}
|
|
603
|
+
var caps = prov.renderCapabilities();
|
|
604
|
+
var found = false;
|
|
605
|
+
for (var i = 0; i < caps.length; i++) { if (caps[i].name === key) { found = true; break; } }
|
|
606
|
+
if (!found) {
|
|
607
|
+
throw new TypeError('[FrqtlApp.getRenderOption] "' + key + '" is not declared by ' +
|
|
608
|
+
_providerLabel(prov) + ' (declared: [' +
|
|
609
|
+
caps.map(function (c) { return c.name; }).join(', ') + ']).');
|
|
610
|
+
}
|
|
611
|
+
if (key.indexOf(FILTER_KEY_PREFIX) === 0) {
|
|
612
|
+
return this.isFilterActive(key.slice(FILTER_KEY_PREFIX.length));
|
|
613
|
+
}
|
|
614
|
+
return this._renderOptions[key];
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* runner.setFilterActive(name, active) / runner.isFilterActive(name) — the
|
|
619
|
+
* canonical write/read pair for a `filter.<name>` render capability (WS
|
|
620
|
+
* Studio Controls Realignment §7 r2 item 2, the ratified filter re-model).
|
|
621
|
+
* `filter()`/`removeFilter()` are ALREADY-REQUIRED `RendererProvider`
|
|
622
|
+
* contract methods and the canonical MULTI-SLOT name-keyed filter primitive
|
|
623
|
+
* (`Filters.water`/`Filters.bloom` = `{name, glsl, uniforms}`) — this pair
|
|
624
|
+
* is NOT a second write path, it is the uniform accessor `controlBind.js`'s
|
|
625
|
+
* render branch calls for a `filter.` capability instead of tunneling
|
|
626
|
+
* through `setRenderOption` (which has no multi-slot/toggle semantics and
|
|
627
|
+
* cannot express "2 filters concurrently active").
|
|
628
|
+
*
|
|
629
|
+
* @param {string} name — a `Filters` catalog key (e.g. 'water', 'bloom').
|
|
630
|
+
* @param {boolean} active
|
|
631
|
+
* @throws {Error} if no renderer is active, or `name` is not a real
|
|
632
|
+
* `Filters` catalog entry (FAIL-LOUD — never a silent no-op filter call).
|
|
633
|
+
*/
|
|
634
|
+
FrqtlApp.prototype.setFilterActive = function (name, active) {
|
|
635
|
+
var prov = this._getRenderer();
|
|
636
|
+
if (!prov || typeof prov.filter !== 'function' || typeof prov.removeFilter !== 'function') {
|
|
637
|
+
throw new Error('[FrqtlApp.setFilterActive] no active renderer with filter()/' +
|
|
638
|
+
'removeFilter() (RendererProvider REQUIRED methods). (FAIL-LOUD.)');
|
|
639
|
+
}
|
|
640
|
+
var FiltersRef = Filters;
|
|
641
|
+
if (!Object.prototype.hasOwnProperty.call(FiltersRef, name)) {
|
|
642
|
+
throw new Error('[FrqtlApp.setFilterActive] unknown filter "' + name + '" — not a ' +
|
|
643
|
+
'Filters.js catalog entry. (FAIL-LOUD.)');
|
|
644
|
+
}
|
|
645
|
+
if (!this._activeFilters) { this._activeFilters = {}; }
|
|
646
|
+
if (active) {
|
|
647
|
+
prov.filter(FiltersRef[name]);
|
|
648
|
+
this._activeFilters[name] = true;
|
|
649
|
+
} else {
|
|
650
|
+
prov.removeFilter(name);
|
|
651
|
+
delete this._activeFilters[name];
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
/** @param {string} name — a `Filters` catalog key. @returns {boolean} */
|
|
656
|
+
FrqtlApp.prototype.isFilterActive = function (name) {
|
|
657
|
+
return !!(this._activeFilters && this._activeFilters[name]);
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
// ── Render-pipeline TAPS (the RENDER_STAGES contract) — DELEGATED ──────
|
|
661
|
+
//
|
|
662
|
+
// The MECHANISM lives at `lib/frqtl/objects/instruments/capture.js` (THE_MINIMAL_SIM
|
|
663
|
+
// §4): the tap is an INSTRUMENT on the primitive surface — bound to the loop, the
|
|
664
|
+
// provider, the scene identity and the live render-options bag — not an editor concern,
|
|
665
|
+
// and it needs no host and no DOM. This host keeps only the conveniences below, and
|
|
666
|
+
// each is a THIN FORWARD: zero dispatch, zero throw rules, zero provenance stamping
|
|
667
|
+
// here. The reject-not-throw wrapper lives in exactly ONE place (the mechanism), so
|
|
668
|
+
// these forwards deliberately add no try/catch of their own.
|
|
669
|
+
//
|
|
670
|
+
// The stage vocabulary stays with its owner, `lib/shim/render/render-stages` (the shim
|
|
671
|
+
// owns cross-layer vocabularies — peer of frame-data's RENDER_INTENT_KEYS); the
|
|
672
|
+
// instrument ferries it.
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* runner.setSourceRef(ref) — register the experiment/rung identity for the SceneRecord.
|
|
676
|
+
* Forwards to the instrument's identity. See instruments/capture.js.
|
|
677
|
+
* @param {string|null} ref
|
|
678
|
+
*/
|
|
679
|
+
FrqtlApp.prototype.setSourceRef = function (ref) {
|
|
680
|
+
return this._capture.setSourceRef(ref);
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* runner.setSourceSha256(sha) — register the project identity (the code half) for the
|
|
685
|
+
* SceneRecord. Forwards to the instrument. See instruments/capture.js.
|
|
686
|
+
* @param {string|null} sha
|
|
687
|
+
*/
|
|
688
|
+
FrqtlApp.prototype.setSourceSha256 = function (sha) {
|
|
689
|
+
return this._capture.setSourceSha256(sha);
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* runner.sceneRecord() → Promise<SceneRecord + its own content id>. Forwards.
|
|
694
|
+
* @returns {Promise<object>}
|
|
695
|
+
*/
|
|
696
|
+
FrqtlApp.prototype.sceneRecord = function () {
|
|
697
|
+
return this._capture.sceneRecord();
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* THE DIVERGENCE GATE — a physics-registered object that no renderer can see is a
|
|
702
|
+
* THROW, never an invisible body.
|
|
703
|
+
*
|
|
704
|
+
* ── Layer 1: API contract ────────────────────────────────────────────────
|
|
705
|
+
* Called before every draw and before every capture. The world answers which of its
|
|
706
|
+
* LIVE engine registrations carry no JS wrapper (`unwrappedLargeObjectHandles` — the
|
|
707
|
+
* only ledger that can tell a live object from a torn-down one); any survivor means
|
|
708
|
+
* the draw list DERIVED from the world's wrapper census is missing something the
|
|
709
|
+
* physics knows about, and this refuses to draw rather than paint a lie.
|
|
710
|
+
*
|
|
711
|
+
* With the census at the world handle the ordinary case cannot arise — constructing a
|
|
712
|
+
* `LargeObject` registers it for physics AND render in one act. What remains is the
|
|
713
|
+
* RAW `universe.registerLargeObject(spec)` path, which is legitimate in a headless
|
|
714
|
+
* conformance harness (nothing draws) and is exactly the silent-invisible case in a
|
|
715
|
+
* rendered world. So this is the enforcement tooth on the preferred shape, not an
|
|
716
|
+
* alternative to it.
|
|
717
|
+
*
|
|
718
|
+
* The capability check is METHOD PRESENCE (the `move()`/`cameraControl` idiom): a
|
|
719
|
+
* world that publishes no ledger is not a world this host can interrogate, and this
|
|
720
|
+
* host does not invent one.
|
|
721
|
+
*
|
|
722
|
+
* ── Layer 2: physics behaviour ───────────────────────────────────────────
|
|
723
|
+
* ZERO. It reads two ledgers and throws or returns; no frame, dimension, tick or
|
|
724
|
+
* op-program is touched.
|
|
725
|
+
* @private
|
|
726
|
+
*/
|
|
727
|
+
FrqtlApp.prototype._assertRenderCensus = function () {
|
|
728
|
+
var u = this.universe;
|
|
729
|
+
if (!u || typeof u.unwrappedLargeObjectHandles !== 'function') { return; }
|
|
730
|
+
var orphans = u.unwrappedLargeObjectHandles();
|
|
731
|
+
if (orphans.length === 0) { return; }
|
|
732
|
+
throw new Error('[FrqtlApp] ' + orphans.length + ' object(s) are registered with the ' +
|
|
733
|
+
'PHYSICS of this world but absent from its render census — handle(s) [' +
|
|
734
|
+
orphans.join(', ') + ']. They collide and count correctly and CANNOT BE SEEN, ' +
|
|
735
|
+
'which is the one outcome this surface refuses: a silently wrong picture is worse ' +
|
|
736
|
+
'than an error, because what the eye is taught from it is wrong. Construct large ' +
|
|
737
|
+
'objects through `new LargeObject(universe, …)` (or a part that does — Wall, ' +
|
|
738
|
+
'GraphingDetectorWall, Tunnel …), which registers them for physics AND render in ' +
|
|
739
|
+
'one act. The raw `universe.registerLargeObject(spec)` path carries no wrapper and ' +
|
|
740
|
+
'belongs to headless harnesses only. (FAIL-LOUD.)');
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* runner.capture(stage) — the generic render-pipeline tap. Forwards.
|
|
745
|
+
* @param {string} stage — a member of `RenderStages.RENDER_STAGES`.
|
|
746
|
+
* @returns {Promise<object>}
|
|
747
|
+
*/
|
|
748
|
+
FrqtlApp.prototype.capture = function (stage) {
|
|
749
|
+
// A Promise-returning entry point never throws SYNCHRONOUSLY: a mixed error channel
|
|
750
|
+
// guarantees an uncaught exception at some call site that only wrote `.catch()`.
|
|
751
|
+
try { this._assertRenderCensus(); } catch (e) { return Promise.reject(e); }
|
|
752
|
+
return this._capture.capture(stage);
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* runner.captureStill() — the LAST-STAGE CONVENIENCE. Forwards.
|
|
757
|
+
* @returns {Promise<object>}
|
|
758
|
+
*/
|
|
759
|
+
FrqtlApp.prototype.captureStill = function () {
|
|
760
|
+
try { this._assertRenderCensus(); } catch (e) { return Promise.reject(e); }
|
|
761
|
+
return this._capture.captureStill();
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Add a background-image under-layer (shared backdrop helper — Phase 2).
|
|
766
|
+
*
|
|
767
|
+
* Creates and registers a NON-COLLIDING LargeObject that the renderer draws as the
|
|
768
|
+
* bottom-most layer via _layerBackdrop (under particles and large objects).
|
|
769
|
+
*
|
|
770
|
+
* This is the canonical way to add a backdrop in ANY experiment — no per-page
|
|
771
|
+
* LargeObject construction needed. The backdop's .visible is false by default;
|
|
772
|
+
* the caller toggles it on when desired.
|
|
773
|
+
*
|
|
774
|
+
* @param {Object} image - a p5.Graphics, HTMLCanvasElement, or any renderable
|
|
775
|
+
* object the active renderer can draw as a backdrop image.
|
|
776
|
+
* @param {Object} [rect] - optional {left, top, w, h} in physics pixels.
|
|
777
|
+
* Defaults to the full canvas rect.
|
|
778
|
+
* @param {Object} [opts] - optional overrides: { visible (default false),
|
|
779
|
+
* color (default [0,0,0]) }
|
|
780
|
+
* @returns {Object} - the LargeObject created, for further manipulation.
|
|
781
|
+
*/
|
|
782
|
+
FrqtlApp.prototype.addBackdrop = function (image, rect, opts) {
|
|
783
|
+
opts = opts || {};
|
|
784
|
+
var r = rect || {
|
|
785
|
+
left: 0, top: 0,
|
|
786
|
+
w: this.canvas_rect.w,
|
|
787
|
+
h: this.canvas_rect.h
|
|
788
|
+
};
|
|
789
|
+
var color = opts.color || [0, 0, 0];
|
|
790
|
+
var visible = (opts.visible !== undefined) ? opts.visible : false;
|
|
791
|
+
|
|
792
|
+
var _LO = LargeObject;
|
|
793
|
+
var _CT = CollisionTypes;
|
|
794
|
+
|
|
795
|
+
var backdrop = new _LO(
|
|
796
|
+
this.universe,
|
|
797
|
+
[r.left, r.top, r.w, r.h],
|
|
798
|
+
_CT.NONE, [], 'backdrop', 'closest', null, 1, color
|
|
799
|
+
);
|
|
800
|
+
backdrop.backdrop = true;
|
|
801
|
+
backdrop.image = image;
|
|
802
|
+
backdrop.visible = visible;
|
|
803
|
+
|
|
804
|
+
// No render-list call: constructing a LargeObject on this world IS registering it,
|
|
805
|
+
// for physics AND for the renderer (CollisionTypes.NONE makes it draw-only).
|
|
806
|
+
return backdrop;
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
// ── THE DECLARED-OBJECT REGISTRY ─────────────────────────────────────────────────────────
|
|
810
|
+
//
|
|
811
|
+
// From STUDIO's point of view only the declared sugar object in code (not its sub objects)
|
|
812
|
+
// is manipulable, so only what the user proactively declares is registered.
|
|
813
|
+
//
|
|
814
|
+
// `declared` is the ordered list of the handles the user's DECLARING calls produced
|
|
815
|
+
// (`exp.addObject`, a palette drop, a codegen replay — all three funnel through
|
|
816
|
+
// `ObjectFactory.create`, the registry's ONE writer). It is a different fact from the world
|
|
817
|
+
// census (`largeObjects` — everything the world draws and collides): a part's composition is
|
|
818
|
+
// in the census but never declared. "Which declared object does this drawn thing belong to"
|
|
819
|
+
// is answered by walking the ONE up-link `_owner` each composer stamps on what it composed
|
|
820
|
+
// (`declaredOf`). A `new Wall(universe, …)` written directly is the PRIMITIVE surface: in the
|
|
821
|
+
// world, drawn, colliding — not declared, not manipulable.
|
|
822
|
+
|
|
823
|
+
/** A declaration's census LOs: the LOs `ObjectFactory.create` saw it put in the world. */
|
|
824
|
+
function _declaredMembersMap(app) {
|
|
825
|
+
return app._declaredMembers || (app._declaredMembers = new Map());
|
|
826
|
+
}
|
|
827
|
+
/** A declaration's host tick-functions: the ones `ObjectFactory.create` saw its build push. */
|
|
828
|
+
function _declaredTicksMap(app) {
|
|
829
|
+
return app._declaredTicks || (app._declaredTicks = new Map());
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* runner.declared — the declared objects, in declaration order. Read-only by contract
|
|
834
|
+
* (written only by `declare`, emptied only by `clearDeclared`). THROWS when an entry's census
|
|
835
|
+
* LOs are no longer in the world: the world was re-inited by a path that did not clear the
|
|
836
|
+
* registry, and a dead handle would otherwise sit in a Layers row silently.
|
|
837
|
+
* @returns {Array}
|
|
838
|
+
*/
|
|
839
|
+
Object.defineProperty(FrqtlApp.prototype, 'declared', {
|
|
840
|
+
configurable: true,
|
|
841
|
+
get: function () {
|
|
842
|
+
var list = this._declared || (this._declared = []);
|
|
843
|
+
if (!list.length) { return list; }
|
|
844
|
+
var census = new Set(this.largeObjects);
|
|
845
|
+
var members = _declaredMembersMap(this);
|
|
846
|
+
for (var i = 0; i < list.length; i++) {
|
|
847
|
+
var ms = members.get(list[i]) || [];
|
|
848
|
+
for (var j = 0; j < ms.length; j++) {
|
|
849
|
+
if (!census.has(ms[j])) {
|
|
850
|
+
throw new Error('[FrqtlApp.declared] stale declaration — "' +
|
|
851
|
+
(list[i].name || list[i]._schemaKind) + '" lost its census LO "' + (ms[j].name || '?') +
|
|
852
|
+
'": the world was re-inited without clearing the registry (clearDeclared runs at ' +
|
|
853
|
+
'FrqtlApp.setup and the RecipeContext co-init exp.createCanvas / exp.createScene — the ' +
|
|
854
|
+
'world-init paths). (FAIL-LOUD.)');
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return list;
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* declare(handle, members, tickFns) — record a declared object. Called ONLY by
|
|
864
|
+
* `ObjectFactory.create` (the one funnel of every declaring call; a static gate holds it to one
|
|
865
|
+
* call site). `members` are the census LOs the declaration put in the world; each must resolve
|
|
866
|
+
* (`declaredOf`) to `handle` — a part that forgot to stamp `_owner` on what it composed
|
|
867
|
+
* THROWS here instead of leaking an unresolvable LO into the scene. `tickFns` are the host
|
|
868
|
+
* tick-functions the build pushed (an emitter's emit fn, an absorb radiator): what the
|
|
869
|
+
* declaration owns besides what it draws. THROWS on a re-declaration.
|
|
870
|
+
* @param {Object} handle @param {Array} members @param {Array} [tickFns] @returns {Object} handle
|
|
871
|
+
*/
|
|
872
|
+
FrqtlApp.prototype.declare = function (handle, members, tickFns) {
|
|
873
|
+
if (!handle || typeof handle !== 'object') {
|
|
874
|
+
throw new TypeError('[FrqtlApp.declare] a declared object is a handle, got ' + typeof handle + '. (FAIL-LOUD.)');
|
|
875
|
+
}
|
|
876
|
+
var list = this._declared || (this._declared = []);
|
|
877
|
+
if (list.indexOf(handle) !== -1) {
|
|
878
|
+
throw new Error('[FrqtlApp.declare] "' + (handle.name || handle._schemaKind) + '" is already declared. ' +
|
|
879
|
+
'(FAIL-LOUD.)');
|
|
880
|
+
}
|
|
881
|
+
list.push(handle);
|
|
882
|
+
var ms = (members || []).slice();
|
|
883
|
+
_declaredMembersMap(this).set(handle, ms);
|
|
884
|
+
_declaredTicksMap(this).set(handle, (tickFns || []).slice());
|
|
885
|
+
for (var i = 0; i < ms.length; i++) {
|
|
886
|
+
if (this.declaredOf(ms[i]) !== handle) {
|
|
887
|
+
list.pop();
|
|
888
|
+
_declaredMembersMap(this).delete(handle);
|
|
889
|
+
_declaredTicksMap(this).delete(handle);
|
|
890
|
+
throw new Error('[FrqtlApp.declare] "' + (handle.name || handle._schemaKind) + '" put the LO "' +
|
|
891
|
+
(ms[i].name || '?') + '" in the world without an `_owner` chain ending at it — a composer ' +
|
|
892
|
+
'stamps ONE up-link on what it composed, so every drawn thing resolves to its declared ' +
|
|
893
|
+
'object. (FAIL-LOUD.)');
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return handle;
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
/** clearDeclared() — empty the registry. Called where the world re-inits (FrqtlApp.setup, and the
|
|
900
|
+
* RecipeContext co-init exp.createCanvas / exp.createScene): a fresh world has no declared objects. */
|
|
901
|
+
FrqtlApp.prototype.clearDeclared = function () {
|
|
902
|
+
this._declared = [];
|
|
903
|
+
this._declaredMembers = new Map();
|
|
904
|
+
this._declaredTicks = new Map();
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* declaredOf(x) → the declared object `x` IS or belongs to, else null. Walks the ONE up-link
|
|
909
|
+
* `_owner` (stamped by the part that composed x) until it reaches a registry member; null when
|
|
910
|
+
* the chain ends outside the registry (an undeclared, primitive-surface object).
|
|
911
|
+
*/
|
|
912
|
+
FrqtlApp.prototype.declaredOf = function (x) {
|
|
913
|
+
var list = this._declared || [];
|
|
914
|
+
for (var cur = x, hops = 0; cur && hops < 64; cur = cur._owner, hops++) {
|
|
915
|
+
if (list.indexOf(cur) !== -1) { return cur; }
|
|
916
|
+
}
|
|
917
|
+
return null;
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
/** membersOf(declared) → the census LOs that declaration put in the world (what draws it). */
|
|
921
|
+
FrqtlApp.prototype.membersOf = function (d) {
|
|
922
|
+
return (_declaredMembersMap(this).get(d) || []).slice();
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* undeclaredTickFunctions() → the host tick-functions running in the world that NO declared
|
|
927
|
+
* object owns: a setup factory's emitter, a page's own regulation fn, a sketch's
|
|
928
|
+
* exp.addTickFunction. They are behaviour the registry cannot write down, so whatever writes the
|
|
929
|
+
* world as declarations (codegen) refuses while any exists.
|
|
930
|
+
* @returns {Function[]}
|
|
931
|
+
*/
|
|
932
|
+
FrqtlApp.prototype.undeclaredTickFunctions = function () {
|
|
933
|
+
var fns = (this.universe && Array.isArray(this.universe.experimentTickFunctions))
|
|
934
|
+
? this.universe.experimentTickFunctions : [];
|
|
935
|
+
var owned = new Set();
|
|
936
|
+
_declaredTicksMap(this).forEach(function (ts) { for (var i = 0; i < ts.length; i++) { owned.add(ts[i]); } });
|
|
937
|
+
return fns.filter(function (fn) { return !owned.has(fn); });
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* removeObject(declared) — delete a DECLARED object: drop it from the registry and call its own
|
|
942
|
+
* `dispose()` (each part tears down what it composed — a Tunnel unsubscribes its redirect and
|
|
943
|
+
* disposes its plates and gate, a Wall drops its absorb radiator, a SphereEmitter its emit
|
|
944
|
+
* tick-fn and its marker). A sub-object THROWS naming its declared owner; an undeclared object
|
|
945
|
+
* THROWS naming the primitive surface. DISPOSE IS THE REMOVAL: an LO's dispose leaves the world
|
|
946
|
+
* census the renderer draws from, and any census LO the declaration put in the world that is
|
|
947
|
+
* still there afterwards THROWS.
|
|
948
|
+
* @param {Object} obj — a member of `runner.declared`
|
|
949
|
+
*/
|
|
950
|
+
FrqtlApp.prototype.removeObject = function (obj) {
|
|
951
|
+
var list = this.declared;
|
|
952
|
+
var i = list.indexOf(obj);
|
|
953
|
+
if (i === -1) {
|
|
954
|
+
var owner = this.declaredOf(obj);
|
|
955
|
+
if (owner) {
|
|
956
|
+
throw new Error('[FrqtlApp.removeObject] "' + (obj && obj.name) + '" is part of the declared ' +
|
|
957
|
+
(owner._schemaKind || 'object') + ' "' + owner.name + '" — delete "' + owner.name +
|
|
958
|
+
'", not its part. (FAIL-LOUD.)');
|
|
959
|
+
}
|
|
960
|
+
throw new Error('[FrqtlApp.removeObject] "' + (obj && (obj.name || obj._schemaKind)) + '" is not a ' +
|
|
961
|
+
'declared object (runner.declared) — only what a declaring call (exp.addObject, a palette drop) ' +
|
|
962
|
+
'produced is removed here; a primitive-surface object is disposed by whoever built it ' +
|
|
963
|
+
'(obj.dispose()). (FAIL-LOUD.)');
|
|
964
|
+
}
|
|
965
|
+
if (typeof obj.dispose !== 'function') {
|
|
966
|
+
throw new Error('[FrqtlApp.removeObject] the declared ' + (obj._schemaKind || 'object') + ' "' + obj.name +
|
|
967
|
+
'" has no dispose() — a part tears down what it composed. (FAIL-LOUD.)');
|
|
968
|
+
}
|
|
969
|
+
var members = this.membersOf(obj);
|
|
970
|
+
var ticks = (_declaredTicksMap(this).get(obj) || []).slice();
|
|
971
|
+
list.splice(i, 1);
|
|
972
|
+
_declaredMembersMap(this).delete(obj);
|
|
973
|
+
_declaredTicksMap(this).delete(obj);
|
|
974
|
+
obj.dispose();
|
|
975
|
+
var running = (this.universe && Array.isArray(this.universe.experimentTickFunctions))
|
|
976
|
+
? this.universe.experimentTickFunctions : [];
|
|
977
|
+
var leftTicks = ticks.filter(function (fn) { return running.indexOf(fn) !== -1; });
|
|
978
|
+
if (leftTicks.length) {
|
|
979
|
+
throw new Error('[FrqtlApp.removeObject] disposing "' + obj.name + '" left ' + leftTicks.length +
|
|
980
|
+
' of its tick-functions running — its dispose() must tear down everything it composed. (FAIL-LOUD.)');
|
|
981
|
+
}
|
|
982
|
+
var census = new Set(this.largeObjects);
|
|
983
|
+
var left = members.filter(function (m) { return census.has(m); });
|
|
984
|
+
if (left.length) {
|
|
985
|
+
throw new Error('[FrqtlApp.removeObject] disposing "' + obj.name + '" left ' + left.length +
|
|
986
|
+
' of its LOs in the world (' + left.map(function (m) { return m.name; }).join(', ') +
|
|
987
|
+
') — its dispose() must tear down everything it composed. (FAIL-LOUD.)');
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* getObjectByName(name, predicate?) → the DECLARED object of that name (passing `predicate` when
|
|
993
|
+
* given), else null — the lazy by-name reference every cross-object link that must survive a
|
|
994
|
+
* Restart uses (an emitter's gateCounter, a LiveChart source). Handles are fresh at registration
|
|
995
|
+
* and never survive codegen, so the reference is a NAME resolved against the registry at use
|
|
996
|
+
* time. The CALLER decides whether null is FAIL-LOUD (the emission gate throws; a LiveChart
|
|
997
|
+
* retries next scan).
|
|
998
|
+
* @param {string} name @param {Function} [predicate] @returns {Object|null}
|
|
999
|
+
*/
|
|
1000
|
+
FrqtlApp.prototype.getObjectByName = function (name, predicate) {
|
|
1001
|
+
if (typeof name !== 'string' || !name) { return null; }
|
|
1002
|
+
var list = this.declared;
|
|
1003
|
+
for (var i = 0; i < list.length; i++) {
|
|
1004
|
+
var d = list[i];
|
|
1005
|
+
if (d && d.name === name && (!predicate || predicate(d))) { return d; }
|
|
1006
|
+
}
|
|
1007
|
+
return null;
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Fan a RENDER-ONLY property write out to the parts a composite composed.
|
|
1012
|
+
*
|
|
1013
|
+
* ── Layer 1: API contract ────────────────────────────────────────────────
|
|
1014
|
+
* `owner` is a composite (Tunnel / LogicGate / Box / OvenBox …). Its DIRECT children are the
|
|
1015
|
+
* things whose ONE up-link `_owner` is `owner` — found by walking each census LO up its
|
|
1016
|
+
* `_owner` chain to the node just below `owner` (one hop below the composite). Each child
|
|
1017
|
+
* receives the SAME write through the SAME accessor, so its own kind branch (a Wall facade
|
|
1018
|
+
* delegating to its LO, a nested composite fanning out again) applies unchanged. A child whose
|
|
1019
|
+
* descriptor does not declare the prop is skipped (a heterogeneous composite is legal); a
|
|
1020
|
+
* non-composite owns nothing and this is a no-op.
|
|
1021
|
+
*
|
|
1022
|
+
* ── Layer 2: physics behavior ────────────────────────────────────────────
|
|
1023
|
+
* None. Only `engineVisible:false` (render-only) props reach here — colour,
|
|
1024
|
+
* opacity, visibility. Zero engine writes, zero op-program recompiles, so a
|
|
1025
|
+
* bench golden cannot move (SU1).
|
|
1026
|
+
*
|
|
1027
|
+
* @param {Object} runner — the FrqtlApp owning the object list.
|
|
1028
|
+
* @param {Object} owner — the wrapper the write was addressed to.
|
|
1029
|
+
* @param {string} prop — a render-only field name.
|
|
1030
|
+
* @param {*} value — the new value.
|
|
1031
|
+
*/
|
|
1032
|
+
function _fanOutRenderPropToGroup(runner, owner, prop, value) {
|
|
1033
|
+
var list = runner.largeObjects;
|
|
1034
|
+
if (!owner || !list || list.length === 0) { return; }
|
|
1035
|
+
var schema = ObjectSchema;
|
|
1036
|
+
var children = [];
|
|
1037
|
+
for (var i = 0; i < list.length; i++) {
|
|
1038
|
+
for (var cur = list[i], hops = 0; cur && cur._owner && hops < 64; cur = cur._owner, hops++) {
|
|
1039
|
+
if (cur._owner === owner) {
|
|
1040
|
+
if (children.indexOf(cur) === -1) { children.push(cur); }
|
|
1041
|
+
break;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
for (var c = 0; c < children.length; c++) {
|
|
1046
|
+
var child = children[c];
|
|
1047
|
+
if (schema) {
|
|
1048
|
+
var childDesc = schema.get(child._schemaKind || child.kind || 'LargeObject');
|
|
1049
|
+
var declared = false;
|
|
1050
|
+
if (childDesc) {
|
|
1051
|
+
for (var f = 0; f < childDesc.fields.length; f++) {
|
|
1052
|
+
if (childDesc.fields[f].name === prop) { declared = true; break; }
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
if (!declared) { continue; }
|
|
1056
|
+
}
|
|
1057
|
+
runner.updateObjectProperty(child, prop, value);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Uniform schema-driven mutation entry point (Phase 4 — runner level).
|
|
1063
|
+
*
|
|
1064
|
+
* ONE public method the IDE uses to edit ANY property of ANY object.
|
|
1065
|
+
* Routing:
|
|
1066
|
+
* engineVisible:false (color, visible, alpha, name, ...) →
|
|
1067
|
+
* mutate the JS-side wrapper directly (render layer; no engine ABI).
|
|
1068
|
+
* engineVisible:true (rect, collision_type, source_rects, ...) →
|
|
1069
|
+
* forward to universe.updateObjectProperty (engine-concurrence path).
|
|
1070
|
+
*
|
|
1071
|
+
* Validation: prop MUST be in the schema for kind → TypeError (FAIL LOUD).
|
|
1072
|
+
*
|
|
1073
|
+
* @param {Object} lo - LargeObject / QuantumEmitter wrapper with .handle and .kind
|
|
1074
|
+
* @param {string} prop - Field name declared in ObjectSchema for lo's kind
|
|
1075
|
+
* @param {*} value - New value
|
|
1076
|
+
* @throws {TypeError} - If prop is unknown / readonly for the object's kind
|
|
1077
|
+
*/
|
|
1078
|
+
FrqtlApp.prototype.updateObjectProperty = function (lo, prop, value) {
|
|
1079
|
+
if (!lo) return;
|
|
1080
|
+
|
|
1081
|
+
// Every shipped object class STAMPS `_schemaKind` (LargeObject, QuantumEmitter,
|
|
1082
|
+
// PointSource, Wall, DetectorWall, SphereEmitter, …), so the
|
|
1083
|
+
// old `|| 'LargeObject'` fallback never described a real object — it only fired for
|
|
1084
|
+
// a handle that is not one of them. Defaulting there turned "I cannot identify this
|
|
1085
|
+
// object" into "Property X is not in the schema for kind LargeObject", which blames
|
|
1086
|
+
// the SCHEMA for what is actually an unidentified caller argument and sends the
|
|
1087
|
+
// reader to the wrong owner entirely. Identify the failure honestly instead.
|
|
1088
|
+
var kind = lo._schemaKind || lo.kind;
|
|
1089
|
+
if (!kind) {
|
|
1090
|
+
throw new TypeError(
|
|
1091
|
+
'[FrqtlApp.updateObjectProperty] this object carries no `_schemaKind` ' +
|
|
1092
|
+
'(nor `kind`), so its descriptor cannot be resolved — property "' + prop + '" was ' +
|
|
1093
|
+
'not rejected by any schema. Every shipped object class stamps `_schemaKind`; a ' +
|
|
1094
|
+
'handle without one is usually the wrong handle (e.g. a raw emitter handle rather ' +
|
|
1095
|
+
'than the palette object that wraps it).'
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// ── Schema lookup ─────────────────────────────────────────────────────
|
|
1100
|
+
var schema = ObjectSchema;
|
|
1101
|
+
|
|
1102
|
+
var fieldDef = null;
|
|
1103
|
+
if (schema) {
|
|
1104
|
+
var descriptor = schema.get(kind);
|
|
1105
|
+
if (!descriptor) {
|
|
1106
|
+
throw new TypeError(
|
|
1107
|
+
'[FrqtlApp.updateObjectProperty] Unknown object kind "' + kind + '"'
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
for (var i = 0; i < descriptor.fields.length; i++) {
|
|
1111
|
+
if (descriptor.fields[i].name === prop) {
|
|
1112
|
+
fieldDef = descriptor.fields[i];
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
if (!fieldDef) {
|
|
1117
|
+
throw new TypeError(
|
|
1118
|
+
'[FrqtlApp.updateObjectProperty] Property "' + prop +
|
|
1119
|
+
'" is not in the schema for kind "' + kind + '"'
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
if (fieldDef.type === 'readonly') {
|
|
1123
|
+
throw new TypeError(
|
|
1124
|
+
'[FrqtlApp.updateObjectProperty] Property "' + prop +
|
|
1125
|
+
'" is readonly for kind "' + kind + '"'
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
// ── Declared-type normalization (THE type gate) ───────────────────
|
|
1129
|
+
// The descriptor declares the TYPE; the accessor is the one validating gate,
|
|
1130
|
+
// so this is where the written value is made to match it — or REJECTED. The
|
|
1131
|
+
// semantics live with the declarations, at the descriptor owner
|
|
1132
|
+
// (lib/frqtl/objects/schema `normalizeValue`), and are FERRIED here: a
|
|
1133
|
+
// boolean 0/1 becomes a real boolean (every consumer tests IDENTITY — a
|
|
1134
|
+
// stored `0` reads as "still visible" and the write silently does nothing);
|
|
1135
|
+
// a wrong-typed value (a `'42'` string, a non-array `color`, a non-array
|
|
1136
|
+
// `rect` that would reach `lo.rect.w = r[2]` as NaN geometry, an undeclared
|
|
1137
|
+
// enum member) THROWS here instead of being stored and poisoning a reader
|
|
1138
|
+
// far away. Throw-or-apply, one call, every caller.
|
|
1139
|
+
value = schema.normalizeValue(fieldDef, value, kind);
|
|
1140
|
+
|
|
1141
|
+
// ── SphereEmitter — sugar; no engine handle ───────────────────────
|
|
1142
|
+
// A SphereEmitter's real state lives in the setupSphereEmitter3D control
|
|
1143
|
+
// closure (a host emit tick-fn), not on any engine object. Route EVERY
|
|
1144
|
+
// editable/adapter prop to the matching control setter via the facade's
|
|
1145
|
+
// own write-router (applyProp) — mechanism-A: the Inspector and the visual
|
|
1146
|
+
// marker's coupled-move call THIS, never resolveBind (which is why the
|
|
1147
|
+
// SphereEmitter kind sits outside controlBind's bind-executable allowlist).
|
|
1148
|
+
// `rect` is the input adapter (rect-centre → control.setCenter).
|
|
1149
|
+
// Return BEFORE the generic engineVisible mirror-write below (which would
|
|
1150
|
+
// assign to a getter-only prop and throw). No universe delegation — the
|
|
1151
|
+
// sugar IS the engine-facing write.
|
|
1152
|
+
// ── A WALL's rect — the wall's ONE position write (Wall.setRect) ─────────
|
|
1153
|
+
// A Wall-family facade (Wall, GraphingDetectorWall — `_wallFacadeOf`, structural): the
|
|
1154
|
+
// rect IS the wall's rect, so the write lands on setRect, which keeps the true slab, the
|
|
1155
|
+
// drawn rect, the engine broad rect + backing frame and an absorber's exit in step.
|
|
1156
|
+
var wallFacade = _wallFacadeOf(lo);
|
|
1157
|
+
if (prop === 'rect' && wallFacade) {
|
|
1158
|
+
wallFacade.setRect(value);
|
|
1159
|
+
if (typeof this._onObjectMoved === 'function') { this._onObjectMoved(wallFacade, prop); }
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
var V2_WALL_PROPS = ['collisionBehavior', 'bounceDirection', 'decoherence',
|
|
1164
|
+
'hooks', 'markTag', 'countFilter', 'capacity', 'orientationDeg', 'pitchDeg'];
|
|
1165
|
+
|
|
1166
|
+
// ── Wall (W1) — sugar facade over ONE LargeObject (lo.lo) ─────────
|
|
1167
|
+
// Any Wall-family facade (_wallFacadeOf), for the fields the Wall descriptor declares. A
|
|
1168
|
+
// subclass's OWN declared fields (a GraphingDetectorWall's binPx/refreshMs/heatBase)
|
|
1169
|
+
// fall through to the generic path, as before.
|
|
1170
|
+
// A lexicon edit RECOMPILES the running op-program (the "flip a wall into a
|
|
1171
|
+
// detector mid-run" live path — Wall.setV2Prop). Every other
|
|
1172
|
+
// prop (rect/color/alpha/visible/layer/shellShape) is DELEGATED DOWN to the
|
|
1173
|
+
// inner LargeObject via the SAME accessor, so its engine-visible rect/layer
|
|
1174
|
+
// push + render-only mirror logic applies UNCHANGED (the inner LO keeps its
|
|
1175
|
+
// own _schemaKind 'LargeObject'). Return before the generic paths below
|
|
1176
|
+
// (which would hit the facade's read-only rect getter and throw).
|
|
1177
|
+
if (wallFacade === lo && _isWallField(schema, prop)) {
|
|
1178
|
+
// Collision LEXICON v2: the nested behavior/direction/modifier/hook props
|
|
1179
|
+
// live on the FACADE (updated in place so the Inspector's visibleWhen re-read
|
|
1180
|
+
// reveals dependent fields immediately) and recompile the running op-program
|
|
1181
|
+
// via setV2Prop. It is the ONE wall write path.
|
|
1182
|
+
// R2AX (two-axis mirror, 2026-07-26): pitchDeg is a FACADE v2 prop,
|
|
1183
|
+
// exactly like orientationDeg — its live edit re-bakes the 3-space
|
|
1184
|
+
// reflection via the SAME setV2Prop recompile path (Wall.setPitch). It
|
|
1185
|
+
// MUST be listed here or it falls to the `lo.lo` delegate below and
|
|
1186
|
+
// recurses into the inner LargeObject, whose schema has no pitchDeg →
|
|
1187
|
+
// a spurious "not in the schema for kind LargeObject" throw on a live
|
|
1188
|
+
// edit that would otherwise land. One facade path, no schema mirror.
|
|
1189
|
+
if (V2_WALL_PROPS.indexOf(prop) !== -1) {
|
|
1190
|
+
if (typeof lo.setV2Prop === 'function') { lo.setV2Prop(prop, value); }
|
|
1191
|
+
} else if (lo.lo) {
|
|
1192
|
+
this.updateObjectProperty(lo.lo, prop, value);
|
|
1193
|
+
}
|
|
1194
|
+
if (prop === 'rect' && typeof this._onObjectMoved === 'function') {
|
|
1195
|
+
this._onObjectMoved(lo, prop);
|
|
1196
|
+
}
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// ── Tunnel (D5) — composite sugar over two Walls + the exit gate ──
|
|
1201
|
+
// Its live SUGAR props (the exit-gate flags) recompile the gate's op-program via
|
|
1202
|
+
// the facade's own write-router. Without this hop the write fell through to the
|
|
1203
|
+
// generic mirror-write below, setting a DEAD property on the wrapper while the
|
|
1204
|
+
// gate program stayed as it was — the Inspector flag flipped and nothing happened.
|
|
1205
|
+
// The router returns FALSE for props it does not own (`color`), which deliberately
|
|
1206
|
+
// falls through to the group fan-out that paints both mirror walls.
|
|
1207
|
+
if (kind === 'Tunnel' && typeof lo.applyProp === 'function') {
|
|
1208
|
+
if (lo.applyProp(prop, value)) {
|
|
1209
|
+
// A position write (the envelope, or the bore origin) moved the whole corridor:
|
|
1210
|
+
// the same object-moved event a Wall's rect write fires (overlay repaint).
|
|
1211
|
+
if ((prop === 'rect' || prop === 'left' || prop === 'top') &&
|
|
1212
|
+
typeof this._onObjectMoved === 'function') { this._onObjectMoved(lo, prop); }
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (kind === 'SphereEmitter') {
|
|
1218
|
+
if (typeof lo.applyProp === 'function') { lo.applyProp(prop, value); }
|
|
1219
|
+
// F5: a position write (a grab carry / programmatic center or rect-adapter)
|
|
1220
|
+
// fires the object-moved event so a host (Studio's overlay) can REPAINT — PR-1
|
|
1221
|
+
// made the bbox DATA fresh, but the flat overlay only redraws on discrete events;
|
|
1222
|
+
// this EXTENDS the event set (never a poll). rect/center are the position props.
|
|
1223
|
+
if ((prop === 'center' || prop === 'rect') && typeof this._onObjectMoved === 'function') {
|
|
1224
|
+
this._onObjectMoved(lo, prop);
|
|
1225
|
+
}
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// ── Render-only (engineVisible:false) ─────────────────────────────
|
|
1230
|
+
// Mutate the JS-side wrapper object directly. The renderer reads from it
|
|
1231
|
+
// on the next draw frame. No engine ABI — physics is not affected.
|
|
1232
|
+
if (!fieldDef.engineVisible) {
|
|
1233
|
+
// Studio Controls Realignment S4:
|
|
1234
|
+
// collision_type/actions are SUGAR-INPUT fields compiled to an
|
|
1235
|
+
// op-program at REGISTRATION (actionCompiler.js) — a bare mirror-write
|
|
1236
|
+
// (what every OTHER engineVisible:false prop gets) would only update
|
|
1237
|
+
// this wrapper's DISPLAY value, never retarget the RUNNING op-program
|
|
1238
|
+
// (a wall flipped to a detector mid-run would keep acting like a
|
|
1239
|
+
// wall). Route these two through the recompile-and-push helper
|
|
1240
|
+
// instead — "flipping a wall into a detector mid-run" made live.
|
|
1241
|
+
if ((kind === 'LargeObject' || kind === 'backdrop') &&
|
|
1242
|
+
(prop === 'collision_type' || prop === 'actions') &&
|
|
1243
|
+
typeof lo.recompileActions === 'function') {
|
|
1244
|
+
var recompileOpts = {};
|
|
1245
|
+
recompileOpts[prop === 'collision_type' ? 'collisionType' : 'actions'] = value;
|
|
1246
|
+
lo.recompileActions(recompileOpts);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
lo[prop] = value;
|
|
1250
|
+
// ── GROUP FAN-OUT (throw-or-apply, never a silent no-op) ───────
|
|
1251
|
+
// A composite wrapper (Box / Tunnel / OvenBox / SlitScreen) owns no
|
|
1252
|
+
// drawn body of its own: it DECLARES a render-only prop ("Render
|
|
1253
|
+
// color for both mirror walls") whose effect lives on the walls it
|
|
1254
|
+
// built. The bare mirror-write above would set a dead property on
|
|
1255
|
+
// the wrapper and change nothing on screen — the exact silent no-op
|
|
1256
|
+
// the accessor contract forbids. So a render-only write on a wrapper
|
|
1257
|
+
// ALSO fans out to the parts it composed, via the SAME accessor.
|
|
1258
|
+
// Ownership is the ONE up-link `_owner` every composer stamps on what
|
|
1259
|
+
// it composed (schema design rule 6) — never a per-kind child list
|
|
1260
|
+
// here (that would be the schema mirror). A non-composite owns nothing.
|
|
1261
|
+
_fanOutRenderPropToGroup(this, lo, prop, value);
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// ── Engine-visible (engineVisible:true) ───────────────────────────────
|
|
1267
|
+
// A 'rect' write on an object that carries a drawn rect lands on the object's OWN
|
|
1268
|
+
// `setRect` — the ONE writer of its drawn rect AND its engine rect (LargeObject.setRect:
|
|
1269
|
+
// the form check, the w/h-then-left/top order, the engine push). The accessor does not
|
|
1270
|
+
// re-implement it. (Kinds whose rect is an adapter with no drawn rect of their own —
|
|
1271
|
+
// PointSource, QuantumEmitter — carry no `.rect` and take their own branches below.)
|
|
1272
|
+
if (prop === 'rect' && lo.rect) {
|
|
1273
|
+
if (typeof lo.setRect !== 'function') {
|
|
1274
|
+
throw new TypeError('[FrqtlApp.updateObjectProperty] "' + (lo.name || kind) + '" (' + kind +
|
|
1275
|
+
') carries a rect but no setRect — its rect has no writer, so a rect write cannot land. ' +
|
|
1276
|
+
'(FAIL-LOUD.)');
|
|
1277
|
+
}
|
|
1278
|
+
lo.setRect(value);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// ── Mirror engine-visible scalar props onto the JS wrapper (Bug #1/#2 fix) ─
|
|
1283
|
+
// The JS wrapper (lo) is the read/serialize source of truth for the inspector
|
|
1284
|
+
// and serializeDesign. On WASM the engine state lives in Rust; the JS wrapper
|
|
1285
|
+
// is a SHADOW. Without this mirror, lo.collision_type / lo.actions / lo.layer /
|
|
1286
|
+
// lo.reposition all stay at CONSTRUCTOR values after a call,
|
|
1287
|
+
// so Save→Restart silently drops every edit to those fields.
|
|
1288
|
+
//
|
|
1289
|
+
// Rule: for every engineVisible scalar prop (not 'rect' — handled via Rectangle
|
|
1290
|
+
// setters above; not 'source_rects' — handled by the emitter path), write
|
|
1291
|
+
// lo[prop] = value NOW so inspector and serializeDesign always read live state.
|
|
1292
|
+
// This mirrors what the render-only path already does for engineVisible:false props.
|
|
1293
|
+
if (prop !== 'rect' && prop !== 'source_rects') {
|
|
1294
|
+
if (prop === 'actions' && (kind === 'LargeObject' || kind === 'backdrop')) {
|
|
1295
|
+
// 'actions' maps to arr_collision_actions on the JS LargeObject wrapper.
|
|
1296
|
+
var actArr = Array.isArray(value) ? value.slice() : [value];
|
|
1297
|
+
lo.arr_collision_actions = actArr;
|
|
1298
|
+
lo.actions = actArr; // keep schema name 'actions' in sync too
|
|
1299
|
+
} else if (prop === 'layer' && (kind === 'LargeObject' || kind === 'backdrop')) {
|
|
1300
|
+
// 'layer' → observedDimensions in the engine. null = all dimensions.
|
|
1301
|
+
lo.layer = (value !== null && value !== undefined) ? value : null;
|
|
1302
|
+
lo.observedDimensions = (value !== null && value !== undefined) ? [value] : [];
|
|
1303
|
+
} else {
|
|
1304
|
+
lo[prop] = value;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// ── PointSource rect: reposition all angle emitters ─────────────────
|
|
1309
|
+
// PointSource is a user-space compound object (N engine emitters, one per angle).
|
|
1310
|
+
// Engine updateObjectProperty does not know about PointSource — handle it here.
|
|
1311
|
+
if (kind === 'PointSource' && prop === 'rect') {
|
|
1312
|
+
if (typeof lo.repositionToRect === 'function') {
|
|
1313
|
+
lo.repositionToRect(value[0], value[1], value[2], value[3], this.universe);
|
|
1314
|
+
}
|
|
1315
|
+
// PointSource has no single engine handle — skip the universe delegation below.
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// ── QuantumEmitter rect: reposition the single canonical source rect ────
|
|
1320
|
+
// QuantumEmitter is engine-agnostic sugar (G-EMIT-2) — its real state is
|
|
1321
|
+
// emState.sourceRects, mutated directly (no provider ABI to delegate to;
|
|
1322
|
+
// the RETIRED updateEmitterSourceRects no longer exists). Mirrors the
|
|
1323
|
+
// PointSource branch above — this is the "composite emitter move" fix
|
|
1324
|
+
// (design §2.2/§2.5.5): before this branch existed, a rect write on a
|
|
1325
|
+
// QuantumEmitter passed schema validation then silently no-oped (the
|
|
1326
|
+
// universe-delegation below has no LargeObject/backdrop branch for it).
|
|
1327
|
+
if (kind === 'QuantumEmitter' && prop === 'rect') {
|
|
1328
|
+
if (typeof lo.setSourceRect === 'function') {
|
|
1329
|
+
lo.setSourceRect(value);
|
|
1330
|
+
}
|
|
1331
|
+
return; // no universe delegation — the sugar IS the engine-facing write.
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// Delegate to universe for the engine-side update.
|
|
1335
|
+
if (this.universe && typeof this.universe.updateObjectProperty === 'function') {
|
|
1336
|
+
this.universe.updateObjectProperty(lo.handle, kind, prop, value);
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1340
|
+
// ── Declared-object CAPABILITIES (what the pick, the aim and the carry ask of it) ────────
|
|
1341
|
+
//
|
|
1342
|
+
// A declared object's position authority is asked of the object itself, in ONE order that
|
|
1343
|
+
// holds for every part: centre-bearing (`getCenter`/`setCenter` — an emitter), else
|
|
1344
|
+
// rect-positioned (`rect` + `setRect` — a wall), else none (the device REFUSES loudly).
|
|
1345
|
+
// Its heading authority is its own `setOrientation`, when it exposes one.
|
|
1346
|
+
|
|
1347
|
+
/** The centre of a centre-bearing declared object, else null. */
|
|
1348
|
+
FrqtlApp.prototype._centerOf = function (d) {
|
|
1349
|
+
if (!d || typeof d.getCenter !== 'function' || typeof d.setCenter !== 'function') { return null; }
|
|
1350
|
+
var c = d.getCenter();
|
|
1351
|
+
return Array.isArray(c) ? c : null;
|
|
1352
|
+
};
|
|
1353
|
+
/** The declared object itself when its rect is its position authority (rect + setRect), else null. */
|
|
1354
|
+
FrqtlApp.prototype._rectAuthorityOf = function (d) {
|
|
1355
|
+
return (d && d.rect && typeof d.setRect === 'function') ? d : null;
|
|
1356
|
+
};
|
|
1357
|
+
/**
|
|
1358
|
+
* positionAuthorityOf(d) → 'center' | 'rect' | null — how a declared object is placed, in the ONE
|
|
1359
|
+
* capability order every device uses (the SPACE carry, the 2D drag). null means no device can
|
|
1360
|
+
* move it, and the device refuses loudly with `noPositionAuthorityMessage(d)`.
|
|
1361
|
+
*/
|
|
1362
|
+
FrqtlApp.prototype.positionAuthorityOf = function (d) {
|
|
1363
|
+
if (this._centerOf(d)) { return 'center'; }
|
|
1364
|
+
if (this._rectAuthorityOf(d)) { return 'rect'; }
|
|
1365
|
+
return null;
|
|
1366
|
+
};
|
|
1367
|
+
/** The refusal text for a declared object with no position authority (one wording, every device). */
|
|
1368
|
+
FrqtlApp.prototype.noPositionAuthorityMessage = function (d) {
|
|
1369
|
+
return '"' + (d && d.name) + '" (' + ((d && d._schemaKind) || 'object') + ') has no position ' +
|
|
1370
|
+
'authority — neither a centre (getCenter/setCenter) nor a rect (rect + setRect) — so it cannot ' +
|
|
1371
|
+
'be moved as a unit.';
|
|
1372
|
+
};
|
|
1373
|
+
/**
|
|
1374
|
+
* The refusal for anything that is not a member of the declared registry: what a scene declared
|
|
1375
|
+
* (ObjectFactory.create — exp.addObject / a palette drop) is the ONLY manipulable set, for every
|
|
1376
|
+
* device (the camera carry, the mouse, a name).
|
|
1377
|
+
* @private
|
|
1378
|
+
*/
|
|
1379
|
+
FrqtlApp.prototype._requireDeclared = function (d, where) {
|
|
1380
|
+
if (d && this.declaredOf(d) === d) { return d; }
|
|
1381
|
+
var owner = d ? this.declaredOf(d) : null;
|
|
1382
|
+
throw new Error('[FrqtlApp.' + where + '] ' + (d ? '"' + (d.name || d._schemaKind || 'object') + '"' : String(d)) +
|
|
1383
|
+
' is not a declared object — only what the scene declared (runner.declared) is placed or read here' +
|
|
1384
|
+
(owner ? '; it belongs to the declared ' + (owner._schemaKind || 'object') + ' "' + owner.name +
|
|
1385
|
+
'" — pass that handle (runner.declaredOf(x))' : '; a primitive-surface object is not manipulable') +
|
|
1386
|
+
'. (FAIL-LOUD.)');
|
|
1387
|
+
};
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* centerOf(d) → [x, y, z] — the placement point of DECLARED object `d`, by its position authority
|
|
1391
|
+
* in the ONE capability order: a centre-bearing object's centre, else a rect-positioned object's
|
|
1392
|
+
* DRAWN centre (lo-drawn-center drawnCenter — the point the renderer draws its box at).
|
|
1393
|
+
* THROWS for an undeclared object and for one with no position authority — never null.
|
|
1394
|
+
*/
|
|
1395
|
+
FrqtlApp.prototype.centerOf = function (d) {
|
|
1396
|
+
this._requireDeclared(d, 'centerOf');
|
|
1397
|
+
var c = this._centerOf(d);
|
|
1398
|
+
if (c) { return c; }
|
|
1399
|
+
var rt = this._rectAuthorityOf(d);
|
|
1400
|
+
if (rt) { return LoDrawnCenter.drawnCenter(rt.rect); }
|
|
1401
|
+
throw new Error(this.noPositionAuthorityMessage(d));
|
|
1402
|
+
};
|
|
1403
|
+
|
|
1404
|
+
/** kindOf(d) → the declared object's kind (the descriptor name ObjectFactory.create stamped). THROWS for an undeclared object. */
|
|
1405
|
+
FrqtlApp.prototype.kindOf = function (d) {
|
|
1406
|
+
this._requireDeclared(d, 'kindOf');
|
|
1407
|
+
if (typeof d._schemaKind !== 'string' || !d._schemaKind) {
|
|
1408
|
+
throw new Error('[FrqtlApp.kindOf] the declared object "' + d.name + '" carries no kind stamp — ' +
|
|
1409
|
+
'ObjectFactory.create stamps every declaration. (FAIL-LOUD.)');
|
|
1410
|
+
}
|
|
1411
|
+
return d._schemaKind;
|
|
1412
|
+
};
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* The placement authority of declared `d` — 'center' | 'rect' — REFUSING what no device may
|
|
1416
|
+
* place: no position authority (`noPositionAuthorityMessage`), or a wall whose op-program another
|
|
1417
|
+
* part pushed (`assertOwnsProgram` — re-baking it would silently replace that part's physics).
|
|
1418
|
+
* The one refusal both the grab (at SPACE) and the placement write ask.
|
|
1419
|
+
* @private
|
|
1420
|
+
*/
|
|
1421
|
+
FrqtlApp.prototype._placementAuthority = function (d) {
|
|
1422
|
+
if (this._centerOf(d)) { return 'center'; }
|
|
1423
|
+
var rt = this._rectAuthorityOf(d);
|
|
1424
|
+
if (!rt) { throw new Error(this.noPositionAuthorityMessage(d)); }
|
|
1425
|
+
if (typeof rt.assertOwnsProgram === 'function') { rt.assertOwnsProgram(); }
|
|
1426
|
+
return 'rect';
|
|
1427
|
+
};
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* placeDeclaredAt(d, [x, y, z?]) → { center: [x,y,z], zHeld: boolean } — THE ONE placement write
|
|
1431
|
+
* for a declared object, whatever the device: the camera carry calls it (after clamping — its
|
|
1432
|
+
* device can fly out of the world), a host addressing the object by NAME calls it directly.
|
|
1433
|
+
*
|
|
1434
|
+
* Refuses (THROWS): an undeclared object; a malformed point (TypeError); a point outside the
|
|
1435
|
+
* universe cube `getUniverseBounds()` (RangeError — x ∈ [0, width], y ∈ [0, height],
|
|
1436
|
+
* z ∈ [−zHalf, zHalf]); no position authority (`noPositionAuthorityMessage`); a wall whose
|
|
1437
|
+
* op-program another part pushed (`assertOwnsProgram`). A refusal writes nothing.
|
|
1438
|
+
* An omitted z is 0 — the same reading the cube clamp and rectAtCenter give it.
|
|
1439
|
+
* Centre authority: written as `center`. Rect authority: the rect placed at that centre by
|
|
1440
|
+
* lo-drawn-center `rectAtCenter`, written as `rect`; a z-less (flat) rect cannot leave z = 0,
|
|
1441
|
+
* so it is placed there and `zHeld` says so.
|
|
1442
|
+
* Every write goes through `updateObjectProperty` (throw-or-apply); `center` is the re-read of
|
|
1443
|
+
* where the object now IS (`centerOf`).
|
|
1444
|
+
*/
|
|
1445
|
+
FrqtlApp.prototype.placeDeclaredAt = function (d, point) {
|
|
1446
|
+
this._requireDeclared(d, 'placeDeclaredAt');
|
|
1447
|
+
if (!Array.isArray(point) || (point.length !== 2 && point.length !== 3) ||
|
|
1448
|
+
!point.every(function (v) { return typeof v === 'number' && isFinite(v); })) {
|
|
1449
|
+
throw new TypeError('[FrqtlApp.placeDeclaredAt] the point is [x, y, z?] of finite numbers; got ' +
|
|
1450
|
+
JSON.stringify(point) + '. (FAIL-LOUD.)');
|
|
1451
|
+
}
|
|
1452
|
+
var k = [point[0], point[1], point.length === 3 ? point[2] : 0];
|
|
1453
|
+
var b = this.getUniverseBounds();
|
|
1454
|
+
if (k[0] < 0 || k[0] > b.width || k[1] < 0 || k[1] > b.height || k[2] < -b.zHalf || k[2] > b.zHalf) {
|
|
1455
|
+
throw new RangeError('[FrqtlApp.placeDeclaredAt] ' + JSON.stringify(k) + ' is outside the universe cube ' +
|
|
1456
|
+
'(getUniverseBounds(): width ' + b.width + ', height ' + b.height + ', zHalf ' + b.zHalf +
|
|
1457
|
+
' — x in [0, width], y in [0, height], z in [-zHalf, zHalf]). (FAIL-LOUD.)');
|
|
1458
|
+
}
|
|
1459
|
+
var zHeld = false;
|
|
1460
|
+
if (this._placementAuthority(d) === 'rect') {
|
|
1461
|
+
var L = LoDrawnCenter;
|
|
1462
|
+
if (!L.hasZ(d.rect)) {
|
|
1463
|
+
zHeld = Math.abs(k[2]) > FrqtlApp.Z_HOLD_EPS;
|
|
1464
|
+
k = [k[0], k[1], 0];
|
|
1465
|
+
}
|
|
1466
|
+
this.updateObjectProperty(d, 'rect', L.rectAtCenter(d.rect, k));
|
|
1467
|
+
} else {
|
|
1468
|
+
this.updateObjectProperty(d, 'center', k);
|
|
1469
|
+
}
|
|
1470
|
+
return { center: this.centerOf(d), zHeld: zHeld };
|
|
1471
|
+
};
|
|
1472
|
+
/** The declared object itself when it carries a heading (setOrientation), else null. */
|
|
1473
|
+
FrqtlApp.prototype._orientationOf = function (d) {
|
|
1474
|
+
return (d && typeof d.setOrientation === 'function') ? d : null;
|
|
1475
|
+
};
|
|
1476
|
+
/**
|
|
1477
|
+
* envelopeOf(d) → `{left, top, w, h[, z, d]}` | null — the box a DECLARED object occupies: its own
|
|
1478
|
+
* `rect` when it has one (a Wall's slab, a Tunnel's members' envelope, an emitter's marker box),
|
|
1479
|
+
* else the union of the census LOs it put in the world. The ONE envelope the 2D overlay box, the
|
|
1480
|
+
* 2D drag and the aim surround read.
|
|
1481
|
+
*/
|
|
1482
|
+
FrqtlApp.prototype.envelopeOf = function (d) {
|
|
1483
|
+
if (d && d.rect && typeof d.rect.w === 'number') { return d.rect; }
|
|
1484
|
+
var ms = this.membersOf(d);
|
|
1485
|
+
var l = Infinity, t = Infinity, r = -Infinity, b = -Infinity, z0 = Infinity, z1 = -Infinity, hasZ = false;
|
|
1486
|
+
for (var i = 0; i < ms.length; i++) {
|
|
1487
|
+
var m = ms[i] && ms[i].rect;
|
|
1488
|
+
if (!m) { continue; }
|
|
1489
|
+
l = Math.min(l, m.left); t = Math.min(t, m.top);
|
|
1490
|
+
r = Math.max(r, m.left + m.w); b = Math.max(b, m.top + m.h);
|
|
1491
|
+
if (LoDrawnCenter.hasZ(m)) {
|
|
1492
|
+
hasZ = true;
|
|
1493
|
+
z0 = Math.min(z0, m.z); z1 = Math.max(z1, m.z + ((typeof m.d === 'number') ? m.d : m.w));
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
if (!(l < r) || !(t < b)) { return null; }
|
|
1497
|
+
var env = { left: l, top: t, w: r - l, h: b - t };
|
|
1498
|
+
if (hasZ) { env.z = z0; env.d = z1 - z0; }
|
|
1499
|
+
return env;
|
|
1500
|
+
};
|
|
1501
|
+
/**
|
|
1502
|
+
* _objectCentroid(lo) → [x,y,z] | null — where a drawn census LO SITS, for the mouse pick: the
|
|
1503
|
+
* centre of its centre-bearing declared owner, else its rect's drawn centre (lo-drawn-center.js
|
|
1504
|
+
* — the point the renderer draws its box at).
|
|
1505
|
+
*/
|
|
1506
|
+
FrqtlApp.prototype._objectCentroid = function (lo) {
|
|
1507
|
+
var c = this._centerOf(this.declaredOf(lo));
|
|
1508
|
+
if (c) { return c; }
|
|
1509
|
+
var r = lo && lo.rect;
|
|
1510
|
+
if (!r) { return null; }
|
|
1511
|
+
return LoDrawnCenter.drawnCenter(r);
|
|
1512
|
+
};
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* getUniverseBounds() → { width, height, zHalf } (engine px). The 2D canvas is
|
|
1516
|
+
* width×height; the "canvas cube" adds a z-extent — a named convention parameter
|
|
1517
|
+
* (spec §2c): zHalf = min(width,height)/2 (a cube-ish volume centered on the z=0
|
|
1518
|
+
* target). Used by the grab-carry clamp; a game may read it too.
|
|
1519
|
+
*/
|
|
1520
|
+
FrqtlApp.prototype.getUniverseBounds = function () {
|
|
1521
|
+
// canvas_rect is written once, at construction, from the REQUIRED canvasW/canvasH; read
|
|
1522
|
+
// through the ONE normalizer, so a missing dimension throws rather than clamping to 0.
|
|
1523
|
+
var r = ObjectSchema.worldRect(this.canvas_rect, 'FrqtlApp.getUniverseBounds canvas_rect');
|
|
1524
|
+
var w = r.w, h = r.h;
|
|
1525
|
+
return { width: w, height: h, zHalf: Math.min(w, h) / 2 };
|
|
1526
|
+
};
|
|
1527
|
+
/** Clamp a carried center to the canvas cube (spec §2c — confinement in the CARRY path,
|
|
1528
|
+
* NEVER in the updateObjectProperty accessor, which stays throw-or-apply against the
|
|
1529
|
+
* schema; the engine/emitter/programmatic placement set `center` freely). */
|
|
1530
|
+
FrqtlApp.prototype._clampToUniverseCube = function (c) {
|
|
1531
|
+
var b = this.getUniverseBounds();
|
|
1532
|
+
var x = Math.max(0, Math.min(b.width, c[0]));
|
|
1533
|
+
var y = Math.max(0, Math.min(b.height, c[1]));
|
|
1534
|
+
var z = Math.max(-b.zHalf, Math.min(b.zHalf, (c[2] != null) ? c[2] : 0));
|
|
1535
|
+
return [x, y, z];
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
// ── THE AIM: a stationary reticle, a center-ray hover, a SPACEBAR grab ──────────────────
|
|
1539
|
+
// The reticle sits at screen centre
|
|
1540
|
+
// and the sticks fly the CAMERA in every state. The first visible object on the camera's
|
|
1541
|
+
// centre ray is GRAB-READY; SPACE grabs it; a grabbed object HOLDS its camera-relative pose
|
|
1542
|
+
// (position + heading) under every camera motion, so it is placed by moving the world and
|
|
1543
|
+
// re-oriented by turning it; SPACE places it. The runner owns the state machine and the
|
|
1544
|
+
// carry; the camera owns motion (and says when it moved: addPoseListener); the provider
|
|
1545
|
+
// owns the paint. No stick ever routes to an object.
|
|
1546
|
+
|
|
1547
|
+
/** World px added on every side of a volume before the centre-ray test, so a thin wall
|
|
1548
|
+
* (a plate of depth ~0) is aimable. */
|
|
1549
|
+
FrqtlApp.HOVER_MARGIN = 6;
|
|
1550
|
+
/** Below this world-xy length of a carried unit heading, the heading is not expressible
|
|
1551
|
+
* in the 1-DOF orientation model (it points along world z) and HOLDS its last value. */
|
|
1552
|
+
FrqtlApp.HEADING_HOLD_EPS = 0.05;
|
|
1553
|
+
/** A carried heading within this many degrees of the object's current one is unchanged (the
|
|
1554
|
+
* camera-basis round trip leaves float noise on a pure MOVE) — no heading write is made. */
|
|
1555
|
+
FrqtlApp.HEADING_WRITE_EPS_DEG = 1e-6;
|
|
1556
|
+
/** Beyond this |z| (world px) a carried centre is not expressible by a z-LESS rect (it sits
|
|
1557
|
+
* at z-centre 0 — lo-drawn-center): the object stays on the z = 0 plane and says Z HELD. */
|
|
1558
|
+
FrqtlApp.Z_HOLD_EPS = 1e-6;
|
|
1559
|
+
|
|
1560
|
+
var AIM_OFF = 'off', AIM_ROAMING = 'roaming', AIM_READY = 'grab-ready', AIM_GRABBED = 'grabbed';
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* setAimArmed(on) — arm/disarm the aim surface. The host arms it in STUDIO Design with a
|
|
1564
|
+
* tool active; Run, the no-tool view and a scene reset disarm it (a disarm RELEASES a grab
|
|
1565
|
+
* where the object stands). Arming needs an ANGULAR camera (`OrbitBag.hasAngularPose`) —
|
|
1566
|
+
* a flat camera has no centre ray to aim with, so arming there leaves the aim off (the same
|
|
1567
|
+
* predicate that keeps the joysticks out of 2D). An angular camera whose handle lacks the
|
|
1568
|
+
* pose notice or the offset pair breaks RENDERER_CONTRACT §1a.2 and THROWS.
|
|
1569
|
+
*/
|
|
1570
|
+
FrqtlApp.prototype.setAimArmed = function (on) {
|
|
1571
|
+
if (!on) {
|
|
1572
|
+
if (!this._aim) { return; }
|
|
1573
|
+
this._aim.unsubscribePose();
|
|
1574
|
+
this._aim = null;
|
|
1575
|
+
this._notifyAim();
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
if (this._aim) { return; }
|
|
1579
|
+
var cam = this.cameraControl;
|
|
1580
|
+
if (!OrbitBag.hasAngularPose(cam)) { return; }
|
|
1581
|
+
if (typeof cam.addPoseListener !== 'function' || typeof cam.cameraOffsetFromWorld !== 'function' ||
|
|
1582
|
+
typeof cam.worldFromCameraOffset !== 'function') {
|
|
1583
|
+
throw new Error('[FrqtlApp.setAimArmed] the angular camera handle lacks addPoseListener / ' +
|
|
1584
|
+
'cameraOffsetFromWorld / worldFromCameraOffset — the aim follows the camera through its ' +
|
|
1585
|
+
'pose notice and reads the ray off the offset pair (RENDERER_CONTRACT §1a.2). (FAIL-LOUD.)');
|
|
1586
|
+
}
|
|
1587
|
+
var self = this;
|
|
1588
|
+
this._aim = { cam: cam, hover: null, grab: null, atEdge: false, headingHeld: false, zHeld: false, error: null,
|
|
1589
|
+
last: null, unsubscribePose: null };
|
|
1590
|
+
this._aim.unsubscribePose = cam.addPoseListener(function () { self._onAimPose(); });
|
|
1591
|
+
this._refreshHover();
|
|
1592
|
+
this._notifyAim();
|
|
1593
|
+
};
|
|
1594
|
+
FrqtlApp.prototype.isAimArmed = function () { return !!this._aim; };
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* getAimState() → `{ state, hover, grabbed, atEdge, headingHeld, zHeld, error }` — `state` ∈
|
|
1598
|
+
* off|roaming|grab-ready|grabbed; `hover`/`grabbed` are object NAMES (or null); `atEdge` is
|
|
1599
|
+
* true while the carried pose is clamped to the universe cube; `headingHeld` while the carried
|
|
1600
|
+
* heading points along world z (not expressible, held); `zHeld` while a z-less (flat) rect is
|
|
1601
|
+
* carried off the z = 0 plane it is pinned to (held there); `error` is the message of a carry
|
|
1602
|
+
* write that threw (the grab was released) or of a refused grab — the property of ONE attempt:
|
|
1603
|
+
* it clears when the aim moves to another target, on the next SPACE, on a selection that
|
|
1604
|
+
* releases the grab, and on disarm.
|
|
1605
|
+
*/
|
|
1606
|
+
FrqtlApp.prototype.getAimState = function () {
|
|
1607
|
+
var a = this._aim;
|
|
1608
|
+
if (!a) { return { state: AIM_OFF, hover: null, grabbed: null, atEdge: false, headingHeld: false, zHeld: false, error: null }; }
|
|
1609
|
+
return {
|
|
1610
|
+
state: a.grab ? AIM_GRABBED : (a.hover ? AIM_READY : AIM_ROAMING),
|
|
1611
|
+
hover: a.hover ? (a.hover.name || '') : null,
|
|
1612
|
+
grabbed: a.grab ? (a.grab.object.name || '') : null,
|
|
1613
|
+
atEdge: !!(a.grab && a.atEdge),
|
|
1614
|
+
headingHeld: !!(a.grab && a.headingHeld),
|
|
1615
|
+
zHeld: !!(a.grab && a.zHeld),
|
|
1616
|
+
error: a.error
|
|
1617
|
+
};
|
|
1618
|
+
};
|
|
1619
|
+
/** The grabbed DECLARED object or null — the host selects it. */
|
|
1620
|
+
FrqtlApp.prototype.getGrabbedObject = function () {
|
|
1621
|
+
return (this._aim && this._aim.grab) ? this._aim.grab.object : null;
|
|
1622
|
+
};
|
|
1623
|
+
|
|
1624
|
+
/**
|
|
1625
|
+
* addAimListener(fn) → unsubscribe. `fn(state)` runs after every change of the aim state
|
|
1626
|
+
* (arm, disarm, hover, grab, release, clamp, heading hold, a carry error) with getAimState().
|
|
1627
|
+
*/
|
|
1628
|
+
FrqtlApp.prototype.addAimListener = function (fn) {
|
|
1629
|
+
var ls = this._aimListeners || (this._aimListeners = []);
|
|
1630
|
+
if (ls.indexOf(fn) === -1) { ls.push(fn); }
|
|
1631
|
+
return function () { var i = ls.indexOf(fn); if (i !== -1) { ls.splice(i, 1); } };
|
|
1632
|
+
};
|
|
1633
|
+
FrqtlApp.prototype._notifyAim = function () {
|
|
1634
|
+
var st = this.getAimState();
|
|
1635
|
+
if (this._aim) { this._aim.last = JSON.stringify(st); }
|
|
1636
|
+
var ls = (this._aimListeners || []).slice();
|
|
1637
|
+
for (var i = 0; i < ls.length; i++) { ls[i](st); }
|
|
1638
|
+
};
|
|
1639
|
+
/** Notify only when the published state actually changed (a carry step usually does not). */
|
|
1640
|
+
FrqtlApp.prototype._notifyAimIfChanged = function () {
|
|
1641
|
+
if (this._aim && JSON.stringify(this.getAimState()) === this._aim.last) { return; }
|
|
1642
|
+
this._notifyAim();
|
|
1643
|
+
};
|
|
1644
|
+
|
|
1645
|
+
/**
|
|
1646
|
+
* grabToggle() — THE spacebar. At GRAB-READY it grabs the hovered object, capturing its full
|
|
1647
|
+
* camera-relative pose; while GRABBED it places the object where it stands; at ROAMING (or
|
|
1648
|
+
* disarmed) it is a no-op. An object that refuses the grab (no position authority, or a wall
|
|
1649
|
+
* carrying a program it did not bake) stays un-grabbed and the refusal is published as `error`.
|
|
1650
|
+
* Returns the new state name.
|
|
1651
|
+
*/
|
|
1652
|
+
FrqtlApp.prototype.grabToggle = function () {
|
|
1653
|
+
var a = this._aim;
|
|
1654
|
+
if (!a) { return AIM_OFF; }
|
|
1655
|
+
a.error = null; // the error is the property of ONE attempt: a new SPACE starts clean
|
|
1656
|
+
if (a.grab) {
|
|
1657
|
+
this._releaseGrab();
|
|
1658
|
+
} else {
|
|
1659
|
+
this._refreshHover();
|
|
1660
|
+
if (a.hover) {
|
|
1661
|
+
try {
|
|
1662
|
+
a.grab = this._captureGrab(a.hover); a.atEdge = false; a.headingHeld = false; a.zHeld = false;
|
|
1663
|
+
} catch (e) {
|
|
1664
|
+
a.grab = null; a.error = 'the object cannot be grabbed: ' + (e && e.message ? e.message : e);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
this._notifyAim();
|
|
1669
|
+
return this.getAimState().state;
|
|
1670
|
+
};
|
|
1671
|
+
FrqtlApp.prototype._releaseGrab = function () {
|
|
1672
|
+
if (this._aim) { this._aim.grab = null; this._aim.atEdge = false; this._aim.headingHeld = false; this._aim.zHeld = false; }
|
|
1673
|
+
};
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* setSelectHighlight(object | null) — the host's selection, handed down so its shadow-surround
|
|
1677
|
+
* paints (the same highlight geometry as the aim's). Selection never grabs; a selection that
|
|
1678
|
+
* is not the grabbed object (a deselect, another pick) RELEASES the grab.
|
|
1679
|
+
*/
|
|
1680
|
+
FrqtlApp.prototype.setSelectHighlight = function (object) {
|
|
1681
|
+
this._selected = object || null;
|
|
1682
|
+
var a = this._aim;
|
|
1683
|
+
if (a && a.grab && a.grab.object !== this._selected) {
|
|
1684
|
+
this._releaseGrab();
|
|
1685
|
+
a.error = null;
|
|
1686
|
+
this._notifyAim();
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
|
|
1690
|
+
/**
|
|
1691
|
+
* The full camera-relative pose of declared object `d` at grab, by its position authority in the
|
|
1692
|
+
* ONE capability order: a centre-bearing object's centre, else a rect-positioned object's DRAWN
|
|
1693
|
+
* centre (lo-drawn-center), else REFUSED (`noPositionAuthorityMessage`) — a part that cannot be
|
|
1694
|
+
* placed is never silently carried by one of its members. The heading's camera-frame components
|
|
1695
|
+
* ride along when `d` carries one (setOrientation).
|
|
1696
|
+
*/
|
|
1697
|
+
FrqtlApp.prototype._captureGrab = function (d) {
|
|
1698
|
+
var cam = this._aim.cam;
|
|
1699
|
+
// The grab refuses at SPACE what the placement would refuse on the first carry.
|
|
1700
|
+
this._placementAuthority(d);
|
|
1701
|
+
var center = this.centerOf(d);
|
|
1702
|
+
var oriTarget = this._orientationOf(d);
|
|
1703
|
+
var g = { object: d, off: cam.cameraOffsetFromWorld(center), oriTarget: oriTarget, hCam: null };
|
|
1704
|
+
if (oriTarget && typeof oriTarget.getOrientation === 'function') {
|
|
1705
|
+
var r = (oriTarget.getOrientation() || 0) * Math.PI / 180;
|
|
1706
|
+
var o0 = cam.cameraOffsetFromWorld([0, 0, 0]);
|
|
1707
|
+
var o1 = cam.cameraOffsetFromWorld([Math.cos(r), Math.sin(r), 0]);
|
|
1708
|
+
g.hCam = { right: o1.right - o0.right, up: o1.up - o0.up, forward: o1.forward - o0.forward };
|
|
1709
|
+
}
|
|
1710
|
+
return g;
|
|
1711
|
+
};
|
|
1712
|
+
|
|
1713
|
+
/** The pose notice: carry the grabbed object, then re-aim the centre ray. */
|
|
1714
|
+
FrqtlApp.prototype._onAimPose = function () {
|
|
1715
|
+
if (this._aim && this._aim.grab) { this._carryGrab(); }
|
|
1716
|
+
this._refreshHover();
|
|
1717
|
+
this._notifyAimIfChanged();
|
|
1718
|
+
};
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* Re-place the grabbed object at its held camera-relative pose in the LIVE basis: the centre
|
|
1722
|
+
* through worldFromCameraOffset → the universe-cube clamp (the camera device's own pre-write
|
|
1723
|
+
* policy) → `placeDeclaredAt`, the ONE placement write (its `zHeld` copied into the aim state),
|
|
1724
|
+
* then the offset re-captured from the clamped point it placed (z 0 when held) — glued at the
|
|
1725
|
+
* wall, no invisible over-travel. The heading is re-expressed in the live basis, projected onto world-xy, written through setOrientation.
|
|
1726
|
+
* A write that throws RELEASES the grab and publishes the error.
|
|
1727
|
+
*/
|
|
1728
|
+
FrqtlApp.prototype._carryGrab = function () {
|
|
1729
|
+
var a = this._aim, g = a.grab, cam = a.cam;
|
|
1730
|
+
try {
|
|
1731
|
+
// The camera device CLAMPS (it can fly out of the world), then places through the ONE write.
|
|
1732
|
+
var c = cam.worldFromCameraOffset(g.off);
|
|
1733
|
+
var k = this._clampToUniverseCube(c);
|
|
1734
|
+
a.atEdge = (Math.abs(k[0] - c[0]) + Math.abs(k[1] - c[1]) + Math.abs(k[2] - c[2])) > 1e-6;
|
|
1735
|
+
var placed = this.placeDeclaredAt(g.object, k);
|
|
1736
|
+
a.zHeld = placed.zHeld;
|
|
1737
|
+
// Re-capture from the point the carry ASKED to place (z held to 0 when it was), never
|
|
1738
|
+
// from the re-read: a centre authority quantizes its write (an emitter's setCenter rounds
|
|
1739
|
+
// to whole px), and re-capturing a quantized read drifts the held pose on every notice.
|
|
1740
|
+
g.off = cam.cameraOffsetFromWorld(placed.zHeld ? [k[0], k[1], 0] : k);
|
|
1741
|
+
if (g.hCam) {
|
|
1742
|
+
var w0 = cam.worldFromCameraOffset({ right: 0, up: 0, forward: 0 });
|
|
1743
|
+
var w1 = cam.worldFromCameraOffset(g.hCam);
|
|
1744
|
+
var hx = w1[0] - w0[0], hy = w1[1] - w0[1];
|
|
1745
|
+
a.headingHeld = Math.hypot(hx, hy) < FrqtlApp.HEADING_HOLD_EPS;
|
|
1746
|
+
if (!a.headingHeld) {
|
|
1747
|
+
// A pose notice that did not turn the heading (a MOVE) writes no heading: a
|
|
1748
|
+
// re-aim to the same degree would re-bake and push the program, region and broad
|
|
1749
|
+
// rect again — the second engine write per notice for the SAME placement.
|
|
1750
|
+
var deg = Math.atan2(hy, hx) * 180 / Math.PI;
|
|
1751
|
+
var cur = (typeof g.oriTarget.getOrientation === 'function') ? g.oriTarget.getOrientation() : null;
|
|
1752
|
+
var dd = (cur === null) ? Infinity : Math.abs((((deg - cur) % 360) + 540) % 360 - 180);
|
|
1753
|
+
if (dd > FrqtlApp.HEADING_WRITE_EPS_DEG) { g.oriTarget.setOrientation(deg); }
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
} catch (e) {
|
|
1757
|
+
this._releaseGrab();
|
|
1758
|
+
a.error = 'the grabbed object could not follow the camera: ' + (e && e.message ? e.message : e);
|
|
1759
|
+
this._notifyAim();
|
|
1760
|
+
}
|
|
1761
|
+
};
|
|
1762
|
+
|
|
1763
|
+
/**
|
|
1764
|
+
* The volume a drawn census LO is aimed at by (compat/renderer/lo-drawn-center.js — the SAME
|
|
1765
|
+
* volume family the shadow-surround paints): when its declared owner is centre-bearing, the
|
|
1766
|
+
* owner's centre as a sphere the size of the drawn marker; else the LO's drawn box turned by its
|
|
1767
|
+
* shell rotation. null when it has neither.
|
|
1768
|
+
*/
|
|
1769
|
+
FrqtlApp.prototype._aimVolume = function (lo, owner) {
|
|
1770
|
+
var L = LoDrawnCenter;
|
|
1771
|
+
var c = this._centerOf(owner === undefined ? this.declaredOf(lo) : owner);
|
|
1772
|
+
if (c) {
|
|
1773
|
+
var r = lo.rect ? Math.max(lo.rect.w || 0, lo.rect.h || 0) / 2 : 0;
|
|
1774
|
+
return L.drawnVolume({ center: c, radius: r });
|
|
1775
|
+
}
|
|
1776
|
+
return lo.rect ? L.drawnVolume(lo) : null;
|
|
1777
|
+
};
|
|
1778
|
+
|
|
1779
|
+
/**
|
|
1780
|
+
* The surround volume of a DECLARED object — ONE shell around the whole object (one object, one
|
|
1781
|
+
* cue): a centre-bearing object's sphere; a single-member object's own drawn volume (its shell
|
|
1782
|
+
* rotation kept); a composite's ENVELOPE box (`envelopeOf` — a Tunnel's own rect).
|
|
1783
|
+
*/
|
|
1784
|
+
FrqtlApp.prototype._declaredVolume = function (d) {
|
|
1785
|
+
var members = this.membersOf(d);
|
|
1786
|
+
if (members.length === 1 || this._centerOf(d)) {
|
|
1787
|
+
return members.length ? this._aimVolume(members[0], d) : null;
|
|
1788
|
+
}
|
|
1789
|
+
var env = this.envelopeOf(d);
|
|
1790
|
+
return env ? LoDrawnCenter.drawnVolume({ rect: env }) : null;
|
|
1791
|
+
};
|
|
1792
|
+
/** Whether the frame DRAWS any part of declared object `d` (one of its members is aimable). */
|
|
1793
|
+
FrqtlApp.prototype._isDeclaredAimable = function (d) {
|
|
1794
|
+
var ms = this.membersOf(d);
|
|
1795
|
+
for (var i = 0; i < ms.length; i++) { if (this._isAimable(ms[i])) { return true; } }
|
|
1796
|
+
return false;
|
|
1797
|
+
};
|
|
1798
|
+
|
|
1799
|
+
/** Whether the frame DRAWS `lo` — the runner's own render view, never the provider's:
|
|
1800
|
+
* not a backdrop, `visible` not false, and its shell shown in the current mode. */
|
|
1801
|
+
FrqtlApp.prototype._isAimable = function (lo) {
|
|
1802
|
+
return !!lo && !lo.backdrop && lo.visible !== false && this._isShellVisible(lo);
|
|
1803
|
+
};
|
|
1804
|
+
|
|
1805
|
+
/**
|
|
1806
|
+
* _centerRay() → `{ origin, dir }` — the camera's screen-centre ray, read off the OFFSET pair:
|
|
1807
|
+
* the eye is the offset origin, the direction the unit forward offset. (The screen pair —
|
|
1808
|
+
* screenToWorld / screenPointOf — answers point-on-plane questions and is not used for rays.)
|
|
1809
|
+
*/
|
|
1810
|
+
FrqtlApp.prototype._centerRay = function () {
|
|
1811
|
+
var cam = this._aim.cam;
|
|
1812
|
+
var o = cam.worldFromCameraOffset({ right: 0, up: 0, forward: 0 });
|
|
1813
|
+
var f = cam.worldFromCameraOffset({ right: 0, up: 0, forward: 1 });
|
|
1814
|
+
return { origin: o, dir: [f[0] - o[0], f[1] - o[1], f[2] - o[2]] };
|
|
1815
|
+
};
|
|
1816
|
+
|
|
1817
|
+
/**
|
|
1818
|
+
* Recompute which DECLARED object the centre ray hits first. The hit set is what the frame DRAWS
|
|
1819
|
+
* (the census's aimable LOs — a composite draws nothing of its own, so its hit volume is the union
|
|
1820
|
+
* of its members' by construction of the set); the result is the declared object the hit LO
|
|
1821
|
+
* belongs to (`declaredOf`). An LO whose `_owner` chain ends outside the registry (a
|
|
1822
|
+
* primitive-surface object) is not a candidate — it is never hovered.
|
|
1823
|
+
*/
|
|
1824
|
+
FrqtlApp.prototype._refreshHover = function () {
|
|
1825
|
+
var a = this._aim;
|
|
1826
|
+
if (!a) { return; }
|
|
1827
|
+
var los = this.largeObjects, owners = [], vols = [];
|
|
1828
|
+
for (var i = 0; i < los.length; i++) {
|
|
1829
|
+
if (!this._isAimable(los[i])) { continue; }
|
|
1830
|
+
var d = this.declaredOf(los[i]);
|
|
1831
|
+
if (!d) { continue; }
|
|
1832
|
+
var v = this._aimVolume(los[i], d);
|
|
1833
|
+
if (v) { owners.push(d); vols.push(v); }
|
|
1834
|
+
}
|
|
1835
|
+
var ray = this._centerRay();
|
|
1836
|
+
var hit = LoDrawnCenter.firstHit(ray.origin, ray.dir, vols, FrqtlApp.HOVER_MARGIN);
|
|
1837
|
+
var next = hit ? owners[hit.index] : null;
|
|
1838
|
+
// Aiming at something else ends the refused/failed attempt: its error goes with it.
|
|
1839
|
+
if (next !== a.hover) { a.error = null; }
|
|
1840
|
+
a.hover = next;
|
|
1841
|
+
};
|
|
1842
|
+
|
|
1843
|
+
// ── The aim feedback bag, threaded to the provider via the renderTick opts channel ──────
|
|
1844
|
+
// LIVE per-frame state (never setRenderOption), under ONE runner-injected key. A provider
|
|
1845
|
+
// that ignores it draws nothing (capability-checked).
|
|
1846
|
+
var AIM_FEEDBACK_KEY = 'aimFeedback';
|
|
1847
|
+
|
|
1848
|
+
/**
|
|
1849
|
+
* The render-option keys this RUNNER merges into the bag per render — never a provider
|
|
1850
|
+
* capability, never user-writable. The read-set conformance test FERRIES this list as its
|
|
1851
|
+
* one exemption.
|
|
1852
|
+
*/
|
|
1853
|
+
FrqtlApp.RUNNER_INJECTED_RENDER_KEYS = Object.freeze([AIM_FEEDBACK_KEY]);
|
|
1854
|
+
|
|
1855
|
+
/**
|
|
1856
|
+
* The live aim feedback, or null when the aim is disarmed:
|
|
1857
|
+
* `{ bounds, state, hover?: {name, volume}, selected?: {name, volume}, grabbed?: {name, volume},
|
|
1858
|
+
* orientation?: {pivot, deg} }`
|
|
1859
|
+
* `hover` is the grab-ready (or grabbed) object's shadow-surround, `selected` the host
|
|
1860
|
+
* selection's, `grabbed` marks the grabbed one (its lock glyph), and `orientation` the passive
|
|
1861
|
+
* heading of the grabbed (else selected) orientation-bearing object. Every volume is the
|
|
1862
|
+
* lo-drawn-center volume the ray tested.
|
|
1863
|
+
*/
|
|
1864
|
+
FrqtlApp.prototype._computeAimFeedback = function () {
|
|
1865
|
+
var a = this._aim;
|
|
1866
|
+
if (!a) { return null; }
|
|
1867
|
+
this._refreshHover();
|
|
1868
|
+
this._notifyAimIfChanged();
|
|
1869
|
+
var self = this;
|
|
1870
|
+
function mark(d) {
|
|
1871
|
+
var v = d && self._declaredVolume(d);
|
|
1872
|
+
return v ? { name: d.name || '', volume: v } : null;
|
|
1873
|
+
}
|
|
1874
|
+
var fb = { bounds: this.getUniverseBounds(), state: this.getAimState().state };
|
|
1875
|
+
var hov = mark(a.grab ? a.grab.object : a.hover);
|
|
1876
|
+
if (hov) { fb.hover = hov; }
|
|
1877
|
+
var sel = (this._selected && this._isDeclaredAimable(this._selected)) ? mark(this._selected) : null;
|
|
1878
|
+
if (sel) { fb.selected = sel; }
|
|
1879
|
+
if (a.grab && hov) { fb.grabbed = hov; }
|
|
1880
|
+
var headed = a.grab ? a.grab.object : this._selected;
|
|
1881
|
+
var oriT = this._orientationOf(headed);
|
|
1882
|
+
var headVol = headed ? this._declaredVolume(headed) : null;
|
|
1883
|
+
if (oriT && headVol && typeof oriT.getOrientation === 'function') {
|
|
1884
|
+
fb.orientation = { pivot: headVol.center, deg: oriT.getOrientation() || 0 };
|
|
1885
|
+
}
|
|
1886
|
+
return fb;
|
|
1887
|
+
};
|
|
1888
|
+
/** The render options for a frame: the persistent user options plus the live aim feedback (if any). */
|
|
1889
|
+
FrqtlApp.prototype._renderOptsWithFeedback = function () {
|
|
1890
|
+
var fb = this._computeAimFeedback();
|
|
1891
|
+
if (!fb) { return this._renderOptions; }
|
|
1892
|
+
var bag = Object.assign({}, this._renderOptions);
|
|
1893
|
+
bag[AIM_FEEDBACK_KEY] = fb;
|
|
1894
|
+
return bag;
|
|
1895
|
+
};
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* screenPointOf(world, cssW, cssH, seed?) → [x,y] | null — THE world→screen map: the point, in
|
|
1899
|
+
* a cssW×cssH screen surface, at which the renderer DRAWS world point `world` ([x,y] or
|
|
1900
|
+
* [x,y,z]; z defaults to 0). It is the ADJOINT of the provider's own screenToWorld (the one
|
|
1901
|
+
* camera authority, in both directions — no second projection is derived here): Newton steps
|
|
1902
|
+
* on the unprojection onto world's z-plane, from `seed` ([x,y]; default the surface centre),
|
|
1903
|
+
* using the local Jacobian (world delta per +1 screen px). The map on a plane is projective
|
|
1904
|
+
* (affine for the 2D pan/zoom camera), so a few steps converge; the answer is accepted only if
|
|
1905
|
+
* it unprojects back onto `world`. null is the contracted "not on screen from this view"
|
|
1906
|
+
* answer — never a cue to try another mapping. THROWS when there is no renderer or it lacks
|
|
1907
|
+
* the screenToWorld capability (RENDERER_CONTRACT: screenToWorld).
|
|
1908
|
+
*/
|
|
1909
|
+
FrqtlApp.prototype.screenPointOf = function (world, cssW, cssH, seed) {
|
|
1910
|
+
var r = this.renderer;
|
|
1911
|
+
if (!r || typeof r.screenToWorld !== 'function') {
|
|
1912
|
+
throw new Error('[FrqtlApp.screenPointOf] the active renderer provider has no ' +
|
|
1913
|
+
'screenToWorld capability (RENDERER_CONTRACT) — world→screen is its adjoint and ' +
|
|
1914
|
+
'has no other source. (FAIL-LOUD.)');
|
|
1915
|
+
}
|
|
1916
|
+
var z = (typeof world[2] === 'number') ? world[2] : 0;
|
|
1917
|
+
var x = seed ? seed[0] : cssW / 2, y = seed ? seed[1] : cssH / 2;
|
|
1918
|
+
for (var k = 0; k < 8; k++) {
|
|
1919
|
+
var p0 = r.screenToWorld(x, y, cssW, cssH, z);
|
|
1920
|
+
var px = r.screenToWorld(x + 1, y, cssW, cssH, z);
|
|
1921
|
+
var py = r.screenToWorld(x, y + 1, cssW, cssH, z);
|
|
1922
|
+
if (!p0 || !px || !py) { return null; }
|
|
1923
|
+
var a = px.x - p0.x, b = py.x - p0.x, e = px.y - p0.y, f = py.y - p0.y;
|
|
1924
|
+
var det = a * f - b * e;
|
|
1925
|
+
if (!det) { return null; } // the plane is edge-on to the view
|
|
1926
|
+
var wx = world[0] - p0.x, wy = world[1] - p0.y;
|
|
1927
|
+
var dx = (f * wx - b * wy) / det, dy = (a * wy - e * wx) / det;
|
|
1928
|
+
x += dx; y += dy;
|
|
1929
|
+
if (Math.abs(dx) + Math.abs(dy) < 0.05) { break; }
|
|
1930
|
+
}
|
|
1931
|
+
var q = r.screenToWorld(x, y, cssW, cssH, z);
|
|
1932
|
+
if (!q || Math.abs(q.x - world[0]) + Math.abs(q.y - world[1]) > 0.5) { return null; }
|
|
1933
|
+
return [x, y];
|
|
1934
|
+
};
|
|
1935
|
+
|
|
1936
|
+
/** Screen radius (in the caller's screen units) inside which a click picks an object. */
|
|
1937
|
+
FrqtlApp.PICK_RADIUS_PX = 32;
|
|
1938
|
+
/** A center-bearing (drivable) object's pick distance is divided by this, so in a dense scene
|
|
1939
|
+
* the thing the sticks can MOVE wins a near-tie against a wall behind it. */
|
|
1940
|
+
FrqtlApp.PICK_DRIVE_PRIORITY = 2;
|
|
1941
|
+
|
|
1942
|
+
/**
|
|
1943
|
+
* pickObjectAtScreen(cssX, cssY, cssW, cssH) → the DECLARED object owning the census LO whose
|
|
1944
|
+
* centroid is DRAWN nearest the screen point, or null (spec §2). The distance is in SCREEN px (each centroid is located on
|
|
1945
|
+
* screen through the provider's screenToWorld — screenPointOf), so the pick radius is what the
|
|
1946
|
+
* eye sees at any zoom, axis and depth; a world-px radius on each object's own plane was
|
|
1947
|
+
* anisotropic on screen and different per plane, which let a wall behind the emitter win inside
|
|
1948
|
+
* the emitter's own footprint. A drivable object's distance is divided by PICK_DRIVE_PRIORITY.
|
|
1949
|
+
* Objects-layer; consumed downward by AuthorView. Null (fail-soft) with no screenToWorld
|
|
1950
|
+
* capability — the host falls back to the Layers panel.
|
|
1951
|
+
*/
|
|
1952
|
+
FrqtlApp.prototype.pickObjectAtScreen = function (cssX, cssY, cssW, cssH) {
|
|
1953
|
+
var r = this.renderer;
|
|
1954
|
+
if (!r || typeof r.screenToWorld !== 'function') { return null; }
|
|
1955
|
+
var los = this.largeObjects;
|
|
1956
|
+
var best = null, bestScore = Infinity;
|
|
1957
|
+
for (var i = 0; i < los.length; i++) {
|
|
1958
|
+
var lo = los[i];
|
|
1959
|
+
var owner = this.declaredOf(lo);
|
|
1960
|
+
if (!owner) { continue; } // a primitive-surface object is never picked
|
|
1961
|
+
var c = this._objectCentroid(lo);
|
|
1962
|
+
if (!c) { continue; }
|
|
1963
|
+
var sp = this.screenPointOf(c, cssW, cssH, [cssX, cssY]);
|
|
1964
|
+
if (!sp) { continue; }
|
|
1965
|
+
var drivable = typeof owner.getCenter === 'function';
|
|
1966
|
+
var score = Math.hypot(sp[0] - cssX, sp[1] - cssY) / (drivable ? FrqtlApp.PICK_DRIVE_PRIORITY : 1);
|
|
1967
|
+
if (score < bestScore) { bestScore = score; best = owner; }
|
|
1968
|
+
}
|
|
1969
|
+
return (bestScore <= FrqtlApp.PICK_RADIUS_PX) ? best : null;
|
|
1970
|
+
};
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* Uniform schema-driven mutation entry point for the live UNIVERSE singleton
|
|
1974
|
+
* (Studio Controls Realignment S1, design §2.3.2 / gap-census #10).
|
|
1975
|
+
*
|
|
1976
|
+
* Mirrors `updateObjectProperty`'s throw-or-apply discipline for the ONE other
|
|
1977
|
+
* object every experiment page has exactly one of: the universe itself. Unlike
|
|
1978
|
+
* a Frame/LargeObject prop there is no separate engine-ABI branch to dispatch
|
|
1979
|
+
* to — `wavelengthScale`'s own JS-side property setter
|
|
1980
|
+
* (`EGPTUniverse.prototype.wavelengthScale`, `wasm-engine.js`) already calls
|
|
1981
|
+
* through to the WASM ABI (`_wasm.set_wavelength_scale`), so once schema
|
|
1982
|
+
* validation has passed, `this.universe[prop] = value` IS the engine write —
|
|
1983
|
+
* never a bare, unvalidated assignment (that's exactly the bind-local-property-
|
|
1984
|
+
* list anti-pattern this accessor exists to close off).
|
|
1985
|
+
*
|
|
1986
|
+
* @param {string} prop - Field name declared in ObjectSchema.get('Universe')
|
|
1987
|
+
* @param {*} value - New value
|
|
1988
|
+
* @throws {TypeError} - If prop is unknown or readonly for the 'Universe' kind
|
|
1989
|
+
*/
|
|
1990
|
+
FrqtlApp.prototype.setUniverseProperty = function (prop, value) {
|
|
1991
|
+
var schema = ObjectSchema;
|
|
1992
|
+
if (!schema) {
|
|
1993
|
+
throw new TypeError(
|
|
1994
|
+
'[FrqtlApp.setUniverseProperty] ObjectSchema is unavailable — cannot ' +
|
|
1995
|
+
'validate "' + prop + '" (FAIL-LOUD: never write to the universe unvalidated).'
|
|
1996
|
+
);
|
|
1997
|
+
}
|
|
1998
|
+
var descriptor = schema.get('Universe');
|
|
1999
|
+
if (!descriptor) {
|
|
2000
|
+
throw new TypeError('[FrqtlApp.setUniverseProperty] Unknown object kind "Universe"');
|
|
2001
|
+
}
|
|
2002
|
+
var fieldDef = null;
|
|
2003
|
+
for (var i = 0; i < descriptor.fields.length; i++) {
|
|
2004
|
+
if (descriptor.fields[i].name === prop) { fieldDef = descriptor.fields[i]; break; }
|
|
2005
|
+
}
|
|
2006
|
+
if (!fieldDef) {
|
|
2007
|
+
throw new TypeError(
|
|
2008
|
+
'[FrqtlApp.setUniverseProperty] Property "' + prop +
|
|
2009
|
+
'" is not in the schema for kind "Universe". Valid properties: ' +
|
|
2010
|
+
descriptor.fields.map(function (f) { return f.name; }).join(', ')
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
if (fieldDef.type === 'readonly') {
|
|
2014
|
+
throw new TypeError(
|
|
2015
|
+
'[FrqtlApp.setUniverseProperty] Property "' + prop + '" is readonly for kind "Universe"'
|
|
2016
|
+
);
|
|
2017
|
+
}
|
|
2018
|
+
this.universe[prop] = value;
|
|
2019
|
+
};
|
|
2020
|
+
|
|
2021
|
+
/**
|
|
2022
|
+
* Read counterpart to setUniverseProperty — same schema validation, live read
|
|
2023
|
+
* (no tick dependency, unlike the LargeObject snapshot path).
|
|
2024
|
+
*
|
|
2025
|
+
* @param {string} prop - Field name declared in ObjectSchema.get('Universe')
|
|
2026
|
+
* @returns {*} the current value of `this.universe[prop]`
|
|
2027
|
+
* @throws {TypeError} - If prop is unknown for the 'Universe' kind
|
|
2028
|
+
*/
|
|
2029
|
+
FrqtlApp.prototype.getUniverseProperty = function (prop) {
|
|
2030
|
+
var schema = ObjectSchema;
|
|
2031
|
+
if (schema) {
|
|
2032
|
+
var descriptor = schema.get('Universe');
|
|
2033
|
+
if (descriptor) {
|
|
2034
|
+
var fieldDef = null;
|
|
2035
|
+
for (var i = 0; i < descriptor.fields.length; i++) {
|
|
2036
|
+
if (descriptor.fields[i].name === prop) { fieldDef = descriptor.fields[i]; break; }
|
|
2037
|
+
}
|
|
2038
|
+
if (!fieldDef) {
|
|
2039
|
+
throw new TypeError(
|
|
2040
|
+
'[FrqtlApp.getUniverseProperty] Property "' + prop +
|
|
2041
|
+
'" is not in the schema for kind "Universe". Valid properties: ' +
|
|
2042
|
+
descriptor.fields.map(function (f) { return f.name; }).join(', ')
|
|
2043
|
+
);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
return this.universe[prop];
|
|
2048
|
+
};
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* Move a LargeObject to a new position.
|
|
2052
|
+
* Private dispatch target — kept so internal callers don't break.
|
|
2053
|
+
* The public entry point is updateObjectProperty(lo, 'rect', [left, top, w, h]).
|
|
2054
|
+
*
|
|
2055
|
+
* @param {Object} lo LargeObject wrapper with .handle and .rect
|
|
2056
|
+
* @param {number} left New left edge (physics pixels)
|
|
2057
|
+
* @param {number} top New top edge (physics pixels)
|
|
2058
|
+
* @param {number} [w] New width (default: unchanged)
|
|
2059
|
+
* @param {number} [h] New height (default: unchanged)
|
|
2060
|
+
*/
|
|
2061
|
+
FrqtlApp.prototype.moveLargeObject = function (lo, left, top, w, h) {
|
|
2062
|
+
if (!lo || !lo.rect) return;
|
|
2063
|
+
var newW = (w !== undefined) ? w : lo.rect.w;
|
|
2064
|
+
var newH = (h !== undefined) ? h : lo.rect.h;
|
|
2065
|
+
// Route through the uniform API so engine-visible mutation goes through one path.
|
|
2066
|
+
this.updateObjectProperty(lo, 'rect', [left, top, newW, newH]);
|
|
2067
|
+
};
|
|
2068
|
+
|
|
2069
|
+
// ── Canvas creation ────────────────────────────────────────────────────
|
|
2070
|
+
|
|
2071
|
+
/**
|
|
2072
|
+
* Create the canvas and attach it to the configured parent element.
|
|
2073
|
+
*
|
|
2074
|
+
* When the p5 renderer is active: MUST be called from p5's setup() context
|
|
2075
|
+
* (delegates to the p5 provider's init(), which calls createCanvas()).
|
|
2076
|
+
*
|
|
2077
|
+
* FAIL-LOUD: with no renderer provider resolved this THROWS naming
|
|
2078
|
+
* the boot module. There is no bare-p5 fallback — a page that draws through
|
|
2079
|
+
* an unresolved renderer is the silent-degradation this file's own doctrine
|
|
2080
|
+
* forbids.
|
|
2081
|
+
*
|
|
2082
|
+
* THE SCENE SURFACE IS DECLARED HERE. `provider.init` returns its drawing surface
|
|
2083
|
+
* (RENDERER_CONTRACT §1); this — the one mount site, so every provider, built-in or BYO,
|
|
2084
|
+
* passes through it — stamps `SCENE_SURFACE_ATTR` on it, so a reader finds the scene's
|
|
2085
|
+
* pixels by the marker instead of guessing among the document's canvases.
|
|
2086
|
+
*
|
|
2087
|
+
* @returns {HTMLCanvasElement} The provider's drawing surface, marked.
|
|
2088
|
+
* @throws {Error} when no RendererProvider is active, or `init` returned no canvas element.
|
|
2089
|
+
*/
|
|
2090
|
+
FrqtlApp.prototype.createCanvas = function () {
|
|
2091
|
+
this._createCanvasCalled = true; // from here init states the rect (see _getRenderer)
|
|
2092
|
+
var prov = this._getRenderer();
|
|
2093
|
+
if (prov && prov.init) {
|
|
2094
|
+
// New path: delegate to provider
|
|
2095
|
+
var initOpts = {
|
|
2096
|
+
parentEl: this._canvasParentId,
|
|
2097
|
+
width: this.canvas_rect.w,
|
|
2098
|
+
height: this.canvas_rect.h,
|
|
2099
|
+
is3D: this._is3D
|
|
2100
|
+
};
|
|
2101
|
+
if (this._depth !== null) { initOpts.depth = this._depth; }
|
|
2102
|
+
var contract = RendererProviderContract;
|
|
2103
|
+
var el = contract.requireSceneSurface('FrqtlApp.createCanvas', prov.init(initOpts));
|
|
2104
|
+
el.setAttribute(contract.SCENE_SURFACE_ATTR, '');
|
|
2105
|
+
this._sceneSurface = el; // the canvas; its parent is the default control host
|
|
2106
|
+
if (this._onReady) { this._onReady(this); }
|
|
2107
|
+
return el;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
throw new Error('[FrqtlApp.createCanvas] no RendererProvider is active — ' +
|
|
2111
|
+
'one is selected by the boot module (lib/frqtl/experiments/_shared/boot.js) — a page boots ' +
|
|
2112
|
+
'through bootPage(), a programmatic host awaits bootRealm() — before calling createCanvas().');
|
|
2113
|
+
};
|
|
2114
|
+
|
|
2115
|
+
// ── Experiment setup ───────────────────────────────────────────────────
|
|
2116
|
+
|
|
2117
|
+
/**
|
|
2118
|
+
* Run a setup factory (e.g. setupDoubleSlit) on this runner's universe: its world
|
|
2119
|
+
* step (`setupFn.initWorld`, when the factory exports one), then the factory itself.
|
|
2120
|
+
*
|
|
2121
|
+
* FACTORY STATICS — the declarations a factory exports for its runner, listed ONCE in
|
|
2122
|
+
* `FrqtlApp.FACTORY_STATICS`; a wrapper around a factory carries them with
|
|
2123
|
+
* `FrqtlApp.carryFactoryStatics(wrapper, factory)`:
|
|
2124
|
+
* - `initWorld(universe, opts)` — the world the factory's part implies, run FIRST.
|
|
2125
|
+
* A factory carrying the deleted `RATE_LIMITS` static THROWS here; pacing is
|
|
2126
|
+
* `runner.setRateLimits(...)` (a scene: `exp.runner.setRateLimits(...)`).
|
|
2127
|
+
*
|
|
2128
|
+
* CANONICAL TEARDOWN (the S4 dispose discipline,
|
|
2129
|
+
* generalized to setup-level reinit): `universe.init()` (called by the world step
|
|
2130
|
+
* below) resets the WASM instance's physics state but does NOT
|
|
2131
|
+
* clear the JS-side `universe.experimentTickFunctions` array — a fact
|
|
2132
|
+
* `fx-experiment.js`'s own loop-control docstring already names explicitly
|
|
2133
|
+
* ("NOT cleared by init() and would duplicate on every reset"), which is
|
|
2134
|
+
* why THAT mechanism never pushes a recurring tick-fn in the first place.
|
|
2135
|
+
* A setup factory that DOES register a recurring host tick-fn (e.g. a
|
|
2136
|
+
* continuous burst injector) for a LIVE, re-runnable experiment page must
|
|
2137
|
+
* therefore tear its OWN registration down before the next `setup()` call
|
|
2138
|
+
* installs a new one, or the OLD tick-fn keeps firing into the SAME array
|
|
2139
|
+
* forever, compounding the population across every reinit (the exact
|
|
2140
|
+
* "5730 → 8890 → 10160 → 10720" runaway the tester measured). Rather than
|
|
2141
|
+
* require every such page to remember a bespoke unhook, this ONE canonical
|
|
2142
|
+
* check-and-call — mirroring `removeObject`'s own `_dispose()` idiom
|
|
2143
|
+
* EXACTLY (any object with a `.dispose()` method gets it called,
|
|
2144
|
+
* idempotently, no kind-sniffing) — runs here, generically, for every
|
|
2145
|
+
* experiment page: a setup factory that returns `{dispose(){...}}` on its
|
|
2146
|
+
* `setupResult` gets automatic teardown-before-reinit for free. Every
|
|
2147
|
+
* EXISTING setup factory (none of which currently returns `.dispose`) is
|
|
2148
|
+
* byte-identical — this is a pure ADDITION, not a behavior change, for
|
|
2149
|
+
* anything that doesn't opt in.
|
|
2150
|
+
*/
|
|
2151
|
+
FrqtlApp.prototype.setup = function (setupFn, opts) {
|
|
2152
|
+
opts = opts || {};
|
|
2153
|
+
// A scene reset disarms the aim: a grabbed or selected object is about to be disposed.
|
|
2154
|
+
if (this._aim) { this.setAimArmed(false); }
|
|
2155
|
+
this._selected = null;
|
|
2156
|
+
if (this._setupResult && typeof this._setupResult.dispose === 'function') {
|
|
2157
|
+
this._setupResult.dispose();
|
|
2158
|
+
}
|
|
2159
|
+
// RETAINED so `restart()` can replay the SAME scene in-place — through the ONE retainer
|
|
2160
|
+
// (retainSceneReplay), which a sketch build sets the same way.
|
|
2161
|
+
var self = this;
|
|
2162
|
+
this.retainSceneReplay(function () { return self.setup(setupFn, opts); });
|
|
2163
|
+
// A fresh world has no declared objects (the factory's own parts are primitives).
|
|
2164
|
+
this.clearDeclared();
|
|
2165
|
+
// ONE BOOT SEQUENCE (#81): a factory that constructs a PART exports its implied
|
|
2166
|
+
// world as `setupFn.initWorld(universe, opts)`; the runner runs it FIRST, then the
|
|
2167
|
+
// part step. A factory that owns its world (setupDoubleSlit, the composites) exports
|
|
2168
|
+
// none and inits inside. Either way `restart()` — which re-enters here — replays the
|
|
2169
|
+
// whole scene from a fresh world, never the part alone on a populated one.
|
|
2170
|
+
_refuseDeletedStatics(setupFn, 'setup');
|
|
2171
|
+
if (typeof setupFn.initWorld === 'function') {
|
|
2172
|
+
setupFn.initWorld(this.universe, opts); // 1. WORLD
|
|
2173
|
+
}
|
|
2174
|
+
this._setupResult = setupFn(this.universe, opts); // 2. PART(S)
|
|
2175
|
+
// NO render-list assignment: a factory's LargeObjects registered themselves with
|
|
2176
|
+
// the world as they were CONSTRUCTED. The old line replaced the host's shadow list
|
|
2177
|
+
// with `setupResult.largeObjects`, which both required every factory to remember to
|
|
2178
|
+
// return the list AND silently dropped anything built outside it.
|
|
2179
|
+
return this._setupResult;
|
|
2180
|
+
};
|
|
2181
|
+
|
|
2182
|
+
/**
|
|
2183
|
+
* retainSceneReplay(fn) — the ONE retainer of "how this runner's scene was built", so
|
|
2184
|
+
* `restart()` can replay it. Whichever path built the scene sets it: `setup(factory, opts)`
|
|
2185
|
+
* retains its factory replay; `RecipeContext.build(sketch)` retains `exp.build(sketch)`. A later
|
|
2186
|
+
* build replaces the earlier retention (the runner holds ONE scene).
|
|
2187
|
+
* @param {Function} fn — replays the scene; its return value is restart()'s.
|
|
2188
|
+
*/
|
|
2189
|
+
FrqtlApp.prototype.retainSceneReplay = function (fn) {
|
|
2190
|
+
if (typeof fn !== 'function') {
|
|
2191
|
+
throw new TypeError('[FrqtlApp] retainSceneReplay(fn) takes the scene\'s replay function; got ' +
|
|
2192
|
+
(fn === null ? 'null' : typeof fn) + '. (FAIL-LOUD.)');
|
|
2193
|
+
}
|
|
2194
|
+
this._sceneReplay = fn;
|
|
2195
|
+
};
|
|
2196
|
+
|
|
2197
|
+
/**
|
|
2198
|
+
* restart() — the PAGE-LEVEL restart primitive (presentation pass Item 2,
|
|
2199
|
+
* E-P2b): stop → replay the RETAINED scene (`retainSceneReplay` — a setup
|
|
2200
|
+
* factory's world + part steps with its ORIGINAL opts, or a sketch's `exp.build(sketch)`
|
|
2201
|
+
* on the same runner and canvas) → start. The scene returns to tick 0 IN-PAGE: no reload,
|
|
2202
|
+
* so the camera orbit, render options and any host wiring survive. The declared `restart`
|
|
2203
|
+
* control (controlAction.js) routes here for every host.
|
|
2204
|
+
*
|
|
2205
|
+
* Teardown runs EXACTLY ONCE — the replay owns it (`setup()` calls the previous
|
|
2206
|
+
* setupResult's `dispose()`; a sketch build tears the previous scene down first —
|
|
2207
|
+
* RecipeContext.build), so this method must NOT dispose again (double teardown was the
|
|
2208
|
+
* stacking-regression this seam was built to avoid).
|
|
2209
|
+
*
|
|
2210
|
+
* DISTINCT BY DESIGN from STUDIO's codegen Restart (AuthorView), which rebuilds
|
|
2211
|
+
* a persisted DESIGN and reloads the page: that one is STUDIO chrome and stays
|
|
2212
|
+
* where it is. This one replays the already-loaded module.
|
|
2213
|
+
*
|
|
2214
|
+
* @returns {*} the replay's result (a setup result, or the sketch's facade).
|
|
2215
|
+
* @throws {Error} FAIL-LOUD when nothing built a scene on this runner (there is nothing
|
|
2216
|
+
* to replay) — never a silent no-op.
|
|
2217
|
+
*/
|
|
2218
|
+
FrqtlApp.prototype.restart = function () {
|
|
2219
|
+
if (typeof this._sceneReplay !== 'function') {
|
|
2220
|
+
throw new Error('[FrqtlApp] restart(): nothing to replay — no scene has been built on this ' +
|
|
2221
|
+
'runner. Build one first with runner.setup(factory, opts) or RecipeContext.build(sketch). ' +
|
|
2222
|
+
'(FAIL-LOUD — a restart with nothing to replay would silently do nothing.)');
|
|
2223
|
+
}
|
|
2224
|
+
this.stop();
|
|
2225
|
+
var result = this._sceneReplay();
|
|
2226
|
+
this._lastTickData = null;
|
|
2227
|
+
// Always resume: "restart" means run the scene again from tick 0 — a restart
|
|
2228
|
+
// that left the loop paused would read as a dead button.
|
|
2229
|
+
this.start();
|
|
2230
|
+
return result;
|
|
2231
|
+
};
|
|
2232
|
+
|
|
2233
|
+
// ── Renderer resolution ────────────────────────────────────────────────
|
|
2234
|
+
|
|
2235
|
+
/**
|
|
2236
|
+
* Resolve the DISPLAY renderer.
|
|
2237
|
+
* 1. this._renderer (already resolved this session, or set directly by a host/test)
|
|
2238
|
+
* 2. the DISPLAY SLOT (`compat/renderer/display-slot.js`) — the ONE answer to
|
|
2239
|
+
* "which provider owns the display", written by whichever boot ran the selection.
|
|
2240
|
+
*
|
|
2241
|
+
* The bare `window._egptActiveRenderer` global this used to read is DELETED: a slot
|
|
2242
|
+
* plus a global would be two truths about one fact (RENDERER_CONTRACT §9).
|
|
2243
|
+
*
|
|
2244
|
+
* FAIL-LOUD: if neither is set when rendering is requested, this is a boot
|
|
2245
|
+
* configuration error — the boot must select a renderer before the draw loop starts
|
|
2246
|
+
* (see `_failLoudMissingRenderer`). No silent P5Renderer fallback (P5Renderer was
|
|
2247
|
+
* deleted in Phase 1). An EMPTY slot is a legitimate state, not an error: the `tick`
|
|
2248
|
+
* and `field` taps are backend-independent and work with no display at all.
|
|
2249
|
+
*/
|
|
2250
|
+
FrqtlApp.prototype._getRenderer = function () {
|
|
2251
|
+
if (!this._renderer) {
|
|
2252
|
+
var prov = FrqtlDisplay.get();
|
|
2253
|
+
if (prov) {
|
|
2254
|
+
// ATTACH. Render options written before this point validate against THIS
|
|
2255
|
+
// provider now. A refusal throws and leaves the provider unattached (and untouched),
|
|
2256
|
+
// so every later draw raises it again instead of rendering without the knob.
|
|
2257
|
+
var validated = this._stagedRenderOptions
|
|
2258
|
+
? _validateRenderWrites(prov, this._stagedRenderOptions) : null;
|
|
2259
|
+
// ATTACH BEFORE createCanvas (a host that defers the canvas — the notebook realm's
|
|
2260
|
+
// mount seam owns createCanvas, so a recipe builds first): the provider has no size of
|
|
2261
|
+
// its own, and a scene may read it while building (runner.cameraControl frames the
|
|
2262
|
+
// view). The runner states ITS rect — the scene's co-init w/h — now; createCanvas's
|
|
2263
|
+
// init restates the same rect. After createCanvas, init alone states it.
|
|
2264
|
+
if (!this._createCanvasCalled) {
|
|
2265
|
+
prov.setSize(this.canvas_rect.w, this.canvas_rect.h);
|
|
2266
|
+
}
|
|
2267
|
+
this._renderer = prov;
|
|
2268
|
+
if (validated) {
|
|
2269
|
+
this._stagedRenderOptions = null;
|
|
2270
|
+
this._applyRenderWrites(validated);
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
return this._renderer || null;
|
|
2275
|
+
};
|
|
2276
|
+
|
|
2277
|
+
/**
|
|
2278
|
+
* runner.cameraControl — the OPTIONAL camera sugar handle from the active
|
|
2279
|
+
* renderer provider, or null. The camera analogue of a setup factory's
|
|
2280
|
+
* temperatureControl: an experiment page does
|
|
2281
|
+
* `var cam = runner.cameraControl; if (cam) wireWidget(cam);`
|
|
2282
|
+
* exactly as blackbody does `if (result.temperatureControl) …`. The presence
|
|
2283
|
+
* of the handle is the CAPABILITY CHECK — the page never sniffs the renderer.
|
|
2284
|
+
* Resolves lazily (after start(), when the provider is live) and caches the
|
|
2285
|
+
* non-null handle (a provider with no pose control re-checks cheaply; a custom provider
|
|
2286
|
+
* that implements getCameraControl() gets the same UI for free). The 2D p5 camera is a
|
|
2287
|
+
* handle too — whether a scene is 3D is `OrbitBag.hasAngularPose(runner.cameraControl)`,
|
|
2288
|
+
* never the handle's presence.
|
|
2289
|
+
* @returns {{ setOrbit(spec):void, getOrbit():Object }|null}
|
|
2290
|
+
*/
|
|
2291
|
+
Object.defineProperty(FrqtlApp.prototype, 'cameraControl', {
|
|
2292
|
+
configurable: true,
|
|
2293
|
+
get: function () {
|
|
2294
|
+
if (!this._cameraControl) {
|
|
2295
|
+
var r = this._getRenderer();
|
|
2296
|
+
if (r && typeof r.getCameraControl === 'function') {
|
|
2297
|
+
this._cameraControl = r.getCameraControl(); // null only with no pose control
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
return this._cameraControl || null;
|
|
2301
|
+
}
|
|
2302
|
+
});
|
|
2303
|
+
|
|
2304
|
+
/**
|
|
2305
|
+
* Report a missing renderer loudly; called from the draw loop when
|
|
2306
|
+
* _getRenderer() returns null on the first render attempt.
|
|
2307
|
+
*/
|
|
2308
|
+
FrqtlApp.prototype._failLoudMissingRenderer = function () {
|
|
2309
|
+
var msg = 'FrqtlApp: the DISPLAY SLOT is empty (FrqtlDisplay.get() === null).\n\n' +
|
|
2310
|
+
'The boot module (lib/frqtl/experiments/_shared/boot.js) must select a renderer\n' +
|
|
2311
|
+
'BEFORE the draw loop fires. This is a page configuration error.\n\n' +
|
|
2312
|
+
'P5Renderer has been removed (WS-RENDERER Phase 1). All experiment pages\n' +
|
|
2313
|
+
'boot the canonical RendererProvider stack through bootPage().';
|
|
2314
|
+
if (typeof document === 'undefined') {
|
|
2315
|
+
// No DOM to paint the overlay into — the message is still LOUD, on the console
|
|
2316
|
+
// this host does have. Never silent.
|
|
2317
|
+
if (typeof console !== 'undefined' && console.error) { console.error('[FrqtlApp] ' + msg); }
|
|
2318
|
+
} else {
|
|
2319
|
+
paintBootFailure('renderer', msg); // THE one boot-failure painter (console-loud too)
|
|
2320
|
+
}
|
|
2321
|
+
/* Stop the loop to prevent repeated error overlays */
|
|
2322
|
+
this._isLooping = false;
|
|
2323
|
+
if (this._rafId !== null) {
|
|
2324
|
+
cancelAnimationFrame(this._rafId);
|
|
2325
|
+
this._rafId = null;
|
|
2326
|
+
}
|
|
2327
|
+
};
|
|
2328
|
+
|
|
2329
|
+
// ── RAF loop (§6, §6.1) ───────────────────────────────────────────────
|
|
2330
|
+
|
|
2331
|
+
/**
|
|
2332
|
+
* Build and return the inner RAF frame callback for this runner.
|
|
2333
|
+
* Shared by start() and toggle() — one definition, no drift.
|
|
2334
|
+
*
|
|
2335
|
+
* When tickHz === Infinity the loop runs up to MAX_TICKS_UNBOUNDED ticks
|
|
2336
|
+
* per animation frame (unbounded TPS mode). In all other cases the normal
|
|
2337
|
+
* MAX_TICKS_PER_FRAME cap applies and a warning fires if it is hit.
|
|
2338
|
+
*/
|
|
2339
|
+
FrqtlApp.prototype._makeFrame = function () {
|
|
2340
|
+
var self = this;
|
|
2341
|
+
function frame(now) {
|
|
2342
|
+
if (!self._isLooping) { return; }
|
|
2343
|
+
self._rafId = requestAnimationFrame(frame);
|
|
2344
|
+
|
|
2345
|
+
// ── Fixed-timestep accumulator (§6.1) ──────────────────────────
|
|
2346
|
+
if (self._lastFrame === 0) { self._lastFrame = now; }
|
|
2347
|
+
var delta = Math.min(now - self._lastFrame, MAX_FRAME_MS);
|
|
2348
|
+
self._lastFrame = now;
|
|
2349
|
+
self._accumulator += delta;
|
|
2350
|
+
|
|
2351
|
+
// Resolve effective tickDt and drawDt for this frame
|
|
2352
|
+
var tickHz = self._tickHz;
|
|
2353
|
+
var drawHz = self._drawHz;
|
|
2354
|
+
var tickDt, drawDt;
|
|
2355
|
+
|
|
2356
|
+
if (tickHz === Infinity) {
|
|
2357
|
+
// Unbounded TPS: run as many ticks per frame as the safety budget allows.
|
|
2358
|
+
// tickDt = 0 keeps the while-condition always true until budget runs out.
|
|
2359
|
+
tickDt = 0;
|
|
2360
|
+
} else if (tickHz > 0) {
|
|
2361
|
+
tickDt = 1000 / tickHz;
|
|
2362
|
+
} else {
|
|
2363
|
+
// Lock to drawHz (1:1 default): advance exactly one tick per draw.
|
|
2364
|
+
tickDt = delta;
|
|
2365
|
+
self._accumulator = delta; // override accumulator to guarantee 1 tick/frame
|
|
2366
|
+
}
|
|
2367
|
+
drawDt = (drawHz > 0) ? (1000 / drawHz) : 0;
|
|
2368
|
+
|
|
2369
|
+
// ── Physics ticks ───────────────────────────────────────────────
|
|
2370
|
+
var lastTickData = null;
|
|
2371
|
+
var budget = (tickHz === Infinity) ? MAX_TICKS_UNBOUNDED : MAX_TICKS_PER_FRAME;
|
|
2372
|
+
// `_tickSuspend` is checked PER TICK, not once per frame: at unbounded TPS this
|
|
2373
|
+
// loop runs a whole batch of ticks inside ONE rAF frame, so a suspend raised from
|
|
2374
|
+
// inside a tick listener must break the batch IMMEDIATELY. Checking it only at the
|
|
2375
|
+
// top of the frame is exactly what let ~120 ticks elapse between a waypoint and an
|
|
2376
|
+
// awaited capture.
|
|
2377
|
+
while ((tickDt === 0 || self._accumulator >= tickDt) && budget-- > 0 && self._tickSuspend === 0 && self._isLooping) {
|
|
2378
|
+
var td = self.universe.doTick();
|
|
2379
|
+
lastTickData = td;
|
|
2380
|
+
self._lastTickData = td; // repaint()'s source — see that method's docstring
|
|
2381
|
+
if (tickDt > 0) { self._accumulator -= tickDt; }
|
|
2382
|
+
self._countTick(now);
|
|
2383
|
+
if (self._onTick) { self._onTick(td, self._setupResult); }
|
|
2384
|
+
self._notifyTickListeners(td);
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
// Budget exhaustion (persistent slow physics). This fires on nearly every
|
|
2388
|
+
// frame whenever the render/tick budget can't keep up, so the warning is
|
|
2389
|
+
// MUTED BY DEFAULT — it was flooding the console once per frame. It surfaces
|
|
2390
|
+
// ONLY when the caller opts into verbose logging (FrqtlApp.verbose = true)
|
|
2391
|
+
// AND at most once per _BUDGET_WARN_INTERVAL_MS so even verbose runs stay
|
|
2392
|
+
// readable. The accumulator reset (the actual correctness action) is unchanged.
|
|
2393
|
+
if (budget <= 0 && tickDt > 0 && self._accumulator >= tickDt) {
|
|
2394
|
+
if (FrqtlApp.verbose === true && typeof console !== 'undefined' && console.warn) {
|
|
2395
|
+
var _lastWarn = self._lastBudgetWarn || 0;
|
|
2396
|
+
if (now - _lastWarn >= self._BUDGET_WARN_INTERVAL_MS) {
|
|
2397
|
+
self._lastBudgetWarn = now;
|
|
2398
|
+
console.warn('[FrqtlApp] tick budget exhausted — physics may be falling behind');
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
self._accumulator = 0; // reset to avoid unbounded catch-up
|
|
2402
|
+
}
|
|
2403
|
+
// In unbounded mode, always drain the accumulator so it does not compound.
|
|
2404
|
+
if (tickHz === Infinity) { self._accumulator = 0; }
|
|
2405
|
+
|
|
2406
|
+
// ── Draw frame ──────────────────────────────────────────────────
|
|
2407
|
+
var shouldDraw = drawDt > 0
|
|
2408
|
+
? (now - self._lastDraw) >= drawDt
|
|
2409
|
+
: true; // no drawHz cap → draw every frame
|
|
2410
|
+
|
|
2411
|
+
if (lastTickData && shouldDraw) {
|
|
2412
|
+
var prov = self._getRenderer();
|
|
2413
|
+
if (prov && prov.renderTick) {
|
|
2414
|
+
self._assertRenderCensus();
|
|
2415
|
+
prov.renderTick(lastTickData, self._visibleLargeObjects(), self._renderOptsWithFeedback());
|
|
2416
|
+
} else if (!self._rendererMissingReported) {
|
|
2417
|
+
self._rendererMissingReported = true;
|
|
2418
|
+
self._failLoudMissingRenderer();
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
2421
|
+
self._countDraw(now);
|
|
2422
|
+
self._lastDraw = now;
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
return frame;
|
|
2426
|
+
};
|
|
2427
|
+
|
|
2428
|
+
/**
|
|
2429
|
+
* Start the simulation loop.
|
|
2430
|
+
* Idempotent — calling start() while already running does nothing.
|
|
2431
|
+
* Owns requestAnimationFrame — never calls p5 loop(). The runner's loop co-exists with a
|
|
2432
|
+
* p5 global-mode page: p5's own draw() is made a no-op (compat/vendor.js suppressP5Draw)
|
|
2433
|
+
* so p5 does not double-render. The started runner becomes the page's runner (the page
|
|
2434
|
+
* door's `FrqtlPage.runner`, read through `pageRunner()`).
|
|
2435
|
+
*/
|
|
2436
|
+
FrqtlApp.prototype.start = function () {
|
|
2437
|
+
// Idempotency guard: if a RAF loop is already running, do nothing.
|
|
2438
|
+
// Without this guard, a second start() call spawns a second concurrent loop,
|
|
2439
|
+
// doubling the tick rate (observable as TPS × 2 on the readout).
|
|
2440
|
+
if (this._isLooping || this._rafId !== null) { return; }
|
|
2441
|
+
_requireAnimationFrame('start');
|
|
2442
|
+
|
|
2443
|
+
// Suppress p5's own draw loop so it doesn't double-render while our RAF loop runs.
|
|
2444
|
+
suppressP5Draw();
|
|
2445
|
+
// The live runner is the PAGE's runner, so any experiment page (and same-origin
|
|
2446
|
+
// embedders such as the IDE author surface, through the page door) drives it
|
|
2447
|
+
// identically — Layers toggles, backdrop controls, inspector edits — without per-page wiring.
|
|
2448
|
+
setPageRunner(this);
|
|
2449
|
+
|
|
2450
|
+
this._isLooping = true;
|
|
2451
|
+
this._accumulator = 0;
|
|
2452
|
+
this._lastFrame = 0;
|
|
2453
|
+
this._lastDraw = 0;
|
|
2454
|
+
this._rateWindowEnd = 0;
|
|
2455
|
+
this._tickCount = 0;
|
|
2456
|
+
this._drawCount = 0;
|
|
2457
|
+
this._lastTps = 0;
|
|
2458
|
+
this._lastFps = 0;
|
|
2459
|
+
|
|
2460
|
+
this._rafId = requestAnimationFrame(this._makeFrame());
|
|
2461
|
+
|
|
2462
|
+
// Also call p5's loop() for pages that still rely on p5 being "running"
|
|
2463
|
+
// (e.g. mousePressed etc. — p5 must be in a running state for event handling).
|
|
2464
|
+
resumeP5Loop();
|
|
2465
|
+
};
|
|
2466
|
+
|
|
2467
|
+
/**
|
|
2468
|
+
* runner.suspendTicks() / resumeTicks() — hold the TICK CLOCK across an async operation
|
|
2469
|
+
* that must observe one specific tick, without touching the user's play/pause state.
|
|
2470
|
+
*
|
|
2471
|
+
* A counted pair (nested suspends are safe; the clock resumes when the LAST one releases).
|
|
2472
|
+
* The batch loop checks the depth PER TICK, so a suspend raised from inside a tick
|
|
2473
|
+
* listener breaks the current batch immediately rather than at the next frame — which is
|
|
2474
|
+
* the whole point: at unbounded TPS a single rAF frame runs a hundred-plus ticks, so
|
|
2475
|
+
* "stop at the next frame" is far too late for anything that means "at THIS tick".
|
|
2476
|
+
*
|
|
2477
|
+
* This is deliberately NOT `stop()`: `stop()` is the user's pause, and a consumer that
|
|
2478
|
+
* borrowed it would leave the play button lying about the run state and could
|
|
2479
|
+
* accidentally restart a sim the user had paused. Physics determinism is untouched — the
|
|
2480
|
+
* tick SEQUENCE is identical either way; only wall-clock pacing changes.
|
|
2481
|
+
*
|
|
2482
|
+
* The canonical consumer is a capture that must correspond to a known tick
|
|
2483
|
+
* (`CameraPath`'s waypoint handlers). Always release in a `finally`.
|
|
2484
|
+
*/
|
|
2485
|
+
FrqtlApp.prototype.suspendTicks = function () {
|
|
2486
|
+
this._tickSuspend += 1;
|
|
2487
|
+
return this._tickSuspend;
|
|
2488
|
+
};
|
|
2489
|
+
|
|
2490
|
+
/** Release one suspend. Never goes below zero (an unbalanced resume is a no-op, not a debt). */
|
|
2491
|
+
FrqtlApp.prototype.resumeTicks = function () {
|
|
2492
|
+
if (this._tickSuspend > 0) { this._tickSuspend -= 1; }
|
|
2493
|
+
return this._tickSuspend;
|
|
2494
|
+
};
|
|
2495
|
+
|
|
2496
|
+
/** @returns {boolean} whether the tick clock is currently held. */
|
|
2497
|
+
Object.defineProperty(FrqtlApp.prototype, 'ticksSuspended', {
|
|
2498
|
+
configurable: true,
|
|
2499
|
+
get: function () { return this._tickSuspend > 0; }
|
|
2500
|
+
});
|
|
2501
|
+
|
|
2502
|
+
/**
|
|
2503
|
+
* Pause the simulation.
|
|
2504
|
+
* Honoured PER TICK: a stop() issued from a tick-fn or tick listener ends the batch after
|
|
2505
|
+
* the current tick — that tick completes and the frame's post-batch draw paints it.
|
|
2506
|
+
*/
|
|
2507
|
+
FrqtlApp.prototype.stop = function () {
|
|
2508
|
+
this._isLooping = false;
|
|
2509
|
+
if (this._rafId !== null) {
|
|
2510
|
+
cancelAnimationFrame(this._rafId);
|
|
2511
|
+
this._rafId = null;
|
|
2512
|
+
}
|
|
2513
|
+
// Keep p5's loop running for event handling but suppress its draw.
|
|
2514
|
+
// (window.draw is already a no-op from start(); keep it that way.)
|
|
2515
|
+
};
|
|
2516
|
+
|
|
2517
|
+
/**
|
|
2518
|
+
* dispose() — the runner is done: stop its loop and, if it is the page's runner, the page has none
|
|
2519
|
+
* any more (FrqtlPage.runner reads null). Only THIS runner is cleared: a newer runner that already
|
|
2520
|
+
* started stays the page's runner.
|
|
2521
|
+
*/
|
|
2522
|
+
FrqtlApp.prototype.dispose = function () {
|
|
2523
|
+
this.stop();
|
|
2524
|
+
if (_pageRunner === this) { _pageRunner = null; }
|
|
2525
|
+
};
|
|
2526
|
+
|
|
2527
|
+
/**
|
|
2528
|
+
* Toggle play/pause.
|
|
2529
|
+
* Resume path re-uses _makeFrame() — same loop body as start(), no drift.
|
|
2530
|
+
*/
|
|
2531
|
+
FrqtlApp.prototype.toggle = function () {
|
|
2532
|
+
if (this._isLooping) {
|
|
2533
|
+
this.stop();
|
|
2534
|
+
} else {
|
|
2535
|
+
_requireAnimationFrame('toggle');
|
|
2536
|
+
// Guard: if somehow a stale _rafId is present, cancel it first.
|
|
2537
|
+
if (this._rafId !== null) {
|
|
2538
|
+
cancelAnimationFrame(this._rafId);
|
|
2539
|
+
this._rafId = null;
|
|
2540
|
+
}
|
|
2541
|
+
this._isLooping = true;
|
|
2542
|
+
this._lastFrame = 0; // reset timer on resume to avoid catch-up spike
|
|
2543
|
+
this._rafId = requestAnimationFrame(this._makeFrame());
|
|
2544
|
+
}
|
|
2545
|
+
};
|
|
2546
|
+
|
|
2547
|
+
/**
|
|
2548
|
+
* Advance exactly one tick + render. Useful for step-by-step debugging.
|
|
2549
|
+
*/
|
|
2550
|
+
FrqtlApp.prototype.step = function () {
|
|
2551
|
+
// THE SECOND TICK DRIVER. `doTick()` is called in exactly two places — the RAF batch
|
|
2552
|
+
// loop and here — and a suspension that only the loop honoured would be a half-closed
|
|
2553
|
+
// guarantee: the notebook realm, hosted eyes and the Studio Design path drive ticks
|
|
2554
|
+
// through step(), and any of them could advance the world out from under an in-flight
|
|
2555
|
+
// capture-at-a-known-tick. Suspended ⇒ THROW. A step that silently did nothing would
|
|
2556
|
+
// let a driver count ticks it never ran (a warm-up of N steps landing short, a drive
|
|
2557
|
+
// "to tick n" stamped with n-1's world) — the caller waits for `ticksSuspended` to
|
|
2558
|
+
// clear, then steps.
|
|
2559
|
+
if (this._tickSuspend > 0) {
|
|
2560
|
+
throw new Error('[FrqtlApp.step] the tick clock is HELD (runner.ticksSuspended is ' +
|
|
2561
|
+
'true, depth ' + this._tickSuspend + '): a capture or waypoint handler is ' +
|
|
2562
|
+
'observing the current tick, and stepping now would advance the world out from ' +
|
|
2563
|
+
'under it. Wait until runner.ticksSuspended is false, then step. (FAIL-LOUD — a ' +
|
|
2564
|
+
'step that silently did nothing would be a tick the driver believes it ran.)');
|
|
2565
|
+
}
|
|
2566
|
+
var tickData = this.universe.doTick();
|
|
2567
|
+
this._lastTickData = tickData; // repaint()'s source — see that method's docstring
|
|
2568
|
+
if (tickData) {
|
|
2569
|
+
var prov = this._getRenderer();
|
|
2570
|
+
if (prov && prov.renderTick) {
|
|
2571
|
+
this._assertRenderCensus();
|
|
2572
|
+
prov.renderTick(tickData, this._visibleLargeObjects(), this._renderOptsWithFeedback());
|
|
2573
|
+
} else if (!this._rendererMissingReported) {
|
|
2574
|
+
this._rendererMissingReported = true;
|
|
2575
|
+
this._failLoudMissingRenderer();
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
if (this._onTick) { this._onTick(tickData, this._setupResult); }
|
|
2579
|
+
this._notifyTickListeners(tickData);
|
|
2580
|
+
return tickData;
|
|
2581
|
+
};
|
|
2582
|
+
|
|
2583
|
+
/**
|
|
2584
|
+
* addTickListener(fn) — subscribe to the per-tick stream WITHOUT clobbering the
|
|
2585
|
+
* page's single `_onTick` slot. `fn(tickData, setupResult)` runs after `_onTick`
|
|
2586
|
+
* at every tick (RAF loop + step()). Returns an unsubscribe function.
|
|
2587
|
+
*
|
|
2588
|
+
* Host-side sugar for the LiveChart wiring (lib/components/live-chart-wiring):
|
|
2589
|
+
* the wiring accumulates its source wall's collided frames once per tick here.
|
|
2590
|
+
* Idempotent (same fn added once); safe to call before start().
|
|
2591
|
+
* @param {Function} fn
|
|
2592
|
+
* @returns {Function} unsubscribe
|
|
2593
|
+
*/
|
|
2594
|
+
FrqtlApp.prototype.addTickListener = function (fn) {
|
|
2595
|
+
if (typeof fn !== 'function') { return function () {}; }
|
|
2596
|
+
if (this._tickListeners.indexOf(fn) === -1) { this._tickListeners.push(fn); }
|
|
2597
|
+
var self = this;
|
|
2598
|
+
return function () { self.removeTickListener(fn); };
|
|
2599
|
+
};
|
|
2600
|
+
|
|
2601
|
+
/**
|
|
2602
|
+
* firstTick({ deadlineMs }) — a promise for the FIRST tick this runner DELIVERS after the
|
|
2603
|
+
* call, bounded by a liveness deadline measured on the runner's OWN clock.
|
|
2604
|
+
*
|
|
2605
|
+
* READINESS IS THE RUNNER'S. A host (the SDK's bootScene/bootRecipe sketch path, a hosted-eyes
|
|
2606
|
+
* driver, a recipe bundle) that wants to know "is this scene live" awaits this instead of
|
|
2607
|
+
* deriving its own answer from `start()` having returned — `start()` schedules a frame; it
|
|
2608
|
+
* delivers nothing. The promise resolves from `_notifyTickListeners` (a tick that RAN), so
|
|
2609
|
+
* a universe that throws in `doTick()` never resolves it.
|
|
2610
|
+
*
|
|
2611
|
+
* THE DEADLINE RUNS ONLY WHILE THE DOCUMENT IS VISIBLE. The tick clock is
|
|
2612
|
+
* requestAnimationFrame (`_makeFrame`), which browsers suspend in a hidden document — a
|
|
2613
|
+
* background tab, a minimised window, an offscreen cross-origin iframe (the claude.ai
|
|
2614
|
+
* artifact case). A wall-clock bound would expire on a HEALTHY runner that was merely
|
|
2615
|
+
* hidden, and a gate that fires on correct behaviour trains everyone to ignore it. So the
|
|
2616
|
+
* countdown PAUSES on `visibilitychange` → hidden (remaining time kept) and resumes when
|
|
2617
|
+
* visible; a runner started hidden waits, however long, for its first visible frame.
|
|
2618
|
+
*
|
|
2619
|
+
* On expiry — `deadlineMs` of VISIBLE time with no delivered tick — the runner STOPS itself
|
|
2620
|
+
* (a loop whose ticks throw would otherwise raise an uncaught error on every frame forever)
|
|
2621
|
+
* and the promise REJECTS naming the runner, its seed, its canvas and the deadline. Never a
|
|
2622
|
+
* resolve: a scene that never ticked is not live, and no caller may be told it is.
|
|
2623
|
+
*
|
|
2624
|
+
* @param {{deadlineMs:number}} opts — `deadlineMs` is REQUIRED (> 0): a promise with no bound
|
|
2625
|
+
* hangs forever on a dead clock, and a hang is silence. FAIL-LOUD on an absent/invalid value.
|
|
2626
|
+
* @returns {Promise<object>} resolves with the first delivered TickData.
|
|
2627
|
+
*/
|
|
2628
|
+
FrqtlApp.prototype.firstTick = function (opts) {
|
|
2629
|
+
var deadlineMs = opts && opts.deadlineMs;
|
|
2630
|
+
if (typeof deadlineMs !== 'number' || !isFinite(deadlineMs) || deadlineMs <= 0) {
|
|
2631
|
+
throw new Error('[FrqtlApp.firstTick] opts.deadlineMs must be a positive number of ms (got ' +
|
|
2632
|
+
String(deadlineMs) + '). A first-tick wait with no bound hangs forever on a dead tick clock.');
|
|
2633
|
+
}
|
|
2634
|
+
var self = this;
|
|
2635
|
+
var doc = (typeof document !== 'undefined') ? document : null;
|
|
2636
|
+
return new Promise(function (resolve, reject) {
|
|
2637
|
+
var settled = false;
|
|
2638
|
+
var timer = null;
|
|
2639
|
+
var remainingMs = deadlineMs;
|
|
2640
|
+
var armedAt = 0;
|
|
2641
|
+
var unsubscribe = null;
|
|
2642
|
+
function isVisible() { return !doc || doc.visibilityState === 'visible'; }
|
|
2643
|
+
function disarm() {
|
|
2644
|
+
if (timer === null) { return; }
|
|
2645
|
+
clearTimeout(timer);
|
|
2646
|
+
timer = null;
|
|
2647
|
+
remainingMs = Math.max(0, remainingMs - (Date.now() - armedAt));
|
|
2648
|
+
}
|
|
2649
|
+
function settle() {
|
|
2650
|
+
settled = true;
|
|
2651
|
+
disarm();
|
|
2652
|
+
if (unsubscribe) { unsubscribe(); }
|
|
2653
|
+
if (doc) { doc.removeEventListener('visibilitychange', onVisibility); }
|
|
2654
|
+
}
|
|
2655
|
+
function expire() {
|
|
2656
|
+
timer = null;
|
|
2657
|
+
if (settled) { return; }
|
|
2658
|
+
settle();
|
|
2659
|
+
self.stop();
|
|
2660
|
+
reject(new Error('[FrqtlApp.firstTick] the runner (FrqtlPage.runner, FrqtlApp seed ' +
|
|
2661
|
+
self._seed + ', canvas ' + self.canvas_rect.w + 'x' + self.canvas_rect.h + ') was started ' +
|
|
2662
|
+
'but delivered no tick within ' + deadlineMs + ' ms of visible time — the scene is NOT ' +
|
|
2663
|
+
'live; the runner has been stopped. A per-frame error in the tick loop (see the page ' +
|
|
2664
|
+
'console) or a held tick clock (runner.ticksSuspended) is the usual cause.'));
|
|
2665
|
+
}
|
|
2666
|
+
function arm() {
|
|
2667
|
+
if (settled || timer !== null || !isVisible()) { return; }
|
|
2668
|
+
armedAt = Date.now();
|
|
2669
|
+
timer = setTimeout(expire, remainingMs);
|
|
2670
|
+
}
|
|
2671
|
+
function onVisibility() { if (isVisible()) { arm(); } else { disarm(); } }
|
|
2672
|
+
unsubscribe = self.addTickListener(function (tickData) {
|
|
2673
|
+
if (settled) { return; }
|
|
2674
|
+
settle();
|
|
2675
|
+
resolve(tickData);
|
|
2676
|
+
});
|
|
2677
|
+
if (doc) { doc.addEventListener('visibilitychange', onVisibility); }
|
|
2678
|
+
arm();
|
|
2679
|
+
});
|
|
2680
|
+
};
|
|
2681
|
+
|
|
2682
|
+
/**
|
|
2683
|
+
* removeTickListener(fn) — drop a previously-added tick listener. No-op if absent.
|
|
2684
|
+
* @param {Function} fn
|
|
2685
|
+
*/
|
|
2686
|
+
FrqtlApp.prototype.removeTickListener = function (fn) {
|
|
2687
|
+
var i = this._tickListeners.indexOf(fn);
|
|
2688
|
+
if (i !== -1) { this._tickListeners.splice(i, 1); }
|
|
2689
|
+
};
|
|
2690
|
+
|
|
2691
|
+
/**
|
|
2692
|
+
* Fan out one tick to every registered listener. A listener throwing does NOT
|
|
2693
|
+
* abort the loop or the other listeners (its error is surfaced to the console,
|
|
2694
|
+
* never swallowed silently) — the tick clock is never held hostage by a consumer.
|
|
2695
|
+
*
|
|
2696
|
+
* The set dispatched is the set AT TICK START (a snapshot): an unsubscribe during
|
|
2697
|
+
* dispatch takes effect from the next tick, and a subscribe during dispatch fires
|
|
2698
|
+
* from the next tick. No listener's coverage depends on a sibling's bookkeeping.
|
|
2699
|
+
* @private
|
|
2700
|
+
*/
|
|
2701
|
+
FrqtlApp.prototype._notifyTickListeners = function (tickData) {
|
|
2702
|
+
if (!this._tickListeners || !this._tickListeners.length) { return; }
|
|
2703
|
+
var ls = this._tickListeners.slice();
|
|
2704
|
+
for (var i = 0; i < ls.length; i++) {
|
|
2705
|
+
try { ls[i](tickData, this._setupResult); }
|
|
2706
|
+
catch (e) {
|
|
2707
|
+
if (typeof console !== 'undefined' && console.error) {
|
|
2708
|
+
console.error('[FrqtlApp] tick listener threw:', e);
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
};
|
|
2713
|
+
|
|
2714
|
+
/**
|
|
2715
|
+
* Repaint the CURRENT physics state WITHOUT advancing the tick clock (WS
|
|
2716
|
+
* Studio Controls Realignment S4, design §2.5.4 — "drag must NEVER call
|
|
2717
|
+
* runner.step() — the tick loop is the only clock"). Re-invokes the active
|
|
2718
|
+
* renderer's `renderTick` against the LAST computed TickData (tracked by
|
|
2719
|
+
* `step()` and the RAF loop) and the CURRENT world census — so a
|
|
2720
|
+
* Design-mode drag (sim stopped, no RAF loop running) still gives immediate
|
|
2721
|
+
* visual feedback for a moved wall/emitter rect (the wrapper's `.rect` was
|
|
2722
|
+
* already mutated in place by `updateObjectProperty` BEFORE this is called)
|
|
2723
|
+
* without ticking physics or drawing a new frame's worth of particle motion.
|
|
2724
|
+
* A no-op before the first tick has ever run (`_lastTickData` is null).
|
|
2725
|
+
*/
|
|
2726
|
+
FrqtlApp.prototype.repaint = function () {
|
|
2727
|
+
if (!this._lastTickData) { return; }
|
|
2728
|
+
var prov = this._getRenderer();
|
|
2729
|
+
if (prov && prov.renderTick) {
|
|
2730
|
+
this._assertRenderCensus();
|
|
2731
|
+
prov.renderTick(this._lastTickData, this._visibleLargeObjects(), this._renderOptsWithFeedback());
|
|
2732
|
+
}
|
|
2733
|
+
};
|
|
2734
|
+
|
|
2735
|
+
// ── Accessors ──────────────────────────────────────────────────────────
|
|
2736
|
+
|
|
2737
|
+
Object.defineProperty(FrqtlApp.prototype, 'isRunning', {
|
|
2738
|
+
get: function () { return this._isLooping; }
|
|
2739
|
+
});
|
|
2740
|
+
|
|
2741
|
+
Object.defineProperty(FrqtlApp.prototype, 'setupResult', {
|
|
2742
|
+
get: function () { return this._setupResult; }
|
|
2743
|
+
});
|
|
2744
|
+
|
|
2745
|
+
/**
|
|
2746
|
+
* The world bounds, on all three axes — the ONE read consumers use for "how big /
|
|
2747
|
+
* how deep is this scene" (e.g. the palette Wall drop's full-scene-depth default,
|
|
2748
|
+
* R#101). A pure two-source FERRY — both bags are built by wasm-engine.js's
|
|
2749
|
+
* `_wrapBoundaryRect` (THE ONE engine-bounds → JS bag normalizer,
|
|
2750
|
+
* {x,y,z,w,h,d,left,right,top,bottom,near,far,r}); this getter builds nothing:
|
|
2751
|
+
* 1. the engine-owned TickData ferry once a tick has run — the authority, since
|
|
2752
|
+
* it tracks live resizes;
|
|
2753
|
+
* 2. before the first tick (design-mode drops happen at tick 0), the wrapper's
|
|
2754
|
+
* init-time declaration `universe.initBounds` (truth-in-labeling read-back at
|
|
2755
|
+
* the init site, gated by init-bounds-conformance.test.mjs).
|
|
2756
|
+
* `null` when the wrapper published no init bounds and no tick has run.
|
|
2757
|
+
*/
|
|
2758
|
+
Object.defineProperty(FrqtlApp.prototype, 'worldBounds', {
|
|
2759
|
+
get: function () {
|
|
2760
|
+
if (this._lastTickData) { return this._lastTickData.universeData.boundaryRect; }
|
|
2761
|
+
var u = this.universe;
|
|
2762
|
+
return (u && u.initBounds) || null;
|
|
2763
|
+
}
|
|
2764
|
+
});
|
|
2765
|
+
|
|
2766
|
+
/**
|
|
2767
|
+
* The active renderer provider (RendererProvider | null).
|
|
2768
|
+
* Available after start() has been called (or after the display slot is written).
|
|
2769
|
+
*/
|
|
2770
|
+
Object.defineProperty(FrqtlApp.prototype, 'renderer', {
|
|
2771
|
+
get: function () { return this._getRenderer(); }
|
|
2772
|
+
});
|
|
2773
|
+
|
|
2774
|
+
/**
|
|
2775
|
+
* Return the most recently computed rate snapshot.
|
|
2776
|
+
*
|
|
2777
|
+
* Rates are recomputed once per ~1 s rolling window (no per-frame allocation;
|
|
2778
|
+
* integers only). Returns { fps: 0, tps: 0 } until the first window expires.
|
|
2779
|
+
*
|
|
2780
|
+
* @returns {{ fps: number, tps: number }}
|
|
2781
|
+
*/
|
|
2782
|
+
FrqtlApp.prototype.getRates = function () {
|
|
2783
|
+
return { fps: this._lastFps, tps: this._lastTps };
|
|
2784
|
+
};
|
|
2785
|
+
|
|
2786
|
+
/**
|
|
2787
|
+
* Read the current rate limits.
|
|
2788
|
+
*
|
|
2789
|
+
* fpsLimit — draw frames per second cap (0 = lock to display refresh, i.e. no cap).
|
|
2790
|
+
* tpsLimit — physics ticks per second cap:
|
|
2791
|
+
* 0 = 1:1 locked to drawHz (one tick per draw frame, the default)
|
|
2792
|
+
* > 0 = tick at that Hz (e.g. 240)
|
|
2793
|
+
* Infinity = unbounded — run up to MAX_TICKS_UNBOUNDED ticks per frame
|
|
2794
|
+
*
|
|
2795
|
+
* @returns {{ fpsLimit: number, tpsLimit: number }}
|
|
2796
|
+
*/
|
|
2797
|
+
FrqtlApp.prototype.getRateLimits = function () {
|
|
2798
|
+
return { fpsLimit: this._drawHz, tpsLimit: this._tickHz };
|
|
2799
|
+
};
|
|
2800
|
+
|
|
2801
|
+
/**
|
|
2802
|
+
* Set rate limits at runtime. Changes take effect on the next RAF frame
|
|
2803
|
+
* (no restart required).
|
|
2804
|
+
*
|
|
2805
|
+
* @param {Object} limits
|
|
2806
|
+
* @param {number} [limits.fpsLimit] Draw frames/s cap. 0 = display refresh.
|
|
2807
|
+
* Must be ≥ 0.
|
|
2808
|
+
* @param {number} [limits.tpsLimit] Physics ticks/s cap.
|
|
2809
|
+
* 0 = 1:1 locked (default).
|
|
2810
|
+
* > 0 = tick at that Hz.
|
|
2811
|
+
* Infinity = unbounded (up to safety budget).
|
|
2812
|
+
* Must be ≥ 0 (or Infinity).
|
|
2813
|
+
*
|
|
2814
|
+
* Throws a RangeError on invalid inputs and a TypeError on a key outside
|
|
2815
|
+
* `FrqtlApp.RATE_LIMIT_KEYS` (FAIL-LOUD).
|
|
2816
|
+
*/
|
|
2817
|
+
FrqtlApp.prototype.setRateLimits = function (limits) {
|
|
2818
|
+
if (!limits || typeof limits !== 'object') {
|
|
2819
|
+
throw new TypeError('[FrqtlApp.setRateLimits] limits must be an object');
|
|
2820
|
+
}
|
|
2821
|
+
Object.keys(limits).forEach(function (k) {
|
|
2822
|
+
if (RATE_LIMIT_KEYS.indexOf(k) === -1) {
|
|
2823
|
+
throw new TypeError('[FrqtlApp.setRateLimits] unknown rate key "' + k +
|
|
2824
|
+
'" — accepted keys: ' + RATE_LIMIT_KEYS.join(', ') + ' (FAIL-LOUD).');
|
|
2825
|
+
}
|
|
2826
|
+
});
|
|
2827
|
+
if (limits.fpsLimit !== undefined) {
|
|
2828
|
+
var fps = limits.fpsLimit;
|
|
2829
|
+
if (typeof fps !== 'number' || fps < 0 || isNaN(fps)) {
|
|
2830
|
+
throw new RangeError('[FrqtlApp.setRateLimits] fpsLimit must be a non-negative number, got: ' + fps);
|
|
2831
|
+
}
|
|
2832
|
+
this._drawHz = fps;
|
|
2833
|
+
}
|
|
2834
|
+
if (limits.tpsLimit !== undefined) {
|
|
2835
|
+
var tps = limits.tpsLimit;
|
|
2836
|
+
if (typeof tps !== 'number' || (tps < 0 && tps !== -Infinity) || isNaN(tps)) {
|
|
2837
|
+
throw new RangeError('[FrqtlApp.setRateLimits] tpsLimit must be ≥ 0 or Infinity, got: ' + tps);
|
|
2838
|
+
}
|
|
2839
|
+
// Normalise -Infinity or negative finite to 0 (1:1 locked).
|
|
2840
|
+
this._tickHz = (tps < 0) ? 0 : tps;
|
|
2841
|
+
}
|
|
2842
|
+
};
|
|
2843
|
+
|
|
2844
|
+
/**
|
|
2845
|
+
* Internal helper: tick the rate counters. Call once per physics tick.
|
|
2846
|
+
* @param {number} now — performance.now() value for this frame
|
|
2847
|
+
*/
|
|
2848
|
+
FrqtlApp.prototype._countTick = function (now) {
|
|
2849
|
+
this._tickCount += 1;
|
|
2850
|
+
if (this._rateWindowEnd === 0) {
|
|
2851
|
+
// Initialise window on first call
|
|
2852
|
+
this._rateWindowEnd = now + this._rateWindowMs;
|
|
2853
|
+
}
|
|
2854
|
+
};
|
|
2855
|
+
|
|
2856
|
+
/**
|
|
2857
|
+
* Internal helper: tick the draw counters. Call once per rendered frame.
|
|
2858
|
+
* Checks whether the window has expired and flushes a new rate snapshot.
|
|
2859
|
+
* @param {number} now — performance.now() value for this frame
|
|
2860
|
+
*/
|
|
2861
|
+
FrqtlApp.prototype._countDraw = function (now) {
|
|
2862
|
+
this._drawCount += 1;
|
|
2863
|
+
if (now >= this._rateWindowEnd && this._rateWindowEnd > 0) {
|
|
2864
|
+
// Compute elapsed — clamp to at least 1 ms to guard against division by zero.
|
|
2865
|
+
var elapsed = Math.max(now - (this._rateWindowEnd - this._rateWindowMs), 1);
|
|
2866
|
+
this._lastFps = Math.round(this._drawCount * 1000 / elapsed);
|
|
2867
|
+
this._lastTps = Math.round(this._tickCount * 1000 / elapsed);
|
|
2868
|
+
this._drawCount = 0;
|
|
2869
|
+
this._tickCount = 0;
|
|
2870
|
+
this._rateWindowEnd = now + this._rateWindowMs;
|
|
2871
|
+
}
|
|
2872
|
+
};
|
|
2873
|
+
|
|
2874
|
+
FrqtlApp.RATE_LIMIT_KEYS = RATE_LIMIT_KEYS;
|
|
2875
|
+
FrqtlApp.FACTORY_STATICS = FACTORY_STATICS;
|
|
2876
|
+
|
|
2877
|
+
/**
|
|
2878
|
+
* Copy the factory statics setup() consumes (`FrqtlApp.FACTORY_STATICS`) from a
|
|
2879
|
+
* factory onto a wrapper around it — only the ones the factory has — so
|
|
2880
|
+
* `runner.setup(wrapper)` boots the same scene as `runner.setup(factory)`.
|
|
2881
|
+
*
|
|
2882
|
+
* @param {Function} wrapper the function handed to setup() in the factory's place.
|
|
2883
|
+
* @param {Function} factory the setup factory it wraps.
|
|
2884
|
+
* @returns {Function} the wrapper.
|
|
2885
|
+
* @throws {TypeError} FAIL-LOUD when either argument is not a function.
|
|
2886
|
+
*/
|
|
2887
|
+
FrqtlApp.carryFactoryStatics = function (wrapper, factory) {
|
|
2888
|
+
if (typeof wrapper !== 'function' || typeof factory !== 'function') {
|
|
2889
|
+
throw new TypeError('[FrqtlApp.carryFactoryStatics] wrapper and factory must both be functions ' +
|
|
2890
|
+
'(got ' + typeof wrapper + ', ' + typeof factory + ') (FAIL-LOUD).');
|
|
2891
|
+
}
|
|
2892
|
+
_refuseDeletedStatics(factory, 'carryFactoryStatics');
|
|
2893
|
+
FACTORY_STATICS.forEach(function (k) {
|
|
2894
|
+
if (factory[k] !== undefined) { wrapper[k] = factory[k]; }
|
|
2895
|
+
});
|
|
2896
|
+
return wrapper;
|
|
2897
|
+
};
|
|
2898
|
+
|
|
2899
|
+
/** Opt-in console diagnostics (the tick-budget warning). Off by default. */
|
|
2900
|
+
FrqtlApp.verbose = false;
|
|
2901
|
+
|
|
2902
|
+
// ── The page's runner ────────────────────────────────────────────────────
|
|
2903
|
+
// The runner a page drives: the one its loop last started, or the one a host page published
|
|
2904
|
+
// after applying a scene (the empty page leaves its runner STOPPED). The page door
|
|
2905
|
+
// (experiments/_shared/page-door.js) reads it as FrqtlPage.runner — this is its ONE store.
|
|
2906
|
+
var _pageRunner = null;
|
|
2907
|
+
/** @param {FrqtlApp} runner */
|
|
2908
|
+
function setPageRunner(runner) {
|
|
2909
|
+
if (!runner || typeof runner.step !== 'function') {
|
|
2910
|
+
throw new TypeError('[FrqtlApp] setPageRunner(runner): expected an FrqtlApp. (FAIL-LOUD.)');
|
|
2911
|
+
}
|
|
2912
|
+
_pageRunner = runner;
|
|
2913
|
+
}
|
|
2914
|
+
/** @returns {FrqtlApp|null} the page's CURRENT live runner — null before a scene ran and after that runner's dispose(). */
|
|
2915
|
+
function pageRunner() { return _pageRunner; }
|
|
2916
|
+
|
|
2917
|
+
// ── Export ─────────────────────────────────────────────────────────────
|
|
2918
|
+
|
|
2919
|
+
export { FrqtlApp, setPageRunner, pageRunner };
|
|
2920
|
+
|