@scenography/engine 0.1.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 +399 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +120 -0
- package/dist/index.d.mts +120 -0
- package/dist/index.mjs +394 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +40 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let three = require("three");
|
|
3
|
+
//#region src/controls/boundaries.ts
|
|
4
|
+
/**
|
|
5
|
+
* Clamp a step on the XZ plane to a square `[-bound, bound]`. Axes are clamped
|
|
6
|
+
* independently so sliding along a wall still works when one axis is blocked.
|
|
7
|
+
*/
|
|
8
|
+
const clampToBounds = (current, step, bound) => {
|
|
9
|
+
const next = new three.Vector3().addVectors(current, step);
|
|
10
|
+
if (next.x < -bound || next.x > bound) next.x = current.x;
|
|
11
|
+
if (next.z < -bound || next.z > bound) next.z = current.z;
|
|
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);
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Integrate the pointer delta into a look quaternion. Mutates `look` and
|
|
83
|
+
* returns the resulting rotation. Yaw is unbounded; pitch is clamped to avoid
|
|
84
|
+
* flipping over.
|
|
85
|
+
*/
|
|
86
|
+
const updateLookFromMouse = (mouse, look) => {
|
|
87
|
+
const xh = mouse.delta.x / window.innerWidth;
|
|
88
|
+
const yh = mouse.delta.y / window.innerHeight;
|
|
89
|
+
look.phi += -xh * PHI_SPEED;
|
|
90
|
+
look.theta = clamp(look.theta + -yh * THETA_SPEED, -Math.PI / 3, Math.PI / 3);
|
|
91
|
+
const qy = new three.Quaternion().setFromAxisAngle(new three.Vector3(0, 1, 0), look.phi);
|
|
92
|
+
const qx = new three.Quaternion().setFromAxisAngle(new three.Vector3(1, 0, 0), look.theta);
|
|
93
|
+
return new three.Quaternion().multiply(qy).multiply(qx);
|
|
94
|
+
};
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/controls/FreeRoamControls.ts
|
|
97
|
+
/**
|
|
98
|
+
* First-person, free-roaming camera controls ported from the original Virtual
|
|
99
|
+
* Gallery: drag-to-look with the mouse, WASD to walk, a subtle head-bob while
|
|
100
|
+
* moving, and square boundary clamping. The cardboard / device-orientation path
|
|
101
|
+
* was intentionally dropped.
|
|
102
|
+
*
|
|
103
|
+
* Framework-agnostic: construct it with a camera and the canvas element, call
|
|
104
|
+
* `attach()`, then `update(delta)` once per frame.
|
|
105
|
+
*/
|
|
106
|
+
var FreeRoamControls = class {
|
|
107
|
+
bounds;
|
|
108
|
+
camera;
|
|
109
|
+
domElement;
|
|
110
|
+
mouse = createMouseState();
|
|
111
|
+
keyboard = createKeyboardState();
|
|
112
|
+
headBob = createHeadBobState();
|
|
113
|
+
look = {
|
|
114
|
+
phi: 0,
|
|
115
|
+
theta: 0
|
|
116
|
+
};
|
|
117
|
+
rotation = new three.Quaternion();
|
|
118
|
+
translation = new three.Vector3(0, 170, 0);
|
|
119
|
+
handlers = [];
|
|
120
|
+
constructor(camera, domElement, options = {}) {
|
|
121
|
+
this.camera = camera;
|
|
122
|
+
this.domElement = domElement;
|
|
123
|
+
this.bounds = options.bounds ?? 450;
|
|
124
|
+
if (options.position) this.translation.copy(options.position);
|
|
125
|
+
if (options.yaw !== void 0) this.look.phi = options.yaw;
|
|
126
|
+
this.rotation.setFromAxisAngle(new three.Vector3(0, 1, 0), this.look.phi);
|
|
127
|
+
}
|
|
128
|
+
attach() {
|
|
129
|
+
const bind = (target, type, listener) => {
|
|
130
|
+
target.addEventListener(type, listener);
|
|
131
|
+
this.handlers.push([
|
|
132
|
+
target,
|
|
133
|
+
type,
|
|
134
|
+
listener
|
|
135
|
+
]);
|
|
136
|
+
};
|
|
137
|
+
bind(this.domElement, "mousedown", (e) => onMouseDown(this.mouse, e));
|
|
138
|
+
bind(window, "mousemove", (e) => onMouseMove(this.mouse, e));
|
|
139
|
+
bind(window, "mouseup", () => onMouseUp(this.mouse));
|
|
140
|
+
bind(window, "keydown", (e) => onKeyDown(this.keyboard, e));
|
|
141
|
+
bind(window, "keyup", (e) => onKeyUp(this.keyboard, e));
|
|
142
|
+
}
|
|
143
|
+
detach() {
|
|
144
|
+
for (const [target, type, listener] of this.handlers) target.removeEventListener(type, listener);
|
|
145
|
+
this.handlers.length = 0;
|
|
146
|
+
}
|
|
147
|
+
/** Advance the camera by `delta` seconds of input. */
|
|
148
|
+
update(delta) {
|
|
149
|
+
if (this.mouse.dragging) this.rotation = updateLookFromMouse(this.mouse, this.look);
|
|
150
|
+
const step = movementFromKeyboard(this.keyboard, this.look.phi, delta);
|
|
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);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/builders/wall.ts
|
|
164
|
+
/** Build the three.js object that materializes a `Wall` definition. */
|
|
165
|
+
function buildWall(wall) {
|
|
166
|
+
const [width, height] = wall.size;
|
|
167
|
+
const mesh = new three.Mesh(new three.PlaneGeometry(width, height), new three.MeshStandardMaterial({
|
|
168
|
+
color: wall.color ?? "#ffffff",
|
|
169
|
+
side: three.DoubleSide
|
|
170
|
+
}));
|
|
171
|
+
mesh.position.set(wall.position[0], wall.position[1], wall.position[2]);
|
|
172
|
+
mesh.rotation.y = wall.rotation ?? 0;
|
|
173
|
+
mesh.name = `wall:${wall.id}`;
|
|
174
|
+
mesh.userData.scenographyId = wall.id;
|
|
175
|
+
return mesh;
|
|
176
|
+
}
|
|
177
|
+
//#endregion
|
|
178
|
+
//#region src/builders/artwork.ts
|
|
179
|
+
const loader = new three.TextureLoader();
|
|
180
|
+
const FRAME_MARGIN = 4;
|
|
181
|
+
const SURFACE_OFFSET = 1;
|
|
182
|
+
/**
|
|
183
|
+
* Build the three.js object for an `Artwork`, placing it on its wall. The piece
|
|
184
|
+
* is positioned in the wall's local plane via `offset` and nudged forward along
|
|
185
|
+
* the wall normal so it sits in front of the surface.
|
|
186
|
+
*/
|
|
187
|
+
function buildArtwork(artwork, wall) {
|
|
188
|
+
const [width, height] = artwork.size;
|
|
189
|
+
const [offsetX, offsetY] = artwork.offset ?? [0, 0];
|
|
190
|
+
const yaw = wall.rotation ?? 0;
|
|
191
|
+
const group = new three.Group();
|
|
192
|
+
const wallQuat = new three.Quaternion().setFromAxisAngle(new three.Vector3(0, 1, 0), yaw);
|
|
193
|
+
const right = new three.Vector3(1, 0, 0).applyQuaternion(wallQuat);
|
|
194
|
+
const normal = new three.Vector3(0, 0, 1).applyQuaternion(wallQuat);
|
|
195
|
+
const position = new three.Vector3(wall.position[0], wall.position[1], wall.position[2]).add(right.clone().multiplyScalar(offsetX)).add(new three.Vector3(0, offsetY, 0)).add(normal.clone().multiplyScalar(SURFACE_OFFSET));
|
|
196
|
+
group.position.copy(position);
|
|
197
|
+
group.rotation.y = yaw;
|
|
198
|
+
const frame = new three.Mesh(new three.PlaneGeometry(width + FRAME_MARGIN, height + FRAME_MARGIN), new three.MeshStandardMaterial({ color: "#1a1a1a" }));
|
|
199
|
+
group.add(frame);
|
|
200
|
+
const texture = loader.load(artwork.src);
|
|
201
|
+
texture.colorSpace = three.SRGBColorSpace;
|
|
202
|
+
const image = new three.Mesh(new three.PlaneGeometry(width, height), new three.MeshBasicMaterial({ map: texture }));
|
|
203
|
+
image.position.z = .1;
|
|
204
|
+
group.add(image);
|
|
205
|
+
group.name = `artwork:${artwork.id}`;
|
|
206
|
+
group.userData.scenographyId = artwork.id;
|
|
207
|
+
group.userData.wallId = artwork.wall;
|
|
208
|
+
return group;
|
|
209
|
+
}
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region src/SceneManager.ts
|
|
212
|
+
const disposeObject = (object) => {
|
|
213
|
+
object.traverse((child) => {
|
|
214
|
+
const mesh = child;
|
|
215
|
+
mesh.geometry?.dispose?.();
|
|
216
|
+
const material = mesh.material;
|
|
217
|
+
const dispose = (m) => m?.dispose?.();
|
|
218
|
+
if (Array.isArray(material)) material.forEach(dispose);
|
|
219
|
+
else dispose(material);
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
/**
|
|
223
|
+
* Owns the live three.js scene graph and keeps it in sync with a `Scene`
|
|
224
|
+
* definition. `reconcile` diffs the incoming definition against what is already
|
|
225
|
+
* mounted — by `id` — and only adds/removes/rebuilds what changed. This is what
|
|
226
|
+
* makes hot scene edits cheap: add a wing or drop one while moving and only the
|
|
227
|
+
* touched objects are rebuilt.
|
|
228
|
+
*/
|
|
229
|
+
var SceneManager = class {
|
|
230
|
+
root;
|
|
231
|
+
walls = /* @__PURE__ */ new Map();
|
|
232
|
+
artworks = /* @__PURE__ */ new Map();
|
|
233
|
+
constructor(root) {
|
|
234
|
+
this.root = root;
|
|
235
|
+
}
|
|
236
|
+
reconcile(scene) {
|
|
237
|
+
const wallById = new Map(scene.walls.map((w) => [w.id, w]));
|
|
238
|
+
this.reconcileWalls(scene.walls);
|
|
239
|
+
this.reconcileArtworks(scene.artworks, wallById);
|
|
240
|
+
}
|
|
241
|
+
reconcileWalls(walls) {
|
|
242
|
+
const next = new Set(walls.map((w) => w.id));
|
|
243
|
+
for (const [id, entry] of this.walls) if (!next.has(id)) {
|
|
244
|
+
this.root.remove(entry.object);
|
|
245
|
+
disposeObject(entry.object);
|
|
246
|
+
this.walls.delete(id);
|
|
247
|
+
}
|
|
248
|
+
for (const wall of walls) {
|
|
249
|
+
const hash = JSON.stringify(wall);
|
|
250
|
+
const existing = this.walls.get(wall.id);
|
|
251
|
+
if (existing?.hash === hash) continue;
|
|
252
|
+
if (existing) {
|
|
253
|
+
this.root.remove(existing.object);
|
|
254
|
+
disposeObject(existing.object);
|
|
255
|
+
}
|
|
256
|
+
const object = buildWall(wall);
|
|
257
|
+
this.root.add(object);
|
|
258
|
+
this.walls.set(wall.id, {
|
|
259
|
+
object,
|
|
260
|
+
hash
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
reconcileArtworks(artworks, wallById) {
|
|
265
|
+
const next = new Set(artworks.map((a) => a.id));
|
|
266
|
+
for (const [id, entry] of this.artworks) if (!next.has(id)) {
|
|
267
|
+
this.root.remove(entry.object);
|
|
268
|
+
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
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** Look up the live three.js object for a wall or artwork `id`. */
|
|
293
|
+
get(id) {
|
|
294
|
+
return this.walls.get(id)?.object ?? this.artworks.get(id)?.object;
|
|
295
|
+
}
|
|
296
|
+
dispose() {
|
|
297
|
+
for (const entry of [...this.walls.values(), ...this.artworks.values()]) {
|
|
298
|
+
this.root.remove(entry.object);
|
|
299
|
+
disposeObject(entry.object);
|
|
300
|
+
}
|
|
301
|
+
this.walls.clear();
|
|
302
|
+
this.artworks.clear();
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
//#endregion
|
|
306
|
+
//#region src/createGallery.ts
|
|
307
|
+
/**
|
|
308
|
+
* Create and run a gallery from a scene definition. Sets up the three.js
|
|
309
|
+
* renderer, camera, lights and free-roam controls, materializes the scene, and
|
|
310
|
+
* starts the render loop. `setScene` later reconciles changes in place.
|
|
311
|
+
*/
|
|
312
|
+
function createGallery(scene, options) {
|
|
313
|
+
const { canvas } = options;
|
|
314
|
+
const width = canvas.clientWidth || window.innerWidth;
|
|
315
|
+
const height = canvas.clientHeight || window.innerHeight;
|
|
316
|
+
const renderer = new three.WebGLRenderer({
|
|
317
|
+
canvas,
|
|
318
|
+
antialias: options.antialias ?? true
|
|
319
|
+
});
|
|
320
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
321
|
+
renderer.setSize(width, height, false);
|
|
322
|
+
const threeScene = new three.Scene();
|
|
323
|
+
const camera = new three.PerspectiveCamera(75, width / height, .1, 5e3);
|
|
324
|
+
const { position, yaw } = scene.camera.start;
|
|
325
|
+
camera.position.set(position[0], position[1], position[2]);
|
|
326
|
+
threeScene.add(new three.AmbientLight(16777215, 1.4));
|
|
327
|
+
threeScene.add(new three.HemisphereLight(16777215, 4473924, 1));
|
|
328
|
+
const manager = new SceneManager(threeScene);
|
|
329
|
+
manager.reconcile(scene);
|
|
330
|
+
const controls = new FreeRoamControls(camera, canvas, {
|
|
331
|
+
bounds: scene.camera.bounds,
|
|
332
|
+
position: camera.position.clone(),
|
|
333
|
+
yaw
|
|
334
|
+
});
|
|
335
|
+
controls.attach();
|
|
336
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
337
|
+
const clock = new three.Clock();
|
|
338
|
+
let rafId = null;
|
|
339
|
+
const renderFrame = () => {
|
|
340
|
+
rafId = requestAnimationFrame(renderFrame);
|
|
341
|
+
controls.update(clock.getDelta());
|
|
342
|
+
renderer.render(threeScene, camera);
|
|
343
|
+
};
|
|
344
|
+
return {
|
|
345
|
+
ctx: {
|
|
346
|
+
entity: (id) => manager.get(id),
|
|
347
|
+
three: {
|
|
348
|
+
scene: threeScene,
|
|
349
|
+
camera,
|
|
350
|
+
renderer
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
setScene(next) {
|
|
354
|
+
manager.reconcile(next);
|
|
355
|
+
if (next.camera.bounds !== void 0) controls.bounds = next.camera.bounds;
|
|
356
|
+
},
|
|
357
|
+
start() {
|
|
358
|
+
if (rafId === null) {
|
|
359
|
+
clock.start();
|
|
360
|
+
renderFrame();
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
stop() {
|
|
364
|
+
if (rafId !== null) {
|
|
365
|
+
cancelAnimationFrame(rafId);
|
|
366
|
+
rafId = null;
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
dispose() {
|
|
370
|
+
this.stop();
|
|
371
|
+
controls.detach();
|
|
372
|
+
manager.dispose();
|
|
373
|
+
renderer.dispose();
|
|
374
|
+
listeners.clear();
|
|
375
|
+
},
|
|
376
|
+
on(event, handler) {
|
|
377
|
+
const set = listeners.get(event) ?? /* @__PURE__ */ new Set();
|
|
378
|
+
set.add(handler);
|
|
379
|
+
listeners.set(event, set);
|
|
380
|
+
return () => set.delete(handler);
|
|
381
|
+
},
|
|
382
|
+
emit(event, payload) {
|
|
383
|
+
listeners.get(event)?.forEach((handler) => handler(payload));
|
|
384
|
+
},
|
|
385
|
+
resize(w, h) {
|
|
386
|
+
camera.aspect = w / h;
|
|
387
|
+
camera.updateProjectionMatrix();
|
|
388
|
+
renderer.setSize(w, h, false);
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
//#endregion
|
|
393
|
+
exports.FreeRoamControls = FreeRoamControls;
|
|
394
|
+
exports.SceneManager = SceneManager;
|
|
395
|
+
exports.buildArtwork = buildArtwork;
|
|
396
|
+
exports.buildWall = buildWall;
|
|
397
|
+
exports.createGallery = createGallery;
|
|
398
|
+
|
|
399
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["Vector3","Quaternion","Vector3","Vector2","Quaternion","Vector3","Quaternion","Vector3","Mesh","PlaneGeometry","MeshStandardMaterial","DoubleSide","TextureLoader","Group","Quaternion","Vector3","Mesh","PlaneGeometry","MeshStandardMaterial","SRGBColorSpace","MeshBasicMaterial","WebGLRenderer","ThreeScene","PerspectiveCamera","AmbientLight","HemisphereLight","Clock"],"sources":["../src/controls/boundaries.ts","../src/controls/headBob.ts","../src/controls/keyboard.ts","../src/controls/mouse.ts","../src/controls/FreeRoamControls.ts","../src/builders/wall.ts","../src/builders/artwork.ts","../src/SceneManager.ts","../src/createGallery.ts"],"sourcesContent":["import { Vector3 } from \"three\";\n\n/**\n * Clamp a step on the XZ plane to a square `[-bound, bound]`. Axes are clamped\n * independently so sliding along a wall still works when one axis is blocked.\n */\nexport const clampToBounds = (current: Vector3, step: Vector3, bound: number): Vector3 => {\n const next = new Vector3().addVectors(current, step);\n if (next.x < -bound || next.x > bound) next.x = current.x;\n if (next.z < -bound || next.z > bound) next.z = current.z;\n return next;\n};\n","/** State for the subtle vertical bob applied while walking. */\nexport interface HeadBobState {\n active: boolean;\n timer: number;\n}\n\nexport const createHeadBobState = (): HeadBobState => ({ active: false, timer: 0 });\n\n/**\n * Advance the head-bob timer by `elapsed` seconds, snapping to half-wavelength\n * steps so the bob always settles back to neutral when movement stops.\n */\nexport const updateHeadBob = (state: HeadBobState, elapsed: number): void => {\n if (!state.active) return;\n\n const wavelength = Math.PI;\n const nextStep = 1 + Math.floor(((state.timer + 1e-6) * 10) / wavelength);\n const nextStepTime = (nextStep * wavelength) / 10;\n state.timer = Math.min(state.timer + elapsed, nextStepTime);\n\n if (state.timer === nextStepTime) state.active = false;\n};\n\n/** Vertical offset to add to the camera, in world units. */\nexport const headBobOffset = (state: HeadBobState): number => Math.sin(state.timer * 10) * 1.5;\n","import { Quaternion, Vector3 } from \"three\";\n\n/** Set of currently-pressed key codes. */\nexport type KeyboardState = Record<string, boolean>;\n\nexport const createKeyboardState = (): KeyboardState => ({});\n\nexport const onKeyDown = (state: KeyboardState, event: KeyboardEvent): void => {\n state[event.code] = true;\n};\n\nexport const onKeyUp = (state: KeyboardState, event: KeyboardEvent): void => {\n state[event.code] = false;\n};\n\n/**\n * Compute the per-frame translation from WASD input, oriented by the current\n * yaw (`phi`). Holding Shift acts as a speed booster.\n */\nexport const movementFromKeyboard = (state: KeyboardState, phi: number, delta: number): Vector3 => {\n const has = (code: string): number => (state[code] ? 1 : 0);\n const forwardVelocity = has(\"KeyW\") - has(\"KeyS\");\n const strafeVelocity = has(\"KeyA\") - has(\"KeyD\");\n const boosted = state[\"ShiftLeft\"] || state[\"ShiftRight\"];\n const factor = boosted ? 30 : 10;\n\n const yaw = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), phi);\n\n const forward = new Vector3(0, 0, -10)\n .applyQuaternion(yaw)\n .multiplyScalar(forwardVelocity * delta * factor);\n const left = new Vector3(-10, 0, 0)\n .applyQuaternion(yaw)\n .multiplyScalar(strafeVelocity * delta * factor);\n\n return forward.add(left);\n};\n","import { Quaternion, Vector2, Vector3 } from \"three\";\n\n/** Mutable pointer state accumulated between frames. */\nexport interface MouseState {\n /** Whether the primary button is held (drag-to-look). */\n dragging: boolean;\n pos: Vector2;\n prevPos: Vector2;\n delta: Vector2;\n}\n\n/** Look angles driven by the pointer. */\nexport interface LookState {\n phi: number;\n theta: number;\n}\n\nconst PHI_SPEED = 8;\nconst THETA_SPEED = 5;\n\nexport const createMouseState = (): MouseState => ({\n dragging: false,\n pos: new Vector2(),\n prevPos: new Vector2(),\n delta: new Vector2(),\n});\n\nconst clamp = (x: number, a: number, b: number): number => Math.min(Math.max(x, a), b);\n\nexport const onMouseDown = (state: MouseState, event: MouseEvent): void => {\n state.dragging = event.button === 0;\n};\n\nexport const onMouseUp = (state: MouseState): void => {\n state.dragging = false;\n};\n\nexport const onMouseMove = (state: MouseState, event: MouseEvent): void => {\n state.pos.set(event.pageX - window.innerWidth / 2, event.pageY - window.innerHeight / 2);\n state.delta.subVectors(state.pos, state.prevPos);\n};\n\nexport const commitMouseFrame = (state: MouseState): void => {\n state.prevPos.copy(state.pos);\n state.delta.set(0, 0);\n};\n\n/**\n * Integrate the pointer delta into a look quaternion. Mutates `look` and\n * returns the resulting rotation. Yaw is unbounded; pitch is clamped to avoid\n * flipping over.\n */\nexport const updateLookFromMouse = (mouse: MouseState, look: LookState): Quaternion => {\n const xh = mouse.delta.x / window.innerWidth;\n const yh = mouse.delta.y / window.innerHeight;\n\n look.phi += -xh * PHI_SPEED;\n look.theta = clamp(look.theta + -yh * THETA_SPEED, -Math.PI / 3, Math.PI / 3);\n\n const qy = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), look.phi);\n const qx = new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), look.theta);\n return new Quaternion().multiply(qy).multiply(qx);\n};\n","import { Quaternion, Vector3, type PerspectiveCamera } from \"three\";\n\nimport { clampToBounds } from \"./boundaries\";\nimport { createHeadBobState, headBobOffset, updateHeadBob } from \"./headBob\";\nimport { createKeyboardState, movementFromKeyboard, onKeyDown, onKeyUp } from \"./keyboard\";\nimport {\n commitMouseFrame,\n createMouseState,\n onMouseDown,\n onMouseMove,\n onMouseUp,\n updateLookFromMouse,\n type LookState,\n} from \"./mouse\";\n\nexport interface FreeRoamOptions {\n /** Square half-extent that clamps XZ movement. */\n bounds?: number;\n /** Starting position. */\n position?: Vector3;\n /** Starting yaw in radians. */\n yaw?: number;\n}\n\n/**\n * First-person, free-roaming camera controls ported from the original Virtual\n * Gallery: drag-to-look with the mouse, WASD to walk, a subtle head-bob while\n * moving, and square boundary clamping. The cardboard / device-orientation path\n * was intentionally dropped.\n *\n * Framework-agnostic: construct it with a camera and the canvas element, call\n * `attach()`, then `update(delta)` once per frame.\n */\nexport class FreeRoamControls {\n bounds: number;\n\n private readonly camera: PerspectiveCamera;\n private readonly domElement: HTMLElement;\n private readonly mouse = createMouseState();\n private readonly keyboard = createKeyboardState();\n private readonly headBob = createHeadBobState();\n private readonly look: LookState = { phi: 0, theta: 0 };\n private rotation = new Quaternion();\n private translation = new Vector3(0, 170, 0);\n\n private readonly handlers: Array<[EventTarget, string, EventListener]> = [];\n\n constructor(camera: PerspectiveCamera, domElement: HTMLElement, options: FreeRoamOptions = {}) {\n this.camera = camera;\n this.domElement = domElement;\n this.bounds = options.bounds ?? 450;\n if (options.position) this.translation.copy(options.position);\n if (options.yaw !== undefined) this.look.phi = options.yaw;\n this.rotation.setFromAxisAngle(new Vector3(0, 1, 0), this.look.phi);\n }\n\n attach(): void {\n const bind = (target: EventTarget, type: string, listener: EventListener): void => {\n target.addEventListener(type, listener);\n this.handlers.push([target, type, listener]);\n };\n\n bind(this.domElement, \"mousedown\", (e) => onMouseDown(this.mouse, e as MouseEvent));\n bind(window, \"mousemove\", (e) => onMouseMove(this.mouse, e as MouseEvent));\n bind(window, \"mouseup\", () => onMouseUp(this.mouse));\n bind(window, \"keydown\", (e) => onKeyDown(this.keyboard, e as KeyboardEvent));\n bind(window, \"keyup\", (e) => onKeyUp(this.keyboard, e as KeyboardEvent));\n }\n\n detach(): void {\n for (const [target, type, listener] of this.handlers)\n target.removeEventListener(type, listener);\n this.handlers.length = 0;\n }\n\n /** Advance the camera by `delta` seconds of input. */\n update(delta: number): void {\n if (this.mouse.dragging) this.rotation = updateLookFromMouse(this.mouse, this.look);\n\n const step = movementFromKeyboard(this.keyboard, this.look.phi, delta);\n this.translation = clampToBounds(this.translation, step, this.bounds);\n if (step.x !== 0 || step.z !== 0) this.headBob.active = true;\n updateHeadBob(this.headBob, delta);\n\n this.camera.quaternion.copy(this.rotation);\n this.camera.position.copy(this.translation);\n this.camera.position.y += headBobOffset(this.headBob);\n\n const target = new Vector3(0, 0, -1)\n .applyQuaternion(this.rotation)\n .multiplyScalar(100)\n .add(this.translation);\n this.camera.lookAt(target);\n\n commitMouseFrame(this.mouse);\n }\n}\n","import { DoubleSide, Mesh, MeshStandardMaterial, PlaneGeometry, type Object3D } from \"three\";\nimport type { Wall } from \"@scenography/core\";\n\n/** Build the three.js object that materializes a `Wall` definition. */\nexport function buildWall(wall: Wall): Object3D {\n const [width, height] = wall.size;\n const geometry = new PlaneGeometry(width, height);\n const material = new MeshStandardMaterial({ color: wall.color ?? \"#ffffff\", side: DoubleSide });\n\n const mesh = new Mesh(geometry, material);\n mesh.position.set(wall.position[0], wall.position[1], wall.position[2]);\n mesh.rotation.y = wall.rotation ?? 0;\n mesh.name = `wall:${wall.id}`;\n mesh.userData.scenographyId = wall.id;\n return mesh;\n}\n","import {\n Group,\n Mesh,\n MeshBasicMaterial,\n MeshStandardMaterial,\n PlaneGeometry,\n Quaternion,\n SRGBColorSpace,\n TextureLoader,\n Vector3,\n type Object3D,\n} from \"three\";\nimport type { Artwork, Wall } from \"@scenography/core\";\n\nconst loader = new TextureLoader();\nconst FRAME_MARGIN = 4;\nconst SURFACE_OFFSET = 1;\n\n/**\n * Build the three.js object for an `Artwork`, placing it on its wall. The piece\n * is positioned in the wall's local plane via `offset` and nudged forward along\n * the wall normal so it sits in front of the surface.\n */\nexport function buildArtwork(artwork: Artwork, wall: Wall): Object3D {\n const [width, height] = artwork.size;\n const [offsetX, offsetY] = artwork.offset ?? [0, 0];\n const yaw = wall.rotation ?? 0;\n\n const group = new Group();\n const wallQuat = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), yaw);\n const right = new Vector3(1, 0, 0).applyQuaternion(wallQuat);\n const normal = new Vector3(0, 0, 1).applyQuaternion(wallQuat);\n\n const position = new Vector3(wall.position[0], wall.position[1], wall.position[2])\n .add(right.clone().multiplyScalar(offsetX))\n .add(new Vector3(0, offsetY, 0))\n .add(normal.clone().multiplyScalar(SURFACE_OFFSET));\n group.position.copy(position);\n group.rotation.y = yaw;\n\n // Frame: a slightly larger dark plane behind the image.\n const frame = new Mesh(\n new PlaneGeometry(width + FRAME_MARGIN, height + FRAME_MARGIN),\n new MeshStandardMaterial({ color: \"#1a1a1a\" }),\n );\n group.add(frame);\n\n // Image: textured plane, nudged forward so it reads above the frame.\n const texture = loader.load(artwork.src);\n texture.colorSpace = SRGBColorSpace;\n const image = new Mesh(new PlaneGeometry(width, height), new MeshBasicMaterial({ map: texture }));\n image.position.z = 0.1;\n group.add(image);\n\n group.name = `artwork:${artwork.id}`;\n group.userData.scenographyId = artwork.id;\n group.userData.wallId = artwork.wall;\n return group;\n}\n","import type { Object3D, Scene as ThreeScene } from \"three\";\nimport type { Artwork, Scene, Wall } from \"@scenography/core\";\n\nimport { buildArtwork, buildWall } from \"./builders\";\n\ninterface Entry {\n object: Object3D;\n /** JSON snapshot used to detect changes between reconciles. */\n hash: string;\n}\n\nconst disposeObject = (object: Object3D): void => {\n object.traverse((child) => {\n const mesh = child as { geometry?: { dispose?: () => void }; material?: unknown };\n mesh.geometry?.dispose?.();\n const material = mesh.material;\n const dispose = (m: unknown): void => (m as { dispose?: () => void })?.dispose?.();\n if (Array.isArray(material)) material.forEach(dispose);\n else dispose(material);\n });\n};\n\n/**\n * Owns the live three.js scene graph and keeps it in sync with a `Scene`\n * definition. `reconcile` diffs the incoming definition against what is already\n * mounted — by `id` — and only adds/removes/rebuilds what changed. This is what\n * makes hot scene edits cheap: add a wing or drop one while moving and only the\n * touched objects are rebuilt.\n */\nexport class SceneManager {\n private readonly root: ThreeScene;\n private readonly walls = new Map<string, Entry>();\n private readonly artworks = new Map<string, Entry>();\n\n constructor(root: ThreeScene) {\n this.root = root;\n }\n\n reconcile(scene: Scene): void {\n const wallById = new Map(scene.walls.map((w) => [w.id, w] as const));\n this.reconcileWalls(scene.walls);\n this.reconcileArtworks(scene.artworks, wallById);\n }\n\n private reconcileWalls(walls: Wall[]): void {\n const next = new Set(walls.map((w) => w.id));\n for (const [id, entry] of this.walls) {\n if (!next.has(id)) {\n this.root.remove(entry.object);\n disposeObject(entry.object);\n this.walls.delete(id);\n }\n }\n for (const wall of walls) {\n const hash = JSON.stringify(wall);\n const existing = this.walls.get(wall.id);\n if (existing?.hash === hash) continue;\n if (existing) {\n this.root.remove(existing.object);\n disposeObject(existing.object);\n }\n const object = buildWall(wall);\n this.root.add(object);\n this.walls.set(wall.id, { object, hash });\n }\n }\n\n private reconcileArtworks(artworks: Artwork[], wallById: Map<string, Wall>): void {\n const next = new Set(artworks.map((a) => a.id));\n for (const [id, entry] of this.artworks) {\n if (!next.has(id)) {\n this.root.remove(entry.object);\n disposeObject(entry.object);\n this.artworks.delete(id);\n }\n }\n for (const artwork of artworks) {\n const wall = wallById.get(artwork.wall);\n if (!wall) continue;\n // Rebuild when either the artwork or the wall it hangs on changed.\n const hash = JSON.stringify({ a: artwork, w: wall });\n const existing = this.artworks.get(artwork.id);\n if (existing?.hash === hash) continue;\n if (existing) {\n this.root.remove(existing.object);\n disposeObject(existing.object);\n }\n const object = buildArtwork(artwork, wall);\n this.root.add(object);\n this.artworks.set(artwork.id, { object, hash });\n }\n }\n\n /** Look up the live three.js object for a wall or artwork `id`. */\n get(id: string): Object3D | undefined {\n return this.walls.get(id)?.object ?? this.artworks.get(id)?.object;\n }\n\n dispose(): void {\n for (const entry of [...this.walls.values(), ...this.artworks.values()]) {\n this.root.remove(entry.object);\n disposeObject(entry.object);\n }\n this.walls.clear();\n this.artworks.clear();\n }\n}\n","import {\n AmbientLight,\n Clock,\n HemisphereLight,\n PerspectiveCamera,\n Scene as ThreeScene,\n WebGLRenderer,\n type Object3D,\n} from \"three\";\nimport type { Scene } from \"@scenography/core\";\n\nimport { FreeRoamControls } from \"./controls\";\nimport { SceneManager } from \"./SceneManager\";\n\nexport interface GalleryOptions {\n /** Canvas the gallery renders into. */\n canvas: HTMLCanvasElement;\n /** Background color. Defaults to a soft gray. */\n background?: string;\n antialias?: boolean;\n}\n\n/**\n * High-level API handed to behavior code. Deliberately NOT raw three.js: this is\n * the supported surface. Reach into `three` only as a documented escape hatch.\n */\nexport interface GalleryContext {\n /** Resolve the live object for a wall/artwork `id`. */\n entity(id: string): Object3D | undefined;\n /** Escape hatch to the underlying three.js objects for advanced use. */\n readonly three: {\n scene: ThreeScene;\n camera: PerspectiveCamera;\n renderer: WebGLRenderer;\n };\n}\n\nexport interface Gallery {\n /** Apply a new scene definition, reconciling only what changed (hot-swap). */\n setScene(scene: Scene): void;\n /** Start the render loop. */\n start(): void;\n /** Stop the render loop. */\n stop(): void;\n /** Tear everything down and release GPU resources. */\n dispose(): void;\n /** Subscribe to a named event; returns an unsubscribe function. */\n on(event: string, handler: (payload?: unknown) => void): () => void;\n /** Emit a named event to subscribers. */\n emit(event: string, payload?: unknown): void;\n /** Resize the renderer/camera to the given pixel size. */\n resize(width: number, height: number): void;\n readonly ctx: GalleryContext;\n}\n\n/**\n * Create and run a gallery from a scene definition. Sets up the three.js\n * renderer, camera, lights and free-roam controls, materializes the scene, and\n * starts the render loop. `setScene` later reconciles changes in place.\n */\nexport function createGallery(scene: Scene, options: GalleryOptions): Gallery {\n const { canvas } = options;\n const width = canvas.clientWidth || window.innerWidth;\n const height = canvas.clientHeight || window.innerHeight;\n\n const renderer = new WebGLRenderer({ canvas, antialias: options.antialias ?? true });\n renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n renderer.setSize(width, height, false);\n\n const threeScene = new ThreeScene();\n\n const camera = new PerspectiveCamera(75, width / height, 0.1, 5000);\n const { position, yaw } = scene.camera.start;\n camera.position.set(position[0], position[1], position[2]);\n\n threeScene.add(new AmbientLight(0xffffff, 1.4));\n threeScene.add(new HemisphereLight(0xffffff, 0x444444, 1));\n\n const manager = new SceneManager(threeScene);\n manager.reconcile(scene);\n\n const controls = new FreeRoamControls(camera, canvas, {\n bounds: scene.camera.bounds,\n position: camera.position.clone(),\n yaw,\n });\n controls.attach();\n\n const listeners = new Map<string, Set<(payload?: unknown) => void>>();\n const clock = new Clock();\n let rafId: number | null = null;\n\n const renderFrame = (): void => {\n rafId = requestAnimationFrame(renderFrame);\n controls.update(clock.getDelta());\n renderer.render(threeScene, camera);\n };\n\n const ctx: GalleryContext = {\n entity: (id) => manager.get(id),\n three: { scene: threeScene, camera, renderer },\n };\n\n return {\n ctx,\n setScene(next) {\n manager.reconcile(next);\n if (next.camera.bounds !== undefined) controls.bounds = next.camera.bounds;\n },\n start() {\n if (rafId === null) {\n clock.start();\n renderFrame();\n }\n },\n stop() {\n if (rafId !== null) {\n cancelAnimationFrame(rafId);\n rafId = null;\n }\n },\n dispose() {\n this.stop();\n controls.detach();\n manager.dispose();\n renderer.dispose();\n listeners.clear();\n },\n on(event, handler) {\n const set = listeners.get(event) ?? new Set();\n set.add(handler);\n listeners.set(event, set);\n return () => set.delete(handler);\n },\n emit(event, payload) {\n listeners.get(event)?.forEach((handler) => handler(payload));\n },\n resize(w, h) {\n camera.aspect = w / h;\n camera.updateProjectionMatrix();\n renderer.setSize(w, h, false);\n },\n };\n}\n"],"mappings":"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"}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Object3D, PerspectiveCamera, Scene, Vector3, WebGLRenderer } from "three";
|
|
2
|
+
import { Artwork, Scene as Scene$1, Wall } from "@scenography/core";
|
|
3
|
+
|
|
4
|
+
//#region src/createGallery.d.ts
|
|
5
|
+
interface GalleryOptions {
|
|
6
|
+
/** Canvas the gallery renders into. */
|
|
7
|
+
canvas: HTMLCanvasElement;
|
|
8
|
+
/** Background color. Defaults to a soft gray. */
|
|
9
|
+
background?: string;
|
|
10
|
+
antialias?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* High-level API handed to behavior code. Deliberately NOT raw three.js: this is
|
|
14
|
+
* the supported surface. Reach into `three` only as a documented escape hatch.
|
|
15
|
+
*/
|
|
16
|
+
interface GalleryContext {
|
|
17
|
+
/** Resolve the live object for a wall/artwork `id`. */
|
|
18
|
+
entity(id: string): Object3D | undefined;
|
|
19
|
+
/** Escape hatch to the underlying three.js objects for advanced use. */
|
|
20
|
+
readonly three: {
|
|
21
|
+
scene: Scene;
|
|
22
|
+
camera: PerspectiveCamera;
|
|
23
|
+
renderer: WebGLRenderer;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
interface Gallery {
|
|
27
|
+
/** Apply a new scene definition, reconciling only what changed (hot-swap). */
|
|
28
|
+
setScene(scene: Scene$1): void;
|
|
29
|
+
/** Start the render loop. */
|
|
30
|
+
start(): void;
|
|
31
|
+
/** Stop the render loop. */
|
|
32
|
+
stop(): void;
|
|
33
|
+
/** Tear everything down and release GPU resources. */
|
|
34
|
+
dispose(): void;
|
|
35
|
+
/** Subscribe to a named event; returns an unsubscribe function. */
|
|
36
|
+
on(event: string, handler: (payload?: unknown) => void): () => void;
|
|
37
|
+
/** Emit a named event to subscribers. */
|
|
38
|
+
emit(event: string, payload?: unknown): void;
|
|
39
|
+
/** Resize the renderer/camera to the given pixel size. */
|
|
40
|
+
resize(width: number, height: number): void;
|
|
41
|
+
readonly ctx: GalleryContext;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Create and run a gallery from a scene definition. Sets up the three.js
|
|
45
|
+
* renderer, camera, lights and free-roam controls, materializes the scene, and
|
|
46
|
+
* starts the render loop. `setScene` later reconciles changes in place.
|
|
47
|
+
*/
|
|
48
|
+
declare function createGallery(scene: Scene$1, options: GalleryOptions): Gallery;
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/SceneManager.d.ts
|
|
51
|
+
/**
|
|
52
|
+
* Owns the live three.js scene graph and keeps it in sync with a `Scene`
|
|
53
|
+
* definition. `reconcile` diffs the incoming definition against what is already
|
|
54
|
+
* mounted — by `id` — and only adds/removes/rebuilds what changed. This is what
|
|
55
|
+
* makes hot scene edits cheap: add a wing or drop one while moving and only the
|
|
56
|
+
* touched objects are rebuilt.
|
|
57
|
+
*/
|
|
58
|
+
declare class SceneManager {
|
|
59
|
+
private readonly root;
|
|
60
|
+
private readonly walls;
|
|
61
|
+
private readonly artworks;
|
|
62
|
+
constructor(root: Scene);
|
|
63
|
+
reconcile(scene: Scene$1): void;
|
|
64
|
+
private reconcileWalls;
|
|
65
|
+
private reconcileArtworks;
|
|
66
|
+
/** Look up the live three.js object for a wall or artwork `id`. */
|
|
67
|
+
get(id: string): Object3D | undefined;
|
|
68
|
+
dispose(): void;
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/controls/FreeRoamControls.d.ts
|
|
72
|
+
interface FreeRoamOptions {
|
|
73
|
+
/** Square half-extent that clamps XZ movement. */
|
|
74
|
+
bounds?: number;
|
|
75
|
+
/** Starting position. */
|
|
76
|
+
position?: Vector3;
|
|
77
|
+
/** Starting yaw in radians. */
|
|
78
|
+
yaw?: number;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* First-person, free-roaming camera controls ported from the original Virtual
|
|
82
|
+
* Gallery: drag-to-look with the mouse, WASD to walk, a subtle head-bob while
|
|
83
|
+
* moving, and square boundary clamping. The cardboard / device-orientation path
|
|
84
|
+
* was intentionally dropped.
|
|
85
|
+
*
|
|
86
|
+
* Framework-agnostic: construct it with a camera and the canvas element, call
|
|
87
|
+
* `attach()`, then `update(delta)` once per frame.
|
|
88
|
+
*/
|
|
89
|
+
declare class FreeRoamControls {
|
|
90
|
+
bounds: number;
|
|
91
|
+
private readonly camera;
|
|
92
|
+
private readonly domElement;
|
|
93
|
+
private readonly mouse;
|
|
94
|
+
private readonly keyboard;
|
|
95
|
+
private readonly headBob;
|
|
96
|
+
private readonly look;
|
|
97
|
+
private rotation;
|
|
98
|
+
private translation;
|
|
99
|
+
private readonly handlers;
|
|
100
|
+
constructor(camera: PerspectiveCamera, domElement: HTMLElement, options?: FreeRoamOptions);
|
|
101
|
+
attach(): void;
|
|
102
|
+
detach(): void;
|
|
103
|
+
/** Advance the camera by `delta` seconds of input. */
|
|
104
|
+
update(delta: number): void;
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/builders/wall.d.ts
|
|
108
|
+
/** Build the three.js object that materializes a `Wall` definition. */
|
|
109
|
+
declare function buildWall(wall: Wall): Object3D;
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/builders/artwork.d.ts
|
|
112
|
+
/**
|
|
113
|
+
* Build the three.js object for an `Artwork`, placing it on its wall. The piece
|
|
114
|
+
* is positioned in the wall's local plane via `offset` and nudged forward along
|
|
115
|
+
* the wall normal so it sits in front of the surface.
|
|
116
|
+
*/
|
|
117
|
+
declare function buildArtwork(artwork: Artwork, wall: Wall): Object3D;
|
|
118
|
+
//#endregion
|
|
119
|
+
export { FreeRoamControls, type FreeRoamOptions, type Gallery, type GalleryContext, type GalleryOptions, SceneManager, buildArtwork, buildWall, createGallery };
|
|
120
|
+
//# sourceMappingURL=index.d.cts.map
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Object3D, PerspectiveCamera, Scene, Vector3, WebGLRenderer } from "three";
|
|
2
|
+
import { Artwork, Scene as Scene$1, Wall } from "@scenography/core";
|
|
3
|
+
|
|
4
|
+
//#region src/createGallery.d.ts
|
|
5
|
+
interface GalleryOptions {
|
|
6
|
+
/** Canvas the gallery renders into. */
|
|
7
|
+
canvas: HTMLCanvasElement;
|
|
8
|
+
/** Background color. Defaults to a soft gray. */
|
|
9
|
+
background?: string;
|
|
10
|
+
antialias?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* High-level API handed to behavior code. Deliberately NOT raw three.js: this is
|
|
14
|
+
* the supported surface. Reach into `three` only as a documented escape hatch.
|
|
15
|
+
*/
|
|
16
|
+
interface GalleryContext {
|
|
17
|
+
/** Resolve the live object for a wall/artwork `id`. */
|
|
18
|
+
entity(id: string): Object3D | undefined;
|
|
19
|
+
/** Escape hatch to the underlying three.js objects for advanced use. */
|
|
20
|
+
readonly three: {
|
|
21
|
+
scene: Scene;
|
|
22
|
+
camera: PerspectiveCamera;
|
|
23
|
+
renderer: WebGLRenderer;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
interface Gallery {
|
|
27
|
+
/** Apply a new scene definition, reconciling only what changed (hot-swap). */
|
|
28
|
+
setScene(scene: Scene$1): void;
|
|
29
|
+
/** Start the render loop. */
|
|
30
|
+
start(): void;
|
|
31
|
+
/** Stop the render loop. */
|
|
32
|
+
stop(): void;
|
|
33
|
+
/** Tear everything down and release GPU resources. */
|
|
34
|
+
dispose(): void;
|
|
35
|
+
/** Subscribe to a named event; returns an unsubscribe function. */
|
|
36
|
+
on(event: string, handler: (payload?: unknown) => void): () => void;
|
|
37
|
+
/** Emit a named event to subscribers. */
|
|
38
|
+
emit(event: string, payload?: unknown): void;
|
|
39
|
+
/** Resize the renderer/camera to the given pixel size. */
|
|
40
|
+
resize(width: number, height: number): void;
|
|
41
|
+
readonly ctx: GalleryContext;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Create and run a gallery from a scene definition. Sets up the three.js
|
|
45
|
+
* renderer, camera, lights and free-roam controls, materializes the scene, and
|
|
46
|
+
* starts the render loop. `setScene` later reconciles changes in place.
|
|
47
|
+
*/
|
|
48
|
+
declare function createGallery(scene: Scene$1, options: GalleryOptions): Gallery;
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/SceneManager.d.ts
|
|
51
|
+
/**
|
|
52
|
+
* Owns the live three.js scene graph and keeps it in sync with a `Scene`
|
|
53
|
+
* definition. `reconcile` diffs the incoming definition against what is already
|
|
54
|
+
* mounted — by `id` — and only adds/removes/rebuilds what changed. This is what
|
|
55
|
+
* makes hot scene edits cheap: add a wing or drop one while moving and only the
|
|
56
|
+
* touched objects are rebuilt.
|
|
57
|
+
*/
|
|
58
|
+
declare class SceneManager {
|
|
59
|
+
private readonly root;
|
|
60
|
+
private readonly walls;
|
|
61
|
+
private readonly artworks;
|
|
62
|
+
constructor(root: Scene);
|
|
63
|
+
reconcile(scene: Scene$1): void;
|
|
64
|
+
private reconcileWalls;
|
|
65
|
+
private reconcileArtworks;
|
|
66
|
+
/** Look up the live three.js object for a wall or artwork `id`. */
|
|
67
|
+
get(id: string): Object3D | undefined;
|
|
68
|
+
dispose(): void;
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/controls/FreeRoamControls.d.ts
|
|
72
|
+
interface FreeRoamOptions {
|
|
73
|
+
/** Square half-extent that clamps XZ movement. */
|
|
74
|
+
bounds?: number;
|
|
75
|
+
/** Starting position. */
|
|
76
|
+
position?: Vector3;
|
|
77
|
+
/** Starting yaw in radians. */
|
|
78
|
+
yaw?: number;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* First-person, free-roaming camera controls ported from the original Virtual
|
|
82
|
+
* Gallery: drag-to-look with the mouse, WASD to walk, a subtle head-bob while
|
|
83
|
+
* moving, and square boundary clamping. The cardboard / device-orientation path
|
|
84
|
+
* was intentionally dropped.
|
|
85
|
+
*
|
|
86
|
+
* Framework-agnostic: construct it with a camera and the canvas element, call
|
|
87
|
+
* `attach()`, then `update(delta)` once per frame.
|
|
88
|
+
*/
|
|
89
|
+
declare class FreeRoamControls {
|
|
90
|
+
bounds: number;
|
|
91
|
+
private readonly camera;
|
|
92
|
+
private readonly domElement;
|
|
93
|
+
private readonly mouse;
|
|
94
|
+
private readonly keyboard;
|
|
95
|
+
private readonly headBob;
|
|
96
|
+
private readonly look;
|
|
97
|
+
private rotation;
|
|
98
|
+
private translation;
|
|
99
|
+
private readonly handlers;
|
|
100
|
+
constructor(camera: PerspectiveCamera, domElement: HTMLElement, options?: FreeRoamOptions);
|
|
101
|
+
attach(): void;
|
|
102
|
+
detach(): void;
|
|
103
|
+
/** Advance the camera by `delta` seconds of input. */
|
|
104
|
+
update(delta: number): void;
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/builders/wall.d.ts
|
|
108
|
+
/** Build the three.js object that materializes a `Wall` definition. */
|
|
109
|
+
declare function buildWall(wall: Wall): Object3D;
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/builders/artwork.d.ts
|
|
112
|
+
/**
|
|
113
|
+
* Build the three.js object for an `Artwork`, placing it on its wall. The piece
|
|
114
|
+
* is positioned in the wall's local plane via `offset` and nudged forward along
|
|
115
|
+
* the wall normal so it sits in front of the surface.
|
|
116
|
+
*/
|
|
117
|
+
declare function buildArtwork(artwork: Artwork, wall: Wall): Object3D;
|
|
118
|
+
//#endregion
|
|
119
|
+
export { FreeRoamControls, type FreeRoamOptions, type Gallery, type GalleryContext, type GalleryOptions, SceneManager, buildArtwork, buildWall, createGallery };
|
|
120
|
+
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
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);
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
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.
|
|
84
|
+
*/
|
|
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);
|
|
93
|
+
};
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region src/controls/FreeRoamControls.ts
|
|
96
|
+
/**
|
|
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.
|
|
104
|
+
*/
|
|
105
|
+
var FreeRoamControls = class {
|
|
106
|
+
bounds;
|
|
107
|
+
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);
|
|
118
|
+
handlers = [];
|
|
119
|
+
constructor(camera, domElement, options = {}) {
|
|
120
|
+
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);
|
|
126
|
+
}
|
|
127
|
+
attach() {
|
|
128
|
+
const bind = (target, type, listener) => {
|
|
129
|
+
target.addEventListener(type, listener);
|
|
130
|
+
this.handlers.push([
|
|
131
|
+
target,
|
|
132
|
+
type,
|
|
133
|
+
listener
|
|
134
|
+
]);
|
|
135
|
+
};
|
|
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));
|
|
141
|
+
}
|
|
142
|
+
detach() {
|
|
143
|
+
for (const [target, type, listener] of this.handlers) target.removeEventListener(type, listener);
|
|
144
|
+
this.handlers.length = 0;
|
|
145
|
+
}
|
|
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);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
//#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;
|
|
175
|
+
}
|
|
176
|
+
//#endregion
|
|
177
|
+
//#region src/builders/artwork.ts
|
|
178
|
+
const loader = new TextureLoader();
|
|
179
|
+
const FRAME_MARGIN = 4;
|
|
180
|
+
const SURFACE_OFFSET = 1;
|
|
181
|
+
/**
|
|
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.
|
|
185
|
+
*/
|
|
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;
|
|
208
|
+
}
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region src/SceneManager.ts
|
|
211
|
+
const disposeObject = (object) => {
|
|
212
|
+
object.traverse((child) => {
|
|
213
|
+
const mesh = child;
|
|
214
|
+
mesh.geometry?.dispose?.();
|
|
215
|
+
const material = mesh.material;
|
|
216
|
+
const dispose = (m) => m?.dispose?.();
|
|
217
|
+
if (Array.isArray(material)) material.forEach(dispose);
|
|
218
|
+
else dispose(material);
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* Owns the live three.js scene graph and keeps it in sync with a `Scene`
|
|
223
|
+
* definition. `reconcile` diffs the incoming definition against what is already
|
|
224
|
+
* mounted — by `id` — and only adds/removes/rebuilds what changed. This is what
|
|
225
|
+
* makes hot scene edits cheap: add a wing or drop one while moving and only the
|
|
226
|
+
* touched objects are rebuilt.
|
|
227
|
+
*/
|
|
228
|
+
var SceneManager = class {
|
|
229
|
+
root;
|
|
230
|
+
walls = /* @__PURE__ */ new Map();
|
|
231
|
+
artworks = /* @__PURE__ */ new Map();
|
|
232
|
+
constructor(root) {
|
|
233
|
+
this.root = root;
|
|
234
|
+
}
|
|
235
|
+
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);
|
|
239
|
+
}
|
|
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);
|
|
254
|
+
}
|
|
255
|
+
const object = buildWall(wall);
|
|
256
|
+
this.root.add(object);
|
|
257
|
+
this.walls.set(wall.id, {
|
|
258
|
+
object,
|
|
259
|
+
hash
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
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)) {
|
|
266
|
+
this.root.remove(entry.object);
|
|
267
|
+
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
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** Look up the live three.js object for a wall or artwork `id`. */
|
|
292
|
+
get(id) {
|
|
293
|
+
return this.walls.get(id)?.object ?? this.artworks.get(id)?.object;
|
|
294
|
+
}
|
|
295
|
+
dispose() {
|
|
296
|
+
for (const entry of [...this.walls.values(), ...this.artworks.values()]) {
|
|
297
|
+
this.root.remove(entry.object);
|
|
298
|
+
disposeObject(entry.object);
|
|
299
|
+
}
|
|
300
|
+
this.walls.clear();
|
|
301
|
+
this.artworks.clear();
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region src/createGallery.ts
|
|
306
|
+
/**
|
|
307
|
+
* Create and run a gallery from a scene definition. Sets up the three.js
|
|
308
|
+
* renderer, camera, lights and free-roam controls, materializes the scene, and
|
|
309
|
+
* starts the render loop. `setScene` later reconciles changes in place.
|
|
310
|
+
*/
|
|
311
|
+
function createGallery(scene, options) {
|
|
312
|
+
const { canvas } = options;
|
|
313
|
+
const width = canvas.clientWidth || window.innerWidth;
|
|
314
|
+
const height = canvas.clientHeight || window.innerHeight;
|
|
315
|
+
const renderer = new WebGLRenderer({
|
|
316
|
+
canvas,
|
|
317
|
+
antialias: options.antialias ?? true
|
|
318
|
+
});
|
|
319
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
320
|
+
renderer.setSize(width, height, false);
|
|
321
|
+
const threeScene = new Scene();
|
|
322
|
+
const camera = new PerspectiveCamera(75, width / height, .1, 5e3);
|
|
323
|
+
const { position, yaw } = scene.camera.start;
|
|
324
|
+
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);
|
|
328
|
+
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();
|
|
335
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
336
|
+
const clock = new Clock();
|
|
337
|
+
let rafId = null;
|
|
338
|
+
const renderFrame = () => {
|
|
339
|
+
rafId = requestAnimationFrame(renderFrame);
|
|
340
|
+
controls.update(clock.getDelta());
|
|
341
|
+
renderer.render(threeScene, camera);
|
|
342
|
+
};
|
|
343
|
+
return {
|
|
344
|
+
ctx: {
|
|
345
|
+
entity: (id) => manager.get(id),
|
|
346
|
+
three: {
|
|
347
|
+
scene: threeScene,
|
|
348
|
+
camera,
|
|
349
|
+
renderer
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
setScene(next) {
|
|
353
|
+
manager.reconcile(next);
|
|
354
|
+
if (next.camera.bounds !== void 0) controls.bounds = next.camera.bounds;
|
|
355
|
+
},
|
|
356
|
+
start() {
|
|
357
|
+
if (rafId === null) {
|
|
358
|
+
clock.start();
|
|
359
|
+
renderFrame();
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
stop() {
|
|
363
|
+
if (rafId !== null) {
|
|
364
|
+
cancelAnimationFrame(rafId);
|
|
365
|
+
rafId = null;
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
dispose() {
|
|
369
|
+
this.stop();
|
|
370
|
+
controls.detach();
|
|
371
|
+
manager.dispose();
|
|
372
|
+
renderer.dispose();
|
|
373
|
+
listeners.clear();
|
|
374
|
+
},
|
|
375
|
+
on(event, handler) {
|
|
376
|
+
const set = listeners.get(event) ?? /* @__PURE__ */ new Set();
|
|
377
|
+
set.add(handler);
|
|
378
|
+
listeners.set(event, set);
|
|
379
|
+
return () => set.delete(handler);
|
|
380
|
+
},
|
|
381
|
+
emit(event, payload) {
|
|
382
|
+
listeners.get(event)?.forEach((handler) => handler(payload));
|
|
383
|
+
},
|
|
384
|
+
resize(w, h) {
|
|
385
|
+
camera.aspect = w / h;
|
|
386
|
+
camera.updateProjectionMatrix();
|
|
387
|
+
renderer.setSize(w, h, false);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
//#endregion
|
|
392
|
+
export { FreeRoamControls, SceneManager, buildArtwork, buildWall, createGallery };
|
|
393
|
+
|
|
394
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["ThreeScene"],"sources":["../src/controls/boundaries.ts","../src/controls/headBob.ts","../src/controls/keyboard.ts","../src/controls/mouse.ts","../src/controls/FreeRoamControls.ts","../src/builders/wall.ts","../src/builders/artwork.ts","../src/SceneManager.ts","../src/createGallery.ts"],"sourcesContent":["import { Vector3 } from \"three\";\n\n/**\n * Clamp a step on the XZ plane to a square `[-bound, bound]`. Axes are clamped\n * independently so sliding along a wall still works when one axis is blocked.\n */\nexport const clampToBounds = (current: Vector3, step: Vector3, bound: number): Vector3 => {\n const next = new Vector3().addVectors(current, step);\n if (next.x < -bound || next.x > bound) next.x = current.x;\n if (next.z < -bound || next.z > bound) next.z = current.z;\n return next;\n};\n","/** State for the subtle vertical bob applied while walking. */\nexport interface HeadBobState {\n active: boolean;\n timer: number;\n}\n\nexport const createHeadBobState = (): HeadBobState => ({ active: false, timer: 0 });\n\n/**\n * Advance the head-bob timer by `elapsed` seconds, snapping to half-wavelength\n * steps so the bob always settles back to neutral when movement stops.\n */\nexport const updateHeadBob = (state: HeadBobState, elapsed: number): void => {\n if (!state.active) return;\n\n const wavelength = Math.PI;\n const nextStep = 1 + Math.floor(((state.timer + 1e-6) * 10) / wavelength);\n const nextStepTime = (nextStep * wavelength) / 10;\n state.timer = Math.min(state.timer + elapsed, nextStepTime);\n\n if (state.timer === nextStepTime) state.active = false;\n};\n\n/** Vertical offset to add to the camera, in world units. */\nexport const headBobOffset = (state: HeadBobState): number => Math.sin(state.timer * 10) * 1.5;\n","import { Quaternion, Vector3 } from \"three\";\n\n/** Set of currently-pressed key codes. */\nexport type KeyboardState = Record<string, boolean>;\n\nexport const createKeyboardState = (): KeyboardState => ({});\n\nexport const onKeyDown = (state: KeyboardState, event: KeyboardEvent): void => {\n state[event.code] = true;\n};\n\nexport const onKeyUp = (state: KeyboardState, event: KeyboardEvent): void => {\n state[event.code] = false;\n};\n\n/**\n * Compute the per-frame translation from WASD input, oriented by the current\n * yaw (`phi`). Holding Shift acts as a speed booster.\n */\nexport const movementFromKeyboard = (state: KeyboardState, phi: number, delta: number): Vector3 => {\n const has = (code: string): number => (state[code] ? 1 : 0);\n const forwardVelocity = has(\"KeyW\") - has(\"KeyS\");\n const strafeVelocity = has(\"KeyA\") - has(\"KeyD\");\n const boosted = state[\"ShiftLeft\"] || state[\"ShiftRight\"];\n const factor = boosted ? 30 : 10;\n\n const yaw = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), phi);\n\n const forward = new Vector3(0, 0, -10)\n .applyQuaternion(yaw)\n .multiplyScalar(forwardVelocity * delta * factor);\n const left = new Vector3(-10, 0, 0)\n .applyQuaternion(yaw)\n .multiplyScalar(strafeVelocity * delta * factor);\n\n return forward.add(left);\n};\n","import { Quaternion, Vector2, Vector3 } from \"three\";\n\n/** Mutable pointer state accumulated between frames. */\nexport interface MouseState {\n /** Whether the primary button is held (drag-to-look). */\n dragging: boolean;\n pos: Vector2;\n prevPos: Vector2;\n delta: Vector2;\n}\n\n/** Look angles driven by the pointer. */\nexport interface LookState {\n phi: number;\n theta: number;\n}\n\nconst PHI_SPEED = 8;\nconst THETA_SPEED = 5;\n\nexport const createMouseState = (): MouseState => ({\n dragging: false,\n pos: new Vector2(),\n prevPos: new Vector2(),\n delta: new Vector2(),\n});\n\nconst clamp = (x: number, a: number, b: number): number => Math.min(Math.max(x, a), b);\n\nexport const onMouseDown = (state: MouseState, event: MouseEvent): void => {\n state.dragging = event.button === 0;\n};\n\nexport const onMouseUp = (state: MouseState): void => {\n state.dragging = false;\n};\n\nexport const onMouseMove = (state: MouseState, event: MouseEvent): void => {\n state.pos.set(event.pageX - window.innerWidth / 2, event.pageY - window.innerHeight / 2);\n state.delta.subVectors(state.pos, state.prevPos);\n};\n\nexport const commitMouseFrame = (state: MouseState): void => {\n state.prevPos.copy(state.pos);\n state.delta.set(0, 0);\n};\n\n/**\n * Integrate the pointer delta into a look quaternion. Mutates `look` and\n * returns the resulting rotation. Yaw is unbounded; pitch is clamped to avoid\n * flipping over.\n */\nexport const updateLookFromMouse = (mouse: MouseState, look: LookState): Quaternion => {\n const xh = mouse.delta.x / window.innerWidth;\n const yh = mouse.delta.y / window.innerHeight;\n\n look.phi += -xh * PHI_SPEED;\n look.theta = clamp(look.theta + -yh * THETA_SPEED, -Math.PI / 3, Math.PI / 3);\n\n const qy = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), look.phi);\n const qx = new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), look.theta);\n return new Quaternion().multiply(qy).multiply(qx);\n};\n","import { Quaternion, Vector3, type PerspectiveCamera } from \"three\";\n\nimport { clampToBounds } from \"./boundaries\";\nimport { createHeadBobState, headBobOffset, updateHeadBob } from \"./headBob\";\nimport { createKeyboardState, movementFromKeyboard, onKeyDown, onKeyUp } from \"./keyboard\";\nimport {\n commitMouseFrame,\n createMouseState,\n onMouseDown,\n onMouseMove,\n onMouseUp,\n updateLookFromMouse,\n type LookState,\n} from \"./mouse\";\n\nexport interface FreeRoamOptions {\n /** Square half-extent that clamps XZ movement. */\n bounds?: number;\n /** Starting position. */\n position?: Vector3;\n /** Starting yaw in radians. */\n yaw?: number;\n}\n\n/**\n * First-person, free-roaming camera controls ported from the original Virtual\n * Gallery: drag-to-look with the mouse, WASD to walk, a subtle head-bob while\n * moving, and square boundary clamping. The cardboard / device-orientation path\n * was intentionally dropped.\n *\n * Framework-agnostic: construct it with a camera and the canvas element, call\n * `attach()`, then `update(delta)` once per frame.\n */\nexport class FreeRoamControls {\n bounds: number;\n\n private readonly camera: PerspectiveCamera;\n private readonly domElement: HTMLElement;\n private readonly mouse = createMouseState();\n private readonly keyboard = createKeyboardState();\n private readonly headBob = createHeadBobState();\n private readonly look: LookState = { phi: 0, theta: 0 };\n private rotation = new Quaternion();\n private translation = new Vector3(0, 170, 0);\n\n private readonly handlers: Array<[EventTarget, string, EventListener]> = [];\n\n constructor(camera: PerspectiveCamera, domElement: HTMLElement, options: FreeRoamOptions = {}) {\n this.camera = camera;\n this.domElement = domElement;\n this.bounds = options.bounds ?? 450;\n if (options.position) this.translation.copy(options.position);\n if (options.yaw !== undefined) this.look.phi = options.yaw;\n this.rotation.setFromAxisAngle(new Vector3(0, 1, 0), this.look.phi);\n }\n\n attach(): void {\n const bind = (target: EventTarget, type: string, listener: EventListener): void => {\n target.addEventListener(type, listener);\n this.handlers.push([target, type, listener]);\n };\n\n bind(this.domElement, \"mousedown\", (e) => onMouseDown(this.mouse, e as MouseEvent));\n bind(window, \"mousemove\", (e) => onMouseMove(this.mouse, e as MouseEvent));\n bind(window, \"mouseup\", () => onMouseUp(this.mouse));\n bind(window, \"keydown\", (e) => onKeyDown(this.keyboard, e as KeyboardEvent));\n bind(window, \"keyup\", (e) => onKeyUp(this.keyboard, e as KeyboardEvent));\n }\n\n detach(): void {\n for (const [target, type, listener] of this.handlers)\n target.removeEventListener(type, listener);\n this.handlers.length = 0;\n }\n\n /** Advance the camera by `delta` seconds of input. */\n update(delta: number): void {\n if (this.mouse.dragging) this.rotation = updateLookFromMouse(this.mouse, this.look);\n\n const step = movementFromKeyboard(this.keyboard, this.look.phi, delta);\n this.translation = clampToBounds(this.translation, step, this.bounds);\n if (step.x !== 0 || step.z !== 0) this.headBob.active = true;\n updateHeadBob(this.headBob, delta);\n\n this.camera.quaternion.copy(this.rotation);\n this.camera.position.copy(this.translation);\n this.camera.position.y += headBobOffset(this.headBob);\n\n const target = new Vector3(0, 0, -1)\n .applyQuaternion(this.rotation)\n .multiplyScalar(100)\n .add(this.translation);\n this.camera.lookAt(target);\n\n commitMouseFrame(this.mouse);\n }\n}\n","import { DoubleSide, Mesh, MeshStandardMaterial, PlaneGeometry, type Object3D } from \"three\";\nimport type { Wall } from \"@scenography/core\";\n\n/** Build the three.js object that materializes a `Wall` definition. */\nexport function buildWall(wall: Wall): Object3D {\n const [width, height] = wall.size;\n const geometry = new PlaneGeometry(width, height);\n const material = new MeshStandardMaterial({ color: wall.color ?? \"#ffffff\", side: DoubleSide });\n\n const mesh = new Mesh(geometry, material);\n mesh.position.set(wall.position[0], wall.position[1], wall.position[2]);\n mesh.rotation.y = wall.rotation ?? 0;\n mesh.name = `wall:${wall.id}`;\n mesh.userData.scenographyId = wall.id;\n return mesh;\n}\n","import {\n Group,\n Mesh,\n MeshBasicMaterial,\n MeshStandardMaterial,\n PlaneGeometry,\n Quaternion,\n SRGBColorSpace,\n TextureLoader,\n Vector3,\n type Object3D,\n} from \"three\";\nimport type { Artwork, Wall } from \"@scenography/core\";\n\nconst loader = new TextureLoader();\nconst FRAME_MARGIN = 4;\nconst SURFACE_OFFSET = 1;\n\n/**\n * Build the three.js object for an `Artwork`, placing it on its wall. The piece\n * is positioned in the wall's local plane via `offset` and nudged forward along\n * the wall normal so it sits in front of the surface.\n */\nexport function buildArtwork(artwork: Artwork, wall: Wall): Object3D {\n const [width, height] = artwork.size;\n const [offsetX, offsetY] = artwork.offset ?? [0, 0];\n const yaw = wall.rotation ?? 0;\n\n const group = new Group();\n const wallQuat = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), yaw);\n const right = new Vector3(1, 0, 0).applyQuaternion(wallQuat);\n const normal = new Vector3(0, 0, 1).applyQuaternion(wallQuat);\n\n const position = new Vector3(wall.position[0], wall.position[1], wall.position[2])\n .add(right.clone().multiplyScalar(offsetX))\n .add(new Vector3(0, offsetY, 0))\n .add(normal.clone().multiplyScalar(SURFACE_OFFSET));\n group.position.copy(position);\n group.rotation.y = yaw;\n\n // Frame: a slightly larger dark plane behind the image.\n const frame = new Mesh(\n new PlaneGeometry(width + FRAME_MARGIN, height + FRAME_MARGIN),\n new MeshStandardMaterial({ color: \"#1a1a1a\" }),\n );\n group.add(frame);\n\n // Image: textured plane, nudged forward so it reads above the frame.\n const texture = loader.load(artwork.src);\n texture.colorSpace = SRGBColorSpace;\n const image = new Mesh(new PlaneGeometry(width, height), new MeshBasicMaterial({ map: texture }));\n image.position.z = 0.1;\n group.add(image);\n\n group.name = `artwork:${artwork.id}`;\n group.userData.scenographyId = artwork.id;\n group.userData.wallId = artwork.wall;\n return group;\n}\n","import type { Object3D, Scene as ThreeScene } from \"three\";\nimport type { Artwork, Scene, Wall } from \"@scenography/core\";\n\nimport { buildArtwork, buildWall } from \"./builders\";\n\ninterface Entry {\n object: Object3D;\n /** JSON snapshot used to detect changes between reconciles. */\n hash: string;\n}\n\nconst disposeObject = (object: Object3D): void => {\n object.traverse((child) => {\n const mesh = child as { geometry?: { dispose?: () => void }; material?: unknown };\n mesh.geometry?.dispose?.();\n const material = mesh.material;\n const dispose = (m: unknown): void => (m as { dispose?: () => void })?.dispose?.();\n if (Array.isArray(material)) material.forEach(dispose);\n else dispose(material);\n });\n};\n\n/**\n * Owns the live three.js scene graph and keeps it in sync with a `Scene`\n * definition. `reconcile` diffs the incoming definition against what is already\n * mounted — by `id` — and only adds/removes/rebuilds what changed. This is what\n * makes hot scene edits cheap: add a wing or drop one while moving and only the\n * touched objects are rebuilt.\n */\nexport class SceneManager {\n private readonly root: ThreeScene;\n private readonly walls = new Map<string, Entry>();\n private readonly artworks = new Map<string, Entry>();\n\n constructor(root: ThreeScene) {\n this.root = root;\n }\n\n reconcile(scene: Scene): void {\n const wallById = new Map(scene.walls.map((w) => [w.id, w] as const));\n this.reconcileWalls(scene.walls);\n this.reconcileArtworks(scene.artworks, wallById);\n }\n\n private reconcileWalls(walls: Wall[]): void {\n const next = new Set(walls.map((w) => w.id));\n for (const [id, entry] of this.walls) {\n if (!next.has(id)) {\n this.root.remove(entry.object);\n disposeObject(entry.object);\n this.walls.delete(id);\n }\n }\n for (const wall of walls) {\n const hash = JSON.stringify(wall);\n const existing = this.walls.get(wall.id);\n if (existing?.hash === hash) continue;\n if (existing) {\n this.root.remove(existing.object);\n disposeObject(existing.object);\n }\n const object = buildWall(wall);\n this.root.add(object);\n this.walls.set(wall.id, { object, hash });\n }\n }\n\n private reconcileArtworks(artworks: Artwork[], wallById: Map<string, Wall>): void {\n const next = new Set(artworks.map((a) => a.id));\n for (const [id, entry] of this.artworks) {\n if (!next.has(id)) {\n this.root.remove(entry.object);\n disposeObject(entry.object);\n this.artworks.delete(id);\n }\n }\n for (const artwork of artworks) {\n const wall = wallById.get(artwork.wall);\n if (!wall) continue;\n // Rebuild when either the artwork or the wall it hangs on changed.\n const hash = JSON.stringify({ a: artwork, w: wall });\n const existing = this.artworks.get(artwork.id);\n if (existing?.hash === hash) continue;\n if (existing) {\n this.root.remove(existing.object);\n disposeObject(existing.object);\n }\n const object = buildArtwork(artwork, wall);\n this.root.add(object);\n this.artworks.set(artwork.id, { object, hash });\n }\n }\n\n /** Look up the live three.js object for a wall or artwork `id`. */\n get(id: string): Object3D | undefined {\n return this.walls.get(id)?.object ?? this.artworks.get(id)?.object;\n }\n\n dispose(): void {\n for (const entry of [...this.walls.values(), ...this.artworks.values()]) {\n this.root.remove(entry.object);\n disposeObject(entry.object);\n }\n this.walls.clear();\n this.artworks.clear();\n }\n}\n","import {\n AmbientLight,\n Clock,\n HemisphereLight,\n PerspectiveCamera,\n Scene as ThreeScene,\n WebGLRenderer,\n type Object3D,\n} from \"three\";\nimport type { Scene } from \"@scenography/core\";\n\nimport { FreeRoamControls } from \"./controls\";\nimport { SceneManager } from \"./SceneManager\";\n\nexport interface GalleryOptions {\n /** Canvas the gallery renders into. */\n canvas: HTMLCanvasElement;\n /** Background color. Defaults to a soft gray. */\n background?: string;\n antialias?: boolean;\n}\n\n/**\n * High-level API handed to behavior code. Deliberately NOT raw three.js: this is\n * the supported surface. Reach into `three` only as a documented escape hatch.\n */\nexport interface GalleryContext {\n /** Resolve the live object for a wall/artwork `id`. */\n entity(id: string): Object3D | undefined;\n /** Escape hatch to the underlying three.js objects for advanced use. */\n readonly three: {\n scene: ThreeScene;\n camera: PerspectiveCamera;\n renderer: WebGLRenderer;\n };\n}\n\nexport interface Gallery {\n /** Apply a new scene definition, reconciling only what changed (hot-swap). */\n setScene(scene: Scene): void;\n /** Start the render loop. */\n start(): void;\n /** Stop the render loop. */\n stop(): void;\n /** Tear everything down and release GPU resources. */\n dispose(): void;\n /** Subscribe to a named event; returns an unsubscribe function. */\n on(event: string, handler: (payload?: unknown) => void): () => void;\n /** Emit a named event to subscribers. */\n emit(event: string, payload?: unknown): void;\n /** Resize the renderer/camera to the given pixel size. */\n resize(width: number, height: number): void;\n readonly ctx: GalleryContext;\n}\n\n/**\n * Create and run a gallery from a scene definition. Sets up the three.js\n * renderer, camera, lights and free-roam controls, materializes the scene, and\n * starts the render loop. `setScene` later reconciles changes in place.\n */\nexport function createGallery(scene: Scene, options: GalleryOptions): Gallery {\n const { canvas } = options;\n const width = canvas.clientWidth || window.innerWidth;\n const height = canvas.clientHeight || window.innerHeight;\n\n const renderer = new WebGLRenderer({ canvas, antialias: options.antialias ?? true });\n renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n renderer.setSize(width, height, false);\n\n const threeScene = new ThreeScene();\n\n const camera = new PerspectiveCamera(75, width / height, 0.1, 5000);\n const { position, yaw } = scene.camera.start;\n camera.position.set(position[0], position[1], position[2]);\n\n threeScene.add(new AmbientLight(0xffffff, 1.4));\n threeScene.add(new HemisphereLight(0xffffff, 0x444444, 1));\n\n const manager = new SceneManager(threeScene);\n manager.reconcile(scene);\n\n const controls = new FreeRoamControls(camera, canvas, {\n bounds: scene.camera.bounds,\n position: camera.position.clone(),\n yaw,\n });\n controls.attach();\n\n const listeners = new Map<string, Set<(payload?: unknown) => void>>();\n const clock = new Clock();\n let rafId: number | null = null;\n\n const renderFrame = (): void => {\n rafId = requestAnimationFrame(renderFrame);\n controls.update(clock.getDelta());\n renderer.render(threeScene, camera);\n };\n\n const ctx: GalleryContext = {\n entity: (id) => manager.get(id),\n three: { scene: threeScene, camera, renderer },\n };\n\n return {\n ctx,\n setScene(next) {\n manager.reconcile(next);\n if (next.camera.bounds !== undefined) controls.bounds = next.camera.bounds;\n },\n start() {\n if (rafId === null) {\n clock.start();\n renderFrame();\n }\n },\n stop() {\n if (rafId !== null) {\n cancelAnimationFrame(rafId);\n rafId = null;\n }\n },\n dispose() {\n this.stop();\n controls.detach();\n manager.dispose();\n renderer.dispose();\n listeners.clear();\n },\n on(event, handler) {\n const set = listeners.get(event) ?? new Set();\n set.add(handler);\n listeners.set(event, set);\n return () => set.delete(handler);\n },\n emit(event, payload) {\n listeners.get(event)?.forEach((handler) => handler(payload));\n },\n resize(w, h) {\n camera.aspect = w / h;\n camera.updateProjectionMatrix();\n renderer.setSize(w, h, false);\n },\n };\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@scenography/engine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The scenography runtime: materializes a scene definition with three.js and runs it. Reconciles by id for hot scene changes.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "./dist/index.cjs",
|
|
11
|
+
"module": "./dist/index.mjs",
|
|
12
|
+
"types": "./dist/index.d.cts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": {
|
|
16
|
+
"types": "./dist/index.d.mts",
|
|
17
|
+
"default": "./dist/index.mjs"
|
|
18
|
+
},
|
|
19
|
+
"require": {
|
|
20
|
+
"types": "./dist/index.d.cts",
|
|
21
|
+
"default": "./dist/index.cjs"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@scenography/core": "0.1.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/three": "^0.160.0",
|
|
30
|
+
"three": "^0.160.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"three": ">=0.160.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsdown",
|
|
37
|
+
"dev": "tsdown --watch",
|
|
38
|
+
"typecheck": "tsc --noEmit"
|
|
39
|
+
}
|
|
40
|
+
}
|