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