gizmo controls

This commit is contained in:
Terry Jia
2026-04-15 09:04:29 -04:00
parent a8e1fa8bef
commit a81113ae09
19 changed files with 1719 additions and 352 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,143 @@
<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 { ref, watch } 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 = ref(false)
const gizmoMode = ref<GizmoMode>('translate')
if (gizmoConfig.value) {
gizmoEnabled.value = gizmoConfig.value.enabled
gizmoMode.value = gizmoConfig.value.mode
}
const emit = defineEmits<{
(e: 'toggleGizmo', enabled: boolean): void
(e: 'setGizmoMode', mode: GizmoMode): void
(e: 'resetGizmoTransform'): void
}>()
const toggleGizmo = () => {
gizmoEnabled.value = !gizmoEnabled.value
emit('toggleGizmo', gizmoEnabled.value)
}
const setMode = (mode: GizmoMode) => {
gizmoMode.value = mode
emit('setGizmoMode', mode)
}
const resetTransform = () => {
emit('resetGizmoTransform')
}
watch(
() => gizmoConfig.value,
(newConfig) => {
if (newConfig) {
gizmoEnabled.value = newConfig.enabled
gizmoMode.value = newConfig.mode
}
},
{ deep: true }
)
watch([gizmoEnabled, gizmoMode], () => {
if (gizmoConfig.value) {
gizmoConfig.value.enabled = gizmoEnabled.value
gizmoConfig.value.mode = gizmoMode.value
}
})
</script>

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>

View File

@@ -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<Load3d['renderer']> 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<string, unknown>
).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<string, unknown>).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<string, unknown>).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()
})
})
})

View File

@@ -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<LGraphNode, Load3dReadyCallback[]>()
export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const nodeRef = toRef(nodeOrRef)
let load3d: Load3d | null = null
let isFirstModelLoad = true
const sceneConfig = ref<SceneConfig>({
showGrid: true,
@@ -49,7 +52,14 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const modelConfig = ref<ModelConfig>({
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<LGraphNode | null>) => {
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<LGraphNode | null>) => {
}
}
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<LGraphNode | null>) => {
}
}
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<LGraphNode | null>) => {
}
}
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<LGraphNode | null>) => {
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<LGraphNode | null>) => {
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<LGraphNode | null>) => {
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<LGraphNode | null>) => {
}
}
}
},
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<LGraphNode | null>) => {
handleHDRIFileUpdate,
handleExportModel,
handleModelDrop,
handleToggleGizmo,
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
cleanup
}
}

View File

@@ -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 () {
@@ -738,4 +635,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<string, unknown>).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<string, unknown>
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<string, unknown>
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')
})
})
})

View File

@@ -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<string, Load3dViewerState>({
@@ -69,6 +74,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const backgroundRenderMode = ref<BackgroundRenderModeType>('tiled')
const upDirection = ref<UpDirection>('original')
const materialMode = ref<MaterialMode>('original')
const gizmoEnabled = ref(false)
const gizmoMode = ref<GizmoMode>('translate')
const needApplyChanges = ref(true)
const isPreview = ref(false)
const isStandaloneMode = ref(false)
@@ -97,7 +104,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
backgroundImage: '',
backgroundRenderMode: 'tiled',
upDirection: 'original',
materialMode: 'original'
materialMode: 'original',
gizmoEnabled: false,
gizmoMode: 'translate'
})
watch(backgroundColor, (newColor) => {
@@ -272,6 +281,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.
*
@@ -362,6 +383,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()
@@ -377,7 +402,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()
@@ -466,7 +493,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
})
}
@@ -488,6 +517,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)
}
@@ -561,7 +592,13 @@ 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 }
}
}
const currentCameraConfig = nodeValue.properties['Camera Config'] as
@@ -603,9 +640,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
}
}
}
@@ -745,6 +791,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
backgroundRenderMode,
upDirection,
materialMode,
gizmoEnabled,
gizmoMode,
needApplyChanges,
isPreview,
isStandaloneMode,
@@ -772,6 +820,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
handleBackgroundImageUpdate,
handleModelDrop,
handleSeek,
resetGizmoTransform: () => {
load3d?.resetGizmoTransform()
},
cleanup,
hasSkeleton: false,

