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