gizmo controls (#11274)

## Summary
Add Gizmo transform controls to load3d

- Remove automatic model normalization (scale + center) on load; models
now appear at their original transform. The previous auto-normalization
conflicted with gizmo controls — applying scale/position on load made it
impossible to track and reset the user's intentional transform edits vs.
the system's normalization
- Add a manual Fit to Viewer button that performs the same normalization
on demand, giving users explicit control
- Add Gizmo Controls (translate/rotate) for interactive model
manipulation with full state persistence across node properties, viewer
dialog, and model reloads
- Gizmo transform state is excluded from scene capture and recording to
keep outputs clean

## Motivation
The gizmo system is a prerequisite for these potential features:
- Custom cameras — user-placed cameras in the scene need transform
gizmos for precise positioning and orientation
- Custom lights — scene lighting setup requires the ability to
interactively position and aim light sources
- Multi-object scene composition — positioning multiple models relative
to each other requires per-object transform controls
- Pose editor — skeletal pose editing depends on the same transform
infrastructure to manipulate individual bones/joints

Auto-normalization was removed because it silently mutated model
transforms on load, making it impossible to distinguish between the
original model pose and user edits. This broke gizmo reset (which needs
to know the "clean" state) and would corrupt round-trip transform
persistence.

## Screenshots (if applicable)

https://github.com/user-attachments/assets/621ea559-d7c8-4c5a-a727-98e6a4130b66

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11274-gizmo-controls-3436d73d365081c38357c2d58e49c558)
by [Unito](https://www.unito.io)
This commit is contained in:
Terry Jia
2026-04-18 22:45:06 -04:00
committed by GitHub
parent 3db0eac353
commit deba72e7a0
25 changed files with 2554 additions and 360 deletions

View File

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

View File

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

View File

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

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

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

View File

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

View File

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