@scenography/engine 0.1.0 → 0.3.0

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/dist/index.mjs CHANGED
@@ -1,219 +1,506 @@
1
- import { AmbientLight, Clock, DoubleSide, Group, HemisphereLight, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, PlaneGeometry, Quaternion, SRGBColorSpace, Scene, TextureLoader, Vector2, Vector3, WebGLRenderer } from "three";
2
- //#region src/controls/boundaries.ts
3
- /**
4
- * Clamp a step on the XZ plane to a square `[-bound, bound]`. Axes are clamped
5
- * independently so sliding along a wall still works when one axis is blocked.
6
- */
7
- const clampToBounds = (current, step, bound) => {
8
- const next = new Vector3().addVectors(current, step);
9
- if (next.x < -bound || next.x > bound) next.x = current.x;
10
- if (next.z < -bound || next.z > bound) next.z = current.z;
11
- return next;
12
- };
13
- //#endregion
14
- //#region src/controls/headBob.ts
15
- const createHeadBobState = () => ({
16
- active: false,
17
- timer: 0
18
- });
19
- /**
20
- * Advance the head-bob timer by `elapsed` seconds, snapping to half-wavelength
21
- * steps so the bob always settles back to neutral when movement stops.
22
- */
23
- const updateHeadBob = (state, elapsed) => {
24
- if (!state.active) return;
25
- const wavelength = Math.PI;
26
- const nextStepTime = (1 + Math.floor((state.timer + 1e-6) * 10 / wavelength)) * wavelength / 10;
27
- state.timer = Math.min(state.timer + elapsed, nextStepTime);
28
- if (state.timer === nextStepTime) state.active = false;
29
- };
30
- /** Vertical offset to add to the camera, in world units. */
31
- const headBobOffset = (state) => Math.sin(state.timer * 10) * 1.5;
32
- //#endregion
33
- //#region src/controls/keyboard.ts
34
- const createKeyboardState = () => ({});
35
- const onKeyDown = (state, event) => {
36
- state[event.code] = true;
37
- };
38
- const onKeyUp = (state, event) => {
39
- state[event.code] = false;
40
- };
41
- /**
42
- * Compute the per-frame translation from WASD input, oriented by the current
43
- * yaw (`phi`). Holding Shift acts as a speed booster.
44
- */
45
- const movementFromKeyboard = (state, phi, delta) => {
46
- const has = (code) => state[code] ? 1 : 0;
47
- const forwardVelocity = has("KeyW") - has("KeyS");
48
- const strafeVelocity = has("KeyA") - has("KeyD");
49
- const factor = state["ShiftLeft"] || state["ShiftRight"] ? 30 : 10;
50
- const yaw = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), phi);
51
- const forward = new Vector3(0, 0, -10).applyQuaternion(yaw).multiplyScalar(forwardVelocity * delta * factor);
52
- const left = new Vector3(-10, 0, 0).applyQuaternion(yaw).multiplyScalar(strafeVelocity * delta * factor);
53
- return forward.add(left);
54
- };
55
- //#endregion
56
- //#region src/controls/mouse.ts
57
- const PHI_SPEED = 8;
58
- const THETA_SPEED = 5;
59
- const createMouseState = () => ({
60
- dragging: false,
61
- pos: new Vector2(),
62
- prevPos: new Vector2(),
63
- delta: new Vector2()
64
- });
65
- const clamp = (x, a, b) => Math.min(Math.max(x, a), b);
66
- const onMouseDown = (state, event) => {
67
- state.dragging = event.button === 0;
68
- };
69
- const onMouseUp = (state) => {
70
- state.dragging = false;
71
- };
72
- const onMouseMove = (state, event) => {
73
- state.pos.set(event.pageX - window.innerWidth / 2, event.pageY - window.innerHeight / 2);
74
- state.delta.subVectors(state.pos, state.prevPos);
75
- };
76
- const commitMouseFrame = (state) => {
77
- state.prevPos.copy(state.pos);
78
- state.delta.set(0, 0);
1
+ import { BoxGeometry, Clock, EquirectangularReflectionMapping, Group, HalfFloatType, Mesh, MeshBasicMaterial, NoColorSpace, PCFShadowMap, PerspectiveCamera, Quaternion, Raycaster, RepeatWrapping, SRGBColorSpace, Scene, StereoCamera, TextureLoader, Vector2, Vector3, WebGLRenderTarget, WebGLRenderer } from "three";
2
+ //#region src/environment/EnvironmentController.ts
3
+ /** Load an equirectangular HDRI, picking the loader by extension. */
4
+ const loadHDRI = (src, onLoad) => {
5
+ if (src.toLowerCase().endsWith(".exr")) import("three/examples/jsm/loaders/EXRLoader.js").then(({ EXRLoader }) => {
6
+ new EXRLoader().load(src, onLoad);
7
+ });
8
+ else import("three/examples/jsm/loaders/RGBELoader.js").then(({ RGBELoader }) => {
9
+ new RGBELoader().load(src, onLoad);
10
+ });
79
11
  };
80
12
  /**
81
- * Integrate the pointer delta into a look quaternion. Mutates `look` and
82
- * returns the resulting rotation. Yaw is unbounded; pitch is clamped to avoid
83
- * flipping over.
13
+ * Owns the scene's HDRI sky / image-based lighting and keeps it in sync with the
14
+ * `Environment` definition. Loads the equirectangular map on demand (its loader
15
+ * ships in its own chunk) and assigns it to `scene.background` and/or
16
+ * `scene.environment`. Reconciles by a JSON hash so hot edits only reload when
17
+ * the definition actually changes.
84
18
  */
85
- const updateLookFromMouse = (mouse, look) => {
86
- const xh = mouse.delta.x / window.innerWidth;
87
- const yh = mouse.delta.y / window.innerHeight;
88
- look.phi += -xh * PHI_SPEED;
89
- look.theta = clamp(look.theta + -yh * THETA_SPEED, -Math.PI / 3, Math.PI / 3);
90
- const qy = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), look.phi);
91
- const qx = new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), look.theta);
92
- return new Quaternion().multiply(qy).multiply(qx);
19
+ var EnvironmentController = class {
20
+ scene;
21
+ hash = null;
22
+ texture = null;
23
+ constructor(scene) {
24
+ this.scene = scene;
25
+ }
26
+ reconcile(env) {
27
+ const hash = env ? JSON.stringify(env) : null;
28
+ if (hash === this.hash) return;
29
+ this.hash = hash;
30
+ this.clear();
31
+ if (!env) return;
32
+ loadHDRI(env.src, (tex) => {
33
+ if (this.hash !== hash) {
34
+ tex.dispose();
35
+ return;
36
+ }
37
+ tex.mapping = EquirectangularReflectionMapping;
38
+ this.texture = tex;
39
+ if (env.background ?? true) {
40
+ this.scene.background = tex;
41
+ this.scene.backgroundIntensity = env.intensity ?? 1;
42
+ }
43
+ if (env.lighting ?? true) this.scene.environment = tex;
44
+ });
45
+ }
46
+ /** Set the visible sky background brightness (0 = black). Live, for behavior. */
47
+ setBackgroundIntensity(value) {
48
+ this.scene.backgroundIntensity = value;
49
+ }
50
+ clear() {
51
+ this.scene.background = null;
52
+ this.scene.environment = null;
53
+ this.texture?.dispose();
54
+ this.texture = null;
55
+ }
56
+ dispose() {
57
+ this.clear();
58
+ this.hash = null;
59
+ }
93
60
  };