View File

@@ -190,28 +190,40 @@ export class CameraManager implements CameraManagerInterface {
}
}
setupForModel(size: THREE.Vector3): void {
setupForModel(
size: THREE.Vector3,
center: THREE.Vector3 = new THREE.Vector3(0, size.y / 2, 0)
): void {
const maxDim = Math.max(size.x, size.y, size.z)
const distance = Math.max(size.x, size.z) * 2
const height = size.y * 2
const height = center.y + maxDim
this.perspectiveCamera.position.set(distance, height, distance)
this.orthographicCamera.position.set(distance, height, distance)
this.perspectiveCamera.position.set(
center.x + distance,
height,
center.z + distance
)
this.orthographicCamera.position.set(
center.x + distance,
height,
center.z + distance
)
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.lookAt(0, size.y / 2, 0)
this.perspectiveCamera.lookAt(center)
this.perspectiveCamera.updateProjectionMatrix()
} else {
const frustumSize = Math.max(size.x, size.y, size.z) * 2
const frustumSize = maxDim * 2
const aspect = this.perspectiveCamera.aspect
this.orthographicCamera.left = (-frustumSize * aspect) / 2
this.orthographicCamera.right = (frustumSize * aspect) / 2
this.orthographicCamera.top = frustumSize / 2
this.orthographicCamera.bottom = -frustumSize / 2
this.orthographicCamera.lookAt(0, size.y / 2, 0)
this.orthographicCamera.lookAt(center)
this.orthographicCamera.updateProjectionMatrix()
}
this.controls?.target.set(0, size.y / 2, 0)
this.controls?.target.copy(center)
this.controls?.update()
}

View File

