diff --git a/browser_tests/tests/load3d/Load3DHelper.ts b/browser_tests/tests/load3d/Load3DHelper.ts index f0a2ff6779..b1f8754ea5 100644 --- a/browser_tests/tests/load3d/Load3DHelper.ts +++ b/browser_tests/tests/load3d/Load3DHelper.ts @@ -34,10 +34,35 @@ export class Load3DHelper { return this.node.getByText(name, { exact: true }) } + get gizmoToggleButton(): Locator { + return this.node.getByRole('button', { name: 'Gizmo' }) + } + + get gizmoTranslateButton(): Locator { + return this.node.getByRole('button', { name: 'Translate' }) + } + + get gizmoRotateButton(): Locator { + return this.node.getByRole('button', { name: 'Rotate' }) + } + + get gizmoScaleButton(): Locator { + return this.node.getByRole('button', { name: 'Scale' }) + } + + get gizmoResetButton(): Locator { + return this.node.getByRole('button', { name: 'Reset Transform' }) + } + async openMenu(): Promise { await this.menuButton.click() } + async openGizmoCategory(): Promise { + await this.openMenu() + await this.getMenuCategory('Gizmo').click() + } + async setBackgroundColor(hex: string): Promise { await this.colorInput.evaluate((el, value) => { ;(el as HTMLInputElement).value = value diff --git a/browser_tests/tests/load3d/gizmoControls.spec.ts b/browser_tests/tests/load3d/gizmoControls.spec.ts new file mode 100644 index 0000000000..fa666cb4aa --- /dev/null +++ b/browser_tests/tests/load3d/gizmoControls.spec.ts @@ -0,0 +1,87 @@ +import { expect } from '@playwright/test' +import type { Page } from '@playwright/test' + +import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures' + +const getGizmoConfig = (page: Page) => + page.evaluate(() => { + const n = window.app!.graph.getNodeById(1) + const modelConfig = n?.properties?.['Model Config'] as + | { gizmo?: { enabled: boolean; mode: string } } + | undefined + return modelConfig?.gizmo + }) + +test.describe('Load3D Gizmo Controls', () => { + test( + 'Gizmo category appears in the controls menu', + { tag: '@smoke' }, + async ({ load3d }) => { + await load3d.openMenu() + + await expect(load3d.getMenuCategory('Gizmo')).toBeVisible() + } + ) + + test( + 'Selecting Gizmo category shows the toggle button', + { tag: '@smoke' }, + async ({ load3d }) => { + await load3d.openGizmoCategory() + + await expect(load3d.gizmoToggleButton).toBeVisible() + await expect(load3d.gizmoTranslateButton).toBeHidden() + await expect(load3d.gizmoRotateButton).toBeHidden() + await expect(load3d.gizmoScaleButton).toBeHidden() + await expect(load3d.gizmoResetButton).toBeHidden() + } + ) + + test( + 'Toggling gizmo reveals mode buttons and updates node state', + { tag: '@smoke' }, + async ({ comfyPage, load3d }) => { + await load3d.openGizmoCategory() + await load3d.gizmoToggleButton.click() + + await expect(load3d.gizmoTranslateButton).toBeVisible() + await expect(load3d.gizmoRotateButton).toBeVisible() + await expect(load3d.gizmoScaleButton).toBeVisible() + await expect(load3d.gizmoResetButton).toBeVisible() + + await expect + .poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.enabled)) + .toBe(true) + + await load3d.gizmoToggleButton.click() + await expect(load3d.gizmoTranslateButton).toBeHidden() + await expect + .poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.enabled)) + .toBe(false) + } + ) + + test( + 'Selecting a gizmo mode updates node state', + { tag: '@smoke' }, + async ({ comfyPage, load3d }) => { + await load3d.openGizmoCategory() + await load3d.gizmoToggleButton.click() + + await load3d.gizmoRotateButton.click() + await expect + .poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.mode)) + .toBe('rotate') + + await load3d.gizmoScaleButton.click() + await expect + .poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.mode)) + .toBe('scale') + + await load3d.gizmoTranslateButton.click() + await expect + .poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.mode)) + .toBe('translate') + } + ) +}) diff --git a/src/components/load3d/Load3D.vue b/src/components/load3d/Load3D.vue index fd48c8d597..eea2148595 100644 --- a/src/components/load3d/Load3D.vue +++ b/src/components/load3d/Load3D.vue @@ -28,6 +28,9 @@ @update-background-image="handleBackgroundImageUpdate" @export-model="handleExportModel" @update-hdri-file="handleHDRIFileUpdate" + @toggle-gizmo="handleToggleGizmo" + @set-gizmo-mode="handleSetGizmoMode" + @reset-gizmo-transform="handleResetGizmoTransform" /> +
+
+ +
+
+
@@ -51,8 +72,8 @@ v-if="!isPreview" class="pointer-events-auto absolute right-2 z-20" :class="{ - 'top-12': !enable3DViewer, - 'top-24': enable3DViewer + 'top-24': !enable3DViewer, + 'top-36': enable3DViewer }" > ) diff --git a/src/components/load3d/Load3DControls.vue b/src/components/load3d/Load3DControls.vue index 36dad94bfd..00ee90136b 100644 --- a/src/components/load3d/Load3DControls.vue +++ b/src/components/load3d/Load3DControls.vue @@ -92,6 +92,14 @@ v-if="showExportControls" @export-model="handleExportModel" /> + + @@ -102,6 +110,7 @@ import { computed, ref } from 'vue' import CameraControls from '@/components/load3d/controls/CameraControls.vue' import { useDismissableOverlay } from '@/composables/useDismissableOverlay' import ExportControls from '@/components/load3d/controls/ExportControls.vue' +import GizmoControls from '@/components/load3d/controls/GizmoControls.vue' import HDRIControls from '@/components/load3d/controls/HDRIControls.vue' import LightControls from '@/components/load3d/controls/LightControls.vue' import ModelControls from '@/components/load3d/controls/ModelControls.vue' @@ -109,6 +118,7 @@ import SceneControls from '@/components/load3d/controls/SceneControls.vue' import Button from '@/components/ui/button/Button.vue' import type { CameraConfig, + GizmoMode, LightConfig, ModelConfig, SceneConfig @@ -148,6 +158,7 @@ const categoryLabels: Record = { model: 'load3d.model', camera: 'load3d.camera', light: 'load3d.light', + gizmo: 'load3d.gizmo.label', export: 'load3d.export' } @@ -156,7 +167,7 @@ const availableCategories = computed(() => { return ['scene', 'model', 'camera'] } - return ['scene', 'model', 'camera', 'light', 'export'] + return ['scene', 'model', 'camera', 'light', 'gizmo', 'export'] }) const showSceneControls = computed( @@ -175,6 +186,9 @@ const showLightControls = computed( !!modelConfig.value ) const showExportControls = computed(() => activeCategory.value === 'export') +const showGizmoControls = computed( + () => activeCategory.value === 'gizmo' && !!modelConfig.value +) const toggleMenu = () => { isMenuOpen.value = !isMenuOpen.value @@ -190,6 +204,7 @@ const categoryIcons = { model: 'icon-[lucide--box]', camera: 'icon-[lucide--camera]', light: 'icon-[lucide--sun]', + gizmo: 'icon-[lucide--move-3d]', export: 'icon-[lucide--download]' } as const @@ -205,6 +220,9 @@ const emit = defineEmits<{ (e: 'updateBackgroundImage', file: File | null): void (e: 'exportModel', format: string): void (e: 'updateHdriFile', file: File | null): void + (e: 'toggleGizmo', enabled: boolean): void + (e: 'setGizmoMode', mode: GizmoMode): void + (e: 'resetGizmoTransform'): void }>() const handleBackgroundImageUpdate = (file: File | null) => { @@ -218,4 +236,16 @@ const handleExportModel = (format: string) => { const handleHDRIFileUpdate = (file: File | null) => { emit('updateHdriFile', file) } + +const handleToggleGizmo = (enabled: boolean) => { + emit('toggleGizmo', enabled) +} + +const handleSetGizmoMode = (mode: GizmoMode) => { + emit('setGizmoMode', mode) +} + +const handleResetGizmoTransform = () => { + emit('resetGizmoTransform') +} diff --git a/src/components/load3d/Load3dViewerContent.vue b/src/components/load3d/Load3dViewerContent.vue index 6be0dfa6bd..b57bf38cc5 100644 --- a/src/components/load3d/Load3dViewerContent.vue +++ b/src/components/load3d/Load3dViewerContent.vue @@ -74,6 +74,14 @@ /> +
+ +
+
@@ -99,6 +107,7 @@ import { useI18n } from 'vue-i18n' import AnimationControls from '@/components/load3d/controls/AnimationControls.vue' import CameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue' import ExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue' +import GizmoControls from '@/components/load3d/controls/viewer/ViewerGizmoControls.vue' import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue' import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue' import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue' diff --git a/src/components/load3d/controls/GizmoControls.test.ts b/src/components/load3d/controls/GizmoControls.test.ts new file mode 100644 index 0000000000..83014aab94 --- /dev/null +++ b/src/components/load3d/controls/GizmoControls.test.ts @@ -0,0 +1,155 @@ +import userEvent from '@testing-library/user-event' +import { render, screen } from '@testing-library/vue' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import GizmoControls from '@/components/load3d/controls/GizmoControls.vue' +import type { GizmoConfig } from '@/extensions/core/load3d/interfaces' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + load3d: { + gizmo: { + toggle: 'Gizmo', + translate: 'Translate', + rotate: 'Rotate', + scale: 'Scale', + reset: 'Reset Transform' + } + } + } + } +}) + +function makeConfig(overrides: Partial = {}): GizmoConfig { + return { + 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 }, + ...overrides + } +} + +function renderComponent(initial: Partial = {}) { + const gizmoConfig = ref(makeConfig(initial)) + + const utils = render(GizmoControls, { + props: { + gizmoConfig: gizmoConfig.value, + 'onUpdate:gizmoConfig': (v: GizmoConfig | undefined) => { + if (v) gizmoConfig.value = v + } + }, + global: { + plugins: [i18n], + directives: { tooltip: () => {} } + } + }) + + return { ...utils, gizmoConfig, user: userEvent.setup() } +} + +describe('GizmoControls', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders only the toggle button when gizmo is disabled', () => { + renderComponent({ enabled: false }) + + expect(screen.getByRole('button', { name: 'Gizmo' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Rotate' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Scale' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Reset Transform' })).toBeNull() + }) + + it('renders mode and reset buttons when gizmo is enabled', () => { + renderComponent({ enabled: true }) + + expect(screen.getByRole('button', { name: 'Translate' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Rotate' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Scale' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Reset Transform' })).toBeTruthy() + }) + + it('flips enabled and emits toggleGizmo when the toggle is clicked', async () => { + const { user, gizmoConfig, emitted } = renderComponent({ enabled: false }) + + await user.click(screen.getByRole('button', { name: 'Gizmo' })) + + expect(gizmoConfig.value.enabled).toBe(true) + expect(emitted().toggleGizmo).toEqual([[true]]) + }) + + it('turns off gizmo and emits false when toggled from enabled state', async () => { + const { user, gizmoConfig, emitted } = renderComponent({ enabled: true }) + + await user.click(screen.getByRole('button', { name: 'Gizmo' })) + + expect(gizmoConfig.value.enabled).toBe(false) + expect(emitted().toggleGizmo).toEqual([[false]]) + }) + + it.each([ + ['Translate', 'translate'], + ['Rotate', 'rotate'], + ['Scale', 'scale'] + ] as const)( + 'sets mode to %s and emits setGizmoMode when clicked', + async (label, mode) => { + const { user, gizmoConfig, emitted } = renderComponent({ enabled: true }) + + await user.click(screen.getByRole('button', { name: label })) + + expect(gizmoConfig.value.mode).toBe(mode) + expect(emitted().setGizmoMode).toEqual([[mode]]) + } + ) + + it('emits resetGizmoTransform without mutating config on reset click', async () => { + const { user, gizmoConfig, emitted } = renderComponent({ + enabled: true, + mode: 'rotate' + }) + + await user.click(screen.getByRole('button', { name: 'Reset Transform' })) + + expect(emitted().resetGizmoTransform).toEqual([[]]) + expect(gizmoConfig.value.mode).toBe('rotate') + expect(gizmoConfig.value.enabled).toBe(true) + }) + + it('highlights the active mode button with a ring', () => { + renderComponent({ enabled: true, mode: 'rotate' }) + + const translate = screen.getByRole('button', { name: 'Translate' }) + const rotate = screen.getByRole('button', { name: 'Rotate' }) + const scale = screen.getByRole('button', { name: 'Scale' }) + + expect(rotate.className).toContain('ring-2') + expect(translate.className).not.toContain('ring-2') + expect(scale.className).not.toContain('ring-2') + }) + + it('does nothing when clicked with no model value bound', async () => { + const user = userEvent.setup() + const { emitted } = render(GizmoControls, { + props: { gizmoConfig: undefined }, + global: { + plugins: [i18n], + directives: { tooltip: () => {} } + } + }) + + await user.click(screen.getByRole('button', { name: 'Gizmo' })) + + expect(emitted().toggleGizmo).toBeUndefined() + }) +}) diff --git a/src/components/load3d/controls/GizmoControls.vue b/src/components/load3d/controls/GizmoControls.vue new file mode 100644 index 0000000000..d57d07a633 --- /dev/null +++ b/src/components/load3d/controls/GizmoControls.vue @@ -0,0 +1,122 @@ + + + diff --git a/src/components/load3d/controls/viewer/ViewerGizmoControls.test.ts b/src/components/load3d/controls/viewer/ViewerGizmoControls.test.ts new file mode 100644 index 0000000000..0adb09312d --- /dev/null +++ b/src/components/load3d/controls/viewer/ViewerGizmoControls.test.ts @@ -0,0 +1,133 @@ +import userEvent from '@testing-library/user-event' +import { render, screen } from '@testing-library/vue' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import ViewerGizmoControls from '@/components/load3d/controls/viewer/ViewerGizmoControls.vue' +import type { GizmoMode } from '@/extensions/core/load3d/interfaces' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { on: 'On', off: 'Off' }, + load3d: { + gizmo: { + toggle: 'Gizmo', + translate: 'Translate', + rotate: 'Rotate', + scale: 'Scale', + reset: 'Reset Transform' + } + } + } + } +}) + +function renderComponent( + initial: { enabled?: boolean; mode?: GizmoMode } = {} +) { + const enabled = ref(initial.enabled ?? false) + const mode = ref(initial.mode ?? 'translate') + + const utils = render(ViewerGizmoControls, { + props: { + gizmoEnabled: enabled.value, + 'onUpdate:gizmoEnabled': (v: boolean | undefined) => { + if (v !== undefined) enabled.value = v + }, + gizmoMode: mode.value, + 'onUpdate:gizmoMode': (v: GizmoMode | undefined) => { + if (v) mode.value = v + } + }, + global: { + plugins: [i18n] + } + }) + + return { ...utils, enabled, mode, user: userEvent.setup() } +} + +describe('ViewerGizmoControls', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders only the on/off toggle when gizmo is disabled', () => { + renderComponent({ enabled: false }) + + expect(screen.getByText('Gizmo')).toBeTruthy() + expect(screen.getByText('Off')).toBeTruthy() + expect(screen.getByText('On')).toBeTruthy() + + expect(screen.queryByText('Translate')).toBeNull() + expect(screen.queryByText('Rotate')).toBeNull() + expect(screen.queryByText('Scale')).toBeNull() + expect(screen.queryByText('Reset Transform')).toBeNull() + }) + + it('renders mode toggles and reset button when gizmo is enabled', () => { + renderComponent({ enabled: true }) + + expect(screen.getByText('Translate')).toBeTruthy() + expect(screen.getByText('Rotate')).toBeTruthy() + expect(screen.getByText('Scale')).toBeTruthy() + expect(screen.getByText('Reset Transform')).toBeTruthy() + }) + + it('enables gizmo when the On item is clicked', async () => { + const { user, enabled } = renderComponent({ enabled: false }) + + await user.click(screen.getByText('On')) + + expect(enabled.value).toBe(true) + }) + + it('disables gizmo when the Off item is clicked from an enabled state', async () => { + const { user, enabled } = renderComponent({ enabled: true }) + + await user.click(screen.getByText('Off')) + + expect(enabled.value).toBe(false) + }) + + it.each([ + ['Translate', 'translate'], + ['Rotate', 'rotate'], + ['Scale', 'scale'] + ] as const)( + 'updates mode to %s when its toggle item is clicked', + async (label, expected) => { + const { user, mode } = renderComponent({ + enabled: true, + mode: 'translate' + }) + + await user.click(screen.getByText(label)) + + expect(mode.value).toBe(expected) + } + ) + + it('emits reset-transform when the reset button is clicked', async () => { + const { user, emitted } = renderComponent({ + enabled: true, + mode: 'rotate' + }) + + await user.click(screen.getByRole('button', { name: /reset transform/i })) + + expect(emitted()['reset-transform']).toEqual([[]]) + }) + + it('leaves mode unchanged when deselecting the active mode', async () => { + const { user, mode } = renderComponent({ enabled: true, mode: 'scale' }) + + await user.click(screen.getByText('Scale')) + + expect(mode.value).toBe('scale') + }) +}) diff --git a/src/components/load3d/controls/viewer/ViewerGizmoControls.vue b/src/components/load3d/controls/viewer/ViewerGizmoControls.vue new file mode 100644 index 0000000000..086c8d6b97 --- /dev/null +++ b/src/components/load3d/controls/viewer/ViewerGizmoControls.vue @@ -0,0 +1,63 @@ + + + diff --git a/src/composables/useLoad3d.test.ts b/src/composables/useLoad3d.test.ts index 153ec60a98..c96414a4ec 100644 --- a/src/composables/useLoad3d.test.ts +++ b/src/composables/useLoad3d.test.ts @@ -146,6 +146,12 @@ describe('useLoad3d', () => { addEventListener: vi.fn(), removeEventListener: vi.fn(), remove: vi.fn(), + setGizmoEnabled: vi.fn(), + setGizmoMode: vi.fn(), + resetGizmoTransform: vi.fn(), + applyGizmoTransform: vi.fn(), + fitToViewer: vi.fn(), + setAnimationTime: vi.fn(), renderer: { domElement: mockCanvas } as Partial as Load3d['renderer'] @@ -169,38 +175,6 @@ describe('useLoad3d', () => { }) describe('initialization', () => { - it('should initialize with default values', () => { - const composable = useLoad3d(mockNode) - - expect(composable.sceneConfig.value).toEqual({ - showGrid: true, - backgroundColor: '#000000', - backgroundImage: '', - backgroundRenderMode: 'tiled' - }) - expect(composable.modelConfig.value).toEqual({ - upDirection: 'original', - materialMode: 'original', - showSkeleton: false - }) - expect(composable.cameraConfig.value).toEqual({ - cameraType: 'perspective', - fov: 75 - }) - expect(composable.lightConfig.value).toEqual({ - intensity: 5, - hdri: { - enabled: false, - hdriPath: '', - showAsBackground: false, - intensity: 1 - } - }) - expect(composable.isRecording.value).toBe(false) - expect(composable.hasRecording.value).toBe(false) - expect(composable.loading.value).toBe(false) - }) - it('should initialize Load3d with container and node', async () => { const composable = useLoad3d(mockNode) const containerRef = document.createElement('div') @@ -229,8 +203,6 @@ describe('useLoad3d', () => { expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(true) expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#000000') expect(mockLoad3d.setBackgroundRenderMode).toHaveBeenCalledWith('tiled') - expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('original') - expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('original') expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('perspective') expect(mockLoad3d.setFOV).toHaveBeenCalledWith(75) expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(5) @@ -271,53 +243,29 @@ describe('useLoad3d', () => { expect(mockLoad3d.renderer!.domElement.hidden).toBe(true) }) - it('should load model if model_file widget exists', async () => { + it('should initialize without loading model (model loading is handled by Load3DConfiguration)', async () => { mockNode.widgets!.push({ name: 'model_file', value: 'test.glb', type: 'text' } as IWidget) - vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([ - 'subfolder', - 'test.glb' - ]) - vi.mocked(Load3dUtils.getResourceURL).mockReturnValue( - '/api/view/test.glb' - ) - vi.mocked(api.apiURL).mockReturnValue( - 'http://localhost/api/view/test.glb' - ) const composable = useLoad3d(mockNode) const containerRef = document.createElement('div') await composable.initializeLoad3d(containerRef) - expect(mockLoad3d.loadModel).toHaveBeenCalledWith( - 'http://localhost/api/view/test.glb' - ) + expect(mockLoad3d.loadModel).not.toHaveBeenCalled() + expect(nodeToLoad3dMap.has(mockNode)).toBe(true) }) - it('should restore camera state after loading model', async () => { - mockNode.widgets!.push({ - name: 'model_file', - value: 'test.glb', - type: 'text' - } as IWidget) - ;(mockNode.properties!['Camera Config'] as { state: unknown }).state = { + it('should restore camera config from node properties', async () => { + ;( + mockNode.properties!['Camera Config'] as Record + ).state = { position: { x: 1, y: 2, z: 3 }, target: { x: 0, y: 0, z: 0 } } - vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([ - 'subfolder', - 'test.glb' - ]) - vi.mocked(Load3dUtils.getResourceURL).mockReturnValue( - '/api/view/test.glb' - ) - vi.mocked(api.apiURL).mockReturnValue( - 'http://localhost/api/view/test.glb' - ) const composable = useLoad3d(mockNode) const containerRef = document.createElement('div') @@ -325,7 +273,7 @@ describe('useLoad3d', () => { await composable.initializeLoad3d(containerRef) await nextTick() - expect(mockLoad3d.setCameraState).toHaveBeenCalledWith({ + expect(composable.cameraConfig.value.state).toEqual({ position: { x: 1, y: 2, z: 3 }, target: { x: 0, y: 0, z: 0 } }) @@ -460,11 +408,13 @@ describe('useLoad3d', () => { expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y') expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe') - expect(mockNode.properties['Model Config']).toEqual({ - upDirection: '+y', - materialMode: 'wireframe', - showSkeleton: false - }) + const savedModelConfig = mockNode.properties['Model Config'] as Record< + string, + unknown + > + expect(savedModelConfig.upDirection).toBe('+y') + expect(savedModelConfig.materialMode).toBe('wireframe') + expect(savedModelConfig.showSkeleton).toBe(false) }) it('should update camera config when values change', async () => { @@ -862,79 +812,72 @@ describe('useLoad3d', () => { }) }) - describe('getModelUrl', () => { - it('should handle http URLs directly', async () => { - mockNode.widgets!.push({ - name: 'model_file', - value: 'http://example.com/model.glb', - type: 'text' - } as IWidget) - - const composable = useLoad3d(mockNode) - const containerRef = document.createElement('div') - - await composable.initializeLoad3d(containerRef) - - expect(mockLoad3d.loadModel).toHaveBeenCalledWith( - 'http://example.com/model.glb' - ) - }) - - it('should construct URL for local files', async () => { - mockNode.widgets!.push({ - name: 'model_file', - value: 'models/test.glb', - type: 'text' - } as IWidget) + describe('handleModelDrop', () => { + it('should upload file, construct URL, and load model', async () => { + vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb') vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([ - 'models', - 'test.glb' + 'uploaded', + 'model.glb' ]) vi.mocked(Load3dUtils.getResourceURL).mockReturnValue( - '/api/view/models/test.glb' + '/api/view/uploaded/model.glb' ) vi.mocked(api.apiURL).mockReturnValue( - 'http://localhost/api/view/models/test.glb' + 'http://localhost/api/view/uploaded/model.glb' ) const composable = useLoad3d(mockNode) const containerRef = document.createElement('div') - await composable.initializeLoad3d(containerRef) - expect(Load3dUtils.splitFilePath).toHaveBeenCalledWith('models/test.glb') - expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith( - 'models', - 'test.glb', - 'input' - ) - expect(api.apiURL).toHaveBeenCalledWith('/api/view/models/test.glb') + const file = new File([''], 'model.glb', { + type: 'model/gltf-binary' + }) + await composable.handleModelDrop(file) + + expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d') expect(mockLoad3d.loadModel).toHaveBeenCalledWith( - 'http://localhost/api/view/models/test.glb' + 'http://localhost/api/view/uploaded/model.glb' ) }) - it('should use output type for preview mode', async () => { - mockNode.widgets = [ - { name: 'model_file', value: 'test.glb', type: 'text' } as IWidget - ] // No width/height widgets - vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'test.glb']) + it('should use resource folder for upload subfolder', async () => { + mockNode.properties['Resource Folder'] = 'subfolder' + vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb') + vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([ + 'uploaded', + 'model.glb' + ]) vi.mocked(Load3dUtils.getResourceURL).mockReturnValue( - '/api/view/test.glb' + '/api/view/uploaded/model.glb' ) vi.mocked(api.apiURL).mockReturnValue( - 'http://localhost/api/view/test.glb' + 'http://localhost/api/view/uploaded/model.glb' ) const composable = useLoad3d(mockNode) const containerRef = document.createElement('div') - await composable.initializeLoad3d(containerRef) - expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith( - '', - 'test.glb', - 'output' + const file = new File([''], 'model.glb', { + type: 'model/gltf-binary' + }) + await composable.handleModelDrop(file) + + expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder') + }) + + it('should not load model when load3d is not initialized', async () => { + const composable = useLoad3d(mockNode) + + const file = new File([''], 'model.glb', { + type: 'model/gltf-binary' + }) + await composable.handleModelDrop(file) + + expect(mockLoad3d.loadModel).not.toHaveBeenCalled() + expect(mockToastStore.addAlert).toHaveBeenCalledWith( + 'toastMessages.no3dScene' ) }) }) @@ -1071,4 +1014,241 @@ describe('useLoad3d', () => { expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('existing.jpg') }) }) + + describe('gizmo controls', () => { + it('should include default gizmo config in modelConfig', () => { + const composable = useLoad3d(mockNode) + + expect(composable.modelConfig.value.gizmo).toEqual({ + 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 } + }) + }) + + it('should restore gizmo config from node properties', async () => { + ;(mockNode.properties!['Model Config'] as Record).gizmo = + { + enabled: true, + mode: 'rotate', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0.1, y: 0.2, z: 0.3 }, + scale: { x: 2, y: 2, z: 2 } + } + + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + expect(composable.modelConfig.value.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: 2, y: 2, z: 2 } + }) + }) + + it('should add default gizmo config when missing from saved config', async () => { + mockNode.properties!['Model Config'] = { + upDirection: 'original', + materialMode: 'original', + showSkeleton: false + } + + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + expect(composable.modelConfig.value.gizmo).toBeDefined() + expect(composable.modelConfig.value.gizmo!.enabled).toBe(false) + }) + + it('should add default scale when gizmo config lacks scale', async () => { + ;(mockNode.properties!['Model Config'] as Record).gizmo = + { + enabled: false, + mode: 'translate', + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 } + } + + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + expect(composable.modelConfig.value.gizmo!.scale).toEqual({ + x: 1, + y: 1, + z: 1 + }) + }) + + it('handleToggleGizmo should enable gizmo and update config', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + composable.handleToggleGizmo(true) + + expect(mockLoad3d.setGizmoEnabled).toHaveBeenCalledWith(true) + expect(composable.modelConfig.value.gizmo!.enabled).toBe(true) + }) + + it('handleToggleGizmo should disable gizmo and update config', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + composable.handleToggleGizmo(true) + composable.handleToggleGizmo(false) + + expect(mockLoad3d.setGizmoEnabled).toHaveBeenLastCalledWith(false) + expect(composable.modelConfig.value.gizmo!.enabled).toBe(false) + }) + + it('handleSetGizmoMode should set mode and update config', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + composable.handleSetGizmoMode('rotate') + + expect(mockLoad3d.setGizmoMode).toHaveBeenCalledWith('rotate') + expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate') + }) + + it('handleResetGizmoTransform should call resetGizmoTransform', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + composable.handleResetGizmoTransform() + + expect(mockLoad3d.resetGizmoTransform).toHaveBeenCalled() + }) + + it('should persist gizmo config to node properties via modelConfig watcher', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + composable.handleToggleGizmo(true) + composable.handleSetGizmoMode('rotate') + await nextTick() + + const savedConfig = mockNode.properties['Model Config'] as { + gizmo: { enabled: boolean; mode: string } + } + expect(savedConfig.gizmo.enabled).toBe(true) + expect(savedConfig.gizmo.mode).toBe('rotate') + }) + + it('should register gizmoTransformChange event handler', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls + const gizmoEventCall = addEventCalls.find( + ([event]) => event === 'gizmoTransformChange' + ) + expect(gizmoEventCall).toBeDefined() + }) + + it('gizmoTransformChange event should update modelConfig', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls + const gizmoEventCall = addEventCalls.find( + ([event]) => event === 'gizmoTransformChange' + ) + const handler = gizmoEventCall![1] as (data: unknown) => void + + handler({ + position: { x: 5, y: 6, z: 7 }, + rotation: { x: 0.5, y: 0.6, z: 0.7 }, + scale: { x: 3, y: 3, z: 3 }, + enabled: true, + mode: 'rotate' + }) + + expect(composable.modelConfig.value.gizmo!.position).toEqual({ + x: 5, + y: 6, + z: 7 + }) + expect(composable.modelConfig.value.gizmo!.rotation).toEqual({ + x: 0.5, + y: 0.6, + z: 0.7 + }) + expect(composable.modelConfig.value.gizmo!.scale).toEqual({ + x: 3, + y: 3, + z: 3 + }) + expect(composable.modelConfig.value.gizmo!.enabled).toBe(true) + expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate') + }) + + it('should reset gizmo config on model switch (not first load)', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + composable.handleToggleGizmo(true) + composable.handleSetGizmoMode('rotate') + + const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls + const loadingStartCall = addEventCalls.find( + ([event]) => event === 'modelLoadingStart' + ) + const loadingStartHandler = loadingStartCall![1] as () => void + + const loadingEndCall = addEventCalls.find( + ([event]) => event === 'modelLoadingEnd' + ) + const loadingEndHandler = loadingEndCall![1] as () => void + loadingEndHandler() + + loadingStartHandler() + + expect(composable.modelConfig.value.gizmo).toEqual({ + 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 } + }) + }) + + it('should not call gizmo methods when load3d is not initialized', () => { + const composable = useLoad3d(mockNode) + + // These should not throw + composable.handleToggleGizmo(true) + composable.handleSetGizmoMode('rotate') + composable.handleResetGizmoTransform() + + expect(mockLoad3d.setGizmoEnabled).not.toHaveBeenCalled() + expect(mockLoad3d.setGizmoMode).not.toHaveBeenCalled() + expect(mockLoad3d.resetGizmoTransform).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index 23ffafcdb2..929d2088ec 100644 --- a/src/composables/useLoad3d.ts +++ b/src/composables/useLoad3d.ts @@ -2,7 +2,7 @@ import type { MaybeRef } from 'vue' import { toRef } from '@vueuse/core' import { getActivePinia } from 'pinia' -import { nextTick, ref, toRaw, watch } from 'vue' +import { ref, toRaw, watch } from 'vue' import Load3d from '@/extensions/core/load3d/Load3d' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' @@ -16,6 +16,8 @@ import type { CameraState, CameraType, EventCallback, + GizmoConfig, + GizmoMode, LightConfig, MaterialMode, ModelConfig, @@ -38,6 +40,7 @@ const pendingCallbacks = new Map() export const useLoad3d = (nodeOrRef: MaybeRef) => { const nodeRef = toRef(nodeOrRef) let load3d: Load3d | null = null + let isFirstModelLoad = true const sceneConfig = ref({ showGrid: true, @@ -49,7 +52,14 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { const modelConfig = ref({ 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 } + } }) const hasSkeleton = ref(false) @@ -183,11 +193,24 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { const savedModelConfig = node.properties['Model Config'] as ModelConfig if (savedModelConfig) { - modelConfig.value = savedModelConfig + modelConfig.value = { + ...savedModelConfig, + gizmo: savedModelConfig.gizmo + ? { + ...savedModelConfig.gizmo, + scale: savedModelConfig.gizmo.scale ?? { x: 1, y: 1, z: 1 } + } + : { + 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 } + } + } } const savedCameraConfig = node.properties['Camera Config'] as CameraConfig - const cameraStateToRestore = savedCameraConfig?.state if (savedCameraConfig) { cameraConfig.value = savedCameraConfig @@ -235,31 +258,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { } } - const modelWidget = node.widgets?.find((w) => w.name === 'model_file') - if (modelWidget?.value) { - const modelUrl = getModelUrl(modelWidget.value as string) - if (modelUrl) { - loading.value = true - loadingMessage.value = t('load3d.reloadingModel') - try { - await load3d.loadModel(modelUrl) - - if (cameraStateToRestore) { - await nextTick() - load3d.setCameraState(cameraStateToRestore) - } - } catch (error) { - console.error('Failed to reload model:', error) - useToastStore().addAlert(t('toastMessages.failedToLoadModel')) - } finally { - loading.value = false - loadingMessage.value = '' - } - } - } else if (cameraStateToRestore) { - load3d.setCameraState(cameraStateToRestore) - } - applySceneConfigToLoad3d() applyLightConfigToLoad3d() } @@ -276,6 +274,31 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { } } + const applyGizmoConfigToLoad3d = () => { + if (!load3d) return + const gizmo = modelConfig.value.gizmo + if (!gizmo) return + const hasTransform = + gizmo.position.x !== 0 || + gizmo.position.y !== 0 || + gizmo.position.z !== 0 || + gizmo.rotation.x !== 0 || + gizmo.rotation.y !== 0 || + gizmo.rotation.z !== 0 || + gizmo.scale.x !== 1 || + gizmo.scale.y !== 1 || + gizmo.scale.z !== 1 + if (hasTransform) { + load3d.applyGizmoTransform(gizmo.position, gizmo.rotation, gizmo.scale) + } + if (gizmo.enabled) { + load3d.setGizmoEnabled(true) + } + if (gizmo.mode !== 'translate') { + load3d.setGizmoMode(gizmo.mode) + } + } + const applyLightConfigToLoad3d = () => { if (!load3d) return const cfg = lightConfig.value @@ -294,29 +317,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { } } - const getModelUrl = (modelPath: string): string | null => { - if (!modelPath) return null - - try { - if (modelPath.startsWith('http')) { - return modelPath - } - - const trimmed = modelPath.trim() - const hasOutputSuffix = trimmed.endsWith('[output]') - const cleanPath = hasOutputSuffix - ? trimmed.replace(/\s*\[output\]$/, '') - : trimmed - const type = hasOutputSuffix || isPreview.value ? 'output' : 'input' - - const [subfolder, filename] = Load3dUtils.splitFilePath(cleanPath) - return api.apiURL(Load3dUtils.getResourceURL(subfolder, filename, type)) - } catch (error) { - console.error('Failed to construct model URL:', error) - return null - } - } - const waitForLoad3d = (callback: Load3dReadyCallback) => { const rawNode = toRaw(nodeRef.value) if (!rawNode) return @@ -380,16 +380,34 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { watch( modelConfig, (newValue) => { - if (load3d && nodeRef.value) { + if (nodeRef.value) { nodeRef.value.properties['Model Config'] = newValue - load3d.setUpDirection(newValue.upDirection) - load3d.setMaterialMode(newValue.materialMode) - load3d.setShowSkeleton(newValue.showSkeleton) } }, { deep: true } ) + watch( + () => modelConfig.value.upDirection, + (newValue) => { + if (load3d) load3d.setUpDirection(newValue) + } + ) + + watch( + () => modelConfig.value.materialMode, + (newValue) => { + if (load3d) load3d.setMaterialMode(newValue) + } + ) + + watch( + () => modelConfig.value.showSkeleton, + (newValue) => { + if (load3d) load3d.setShowSkeleton(newValue) + } + ) + watch( cameraConfig, (newValue) => { @@ -741,6 +759,20 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { modelLoadingStart: () => { loadingMessage.value = t('load3d.loadingModel') loading.value = true + if (!isFirstModelLoad) { + modelConfig.value = { + upDirection: 'original', + materialMode: 'original', + 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 } + } + } + } }, modelLoadingEnd: () => { loadingMessage.value = '' @@ -748,8 +780,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { isSplatModel.value = load3d?.isSplatModel() ?? false isPlyModel.value = load3d?.isPlyModel() ?? false hasSkeleton.value = load3d?.hasSkeleton() ?? false - // Reset skeleton visibility when loading new model - modelConfig.value.showSkeleton = false + applyGizmoConfigToLoad3d() + isFirstModelLoad = false if (load3d && isAssetPreviewSupported()) { const node = nodeRef.value @@ -816,9 +848,44 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { } } } + }, + gizmoTransformChange: (data: GizmoConfig) => { + if (modelConfig.value.gizmo && nodeRef.value) { + modelConfig.value.gizmo.position = data.position + modelConfig.value.gizmo.rotation = data.rotation + modelConfig.value.gizmo.scale = data.scale + modelConfig.value.gizmo.enabled = data.enabled + modelConfig.value.gizmo.mode = data.mode + } } } as const + const handleToggleGizmo = (enabled: boolean) => { + if (load3d && modelConfig.value.gizmo) { + modelConfig.value.gizmo.enabled = enabled + load3d.setGizmoEnabled(enabled) + } + } + + const handleSetGizmoMode = (mode: GizmoMode) => { + if (load3d && modelConfig.value.gizmo) { + modelConfig.value.gizmo.mode = mode + load3d.setGizmoMode(mode) + } + } + + const handleFitToViewer = () => { + if (load3d) { + load3d.fitToViewer() + } + } + + const handleResetGizmoTransform = () => { + if (load3d) { + load3d.resetGizmoTransform() + } + } + const handleEvents = (action: 'add' | 'remove') => { Object.entries(eventConfig).forEach(([event, handler]) => { const method = `${action}EventListener` as const @@ -878,6 +945,10 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { handleHDRIFileUpdate, handleExportModel, handleModelDrop, + handleToggleGizmo, + handleSetGizmoMode, + handleResetGizmoTransform, + handleFitToViewer, cleanup } } diff --git a/src/composables/useLoad3dViewer.test.ts b/src/composables/useLoad3dViewer.test.ts index 5b8f424300..1866bdf68f 100644 --- a/src/composables/useLoad3dViewer.test.ts +++ b/src/composables/useLoad3dViewer.test.ts @@ -110,7 +110,15 @@ describe('useLoad3dViewer', () => { addEventListener: vi.fn(), hasAnimations: vi.fn().mockReturnValue(false), isSplatModel: vi.fn().mockReturnValue(false), - isPlyModel: vi.fn().mockReturnValue(false) + isPlyModel: vi.fn().mockReturnValue(false), + setGizmoEnabled: vi.fn(), + setGizmoMode: vi.fn(), + setBackgroundRenderMode: vi.fn(), + getGizmoTransform: vi.fn().mockReturnValue({ + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + }) } mockSourceLoad3d = { @@ -163,20 +171,6 @@ describe('useLoad3dViewer', () => { }) describe('initialization', () => { - it('should initialize with default values', () => { - const viewer = useLoad3dViewer(mockNode) - - expect(viewer.backgroundColor.value).toBe('') - expect(viewer.showGrid.value).toBe(true) - expect(viewer.cameraType.value).toBe('perspective') - expect(viewer.fov.value).toBe(75) - expect(viewer.lightIntensity.value).toBe(1) - expect(viewer.backgroundImage.value).toBe('') - expect(viewer.hasBackgroundImage.value).toBe(false) - expect(viewer.upDirection.value).toBe('original') - expect(viewer.materialMode.value).toBe('original') - }) - it('should initialize viewer with source Load3d state', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') @@ -240,104 +234,7 @@ describe('useLoad3dViewer', () => { }) }) - describe('state watchers', () => { - it('should update background color when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.backgroundColor.value = '#ff0000' - await nextTick() - - expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#ff0000') - }) - - it('should update grid visibility when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.showGrid.value = false - await nextTick() - - expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(false) - }) - - it('should update camera type when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.cameraType.value = 'orthographic' - await nextTick() - - expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic') - }) - - it('should update FOV when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.fov.value = 90 - await nextTick() - - expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90) - }) - - it('should update light intensity when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.lightIntensity.value = 2 - await nextTick() - - expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(2) - }) - - it('should update background image when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.backgroundImage.value = 'new-bg.jpg' - await nextTick() - - expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('new-bg.jpg') - expect(viewer.hasBackgroundImage.value).toBe(true) - }) - - it('should update up direction when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.upDirection.value = '+y' - await nextTick() - - expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y') - }) - - it('should update material mode when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.materialMode.value = 'wireframe' - await nextTick() - - expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe') - }) - + describe('error handling', () => { it('should handle watcher errors gracefully', async () => { vi.mocked(mockLoad3d.setBackgroundColor!).mockImplementationOnce( function () { @@ -749,4 +646,118 @@ describe('useLoad3dViewer', () => { expect(newViewer.backgroundColor.value).toBe('#0000ff') }) }) + + describe('gizmo controls', () => { + it('should initialize gizmo state from node model config', async () => { + ;(mockNode.properties!['Model Config'] as Record).gizmo = + { + enabled: true, + mode: 'rotate' + } + + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) + + expect(viewer.gizmoEnabled.value).toBe(true) + expect(viewer.gizmoMode.value).toBe('rotate') + }) + + it('should default gizmo to disabled translate when no config', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) + + expect(viewer.gizmoEnabled.value).toBe(false) + expect(viewer.gizmoMode.value).toBe('translate') + }) + + it('should persist gizmo state in applyChanges', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) + + viewer.gizmoEnabled.value = true + viewer.gizmoMode.value = 'rotate' + + await viewer.applyChanges() + + const modelConfig = mockNode.properties!['Model Config'] as Record< + string, + unknown + > + const gizmo = modelConfig.gizmo as Record + expect(gizmo.enabled).toBe(true) + expect(gizmo.mode).toBe('rotate') + }) + + it('should save gizmo transform from load3d in applyChanges', async () => { + vi.mocked(mockLoad3d.getGizmoTransform!).mockReturnValue({ + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0.1, y: 0.2, z: 0.3 }, + scale: { x: 2, y: 2, z: 2 } + }) + + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) + + await viewer.applyChanges() + + const modelConfig = mockNode.properties!['Model Config'] as Record< + string, + unknown + > + const gizmo = modelConfig.gizmo as { + position: { x: number; y: number; z: number } + rotation: { x: number; y: number; z: number } + scale: { x: number; y: number; z: number } + } + expect(gizmo.position).toEqual({ x: 1, y: 2, z: 3 }) + expect(gizmo.rotation).toEqual({ x: 0.1, y: 0.2, z: 0.3 }) + expect(gizmo.scale).toEqual({ x: 2, y: 2, z: 2 }) + }) + + it('should restore gizmo state in restoreInitialState', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) + + viewer.gizmoEnabled.value = true + viewer.gizmoMode.value = 'rotate' + + viewer.restoreInitialState() + + const modelConfig = mockNode.properties!['Model Config'] as Record< + string, + unknown + > + const gizmo = modelConfig.gizmo as Record + expect(gizmo.enabled).toBe(false) + expect(gizmo.mode).toBe('translate') + }) + + it('should restore gizmo state from standalone config cache', async () => { + const viewer = useLoad3dViewer() + const containerRef = document.createElement('div') + const model1 = 'gizmo_model1.glb' + + await viewer.initializeStandaloneViewer(containerRef, model1) + viewer.gizmoEnabled.value = true + viewer.gizmoMode.value = 'rotate' + await nextTick() + + viewer.cleanup() + + const restoredViewer = useLoad3dViewer() + await restoredViewer.initializeStandaloneViewer(containerRef, model1) + expect(restoredViewer.gizmoEnabled.value).toBe(true) + expect(restoredViewer.gizmoMode.value).toBe('rotate') + }) + }) }) diff --git a/src/composables/useLoad3dViewer.ts b/src/composables/useLoad3dViewer.ts index 301b0bd2a9..f9126e4625 100644 --- a/src/composables/useLoad3dViewer.ts +++ b/src/composables/useLoad3dViewer.ts @@ -9,6 +9,7 @@ import type { CameraConfig, CameraState, CameraType, + GizmoMode, LightConfig, MaterialMode, ModelConfig, @@ -32,6 +33,8 @@ interface Load3dViewerState { backgroundRenderMode: BackgroundRenderModeType upDirection: UpDirection materialMode: MaterialMode + gizmoEnabled: boolean + gizmoMode: GizmoMode } const DEFAULT_STANDALONE_CONFIG: Load3dViewerState = { @@ -44,7 +47,9 @@ const DEFAULT_STANDALONE_CONFIG: Load3dViewerState = { backgroundImage: '', backgroundRenderMode: 'tiled', upDirection: 'original', - materialMode: 'original' + materialMode: 'original', + gizmoEnabled: false, + gizmoMode: 'translate' } const standaloneConfigCache = new QuickLRU({ @@ -69,6 +74,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => { const backgroundRenderMode = ref('tiled') const upDirection = ref('original') const materialMode = ref('original') + const gizmoEnabled = ref(false) + const gizmoMode = ref('translate') const needApplyChanges = ref(true) const isPreview = ref(false) const isStandaloneMode = ref(false) @@ -98,7 +105,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => { backgroundImage: '', backgroundRenderMode: 'tiled', upDirection: 'original', - materialMode: 'original' + materialMode: 'original', + gizmoEnabled: false, + gizmoMode: 'translate' }) watch(backgroundColor, (newColor) => { @@ -273,6 +282,18 @@ export const useLoad3dViewer = (node?: LGraphNode) => { } } + watch(gizmoEnabled, (newValue) => { + if (load3d) { + load3d.setGizmoEnabled(newValue) + } + }) + + watch(gizmoMode, (newValue) => { + if (load3d) { + load3d.setGizmoMode(newValue) + } + }) + /** * Initializes the viewer in node mode using a source Load3d instance. * @@ -367,6 +388,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => { modelConfig.upDirection || source.modelManager.currentUpDirection materialMode.value = modelConfig.materialMode || source.modelManager.materialMode + if (modelConfig.gizmo) { + gizmoEnabled.value = modelConfig.gizmo.enabled + gizmoMode.value = modelConfig.gizmo.mode + } } isSplatModel.value = source.isSplatModel() @@ -382,7 +407,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => { backgroundImage: backgroundImage.value, backgroundRenderMode: backgroundRenderMode.value, upDirection: upDirection.value, - materialMode: materialMode.value + materialMode: materialMode.value, + gizmoEnabled: gizmoEnabled.value, + gizmoMode: gizmoMode.value } setupAnimationEvents() @@ -475,7 +502,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => { backgroundImage: backgroundImage.value, backgroundRenderMode: backgroundRenderMode.value, upDirection: upDirection.value, - materialMode: materialMode.value + materialMode: materialMode.value, + gizmoEnabled: gizmoEnabled.value, + gizmoMode: gizmoMode.value }) } @@ -497,6 +526,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => { backgroundRenderMode.value = config.backgroundRenderMode upDirection.value = config.upDirection materialMode.value = config.materialMode + gizmoEnabled.value = config.gizmoEnabled + gizmoMode.value = config.gizmoMode if (cached?.cameraState && load3d) { load3d.setCameraState(cached.cameraState) } @@ -572,7 +603,14 @@ export const useLoad3dViewer = (node?: LGraphNode) => { nodeValue.properties['Model Config'] = { upDirection: initialState.value.upDirection, - materialMode: initialState.value.materialMode + materialMode: initialState.value.materialMode, + gizmo: { + enabled: initialState.value.gizmoEnabled, + mode: initialState.value.gizmoMode, + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + } } const currentCameraConfig = nodeValue.properties['Camera Config'] as @@ -614,9 +652,18 @@ export const useLoad3dViewer = (node?: LGraphNode) => { intensity: lightIntensity.value } + const gizmoTransform = load3d.getGizmoTransform() nodeValue.properties['Model Config'] = { upDirection: upDirection.value, - materialMode: materialMode.value + materialMode: materialMode.value, + showSkeleton: false, + gizmo: { + enabled: gizmoEnabled.value, + mode: gizmoMode.value, + position: gizmoTransform.position, + rotation: gizmoTransform.rotation, + scale: gizmoTransform.scale + } } } @@ -757,6 +804,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => { backgroundRenderMode, upDirection, materialMode, + gizmoEnabled, + gizmoMode, needApplyChanges, isPreview, isStandaloneMode, @@ -784,6 +833,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => { handleBackgroundImageUpdate, handleModelDrop, handleSeek, + resetGizmoTransform: () => { + load3d?.resetGizmoTransform() + }, cleanup, hasSkeleton: false, diff --git a/src/extensions/core/load3d/CameraManager.ts b/src/extensions/core/load3d/CameraManager.ts index c92190b809..13f529bf3e 100644 --- a/src/extensions/core/load3d/CameraManager.ts +++ b/src/extensions/core/load3d/CameraManager.ts @@ -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() } diff --git a/src/extensions/core/load3d/GizmoManager.test.ts b/src/extensions/core/load3d/GizmoManager.test.ts new file mode 100644 index 0000000000..10e35b4c5d --- /dev/null +++ b/src/extensions/core/load3d/GizmoManager.test.ts @@ -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 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 + 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) + }) + }) +}) diff --git a/src/extensions/core/load3d/GizmoManager.ts b/src/extensions/core/load3d/GizmoManager.ts new file mode 100644 index 0000000000..4089ab9ea0 --- /dev/null +++ b/src/extensions/core/load3d/GizmoManager.ts @@ -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 + } +} diff --git a/src/extensions/core/load3d/Load3DConfiguration.test.ts b/src/extensions/core/load3d/Load3DConfiguration.test.ts new file mode 100644 index 0000000000..b26881ec3f --- /dev/null +++ b/src/extensions/core/load3d/Load3DConfiguration.test.ts @@ -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) { + 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) + }) +}) diff --git a/src/extensions/core/load3d/Load3DConfiguration.ts b/src/extensions/core/load3d/Load3DConfiguration.ts index 816a94b488..4906213abf 100644 --- a/src/extensions/core/load3d/Load3DConfiguration.ts +++ b/src/extensions/core/load3d/Load3DConfiguration.ts @@ -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 } + } } } diff --git a/src/extensions/core/load3d/Load3d.test.ts b/src/extensions/core/load3d/Load3d.test.ts new file mode 100644 index 0000000000..2c3e677379 --- /dev/null +++ b/src/extensions/core/load3d/Load3d.test.ts @@ -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 + setMode: ReturnType + reset: ReturnType + applyTransform: ReturnType + getTransform: ReturnType + setupForModel: ReturnType + updateCamera: ReturnType + detach: ReturnType + dispose: ReturnType + removeFromScene: ReturnType + ensureHelperInScene: ReturnType + isEnabled: ReturnType + getMode: ReturnType +} + +type ModelManagerStub = { + fitToViewer: ReturnType + clearModel: ReturnType +} + +type CameraManagerStub = { + toggleCamera: ReturnType + setupForModel: ReturnType + reset: ReturnType + activeCamera: THREE.Camera +} + +type SceneManagerStub = { + captureScene: ReturnType + dispose: ReturnType +} + +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 + } +} + +describe('Load3d', () => { + let ctx: ReturnType + + 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() + }) + }) +}) diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 5d629cbe58..7d6ecdb6d7 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -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 { 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 { - return this.sceneManager.captureScene(width, height) + this.gizmoManager.removeFromScene() + + return this.sceneManager.captureScene(width, height).finally(() => { + this.gizmoManager.ensureHelperInScene() + }) } public async startRecording(): Promise { @@ -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() diff --git a/src/extensions/core/load3d/SceneManager.ts b/src/extensions/core/load3d/SceneManager.ts index 00a3383d6e..a9600b1f86 100644 --- a/src/extensions/core/load3d/SceneManager.ts +++ b/src/extensions/core/load3d/SceneManager.ts @@ -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() } diff --git a/src/extensions/core/load3d/SceneModelManager.ts b/src/extensions/core/load3d/SceneModelManager.ts index a480e4554d..a896fa7741 100644 --- a/src/extensions/core/load3d/SceneModelManager.ts +++ b/src/extensions/core/load3d/SceneModelManager.ts @@ -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) + } } } diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts index d78fd239dc..ef0ae30f92 100644 --- a/src/extensions/core/load3d/interfaces.ts +++ b/src/extensions/core/load3d/interfaces.ts @@ -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 { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 6868f54f3c..0f63a681b1 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -129,6 +129,8 @@ "saveAnyway": "Save Anyway", "saving": "Saving", "no": "No", + "on": "On", + "off": "Off", "cancel": "Cancel", "close": "Close", "closeDialog": "Close dialog", @@ -1941,6 +1943,7 @@ "upDirection": "Up Direction", "materialMode": "Material Mode", "showSkeleton": "Show Skeleton", + "fitToViewer": "Fit to Viewer", "scene": "Scene", "model": "Model", "camera": "Camera", @@ -1997,6 +2000,14 @@ "removeFile": "Remove HDRI", "showAsBackground": "Show as Background", "intensity": "Intensity" + }, + "gizmo": { + "label": "Gizmo", + "toggle": "Gizmo", + "translate": "Translate", + "rotate": "Rotate", + "scale": "Scale", + "reset": "Reset Transform" } }, "imageCrop": { @@ -2094,7 +2105,9 @@ "failedToUploadBackgroundImage": "Failed to upload background image", "failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}", "failedToLoadHDRI": "Failed to load HDRI file", - "unsupportedHDRIFormat": "Unsupported file format. Please upload a .hdr or .exr file." + "unsupportedHDRIFormat": "Unsupported file format. Please upload a .hdr or .exr file.", + "failedToToggleGizmo": "Failed to toggle gizmo", + "failedToSetGizmoMode": "Failed to set gizmo mode" }, "nodeErrors": { "render": "Node Render Error", diff --git a/src/services/load3dService.ts b/src/services/load3dService.ts index f851da8331..ef0233a72d 100644 --- a/src/services/load3dService.ts +++ b/src/services/load3dService.ts @@ -216,6 +216,9 @@ class Load3dService { async copyLoad3dState(source: Load3d, target: Load3d) { const sourceModel = source.modelManager.currentModel + const gizmoWasEnabled = target.getGizmoManager().isEnabled() + target.getGizmoManager().detach() + if (sourceModel) { // Remove existing model from target scene before adding new one const existingModel = target.getModelManager().currentModel @@ -256,6 +259,36 @@ class Load3dService { source.getModelManager().appliedTexture } + const sourceInitial = source.getGizmoManager().getInitialTransform() + modelClone.position.set( + sourceInitial.position.x, + sourceInitial.position.y, + sourceInitial.position.z + ) + modelClone.rotation.set( + sourceInitial.rotation.x, + sourceInitial.rotation.y, + sourceInitial.rotation.z + ) + modelClone.scale.set( + sourceInitial.scale.x, + sourceInitial.scale.y, + sourceInitial.scale.z + ) + + target.getGizmoManager().setupForModel(modelClone) + const gizmoTransform = source.getGizmoTransform() + target.applyGizmoTransform( + gizmoTransform.position, + gizmoTransform.rotation, + gizmoTransform.scale + ) + const shouldEnable = + gizmoWasEnabled || source.getGizmoManager().isEnabled() + if (shouldEnable) { + target.setGizmoEnabled(true) + } + // Copy animation state if (source.hasAnimations()) { target.animationManager.setupModelAnimations(