@volter/editor-threejs 0.5.57
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/LICENSE +661 -0
- package/LICENSE-APACHE +202 -0
- package/NOTICE +12 -0
- package/README.md +16 -0
- package/package.json +43 -0
- package/src/adapter/constraint.ts +78 -0
- package/src/adapter/hierarchy-marks.ts +156 -0
- package/src/adapter/ingest/scene-capture.ts +865 -0
- package/src/adapter/ingest/structural-ids.ts +139 -0
- package/src/adapter/ingest/visible-capture-window.ts +302 -0
- package/src/adapter/object3d-authoring-subject.ts +50 -0
- package/src/adapter/reflection-probe.ts +75 -0
- package/src/adapter/renderer-config.ts +116 -0
- package/src/adapter/trigger-volume.ts +29 -0
- package/src/animation/animation-clock.ts +479 -0
- package/src/animation/runtime-inspection.ts +45 -0
- package/src/asset-loaders.ts +242 -0
- package/src/asset-parse-error.ts +29 -0
- package/src/capture/output-pass.ts +36 -0
- package/src/capture/scene.ts +146 -0
- package/src/ecs/object-marks.ts +75 -0
- package/src/ecs/user-data.ts +251 -0
- package/src/loader.ts +134 -0
- package/src/render/matcap-texture.ts +92 -0
- package/src/render/spark-renderer-lifecycle.ts +64 -0
- package/src/render/viewport-shading.ts +163 -0
- package/src/viewport/clip-planes.ts +63 -0
- package/src/viewport/content-bounds.ts +355 -0
- package/src/viewport/editor-layers.ts +62 -0
- package/src/viewport/environment.ts +26 -0
- package/src/viewport/preview-renderer.ts +179 -0
- package/src/viewport/renderer-ownership.ts +86 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural identity — the deterministic id scheme (and the small material
|
|
3
|
+
* reflection helper that rides along with it) the editor's three authoring
|
|
4
|
+
* adapter uses to address the objects of a world whose source carries no
|
|
5
|
+
* serve-time identity stamps
|
|
6
|
+
* (`packages/editor/src/projection/three.ts`, `structuralIdentity`).
|
|
7
|
+
*
|
|
8
|
+
* Identity: each object gets a **structural-path id** — deterministic from the
|
|
9
|
+
* scene's shape (position in the tree + three.js type + name), so the SAME id
|
|
10
|
+
* re-binds to the SAME object after the game rebuilds its scene within a
|
|
11
|
+
* session. The pure walk returns that identity beside the native objects; the
|
|
12
|
+
* editor keeps the reverse lookup in its authoring adapter rather than writing
|
|
13
|
+
* editor currency into a foreign graph.
|
|
14
|
+
*
|
|
15
|
+
* This module used to be the shared core of a per-game JSON sidecar
|
|
16
|
+
* persistence system, which was deleted outright (2026-08-02) — ingest edits
|
|
17
|
+
* are LIVE-ONLY now. The id scheme survives because it is what makes the live
|
|
18
|
+
* hierarchy/selection/inspector work at all; nothing here reads or writes a
|
|
19
|
+
* file.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type * as THREE from 'three';
|
|
23
|
+
import { setUserData } from '../../ecs/user-data';
|
|
24
|
+
|
|
25
|
+
/** Fixed id for the captured render camera (not a scene child, so no structural path). */
|
|
26
|
+
export const CAMERA_ID = 'ingest:camera';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The editor parks its OWN objects — grid, its two lights, the particle
|
|
30
|
+
* BatchedRenderer, every TransformControls/gizmo helper — on layer 31
|
|
31
|
+
* (`@vgai/threejs/viewport/editor-layers`'s `EDITOR_LAYER`) so the game camera
|
|
32
|
+
* never sees them. On an ingest root those objects are added to the GAME'S OWN
|
|
33
|
+
* scene, which is the same tree this walk indexes.
|
|
34
|
+
*/
|
|
35
|
+
const EDITOR_ONLY_LAYER_MASK = 1 << 31;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Editor furniture, not game content. Excluded from the walk entirely — see
|
|
39
|
+
* {@link collectStructuralIds} for why that exclusion is what makes the id
|
|
40
|
+
* scheme's central promise true.
|
|
41
|
+
*/
|
|
42
|
+
function isEditorOnly(o: THREE.Object3D): boolean {
|
|
43
|
+
return (o.layers.mask & EDITOR_ONLY_LAYER_MASK) !== 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The material whose colour a mesh's swatch reflects, if it has one.
|
|
48
|
+
*
|
|
49
|
+
* A multi-material mesh reflects its FIRST slot — that is what a single swatch
|
|
50
|
+
* can honestly stand for, and it is also the slot a creation-site anchor for
|
|
51
|
+
* `material.color` resolves against. Returns `null` for anything with no
|
|
52
|
+
* `color` at all rather than fabricating one.
|
|
53
|
+
*/
|
|
54
|
+
export function colorMaterialOf(o: THREE.Object3D): THREE.MeshStandardMaterial | null {
|
|
55
|
+
const mat = (o as THREE.Mesh).material;
|
|
56
|
+
if (!mat) return null;
|
|
57
|
+
const m = (Array.isArray(mat) ? mat[0] : mat) as THREE.MeshStandardMaterial | undefined;
|
|
58
|
+
return m?.color ? m : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Result of a structural-path walk: the id→object map plus a few reflection stats. */
|
|
62
|
+
export interface StructuralIdWalk {
|
|
63
|
+
byId: Map<string, THREE.Object3D>;
|
|
64
|
+
count: number;
|
|
65
|
+
meshes: number;
|
|
66
|
+
lights: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* (Re)collect structural-path ids for every object under `scene` (and, if
|
|
71
|
+
* given, the separately-captured render `camera`, under the fixed
|
|
72
|
+
* {@link CAMERA_ID}). Idempotent and deterministic from scene structure
|
|
73
|
+
* (position + type + name) — so a re-walk after the game rebuilds part of its
|
|
74
|
+
* tree re-binds the same ids to the same objects.
|
|
75
|
+
*
|
|
76
|
+
* EDITOR FURNITURE IS EXCLUDED, and that exclusion is load-bearing rather than
|
|
77
|
+
* cosmetic. On an ingest root the editor adds its grid, lights, particle
|
|
78
|
+
* BatchedRenderer and TransformControls gizmo to the GAME'S OWN scene, and
|
|
79
|
+
* they arrive asynchronously — some before the walk, some after. Indexing them
|
|
80
|
+
* made a game object's path depend on how much editor furniture happened to be
|
|
81
|
+
* attached at walk time, so the same mesh changed id across a re-walk (observed
|
|
82
|
+
* in the games-fps ingest: an object addressed as `ingest:106/0/0/0:…` came
|
|
83
|
+
* back as `ingest:102/0:Mesh:Cube004` — four root slots earlier, exactly the
|
|
84
|
+
* grid + two editor lights + BatchedRenderer).
|
|
85
|
+
*
|
|
86
|
+
* Skipping the furniture entirely — not merely declining to give it an id —
|
|
87
|
+
* is what fixes that: the game's own children occupy 0..N-1 whatever else is
|
|
88
|
+
* parented alongside them. It also keeps gizmo handles and the grid out of the
|
|
89
|
+
* hierarchy/inspector projections built on this walk, where they were
|
|
90
|
+
* selectable and colorable as if they were game content.
|
|
91
|
+
*/
|
|
92
|
+
export function collectStructuralIds(
|
|
93
|
+
scene: THREE.Object3D,
|
|
94
|
+
camera?: THREE.Object3D | undefined,
|
|
95
|
+
): StructuralIdWalk {
|
|
96
|
+
const byId = new Map<string, THREE.Object3D>();
|
|
97
|
+
let count = 0;
|
|
98
|
+
let meshes = 0;
|
|
99
|
+
let lights = 0;
|
|
100
|
+
const visit = (
|
|
101
|
+
o: THREE.Object3D & { isMesh?: boolean; isLight?: boolean },
|
|
102
|
+
path: string,
|
|
103
|
+
): void => {
|
|
104
|
+
const id = `ingest:${path}:${o.type}:${o.name || ''}`;
|
|
105
|
+
byId.set(id, o);
|
|
106
|
+
count++;
|
|
107
|
+
if (o.isLight) lights++;
|
|
108
|
+
else if (o.isMesh) meshes++;
|
|
109
|
+
visitChildren(o, path);
|
|
110
|
+
};
|
|
111
|
+
const visitChildren = (o: THREE.Object3D, path: string): void => {
|
|
112
|
+
let i = 0;
|
|
113
|
+
for (const c of o.children) {
|
|
114
|
+
if (isEditorOnly(c)) continue;
|
|
115
|
+
visit(c, path ? `${path}/${i}` : `${i}`);
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
visitChildren(scene, '');
|
|
120
|
+
if (camera) {
|
|
121
|
+
byId.set(CAMERA_ID, camera);
|
|
122
|
+
count++;
|
|
123
|
+
}
|
|
124
|
+
return { byId, count, meshes, lights };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Compatibility entry point for first-party callers that explicitly want
|
|
129
|
+
* structural ids stamped into their own graph. Foreign ingest authoring uses
|
|
130
|
+
* {@link collectStructuralIds} and never calls this mutating form.
|
|
131
|
+
*/
|
|
132
|
+
export function assignStructuralIds(
|
|
133
|
+
scene: THREE.Object3D,
|
|
134
|
+
camera?: THREE.Object3D | undefined,
|
|
135
|
+
): StructuralIdWalk {
|
|
136
|
+
const walk = collectStructuralIds(scene, camera);
|
|
137
|
+
for (const [id, object] of walk.byId) setUserData(object, 'entityId', id);
|
|
138
|
+
return walk;
|
|
139
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capture window is a budget of VISIBLE time, not of wall-clock time.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS MODULE EXISTS. An ingest mount waits for the game to render its
|
|
5
|
+
* first frame and fails by name when that never happens
|
|
6
|
+
* (`scene-capture.ts`'s `waitForCapture`). That wait used to be a plain
|
|
7
|
+
* `setTimeout`, i.e. a wall-clock deadline started AT BOOT — and a game cannot
|
|
8
|
+
* render while its tab is hidden, because the browser parks `requestAnimation
|
|
9
|
+
* Frame` for a backgrounded document. The two facts together make one
|
|
10
|
+
* deterministic failure: a tab that opens in the BACKGROUND (the normal human
|
|
11
|
+
* path — `vgai edit` auto-opens a tab that routinely lands behind the current
|
|
12
|
+
* window) burns its whole capture window unable to draw, the deadline fires,
|
|
13
|
+
* the mount dies terminally, and foregrounding the tab later changes nothing.
|
|
14
|
+
* Agents, whose tabs happen to be visible, never saw it.
|
|
15
|
+
*
|
|
16
|
+
* So the clock only runs while the document is VISIBLE. Hidden time is not
|
|
17
|
+
* spent, and while a tab is hidden the wait PARKS rather than expiring: the
|
|
18
|
+
* capture trap stays installed, the game's modules stay live, and the first
|
|
19
|
+
* frame the tab draws after the human brings it forward is trapped exactly as
|
|
20
|
+
* it would have been at boot. That is the retry — no second mount, no
|
|
21
|
+
* re-running `load()` (which would re-construct module-level state: the
|
|
22
|
+
* ARCHITECTURE-CORE "LOADING CONSTRUCTS, once" contract), and no forcing of a
|
|
23
|
+
* frame: the game's own loop resumes on its own when the browser un-parks it.
|
|
24
|
+
*
|
|
25
|
+
* There is deliberately no wall-clock cap on the parked state. An unspent
|
|
26
|
+
* budget is not a failure — a human returning to the tab is what spends it —
|
|
27
|
+
* and a cap would be exactly the boot-time clock this module exists to
|
|
28
|
+
* remove. The parked wait is reported instead of being silent: see
|
|
29
|
+
* `CaptureWaitObserver` in `scene-capture.ts` and `vgai status`'s
|
|
30
|
+
* `ingestCaptureWait`.
|
|
31
|
+
*
|
|
32
|
+
* The state machine is a pure function of (banked segments, now, hidden) so
|
|
33
|
+
* the whole accrual rule is testable with no browser and no timers
|
|
34
|
+
* (`packages/engine/test/visible-capture-window.test.ts`); the runtime half
|
|
35
|
+
* below is only the timer arm/disarm around it.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Everything the budget reads about the outside world. Injected so the pure
|
|
40
|
+
* core stays pure and the runtime half is drivable by a fake in tests — and,
|
|
41
|
+
* as a side effect, so this module never hard-depends on `document` existing
|
|
42
|
+
* (unit runners, SSR).
|
|
43
|
+
*/
|
|
44
|
+
export interface VisibilityClock {
|
|
45
|
+
/** Milliseconds, monotonic-ish; `performance.now()`/`Date.now()` both fit. */
|
|
46
|
+
now(): number;
|
|
47
|
+
/** True while the document is hidden (no rAF, so no frame can be captured). */
|
|
48
|
+
hidden(): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* True while the browser is not presenting frames. This is wider than
|
|
51
|
+
* `hidden()`: WebKit also stops rAF for an unfocused window (and can suspend
|
|
52
|
+
* the page entirely) while `document.hidden` still reads false.
|
|
53
|
+
*
|
|
54
|
+
* Optional for compatibility with injected clocks; absent means `hidden()`.
|
|
55
|
+
*/
|
|
56
|
+
suspended?(): boolean;
|
|
57
|
+
/** Why {@link suspended} is true, when the clock can say. */
|
|
58
|
+
suspensionReason?(): 'hidden' | 'unfocused' | 'page-suspended' | null;
|
|
59
|
+
/** Subscribe to visibility transitions; returns the unsubscribe. */
|
|
60
|
+
subscribe(onChange: () => void): () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The real one: `document.visibilityState` + `visibilitychange`. Falls back
|
|
64
|
+
* to permanently-visible where there is no `document` at all, which is the
|
|
65
|
+
* honest answer for a headless caller — it has no tab to background. */
|
|
66
|
+
export function documentVisibilityClock(): VisibilityClock {
|
|
67
|
+
const doc = typeof document === 'undefined' ? null : document;
|
|
68
|
+
const win = doc?.defaultView ?? null;
|
|
69
|
+
let pageSuspended = false;
|
|
70
|
+
const reason = (): 'hidden' | 'unfocused' | 'page-suspended' | null => {
|
|
71
|
+
if (pageSuspended) return 'page-suspended';
|
|
72
|
+
if (doc?.hidden === true) return 'hidden';
|
|
73
|
+
// WebKit stops rAF when the browser window becomes inactive, including
|
|
74
|
+
// when another app is in front. Page Visibility can still say `visible`
|
|
75
|
+
// in that state. Focus is a platform fact, not a Safari/UA sniff, and in
|
|
76
|
+
// browsers that keep rendering while unfocused this merely parks the
|
|
77
|
+
// expiry clock until either a frame arrives or focus returns.
|
|
78
|
+
if (doc && typeof doc.hasFocus === 'function' && !doc.hasFocus()) return 'unfocused';
|
|
79
|
+
return null;
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
now: () => (typeof performance === 'undefined' ? Date.now() : performance.now()),
|
|
83
|
+
hidden: () => doc?.hidden === true,
|
|
84
|
+
suspended: () => reason() !== null,
|
|
85
|
+
suspensionReason: reason,
|
|
86
|
+
subscribe(onChange) {
|
|
87
|
+
if (!doc) return () => {};
|
|
88
|
+
const onPageHide = () => {
|
|
89
|
+
pageSuspended = true;
|
|
90
|
+
onChange();
|
|
91
|
+
};
|
|
92
|
+
const onPageShow = () => {
|
|
93
|
+
pageSuspended = false;
|
|
94
|
+
onChange();
|
|
95
|
+
};
|
|
96
|
+
doc.addEventListener('visibilitychange', onChange);
|
|
97
|
+
win?.addEventListener('blur', onChange);
|
|
98
|
+
win?.addEventListener('focus', onChange);
|
|
99
|
+
win?.addEventListener('pagehide', onPageHide);
|
|
100
|
+
win?.addEventListener('pageshow', onPageShow);
|
|
101
|
+
return () => {
|
|
102
|
+
doc.removeEventListener('visibilitychange', onChange);
|
|
103
|
+
win?.removeEventListener('blur', onChange);
|
|
104
|
+
win?.removeEventListener('focus', onChange);
|
|
105
|
+
win?.removeEventListener('pagehide', onPageHide);
|
|
106
|
+
win?.removeEventListener('pageshow', onPageShow);
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The banked halves of the wait plus the segment currently running. Immutable:
|
|
114
|
+
* every transition returns a new value, so a caller can hold one and compare.
|
|
115
|
+
*/
|
|
116
|
+
export interface VisibleBudgetState {
|
|
117
|
+
/** The window, in VISIBLE milliseconds. */
|
|
118
|
+
readonly budgetMs: number;
|
|
119
|
+
/** Visible time banked from completed segments. */
|
|
120
|
+
readonly visibleMs: number;
|
|
121
|
+
/** Hidden time banked from completed segments (reported, never spent). */
|
|
122
|
+
readonly hiddenMs: number;
|
|
123
|
+
/** When the current segment started. */
|
|
124
|
+
readonly since: number;
|
|
125
|
+
/** Whether the current segment is a hidden one. */
|
|
126
|
+
readonly hidden: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Open the window at `now`, in whichever visibility the document is in. */
|
|
130
|
+
export function beginVisibleBudget(
|
|
131
|
+
budgetMs: number,
|
|
132
|
+
now: number,
|
|
133
|
+
hidden: boolean,
|
|
134
|
+
): VisibleBudgetState {
|
|
135
|
+
return { budgetMs, visibleMs: 0, hiddenMs: 0, since: now, hidden };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Apply the document's current visibility at `now`: bank the segment that just
|
|
140
|
+
* ended into its own bucket and start the next one. A call that does not change
|
|
141
|
+
* visibility is a no-op *by value* (same accrual, same segment start), so a
|
|
142
|
+
* duplicate `visibilitychange` can never bank a zero-length segment twice or
|
|
143
|
+
* restart the clock.
|
|
144
|
+
*/
|
|
145
|
+
export function applyVisibility(
|
|
146
|
+
state: VisibleBudgetState,
|
|
147
|
+
now: number,
|
|
148
|
+
hidden: boolean,
|
|
149
|
+
): VisibleBudgetState {
|
|
150
|
+
if (hidden === state.hidden) return state;
|
|
151
|
+
const elapsed = Math.max(0, now - state.since);
|
|
152
|
+
return {
|
|
153
|
+
budgetMs: state.budgetMs,
|
|
154
|
+
visibleMs: state.hidden ? state.visibleMs : state.visibleMs + elapsed,
|
|
155
|
+
hiddenMs: state.hidden ? state.hiddenMs + elapsed : state.hiddenMs,
|
|
156
|
+
since: now,
|
|
157
|
+
hidden,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Visible time spent so far, including the segment in flight. */
|
|
162
|
+
export function visibleElapsedMs(state: VisibleBudgetState, now: number): number {
|
|
163
|
+
return state.visibleMs + (state.hidden ? 0 : Math.max(0, now - state.since));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Hidden time so far, including the segment in flight. Never spent — it is
|
|
167
|
+
* reported so a failure message can say what the window did NOT count. */
|
|
168
|
+
export function hiddenElapsedMs(state: VisibleBudgetState, now: number): number {
|
|
169
|
+
return state.hiddenMs + (state.hidden ? Math.max(0, now - state.since) : 0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Visible time left in the window; `0` once it is spent. */
|
|
173
|
+
export function visibleRemainingMs(state: VisibleBudgetState, now: number): number {
|
|
174
|
+
return Math.max(0, state.budgetMs - visibleElapsedMs(state, now));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A live view of one running window, for the status wire and the failure message. */
|
|
178
|
+
export interface VisibleCaptureWindow {
|
|
179
|
+
/** The window's size, in visible milliseconds. */
|
|
180
|
+
readonly budgetMs: number;
|
|
181
|
+
/** Visible milliseconds spent so far. */
|
|
182
|
+
elapsedVisibleMs(): number;
|
|
183
|
+
/** Milliseconds this window has spent browser-suspended (not counted). */
|
|
184
|
+
elapsedHiddenMs(): number;
|
|
185
|
+
/**
|
|
186
|
+
* Whether the document itself is hidden. Kept distinct from browser
|
|
187
|
+
* suspension so status never calls an unfocused, still-visible window hidden.
|
|
188
|
+
*/
|
|
189
|
+
isHidden(): boolean;
|
|
190
|
+
/** Whether the expiry budget is parked because the browser is not presenting frames. */
|
|
191
|
+
isSuspended(): boolean;
|
|
192
|
+
/** The browser condition parking the budget, when observable. */
|
|
193
|
+
suspensionReason(): 'hidden' | 'unfocused' | 'page-suspended' | null;
|
|
194
|
+
/** Stop the timer and drop the visibility listener. Idempotent. */
|
|
195
|
+
cancel(): void;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* How a caller configures ONE such wait — the options both `waitForCapture`s
|
|
200
|
+
* take (`adapter/ingest/scene-capture.ts` on the three surface,
|
|
201
|
+
* `pixi/scene-capture.ts` on the canvas surface). Passing a bare number
|
|
202
|
+
* instead is `{ timeoutMs }`, the shape every existing caller uses.
|
|
203
|
+
*
|
|
204
|
+
* It lives HERE, with the window, rather than once per lane, because every
|
|
205
|
+
* field is a parameter of the mechanism above and none of them is a parameter
|
|
206
|
+
* of a surface: `timeoutMs` is {@link startVisibleCaptureWindow}'s `budgetMs`,
|
|
207
|
+
* `visibility` is its {@link VisibilityClock}, and `onWait` hands out the
|
|
208
|
+
* {@link VisibleCaptureWindow} it returns. The two lanes had identical copies
|
|
209
|
+
* of this — which is what a shape with no lane-specific field looks like when
|
|
210
|
+
* it is declared per lane — while both already imported those three names from
|
|
211
|
+
* this module. Genuinely per-surface shapes (`CapturedRuntime` vs
|
|
212
|
+
* `CapturedRuntime2D`: a three scene/renderer against a Pixi stage/app) stay in
|
|
213
|
+
* their own lanes and keep their own names.
|
|
214
|
+
*/
|
|
215
|
+
export interface CaptureWaitOptions {
|
|
216
|
+
/** The capture window, in VISIBLE milliseconds (default 10s). */
|
|
217
|
+
timeoutMs?: number | undefined;
|
|
218
|
+
/** Injected in tests; defaults to the document's own visibility. */
|
|
219
|
+
visibility?: VisibilityClock | undefined;
|
|
220
|
+
/**
|
|
221
|
+
* Called with a LIVE view of the wait when it begins, and with `null` the
|
|
222
|
+
* moment it ends (captured, expired, or the window was cancelled).
|
|
223
|
+
*
|
|
224
|
+
* A wait parked on a hidden tab is otherwise indistinguishable from a hung
|
|
225
|
+
* mount: nothing renders, nothing fails, and every door reports silence.
|
|
226
|
+
* This is the seam the editor publishes to `vgai status` so the answer is
|
|
227
|
+
* "waiting for the first visible frame — the tab is hidden", not a countdown
|
|
228
|
+
* that is not running.
|
|
229
|
+
*/
|
|
230
|
+
onWait?: ((wait: VisibleCaptureWindow | null) => void) | undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Start a window that calls `onExpire` after `budgetMs` of VISIBLE time.
|
|
235
|
+
* While browser frame presentation is suspended the timer is disarmed entirely
|
|
236
|
+
* (so a throttled background timer cannot fire it late either) and re-armed
|
|
237
|
+
* with the remaining budget when frame presentation resumes.
|
|
238
|
+
*/
|
|
239
|
+
export function startVisibleCaptureWindow(opts: {
|
|
240
|
+
budgetMs: number;
|
|
241
|
+
onExpire: () => void;
|
|
242
|
+
/** Defaults to the document's own visibility. */
|
|
243
|
+
clock?: VisibilityClock;
|
|
244
|
+
/** Defaults to `setTimeout`/`clearTimeout`. */
|
|
245
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
246
|
+
clearTimer?: (handle: unknown) => void;
|
|
247
|
+
}): VisibleCaptureWindow {
|
|
248
|
+
const clock = opts.clock ?? documentVisibilityClock();
|
|
249
|
+
const setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
250
|
+
const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
|
|
251
|
+
|
|
252
|
+
const suspended = () => clock.suspended?.() ?? clock.hidden();
|
|
253
|
+
let state = beginVisibleBudget(opts.budgetMs, clock.now(), suspended());
|
|
254
|
+
let timer: unknown = null;
|
|
255
|
+
let done = false;
|
|
256
|
+
|
|
257
|
+
function disarm(): void {
|
|
258
|
+
if (timer !== null) {
|
|
259
|
+
clearTimer(timer);
|
|
260
|
+
timer = null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function arm(): void {
|
|
265
|
+
disarm();
|
|
266
|
+
if (done || state.hidden) return;
|
|
267
|
+
timer = setTimer(
|
|
268
|
+
() => {
|
|
269
|
+
timer = null;
|
|
270
|
+
if (done) return;
|
|
271
|
+
done = true;
|
|
272
|
+
unsubscribe();
|
|
273
|
+
opts.onExpire();
|
|
274
|
+
},
|
|
275
|
+
visibleRemainingMs(state, clock.now()),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const unsubscribe = clock.subscribe(() => {
|
|
280
|
+
if (done) return;
|
|
281
|
+
state = applyVisibility(state, clock.now(), suspended());
|
|
282
|
+
arm();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
arm();
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
budgetMs: opts.budgetMs,
|
|
289
|
+
elapsedVisibleMs: () => visibleElapsedMs(state, clock.now()),
|
|
290
|
+
elapsedHiddenMs: () => hiddenElapsedMs(state, clock.now()),
|
|
291
|
+
isHidden: () => clock.hidden(),
|
|
292
|
+
isSuspended: () => state.hidden,
|
|
293
|
+
suspensionReason: () =>
|
|
294
|
+
state.hidden ? (clock.suspensionReason?.() ?? (clock.hidden() ? 'hidden' : null)) : null,
|
|
295
|
+
cancel() {
|
|
296
|
+
if (done) return;
|
|
297
|
+
done = true;
|
|
298
|
+
disarm();
|
|
299
|
+
unsubscribe();
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transient authoring identity for a native subject represented by an
|
|
3
|
+
* Object3D proxy.
|
|
4
|
+
*
|
|
5
|
+
* Some ecosystem-native objects are not Object3Ds themselves: Rapier bodies
|
|
6
|
+
* and joints are the first examples. A project library may already own a
|
|
7
|
+
* viewport proxy for one of those objects. This mark lets the ordinary
|
|
8
|
+
* Object3D authoring projection name and inspect that proxy without teaching
|
|
9
|
+
* the editor shell the library's nouns or creating a persisted sidecar.
|
|
10
|
+
*
|
|
11
|
+
* The mark is observation only. Its values are read from the native owner at
|
|
12
|
+
* the moment they are requested, and any authored change still belongs in the
|
|
13
|
+
* owning TS/TSX or ecosystem artifact.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { PropertyDescriptor } from '@volter/editor-project/adapter/authoring';
|
|
17
|
+
import type * as THREE from 'three';
|
|
18
|
+
import { deleteUserData, getUserData, setUserData } from '../ecs/user-data';
|
|
19
|
+
|
|
20
|
+
export interface Object3DAuthoringSubjectField extends PropertyDescriptor {
|
|
21
|
+
/** Read the current value from the native subject. */
|
|
22
|
+
readonly value: () => unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Object3DAuthoringSubjectMark {
|
|
26
|
+
readonly label: string;
|
|
27
|
+
readonly kind: string;
|
|
28
|
+
readonly typeLabel?: string;
|
|
29
|
+
readonly fields?: () => readonly Object3DAuthoringSubjectField[];
|
|
30
|
+
/** Presentation-only selection feedback for proxies hidden by default. */
|
|
31
|
+
readonly selectionChanged?: (selected: boolean) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function object3DAuthoringSubjectOf(
|
|
35
|
+
object: THREE.Object3D | null | undefined,
|
|
36
|
+
): Object3DAuthoringSubjectMark | null {
|
|
37
|
+
const mark = getUserData(object, 'authoringSubject');
|
|
38
|
+
return mark && typeof mark.label === 'string' && typeof mark.kind === 'string' ? mark : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function setObject3DAuthoringSubject(
|
|
42
|
+
object: THREE.Object3D,
|
|
43
|
+
mark: Object3DAuthoringSubjectMark,
|
|
44
|
+
): void {
|
|
45
|
+
setUserData(object, 'authoringSubject', mark);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function clearObject3DAuthoringSubject(object: THREE.Object3D): void {
|
|
49
|
+
deleteUserData(object, 'authoringSubject');
|
|
50
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format-neutral live reflection-probe mark.
|
|
3
|
+
*
|
|
4
|
+
* A project-owned scene component writes this mark onto the Object3D it
|
|
5
|
+
* returns. The renderer capability owns capture and material integration; the
|
|
6
|
+
* editor only reads the mark to draw ordinary probe gizmos and expose the
|
|
7
|
+
* capture command/status. Authored truth remains the component's JSX props —
|
|
8
|
+
* this is a live adapter seam, not another document format.
|
|
9
|
+
*/
|
|
10
|
+
import type * as THREE from 'three';
|
|
11
|
+
import { deleteUserData, getUserData, setUserData } from '../ecs/user-data';
|
|
12
|
+
|
|
13
|
+
export type ReflectionProbeShape = 'box' | 'sphere';
|
|
14
|
+
export type ReflectionProbeCaptureMode = 'on-change' | 'manual' | 'realtime';
|
|
15
|
+
export type ReflectionProbeCaptureStatus = 'idle' | 'queued' | 'capturing' | 'ready' | 'error';
|
|
16
|
+
|
|
17
|
+
export interface ReflectionProbeConfig {
|
|
18
|
+
readonly shape: ReflectionProbeShape;
|
|
19
|
+
/** Full local-space box dimensions. */
|
|
20
|
+
readonly size: readonly [number, number, number];
|
|
21
|
+
/** Local-space sphere radius when `shape === 'sphere'`. */
|
|
22
|
+
readonly radius: number;
|
|
23
|
+
/** Local-space distance over which this probe fades at its boundary. */
|
|
24
|
+
readonly blendDistance: number;
|
|
25
|
+
/** Larger values win before equal-priority probes blend. */
|
|
26
|
+
readonly priority: number;
|
|
27
|
+
readonly intensity: number;
|
|
28
|
+
readonly parallaxProjection: boolean;
|
|
29
|
+
/** Full local-space projection-box dimensions. */
|
|
30
|
+
readonly parallaxSize: readonly [number, number, number];
|
|
31
|
+
readonly parallaxOffset: readonly [number, number, number];
|
|
32
|
+
readonly captureOffset: readonly [number, number, number];
|
|
33
|
+
readonly captureMode: ReflectionProbeCaptureMode;
|
|
34
|
+
readonly resolution: number;
|
|
35
|
+
readonly near: number;
|
|
36
|
+
readonly far: number;
|
|
37
|
+
readonly cullMask: number;
|
|
38
|
+
readonly captureShadows: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ReflectionProbeSnapshot {
|
|
42
|
+
readonly status: ReflectionProbeCaptureStatus;
|
|
43
|
+
/** Monotonic browser time from `performance.now()`, or null before capture. */
|
|
44
|
+
readonly lastCapturedAt: number | null;
|
|
45
|
+
readonly message?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ReflectionProbeMark {
|
|
49
|
+
readonly config: ReflectionProbeConfig;
|
|
50
|
+
readonly revision: number;
|
|
51
|
+
recapture(): void;
|
|
52
|
+
getSnapshot(): ReflectionProbeSnapshot;
|
|
53
|
+
subscribe(listener: () => void): () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function reflectionProbeOf(
|
|
57
|
+
object: THREE.Object3D | null | undefined,
|
|
58
|
+
): ReflectionProbeMark | null {
|
|
59
|
+
const candidate = getUserData(object, 'reflectionProbe');
|
|
60
|
+
if (!candidate || typeof candidate !== 'object') return null;
|
|
61
|
+
const mark = candidate as Partial<ReflectionProbeMark>;
|
|
62
|
+
return mark.config &&
|
|
63
|
+
typeof mark.recapture === 'function' &&
|
|
64
|
+
typeof mark.getSnapshot === 'function'
|
|
65
|
+
? (candidate as ReflectionProbeMark)
|
|
66
|
+
: null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function setReflectionProbeMark(object: THREE.Object3D, mark: ReflectionProbeMark): void {
|
|
70
|
+
setUserData(object, 'reflectionProbe', mark);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function clearReflectionProbeMark(object: THREE.Object3D): void {
|
|
74
|
+
deleteUserData(object, 'reflectionProbe');
|
|
75
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `applyWorldRendererConfig` — the three.js half of the renderer-config seam.
|
|
3
|
+
*
|
|
4
|
+
* The SHAPE is the project contract's (`@volter/editor-project/adapter/renderer-config`,
|
|
5
|
+
* whose header states the rule and the three load-bearing properties); this
|
|
6
|
+
* file is what writes it onto a live `WebGLRenderer` and hands back the
|
|
7
|
+
* restore. `world3d-react/r3f-root-factory.tsx` is the declarer,
|
|
8
|
+
* `runtime/create-runtime.ts` the host, the editor's world-root stage the
|
|
9
|
+
* applier.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
WorldOutputColorSpace,
|
|
14
|
+
WorldRendererConfig,
|
|
15
|
+
WorldShadowMapType,
|
|
16
|
+
WorldToneMapping,
|
|
17
|
+
} from '@volter/editor-project/adapter/renderer-config';
|
|
18
|
+
import type * as THREE from 'three';
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
WorldOutputColorSpace,
|
|
22
|
+
WorldRendererConfig,
|
|
23
|
+
WorldShadowMapType,
|
|
24
|
+
WorldToneMapping,
|
|
25
|
+
} from '@volter/editor-project/adapter/renderer-config';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Apply `config` to `renderer`, returning the restore function that puts back what was there.
|
|
29
|
+
*
|
|
30
|
+
* `three` is passed in rather than imported for values so the enum constants come from the HOST's
|
|
31
|
+
* three instance — the same identity rule `r3f-root-factory.tsx` follows for the scene and camera.
|
|
32
|
+
*/
|
|
33
|
+
export function applyWorldRendererConfig(
|
|
34
|
+
three: typeof THREE,
|
|
35
|
+
renderer: THREE.WebGLRenderer,
|
|
36
|
+
config: WorldRendererConfig,
|
|
37
|
+
): () => void {
|
|
38
|
+
// A host that mounts a world WITHOUT rasterizing it hands the adapter a duck-typed renderer —
|
|
39
|
+
// the editor's design session (`createDesignTimeRenderer`: four members, deliberately never
|
|
40
|
+
// widened) and jsdom test harnesses both do. Such a surface has no colour pipeline to configure:
|
|
41
|
+
// the frame the user sees is drawn by a DIFFERENT renderer (the editor's own), so applying the
|
|
42
|
+
// world's config there is meaningless — and calling `getClearColor` on it is a TypeError that
|
|
43
|
+
// unmounts the whole world at edit time (measured: every Godot port's edit viewport blanked with
|
|
44
|
+
// '"world" failed to mount — renderer.getClearColor is not a function'). Detect the real
|
|
45
|
+
// `WebGLRenderer` surface by the one method this function must call, and no-op otherwise.
|
|
46
|
+
if (typeof renderer.getClearColor !== 'function') {
|
|
47
|
+
return () => {};
|
|
48
|
+
}
|
|
49
|
+
const toneMappings: Record<WorldToneMapping, THREE.ToneMapping> = {
|
|
50
|
+
none: three.NoToneMapping,
|
|
51
|
+
linear: three.LinearToneMapping,
|
|
52
|
+
reinhard: three.ReinhardToneMapping,
|
|
53
|
+
cineon: three.CineonToneMapping,
|
|
54
|
+
aces: three.ACESFilmicToneMapping,
|
|
55
|
+
agx: three.AgXToneMapping,
|
|
56
|
+
neutral: three.NeutralToneMapping,
|
|
57
|
+
};
|
|
58
|
+
const colorSpaces: Record<WorldOutputColorSpace, THREE.ColorSpace> = {
|
|
59
|
+
srgb: three.SRGBColorSpace,
|
|
60
|
+
'srgb-linear': three.LinearSRGBColorSpace,
|
|
61
|
+
};
|
|
62
|
+
const shadowMapTypes: Record<WorldShadowMapType, THREE.ShadowMapType> = {
|
|
63
|
+
basic: three.BasicShadowMap,
|
|
64
|
+
pcf: three.PCFShadowMap,
|
|
65
|
+
'pcf-soft': three.PCFSoftShadowMap,
|
|
66
|
+
vsm: three.VSMShadowMap,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const restores: (() => void)[] = [];
|
|
70
|
+
|
|
71
|
+
if (config.toneMapping !== undefined) {
|
|
72
|
+
const previous = renderer.toneMapping;
|
|
73
|
+
renderer.toneMapping = toneMappings[config.toneMapping];
|
|
74
|
+
restores.push(() => {
|
|
75
|
+
renderer.toneMapping = previous;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (config.toneMappingExposure !== undefined) {
|
|
79
|
+
const previous = renderer.toneMappingExposure;
|
|
80
|
+
renderer.toneMappingExposure = config.toneMappingExposure;
|
|
81
|
+
restores.push(() => {
|
|
82
|
+
renderer.toneMappingExposure = previous;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (config.outputColorSpace !== undefined) {
|
|
86
|
+
const previous = renderer.outputColorSpace;
|
|
87
|
+
renderer.outputColorSpace = colorSpaces[config.outputColorSpace];
|
|
88
|
+
restores.push(() => {
|
|
89
|
+
renderer.outputColorSpace = previous;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (config.shadowMapType !== undefined && renderer.shadowMap !== undefined) {
|
|
93
|
+
const previous = renderer.shadowMap.type;
|
|
94
|
+
renderer.shadowMap.type = shadowMapTypes[config.shadowMapType];
|
|
95
|
+
renderer.shadowMap.needsUpdate = true;
|
|
96
|
+
restores.push(() => {
|
|
97
|
+
renderer.shadowMap.type = previous;
|
|
98
|
+
renderer.shadowMap.needsUpdate = true;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (config.clearColor !== undefined) {
|
|
102
|
+
const previousColor = new three.Color();
|
|
103
|
+
renderer.getClearColor(previousColor);
|
|
104
|
+
// Alpha is READ BACK and re-passed, never assumed: see `clearColor`'s doc above.
|
|
105
|
+
const alpha = renderer.getClearAlpha();
|
|
106
|
+
renderer.setClearColor(new three.Color(config.clearColor), alpha);
|
|
107
|
+
restores.push(() => {
|
|
108
|
+
renderer.setClearColor(previousColor, alpha);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return () => {
|
|
113
|
+
// Reverse order, so a field written twice (it cannot be, today) unwinds correctly.
|
|
114
|
+
for (let i = restores.length - 1; i >= 0; i--) restores[i]?.();
|
|
115
|
+
};
|
|
116
|
+
}
|