@@ -0,0 +1,355 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GizmoManager } from './GizmoManager'
const { mockSetMode, mockAttach, mockDetach, mockGetHelper, mockDispose } =
vi.hoisted(() => ({
mockSetMode: vi.fn(),
mockAttach: vi.fn(),
mockDetach: vi.fn(),
mockGetHelper: vi.fn(),
mockDispose: vi.fn()
}))
vi.mock('three/examples/jsm/controls/TransformControls', () => {
class TransformControls {
enabled = true
camera: THREE.Camera
private listeners = new Map<string, ((e: unknown) => void)[]>()
constructor(camera: THREE.Camera) {
this.camera = camera
}
addEventListener(event: string, cb: (e: unknown) => void) {
if (!this.listeners.has(event)) this.listeners.set(event, [])
this.listeners.get(event)!.push(cb)
}
setMode = mockSetMode
attach = mockAttach
detach = mockDetach
getHelper = mockGetHelper
dispose = mockDispose
emit(event: string, data: unknown) {
for (const cb of this.listeners.get(event) ?? []) cb(data)
}
}
return { TransformControls }
})
vi.mock('three/examples/jsm/controls/OrbitControls', () => {
class OrbitControls {
enabled = true
}
return { OrbitControls }
})
function makeMockOrbitControls() {
return { enabled: true } as unknown as InstanceType<
typeof import('three/examples/jsm/controls/OrbitControls').OrbitControls
>
}
describe('GizmoManager', () => {
let scene: THREE.Scene
let renderer: THREE.WebGLRenderer
let camera: THREE.PerspectiveCamera
let orbitControls: ReturnType<typeof makeMockOrbitControls>
let manager: GizmoManager
let onTransformChange: () => void
let mockHelper: THREE.Object3D
beforeEach(() => {
vi.clearAllMocks()
scene = new THREE.Scene()
renderer = {
domElement: document.createElement('canvas')
} as unknown as THREE.WebGLRenderer
camera = new THREE.PerspectiveCamera()
orbitControls = makeMockOrbitControls()
onTransformChange = vi.fn()
mockHelper = new THREE.Object3D()
mockHelper.name = ''
mockHelper.renderOrder = 0
mockGetHelper.mockReturnValue(mockHelper)
manager = new GizmoManager(
scene,
renderer,
orbitControls,
() => camera,
onTransformChange
)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('init', () => {
it('adds helper to scene with correct name and render order', () => {
manager.init()
expect(mockGetHelper).toHaveBeenCalled()
expect(mockHelper.name).toBe('GizmoTransformControls')
expect(mockHelper.renderOrder).toBe(999)
expect(scene.children).toContain(mockHelper)
})
})
describe('setupForModel', () => {
it('attaches to model and stores initial transform when enabled', () => {
manager.init()
manager.setEnabled(true)
const model = new THREE.Object3D()
model.position.set(1, 2, 3)
model.rotation.set(0.1, 0.2, 0.3)
manager.setupForModel(model)
expect(mockDetach).toHaveBeenCalled()
expect(mockAttach).toHaveBeenCalledWith(model)
expect(mockSetMode).toHaveBeenCalledWith('translate')
})
it('does not attach when disabled', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
expect(mockAttach).not.toHaveBeenCalled()
})
it('does nothing before init', () => {
const model = new THREE.Object3D()
manager.setupForModel(model)
expect(mockDetach).not.toHaveBeenCalled()
})
})
describe('setEnabled', () => {
it('attaches to target when enabled with a target', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
vi.mocked(mockAttach).mockClear()
manager.setEnabled(true)
expect(mockAttach).toHaveBeenCalledWith(model)
expect(manager.isEnabled()).toBe(true)
})
it('detaches when disabled', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.setEnabled(true)
vi.mocked(mockDetach).mockClear()
manager.setEnabled(false)
expect(mockDetach).toHaveBeenCalled()
expect(manager.isEnabled()).toBe(false)
})
it('does nothing before init', () => {
manager.setEnabled(true)
expect(mockAttach).not.toHaveBeenCalled()
})
})
describe('detach', () => {
it('detaches and clears target', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.setEnabled(true)
vi.mocked(mockDetach).mockClear()
manager.detach()
expect(mockDetach).toHaveBeenCalled()
expect(manager.isEnabled()).toBe(false)
})
})
describe('setMode / getMode', () => {
it('defaults to translate', () => {
expect(manager.getMode()).toBe('translate')
})
it('switches to rotate', () => {
manager.init()
manager.setMode('rotate')
expect(manager.getMode()).toBe('rotate')
expect(mockSetMode).toHaveBeenCalledWith('rotate')
})
it('stores mode before init', () => {
manager.setMode('rotate')
expect(manager.getMode()).toBe('rotate')
})
})
describe('reset', () => {
it('restores initial position, rotation, and scale', () => {
manager.init()
const model = new THREE.Object3D()
model.position.set(1, 2, 3)
model.rotation.set(0.1, 0.2, 0.3)
model.scale.set(2, 2, 2)
manager.setupForModel(model)
model.position.set(10, 20, 30)
model.rotation.set(1, 2, 3)
model.scale.set(5, 5, 5)
manager.reset()
expect(model.position.x).toBeCloseTo(1)
expect(model.position.y).toBeCloseTo(2)
expect(model.position.z).toBeCloseTo(3)
expect(model.rotation.x).toBeCloseTo(0.1)
expect(model.rotation.y).toBeCloseTo(0.2)
expect(model.rotation.z).toBeCloseTo(0.3)
expect(model.scale.x).toBeCloseTo(2)
expect(model.scale.y).toBeCloseTo(2)
expect(model.scale.z).toBeCloseTo(2)
})
it('does nothing without a target', () => {
manager.init()
expect(() => manager.reset()).not.toThrow()
})
})
describe('applyTransform', () => {
it('sets position and rotation on target', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.applyTransform({ x: 5, y: 6, z: 7 }, { x: 0.5, y: 0.6, z: 0.7 })
expect(model.position.x).toBeCloseTo(5)
expect(model.position.y).toBeCloseTo(6)
expect(model.position.z).toBeCloseTo(7)
expect(model.rotation.x).toBeCloseTo(0.5)
expect(model.rotation.y).toBeCloseTo(0.6)
expect(model.rotation.z).toBeCloseTo(0.7)
})
it('applies scale when provided', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.applyTransform(
{ x: 0, y: 0, z: 0 },
{ x: 0, y: 0, z: 0 },
{ x: 2, y: 3, z: 4 }
)
expect(model.scale.x).toBeCloseTo(2)
expect(model.scale.y).toBeCloseTo(3)
expect(model.scale.z).toBeCloseTo(4)
})
it('does nothing without a target', () => {
manager.init()
expect(() =>
manager.applyTransform({ x: 1, y: 2, z: 3 }, { x: 0, y: 0, z: 0 })
).not.toThrow()
})
})
describe('getTransform', () => {
it('returns current target transform', () => {
manager.init()
const model = new THREE.Object3D()
model.position.set(1, 2, 3)
model.rotation.set(0.1, 0.2, 0.3)
model.scale.set(4, 5, 6)
manager.setupForModel(model)
const transform = manager.getTransform()
expect(transform.position).toEqual({ x: 1, y: 2, z: 3 })
expect(transform.rotation.x).toBeCloseTo(0.1)
expect(transform.rotation.y).toBeCloseTo(0.2)
expect(transform.rotation.z).toBeCloseTo(0.3)
expect(transform.scale).toEqual({ x: 4, y: 5, z: 6 })
})
it('returns zero/identity when no target', () => {
const transform = manager.getTransform()
expect(transform.position).toEqual({ x: 0, y: 0, z: 0 })
expect(transform.rotation).toEqual({ x: 0, y: 0, z: 0 })
expect(transform.scale).toEqual({ x: 1, y: 1, z: 1 })
})
})
describe('removeFromScene / ensureHelperInScene', () => {
it('removes helper from scene', () => {
manager.init()
expect(scene.children).toContain(mockHelper)
manager.removeFromScene()
expect(scene.children).not.toContain(mockHelper)
})
it('restores helper to scene', () => {
manager.init()
manager.removeFromScene()
manager.ensureHelperInScene()
expect(scene.children).toContain(mockHelper)
})
})
describe('dispose', () => {
it('removes helper, detaches, and disposes controls', () => {
manager.init()
scene.add(mockHelper)
manager.dispose()
expect(mockDetach).toHaveBeenCalled()
expect(mockDispose).toHaveBeenCalled()
})
it('is safe to call before init', () => {
expect(() => manager.dispose()).not.toThrow()
})
})
describe('ensureHelperInScene', () => {
it('re-adds helper if it was removed from its parent', () => {
manager.init()
// Simulate helper being removed from scene
scene.remove(mockHelper)
expect(scene.children).not.toContain(mockHelper)
// setEnabled triggers ensureHelperInScene internally
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.setEnabled(true)
expect(scene.children).toContain(mockHelper)
})
})
})

