model-preview 0.1.0

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.
@@ -0,0 +1,268 @@
1
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
2
+ import { MOUSE, Quaternion, Vector3, type Camera } from 'three';
3
+
4
+ const _STATE_ROTATE = 0;
5
+ const _CHANGE_EVENT = { type: 'change' } as const;
6
+
7
+ const _offset = new Vector3();
8
+ const _yAxis = new Vector3();
9
+ const _xAxis = new Vector3();
10
+ const _viewOffset = new Vector3();
11
+ const _lookDirection = new Vector3();
12
+ const _qStep = new Quaternion();
13
+
14
+ const _yAxisUp = new Vector3(0, 1, 0);
15
+ const _DAMPING_EPS = 0.00001;
16
+
17
+ type OrbitControlsInternals = OrbitControls & {
18
+ _quat: Quaternion;
19
+ _quatInverse: Quaternion;
20
+ _spherical: { theta: number; phi: number; setFromVector3(v: Vector3): void };
21
+ _sphericalDelta: { theta: number; phi: number; set(x: number, y: number, z: number): void };
22
+ _panOffset: Vector3;
23
+ _scale: number;
24
+ state: number;
25
+ };
26
+
27
+ function internals(controls: CustomControls): OrbitControlsInternals {
28
+ return controls as unknown as OrbitControlsInternals;
29
+ }
30
+
31
+ /**
32
+ * Blender-style orbit controls used by UEditor and model preview viewports.
33
+ */
34
+ export class CustomControls extends OrbitControls {
35
+ getOrbitPivot: () => Vector3 | null;
36
+
37
+ private _orbitPivotMode = false;
38
+ private _orbitPivotPendingEnd = false;
39
+ private _orbitPivot = new Vector3();
40
+
41
+ constructor(
42
+ object: Camera,
43
+ domElement: HTMLElement,
44
+ options: { getOrbitPivot?: () => Vector3 | null } = {},
45
+ ) {
46
+ super(object, domElement);
47
+
48
+ this.getOrbitPivot = options.getOrbitPivot ?? (() => null);
49
+
50
+ this.addEventListener('start', this._onControlStart.bind(this));
51
+ this.addEventListener('end', this._onControlEnd.bind(this));
52
+ }
53
+
54
+ getViewCenter() {
55
+ return this.target;
56
+ }
57
+
58
+ syncFromCamera() {
59
+ this._syncOrbitStateFromCamera();
60
+ }
61
+
62
+ syncOrbitState() {
63
+ const ctrl = internals(this);
64
+ _offset.copy(this.object.position).sub(this.target);
65
+ _offset.applyQuaternion(ctrl._quat);
66
+ ctrl._spherical.setFromVector3(_offset);
67
+ ctrl._sphericalDelta.set(0, 0, 0);
68
+ ctrl._panOffset.set(0, 0, 0);
69
+ ctrl._scale = 1;
70
+ }
71
+
72
+ updateOrbitUpBasis() {
73
+ const ctrl = internals(this);
74
+ ctrl._quat.setFromUnitVectors(this.object.up, _yAxisUp);
75
+ ctrl._quatInverse.copy(ctrl._quat).invert();
76
+ }
77
+
78
+ applyViewSnap(qDelta: Quaternion, pivot: Vector3) {
79
+ this.object.position.sub(pivot).applyQuaternion(qDelta).add(pivot);
80
+ this.target.sub(pivot).applyQuaternion(qDelta).add(pivot);
81
+ this.object.quaternion.premultiply(qDelta);
82
+ }
83
+
84
+ beginOrbitAroundPivot(pivot: Vector3) {
85
+ this._orbitPivotMode = true;
86
+ this._orbitPivotPendingEnd = false;
87
+ this._orbitPivot.copy(pivot);
88
+ internals(this)._sphericalDelta.set(0, 0, 0);
89
+ }
90
+
91
+ private _rotateAroundPivot(axis: Vector3, angle: number, pivot = this._orbitPivot) {
92
+ if (angle === 0) return;
93
+
94
+ _qStep.setFromAxisAngle(axis, angle);
95
+
96
+ this.object.position.sub(pivot);
97
+ this.object.position.applyQuaternion(_qStep);
98
+ this.object.position.add(pivot);
99
+
100
+ this.object.quaternion.premultiply(_qStep);
101
+ this.object.up.applyQuaternion(_qStep).normalize();
102
+
103
+ this.target.sub(pivot);
104
+ this.target.applyQuaternion(_qStep);
105
+ this.target.add(pivot);
106
+ }
107
+
108
+ private _getPitchAxis(pivot: Vector3) {
109
+ _viewOffset.copy(this.object.position).sub(pivot);
110
+ if (_viewOffset.lengthSq() <= 1e-10) return null;
111
+ _viewOffset.normalize();
112
+ _xAxis.crossVectors(_yAxis, _viewOffset);
113
+ if (_xAxis.lengthSq() <= 1e-10) return null;
114
+ return _xAxis.normalize();
115
+ }
116
+
117
+ commitViewRotation() {
118
+ this.updateOrbitUpBasis();
119
+ this._syncOrbitStateFromCamera();
120
+ this.dispatchEvent(_CHANGE_EVENT as never);
121
+ }
122
+
123
+ rotateViewAtPivot(pivot: Vector3, direction: 'up' | 'down' | 'left' | 'right', angleRadians: number) {
124
+ if (!angleRadians) return;
125
+
126
+ _yAxis.copy(this.object.up).normalize();
127
+
128
+ switch (direction) {
129
+ case 'left':
130
+ this._rotateAroundPivot(_yAxis, angleRadians, pivot);
131
+ break;
132
+ case 'right':
133
+ this._rotateAroundPivot(_yAxis, -angleRadians, pivot);
134
+ break;
135
+ case 'up':
136
+ case 'down': {
137
+ const pitchAxis = this._getPitchAxis(pivot);
138
+ if (!pitchAxis) break;
139
+ const pitch = direction === 'up' ? -angleRadians : angleRadians;
140
+ this._rotateAroundPivot(pitchAxis, pitch, pivot);
141
+ break;
142
+ }
143
+ default:
144
+ break;
145
+ }
146
+ }
147
+
148
+ flipViewAtPivot(pivot: Vector3) {
149
+ _yAxis.copy(this.object.up).normalize();
150
+ this._rotateAroundPivot(_yAxis, Math.PI, pivot);
151
+ }
152
+
153
+ private _consumeRotationDelta() {
154
+ const ctrl = internals(this);
155
+ let deltaTheta: number;
156
+ let deltaPhi: number;
157
+
158
+ if (this.enableDamping) {
159
+ deltaTheta = ctrl._sphericalDelta.theta * this.dampingFactor;
160
+ deltaPhi = ctrl._sphericalDelta.phi * this.dampingFactor;
161
+ ctrl._sphericalDelta.theta *= 1 - this.dampingFactor;
162
+ ctrl._sphericalDelta.phi *= 1 - this.dampingFactor;
163
+ } else {
164
+ deltaTheta = ctrl._sphericalDelta.theta;
165
+ deltaPhi = ctrl._sphericalDelta.phi;
166
+ ctrl._sphericalDelta.set(0, 0, 0);
167
+ }
168
+
169
+ return { deltaTheta, deltaPhi };
170
+ }
171
+
172
+ private _hasPendingRotation() {
173
+ const ctrl = internals(this);
174
+ return (
175
+ Math.abs(ctrl._sphericalDelta.theta) >= _DAMPING_EPS ||
176
+ Math.abs(ctrl._sphericalDelta.phi) >= _DAMPING_EPS
177
+ );
178
+ }
179
+
180
+ private _updatePivotOrbit() {
181
+ const { deltaTheta, deltaPhi } = this._consumeRotationDelta();
182
+
183
+ if (deltaTheta === 0 && deltaPhi === 0) {
184
+ return false;
185
+ }
186
+
187
+ _yAxis.copy(this.object.up).normalize();
188
+
189
+ if (deltaTheta !== 0) {
190
+ this._rotateAroundPivot(_yAxis, deltaTheta);
191
+ }
192
+
193
+ if (deltaPhi !== 0) {
194
+ const pitchAxis = this._getPitchAxis(this._orbitPivot);
195
+ if (pitchAxis) {
196
+ this._rotateAroundPivot(pitchAxis, deltaPhi);
197
+ }
198
+ }
199
+
200
+ return true;
201
+ }
202
+
203
+ private _syncOrbitStateFromCamera() {
204
+ const ctrl = internals(this);
205
+ this.object.getWorldDirection(_lookDirection);
206
+
207
+ let distance = this.object.position.distanceTo(this.target);
208
+ if (distance < 1e-4) {
209
+ distance = 10;
210
+ }
211
+
212
+ this.target.copy(this.object.position).addScaledVector(_lookDirection, distance);
213
+
214
+ _offset.copy(this.object.position).sub(this.target);
215
+ _offset.applyQuaternion(ctrl._quat);
216
+ ctrl._spherical.setFromVector3(_offset);
217
+ ctrl._sphericalDelta.set(0, 0, 0);
218
+ ctrl._panOffset.set(0, 0, 0);
219
+ ctrl._scale = 1;
220
+ }
221
+
222
+ endOrbitAroundPivot() {
223
+ if (!this._orbitPivotMode) return;
224
+
225
+ this._orbitPivotMode = false;
226
+ this._orbitPivotPendingEnd = false;
227
+ this.updateOrbitUpBasis();
228
+ this._syncOrbitStateFromCamera();
229
+ }
230
+
231
+ private _onControlStart() {
232
+ if (internals(this).state !== _STATE_ROTATE) return;
233
+
234
+ const pivot = this.getOrbitPivot();
235
+ if (!pivot) return;
236
+
237
+ this.beginOrbitAroundPivot(pivot);
238
+ }
239
+
240
+ private _onControlEnd() {
241
+ if (!this._orbitPivotMode) return;
242
+
243
+ this._orbitPivotPendingEnd = true;
244
+
245
+ if (!this.enableDamping || !this._hasPendingRotation()) {
246
+ this.endOrbitAroundPivot();
247
+ }
248
+ }
249
+
250
+ override update(deltaTime: number | null = null): boolean {
251
+ if (!this._orbitPivotMode) {
252
+ return super.update(deltaTime);
253
+ }
254
+
255
+ const changed = this._updatePivotOrbit();
256
+ if (changed) {
257
+ this.dispatchEvent(_CHANGE_EVENT as never);
258
+ }
259
+
260
+ if (this._orbitPivotPendingEnd && !this._hasPendingRotation()) {
261
+ this.endOrbitAroundPivot();
262
+ }
263
+
264
+ return changed;
265
+ }
266
+ }
267
+
268
+ export { MOUSE };
@@ -0,0 +1,268 @@
1
+ import {
2
+ Color,
3
+ DoubleSide,
4
+ Mesh,
5
+ PlaneGeometry,
6
+ ShaderMaterial,
7
+ Vector3,
8
+ type Mesh as ThreeMesh,
9
+ } from 'three';
10
+
11
+ import { HELPER_OBJECT_TYPE } from '../constants.js';
12
+ import { getViewAspect, getViewHeightAtDistance, type ViewCamera } from './ViewportUtils.js';
13
+ import type { CustomControls } from './CustomControls.js';
14
+
15
+ const GRID_VERTEX_SHADER = `
16
+ varying vec3 vWorldPosition;
17
+
18
+ void main() {
19
+ vec4 worldPosition = modelMatrix * vec4(position, 1.0);
20
+ vWorldPosition = worldPosition.xyz;
21
+ gl_Position = projectionMatrix * viewMatrix * worldPosition;
22
+ }
23
+ `;
24
+
25
+ const GRID_FRAGMENT_SHADER = `
26
+ varying vec3 vWorldPosition;
27
+
28
+ uniform float uCellSize;
29
+ uniform float uSubCellSize;
30
+ uniform float uSectionSize;
31
+ uniform float uSubBlend;
32
+ uniform vec3 uCellColor;
33
+ uniform vec3 uSectionColor;
34
+ uniform vec3 uCenter;
35
+ uniform float uFadeRadius;
36
+
37
+ const float FW_EPSILON = 1.0e-7;
38
+
39
+ float gridFactor(vec2 coord, float step) {
40
+ vec2 uv = coord / step;
41
+ vec2 grid = abs(fract(uv - 0.5) - 0.5);
42
+ vec2 fw = max(fwidth(uv), vec2(FW_EPSILON));
43
+ float distPx = min(grid.x / fw.x, grid.y / fw.y);
44
+ return 1.0 - smoothstep(0.0, 1.0, distPx);
45
+ }
46
+
47
+ float gridLineAlongX(vec2 coord, float step) {
48
+ float uv = coord.x / step;
49
+ float grid = abs(fract(uv - 0.5) - 0.5);
50
+ float fw = max(fwidth(uv), FW_EPSILON);
51
+ return 1.0 - smoothstep(0.0, 1.0, grid / fw);
52
+ }
53
+
54
+ float gridLineAlongZ(vec2 coord, float step) {
55
+ float uv = coord.y / step;
56
+ float grid = abs(fract(uv - 0.5) - 0.5);
57
+ float fw = max(fwidth(uv), FW_EPSILON);
58
+ return 1.0 - smoothstep(0.0, 1.0, grid / fw);
59
+ }
60
+
61
+ float subScreenFade(vec2 coord, float cellSize) {
62
+ float fw = max(fwidth(coord.x), FW_EPSILON);
63
+ float pixelWidth = cellSize / fw;
64
+ return smoothstep(3.0, 6.0, pixelWidth);
65
+ }
66
+
67
+ void main() {
68
+ vec2 coord = vWorldPosition.xz;
69
+ vec2 fadeCoord = vWorldPosition.xz - uCenter.xz;
70
+
71
+ float subMinor = gridFactor(coord, uSubCellSize);
72
+ float subMask = subMinor * 0.35 * uSubBlend * subScreenFade(coord, uSubCellSize);
73
+
74
+ float mainMinor = gridFactor(coord, uCellSize);
75
+ float mainMajor = gridFactor(coord, uSectionSize);
76
+ float mainMask = max(mainMinor * 0.35, mainMajor * 0.55);
77
+
78
+ float lineMask = max(subMask, mainMask);
79
+
80
+ float axisGap = uSubCellSize * 0.6;
81
+ bool onVertLine = gridLineAlongX(coord, uSubCellSize) > 0.01
82
+ || gridLineAlongX(coord, uCellSize) > 0.01
83
+ || gridLineAlongX(coord, uSectionSize) > 0.01;
84
+ bool onHorizLine = gridLineAlongZ(coord, uSubCellSize) > 0.01
85
+ || gridLineAlongZ(coord, uCellSize) > 0.01
86
+ || gridLineAlongZ(coord, uSectionSize) > 0.01;
87
+
88
+ if (abs(vWorldPosition.x) < axisGap && onVertLine) {
89
+ discard;
90
+ }
91
+ if (abs(vWorldPosition.z) < axisGap && onHorizLine) {
92
+ discard;
93
+ }
94
+
95
+ if (lineMask < 0.01) {
96
+ discard;
97
+ }
98
+
99
+ vec3 lineColor = mix(uCellColor, uSectionColor, mainMajor);
100
+
101
+ float dist = length(fadeCoord);
102
+ float fade = 1.0 - smoothstep(uFadeRadius * 0.65, uFadeRadius, dist);
103
+ float alpha = lineMask * fade;
104
+
105
+ if (alpha < 0.01) {
106
+ discard;
107
+ }
108
+
109
+ gl_FragColor = vec4(lineColor, alpha);
110
+ }
111
+ `;
112
+
113
+ const NICE_STEPS = [1, 2, 5];
114
+ const BASE_PLANE_SIZE = 1;
115
+
116
+ function snapToNiceNumber(value: number) {
117
+ if (!Number.isFinite(value) || value <= 0) return 1;
118
+
119
+ const exponent = Math.floor(Math.log10(value));
120
+ const base = 10 ** exponent;
121
+ const normalized = value / base;
122
+
123
+ for (const step of NICE_STEPS) {
124
+ if (normalized <= step) {
125
+ return step * base;
126
+ }
127
+ }
128
+
129
+ return 10 * base;
130
+ }
131
+
132
+ function calcRawCellSize({
133
+ visibleHeight,
134
+ viewportHeight,
135
+ pixelsPerMinorLine = 64,
136
+ }: {
137
+ visibleHeight: number;
138
+ viewportHeight: number;
139
+ pixelsPerMinorLine?: number;
140
+ }) {
141
+ if (!Number.isFinite(visibleHeight) || visibleHeight <= 0) return 1;
142
+ if (!Number.isFinite(viewportHeight) || viewportHeight <= 0) return 1;
143
+
144
+ const worldPerPixel = visibleHeight / viewportHeight;
145
+ return worldPerPixel * pixelsPerMinorLine;
146
+ }
147
+
148
+ function calcScaleState(params: {
149
+ visibleHeight: number;
150
+ viewportHeight: number;
151
+ pixelsPerMinorLine?: number;
152
+ }) {
153
+ const rawCell = calcRawCellSize(params);
154
+
155
+ if (!Number.isFinite(rawCell) || rawCell <= 0) {
156
+ return {
157
+ rawCell: 1,
158
+ cellSize: 1,
159
+ subCellSize: 0.1,
160
+ sectionSize: 10,
161
+ subBlend: 1,
162
+ };
163
+ }
164
+
165
+ const cellSize = snapToNiceNumber(rawCell);
166
+ const subCellSize = cellSize / 10;
167
+ let subBlend = 0;
168
+
169
+ if (cellSize > subCellSize) {
170
+ subBlend = (cellSize - rawCell) / (cellSize - subCellSize);
171
+ subBlend = Math.max(0, Math.min(1, subBlend));
172
+ }
173
+
174
+ return {
175
+ rawCell,
176
+ cellSize,
177
+ subCellSize,
178
+ sectionSize: cellSize * 10,
179
+ subBlend,
180
+ };
181
+ }
182
+
183
+ function calcPlaneSize({
184
+ visibleHeight,
185
+ aspect,
186
+ margin = 2.5,
187
+ }: {
188
+ visibleHeight: number;
189
+ aspect: number;
190
+ margin?: number;
191
+ }) {
192
+ if (!Number.isFinite(visibleHeight) || visibleHeight <= 0) return 20;
193
+
194
+ const safeAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 16 / 9;
195
+ const visibleWidth = visibleHeight * safeAspect;
196
+
197
+ return Math.max(visibleWidth, visibleHeight) * margin;
198
+ }
199
+
200
+ export class ViewGrid {
201
+ camera: ViewCamera | null = null;
202
+ controls: CustomControls | null = null;
203
+ grid: ThreeMesh | null = null;
204
+ uniforms: Record<string, { value: unknown }> | null = null;
205
+
206
+ create(camera: ViewCamera, controls: CustomControls) {
207
+ this.camera = camera;
208
+ this.controls = controls;
209
+
210
+ const geometry = new PlaneGeometry(BASE_PLANE_SIZE, BASE_PLANE_SIZE, 1, 1);
211
+ geometry.rotateX(-Math.PI / 2);
212
+
213
+ this.uniforms = {
214
+ uCellSize: { value: 1 },
215
+ uSubCellSize: { value: 0.1 },
216
+ uSectionSize: { value: 10 },
217
+ uSubBlend: { value: 1 },
218
+ uCellColor: { value: new Color(0xd0d0d0) },
219
+ uSectionColor: { value: new Color(0xaaaaaa) },
220
+ uCenter: { value: new Vector3() },
221
+ uFadeRadius: { value: 50 },
222
+ };
223
+
224
+ const material = new ShaderMaterial({
225
+ uniforms: this.uniforms,
226
+ vertexShader: GRID_VERTEX_SHADER,
227
+ fragmentShader: GRID_FRAGMENT_SHADER,
228
+ transparent: true,
229
+ depthWrite: false,
230
+ side: DoubleSide,
231
+ });
232
+
233
+ this.grid = new Mesh(geometry, material);
234
+ this.grid.frustumCulled = false;
235
+ this.grid.renderOrder = 1;
236
+ this.grid.raycast = () => {};
237
+ this.grid.userData.objectType = HELPER_OBJECT_TYPE;
238
+
239
+ return this.grid;
240
+ }
241
+
242
+ update(viewportHeight: number) {
243
+ if (!this.camera || !this.controls || !this.grid || !this.uniforms) return;
244
+
245
+ const target = this.controls.getViewCenter?.() ?? this.controls.target;
246
+ const distance = this.camera.position.distanceTo(target);
247
+ const visibleHeight = getViewHeightAtDistance(this.camera, distance);
248
+ const gridScale = calcScaleState({
249
+ visibleHeight,
250
+ viewportHeight: viewportHeight || 720,
251
+ });
252
+ const planeSize = calcPlaneSize({
253
+ visibleHeight,
254
+ aspect: getViewAspect(this.camera),
255
+ });
256
+
257
+ this.uniforms.uCellSize.value = gridScale.cellSize;
258
+ this.uniforms.uSubCellSize.value = gridScale.subCellSize;
259
+ this.uniforms.uSectionSize.value = gridScale.sectionSize;
260
+ this.uniforms.uSubBlend.value = gridScale.subBlend;
261
+ (this.uniforms.uCenter.value as Vector3).copy(target);
262
+ this.uniforms.uFadeRadius.value = planeSize * 0.5;
263
+
264
+ const scale = planeSize / BASE_PLANE_SIZE;
265
+ this.grid.position.set(target.x, 0, target.z);
266
+ this.grid.scale.set(scale, 1, scale);
267
+ }
268
+ }
@@ -0,0 +1,173 @@
1
+ import {
2
+ CanvasTexture,
3
+ LinearFilter,
4
+ SRGBColorSpace,
5
+ Vector3,
6
+ type Box3,
7
+ type PerspectiveCamera,
8
+ } from 'three';
9
+
10
+ export type ViewCamera = {
11
+ fov?: number;
12
+ aspect?: number;
13
+ isOrthographicCamera?: boolean;
14
+ top?: number;
15
+ bottom?: number;
16
+ left?: number;
17
+ right?: number;
18
+ zoom?: number;
19
+ far?: number;
20
+ position: { distanceTo(v: { x: number; y: number; z: number }): number };
21
+ };
22
+
23
+ export function degToRad(deg: number): number {
24
+ return (deg * Math.PI) / 180;
25
+ }
26
+
27
+ /**
28
+ * Compute camera placement from bounds and viewport parameters.
29
+ * Shared by the main editor and model preview viewports.
30
+ */
31
+ export function computeCameraPlacement({
32
+ size,
33
+ center,
34
+ fov,
35
+ aspect,
36
+ padding = 1.6,
37
+ minDistance = 5,
38
+ direction = { x: 1, y: 0.8, z: 1 },
39
+ }: {
40
+ size: Vector3;
41
+ center: Vector3;
42
+ fov: number;
43
+ aspect: number;
44
+ padding?: number;
45
+ minDistance?: number;
46
+ direction?: { x: number; y: number; z: number };
47
+ }) {
48
+ const halfFov = degToRad(fov) / 2;
49
+ const fitHeightDistance = size.y / (2 * Math.tan(halfFov));
50
+ const fitWidthDistance = size.x / (2 * Math.tan(halfFov) * aspect);
51
+ const fitDepthDistance = size.z / 2;
52
+ const distance = Math.max(fitHeightDistance, fitWidthDistance, fitDepthDistance, minDistance) * padding;
53
+
54
+ const length = Math.hypot(direction.x, direction.y, direction.z) || 1;
55
+ const normalizedDirection = {
56
+ x: direction.x / length,
57
+ y: direction.y / length,
58
+ z: direction.z / length,
59
+ };
60
+
61
+ return {
62
+ distance,
63
+ position: {
64
+ x: center.x + normalizedDirection.x * distance,
65
+ y: center.y + normalizedDirection.y * distance,
66
+ z: center.z + normalizedDirection.z * distance,
67
+ },
68
+ };
69
+ }
70
+
71
+ /** Visible world height at a given distance from the camera target. */
72
+ export function getViewHeightAtDistance(camera: ViewCamera, distance: number): number {
73
+ if (camera?.isOrthographicCamera) {
74
+ const zoom = camera.zoom || 1;
75
+ return ((camera.top ?? 0) - (camera.bottom ?? 0)) / zoom;
76
+ }
77
+
78
+ const fov = Number.isFinite(camera?.fov) && (camera.fov ?? 0) > 0 ? (camera.fov as number) : 50;
79
+ const halfFovRad = (fov * Math.PI) / 360;
80
+ const safeDistance = Number.isFinite(distance) && distance > 0 ? distance : 1;
81
+ return 2 * Math.tan(halfFovRad) * safeDistance;
82
+ }
83
+
84
+ /** Camera aspect ratio (perspective or orthographic). */
85
+ export function getViewAspect(camera: ViewCamera): number {
86
+ if (camera?.isOrthographicCamera) {
87
+ const height = (camera.top ?? 0) - (camera.bottom ?? 0);
88
+ return height !== 0 ? ((camera.right ?? 0) - (camera.left ?? 0)) / height : 1;
89
+ }
90
+
91
+ return Number.isFinite(camera?.aspect) && (camera.aspect ?? 0) > 0 ? (camera.aspect as number) : 16 / 9;
92
+ }
93
+
94
+ /** Blender-style viewport background gradient. */
95
+ export function createViewportBackground(topColor = '#454545', bottomColor = '#303030') {
96
+ const canvas = document.createElement('canvas');
97
+ canvas.width = 2;
98
+ canvas.height = 512;
99
+
100
+ const ctx = canvas.getContext('2d');
101
+ if (!ctx) {
102
+ throw new Error('Failed to create viewport background canvas context');
103
+ }
104
+
105
+ const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
106
+ gradient.addColorStop(0, topColor || '#454545');
107
+ gradient.addColorStop(1, bottomColor || '#303030');
108
+ ctx.fillStyle = gradient;
109
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
110
+
111
+ const texture = new CanvasTexture(canvas);
112
+ texture.colorSpace = SRGBColorSpace;
113
+ texture.magFilter = LinearFilter;
114
+ texture.minFilter = LinearFilter;
115
+
116
+ return texture;
117
+ }
118
+
119
+ const _center = new Vector3();
120
+ const _size = new Vector3();
121
+
122
+ /** Frame the camera on object or animated bounds. */
123
+ export function frameObjectInView({
124
+ camera,
125
+ controls,
126
+ getBounds,
127
+ fov,
128
+ aspect,
129
+ padding = 1.8,
130
+ direction = { x: 1, y: 0.55, z: 1 },
131
+ }: {
132
+ camera: PerspectiveCamera;
133
+ controls?: { target: Vector3; maxDistance?: number; update(): void } | null;
134
+ getBounds: () => Box3;
135
+ fov: number;
136
+ aspect: number;
137
+ padding?: number;
138
+ direction?: { x: number; y: number; z: number };
139
+ }) {
140
+ const box = getBounds();
141
+ if (!box || box.isEmpty()) {
142
+ return false;
143
+ }
144
+
145
+ box.getCenter(_center);
146
+ box.getSize(_size);
147
+
148
+ const placement = computeCameraPlacement({
149
+ size: _size,
150
+ center: _center,
151
+ fov,
152
+ aspect,
153
+ padding,
154
+ minDistance: 0.5,
155
+ direction,
156
+ });
157
+
158
+ camera.fov = fov;
159
+ camera.position.set(placement.position.x, placement.position.y, placement.position.z);
160
+ camera.near = Math.max(placement.distance / 200, 0.01);
161
+ camera.far = Math.max(placement.distance * 200, 1000);
162
+ camera.aspect = aspect;
163
+ camera.updateProjectionMatrix();
164
+ camera.lookAt(_center);
165
+
166
+ if (controls) {
167
+ controls.target.copy(_center);
168
+ controls.maxDistance = placement.distance * 20;
169
+ controls.update();
170
+ }
171
+
172
+ return true;
173
+ }