@volter/editor-threejs 0.5.65 → 0.5.66

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,344 @@
1
+ /// <reference lib="dom" />
2
+
3
+ import { readFile } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import { dirname, extname } from 'node:path';
6
+ import * as THREE from 'three';
7
+ import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
8
+ import { ColladaLoader } from 'three/addons/loaders/ColladaLoader.js';
9
+ import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
10
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
11
+ import { MTLLoader } from 'three/addons/loaders/MTLLoader.js';
12
+ import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
13
+ import { PLYLoader } from 'three/addons/loaders/PLYLoader.js';
14
+ import { STLLoader } from 'three/addons/loaders/STLLoader.js';
15
+ import { TDSLoader } from 'three/addons/loaders/TDSLoader.js';
16
+ import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
17
+ import type { ModelConverter, ModelImportSettings } from '@volter/editor-sdk/session/project-serving';
18
+
19
+ /** three.js's conversion of the source formats its loaders read, as the asset library's
20
+ * model converter (`ProjectServingServices.registerModelConverter`). */
21
+ export const threeModelConverter: ModelConverter = {
22
+ formats: ['fbx', 'obj', 'dae', 'stl', 'ply', '3ds'],
23
+ convert: (primaryPath, format, settings) => convertStagedModelToGlb(primaryPath, format, settings),
24
+ };
25
+
26
+ /** Convert a staged source model to a runtime GLB without requiring Blender. */
27
+ export async function convertStagedModelToGlb(
28
+ primaryPath: string,
29
+ format: string,
30
+ settings: ModelImportSettings,
31
+ ): Promise<Uint8Array> {
32
+ installNodeDom();
33
+ const bytes = new Uint8Array(await readFile(primaryPath));
34
+ const object = await parseSourceModel(primaryPath, format.toLowerCase(), bytes);
35
+ discardUnloadedTextures(object);
36
+ applyModelImportSettings(object, settings);
37
+ installNodeFileReader();
38
+ try {
39
+ const result = await new Promise<ArrayBuffer>((resolve, reject) => {
40
+ new GLTFExporter().parse(
41
+ object,
42
+ (output) =>
43
+ output instanceof ArrayBuffer
44
+ ? resolve(output)
45
+ : reject(new Error('Model conversion produced JSON instead of binary GLB.')),
46
+ reject,
47
+ { binary: true, animations: object.animations, onlyVisible: false },
48
+ );
49
+ });
50
+ const output = new Uint8Array(result);
51
+ await validateConvertedGlb(output);
52
+ return output;
53
+ } finally {
54
+ disposeObject(object);
55
+ }
56
+ }
57
+
58
+ const materialTextureSlots = [
59
+ 'map',
60
+ 'alphaMap',
61
+ 'aoMap',
62
+ 'bumpMap',
63
+ 'displacementMap',
64
+ 'emissiveMap',
65
+ 'envMap',
66
+ 'lightMap',
67
+ 'metalnessMap',
68
+ 'normalMap',
69
+ 'roughnessMap',
70
+ 'specularMap',
71
+ ] as const;
72
+
73
+ /** Apply the persisted conversion settings before the runtime GLB is exported. */
74
+ export function applyModelImportSettings(
75
+ object: THREE.Object3D,
76
+ settings: ModelImportSettings,
77
+ ): void {
78
+ object.scale.multiplyScalar(settings.scale * sourceUnitScale(settings.sourceUnits));
79
+ object.quaternion.premultiply(importAxisRotation(settings.upAxis, settings.forwardAxis));
80
+
81
+ const discardedTextures = new Set<THREE.Texture>();
82
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one traversal deliberately applies the persisted mesh, material, texture, normal, tangent, and optimization settings to each converted renderable.
83
+ object.traverse((node) => {
84
+ const mesh = node as THREE.Mesh;
85
+ if (!mesh.geometry) return;
86
+ let geometry = mesh.geometry;
87
+ if (settings.meshOptimization !== 'none') {
88
+ const optimized = mergeVertices(
89
+ geometry,
90
+ settings.meshOptimization === 'aggressive' ? 1e-3 : 1e-6,
91
+ );
92
+ if (optimized !== geometry) {
93
+ mesh.geometry = optimized;
94
+ geometry.dispose();
95
+ geometry = optimized;
96
+ }
97
+ }
98
+ if (settings.normals !== 'preserve') {
99
+ geometry.deleteAttribute('normal');
100
+ geometry.computeVertexNormals();
101
+ }
102
+ if (settings.tangents === 'discard') geometry.deleteAttribute('tangent');
103
+ if (
104
+ settings.tangents === 'generate' &&
105
+ geometry.index &&
106
+ geometry.hasAttribute('position') &&
107
+ geometry.hasAttribute('normal') &&
108
+ geometry.hasAttribute('uv')
109
+ ) {
110
+ geometry.computeTangents();
111
+ }
112
+ geometry.computeBoundingBox();
113
+ geometry.computeBoundingSphere();
114
+
115
+ const priorMaterials = Array.isArray(mesh.material)
116
+ ? mesh.material
117
+ : mesh.material
118
+ ? [mesh.material]
119
+ : [];
120
+ if (settings.materials === 'discard') {
121
+ mesh.material = Array.isArray(mesh.material)
122
+ ? priorMaterials.map(() => new THREE.MeshStandardMaterial())
123
+ : new THREE.MeshStandardMaterial();
124
+ for (const material of priorMaterials) material.dispose();
125
+ return;
126
+ }
127
+ if (settings.textures !== 'discard') return;
128
+ for (const material of priorMaterials) {
129
+ const textured = material as THREE.Material & Record<string, unknown>;
130
+ for (const slot of materialTextureSlots) {
131
+ const texture = textured[slot];
132
+ if (texture instanceof THREE.Texture) discardedTextures.add(texture);
133
+ textured[slot] = null;
134
+ }
135
+ material.needsUpdate = true;
136
+ }
137
+ });
138
+ for (const texture of discardedTextures) texture.dispose();
139
+
140
+ if (settings.animations.length > 0) {
141
+ const requested = new Set(settings.animations);
142
+ const selected = object.animations.filter((clip) => requested.has(clip.name));
143
+ const missing = settings.animations.filter(
144
+ (name) => !selected.some((clip) => clip.name === name),
145
+ );
146
+ if (missing.length > 0) throw new Error(`Animation clips not found: ${missing.join(', ')}.`);
147
+ object.animations = selected;
148
+ }
149
+ object.updateWorldMatrix(true, true);
150
+ }
151
+
152
+ function sourceUnitScale(units: ModelImportSettings['sourceUnits']): number {
153
+ return { auto: 1, mm: 0.001, cm: 0.01, m: 1, in: 0.0254, ft: 0.3048 }[units];
154
+ }
155
+
156
+ function importAxisRotation(
157
+ upAxis: ModelImportSettings['upAxis'],
158
+ forwardAxis: ModelImportSettings['forwardAxis'],
159
+ ): THREE.Quaternion {
160
+ const up = upAxis === 'auto' ? null : axisVector(upAxis);
161
+ const forward = forwardAxis === 'auto' ? null : axisVector(forwardAxis);
162
+ if (up && forward) {
163
+ if (Math.abs(up.dot(forward)) > 0.001)
164
+ throw new Error('Import up and forward axes must be perpendicular.');
165
+ const right = new THREE.Vector3().crossVectors(forward, up).normalize();
166
+ const correctedForward = new THREE.Vector3().crossVectors(up, right).normalize();
167
+ const sourceBasis = new THREE.Matrix4().makeBasis(right, up, correctedForward.clone().negate());
168
+ return new THREE.Quaternion().setFromRotationMatrix(sourceBasis.invert());
169
+ }
170
+ if (up) return new THREE.Quaternion().setFromUnitVectors(up, new THREE.Vector3(0, 1, 0));
171
+ if (forward)
172
+ return new THREE.Quaternion().setFromUnitVectors(forward, new THREE.Vector3(0, 0, -1));
173
+ return new THREE.Quaternion();
174
+ }
175
+
176
+ function axisVector(axis: Exclude<ModelImportSettings['forwardAxis'], 'auto'> | 'x' | 'y' | 'z') {
177
+ const sign = axis.startsWith('-') ? -1 : 1;
178
+ const name = axis.replace('-', '');
179
+ return new THREE.Vector3(
180
+ name === 'x' ? sign : 0,
181
+ name === 'y' ? sign : 0,
182
+ name === 'z' ? sign : 0,
183
+ );
184
+ }
185
+
186
+ async function parseSourceModel(
187
+ primaryPath: string,
188
+ format: string,
189
+ bytes: Uint8Array,
190
+ ): Promise<THREE.Object3D> {
191
+ const resourcePath = `${dirname(primaryPath)}/`;
192
+ switch (format) {
193
+ case 'fbx':
194
+ return new FBXLoader().parse(bytes.buffer as ArrayBuffer, resourcePath);
195
+ case 'obj': {
196
+ const loader = new OBJLoader();
197
+ const materialPath = primaryPath.replace(/\.obj$/i, '.mtl');
198
+ try {
199
+ const materials = new MTLLoader().parse(await readFile(materialPath, 'utf8'), resourcePath);
200
+ materials.preload();
201
+ loader.setMaterials(materials);
202
+ } catch {
203
+ // Geometry-only OBJ conversion remains valid when no MTL is present.
204
+ }
205
+ return loader.parse(new TextDecoder().decode(bytes));
206
+ }
207
+ case 'dae': {
208
+ return new ColladaLoader().parse(new TextDecoder().decode(bytes), resourcePath).scene;
209
+ }
210
+ case 'stl': {
211
+ const geometry = new STLLoader().parse(bytes.buffer as ArrayBuffer);
212
+ return new THREE.Mesh(
213
+ geometry,
214
+ new THREE.MeshStandardMaterial({ color: 0x8fb5da, roughness: 0.75 }),
215
+ );
216
+ }
217
+ case 'ply': {
218
+ const geometry = new PLYLoader().parse(bytes.buffer as ArrayBuffer);
219
+ if (!geometry.hasAttribute('normal')) geometry.computeVertexNormals();
220
+ return new THREE.Mesh(
221
+ geometry,
222
+ new THREE.MeshStandardMaterial({
223
+ color: geometry.hasAttribute('color') ? 0xffffff : 0xb8d09b,
224
+ vertexColors: geometry.hasAttribute('color'),
225
+ roughness: 0.8,
226
+ side: THREE.DoubleSide,
227
+ }),
228
+ );
229
+ }
230
+ case '3ds':
231
+ return new TDSLoader().parse(bytes.buffer as ArrayBuffer, resourcePath);
232
+ default:
233
+ throw new Error(`Built-in conversion does not support ${format || extname(primaryPath)}.`);
234
+ }
235
+ }
236
+
237
+ /** Three's source loaders construct browser image elements even when the
238
+ * conversion runs in Node. JSDOM supplies that parser boundary; unresolved
239
+ * asynchronous images are removed below rather than passed to GLTFExporter
240
+ * as invalid zero-sized image objects. */
241
+ function installNodeDom(): void {
242
+ if (typeof document !== 'undefined' && typeof DOMParser !== 'undefined') return;
243
+ const { JSDOM } = createRequire(import.meta.url)('jsdom') as {
244
+ JSDOM: new () => {
245
+ window: {
246
+ document: Document;
247
+ DOMParser: typeof DOMParser;
248
+ HTMLImageElement: typeof HTMLImageElement;
249
+ HTMLCanvasElement: typeof HTMLCanvasElement;
250
+ Image: typeof Image;
251
+ };
252
+ };
253
+ };
254
+ const window = new JSDOM().window;
255
+ Object.assign(globalThis, {
256
+ document: window.document,
257
+ DOMParser: window.DOMParser,
258
+ HTMLImageElement: window.HTMLImageElement,
259
+ HTMLCanvasElement: window.HTMLCanvasElement,
260
+ Image: window.Image,
261
+ });
262
+ }
263
+
264
+ function discardUnloadedTextures(root: THREE.Object3D): void {
265
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one bounded traversal removes only unresolved image slots across every supported material shape.
266
+ root.traverse((object) => {
267
+ const mesh = object as THREE.Mesh;
268
+ const materials = Array.isArray(mesh.material)
269
+ ? mesh.material
270
+ : mesh.material
271
+ ? [mesh.material]
272
+ : [];
273
+ for (const material of materials) {
274
+ const textured = material as THREE.Material & Record<string, unknown>;
275
+ for (const slot of materialTextureSlots) {
276
+ const value = textured[slot];
277
+ if (!(value instanceof THREE.Texture)) continue;
278
+ const image = value.image as { width?: number; height?: number } | null;
279
+ if (image && (image.width ?? 0) > 0 && (image.height ?? 0) > 0) continue;
280
+ textured[slot] = null;
281
+ value.dispose();
282
+ }
283
+ material.needsUpdate = true;
284
+ }
285
+ });
286
+ }
287
+
288
+ async function validateConvertedGlb(bytes: Uint8Array): Promise<void> {
289
+ const parsed = await new GLTFLoader().parseAsync(Uint8Array.from(bytes).buffer, '');
290
+ let renderable = false;
291
+ parsed.scene.traverse((object) => {
292
+ renderable ||= Boolean((object as THREE.Mesh).isMesh || (object as THREE.Points).isPoints);
293
+ });
294
+ disposeObject(parsed.scene);
295
+ if (!renderable && parsed.animations.length === 0) {
296
+ throw new Error('Converted GLB contains neither renderable geometry nor animation clips.');
297
+ }
298
+ }
299
+
300
+ function installNodeFileReader(): void {
301
+ if (typeof FileReader !== 'undefined') return;
302
+ class NodeFileReader {
303
+ result: string | ArrayBuffer | null = null;
304
+ error: Error | null = null;
305
+ onloadend: ((event: ProgressEvent<FileReader>) => void) | null = null;
306
+ onerror: ((event: ProgressEvent<FileReader>) => void) | null = null;
307
+ readAsArrayBuffer(blob: Blob): void {
308
+ void blob.arrayBuffer().then(
309
+ (result) => {
310
+ this.result = result;
311
+ this.onloadend?.({ target: this } as unknown as ProgressEvent<FileReader>);
312
+ },
313
+ (error) => {
314
+ this.error = error instanceof Error ? error : new Error(String(error));
315
+ this.onerror?.({ target: this } as unknown as ProgressEvent<FileReader>);
316
+ },
317
+ );
318
+ }
319
+ readAsDataURL(blob: Blob): void {
320
+ void blob.arrayBuffer().then((result) => {
321
+ this.result = `data:${blob.type || 'application/octet-stream'};base64,${Buffer.from(result).toString('base64')}`;
322
+ this.onloadend?.({ target: this } as unknown as ProgressEvent<FileReader>);
323
+ });
324
+ }
325
+ }
326
+ Object.assign(globalThis, { FileReader: NodeFileReader });
327
+ }
328
+
329
+ function disposeObject(root: THREE.Object3D): void {
330
+ root.traverse((object) => {
331
+ const renderable = object as THREE.Mesh;
332
+ renderable.geometry?.dispose();
333
+ const materials = Array.isArray(renderable.material)
334
+ ? renderable.material
335
+ : renderable.material
336
+ ? [renderable.material]
337
+ : [];
338
+ for (const material of materials) {
339
+ for (const value of Object.values(material))
340
+ if (value instanceof THREE.Texture) value.dispose();
341
+ material.dispose();
342
+ }
343
+ });
344
+ }
@@ -53,6 +53,7 @@
53
53
  * (`docs/f13-bloom-composer-proof/record-fixed.mjs`).
