@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,31 @@
1
+ import type * as THREE from 'three';
2
+ import { isEntityObject } from './entity-object';
3
+
4
+ /**
5
+ * The selected object when it is itself a live THREE.LOD (the native R3F
6
+ * shape), otherwise the entity's live THREE.LOD preview object, or null while
7
+ * an async asset is still loading. A subtree search skips child authoring
8
+ * nodes so a nested LOD entity can never shadow this one's.
9
+ *
10
+ * Format-neutral: reads the caller's native-node membership answer and
11
+ * Three's own `isLOD` marker, never a scene descriptor. The default retains
12
+ * stamp-based compatibility for first-party callers. `editor-viewport.ts`'s
13
+ * per-frame forced-LOD-level enforcement is the only caller.
14
+ */
15
+ export function findEntityLod(
16
+ obj: THREE.Object3D,
17
+ isNestedAuthoringNode: (object: THREE.Object3D) => boolean = isEntityObject,
18
+ ): THREE.LOD | null {
19
+ // Adapter-backed worlds can expose the native LOD itself as the selected
20
+ // authored row (for example Drei Detailed), rather than wrapping it in the
21
+ // old entity preview object this helper was originally extracted for.
22
+ if ((obj as THREE.LOD).isLOD) return obj as THREE.LOD;
23
+ const stack = [...obj.children];
24
+ while (stack.length > 0) {
25
+ const node = stack.pop()!;
26
+ if (isNestedAuthoringNode(node)) continue; // nested entity subtree
27
+ if ((node as THREE.LOD).isLOD) return node as THREE.LOD;
28
+ stack.push(...node.children);
29
+ }
30
+ return null;
31
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * One answer to "which live `Object3D` is this entity id?", for every editor
3
+ * surface that needs one.
4
+ *
5
+ * There are TWO places an id can resolve, and neither is a superset of the
6
+ * other. The ACTIVE ADAPTER's `hierarchy.object3D` is authoritative: a
7
+ * source-backed component may select one identity and render its transform on a
8
+ * child, and an ADOPTED live scene (play mode) is walked by the adapter, so its
9
+ * ids exist there and nowhere else. `EditorShellStore.objectMap` is the shell's
10
+ * own map, which the adapter's answer falls back to.
11
+ *
12
+ * Asking only the store is how `editor.frame(id)` came to refuse an entity the
13
+ * same session had just selected and drawn a cage around. Measured live
14
+ * (2026-08-14, the translated platformer in play mode): `editor.status()`
15
+ * listed `r3f:world:o4a7fjmzo`, `editor.select` on it put a bracket cage on the
16
+ * coin — and `editor.frame` on that same id answered
17
+ * `Entity not found: r3f:world:o4a7fjmzo`, because the adopted play scene's
18
+ * nodes live in the adapter's walk and not in `objectMap`. A verb's EXISTENCE
19
+ * CHECK has to be the resolver its ACTION uses, or the refusal is about a
20
+ * different question than the one asked.
21
+ *
22
+ * ## The REVERSE direction lives here too, and that is the point
23
+ *
24
+ * `userData.entityId` is the stamp an adapter's walk writes when it ADMITS an
25
+ * object as an authoring node (`projection/three.ts`,
26
+ * `R3fSourceAuthoringAdapter.indexGraph`). Reading that stamp is the node → id
27
+ * half of the very same question `entityObject3D` answers id → node, so both
28
+ * halves are spelled once, here — and the surfaces that used to reach for
29
+ * `getUserData(object, 'entityId')` themselves (the viewport gizmo and its
30
+ * surface-snap, the raycast climb, the store's adoption index, the LOD walk)
31
+ * ask this module instead.
32
+ *
33
+ * Presence of the stamp is also the ADMISSION answer: an object the walk
34
+ * skipped (an editor helper, a library-built internal folded into its owner)
35
+ * carries none, which is exactly what {@link isEntityObject} and
36
+ * {@link nearestEntityObject} read. Do not re-derive that from a type test, a
37
+ * name convention or a layer — those disagree with the walk, and a surface that
38
+ * disagrees with the walk is the "one id, three answers" defect.
39
+ */
40
+
41
+ import type { AuthoringAdapter } from '@volter/editor-project/adapter';
42
+ import { getUserData, setUserData } from '@volter/editor-threejs/ecs/user-data';
43
+ import type * as THREE from 'three';
44
+ import { threeObject } from '../adapter/three-contract';
45
+
46
+ /** The live object `id` names, or `null` when nothing in this session answers to it. */
47
+ export function entityObject3D(
48
+ adapter: AuthoringAdapter,
49
+ objectMap: ReadonlyMap<string, THREE.Object3D>,
50
+ id: string,
51
+ ): THREE.Object3D | null {
52
+ return threeObject(adapter.hierarchy, id) ?? objectMap.get(id) ?? null;
53
+ }
54
+
55
+ /**
56
+ * The id `object` answers to, or `undefined` when no adapter walk has admitted
57
+ * it as an authoring node.
58
+ */
59
+ export function entityIdOf(object: THREE.Object3D | null | undefined): string | undefined {
60
+ const id = getUserData(object, 'entityId');
61
+ return typeof id === 'string' && id ? id : undefined;
62
+ }
63
+
64
+ /** Whether `object` is an admitted authoring node — see {@link entityIdOf}. */
65
+ export function isEntityObject(object: THREE.Object3D | null | undefined): boolean {
66
+ return entityIdOf(object) !== undefined;
67
+ }
68
+
69
+ /**
70
+ * The nearest ancestor-or-self of `object` that is an admitted authoring node,
71
+ * or `null` when the climb reaches the top without finding one.
72
+ *
73
+ * This is what a raycast hit has to do: the ray lands on whatever geometry is
74
+ * in front, which is routinely a library-built part folded INTO a node rather
75
+ * than the node itself.
76
+ */
77
+ export function nearestEntityObject(
78
+ object: THREE.Object3D | null | undefined,
79
+ ): THREE.Object3D | null {
80
+ let cursor: THREE.Object3D | null = object ?? null;
81
+ while (cursor && !isEntityObject(cursor)) cursor = cursor.parent;
82
+ return cursor;
83
+ }
84
+
85
+ /**
86
+ * Record that `object` is the authoring node `id` — called by the adapter walk
87
+ * that MINTED the id, and by nothing else. Every reader above is the other side
88
+ * of this one write.
89
+ */
90
+ export function stampEntityId(object: THREE.Object3D, id: string): void {
91
+ setUserData(object, 'entityId', id);
92
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The one place the hierarchy panel's mark convention meets a real `Object3D`.
3
+ *
4
+ * `hierarchy-component-marks.ts` is deliberately pure — it takes a
5
+ * {@link NodeMarkReader} and never learns what a node is made of — and
6
+ * `GameHierarchy` is deliberately contract-only (rule zero: no 3D-library value
7
+ * import). This module is the seam between them: it asks the adapter's OPTIONAL
8
+ * `object3D` capability for the node and reads
9
+ * `@volter/editor-threejs/adapter/hierarchy-marks`' two marks off it.
10
+ *
11
+ * An adapter with no `Object3D` seam (React, Pixi, DOM) yields {@link NO_MARKS},
12
+ * which `componentMarkView` recognizes by identity and short-circuits on — so a
13
+ * non-three surface pays nothing and renders byte-identically.
14
+ */
15
+
16
+ import type { HierarchyProvider } from '@volter/editor-project/adapter';
17
+ import { componentRootName, isBuiltInternal } from '@volter/editor-threejs/adapter/hierarchy-marks';
18
+ import { getUserData } from '@volter/editor-threejs/ecs/user-data';
19
+ import { isComponentInstanceRoot } from './authoring/component-instance-root';
20
+ import { NO_MARKS, type NodeMarkReader, type NodeMarks } from '@volter/editor-sdk/kit/hierarchy-component-marks';
21
+ import { threeObject } from '../adapter/three-contract';
22
+
23
+ /** Project-local R3F source already carries its component ownership on the
24
+ * native node. Read that existing stamp as the automatic equivalent of an
25
+ * imperative `markComponentRoot`; explicit marks still win. */
26
+ function componentName(object: Parameters<typeof componentRootName>[0]): string | undefined {
27
+ const explicit = componentRootName(object);
28
+ // Some project-local components are the native authoring ENTITY constructor rather than a
29
+ // reusable component boundary. Importers are the canonical case: `<UnityNode>` creates the
30
+ // GameObject itself, while a generated `<Player>` prefab remains a component instance around
31
+ // that node. `authoringRoot` is the existing generic declaration for exactly that distinction.
32
+ // An explicit component mark still wins, so a prefab root can be both its own authoring object
33
+ // and the boundary of the reusable prefab instance. A built-internal mark suppresses only the
34
+ // automatic component classification: a React helper that renders implementation machinery is
35
+ // still implementation, not a prefab merely because Fiber recorded its component boundary.
36
+ if (
37
+ explicit ||
38
+ isBuiltInternal(object) ||
39
+ getUserData(object, 'authoringRoot') === true ||
40
+ !isComponentInstanceRoot(object)
41
+ ) {
42
+ return explicit;
43
+ }
44
+ const authored = getUserData(object, 'authoringComponent');
45
+ return typeof authored === 'string' && authored ? authored : undefined;
46
+ }
47
+
48
+ /**
49
+ * A memoized reader over `hierarchy.object3D`.
50
+ *
51
+ * The cache lives for the reader's lifetime — one render pass — because the
52
+ * live graph it reads is re-walked every render anyway (`hierarchy.roots()` IS
53
+ * the refresh point on a live adapter). Within one pass the marks cannot move,
54
+ * and the walk asks for the same id many times: once per `node()` projection,
55
+ * again per internals probe.
56
+ */
57
+ export function markReaderFor(hierarchy: Pick<HierarchyProvider, 'object3D'>): NodeMarkReader {
58
+ const object3D = hierarchy.object3D;
59
+ if (typeof object3D !== 'function') return NO_MARKS;
60
+ const cache = new Map<string, NodeMarks | undefined>();
61
+ return (id) => {
62
+ if (cache.has(id)) return cache.get(id);
63
+ const object = threeObject(hierarchy, id);
64
+ const marks: NodeMarks | undefined = object
65
+ ? {
66
+ componentRoot: componentName(object),
67
+ authoredEntity: getUserData(object, 'authoringRoot') === true,
68
+ builtInternal: isBuiltInternal(object),
69
+ }
70
+ : undefined;
71
+ cache.set(id, marks);
72
+ return marks;
73
+ };
74
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * What an `InstancedMesh` IS to a reader looking at it — derived by measuring
3
+ * the live object, never by guessing what the game meant.
4
+ *
5
+ * ## The two questions, and why the second one exists
6
+ *
7
+ * 1. **How many units does this row draw?** Every other count in the editor is
8
+ * keyed on OBJECTS, and an `InstancedMesh` is one object drawing N units, so
9
+ * a hierarchy row reports 1 where a reader sees 200 — both numbers correct,
10
+ * no error anywhere. `ThreeWalkStats.instances`
11
+ * (`projection/three.ts`) is the world-level half of the same
12
+ * shortfall; this is the per-node half.
13
+ *
14
+ * 2. **Is this a WORLD-ANCHORED instanced system?** The measured case
15
+ * (2026-08-16, the vendored racing-game): `Dust` and `Skid` write per-unit
16
+ * matrices in WORLD coordinates — `wheels[2].current.getWorldPosition(v)` →
17
+ * `setItemAt` — while the `instancedMesh` container sits at identity at the
18
+ * world origin, deliberately, because a skid mark must stay where it was laid
19
+ * (`vendor/games/racing-game/src/models/vehicle/Vehicle.tsx` renders both
20
+ * OUTSIDE `<Chassis>` for exactly that reason). Selecting one put the
21
+ * selection cage and the gizmo on (0,0,0) while every unit the reader can see
22
+ * is out at the car — measured live: focusing `Dust` flew the camera inside
23
+ * the canyon to two bracket marks at the origin, which reads as a broken
24
+ * editor rather than as a correctly-drawn trail system.
25
+ *
26
+ * The class is the native-engine vocabulary (Unity calls it simulation space).
27
+ * We do not read the game's intent to find it: the SIGNATURE is measurable and
28
+ * three.js forces it — to write world coordinates into instance matrices the
29
+ * container's own world matrix MUST be identity, so "identity container, content
30
+ * elsewhere" is the pattern's fingerprint and not an inference about it.
31
+ *
32
+ * ## What "elsewhere" is measured against, and why it is not a threshold
33
+ *
34
+ * The reference volume is the container's OWN drawn geometry, sitting at its own
35
+ * origin. That is the only intrinsic scale an instanced draw has, and it makes
36
+ * the test tuning-free: content whose centre still falls inside the shape the
37
+ * container itself draws is content the pivot is already on top of, and nothing
38
+ * about that reads as broken.
39
+ *
40
+ * It also makes the answer honest at REST, which matters more than it looks.
41
+ * `new InstancedMesh(geometry, material, count)` seeds every unit to IDENTITY
42
+ * (three r180 `InstancedMesh.js` — the constructor's `setMatrixAt(i, _identity)`
43
+ * loop), so a system that has placed nothing yet genuinely draws `count` copies
44
+ * stacked on the container's origin. There is no world-space anchoring to see
45
+ * there, and the class correctly does not fire until the game has actually put
46
+ * content somewhere else.
47
+ */
48
+
49
+ import * as THREE from 'three';
50
+
51
+ /**
52
+ * Element-wise slack against the identity matrix. Generous enough to survive
53
+ * the float round-trip of a `Matrix4` composed from an untouched
54
+ * position/quaternion/scale, tight enough that any authored placement — a
55
+ * millimetre offset included — fails it.
56
+ */
57
+ const IDENTITY_EPSILON = 1e-4;
58
+
59
+ /** The world-anchored class's ONE sentence, shown verbatim wherever it appears. */
60
+ export const WORLD_ANCHORED_INSTANCED_NOTE =
61
+ 'World-anchored — content is placed in world space by the game each frame; moving this container offsets future placements away from their source.';
62
+
63
+ /** Everything both surfaces need about one node's instancing, or `null` when the
64
+ * node is not an instanced draw at all. */
65
+ export interface InstancedPresentation {
66
+ /** `InstancedMesh.count` — the units this one object draws. Always ≥ 1. */
67
+ readonly units: number;
68
+ /** The hierarchy row's dim detail: `200 instanced units`. */
69
+ readonly detail: string;
70
+ /** True for the class described in the module header. */
71
+ readonly worldAnchored: boolean;
72
+ /** {@link WORLD_ANCHORED_INSTANCED_NOTE} when {@link worldAnchored}, else `null`. */
73
+ readonly note: string | null;
74
+ }
75
+
76
+ /**
77
+ * `InstancedMesh.count`, or 0 for anything that is not one.
78
+ *
79
+ * `count` is the DRAWN range, not the allocated capacity, which is the number a
80
+ * reader is looking at. A count of 0 is not an instanced draw for presentation
81
+ * purposes — there is nothing to annotate — so it answers 0 like a plain mesh.
82
+ */
83
+ export function instancedUnitCount(object: THREE.Object3D | null | undefined): number {
84
+ if (!object) return 0;
85
+ const instanced = object as THREE.Object3D & { isInstancedMesh?: boolean; count?: number };
86
+ if (instanced.isInstancedMesh !== true) return 0;
87
+ const count = instanced.count;
88
+ return typeof count === 'number' && count > 0 ? count : 0;
89
+ }
90
+
91
+ /** Is `matrix` the identity matrix, within {@link IDENTITY_EPSILON}? */
92
+ export function isNearIdentityMatrix(matrix: THREE.Matrix4): boolean {
93
+ const e = matrix.elements;
94
+ for (let i = 0; i < 16; i++) {
95
+ const expected = i % 5 === 0 ? 1 : 0;
96
+ const value = e[i]!;
97
+ if (!Number.isFinite(value) || Math.abs(value - expected) > IDENTITY_EPSILON) return false;
98
+ }
99
+ return true;
100
+ }
101
+
102
+ /** Scratch for the reference volume below — this module is synchronous and
103
+ * single-threaded, so one instance serves every call. */
104
+ const pivotVolume = new THREE.Box3();
105
+ const contentCentre = new THREE.Vector3();
106
+
107
+ /**
108
+ * The whole presentation verdict for one live node.
109
+ *
110
+ * `contentBounds` is the node's measured world-space content (`content-bounds.ts`
111
+ * — `contentWorldBounds`, which resolves instance matrices live rather than
112
+ * through three's cached object box). It is passed IN rather than measured here
113
+ * so this module stays a pure predicate: a caller that has the walk already does
114
+ * not pay for a second one, and a test can state the geometry directly.
115
+ */
116
+ export function describeInstancedPresentation(
117
+ object: THREE.Object3D | null | undefined,
118
+ contentBounds: THREE.Box3 | null | undefined,
119
+ ): InstancedPresentation | null {
120
+ const units = instancedUnitCount(object);
121
+ if (units === 0) return null;
122
+ const worldAnchored = isWorldAnchored(object as THREE.InstancedMesh, contentBounds);
123
+ return {
124
+ units,
125
+ detail: unitsLabel(units),
126
+ worldAnchored,
127
+ note: worldAnchored ? WORLD_ANCHORED_INSTANCED_NOTE : null,
128
+ };
129
+ }
130
+
131
+ function isWorldAnchored(
132
+ mesh: THREE.InstancedMesh,
133
+ contentBounds: THREE.Box3 | null | undefined,
134
+ ): boolean {
135
+ if (!contentBounds || contentBounds.isEmpty()) return false;
136
+ if (!isNearIdentityMatrix(mesh.matrixWorld)) return false;
137
+ const geometry = mesh.geometry;
138
+ if (geometry.boundingBox === null) geometry.computeBoundingBox();
139
+ if (!geometry.boundingBox) return false;
140
+ // The container's own drawn shape, at its own (identity) origin — see the
141
+ // header. A degenerate box still works: a flat plane's centre test is the
142
+ // in-plane one, which is the right question for a decal system.
143
+ pivotVolume.copy(geometry.boundingBox);
144
+ contentBounds.getCenter(contentCentre);
145
+ return !pivotVolume.containsPoint(contentCentre);
146
+ }
147
+
148
+ /**
149
+ * The hierarchy row's detail for `id`, or `null` — the whole of what a row needs.
150
+ *
151
+ * Rows are rendered for every visible node on every notify, so this is
152
+ * deliberately the CHEAP half: a type check and a number read, with no bounds
153
+ * walk. The world-anchored class costs a walk and is therefore an
154
+ * inspector-only fact, on the one node the reader has actually selected.
155
+ */
156
+ export function instancedRowDetail(object: THREE.Object3D | null | undefined): string | null {
157
+ const units = instancedUnitCount(object);
158
+ return units === 0 ? null : unitsLabel(units);
159
+ }
160
+
161
+ /** The ONE wording, so the row and the inspector cannot drift apart. */
162
+ function unitsLabel(units: number): string {
163
+ return `${units} instanced unit${units === 1 ? '' : 's'}`;
164
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * The module→`Object3D` step, IN THE BROWSER — the live twin of the bake
3
+ * lane's Node-side `module-source.ts`
4
+ * (`catalog/project-source/src/tools/module-source.ts`), and deliberately the
5
+ * SAME contract: a project TypeScript module whose export builds a native
6
+ * `THREE.Object3D`. `project.bake.module` / `project.bake.preview` /
7
+ * `vgai screenshot <module>` already define that contract; the live modeling
8
+ * document opens the same thing, so a module that bakes opens, and a module
9
+ * that opens bakes (docs/BLENDER-PARITY.md §The model file).
10
+ *
11
+ * TWO HALVES, ONE CONTRACT. Node imports a `file://` url and has no GPU; the
12
+ * browser imports the dev server's `/@fs/` url and IS the GPU. Nothing else
13
+ * differs, which is why this file re-states the resolution rather than
14
+ * importing the tool's copy: that copy is `host: 'node'` capability source
15
+ * (`node:fs/promises`, `node:url`) that a project's own tsconfig excludes from
16
+ * its browser build. Sharing the module would drag Node into the editor
17
+ * bundle; sharing the CONTRACT is what matters, and the contract is small
18
+ * enough to be stated twice and obviously identical.
19
+ *
20
+ * DETECTION IS STRUCTURAL, not a filename convention — the quarks precedent
21
+ * (`components/asset-viewers/JsonAssetDocument.tsx`: three.quarks names no
22
+ * extension of its own, so the file's own structure is what says whether it is
23
+ * a particle document). There is no `.model.ts`: a project `.ts`/`.tsx`
24
+ * whose export RESOLVES to an `Object3D` is a model module, and everything
25
+ * else falls back to the ordinary source viewer.
26
+ */
27
+
28
+ import { liveModuleImportUrl } from '@volter/editor-sdk/session/project-module-url';
29
+ import type * as THREE from 'three';
30
+
31
+ /** Why this module is not a live model module — the sentence the document
32
+ * shows, and the reason it fell back to the source viewer. */
33
+ export type LiveModuleRefusal =
34
+ /** Nothing importable at all: a syntax error, a throwing top level, a
35
+ * missing dependency. The one refusal that is USUALLY a defect in a module
36
+ * that WAS a model module a moment ago, which is why the document keeps its
37
+ * last good root on screen for it. */
38
+ | { readonly kind: 'import-failed'; readonly message: string; readonly stack?: string }
39
+ /** It imported fine, it is simply not a model module. */
40
+ | { readonly kind: 'not-a-model'; readonly message: string };
41
+
42
+ export interface LiveModuleBuild {
43
+ readonly root: THREE.Object3D;
44
+ /** Which export produced it — `default`, or the single named export. */
45
+ readonly exportName: string;
46
+ /** The BUILDER's own teardown, when the export returned a build result that
47
+ * carried one. The document runs it in place of the generic graph dispose:
48
+ * a rig holds mixers, actions and materials a `traverse` walk cannot see. */
49
+ readonly dispose?: () => void;
50
+ }
51
+
52
+ export class LiveModuleError extends Error {
53
+ constructor(readonly refusal: LiveModuleRefusal) {
54
+ super(refusal.message);
55
+ this.name = 'LiveModuleError';
56
+ }
57
+ }
58
+
59
+ /** The module-namespace keys that are never a candidate export. */
60
+ function candidateExportNames(namespace: Record<string, unknown>): string[] {
61
+ return Object.keys(namespace).filter(
62
+ (name) => name !== '__esModule' && name !== 'Symbol.toStringTag',
63
+ );
64
+ }
65
+
66
+ function isObject3D(value: unknown): value is THREE.Object3D {
67
+ return (
68
+ typeof value === 'object' &&
69
+ value !== null &&
70
+ (value as { isObject3D?: unknown }).isObject3D === true
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Pick the export this module offers as its subject, matching the bake lane's
76
+ * `exportName` default: the DEFAULT export, or — when there is no default —
77
+ * the single named export. Two named exports and no default is ambiguous and
78
+ * refuses by name rather than guessing; the bake verbs make the caller say
79
+ * which one, and a document has nobody to ask.
80
+ */
81
+ export function pickLiveModuleExport(
82
+ namespace: Record<string, unknown>,
83
+ ): { readonly name: string; readonly value: unknown } | LiveModuleRefusal {
84
+ if ('default' in namespace) return { name: 'default', value: namespace['default'] };
85
+ const named = candidateExportNames(namespace);
86
+ if (named.length === 0) {
87
+ return { kind: 'not-a-model', message: 'This module exports nothing.' };
88
+ }
89
+ if (named.length > 1) {
90
+ return {
91
+ kind: 'not-a-model',
92
+ message:
93
+ `This module has no default export and ${named.length} named exports ` +
94
+ `(${named.join(', ')}), so there is no single subject to build. A model module ` +
95
+ 'default-exports its builder.',
96
+ };
97
+ }
98
+ return { name: named[0]!, value: namespace[named[0]!] };
99
+ }
100
+
101
+ /**
102
+ * THE TWO SHAPES A MODEL MODULE MAY PRODUCE — the same pair the Node twin
103
+ * normalizes (`catalog/project-source/src/tools/module-source.ts`'s
104
+ * `asModuleBuild`), because a module that bakes must open and a module that
105
+ * opens must bake:
106
+ *
107
+ * 1. a bare `THREE.Object3D`;
108
+ * 2. a BUILD RESULT `{ root, animations?, dispose? }` — what every parametric
109
+ * lib in the kit returns (`createBird`'s `BirdBuild` and its descendants),
110
+ * and what `bakeObject3DSource`'s `build` callback has always taken.
111
+ *
112
+ * `animations` is written onto `root.animations`, three's own carrier for a
113
+ * model's clips — so the clip list travels with the graph the document mounts
114
+ * and every reader downstream (the Object3D document session, its animation
115
+ * transport) finds it where it finds a GLTF's.
116
+ */
117
+ function asLiveModuleBuild(
118
+ built: unknown,
119
+ ): { root: THREE.Object3D; dispose?: () => void } | undefined {
120
+ if (isObject3D(built)) return { root: built };
121
+ if (!built || typeof built !== 'object') return undefined;
122
+ const result = built as { root?: unknown; animations?: unknown; dispose?: unknown };
123
+ if (!isObject3D(result.root)) return undefined;
124
+ const root = result.root;
125
+ if (Array.isArray(result.animations) && root.animations.length === 0) {
126
+ root.animations = result.animations as THREE.AnimationClip[];
127
+ }
128
+ return {
129
+ root,
130
+ ...(typeof result.dispose === 'function'
131
+ ? { dispose: () => (result.dispose as () => void).call(built) }
132
+ : {}),
133
+ };
134
+ }
135
+
136
+ /** What the export returned, for a refusal that can be acted on — `object`
137
+ * alone cannot tell a build result with a mistyped key from a wrong value. */
138
+ function describeReturn(built: unknown): string {
139
+ if (built === undefined) return 'undefined';
140
+ if (built === null) return 'null';
141
+ if (typeof built !== 'object') return typeof built;
142
+ if (Array.isArray(built)) return 'an array';
143
+ const keys = Object.keys(built);
144
+ return keys.length > 0
145
+ ? `an object with keys ${keys.slice(0, 8).join(', ')}`
146
+ : 'an object with no own keys';
147
+ }
148
+
149
+ const ACCEPTED_SHAPES =
150
+ 'a model module returns a THREE.Object3D, or a build result ' +
151
+ '`{ root, animations?, dispose? }` whose `root` is one.';
152
+
153
+ /**
154
+ * Resolve a picked export to an `Object3D`. A FUNCTION is called (its result
155
+ * may be a promise — the bake lane awaits it too); a VALUE is taken as-is, so
156
+ * `export default new THREE.Group()` is as legitimate a model module as
157
+ * `export default () => …`.
158
+ */
159
+ export async function resolveLiveModuleExport(
160
+ name: string,
161
+ value: unknown,
162
+ ): Promise<LiveModuleBuild | LiveModuleRefusal> {
163
+ const direct = asLiveModuleBuild(value);
164
+ if (direct) return { ...direct, exportName: name };
165
+ if (typeof value !== 'function') {
166
+ return {
167
+ kind: 'not-a-model',
168
+ message:
169
+ `Export '${name}' is ${describeReturn(value)}, not a function and not a model — ` +
170
+ ACCEPTED_SHAPES,
171
+ };
172
+ }
173
+ let built: unknown;
174
+ try {
175
+ built = await (value as () => unknown)();
176
+ } catch (error) {
177
+ return {
178
+ kind: 'import-failed',
179
+ message: `Export '${name}' threw while building: ${messageOf(error)}`,
180
+ ...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
181
+ };
182
+ }
183
+ const build = asLiveModuleBuild(built);
184
+ if (!build) {
185
+ return {
186
+ kind: 'not-a-model',
187
+ message: `Export '${name}' ran but returned ${describeReturn(built)} — ${ACCEPTED_SHAPES}`,
188
+ };
189
+ }
190
+ return { ...build, exportName: name };
191
+ }
192
+
193
+ function messageOf(error: unknown): string {
194
+ return error instanceof Error ? error.message : String(error);
195
+ }
196
+
197
+ /**
198
+ * Import `modulePath` from the dev server and build its `Object3D`.
199
+ *
200
+ * `revision` is the document's rebuild counter — see
201
+ * {@link liveModuleImportUrl} for why the url carries it and why that is only
202
+ * half the freshness story.
203
+ *
204
+ * Throws {@link LiveModuleError}; the `refusal.kind` is what the caller routes
205
+ * on (`not-a-model` falls back to the source viewer, `import-failed` keeps the
206
+ * last good root and shows the failure).
207
+ */
208
+ export async function buildLiveModuleObject3D(
209
+ projectRoot: string,
210
+ modulePath: string,
211
+ revision: number,
212
+ ): Promise<LiveModuleBuild> {
213
+ let namespace: Record<string, unknown>;
214
+ try {
215
+ namespace = (await import(
216
+ /* @vite-ignore */ liveModuleImportUrl(projectRoot, modulePath, revision)
217
+ )) as Record<string, unknown>;
218
+ } catch (error) {
219
+ throw new LiveModuleError({
220
+ kind: 'import-failed',
221
+ message: messageOf(error),
222
+ ...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
223
+ });
224
+ }
225
+ const picked = pickLiveModuleExport(namespace);
226
+ if ('kind' in picked) throw new LiveModuleError(picked);
227
+ const resolved = await resolveLiveModuleExport(picked.name, picked.value);
228
+ if ('kind' in resolved) throw new LiveModuleError(resolved);
229
+ return resolved;
230
+ }