94
61
  //#endregion
95
- //#region src/controls/FreeRoamControls.ts
62
+ //#region src/interaction/InteractionController.ts
63
+ const HIGHLIGHT_COLOR = "#ef4444";
64
+ const HIGHLIGHT_THICKNESS = 1.5;
65
+ const HIGHLIGHT_MARGIN = 1.5;
66
+ const CLICK_SLOP = 4;
96
67
  /**
97
- * First-person, free-roaming camera controls ported from the original Virtual
98
- * Gallery: drag-to-look with the mouse, WASD to walk, a subtle head-bob while
99
- * moving, and square boundary clamping. The cardboard / device-orientation path
100
- * was intentionally dropped.
101
- *
102
- * Framework-agnostic: construct it with a camera and the canvas element, call
103
- * `attach()`, then `update(delta)` once per frame.
68
+ * Pointer picking for artworks: hovering a piece's click target (its label, or
69
+ * the piece itself when it has none) draws a pulsing red frame and shows a
70
+ * pointer cursor; clicking it emits `artwork:select { id }`. Targets are marked
71
+ * in the scene graph (`userData.pickTarget`/`pickInfo`) by the artwork builder,
72
+ * so this just raycasts and walks up to find them. An optional per-id radius
73
+ * gates interaction to when the camera is reasonably close.
104
74
  */
