@volter/editor-threejs 0.5.65 → 0.5.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +2 -0
- package/contributions/animation-mixers.service.ts +20 -0
- package/contributions/animation-timeline.utility.tsx +44 -0
- package/contributions/three-integration.service.ts +13 -0
- package/dist-node/serving.mjs +405 -0
- package/package.json +113 -5
- package/serving/animation-live-module.ts +70 -0
- package/serving/animation-stamp.ts +88 -0
- package/serving/index.ts +14 -0
- package/serving/model-import-conversion.ts +344 -0
- package/src/adapter/ingest/scene-capture.ts +1 -23
- package/src/adapter/renderer-config.ts +3 -4
- package/src/adapter/three-contract.ts +72 -0
- package/src/animation/live-mixers.ts +55 -0
- package/src/ecs/object-marks.ts +1 -1
- package/src/ecs/user-data.ts +0 -16
- package/src/host-hierarchy-objects.ts +31 -0
- package/src/kit/animation/three-clips-subject.ts +190 -0
- package/src/kit/asset-compare.ts +294 -0
- package/src/kit/asset-preview-command.ts +265 -0
- package/src/kit/asset-preview-framing.ts +357 -0
- package/src/kit/asset-preview.ts +2802 -0
- package/src/kit/asset-workflow/model-inspection.ts +830 -0
- package/src/kit/authoring/component-instance-root.ts +171 -0
- package/src/kit/authoring/design-time-settle.ts +343 -0
- package/src/kit/authoring/live-object-transform.ts +62 -0
- package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
- package/src/kit/authoring/object3d-document-session.ts +1965 -0
- package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
- package/src/kit/authoring/quarks-particle-systems.ts +19 -0
- package/src/kit/authoring/shell-viewport-policy.ts +48 -0
- package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
- package/src/kit/authoring/three-projection-core.ts +226 -0
- package/src/kit/authoring/viewport-pick-context.ts +39 -0
- package/src/kit/authoring/viewport-raycast.ts +240 -0
- package/src/kit/authoring/world-hidden-viewport.ts +95 -0
- package/src/kit/camera-authoring.ts +175 -0
- package/src/kit/components/CameraInfo.tsx +56 -0
- package/src/kit/components/InspectorObjectPreview.tsx +57 -0
- package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
- package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
- package/src/kit/components/StageHost.tsx +2547 -0
- package/src/kit/components/StageOverlays.tsx +21 -0
- package/src/kit/components/StatsOverlay.tsx +78 -0
- package/src/kit/components/ToolObject3DPreview.tsx +39 -0
- package/src/kit/components/ViewportFurniture.tsx +655 -0
- package/src/kit/components/ViewportOverlay.tsx +215 -0
- package/src/kit/components/ViewportShadingMenu.tsx +340 -0
- package/src/kit/components/ViewportViewMenu.tsx +155 -0
- package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
- package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
- package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
- package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
- package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
- package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
- package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
- package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
- package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
- package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
- package/src/kit/components/stage-keyboard.tsx +40 -0
- package/src/kit/components/stage-overlay-set.tsx +105 -0
- package/src/kit/components/stage-presence-markers.ts +482 -0
- package/src/kit/components/stage-transform-chrome.ts +30 -0
- package/src/kit/components/stage-transform-tools.tsx +73 -0
- package/src/kit/components/stage-view-name.ts +30 -0
- package/src/kit/components/standard-viewport-dressing.ts +1042 -0
- package/src/kit/components/world-root-binding.ts +64 -0
- package/src/kit/constraint-helper.ts +338 -0
- package/src/kit/editor-shell-store.ts +814 -0
- package/src/kit/editor-viewport.ts +6621 -0
- package/src/kit/entity-lod.ts +31 -0
- package/src/kit/entity-object.ts +92 -0
- package/src/kit/hierarchy-mark-reader.ts +74 -0
- package/src/kit/instanced-presentation.ts +164 -0
- package/src/kit/live-module-source.ts +230 -0
- package/src/kit/model-thumbnail.ts +539 -0
- package/src/kit/play-camera-flight.ts +300 -0
- package/src/kit/projection/three.ts +898 -0
- package/src/kit/reflection-probe-helper.ts +142 -0
- package/src/kit/scene-document-viewport.ts +51 -0
- package/src/kit/scene-framing.ts +315 -0
- package/src/kit/scene-view-fog.ts +89 -0
- package/src/kit/spatial-handle-visuals.ts +332 -0
- package/src/kit/stories/three-story-model.ts +66 -0
- package/src/kit/three-canvas-render.ts +44 -0
- package/src/kit/three-hierarchy-row-media.ts +26 -0
- package/src/kit/three-inspection-media.ts +73 -0
- package/src/kit/three-integration.ts +86 -0
- package/src/kit/three-state.ts +33 -0
- package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
- package/src/kit/three-viewport/camera-fit.ts +41 -0
- package/src/kit/three-viewport/interactive-renderer.ts +132 -0
- package/src/kit/three-viewport/selection-brackets.ts +355 -0
- package/src/kit/three-viewport/selection-outline.ts +333 -0
- package/src/kit/three-viewport/skeleton-helper.ts +61 -0
- package/src/kit/three-viewport/source-color.ts +197 -0
- package/src/kit/three-viewport/studio-environment.ts +96 -0
- package/src/kit/trigger-volume-helper.ts +116 -0
- package/src/kit/viewport-actions.ts +128 -0
- package/src/kit/viewport-authoring-policy.ts +154 -0
- package/src/kit/viewport-commands.ts +318 -0
- package/src/kit/viewport-hotkeys.ts +119 -0
- package/src/kit/viewport-shading-boundary.ts +12 -0
- package/src/kit/viewport-status-facet.ts +53 -0
- package/src/object3d-contributions.ts +494 -0
- package/src/render/viewport-shading.ts +6 -2
- package/src/viewport/content-bounds.ts +38 -4
- package/src/viewport/environment.ts +16 -0
- package/src/viewport-api.ts +92 -0
- package/src/viewport-door.ts +237 -0
- package/src/animation/animation-clock.ts +0 -479
- package/src/animation/runtime-inspection.ts +0 -45
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `capture-asset-preview` — the Asset Lab's photographs of a model or an entity:
|
|
3
|
+
* the plain four views, a project shot set, the source review set, a compare
|
|
4
|
+
* against a reference GLB and the live scene stage. Registered with the
|
|
5
|
+
* viewport's relay verbs (`viewport-commands.ts`).
|
|
6
|
+
*/
|
|
7
|
+
import type { EditorCommandMessage, EditorCommandResult } from '@volter/editor-sdk/commands';
|
|
8
|
+
import { parseForwardVector } from '@volter/editor-sdk/kit/asset-compare-core';
|
|
9
|
+
import { captureEntityComparePreview, captureModelComparePreview } from './asset-compare';
|
|
10
|
+
import {
|
|
11
|
+
type AssetPreviewBackground,
|
|
12
|
+
captureGlbBytesAssetPreview,
|
|
13
|
+
captureModelAssetPreview,
|
|
14
|
+
captureObjectAssetPreview,
|
|
15
|
+
captureSceneStageAssetPreview,
|
|
16
|
+
captureShotSetAssetPreview,
|
|
17
|
+
captureShotSetGlbBytesPreview,
|
|
18
|
+
captureShotSetModelPreview,
|
|
19
|
+
captureSourceReviewShotSetAssetPreview,
|
|
20
|
+
captureSourceReviewShotSetModelPreview,
|
|
21
|
+
parseShotSetDefinition,
|
|
22
|
+
} from './asset-preview';
|
|
23
|
+
import { activeDocumentAuthoring } from '@volter/editor-sdk/kit/authoring/shell-document-ops';
|
|
24
|
+
import { parseCameraChoice, parsePoseChoice } from '@volter/editor-sdk/kit/capture-camera-pose';
|
|
25
|
+
import type { EditorShellStore } from './editor-shell-store';
|
|
26
|
+
import { entityObject3D } from './entity-object';
|
|
27
|
+
|
|
28
|
+
export async function handleAssetPreviewCommand(
|
|
29
|
+
store: EditorShellStore,
|
|
30
|
+
cmd: EditorCommandMessage,
|
|
31
|
+
): Promise<EditorCommandResult> {
|
|
32
|
+
const assetPath = cmd['assetPath'];
|
|
33
|
+
const entityId = cmd['entityId'];
|
|
34
|
+
// The THIRD source: a GLB that travels IN the command rather than being
|
|
35
|
+
// fetched or looked up — the module-look lane (`project.bake.preview`)
|
|
36
|
+
// builds an Object3D in Node, where there is no GPU, and hands the editor
|
|
37
|
+
// the exported bytes. Counted rather than XOR-ed because there are now more
|
|
38
|
+
// than two sources and "exactly one" has to stay exactly one.
|
|
39
|
+
const glbBase64 = cmd['glbBase64'];
|
|
40
|
+
const sourceCount =
|
|
41
|
+
Number(typeof assetPath === 'string') +
|
|
42
|
+
Number(typeof entityId === 'string') +
|
|
43
|
+
Number(typeof glbBase64 === 'string');
|
|
44
|
+
if (sourceCount !== 1) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
error: 'capture-asset-preview requires exactly one of assetPath, entityId or glbBase64.',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const width = cmd['width'];
|
|
51
|
+
const height = cmd['height'];
|
|
52
|
+
const background = cmd['background'];
|
|
53
|
+
const shots = cmd['shots'];
|
|
54
|
+
const shotSet = cmd['shotSet'];
|
|
55
|
+
const forward = cmd['forward'];
|
|
56
|
+
const compare = cmd['compare'];
|
|
57
|
+
const stage = cmd['stage'];
|
|
58
|
+
const camera = cmd['camera'];
|
|
59
|
+
const pose = cmd['pose'];
|
|
60
|
+
if (
|
|
61
|
+
(width !== undefined && typeof width !== 'number') ||
|
|
62
|
+
(height !== undefined && typeof height !== 'number') ||
|
|
63
|
+
(background !== undefined && background !== 'neutral' && background !== 'transparent') ||
|
|
64
|
+
(shots !== undefined && shots !== 'source')
|
|
65
|
+
) {
|
|
66
|
+
return { ok: false, error: 'Invalid asset preview dimensions, background, or shots mode.' };
|
|
67
|
+
}
|
|
68
|
+
if (stage !== undefined && stage !== 'lab' && stage !== 'scene') {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: `capture-asset-preview stage must be "lab" or "scene", got ${String(stage)}.`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (shotSet !== undefined && shots !== undefined) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: 'capture-asset-preview cannot combine a shotSet definition with a shots mode.',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// The free capture camera and clip pose (`--azimuth/--elevation/--distance`,
|
|
81
|
+
// `--clip/--time`) belong to the plain Asset Lab legs: a shot set / source
|
|
82
|
+
// review / compare each stage their own cameras and poses, and the scene
|
|
83
|
+
// stage photographs an entity where it stands. Refused by name, never
|
|
84
|
+
// silently ignored.
|
|
85
|
+
const cameraChoice = parseCameraChoice('capture-asset-preview', camera);
|
|
86
|
+
if (typeof cameraChoice === 'string') return { ok: false, error: cameraChoice };
|
|
87
|
+
const posedClip = parsePoseChoice('capture-asset-preview', pose);
|
|
88
|
+
if (typeof posedClip === 'string') return { ok: false, error: posedClip };
|
|
89
|
+
if (
|
|
90
|
+
(cameraChoice || posedClip) &&
|
|
91
|
+
(shots !== undefined || shotSet !== undefined || compare !== undefined || stage === 'scene')
|
|
92
|
+
) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
error:
|
|
96
|
+
'capture-asset-preview cannot combine camera/pose with a shots mode, a shotSet ' +
|
|
97
|
+
'definition, compare, or stage "scene" — those legs stage their own cameras and poses.',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// A project-defined labeled shot set travels WITH the command (the CLI
|
|
101
|
+
// resolves `--shots <set>` through the registered
|
|
102
|
+
// `project.<set>.previewShots` tool); validate the untrusted
|
|
103
|
+
// definition at the relay boundary so a malformed contribution fails with
|
|
104
|
+
// a named reason instead of a deep three.js error.
|
|
105
|
+
let shotSetDefinition: ReturnType<typeof parseShotSetDefinition> | undefined;
|
|
106
|
+
if (shotSet !== undefined) {
|
|
107
|
+
try {
|
|
108
|
+
shotSetDefinition = parseShotSetDefinition(shotSet);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const parsedForward = parseForwardVector(forward);
|
|
114
|
+
if (shots === 'source' && parsedForward === null) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
error: 'capture-asset-preview --shots source requires a non-degenerate forward [x,y,z].',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (compare !== undefined) {
|
|
121
|
+
// B8.4 — the compare mode (`vgai screenshot <model.glb> --compare <ref.glb>`).
|
|
122
|
+
// Validated here at the relay boundary so a malformed payload fails with
|
|
123
|
+
// a named reason instead of a deep three.js error.
|
|
124
|
+
if (shots !== undefined || shotSetDefinition !== undefined) {
|
|
125
|
+
return { ok: false, error: 'capture-asset-preview cannot combine compare with shots.' };
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
typeof compare !== 'object' ||
|
|
129
|
+
compare === null ||
|
|
130
|
+
typeof (compare as Record<string, unknown>)['glbBase64'] !== 'string' ||
|
|
131
|
+
((compare as Record<string, unknown>)['forward'] !== undefined &&
|
|
132
|
+
parseForwardVector((compare as Record<string, unknown>)['forward']) === null)
|
|
133
|
+
) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
error:
|
|
137
|
+
'capture-asset-preview compare requires { glbBase64: string, forward?: [x, y, z] } ' +
|
|
138
|
+
'with a non-degenerate ground-plane forward.',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Wire-carried GLB bytes stand NOWHERE, so they take no scene stage and no
|
|
143
|
+
// compare (whose reference is named some other way). An explicit `shotSet`
|
|
144
|
+
// definition is a different matter and is served: a shot set stages the
|
|
145
|
+
// subject itself, which is what lets `vgai screenshot <module> --orbit <n>`
|
|
146
|
+
// circle a model that only ever existed as bytes. `shots: 'source'` stays
|
|
147
|
+
// out — it is the asset-path review set, keyed to a stored forward vector.
|
|
148
|
+
if (typeof glbBase64 === 'string') {
|
|
149
|
+
if (shots !== undefined || compare !== undefined) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
error: 'capture-asset-preview cannot combine glbBase64 with a named shots mode or compare.',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (stage === 'scene') {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
error:
|
|
159
|
+
'capture-asset-preview stage "scene" photographs a live scene entity — glbBase64 bytes stand nowhere in the scene.',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// `stage: 'scene'` photographs a LIVE entity in the live scene (see
|
|
164
|
+
// `captureSceneStageAssetPreview`), so it is meaningful only for the plain
|
|
165
|
+
// four-view entity capture: a model loaded from `assetPath` stands nowhere,
|
|
166
|
+
// and the shot-set/source/compare modes each stage their own subject.
|
|
167
|
+
// Refused by name at this boundary rather than silently downgraded to the
|
|
168
|
+
// lab stage — a caller who asked for the scene and got the studio would
|
|
169
|
+
// never know.
|
|
170
|
+
const liveScene = stage === 'scene' ? store.scene : null;
|
|
171
|
+
if (stage === 'scene') {
|
|
172
|
+
if (typeof entityId !== 'string') {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
error:
|
|
176
|
+
'capture-asset-preview stage "scene" photographs a live scene entity — pass entityId, not assetPath.',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (shots !== undefined || shotSetDefinition !== undefined || compare !== undefined) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
error:
|
|
183
|
+
'capture-asset-preview cannot combine stage "scene" with a shots mode, a shotSet definition or compare.',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (!liveScene) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
error:
|
|
190
|
+
'capture-asset-preview stage "scene" requires a bound viewport scene; none is bound yet.',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Resolve the screenshot subject through the SAME active-document adapter
|
|
195
|
+
// that produced `status.entities`, drives Hierarchy/Inspector selection and
|
|
196
|
+
// answers `frame-entity`. An adopted Play scene can own a stable OID in its
|
|
197
|
+
// projection without publishing that object through the shell's edit-mode
|
|
198
|
+
// `objectMap`; checking the map alone made the final door reject an id every
|
|
199
|
+
// preceding door had just accepted.
|
|
200
|
+
const entityObject =
|
|
201
|
+
typeof entityId === 'string'
|
|
202
|
+
? entityObject3D(activeDocumentAuthoring(store.shell), store.objectMap, entityId)
|
|
203
|
+
: null;
|
|
204
|
+
if (typeof entityId === 'string' && !entityObject) {
|
|
205
|
+
return { ok: false, error: `Entity not found: ${entityId}` };
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const options = {
|
|
209
|
+
...(typeof width === 'number' ? { width } : {}),
|
|
210
|
+
...(typeof height === 'number' ? { height } : {}),
|
|
211
|
+
...(background ? { background: background as AssetPreviewBackground } : {}),
|
|
212
|
+
...(cameraChoice ? { camera: cameraChoice } : {}),
|
|
213
|
+
...(posedClip ? { pose: posedClip } : {}),
|
|
214
|
+
};
|
|
215
|
+
if (compare !== undefined) {
|
|
216
|
+
const compareRecord = compare as { glbBase64: string; forward?: unknown };
|
|
217
|
+
const refForward = parseForwardVector(compareRecord.forward);
|
|
218
|
+
const compareOptions = {
|
|
219
|
+
...(typeof width === 'number' ? { width } : {}),
|
|
220
|
+
...(typeof height === 'number' ? { height } : {}),
|
|
221
|
+
...(refForward ? { refForward } : {}),
|
|
222
|
+
};
|
|
223
|
+
const capture =
|
|
224
|
+
typeof assetPath === 'string'
|
|
225
|
+
? await captureModelComparePreview(assetPath, compareRecord.glbBase64, compareOptions)
|
|
226
|
+
: await captureEntityComparePreview(
|
|
227
|
+
entityObject!,
|
|
228
|
+
compareRecord.glbBase64,
|
|
229
|
+
compareOptions,
|
|
230
|
+
);
|
|
231
|
+
return { ok: true, data: { ...capture } };
|
|
232
|
+
}
|
|
233
|
+
if (shotSetDefinition !== undefined) {
|
|
234
|
+
const capture =
|
|
235
|
+
typeof glbBase64 === 'string'
|
|
236
|
+
? await captureShotSetGlbBytesPreview(glbBase64, shotSetDefinition, options)
|
|
237
|
+
: typeof assetPath === 'string'
|
|
238
|
+
? await captureShotSetModelPreview(assetPath, shotSetDefinition, options)
|
|
239
|
+
: captureShotSetAssetPreview(entityObject!, shotSetDefinition, options);
|
|
240
|
+
return { ok: true, data: { ...capture } };
|
|
241
|
+
}
|
|
242
|
+
if (shots === 'source') {
|
|
243
|
+
const capture =
|
|
244
|
+
typeof assetPath === 'string'
|
|
245
|
+
? await captureSourceReviewShotSetModelPreview(assetPath, parsedForward!, options)
|
|
246
|
+
: captureSourceReviewShotSetAssetPreview(entityObject!, parsedForward!, options);
|
|
247
|
+
return { ok: true, data: { ...capture } };
|
|
248
|
+
}
|
|
249
|
+
if (liveScene) {
|
|
250
|
+
const capture = captureSceneStageAssetPreview(entityObject!, liveScene, options);
|
|
251
|
+
return { ok: true, data: { ...capture } };
|
|
252
|
+
}
|
|
253
|
+
if (typeof glbBase64 === 'string') {
|
|
254
|
+
const capture = await captureGlbBytesAssetPreview(glbBase64, options);
|
|
255
|
+
return { ok: true, data: { ...capture } };
|
|
256
|
+
}
|
|
257
|
+
const capture =
|
|
258
|
+
typeof assetPath === 'string'
|
|
259
|
+
? await captureModelAssetPreview(assetPath, options)
|
|
260
|
+
: captureObjectAssetPreview(entityObject!, options);
|
|
261
|
+
return { ok: true, data: { ...capture } };
|
|
262
|
+
} catch (err) {
|
|
263
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The asset-preview capture engine's FRAMING MATH, extracted from
|
|
3
|
+
* `asset-preview.ts` so it can be exercised without a WebGL context.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is pure: it takes points, a camera basis and an output
|
|
6
|
+
* aspect, and returns frustum half-extents or a configured
|
|
7
|
+
* `THREE.OrthographicCamera`. Nothing in this module traverses a scene,
|
|
8
|
+
* renders, or allocates GPU resources — the traversal that FEEDS it
|
|
9
|
+
* (`forEachRenderableVertex`) stays in `asset-preview.ts`, because that is
|
|
10
|
+
* scene-graph knowledge rather than geometry.
|
|
11
|
+
*
|
|
12
|
+
* Two facts drove the extraction:
|
|
13
|
+
*
|
|
14
|
+
* 1. A turntable shot must be framed from the subject's ACTUAL projected
|
|
15
|
+
* extent along that shot's own camera axes. The engine used to derive
|
|
16
|
+
* every yaw's frame from ONE world-axis-aligned bounding box, which is a
|
|
17
|
+
* proxy: it is conservative for a compact subject and wasteful for a
|
|
18
|
+
* long one held in a bent pose, and it centres every yaw on the AABB's
|
|
19
|
+
* centre rather than on what that yaw actually sees.
|
|
20
|
+
* 2. A `bone-zoom` crop needs the same freedom of angle the turntable has.
|
|
21
|
+
* Its basis used to be hardcoded to +Z, so a crop anchored on the tail of
|
|
22
|
+
* an 8 m quadruped photographed the hind legs standing in front of it.
|
|
23
|
+
*
|
|
24
|
+
* @see {@link turntableViewBasis} for the yaw convention both shot kinds share.
|
|
25
|
+
*/
|
|
26
|
+
import * as THREE from 'three';
|
|
27
|
+
|
|
28
|
+
/** Frame margin: the fitted half-extents are scaled by this, so a subject
|
|
29
|
+
* never touches the frame edge. Shared by every orthographic preview
|
|
30
|
+
* camera — the value is the engine's one framing constant. */
|
|
31
|
+
export const ASSET_PREVIEW_PADDING = 1.2;
|
|
32
|
+
|
|
33
|
+
/** The smallest half-extent a fitted frame may have, so a degenerate
|
|
34
|
+
* (single-point) subject still produces a valid frustum. */
|
|
35
|
+
const MIN_HALF_EXTENT = 0.001;
|
|
36
|
+
|
|
37
|
+
/** An orthonormal camera basis. `direction` points from the subject TOWARD
|
|
38
|
+
* the camera (the convention every preview camera in this engine uses:
|
|
39
|
+
* `position = target + direction * distance`). */
|
|
40
|
+
export interface OrthographicViewBasis {
|
|
41
|
+
direction: THREE.Vector3;
|
|
42
|
+
right: THREE.Vector3;
|
|
43
|
+
up: THREE.Vector3;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The turntable yaw convention, shared by `turntable` shots and — since the
|
|
48
|
+
* `bone-zoom` yaw was added — by bone-anchored crops too, so a definition
|
|
49
|
+
* reads one angle convention rather than two.
|
|
50
|
+
*
|
|
51
|
+
* `yaw = 0` puts the camera on +Z looking at the subject's front (the
|
|
52
|
+
* direction `faceFrontSubject` normalises the model's own forward to face),
|
|
53
|
+
* and yaw increases toward +X: `PI` is the back, `-PI/2` and `+PI/2` the two
|
|
54
|
+
* sides — the angles shot sets already label 'left' and 'right'.
|
|
55
|
+
*/
|
|
56
|
+
export function turntableViewBasis(yaw: number): OrthographicViewBasis {
|
|
57
|
+
const direction = new THREE.Vector3(Math.sin(yaw), 0, Math.cos(yaw));
|
|
58
|
+
const worldUp = new THREE.Vector3(0, 1, 0);
|
|
59
|
+
const right = worldUp.clone().cross(direction).normalize();
|
|
60
|
+
const up = direction.clone().cross(right).normalize();
|
|
61
|
+
return { direction, right, up };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The subject's extent along one camera basis, accumulated point by point.
|
|
66
|
+
* This is an oriented (not axis-aligned) measurement: `right`/`up` are the
|
|
67
|
+
* screen axes of the shot being framed, so the span is exactly what that
|
|
68
|
+
* shot's frustum has to contain.
|
|
69
|
+
*/
|
|
70
|
+
export interface ProjectedSpan {
|
|
71
|
+
minRight: number;
|
|
72
|
+
maxRight: number;
|
|
73
|
+
minUp: number;
|
|
74
|
+
maxUp: number;
|
|
75
|
+
minDepth: number;
|
|
76
|
+
maxDepth: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createProjectedSpan(): ProjectedSpan {
|
|
80
|
+
return {
|
|
81
|
+
minRight: Number.POSITIVE_INFINITY,
|
|
82
|
+
maxRight: Number.NEGATIVE_INFINITY,
|
|
83
|
+
minUp: Number.POSITIVE_INFINITY,
|
|
84
|
+
maxUp: Number.NEGATIVE_INFINITY,
|
|
85
|
+
minDepth: Number.POSITIVE_INFINITY,
|
|
86
|
+
maxDepth: Number.NEGATIVE_INFINITY,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isProjectedSpanEmpty(span: ProjectedSpan): boolean {
|
|
91
|
+
return span.maxRight < span.minRight;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Return a span to its empty state, so one allocation can measure a long
|
|
95
|
+
* series of small subjects (the per-primitive coverage walk measures one
|
|
96
|
+
* span per rendered triangle and would otherwise allocate per triangle). */
|
|
97
|
+
export function resetProjectedSpan(span: ProjectedSpan): void {
|
|
98
|
+
span.minRight = Number.POSITIVE_INFINITY;
|
|
99
|
+
span.maxRight = Number.NEGATIVE_INFINITY;
|
|
100
|
+
span.minUp = Number.POSITIVE_INFINITY;
|
|
101
|
+
span.maxUp = Number.NEGATIVE_INFINITY;
|
|
102
|
+
span.minDepth = Number.POSITIVE_INFINITY;
|
|
103
|
+
span.maxDepth = Number.NEGATIVE_INFINITY;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Fold one world-space point into a span. Mutates `span` — a capture walks
|
|
107
|
+
* every rendered vertex once and folds it into every basis it needs. */
|
|
108
|
+
export function expandProjectedSpan(
|
|
109
|
+
span: ProjectedSpan,
|
|
110
|
+
point: THREE.Vector3,
|
|
111
|
+
basis: OrthographicViewBasis,
|
|
112
|
+
): void {
|
|
113
|
+
const right = point.dot(basis.right);
|
|
114
|
+
const up = point.dot(basis.up);
|
|
115
|
+
const depth = point.dot(basis.direction);
|
|
116
|
+
if (right < span.minRight) span.minRight = right;
|
|
117
|
+
if (right > span.maxRight) span.maxRight = right;
|
|
118
|
+
if (up < span.minUp) span.minUp = up;
|
|
119
|
+
if (up > span.maxUp) span.maxUp = up;
|
|
120
|
+
if (depth < span.minDepth) span.minDepth = depth;
|
|
121
|
+
if (depth > span.maxDepth) span.maxDepth = depth;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The world-space point at the centre of a span — where the shot's camera
|
|
125
|
+
* looks. Reconstructed from the basis, so it is the centre of the ORIENTED
|
|
126
|
+
* box the shot sees, not of a world-axis-aligned proxy. */
|
|
127
|
+
export function projectedSpanCenter(
|
|
128
|
+
span: ProjectedSpan,
|
|
129
|
+
basis: OrthographicViewBasis,
|
|
130
|
+
): THREE.Vector3 {
|
|
131
|
+
if (isProjectedSpanEmpty(span)) {
|
|
132
|
+
throw new Error('Asset preview cannot frame an empty projected span.');
|
|
133
|
+
}
|
|
134
|
+
return new THREE.Vector3()
|
|
135
|
+
.addScaledVector(basis.right, (span.minRight + span.maxRight) / 2)
|
|
136
|
+
.addScaledVector(basis.up, (span.minUp + span.maxUp) / 2)
|
|
137
|
+
.addScaledVector(basis.direction, (span.minDepth + span.maxDepth) / 2);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** An orthographic frustum's half-extents, already padded and aspect-fitted. */
|
|
141
|
+
export interface OrthographicFrame {
|
|
142
|
+
halfWidth: number;
|
|
143
|
+
halfHeight: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Fit half-extents that contain `halfRight` x `halfUp` at the output aspect,
|
|
148
|
+
* with the engine's standard padding. Height leads and width follows, so the
|
|
149
|
+
* rendered pixels are never anisotropic relative to the subject.
|
|
150
|
+
*/
|
|
151
|
+
export function fitOrthographicFrame(
|
|
152
|
+
halfRight: number,
|
|
153
|
+
halfUp: number,
|
|
154
|
+
aspect: number,
|
|
155
|
+
padding: number = ASSET_PREVIEW_PADDING,
|
|
156
|
+
): OrthographicFrame {
|
|
157
|
+
const halfHeight = Math.max(halfUp, halfRight / aspect, MIN_HALF_EXTENT) * padding;
|
|
158
|
+
return { halfHeight, halfWidth: halfHeight * aspect };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Fit the frame a single projected span needs. */
|
|
162
|
+
export function fitProjectedSpanFrame(
|
|
163
|
+
span: ProjectedSpan,
|
|
164
|
+
aspect: number,
|
|
165
|
+
padding: number = ASSET_PREVIEW_PADDING,
|
|
166
|
+
): OrthographicFrame {
|
|
167
|
+
if (isProjectedSpanEmpty(span)) {
|
|
168
|
+
throw new Error('Asset preview cannot frame an empty projected span.');
|
|
169
|
+
}
|
|
170
|
+
return fitOrthographicFrame(
|
|
171
|
+
(span.maxRight - span.minRight) / 2,
|
|
172
|
+
(span.maxUp - span.minUp) / 2,
|
|
173
|
+
aspect,
|
|
174
|
+
padding,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The frame every turntable shot of one staged subject shares: the UNION of
|
|
180
|
+
* what each yaw needs.
|
|
181
|
+
*
|
|
182
|
+
* Why the union rather than a per-shot exact fit. A verify shot set is read
|
|
183
|
+
* as a SET — the reviewer compares the same junction across yaws (and, on a
|
|
184
|
+
* contact sheet, side by side). A per-shot fit silently rescales the subject
|
|
185
|
+
* between frames, so a wing that looks thicker at yaw 0 than at yaw PI/2
|
|
186
|
+
* would be a framing artefact rather than geometry, which is exactly the
|
|
187
|
+
* kind of false signal a verify render exists to eliminate. The union keeps
|
|
188
|
+
* ONE scale for every turntable shot of a staged subject while each shot is
|
|
189
|
+
* still CENTRED on its own projected span — so nothing clips and nothing
|
|
190
|
+
* rescales.
|
|
191
|
+
*
|
|
192
|
+
* The union is per staged subject (the rest scene, and each named pose's
|
|
193
|
+
* disposable snapshot) rather than across poses: poses are separately
|
|
194
|
+
* staged already, and a single extreme pose must not shrink every other
|
|
195
|
+
* frame in the set.
|
|
196
|
+
*/
|
|
197
|
+
export function unionOrthographicFrames(frames: readonly OrthographicFrame[]): OrthographicFrame {
|
|
198
|
+
if (frames.length === 0) {
|
|
199
|
+
throw new Error('Asset preview cannot union an empty set of frames.');
|
|
200
|
+
}
|
|
201
|
+
let halfWidth = 0;
|
|
202
|
+
let halfHeight = 0;
|
|
203
|
+
for (const frame of frames) {
|
|
204
|
+
halfWidth = Math.max(halfWidth, frame.halfWidth);
|
|
205
|
+
halfHeight = Math.max(halfHeight, frame.halfHeight);
|
|
206
|
+
}
|
|
207
|
+
return { halfWidth, halfHeight };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The frame a bone-anchored crop needs: a margin proportional to the WHOLE
|
|
212
|
+
* model's own bounding radius (never a fixed reference-human height, so a
|
|
213
|
+
* goblin's crop stays goblin-scaled), widened until it actually contains
|
|
214
|
+
* every anchor.
|
|
215
|
+
*
|
|
216
|
+
* Offsets are measured along the SHOT's basis, so a crop taken from the side
|
|
217
|
+
* frames the anchors it names from the side. At the default yaw the basis is
|
|
218
|
+
* (+X, +Y, +Z) and this reduces exactly to the world-axis arithmetic it
|
|
219
|
+
* replaced.
|
|
220
|
+
*/
|
|
221
|
+
export function fitBoneZoomFrame(
|
|
222
|
+
anchors: readonly THREE.Vector3[],
|
|
223
|
+
center: THREE.Vector3,
|
|
224
|
+
basis: OrthographicViewBasis,
|
|
225
|
+
overallRadius: number,
|
|
226
|
+
spanFraction: number,
|
|
227
|
+
aspect: number,
|
|
228
|
+
): OrthographicFrame {
|
|
229
|
+
const margin = Math.max(overallRadius * spanFraction, MIN_HALF_EXTENT);
|
|
230
|
+
let halfWidth = margin;
|
|
231
|
+
let halfHeight = margin;
|
|
232
|
+
const offset = new THREE.Vector3();
|
|
233
|
+
for (const point of anchors) {
|
|
234
|
+
offset.copy(point).sub(center);
|
|
235
|
+
halfWidth = Math.max(halfWidth, Math.abs(offset.dot(basis.right)) + margin);
|
|
236
|
+
halfHeight = Math.max(halfHeight, Math.abs(offset.dot(basis.up)) + margin);
|
|
237
|
+
}
|
|
238
|
+
// Keep the crop's aspect consistent with the output image so neither axis
|
|
239
|
+
// is silently clipped relative to what actually got rendered.
|
|
240
|
+
halfWidth = Math.max(halfWidth, halfHeight * aspect);
|
|
241
|
+
halfHeight = Math.max(halfHeight, halfWidth / aspect);
|
|
242
|
+
return { halfWidth, halfHeight };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The mean of the anchor points a bone-zoom crop is built around. */
|
|
246
|
+
export function boneZoomCenter(anchors: readonly THREE.Vector3[]): THREE.Vector3 {
|
|
247
|
+
if (anchors.length === 0) {
|
|
248
|
+
throw new Error('Asset preview cannot centre a bone-zoom crop on zero anchors.');
|
|
249
|
+
}
|
|
250
|
+
const center = new THREE.Vector3();
|
|
251
|
+
for (const point of anchors) center.add(point);
|
|
252
|
+
return center.multiplyScalar(1 / anchors.length);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* What one orthographic shot actually renders, in the SAME scalar
|
|
257
|
+
* coordinates a {@link ProjectedSpan} is measured in (`point.dot(right)`,
|
|
258
|
+
* `point.dot(up)`, `point.dot(direction)`) — so a shot's frame and the
|
|
259
|
+
* subject's measured extent are directly comparable without a second
|
|
260
|
+
* projection convention.
|
|
261
|
+
*
|
|
262
|
+
* This exists for the empty-frame guard: a verify shot that frames NO
|
|
263
|
+
* geometry renders pure background, lands on a contact sheet looking like
|
|
264
|
+
* coverage, and proves nothing. Detecting that is a containment question
|
|
265
|
+
* about the vertices the capture already walks, not a question about pixels.
|
|
266
|
+
*/
|
|
267
|
+
export interface ShotFrameWindow {
|
|
268
|
+
minRight: number;
|
|
269
|
+
maxRight: number;
|
|
270
|
+
minUp: number;
|
|
271
|
+
maxUp: number;
|
|
272
|
+
/** Depth here is `point.dot(direction)`, which INCREASES toward the camera
|
|
273
|
+
* (`direction` points from subject to camera), so the near plane is the
|
|
274
|
+
* MAXIMUM depth and the far plane the minimum. */
|
|
275
|
+
minDepth: number;
|
|
276
|
+
maxDepth: number;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The window a configured orthographic shot camera renders, read back off the
|
|
281
|
+
* camera itself rather than recomputed from the inputs that built it — so the
|
|
282
|
+
* guard tests what will be drawn, including the padding and aspect fitting
|
|
283
|
+
* {@link fitOrthographicFrame} applied.
|
|
284
|
+
*/
|
|
285
|
+
export function orthographicShotFrameWindow(
|
|
286
|
+
camera: THREE.OrthographicCamera,
|
|
287
|
+
basis: OrthographicViewBasis,
|
|
288
|
+
): ShotFrameWindow {
|
|
289
|
+
const right = camera.position.dot(basis.right);
|
|
290
|
+
const up = camera.position.dot(basis.up);
|
|
291
|
+
const depth = camera.position.dot(basis.direction);
|
|
292
|
+
const zoom = camera.zoom || 1;
|
|
293
|
+
return {
|
|
294
|
+
minRight: right + camera.left / zoom,
|
|
295
|
+
maxRight: right + camera.right / zoom,
|
|
296
|
+
minUp: up + camera.bottom / zoom,
|
|
297
|
+
maxUp: up + camera.top / zoom,
|
|
298
|
+
minDepth: depth - camera.far,
|
|
299
|
+
maxDepth: depth - camera.near,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Could anything measured into `span` appear in this shot?
|
|
305
|
+
*
|
|
306
|
+
* `span` is expected to be ONE rendered primitive (a triangle, a line
|
|
307
|
+
* segment, a sprite quad), measured in the shot's own basis — so this is an
|
|
308
|
+
* overlap test between the primitive's projected bounding box and the frame.
|
|
309
|
+
*
|
|
310
|
+
* The asymmetry is deliberate and is what makes the empty-frame warning
|
|
311
|
+
* trustworthy: a primitive whose box overlaps the frame may still miss it
|
|
312
|
+
* (a thin diagonal triangle), so "overlaps" does not prove the shot shows
|
|
313
|
+
* something — but NO primitive overlapping does prove it shows nothing. The
|
|
314
|
+
* guard only ever claims the second. Testing individual VERTICES instead
|
|
315
|
+
* would invert that: a crop sitting inside one large flat face contains no
|
|
316
|
+
* vertex while rendering solid geometry, and would be reported as empty.
|
|
317
|
+
*/
|
|
318
|
+
export function spanOverlapsShotFrame(span: ProjectedSpan, window: ShotFrameWindow): boolean {
|
|
319
|
+
if (isProjectedSpanEmpty(span)) return false;
|
|
320
|
+
return (
|
|
321
|
+
span.maxRight >= window.minRight &&
|
|
322
|
+
span.minRight <= window.maxRight &&
|
|
323
|
+
span.maxUp >= window.minUp &&
|
|
324
|
+
span.minUp <= window.maxUp &&
|
|
325
|
+
span.maxDepth >= window.minDepth &&
|
|
326
|
+
span.minDepth <= window.maxDepth
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Build the configured orthographic camera for one shot. `distance` and
|
|
332
|
+
* `far` are the depth budget the caller derives from the subject's own
|
|
333
|
+
* bounding radius; the frame and target come from the projected measurements
|
|
334
|
+
* above.
|
|
335
|
+
*/
|
|
336
|
+
export function createOrthographicShotCamera(
|
|
337
|
+
target: THREE.Vector3,
|
|
338
|
+
basis: OrthographicViewBasis,
|
|
339
|
+
frame: OrthographicFrame,
|
|
340
|
+
distance: number,
|
|
341
|
+
far: number,
|
|
342
|
+
): THREE.OrthographicCamera {
|
|
343
|
+
const camera = new THREE.OrthographicCamera(
|
|
344
|
+
-frame.halfWidth,
|
|
345
|
+
frame.halfWidth,
|
|
346
|
+
frame.halfHeight,
|
|
347
|
+
-frame.halfHeight,
|
|
348
|
+
0.01,
|
|
349
|
+
far,
|
|
350
|
+
);
|
|
351
|
+
camera.position.copy(target).addScaledVector(basis.direction, distance);
|
|
352
|
+
camera.up.copy(basis.up);
|
|
353
|
+
camera.lookAt(target);
|
|
354
|
+
camera.updateProjectionMatrix();
|
|
355
|
+
camera.updateMatrixWorld(true);
|
|
356
|
+
return camera;
|
|
357
|
+
}
|