54
54
  */
55
55
 
56
+ import type { WorldAdoptionEvent } from '@volter/editor-sdk/kit/world-adoption-event';
56
57
  import type * as THREE from 'three';
57
58
  import {
58
59
  type CaptureWaitOptions,
@@ -144,29 +145,6 @@ export interface SceneCaptureOptions {
144
145
  onWorldAdoption?: (event: WorldAdoptionEvent) => void;
145
146
  }
146
147
 
147
- /**
148
- * One world-adoption fact. `adopted` fires exactly once, when the trap commits
149
- * to a (scene, camera, renderer) triple; `alternate` fires for each DISTINCT
150
- * triple seen afterwards, up to {@link MAX_RECORDED_ALTERNATES}.
151
- */
152
- export type WorldAdoptionEvent =
153
- | {
154
- readonly phase: 'adopted';
155
- /** `declared` = the contract named this scene; `measured` = first render won. */
156
- readonly source: 'declared' | 'measured';
157
- readonly sceneId: string;
158
- readonly cameraId: string;
159
- }
160
- | {
161
- readonly phase: 'alternate';
162
- readonly sceneId: string;
163
- readonly cameraId: string;
164
- /** `false` ⇒ a SECOND renderer is drawing, which is the stronger signal. */
165
- readonly sameRenderer: boolean;
166
- /** Draws observed when this alternate first appeared — how far past the
167
- * adoption it is, without a wall clock. */
168
- readonly drawCount: number;
169
- };
170
148
 
171
149
  /**
172
150
  * How many distinct alternates are recorded before the trap stops looking.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The mixers a running world animates with, as the editor reads them: the registry the served
3
+ * animation stamp fills (`serving/animation-live-module.ts`) in the game's graph. An instrument
4
+ * that drives time on an object asks here for the mixer the game already made for it, so the
5
+ * skeleton keeps one writer.
6
+ */
7
+
8
+ import type * as THREE from 'three';
9
+
10
+ export interface LiveMixer {
11
+ /** The call site that made it (`src/prefabs/Player.tsx:358:37`). */
12
+ readonly key: string;
13
+ readonly mixer: THREE.AnimationMixer;
14
+ /** Every clip the game has played through it, and every clip `useAnimations` was handed. */
15
+ readonly clips: ReadonlyMap<string, THREE.AnimationClip>;
16
+ /** The objects its actions animate. */
17
+ readonly roots: ReadonlySet<unknown>;
18
+ }
19
+
20
+ interface Registry {
21
+ readonly mixers: Map<unknown, LiveMixer>;
22
+ readonly listeners: Set<() => void>;
23
+ version: number;
24
+ }
25
+
26
+ const REGISTRY = Symbol.for('volter.three.animation.live');
27
+
28
+ function registry(): Registry {
29
+ const holder = globalThis as unknown as Record<symbol, Registry | undefined>;
30
+ return (holder[REGISTRY] ??= { mixers: new Map(), listeners: new Set(), version: 0 });
31
+ }
32
+
33
+ /** The live mixer that animates `object`, or null when the game animates it with none. */
34
+ export function liveMixerFor(object: THREE.Object3D): LiveMixer | null {
35
+ for (const entry of registry().mixers.values()) {
36
+ if (entry.clips.size === 0) continue;
37
+ if (entry.mixer.getRoot() === object || entry.roots.has(object)) return entry;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /** Every mixer the running world's code has made, in the order it made them. */
43
+ export function liveMixers(): LiveMixer[] {
44
+ return [...registry().mixers.values()];
45
+ }
46
+
47
+ export function subscribeLiveMixers(listener: () => void): () => void {
48
+ const listeners = registry().listeners;
49
+ listeners.add(listener);
50
+ return () => listeners.delete(listener);
51
+ }
52
+
53
+ export function liveMixersVersion(): number {
54
+ return registry().version;
55
+ }
@@ -42,10 +42,8 @@
42
42
  *
43
43
  * Animation (load-bearing — ED5 disposal contract):
44
44
  * - `_animMixer` — THREE.AnimationMixer driving this subtree's clips.
45
- * - `_animClips` — Map<string, AnimationClip> discovered on the GLTF.
46
45
  * - `_availableClips` — string[] of clip names discovered on the GLTF
47
- * (inspector dropdown; same names as `_animClips`' keys).
48
- * - `_animationRuntime` — format-neutral live native mixer/action inspection.
46
+ * (inspector dropdown).
49
47
  *
50
48
  * Disposal contract:
51
49
  * - `__sharedGeometry` — `true` when a mesh's geometry is shared/cached and MUST
@@ -119,7 +117,6 @@ import type { ConstraintMark } from '../adapter/constraint';
119
117
  import type { Object3DAuthoringSubjectMark } from '../adapter/object3d-authoring-subject';
120
118
  import type { ReflectionProbeMark } from '../adapter/reflection-probe';
121
119
  import type { TriggerVolumeMark } from '../adapter/trigger-volume';
122
- import type { AnimationRuntimeInspection } from '../animation/runtime-inspection';
123
120
  import { ObjectMarkKeys, type ObjectMarkSchema } from './object-marks';
124
121
 
125
122
  export type { EditorHelperType } from './object-marks';
@@ -141,9 +138,7 @@ export interface UserDataSchema extends ObjectMarkSchema {
141
138
  _particleSystem: ParticleSystem;
142
139
  gaussianSplat: { src: string; numSplats: number };
143
140
  _animMixer: THREE.AnimationMixer;
144
- _animClips: Map<string, THREE.AnimationClip>;
145
141
  _availableClips: string[];
146
- _animationRuntime: AnimationRuntimeInspection;
147
142
  __sharedGeometry: boolean;
148
143
  __shadeOrig: THREE.Material | THREE.Material[];
149
144
  __shadeUnlit: THREE.Material[];
@@ -195,9 +190,7 @@ export const UserDataKeys = {
195
190
  _particleSystem: '_particleSystem',
196
191
  gaussianSplat: 'gaussianSplat',
197
192
  _animMixer: '_animMixer',
198
- _animClips: '_animClips',
199
193
  _availableClips: '_availableClips',
200
- _animationRuntime: '_animationRuntime',
201
194
  __sharedGeometry: '__sharedGeometry',
202
195
  __shadeOrig: '__shadeOrig',
203
196
  __shadeUnlit: '__shadeUnlit',
@@ -71,6 +71,7 @@ export function traverseContent(
71
71
  /** Scratch for one node's transformed geometry box — this module is synchronous
72
72
  * and single-threaded, so one instance serves every call. */
73
73
  const nodeBox = new THREE.Box3();
74
+ const frameMatrix = new THREE.Matrix4();
74
75
 
75
76
  /**
76
77
  * Is this box a measurement at all?
@@ -139,10 +140,15 @@ function expandByInstances(
139
140
  target: THREE.Box3,
140
141
  node: THREE.InstancedMesh,
141
142
  geometryBox: THREE.Box3,
143
+ frame: THREE.Matrix4 | null,
142
144
  ): void {
143
145
  for (let index = 0; index < node.count; index++) {
144
146
  node.getMatrixAt(index, instanceMatrix);
145
- instanceBox.copy(geometryBox).applyMatrix4(instanceMatrix).applyMatrix4(node.matrixWorld);
147
+ // ONE transform into the target space: a box carried through two is re-boxed twice, and a
148
+ // rotated object's box grows at each step.
149
+ instanceMatrix.premultiply(node.matrixWorld);
150
+ if (frame) instanceMatrix.premultiply(frame);
151
+ instanceBox.copy(geometryBox).applyMatrix4(instanceMatrix);
146
152
  if (!isFiniteBox(instanceBox)) continue;
147
153
  target.union(instanceBox);
148
154
  }
@@ -162,16 +168,26 @@ function expandByInstances(
162
168
  * `InstancedMesh` is the one node three's own rule gets wrong for a LIVE world;
163
169
  * see {@link expandByInstances}.
164
170
  */
165
- function expandByNodeGeometry(target: THREE.Box3, node: THREE.Object3D): void {
171
+ function expandByNodeGeometry(
172
+ target: THREE.Box3,
173
+ node: THREE.Object3D,
174
+ frame: THREE.Matrix4 | null = null,
175
+ ): void {
166
176
  const geometry = (node as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
167
177
  if (!geometry) return;
168
178
  const local = localRenderableBox(node, geometry);
169
179
  if (!local) return;
170
180
  if ((node as THREE.InstancedMesh).isInstancedMesh) {
171
- expandByInstances(target, node as THREE.InstancedMesh, local);
181
+ expandByInstances(target, node as THREE.InstancedMesh, local, frame);
172
182
  return;
173
183
  }
174
- nodeBox.copy(local).applyMatrix4(node.matrixWorld);
184
+ if (frame) {
185
+ // One transform, for the same reason as the instances above.
186
+ frameMatrix.multiplyMatrices(frame, node.matrixWorld);
187
+ nodeBox.copy(local).applyMatrix4(frameMatrix);
188
+ } else {
189
+ nodeBox.copy(local).applyMatrix4(node.matrixWorld);
190
+ }
175
191
  if (!isFiniteBox(nodeBox)) return;
176
192
  target.union(nodeBox);
177
193
  }
@@ -329,6 +345,24 @@ export function contentWorldBounds(
329
345
  return expandBoxByContent(target, object);
330
346
  }
331
347
 
348
+ /**
349
+ * `object`'s content box in a FRAME — `worldToFrame` carries world space into it (the
350
+ * inverse of the object's own `matrixWorld` gives its own axes, the box a rotated object's
351
+ * selection turns with in Godot). Same walk as {@link contentWorldBounds}.
352
+ */
353
+ export function contentBoundsInFrame(
354
+ object: THREE.Object3D,
355
+ worldToFrame: THREE.Matrix4,
356
+ target: THREE.Box3 = new THREE.Box3(),
357
+ ): THREE.Box3 {
358
+ target.makeEmpty();
359
+ traverseContent(object, (node) => {
360
+ node.updateWorldMatrix(false, false);
361
+ expandByNodeGeometry(target, node, worldToFrame);
362
+ });
363
+ return target;
364
+ }
365
+
332
366
  /**
333
367
  * The Bounds diagnostic's twelve-edge box, drawn on {@link contentWorldBounds}
334
368
  * rather than `BoxHelper`'s own `setFromObject`.
@@ -1,5 +1,7 @@
1
1
  import * as THREE from 'three';
2
2
  import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
3
+ import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js';
4
+ import { HDRLoader } from 'three/examples/jsm/loaders/HDRLoader.js';
3
5
 
4
6
  /** A baked IBL environment and the handle that frees its render target. */
5
7
  export interface StandardEnvironment {
@@ -24,3 +26,17 @@ export function createStandardEnvironment(renderer: THREE.WebGLRenderer): Standa
24
26
  pmrem.dispose();
25
27
  }
26
28
  }
29
+
30
+ /**
31
+ * An environment image (`@volter/editor-sdk/kit/environment-images`) as an equirectangular
32
+ * texture in scene-referred light. Read as half float: a sun past white stays bright, and half
33
+ * float filters linearly where full float cannot (phones).
34
+ */
35
+ export async function loadEnvironmentImage(url: string, format: 'exr' | 'hdr'): Promise<THREE.DataTexture> {
36
+ const loader = format === 'exr' ? new EXRLoader() : new HDRLoader();
37
+ loader.setDataType(THREE.HalfFloatType);
38
+ const texture = await loader.loadAsync(url);
39
+ texture.mapping = THREE.EquirectangularReflectionMapping;
40
+ texture.colorSpace = THREE.LinearSRGBColorSpace;
41
+ return texture;
42
+ }