@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,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
+