@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,119 @@
1
+ import { EDITOR_LAYER } from '@volter/editor-threejs/viewport/editor-layers';
2
+ import { setObjectMark } from '@volter/editor-threejs/ecs/object-marks';
3
+ import * as THREE from 'three';
4
+ import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
5
+ import { LineSegments2 } from 'three/addons/lines/LineSegments2.js';
6
+ import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js';
7
+
8
+ /**
9
+ * Viewport-owned emphasis for bones selected in an Object3D document.
10
+ * SkeletonHelper supplies the whole rig context; this draws the selected
11
+ * joint and its incident segments through the model so selection cannot be
12
+ * lost inside opaque geometry.
13
+ */
14
+ export class BoneSelectionHighlight extends THREE.Group {
15
+ private readonly segments: Array<readonly [THREE.Bone, THREE.Bone]> = [];
16
+ private readonly linePositions: Float32Array;
17
+ private readonly pointPositions: Float32Array;
18
+ private readonly lines: LineSegments2;
19
+ private readonly lineMaterial: LineMaterial;
20
+ private readonly points: THREE.Points;
21
+ private readonly worldPosition = new THREE.Vector3();
22
+
23
+ constructor(
24
+ private readonly bones: readonly THREE.Bone[],
25
+ color: number,
26
+ ) {
27
+ super();
28
+ const seen = new Set<string>();
29
+ for (const bone of bones) {
30
+ const parent = bone.parent as THREE.Bone | null;
31
+ if (parent?.isBone) this.addSegment(parent, bone, seen);
32
+ for (const child of bone.children) {
33
+ if ((child as THREE.Bone).isBone) this.addSegment(bone, child as THREE.Bone, seen);
34
+ }
35
+ }
36
+
37
+ this.linePositions = new Float32Array(Math.max(this.segments.length * 6, 6));
38
+ const lineGeometry = new LineSegmentsGeometry();
39
+ lineGeometry.setPositions(this.linePositions);
40
+ this.lineMaterial = new LineMaterial({
41
+ color,
42
+ linewidth: 5,
43
+ worldUnits: false,
44
+ depthTest: false,
45
+ depthWrite: false,
46
+ toneMapped: false,
47
+ alphaToCoverage: true,
48
+ });
49
+ this.lines = new LineSegments2(lineGeometry, this.lineMaterial);
50
+ this.lines.onBeforeRender = (renderer) => {
51
+ renderer.getSize(this.lineMaterial.resolution);
52
+ };
53
+ this.lines.frustumCulled = false;
54
+ this.lines.renderOrder = 1001;
55
+ this.add(this.lines);
56
+
57
+ this.pointPositions = new Float32Array(Math.max(bones.length * 3, 3));
58
+ const pointGeometry = new THREE.BufferGeometry();
59
+ pointGeometry.setAttribute('position', new THREE.BufferAttribute(this.pointPositions, 3));
60
+ const pointMaterial = new THREE.PointsMaterial({
61
+ color,
62
+ size: 11,
63
+ sizeAttenuation: false,
64
+ depthTest: false,
65
+ depthWrite: false,
66
+ toneMapped: false,
67
+ });
68
+ this.points = new THREE.Points(pointGeometry, pointMaterial);
69
+ this.points.frustumCulled = false;
70
+ this.points.renderOrder = 1002;
71
+ this.add(this.points);
72
+
73
+ this.name = '__object3d_document_bone_selection';
74
+ setObjectMark(this, 'editorHelper', true);
75
+ this.traverse((object) => object.layers.set(EDITOR_LAYER));
76
+ this.update();
77
+ }
78
+
79
+ setColor(color: number): void {
80
+ this.lineMaterial.color.setHex(color);
81
+ (this.points.material as THREE.PointsMaterial).color.setHex(color);
82
+ }
83
+
84
+ update(): void {
85
+ let offset = 0;
86
+ for (const [start, end] of this.segments) {
87
+ start.getWorldPosition(this.worldPosition);
88
+ this.linePositions.set(this.worldPosition.toArray(), offset);
89
+ end.getWorldPosition(this.worldPosition);
90
+ this.linePositions.set(this.worldPosition.toArray(), offset + 3);
91
+ offset += 6;
92
+ }
93
+ const lineStart = this.lines.geometry.getAttribute('instanceStart');
94
+ if (lineStart) (lineStart as THREE.InterleavedBufferAttribute).data.needsUpdate = true;
95
+
96
+ offset = 0;
97
+ for (const bone of this.bones) {
98
+ bone.getWorldPosition(this.worldPosition);
99
+ this.pointPositions.set(this.worldPosition.toArray(), offset);
100
+ offset += 3;
101
+ }
102
+ const points = this.points.geometry.getAttribute('position');
103
+ if (points) points.needsUpdate = true;
104
+ }
105
+
106
+ dispose(): void {
107
+ this.lines.geometry.dispose();
108
+ this.lineMaterial.dispose();
109
+ this.points.geometry.dispose();
110
+ (this.points.material as THREE.Material).dispose();
111
+ }
112
+
113
+ private addSegment(start: THREE.Bone, end: THREE.Bone, seen: Set<string>): void {
114
+ const key = `${start.uuid}:${end.uuid}`;
115
+ if (seen.has(key)) return;
116
+ seen.add(key);
117
+ this.segments.push([start, end]);
118
+ }
119
+ }
@@ -0,0 +1,41 @@
1
+ import * as THREE from 'three';
2
+
3
+ /** Distance that fits an axis-aligned world box for one perspective viewing direction. */
4
+ export function perspectiveDistanceToFitBox(
5
+ box: THREE.Box3,
6
+ camera: THREE.PerspectiveCamera,
7
+ direction: THREE.Vector3,
8
+ padding = 1.15,
9
+ ): number {
10
+ if (box.isEmpty()) return 3;
11
+ const viewDirection = direction.clone().normalize();
12
+ const fallbackUp =
13
+ Math.abs(viewDirection.dot(camera.up)) > 0.999 ? new THREE.Vector3(0, 0, 1) : camera.up;
14
+ const right = new THREE.Vector3().crossVectors(fallbackUp, viewDirection).normalize();
15
+ const up = new THREE.Vector3().crossVectors(viewDirection, right).normalize();
16
+ const center = box.getCenter(new THREE.Vector3());
17
+ let halfDepth = 0;
18
+ let halfHeight = 0;
19
+ let halfWidth = 0;
20
+ for (const x of [box.min.x, box.max.x]) {
21
+ for (const y of [box.min.y, box.max.y]) {
22
+ for (const z of [box.min.z, box.max.z]) {
23
+ const offset = new THREE.Vector3(x, y, z).sub(center);
24
+ halfWidth = Math.max(halfWidth, Math.abs(offset.dot(right)));
25
+ halfHeight = Math.max(halfHeight, Math.abs(offset.dot(up)));
26
+ halfDepth = Math.max(halfDepth, Math.abs(offset.dot(viewDirection)));
27
+ }
28
+ }
29
+ }
30
+ const verticalHalfFov = THREE.MathUtils.degToRad(camera.fov * 0.5);
31
+ const horizontalHalfFov = Math.atan(Math.tan(verticalHalfFov) * Math.max(camera.aspect, 0.01));
32
+ const fitDistance = Math.max(
33
+ halfHeight / Math.tan(verticalHalfFov),
34
+ halfWidth / Math.tan(horizontalHalfFov),
35
+ );
36
+ // The floor is the camera's own near plane, not a metre count: a 3 m floor
37
+ // framed every prop under two metres at the same distance, so a 0.5 m
38
+ // mushroom opened as a thumb-sized thing in the middle of the viewport
39
+ // and Frame did nothing (measured 2026-09-06: fit 0.62 m, answer 3.0 m).
40
+ return Math.max(fitDistance * padding + halfDepth, camera.near * 4, 0.01);
41
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The reusable renderer lane for interactive Object3D document stages.
3
+ *
4
+ * A document owns its source and per-pane state; this pool owns only the GPU
5
+ * attachment. Hidden panes return their lease, so open tabs do not each pin a
6
+ * WebGL context. One idle entry keeps the expensive driver/program setup warm
7
+ * for the next revealed pane. Active panes receive exclusive leases and scale
8
+ * with the number of panes that are actually visible.
9
+ */
10
+
11
+ import { markHostRenderer } from '@volter/editor-threejs/viewport/renderer-ownership';
12
+ import * as THREE from 'three';
13
+
14
+ const IDLE_CAPACITY = 1;
15
+
16
+ interface InteractiveRendererEntry {
17
+ readonly canvas: HTMLCanvasElement;
18
+ readonly renderer: THREE.WebGLRenderer;
19
+ leased: boolean;
20
+ lost: boolean;
21
+ onContextLost: (() => void) | null;
22
+ }
23
+
24
+ const entries: InteractiveRendererEntry[] = [];
25
+
26
+ export interface InteractiveViewportRendererLease {
27
+ readonly canvas: HTMLCanvasElement;
28
+ readonly renderer: THREE.WebGLRenderer;
29
+ release(): void;
30
+ }
31
+
32
+ function disposeEntry(entry: InteractiveRendererEntry): void {
33
+ const index = entries.indexOf(entry);
34
+ if (index >= 0) entries.splice(index, 1);
35
+ entry.renderer.dispose();
36
+ entry.renderer.forceContextLoss();
37
+ entry.canvas.remove();
38
+ }
39
+
40
+ function createEntry(width: number, height: number): InteractiveRendererEntry {
41
+ const canvas = document.createElement('canvas');
42
+ canvas.style.width = '100%';
43
+ canvas.style.height = '100%';
44
+ canvas.style.display = 'block';
45
+ canvas.width = Math.max(1, width);
46
+ canvas.height = Math.max(1, height);
47
+ const entry: InteractiveRendererEntry = {
48
+ canvas,
49
+ renderer: markHostRenderer(new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true })),
50
+ leased: true,
51
+ lost: false,
52
+ onContextLost: null,
53
+ };
54
+ canvas.addEventListener('webglcontextlost', () => {
55
+ entry.lost = true;
56
+ entry.onContextLost?.();
57
+ const index = entries.indexOf(entry);
58
+ if (index >= 0) entries.splice(index, 1);
59
+ if (!entry.leased) disposeEntry(entry);
60
+ });
61
+ entries.push(entry);
62
+ return entry;
63
+ }
64
+
65
+ /** Reset every mutable renderer setting a document is allowed to change. */
66
+ function resetRenderer(entry: InteractiveRendererEntry): void {
67
+ const { renderer } = entry;
68
+ renderer.setRenderTarget(null);
69
+ renderer.setScissorTest(false);
70
+ renderer.setViewport(0, 0, Math.max(1, entry.canvas.width), Math.max(1, entry.canvas.height));
71
+ renderer.setScissor(0, 0, Math.max(1, entry.canvas.width), Math.max(1, entry.canvas.height));
72
+ renderer.autoClear = true;
73
+ renderer.autoClearColor = true;
74
+ renderer.autoClearDepth = true;
75
+ renderer.autoClearStencil = true;
76
+ renderer.sortObjects = true;
77
+ renderer.localClippingEnabled = false;
78
+ renderer.clippingPlanes = [];
79
+ renderer.shadowMap.enabled = false;
80
+ renderer.shadowMap.autoUpdate = true;
81
+ renderer.toneMapping = THREE.NoToneMapping;
82
+ renderer.toneMappingExposure = 1;
83
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
84
+ renderer.xr.enabled = false;
85
+ renderer.setAnimationLoop(null);
86
+ renderer.setClearColor(0x000000, 0);
87
+ renderer.clear(true, true, true);
88
+ renderer.info.reset();
89
+ }
90
+
91
+ /** Acquire one exclusive interactive renderer. */
92
+ export function acquireInteractiveViewportRenderer(
93
+ width: number,
94
+ height: number,
95
+ onContextLost?: () => void,
96
+ ): InteractiveViewportRendererLease {
97
+ let entry = entries.find((candidate) => !candidate.leased && !candidate.lost);
98
+ if (!entry) entry = createEntry(width, height);
99
+ entry.leased = true;
100
+ entry.onContextLost = onContextLost ?? null;
101
+ const held = entry;
102
+ let released = false;
103
+ return {
104
+ canvas: held.canvas,
105
+ renderer: held.renderer,
106
+ release(): void {
107
+ if (released) return;
108
+ released = true;
109
+ held.leased = false;
110
+ held.onContextLost = null;
111
+ held.canvas.remove();
112
+ if (held.lost) {
113
+ disposeEntry(held);
114
+ return;
115
+ }
116
+ resetRenderer(held);
117
+ const idle = entries.filter((candidate) => !candidate.leased && !candidate.lost);
118
+ for (const overflow of idle.slice(IDLE_CAPACITY)) disposeEntry(overflow);
119
+ },
120
+ };
121
+ }
122
+
123
+ /** Diagnostic readback used by live acceptance checks. */
124
+ export function interactiveViewportRendererCounts(): {
125
+ readonly active: number;
126
+ readonly idle: number;
127
+ } {
128
+ return {
129
+ active: entries.filter((entry) => entry.leased && !entry.lost).length,
130
+ idle: entries.filter((entry) => !entry.leased && !entry.lost).length,
131
+ };
132
+ }
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Selection fallback: eight CORNER BRACKETS on an entity's world AABB.
3
+ *
4
+ * Renderable geometry receives the native silhouette in
5
+ * `selection-outline.ts`. These brackets remain for cameras, lights,
6
+ * audio sources and empty transforms: objects with no pixels for a silhouette
7
+ * effect to find. At each AABB corner, three short arms run back along the
8
+ * box's own edges, preserving an extent cue without drawing a full wire box.
9
+ *
10
+ * Drawn with three's fat-line addons (`LineSegments2` + `LineSegmentsGeometry`
11
+ * + `LineMaterial`) so the stroke is a real screen-space width rather than the
12
+ * driver's 1px `gl.LINES`, which is the other half of why the old box vanished.
13
+ * The screen-space width needs `LineMaterial.resolution`; nothing here or in
14
+ * the viewport sets it, because `LineSegments2.onBeforeRender` already pushes
15
+ * the live renderer viewport into that uniform on every draw. A second writer
16
+ * would only be a stale one.
17
+ *
18
+ * The Bounds DIAGNOSTIC (`helperVisibility.bounds`) deliberately keeps the
19
+ * plain full twelve-edge box on every entity: that view is about density, and
20
+ * brackets on everything would be noise.
21
+ */
22
+
23
+ import { liveMixerFor } from '@volter/editor-threejs/animation/live-mixers';
24
+ import {
25
+ contentBoundsInFrame,
26
+ contentWorldBounds,
27
+ } from '@volter/editor-threejs/viewport/content-bounds';
28
+ import * as THREE from 'three';
29
+ import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
30
+ import { LineSegments2 } from 'three/addons/lines/LineSegments2.js';
31
+ import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js';
32
+
33
+ /** Stroke width in CSS pixels (`LineMaterial` screen-space units). */
34
+ export const SELECTION_BRACKET_LINEWIDTH = 3;
35
+
36
+ /**
37
+ * How far the cage stands OFF the entity, as a fraction of each axis extent.
38
+ *
39
+ * Owner ask (2026-08-11): "the corner highlights are exactly ON the model. I
40
+ * frequently do not see it." Drawn on the exact AABB, a bracket on anything
41
+ * box-shaped is coplanar with the surface it is supposed to be marking — it
42
+ * z-fights, it takes the object's own shading, and on a flat face it simply
43
+ * disappears into the edge it lies along. The cage has to float clear of the
44
+ * silhouette to read as a separate thing.
45
+ */
46
+ export const BRACKET_STANDOFF_FRACTION = 0.05;
47
+
48
+ /** World-unit floor for that stand-off, so a tiny prop still gets clearance. */
49
+ export const BRACKET_STANDOFF_MIN = 0.03;
50
+
51
+ /** World-unit ceiling, so a terrain-scale box does not float meters away. */
52
+ export const BRACKET_STANDOFF_MAX = 0.6;
53
+
54
+ /** Arm length as a fraction of that axis's box extent. */
55
+ export const BRACKET_ARM_FRACTION = 0.15;
56
+
57
+ /** World-unit floor, so a small prop still shows a bracket rather than a dot. */
58
+ export const BRACKET_ARM_MIN = 0.08;
59
+
60
+ /** World-unit ceiling, so a terrain-scale box does not draw multi-meter arms. */
61
+ export const BRACKET_ARM_MAX = 3;
62
+
63
+ /**
64
+ * Hard cap as a fraction of the extent. Without it the clamped MIN would make
65
+ * arms from opposite corners meet on a small box — which is a full box outline
66
+ * again, i.e. exactly the shape we are moving away from.
67
+ */
68
+ export const BRACKET_ARM_EXTENT_CAP = 0.45;
69
+
70
+ /**
71
+ * Non-zero floor so a flat box (a ground plane's zero Y extent) still yields a
72
+ * real direction vector. `LineMaterial` normalizes the segment direction, so a
73
+ * genuinely zero-length segment is a NaN in the vertex shader.
74
+ */
75
+ const BRACKET_ARM_EPSILON = 1e-3;
76
+
77
+ /** Eight corners × three arms. */
78
+ export const BRACKET_SEGMENT_COUNT = 24;
79
+
80
+ /** Six floats per segment (start xyz, end xyz). */
81
+ const FLOATS_PER_SEGMENT = 6;
82
+
83
+ /** The eight AABB corners as min(0)/max(1) picks per axis. */
84
+ const BOX_CORNERS: readonly (readonly [0 | 1, 0 | 1, 0 | 1])[] = [
85
+ [0, 0, 0],
86
+ [0, 0, 1],
87
+ [0, 1, 0],
88
+ [0, 1, 1],
89
+ [1, 0, 0],
90
+ [1, 0, 1],
91
+ [1, 1, 0],
92
+ [1, 1, 1],
93
+ ];
94
+
95
+ /** Arm length for one axis, given that axis's extent of the world AABB. */
96
+ export function bracketArmLength(extent: number): number {
97
+ const scaled = THREE.MathUtils.clamp(
98
+ extent * BRACKET_ARM_FRACTION,
99
+ BRACKET_ARM_MIN,
100
+ BRACKET_ARM_MAX,
101
+ );
102
+ return Math.max(Math.min(scaled, extent * BRACKET_ARM_EXTENT_CAP), BRACKET_ARM_EPSILON);
103
+ }
104
+
105
+ /** Clearance for one axis, given that axis's extent of the world AABB. */
106
+ export function bracketStandoff(extent: number): number {
107
+ return THREE.MathUtils.clamp(
108
+ extent * BRACKET_STANDOFF_FRACTION,
109
+ BRACKET_STANDOFF_MIN,
110
+ BRACKET_STANDOFF_MAX,
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Write the 24 bracket segments for `box` into `out` (length
116
+ * `BRACKET_SEGMENT_COUNT * 6`). Positions are world-space; the holder object
117
+ * stays at the identity transform.
118
+ */
119
+ export function writeBracketSegments(box: THREE.Box3, out: Float32Array, edges = false): void {
120
+ const { min, max } = box;
121
+ // `edges`: every arm runs half its edge, so the arms from opposite corners meet and the cage is
122
+ // the full twelve-edge box (Godot's selection box, `editors/3d/selection_box_color`).
123
+ const arm = (extent: number) => (edges ? Math.max(extent / 2, BRACKET_ARM_EPSILON) : bracketArmLength(extent));
124
+ const ax = arm(max.x - min.x);
125
+ const ay = arm(max.y - min.y);
126
+ const az = arm(max.z - min.z);
127
+
128
+ let i = 0;
129
+ for (const [cx, cy, cz] of BOX_CORNERS) {
130
+ const x = cx === 0 ? min.x : max.x;
131
+ const y = cy === 0 ? min.y : max.y;
132
+ const z = cz === 0 ? min.z : max.z;
133
+ // Arms run INWARD along their edge, so a bracket always sits on the box
134
+ // rather than sticking out past the corner.
135
+ const dx = cx === 0 ? ax : -ax;
136
+ const dy = cy === 0 ? ay : -ay;
137
+ const dz = cz === 0 ? az : -az;
138
+ // X arm
139
+ out[i++] = x;
140
+ out[i++] = y;
141
+ out[i++] = z;
142
+ out[i++] = x + dx;
143
+ out[i++] = y;
144
+ out[i++] = z;
145
+ // Y arm
146
+ out[i++] = x;
147
+ out[i++] = y;
148
+ out[i++] = z;
149
+ out[i++] = x;
150
+ out[i++] = y + dy;
151
+ out[i++] = z;
152
+ // Z arm
153
+ out[i++] = x;
154
+ out[i++] = y;
155
+ out[i++] = z;
156
+ out[i++] = x;
157
+ out[i++] = y;
158
+ out[i++] = z + dz;
159
+ }
160
+ }
161
+
162
+ export interface SelectionBracketsOptions {
163
+ /** Renderer-ready current palette accent. */
164
+ readonly color: number;
165
+ /** Draw the full box instead of corner brackets (the look's `stage.selectionBox`). */
166
+ readonly edges?: boolean;
167
+ /** Stroke width in CSS px; the editor's own is {@link SELECTION_BRACKET_LINEWIDTH}. */
168
+ readonly lineWidth?: number;
169
+ /** Measure the box along the world's axes (the editor's own, Unity's bounds) or the object's
170
+ * own, so it turns with the object (Godot's) — `stage.selectionBoxFrame`. */
171
+ readonly frame?: 'world' | 'object';
172
+ /**
173
+ * Edge length of a fixed-size cube centered on the entity's world position,
174
+ * used instead of a computed AABB. This is the degenerate-geometry path —
175
+ * cameras, lights and audio sources own no renderable geometry, so the bounds
176
+ * walk returns an empty or meaningless box.
177
+ */
178
+ readonly fixedSize?: number;
179
+ }
180
+
181
+ /**
182
+ * Corner brackets for one entity, recomputed in place when its bounds are
183
+ * invalidated.
184
+ *
185
+ * A bounds refresh costs one `contentWorldBounds` walk plus 144 writes into the
186
+ * already-allocated instance buffer. Static selections retain that result;
187
+ * root motion and vertex animation invalidate it. Nothing reallocates after
188
+ * construction.
189
+ */
190
+ export class SelectionBrackets extends LineSegments2 {
191
+ /** The entity object this bracket set is glued to. */
192
+ readonly entityObject: THREE.Object3D;
193
+
194
+ private readonly _fixedSize: number | undefined;
195
+ private readonly _edges: boolean;
196
+ private readonly _objectFrame: boolean;
197
+ private readonly _toObject = new THREE.Matrix4();
198
+ private readonly _point = new THREE.Vector3();
199
+ private readonly _axisX = new THREE.Vector3();
200
+ private readonly _axisY = new THREE.Vector3();
201
+ private readonly _axisZ = new THREE.Vector3();
202
+ private readonly _scale = new THREE.Vector3();
203
+ private readonly _positions = new Float32Array(BRACKET_SEGMENT_COUNT * FLOATS_PER_SEGMENT);
204
+ private readonly _box = new THREE.Box3();
205
+ private readonly _worldPos = new THREE.Vector3();
206
+ private readonly _fixedExtent = new THREE.Vector3();
207
+ private readonly _standoff = new THREE.Vector3();
208
+ private readonly _lastRootMatrix = new Float32Array(16);
209
+ private readonly _requiresContinuousBounds: boolean;
210
+
211
+ constructor(entityObject: THREE.Object3D, options: SelectionBracketsOptions) {
212
+ const geometry = new LineSegmentsGeometry();
213
+ const material = new LineMaterial({
214
+ color: options.color,
215
+ linewidth: options.lineWidth ?? SELECTION_BRACKET_LINEWIDTH,
216
+ // Screen-space width: a bracket must read the same on a 0.2m prop and a
217
+ // 200m terrain chunk.
218
+ worldUnits: false,
219
+ alphaToCoverage: true,
220
+ toneMapped: false,
221
+ // DRAW THROUGH. A depth-tested cage is hidden by the very object it
222
+ // marks the moment the two touch, and by anything standing in front of
223
+ // it — so the one question this shape exists to answer, "where is my
224
+ // selection", goes unanswered exactly when the scene is busy enough for
225
+ // the reader to be asking. Selection is chrome about the scene, not
226
+ // geometry in it, and chrome is never occluded.
227
+ depthTest: false,
228
+ depthWrite: false,
229
+ });
230
+ super(geometry, material);
231
+ this.entityObject = entityObject;
232
+ this._fixedSize = options.fixedSize;
233
+ this._edges = options.edges === true;
234
+ this._objectFrame = options.frame === 'object';
235
+ // A static hierarchy's world bounds do not change because the camera did.
236
+ // Detect the cases whose vertices can move without an editor transform
237
+ // notification; those keep the old every-frame bounds refresh. Ordinary
238
+ // terrain/building groups instead refresh only when their root transform
239
+ // changes (and `_syncBoxHelpers` reconstructs them after source/store
240
+ // changes), avoiding one recursive Box3 walk per selected subtree per RAF.
241
+ let requiresContinuousBounds = false;
242
+ entityObject.traverse((child) => {
243
+ const mesh = child as THREE.Mesh;
244
+ if (
245
+ (child as THREE.SkinnedMesh).isSkinnedMesh ||
246
+ (Array.isArray(mesh.morphTargetInfluences) && mesh.morphTargetInfluences.length > 0) ||
247
+ // A mixer the game's own code made animates it (the served animation stamp,
248
+ // `@volter/editor-threejs/animation/live-mixers`).
249
+ liveMixerFor(child) !== null
250
+ ) {
251
+ requiresContinuousBounds = true;
252
+ }
253
+ });
254
+ this._requiresContinuousBounds = requiresContinuousBounds;
255
+ // Drawn after the scene so the un-depth-tested cage lands on top of it.
256
+ // Left below the transform gizmo, which owns the foreground while a drag
257
+ // is live.
258
+ this.renderOrder = 1;
259
+ // Positions are world-space and may be rewritten immediately before a
260
+ // draw, so the geometry's own bounds can be stale; culling 24 instanced
261
+ // quads buys nothing worth that risk.
262
+ this.frustumCulled = false;
263
+ geometry.setPositions(this._positions);
264
+ this.update();
265
+ }
266
+
267
+ setColor(color: number): void {
268
+ (this.material as LineMaterial).color.setHex(color);
269
+ }
270
+
271
+ /**
272
+ * Cheap RAF path. Static selected subtrees retain their already-computed
273
+ * world AABB until their root moves; animated vertex data still refreshes
274
+ * continuously. Call {@link update} to force a refresh after an out-of-band
275
+ * descendant mutation.
276
+ */
277
+ updateIfNeeded(): void {
278
+ this.entityObject.updateWorldMatrix(true, false);
279
+ const matrix = this.entityObject.matrixWorld.elements;
280
+ let rootChanged = false;
281
+ for (let index = 0; index < matrix.length; index++) {
282
+ if (matrix[index] !== this._lastRootMatrix[index]) {
283
+ rootChanged = true;
284
+ break;
285
+ }
286
+ }
287
+ if (rootChanged || this._requiresContinuousBounds) this.update();
288
+ }
289
+
290
+ /** Recompute the AABB and reposition the arms. Safe to call every frame. */
291
+ update(): void {
292
+ this.entityObject.updateWorldMatrix(true, false);
293
+ // A frame with a zero axis cannot be inverted: such an object is boxed along the world's.
294
+ const objectFrame =
295
+ this._objectFrame &&
296
+ this._fixedSize === undefined &&
297
+ Math.abs(this.entityObject.matrixWorld.determinant()) > 1e-30;
298
+ this.entityObject.matrixWorld.extractBasis(this._axisX, this._axisY, this._axisZ);
299
+ const scale = objectFrame
300
+ ? this._scale.set(this._axisX.length(), this._axisY.length(), this._axisZ.length())
301
+ : this._scale.set(1, 1, 1);
302
+ if (this._fixedSize !== undefined) {
303
+ this.entityObject.getWorldPosition(this._worldPos);
304
+ this._fixedExtent.setScalar(this._fixedSize);
305
+ this._box.setFromCenterAndSize(this._worldPos, this._fixedExtent);
306
+ } else {
307
+ // Content only — a built-internal child (a world-space particle renderer
308
+ // at identity, a pooled batch) would otherwise drag the cage off to
309
+ // wherever its machinery lives. See `content-bounds.ts`.
310
+ if (objectFrame) {
311
+ this._toObject.copy(this.entityObject.matrixWorld).invert();
312
+ contentBoundsInFrame(this.entityObject, this._toObject, this._box);
313
+ } else {
314
+ contentWorldBounds(this.entityObject, this._box);
315
+ }
316
+ if (this._box.isEmpty()) return;
317
+ }
318
+ // Stand the cage off the silhouette. Per-axis, so a flat object (a ground
319
+ // plane, a wall panel) gains real clearance on the axis it has no extent
320
+ // in rather than staying welded to its own face.
321
+ // The stand-off is a WORLD distance; a box in the object's frame is measured in the object's
322
+ // units, so it is taken through the object's scale on each axis and back.
323
+ this._box.getSize(this._standoff).multiply(scale);
324
+ this._standoff.set(
325
+ bracketStandoff(this._standoff.x),
326
+ bracketStandoff(this._standoff.y),
327
+ bracketStandoff(this._standoff.z),
328
+ ).divide(scale);
329
+ this._box.expandByVector(this._standoff);
330
+ writeBracketSegments(this._box, this._positions, this._edges);
331
+ // A box in the object's frame is written there and carried out to the world by the object.
332
+ if (objectFrame) {
333
+ const matrix = this.entityObject.matrixWorld;
334
+ for (let index = 0; index < this._positions.length; index += 3) {
335
+ this._point
336
+ .set(this._positions[index]!, this._positions[index + 1]!, this._positions[index + 2]!)
337
+ .applyMatrix4(matrix);
338
+ this._positions[index] = this._point.x;
339
+ this._positions[index + 1] = this._point.y;
340
+ this._positions[index + 2] = this._point.z;
341
+ }
342
+ }
343
+ const start = this.geometry.getAttribute('instanceStart');
344
+ // Both instanceStart and instanceEnd are views onto the one interleaved
345
+ // buffer that owns `_positions`; flagging either one uploads all of it.
346
+ if (start) (start as THREE.InterleavedBufferAttribute).data.needsUpdate = true;
347
+ this._lastRootMatrix.set(this.entityObject.matrixWorld.elements);
348
+ }
349
+
350
+ /** Release GPU resources. Mirrors the viewport's BoxHelper dispose path. */
351
+ dispose(): void {
352
+ this.geometry.dispose();
353
+ (this.material as LineMaterial).dispose();
354
+ }
355
+ }