@volter/editor-threejs 0.5.66 → 0.5.68
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/contributions/animation-timeline.utility.tsx +44 -0
- package/contributions/three-integration.service.ts +13 -0
- package/package.json +96 -6
- package/src/adapter/renderer-config.ts +3 -4
- package/src/adapter/three-contract.ts +72 -0
- package/src/ecs/object-marks.ts +1 -1
- package/src/ecs/user-data.ts +0 -9
- 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-api.ts +92 -0
- package/src/viewport-door.ts +237 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SHARED LIVE-`Object3D` AUTHORING PROJECTION — the derived views every
|
|
3
|
+
* three-surface configuration reads instead of spelling for itself.
|
|
4
|
+
*
|
|
5
|
+
* The layer below is `../projection/three.ts`: it owns the walk, identity
|
|
6
|
+
* minting, the two-way index and picking. This module owns what sits ON that
|
|
7
|
+
* index and used to be duplicated across the configurations — the
|
|
8
|
+
* transparent-wrapper collapse view (`CollapsedHierarchyView`), the transform
|
|
9
|
+
* READ ({@link readLocalTransform}) and the store-selection adoption
|
|
10
|
+
* ({@link StoreSelectionAdoption}).
|
|
11
|
+
*
|
|
12
|
+
* PICKING and BOUNDS are not here because they were never duplicated:
|
|
13
|
+
* `ThreeProjector.pick`/`candidates` is already the one raycast, and
|
|
14
|
+
* `@volter/editor-threejs/viewport/content-bounds` is the bounds walk. Duplicating either would
|
|
15
|
+
* be a hop, not an extraction.
|
|
16
|
+
*
|
|
17
|
+
* The configurations are `three-authoring-adapter.ts` (play adoption and
|
|
18
|
+
* every ingest mount), `r3f-source-authoring-adapter.ts` (edit write-back) and
|
|
19
|
+
* `source-object3d-authoring-adapter.ts` (a native graph opened as a document).
|
|
20
|
+
* They differ by IDENTITY PROVIDER and WRITE TARGET, never by where their graph
|
|
21
|
+
* came from; `three-projection-inventory.test.ts` is the census that keeps it
|
|
22
|
+
* that way.
|
|
23
|
+
*
|
|
24
|
+
* What is deliberately NOT here: the R3F lane's COMPONENT-INSTANCE collapse
|
|
25
|
+
* (`isComponentBoundary` and the owner chain). That rule answers a different
|
|
26
|
+
* question — which callsite rendered this — and merging it with the rule below
|
|
27
|
+
* would be inventing a third, not extracting a shared one.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { EditorNode, Transform } from '@volter/editor-project/adapter';
|
|
31
|
+
import { isEditorOwnedObject } from '@volter/editor-threejs/viewport/editor-layers';
|
|
32
|
+
import { object3DAuthoringSubjectOf } from '@volter/editor-threejs/adapter/object3d-authoring-subject';
|
|
33
|
+
import { getUserData } from '@volter/editor-threejs/ecs/user-data';
|
|
34
|
+
import type * as THREE from 'three';
|
|
35
|
+
import type { ShellStore } from '@volter/editor-sdk/kit/shell-store';
|
|
36
|
+
import { nativeKindOf, type ThreeProjector } from '../projection/three';
|
|
37
|
+
import { localTransformOf } from './live-object-transform';
|
|
38
|
+
|
|
39
|
+
export interface StoreSelectionAdoptionOptions {
|
|
40
|
+
readonly store: ShellStore;
|
|
41
|
+
/** Which ids this projection owns. The store is shared, so it can hold
|
|
42
|
+
* another surface's ids and a synthetic row this projection invented. */
|
|
43
|
+
readonly owns: (id: string) => boolean;
|
|
44
|
+
/** The selection before anything is picked — usually the document row. */
|
|
45
|
+
readonly initial: readonly string[];
|
|
46
|
+
/** Runs with the OWNED ids whenever the adopted set changes, in either
|
|
47
|
+
* direction. The Object3D-document configuration drives its nodes'
|
|
48
|
+
* `selectionChanged` marks from here. */
|
|
49
|
+
readonly onChange?: (ids: readonly string[]) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sameIds(a: readonly string[], b: readonly string[]): boolean {
|
|
53
|
+
return a.length === b.length && a.every((id, index) => id === b[index]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* SELECTION, shared between the store and a configuration's own answer.
|
|
58
|
+
*
|
|
59
|
+
* `EditorViewport`'s additive/toggle paths mutate `ShellStore` directly
|
|
60
|
+
* rather than going through the adapter, so a `SelectionProvider` that only
|
|
61
|
+
* remembered what it was last told drifted from what the viewport had drawn.
|
|
62
|
+
* Reading the store on every `get` is what keeps the hierarchy, the Inspector
|
|
63
|
+
* and the viewport agreeing; comparing against the last adopted ids is what
|
|
64
|
+
* stops that read from re-notifying on every render.
|
|
65
|
+
*/
|
|
66
|
+
export class StoreSelectionAdoption {
|
|
67
|
+
/** The store ids last adopted or published — the comparison basis. */
|
|
68
|
+
private adopted: readonly string[] = [];
|
|
69
|
+
private selected: string[];
|
|
70
|
+
|
|
71
|
+
constructor(private readonly options: StoreSelectionAdoptionOptions) {
|
|
72
|
+
this.selected = [...options.initial];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** This projection's selection, having adopted whatever the viewport wrote. */
|
|
76
|
+
current(): string[] {
|
|
77
|
+
const storeIds = [...this.options.store.selectedEntityIds].filter(this.options.owns);
|
|
78
|
+
if (!sameIds(storeIds, this.adopted)) {
|
|
79
|
+
this.adopted = storeIds;
|
|
80
|
+
this.selected = storeIds;
|
|
81
|
+
this.options.onChange?.(storeIds);
|
|
82
|
+
}
|
|
83
|
+
return [...this.selected];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Publish a selection the configuration has already resolved — rows it owns
|
|
88
|
+
* plus, possibly, a synthetic row of its own. Only the owned ids reach the
|
|
89
|
+
* store, because that is the index the viewport draws from.
|
|
90
|
+
*/
|
|
91
|
+
publish(selected: readonly string[]): void {
|
|
92
|
+
this.selected = [...selected];
|
|
93
|
+
const storeIds = this.selected.filter(this.options.owns);
|
|
94
|
+
this.adopted = storeIds;
|
|
95
|
+
this.options.onChange?.(storeIds);
|
|
96
|
+
this.options.store.selectMultiple(storeIds);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A `TransformProvider.get` over a live `Object3D` — the read every three
|
|
102
|
+
* configuration answers with.
|
|
103
|
+
*
|
|
104
|
+
* `null` is the honest identity for an id this projection does not own (the
|
|
105
|
+
* document row, a synthetic row, a node the last walk dropped): the provider's
|
|
106
|
+
* contract has no absent answer, and the caller has already been told through
|
|
107
|
+
* `editability`/`dimensions` that there is nothing there.
|
|
108
|
+
*
|
|
109
|
+
* The read itself is `live-object-transform.ts`'s rule — a node whose driver
|
|
110
|
+
* owns its matrix keeps stale `.position`/`.quaternion`/`.scale` forever, so
|
|
111
|
+
* the matrix is the transform exactly when the node maintains it. That answer
|
|
112
|
+
* feeds the Inspector, the selection overlay's anchor and the origin marker,
|
|
113
|
+
* which is why all three must give it.
|
|
114
|
+
*/
|
|
115
|
+
export function readLocalTransform(object: THREE.Object3D | null): Transform {
|
|
116
|
+
if (!object) return { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] };
|
|
117
|
+
const live = localTransformOf(object);
|
|
118
|
+
return { position: live.position, rotation: live.quaternion, scale: live.scale };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* THE COLLAPSE RULE. A live graph contains connective nodes a library inserted
|
|
123
|
+
* rather than the project authoring them — Rapier's `<RigidBody>` puts an
|
|
124
|
+
* anonymous generic `Object3D` between the prefab and its named mesh. Showing
|
|
125
|
+
* it starts the hierarchy with an uneditable "Object3D" row and asks the author
|
|
126
|
+
* to understand library implementation structure.
|
|
127
|
+
*
|
|
128
|
+
* This is an OWNERSHIP rule, not a package/name exception: a source-stamped
|
|
129
|
+
* node, component root, explicit authoring root or native authoring proxy is
|
|
130
|
+
* semantic and stays visible. Only an unnamed, unclaimed generic container is
|
|
131
|
+
* transparent, and its meaningful descendants are promoted in place.
|
|
132
|
+
*/
|
|
133
|
+
export function isTransparentWrapper(object: THREE.Object3D): boolean {
|
|
134
|
+
return (
|
|
135
|
+
object.type === 'Object3D' &&
|
|
136
|
+
object.name === '' &&
|
|
137
|
+
object3DAuthoringSubjectOf(object) === null &&
|
|
138
|
+
object.userData['oid'] === undefined &&
|
|
139
|
+
getUserData(object, 'authoringInstance') === undefined &&
|
|
140
|
+
getUserData(object, 'authoringRoot') !== true &&
|
|
141
|
+
getUserData(object, 'vgaiComponentRoot') === undefined
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface CollapsedHierarchyOptions {
|
|
146
|
+
/** The index this view derives from — never a second walk. */
|
|
147
|
+
readonly projector: ThreeProjector;
|
|
148
|
+
/** Climbing stops here: a graph's own scene is not a row. */
|
|
149
|
+
readonly scene: THREE.Scene;
|
|
150
|
+
/** The row a projection root (and any climb that escapes the graph) answers
|
|
151
|
+
* to. It exists in the VIEW only, so the projector answers `null` for it. */
|
|
152
|
+
readonly documentNodeId: string;
|
|
153
|
+
/** This view's declared roots, as ids of {@link projector}'s index. */
|
|
154
|
+
readonly rootIds: readonly string[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The hierarchy view a configuration hands its `HierarchyProvider`: the
|
|
159
|
+
* projection's own parent/child structure with {@link isTransparentWrapper}
|
|
160
|
+
* nodes folded out of it.
|
|
161
|
+
*
|
|
162
|
+
* Every method answers from the projector's live index, so a configuration that
|
|
163
|
+
* re-walks (a running world spawns objects) sees the new graph without this
|
|
164
|
+
* view holding anything stale.
|
|
165
|
+
*/
|
|
166
|
+
export class CollapsedHierarchyView {
|
|
167
|
+
private readonly rootIdSet: ReadonlySet<string>;
|
|
168
|
+
|
|
169
|
+
constructor(private readonly options: CollapsedHierarchyOptions) {
|
|
170
|
+
this.rootIdSet = new Set(options.rootIds);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `object`'s visible children — the live objects themselves, so a caller
|
|
174
|
+
* that needs the object (not its row) does not go back through the index. */
|
|
175
|
+
childObjects(object: THREE.Object3D): THREE.Object3D[] {
|
|
176
|
+
return object.children.flatMap((child) => {
|
|
177
|
+
if (isEditorOwnedObject(child)) return [];
|
|
178
|
+
return isTransparentWrapper(child) ? this.childObjects(child) : [child];
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
childIds(object: THREE.Object3D): string[] {
|
|
183
|
+
return this.options.projector.childIdsOf(this.childObjects(object));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The declared roots with transparent ones replaced by what they contain. */
|
|
187
|
+
rootObjects(): THREE.Object3D[] {
|
|
188
|
+
return this.options.rootIds.flatMap((id) => {
|
|
189
|
+
const object = this.options.projector.objectOf(id);
|
|
190
|
+
if (!object) return [];
|
|
191
|
+
return isTransparentWrapper(object) ? this.childObjects(object) : [object];
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
rootIds(): string[] {
|
|
196
|
+
return this.options.projector.childIdsOf(this.rootObjects());
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The row that owns `object`: the nearest ancestor this view shows, or the
|
|
200
|
+
* document row when the climb reaches a declared root or the scene. */
|
|
201
|
+
parentId(object: THREE.Object3D): string {
|
|
202
|
+
const { projector, scene, documentNodeId } = this.options;
|
|
203
|
+
const id = projector.idOf(object);
|
|
204
|
+
if (id !== null && this.rootIdSet.has(id)) return documentNodeId;
|
|
205
|
+
let parent = object.parent;
|
|
206
|
+
while (parent && parent !== scene && isTransparentWrapper(parent)) {
|
|
207
|
+
parent = parent.parent;
|
|
208
|
+
}
|
|
209
|
+
return parent && parent !== scene ? (projector.idOf(parent) ?? documentNodeId) : documentNodeId;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** `object`'s row. An object is named and typed by what it IS — its own
|
|
213
|
+
* authoring subject, name, or native type — never by an owner above it. */
|
|
214
|
+
node(object: THREE.Object3D): EditorNode {
|
|
215
|
+
const subject = object3DAuthoringSubjectOf(object);
|
|
216
|
+
return {
|
|
217
|
+
id: this.options.projector.idOf(object)!,
|
|
218
|
+
label: subject?.label || object.name || object.type || 'Object3D',
|
|
219
|
+
role: 'entity',
|
|
220
|
+
kind: nativeKindOf(object),
|
|
221
|
+
...(subject?.typeLabel ? { typeLabel: subject.typeLabel } : {}),
|
|
222
|
+
parentId: this.parentId(object),
|
|
223
|
+
childIds: this.childIds(object),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewport pick context (B4, D12) — the camera/canvas seam that
|
|
3
|
+
* `VgaiSceneAuthoringAdapter.pickable.pick` needs but cannot own itself: both
|
|
4
|
+
* live on `EditorViewport`, not on the adapter (the adapter only ever held a
|
|
5
|
+
* `store` — see its own doc comment). A tiny module-level slot, same shape as
|
|
6
|
+
* `./active-systems.ts`: `EditorViewport`'s constructor sets it once its
|
|
7
|
+
* camera/canvas exist; its `dispose()` clears it.
|
|
8
|
+
*
|
|
9
|
+
* Absent (module load, a headless unit test that builds the adapter with no
|
|
10
|
+
* live viewport, or after `dispose()`) ⇒ `pick()` returns `null` — an honest
|
|
11
|
+
* degrade, never a throw.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { setViewportEditorControls, type ViewportEditorControls } from '@volter/editor-sdk/kit/viewport-editor-controls';
|
|
15
|
+
import type * as THREE from 'three';
|
|
16
|
+
|
|
17
|
+
export interface ViewportPickContext {
|
|
18
|
+
readonly camera: THREE.Camera;
|
|
19
|
+
readonly canvas: HTMLCanvasElement;
|
|
20
|
+
/** Editor-owned direct-manipulation projections (currently constraint
|
|
21
|
+
* target/pole effectors). The full-cover selection overlay owns pointer
|
|
22
|
+
* events in the project viewport, so it forwards them through this narrow
|
|
23
|
+
* seam instead of duplicating Three raycasting or constraint semantics. */
|
|
24
|
+
readonly editorControls?: ViewportEditorControls;
|
|
25
|
+
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let _ctx: ViewportPickContext | null = null;
|
|
29
|
+
|
|
30
|
+
/** Install/clear the live viewport's pick context (`EditorViewport` ctor/dispose). */
|
|
31
|
+
export function setViewportPickContext(ctx: ViewportPickContext | null): void {
|
|
32
|
+
_ctx = ctx;
|
|
33
|
+
setViewportEditorControls(ctx?.editorControls ?? null);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The live viewport's camera/canvas, or `null` when none is mounted. */
|
|
37
|
+
export function getViewportPickContext(): ViewportPickContext | null {
|
|
38
|
+
return _ctx;
|
|
39
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared three viewport raycast pick (B4, D12) — the adapter-AGNOSTIC
|
|
3
|
+
* entity-under-the-pointer raycast, extracted verbatim from
|
|
4
|
+
* `editor-viewport.ts`'s former `_raycastEntity` so EVERY threejs-backed
|
|
5
|
+
* authoring adapter whose live objects sit in `store.objectMap` reuses the
|
|
6
|
+
* exact same logic:
|
|
7
|
+
* - first-party `VgaiSceneAuthoringAdapter` (edit mode's focused world),
|
|
8
|
+
* - `ThreeAuthoringAdapter` (any live three tree — bare ingest
|
|
9
|
+
* mode AND the three child of a multi-world PLAY composite).
|
|
10
|
+
*
|
|
11
|
+
* Pre-B4, `_onPointerUp` ran this raycast UNCONDITIONALLY against
|
|
12
|
+
* `store.objectMap` regardless of which adapter was active, so an ingest/
|
|
13
|
+
* multi-world-play canvas click selected the object under the pointer. B4's
|
|
14
|
+
* `pickTopmost` routes through the active adapter's `pickable` instead, so
|
|
15
|
+
* that behavior only survives if each three adapter exposes a `pickable`
|
|
16
|
+
* backed by THIS helper (the raycast depends
|
|
17
|
+
* only on native Object3Ds plus the active adapter's object→id answer. The
|
|
18
|
+
* default still reads `store.objectMap` and entity stamps for first-party
|
|
19
|
+
* callers; foreign adapters inject their projection without mutating it.
|
|
20
|
+
*
|
|
21
|
+
* The camera/canvas live only on `EditorViewport`, reached through the
|
|
22
|
+
* `viewport-pick-context.ts` seam (its ctor/`dispose()` set/clear it). Absent
|
|
23
|
+
* (a headless unit test, or no live viewport mounted) ⇒ `null`, an honest
|
|
24
|
+
* degrade rather than a throw. Uses its OWN `THREE.Raycaster` with
|
|
25
|
+
* `layers.enableAll()` replicated so it hits gizmo-adjacent EDITOR_LAYER-tagged
|
|
26
|
+
* geometry exactly like the original.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { isInEditorOwnedSubtree } from '@volter/editor-threejs/viewport/editor-layers';
|
|
30
|
+
import * as THREE from 'three';
|
|
31
|
+
import type { EditorShellStore } from '../editor-shell-store';
|
|
32
|
+
import { entityIdOf, nearestEntityObject } from '../entity-object';
|
|
33
|
+
import { getViewportPickContext } from './viewport-pick-context';
|
|
34
|
+
|
|
35
|
+
function isBackdropMaterial(material: THREE.Material): boolean {
|
|
36
|
+
return material.depthWrite === false && material.side === THREE.BackSide;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Is this object the scene's BACKDROP rather than a thing in it?
|
|
41
|
+
*
|
|
42
|
+
* A sky dome / environment shell is painted behind everything
|
|
43
|
+
* (`depthWrite: false`) and is only visible from INSIDE (`side: BackSide`).
|
|
44
|
+
* Both flags are authored by the game itself, so this is the idiom read as
|
|
45
|
+
* DATA — no name list, no per-project rule.
|
|
46
|
+
*
|
|
47
|
+
* Why picking must skip it: such a mesh ENCLOSES the camera, so every ray
|
|
48
|
+
* eventually reaches it and the pick can never miss. Measured live
|
|
49
|
+
* (2026-08-06, `examples/third-person`): all 64 points of an 8×8 grid over the
|
|
50
|
+
* canvas returned a hit, the top-left "empty sky" among them, which is why
|
|
51
|
+
* clicking empty space never cleared the selection — `_onPointerUp`'s
|
|
52
|
+
* `selection.set([])` branch was simply unreachable in any scene with a sky.
|
|
53
|
+
* A ray that reaches the backdrop has passed through every real thing in the
|
|
54
|
+
* scene; that is a MISS, and the surface's no-selection subject is the honest
|
|
55
|
+
* answer. The backdrop stays selectable from the hierarchy, where it is a row
|
|
56
|
+
* like any other.
|
|
57
|
+
*/
|
|
58
|
+
export function isBackdropObject(object: THREE.Object3D): boolean {
|
|
59
|
+
const material = (object as Partial<THREE.Mesh>).material;
|
|
60
|
+
if (!material) return false;
|
|
61
|
+
return Array.isArray(material)
|
|
62
|
+
? material.length > 0 && material.every(isBackdropMaterial)
|
|
63
|
+
: isBackdropMaterial(material);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Raycast `store.objectMap` and return the entity id under `(clientX,
|
|
67
|
+
* clientY)`, or `null`. Skips editor-helper geometry, and skips ids `isLocked`
|
|
68
|
+
* rejects — identical to the pre-B4 viewport raycast.
|
|
69
|
+
*
|
|
70
|
+
* `isLocked` is INJECTED rather than read off the store because "locked" is a
|
|
71
|
+
* per-format notion: an adapter with a lock concept supplies its own check
|
|
72
|
+
* (own flag OR any ancestor's — richer than what a generic
|
|
73
|
+
* `inspector.get(id,'locked')` reproduces), while every live/foreign adapter
|
|
74
|
+
* has no lock concept and omits it. */
|
|
75
|
+
export function raycastCandidates(
|
|
76
|
+
store: EditorShellStore,
|
|
77
|
+
clientX: number,
|
|
78
|
+
clientY: number,
|
|
79
|
+
isLocked: (id: string) => boolean = () => false,
|
|
80
|
+
projection?: {
|
|
81
|
+
readonly objects: Iterable<THREE.Object3D>;
|
|
82
|
+
readonly idForObject3D: (object: THREE.Object3D) => string | null;
|
|
83
|
+
},
|
|
84
|
+
): string[] {
|
|
85
|
+
const ctx = getViewportPickContext();
|
|
86
|
+
if (!ctx) return []; // no live viewport mounted — honest degrade
|
|
87
|
+
const rect = ctx.canvas.getBoundingClientRect();
|
|
88
|
+
// Chrome and other panes are not part of this viewport. Coverage also asks
|
|
89
|
+
// about (0,0); previously that off-canvas query still skinned/raycast the
|
|
90
|
+
// entire world twice after an interaction.
|
|
91
|
+
if (
|
|
92
|
+
rect.width <= 0 ||
|
|
93
|
+
rect.height <= 0 ||
|
|
94
|
+
!Number.isFinite(clientX) ||
|
|
95
|
+
!Number.isFinite(clientY) ||
|
|
96
|
+
clientX < rect.left ||
|
|
97
|
+
clientY < rect.top ||
|
|
98
|
+
clientX >= rect.left + rect.width ||
|
|
99
|
+
clientY >= rect.top + rect.height
|
|
100
|
+
)
|
|
101
|
+
return [];
|
|
102
|
+
const mouse = new THREE.Vector2(
|
|
103
|
+
((clientX - rect.left) / rect.width) * 2 - 1,
|
|
104
|
+
-((clientY - rect.top) / rect.height) * 2 + 1,
|
|
105
|
+
);
|
|
106
|
+
const raycaster = new THREE.Raycaster();
|
|
107
|
+
raycaster.layers.enableAll();
|
|
108
|
+
raycaster.setFromCamera(mouse, ctx.camera);
|
|
109
|
+
|
|
110
|
+
const indexed = new Set(projection?.objects ?? store.objectMap.values());
|
|
111
|
+
// intersectObjects(..., true) already visits descendants. Passing both a
|
|
112
|
+
// parent and each indexed child repeatedly intersects the same geometry.
|
|
113
|
+
const entityObjects = [...indexed].filter((object) => {
|
|
114
|
+
for (let parent = object.parent; parent; parent = parent.parent) {
|
|
115
|
+
if (indexed.has(parent)) return false;
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// A SkinnedMesh with CACHED mesh-level bounds is a poisoned pick target:
|
|
121
|
+
// `SkinnedMesh.raycast` gates on `this.boundingBox`/`boundingSphere`, and a
|
|
122
|
+
// cache computed through the CPU skinning path carries bone-world
|
|
123
|
+
// contamination on the humanoid rigs — the gate then misses the localized
|
|
124
|
+
// ray forever and every click falls through the character (measured live:
|
|
125
|
+
// 0 triangle hits with the cache present, 8 with it cleared, same ray, same
|
|
126
|
+
// mesh). Clearing here immunizes the pick against every planter; the
|
|
127
|
+
// raycast recomputes what it needs, and the framing walk no longer consumes
|
|
128
|
+
// mesh-level skinned bounds at all (`content-bounds.ts`).
|
|
129
|
+
for (const root of entityObjects) {
|
|
130
|
+
root.traverse((node) => {
|
|
131
|
+
// Structural write: three's own `SkinnedMesh` fields hold `Box3 | null`
|
|
132
|
+
// at runtime (`null` = "compute on demand"), the published type just
|
|
133
|
+
// does not admit the null it initializes with.
|
|
134
|
+
const skinned = node as unknown as {
|
|
135
|
+
isSkinnedMesh?: boolean;
|
|
136
|
+
boundingBox: unknown;
|
|
137
|
+
boundingSphere: unknown;
|
|
138
|
+
};
|
|
139
|
+
if (skinned.isSkinnedMesh) {
|
|
140
|
+
skinned.boundingBox = null;
|
|
141
|
+
skinned.boundingSphere = null;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const intersects = raycaster.intersectObjects(entityObjects, true);
|
|
147
|
+
const hits: string[] = [];
|
|
148
|
+
const seen = new Set<string>();
|
|
149
|
+
for (const isect of intersects) {
|
|
150
|
+
// Skip every form of editor-owned geometry. TransformControls marks its
|
|
151
|
+
// invisible interaction plane only with EDITOR_LAYER; other helpers use
|
|
152
|
+
// userData flags. Checking the shared predicate prevents either form from
|
|
153
|
+
// becoming the selected "entity" while still allowing the ray to fall
|
|
154
|
+
// through to game content behind it.
|
|
155
|
+
if (isInEditorOwnedSubtree(isect.object)) continue;
|
|
156
|
+
|
|
157
|
+
// The scene's backdrop is not a thing under the pointer — see
|
|
158
|
+
// {@link isBackdropObject}. Skipping it is what lets a click on empty sky
|
|
159
|
+
// be a MISS, which is what clears the selection.
|
|
160
|
+
if (isBackdropObject(isect.object)) continue;
|
|
161
|
+
|
|
162
|
+
// Walk up to find the projected authoring node. A foreign adapter answers
|
|
163
|
+
// from its own reverse map; the default keeps the established stamp-based
|
|
164
|
+
// first-party behavior.
|
|
165
|
+
let cursor: THREE.Object3D | null = isect.object;
|
|
166
|
+
let eid: string | null = null;
|
|
167
|
+
if (projection) {
|
|
168
|
+
while (cursor && eid === null) {
|
|
169
|
+
eid = projection.idForObject3D(cursor);
|
|
170
|
+
cursor = cursor.parent;
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
const hit = nearestEntityObject(cursor);
|
|
174
|
+
eid = hit ? (entityIdOf(hit) ?? null) : null;
|
|
175
|
+
}
|
|
176
|
+
// Skip locked entities in viewport selection.
|
|
177
|
+
if (eid !== null && !isLocked(eid) && !seen.has(eid)) {
|
|
178
|
+
seen.add(eid);
|
|
179
|
+
hits.push(eid);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
reportEmptyPick(hits, entityObjects, intersects.length, clientX, clientY);
|
|
183
|
+
return hits;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A CLICK THAT SELECTS NOTHING SAYS WHY — and only then.
|
|
188
|
+
*
|
|
189
|
+
* Repeated instances of one component are reported unselectable in the
|
|
190
|
+
* viewport while the HIERARCHY addresses every one of them fine (runhuman
|
|
191
|
+
* passes 100/109/110/111: "in order to move the one previous, I need to click
|
|
192
|
+
* it from the hierarchy"). Identity is not the cause — a reproduction over
|
|
193
|
+
* `sourceOidIdentity` mints distinct ids AND distinct stamps for four renders
|
|
194
|
+
* of one definition element — so the failure is somewhere in this walk, and
|
|
195
|
+
* the three numbers below separate its three possible causes:
|
|
196
|
+
*
|
|
197
|
+
* - `candidates: 0` — the object never reached `store.objectMap`;
|
|
198
|
+
* - `candidates: N, rayHits: 0` — it is in the map but the ray missed it
|
|
199
|
+
* (stale transform, detached subtree, wrong scene);
|
|
200
|
+
* - `rayHits: N, ids: 0` — it was hit but every hit was filtered as editor
|
|
201
|
+
* furniture/backdrop, or carried no stamp to resolve.
|
|
202
|
+
*
|
|
203
|
+
* Silent on every click that DOES select something, so an ordinary session
|
|
204
|
+
* never sees it.
|
|
205
|
+
*/
|
|
206
|
+
function reportEmptyPick(
|
|
207
|
+
hits: readonly string[],
|
|
208
|
+
candidates: readonly THREE.Object3D[],
|
|
209
|
+
rayHits: number,
|
|
210
|
+
clientX: number,
|
|
211
|
+
clientY: number,
|
|
212
|
+
): void {
|
|
213
|
+
if (hits.length > 0) return;
|
|
214
|
+
// The coverage CONFORMANCE PROBE exercises `editor.pickable.pick(0, 0)` on
|
|
215
|
+
// a timer to audit the seam's shape (`coverage/authoring-seam-evidence`,
|
|
216
|
+
// source: "conformance-probe") — twice every ~5s in a live session,
|
|
217
|
+
// measured by stack capture. (0,0) is over the app chrome, never a real
|
|
218
|
+
// viewport click, so the probe's synthetic misses must not narrate as user
|
|
219
|
+
// clicks that selected nothing.
|
|
220
|
+
if (clientX === 0 && clientY === 0) return;
|
|
221
|
+
// biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb, same channel as the editor's other timing/stall notes
|
|
222
|
+
console.info(
|
|
223
|
+
`[viewport-pick] click selected nothing (candidates ${candidates.length}, ` +
|
|
224
|
+
`rayHits ${rayHits}, resolvedIds 0)`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Frontmost authorable subject under the pointer. */
|
|
229
|
+
export function raycastPick(
|
|
230
|
+
store: EditorShellStore,
|
|
231
|
+
clientX: number,
|
|
232
|
+
clientY: number,
|
|
233
|
+
isLocked: (id: string) => boolean = () => false,
|
|
234
|
+
projection?: {
|
|
235
|
+
readonly objects: Iterable<THREE.Object3D>;
|
|
236
|
+
readonly idForObject3D: (object: THREE.Object3D) => string | null;
|
|
237
|
+
},
|
|
238
|
+
): string | null {
|
|
239
|
+
return raycastCandidates(store, clientX, clientY, isLocked, projection)[0] ?? null;
|
|
240
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* World-hidden eye (D9, `world-session-state.ts`) applied to the EDIT-mode
|
|
3
|
+
* three viewport.
|
|
4
|
+
*
|
|
5
|
+
* Bug this fixes: the eye on a hierarchy world-group row (`world:<id>
|
|
6
|
+
* (<kind>)`, shown for 2+ world manifests) toggles `isRootHidden(worldId)`
|
|
7
|
+
* and culls the hierarchy subtree, but for a react/pixi world that's ALSO the
|
|
8
|
+
* only thing that mattered — `design-time-layers.ts`'s `applySessionStyle`
|
|
9
|
+
* sets `display:none` on that world's DOM layer. A three world has no DOM
|
|
10
|
+
* layer; nothing else consumes `isRootHidden` for its actual
|
|
11
|
+
* `THREE.Object3D`s, so without this module the eye is a viewport no-op.
|
|
12
|
+
*
|
|
13
|
+
* Session-local only — it writes no file and pushes no undo entry, matching
|
|
14
|
+
* the DOM roots' own `display:none` semantics. Every call RECOMPUTES
|
|
15
|
+
* `obj.visible` rather than remembering a previous value, which is what makes
|
|
16
|
+
* it rebuild-proof: an adapter that re-renders its tree re-stamps
|
|
17
|
+
* `obj.visible` from its own truth and would silently drop a one-shot
|
|
18
|
+
* override, but the very next call here puts the world-hidden override back.
|
|
19
|
+
* Callers must re-invoke this after every rebuild
|
|
20
|
+
* AND after the toggle itself; `editor-viewport.ts`'s `syncFromStore()` is
|
|
21
|
+
* exactly that seam — it already re-runs on every `store.subscribe` notify,
|
|
22
|
+
* which covers both an adapter rebuild (which notifies) and
|
|
23
|
+
* `GameHierarchy.tsx`'s toggle handler (`toggleRootHidden` +
|
|
24
|
+
* `store.notifyIngestEdit()`).
|
|
25
|
+
*
|
|
26
|
+
* A composite's `store.objectMap`/live THREE.Scene holds the manifest's sole
|
|
27
|
+
* three content root. `GameManifestSchema` rejects a second content root of
|
|
28
|
+
* the same medium, so `resolveThreeViewportRootId` below only needs to name
|
|
29
|
+
* that one world id rather than arbitrate per-object ownership.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { getUserData } from '@volter/editor-threejs/ecs/user-data';
|
|
33
|
+
import * as THREE from 'three';
|
|
34
|
+
import { isRootHidden } from '@volter/editor-sdk/kit/authoring/world-session-state';
|
|
35
|
+
import { isThreejsSurfaceVisible, resolveThreeViewportRootId } from '@volter/editor-sdk/kit/authoring/three-root';
|
|
36
|
+
|
|
37
|
+
export { isThreejsSurfaceVisible, resolveThreeViewportRootId };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Apply the eye toggle to every object in `objectMap` (see module doc
|
|
41
|
+
* comment for the full rebuild-proofing rationale). `threeRootId` is
|
|
42
|
+
* accepted as a parameter (rather than re-resolved internally) so a caller
|
|
43
|
+
* that already computed it once per call (e.g. for a cheap re-apply
|
|
44
|
+
* signature) doesn't pay for `resolveThreeViewportRootId`'s composite walk
|
|
45
|
+
* twice.
|
|
46
|
+
*/
|
|
47
|
+
export function applyRootHiddenVisibility(
|
|
48
|
+
objectMap: ReadonlyMap<string, THREE.Object3D>,
|
|
49
|
+
threeRootId: string | null,
|
|
50
|
+
): void {
|
|
51
|
+
const hidden = threeRootId !== null && isRootHidden(threeRootId);
|
|
52
|
+
if (!hidden) return;
|
|
53
|
+
// The live `Object3D.visible` IS the authored truth, so the SHOW edge has
|
|
54
|
+
// nothing to restore FROM — the adapter re-renders its own tree there.
|
|
55
|
+
// Hiding wins over everything: nothing in a hidden world renders.
|
|
56
|
+
for (const obj of objectMap.values()) obj.visible = false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The background an editor viewport with NO authored environment shows. A
|
|
61
|
+
* single shared instance so the per-notify re-apply below never allocates.
|
|
62
|
+
*/
|
|
63
|
+
const HIDDEN_WORLD_BACKGROUND = new THREE.Color('#aaaaaa');
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The scene-level half of the eye: `applyRootHiddenVisibility` above only
|
|
67
|
+
* covers entity `Object3D`s (the `objectMap`), but a three world also
|
|
68
|
+
* renders things that live directly on the `THREE.Scene` — the authored
|
|
69
|
+
* `background` color/skybox, `fog`, IBL `environment`, and the
|
|
70
|
+
* `envObject`-tagged ambient light `applyEnvironment` adds. None of those are
|
|
71
|
+
* entities, so without this function hiding the world leaves the viewport
|
|
72
|
+
* painted in the world's background color — looking exactly like "the eye
|
|
73
|
+
* didn't work".
|
|
74
|
+
*
|
|
75
|
+
* Same recompute-don't-remember contract as above: this only ever writes the
|
|
76
|
+
* SUPPRESSED state, idempotently, and callers invoke it on every notify while
|
|
77
|
+
* the world is hidden (an inspector environment edit mid-hide re-paints the
|
|
78
|
+
* authored values via `_applyEnvironment` — the very next notify re-suppresses
|
|
79
|
+
* them here). The restore path is `ShellStore.reapplyEnvironment()`, whose
|
|
80
|
+
* truth is `_sceneMeta.environment` — never a snapshot taken here.
|
|
81
|
+
*
|
|
82
|
+
* Post-processing (bloom/vignette) is the composer's half: `the world root's stage`'s
|
|
83
|
+
* `rebuildComposer` passes `undefined` instead of `store.environment` while
|
|
84
|
+
* the world is hidden; `editor-viewport.ts` bumps `composerVersion` (via
|
|
85
|
+
* `reapplyEnvironment`) on both hide/show edges so that rebuild actually runs.
|
|
86
|
+
*/
|
|
87
|
+
export function suppressRootEnvironment(scene: THREE.Scene): void {
|
|
88
|
+
scene.background = HIDDEN_WORLD_BACKGROUND;
|
|
89
|
+
scene.fog = null;
|
|
90
|
+
scene.environment = null;
|
|
91
|
+
scene.traverse((obj) => {
|
|
92
|
+
if (getUserData(obj, 'envObject')) obj.visible = false;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|