@volter/editor-threejs 0.5.66 → 0.5.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/contributions/animation-timeline.utility.tsx +44 -0
  2. package/contributions/three-integration.service.ts +13 -0
  3. package/package.json +96 -6
  4. package/src/adapter/renderer-config.ts +3 -4
  5. package/src/adapter/three-contract.ts +72 -0
  6. package/src/ecs/object-marks.ts +1 -1
  7. package/src/ecs/user-data.ts +0 -9
  8. package/src/host-hierarchy-objects.ts +31 -0
  9. package/src/kit/animation/three-clips-subject.ts +190 -0
  10. package/src/kit/asset-compare.ts +294 -0
  11. package/src/kit/asset-preview-command.ts +265 -0
  12. package/src/kit/asset-preview-framing.ts +357 -0
  13. package/src/kit/asset-preview.ts +2802 -0
  14. package/src/kit/asset-workflow/model-inspection.ts +830 -0
  15. package/src/kit/authoring/component-instance-root.ts +171 -0
  16. package/src/kit/authoring/design-time-settle.ts +343 -0
  17. package/src/kit/authoring/live-object-transform.ts +62 -0
  18. package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
  19. package/src/kit/authoring/object3d-document-session.ts +1965 -0
  20. package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
  21. package/src/kit/authoring/quarks-particle-systems.ts +19 -0
  22. package/src/kit/authoring/shell-viewport-policy.ts +48 -0
  23. package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
  24. package/src/kit/authoring/three-projection-core.ts +226 -0
  25. package/src/kit/authoring/viewport-pick-context.ts +39 -0
  26. package/src/kit/authoring/viewport-raycast.ts +240 -0
  27. package/src/kit/authoring/world-hidden-viewport.ts +95 -0
  28. package/src/kit/camera-authoring.ts +175 -0
  29. package/src/kit/components/CameraInfo.tsx +56 -0
  30. package/src/kit/components/InspectorObjectPreview.tsx +57 -0
  31. package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
  32. package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
  33. package/src/kit/components/StageHost.tsx +2547 -0
  34. package/src/kit/components/StageOverlays.tsx +21 -0
  35. package/src/kit/components/StatsOverlay.tsx +78 -0
  36. package/src/kit/components/ToolObject3DPreview.tsx +39 -0
  37. package/src/kit/components/ViewportFurniture.tsx +655 -0
  38. package/src/kit/components/ViewportOverlay.tsx +215 -0
  39. package/src/kit/components/ViewportShadingMenu.tsx +340 -0
  40. package/src/kit/components/ViewportViewMenu.tsx +155 -0
  41. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
  42. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  43. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
  44. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
  45. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  46. package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
  47. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
  48. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  49. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
  50. package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
  51. package/src/kit/components/stage-keyboard.tsx +40 -0
  52. package/src/kit/components/stage-overlay-set.tsx +105 -0
  53. package/src/kit/components/stage-presence-markers.ts +482 -0
  54. package/src/kit/components/stage-transform-chrome.ts +30 -0
  55. package/src/kit/components/stage-transform-tools.tsx +73 -0
  56. package/src/kit/components/stage-view-name.ts +30 -0
  57. package/src/kit/components/standard-viewport-dressing.ts +1042 -0
  58. package/src/kit/components/world-root-binding.ts +64 -0
  59. package/src/kit/constraint-helper.ts +338 -0
  60. package/src/kit/editor-shell-store.ts +814 -0
  61. package/src/kit/editor-viewport.ts +6621 -0
  62. package/src/kit/entity-lod.ts +31 -0
  63. package/src/kit/entity-object.ts +92 -0
  64. package/src/kit/hierarchy-mark-reader.ts +74 -0
  65. package/src/kit/instanced-presentation.ts +164 -0
  66. package/src/kit/live-module-source.ts +230 -0
  67. package/src/kit/model-thumbnail.ts +539 -0
  68. package/src/kit/play-camera-flight.ts +300 -0
  69. package/src/kit/projection/three.ts +898 -0
  70. package/src/kit/reflection-probe-helper.ts +142 -0
  71. package/src/kit/scene-document-viewport.ts +51 -0
  72. package/src/kit/scene-framing.ts +315 -0
  73. package/src/kit/scene-view-fog.ts +89 -0
  74. package/src/kit/spatial-handle-visuals.ts +332 -0
  75. package/src/kit/stories/three-story-model.ts +66 -0
  76. package/src/kit/three-canvas-render.ts +44 -0
  77. package/src/kit/three-hierarchy-row-media.ts +26 -0
  78. package/src/kit/three-inspection-media.ts +73 -0
  79. package/src/kit/three-integration.ts +86 -0
  80. package/src/kit/three-state.ts +33 -0
  81. package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
  82. package/src/kit/three-viewport/camera-fit.ts +41 -0
  83. package/src/kit/three-viewport/interactive-renderer.ts +132 -0
  84. package/src/kit/three-viewport/selection-brackets.ts +355 -0
  85. package/src/kit/three-viewport/selection-outline.ts +333 -0
  86. package/src/kit/three-viewport/skeleton-helper.ts +61 -0
  87. package/src/kit/three-viewport/source-color.ts +197 -0
  88. package/src/kit/three-viewport/studio-environment.ts +96 -0
  89. package/src/kit/trigger-volume-helper.ts +116 -0
  90. package/src/kit/viewport-actions.ts +128 -0
  91. package/src/kit/viewport-authoring-policy.ts +154 -0
  92. package/src/kit/viewport-commands.ts +318 -0
  93. package/src/kit/viewport-hotkeys.ts +119 -0
  94. package/src/kit/viewport-shading-boundary.ts +12 -0
  95. package/src/kit/viewport-status-facet.ts +53 -0
  96. package/src/object3d-contributions.ts +494 -0
  97. package/src/render/viewport-shading.ts +6 -2
  98. package/src/viewport-api.ts +92 -0
  99. package/src/viewport-door.ts +237 -0
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Native Three selection presentation for editor-owned authoring viewports.
3
+ *
4
+ * A transform box answers where an object's editable bounds are; it does not
5
+ * answer which pixels belong to the selected object. Unity and Unreal draw
6
+ * both for that reason. This module supplies the geometry-following half with
7
+ * postprocessing's real OutlineEffect, while EditorViewport keeps native
8
+ * helpers for cameras, lights, bones and other nodes that draw no geometry.
9
+ *
10
+ * The effect is presentation only. It never enters a project render pipeline,
11
+ * changes source, or adds an authoring-data format.
12
+ */
13
+
14
+ import {
15
+ EDITOR_LAYER,
16
+ EDITOR_SELECTION_LAYER,
17
+ isInEditorOwnedSubtree,
18
+ } from '@volter/editor-threejs/viewport/editor-layers';
19
+ import type { EffectPass, OutlineEffect } from 'postprocessing';
20
+ import type * as THREE from 'three';
21
+
22
+ /**
23
+ * The three postprocessing bindings {@link createThreeSelectionOutline} needs,
24
+ * passed in rather than statically imported.
25
+ *
26
+ * `postprocessing` is ~200 kB of shaders and passes, and the surfaces this
27
+ * module serves do not all want it: `editor-viewport.ts` imports only
28
+ * {@link collectThreeSelectionOutlineTargets} (a pure traversal), and the
29
+ * Asset Lab 3D document builds its composer on its FIRST frame, not at module
30
+ * load. A static import here put the whole library in both closures
31
+ * eagerly. A caller that already loaded postprocessing for its own composer
32
+ * hands over its own bindings (`the world root's stage`); one that has not opens the
33
+ * lazy door {@link loadThreeSelectionOutline}.
34
+ */
35
+ export interface ThreeSelectionOutlineEffects {
36
+ readonly OutlineEffect: typeof import('postprocessing').OutlineEffect;
37
+ readonly BlendFunction: typeof import('postprocessing').BlendFunction;
38
+ readonly KernelSize: typeof import('postprocessing').KernelSize;
39
+ }
40
+
41
+ /**
42
+ * Build the editor's real postprocessing effect. A half-resolution medium
43
+ * blur resolves to a stable 3–4 CSS-pixel silhouette on both 1x and HiDPI
44
+ * displays instead of a driver-dependent hairline; pulse is deliberately
45
+ * disabled because selection is state, not an alert.
46
+ */
47
+ export function createThreeSelectionOutline(
48
+ { BlendFunction, KernelSize, OutlineEffect }: ThreeSelectionOutlineEffects,
49
+ scene: THREE.Scene,
50
+ camera: THREE.Camera,
51
+ colors: OutlineColors,
52
+ ): OutlineEffect {
53
+ const effect = new OutlineEffect(scene, camera, {
54
+ // Alpha keeps the selection color legible over both black and white
55
+ // materials. The effect's default screen blend washes out on pale assets.
56
+ blendFunction: BlendFunction.ALPHA,
57
+ visibleEdgeColor: colors.visible,
58
+ hiddenEdgeColor: colors.hidden,
59
+ edgeStrength: 5,
60
+ pulseSpeed: 0,
61
+ blur: true,
62
+ kernelSize: KernelSize.MEDIUM,
63
+ resolutionScale: 0.5,
64
+ xRay: true,
65
+ });
66
+ // OutlineEffect temporarily removes layer 0 from selected meshes, renders
67
+ // scene depth, then renders only its selection layer for the mask. The
68
+ // editor camera normally enables ALL layers so it can draw layer-31 gizmos;
69
+ // without this exclusion the selected mesh remains in the depth pass via
70
+ // the effect's own layer and both masks cancel to no visible edge.
71
+ effect.selectionLayer = EDITOR_SELECTION_LAYER;
72
+ camera.layers.disable(EDITOR_SELECTION_LAYER);
73
+ // A CRISP LINE NEEDS ITS COLOUR UNSCALED. postprocessing's composite multiplies the edge colour
74
+ // by the edge value and uses that value again as alpha, so a partial edge darkens (Blender's
75
+ // orange read brown) and a strength above one pushes the colour past itself (it read white).
76
+ // Under `vgaiCrisp` the colour is divided back out of the edge value, leaving only the alpha
77
+ // to follow the edge; the editor's own halo keeps the library's composite. And the library
78
+ // draws an edge only OUTSIDE the silhouette (it scales the edge by the mask, which is zero on
79
+ // the selection); Blender's detect marks the pixels on both sides of it, so a crisp line keeps
80
+ // its inner half.
81
+ const shader = effect.getFragmentShader();
82
+ const stock = 'vec3 color=edge.x*visibleEdgeColor+edge.y*hiddenEdgeColor;';
83
+ const outsideOnly = 'edge*=(edgeStrength*mask.x*pulse);';
84
+ if (shader.includes(stock) && shader.includes(outsideOnly)) {
85
+ (effect as unknown as { setFragmentShader(source: string): void }).setFragmentShader(
86
+ shader
87
+ .replace('uniform float edgeStrength;', 'uniform float edgeStrength;uniform float vgaiCrisp;')
88
+ .replace(outsideOnly, 'edge*=(edgeStrength*(vgaiCrisp>0.5?1.0:mask.x)*pulse);')
89
+ .replace(
90
+ stock,
91
+ `${stock}if(vgaiCrisp>0.5){float vgaiSum=edge.x+edge.y;if(vgaiSum>0.0)color/=vgaiSum;edge=min(edge,vec2(1.0));}`,
92
+ ),
93
+ );
94
+ // Any `{ value }` is a uniform to three; no runtime three import in this module.
95
+ effect.uniforms.set('vgaiCrisp', { value: 0 } as THREE.Uniform<number>);
96
+ }
97
+ // THE EDITOR'S OWN OVERLAYS DO NOT HIDE THE SELECTION. The effect measures what stands in
98
+ // front of the selected object with a depth pass under an override material, which writes
99
+ // depth for everything the camera sees — the floor grid included, though it writes none
100
+ // itself — so the grid hid the outline's lower half wherever it crossed the object (measured
101
+ // on Unity's look). Its layer is off the camera for the effect's update only; the effect
102
+ // saves and restores the camera's mask around its own mask pass.
103
+ const update = effect.update.bind(effect);
104
+ effect.update = (renderer, inputBuffer, deltaTime) => {
105
+ const current = (effect as unknown as { camera: THREE.Camera }).camera;
106
+ const editorLayerOn = current.layers.isEnabled(EDITOR_LAYER);
107
+ current.layers.disable(EDITOR_LAYER);
108
+ try {
109
+ update(renderer, inputBuffer, deltaTime);
110
+ } finally {
111
+ if (editorLayerOn) current.layers.enable(EDITOR_LAYER);
112
+ }
113
+ };
114
+ outlineState.set(effect, { colors, roots: 0 });
115
+ paintOutline(effect);
116
+ return effect;
117
+ }
118
+
119
+ /**
120
+ * ONE EFFECT PER RENDERER, REUSED. `OutlineEffect` constructs its `Selection`
121
+ * with a render layer from a module-wide counter that warns "Layer out of
122
+ * range, resetting to 2" on its 30th construction in a page; the editor
123
+ * overrides that layer, but built one effect per document session and per
124
+ * world pipeline rebuild, the warning sat on every session's console as
125
+ * unresolved work. A released effect goes back to its renderer's pool — its
126
+ * render targets belong to that renderer's context — and the next surface on
127
+ * that renderer re-points it at its own scene and camera. Constructions are
128
+ * bounded by the surfaces open at once.
129
+ */
130
+ const pooled = new WeakMap<object, OutlineEffect[]>();
131
+
132
+ /** An outline for `renderer`: a released one re-pointed at `scene` and `camera`, or a new one. */
133
+ export function acquireThreeSelectionOutline(
134
+ effects: ThreeSelectionOutlineEffects,
135
+ renderer: object,
136
+ scene: THREE.Scene,
137
+ camera: THREE.Camera,
138
+ colors: OutlineColors,
139
+ ): OutlineEffect {
140
+ const effect = pooled.get(renderer)?.pop();
141
+ if (!effect) return createThreeSelectionOutline(effects, scene, camera, colors);
142
+ effect.mainScene = scene;
143
+ effect.mainCamera = camera;
144
+ camera.layers.disable(EDITOR_SELECTION_LAYER);
145
+ outlineState.set(effect, { colors, roots: 0 });
146
+ paintOutline(effect);
147
+ return effect;
148
+ }
149
+
150
+ /**
151
+ * Give `effect` back to `renderer`'s pool, detached from `pass` so the pass's
152
+ * disposal leaves it intact. Its selection is cleared first: a `Selection`
153
+ * owns temporary render-layer bits on every object in it.
154
+ */
155
+ export function releaseThreeSelectionOutline(
156
+ renderer: object,
157
+ effect: OutlineEffect,
158
+ pass: EffectPass | null,
159
+ ): void {
160
+ effect.selection.clear();
161
+ // `setEffects` is the pass's own (protected) way to let go of its effects.
162
+ (pass as unknown as { setEffects(effects: never[]): void } | null)?.setEffects([]);
163
+ const free = pooled.get(renderer) ?? [];
164
+ if (!free.includes(effect)) free.push(effect);
165
+ pooled.set(renderer, free);
166
+ }
167
+
168
+ /** THE LAZY DOOR: load `postprocessing` on demand and build the effect. For a
169
+ * caller with no composer of its own, so the library arrives with the
170
+ * silhouette rather than with the surface. Repeat calls are free — the module
171
+ * is cached after the first. */
172
+ export async function loadThreeSelectionOutline(
173
+ scene: THREE.Scene,
174
+ camera: THREE.Camera,
175
+ colors: OutlineColors,
176
+ ): Promise<OutlineEffect> {
177
+ const { BlendFunction, KernelSize, OutlineEffect } = await import('postprocessing');
178
+ return createThreeSelectionOutline(
179
+ { BlendFunction, KernelSize, OutlineEffect },
180
+ scene,
181
+ camera,
182
+ colors,
183
+ );
184
+ }
185
+
186
+ type OutlineColors = {
187
+ readonly visible: number;
188
+ readonly hidden: number;
189
+ readonly active?: { readonly visible: number; readonly hidden: number };
190
+ readonly outline?: { readonly style: 'soft' | 'crisp'; readonly width: number | null; readonly hidden: boolean };
191
+ };
192
+
193
+ /**
194
+ * BLENDER'S OUTLINE DETECT (`overlay_outline_detect_frag.glsl`), over our selection mask instead
195
+ * of its object-id buffer: a pixel is outline when the mask changes along an axis within the
196
+ * reach, looked for at one pixel and, with `do_thick_outlines`, two, on both sides of the
197
+ * silhouette. Only the axes are looked along, which is why Blender's convex corner leaves its
198
+ * one diagonal pixel dark. Blender's later line anti-aliasing is not transcribed: diagonal edges
199
+ * step where its are smoothed. Visibility is the stock material's: the least visible of the
200
+ * pixels looked at.
201
+ */
202
+ const BLENDER_OUTLINE_DETECT = /* glsl */ `uniform lowp sampler2D inputBuffer;uniform vec2 texelSize;uniform float vgaiReach;
203
+ varying vec2 vUv0;varying vec2 vUv1;varying vec2 vUv2;varying vec2 vUv3;
204
+ void main(){
205
+ vec2 uv=(vUv0+vUv1)*0.5;
206
+ vec2 c=texture2D(inputBuffer,uv).rg;
207
+ float edge=0.0;float visibility=1.0;
208
+ for(int i=1;i<=2;i++){
209
+ if(float(i)>vgaiReach||edge>0.0)break;
210
+ vec2 o=texelSize*float(i);
211
+ vec2 s0=texture2D(inputBuffer,uv+vec2(o.x,0.0)).rg;vec2 s1=texture2D(inputBuffer,uv-vec2(o.x,0.0)).rg;
212
+ vec2 s2=texture2D(inputBuffer,uv+vec2(0.0,o.y)).rg;vec2 s3=texture2D(inputBuffer,uv-vec2(0.0,o.y)).rg;
213
+ if(abs(s0.x-c.x)>0.5||abs(s1.x-c.x)>0.5||abs(s2.x-c.x)>0.5||abs(s3.x-c.x)>0.5)edge=1.0;
214
+ visibility=min(min(s0.y,s1.y),min(s2.y,s3.y));
215
+ }
216
+ gl_FragColor.rg=(1.0-visibility>0.001)?vec2(edge,0.0):vec2(0.0,edge);
217
+ }`;
218
+
219
+ /** The detect material's own shader, kept to put back when a look turns the crisp form off. */
220
+ const stockDetect = new WeakMap<THREE.ShaderMaterial, string>();
221
+
222
+ /** Each effect's palette colours and how many roots it last outlined, so a selection change
223
+ * and a palette change each paint the right one. */
224
+ const outlineState = new WeakMap<OutlineEffect, { colors: OutlineColors; roots: number }>();
225
+
226
+ /**
227
+ * THE ACTIVE OBJECT'S OUTLINE: a lone selected object is the active one, and a palette that
228
+ * names an active colour draws it in that (Blender's light orange over its darker selection
229
+ * orange, `modeling-object-selected.png`). Several selected objects draw in the selection
230
+ * colour; which of them is active is not yet told apart.
231
+ */
232
+ function paintOutline(effect: OutlineEffect): void {
233
+ const state = outlineState.get(effect);
234
+ if (!state) return;
235
+ const colors = state.roots === 1 && state.colors.active ? state.colors.active : state.colors;
236
+ effect.visibleEdgeColor.setHex(colors.visible);
237
+ effect.hiddenEdgeColor.setHex(colors.hidden);
238
+ // THE LOOK'S FORM. The editor's own is the soft halo `createThreeSelectionOutline` builds (a
239
+ // half-resolution mask under a medium blur). A crisp line is Blender's: the mask at full
240
+ // resolution, edge-detected by `BLENDER_OUTLINE_DETECT` and not blurred, so its band is hard
241
+ // and its corners square.
242
+ const form = state.colors.outline;
243
+ const crisp = form?.style === 'crisp';
244
+ const width = form?.width ?? 2;
245
+ effect.resolution.scale = crisp ? 1 : 0.5;
246
+ effect.blurPass.enabled = !crisp;
247
+ effect.blurPass.kernelSize = 2;
248
+ const detect = (effect as unknown as { outlinePass: { fullscreenMaterial: THREE.ShaderMaterial } }).outlinePass
249
+ .fullscreenMaterial;
250
+ const stock = stockDetect.get(detect) ?? detect.fragmentShader;
251
+ stockDetect.set(detect, stock);
252
+ const fragment = crisp ? BLENDER_OUTLINE_DETECT : stock;
253
+ if (detect.fragmentShader !== fragment) {
254
+ detect.fragmentShader = fragment;
255
+ detect.needsUpdate = true;
256
+ }
257
+ // Half the width on each side of the silhouette's edge: Blender's 4 device px is its
258
+ // `do_thick_outlines` reach of 2.
259
+ detect.uniforms['vgaiReach'] = { value: Math.max(1, Math.round(width / 2)) } as THREE.IUniform<number>;
260
+ effect.edgeStrength = crisp ? 8 : 5;
261
+ const crispUniform = effect.uniforms.get('vgaiCrisp');
262
+ if (crispUniform) crispUniform.value = crisp ? 1 : 0;
263
+ effect.xRay = form?.hidden ?? true;
264
+ }
265
+
266
+ /** Apply supplied renderer-ready colors without rebuilding GPU pass resources. */
267
+ export function setThreeSelectionOutlineColors(effect: OutlineEffect, colors: OutlineColors): void {
268
+ outlineState.set(effect, { colors, roots: outlineState.get(effect)?.roots ?? 0 });
269
+ paintOutline(effect);
270
+ }
271
+
272
+ function isOutlineRenderable(object: THREE.Object3D): boolean {
273
+ if ((object as THREE.Bone).isBone) return false;
274
+ return (
275
+ (object as THREE.Mesh).isMesh ||
276
+ (object as THREE.Line).isLine ||
277
+ (object as THREE.Points).isPoints ||
278
+ (object as THREE.Sprite).isSprite
279
+ );
280
+ }
281
+
282
+ /**
283
+ * Expand selected semantic roots to the actual renderables postprocessing can
284
+ * mask. Selection rows commonly name a Group/component root, while an outline
285
+ * effect can only paint its Mesh/Line/Points/Sprite descendants.
286
+ */
287
+ export function collectThreeSelectionOutlineTargets(
288
+ selectedRoots: Iterable<THREE.Object3D>,
289
+ ): THREE.Object3D[] {
290
+ const targets: THREE.Object3D[] = [];
291
+ const seen = new Set<THREE.Object3D>();
292
+ for (const root of selectedRoots) {
293
+ // A bone has its own native joint highlight. Walking through it to a
294
+ // skinned/accessory descendant would make selecting the joint look like
295
+ // selecting the model instead.
296
+ if ((root as THREE.Bone).isBone) continue;
297
+ // Select what the component actually DRAWS, including implementation
298
+ // children folded out of the hierarchy. Unlike an AABB measurement, the
299
+ // mask pass cannot be poisoned by pooled/template geometry that is not
300
+ // currently rendered; it only paints pixels the subtree draws now.
301
+ root.traverse((object) => {
302
+ if (seen.has(object) || isInEditorOwnedSubtree(object) || !isOutlineRenderable(object)) {
303
+ return;
304
+ }
305
+ seen.add(object);
306
+ targets.push(object);
307
+ });
308
+ }
309
+ return targets;
310
+ }
311
+
312
+ /** Update only when the concrete renderable set changed, avoiding layer churn. */
313
+ export function syncThreeSelectionOutline(
314
+ effect: OutlineEffect,
315
+ selectedRoots: Iterable<THREE.Object3D>,
316
+ ): void {
317
+ const roots = [...selectedRoots];
318
+ // A bone draws its own joint highlight and is never outlined, so it is not an object here.
319
+ const objects = roots.filter((root) => !(root as THREE.Bone).isBone).length;
320
+ const state = outlineState.get(effect);
321
+ if (state && state.roots !== objects) {
322
+ state.roots = objects;
323
+ paintOutline(effect);
324
+ }
325
+ const targets = collectThreeSelectionOutlineTargets(roots);
326
+ if (
327
+ effect.selection.size === targets.length &&
328
+ targets.every((target) => effect.selection.has(target))
329
+ ) {
330
+ return;
331
+ }
332
+ effect.selection.set(targets);
333
+ }
@@ -0,0 +1,61 @@
1
+ import { EDITOR_LAYER } from '@volter/editor-threejs/viewport/editor-layers';
2
+ import { getObjectMark, setObjectMark } from '@volter/editor-threejs/ecs/object-marks';
3
+ import * as THREE from 'three';
4
+
5
+ const SKELETON_HELPER_TYPE = 'skeletons';
6
+ const SKELETON_PARENT_COLOR = new THREE.Color(0x00e5ff);
7
+ const SKELETON_CHILD_COLOR = new THREE.Color(0xffb000);
8
+
9
+ export function styleEditorSkeletonHelper(helper: THREE.SkeletonHelper): void {
10
+ const colors = helper.geometry.getAttribute('color');
11
+ // SkeletonHelper emits two vertices per bone link. Three's stock pure
12
+ // blue/green endpoints disappear against vgai's dark viewport, especially
13
+ // in captures. Keep the native helper geometry, but use editor-contrast
14
+ // colors so the inspection affordance is actually visible.
15
+ for (let i = 0; i < colors.count; i += 2) {
16
+ colors.setXYZ(i, SKELETON_PARENT_COLOR.r, SKELETON_PARENT_COLOR.g, SKELETON_PARENT_COLOR.b);
17
+ if (i + 1 < colors.count) {
18
+ colors.setXYZ(i + 1, SKELETON_CHILD_COLOR.r, SKELETON_CHILD_COLOR.g, SKELETON_CHILD_COLOR.b);
19
+ }
20
+ }
21
+ colors.needsUpdate = true;
22
+ helper.renderOrder = 999;
23
+ const material = helper.material as THREE.LineBasicMaterial;
24
+ material.depthTest = false;
25
+ material.depthWrite = false;
26
+ material.toneMapped = false;
27
+ setObjectMark(helper, 'editorHelper', true);
28
+ setObjectMark(helper, 'editorHelperType', SKELETON_HELPER_TYPE);
29
+ helper.traverse((child) => child.layers.set(EDITOR_LAYER));
30
+ }
31
+
32
+ export function ensureSkeletonHelper(root: THREE.Object3D): THREE.SkeletonHelper | null {
33
+ let existing: THREE.SkeletonHelper | null = null;
34
+ let hasSkinnedMesh = false;
35
+ root.traverse((object) => {
36
+ if (getObjectMark(object, 'editorHelperType') === SKELETON_HELPER_TYPE) {
37
+ existing = object as THREE.SkeletonHelper;
38
+ }
39
+ if ((object as THREE.SkinnedMesh).isSkinnedMesh) hasSkinnedMesh = true;
40
+ });
41
+ if (existing || !hasSkinnedMesh) return existing;
42
+
43
+ const helper = new THREE.SkeletonHelper(root);
44
+ if (helper.bones.length === 0) {
45
+ helper.dispose();
46
+ return null;
47
+ }
48
+ styleEditorSkeletonHelper(helper);
49
+
50
+ // SkeletonHelper normally expects to be a scene-root sibling. Keeping it
51
+ // under the entity makes lifecycle cleanup automatic, so use entity-local
52
+ // coordinates instead of applying the entity world transform twice.
53
+ helper.matrix = new THREE.Matrix4();
54
+ helper.matrixAutoUpdate = false;
55
+ helper.name = 'EditorSkeletonHelper';
56
+ helper.visible = Boolean(getObjectMark(root, 'skeletonVisible'));
57
+ setObjectMark(helper, 'skeletonEnabled', helper.visible);
58
+ root.add(helper);
59
+ root.updateMatrixWorld(true);
60
+ return helper;
61
+ }
@@ -0,0 +1,197 @@
1
+ import * as THREE from 'three';
2
+
3
+ /**
4
+ * The colour to give a TONE-MAPPED material so the screen shows the palette's
5
+ * exact sRGB hex — the inverse of three's ACES fit (the input and output
6
+ * matrices and the RRT/ODT rational, `tonemapping_pars_fragment`) through the
7
+ * exposure; Linear divides by it; no tone mapping passes through; any other
8
+ * operator is left as-is.
9
+ *
10
+ * WHICH SURFACES ACTUALLY WANT IT, measured on the live Model stage
11
+ * (`document-script` over the session's scene, beside a `capture-editor-chrome`
12
+ * frame of the same instant — the chrome door serves the PRESENTED frame, so
13
+ * the two are the same pixels):
14
+ *
15
+ * - `scene.background` as a `THREE.Color`: **no**. three does not shade a
16
+ * colour background, it CLEARS to it (`WebGLBackground.setClear` →
17
+ * `glClearColor`), so no fragment shader and no tone map ever touches it.
18
+ * Measured: the scene held `#494949` — this function's inversion of the
19
+ * palette's `#3f3f3f` — and the screen read `#494949` exactly, luminance
20
+ * 73 against Blender's 63. The inversion was compensating for a transform
21
+ * that does not run, and that alone is what made the stage light.
22
+ * - the floor grid (`editor-floor-grid`, a `ShaderMaterial` with
23
+ * `toneMapped: true`): **yes**. Measured: `uColor` held `#575757` — this
24
+ * function's inversion of the palette's `#545454` — and the 1 m line read
25
+ * 83–84 on screen, i.e. the operator ran and landed it on the palette.
26
+ * - the floor axes (`LineSegments2`/`LineMaterial`, `toneMapped: false`):
27
+ * **no**. `toneMapped: false` is honoured per material (three compiles the
28
+ * tone-map chunk per program, not as a composer output pass), so the
29
+ * inverted colour reached the screen raw and over-saturated: `#ee234d`
30
+ * where Blender's X axis is `#cb293f`.
31
+ *
32
+ * So the operator runs PER MATERIAL here, not in a final composer pass: the
33
+ * grid's `toneMapped: true` was mapped and the axes' `toneMapped: false` was
34
+ * not, in the same frame, through the same composer.
35
+ */
36
+ export function toneMappedSourceColor(
37
+ hex: number,
38
+ renderer: THREE.WebGLRenderer | undefined,
39
+ ): THREE.Color {
40
+ const target = new THREE.Color(hex); // sRGB hex → linear, three's management
41
+ if (!renderer || renderer.toneMapping === THREE.NoToneMapping) return target;
42
+ const exposure = renderer.toneMappingExposure || 1;
43
+ if (renderer.toneMapping === THREE.LinearToneMapping) return target.multiplyScalar(1 / exposure);
44
+ if (renderer.toneMapping === THREE.AgXToneMapping) return agxSourceColor(target, exposure);
45
+ if (renderer.toneMapping === THREE.CustomToneMapping) return godotFilmicSourceColor(target, exposure);
46
+ if (renderer.toneMapping !== THREE.ACESFilmicToneMapping) return target;
47
+ const IN: Mat3 = [
48
+ [0.59719, 0.35458, 0.04823],
49
+ [0.076, 0.90834, 0.01566],
50
+ [0.0284, 0.13383, 0.83777],
51
+ ];
52
+ const OUT: Mat3 = [
53
+ [1.60475, -0.53108, -0.07367],
54
+ [-0.10208, 1.10813, -0.00605],
55
+ [-0.00327, -0.07276, 1.07602],
56
+ ];
57
+ const fit = (v: number) =>
58
+ (v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081);
59
+ const invertFit = (f: number) => {
60
+ let lo = 0;
61
+ let hi = 64;
62
+ for (let i = 0; i < 60; i++) {
63
+ const mid = (lo + hi) / 2;
64
+ if (fit(mid) < f) lo = mid;
65
+ else hi = mid;
66
+ }
67
+ return (lo + hi) / 2;
68
+ };
69
+ const f = mulMat3(invertMat3(OUT), [target.r, target.g, target.b]);
70
+ const u = f.map((c) => (c <= 0 ? 0 : invertFit(Math.min(c, 0.999)))) as Vec3;
71
+ const x = mulMat3(invertMat3(IN), u).map((c) => Math.max(0, c) * (0.6 / exposure)) as Vec3;
72
+ return new THREE.Color().setRGB(x[0], x[1], x[2], THREE.LinearSRGBColorSpace);
73
+ }
74
+ /**
75
+ * The same inversion for AgX — the operator the Blender look's stage runs
76
+ * (`ToolViewportDressing.toneMapping`), because Blender's own scene does.
77
+ *
78
+ * Every step of `AgXToneMapping` (`tonemapping_pars_fragment.glsl`) run
79
+ * backwards: the Rec.2020 round trip, the outset matrix, the 2.2 linearize,
80
+ * the sigmoid (bisected — the contrast approximation is a sixth-order
81
+ * polynomial with no closed inverse), the log2 encode, and the inset matrix.
82
+ * three spells its matrices COLUMN-major, as GLSL's `mat3(vec3, vec3, vec3)`
83
+ * constructor does; these are the transposes, because {@link mulMat3} is
84
+ * row-major.
85
+ */
86
+ function agxSourceColor(target: THREE.Color, exposure: number): THREE.Color {
87
+ // three spells its matrices COLUMN-major, as GLSL's `mat3(vec3, vec3, vec3)`
88
+ // constructor does; these are the transposes, because {@link mulMat3} is
89
+ // row-major. Every row sums to 1 — each of the four is grey-preserving, and
90
+ // that is the cheap check that the transposition is the right way round.
91
+ const SRGB_TO_REC2020: Mat3 = [
92
+ [0.6274, 0.3293, 0.0433],
93
+ [0.0691, 0.9195, 0.0113],
94
+ [0.0164, 0.088, 0.8956],
95
+ ];
96
+ const REC2020_TO_SRGB: Mat3 = [
97
+ [1.6605, -0.5876, -0.0728],
98
+ [-0.1246, 1.1329, -0.0083],
99
+ [-0.0182, -0.1006, 1.1187],
100
+ ];
101
+ const INSET: Mat3 = [
102
+ [0.856627153315983, 0.0951212405381588, 0.0482516061458583],
103
+ [0.137318972929847, 0.761241990602591, 0.101439036467562],
104
+ [0.11189821299995, 0.0767994186031903, 0.811302368396859],
105
+ ];
106
+ const OUTSET: Mat3 = [
107
+ [1.1271005818144368, -0.11060664309660323, -0.016493938717834573],
108
+ [-0.1413297634984383, 1.157823702216272, -0.016493938717834257],
109
+ [-0.14132976349843826, -0.11060664309660294, 1.2519364065950405],
110
+ ];
111
+ const MIN_EV = -12.47393;
112
+ const MAX_EV = 4.026069;
113
+ const contrast = (x: number) => {
114
+ const x2 = x * x;
115
+ const x4 = x2 * x2;
116
+ return (
117
+ 15.5 * x4 * x2 -
118
+ 40.14 * x4 * x +
119
+ 31.96 * x4 -
120
+ 6.868 * x2 * x +
121
+ 0.4298 * x2 +
122
+ 0.1191 * x -
123
+ 0.00232
124
+ );
125
+ };
126
+ const invertContrast = (f: number) => {
127
+ let lo = 0;
128
+ let hi = 1;
129
+ for (let i = 0; i < 60; i++) {
130
+ const mid = (lo + hi) / 2;
131
+ if (contrast(mid) < f) lo = mid;
132
+ else hi = mid;
133
+ }
134
+ return (lo + hi) / 2;
135
+ };
136
+ // Every step of the forward operator, backwards and in reverse order.
137
+ const rec2020 = mulMat3(SRGB_TO_REC2020, [target.r, target.g, target.b]);
138
+ const sigmoid = mulMat3(
139
+ invertMat3(OUTSET),
140
+ rec2020.map((c) => Math.max(0, c) ** (1 / 2.2)) as Vec3,
141
+ );
142
+ const open = sigmoid.map((c) => 2 ** (invertContrast(c) * (MAX_EV - MIN_EV) + MIN_EV)) as Vec3;
143
+ const linear = mulMat3(REC2020_TO_SRGB, mulMat3(invertMat3(INSET), open)).map(
144
+ (c) => Math.max(0, c) / exposure,
145
+ ) as Vec3;
146
+ return new THREE.Color().setRGB(linear[0], linear[1], linear[2], THREE.LinearSRGBColorSpace);
147
+ }
148
+ type Vec3 = [number, number, number];
149
+ type Mat3 = [Vec3, Vec3, Vec3];
150
+ function mulMat3(m: Mat3, v: Vec3): Vec3 {
151
+ return [
152
+ m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
153
+ m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
154
+ m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
155
+ ];
156
+ }
157
+ function invertMat3(m: Mat3): Mat3 {
158
+ const [[a, b, c], [d, e, f], [g, h, i]] = m;
159
+ const A = e * i - f * h;
160
+ const B = -(d * i - f * g);
161
+ const C = d * h - e * g;
162
+ const det = a * A + b * B + c * C;
163
+ return [
164
+ [A / det, -(b * i - c * h) / det, (b * f - c * e) / det],
165
+ [B / det, (a * i - c * g) / det, -(a * f - c * d) / det],
166
+ [C / det, -(a * h - b * g) / det, (a * e - b * d) / det],
167
+ ];
168
+ }
169
+
170
+ /**
171
+ * The same inversion for Godot's Filmic — the curve the presentation's `filmic` mapper installs
172
+ * in three's custom slot (`components/standard-viewport-dressing.ts`): Hable's curve with an
173
+ * exposure bias of 2, divided by its value at white, per channel. Monotonic, so bisected.
174
+ */
175
+ function godotFilmicSourceColor(target: THREE.Color, exposure: number): THREE.Color {
176
+ const hable = (x: number) =>
177
+ (x * (0.88 * x + 0.06) + 0.002) / (x * (0.88 * x + 0.6) + 0.06) - 0.01 / 0.3;
178
+ const white = hable(1);
179
+ const curve = (x: number) => hable(x) / white;
180
+ const invert = (f: number) => {
181
+ if (f <= 0) return 0;
182
+ let lo = 0;
183
+ let hi = 1;
184
+ for (let i = 0; i < 60; i++) {
185
+ const mid = (lo + hi) / 2;
186
+ if (curve(mid) < f) lo = mid;
187
+ else hi = mid;
188
+ }
189
+ return (lo + hi) / 2;
190
+ };
191
+ return new THREE.Color().setRGB(
192
+ invert(Math.min(target.r, 0.999)) / exposure,
193
+ invert(Math.min(target.g, 0.999)) / exposure,
194
+ invert(Math.min(target.b, 0.999)) / exposure,
195
+ THREE.LinearSRGBColorSpace,
196
+ );
197
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The LOOK LANES' neutral studio IBL — `scene.environment` for every surface
3
+ * that photographs an asset instead of displaying it: the Asset Lab capture
4
+ * path (`asset-preview.ts`: the model-file lane, `vgai screenshot <module>`
5
+ * through `project.bake.preview`, labeled shot sets, source-review sheets,
6
+ * splats) and the asset-browser thumbnails (`model-thumbnail.ts`).
7
+ *
8
+ * Why it exists: a `MeshStandardMaterial` with `metalness: 1` has NO diffuse
9
+ * term — everything it shows is reflected environment. Lit by directional
10
+ * lights alone it renders BLACK with a pinprick specular, so iron reads as
11
+ * painted plastic, and the measured cost of that (cold barrel, 2026-08-29) is
12
+ * that agents detune `metalness` to survive the screenshot — the look lane
13
+ * silently teaching a wrong material value into shipped assets.
14
+ *
15
+ * The bake is three's own `RoomEnvironment` through `PMREMGenerator`, reused
16
+ * verbatim from the document viewport's dressing
17
+ * ({@link createStandardEnvironment}) so a capture and the open document light
18
+ * the same subject the same way. Procedural: no HDRI binary, deterministic
19
+ * frame to frame.
20
+ *
21
+ * Two rules this module exists to hold:
22
+ *
23
+ * - **`environment` only, never `background`.** Each lane keeps its own
24
+ * backdrop (the Asset Lab's neutral clear colour, or transparent). An IBL
25
+ * that also painted the background would change every existing frame's
26
+ * composition, not just its materials.
27
+ * - **One bake per renderer.** A PMREM target is GL-context-bound, so it
28
+ * cannot be shared across renderers, and baking it per SCENE would pay for
29
+ * it once per shot in a 24-frame orbit. The cache is keyed by renderer and
30
+ * freed through {@link disposeStudioEnvironment}, which every caller that
31
+ * disposes its renderer must call — a PMREM render target leaks otherwise.
32
+ */
33
+
34
+ import {
35
+ createStandardEnvironment,
36
+ type StandardEnvironment,
37
+ } from '@volter/editor-threejs/viewport/environment';
38
+ import type * as THREE from 'three';
39
+
40
+ /**
41
+ * How hard the IBL pushes against the lane's existing key/fill rig.
42
+ *
43
+ * The key and fill are UNCHANGED — they still own the form, the direction of
44
+ * the shading and the highlight. This is a reflection budget, not a second key.
45
+ *
46
+ * The blend was picked by reading before/after pairs of the barrel GLB and a
47
+ * metalness sweep in the module lane, holding the wood constant and asking how
48
+ * little environment buys back the metal:
49
+ * - 1.0 with the ambient untouched: the iron reads, the wood lifts and flattens
50
+ * — the shadow side of the staves fills in and the stave-to-stave tone
51
+ * variation collapses;
52
+ * - 0.55 with {@link STUDIO_AMBIENT_WITH_ENVIRONMENT} at 0.35: metal good,
53
+ * wood still visibly brighter and lower-contrast than the historical frame;
54
+ * - 0.45 with the ambient at 0.25 (shipped): the wood frame is indistinguishable
55
+ * from the historical one at full scale — same warmth, same grain, same
56
+ * terminator — while every hoop gains a gradient across the band and the
57
+ * bottom hoop stops reading as a shadow.
58
+ */
59
+ export const STUDIO_ENVIRONMENT_INTENSITY = 0.45;
60
+
61
+ /**
62
+ * The lanes' ambient fill is REDUCED when the IBL is present: a
63
+ * hemisphere/ambient light is a crude stand-in for exactly what the environment
64
+ * now does properly, and leaving both at full strength double-pays the ambient
65
+ * term and washes the subject. Each lane multiplies its own historical ambient
66
+ * intensity by this, so the total ambient energy lands near where it was and
67
+ * only its DIRECTIONALITY improves.
68
+ */
69
+ export const STUDIO_AMBIENT_WITH_ENVIRONMENT = 0.25;
70
+
71
+ const perRenderer = new WeakMap<THREE.WebGLRenderer, StandardEnvironment>();
72
+
73
+ /**
74
+ * Light `scene` with the studio IBL baked for `renderer`, baking it on first
75
+ * use for that renderer. `scene.background` is deliberately untouched.
76
+ */
77
+ export function applyStudioEnvironment(scene: THREE.Scene, renderer: THREE.WebGLRenderer): void {
78
+ let environment = perRenderer.get(renderer);
79
+ if (!environment) {
80
+ environment = createStandardEnvironment(renderer);
81
+ perRenderer.set(renderer, environment);
82
+ }
83
+ scene.environment = environment.texture;
84
+ scene.environmentIntensity = STUDIO_ENVIRONMENT_INTENSITY;
85
+ }
86
+
87
+ /**
88
+ * Free the bake this renderer owns. Idempotent, and safe for a renderer that
89
+ * never had one — call it beside `renderer.dispose()`.
90
+ */
91
+ export function disposeStudioEnvironment(renderer: THREE.WebGLRenderer): void {
92
+ const environment = perRenderer.get(renderer);
93
+ if (!environment) return;
94
+ perRenderer.delete(renderer);
95
+ environment.dispose();
96
+ }