@volter/editor-threejs 0.5.65 → 0.5.67

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.
Files changed (112) hide show
  1. package/NOTICE +2 -0
  2. package/contributions/animation-mixers.service.ts +20 -0
  3. package/contributions/animation-timeline.utility.tsx +44 -0
  4. package/contributions/three-integration.service.ts +13 -0
  5. package/dist-node/serving.mjs +405 -0
  6. package/package.json +113 -5
  7. package/serving/animation-live-module.ts +70 -0
  8. package/serving/animation-stamp.ts +88 -0
  9. package/serving/index.ts +14 -0
  10. package/serving/model-import-conversion.ts +344 -0
  11. package/src/adapter/ingest/scene-capture.ts +1 -23
  12. package/src/adapter/renderer-config.ts +3 -4
  13. package/src/adapter/three-contract.ts +72 -0
  14. package/src/animation/live-mixers.ts +55 -0
  15. package/src/ecs/object-marks.ts +1 -1
  16. package/src/ecs/user-data.ts +0 -16
  17. package/src/host-hierarchy-objects.ts +31 -0
  18. package/src/kit/animation/three-clips-subject.ts +190 -0
  19. package/src/kit/asset-compare.ts +294 -0
  20. package/src/kit/asset-preview-command.ts +265 -0
  21. package/src/kit/asset-preview-framing.ts +357 -0
  22. package/src/kit/asset-preview.ts +2802 -0
  23. package/src/kit/asset-workflow/model-inspection.ts +830 -0
  24. package/src/kit/authoring/component-instance-root.ts +171 -0
  25. package/src/kit/authoring/design-time-settle.ts +343 -0
  26. package/src/kit/authoring/live-object-transform.ts +62 -0
  27. package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
  28. package/src/kit/authoring/object3d-document-session.ts +1965 -0
  29. package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
  30. package/src/kit/authoring/quarks-particle-systems.ts +19 -0
  31. package/src/kit/authoring/shell-viewport-policy.ts +48 -0
  32. package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
  33. package/src/kit/authoring/three-projection-core.ts +226 -0
  34. package/src/kit/authoring/viewport-pick-context.ts +39 -0
  35. package/src/kit/authoring/viewport-raycast.ts +240 -0
  36. package/src/kit/authoring/world-hidden-viewport.ts +95 -0
  37. package/src/kit/camera-authoring.ts +175 -0
  38. package/src/kit/components/CameraInfo.tsx +56 -0
  39. package/src/kit/components/InspectorObjectPreview.tsx +57 -0
  40. package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
  41. package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
  42. package/src/kit/components/StageHost.tsx +2547 -0
  43. package/src/kit/components/StageOverlays.tsx +21 -0
  44. package/src/kit/components/StatsOverlay.tsx +78 -0
  45. package/src/kit/components/ToolObject3DPreview.tsx +39 -0
  46. package/src/kit/components/ViewportFurniture.tsx +655 -0
  47. package/src/kit/components/ViewportOverlay.tsx +215 -0
  48. package/src/kit/components/ViewportShadingMenu.tsx +340 -0
  49. package/src/kit/components/ViewportViewMenu.tsx +155 -0
  50. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
  51. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  52. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
  53. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
  54. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  55. package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
  56. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
  57. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  58. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
  59. package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
  60. package/src/kit/components/stage-keyboard.tsx +40 -0
  61. package/src/kit/components/stage-overlay-set.tsx +105 -0
  62. package/src/kit/components/stage-presence-markers.ts +482 -0
  63. package/src/kit/components/stage-transform-chrome.ts +30 -0
  64. package/src/kit/components/stage-transform-tools.tsx +73 -0
  65. package/src/kit/components/stage-view-name.ts +30 -0
  66. package/src/kit/components/standard-viewport-dressing.ts +1042 -0
  67. package/src/kit/components/world-root-binding.ts +64 -0
  68. package/src/kit/constraint-helper.ts +338 -0
  69. package/src/kit/editor-shell-store.ts +814 -0
  70. package/src/kit/editor-viewport.ts +6621 -0
  71. package/src/kit/entity-lod.ts +31 -0
  72. package/src/kit/entity-object.ts +92 -0
  73. package/src/kit/hierarchy-mark-reader.ts +74 -0
  74. package/src/kit/instanced-presentation.ts +164 -0
  75. package/src/kit/live-module-source.ts +230 -0
  76. package/src/kit/model-thumbnail.ts +539 -0
  77. package/src/kit/play-camera-flight.ts +300 -0
  78. package/src/kit/projection/three.ts +898 -0
  79. package/src/kit/reflection-probe-helper.ts +142 -0
  80. package/src/kit/scene-document-viewport.ts +51 -0
  81. package/src/kit/scene-framing.ts +315 -0
  82. package/src/kit/scene-view-fog.ts +89 -0
  83. package/src/kit/spatial-handle-visuals.ts +332 -0
  84. package/src/kit/stories/three-story-model.ts +66 -0
  85. package/src/kit/three-canvas-render.ts +44 -0
  86. package/src/kit/three-hierarchy-row-media.ts +26 -0
  87. package/src/kit/three-inspection-media.ts +73 -0
  88. package/src/kit/three-integration.ts +86 -0
  89. package/src/kit/three-state.ts +33 -0
  90. package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
  91. package/src/kit/three-viewport/camera-fit.ts +41 -0
  92. package/src/kit/three-viewport/interactive-renderer.ts +132 -0
  93. package/src/kit/three-viewport/selection-brackets.ts +355 -0
  94. package/src/kit/three-viewport/selection-outline.ts +333 -0
  95. package/src/kit/three-viewport/skeleton-helper.ts +61 -0
  96. package/src/kit/three-viewport/source-color.ts +197 -0
  97. package/src/kit/three-viewport/studio-environment.ts +96 -0
  98. package/src/kit/trigger-volume-helper.ts +116 -0
  99. package/src/kit/viewport-actions.ts +128 -0
  100. package/src/kit/viewport-authoring-policy.ts +154 -0
  101. package/src/kit/viewport-commands.ts +318 -0
  102. package/src/kit/viewport-hotkeys.ts +119 -0
  103. package/src/kit/viewport-shading-boundary.ts +12 -0
  104. package/src/kit/viewport-status-facet.ts +53 -0
  105. package/src/object3d-contributions.ts +494 -0
  106. package/src/render/viewport-shading.ts +6 -2
  107. package/src/viewport/content-bounds.ts +38 -4
  108. package/src/viewport/environment.ts +16 -0
  109. package/src/viewport-api.ts +92 -0
  110. package/src/viewport-door.ts +237 -0
  111. package/src/animation/animation-clock.ts +0 -479
  112. package/src/animation/runtime-inspection.ts +0 -45
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The module the animation stamp imports into a project's modules (`animation-stamp.ts`), served
3
+ * as source into the GAME's own graph. It imports no three: it wraps the mixer the game's own
4
+ * three built, so the game keeps exactly the library it installed.
5
+ *
6
+ * A stamped mixer lists itself with every clip the game plays through it (`clipAction`) and every
7
+ * root those actions animate, so a mixer drei's `useAnimations` makes without a root is still
8
+ * found by the object it moves. The registry lives on `globalThis` under a `Symbol.for` key, where
9
+ * the editor's bundle reads it (`src/animation/live-mixers.ts`).
10
+ */
11
+
12
+ export const ANIMATION_LIVE_MODULE_ID = 'virtual:vgai-three-animation-live';
13
+
14
+ export const animationLiveModuleSource = `
15
+ const live = (globalThis[Symbol.for('volter.three.animation.live')] ??= {
16
+ mixers: new Map(),
17
+ listeners: new Set(),
18
+ version: 0,
19
+ });
20
+ function notify() {
21
+ live.version++;
22
+ for (const listener of live.listeners) {
23
+ try { listener(); } catch (error) { console.error(error); }
24
+ }
25
+ }
26
+ export function __vgaiMixer(mixer, key) {
27
+ if (!mixer || typeof mixer.clipAction !== 'function') return mixer;
28
+ if (mixer.__vgaiMixerKey) return mixer;
29
+ Object.defineProperty(mixer, '__vgaiMixerKey', { value: key });
30
+ const entry = { key, mixer, clips: new Map(), roots: new Set() };
31
+ live.mixers.set(mixer, entry);
32
+ const clipAction = mixer.clipAction;
33
+ mixer.clipAction = function (clip, root, blendMode) {
34
+ const action = clipAction.call(this, clip, root, blendMode);
35
+ if (action) {
36
+ const played = action.getClip();
37
+ const target = action.getRoot();
38
+ let changed = false;
39
+ if (played && !entry.clips.has(played.name)) { entry.clips.set(played.name, played); changed = true; }
40
+ if (target && !entry.roots.has(target)) { entry.roots.add(target); changed = true; }
41
+ if (changed) notify();
42
+ }
43
+ return action;
44
+ };
45
+ const uncacheRoot = mixer.uncacheRoot;
46
+ mixer.uncacheRoot = function (root) {
47
+ const result = uncacheRoot.call(this, root);
48
+ entry.roots.delete(root);
49
+ if (root === this.getRoot()) live.mixers.delete(this);
50
+ notify();
51
+ return result;
52
+ };
53
+ notify();
54
+ return mixer;
55
+ }
56
+ /** drei's \`useAnimations\` result: its mixer, and the clips it was handed. */
57
+ export function __vgaiAnimations(result, key) {
58
+ if (!result || !result.mixer) return result;
59
+ __vgaiMixer(result.mixer, key);
60
+ const entry = live.mixers.get(result.mixer);
61
+ if (entry && Array.isArray(result.clips)) {
62
+ let changed = false;
63
+ for (const clip of result.clips) {
64
+ if (clip && clip.name && !entry.clips.has(clip.name)) { entry.clips.set(clip.name, clip); changed = true; }
65
+ }
66
+ if (changed) notify();
67
+ }
68
+ return result;
69
+ }
70
+ `;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `@volter/editor-threejs`'s server half: the ANIMATION STAMP over the project's served modules.
3
+ *
4
+ * THE GAME REGISTERS NOTHING (ARCHITECTURE.md rule 4). A module animates with three's own API
5
+ * (`new THREE.AnimationMixer(model)`, `mixer.clipAction(clip).play()`) or drei's
6
+ * (`useAnimations(animations, ref)`); in the editor's served graph only, each of those call sites
7
+ * is wrapped so the mixer it makes reports itself (`animation-live-module.ts`). The editor's
8
+ * animation instruments drive the mixer the game made instead of minting a second writer over
9
+ * the same skeleton. A standalone build never passes through this plugin.
10
+ */
11
+
12
+ import { relative, sep } from 'node:path';
13
+ import ts from 'typescript';
14
+ import type { Plugin } from 'vite';
15
+ import { ANIMATION_LIVE_MODULE_ID, animationLiveModuleSource } from './animation-live-module';
16
+
17
+ /** The kit services this plugin reads (`@volter/editor-sdk/session/project-serving`), by shape. */
18
+ export interface AnimationServingServices {
19
+ readonly projectRoots: () => ReadonlySet<string>;
20
+ readonly currentProjectRoot: () => string | undefined;
21
+ }
22
+
23
+ const VIRTUAL_ID = `\0${ANIMATION_LIVE_MODULE_ID}`;
24
+ const NODE_MODULES = /[\\/]node_modules[\\/]/;
25
+ const SCRIPT = /\.[cm]?[jt]sx?$/;
26
+
27
+ function isMixerConstruction(node: ts.Node): node is ts.NewExpression {
28
+ if (!ts.isNewExpression(node)) return false;
29
+ const callee = node.expression;
30
+ if (ts.isIdentifier(callee)) return callee.text === 'AnimationMixer';
31
+ return ts.isPropertyAccessExpression(callee) && callee.name.text === 'AnimationMixer';
32
+ }
33
+
34
+ function isUseAnimations(node: ts.Node): node is ts.CallExpression {
35
+ return ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'useAnimations';
36
+ }
37
+
38
+ /** Wrap every mixer a module makes; `null` when it makes none. */
39
+ export function stampAnimation(code: string, file: string, relativeFile: string): string | null {
40
+ if (!code.includes('AnimationMixer') && !code.includes('useAnimations')) return null;
41
+ const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : file.endsWith('.jsx') ? ts.ScriptKind.JSX : ts.ScriptKind.TS;
42
+ const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true, kind);
43
+ const sites: { start: number; end: number; wrapper: '__vgaiMixer' | '__vgaiAnimations' }[] = [];
44
+ const visit = (node: ts.Node): void => {
45
+ if (isMixerConstruction(node)) sites.push({ start: node.getStart(source), end: node.getEnd(), wrapper: '__vgaiMixer' });
46
+ else if (isUseAnimations(node)) sites.push({ start: node.getStart(source), end: node.getEnd(), wrapper: '__vgaiAnimations' });
47
+ ts.forEachChild(node, visit);
48
+ };
49
+ visit(source);
50
+ if (sites.length === 0) return null;
51
+ let out = code;
52
+ for (const site of [...sites].sort((a, b) => b.start - a.start)) {
53
+ const { line, character } = source.getLineAndCharacterOfPosition(site.start);
54
+ const key = `${relativeFile}:${line + 1}:${character + 1}`;
55
+ out = `${out.slice(0, site.start)}${site.wrapper}(${out.slice(site.start, site.end)}, ${JSON.stringify(key)})${out.slice(site.end)}`;
56
+ }
57
+ return `import { __vgaiMixer, __vgaiAnimations } from ${JSON.stringify(ANIMATION_LIVE_MODULE_ID)};\n${out}`;
58
+ }
59
+
60
+ export function animationStampPlugin(services: AnimationServingServices): Plugin {
61
+ let serverRoot = process.cwd();
62
+ const isProjectFile = (file: string): boolean => {
63
+ if (NODE_MODULES.test(file) || !SCRIPT.test(file)) return false;
64
+ const roots = [...services.projectRoots(), services.currentProjectRoot()].filter((root): root is string => !!root);
65
+ return roots.some((root) => file.startsWith(root + sep) || file.startsWith(root + '/'));
66
+ };
67
+ return {
68
+ name: 'vgai-three-animation',
69
+ enforce: 'pre',
70
+ resolveId(id) {
71
+ return id === ANIMATION_LIVE_MODULE_ID ? VIRTUAL_ID : null;
72
+ },
73
+ load(id) {
74
+ return id === VIRTUAL_ID ? animationLiveModuleSource : null;
75
+ },
76
+ configureServer(server) {
77
+ serverRoot = server.config.root;
78
+ },
79
+ transform(code, id) {
80
+ if (id.startsWith('\0')) return null;
81
+ const file = id.split('?')[0] ?? id;
82
+ if (!isProjectFile(file)) return null;
83
+ const root = services.currentProjectRoot() ?? serverRoot;
84
+ const stamped = stampAnimation(code, file, relative(root, file).split(sep).join('/'));
85
+ return stamped === null ? null : { code: stamped, map: null };
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@volter/editor-threejs`'s server half (`package.json#vgai.serving`): the animation stamp that
3
+ * lets the editor drive the mixers a project's own code makes, and the conversion of source model
4
+ * formats to runtime GLB for the asset library's imports.
5
+ */
6
+
7
+ import type { ProjectServingServices } from '@volter/editor-sdk/session/project-serving';
8
+ import { animationStampPlugin } from './animation-stamp';
9
+ import { threeModelConverter } from './model-import-conversion';
10
+
11
+ export const servingPlugins = (services: ProjectServingServices): readonly unknown[] => {
12
+ services.registerModelConverter(threeModelConverter);
13
+ return [animationStampPlugin(services)];
14
+ };
@@ -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.
@@ -4,9 +4,8 @@
4
4
  * The SHAPE is the project contract's (`@volter/editor-project/adapter/renderer-config`,
5
5
  * whose header states the rule and the three load-bearing properties); this
6
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.
7
+ * restore. A mounted three root declares it (`MountedThreeRoot.rendererConfig`)
8
+ * and the editor's world-root stage applies it.
10
9
  */
11
10
 
12
11
  import type {
@@ -28,7 +27,7 @@ export type {
28
27
  * Apply `config` to `renderer`, returning the restore function that puts back what was there.
29
28
  *
30
29
  * `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.
30
+ * three instance — the same identity rule `r3f-root.tsx` follows for the scene and camera.
32
31
  */
33
32
  export function applyWorldRendererConfig(
34
33
  three: typeof THREE,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The Three-typed half of the adapter contract. `@volter/editor-project`
3
+ * states the `three` surface with its medium objects opaque (a scene, a
4
+ * camera, a renderer, a hierarchy's scene objects, navigation's debug mesh);
5
+ * this module names them as three.js objects for the editor side that renders
6
+ * and authors them. A `three`-surface adapter hands exactly these objects, so
7
+ * each view below is a narrowing of what the contract already carries, not a
8
+ * conversion.
9
+ */
10
+
11
+ import type {
12
+ AuthoringAdapter,
13
+ HierarchyProvider,
14
+ MountedThreeRoot,
15
+ NavigationAdapter,
16
+ PhysicsAdapter,
17
+ ThreeHostContext,
18
+ } from '@volter/editor-project/adapter';
19
+ import type { NavBakeParams } from '@volter/editor-project/adapter/system-adapter';
20
+ import type * as THREE from 'three';
21
+
22
+ /** Shared GLTF/texture cache a `three` host hands its roots. */
23
+ export interface ThreeAssetCache {
24
+ /** Load an asset by URL. Returns a cached promise if already loading/loaded. */
25
+ load<T = unknown>(url: string): Promise<T>;
26
+ /** Get a previously-loaded asset synchronously. Throws if not yet loaded. */
27
+ get<T = unknown>(url: string): T;
28
+ }
29
+
30
+ /** What a `three`-surface root is handed, Three-typed. */
31
+ export type ThreeRootHostContext = ThreeHostContext<typeof THREE, THREE.WebGLRenderer, ThreeAssetCache>;
32
+
33
+ /** A live, mounted Three world, Three-typed. */
34
+ export type ThreeMountedRoot = MountedThreeRoot<THREE.Scene, THREE.Camera>;
35
+
36
+ /** A mounted `three` root, as the Three objects it hands. */
37
+ export function threeRoot(mounted: MountedThreeRoot): ThreeMountedRoot {
38
+ return mounted as ThreeMountedRoot;
39
+ }
40
+
41
+ /** The hierarchy's scene-object doors, Three-typed. */
42
+ export interface ThreeHierarchy {
43
+ object3D?(id: string): THREE.Object3D | null;
44
+ idForObject3D?(o: THREE.Object3D): string | null;
45
+ }
46
+
47
+ /** A `three`-surface authoring adapter's hierarchy, as the Three objects it hands. */
48
+ export function threeHierarchy(adapter: Pick<AuthoringAdapter, 'hierarchy'>): HierarchyProvider & ThreeHierarchy {
49
+ return adapter.hierarchy as HierarchyProvider & ThreeHierarchy;
50
+ }
51
+
52
+ /** The live Three object a `three`-surface hierarchy hands for `id` (null
53
+ * when it has none, or omits the door). */
54
+ export function threeObject(hierarchy: Pick<HierarchyProvider, 'object3D'>, id: string): THREE.Object3D | null {
55
+ return (hierarchy.object3D?.(id) ?? null) as THREE.Object3D | null;
56
+ }
57
+
58
+ /** A `three`-surface navigation adapter, as the Three objects it trades in. */
59
+ export interface ThreeNavigationAdapter extends NavigationAdapter {
60
+ debugMesh(scene: THREE.Scene): THREE.Object3D | null;
61
+ bake?(meshes: THREE.Mesh[], params?: NavBakeParams): boolean;
62
+ clear?(scene: THREE.Scene): void;
63
+ }
64
+
65
+ export function threeNavigation(adapter: NavigationAdapter): ThreeNavigationAdapter {
66
+ return adapter as ThreeNavigationAdapter;
67
+ }
68
+
69
+ /** A `three`-surface physics adapter's debug-draw object. */
70
+ export function threePhysicsDebugDraw(adapter: PhysicsAdapter): THREE.Object3D | null {
71
+ return (adapter.debugDraw?.() ?? null) as THREE.Object3D | null;
72
+ }