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:
Terry Jia
2026-04-18 22:45:06 -04:00
committed by GitHub
parent 3db0eac353
commit deba72e7a0
25 changed files with 2554 additions and 360 deletions

View File

@@ -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()
}

View 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)
})
})
})

View 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
}
}

View 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)
})
})

View File

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

View 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()
})
})
})

View File

@@ -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()

View File

@@ -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()
}

View File

@@ -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)
}
}
}

View File

@@ -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 {