@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.
@@ -0,0 +1,163 @@
1
+ import * as THREE from 'three';
2
+ import { createNeutralMatcapTexture } from './matcap-texture';
3
+
4
+ /** Temporary developer-facing shading modes. These never become scene data. */
5
+ export type ViewportShadingMode =
6
+ | 'solid'
7
+ | 'clay'
8
+ | 'unlit'
9
+ | 'wireframe'
10
+ | 'matcap'
11
+ | 'normals'
12
+ | 'overdraw';
13
+
14
+ type MaterialPair = {
15
+ clay: THREE.MeshStandardMaterial;
16
+ unlit: THREE.MeshBasicMaterial;
17
+ wireframe: THREE.MeshBasicMaterial;
18
+ };
19
+
20
+ function materialColor(material: THREE.Material): THREE.Color {
21
+ const color = (material as THREE.Material & { color?: THREE.Color }).color;
22
+ return color?.clone() ?? new THREE.Color(0xbec7d1);
23
+ }
24
+
25
+ function textureOf(material: THREE.Material, key: 'map' | 'alphaMap'): THREE.Texture | null {
26
+ return (
27
+ (material as THREE.Material & { map?: THREE.Texture | null; alphaMap?: THREE.Texture | null })[
28
+ key
29
+ ] ?? null
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Applies a render-only material view and restores every native material in a
35
+ * `finally` block. Gameplay and authoring therefore always observe the real
36
+ * materials; only renderer work inside {@link render} sees the diagnostic view.
37
+ */
38
+ export class ViewportShadingRenderer {
39
+ private readonly _derived = new Map<THREE.Material, MaterialPair>();
40
+ private readonly _normals = new THREE.MeshNormalMaterial();
41
+ /**
42
+ * The clay/sculpt look. Built on FIRST USE, not at construction: the matcap
43
+ * sphere is drawn into a 2D canvas, and every ViewportShadingRenderer that
44
+ * only ever draws `solid` would otherwise pay for a texture nobody samples.
45
+ * `toneMapped: false` for the same reason the overdraw material sets it —
46
+ * a diagnostic look must read exactly as authored, not as the document's
47
+ * exposure and tone curve happen to grade it.
48
+ */
49
+ private _matcap: THREE.MeshMatcapMaterial | null = null;
50
+ private _matcapTexture: THREE.Texture | null = null;
51
+ private readonly _overdraw = new THREE.MeshBasicMaterial({
52
+ color: 0xffffff,
53
+ transparent: true,
54
+ opacity: 0.08,
55
+ depthTest: false,
56
+ depthWrite: false,
57
+ blending: THREE.AdditiveBlending,
58
+ side: THREE.DoubleSide,
59
+ toneMapped: false,
60
+ });
61
+
62
+ render(
63
+ scene: THREE.Scene,
64
+ mode: ViewportShadingMode,
65
+ draw: () => void,
66
+ include: (mesh: THREE.Mesh) => boolean = () => true,
67
+ ): void {
68
+ if (mode === 'solid') {
69
+ draw();
70
+ return;
71
+ }
72
+
73
+ const originals: Array<{
74
+ mesh: THREE.Mesh;
75
+ material: THREE.Material | THREE.Material[];
76
+ }> = [];
77
+ scene.traverse((object) => {
78
+ const mesh = object as THREE.Mesh;
79
+ if (!mesh.isMesh || !mesh.material || !include(mesh)) return;
80
+ originals.push({ mesh, material: mesh.material });
81
+ mesh.material = Array.isArray(mesh.material)
82
+ ? mesh.material.map((material) => this._materialFor(material, mode))
83
+ : this._materialFor(mesh.material, mode);
84
+ });
85
+
86
+ try {
87
+ draw();
88
+ } finally {
89
+ for (const { mesh, material } of originals) mesh.material = material;
90
+ }
91
+ }
92
+
93
+ dispose(): void {
94
+ for (const pair of this._derived.values()) {
95
+ pair.clay.dispose();
96
+ pair.unlit.dispose();
97
+ pair.wireframe.dispose();
98
+ }
99
+ this._derived.clear();
100
+ this._normals.dispose();
101
+ this._overdraw.dispose();
102
+ this._matcap?.dispose();
103
+ this._matcapTexture?.dispose();
104
+ this._matcap = null;
105
+ this._matcapTexture = null;
106
+ }
107
+
108
+ private _materialFor(
109
+ material: THREE.Material,
110
+ mode: Exclude<ViewportShadingMode, 'solid'>,
111
+ ): THREE.Material {
112
+ if (mode === 'normals') return this._normals;
113
+ if (mode === 'overdraw') return this._overdraw;
114
+ if (mode === 'matcap') {
115
+ if (!this._matcap) {
116
+ this._matcapTexture = createNeutralMatcapTexture();
117
+ this._matcap = new THREE.MeshMatcapMaterial({
118
+ matcap: this._matcapTexture,
119
+ toneMapped: false,
120
+ });
121
+ }
122
+ return this._matcap;
123
+ }
124
+
125
+ let pair = this._derived.get(material);
126
+ if (!pair) {
127
+ const common: THREE.MeshBasicMaterialParameters = {
128
+ color: materialColor(material),
129
+ map: textureOf(material, 'map'),
130
+ alphaMap: textureOf(material, 'alphaMap'),
131
+ alphaTest: material.alphaTest,
132
+ opacity: material.opacity,
133
+ transparent: material.transparent,
134
+ side: material.side,
135
+ depthTest: material.depthTest,
136
+ depthWrite: material.depthWrite,
137
+ vertexColors: Boolean(
138
+ (material as THREE.Material & { vertexColors?: boolean }).vertexColors,
139
+ ),
140
+ fog: (material as THREE.Material & { fog?: boolean }).fog ?? true,
141
+ };
142
+ pair = {
143
+ clay: new THREE.MeshStandardMaterial({
144
+ color: 0xaeb6c0,
145
+ roughness: 0.82,
146
+ metalness: 0,
147
+ flatShading: true,
148
+ alphaMap: textureOf(material, 'alphaMap'),
149
+ alphaTest: material.alphaTest,
150
+ opacity: material.opacity,
151
+ transparent: material.transparent,
152
+ side: material.side,
153
+ depthTest: material.depthTest,
154
+ depthWrite: material.depthWrite,
155
+ }),
156
+ unlit: new THREE.MeshBasicMaterial(common),
157
+ wireframe: new THREE.MeshBasicMaterial({ ...common, wireframe: true }),
158
+ };
159
+ this._derived.set(material, pair);
160
+ }
161
+ return pair[mode];
162
+ }
163
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * How far a Three authoring view's perspective camera can see.
3
+ *
4
+ * ## The measured defect this exists for
5
+ *
6
+ * The editor camera was constructed once, with `far = 1000`, and never
7
+ * adjusted. That is fine for the starter template (a cube on a 50-unit grid)
8
+ * and wrong for anything world-sized: framing a world several hundred units
9
+ * across puts the camera past the far plane, so every triangle is clipped and
10
+ * the Scene viewport is a flat, empty field. The failure is graded, which is
11
+ * what made it confusing — at a 454-unit framing the world draws; at 1099 only
12
+ * the fragments within 1000 survive; at 1276 nothing does. A game whose own
13
+ * camera declares `far = 10000` is stating the honest scale of its world, and
14
+ * the editor camera has to reach it.
15
+ *
16
+ * Nothing about that is ingest-specific — a first-party world larger than
17
+ * ~1000 units disappears the same way — so the fix is a property of the editor
18
+ * camera, not of the ingest route.
19
+ *
20
+ * ## Why grow `far` instead of just setting it large
21
+ *
22
+ * The depth buffer's precision is governed by `far / near`, not by `far`. A
23
+ * blanket `far = 100000` would give every small scene a 10^6 ratio and visible
24
+ * z-fighting. So `far` tracks the content the camera actually has to reach,
25
+ * and `near` is pushed out only as much as is needed to keep the ratio bounded
26
+ * — which, for anything at the template's scale, is not at all: the planes come
27
+ * back exactly `0.1 / 1000`, the values the camera has always had.
28
+ */
29
+
30
+ /** The camera's minimum near plane — also the exact value for any small scene. */
31
+ export const EDITOR_CAMERA_NEAR = 0.1;
32
+
33
+ /** The camera's minimum far plane — also the exact value for any small scene. */
34
+ export const EDITOR_CAMERA_FAR = 1000;
35
+
36
+ /**
37
+ * Ceiling on `far / near`. 0.1/1000 is 10^4, so this leaves two doublings of
38
+ * headroom before `near` starts moving at all, and caps the depth-precision
39
+ * loss a very large world can inflict.
40
+ */
41
+ export const EDITOR_CAMERA_MAX_DEPTH_RATIO = 20_000;
42
+
43
+ /** Slack past the farthest content, so the far plane never grazes it. */
44
+ const REACH_MARGIN = 1.25;
45
+
46
+ /**
47
+ * Clip planes that reach `contentRadius` around a center `distanceToCenter`
48
+ * away, never tighter than the base planes.
49
+ *
50
+ * Pure and total: a non-finite or negative input yields the base planes rather
51
+ * than an `Infinity`/`NaN` projection matrix, because a camera whose bounds
52
+ * could not be measured must still draw.
53
+ */
54
+ export function fitClipPlanes(
55
+ distanceToCenter: number,
56
+ contentRadius: number,
57
+ ): { near: number; far: number } {
58
+ const d = Number.isFinite(distanceToCenter) ? Math.max(0, distanceToCenter) : 0;
59
+ const r = Number.isFinite(contentRadius) ? Math.max(0, contentRadius) : 0;
60
+ const far = Math.max(EDITOR_CAMERA_FAR, (d + r) * REACH_MARGIN);
61
+ const near = Math.max(EDITOR_CAMERA_NEAR, far / EDITOR_CAMERA_MAX_DEPTH_RATIO);
62
+ return { near, far };
63
+ }
@@ -0,0 +1,355 @@
1
+ /**
2
+ * World-space bounds of the GAME OBJECT a reader selected — the one walk every
3
+ * bounds consumer in the editor uses instead of `Box3.setFromObject`.
4
+ *
5
+ * ## Why `Box3.setFromObject` is the wrong instrument here
6
+ *
7
+ * `setFromObject` answers "what is the extent of this whole subtree", and a
8
+ * live game's subtree contains machinery as well as content. The marks in
9
+ * `@volter/editor-threejs/adapter/hierarchy-marks` already say which is which — a component
10
+ * WRAPS its functionality and MARKS the implementation it attaches — and the
11
+ * hierarchy panel and the drag rules already read them. Bounds is the third
12
+ * consumer, and it is the one where getting it wrong is visible on screen.
13
+ *
14
+ * The measurement that forced this (2026-08-14, the translated platformer's
15
+ * coins): a component root at `(12, 3, -5)` owning a 0.5 m coin mesh and, as a
16
+ * real child, its three.quarks `BatchedRenderer` with
17
+ * `matrixWorldAutoUpdate = false`. That renderer draws in WORLD space, so its
18
+ * identity `matrixWorld` is deliberate and correct for rendering — but its
19
+ * `VFXBatch` child carries the billboard template geometry, whose bounding box
20
+ * is `(-0.5, -0.5, 0)…(0.5, 0.5, 0)` and, at identity, sits AT THE WORLD
21
+ * ORIGIN. `setFromObject` unioned the two and returned
22
+ * `(-0.5, -0.5, -5.05)…(12.25, 3.25, 0)`: a selection cage stretching from the
23
+ * coin all the way back to the origin, on a coin that is 0.5 m across. The
24
+ * batch contributes that box whether or not a single particle is alive, so the
25
+ * cage was wrong from the first frame.
26
+ *
27
+ * That is the general shape, not a quarks quirk: ANY built-internal child whose
28
+ * world transform its owner writes by hand (`matrixWorldAutoUpdate = false` is
29
+ * the mechanism the marks' own header recommends) sits somewhere the subtree
30
+ * walk has no reason to expect, and pooled machinery routinely carries stale or
31
+ * template-sized geometry. Excluding implementation is the only rule that
32
+ * survives all of them.
33
+ *
34
+ * ## What is NOT excluded
35
+ *
36
+ * Editor furniture is deliberately still measured. `isEditorOwnedObject`
37
+ * matches `userData.engineInternal`, and the splat bounds proxy
38
+ * (`@volter/editor-threejs-runtime/asset-loaders`, `__vgai_splat_bounds`) is engine-internal
39
+ * geometry that exists PRECISELY so generic focus/selection bounds can frame a
40
+ * Gaussian splat — a splat renders no `BufferGeometry` of its own. Excluding
41
+ * editor-owned nodes here would silently un-frame every splat. Consumers that
42
+ * want helpers out of their measurement (`_hasBoundableGeometry`, the asset
43
+ * preview's staging) filter for that themselves, on top of this walk.
44
+ *
45
+ * The selected object ITSELF is always measured, even when it is marked. A
46
+ * revealed internals row is selectable, and answering "where is it" with an
47
+ * empty box would be a refusal dressed as a measurement.
48
+ */
49
+
50
+ import { isBuiltInternal } from '@volter/editor-threejs/adapter/hierarchy-marks';
51
+ import * as THREE from 'three';
52
+
53
+ /**
54
+ * Visit `object` and every descendant that is not implementation, top-down.
55
+ *
56
+ * Subtree-scoped, matching the mark's own contract: a marked node is skipped
57
+ * WITH everything under it, so a rig or a pool costs one check rather than one
58
+ * per bone. `object` itself is always visited — see the header.
59
+ */
60
+ export function traverseContent(
61
+ object: THREE.Object3D,
62
+ visit: (node: THREE.Object3D) => void,
63
+ ): void {
64
+ visit(object);
65
+ for (const child of object.children) {
66
+ if (isBuiltInternal(child)) continue;
67
+ traverseContent(child, visit);
68
+ }
69
+ }
70
+
71
+ /** Scratch for one node's transformed geometry box — this module is synchronous
72
+ * and single-threaded, so one instance serves every call. */
73
+ const nodeBox = new THREE.Box3();
74
+
75
+ /**
76
+ * Is this box a measurement at all?
77
+ *
78
+ * `Box3.isEmpty()` compares `max < min`, and EVERY comparison against `NaN` is
79
+ * false — so a box with a non-finite corner reports itself as a real,
80
+ * non-empty box, unions into every consumer, and turns the whole world's
81
+ * extent into `NaN`.
82
+ *
83
+ * That is not hypothetical and it is not the game's fault: a live world holds
84
+ * transforms that are not numbers yet. Measured on the racing-game mount
85
+ * (2026-08-15) — two of its meshes sit at `quaternion = (NaN, NaN, NaN, NaN)`
86
+ * while the game is PAUSED at frame zero, because a physics binding reads its
87
+ * worker's buffers before the worker has ever answered. The editor mounts an
88
+ * ingested game paused, so that is the exact state the first look measures.
89
+ * One such node poisoned all 71 boxes: the framing refused (`isEmpty()` false,
90
+ * fit distance `NaN`), the game-camera seed refused (`radius` `NaN`), and the
91
+ * Scene tab opened on the editor's boot pose — inside canyon geometry, a
92
+ * full-bleed rectangle of rock.
93
+ *
94
+ * The clip planes already made this decision for themselves
95
+ * (`viewport-clip-planes.ts`: "a camera whose bounds could not be measured
96
+ * must still draw"). Dropping the node HERE is the same decision made once, at
97
+ * the measurement, so framing, clipping and the selection cage all get the
98
+ * finite answer instead of each guarding separately.
99
+ */
100
+ function isFiniteBox(box: THREE.Box3): boolean {
101
+ return (
102
+ Number.isFinite(box.min.x) &&
103
+ Number.isFinite(box.min.y) &&
104
+ Number.isFinite(box.min.z) &&
105
+ Number.isFinite(box.max.x) &&
106
+ Number.isFinite(box.max.y) &&
107
+ Number.isFinite(box.max.z)
108
+ );
109
+ }
110
+
111
+ /** Scratch for the per-instance walk below — same single-threaded argument. */
112
+ const instanceMatrix = new THREE.Matrix4();
113
+ const instanceBox = new THREE.Box3();
114
+
115
+ /**
116
+ * Union an `InstancedMesh`'s OWN drawn units into `target`, in world space.
117
+ *
118
+ * **Why not the object-level `boundingBox` three's own rule prefers here:
119
+ * `InstancedMesh.computeBoundingBox()` CACHES, and instanced content MOVES.**
120
+ * `boundingBox` starts `null`, is filled on the first request, and is never
121
+ * recomputed — three's docs say so outright ("You may need to recompute the
122
+ * bounding box if an instance is transformed"). A trail system that rewrites
123
+ * every instance matrix each frame (the racing-game's `Dust`/`Skid`) is
124
+ * therefore measured forever at whatever pose the editor first happened to ask
125
+ * about, and the per-frame selection cage — which exists to be glued to the
126
+ * thing — is glued to where the content USED to be. Nothing invalidates that
127
+ * cache, because nothing in the game's code knows the editor is looking.
128
+ *
129
+ * So the union is taken from the instance matrices as they stand, per call. The
130
+ * per-instance finite filter is the same decision {@link isFiniteBox} makes one
131
+ * level up and for the same reason: a live world holds transforms that are not
132
+ * numbers yet, and one such unit would otherwise turn the node's whole box into
133
+ * `NaN` — which `isEmpty()` cannot see.
134
+ *
135
+ * Cost is O(count) per call, which is exactly what `computeBoundingBox` costs;
136
+ * the callers that pay it per frame are scoped to the selection.
137
+ */
138
+ function expandByInstances(
139
+ target: THREE.Box3,
140
+ node: THREE.InstancedMesh,
141
+ geometryBox: THREE.Box3,
142
+ ): void {
143
+ for (let index = 0; index < node.count; index++) {
144
+ node.getMatrixAt(index, instanceMatrix);
145
+ instanceBox.copy(geometryBox).applyMatrix4(instanceMatrix).applyMatrix4(node.matrixWorld);
146
+ if (!isFiniteBox(instanceBox)) continue;
147
+ target.union(instanceBox);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Union `node`'s OWN renderable extent into `target`, ignoring its children.
153
+ *
154
+ * The object-level `boundingBox` (SkinnedMesh / BatchedMesh, which account for
155
+ * skinning and draw ranges) takes precedence over the shared geometry's, exactly
156
+ * as three's own `Box3.expandByObject` decides it. We cannot call that method —
157
+ * it walks children, which is the whole thing this module exists to control — so
158
+ * the per-node half is spelled here, over three's public `boundingBox` /
159
+ * `computeBoundingBox` API. Same shape as `model-thumbnail.ts`'s
160
+ * `modelPreviewBounds`, which already needed a filtered walk for its own reason.
161
+ *
162
+ * `InstancedMesh` is the one node three's own rule gets wrong for a LIVE world;
163
+ * see {@link expandByInstances}.
164
+ */
165
+ function expandByNodeGeometry(target: THREE.Box3, node: THREE.Object3D): void {
166
+ const geometry = (node as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
167
+ if (!geometry) return;
168
+ const local = localRenderableBox(node, geometry);
169
+ if (!local) return;
170
+ if ((node as THREE.InstancedMesh).isInstancedMesh) {
171
+ expandByInstances(target, node as THREE.InstancedMesh, local);
172
+ return;
173
+ }
174
+ nodeBox.copy(local).applyMatrix4(node.matrixWorld);
175
+ if (!isFiniteBox(nodeBox)) return;
176
+ target.union(nodeBox);
177
+ }
178
+
179
+ /** The box `node` draws in its OWN space, computing it on first ask exactly as
180
+ * three does. `null` when the geometry cannot produce one at all. */
181
+ function localRenderableBox(
182
+ node: THREE.Object3D,
183
+ geometry: THREE.BufferGeometry,
184
+ ): THREE.Box3 | null {
185
+ const renderable = node as THREE.Object3D & {
186
+ boundingBox?: THREE.Box3 | null;
187
+ computeBoundingBox?: () => void;
188
+ };
189
+ // An InstancedMesh's own `boundingBox` already has the instance matrices
190
+ // baked in, so it is the GEOMETRY box that `expandByInstances` needs.
191
+ //
192
+ // A SkinnedMesh is measured by its GEOMETRY (bind-pose) box, and its
193
+ // mesh-level cache is deliberately never touched. `computeBoundingBox()` on
194
+ // a SkinnedMesh runs the CPU skinning path, and on the humanoid rigs it
195
+ // yields bone-WORLD-contaminated "local" bounds (measured live on the
196
+ // hosted editor: local box z≈7.8..8.2 for a body whose matrixWorld already
197
+ // carries z=8). Three CACHES that box on the mesh, and `SkinnedMesh.raycast`
198
+ // then uses it as an early-out gate against the LOCALIZED ray — so the one
199
+ // framing/thumbnail pass that touched a character made it click-through
200
+ // FOREVER: rays hit the floor behind while the body rendered right under
201
+ // the cursor (the enemy-torso click that selected the jump pad ten units
202
+ // behind it). The bind-pose box is the honest edit-mode answer here anyway
203
+ // — edit mode shows the authored pose.
204
+ if ((node as THREE.SkinnedMesh).isSkinnedMesh) {
205
+ if (geometry.boundingBox === null) geometry.computeBoundingBox();
206
+ return geometry.boundingBox;
207
+ }
208
+ if (renderable.boundingBox !== undefined && !(node as THREE.InstancedMesh).isInstancedMesh) {
209
+ if (renderable.boundingBox === null) renderable.computeBoundingBox?.();
210
+ return renderable.boundingBox ?? null;
211
+ }
212
+ if (geometry.boundingBox === null) geometry.computeBoundingBox();
213
+ return geometry.boundingBox;
214
+ }
215
+
216
+ /**
217
+ * Grow `target` to contain `object`'s content subtree, in world space.
218
+ *
219
+ * Drop-in for `Box3.expandByObject(object)`, minus implementation subtrees.
220
+ * Like three's own, it refreshes each visited node's `matrixWorld` from its
221
+ * parent as it descends and never touches ancestors — a caller that needs the
222
+ * chain above `object` current calls `updateWorldMatrix(true, false)` first,
223
+ * exactly as it does today.
224
+ */
225
+ export function expandBoxByContent(target: THREE.Box3, object: THREE.Object3D): THREE.Box3 {
226
+ traverseContent(object, (node) => {
227
+ node.updateWorldMatrix(false, false);
228
+ expandByNodeGeometry(target, node);
229
+ });
230
+ return target;
231
+ }
232
+
233
+ /** Scratch for the per-node collection below — same single-threaded argument. */
234
+ const collectBox = new THREE.Box3();
235
+
236
+ /**
237
+ * One drawing node's world box, plus whether it is authored as a non-occluding
238
+ * overlay. The 3D board's framing uses the overlay bit to leave a light-beam
239
+ * cone (or a trigger / AoE) out of the default camera without judging size.
240
+ */
241
+ export interface ContentNodeRecord {
242
+ readonly box: THREE.Box3;
243
+ /**
244
+ * Every material on this mesh has `depthWrite === false`. That is the
245
+ * authored "I am a volume, not a body" flag — measured on the lighthouse
246
+ * beam (`transparent`, `opacity: 0.16`, `depthWrite: false`) and absent
247
+ * from every body mesh of that prefab and of an opaque hull.
248
+ */
249
+ readonly overlay: boolean;
250
+ /**
251
+ * Every material on this mesh is `BackSide`. That is the authored
252
+ * "I am the inside of an enclosure" flag a skybox carries, and a hull
253
+ * or building does not.
254
+ */
255
+ readonly enclosure: boolean;
256
+ }
257
+
258
+ function nodeMaterials(node: THREE.Object3D): THREE.Material[] {
259
+ const material = (node as THREE.Mesh).material as THREE.Material | THREE.Material[] | undefined;
260
+ if (!material) return [];
261
+ return Array.isArray(material) ? material : [material];
262
+ }
263
+
264
+ /** True when every material on `node` is authored not to write depth. */
265
+ export function nodeIsOverlay(node: THREE.Object3D): boolean {
266
+ const list = nodeMaterials(node);
267
+ return list.length > 0 && list.every((entry) => entry.depthWrite === false);
268
+ }
269
+
270
+ /** True when every material on `node` is authored as an interior enclosure. */
271
+ export function nodeIsEnclosure(node: THREE.Object3D): boolean {
272
+ const list = nodeMaterials(node);
273
+ return list.length > 0 && list.every((entry) => entry.side === THREE.BackSide);
274
+ }
275
+
276
+ /**
277
+ * The same walk as {@link expandBoxByContent}, reported one record PER NODE
278
+ * instead of unioned.
279
+ *
280
+ * The union answers "how big is this world"; framing needs "where is this
281
+ * world's content", and those are different questions the moment a world has a
282
+ * backdrop — one skybox sphere or one far prop moves the union and moves
283
+ * nothing else (`scene-framing.ts` has the measurement). Only nodes that
284
+ * actually draw contribute a box, so groups, lights and cameras add nothing to
285
+ * the distribution.
286
+ */
287
+ export function collectContentNodeRecords(
288
+ object: THREE.Object3D,
289
+ out: ContentNodeRecord[] = [],
290
+ ): ContentNodeRecord[] {
291
+ traverseContent(object, (node) => {
292
+ node.updateWorldMatrix(false, false);
293
+ collectBox.makeEmpty();
294
+ expandByNodeGeometry(collectBox, node);
295
+ if (collectBox.isEmpty()) return;
296
+ out.push({
297
+ box: collectBox.clone(),
298
+ overlay: nodeIsOverlay(node),
299
+ enclosure: nodeIsEnclosure(node),
300
+ });
301
+ });
302
+ return out;
303
+ }
304
+
305
+ /**
306
+ * The same walk as {@link collectContentNodeRecords}, boxes only. Callers that
307
+ * do not need the overlay bit keep this entry.
308
+ */
309
+ export function collectContentNodeBoxes(
310
+ object: THREE.Object3D,
311
+ out: THREE.Box3[] = [],
312
+ ): THREE.Box3[] {
313
+ for (const record of collectContentNodeRecords(object)) out.push(record.box);
314
+ return out;
315
+ }
316
+
317
+ /**
318
+ * The world AABB of `object`'s content subtree.
319
+ *
320
+ * Drop-in for `new THREE.Box3().setFromObject(object)`. Pass `target` to reuse
321
+ * a box across frames (the selection cage recomputes one per selected entity
322
+ * per frame).
323
+ */
324
+ export function contentWorldBounds(
325
+ object: THREE.Object3D,
326
+ target: THREE.Box3 = new THREE.Box3(),
327
+ ): THREE.Box3 {
328
+ target.makeEmpty();
329
+ return expandBoxByContent(target, object);
330
+ }
331
+
332
+ /**
333
+ * The Bounds diagnostic's twelve-edge box, drawn on {@link contentWorldBounds}
334
+ * rather than `BoxHelper`'s own `setFromObject`.
335
+ *
336
+ * `THREE.BoxHelper` recomputes its box internally on every `update()`, so it
337
+ * cannot be pointed at a filtered walk; `Box3Helper` is three's own class for
338
+ * "draw THIS box", which is what we have. Same twelve edges, same colour.
339
+ */
340
+ export class ContentBoundsHelper extends THREE.Box3Helper {
341
+ /** The entity object this box is measured from. */
342
+ readonly entityObject: THREE.Object3D;
343
+
344
+ constructor(object: THREE.Object3D, color: THREE.ColorRepresentation) {
345
+ super(new THREE.Box3(), color);
346
+ this.entityObject = object;
347
+ this.update();
348
+ }
349
+
350
+ /** Re-measure. Named to match `BoxHelper.update()`, which the viewport's
351
+ * per-frame refresh already calls on everything in its bounds map. */
352
+ update(): void {
353
+ contentWorldBounds(this.entityObject, this.box);
354
+ }
355
+ }
@@ -0,0 +1,62 @@
1
+ import { getObjectMark } from '@volter/editor-threejs/ecs/object-marks';
2
+ import type * as THREE from 'three';
3
+
4
+ /** Layer used for editor-only infrastructure (grid, gizmos, helpers, editor lights). */
5
+ export const EDITOR_LAYER = 31;
6
+
7
+ /**
8
+ * Temporary mask layer used by the native Three selection effect. Unlike
9
+ * {@link EDITOR_LAYER}, membership here does NOT make an object editor-owned:
10
+ * OutlineEffect adds this bit to the selected game mesh while deriving its
11
+ * mask. Editor cameras exclude the bit from their ordinary pass so the
12
+ * effect's depth comparison can actually separate selected pixels.
13
+ */
14
+ export const EDITOR_SELECTION_LAYER = 30;
15
+
16
+ /**
17
+ * True when this object is the EDITOR'S OWN furniture rather than game content.
18
+ *
19
+ * There are two marking conventions in this codebase and neither one covers
20
+ * everything: the viewport's constructor-time furniture (`GridHelper`, the
21
+ * editor ambient/directional lights, the three.quarks `BatchedRenderer`, the
22
+ * TransformControls helper, the pivot dummy, the snap indicators) is marked
23
+ * ONLY with `layers.set(EDITOR_LAYER)`, while helpers added later
24
+ * (skeleton/physics/crowd/component gizmos) are marked ONLY with
25
+ * `userData.editorHelper`. A walk that checks one convention silently presents
26
+ * the other half as game content.
27
+ *
28
+ * That is the SimCity ledger's S-1 hierarchy defect: `projectThreeScene`
29
+ * checked only the userData flags, so when an ingest mount FAILED — leaving
30
+ * `store.scene` pointed at the editor viewport scene instead of a captured game
31
+ * scene — the hierarchy listed the editor's own grid, lights, BatchedRenderer,
32
+ * gizmo helper, pivot dummy and two snap indicators under the game world's id.
33
+ * A dead game was indistinguishable from a live one.
34
+ *
35
+ * This is the SAME predicate the engine's ingest walk already applies
36
+ * (`@volter/editor-threejs-runtime/adapter/ingest/structural-ids`'s `isEditorOnly`, layer half) plus
37
+ * the userData half `viewport-shading-boundary.ts` applies — stated once here
38
+ * so the two halves can never drift apart again.
39
+ */
40
+ export function isEditorOwnedObject(object: THREE.Object3D): boolean {
41
+ return (
42
+ // Read the public mask, which has existed for the full three.js range the
43
+ // ingest devtools hook supports. `Layers.isEnabled()` is newer than r129:
44
+ // calling it rejected an otherwise healthy Cuberun capture before the
45
+ // hierarchy could mount.
46
+ (object.layers.mask & (1 << EDITOR_LAYER)) !== 0 ||
47
+ !!getObjectMark(object, 'editorHelper') ||
48
+ !!getObjectMark(object, 'engineInternal')
49
+ );
50
+ }
51
+
52
+ /** True when `object` is editor furniture itself or lives below an
53
+ * editor-owned root. Helper conventions are not guaranteed to be repeated on
54
+ * every descendant, so hit-testing a leaf must check its ancestry. */
55
+ export function isInEditorOwnedSubtree(object: THREE.Object3D): boolean {
56
+ let current: THREE.Object3D | null = object;
57
+ while (current) {
58
+ if (isEditorOwnedObject(current)) return true;
59
+ current = current.parent;
60
+ }
61
+ return false;
62
+ }
@@ -0,0 +1,26 @@
1
+ import * as THREE from 'three';
2
+ import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
3
+
4
+ /** A baked IBL environment and the handle that frees its render target. */
5
+ export interface StandardEnvironment {
6
+ readonly texture: THREE.Texture;
7
+ dispose(): void;
8
+ }
9
+
10
+ /**
11
+ * Bake Three's RoomEnvironment for an authoring view. The caller owns the
12
+ * returned target; a preview pool may lend its texture without transferring
13
+ * ownership. No palette, document registration or product service is needed.
14
+ */
15
+ export function createStandardEnvironment(renderer: THREE.WebGLRenderer): StandardEnvironment {
16
+ const pmrem = new THREE.PMREMGenerator(renderer);
17
+ const room = new RoomEnvironment();
18
+ try {
19
+ const target = pmrem.fromScene(room, 0.04);
20
+ return { texture: target.texture, dispose: () => target.dispose() };
21
+ } finally {
22
+ // A failed bake must release its temporary scene and generator too.
23
+ room.dispose();
24
+ pmrem.dispose();
25
+ }
26
+ }