@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.
- package/NOTICE +2 -0
- package/contributions/animation-mixers.service.ts +20 -0
- package/contributions/animation-timeline.utility.tsx +44 -0
- package/contributions/three-integration.service.ts +13 -0
- package/dist-node/serving.mjs +405 -0
- package/package.json +113 -5
- package/serving/animation-live-module.ts +70 -0
- package/serving/animation-stamp.ts +88 -0
- package/serving/index.ts +14 -0
- package/serving/model-import-conversion.ts +344 -0
- package/src/adapter/ingest/scene-capture.ts +1 -23
- package/src/adapter/renderer-config.ts +3 -4
- package/src/adapter/three-contract.ts +72 -0
- package/src/animation/live-mixers.ts +55 -0
- package/src/ecs/object-marks.ts +1 -1
- package/src/ecs/user-data.ts +0 -16
- package/src/host-hierarchy-objects.ts +31 -0
- package/src/kit/animation/three-clips-subject.ts +190 -0
- package/src/kit/asset-compare.ts +294 -0
- package/src/kit/asset-preview-command.ts +265 -0
- package/src/kit/asset-preview-framing.ts +357 -0
- package/src/kit/asset-preview.ts +2802 -0
- package/src/kit/asset-workflow/model-inspection.ts +830 -0
- package/src/kit/authoring/component-instance-root.ts +171 -0
- package/src/kit/authoring/design-time-settle.ts +343 -0
- package/src/kit/authoring/live-object-transform.ts +62 -0
- package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
- package/src/kit/authoring/object3d-document-session.ts +1965 -0
- package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
- package/src/kit/authoring/quarks-particle-systems.ts +19 -0
- package/src/kit/authoring/shell-viewport-policy.ts +48 -0
- package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
- package/src/kit/authoring/three-projection-core.ts +226 -0
- package/src/kit/authoring/viewport-pick-context.ts +39 -0
- package/src/kit/authoring/viewport-raycast.ts +240 -0
- package/src/kit/authoring/world-hidden-viewport.ts +95 -0
- package/src/kit/camera-authoring.ts +175 -0
- package/src/kit/components/CameraInfo.tsx +56 -0
- package/src/kit/components/InspectorObjectPreview.tsx +57 -0
- package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
- package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
- package/src/kit/components/StageHost.tsx +2547 -0
- package/src/kit/components/StageOverlays.tsx +21 -0
- package/src/kit/components/StatsOverlay.tsx +78 -0
- package/src/kit/components/ToolObject3DPreview.tsx +39 -0
- package/src/kit/components/ViewportFurniture.tsx +655 -0
- package/src/kit/components/ViewportOverlay.tsx +215 -0
- package/src/kit/components/ViewportShadingMenu.tsx +340 -0
- package/src/kit/components/ViewportViewMenu.tsx +155 -0
- package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
- package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
- package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
- package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
- package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
- package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
- package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
- package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
- package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
- package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
- package/src/kit/components/stage-keyboard.tsx +40 -0
- package/src/kit/components/stage-overlay-set.tsx +105 -0
- package/src/kit/components/stage-presence-markers.ts +482 -0
- package/src/kit/components/stage-transform-chrome.ts +30 -0
- package/src/kit/components/stage-transform-tools.tsx +73 -0
- package/src/kit/components/stage-view-name.ts +30 -0
- package/src/kit/components/standard-viewport-dressing.ts +1042 -0
- package/src/kit/components/world-root-binding.ts +64 -0
- package/src/kit/constraint-helper.ts +338 -0
- package/src/kit/editor-shell-store.ts +814 -0
- package/src/kit/editor-viewport.ts +6621 -0
- package/src/kit/entity-lod.ts +31 -0
- package/src/kit/entity-object.ts +92 -0
- package/src/kit/hierarchy-mark-reader.ts +74 -0
- package/src/kit/instanced-presentation.ts +164 -0
- package/src/kit/live-module-source.ts +230 -0
- package/src/kit/model-thumbnail.ts +539 -0
- package/src/kit/play-camera-flight.ts +300 -0
- package/src/kit/projection/three.ts +898 -0
- package/src/kit/reflection-probe-helper.ts +142 -0
- package/src/kit/scene-document-viewport.ts +51 -0
- package/src/kit/scene-framing.ts +315 -0
- package/src/kit/scene-view-fog.ts +89 -0
- package/src/kit/spatial-handle-visuals.ts +332 -0
- package/src/kit/stories/three-story-model.ts +66 -0
- package/src/kit/three-canvas-render.ts +44 -0
- package/src/kit/three-hierarchy-row-media.ts +26 -0
- package/src/kit/three-inspection-media.ts +73 -0
- package/src/kit/three-integration.ts +86 -0
- package/src/kit/three-state.ts +33 -0
- package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
- package/src/kit/three-viewport/camera-fit.ts +41 -0
- package/src/kit/three-viewport/interactive-renderer.ts +132 -0
- package/src/kit/three-viewport/selection-brackets.ts +355 -0
- package/src/kit/three-viewport/selection-outline.ts +333 -0
- package/src/kit/three-viewport/skeleton-helper.ts +61 -0
- package/src/kit/three-viewport/source-color.ts +197 -0
- package/src/kit/three-viewport/studio-environment.ts +96 -0
- package/src/kit/trigger-volume-helper.ts +116 -0
- package/src/kit/viewport-actions.ts +128 -0
- package/src/kit/viewport-authoring-policy.ts +154 -0
- package/src/kit/viewport-commands.ts +318 -0
- package/src/kit/viewport-hotkeys.ts +119 -0
- package/src/kit/viewport-shading-boundary.ts +12 -0
- package/src/kit/viewport-status-facet.ts +53 -0
- package/src/object3d-contributions.ts +494 -0
- package/src/render/viewport-shading.ts +6 -2
- package/src/viewport/content-bounds.ts +38 -4
- package/src/viewport/environment.ts +16 -0
- package/src/viewport-api.ts +92 -0
- package/src/viewport-door.ts +237 -0
- package/src/animation/animation-clock.ts +0 -479
- 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
|
+
}
|