From deba72e7a0e3ea90ff6782171f6e19a33bb985ce Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Sat, 18 Apr 2026 22:45:06 -0400 Subject: [PATCH] gizmo controls (#11274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add Gizmo transform controls to load3d - Remove automatic model normalization (scale + center) on load; models now appear at their original transform. The previous auto-normalization conflicted with gizmo controls — applying scale/position on load made it impossible to track and reset the user's intentional transform edits vs. the system's normalization - Add a manual Fit to Viewer button that performs the same normalization on demand, giving users explicit control - Add Gizmo Controls (translate/rotate) for interactive model manipulation with full state persistence across node properties, viewer dialog, and model reloads - Gizmo transform state is excluded from scene capture and recording to keep outputs clean ## Motivation The gizmo system is a prerequisite for these potential features: - Custom cameras — user-placed cameras in the scene need transform gizmos for precise positioning and orientation - Custom lights — scene lighting setup requires the ability to interactively position and aim light sources - Multi-object scene composition — positioning multiple models relative to each other requires per-object transform controls - Pose editor — skeletal pose editing depends on the same transform infrastructure to manipulate individual bones/joints Auto-normalization was removed because it silently mutated model transforms on load, making it impossible to distinguish between the original model pose and user edits. This broke gizmo reset (which needs to know the "clean" state) and would corrupt round-trip transform persistence. ## Screenshots (if applicable) https://github.com/user-attachments/assets/621ea559-d7c8-4c5a-a727-98e6a4130b66 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11274-gizmo-controls-3436d73d365081c38357c2d58e49c558) by [Unito](https://www.unito.io) --- browser_tests/tests/load3d/Load3DHelper.ts | 25 ++ .../tests/load3d/gizmoControls.spec.ts | 87 ++++ src/components/load3d/Load3D.vue | 32 +- src/components/load3d/Load3DControls.vue | 32 +- src/components/load3d/Load3dViewerContent.vue | 9 + .../load3d/controls/GizmoControls.test.ts | 155 +++++++ .../load3d/controls/GizmoControls.vue | 122 +++++ .../viewer/ViewerGizmoControls.test.ts | 133 ++++++ .../controls/viewer/ViewerGizmoControls.vue | 63 +++ src/composables/useLoad3d.test.ts | 420 +++++++++++++----- src/composables/useLoad3d.ts | 187 +++++--- src/composables/useLoad3dViewer.test.ts | 237 +++++----- src/composables/useLoad3dViewer.ts | 64 ++- src/extensions/core/load3d/CameraManager.ts | 28 +- .../core/load3d/GizmoManager.test.ts | 368 +++++++++++++++ src/extensions/core/load3d/GizmoManager.ts | 229 ++++++++++ .../core/load3d/Load3DConfiguration.test.ts | 164 +++++++ .../core/load3d/Load3DConfiguration.ts | 23 +- src/extensions/core/load3d/Load3d.test.ts | 269 +++++++++++ src/extensions/core/load3d/Load3d.ts | 98 +++- src/extensions/core/load3d/SceneManager.ts | 9 +- .../core/load3d/SceneModelManager.ts | 101 +++-- src/extensions/core/load3d/interfaces.ts | 11 + src/locales/en/main.json | 15 +- src/services/load3dService.ts | 33 ++ 25 files changed, 2554 insertions(+), 360 deletions(-) create mode 100644 browser_tests/tests/load3d/gizmoControls.spec.ts create mode 100644 src/components/load3d/controls/GizmoControls.test.ts create mode 100644 src/components/load3d/controls/GizmoControls.vue create mode 100644 src/components/load3d/controls/viewer/ViewerGizmoControls.test.ts create mode 100644 src/components/load3d/controls/viewer/ViewerGizmoControls.vue create mode 100644 src/extensions/core/load3d/GizmoManager.test.ts create mode 100644 src/extensions/core/load3d/GizmoManager.ts create mode 100644 src/extensions/core/load3d/Load3DConfiguration.test.ts create mode 100644 src/extensions/core/load3d/Load3d.test.ts 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(