@volter/play 0.5.211 → 0.5.213

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/src/model-play.ts DELETED
@@ -1,376 +0,0 @@
1
- /**
2
- * WHETHER A MODEL DOCUMENT IS PLAYING (Play in the Game panel, `blender-game-panel.tsx`; drawn by
3
- * `blender-runtime.document.tsx`). Playing, the document's area shows a detached copy of the
4
- * model (`BlenderRuntimeView.detach`) that the project's play script moves
5
- * (`play-script.ts`); the model, its selection and its history stand as they were, and Stop
6
- * returns to them. Kept for the page's life and never stored: a reload opens the model, not a
7
- * game.
8
- *
9
- * AND HOW ITS CLOCK RUNS. Pause, step, speed and restart are state of the RUN, so they are kept
10
- * here beside "is it playing", and the runner (`play-script.ts`) reads them on every frame: the
11
- * `dt` a script's `update` receives is the only time a play script is given, so holding it is
12
- * what freezes the game and scaling it is what speeds the game up. A script that reads the
13
- * page's own clock (`performance.now()`, `Date.now()`) instead is outside that reach.
14
- *
15
- * AND WHO DRIVES. Autoplay is the editor's switch, not the game's: a script only offers a bot
16
- * (`play.autoplay(controller)`, `play-script.ts`), and whether that bot drives is kept here. It
17
- * is off whenever a run begins — Play, Restart, Stop — and only the Game panel's toggle or its
18
- * verb (`volter.model-play.autoplay`) turns it on. The one carry-over is an ARM: a person may
19
- * press Autoplay while stopped (there is no bot yet — a script offers it only once it runs), and
20
- * the next start turns autoplay on the moment its script has run its first update with a bot, or
21
- * drops the arm if it offers none. The runner turns it off the moment a person
22
- * presses a key or touches the game (`takeover`), or when the running script stops offering a
23
- * bot (`script`). Its changes are announced through the clock's subscription, which the panel
24
- * and the runner already hold.
25
- */
26
- const listeners = new Set<() => void>();
27
- const clockListeners = new Set<() => void>();
28
- const playing = new Set<string>();
29
- const stops = new Map<string, { readonly stop: (escape: boolean) => void; readonly generation: number }>();
30
-
31
- /** The speeds the transport offers, slowest first (simulation seconds per real second). */
32
- export const MODEL_PLAY_SPEEDS: readonly number[] = [0.25, 0.5, 1, 2, 4];
33
- /** The `dt` one Step hands the game: one nominal 60 Hz frame, whatever the speed. */
34
- export const MODEL_PLAY_STEP_SECONDS = 1 / 60;
35
-
36
- export interface ModelPlayClock {
37
- readonly time: number;
38
- readonly tick: number;
39
- readonly paused: boolean;
40
- readonly speed: number;
41
- /** The run is playing but no game runs: the script failed to start, or threw (`play-script.ts`). */
42
- readonly failure: string | null;
43
- /** A game has started in this run and runs (`DocumentPlayClock.running`). */
44
- readonly running: boolean;
45
- }
46
- const STILL: ModelPlayClock = { time: 0, tick: 0, paused: false, speed: 1, failure: null, running: false };
47
- /** Replaced, never mutated, so a snapshot read by `useSyncExternalStore` changes identity
48
- * exactly when it changes value. */
49
- const clocks = new Map<string, ModelPlayClock>();
50
- /** Steps asked for while paused and not yet run; the runner takes one per drawn frame. */
51
- const steps = new Map<string, number>();
52
- /** Bumped by Restart; the document keys its detached copy on it (`DocumentPlayTransport`). */
53
- const generations = new Map<string, number>();
54
- /** Documents whose next run was begun by Restart, and so enters without the camera's blend. */
55
- const restarted = new Set<string>();
56
-
57
- /** Who last switched autoplay: the panel's toggle, a verb (CLI or `eval`), a person's input
58
- * in the game, the running script no longer offering a bot (replaced, failed, or its bot
59
- * threw), or the run's time limit. */
60
- export type ModelPlayAutoplayBy = 'panel' | 'cli' | 'takeover' | 'script' | 'limit';
61
- /**
62
- * EVERY AUTOPLAY RUN HAS A LIMIT, in simulation seconds: a bot whose game never ends would
63
- * otherwise drive forever, and an agent waiting on it would wait forever. Reaching it turns
64
- * autoplay off, pauses the game and logs `autoplay-limit` (`play-script.ts`).
65
- */
66
- export const MODEL_PLAY_AUTOPLAY_LIMIT_SECONDS = 300;
67
- export interface ModelPlayAutoplay {
68
- /** The bot drives: its keys are merged into the keys the script reads. */
69
- readonly on: boolean;
70
- /** The running script registered a bot with `play.autoplay`. */
71
- readonly available: boolean;
72
- /** The behaviours the running script's bot offers (`play.autoplay({ win, lose })`), in its
73
- * order; a bare function is the one behaviour `play`. */
74
- readonly behaviors: readonly string[];
75
- /** The behaviour driving now, or armed for the next start. */
76
- readonly behavior: string | null;
77
- /** This run's limit and the simulation time it began at; null while autoplay is off. */
78
- readonly limit: number | null;
79
- readonly since: number | null;
80
- readonly by: ModelPlayAutoplayBy | null;
81
- /** Pressed while stopped: the next start turns autoplay on once its script offers a bot. */
82
- readonly armed: boolean;
83
- /** A person's key or pointer reached the game in this run. Until one does, nobody drives
84
- * while autoplay is off — the game runs on no input. */
85
- readonly person: boolean;
86
- /** What the bot last said it is doing (`{ keys, state }` from its controller); kept after
87
- * autoplay goes off, so a limit or takeover still shows where the bot was. */
88
- readonly state: string | null;
89
- /** Why the last arm was not taken at start (the bot offers no bot, or not the behaviour it asked
90
- * for, or several with none named); null otherwise. Said in the panel and the play log. */
91
- readonly refused: string | null;
92
- }
93
- /** What a switch-on asks for: the behaviour (required when the bot offers several) and the limit. */
94
- export interface ModelPlayAutoplayRequest {
95
- readonly behavior?: string | null;
96
- readonly limit?: number | null;
97
- }
98
- const NO_BOT: ModelPlayAutoplay = { on: false, available: false, behaviors: [], behavior: null, limit: null, since: null, by: null, armed: false, person: false, state: null, refused: null };
99
- const ARMED: ModelPlayAutoplay = { ...NO_BOT, armed: true };
100
- /** Replaced, never mutated, as the clocks are. Absent is {@link NO_BOT}: a new run's state. */
101
- const autoplays = new Map<string, ModelPlayAutoplay>();
102
-
103
- /** A new run's autoplay: off, no bot yet, nobody driving. Play keeps an arm made while stopped
104
- * (with the behaviour and limit it asked for); Stop drops it. The behaviours the last run's bot
105
- * offered are kept, not available: while stopped they are what the panel's picker offers for an
106
- * arm, which is checked against the bot the next start offers. */
107
- function resetAutoplay(documentId: string, keepArm: boolean): void {
108
- const now = modelPlayAutoplay(documentId);
109
- if (keepArm && now.armed) autoplays.set(documentId, { ...ARMED, behaviors: now.behaviors, behavior: now.behavior, limit: now.limit });
110
- else if (now.behaviors.length) autoplays.set(documentId, { ...NO_BOT, behaviors: now.behaviors });
111
- else autoplays.delete(documentId);
112
- }
113
-
114
- /** A limit as given: a positive number of simulation seconds, or the default. */
115
- function autoplayLimit(limit: number | null | undefined): number {
116
- if (limit === undefined || limit === null) return MODEL_PLAY_AUTOPLAY_LIMIT_SECONDS;
117
- if (!Number.isFinite(limit) || limit <= 0) throw new Error(`An autoplay limit is a positive number of simulation seconds; got ${String(limit)}.`);
118
- return limit;
119
- }
120
-
121
- /** The behaviour a switch-on drives: the one named, which the bot must offer; with none named,
122
- * the bot's only behaviour. A bot with several needs one named, so a run always says what its
123
- * bot was trying to do. */
124
- function chosenBehavior(behaviors: readonly string[], named: string | null | undefined): string {
125
- if (named) {
126
- if (!behaviors.includes(named))
127
- throw new Error(`This game's bot has no behaviour "${named}"; it offers ${behaviors.map(b => `"${b}"`).join(', ')}.`);
128
- return named;
129
- }
130
- if (behaviors.length === 1) return behaviors[0]!;
131
- throw new Error(`This game's bot offers several behaviours (${behaviors.join(', ')}); name the one to run: \`play autoplay on <behaviour>\`.`);
132
- }
133
-
134
- function publish(): void {
135
- for (const listener of [...listeners]) listener();
136
- }
137
- function publishClock(): void {
138
- for (const listener of [...clockListeners]) listener();
139
- }
140
- function setClock(documentId: string, next: Partial<ModelPlayClock>): void {
141
- clocks.set(documentId, { ...modelPlayClock(documentId), ...next });
142
- publishClock();
143
- }
144
-
145
- export function modelPlaying(documentId: string): boolean {
146
- return playing.has(documentId);
147
- }
148
-
149
- export function setModelPlaying(documentId: string, value: boolean): void {
150
- if (playing.has(documentId) === value) {
151
- // Stop with nothing playing is how a closing document lets go of its play: an arm made while
152
- // stopped goes with it, or reopening the model and pressing Play would still take it.
153
- if (!value && modelPlayAutoplay(documentId).armed) { resetAutoplay(documentId, false); publishClock(); }
154
- return;
155
- }
156
- const stop = value ? null : currentStop(documentId);
157
- if (stop) { stop(false); return; }
158
- if (value) {
159
- playing.add(documentId);
160
- // A new run starts its clock at zero and running; the speed is the person's and stays.
161
- steps.delete(documentId);
162
- resetAutoplay(documentId, true);
163
- setClock(documentId, { time: 0, tick: 0, paused: false, failure: null, running: false });
164
- } else {
165
- playing.delete(documentId);
166
- restarted.delete(documentId);
167
- steps.delete(documentId);
168
- resetAutoplay(documentId, false);
169
- // The clock keeps the stopped run's time and tick, so the panel still says how far it got.
170
- // Re-issued even unchanged, after `playing` has changed: every way a game stops (Stop,
171
- // Escape, a deleted script, a mode switch, an agent's `stop`) ends here, and a reader of
172
- // the clock alone must see the run end too.
173
- setClock(documentId, { paused: false, failure: null, running: false });
174
- }
175
- publish();
176
- }
177
-
178
- export function finishModelPlay(documentId: string): void {
179
- stops.delete(documentId);
180
- setModelPlaying(documentId, false);
181
- }
182
-
183
- export function escapeModelPlay(documentId: string): void {
184
- const stop = currentStop(documentId);
185
- if (stop) stop(true);
186
- else finishModelPlay(documentId);
187
- }
188
-
189
- /**
190
- * A RUNNER'S STOP, BOUND TO ITS GENERATION. Stop and Escape go to the runner of the CURRENT
191
- * generation, which blends the camera back first; a stop a Restart has superseded is never
192
- * asked, because that runner stands still from the moment its generation passes
193
- * (`play-script.ts`) and would never finish the blend — the stop would be lost. With no current
194
- * runner (a restarted game still preparing) the play simply ends.
195
- */
196
- export function registerModelPlayStop(documentId: string, stop: (escape: boolean) => void): () => void {
197
- const entry = { stop, generation: modelPlayGeneration(documentId) };
198
- stops.set(documentId, entry);
199
- return () => { if (stops.get(documentId) === entry) stops.delete(documentId); };
200
- }
201
-
202
- function currentStop(documentId: string): ((escape: boolean) => void) | null {
203
- const entry = stops.get(documentId);
204
- return entry !== undefined && entry.generation === modelPlayGeneration(documentId) ? entry.stop : null;
205
- }
206
-
207
- /** The runner's report that no game runs although the run plays (`null` once one does, which
208
- * is also the moment the run is `running`). */
209
- export function setModelPlayFailure(documentId: string, failure: string | null): void {
210
- const clock = modelPlayClock(documentId);
211
- const running = failure === null;
212
- if (!playing.has(documentId) || (clock.failure === failure && clock.running === running)) return;
213
- setClock(documentId, { failure, running });
214
- }
215
-
216
- export function subscribeModelPlay(listener: () => void): () => void {
217
- listeners.add(listener);
218
- return () => {
219
- listeners.delete(listener);
220
- };
221
- }
222
-
223
- export function modelPlayClock(documentId: string): ModelPlayClock {
224
- return clocks.get(documentId) ?? STILL;
225
- }
226
-
227
- export function subscribeModelPlayClock(listener: () => void): () => void {
228
- clockListeners.add(listener);
229
- return () => {
230
- clockListeners.delete(listener);
231
- };
232
- }
233
-
234
- /** Hold or release the game's updates. Only a playing document has updates to hold. */
235
- export function setModelPlayPaused(documentId: string, paused: boolean): void {
236
- if (!playing.has(documentId) || modelPlayClock(documentId).paused === paused) return;
237
- if (!paused) steps.delete(documentId);
238
- setClock(documentId, { paused });
239
- }
240
-
241
- /** One update while paused. Steps asked for faster than frames are drawn queue, one per frame,
242
- * so each one is seen. */
243
- export function stepModelPlay(documentId: string): void {
244
- if (!playing.has(documentId) || !modelPlayClock(documentId).paused) return;
245
- steps.set(documentId, (steps.get(documentId) ?? 0) + 1);
246
- }
247
-
248
- /** The runner's half of {@link stepModelPlay}: whether this frame runs a step. */
249
- export function takeModelPlayStep(documentId: string): boolean {
250
- const pending = steps.get(documentId) ?? 0;
251
- if (pending === 0) return false;
252
- if (pending === 1) steps.delete(documentId);
253
- else steps.set(documentId, pending - 1);
254
- return true;
255
- }
256
-
257
- export function setModelPlaySpeed(documentId: string, speed: number): void {
258
- if (!MODEL_PLAY_SPEEDS.includes(speed))
259
- throw new Error(`Play speed ${speed} is not one the transport offers: ${MODEL_PLAY_SPEEDS.join(', ')}.`);
260
- if (modelPlayClock(documentId).speed !== speed) setClock(documentId, { speed });
261
- }
262
-
263
- /** The runner's report of the updates it ran this frame. */
264
- export function advanceModelPlayClock(documentId: string, seconds: number, ticks: number): void {
265
- if (ticks === 0) return;
266
- const clock = modelPlayClock(documentId);
267
- setClock(documentId, { time: clock.time + seconds, tick: clock.tick + ticks });
268
- }
269
-
270
- export function modelPlayGeneration(documentId: string): number {
271
- return generations.get(documentId) ?? 0;
272
- }
273
-
274
- /**
275
- * BEGIN THE RUN AGAIN ON A FRESH COPY. Playing stays true throughout — nothing returns to the
276
- * model — and the generation moves, which is what makes the document detach a new copy and mount
277
- * a new runner over it; the old runner sees it is no longer the current generation and stands
278
- * down without ending the play. A document that is not playing simply starts.
279
- */
280
- export function restartModelPlay(documentId: string): void {
281
- if (!playing.has(documentId)) { setModelPlaying(documentId, true); return; }
282
- generations.set(documentId, modelPlayGeneration(documentId) + 1);
283
- restarted.add(documentId);
284
- steps.delete(documentId);
285
- // An arm not yet taken (the game was still starting) carries to the fresh run.
286
- resetAutoplay(documentId, true);
287
- setClock(documentId, { time: 0, tick: 0, paused: false, failure: null, running: false });
288
- publish();
289
- }
290
-
291
- /** Whether the run starting now was begun by Restart (asked once, by that run's runner). */
292
- export function consumeModelPlayRestart(documentId: string): boolean {
293
- return restarted.delete(documentId);
294
- }
295
-
296
- export function modelPlayAutoplay(documentId: string): ModelPlayAutoplay {
297
- return autoplays.get(documentId) ?? NO_BOT;
298
- }
299
-
300
- function setAutoplay(documentId: string, next: Partial<ModelPlayAutoplay>): void {
301
- autoplays.set(documentId, { ...modelPlayAutoplay(documentId), ...next });
302
- publishClock();
303
- }
304
-
305
- /** Switch the running script's bot on or off. On asks for a playing document whose script
306
- * registered a bot, names the behaviour to drive (required when the bot offers several) and
307
- * starts the run's limit; off always succeeds, and also drops an arm not yet taken — a person
308
- * who takes over while the game is still starting is driving, and the arm must not override them. */
309
- export function setModelPlayAutoplay(documentId: string, on: boolean, by: ModelPlayAutoplayBy, request: ModelPlayAutoplayRequest = {}): void {
310
- const now = modelPlayAutoplay(documentId);
311
- if (on && !playing.has(documentId))
312
- throw new Error(`Autoplay is available once the game is running, and nothing is playing in ${documentId}; \`play\` starts it, then \`play autoplay on\`.`);
313
- const clock = modelPlayClock(documentId);
314
- if (on && !now.available)
315
- throw new Error(clock.running
316
- ? 'No autoplay: this game doesn’t provide a bot — its play script registers none with `play.autoplay(controller)`.'
317
- : `Autoplay is available once the game is running, and it is not running yet${clock.failure ? ` (${clock.failure})` : ''}.`);
318
- if (on) {
319
- const behavior = chosenBehavior(now.behaviors, request.behavior);
320
- const limit = autoplayLimit(request.limit);
321
- // Switching on again (another behaviour, a new limit) is a new run of the bot.
322
- setAutoplay(documentId, { on, by, behavior, limit, since: clock.time, armed: false, state: null, refused: null });
323
- } else if (now.on || now.armed) setAutoplay(documentId, { on, by, armed: false, since: null });
324
- }
325
-
326
- /** Arm (or disarm) autoplay for the next start, while stopped: the Game panel's Autoplay button
327
- * before Play. The behaviour and limit are checked when the start takes the arm, since the bot
328
- * is offered only then. Disarming always succeeds. */
329
- export function armModelPlayAutoplay(documentId: string, armed: boolean, request: ModelPlayAutoplayRequest = {}): void {
330
- if (armed && playing.has(documentId))
331
- throw new Error(`${documentId} is already playing; switch autoplay on instead of arming it.`);
332
- if (armed) setAutoplay(documentId, { armed, refused: null, behavior: request.behavior ?? null, limit: request.limit === undefined || request.limit === null ? null : autoplayLimit(request.limit) });
333
- else if (modelPlayAutoplay(documentId).armed) setAutoplay(documentId, { armed, behavior: null, limit: null });
334
- }
335
-
336
- /** The runner's report that a script has run its first update, offering a bot's behaviours or
337
- * none. An arm is taken here: on with a bot that has the behaviour it asked for (or only one),
338
- * dropped otherwise. */
339
- export function settleModelPlayAutoplay(documentId: string, behaviors: readonly string[]): void {
340
- const now = modelPlayAutoplay(documentId);
341
- if (!playing.has(documentId)) return;
342
- if (!now.armed) { setModelPlayAutoplayAvailable(documentId, behaviors); return; }
343
- let behavior: string | null = null;
344
- let refused: string | null = null;
345
- if (!behaviors.length) refused = 'the play script registers no bot with play.autoplay';
346
- else {
347
- try { behavior = chosenBehavior(behaviors, now.behavior); }
348
- catch (error) { refused = error instanceof Error ? error.message : String(error); }
349
- }
350
- setAutoplay(documentId, behavior !== null
351
- ? { available: true, behaviors, armed: false, on: true, by: 'panel', behavior, limit: now.limit ?? MODEL_PLAY_AUTOPLAY_LIMIT_SECONDS, since: modelPlayClock(documentId).time, state: null, refused: null }
352
- : { available: behaviors.length > 0, behaviors, armed: false, behavior: null, limit: null, by: 'script', refused });
353
- }
354
-
355
- /** The runner's report of the behaviours the running script's bot offers (none: no bot). Losing
356
- * the bot, or the behaviour driving, turns autoplay off. */
357
- export function setModelPlayAutoplayAvailable(documentId: string, behaviors: readonly string[]): void {
358
- const now = modelPlayAutoplay(documentId);
359
- if (!playing.has(documentId)) return;
360
- const available = behaviors.length > 0;
361
- const same = now.available === available && now.behaviors.length === behaviors.length && now.behaviors.every((b, i) => b === behaviors[i]);
362
- if (same) return;
363
- const lost = now.on && (now.behavior === null || !behaviors.includes(now.behavior));
364
- setAutoplay(documentId, lost ? { available, behaviors, on: false, by: 'script', since: null } : { available, behaviors });
365
- }
366
-
367
- /** The runner's report of what the driving bot says it is doing; announced only when it changes. */
368
- export function setModelPlayBotState(documentId: string, state: string | null): void {
369
- if (playing.has(documentId) && modelPlayAutoplay(documentId).state !== state) setAutoplay(documentId, { state });
370
- }
371
-
372
- /** The runner's report that a person's key or pointer reached the game: from now on in this run,
373
- * autoplay off means the person drives. */
374
- export function noteModelPlayPerson(documentId: string): void {
375
- if (playing.has(documentId) && !modelPlayAutoplay(documentId).person) setAutoplay(documentId, { person: true });
376
- }
@@ -1,199 +0,0 @@
1
- /**
2
- * RECOLOURING THE DETACHED COPY — `play.tint` and `play.setOpacity` (`play-script.ts`).
3
- *
4
- * Changing a presented mesh's colour the three.js way does not work on the copy, for reasons
5
- * the presenter has and a script cannot see:
6
- *
7
- * - A Blender mesh's `material` is an ARRAY, one entry per material slot (a lone material only
8
- * for an object with no slots, which wears the presenter's grey fallback), so
9
- * `mesh.material.color` is `undefined`.
10
- * - The entries are SHARED: one material per Blender material, worn by every object with it
11
- * in a slot. Recolouring one in place recolours them all.
12
- * - The presenter OWNS `mesh.material`: whenever it re-applies shading it assigns every mesh's
13
- * slots again from its own table, so materials a script assigned are put back.
14
- * - `clone()` drops the presenter's draw hooks (its shader, private uniforms and node graph).
15
- * - A material whose Base Color or Alpha a node graph drives never reads `color` or `opacity`:
16
- * the graph's output replaces them in the shader.
17
- *
18
- * So an override is per OBJECT: the object's slots are swapped for copies of their own (the
19
- * document's `ownMaterial`, which keeps its hooks and draws the material's constants, its
20
- * image texture included), the copies carry the tint and opacity, and the slots the presenter
21
- * gave are remembered. A slot the document cannot copy (no `ownMaterial`, or a material that
22
- * is not the document's) leaves the object as it is and says so through `unsupported`; a
23
- * `clone()` would draw without the presenter's hooks.
24
- *
25
- * The presenter's slots are re-read whenever they may have moved: an object whose slots the
26
- * presenter has put back is given copies of the slots it now has, both each frame and before
27
- * any change, so clearing returns the presenter's current slots, never a stale set. An object
28
- * the script removed from the copy is let go on the next frame. That is heard, not searched
29
- * for: each overridden mesh and its parents up to the copy's root report their own removal
30
- * (three's `removed` event, which `remove`, `add` elsewhere and `attach` all dispatch), and only
31
- * a mesh one of them reported is walked up to see whether it is still in the copy.
32
- */
33
- import type * as THREE from 'three';
34
-
35
- type Slots = THREE.Material | THREE.Material[];
36
- type Colored = THREE.Material & { color?: THREE.Color; emissive?: THREE.Color };
37
-
38
- interface Override {
39
- readonly mesh: THREE.Mesh;
40
- authored: Slots;
41
- shown: Slots;
42
- copies: Colored[];
43
- color: THREE.ColorRepresentation | null;
44
- opacity: number | null;
45
- /** The mesh and its parents below the copy's root, each told to report its removal. */
46
- watched: THREE.Object3D[];
47
- /** One of them was removed from its parent since the last frame. */
48
- moved: boolean;
49
- readonly onRemoved: () => void;
50
- }
51
-
52
- const slotsOf = (slots: Slots): THREE.Material[] => Array.isArray(slots) ? slots : [slots];
53
- const isColor = (value: unknown): value is THREE.Color =>
54
- typeof value === 'object' && value !== null && (value as THREE.Color).isColor === true;
55
-
56
- export interface MaterialOverrides {
57
- tint(target: THREE.Object3D, color: THREE.ColorRepresentation | null): void;
58
- setOpacity(target: THREE.Object3D, opacity: number | null): void;
59
- /** After the script's update: let go of removed objects, and re-wear the copies on any
60
- * object the presenter re-dressed. */
61
- frame(): void;
62
- /** Return every object the presenter's slots and release the copies; later calls do nothing. */
63
- dispose(): void;
64
- }
65
-
66
- /**
67
- * `ownMaterial` answers the document's copy of one of its materials, or null for one that is
68
- * not the document's. `unsupported` hears of an object that could not be overridden.
69
- */
70
- export function materialOverrides(options: {
71
- readonly root: THREE.Object3D;
72
- readonly ownMaterial: ((material: THREE.Material) => THREE.Material | null) | undefined;
73
- readonly unsupported: (mesh: THREE.Mesh, material: THREE.Material) => void;
74
- }): MaterialOverrides {
75
- const { root, ownMaterial } = options;
76
- const overrides = new Map<THREE.Mesh, Override>();
77
- let disposed = false;
78
- const release = (override: Override): void => {
79
- for (const copy of override.copies) copy.dispose();
80
- override.copies = [];
81
- };
82
- const unwatch = (override: Override): void => {
83
- for (const object of override.watched) object.removeEventListener('removed', override.onRemoved);
84
- override.watched = [];
85
- };
86
- /** Hear the mesh or a parent leave; a mesh not under the copy's root is let go next frame. */
87
- const watch = (override: Override): void => {
88
- unwatch(override);
89
- let at: THREE.Object3D | null = override.mesh;
90
- for (; at && at !== root; at = at.parent) {
91
- at.addEventListener('removed', override.onRemoved);
92
- override.watched.push(at);
93
- }
94
- override.moved = at !== root;
95
- };
96
- /** Wear copies of the object's slots; false (and nothing worn) when one cannot be copied. */
97
- const dress = (override: Override): boolean => {
98
- release(override);
99
- const copies: Colored[] = [];
100
- for (const material of slotsOf(override.authored)) {
101
- const copy = ownMaterial?.(material) ?? null;
102
- if (!copy) {
103
- for (const made of copies) made.dispose();
104
- override.mesh.material = override.authored;
105
- options.unsupported(override.mesh, material);
106
- return false;
107
- }
108
- copies.push(copy as Colored);
109
- }
110
- override.copies = copies;
111
- override.shown = Array.isArray(override.authored) ? copies : copies[0]!;
112
- override.mesh.material = override.shown;
113
- return true;
114
- };
115
- // Values only: the copies were made from the authored slots, so each starts from its own.
116
- const paint = (override: Override): void => {
117
- const authored = slotsOf(override.authored) as Colored[];
118
- override.copies.forEach((copy, index) => {
119
- const source = authored[index]!;
120
- if (isColor(copy.color) && isColor(source.color)) {
121
- if (override.color === null) copy.color.copy(source.color);
122
- else copy.color.set(override.color);
123
- }
124
- // An emitting surface glows in its tint; one that does not stays unlit by it.
125
- if (isColor(copy.emissive) && isColor(source.emissive)) {
126
- const emits = source.emissive.r + source.emissive.g + source.emissive.b > 0;
127
- if (override.color === null || !emits) copy.emissive.copy(source.emissive);
128
- else copy.emissive.set(override.color);
129
- }
130
- copy.opacity = override.opacity ?? source.opacity;
131
- const transparent = source.transparent || copy.opacity < 1;
132
- if (copy.transparent !== transparent) {
133
- copy.transparent = transparent;
134
- copy.needsUpdate = true;
135
- }
136
- });
137
- };
138
- const forget = (override: Override): void => {
139
- if (override.mesh.material === override.shown) override.mesh.material = override.authored;
140
- release(override);
141
- unwatch(override);
142
- overrides.delete(override.mesh);
143
- };
144
- /** The presenter re-assigned the object's slots since it last wore copies: adopt its slots
145
- * as the authored ones and dress again. False when they cannot be copied. */
146
- const resync = (override: Override): boolean => {
147
- if (override.mesh.material === override.shown) return true;
148
- override.authored = override.mesh.material;
149
- override.shown = override.mesh.material;
150
- if (dress(override)) return true;
151
- release(override);
152
- unwatch(override);
153
- overrides.delete(override.mesh);
154
- return false;
155
- };
156
- const change = (target: THREE.Object3D, edit: (override: Override) => void): void => {
157
- if (disposed) return;
158
- target.traverse((object) => {
159
- const mesh = object as THREE.Mesh;
160
- if (mesh.isMesh !== true || !mesh.material) return;
161
- let override = overrides.get(mesh);
162
- if (override && !resync(override)) override = undefined;
163
- if (!override) {
164
- const fresh: Override = { mesh, authored: mesh.material, shown: mesh.material, copies: [], color: null, opacity: null,
165
- watched: [], moved: false, onRemoved: () => { fresh.moved = true; } };
166
- edit(fresh);
167
- if (fresh.color === null && fresh.opacity === null) return;
168
- if (!dress(fresh)) return;
169
- overrides.set(mesh, fresh);
170
- watch(fresh);
171
- override = fresh;
172
- } else edit(override);
173
- if (override.color === null && override.opacity === null) forget(override);
174
- else paint(override);
175
- });
176
- };
177
- return {
178
- tint(target, color) { change(target, (override) => { override.color = color; }); },
179
- setOpacity(target, opacity) {
180
- if (opacity !== null && !Number.isFinite(opacity)) throw new Error(`setOpacity takes a number from 0 to 1, or null; it was given ${String(opacity)}.`);
181
- const value = opacity === null ? null : Math.min(1, Math.max(0, opacity));
182
- change(target, (override) => { override.opacity = value; });
183
- },
184
- frame() {
185
- for (const override of [...overrides.values()]) {
186
- if (override.moved) {
187
- // Re-walked only after a reported removal; one put back under the copy is watched anew.
188
- watch(override);
189
- if (override.moved) { forget(override); continue; }
190
- }
191
- if (override.mesh.material !== override.shown && resync(override)) paint(override);
192
- }
193
- },
194
- dispose() {
195
- disposed = true;
196
- for (const override of [...overrides.values()]) forget(override);
197
- },
198
- };
199
- }