View File

@@ -0,0 +1,228 @@
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)
}
applyTransform(
position: { x: number; y: number; z: number },
rotation: { x: number; y: number; z: number },
scale?: { x: number; y: number; z: number }
): void {
if (!this.targetObject) return
this.targetObject.position.set(position.x, position.y, position.z)
this.targetObject.rotation.set(rotation.x, rotation.y, rotation.z)
if (scale) {
this.targetObject.scale.set(scale.x, scale.y, scale.z)
}
}
getInitialTransform(): {
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }
scale: { x: number; y: number; z: number }
} {
return {
position: {
x: this.initialPosition.x,
y: this.initialPosition.y,
z: this.initialPosition.z
},
rotation: {
x: this.initialRotation.x,
y: this.initialRotation.y,
z: this.initialRotation.z
},
scale: {
x: this.initialScale.x,
y: this.initialScale.y,
z: this.initialScale.z
}
}
}
getTransform(): {
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }
scale: { x: number; y: number; z: number }
} {
if (!this.targetObject) {
return {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
return {
position: {
x: this.targetObject.position.x,
y: this.targetObject.position.y,
z: this.targetObject.position.z
},
rotation: {
x: this.targetObject.rotation.x,
y: this.targetObject.rotation.y,
z: this.targetObject.rotation.z
},
scale: {
x: this.targetObject.scale.x,
y: this.targetObject.scale.y,
z: this.targetObject.scale.z
}
}
}
dispose(): void {
if (this.transformControls) {
const helper = this.transformControls.getHelper()
this.scene.remove(helper)
this.transformControls.detach()
this.transformControls.dispose()
this.transformControls = null
}
this.targetObject = null
}
}

View File

@@ -167,13 +167,32 @@ class Load3DConfiguration {
private loadModelConfig(): ModelConfig {
if (this.properties && 'Model Config' in this.properties) {
return this.properties['Model Config'] as ModelConfig
const config = this.properties['Model Config'] as ModelConfig
if (!config.gizmo) {
config.gizmo = {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
} else if (!config.gizmo.scale) {
config.gizmo.scale = { x: 1, y: 1, z: 1 }
}
return config
}
return {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false
showSkeleton: false,
gizmo: {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
}

View File

@@ -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'
@@ -18,10 +19,12 @@ import {
type CameraState,
type CaptureResult,
type EventCallback,
type GizmoMode,
type Load3DOptions,
type MaterialMode,
type UpDirection
} from './interfaces'
import { Object3D } from 'three'
function positionThumbnailCamera(
camera: THREE.PerspectiveCamera,
@@ -61,6 +64,7 @@ class Load3d {
modelManager: SceneModelManager
recordingManager: RecordingManager
animationManager: AnimationManager
gizmoManager: GizmoManager
STATUS_MOUSE_ON_NODE: boolean
STATUS_MOUSE_ON_SCENE: boolean
@@ -146,7 +150,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 +163,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 +309,10 @@ class Load3d {
return this.recordingManager
}
getGizmoManager(): GizmoManager {
return this.gizmoManager
}
getTargetSize(): { width: number; height: number } {
return {
width: this.targetWidth,
@@ -388,8 +414,12 @@ class Load3d {
return this.controlsManager.controls
}
private setupCamera(size: THREE.Vector3): void {
this.cameraManager.setupForModel(size)
private setGizmo(model: 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 +581,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 +632,7 @@ class Load3d {
): Promise<void> {
this.cameraManager.reset()
this.controlsManager.reset()
this.gizmoManager.detach()
this.modelManager.clearModel()
this.animationManager.dispose()
@@ -629,6 +661,7 @@ class Load3d {
clearModel(): void {
this.animationManager.dispose()
this.gizmoManager.detach()
this.modelManager.clearModel()
this.forceRender()
}
@@ -736,7 +769,11 @@ class Load3d {
}
captureScene(width: number, height: number): Promise<CaptureResult> {
return this.sceneManager.captureScene(width, height)
this.gizmoManager.removeFromScene()
return this.sceneManager.captureScene(width, height).finally(() => {
this.gizmoManager.ensureHelperInScene()
})
}
public async startRecording(): Promise<void> {
@@ -866,6 +903,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 +973,7 @@ class Load3d {
this.modelManager.dispose()
this.recordingManager.dispose()
this.animationManager.dispose()
this.gizmoManager.dispose()
this.renderer.dispose()
this.renderer.domElement.remove()

View File

@@ -9,10 +9,10 @@ import {
} from './interfaces'
export class SceneManager implements SceneManagerInterface {
scene: THREE.Scene
scene!: THREE.Scene
gridHelper: THREE.GridHelper
backgroundScene: THREE.Scene
backgroundScene!: THREE.Scene
backgroundCamera: THREE.OrthographicCamera
backgroundMesh: THREE.Mesh | null = null
backgroundTexture: THREE.Texture | null = null
@@ -38,6 +38,8 @@ export class SceneManager implements SceneManagerInterface {
this.eventManager = eventManager
this.scene = new THREE.Scene()
this.scene.name = 'MainScene'
this.getActiveCamera = getActiveCamera
this.gridHelper = new THREE.GridHelper(20, 20)
@@ -45,6 +47,7 @@ export class SceneManager implements SceneManagerInterface {
this.scene.add(this.gridHelper)
this.backgroundScene = new THREE.Scene()
this.backgroundScene.name = 'BackgroundScene'
this.backgroundCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1)
this.initBackgroundScene()
@@ -93,6 +96,8 @@ export class SceneManager implements SceneManagerInterface {
this.scene.background = null
}
this.backgroundScene.clear()
this.scene.clear()
}

View File

@@ -37,14 +37,16 @@ export class SceneModelManager implements ModelManagerInterface {
private renderer: THREE.WebGLRenderer
private eventManager: EventManagerInterface
private activeCamera: THREE.Camera
private setupCamera: (size: THREE.Vector3) => void
private setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void
private setupGizmo: (model: THREE.Object3D) => void
constructor(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
eventManager: EventManagerInterface,
getActiveCamera: () => THREE.Camera,
setupCamera: (size: THREE.Vector3) => void
setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void,
setupGizmo: (model: THREE.Object3D) => void
) {
this.scene = scene
this.renderer = renderer
@@ -52,6 +54,7 @@ export class SceneModelManager implements ModelManagerInterface {
this.activeCamera = getActiveCamera()
this.setupCamera = setupCamera
this.textureLoader = new THREE.TextureLoader()
this.setupGizmo = setupGizmo
this.normalMaterial = new THREE.MeshNormalMaterial({
flatShading: false,
@@ -371,32 +374,31 @@ export class SceneModelManager implements ModelManagerInterface {
clearModel(): void {
const objectsToRemove: THREE.Object3D[] = []
this.scene.traverse((object) => {
for (const object of [...this.scene.children]) {
const isEnvironmentObject =
object instanceof THREE.GridHelper ||
object instanceof THREE.Light ||
object instanceof THREE.Camera
object instanceof THREE.Camera ||
object.name === 'GizmoTransformControls'
if (!isEnvironmentObject) {
objectsToRemove.push(object)
}
})
}
objectsToRemove.forEach((obj) => {
if (obj.parent && obj.parent !== this.scene) {
obj.parent.remove(obj)
} else {
this.scene.remove(obj)
}
this.scene.remove(obj)
if (obj instanceof THREE.Mesh) {
obj.geometry?.dispose()
if (Array.isArray(obj.material)) {
obj.material.forEach((material) => material.dispose())
} else {
obj.material?.dispose()
obj.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry?.dispose()
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose())
} else {
child.material?.dispose()
}
}
}
})
})
this.reset()
@@ -497,25 +499,10 @@ export class SceneModelManager implements ModelManagerInterface {
// SplatMesh handles its own rendering, just add to scene
this.scene.add(model)
// Set a default camera distance for splat models
this.setupCamera(new THREE.Vector3(5, 5, 5))
this.setupCamera(new THREE.Vector3(5, 5, 5), new THREE.Vector3(0, 2.5, 0))
return
}
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const targetSize = 5
const scale = targetSize / maxDim
model.scale.multiplyScalar(scale)
box.setFromObject(model)
box.getCenter(center)
box.getSize(size)
model.position.set(-center.x, -box.min.y, -center.z)
this.scene.add(model)
if (this.materialMode !== 'original') {
@@ -527,7 +514,46 @@ 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)
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 +574,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 +609,9 @@ export class SceneModelManager implements ModelManagerInterface {
}
this.eventManager.emitEvent('upDirectionChange', direction)
if (directionChanged) {
this.setupGizmo(this.currentModel)
}
}
}

View File

@@ -33,10 +33,21 @@ export interface SceneConfig {
backgroundRenderMode?: BackgroundRenderModeType
}
export type GizmoMode = 'translate' | 'rotate' | 'scale'
export interface GizmoConfig {
enabled: boolean
mode: GizmoMode
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }
scale: { x: number; y: number; z: number }
}
export interface ModelConfig {
upDirection: UpDirection
materialMode: MaterialMode
showSkeleton: boolean
gizmo?: GizmoConfig
}
export interface CameraConfig {

View File

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

View File

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