mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
gizmo controls (#11274)
## Summary Add Gizmo transform controls to load3d - Remove automatic model normalization (scale + center) on load; models now appear at their original transform. The previous auto-normalization conflicted with gizmo controls — applying scale/position on load made it impossible to track and reset the user's intentional transform edits vs. the system's normalization - Add a manual Fit to Viewer button that performs the same normalization on demand, giving users explicit control - Add Gizmo Controls (translate/rotate) for interactive model manipulation with full state persistence across node properties, viewer dialog, and model reloads - Gizmo transform state is excluded from scene capture and recording to keep outputs clean ## Motivation The gizmo system is a prerequisite for these potential features: - Custom cameras — user-placed cameras in the scene need transform gizmos for precise positioning and orientation - Custom lights — scene lighting setup requires the ability to interactively position and aim light sources - Multi-object scene composition — positioning multiple models relative to each other requires per-object transform controls - Pose editor — skeletal pose editing depends on the same transform infrastructure to manipulate individual bones/joints Auto-normalization was removed because it silently mutated model transforms on load, making it impossible to distinguish between the original model pose and user edits. This broke gizmo reset (which needs to know the "clean" state) and would corrupt round-trip transform persistence. ## Screenshots (if applicable) https://github.com/user-attachments/assets/621ea559-d7c8-4c5a-a727-98e6a4130b66 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11274-gizmo-controls-3436d73d365081c38357c2d58e49c558) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
<AnimationControls
|
||||
v-if="animations && animations.length > 0"
|
||||
@@ -40,9 +43,27 @@
|
||||
@seek="handleSeek"
|
||||
/>
|
||||
</div>
|
||||
<div class="pointer-events-auto absolute top-12 right-2 z-20">
|
||||
<div class="flex flex-col rounded-lg bg-backdrop/30">
|
||||
<Button
|
||||
v-tooltip.left="{
|
||||
value: $t('load3d.fitToViewer'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.fitToViewer')"
|
||||
@click="handleFitToViewer"
|
||||
>
|
||||
<i class="pi pi-window-maximize text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="enable3DViewer && node"
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20"
|
||||
class="pointer-events-auto absolute top-24 right-2 z-20"
|
||||
>
|
||||
<ViewerControls :node="node as LGraphNode" />
|
||||
</div>
|
||||
@@ -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
|
||||
}"
|
||||
>
|
||||
<RecordingControls
|
||||
@@ -77,6 +98,7 @@ import Load3DScene from '@/components/load3d/Load3DScene.vue'
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
|
||||
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useLoad3d } from '@/composables/useLoad3d'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -143,6 +165,10 @@ const {
|
||||
handleHDRIFileUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
handleToggleGizmo,
|
||||
handleSetGizmoMode,
|
||||
handleResetGizmoTransform,
|
||||
handleFitToViewer,
|
||||
cleanup
|
||||
} = useLoad3d(node as Ref<LGraphNode | null>)
|
||||
|
||||
|
||||
@@ -92,6 +92,14 @@
|
||||
v-if="showExportControls"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
|
||||
<GizmoControls
|
||||
v-if="showGizmoControls"
|
||||
v-model:gizmo-config="modelConfig!.gizmo"
|
||||
@toggle-gizmo="handleToggleGizmo"
|
||||
@set-gizmo-mode="handleSetGizmoMode"
|
||||
@reset-gizmo-transform="handleResetGizmoTransform"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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<string, string> = {
|
||||
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')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -74,6 +74,14 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<GizmoControls
|
||||
v-model:gizmo-enabled="viewer.gizmoEnabled.value"
|
||||
v-model:gizmo-mode="viewer.gizmoMode.value"
|
||||
@reset-transform="viewer.resetGizmoTransform"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2">
|
||||
<ExportControls @export-model="viewer.exportModel" />
|
||||
</div>
|
||||
@@ -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'
|
||||
|
||||
155
src/components/load3d/controls/GizmoControls.test.ts
Normal file
155
src/components/load3d/controls/GizmoControls.test.ts
Normal file
@@ -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> = {}): 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<GizmoConfig> = {}) {
|
||||
const gizmoConfig = ref<GizmoConfig>(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()
|
||||
})
|
||||
})
|
||||
122
src/components/load3d/controls/GizmoControls.vue
Normal file
122
src/components/load3d/controls/GizmoControls.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<Button
|
||||
v-tooltip.right="{ value: t('load3d.gizmo.toggle'), showDelay: 300 }"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:class="cn('rounded-full', gizmoEnabled && 'ring-2 ring-white/50')"
|
||||
:aria-label="t('load3d.gizmo.toggle')"
|
||||
@click="toggleGizmo"
|
||||
>
|
||||
<i class="pi pi-compass text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<template v-if="gizmoEnabled">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.gizmo.translate'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-full',
|
||||
gizmoMode === 'translate' && 'ring-2 ring-white/50'
|
||||
)
|
||||
"
|
||||
:aria-label="t('load3d.gizmo.translate')"
|
||||
@click="setMode('translate')"
|
||||
>
|
||||
<i class="pi pi-arrows-alt text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.gizmo.rotate'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:class="
|
||||
cn('rounded-full', gizmoMode === 'rotate' && 'ring-2 ring-white/50')
|
||||
"
|
||||
:aria-label="t('load3d.gizmo.rotate')"
|
||||
@click="setMode('rotate')"
|
||||
>
|
||||
<i class="pi pi-sync text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.gizmo.scale'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:class="
|
||||
cn('rounded-full', gizmoMode === 'scale' && 'ring-2 ring-white/50')
|
||||
"
|
||||
:aria-label="t('load3d.gizmo.scale')"
|
||||
@click="setMode('scale')"
|
||||
>
|
||||
<i class="pi pi-expand text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.gizmo.reset'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
:aria-label="t('load3d.gizmo.reset')"
|
||||
@click="resetTransform"
|
||||
>
|
||||
<i class="pi pi-refresh text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
GizmoConfig,
|
||||
GizmoMode
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const gizmoConfig = defineModel<GizmoConfig>('gizmoConfig')
|
||||
|
||||
const gizmoEnabled = computed(() => gizmoConfig.value?.enabled ?? false)
|
||||
const gizmoMode = computed(() => gizmoConfig.value?.mode ?? 'translate')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggleGizmo', enabled: boolean): void
|
||||
(e: 'setGizmoMode', mode: GizmoMode): void
|
||||
(e: 'resetGizmoTransform'): void
|
||||
}>()
|
||||
|
||||
const toggleGizmo = () => {
|
||||
if (!gizmoConfig.value) return
|
||||
gizmoConfig.value.enabled = !gizmoConfig.value.enabled
|
||||
emit('toggleGizmo', gizmoConfig.value.enabled)
|
||||
}
|
||||
|
||||
const setMode = (mode: GizmoMode) => {
|
||||
if (!gizmoConfig.value) return
|
||||
gizmoConfig.value.mode = mode
|
||||
emit('setGizmoMode', mode)
|
||||
}
|
||||
|
||||
const resetTransform = () => {
|
||||
emit('resetGizmoTransform')
|
||||
}
|
||||
</script>
|
||||
@@ -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<boolean>(initial.enabled ?? false)
|
||||
const mode = ref<GizmoMode>(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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<label>{{ $t('load3d.gizmo.toggle') }}</label>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
:model-value="gizmoEnabled ? 'on' : 'off'"
|
||||
@update:model-value="(v) => (gizmoEnabled = v === 'on')"
|
||||
>
|
||||
<ToggleGroupItem value="off" size="sm">
|
||||
{{ $t('g.off') }}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="on" size="sm">
|
||||
{{ $t('g.on') }}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<template v-if="gizmoEnabled">
|
||||
<div>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
:model-value="gizmoMode"
|
||||
@update:model-value="
|
||||
(v) => {
|
||||
if (v) gizmoMode = v as GizmoMode
|
||||
}
|
||||
"
|
||||
>
|
||||
<ToggleGroupItem value="translate">
|
||||
{{ $t('load3d.gizmo.translate') }}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="rotate">
|
||||
{{ $t('load3d.gizmo.rotate') }}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="scale">
|
||||
{{ $t('load3d.gizmo.scale') }}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant="secondary" @click="$emit('reset-transform')">
|
||||
<i class="pi pi-refresh" />
|
||||
{{ $t('load3d.gizmo.reset') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const gizmoEnabled = defineModel<boolean>('gizmoEnabled')
|
||||
const gizmoMode = defineModel<GizmoMode>('gizmoMode')
|
||||
|
||||
defineEmits<{
|
||||
(e: 'reset-transform'): void
|
||||
}>()
|
||||
</script>
|
||||
Reference in New Issue
Block a user