mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
gizmo controls
This commit is contained in:
@@ -28,6 +28,9 @@
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
@update-hdri-file="handleHDRIFileUpdate"
|
||||
@toggle-gizmo="handleToggleGizmo"
|
||||
@set-gizmo-mode="handleSetGizmoMode"
|
||||
@reset-gizmo-transform="handleResetGizmoTransform"
|
||||
/>
|
||||
<AnimationControls
|
||||
v-if="animations && animations.length > 0"
|
||||
@@ -40,9 +43,27 @@
|
||||
@seek="handleSeek"
|
||||
/>
|
||||
</div>
|
||||
<div class="pointer-events-auto absolute top-12 right-2 z-20">
|
||||
<div class="flex flex-col rounded-lg bg-backdrop/30">
|
||||
<Button
|
||||
v-tooltip.left="{
|
||||
value: $t('load3d.fitToViewer'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.fitToViewer')"
|
||||
@click="handleFitToViewer"
|
||||
>
|
||||
<i class="pi pi-window-maximize text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="enable3DViewer && node"
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20"
|
||||
class="pointer-events-auto absolute top-24 right-2 z-20"
|
||||
>
|
||||
<ViewerControls :node="node as LGraphNode" />
|
||||
</div>
|
||||
@@ -51,8 +72,8 @@
|
||||
v-if="!isPreview"
|
||||
class="pointer-events-auto absolute right-2 z-20"
|
||||
:class="{
|
||||
'top-12': !enable3DViewer,
|
||||
'top-24': enable3DViewer
|
||||
'top-24': !enable3DViewer,
|
||||
'top-36': enable3DViewer
|
||||
}"
|
||||
>
|
||||
<RecordingControls
|
||||
@@ -77,6 +98,7 @@ import Load3DScene from '@/components/load3d/Load3DScene.vue'
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
|
||||
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useLoad3d } from '@/composables/useLoad3d'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -143,6 +165,10 @@ const {
|
||||
handleHDRIFileUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
handleToggleGizmo,
|
||||
handleSetGizmoMode,
|
||||
handleResetGizmoTransform,
|
||||
handleFitToViewer,
|
||||
cleanup
|
||||
} = useLoad3d(node as Ref<LGraphNode | null>)
|
||||
|
||||
|
||||
@@ -92,6 +92,14 @@
|
||||
v-if="showExportControls"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
|
||||
<GizmoControls
|
||||
v-if="showGizmoControls"
|
||||
v-model:gizmo-config="modelConfig!.gizmo"
|
||||
@toggle-gizmo="handleToggleGizmo"
|
||||
@set-gizmo-mode="handleSetGizmoMode"
|
||||
@reset-gizmo-transform="handleResetGizmoTransform"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -102,6 +110,7 @@ import { computed, ref } from 'vue'
|
||||
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
|
||||
import GizmoControls from '@/components/load3d/controls/GizmoControls.vue'
|
||||
import HDRIControls from '@/components/load3d/controls/HDRIControls.vue'
|
||||
import LightControls from '@/components/load3d/controls/LightControls.vue'
|
||||
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
|
||||
@@ -109,6 +118,7 @@ import SceneControls from '@/components/load3d/controls/SceneControls.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
CameraConfig,
|
||||
GizmoMode,
|
||||
LightConfig,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
@@ -148,6 +158,7 @@ const categoryLabels: Record<string, string> = {
|
||||
model: 'load3d.model',
|
||||
camera: 'load3d.camera',
|
||||
light: 'load3d.light',
|
||||
gizmo: 'load3d.gizmo.label',
|
||||
export: 'load3d.export'
|
||||
}
|
||||
|
||||
@@ -156,7 +167,7 @@ const availableCategories = computed(() => {
|
||||
return ['scene', 'model', 'camera']
|
||||
}
|
||||
|
||||
return ['scene', 'model', 'camera', 'light', 'export']
|
||||
return ['scene', 'model', 'camera', 'light', 'gizmo', 'export']
|
||||
})
|
||||
|
||||
const showSceneControls = computed(
|
||||
@@ -175,6 +186,9 @@ const showLightControls = computed(
|
||||
!!modelConfig.value
|
||||
)
|
||||
const showExportControls = computed(() => activeCategory.value === 'export')
|
||||
const showGizmoControls = computed(
|
||||
() => activeCategory.value === 'gizmo' && !!modelConfig.value
|
||||
)
|
||||
|
||||
const toggleMenu = () => {
|
||||
isMenuOpen.value = !isMenuOpen.value
|
||||
@@ -190,6 +204,7 @@ const categoryIcons = {
|
||||
model: 'icon-[lucide--box]',
|
||||
camera: 'icon-[lucide--camera]',
|
||||
light: 'icon-[lucide--sun]',
|
||||
gizmo: 'icon-[lucide--move-3d]',
|
||||
export: 'icon-[lucide--download]'
|
||||
} as const
|
||||
|
||||
@@ -205,6 +220,9 @@ const emit = defineEmits<{
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
(e: 'exportModel', format: string): void
|
||||
(e: 'updateHdriFile', file: File | null): void
|
||||
(e: 'toggleGizmo', enabled: boolean): void
|
||||
(e: 'setGizmoMode', mode: GizmoMode): void
|
||||
(e: 'resetGizmoTransform'): void
|
||||
}>()
|
||||
|
||||
const handleBackgroundImageUpdate = (file: File | null) => {
|
||||
@@ -218,4 +236,16 @@ const handleExportModel = (format: string) => {
|
||||
const handleHDRIFileUpdate = (file: File | null) => {
|
||||
emit('updateHdriFile', file)
|
||||
}
|
||||
|
||||
const handleToggleGizmo = (enabled: boolean) => {
|
||||
emit('toggleGizmo', enabled)
|
||||
}
|
||||
|
||||
const handleSetGizmoMode = (mode: GizmoMode) => {
|
||||
emit('setGizmoMode', mode)
|
||||
}
|
||||
|
||||
const handleResetGizmoTransform = () => {
|
||||
emit('resetGizmoTransform')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -74,6 +74,14 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<GizmoControls
|
||||
v-model:gizmo-enabled="viewer.gizmoEnabled.value"
|
||||
v-model:gizmo-mode="viewer.gizmoMode.value"
|
||||
@reset-transform="viewer.resetGizmoTransform"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2">
|
||||
<ExportControls @export-model="viewer.exportModel" />
|
||||
</div>
|
||||
@@ -99,6 +107,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
import CameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
|
||||
import ExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
|
||||
import GizmoControls from '@/components/load3d/controls/viewer/ViewerGizmoControls.vue'
|
||||
import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue'
|
||||
import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
|
||||
import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
|
||||
|
||||
143
src/components/load3d/controls/GizmoControls.vue
Normal file
143
src/components/load3d/controls/GizmoControls.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
355
src/extensions/core/load3d/GizmoManager.test.ts
Normal file
355
src/extensions/core/load3d/GizmoManager.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
228
src/extensions/core/load3d/GizmoManager.ts
Normal file
228
src/extensions/core/load3d/GizmoManager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user