105
- var FreeRoamControls = class {
106
- bounds;
75
+ var InteractionController = class {
107
76
  camera;
108
- domElement;
109
- mouse = createMouseState();
110
- keyboard = createKeyboardState();
111
- headBob = createHeadBobState();
112
- look = {
113
- phi: 0,
114
- theta: 0
115
- };
116
- rotation = new Quaternion();
117
- translation = new Vector3(0, 170, 0);
77
+ canvas;
78
+ scene;
79
+ emit;
80
+ canInteract;
81
+ raycaster = new Raycaster();
82
+ pointer = new Vector2();
83
+ over = false;
84
+ downPos = null;
85
+ radii = /* @__PURE__ */ new Map();
86
+ hovered = null;
87
+ time = 0;
88
+ highlight = new Group();
89
+ highlightMat = null;
118
90
  handlers = [];
119
- constructor(camera, domElement, options = {}) {
91
+ constructor(camera, canvas, scene, emit, canInteract) {
120
92
  this.camera = camera;
121
- this.domElement = domElement;
122
- this.bounds = options.bounds ?? 450;
123
- if (options.position) this.translation.copy(options.position);
124
- if (options.yaw !== void 0) this.look.phi = options.yaw;
125
- this.rotation.setFromAxisAngle(new Vector3(0, 1, 0), this.look.phi);
93
+ this.canvas = canvas;
94
+ this.scene = scene;
95
+ this.emit = emit;
96
+ this.canInteract = canInteract;
97
+ this.highlight.visible = false;
98
+ this.scene.add(this.highlight);
99
+ }
100
+ /** Per-artwork max interaction distance (XZ). Absent id = no distance gate. */
101
+ setRadii(radii) {
102
+ this.radii = radii;
126
103
  }
127
104
  attach() {
128
- const bind = (target, type, listener) => {
129
- target.addEventListener(type, listener);
105
+ const bind = (type, listener) => {
106
+ this.canvas.addEventListener(type, listener);
130
107
  this.handlers.push([
131
- target,
108
+ this.canvas,
132
109
  type,
133
110
  listener
134
111
  ]);
135
112
  };
136
- bind(this.domElement, "mousedown", (e) => onMouseDown(this.mouse, e));
137
- bind(window, "mousemove", (e) => onMouseMove(this.mouse, e));
138
- bind(window, "mouseup", () => onMouseUp(this.mouse));
139
- bind(window, "keydown", (e) => onKeyDown(this.keyboard, e));
140
- bind(window, "keyup", (e) => onKeyUp(this.keyboard, e));
113
+ bind("pointermove", (e) => this.onMove(e));
114
+ bind("pointerdown", (e) => this.onDown(e));
115
+ bind("pointerup", (e) => this.onUp(e));
116
+ bind("pointerleave", () => {
117
+ this.over = false;
118
+ });
141
119
  }
142
120
  detach() {
143
121
  for (const [target, type, listener] of this.handlers) target.removeEventListener(type, listener);
144
122
  this.handlers.length = 0;
123
+ this.scene.remove(this.highlight);
124
+ this.clearHighlight();
125
+ }
126
+ setPointer(e) {
127
+ const rect = this.canvas.getBoundingClientRect();
128
+ this.pointer.set((e.clientX - rect.left) / rect.width * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
129
+ this.over = true;
130
+ }
131
+ onMove(e) {
132
+ this.setPointer(e);
133
+ }
134
+ onDown(e) {
135
+ this.downPos = {
136
+ x: e.clientX,
137
+ y: e.clientY
138
+ };
139
+ }
140
+ onUp(e) {
141
+ const down = this.downPos;
142
+ this.downPos = null;
143
+ if (!down || !this.canInteract()) return;
144
+ if (Math.hypot(e.clientX - down.x, e.clientY - down.y) > CLICK_SLOP) return;
145
+ this.setPointer(e);
146
+ const pick = this.raycast();
147
+ if (pick) this.emit("artwork:select", { id: pick.id });
148
+ }
149
+ /** Resolve the nearest pickable artwork under the pointer, honoring the gate. */
150
+ raycast() {
151
+ this.raycaster.setFromCamera(this.pointer, this.camera);
152
+ const hits = this.raycaster.intersectObjects(this.scene.children, true);
153
+ for (const hit of hits) {
154
+ let node = hit.object;
155
+ while (node && node.userData.pickTarget === void 0) node = node.parent;
156
+ if (!node) continue;
157
+ const id = node.userData.pickTarget;
158
+ const radius = this.radii.get(id);
159
+ if (radius !== void 0) {
160
+ const p = node.getWorldPosition(new Vector3());
161
+ if (Math.hypot(this.camera.position.x - p.x, this.camera.position.z - p.z) > radius) continue;
162
+ }
163
+ return {
164
+ id,
165
+ holder: node,
166
+ info: node.userData.pickInfo
167
+ };
168
+ }
169
+ return null;
170
+ }
171
+ /** Recompute hover state and animate the highlight. Call once per frame. */
172
+ update(deltaMs) {
173
+ this.time += deltaMs;
174
+ const pick = this.over && this.canInteract() ? this.raycast() : null;
175
+ if ((pick?.id ?? null) !== this.hovered) {
176
+ this.hovered = pick?.id ?? null;
177
+ this.canvas.style.cursor = pick ? "pointer" : "";
178
+ if (pick) this.buildHighlight(pick);
179
+ else this.highlight.visible = false;
180
+ }
181
+ if (this.highlight.visible && this.highlightMat) this.highlightMat.opacity = .45 + .45 * (.5 + .5 * Math.sin(this.time / 320));
182
+ }
183
+ clearHighlight() {
184
+ for (const child of [...this.highlight.children]) {
185
+ this.highlight.remove(child);
186
+ child.geometry?.dispose();
187
+ }
188
+ this.highlightMat?.dispose();
189
+ this.highlightMat = null;
190
+ }
191
+ /** Position a fresh pulsing red frame over the picked target's front face. */
192
+ buildHighlight(pick) {
193
+ this.clearHighlight();
194
+ const mat = new MeshBasicMaterial({
195
+ color: HIGHLIGHT_COLOR,
196
+ transparent: true,
197
+ depthTest: false
198
+ });
199
+ this.highlightMat = mat;
200
+ const w = pick.info.width + 2 * HIGHLIGHT_MARGIN;
201
+ const h = pick.info.height + 2 * HIGHLIGHT_MARGIN;
202
+ const t = HIGHLIGHT_THICKNESS;
203
+ const bars = [
204
+ [
205
+ w + 2 * t,
206
+ t,
207
+ 0,
208
+ h / 2 + t / 2
209
+ ],
210
+ [
211
+ w + 2 * t,
212
+ t,
213
+ 0,
214
+ -(h / 2 + t / 2)
215
+ ],
216
+ [
217
+ t,
218
+ h,
219
+ -(w / 2 + t / 2),
220
+ 0
221
+ ],
222
+ [
223
+ t,
224
+ h,
225
+ w / 2 + t / 2,
226
+ 0
227
+ ]
228
+ ];
229
+ for (const [bw, bh, x, y] of bars) {
230
+ const bar = new Mesh(new BoxGeometry(bw, bh, .5), mat);
231
+ bar.position.set(x, y, 0);
232
+ bar.renderOrder = 999;
233
+ this.highlight.add(bar);
234
+ }
235
+ const pos = pick.holder.getWorldPosition(new Vector3());
236
+ const quat = pick.holder.getWorldQuaternion(new Quaternion());
237
+ const normal = new Vector3(0, 0, 1).applyQuaternion(quat);
238
+ pos.addScaledVector(normal, pick.info.frontOffset);
239
+ this.highlight.position.copy(pos);
240
+ this.highlight.quaternion.copy(quat);
241
+ this.highlight.visible = true;
242
+ }
243
+ };
244
+ //#endregion
245
+ //#region src/input/InputBus.ts
246
+ /**
247
+ * The shared analog-input channel: the current movement and look intent, each
248
+ * axis in `-1..1` and held until a producer changes it. Producers (on-screen
249
+ * joysticks in the overlay, a gamepad…) write to it; whatever camera system is
250
+ * active reads it and interprets it its own way — free-roam walks with it, a
251
+ * fixed camera might only use the look axes to glance around. This is what makes
252
+ * the joysticks reusable across camera types. Pure state — no three.js.
253
+ */
254
+ var InputBus = class {
255
+ /** Forward (`+1`) / backward (`-1`). */
256
+ moveForward = 0;
257
+ /** Strafe right (`+1`) / left (`-1`). */
258
+ moveStrafe = 0;
259
+ /** Look right (`+1`) / left (`-1`). */
260
+ lookX = 0;
261
+ /** Look up (`+1`) / down (`-1`). */
262
+ lookY = 0;
263
+ /** Set the movement intent (forward, strafe). */
264
+ setMove(forward, strafe = 0) {
265
+ this.moveForward = forward;
266
+ this.moveStrafe = strafe;
145
267
  }
146
- /** Advance the camera by `delta` seconds of input. */
147
- update(delta) {
148
- if (this.mouse.dragging) this.rotation = updateLookFromMouse(this.mouse, this.look);
149
- const step = movementFromKeyboard(this.keyboard, this.look.phi, delta);
150
- this.translation = clampToBounds(this.translation, step, this.bounds);
151
- if (step.x !== 0 || step.z !== 0) this.headBob.active = true;
152
- updateHeadBob(this.headBob, delta);
153
- this.camera.quaternion.copy(this.rotation);
154
- this.camera.position.copy(this.translation);
155
- this.camera.position.y += headBobOffset(this.headBob);
156
- const target = new Vector3(0, 0, -1).applyQuaternion(this.rotation).multiplyScalar(100).add(this.translation);
157
- this.camera.lookAt(target);
158
- commitMouseFrame(this.mouse);
268
+ /** Set the look intent (turn, pitch). */
269
+ setLook(x, y = 0) {
270
+ this.lookX = x;
271
+ this.lookY = y;
159
272
  }
160
273
  };
161
274
  //#endregion
162
- //#region src/builders/wall.ts
163
- /** Build the three.js object that materializes a `Wall` definition. */
164
- function buildWall(wall) {
165
- const [width, height] = wall.size;
166
- const mesh = new Mesh(new PlaneGeometry(width, height), new MeshStandardMaterial({
167
- color: wall.color ?? "#ffffff",
168
- side: DoubleSide
169
- }));
170
- mesh.position.set(wall.position[0], wall.position[1], wall.position[2]);
171
- mesh.rotation.y = wall.rotation ?? 0;
172
- mesh.name = `wall:${wall.id}`;
173
- mesh.userData.scenographyId = wall.id;
174
- return mesh;
275
+ //#region src/input/controlSettings.ts
276
+ /**
277
+ * Mode presets. Touch = two joysticks, no gyro. VR = the gyro aims and a single
278
+ * move joystick walks you, no split. Cardboard = gyro aims, stereo split, gaze
279
+ * select (no joysticks). The move-only single stick in VR is derived downstream
280
+ * from `gyro && !splitScreen`, so it needs no flag of its own.
281
+ */
282
+ const MODE_PRESETS = {
283
+ touch: {
284
+ gyro: false,
285
+ splitScreen: false,
286
+ joysticks: true
287
+ },
288
+ vr: {
289
+ gyro: true,
290
+ splitScreen: false,
291
+ joysticks: true
292
+ },
293
+ cardboard: {
294
+ gyro: true,
295
+ splitScreen: true,
296
+ joysticks: false
297
+ }
298
+ };
299
+ /** Default flags: plain touch, no look inversion. */
300
+ const DEFAULT_CONTROL_FLAGS = {
301
+ ...MODE_PRESETS.touch,
302
+ invertLookX: false,
303
+ invertLookY: false
304
+ };
305
+ const FLAG_KEYS$1 = [
306
+ "gyro",
307
+ "splitScreen",
308
+ "joysticks",
309
+ "invertLookX",
310
+ "invertLookY"
311
+ ];
312
+ const equalFlags = (a, b) => FLAG_KEYS$1.every((key) => a[key] === b[key]);
313
+ /** The mode a flag set represents, or `custom` when no preset matches. */
314
+ function modeOf(flags) {
315
+ for (const mode of [
316
+ "touch",
317
+ "vr",
318
+ "cardboard"
319
+ ]) {
320
+ const preset = MODE_PRESETS[mode];
321
+ if (flags.gyro === preset.gyro && flags.splitScreen === preset.splitScreen && flags.joysticks === preset.joysticks) return mode;
322
+ }
323
+ return "custom";
175
324
  }
325
+ /** Apply a mode's preset onto `flags`, leaving the look-invert axes untouched. */
326
+ function applyMode(flags, mode) {
327
+ return {
328
+ ...flags,
329
+ ...MODE_PRESETS[mode]
330
+ };
331
+ }
332
+ /**
333
+ * Live control-preferences state: holds the flags, derives the mode, and notifies
334
+ * subscribers on every real change. Callers apply the effects — the engine wires
335
+ * gyro and split, the overlay redraws — so this object is just the shared source
336
+ * of truth they all read and write.
337
+ */
338
+ var ControlSettings = class {
339
+ flags;
340
+ subscribers = /* @__PURE__ */ new Set();
341
+ constructor(initial = {}) {
342
+ this.flags = {
343
+ ...DEFAULT_CONTROL_FLAGS,
344
+ ...initial
345
+ };
346
+ }
347
+ /** The current flags (a copy — mutate through `setMode` / `setFlag`). */
348
+ get() {
349
+ return { ...this.flags };
350
+ }
351
+ /** The mode the current flags represent (`custom` when hand-tuned). */
352
+ getMode() {
353
+ return modeOf(this.flags);
354
+ }
355
+ /** Switch to a mode's preset (keeps the look-invert axes). No-op if unchanged. */
356
+ setMode(mode) {
357
+ this.replace(applyMode(this.flags, mode));
358
+ }
359
+ /** Set one flag. Flipping a mode-defining flag can drop you into `custom`. */
360
+ setFlag(key, value) {
361
+ this.replace({
362
+ ...this.flags,
363
+ [key]: value
364
+ });
365
+ }
366
+ /** Subscribe to changes; returns an unsubscribe. Fires on every real change. */
367
+ subscribe(fn) {
368
+ this.subscribers.add(fn);
369
+ return () => this.subscribers.delete(fn);
370
+ }
371
+ replace(next) {
372
+ if (equalFlags(this.flags, next)) return;
373
+ this.flags = next;
374
+ const snapshot = this.get();
375
+ for (const fn of this.subscribers) fn(snapshot);
376
+ }
377
+ };
176
378
  //#endregion
177
- //#region src/builders/artwork.ts
178
- const loader = new TextureLoader();
179
- const FRAME_MARGIN = 4;
180
- const SURFACE_OFFSET = 1;
379
+ //#region src/input/controlStorage.ts
181
380
  /**
182
- * Build the three.js object for an `Artwork`, placing it on its wall. The piece
183
- * is positioned in the wall's local plane via `offset` and nudged forward along
184
- * the wall normal so it sits in front of the surface.
381
+ * localStorage persistence for the control flags, kept out of the pure
382
+ * `ControlSettings` so that stays DOM-free and unit-testable. Both functions are
383
+ * best-effort and guarded: no `localStorage` (SSR, privacy mode) or malformed
384
+ * data just falls back to defaults, never throws.
185
385
  */
186
- function buildArtwork(artwork, wall) {
187
- const [width, height] = artwork.size;
188
- const [offsetX, offsetY] = artwork.offset ?? [0, 0];
189
- const yaw = wall.rotation ?? 0;
190
- const group = new Group();
191
- const wallQuat = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), yaw);
192
- const right = new Vector3(1, 0, 0).applyQuaternion(wallQuat);
193
- const normal = new Vector3(0, 0, 1).applyQuaternion(wallQuat);
194
- const position = new Vector3(wall.position[0], wall.position[1], wall.position[2]).add(right.clone().multiplyScalar(offsetX)).add(new Vector3(0, offsetY, 0)).add(normal.clone().multiplyScalar(SURFACE_OFFSET));
195
- group.position.copy(position);
196
- group.rotation.y = yaw;
197
- const frame = new Mesh(new PlaneGeometry(width + FRAME_MARGIN, height + FRAME_MARGIN), new MeshStandardMaterial({ color: "#1a1a1a" }));
198
- group.add(frame);
199
- const texture = loader.load(artwork.src);
200
- texture.colorSpace = SRGBColorSpace;
201
- const image = new Mesh(new PlaneGeometry(width, height), new MeshBasicMaterial({ map: texture }));
202
- image.position.z = .1;
203
- group.add(image);
204
- group.name = `artwork:${artwork.id}`;
205
- group.userData.scenographyId = artwork.id;
206
- group.userData.wallId = artwork.wall;
207
- return group;
386
+ const STORAGE_KEY = "scenography:controls";
387
+ const FLAG_KEYS = Object.keys(DEFAULT_CONTROL_FLAGS);
388
+ /** Read persisted control flags; `null` when absent, unavailable or malformed. */
389
+ function loadControlFlags() {
390
+ try {
391
+ const raw = globalThis.localStorage?.getItem(STORAGE_KEY);
392
+ if (!raw) return null;
393
+ const parsed = JSON.parse(raw);
394
+ const flags = {};
395
+ for (const key of FLAG_KEYS) if (typeof parsed[key] === "boolean") flags[key] = parsed[key];
396
+ return flags;
397
+ } catch {
398
+ return null;
399
+ }
400
+ }
401
+ /** Persist control flags (best-effort; ignores a full/blocked store). */
402
+ function saveControlFlags(flags) {
403
+ try {
404
+ globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(flags));
405
+ } catch {}
208
406
  }
209
407
  //#endregion
408
+ //#region src/plugin/CapabilityRegistry.ts
409
+ /**
410
+ * Shared runtime contracts between plugins, keyed by name. A plugin (or the
411
+ * kernel) *provides* a capability — e.g. the floor provides `terrain` — and
412
+ * other plugins *consume* it if present, without depending on the provider's
413
+ * package. Each name keeps a monotonic version counter so consumers can hash
414
+ * their dependencies cheaply and rebuild when a provider changes.
415
+ */
416
+ var CapabilityRegistry = class {
417
+ values = /* @__PURE__ */ new Map();
418
+ versions = /* @__PURE__ */ new Map();
419
+ /** Last change-token seen per name, to suppress redundant version bumps. */
420
+ tokens = /* @__PURE__ */ new Map();
421
+ /**
422
+ * Publish (or replace) the capability under `name`. Pass a `token` (a cheap
423
+ * hash of the inputs) to dedupe: re-providing with an unchanged token is a
424
+ * no-op, so consumers that depend on this capability don't rebuild when the
425
+ * provider recomputes an identical value.
426
+ */
427
+ provide(name, value, token) {
428
+ if (token !== void 0 && this.values.has(name) && this.tokens.get(name) === token) return;
429
+ this.values.set(name, value);
430
+ if (token !== void 0) this.tokens.set(name, token);
431
+ else this.tokens.delete(name);
432
+ this.bump(name);
433
+ }
434
+ /** Remove the capability; consumers see `undefined` and a version bump. */
435
+ revoke(name) {
436
+ if (this.values.delete(name)) {
437
+ this.tokens.delete(name);
438
+ this.bump(name);
439
+ }
440
+ }
441
+ get(name) {
442
+ return this.values.get(name);
443
+ }
444
+ /** Monotonic change counter for `name` (`0` if never provided). */
445
+ version(name) {
446
+ return this.versions.get(name) ?? 0;
447
+ }
448
+ bump(name) {
449
+ this.versions.set(name, (this.versions.get(name) ?? 0) + 1);
450
+ }
451
+ };
452
+ //#endregion
453
+ //#region src/plugin/systemPlugin.ts
454
+ /** Narrow a mixed plugin to a system plugin (has `setup`, not `build`). */
455
+ const isSystemPlugin = (plugin) => typeof plugin === "object" && plugin !== null && "setup" in plugin;
456
+ //#endregion
457
+ //#region src/plugin/roamControl.ts
458
+ /** Capability name under which a roaming camera system exposes pose control. */
459
+ const ROAM_CONTROL = "roamControl";
460
+ //#endregion
461
+ //#region src/plugin/terrain.ts
462
+ /** Capability name under which the floor publishes its terrain. */
463
+ const TERRAIN = "terrain";
464
+ //#endregion
465
+ //#region src/plugin/colliders.ts
466
+ /** Capability name under which walkable-space obstacles are published. */
467
+ const COLLIDERS = "colliders";
468
+ //#endregion
469
+ //#region src/plugin/edges.ts
470
+ /** Capability name under which named wall edges are published for hanging. */
471
+ const EDGES = "edges";
472
+ //#endregion
473
+ //#region src/plugin/eyeHeight.ts
474
+ /**
475
+ * Capability name under which the visitor's eye height (world units) is
476
+ * published — the default vertical placement for wall-hung artworks. The kernel
477
+ * provides it from the camera start; a camera plugin will own it later.
478
+ */
479
+ const EYE_HEIGHT = "eyeHeight";
480
+ //#endregion
210
481
  //#region src/SceneManager.ts
211
482
  const disposeObject = (object) => {
212
483
  object.traverse((child) => {
484
+ const video = child.userData?.video;
485
+ if (video) {
486
+ video.pause();
487
+ video.removeAttribute("src");
488
+ video.load();
489
+ }
213
490
  const mesh = child;
214
491
  mesh.geometry?.dispose?.();
492
+ const dispose = (m) => {
493
+ const material = m;
494
+ for (const slot of [
495
+ "map",
496
+ "normalMap",
497
+ "roughnessMap",
498
+ "aoMap",
499
+ "metalnessMap"
500
+ ]) (material?.[slot])?.dispose?.();
501
+ material?.dispose?.();
502
+ };
215
503
  const material = mesh.material;
216
- const dispose = (m) => m?.dispose?.();
217
504
  if (Array.isArray(material)) material.forEach(dispose);
218
505
  else dispose(material);
219
506
  });
@@ -227,82 +514,109 @@ const disposeObject = (object) => {
227
514
  */
228
515
  var SceneManager = class {
229
516
  root;
230
- walls = /* @__PURE__ */ new Map();
231
- artworks = /* @__PURE__ */ new Map();
232
- constructor(root) {
517
+ plugins;
518
+ capabilities;
519
+ /** Mounted plugin elements, per kind, keyed by element id ("" for singletons). */
520
+ pluginElements = /* @__PURE__ */ new Map();
521
+ constructor(root, plugins = [], capabilities = new CapabilityRegistry()) {
233
522
  this.root = root;
523
+ this.plugins = plugins;
524
+ this.capabilities = capabilities;
234
525
  }
235
526
  reconcile(scene) {
236
- const wallById = new Map(scene.walls.map((w) => [w.id, w]));
237
- this.reconcileWalls(scene.walls);
238
- this.reconcileArtworks(scene.artworks, wallById);
527
+ const eye = scene.camera.start.position[1];
528
+ this.capabilities.provide(EYE_HEIGHT, eye, String(eye));
529
+ this.reconcilePlugins(scene);
239
530
  }
240
- reconcileWalls(walls) {
241
- const next = new Set(walls.map((w) => w.id));
242
- for (const [id, entry] of this.walls) if (!next.has(id)) {
243
- this.root.remove(entry.object);
244
- disposeObject(entry.object);
245
- this.walls.delete(id);
246
- }
247
- for (const wall of walls) {
248
- const hash = JSON.stringify(wall);
249
- const existing = this.walls.get(wall.id);
250
- if (existing?.hash === hash) continue;
251
- if (existing) {
252
- this.root.remove(existing.object);
253
- disposeObject(existing.object);
531
+ /**
532
+ * Reconcile every registered plugin's elements by content: an element
533
+ * rebuilds when its definition changes or when a capability it depends on
534
+ * was re-provided (the capability versions are part of the hash).
535
+ */
536
+ reconcilePlugins(scene) {
537
+ for (const plugin of this.plugins) {
538
+ const { kind, cardinality } = plugin.data;
539
+ let mounted = this.pluginElements.get(kind);
540
+ if (!mounted) {
541
+ mounted = /* @__PURE__ */ new Map();
542
+ this.pluginElements.set(kind, mounted);
254
543
  }
255
- const object = buildWall(wall);
256
- this.root.add(object);
257
- this.walls.set(wall.id, {
258
- object,
259
- hash
260
- });
544
+ const raw = scene[kind];
545
+ const items = cardinality === "many" ? Array.isArray(raw) ? raw.map((item, i) => [String(item.id ?? i), item]) : [] : raw !== void 0 ? [["", raw]] : [];
546
+ const deps = (plugin.dependsOn ?? []).map((name) => this.capabilities.version(name));
547
+ const seen = /* @__PURE__ */ new Set();
548
+ for (const [key, value] of items) {
549
+ seen.add(key);
550
+ const hash = JSON.stringify({
551
+ value,
552
+ deps
553
+ });
554
+ const existing = mounted.get(key);
555
+ if (existing?.hash === hash) continue;
556
+ if (existing) this.removePluginElement(mounted, key, existing);
557
+ const built = plugin.build(value, { capabilities: this.capabilities });
558
+ if (built.object) this.root.add(built.object);
559
+ mounted.set(key, {
560
+ ...built,
561
+ hash
562
+ });
563
+ }
564
+ for (const [key, entry] of mounted) if (!seen.has(key)) this.removePluginElement(mounted, key, entry);
565
+ plugin.provide?.(items.map(([, value]) => value), { capabilities: this.capabilities });
261
566
  }
262
567
  }
263
- reconcileArtworks(artworks, wallById) {
264
- const next = new Set(artworks.map((a) => a.id));
265
- for (const [id, entry] of this.artworks) if (!next.has(id)) {
568
+ removePluginElement(mounted, key, entry) {
569
+ if (entry.object) {
266
570
  this.root.remove(entry.object);
267
571
  disposeObject(entry.object);
268
- this.artworks.delete(id);
269
- }
270
- for (const artwork of artworks) {
271
- const wall = wallById.get(artwork.wall);
272
- if (!wall) continue;
273
- const hash = JSON.stringify({
274
- a: artwork,
275
- w: wall
276
- });
277
- const existing = this.artworks.get(artwork.id);
278
- if (existing?.hash === hash) continue;
279
- if (existing) {
280
- this.root.remove(existing.object);
281
- disposeObject(existing.object);
282
- }
283
- const object = buildArtwork(artwork, wall);
284
- this.root.add(object);
285
- this.artworks.set(artwork.id, {
286
- object,
287
- hash
288
- });
289
572
  }
573
+ entry.dispose?.();
574
+ mounted.delete(key);
575
+ }
576
+ /** Advance time-based plugin elements (e.g. water ripple). Call once per frame. */
577
+ updateElements(deltaMs) {
578
+ for (const mounted of this.pluginElements.values()) for (const entry of mounted.values()) entry.update?.(deltaMs);
290
579
  }
291
- /** Look up the live three.js object for a wall or artwork `id`. */
580
+ /** Run every element's auxiliary pre-render pass (e.g. water reflection). */
581
+ beforeRender(renderer, scene, camera) {
582
+ for (const mounted of this.pluginElements.values()) for (const entry of mounted.values()) entry.beforeRender?.(renderer, scene, camera);
583
+ }
584
+ /** Look up the live three.js object for any plugin element `id`. */
292
585
  get(id) {
293
- return this.walls.get(id)?.object ?? this.artworks.get(id)?.object;
586
+ for (const mounted of this.pluginElements.values()) {
587
+ const object = mounted.get(id)?.object;
588
+ if (object) return object;
589
+ }
590
+ }
591
+ /** Live objects of one plugin kind, keyed by `id` — for that plugin's controller. */
592
+ elementsOf(kind) {
593
+ const out = /* @__PURE__ */ new Map();
594
+ const mounted = this.pluginElements.get(kind);
595
+ if (mounted) {
596
+ for (const [id, entry] of mounted) if (entry.object) out.set(id, entry.object);
597
+ }
598
+ return out;
294
599
  }
295
600
  dispose() {
296
- for (const entry of [...this.walls.values(), ...this.artworks.values()]) {
297
- this.root.remove(entry.object);
298
- disposeObject(entry.object);
601
+ for (const [kind, mounted] of this.pluginElements) {
602
+ for (const [key, entry] of mounted) this.removePluginElement(mounted, key, entry);
603
+ this.pluginElements.delete(kind);
299
604
  }
300
- this.walls.clear();
301
- this.artworks.clear();
302
605
  }
303
606
  };
304
607
  //#endregion
305
608
  //#region src/createGallery.ts
609
+ /** Maps a plugin kind/name onto the `ctx` slot its api fills. */
610
+ const CTX_SLOT = {
611
+ camera: "focus",
612
+ lights: "lights",
613
+ video: "videos",
614
+ sky: "sky",
615
+ media: "audio"
616
+ };
617
+ /** The system the cardboard render toggle drives (gyroscope head-look). */
618
+ const CARDBOARD_SYSTEM = "cardboard";
619
+ const DEFAULT_EYE_SEPARATION = 3;
306
620
  /**
307
621
  * Create and run a gallery from a scene definition. Sets up the three.js
308
622
  * renderer, camera, lights and free-roam controls, materializes the scene, and
@@ -314,35 +628,243 @@ function createGallery(scene, options) {
314
628
  const height = canvas.clientHeight || window.innerHeight;
315
629
  const renderer = new WebGLRenderer({
316
630
  canvas,
317
- antialias: options.antialias ?? true
631
+ antialias: false
318
632
  });
319
633
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
320
634
  renderer.setSize(width, height, false);
635
+ renderer.shadowMap.type = PCFShadowMap;
636
+ const render = {
637
+ antialias: options.antialias ?? 4,
638
+ ssao: options.ssao ?? false,
639
+ bloom: options.bloom ?? 0,
640
+ shadows: options.shadows ?? true,
641
+ cardboard: options.cardboard ?? false,
642
+ eyeSeparation: options.eyeSeparation ?? DEFAULT_EYE_SEPARATION
643
+ };
644
+ const stereo = new StereoCamera();
645
+ stereo.aspect = .5;
646
+ stereo.eyeSep = render.eyeSeparation;
647
+ const stereoSize = new Vector2();
321
648
  const threeScene = new Scene();
322
- const camera = new PerspectiveCamera(75, width / height, .1, 5e3);
323
- const { position, yaw } = scene.camera.start;
649
+ const camera = new PerspectiveCamera(scene.camera.fov ?? 55, width / height, .1, 5e3);
650
+ const { position } = scene.camera.start;
324
651
  camera.position.set(position[0], position[1], position[2]);
325
- threeScene.add(new AmbientLight(16777215, 1.4));
326
- threeScene.add(new HemisphereLight(16777215, 4473924, 1));
327
- const manager = new SceneManager(threeScene);
652
+ const allPlugins = options.plugins ?? [];
653
+ const systemPlugins = allPlugins.filter(isSystemPlugin);
654
+ const elementPlugins = allPlugins.filter((p) => !isSystemPlugin(p));
655
+ const capabilities = new CapabilityRegistry();
656
+ const manager = new SceneManager(threeScene, elementPlugins, capabilities);
328
657
  manager.reconcile(scene);
329
- const controls = new FreeRoamControls(camera, canvas, {
330
- bounds: scene.camera.bounds,
331
- position: camera.position.clone(),
332
- yaw
333
- });
334
- controls.attach();
658
+ const input = new InputBus();
659
+ const controlSettings = new ControlSettings(loadControlFlags() ?? (options.cardboard ? MODE_PRESETS.cardboard : {}));
660
+ const environment = new EnvironmentController(threeScene);
661
+ environment.reconcile(scene.environment);
335
662
  const listeners = /* @__PURE__ */ new Map();
663
+ const emit = (event, payload) => {
664
+ listeners.get(event)?.forEach((handler) => handler(payload));
665
+ };
666
+ const frameHandlers = /* @__PURE__ */ new Set();
667
+ const apis = /* @__PURE__ */ new Map();
668
+ const controllers = [];
669
+ for (const plugin of elementPlugins) {
670
+ const controller = plugin.controller?.(() => manager.elementsOf(plugin.data.kind));
671
+ if (!controller) continue;
672
+ if (controller.api !== void 0) apis.set(plugin.data.kind, controller.api);
673
+ controllers.push(controller);
674
+ }
675
+ const systems = systemPlugins.map((plugin) => {
676
+ const instance = plugin.setup({
677
+ scene,
678
+ camera,
679
+ canvas,
680
+ scene3d: threeScene,
681
+ renderer,
682
+ input,
683
+ controlFlags: () => controlSettings.get(),
684
+ capabilities,
685
+ emit,
686
+ resolve: (id) => manager.get(id)
687
+ });
688
+ if (instance.api !== void 0) apis.set(plugin.name, instance.api);
689
+ return instance;
690
+ });
691
+ const cameraApi = apis.get(CTX_SLOT.camera) ?? {
692
+ focusArtwork: () => {},
693
+ centerArtwork: () => {},
694
+ release: () => {},
695
+ reset: () => {},
696
+ isAnimating: () => false
697
+ };
698
+ const headTracking = apis.get(CARDBOARD_SYSTEM);
699
+ const initialFlags = controlSettings.get();
700
+ render.cardboard = initialFlags.splitScreen;
701
+ if (initialFlags.gyro) headTracking?.setEnabled(true);
702
+ let prevFlags = controlSettings.get();
703
+ controlSettings.subscribe((flags) => {
704
+ if (flags.gyro !== prevFlags.gyro) headTracking?.setEnabled(flags.gyro);
705
+ if (flags.splitScreen !== prevFlags.splitScreen) render.cardboard = flags.splitScreen;
706
+ prevFlags = flags;
707
+ saveControlFlags(flags);
708
+ emit("controls:change", {
709
+ flags,
710
+ mode: controlSettings.getMode()
711
+ });
712
+ });
713
+ const lightsApi = apis.get(CTX_SLOT.lights) ?? {
714
+ set: () => {},
715
+ dimAll: () => {},
716
+ restore: () => {}
717
+ };
718
+ const videoApi = apis.get(CTX_SLOT.video) ?? {
719
+ play: () => {},
720
+ pause: () => {},
721
+ setVisible: () => {}
722
+ };
723
+ const skyApi = apis.get(CTX_SLOT.sky) ?? {
724
+ setTime: () => {},
725
+ getTime: () => 12,
726
+ setAzimuth: () => {},
727
+ getAzimuth: () => 0
728
+ };
729
+ const mediaApi = apis.get(CTX_SLOT.media) ?? {
730
+ play: () => {},
731
+ pause: () => {},
732
+ stop: () => {},
733
+ setVolume: () => {},
734
+ playing: () => false,
735
+ setMuted: () => {},
736
+ muted: () => false
737
+ };
738
+ const radiiOf = (s) => {
739
+ const gated = s.artworks ?? [];
740
+ return new Map(gated.filter((a) => a.infoRadius !== void 0).map((a) => [a.id, a.infoRadius]));
741
+ };
742
+ const interaction = new InteractionController(camera, canvas, threeScene, emit, () => !cameraApi.isAnimating());
743
+ interaction.setRadii(radiiOf(scene));
744
+ interaction.attach();
336
745
  const clock = new Clock();
337
746
  let rafId = null;
747
+ const BLOOM = {
748
+ radius: .4,
749
+ threshold: .85
750
+ };
751
+ let postClasses = null;
752
+ let composer = null;
753
+ let ssaoPass = null;
754
+ let bloomPass = null;
755
+ let composerLoading = false;
756
+ const pixelRatio = Math.min(window.devicePixelRatio, 2);
757
+ const needsComposer = () => render.antialias > 0 || render.ssao || render.bloom > 0;
758
+ const buildComposer = () => {
759
+ if (!postClasses) return;
760
+ composer?.dispose();
761
+ const { EffectComposer, RenderPass, SSAOPass, UnrealBloomPass, OutputPass } = postClasses;
762
+ const c = new EffectComposer(renderer, new WebGLRenderTarget(1, 1, {
763
+ samples: render.antialias,
764
+ type: HalfFloatType
765
+ }));
766
+ c.setPixelRatio(pixelRatio);
767
+ c.setSize(width, height);
768
+ c.addPass(new RenderPass(threeScene, camera));
769
+ const ssao = new SSAOPass(threeScene, camera, width, height);
770
+ ssao.kernelRadius = 16;
771
+ ssao.enabled = render.ssao;
772
+ c.addPass(ssao);
773
+ const bloom = new UnrealBloomPass(new Vector2(width, height), render.bloom, BLOOM.radius, BLOOM.threshold);
774
+ bloom.enabled = render.bloom > 0;
775
+ c.addPass(bloom);
776
+ c.addPass(new OutputPass());
777
+ composer = c;
778
+ ssaoPass = ssao;
779
+ bloomPass = bloom;
780
+ };
781
+ const ensureComposer = () => {
782
+ if (!needsComposer()) return;
783
+ if (postClasses) {
784
+ if (!composer) buildComposer();
785
+ return;
786
+ }
787
+ if (composerLoading) return;
788
+ composerLoading = true;
789
+ Promise.all([
790
+ import("three/examples/jsm/postprocessing/EffectComposer.js"),
791
+ import("three/examples/jsm/postprocessing/RenderPass.js"),
792
+ import("three/examples/jsm/postprocessing/SSAOPass.js"),
793
+ import("three/examples/jsm/postprocessing/UnrealBloomPass.js"),
794
+ import("three/examples/jsm/postprocessing/OutputPass.js")
795
+ ]).then(([{ EffectComposer }, { RenderPass }, { SSAOPass }, { UnrealBloomPass }, { OutputPass }]) => {
796
+ postClasses = {
797
+ EffectComposer,
798
+ RenderPass,
799
+ SSAOPass,
800
+ UnrealBloomPass,
801
+ OutputPass
802
+ };
803
+ composerLoading = false;
804
+ buildComposer();
805
+ });
806
+ };
807
+ const refreshShadows = () => {
808
+ renderer.shadowMap.enabled = render.shadows;
809
+ threeScene.traverse((obj) => {
810
+ const mat = obj.material;
811
+ if (!mat) return;
812
+ for (const m of Array.isArray(mat) ? mat : [mat]) m.needsUpdate = true;
813
+ });
814
+ };
815
+ refreshShadows();
816
+ ensureComposer();
817
+ const renderStereo = () => {
818
+ camera.updateWorldMatrix(true, false);
819
+ stereo.update(camera);
820
+ renderer.getSize(stereoSize);
821
+ const w = stereoSize.x;
822
+ const h = stereoSize.y;
823
+ if (renderer.autoClear) renderer.clear();
824
+ renderer.setScissorTest(true);
825
+ renderer.setScissor(0, 0, w / 2, h);
826
+ renderer.setViewport(0, 0, w / 2, h);
827
+ renderer.render(threeScene, stereo.cameraL);
828
+ renderer.setScissor(w / 2, 0, w / 2, h);
829
+ renderer.setViewport(w / 2, 0, w / 2, h);
830
+ renderer.render(threeScene, stereo.cameraR);
831
+ renderer.setScissorTest(false);
832
+ renderer.setViewport(0, 0, w, h);
833
+ };
338
834
  const renderFrame = () => {
339
835
  rafId = requestAnimationFrame(renderFrame);
340
- controls.update(clock.getDelta());
341
- renderer.render(threeScene, camera);
836
+ const delta = clock.getDelta();
837
+ const deltaMs = delta * 1e3;
838
+ for (const system of systems) system.update?.(deltaMs);
839
+ interaction.update(deltaMs);
840
+ manager.updateElements(deltaMs);
841
+ for (const controller of controllers) controller.update?.(deltaMs);
842
+ for (const handler of frameHandlers) handler(deltaMs);
843
+ manager.beforeRender(renderer, threeScene, camera);
844
+ if (render.cardboard) renderStereo();
845
+ else if (composer && needsComposer()) composer.render(delta);
846
+ else renderer.render(threeScene, camera);
342
847
  };
343
848
  return {
344
849
  ctx: {
345
850
  entity: (id) => manager.get(id),
851
+ lights: lightsApi,
852
+ media: mediaApi,
853
+ controls: {
854
+ setMoveInput: (forward, strafe) => input.setMove(forward, strafe),
855
+ setLookInput: (x, y) => input.setLook(x, y)
856
+ },
857
+ viewControls: {
858
+ get: () => controlSettings.get(),
859
+ getMode: () => controlSettings.getMode(),
860
+ setMode: (mode) => controlSettings.setMode(mode),
861
+ setFlag: (key, value) => controlSettings.setFlag(key, value),
862
+ subscribe: (fn) => controlSettings.subscribe(fn)
863
+ },
864
+ environment: { setBackgroundIntensity: (v) => environment.setBackgroundIntensity(v) },
865
+ sky: skyApi,
866
+ camera: cameraApi,
867
+ video: videoApi,
346
868
  three: {
347
869
  scene: threeScene,
348
870
  camera,
@@ -351,7 +873,14 @@ function createGallery(scene, options) {
351
873
  },
352
874
  setScene(next) {
353
875
  manager.reconcile(next);
354
- if (next.camera.bounds !== void 0) controls.bounds = next.camera.bounds;
876
+ interaction.setRadii(radiiOf(next));
877
+ environment.reconcile(next.environment);
878
+ for (const system of systems) system.setScene?.(next);
879
+ const fov = next.camera.fov ?? 55;
880
+ if (camera.fov !== fov) {
881
+ camera.fov = fov;
882
+ camera.updateProjectionMatrix();
883
+ }
355
884
  },
356
885
  start() {
357
886
  if (rafId === null) {
@@ -367,10 +896,15 @@ function createGallery(scene, options) {
367
896
  },
368
897
  dispose() {
369
898
  this.stop();
370
- controls.detach();
899
+ for (const system of systems) system.dispose?.();
900
+ for (const controller of controllers) controller.dispose?.();
901
+ interaction.detach();
902
+ environment.dispose();
371
903
  manager.dispose();
904
+ composer?.dispose();
372
905
  renderer.dispose();
373
906
  listeners.clear();
907
+ frameHandlers.clear();
374
908
  },
375
909
  on(event, handler) {
376
910
  const set = listeners.get(event) ?? /* @__PURE__ */ new Set();
@@ -379,16 +913,89 @@ function createGallery(scene, options) {
379
913
  return () => set.delete(handler);
380
914
  },
381
915
  emit(event, payload) {
382
- listeners.get(event)?.forEach((handler) => handler(payload));
916
+ emit(event, payload);
917
+ },
918
+ onFrame(handler) {
919
+ frameHandlers.add(handler);
920
+ return () => frameHandlers.delete(handler);
383
921
  },
384
922
  resize(w, h) {
385
923
  camera.aspect = w / h;
386
924
  camera.updateProjectionMatrix();
387
925
  renderer.setSize(w, h, false);
926
+ composer?.setSize(w, h);
927
+ ssaoPass?.setSize(w, h);
928
+ bloomPass?.setSize(w, h);
929
+ },
930
+ setRender(next) {
931
+ if (next.shadows !== void 0 && next.shadows !== render.shadows) {
932
+ render.shadows = next.shadows;
933
+ refreshShadows();
934
+ }
935
+ if (next.ssao !== void 0) render.ssao = next.ssao;
936
+ if (next.bloom !== void 0) render.bloom = next.bloom;
937
+ const aaChanged = next.antialias !== void 0 && next.antialias !== render.antialias;
938
+ if (next.antialias !== void 0) render.antialias = next.antialias;
939
+ if (next.cardboard !== void 0 && next.cardboard !== render.cardboard) controlSettings.setMode(next.cardboard ? "cardboard" : "touch");
940
+ if (next.eyeSeparation !== void 0) {
941
+ render.eyeSeparation = next.eyeSeparation;
942
+ stereo.eyeSep = next.eyeSeparation;
943
+ }
944
+ if (ssaoPass) ssaoPass.enabled = render.ssao;
945
+ if (bloomPass) {
946
+ bloomPass.strength = render.bloom;
947
+ bloomPass.enabled = render.bloom > 0;
948
+ }
949
+ if (aaChanged && composer) buildComposer();
950
+ ensureComposer();
388
951
  }
389
952
  };
390
953
  }
391
954
  //#endregion
392
- export { FreeRoamControls, SceneManager, buildArtwork, buildWall, createGallery };
955
+ //#region src/builders/texture.ts
956
+ const textureLoader = new TextureLoader();
957
+ /** Configure a loaded texture: tiling + color space (sRGB only for color maps). */
958
+ const configure = (tex, repeat, srgb) => {
959
+ tex.wrapS = RepeatWrapping;
960
+ tex.wrapT = RepeatWrapping;
961
+ tex.repeat.set(repeat[0], repeat[1]);
962
+ tex.colorSpace = srgb ? SRGBColorSpace : NoColorSpace;
963
+ tex.needsUpdate = true;
964
+ };
965
+ /**
966
+ * Load one map and assign it to `material[slot]` once ready. `.exr` maps go
967
+ * through EXRLoader, imported on demand so it only ships in the bundle when a
968
+ * scene actually uses an EXR map; everything else uses the image loader.
969
+ */
970
+ const loadMap = (material, slot, src, repeat, srgb) => {
971
+ const assign = (tex) => {
972
+ configure(tex, repeat, srgb);
973
+ material[slot] = tex;
974
+ material.needsUpdate = true;
975
+ };
976
+ if (src.toLowerCase().endsWith(".exr")) import("three/examples/jsm/loaders/EXRLoader.js").then(({ EXRLoader }) => {
977
+ new EXRLoader().load(src, assign);
978
+ });
979
+ else textureLoader.load(src, assign);
980
+ };
981
+ /**
982
+ * Apply a `SurfaceTexture` to a standard material. Returns whether a second UV
983
+ * set (`uv1`) is needed — three samples `aoMap` from it, so the geometry must
984
+ * provide one.
985
+ */
986
+ const applyTexture = (material, texture) => {
987
+ const repeat = texture.repeat ?? [1, 1];
988
+ loadMap(material, "map", texture.src, repeat, true);
989
+ if (texture.normal) {
990
+ loadMap(material, "normalMap", texture.normal, repeat, false);
991
+ const s = texture.normalScale ?? 1;
992
+ material.normalScale = new Vector2(s, s);
993
+ }
994
+ if (texture.roughness) loadMap(material, "roughnessMap", texture.roughness, repeat, false);
995
+ if (texture.ao) loadMap(material, "aoMap", texture.ao, repeat, false);
996
+ return texture.ao !== void 0;
997
+ };
998
+ //#endregion
999
+ export { COLLIDERS, CapabilityRegistry, ControlSettings, DEFAULT_CONTROL_FLAGS, EDGES, EYE_HEIGHT, InputBus, InteractionController, MODE_PRESETS, ROAM_CONTROL, SceneManager, TERRAIN, applyMode, applyTexture, createGallery, isSystemPlugin, modeOf };
393
1000
 
394
1001
  //# sourceMappingURL=index.mjs.map