mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 14:45:36 +00:00
gizmo controls (#11274)
## Summary Add Gizmo transform controls to load3d - Remove automatic model normalization (scale + center) on load; models now appear at their original transform. The previous auto-normalization conflicted with gizmo controls — applying scale/position on load made it impossible to track and reset the user's intentional transform edits vs. the system's normalization - Add a manual Fit to Viewer button that performs the same normalization on demand, giving users explicit control - Add Gizmo Controls (translate/rotate) for interactive model manipulation with full state persistence across node properties, viewer dialog, and model reloads - Gizmo transform state is excluded from scene capture and recording to keep outputs clean ## Motivation The gizmo system is a prerequisite for these potential features: - Custom cameras — user-placed cameras in the scene need transform gizmos for precise positioning and orientation - Custom lights — scene lighting setup requires the ability to interactively position and aim light sources - Multi-object scene composition — positioning multiple models relative to each other requires per-object transform controls - Pose editor — skeletal pose editing depends on the same transform infrastructure to manipulate individual bones/joints Auto-normalization was removed because it silently mutated model transforms on load, making it impossible to distinguish between the original model pose and user edits. This broke gizmo reset (which needs to know the "clean" state) and would corrupt round-trip transform persistence. ## Screenshots (if applicable) https://github.com/user-attachments/assets/621ea559-d7c8-4c5a-a727-98e6a4130b66 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11274-gizmo-controls-3436d73d365081c38357c2d58e49c558) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -190,28 +190,40 @@ export class CameraManager implements CameraManagerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
setupForModel(size: THREE.Vector3): void {
|
||||
setupForModel(
|
||||
size: THREE.Vector3,
|
||||
center: THREE.Vector3 = new THREE.Vector3(0, size.y / 2, 0)
|
||||
): void {
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
const distance = Math.max(size.x, size.z) * 2
|
||||
const height = size.y * 2
|
||||
const height = center.y + maxDim
|
||||
|
||||
this.perspectiveCamera.position.set(distance, height, distance)
|
||||
this.orthographicCamera.position.set(distance, height, distance)
|
||||
this.perspectiveCamera.position.set(
|
||||
center.x + distance,
|
||||
height,
|
||||
center.z + distance
|
||||
)
|
||||
this.orthographicCamera.position.set(
|
||||
center.x + distance,
|
||||
height,
|
||||
center.z + distance
|
||||
)
|
||||
|
||||
if (this.activeCamera === this.perspectiveCamera) {
|
||||
this.perspectiveCamera.lookAt(0, size.y / 2, 0)
|
||||
this.perspectiveCamera.lookAt(center)
|
||||
this.perspectiveCamera.updateProjectionMatrix()
|
||||
} else {
|
||||
const frustumSize = Math.max(size.x, size.y, size.z) * 2
|
||||
const frustumSize = maxDim * 2
|
||||
const aspect = this.perspectiveCamera.aspect
|
||||
this.orthographicCamera.left = (-frustumSize * aspect) / 2
|
||||
this.orthographicCamera.right = (frustumSize * aspect) / 2
|
||||
this.orthographicCamera.top = frustumSize / 2
|
||||
this.orthographicCamera.bottom = -frustumSize / 2
|
||||
this.orthographicCamera.lookAt(0, size.y / 2, 0)
|
||||
this.orthographicCamera.lookAt(center)
|
||||
this.orthographicCamera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
this.controls?.target.set(0, size.y / 2, 0)
|
||||
this.controls?.target.copy(center)
|
||||
this.controls?.update()
|
||||
}
|
||||
|
||||
|
||||
368
src/extensions/core/load3d/GizmoManager.test.ts
Normal file
368
src/extensions/core/load3d/GizmoManager.test.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { GizmoManager } from './GizmoManager'
|
||||
|
||||
const { mockSetMode, mockAttach, mockDetach, mockGetHelper, mockDispose } =
|
||||
vi.hoisted(() => ({
|
||||
mockSetMode: vi.fn(),
|
||||
mockAttach: vi.fn(),
|
||||
mockDetach: vi.fn(),
|
||||
mockGetHelper: vi.fn(),
|
||||
mockDispose: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/controls/TransformControls', () => {
|
||||
class TransformControls {
|
||||
enabled = true
|
||||
camera: THREE.Camera
|
||||
private listeners = new Map<string, ((e: unknown) => void)[]>()
|
||||
|
||||
constructor(camera: THREE.Camera) {
|
||||
this.camera = camera
|
||||
}
|
||||
|
||||
addEventListener(event: string, cb: (e: unknown) => void) {
|
||||
if (!this.listeners.has(event)) this.listeners.set(event, [])
|
||||
this.listeners.get(event)!.push(cb)
|
||||
}
|
||||
|
||||
setMode = mockSetMode
|
||||
attach = mockAttach
|
||||
detach = mockDetach
|
||||
getHelper = mockGetHelper
|
||||
dispose = mockDispose
|
||||
|
||||
emit(event: string, data: unknown) {
|
||||
for (const cb of this.listeners.get(event) ?? []) cb(data)
|
||||
}
|
||||
}
|
||||
return { TransformControls }
|
||||
})
|
||||
|
||||
vi.mock('three/examples/jsm/controls/OrbitControls', () => {
|
||||
class OrbitControls {
|
||||
enabled = true
|
||||
}
|
||||
return { OrbitControls }
|
||||
})
|
||||
|
||||
function makeMockOrbitControls() {
|
||||
return { enabled: true } as unknown as InstanceType<
|
||||
typeof import('three/examples/jsm/controls/OrbitControls').OrbitControls
|
||||
>
|
||||
}
|
||||
|
||||
describe('GizmoManager', () => {
|
||||
let scene: THREE.Scene
|
||||
let renderer: THREE.WebGLRenderer
|
||||
let camera: THREE.PerspectiveCamera
|
||||
let orbitControls: ReturnType<typeof makeMockOrbitControls>
|
||||
let manager: GizmoManager
|
||||
let onTransformChange: () => void
|
||||
let mockHelper: THREE.Object3D
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
scene = new THREE.Scene()
|
||||
renderer = {
|
||||
domElement: document.createElement('canvas')
|
||||
} as unknown as THREE.WebGLRenderer
|
||||
camera = new THREE.PerspectiveCamera()
|
||||
orbitControls = makeMockOrbitControls()
|
||||
onTransformChange = vi.fn()
|
||||
|
||||
mockHelper = new THREE.Object3D()
|
||||
mockHelper.name = ''
|
||||
mockHelper.renderOrder = 0
|
||||
mockGetHelper.mockReturnValue(mockHelper)
|
||||
|
||||
manager = new GizmoManager(
|
||||
scene,
|
||||
renderer,
|
||||
orbitControls,
|
||||
() => camera,
|
||||
onTransformChange
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('init', () => {
|
||||
it('adds helper to scene with correct name and render order', () => {
|
||||
manager.init()
|
||||
|
||||
expect(mockGetHelper).toHaveBeenCalled()
|
||||
expect(mockHelper.name).toBe('GizmoTransformControls')
|
||||
expect(mockHelper.renderOrder).toBe(999)
|
||||
expect(scene.children).toContain(mockHelper)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupForModel', () => {
|
||||
it('attaches to model and stores initial transform when enabled', () => {
|
||||
manager.init()
|
||||
manager.setEnabled(true)
|
||||
|
||||
const model = new THREE.Object3D()
|
||||
model.position.set(1, 2, 3)
|
||||
model.rotation.set(0.1, 0.2, 0.3)
|
||||
|
||||
manager.setupForModel(model)
|
||||
|
||||
expect(mockDetach).toHaveBeenCalled()
|
||||
expect(mockAttach).toHaveBeenCalledWith(model)
|
||||
expect(mockSetMode).toHaveBeenCalledWith('translate')
|
||||
})
|
||||
|
||||
it('does not attach when disabled', () => {
|
||||
manager.init()
|
||||
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
|
||||
expect(mockAttach).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing before init', () => {
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
|
||||
expect(mockDetach).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setEnabled', () => {
|
||||
it('attaches to target when enabled with a target', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
|
||||
vi.mocked(mockAttach).mockClear()
|
||||
manager.setEnabled(true)
|
||||
|
||||
expect(mockAttach).toHaveBeenCalledWith(model)
|
||||
expect(manager.isEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
it('detaches when disabled', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
manager.setEnabled(true)
|
||||
|
||||
vi.mocked(mockDetach).mockClear()
|
||||
manager.setEnabled(false)
|
||||
|
||||
expect(mockDetach).toHaveBeenCalled()
|
||||
expect(manager.isEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing before init', () => {
|
||||
manager.setEnabled(true)
|
||||
expect(mockAttach).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detach', () => {
|
||||
it('detaches and clears target', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
manager.setEnabled(true)
|
||||
|
||||
vi.mocked(mockDetach).mockClear()
|
||||
manager.detach()
|
||||
|
||||
expect(mockDetach).toHaveBeenCalled()
|
||||
expect(manager.isEnabled()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setMode / getMode', () => {
|
||||
it('defaults to translate', () => {
|
||||
expect(manager.getMode()).toBe('translate')
|
||||
})
|
||||
|
||||
it('switches to rotate', () => {
|
||||
manager.init()
|
||||
manager.setMode('rotate')
|
||||
|
||||
expect(manager.getMode()).toBe('rotate')
|
||||
expect(mockSetMode).toHaveBeenCalledWith('rotate')
|
||||
})
|
||||
|
||||
it('stores mode before init', () => {
|
||||
manager.setMode('rotate')
|
||||
expect(manager.getMode()).toBe('rotate')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('restores initial position, rotation, and scale', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
model.position.set(1, 2, 3)
|
||||
model.rotation.set(0.1, 0.2, 0.3)
|
||||
model.scale.set(2, 2, 2)
|
||||
|
||||
manager.setupForModel(model)
|
||||
|
||||
model.position.set(10, 20, 30)
|
||||
model.rotation.set(1, 2, 3)
|
||||
model.scale.set(5, 5, 5)
|
||||
|
||||
manager.reset()
|
||||
|
||||
expect(model.position.x).toBeCloseTo(1)
|
||||
expect(model.position.y).toBeCloseTo(2)
|
||||
expect(model.position.z).toBeCloseTo(3)
|
||||
expect(model.rotation.x).toBeCloseTo(0.1)
|
||||
expect(model.rotation.y).toBeCloseTo(0.2)
|
||||
expect(model.rotation.z).toBeCloseTo(0.3)
|
||||
expect(model.scale.x).toBeCloseTo(2)
|
||||
expect(model.scale.y).toBeCloseTo(2)
|
||||
expect(model.scale.z).toBeCloseTo(2)
|
||||
})
|
||||
|
||||
it('does nothing without a target', () => {
|
||||
manager.init()
|
||||
expect(() => manager.reset()).not.toThrow()
|
||||
})
|
||||
|
||||
it('invokes onTransformChange after resetting', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
model.position.set(1, 2, 3)
|
||||
manager.setupForModel(model)
|
||||
|
||||
expect(onTransformChange).not.toHaveBeenCalled()
|
||||
|
||||
manager.reset()
|
||||
|
||||
expect(onTransformChange).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyTransform', () => {
|
||||
it('sets position and rotation on target', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
|
||||
manager.applyTransform({ x: 5, y: 6, z: 7 }, { x: 0.5, y: 0.6, z: 0.7 })
|
||||
|
||||
expect(model.position.x).toBeCloseTo(5)
|
||||
expect(model.position.y).toBeCloseTo(6)
|
||||
expect(model.position.z).toBeCloseTo(7)
|
||||
expect(model.rotation.x).toBeCloseTo(0.5)
|
||||
expect(model.rotation.y).toBeCloseTo(0.6)
|
||||
expect(model.rotation.z).toBeCloseTo(0.7)
|
||||
})
|
||||
|
||||
it('applies scale when provided', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
|
||||
manager.applyTransform(
|
||||
{ x: 0, y: 0, z: 0 },
|
||||
{ x: 0, y: 0, z: 0 },
|
||||
{ x: 2, y: 3, z: 4 }
|
||||
)
|
||||
|
||||
expect(model.scale.x).toBeCloseTo(2)
|
||||
expect(model.scale.y).toBeCloseTo(3)
|
||||
expect(model.scale.z).toBeCloseTo(4)
|
||||
})
|
||||
|
||||
it('does nothing without a target', () => {
|
||||
manager.init()
|
||||
expect(() =>
|
||||
manager.applyTransform({ x: 1, y: 2, z: 3 }, { x: 0, y: 0, z: 0 })
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTransform', () => {
|
||||
it('returns current target transform', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
model.position.set(1, 2, 3)
|
||||
model.rotation.set(0.1, 0.2, 0.3)
|
||||
model.scale.set(4, 5, 6)
|
||||
manager.setupForModel(model)
|
||||
|
||||
const transform = manager.getTransform()
|
||||
|
||||
expect(transform.position).toEqual({ x: 1, y: 2, z: 3 })
|
||||
expect(transform.rotation.x).toBeCloseTo(0.1)
|
||||
expect(transform.rotation.y).toBeCloseTo(0.2)
|
||||
expect(transform.rotation.z).toBeCloseTo(0.3)
|
||||
expect(transform.scale).toEqual({ x: 4, y: 5, z: 6 })
|
||||
})
|
||||
|
||||
it('returns zero/identity when no target', () => {
|
||||
const transform = manager.getTransform()
|
||||
|
||||
expect(transform.position).toEqual({ x: 0, y: 0, z: 0 })
|
||||
expect(transform.rotation).toEqual({ x: 0, y: 0, z: 0 })
|
||||
expect(transform.scale).toEqual({ x: 1, y: 1, z: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeFromScene / ensureHelperInScene', () => {
|
||||
it('removes helper from scene', () => {
|
||||
manager.init()
|
||||
expect(scene.children).toContain(mockHelper)
|
||||
|
||||
manager.removeFromScene()
|
||||
|
||||
expect(scene.children).not.toContain(mockHelper)
|
||||
})
|
||||
|
||||
it('restores helper to scene', () => {
|
||||
manager.init()
|
||||
manager.removeFromScene()
|
||||
|
||||
manager.ensureHelperInScene()
|
||||
|
||||
expect(scene.children).toContain(mockHelper)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('removes helper, detaches, and disposes controls', () => {
|
||||
manager.init()
|
||||
scene.add(mockHelper)
|
||||
|
||||
manager.dispose()
|
||||
|
||||
expect(mockDetach).toHaveBeenCalled()
|
||||
expect(mockDispose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is safe to call before init', () => {
|
||||
expect(() => manager.dispose()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureHelperInScene', () => {
|
||||
it('re-adds helper if it was removed from its parent', () => {
|
||||
manager.init()
|
||||
// Simulate helper being removed from scene
|
||||
scene.remove(mockHelper)
|
||||
expect(scene.children).not.toContain(mockHelper)
|
||||
|
||||
// setEnabled triggers ensureHelperInScene internally
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
manager.setEnabled(true)
|
||||
|
||||
expect(scene.children).toContain(mockHelper)
|
||||
})
|
||||
})
|
||||
})
|
||||
229
src/extensions/core/load3d/GizmoManager.ts
Normal file
229
src/extensions/core/load3d/GizmoManager.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import * as THREE from 'three'
|
||||
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
|
||||
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
|
||||
import type { GizmoMode } from './interfaces'
|
||||
|
||||
export class GizmoManager {
|
||||
private transformControls: TransformControls | null = null
|
||||
private targetObject: THREE.Object3D | null = null
|
||||
private initialPosition: THREE.Vector3 = new THREE.Vector3()
|
||||
private initialRotation: THREE.Euler = new THREE.Euler()
|
||||
private initialScale: THREE.Vector3 = new THREE.Vector3(1, 1, 1)
|
||||
private enabled: boolean = false
|
||||
private activeCamera: THREE.Camera
|
||||
private mode: GizmoMode = 'translate'
|
||||
private scene: THREE.Scene
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private orbitControls: OrbitControls
|
||||
private onTransformChange?: () => void
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
orbitControls: OrbitControls,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
onTransformChange?: () => void
|
||||
) {
|
||||
this.scene = scene
|
||||
this.renderer = renderer
|
||||
this.orbitControls = orbitControls
|
||||
this.activeCamera = getActiveCamera()
|
||||
this.onTransformChange = onTransformChange
|
||||
}
|
||||
|
||||
init(): void {
|
||||
this.transformControls = new TransformControls(
|
||||
this.activeCamera,
|
||||
this.renderer.domElement
|
||||
)
|
||||
|
||||
this.transformControls.addEventListener('dragging-changed', (event) => {
|
||||
this.orbitControls.enabled = !event.value
|
||||
if (!event.value && this.onTransformChange) {
|
||||
this.onTransformChange()
|
||||
}
|
||||
})
|
||||
|
||||
const helper = this.transformControls.getHelper()
|
||||
helper.name = 'GizmoTransformControls'
|
||||
helper.renderOrder = 999
|
||||
this.scene.add(helper)
|
||||
}
|
||||
|
||||
setupForModel(model: THREE.Object3D): void {
|
||||
if (!this.transformControls) return
|
||||
|
||||
this.ensureHelperInScene()
|
||||
|
||||
this.transformControls.detach()
|
||||
this.transformControls.enabled = false
|
||||
|
||||
this.targetObject = model
|
||||
this.initialPosition.copy(model.position)
|
||||
this.initialRotation.copy(model.rotation)
|
||||
this.initialScale.copy(model.scale)
|
||||
|
||||
if (this.enabled) {
|
||||
this.transformControls.attach(model)
|
||||
this.transformControls.setMode(this.mode)
|
||||
this.transformControls.enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.enabled = false
|
||||
if (this.transformControls) {
|
||||
this.transformControls.detach()
|
||||
this.transformControls.enabled = false
|
||||
}
|
||||
this.targetObject = null
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled
|
||||
|
||||
if (!this.transformControls) return
|
||||
|
||||
this.ensureHelperInScene()
|
||||
|
||||
if (enabled && this.targetObject) {
|
||||
this.transformControls.attach(this.targetObject)
|
||||
this.transformControls.setMode(this.mode)
|
||||
this.transformControls.enabled = true
|
||||
} else {
|
||||
this.transformControls.detach()
|
||||
this.transformControls.enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
ensureHelperInScene(): void {
|
||||
if (!this.transformControls) return
|
||||
const helper = this.transformControls.getHelper()
|
||||
if (!helper.parent) {
|
||||
this.scene.add(helper)
|
||||
}
|
||||
}
|
||||
|
||||
removeFromScene(): void {
|
||||
if (!this.transformControls) return
|
||||
const helper = this.transformControls.getHelper()
|
||||
if (helper.parent) {
|
||||
helper.parent.remove(helper)
|
||||
}
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.enabled
|
||||
}
|
||||
|
||||
updateCamera(camera: THREE.Camera): void {
|
||||
this.activeCamera = camera
|
||||
if (this.transformControls) {
|
||||
this.transformControls.camera = camera
|
||||
}
|
||||
}
|
||||
|
||||
setMode(mode: GizmoMode): void {
|
||||
this.mode = mode
|
||||
|
||||
if (this.transformControls) {
|
||||
this.transformControls.setMode(mode)
|
||||
}
|
||||
}
|
||||
|
||||
getMode(): GizmoMode {
|
||||
return this.mode
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
if (!this.targetObject) return
|
||||
|
||||
this.targetObject.position.copy(this.initialPosition)
|
||||
this.targetObject.rotation.copy(this.initialRotation)
|
||||
this.targetObject.scale.copy(this.initialScale)
|
||||
this.onTransformChange?.()
|
||||
}
|
||||
|
||||
applyTransform(
|
||||
position: { x: number; y: number; z: number },
|
||||
rotation: { x: number; y: number; z: number },
|
||||
scale?: { x: number; y: number; z: number }
|
||||
): void {
|
||||
if (!this.targetObject) return
|
||||
this.targetObject.position.set(position.x, position.y, position.z)
|
||||
this.targetObject.rotation.set(rotation.x, rotation.y, rotation.z)
|
||||
if (scale) {
|
||||
this.targetObject.scale.set(scale.x, scale.y, scale.z)
|
||||
}
|
||||
}
|
||||
|
||||
getInitialTransform(): {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
scale: { x: number; y: number; z: number }
|
||||
} {
|
||||
return {
|
||||
position: {
|
||||
x: this.initialPosition.x,
|
||||
y: this.initialPosition.y,
|
||||
z: this.initialPosition.z
|
||||
},
|
||||
rotation: {
|
||||
x: this.initialRotation.x,
|
||||
y: this.initialRotation.y,
|
||||
z: this.initialRotation.z
|
||||
},
|
||||
scale: {
|
||||
x: this.initialScale.x,
|
||||
y: this.initialScale.y,
|
||||
z: this.initialScale.z
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getTransform(): {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
scale: { x: number; y: number; z: number }
|
||||
} {
|
||||
if (!this.targetObject) {
|
||||
return {
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
position: {
|
||||
x: this.targetObject.position.x,
|
||||
y: this.targetObject.position.y,
|
||||
z: this.targetObject.position.z
|
||||
},
|
||||
rotation: {
|
||||
x: this.targetObject.rotation.x,
|
||||
y: this.targetObject.rotation.y,
|
||||
z: this.targetObject.rotation.z
|
||||
},
|
||||
scale: {
|
||||
x: this.targetObject.scale.x,
|
||||
y: this.targetObject.scale.y,
|
||||
z: this.targetObject.scale.z
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.transformControls) {
|
||||
const helper = this.transformControls.getHelper()
|
||||
this.scene.remove(helper)
|
||||
this.transformControls.detach()
|
||||
this.transformControls.dispose()
|
||||
this.transformControls = null
|
||||
}
|
||||
|
||||
this.targetObject = null
|
||||
}
|
||||
}
|
||||
164
src/extensions/core/load3d/Load3DConfiguration.test.ts
Normal file
164
src/extensions/core/load3d/Load3DConfiguration.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import type {
|
||||
GizmoConfig,
|
||||
ModelConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { Dictionary } from '@/lib/litegraph/src/interfaces'
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (p: string) => p,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchCustomEvent: vi.fn(),
|
||||
fetchApi: vi.fn(),
|
||||
getSystemStats: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: { extra: {} } }
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3d', () => ({ default: class {} }))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
default: {
|
||||
splitFilePath: vi.fn(),
|
||||
getResourceURL: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
type WithPrivate = { loadModelConfig(): ModelConfig }
|
||||
|
||||
function createConfig(properties?: Dictionary<NodeProperty | undefined>) {
|
||||
const load3d = {} as Load3d
|
||||
return new Load3DConfiguration(load3d, properties) as unknown as WithPrivate
|
||||
}
|
||||
|
||||
const defaultGizmo: GizmoConfig = {
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
|
||||
describe('Load3DConfiguration.loadModelConfig', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns full defaults including gizmo when no properties are provided', () => {
|
||||
const result = createConfig().loadModelConfig()
|
||||
|
||||
expect(result).toEqual({
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false,
|
||||
gizmo: defaultGizmo
|
||||
})
|
||||
})
|
||||
|
||||
it('returns full defaults when properties do not contain Model Config', () => {
|
||||
const result = createConfig({ 'Other Key': 'x' }).loadModelConfig()
|
||||
|
||||
expect(result.gizmo).toEqual(defaultGizmo)
|
||||
})
|
||||
|
||||
it('adds default gizmo when Model Config exists but has no gizmo field', () => {
|
||||
const stored: ModelConfig = {
|
||||
upDirection: '+y',
|
||||
materialMode: 'wireframe',
|
||||
showSkeleton: true
|
||||
}
|
||||
const properties = { 'Model Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
|
||||
const result = createConfig(properties).loadModelConfig()
|
||||
|
||||
expect(result.upDirection).toBe('+y')
|
||||
expect(result.materialMode).toBe('wireframe')
|
||||
expect(result.showSkeleton).toBe(true)
|
||||
expect(result.gizmo).toEqual(defaultGizmo)
|
||||
})
|
||||
|
||||
it('mutates the original Model Config property to persist gizmo defaults', () => {
|
||||
const stored: ModelConfig = {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
}
|
||||
const properties = { 'Model Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
|
||||
createConfig(properties).loadModelConfig()
|
||||
|
||||
expect((properties['Model Config'] as ModelConfig).gizmo).toEqual(
|
||||
defaultGizmo
|
||||
)
|
||||
})
|
||||
|
||||
it('backfills scale on legacy gizmo config missing the scale field', () => {
|
||||
const legacyGizmo = {
|
||||
enabled: true,
|
||||
mode: 'rotate',
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
rotation: { x: 0.1, y: 0.2, z: 0.3 }
|
||||
} as unknown as GizmoConfig
|
||||
const stored: ModelConfig = {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false,
|
||||
gizmo: legacyGizmo
|
||||
}
|
||||
const properties = { 'Model Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
|
||||
const result = createConfig(properties).loadModelConfig()
|
||||
|
||||
expect(result.gizmo).toEqual({
|
||||
enabled: true,
|
||||
mode: 'rotate',
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
rotation: { x: 0.1, y: 0.2, z: 0.3 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a fully populated gizmo config unchanged', () => {
|
||||
const fullGizmo: GizmoConfig = {
|
||||
enabled: true,
|
||||
mode: 'scale',
|
||||
position: { x: 5, y: 6, z: 7 },
|
||||
rotation: { x: 1, y: 2, z: 3 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
}
|
||||
const stored: ModelConfig = {
|
||||
upDirection: '-z',
|
||||
materialMode: 'normal',
|
||||
showSkeleton: false,
|
||||
gizmo: fullGizmo
|
||||
}
|
||||
const properties = { 'Model Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
|
||||
const result = createConfig(properties).loadModelConfig()
|
||||
|
||||
expect(result.gizmo).toEqual(fullGizmo)
|
||||
})
|
||||
})
|
||||
@@ -167,13 +167,32 @@ class Load3DConfiguration {
|
||||
|
||||
private loadModelConfig(): ModelConfig {
|
||||
if (this.properties && 'Model Config' in this.properties) {
|
||||
return this.properties['Model Config'] as ModelConfig
|
||||
const config = this.properties['Model Config'] as ModelConfig
|
||||
if (!config.gizmo) {
|
||||
config.gizmo = {
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
} else if (!config.gizmo.scale) {
|
||||
config.gizmo.scale = { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
showSkeleton: false,
|
||||
gizmo: {
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
269
src/extensions/core/load3d/Load3d.test.ts
Normal file
269
src/extensions/core/load3d/Load3d.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
type GizmoStub = {
|
||||
setEnabled: ReturnType<typeof vi.fn>
|
||||
setMode: ReturnType<typeof vi.fn>
|
||||
reset: ReturnType<typeof vi.fn>
|
||||
applyTransform: ReturnType<typeof vi.fn>
|
||||
getTransform: ReturnType<typeof vi.fn>
|
||||
setupForModel: ReturnType<typeof vi.fn>
|
||||
updateCamera: ReturnType<typeof vi.fn>
|
||||
detach: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
removeFromScene: ReturnType<typeof vi.fn>
|
||||
ensureHelperInScene: ReturnType<typeof vi.fn>
|
||||
isEnabled: ReturnType<typeof vi.fn>
|
||||
getMode: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type ModelManagerStub = {
|
||||
fitToViewer: ReturnType<typeof vi.fn>
|
||||
clearModel: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type CameraManagerStub = {
|
||||
toggleCamera: ReturnType<typeof vi.fn>
|
||||
setupForModel: ReturnType<typeof vi.fn>
|
||||
reset: ReturnType<typeof vi.fn>
|
||||
activeCamera: THREE.Camera
|
||||
}
|
||||
|
||||
type SceneManagerStub = {
|
||||
captureScene: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type Load3dPrivate = {
|
||||
setGizmo(model: THREE.Object3D): void
|
||||
setupCamera(size: THREE.Vector3, center: THREE.Vector3): void
|
||||
}
|
||||
|
||||
function makeGizmoStub(): GizmoStub {
|
||||
return {
|
||||
setEnabled: vi.fn(),
|
||||
setMode: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
applyTransform: vi.fn(),
|
||||
getTransform: vi.fn(() => ({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
})),
|
||||
setupForModel: vi.fn(),
|
||||
updateCamera: vi.fn(),
|
||||
detach: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
removeFromScene: vi.fn(),
|
||||
ensureHelperInScene: vi.fn(),
|
||||
isEnabled: vi.fn(() => false),
|
||||
getMode: vi.fn(() => 'translate')
|
||||
}
|
||||
}
|
||||
|
||||
function makeInstance() {
|
||||
const gizmo = makeGizmoStub()
|
||||
const modelManager: ModelManagerStub = {
|
||||
fitToViewer: vi.fn(),
|
||||
clearModel: vi.fn()
|
||||
}
|
||||
const cameraManager: CameraManagerStub = {
|
||||
toggleCamera: vi.fn(),
|
||||
setupForModel: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
activeCamera: new THREE.PerspectiveCamera()
|
||||
}
|
||||
const sceneManager: SceneManagerStub = {
|
||||
captureScene: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
const controlsManager = { updateCamera: vi.fn() }
|
||||
const viewHelperManager = { recreateViewHelper: vi.fn() }
|
||||
const animationManager = { dispose: vi.fn() }
|
||||
|
||||
// Load3d's constructor instantiates THREE.WebGLRenderer, ResizeObserver
|
||||
// and ViewHelper, none of which are available in happy-dom. Skip it and
|
||||
// inject stubs directly onto the prototype instance so delegation methods
|
||||
// can be exercised in isolation.
|
||||
const load3d = Object.create(Load3d.prototype) as Load3d
|
||||
Object.assign(load3d, {
|
||||
gizmoManager: gizmo,
|
||||
modelManager,
|
||||
cameraManager,
|
||||
sceneManager,
|
||||
controlsManager,
|
||||
viewHelperManager,
|
||||
animationManager,
|
||||
forceRender: vi.fn(),
|
||||
handleResize: vi.fn()
|
||||
})
|
||||
|
||||
return {
|
||||
load3d,
|
||||
gizmo,
|
||||
modelManager,
|
||||
cameraManager,
|
||||
sceneManager,
|
||||
controlsManager,
|
||||
viewHelperManager,
|
||||
animationManager,
|
||||
forceRender: load3d.forceRender as ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
describe('Load3d', () => {
|
||||
let ctx: ReturnType<typeof makeInstance>
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = makeInstance()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('gizmo delegation', () => {
|
||||
it('getGizmoManager returns the underlying manager', () => {
|
||||
expect(ctx.load3d.getGizmoManager()).toBe(ctx.gizmo)
|
||||
})
|
||||
|
||||
it('setGizmoEnabled delegates to gizmoManager.setEnabled and forces a render', () => {
|
||||
ctx.load3d.setGizmoEnabled(true)
|
||||
|
||||
expect(ctx.gizmo.setEnabled).toHaveBeenCalledWith(true)
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['translate', 'rotate', 'scale'] as const)(
|
||||
'setGizmoMode delegates "%s" and forces a render',
|
||||
(mode: GizmoMode) => {
|
||||
ctx.load3d.setGizmoMode(mode)
|
||||
|
||||
expect(ctx.gizmo.setMode).toHaveBeenCalledWith(mode)
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('resetGizmoTransform delegates to gizmoManager.reset and forces a render', () => {
|
||||
ctx.load3d.resetGizmoTransform()
|
||||
|
||||
expect(ctx.gizmo.reset).toHaveBeenCalledOnce()
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('applyGizmoTransform forwards position, rotation and scale', () => {
|
||||
const pos = { x: 1, y: 2, z: 3 }
|
||||
const rot = { x: 0.1, y: 0.2, z: 0.3 }
|
||||
const scale = { x: 2, y: 2, z: 2 }
|
||||
|
||||
ctx.load3d.applyGizmoTransform(pos, rot, scale)
|
||||
|
||||
expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, scale)
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('applyGizmoTransform forwards undefined scale when not provided', () => {
|
||||
const pos = { x: 0, y: 0, z: 0 }
|
||||
const rot = { x: 0, y: 0, z: 0 }
|
||||
|
||||
ctx.load3d.applyGizmoTransform(pos, rot)
|
||||
|
||||
expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, undefined)
|
||||
})
|
||||
|
||||
it('getGizmoTransform returns the gizmoManager transform', () => {
|
||||
const transform = {
|
||||
position: { x: 5, y: 6, z: 7 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
ctx.gizmo.getTransform.mockReturnValue(transform)
|
||||
|
||||
expect(ctx.load3d.getGizmoTransform()).toEqual(transform)
|
||||
})
|
||||
|
||||
it('fitToViewer delegates to modelManager and forces a render', () => {
|
||||
ctx.load3d.fitToViewer()
|
||||
|
||||
expect(ctx.modelManager.fitToViewer).toHaveBeenCalledOnce()
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lifecycle interactions', () => {
|
||||
it('clearModel detaches the gizmo before clearing the model', () => {
|
||||
const order: string[] = []
|
||||
ctx.animationManager.dispose.mockImplementation(() =>
|
||||
order.push('animation')
|
||||
)
|
||||
ctx.gizmo.detach.mockImplementation(() => order.push('detach'))
|
||||
ctx.modelManager.clearModel.mockImplementation(() => order.push('clear'))
|
||||
|
||||
ctx.load3d.clearModel()
|
||||
|
||||
expect(order).toEqual(['animation', 'detach', 'clear'])
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('toggleCamera updates both controls and gizmo with the active camera', () => {
|
||||
ctx.load3d.toggleCamera('orthographic')
|
||||
|
||||
expect(ctx.cameraManager.toggleCamera).toHaveBeenCalledWith(
|
||||
'orthographic'
|
||||
)
|
||||
expect(ctx.controlsManager.updateCamera).toHaveBeenCalledWith(
|
||||
ctx.cameraManager.activeCamera
|
||||
)
|
||||
expect(ctx.gizmo.updateCamera).toHaveBeenCalledWith(
|
||||
ctx.cameraManager.activeCamera
|
||||
)
|
||||
expect(ctx.viewHelperManager.recreateViewHelper).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('setGizmo (private) forwards the model to gizmoManager.setupForModel', () => {
|
||||
const model = new THREE.Object3D()
|
||||
|
||||
;(ctx.load3d as unknown as Load3dPrivate).setGizmo(model)
|
||||
|
||||
expect(ctx.gizmo.setupForModel).toHaveBeenCalledWith(model)
|
||||
})
|
||||
|
||||
it('setupCamera (private) forwards size and center to cameraManager', () => {
|
||||
const size = new THREE.Vector3(1, 2, 3)
|
||||
const center = new THREE.Vector3(4, 5, 6)
|
||||
|
||||
;(ctx.load3d as unknown as Load3dPrivate).setupCamera(size, center)
|
||||
|
||||
expect(ctx.cameraManager.setupForModel).toHaveBeenCalledWith(size, center)
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScene', () => {
|
||||
it('hides the gizmo helper during capture and restores it after success', async () => {
|
||||
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }
|
||||
ctx.sceneManager.captureScene.mockResolvedValue(captureResult)
|
||||
|
||||
const result = await ctx.load3d.captureScene(100, 200)
|
||||
|
||||
expect(ctx.gizmo.removeFromScene).toHaveBeenCalledBefore(
|
||||
ctx.sceneManager.captureScene
|
||||
)
|
||||
expect(ctx.sceneManager.captureScene).toHaveBeenCalledWith(100, 200)
|
||||
expect(ctx.gizmo.ensureHelperInScene).toHaveBeenCalledOnce()
|
||||
expect(result).toBe(captureResult)
|
||||
})
|
||||
|
||||
it('restores the gizmo helper even when capture fails', async () => {
|
||||
const err = new Error('capture failed')
|
||||
ctx.sceneManager.captureScene.mockRejectedValue(err)
|
||||
|
||||
await expect(ctx.load3d.captureScene(100, 200)).rejects.toBe(err)
|
||||
|
||||
expect(ctx.gizmo.removeFromScene).toHaveBeenCalledOnce()
|
||||
expect(ctx.gizmo.ensureHelperInScene).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
import { EventManager } from './EventManager'
|
||||
import { HDRIManager } from './HDRIManager'
|
||||
import { GizmoManager } from './GizmoManager'
|
||||
import { LightingManager } from './LightingManager'
|
||||
import { LoaderManager } from './LoaderManager'
|
||||
import { ModelExporter } from './ModelExporter'
|
||||
@@ -14,13 +15,14 @@ import { RecordingManager } from './RecordingManager'
|
||||
import { SceneManager } from './SceneManager'
|
||||
import { SceneModelManager } from './SceneModelManager'
|
||||
import { ViewHelperManager } from './ViewHelperManager'
|
||||
import {
|
||||
type CameraState,
|
||||
type CaptureResult,
|
||||
type EventCallback,
|
||||
type Load3DOptions,
|
||||
type MaterialMode,
|
||||
type UpDirection
|
||||
import type {
|
||||
CameraState,
|
||||
CaptureResult,
|
||||
EventCallback,
|
||||
GizmoMode,
|
||||
Load3DOptions,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from './interfaces'
|
||||
|
||||
function positionThumbnailCamera(
|
||||
@@ -61,6 +63,7 @@ class Load3d {
|
||||
modelManager: SceneModelManager
|
||||
recordingManager: RecordingManager
|
||||
animationManager: AnimationManager
|
||||
gizmoManager: GizmoManager
|
||||
|
||||
STATUS_MOUSE_ON_NODE: boolean
|
||||
STATUS_MOUSE_ON_SCENE: boolean
|
||||
@@ -146,7 +149,8 @@ class Load3d {
|
||||
this.renderer,
|
||||
this.eventManager,
|
||||
this.getActiveCamera.bind(this),
|
||||
this.setupCamera.bind(this)
|
||||
this.setupCamera.bind(this),
|
||||
this.setGizmo.bind(this)
|
||||
)
|
||||
|
||||
this.loaderManager = new LoaderManager(this.modelManager, this.eventManager)
|
||||
@@ -158,12 +162,29 @@ class Load3d {
|
||||
)
|
||||
|
||||
this.animationManager = new AnimationManager(this.eventManager)
|
||||
|
||||
this.gizmoManager = new GizmoManager(
|
||||
this.sceneManager.scene,
|
||||
this.renderer,
|
||||
this.controlsManager.controls,
|
||||
this.getActiveCamera.bind(this),
|
||||
() => {
|
||||
const transform = this.gizmoManager.getTransform()
|
||||
this.eventManager.emitEvent('gizmoTransformChange', {
|
||||
...transform,
|
||||
enabled: this.gizmoManager.isEnabled(),
|
||||
mode: this.gizmoManager.getMode()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
this.sceneManager.init()
|
||||
this.cameraManager.init()
|
||||
this.controlsManager.init()
|
||||
this.lightingManager.init()
|
||||
this.loaderManager.init()
|
||||
this.animationManager.init()
|
||||
this.gizmoManager.init()
|
||||
|
||||
this.viewHelperManager.createViewHelper(container)
|
||||
this.viewHelperManager.init()
|
||||
@@ -287,6 +308,10 @@ class Load3d {
|
||||
return this.recordingManager
|
||||
}
|
||||
|
||||
getGizmoManager(): GizmoManager {
|
||||
return this.gizmoManager
|
||||
}
|
||||
|
||||
getTargetSize(): { width: number; height: number } {
|
||||
return {
|
||||
width: this.targetWidth,
|
||||
@@ -388,8 +413,12 @@ class Load3d {
|
||||
return this.controlsManager.controls
|
||||
}
|
||||
|
||||
private setupCamera(size: THREE.Vector3): void {
|
||||
this.cameraManager.setupForModel(size)
|
||||
private setGizmo(model: THREE.Object3D): void {
|
||||
this.gizmoManager.setupForModel(model)
|
||||
}
|
||||
|
||||
private setupCamera(size: THREE.Vector3, center: THREE.Vector3): void {
|
||||
this.cameraManager.setupForModel(size, center)
|
||||
}
|
||||
|
||||
private startAnimation(): void {
|
||||
@@ -551,6 +580,7 @@ class Load3d {
|
||||
this.cameraManager.toggleCamera(cameraType)
|
||||
|
||||
this.controlsManager.updateCamera(this.cameraManager.activeCamera)
|
||||
this.gizmoManager.updateCamera(this.cameraManager.activeCamera)
|
||||
this.viewHelperManager.recreateViewHelper()
|
||||
|
||||
this.handleResize()
|
||||
@@ -601,6 +631,7 @@ class Load3d {
|
||||
): Promise<void> {
|
||||
this.cameraManager.reset()
|
||||
this.controlsManager.reset()
|
||||
this.gizmoManager.detach()
|
||||
this.modelManager.clearModel()
|
||||
this.animationManager.dispose()
|
||||
|
||||
@@ -629,6 +660,7 @@ class Load3d {
|
||||
|
||||
clearModel(): void {
|
||||
this.animationManager.dispose()
|
||||
this.gizmoManager.detach()
|
||||
this.modelManager.clearModel()
|
||||
this.forceRender()
|
||||
}
|
||||
@@ -736,7 +768,11 @@ class Load3d {
|
||||
}
|
||||
|
||||
captureScene(width: number, height: number): Promise<CaptureResult> {
|
||||
return this.sceneManager.captureScene(width, height)
|
||||
this.gizmoManager.removeFromScene()
|
||||
|
||||
return this.sceneManager.captureScene(width, height).finally(() => {
|
||||
this.gizmoManager.ensureHelperInScene()
|
||||
})
|
||||
}
|
||||
|
||||
public async startRecording(): Promise<void> {
|
||||
@@ -853,7 +889,7 @@ class Load3d {
|
||||
this.controlsManager.controls.update()
|
||||
}
|
||||
|
||||
const result = await this.sceneManager.captureScene(width, height)
|
||||
const result = await this.captureScene(width, height)
|
||||
return result.scene
|
||||
} finally {
|
||||
this.sceneManager.gridHelper.visible = savedGridVisible
|
||||
@@ -866,6 +902,43 @@ class Load3d {
|
||||
}
|
||||
}
|
||||
|
||||
public setGizmoEnabled(enabled: boolean): void {
|
||||
this.gizmoManager.setEnabled(enabled)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public setGizmoMode(mode: GizmoMode): void {
|
||||
this.gizmoManager.setMode(mode)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public resetGizmoTransform(): void {
|
||||
this.gizmoManager.reset()
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public applyGizmoTransform(
|
||||
position: { x: number; y: number; z: number },
|
||||
rotation: { x: number; y: number; z: number },
|
||||
scale?: { x: number; y: number; z: number }
|
||||
): void {
|
||||
this.gizmoManager.applyTransform(position, rotation, scale)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public getGizmoTransform(): {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
scale: { x: number; y: number; z: number }
|
||||
} {
|
||||
return this.gizmoManager.getTransform()
|
||||
}
|
||||
|
||||
public fitToViewer(): void {
|
||||
this.modelManager.fitToViewer()
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public remove(): void {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect()
|
||||
@@ -899,6 +972,7 @@ class Load3d {
|
||||
this.modelManager.dispose()
|
||||
this.recordingManager.dispose()
|
||||
this.animationManager.dispose()
|
||||
this.gizmoManager.dispose()
|
||||
|
||||
this.renderer.dispose()
|
||||
this.renderer.domElement.remove()
|
||||
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
} from './interfaces'
|
||||
|
||||
export class SceneManager implements SceneManagerInterface {
|
||||
scene: THREE.Scene
|
||||
scene!: THREE.Scene
|
||||
gridHelper: THREE.GridHelper
|
||||
|
||||
backgroundScene: THREE.Scene
|
||||
backgroundScene!: THREE.Scene
|
||||
backgroundCamera: THREE.OrthographicCamera
|
||||
backgroundMesh: THREE.Mesh | null = null
|
||||
backgroundTexture: THREE.Texture | null = null
|
||||
@@ -38,6 +38,8 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.eventManager = eventManager
|
||||
this.scene = new THREE.Scene()
|
||||
|
||||
this.scene.name = 'MainScene'
|
||||
|
||||
this.getActiveCamera = getActiveCamera
|
||||
|
||||
this.gridHelper = new THREE.GridHelper(20, 20)
|
||||
@@ -45,6 +47,7 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.scene.add(this.gridHelper)
|
||||
|
||||
this.backgroundScene = new THREE.Scene()
|
||||
this.backgroundScene.name = 'BackgroundScene'
|
||||
this.backgroundCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1)
|
||||
|
||||
this.initBackgroundScene()
|
||||
@@ -93,6 +96,8 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.scene.background = null
|
||||
}
|
||||
|
||||
this.backgroundScene.clear()
|
||||
|
||||
this.scene.clear()
|
||||
}
|
||||
|
||||
|
||||
@@ -37,14 +37,16 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private eventManager: EventManagerInterface
|
||||
private activeCamera: THREE.Camera
|
||||
private setupCamera: (size: THREE.Vector3) => void
|
||||
private setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void
|
||||
private setupGizmo: (model: THREE.Object3D) => void
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
eventManager: EventManagerInterface,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
setupCamera: (size: THREE.Vector3) => void
|
||||
setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void,
|
||||
setupGizmo: (model: THREE.Object3D) => void
|
||||
) {
|
||||
this.scene = scene
|
||||
this.renderer = renderer
|
||||
@@ -52,6 +54,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.activeCamera = getActiveCamera()
|
||||
this.setupCamera = setupCamera
|
||||
this.textureLoader = new THREE.TextureLoader()
|
||||
this.setupGizmo = setupGizmo
|
||||
|
||||
this.normalMaterial = new THREE.MeshNormalMaterial({
|
||||
flatShading: false,
|
||||
@@ -371,32 +374,31 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
clearModel(): void {
|
||||
const objectsToRemove: THREE.Object3D[] = []
|
||||
|
||||
this.scene.traverse((object) => {
|
||||
for (const object of [...this.scene.children]) {
|
||||
const isEnvironmentObject =
|
||||
object instanceof THREE.GridHelper ||
|
||||
object instanceof THREE.Light ||
|
||||
object instanceof THREE.Camera
|
||||
object instanceof THREE.Camera ||
|
||||
object.name === 'GizmoTransformControls'
|
||||
|
||||
if (!isEnvironmentObject) {
|
||||
objectsToRemove.push(object)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
objectsToRemove.forEach((obj) => {
|
||||
if (obj.parent && obj.parent !== this.scene) {
|
||||
obj.parent.remove(obj)
|
||||
} else {
|
||||
this.scene.remove(obj)
|
||||
}
|
||||
this.scene.remove(obj)
|
||||
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
obj.geometry?.dispose()
|
||||
if (Array.isArray(obj.material)) {
|
||||
obj.material.forEach((material) => material.dispose())
|
||||
} else {
|
||||
obj.material?.dispose()
|
||||
obj.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry?.dispose()
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach((material) => material.dispose())
|
||||
} else {
|
||||
child.material?.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.reset()
|
||||
@@ -497,25 +499,10 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
// SplatMesh handles its own rendering, just add to scene
|
||||
this.scene.add(model)
|
||||
// Set a default camera distance for splat models
|
||||
this.setupCamera(new THREE.Vector3(5, 5, 5))
|
||||
this.setupCamera(new THREE.Vector3(5, 5, 5), new THREE.Vector3(0, 2.5, 0))
|
||||
return
|
||||
}
|
||||
|
||||
const box = new THREE.Box3().setFromObject(model)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
const targetSize = 5
|
||||
const scale = targetSize / maxDim
|
||||
model.scale.multiplyScalar(scale)
|
||||
|
||||
box.setFromObject(model)
|
||||
box.getCenter(center)
|
||||
box.getSize(size)
|
||||
|
||||
model.position.set(-center.x, -box.min.y, -center.z)
|
||||
|
||||
this.scene.add(model)
|
||||
|
||||
if (this.materialMode !== 'original') {
|
||||
@@ -527,7 +514,47 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
}
|
||||
this.setupModelMaterials(model)
|
||||
|
||||
this.setupCamera(size)
|
||||
const box = new THREE.Box3().setFromObject(model)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
this.setupCamera(size, center)
|
||||
|
||||
this.setupGizmo(model)
|
||||
}
|
||||
|
||||
fitToViewer(): void {
|
||||
if (!this.currentModel || this.containsSplatMesh()) return
|
||||
const model = this.currentModel
|
||||
|
||||
// Reset transform to compute from raw geometry (idempotent)
|
||||
model.scale.set(1, 1, 1)
|
||||
model.position.set(0, 0, 0)
|
||||
model.rotation.set(0, 0, 0)
|
||||
|
||||
const box = new THREE.Box3().setFromObject(model)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
if (maxDim === 0) return
|
||||
|
||||
const targetSize = 5
|
||||
const scale = targetSize / maxDim
|
||||
model.scale.set(scale, scale, scale)
|
||||
|
||||
box.setFromObject(model)
|
||||
box.getCenter(center)
|
||||
box.getSize(size)
|
||||
|
||||
model.position.set(-center.x, -box.min.y, -center.z)
|
||||
|
||||
const newBox = new THREE.Box3().setFromObject(model)
|
||||
const newSize = newBox.getSize(new THREE.Vector3())
|
||||
const newCenter = newBox.getCenter(new THREE.Vector3())
|
||||
|
||||
this.setupCamera(newSize, newCenter)
|
||||
this.setupGizmo(model)
|
||||
}
|
||||
|
||||
containsSplatMesh(model?: THREE.Object3D | null): boolean {
|
||||
@@ -548,6 +575,8 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
setUpDirection(direction: UpDirection): void {
|
||||
if (!this.currentModel) return
|
||||
|
||||
const directionChanged = this.currentUpDirection !== direction
|
||||
|
||||
if (!this.originalRotation && this.currentModel.rotation) {
|
||||
this.originalRotation = this.currentModel.rotation.clone()
|
||||
}
|
||||
@@ -581,5 +610,9 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('upDirectionChange', direction)
|
||||
|
||||
if (directionChanged) {
|
||||
this.setupGizmo(this.currentModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +33,21 @@ export interface SceneConfig {
|
||||
backgroundRenderMode?: BackgroundRenderModeType
|
||||
}
|
||||
|
||||
export type GizmoMode = 'translate' | 'rotate' | 'scale'
|
||||
|
||||
export interface GizmoConfig {
|
||||
enabled: boolean
|
||||
mode: GizmoMode
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
scale: { x: number; y: number; z: number }
|
||||
}
|
||||
|
||||
export interface ModelConfig {
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
showSkeleton: boolean
|
||||
gizmo?: GizmoConfig
|
||||
}
|
||||
|
||||
export interface CameraConfig {
|
||||
|
||||
Reference in New Issue
Block a user