@volter/editor-threejs 0.5.211 → 0.5.212
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 +7 -2
- package/README.md +8 -13
- package/contributions/animation-mixers.service.ts +2 -4
- package/contributions/animation-timeline.utility.tsx +1 -2
- package/contributions/three.page.ts +50 -0
- package/dist-node/serving.mjs +84 -3
- package/dist-node/shared-three.mjs +145 -0
- package/package.json +296 -16
- package/serving/animation-stamp.ts +1 -1
- package/serving/index.ts +22 -5
- package/serving/model-import-conversion.ts +6 -3
- package/serving/shared-three.ts +176 -0
- package/src/adapter/constraint.ts +1 -18
- package/src/adapter/object3d-authoring-subject.ts +1 -12
- package/src/adapter/reflection-probe.ts +1 -9
- package/src/adapter/three-contract.ts +6 -60
- package/src/animation/live-mixers.ts +0 -4
- package/src/api.ts +7 -0
- package/src/asset-loaders.ts +2 -178
- package/src/capture/photograph-sampling.ts +333 -0
- package/src/capture/scene.ts +1 -1
- package/src/ecs/object-marks.ts +6 -14
- package/src/ecs/user-data.ts +3 -138
- package/src/kit/animation/three-clips-subject.ts +19 -42
- package/src/kit/asset-compare-core.ts +161 -0
- package/src/kit/asset-compare.ts +8 -27
- package/src/kit/asset-preview-command.ts +14 -19
- package/src/kit/asset-preview-framing.ts +8 -67
- package/src/kit/asset-preview.ts +109 -342
- package/src/kit/asset-workflow/model-inspection.ts +11 -154
- package/src/kit/asset-workflow/preview-resource-lifetime.ts +25 -0
- package/src/kit/authoring/component-instance-root.ts +3 -39
- package/src/kit/authoring/live-object-transform.ts +1 -30
- package/src/kit/authoring/object3d-document-session-registry.ts +7 -15
- package/src/kit/authoring/object3d-document-session.ts +471 -208
- package/src/kit/authoring/shell-viewport-policy.ts +18 -24
- package/src/kit/authoring/source-object3d-authoring-adapter.ts +6 -48
- package/src/kit/authoring/three-projection-core.ts +11 -40
- package/src/kit/authoring/viewport-pick-context.ts +5 -12
- package/src/kit/authoring/viewport-raycast.ts +15 -93
- package/src/kit/authoring/world-hidden-viewport.ts +8 -61
- package/src/kit/capture-camera-pose.ts +68 -0
- package/src/kit/collaboration-presence.ts +41 -0
- package/src/kit/components/CameraInfo.tsx +4 -6
- package/src/kit/components/InspectorObjectPreview.tsx +3 -30
- package/src/kit/components/Object3DDocumentToolbar.css +75 -0
- package/src/kit/components/Object3DDocumentToolbar.tsx +18 -84
- package/src/kit/components/Object3DDocumentViewport.tsx +5 -8
- package/src/kit/components/StageHost.css +40 -0
- package/src/kit/components/StageHost.tsx +181 -404
- package/src/kit/components/StageOverlays.tsx +1 -2
- package/src/kit/components/StatsOverlay.tsx +5 -17
- package/src/kit/components/ViewportFurniture.tsx +27 -154
- package/src/kit/components/ViewportOverlay.tsx +13 -17
- package/src/kit/components/ViewportOverlaysMenu.tsx +177 -0
- package/src/kit/components/ViewportShadingMenu.tsx +2 -42
- package/src/kit/components/ViewportViewMenu.tsx +7 -18
- package/src/kit/components/asset-viewers/EntityModelDocument.tsx +4 -13
- package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +5 -5
- package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +24 -102
- package/src/kit/components/asset-viewers/LutAssetDocument.tsx +6 -6
- package/src/kit/components/asset-viewers/Object3DPreview.tsx +10 -45
- package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +2 -2
- package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +11 -10
- package/src/kit/components/asset-viewers/shader-source.ts +144 -0
- package/src/kit/components/asset-viewers/three-asset-viewers.tsx +2 -14
- package/src/kit/components/object3d-contribution-surfaces.tsx +1 -1
- package/src/kit/components/stage-keyboard.tsx +6 -12
- package/src/kit/components/stage-overlay-set.tsx +11 -20
- package/src/kit/components/stage-presence-markers.ts +9 -32
- package/src/kit/components/stage-transform-chrome.ts +4 -4
- package/src/kit/components/stage-transform-tools.tsx +7 -14
- package/src/kit/components/standard-viewport-dressing.ts +47 -167
- package/src/kit/components/world-root-binding.ts +2 -7
- package/src/kit/constraint-helper.ts +1 -1
- package/src/kit/document-renderer-session.ts +114 -0
- package/src/kit/editor-presentation-activity.ts +58 -0
- package/src/kit/editor-shell-store.ts +31 -403
- package/src/kit/editor-viewport.ts +476 -1762
- package/src/kit/entity-lod.ts +3 -16
- package/src/kit/entity-object.ts +1 -39
- package/src/kit/hierarchy-mark-reader.ts +13 -30
- package/src/kit/instanced-presentation.ts +1 -47
- package/src/kit/live-gesture-lock.ts +35 -0
- package/src/kit/live-module-source.ts +12 -52
- package/src/kit/model-thumbnail.ts +7 -35
- package/src/kit/play-camera-flight.ts +7 -16
- package/src/kit/projection/three.ts +13 -366
- package/src/kit/retained-document-states.ts +91 -0
- package/src/kit/scene-document-viewport.ts +3 -3
- package/src/kit/scene-framing.ts +11 -152
- package/src/kit/scene-view-fog.ts +2 -47
- package/src/kit/spatial-handle-visuals.ts +19 -28
- package/src/kit/stories/three-story-model.ts +5 -33
- package/src/kit/three-canvas-render.ts +3 -4
- package/src/kit/three-hierarchy-row-media.ts +3 -11
- package/src/kit/three-inspection-media.ts +6 -6
- package/src/kit/three-integration.ts +18 -18
- package/src/kit/three-state.ts +2 -19
- package/src/kit/three-viewport/camera-fit.ts +2 -6
- package/src/kit/three-viewport/selection-brackets.ts +5 -20
- package/src/kit/three-viewport/selection-outline.ts +4 -56
- package/src/kit/three-viewport/skeleton-helper.ts +1 -32
- package/src/kit/three-viewport/source-color.ts +1 -33
- package/src/kit/three-viewport/studio-environment.ts +4 -55
- package/src/kit/three-viewport-presentation.ts +23 -0
- package/src/kit/transport-strip.tsx +147 -0
- package/src/kit/viewport-actions.ts +8 -50
- package/src/kit/viewport-authoring-policy.ts +5 -52
- package/src/kit/viewport-commands.ts +28 -57
- package/src/kit/viewport-hotkeys.ts +29 -31
- package/src/kit/viewport-status-facet.ts +4 -5
- package/src/kit/viewport-surface-status.tsx +44 -0
- package/src/loader.ts +8 -71
- package/src/object3d-contributions.ts +18 -34
- package/src/viewport/clip-planes.ts +4 -32
- package/src/viewport/content-bounds.ts +13 -142
- package/src/viewport/editor-layers.ts +4 -26
- package/src/viewport/environment.ts +1 -1
- package/src/viewport/preview-renderer.ts +7 -45
- package/src/viewport/renderer-ownership.ts +14 -62
- package/src/viewport-api.ts +14 -33
- package/src/viewport-door.ts +16 -86
- package/contributions/three-integration.service.ts +0 -13
- package/src/adapter/hierarchy-marks.ts +0 -156
- package/src/adapter/ingest/scene-capture.ts +0 -843
- package/src/adapter/ingest/structural-ids.ts +0 -139
- package/src/adapter/ingest/visible-capture-window.ts +0 -302
- package/src/adapter/renderer-config.ts +0 -115
- package/src/asset-parse-error.ts +0 -29
- package/src/host-hierarchy-objects.ts +0 -31
- package/src/kit/authoring/design-time-settle.ts +0 -343
- package/src/kit/camera-authoring.ts +0 -175
|
@@ -2,47 +2,41 @@
|
|
|
2
2
|
* THE SHELL'S ANSWERS to `ViewportAuthoringPolicy` — the composition the
|
|
3
3
|
* editor installs at boot (`EditorContext.tsx`'s `EditorProvider`).
|
|
4
4
|
*
|
|
5
|
-
* Every member here is a one-line delegation to the module that
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Every member here is a one-line delegation to the module that owns that
|
|
6
|
+
* answer. The point of the seam is only that `editor-viewport.ts` does not
|
|
7
|
+
* import these modules, so
|
|
8
8
|
* the gizmo viewport does not carry the composite adapter, the workspace
|
|
9
9
|
* document registry, or the layered-pick walk in its own closure
|
|
10
|
-
|
|
10
|
+
*.
|
|
11
11
|
*
|
|
12
12
|
* This file is the shell side of that boundary and is free to import all of
|
|
13
13
|
* them.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { ViewportAuthoringPolicy } from '../viewport-authoring-policy';
|
|
17
|
-
import { getActiveAuthoring } from '@volter/sdk/
|
|
18
|
-
import { CompositeAuthoringAdapter } from '@volter/sdk/
|
|
19
|
-
import { pickTopmost } from '@volter/sdk/
|
|
20
|
-
import { resolvePanelAuthoring } from '@volter/sdk/
|
|
21
|
-
import { isViewportToolContextVisible, resolveViewportToolContext } from '@volter/sdk/
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
isThreejsSurfaceVisible,
|
|
25
|
-
resolveThreeViewportRootId,
|
|
26
|
-
suppressRootEnvironment,
|
|
27
|
-
} from './world-hidden-viewport';
|
|
17
|
+
import { getActiveAuthoring } from '@volter/sdk/authoring/active-adapter';
|
|
18
|
+
import { CompositeAuthoringAdapter } from '@volter/sdk/authoring/composite-authoring-adapter';
|
|
19
|
+
import { pickTopmost } from '@volter/sdk/authoring/layered-pick';
|
|
20
|
+
import { resolvePanelAuthoring } from '@volter/sdk/authoring/panel-authoring';
|
|
21
|
+
import { isViewportToolContextVisible, resolveViewportToolContext } from '@volter/sdk/authoring/viewport-tool-context';
|
|
22
|
+
import { isStageVisible, resolveStageRootId } from '@volter/sdk/authoring/stage-root';
|
|
23
|
+
import { applyRootHiddenVisibility, suppressRootEnvironment } from './world-hidden-viewport';
|
|
28
24
|
|
|
29
25
|
export const SHELL_VIEWPORT_AUTHORING_POLICY: ViewportAuthoringPolicy = {
|
|
30
26
|
activeAuthoring: (store) => getActiveAuthoring(store),
|
|
31
27
|
panelAuthoring: (store) => resolvePanelAuthoring(store).adapter,
|
|
32
28
|
pick: (store, clientX, clientY, intent) => pickTopmost(store, clientX, clientY, { intent }),
|
|
33
29
|
toolOwner: (adapter, selectedIds) => resolveViewportToolContext(adapter, selectedIds),
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// edited. The viewport ANDs the result with `kind === 'three'`, exactly as it
|
|
37
|
-
// did when it called these two itself.
|
|
30
|
+
// Require both a visible three.js surface and ownership by the world being edited. The viewport
|
|
31
|
+
// also gates this result on a stage surface.
|
|
38
32
|
toolOwnerPainted: (store, owner) =>
|
|
39
|
-
|
|
33
|
+
isStageVisible(store) && isViewportToolContextVisible(store, owner),
|
|
40
34
|
threeSurfaceShowing: (store, adapter) =>
|
|
41
|
-
|
|
35
|
+
isStageVisible(store) &&
|
|
42
36
|
(adapter instanceof CompositeAuthoringAdapter
|
|
43
|
-
? adapter.childAdapters().some((child) => child.kind === '
|
|
44
|
-
: !adapter.rects && typeof adapter.hierarchy.
|
|
45
|
-
threeViewportRootId: (store) =>
|
|
37
|
+
? adapter.childAdapters().some((child) => child.kind === 'stage')
|
|
38
|
+
: !adapter.rects && typeof adapter.hierarchy.nativeObject === 'function'),
|
|
39
|
+
threeViewportRootId: (store) => resolveStageRootId(store),
|
|
46
40
|
applyRootHiddenVisibility,
|
|
47
41
|
suppressRootEnvironment,
|
|
48
42
|
};
|
|
@@ -178,23 +178,7 @@ export class SourceObject3DAuthoringAdapter implements AuthoringAdapter {
|
|
|
178
178
|
});
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
// The constructor's walk is a snapshot of the graph AT MOUNT, and one class
|
|
184
|
-
// of document mutates its graph afterwards: the live module document
|
|
185
|
-
// (`components/asset-viewers/LiveModuleDocument.tsx`) keeps a stable
|
|
186
|
-
// container root and SWAPS ITS CHILD on every save, precisely so the
|
|
187
|
-
// viewport's camera survives a rebuild. Measured 2026-08-29: the viewport
|
|
188
|
-
// showed the rebuilt model while the hierarchy kept the mount-time walk, so
|
|
189
|
-
// the document row lost its child and the picker addressed a disposed
|
|
190
|
-
// object.
|
|
191
|
-
//
|
|
192
|
-
// The fix is `r3f-source-authoring-adapter.ts`'s, transcribed: re-index on
|
|
193
|
-
// the GRAPH'S OWN structural events. `parent.add(child)` dispatches
|
|
194
|
-
// `childadded` on the parent, so watching the scene plus every indexed
|
|
195
|
-
// object covers every path a subtree can enter through, and no document has
|
|
196
|
-
// to remember to tell this adapter anything. Editor furniture (gizmo and
|
|
197
|
-
// helper attachments) is filtered so selection churn does not re-index.
|
|
181
|
+
// Re-index a live module’s stable root when its child graph changes. Ignore editor helpers so gizmo and selection attachments do not trigger another walk.
|
|
198
182
|
|
|
199
183
|
private disposed = false;
|
|
200
184
|
dispose(): void {
|
|
@@ -299,7 +283,7 @@ export class SourceObject3DAuthoringAdapter implements AuthoringAdapter {
|
|
|
299
283
|
return label;
|
|
300
284
|
}
|
|
301
285
|
|
|
302
|
-
readonly hierarchy: HierarchyProvider = {
|
|
286
|
+
readonly hierarchy: HierarchyProvider & Required<Pick<HierarchyProvider, 'nativeObject' | 'idForNativeObject'>> = {
|
|
303
287
|
roots: () => [this.hierarchy.node(this.documentNodeId)!],
|
|
304
288
|
node: (id) => {
|
|
305
289
|
if (id === this.documentNodeId) {
|
|
@@ -323,8 +307,8 @@ export class SourceObject3DAuthoringAdapter implements AuthoringAdapter {
|
|
|
323
307
|
const object = this.byId.get(id);
|
|
324
308
|
return object ? this.view.node(object) : null;
|
|
325
309
|
},
|
|
326
|
-
|
|
327
|
-
|
|
310
|
+
nativeObject: (id) => this.byId.get(id) ?? null,
|
|
311
|
+
idForNativeObject: (object: THREE.Object3D) => this.idByObject.get(object) ?? null,
|
|
328
312
|
};
|
|
329
313
|
|
|
330
314
|
readonly selection: SelectionProvider = {
|
|
@@ -345,33 +329,7 @@ export class SourceObject3DAuthoringAdapter implements AuthoringAdapter {
|
|
|
345
329
|
private static readonly READ_ONLY_REASON =
|
|
346
330
|
'This document shows what the module builds; its properties are read-only here. Edit the module.';
|
|
347
331
|
|
|
348
|
-
/**
|
|
349
|
-
* READ-ONLY, AND IT SAYS SO NOW. Every write door on this adapter is a
|
|
350
|
-
* no-op and always was — `transforms.beginEdit`/`apply`/`endEdit` below and
|
|
351
|
-
* `inspector.set` further down all return `undefined` — but it declared no
|
|
352
|
-
* `editability`, which is the seam the shell asks BEFORE drawing a control
|
|
353
|
-
* (`InspectorProvider.editability`'s own contract names "hierarchy eye/lock
|
|
354
|
-
* controls"). Its default is `{ writable: true }`, so the shell believed
|
|
355
|
-
* this projection and drew a live control over a surface that discards it.
|
|
356
|
-
*
|
|
357
|
-
* MEASURED 2026-09-19 in a cold models scaffold, which is what bought this:
|
|
358
|
-
* the Outliner's EYE on a Model document lit, kept its `Hide` tooltip and
|
|
359
|
-
* its `aria-pressed="false"`, and left the frame hash byte-identical —
|
|
360
|
-
* `GameHierarchy.tsx` gates it on `visibleKnown && (editability?.writable ??
|
|
361
|
-
* true)`, and `visible` is knowable here (it reads `object.visible`) even
|
|
362
|
-
* though it is `readonly: true` in the very descriptor list below.
|
|
363
|
-
*
|
|
364
|
-
* `transforms` DELIBERATELY DOES NOT GET ONE, and that is a measurement
|
|
365
|
-
* rather than an oversight. Its write doors are no-ops too, so the honest
|
|
366
|
-
* answer is the same — but declaring it bought nothing and cost a mark:
|
|
367
|
-
* `transform-mode-request.ts`, the one place that would have NAMED the
|
|
368
|
-
* refusal, reads `store.selectedEntityIds` off the SHELL store while a
|
|
369
|
-
* document stage keeps its selection in its own, so it returns before it
|
|
370
|
-
* can speak and the host's Move/Rotate/Scale stayed silent either way —
|
|
371
|
-
* while `GameHierarchy`'s `hierarchy-transform-lock` span immediately
|
|
372
|
-
* appeared on every row, a glyph Blender's Outliner does not draw. Answer
|
|
373
|
-
* it when something acts on the answer.
|
|
374
|
-
*/
|
|
332
|
+
/** This source projection is read-only. Expose editability where the shell consumes it instead of advertising writes the adapter discards. */
|
|
375
333
|
private readonly readOnly = (): { writable: boolean; reason: string } => ({
|
|
376
334
|
writable: false,
|
|
377
335
|
reason: SourceObject3DAuthoringAdapter.READ_ONLY_REASON,
|
|
@@ -502,7 +460,7 @@ export class SourceObject3DAuthoringAdapter implements AuthoringAdapter {
|
|
|
502
460
|
}
|
|
503
461
|
|
|
504
462
|
get documentRootObject(): THREE.Object3D | null {
|
|
505
|
-
const rootId = this.hierarchy.node(this.documentNodeId)
|
|
463
|
+
const rootId = this.hierarchy.node(this.documentNodeId)!.childIds[0];
|
|
506
464
|
return rootId ? (this.byId.get(rootId) ?? null) : null;
|
|
507
465
|
}
|
|
508
466
|
|
|
@@ -1,30 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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.
|
|
2
|
+
* Shared derived views over the live Object3D projection: transparent-wrapper collapse,
|
|
3
|
+
* local-transform reads and store-selection adoption. projection/three.ts owns graph traversal,
|
|
4
|
+
* identity, the two-way index and picking; viewport/content-bounds owns bounds. The source document
|
|
5
|
+
* adapter chooses identity and write targets.
|
|
28
6
|
*/
|
|
29
7
|
|
|
30
8
|
import type { EditorNode, Transform } from '@volter/project/adapter';
|
|
@@ -32,7 +10,7 @@ import { isEditorOwnedObject } from '@volter/editor-threejs/viewport/editor-laye
|
|
|
32
10
|
import { object3DAuthoringSubjectOf } from '@volter/editor-threejs/adapter/object3d-authoring-subject';
|
|
33
11
|
import { getUserData } from '@volter/editor-threejs/ecs/user-data';
|
|
34
12
|
import type * as THREE from 'three';
|
|
35
|
-
import type { ShellStore } from '@volter/sdk/
|
|
13
|
+
import type { ShellStore } from '@volter/sdk/shell-store';
|
|
36
14
|
import { nativeKindOf, type ThreeProjector } from '../projection/three';
|
|
37
15
|
import { localTransformOf } from './live-object-transform';
|
|
38
16
|
|
|
@@ -46,7 +24,7 @@ export interface StoreSelectionAdoptionOptions {
|
|
|
46
24
|
/** Runs with the OWNED ids whenever the adopted set changes, in either
|
|
47
25
|
* direction. The Object3D-document configuration drives its nodes'
|
|
48
26
|
* `selectionChanged` marks from here. */
|
|
49
|
-
readonly onChange
|
|
27
|
+
readonly onChange: (ids: readonly string[]) => void;
|
|
50
28
|
}
|
|
51
29
|
|
|
52
30
|
function sameIds(a: readonly string[], b: readonly string[]): boolean {
|
|
@@ -54,14 +32,8 @@ function sameIds(a: readonly string[], b: readonly string[]): boolean {
|
|
|
54
32
|
}
|
|
55
33
|
|
|
56
34
|
/**
|
|
57
|
-
*
|
|
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.
|
|
35
|
+
* Read selection from the store so direct viewport additive and toggle operations remain visible to
|
|
36
|
+
* the adapter. Compare with the last adopted ids to avoid notifying again on unchanged reads.
|
|
65
37
|
*/
|
|
66
38
|
export class StoreSelectionAdoption {
|
|
67
39
|
/** The store ids last adopted or published — the comparison basis. */
|
|
@@ -78,7 +50,7 @@ export class StoreSelectionAdoption {
|
|
|
78
50
|
if (!sameIds(storeIds, this.adopted)) {
|
|
79
51
|
this.adopted = storeIds;
|
|
80
52
|
this.selected = storeIds;
|
|
81
|
-
this.options.onChange
|
|
53
|
+
this.options.onChange(storeIds);
|
|
82
54
|
}
|
|
83
55
|
return [...this.selected];
|
|
84
56
|
}
|
|
@@ -92,7 +64,7 @@ export class StoreSelectionAdoption {
|
|
|
92
64
|
this.selected = [...selected];
|
|
93
65
|
const storeIds = this.selected.filter(this.options.owns);
|
|
94
66
|
this.adopted = storeIds;
|
|
95
|
-
this.options.onChange
|
|
67
|
+
this.options.onChange(storeIds);
|
|
96
68
|
this.options.store.selectMultiple(storeIds);
|
|
97
69
|
}
|
|
98
70
|
}
|
|
@@ -137,8 +109,7 @@ export function isTransparentWrapper(object: THREE.Object3D): boolean {
|
|
|
137
109
|
object3DAuthoringSubjectOf(object) === null &&
|
|
138
110
|
object.userData['oid'] === undefined &&
|
|
139
111
|
getUserData(object, 'authoringInstance') === undefined &&
|
|
140
|
-
getUserData(object, 'authoringRoot') !== true
|
|
141
|
-
getUserData(object, 'volterComponentRoot') === undefined
|
|
112
|
+
getUserData(object, 'authoringRoot') !== true
|
|
142
113
|
);
|
|
143
114
|
}
|
|
144
115
|
|
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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.
|
|
2
|
+
* Camera, canvas and editor controls for adapter raycasting, owned by EditorViewport. A viewport
|
|
3
|
+
* configured to publish its context installs it after construction and clears it on disposal.
|
|
4
|
+
* Before a viewport binds or after it is disposed, picking has no context and returns null.
|
|
12
5
|
*/
|
|
13
6
|
|
|
14
|
-
import { setViewportEditorControls, type ViewportEditorControls } from '@volter/sdk/
|
|
7
|
+
import { setViewportEditorControls, type ViewportEditorControls } from '@volter/sdk/viewport-editor-controls';
|
|
15
8
|
import type * as THREE from 'three';
|
|
16
9
|
|
|
17
10
|
export interface ViewportPickContext {
|
|
@@ -21,7 +14,7 @@ export interface ViewportPickContext {
|
|
|
21
14
|
* target/pole effectors). The full-cover selection overlay owns pointer
|
|
22
15
|
* events in the project viewport, so it forwards them through this narrow
|
|
23
16
|
* seam instead of duplicating Three raycasting or constraint semantics. */
|
|
24
|
-
readonly editorControls
|
|
17
|
+
readonly editorControls: ViewportEditorControls;
|
|
25
18
|
|
|
26
19
|
}
|
|
27
20
|
|
|
@@ -1,29 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* entity
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - first-party `VolterSceneAuthoringAdapter` (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.
|
|
2
|
+
* Raycast live three.js authoring graphs through the active adapter’s object-to-id lookup. The
|
|
3
|
+
* default reads store.objectMap and entity stamps; document adapters can supply their projection.
|
|
4
|
+
* viewport-pick-context supplies the mounted viewport’s camera and canvas. Return null when no
|
|
5
|
+
* viewport is mounted, and enable all raycast layers to include selectable overlay-adjacent
|
|
6
|
+
* geometry.
|
|
27
7
|
*/
|
|
28
8
|
|
|
29
9
|
import { isInEditorOwnedSubtree } from '@volter/editor-threejs/viewport/editor-layers';
|
|
@@ -36,25 +16,7 @@ function isBackdropMaterial(material: THREE.Material): boolean {
|
|
|
36
16
|
return material.depthWrite === false && material.side === THREE.BackSide;
|
|
37
17
|
}
|
|
38
18
|
|
|
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
|
-
*/
|
|
19
|
+
/** Skip enclosing backdrops during viewport picking: BackSide meshes with depthWrite false surround the camera and otherwise turn every ray into a hit. Hierarchy selection still reaches them. */
|
|
58
20
|
export function isBackdropObject(object: THREE.Object3D): boolean {
|
|
59
21
|
const material = (object as Partial<THREE.Mesh>).material;
|
|
60
22
|
if (!material) return false;
|
|
@@ -63,15 +25,7 @@ export function isBackdropObject(object: THREE.Object3D): boolean {
|
|
|
63
25
|
: isBackdropMaterial(material);
|
|
64
26
|
}
|
|
65
27
|
|
|
66
|
-
/** Raycast
|
|
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. */
|
|
28
|
+
/** Raycast objectMap at client coordinates, skipping helpers and ids rejected by the caller’s format-specific lock predicate. */
|
|
75
29
|
export function raycastCandidates(
|
|
76
30
|
store: EditorShellStore,
|
|
77
31
|
clientX: number,
|
|
@@ -79,15 +33,15 @@ export function raycastCandidates(
|
|
|
79
33
|
isLocked: (id: string) => boolean = () => false,
|
|
80
34
|
projection?: {
|
|
81
35
|
readonly objects: Iterable<THREE.Object3D>;
|
|
82
|
-
readonly
|
|
36
|
+
readonly idForNativeObject: (object: THREE.Object3D) => string | null;
|
|
83
37
|
},
|
|
84
38
|
): string[] {
|
|
85
39
|
const ctx = getViewportPickContext();
|
|
86
40
|
if (!ctx) return []; // no live viewport mounted — honest degrade
|
|
87
41
|
const rect = ctx.canvas.getBoundingClientRect();
|
|
88
42
|
// Chrome and other panes are not part of this viewport. Coverage also asks
|
|
89
|
-
// about (0,0)
|
|
90
|
-
//
|
|
43
|
+
// about (0,0), and that off-canvas query must not skin/raycast the entire
|
|
44
|
+
// world.
|
|
91
45
|
if (
|
|
92
46
|
rect.width <= 0 ||
|
|
93
47
|
rect.height <= 0 ||
|
|
@@ -117,15 +71,7 @@ export function raycastCandidates(
|
|
|
117
71
|
return true;
|
|
118
72
|
});
|
|
119
73
|
|
|
120
|
-
//
|
|
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`).
|
|
74
|
+
// Clear cached SkinnedMesh bounds before raycasting so the localized ray is tested against current geometry, not a stale bone-world-space cache.
|
|
129
75
|
for (const root of entityObjects) {
|
|
130
76
|
root.traverse((node) => {
|
|
131
77
|
// Structural write: three's own `SkinnedMesh` fields hold `Box3 | null`
|
|
@@ -166,7 +112,7 @@ export function raycastCandidates(
|
|
|
166
112
|
let eid: string | null = null;
|
|
167
113
|
if (projection) {
|
|
168
114
|
while (cursor && eid === null) {
|
|
169
|
-
eid = projection.
|
|
115
|
+
eid = projection.idForNativeObject(cursor);
|
|
170
116
|
cursor = cursor.parent;
|
|
171
117
|
}
|
|
172
118
|
} else {
|
|
@@ -183,26 +129,7 @@ export function raycastCandidates(
|
|
|
183
129
|
return hits;
|
|
184
130
|
}
|
|
185
131
|
|
|
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
|
-
*/
|
|
132
|
+
/** Report genuine empty viewport picks with candidate, hit and filtered-hit counts to distinguish missing targets from identity and filtering failures. */
|
|
206
133
|
function reportEmptyPick(
|
|
207
134
|
hits: readonly string[],
|
|
208
135
|
candidates: readonly THREE.Object3D[],
|
|
@@ -211,12 +138,7 @@ function reportEmptyPick(
|
|
|
211
138
|
clientY: number,
|
|
212
139
|
): void {
|
|
213
140
|
if (hits.length > 0) return;
|
|
214
|
-
//
|
|
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.
|
|
141
|
+
// Ignore (0, 0), which the shell uses for a synthetic conformance probe over app chrome.
|
|
220
142
|
if (clientX === 0 && clientY === 0) return;
|
|
221
143
|
// biome-ignore lint/suspicious/noConsole: diagnostic breadcrumb, same channel as the editor's other timing/stall notes
|
|
222
144
|
console.info(
|
|
@@ -233,7 +155,7 @@ export function raycastPick(
|
|
|
233
155
|
isLocked: (id: string) => boolean = () => false,
|
|
234
156
|
projection?: {
|
|
235
157
|
readonly objects: Iterable<THREE.Object3D>;
|
|
236
|
-
readonly
|
|
158
|
+
readonly idForNativeObject: (object: THREE.Object3D) => string | null;
|
|
237
159
|
},
|
|
238
160
|
): string | null {
|
|
239
161
|
return raycastCandidates(store, clientX, clientY, isLocked, projection)[0] ?? null;
|
|
@@ -1,49 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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.
|
|
2
|
+
* Apply hidden-world suppression to live three.js content and its scene environment.
|
|
30
3
|
*/
|
|
31
4
|
|
|
32
5
|
import { getUserData } from '@volter/editor-threejs/ecs/user-data';
|
|
33
6
|
import * as THREE from 'three';
|
|
34
|
-
import { isRootHidden } from '@volter/sdk/
|
|
35
|
-
import { isThreejsSurfaceVisible, resolveThreeViewportRootId } from '@volter/sdk/kit/authoring/three-root';
|
|
7
|
+
import { isRootHidden } from '@volter/sdk/authoring/world-session-state';
|
|
36
8
|
|
|
37
|
-
|
|
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
|
-
*/
|
|
9
|
+
/** Set every mapped object invisible when the identified stage root is hidden. Otherwise leave
|
|
10
|
+
* visibility unchanged. The caller supplies its resolved root id. */
|
|
47
11
|
export function applyRootHiddenVisibility(
|
|
48
12
|
objectMap: ReadonlyMap<string, THREE.Object3D>,
|
|
49
13
|
threeRootId: string | null,
|
|
@@ -57,32 +21,15 @@ export function applyRootHiddenVisibility(
|
|
|
57
21
|
}
|
|
58
22
|
|
|
59
23
|
/**
|
|
60
|
-
* The background
|
|
24
|
+
* The background used while an entire world is hidden. A
|
|
61
25
|
* single shared instance so the per-notify re-apply below never allocates.
|
|
62
26
|
*/
|
|
63
27
|
const HIDDEN_WORLD_BACKGROUND = new THREE.Color('#aaaaaa');
|
|
64
28
|
|
|
65
29
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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.
|
|
30
|
+
* Clear the scene's background, fog and IBL environment, and hide envObject-tagged lights.
|
|
31
|
+
* The viewport policy calls this while the world is hidden. It writes only the suppressed state
|
|
32
|
+
* and stores no environment snapshot.
|
|
86
33
|
*/
|
|
87
34
|
export function suppressRootEnvironment(scene: THREE.Scene): void {
|
|
88
35
|
scene.background = HIDDEN_WORLD_BACKGROUND;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE FREE CAPTURE CAMERA AND CLIP POSE, validated at the relay boundary —
|
|
3
|
+
* `--azimuth/--elevation/--distance` and `--clip/--time` as they arrive on a
|
|
4
|
+
* capture command.
|
|
5
|
+
*
|
|
6
|
+
* THE VERB NAMES ITSELF: the verb is a parameter, so a bad `--azimuth` refuses
|
|
7
|
+
* in the name of the command the caller ran.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Validate an untrusted `camera` payload: the parsed choice, `undefined` when
|
|
11
|
+
* absent, or the refusal message naming `verb`. */
|
|
12
|
+
export function parseCameraChoice(
|
|
13
|
+
verb: string,
|
|
14
|
+
value: unknown,
|
|
15
|
+
): { azimuthDegrees: number; elevationDegrees: number; distance?: number } | undefined | string {
|
|
16
|
+
if (value === undefined) return undefined;
|
|
17
|
+
const record = value as {
|
|
18
|
+
azimuthDegrees?: unknown;
|
|
19
|
+
elevationDegrees?: unknown;
|
|
20
|
+
distance?: unknown;
|
|
21
|
+
} | null;
|
|
22
|
+
if (
|
|
23
|
+
record === null ||
|
|
24
|
+
typeof record !== 'object' ||
|
|
25
|
+
typeof record.azimuthDegrees !== 'number' ||
|
|
26
|
+
!Number.isFinite(record.azimuthDegrees) ||
|
|
27
|
+
typeof record.elevationDegrees !== 'number' ||
|
|
28
|
+
!Number.isFinite(record.elevationDegrees) ||
|
|
29
|
+
record.elevationDegrees < -90 ||
|
|
30
|
+
record.elevationDegrees > 90 ||
|
|
31
|
+
(record.distance !== undefined &&
|
|
32
|
+
(typeof record.distance !== 'number' ||
|
|
33
|
+
!Number.isFinite(record.distance) ||
|
|
34
|
+
record.distance <= 0))
|
|
35
|
+
) {
|
|
36
|
+
return (
|
|
37
|
+
`${verb} camera must be {azimuthDegrees, elevationDegrees in [-90, 90], ` +
|
|
38
|
+
'distance? > 0 meters}.'
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
azimuthDegrees: record.azimuthDegrees,
|
|
43
|
+
elevationDegrees: record.elevationDegrees,
|
|
44
|
+
...(record.distance === undefined ? {} : { distance: record.distance }),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Validate an untrusted `pose` payload: the parsed pose, `undefined` when
|
|
49
|
+
* absent, or the refusal message naming `verb`. */
|
|
50
|
+
export function parsePoseChoice(
|
|
51
|
+
verb: string,
|
|
52
|
+
value: unknown,
|
|
53
|
+
): { clip: string; timeSeconds: number } | undefined | string {
|
|
54
|
+
if (value === undefined) return undefined;
|
|
55
|
+
const record = value as { clip?: unknown; timeSeconds?: unknown } | null;
|
|
56
|
+
if (
|
|
57
|
+
record === null ||
|
|
58
|
+
typeof record !== 'object' ||
|
|
59
|
+
typeof record.clip !== 'string' ||
|
|
60
|
+
record.clip.length === 0 ||
|
|
61
|
+
typeof record.timeSeconds !== 'number' ||
|
|
62
|
+
!Number.isFinite(record.timeSeconds) ||
|
|
63
|
+
record.timeSeconds < 0
|
|
64
|
+
) {
|
|
65
|
+
return `${verb} pose must be {clip: non-empty string, timeSeconds >= 0}.`;
|
|
66
|
+
}
|
|
67
|
+
return { clip: record.clip, timeSeconds: record.timeSeconds };
|
|
68
|
+
}
|