mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
Compare commits
2 Commits
ext-api/i-
...
feat/load3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3c67e826a | ||
|
|
c0feaad4f6 |
254
src/components/load3d/Load3D.test.ts
Normal file
254
src/components/load3d/Load3D.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import Load3D from '@/components/load3d/Load3D.vue'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
const { load3dState, resolveNodeMock, settingGetMock } = vi.hoisted(() => ({
|
||||
load3dState: {
|
||||
current: null as ReturnType<typeof buildLoad3dStub> | null
|
||||
},
|
||||
resolveNodeMock: vi.fn(),
|
||||
settingGetMock: vi.fn()
|
||||
}))
|
||||
|
||||
function buildLoad3dStub() {
|
||||
return {
|
||||
sceneConfig: ref({}),
|
||||
modelConfig: ref({}),
|
||||
cameraConfig: ref({}),
|
||||
lightConfig: ref({}),
|
||||
isRecording: ref(false),
|
||||
isPreview: ref(false),
|
||||
canFitToViewer: ref(true),
|
||||
canUseGizmo: ref(true),
|
||||
canUseLighting: ref(true),
|
||||
canExport: ref(true),
|
||||
materialModes: ref(['original', 'normal', 'wireframe']),
|
||||
hasSkeleton: ref(false),
|
||||
hasRecording: ref(false),
|
||||
recordingDuration: ref(0),
|
||||
animations: ref<Array<{ name: string; index: number }>>([]),
|
||||
playing: ref(false),
|
||||
selectedSpeed: ref(1),
|
||||
selectedAnimation: ref(0),
|
||||
animationProgress: ref(0),
|
||||
animationDuration: ref(0),
|
||||
loading: ref(false),
|
||||
loadingMessage: ref(''),
|
||||
initializeLoad3d: vi.fn(),
|
||||
handleMouseEnter: vi.fn(),
|
||||
handleMouseLeave: vi.fn(),
|
||||
handleStartRecording: vi.fn(),
|
||||
handleStopRecording: vi.fn(),
|
||||
handleExportRecording: vi.fn(),
|
||||
handleClearRecording: vi.fn(),
|
||||
handleSeek: vi.fn(),
|
||||
handleBackgroundImageUpdate: vi.fn(),
|
||||
handleHDRIFileUpdate: vi.fn(),
|
||||
handleExportModel: vi.fn(),
|
||||
handleModelDrop: vi.fn(),
|
||||
handleToggleGizmo: vi.fn(),
|
||||
handleSetGizmoMode: vi.fn(),
|
||||
handleResetGizmoTransform: vi.fn(),
|
||||
handleFitToViewer: vi.fn(),
|
||||
cleanup: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/composables/useLoad3d', () => ({
|
||||
useLoad3d: () => load3dState.current
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: settingGetMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
resolveNode: resolveNodeMock
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: { fitToViewer: 'Fit to viewer' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type RenderOptions = {
|
||||
widget?: unknown
|
||||
nodeId?: number | string
|
||||
stateOverrides?: Partial<ReturnType<typeof buildLoad3dStub>>
|
||||
enable3DViewer?: boolean
|
||||
}
|
||||
|
||||
const MOCK_NODE = { id: 'node', type: 'Load3D' }
|
||||
|
||||
function renderLoad3D(options: RenderOptions = {}) {
|
||||
const stub = buildLoad3dStub()
|
||||
if (options.stateOverrides) {
|
||||
Object.assign(stub, options.stateOverrides)
|
||||
}
|
||||
load3dState.current = stub
|
||||
|
||||
settingGetMock.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Load3D.3DViewerEnable'
|
||||
? (options.enable3DViewer ?? false)
|
||||
: undefined
|
||||
)
|
||||
|
||||
return {
|
||||
...render(Load3D, {
|
||||
props: {
|
||||
widget: (options.widget ?? {
|
||||
node: MOCK_NODE
|
||||
}) as unknown as ComponentWidget<string[]>,
|
||||
nodeId: options.nodeId
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Load3DControls: {
|
||||
name: 'Load3DControls',
|
||||
template: '<div data-testid="load3d-controls" />'
|
||||
},
|
||||
Load3DScene: {
|
||||
name: 'Load3DScene',
|
||||
template: '<div data-testid="load3d-scene" />'
|
||||
},
|
||||
AnimationControls: {
|
||||
name: 'AnimationControls',
|
||||
template: '<div data-testid="animation-controls" />'
|
||||
},
|
||||
RecordingControls: {
|
||||
name: 'RecordingControls',
|
||||
template: '<div data-testid="recording-controls" />'
|
||||
},
|
||||
ViewerControls: {
|
||||
name: 'ViewerControls',
|
||||
template: '<div data-testid="viewer-controls" />'
|
||||
},
|
||||
Button: {
|
||||
name: 'Button',
|
||||
props: ['ariaLabel'],
|
||||
template:
|
||||
'<button type="button" :aria-label="ariaLabel"><slot /></button>'
|
||||
}
|
||||
},
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
}
|
||||
}
|
||||
}),
|
||||
stub
|
||||
}
|
||||
}
|
||||
|
||||
describe('Load3D', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
load3dState.current = null
|
||||
})
|
||||
|
||||
describe('node resolution', () => {
|
||||
it('uses widget.node when the widget is a ComponentWidget', () => {
|
||||
renderLoad3D({ widget: { node: MOCK_NODE } })
|
||||
|
||||
expect(screen.getByTestId('load3d-scene')).toBeInTheDocument()
|
||||
expect(resolveNodeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to resolveNode(nodeId) when the widget lacks a node', async () => {
|
||||
resolveNodeMock.mockReturnValue(MOCK_NODE)
|
||||
renderLoad3D({ widget: {}, nodeId: 42 })
|
||||
|
||||
expect(resolveNodeMock).toHaveBeenCalledWith(42)
|
||||
expect(await screen.findByTestId('load3d-scene')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render Load3DScene when no node can be resolved', async () => {
|
||||
resolveNodeMock.mockReturnValue(null)
|
||||
renderLoad3D({ widget: {}, nodeId: 99 })
|
||||
|
||||
await Promise.resolve()
|
||||
expect(screen.queryByTestId('load3d-scene')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('capability-driven chrome', () => {
|
||||
it('shows the fit-to-viewer button when canFitToViewer is true', () => {
|
||||
renderLoad3D({ stateOverrides: { canFitToViewer: ref(true) } })
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Fit to viewer' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the fit-to-viewer button when canFitToViewer is false', () => {
|
||||
renderLoad3D({ stateOverrides: { canFitToViewer: ref(false) } })
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Fit to viewer' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('invokes handleFitToViewer when the fit button is clicked', async () => {
|
||||
const { stub } = renderLoad3D()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Fit to viewer' }))
|
||||
|
||||
expect(stub.handleFitToViewer).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewer controls', () => {
|
||||
it('renders ViewerControls when the 3D viewer setting is enabled', () => {
|
||||
renderLoad3D({ enable3DViewer: true })
|
||||
expect(screen.getByTestId('viewer-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides ViewerControls when the 3D viewer setting is disabled', () => {
|
||||
renderLoad3D({ enable3DViewer: false })
|
||||
expect(screen.queryByTestId('viewer-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides ViewerControls when there is no node even if the setting is on', () => {
|
||||
resolveNodeMock.mockReturnValue(null)
|
||||
renderLoad3D({ widget: {}, nodeId: 1, enable3DViewer: true })
|
||||
expect(screen.queryByTestId('viewer-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('recording controls', () => {
|
||||
it('renders RecordingControls in regular (non-preview) mode', () => {
|
||||
renderLoad3D({ stateOverrides: { isPreview: ref(false) } })
|
||||
expect(screen.getByTestId('recording-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides RecordingControls in preview mode', () => {
|
||||
renderLoad3D({ stateOverrides: { isPreview: ref(true) } })
|
||||
expect(screen.queryByTestId('recording-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('animation controls', () => {
|
||||
it('renders AnimationControls when animations are present', () => {
|
||||
renderLoad3D({
|
||||
stateOverrides: {
|
||||
animations: ref([{ name: 'idle', index: 0 }])
|
||||
}
|
||||
})
|
||||
expect(screen.getByTestId('animation-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides AnimationControls when the animation list is empty', () => {
|
||||
renderLoad3D()
|
||||
expect(screen.queryByTestId('animation-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -22,8 +22,10 @@
|
||||
v-model:model-config="modelConfig"
|
||||
v-model:camera-config="cameraConfig"
|
||||
v-model:light-config="lightConfig"
|
||||
:is-splat-model="isSplatModel"
|
||||
:is-ply-model="isPlyModel"
|
||||
:can-use-gizmo="canUseGizmo"
|
||||
:can-use-lighting="canUseLighting"
|
||||
:can-export="canExport"
|
||||
:material-modes="materialModes"
|
||||
:has-skeleton="hasSkeleton"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
@@ -43,7 +45,10 @@
|
||||
@seek="handleSeek"
|
||||
/>
|
||||
</div>
|
||||
<div class="pointer-events-auto absolute top-12 right-2 z-20">
|
||||
<div
|
||||
v-if="canFitToViewer"
|
||||
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="{
|
||||
@@ -138,8 +143,11 @@ const {
|
||||
// other state
|
||||
isRecording,
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
canFitToViewer,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
canExport,
|
||||
materialModes,
|
||||
hasSkeleton,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
|
||||
352
src/components/load3d/Load3DControls.test.ts
Normal file
352
src/components/load3d/Load3DControls.test.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import Load3DControls from '@/components/load3d/Load3DControls.vue'
|
||||
import type {
|
||||
CameraConfig,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('@/composables/useDismissableOverlay', () => ({
|
||||
useDismissableOverlay: vi.fn()
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
menu: { showMenu: 'Show menu' },
|
||||
load3d: {
|
||||
scene: 'Scene',
|
||||
model: 'Model',
|
||||
camera: 'Camera',
|
||||
light: 'Light',
|
||||
gizmo: { label: 'Gizmo' },
|
||||
export: 'Export'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const childStubs = {
|
||||
SceneControls: {
|
||||
name: 'SceneControls',
|
||||
emits: ['update-background-image'],
|
||||
template: `<div data-testid="scene-controls">
|
||||
<button data-testid="scene-emit-bg" @click="$emit('update-background-image', null)" />
|
||||
</div>`
|
||||
},
|
||||
ModelControls: {
|
||||
name: 'ModelControls',
|
||||
template: '<div data-testid="model-controls" />'
|
||||
},
|
||||
CameraControls: {
|
||||
name: 'CameraControls',
|
||||
template: '<div data-testid="camera-controls" />'
|
||||
},
|
||||
LightControls: {
|
||||
name: 'LightControls',
|
||||
template: '<div data-testid="light-controls" />'
|
||||
},
|
||||
HDRIControls: {
|
||||
name: 'HDRIControls',
|
||||
emits: ['update-hdri-file'],
|
||||
template: `<div data-testid="hdri-controls">
|
||||
<button data-testid="hdri-emit-file" @click="$emit('update-hdri-file', null)" />
|
||||
</div>`
|
||||
},
|
||||
ExportControls: {
|
||||
name: 'ExportControls',
|
||||
emits: ['export-model'],
|
||||
template: `<div data-testid="export-controls">
|
||||
<button data-testid="export-emit-glb" @click="$emit('export-model', 'glb')" />
|
||||
</div>`
|
||||
},
|
||||
GizmoControls: {
|
||||
name: 'GizmoControls',
|
||||
emits: ['toggle-gizmo', 'set-gizmo-mode', 'reset-gizmo-transform'],
|
||||
template: `<div data-testid="gizmo-controls">
|
||||
<button data-testid="gizmo-emit-toggle" @click="$emit('toggle-gizmo', true)" />
|
||||
<button data-testid="gizmo-emit-mode" @click="$emit('set-gizmo-mode', 'rotate')" />
|
||||
<button data-testid="gizmo-emit-reset" @click="$emit('reset-gizmo-transform')" />
|
||||
</div>`
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSceneConfig: SceneConfig = {
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled'
|
||||
}
|
||||
|
||||
const defaultModelConfig: ModelConfig = {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
const defaultCameraConfig: CameraConfig = {
|
||||
cameraType: 'perspective',
|
||||
fov: 75
|
||||
}
|
||||
|
||||
const defaultLightConfig: LightConfig = {
|
||||
intensity: 5,
|
||||
hdri: {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
}
|
||||
|
||||
type RenderProps = {
|
||||
sceneConfig?: SceneConfig
|
||||
modelConfig?: ModelConfig
|
||||
cameraConfig?: CameraConfig
|
||||
lightConfig?: LightConfig
|
||||
canUseGizmo?: boolean
|
||||
canUseLighting?: boolean
|
||||
canExport?: boolean
|
||||
materialModes?: readonly MaterialMode[]
|
||||
hasSkeleton?: boolean
|
||||
onUpdateBackgroundImage?: (file: File | null) => void
|
||||
onExportModel?: (format: string) => void
|
||||
onUpdateHdriFile?: (file: File | null) => void
|
||||
onToggleGizmo?: (enabled: boolean) => void
|
||||
onSetGizmoMode?: (mode: string) => void
|
||||
onResetGizmoTransform?: () => void
|
||||
}
|
||||
|
||||
function renderControls(overrides: RenderProps = {}) {
|
||||
const result = render(Load3DControls, {
|
||||
props: {
|
||||
sceneConfig: defaultSceneConfig,
|
||||
modelConfig: defaultModelConfig,
|
||||
cameraConfig: defaultCameraConfig,
|
||||
lightConfig: defaultLightConfig,
|
||||
canUseGizmo: true,
|
||||
canUseLighting: true,
|
||||
canExport: true,
|
||||
materialModes: ['original', 'normal', 'wireframe'],
|
||||
hasSkeleton: false,
|
||||
...overrides
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: childStubs,
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
async function openMenu(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: 'Show menu' }))
|
||||
}
|
||||
|
||||
describe('Load3DControls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('category menu', () => {
|
||||
it('renders SceneControls by default', () => {
|
||||
renderControls()
|
||||
expect(screen.getByTestId('scene-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the category menu closed until the trigger is clicked', async () => {
|
||||
const { user } = renderControls()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Scene' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await openMenu(user)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Scene' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows every category when all capabilities are enabled', async () => {
|
||||
const { user } = renderControls()
|
||||
await openMenu(user)
|
||||
|
||||
for (const label of [
|
||||
'Scene',
|
||||
'Model',
|
||||
'Camera',
|
||||
'Light',
|
||||
'Gizmo',
|
||||
'Export'
|
||||
]) {
|
||||
expect(screen.getByRole('button', { name: label })).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('omits the light category when canUseLighting is false', async () => {
|
||||
const { user } = renderControls({ canUseLighting: false })
|
||||
await openMenu(user)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Light' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Scene' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the gizmo category when canUseGizmo is false', async () => {
|
||||
const { user } = renderControls({ canUseGizmo: false })
|
||||
await openMenu(user)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Gizmo' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the export category when canExport is false', async () => {
|
||||
const { user } = renderControls({ canExport: false })
|
||||
await openMenu(user)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Export' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selecting a category closes the menu and swaps the visible control', async () => {
|
||||
const { user } = renderControls()
|
||||
await openMenu(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Model' }))
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Scene' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-controls')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('control visibility', () => {
|
||||
async function selectCategory(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
label: string
|
||||
) {
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: label }))
|
||||
}
|
||||
|
||||
it.each([
|
||||
['Model', 'model-controls'],
|
||||
['Camera', 'camera-controls']
|
||||
])('%s category renders only %s', async (label, testId) => {
|
||||
const { user } = renderControls()
|
||||
await selectCategory(user, label)
|
||||
|
||||
expect(screen.getByTestId(testId)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Light category renders both LightControls and HDRIControls', async () => {
|
||||
const { user } = renderControls()
|
||||
await selectCategory(user, 'Light')
|
||||
|
||||
expect(screen.getByTestId('light-controls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('hdri-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Gizmo category renders GizmoControls', async () => {
|
||||
const { user } = renderControls()
|
||||
await selectCategory(user, 'Gizmo')
|
||||
|
||||
expect(screen.getByTestId('gizmo-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Export category renders ExportControls', async () => {
|
||||
const { user } = renderControls()
|
||||
await selectCategory(user, 'Export')
|
||||
|
||||
expect(screen.getByTestId('export-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides all controls when the corresponding v-model is undefined', () => {
|
||||
renderControls({
|
||||
sceneConfig: undefined,
|
||||
modelConfig: undefined,
|
||||
cameraConfig: undefined,
|
||||
lightConfig: undefined
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('event forwarding', () => {
|
||||
it('forwards updateBackgroundImage from SceneControls', async () => {
|
||||
const onUpdateBackgroundImage = vi.fn()
|
||||
const { user } = renderControls({ onUpdateBackgroundImage })
|
||||
|
||||
await user.click(screen.getByTestId('scene-emit-bg'))
|
||||
|
||||
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('forwards exportModel from ExportControls', async () => {
|
||||
const onExportModel = vi.fn()
|
||||
const { user } = renderControls({ onExportModel })
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
|
||||
await user.click(screen.getByTestId('export-emit-glb'))
|
||||
|
||||
expect(onExportModel).toHaveBeenCalledWith('glb')
|
||||
})
|
||||
|
||||
it('forwards updateHdriFile from HDRIControls', async () => {
|
||||
const onUpdateHdriFile = vi.fn()
|
||||
const { user } = renderControls({ onUpdateHdriFile })
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: 'Light' }))
|
||||
|
||||
await user.click(screen.getByTestId('hdri-emit-file'))
|
||||
|
||||
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('forwards gizmo events from GizmoControls', async () => {
|
||||
const onToggleGizmo = vi.fn()
|
||||
const onSetGizmoMode = vi.fn()
|
||||
const onResetGizmoTransform = vi.fn()
|
||||
const { user } = renderControls({
|
||||
onToggleGizmo,
|
||||
onSetGizmoMode,
|
||||
onResetGizmoTransform
|
||||
})
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
|
||||
|
||||
await user.click(screen.getByTestId('gizmo-emit-toggle'))
|
||||
await user.click(screen.getByTestId('gizmo-emit-mode'))
|
||||
await user.click(screen.getByTestId('gizmo-emit-reset'))
|
||||
|
||||
expect(onToggleGizmo).toHaveBeenCalledWith(true)
|
||||
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
|
||||
expect(onResetGizmoTransform).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -63,8 +63,7 @@
|
||||
v-model:material-mode="modelConfig!.materialMode"
|
||||
v-model:up-direction="modelConfig!.upDirection"
|
||||
v-model:show-skeleton="modelConfig!.showSkeleton"
|
||||
:hide-material-mode="isSplatModel"
|
||||
:is-ply-model="isPlyModel"
|
||||
:material-modes="materialModes"
|
||||
:has-skeleton="hasSkeleton"
|
||||
/>
|
||||
|
||||
@@ -120,18 +119,23 @@ import type {
|
||||
CameraConfig,
|
||||
GizmoMode,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
isSplatModel = false,
|
||||
isPlyModel = false,
|
||||
canUseGizmo = true,
|
||||
canUseLighting = true,
|
||||
canExport = true,
|
||||
materialModes = ['original', 'normal', 'wireframe'],
|
||||
hasSkeleton = false
|
||||
} = defineProps<{
|
||||
isSplatModel?: boolean
|
||||
isPlyModel?: boolean
|
||||
canUseGizmo?: boolean
|
||||
canUseLighting?: boolean
|
||||
canExport?: boolean
|
||||
materialModes?: readonly MaterialMode[]
|
||||
hasSkeleton?: boolean
|
||||
}>()
|
||||
|
||||
@@ -163,11 +167,11 @@ const categoryLabels: Record<string, string> = {
|
||||
}
|
||||
|
||||
const availableCategories = computed(() => {
|
||||
if (isSplatModel) {
|
||||
return ['scene', 'model', 'camera']
|
||||
}
|
||||
|
||||
return ['scene', 'model', 'camera', 'light', 'gizmo', 'export']
|
||||
const categories = ['scene', 'model', 'camera']
|
||||
if (canUseLighting) categories.push('light')
|
||||
if (canUseGizmo) categories.push('gizmo')
|
||||
if (canExport) categories.push('export')
|
||||
return categories
|
||||
})
|
||||
|
||||
const showSceneControls = computed(
|
||||
|
||||
360
src/components/load3d/Load3dViewerContent.test.ts
Normal file
360
src/components/load3d/Load3dViewerContent.test.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
class NoopMutationObserver {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
takeRecords(): MutationRecord[] {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
viewerState,
|
||||
dragState,
|
||||
capturedDragOptions,
|
||||
dialogCloseMock,
|
||||
serviceSourceLoad3d,
|
||||
getLoad3dAsyncMock
|
||||
} = vi.hoisted(() => ({
|
||||
viewerState: {
|
||||
current: null as ReturnType<typeof buildViewerStub> | null
|
||||
},
|
||||
dragState: {
|
||||
current: null as ReturnType<typeof buildDragStub> | null
|
||||
},
|
||||
capturedDragOptions: {
|
||||
current: null as { onModelDrop?: (file: File) => Promise<void> } | null
|
||||
},
|
||||
dialogCloseMock: vi.fn(),
|
||||
serviceSourceLoad3d: {
|
||||
current: null as unknown
|
||||
},
|
||||
getLoad3dAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
function buildViewerStub() {
|
||||
return {
|
||||
backgroundColor: ref('#282828'),
|
||||
showGrid: ref(true),
|
||||
cameraType: ref('perspective'),
|
||||
fov: ref(75),
|
||||
lightIntensity: ref(1),
|
||||
backgroundImage: ref(''),
|
||||
hasBackgroundImage: ref(false),
|
||||
backgroundRenderMode: ref('tiled'),
|
||||
upDirection: ref('original'),
|
||||
materialMode: ref('original'),
|
||||
gizmoEnabled: ref(false),
|
||||
gizmoMode: ref('translate'),
|
||||
isPreview: ref(false),
|
||||
isStandaloneMode: ref(false),
|
||||
canUseGizmo: ref(true),
|
||||
canUseLighting: ref(true),
|
||||
canExport: ref(true),
|
||||
materialModes: ref(['original', 'normal', 'wireframe']),
|
||||
animations: ref<Array<{ name: string; index: number }>>([]),
|
||||
playing: ref(false),
|
||||
selectedSpeed: ref(1),
|
||||
selectedAnimation: ref(0),
|
||||
animationProgress: ref(0),
|
||||
animationDuration: ref(0),
|
||||
initializeViewer: vi.fn().mockResolvedValue(undefined),
|
||||
initializeStandaloneViewer: vi.fn().mockResolvedValue(undefined),
|
||||
exportModel: vi.fn(),
|
||||
handleResize: vi.fn(),
|
||||
handleMouseEnter: vi.fn(),
|
||||
handleMouseLeave: vi.fn(),
|
||||
restoreInitialState: vi.fn(),
|
||||
refreshViewport: vi.fn(),
|
||||
handleBackgroundImageUpdate: vi.fn(),
|
||||
handleModelDrop: vi.fn().mockResolvedValue(undefined),
|
||||
handleSeek: vi.fn(),
|
||||
resetGizmoTransform: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function buildDragStub() {
|
||||
return {
|
||||
isDragging: ref(false),
|
||||
dragMessage: ref(''),
|
||||
handleDragOver: vi.fn(),
|
||||
handleDragLeave: vi.fn(),
|
||||
handleDrop: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/composables/useLoad3dViewer', () => ({
|
||||
useLoad3dViewer: () => viewerState.current
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3dDrag', () => ({
|
||||
useLoad3dDrag: (opts: { onModelDrop?: (file: File) => Promise<void> }) => {
|
||||
capturedDragOptions.current = opts
|
||||
return dragState.current
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/services/load3dService', () => ({
|
||||
useLoad3dService: () => ({
|
||||
getOrCreateViewerSync: () => viewerState.current,
|
||||
getLoad3dAsync: getLoad3dAsyncMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ closeDialog: dialogCloseMock })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { cancel: 'Cancel' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type RenderOptions = {
|
||||
node?: LGraphNode
|
||||
modelUrl?: string
|
||||
viewerOverrides?: Partial<ReturnType<typeof buildViewerStub>>
|
||||
dragOverrides?: Partial<ReturnType<typeof buildDragStub>>
|
||||
}
|
||||
|
||||
const MOCK_NODE = { id: 'node-1', type: 'Load3D' } as unknown as LGraphNode
|
||||
|
||||
async function renderViewerContent(options: RenderOptions = {}) {
|
||||
const viewerStub = buildViewerStub()
|
||||
if (options.viewerOverrides) {
|
||||
Object.assign(viewerStub, options.viewerOverrides)
|
||||
}
|
||||
viewerState.current = viewerStub
|
||||
|
||||
const dragStub = buildDragStub()
|
||||
if (options.dragOverrides) {
|
||||
Object.assign(dragStub, options.dragOverrides)
|
||||
}
|
||||
dragState.current = dragStub
|
||||
|
||||
getLoad3dAsyncMock.mockResolvedValue(serviceSourceLoad3d.current)
|
||||
|
||||
const result = render(Load3dViewerContent, {
|
||||
props: {
|
||||
node: options.node,
|
||||
modelUrl: options.modelUrl
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
AnimationControls: {
|
||||
name: 'AnimationControls',
|
||||
template: '<div data-testid="animation-controls" />'
|
||||
},
|
||||
CameraControls: {
|
||||
name: 'CameraControls',
|
||||
template: '<div data-testid="camera-controls" />'
|
||||
},
|
||||
ExportControls: {
|
||||
name: 'ExportControls',
|
||||
template: '<div data-testid="export-controls" />'
|
||||
},
|
||||
GizmoControls: {
|
||||
name: 'GizmoControls',
|
||||
template: '<div data-testid="gizmo-controls" />'
|
||||
},
|
||||
LightControls: {
|
||||
name: 'LightControls',
|
||||
template: '<div data-testid="light-controls" />'
|
||||
},
|
||||
ModelControls: {
|
||||
name: 'ModelControls',
|
||||
template: '<div data-testid="model-controls" />'
|
||||
},
|
||||
SceneControls: {
|
||||
name: 'SceneControls',
|
||||
template: '<div data-testid="scene-controls" />'
|
||||
},
|
||||
Button: {
|
||||
name: 'Button',
|
||||
template: '<button type="button"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
...result,
|
||||
viewer: viewerStub,
|
||||
drag: dragStub,
|
||||
user: userEvent.setup()
|
||||
}
|
||||
}
|
||||
|
||||
describe('Load3dViewerContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('MutationObserver', NoopMutationObserver)
|
||||
viewerState.current = null
|
||||
dragState.current = null
|
||||
capturedDragOptions.current = null
|
||||
serviceSourceLoad3d.current = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('invokes initializeStandaloneViewer when a modelUrl is provided without a node', async () => {
|
||||
const { viewer } = await renderViewerContent({
|
||||
modelUrl: 'api/view?filename=cube.glb'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(viewer.initializeStandaloneViewer).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
'api/view?filename=cube.glb'
|
||||
)
|
||||
)
|
||||
expect(viewer.initializeViewer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invokes initializeViewer with the source load3d when a node is provided', async () => {
|
||||
const source = { id: 'source-load3d' }
|
||||
serviceSourceLoad3d.current = source
|
||||
const { viewer } = await renderViewerContent({ node: MOCK_NODE })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(viewer.initializeViewer).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
source
|
||||
)
|
||||
)
|
||||
expect(getLoad3dAsyncMock).toHaveBeenCalledWith(MOCK_NODE)
|
||||
expect(viewer.initializeStandaloneViewer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips initializeViewer if the source load3d cannot be resolved', async () => {
|
||||
serviceSourceLoad3d.current = null
|
||||
const { viewer } = await renderViewerContent({ node: MOCK_NODE })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(getLoad3dAsyncMock).toHaveBeenCalledWith(MOCK_NODE)
|
||||
)
|
||||
expect(viewer.initializeViewer).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('capability gating', () => {
|
||||
it('hides LightControls when canUseLighting is false', async () => {
|
||||
await renderViewerContent({
|
||||
node: MOCK_NODE,
|
||||
viewerOverrides: { canUseLighting: ref(false) }
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('light-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides GizmoControls when canUseGizmo is false', async () => {
|
||||
await renderViewerContent({
|
||||
node: MOCK_NODE,
|
||||
viewerOverrides: { canUseGizmo: ref(false) }
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('gizmo-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides ExportControls when canExport is false', async () => {
|
||||
await renderViewerContent({
|
||||
node: MOCK_NODE,
|
||||
viewerOverrides: { canExport: ref(false) }
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('export-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all capability-gated controls when all flags are true', async () => {
|
||||
await renderViewerContent({ node: MOCK_NODE })
|
||||
|
||||
expect(screen.getByTestId('light-controls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('gizmo-controls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('export-controls')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('animation controls', () => {
|
||||
it('hides AnimationControls when the animation list is empty', async () => {
|
||||
await renderViewerContent({ node: MOCK_NODE })
|
||||
expect(screen.queryByTestId('animation-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows AnimationControls when animations are present', async () => {
|
||||
await renderViewerContent({
|
||||
node: MOCK_NODE,
|
||||
viewerOverrides: {
|
||||
animations: ref([{ name: 'idle', index: 0 }])
|
||||
}
|
||||
})
|
||||
expect(screen.getByTestId('animation-controls')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag overlay', () => {
|
||||
it('is hidden by default', async () => {
|
||||
await renderViewerContent({ node: MOCK_NODE })
|
||||
expect(screen.queryByText(/drag/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the drag message when useLoad3dDrag reports dragging', async () => {
|
||||
await renderViewerContent({
|
||||
node: MOCK_NODE,
|
||||
dragOverrides: {
|
||||
isDragging: ref(true),
|
||||
dragMessage: ref('Drop to load')
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Drop to load')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag integration', () => {
|
||||
it('routes a dropped file through useLoad3dDrag back to viewer.handleModelDrop', async () => {
|
||||
const { viewer } = await renderViewerContent({ node: MOCK_NODE })
|
||||
const file = new File(['cube'], 'cube.glb')
|
||||
|
||||
await capturedDragOptions.current!.onModelDrop!(file)
|
||||
|
||||
expect(viewer.handleModelDrop).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel button', () => {
|
||||
it('closes the dialog in node mode and restores initial viewer state', async () => {
|
||||
const { user, viewer } = await renderViewerContent({ node: MOCK_NODE })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/ }))
|
||||
|
||||
expect(viewer.restoreInitialState).toHaveBeenCalledOnce()
|
||||
expect(dialogCloseMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes the dialog in standalone mode without touching initial state', async () => {
|
||||
const { user, viewer } = await renderViewerContent({
|
||||
modelUrl: 'api/view?filename=cube.glb'
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/ }))
|
||||
|
||||
expect(viewer.restoreInitialState).not.toHaveBeenCalled()
|
||||
expect(dialogCloseMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -56,8 +56,7 @@
|
||||
<ModelControls
|
||||
v-model:up-direction="viewer.upDirection.value"
|
||||
v-model:material-mode="viewer.materialMode.value"
|
||||
:hide-material-mode="viewer.isSplatModel.value"
|
||||
:is-ply-model="viewer.isPlyModel.value"
|
||||
:material-modes="viewer.materialModes.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -68,13 +67,13 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2">
|
||||
<div v-if="viewer.canUseLighting.value" class="space-y-4 p-2">
|
||||
<LightControls
|
||||
v-model:light-intensity="viewer.lightIntensity.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div v-if="viewer.canUseGizmo.value" class="space-y-4 p-2">
|
||||
<GizmoControls
|
||||
v-model:gizmo-enabled="viewer.gizmoEnabled.value"
|
||||
v-model:gizmo-mode="viewer.gizmoMode.value"
|
||||
@@ -82,7 +81,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2">
|
||||
<div v-if="viewer.canExport.value" class="space-y-4 p-2">
|
||||
<ExportControls @export-model="viewer.exportModel" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideMaterialMode" class="show-material-mode relative">
|
||||
<div v-if="materialModes.length > 0" class="show-material-mode relative">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.materialMode'),
|
||||
@@ -93,7 +93,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -105,12 +105,10 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
hideMaterialMode = false,
|
||||
isPlyModel = false,
|
||||
materialModes = ['original', 'normal', 'wireframe'],
|
||||
hasSkeleton = false
|
||||
} = defineProps<{
|
||||
hideMaterialMode?: boolean
|
||||
isPlyModel?: boolean
|
||||
materialModes?: readonly MaterialMode[]
|
||||
hasSkeleton?: boolean
|
||||
}>()
|
||||
|
||||
@@ -131,22 +129,6 @@ const upDirections: UpDirection[] = [
|
||||
'+z'
|
||||
]
|
||||
|
||||
const materialModes = computed(() => {
|
||||
const modes: MaterialMode[] = [
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
//'depth' disable for now
|
||||
]
|
||||
|
||||
// Only show pointCloud mode for PLY files (point cloud rendering)
|
||||
if (isPlyModel) {
|
||||
modes.splice(1, 0, 'pointCloud')
|
||||
}
|
||||
|
||||
return modes
|
||||
})
|
||||
|
||||
function toggleUpDirection() {
|
||||
showUpDirection.value = !showUpDirection.value
|
||||
showMaterialMode.value = false
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ViewerModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
|
||||
import type {
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: {
|
||||
upDirection: 'Up direction',
|
||||
materialMode: 'Material mode',
|
||||
upDirections: { original: 'Original' },
|
||||
materialModes: {
|
||||
original: 'Original',
|
||||
normal: 'Normal',
|
||||
wireframe: 'Wireframe',
|
||||
pointCloud: 'Point Cloud',
|
||||
depth: 'Depth'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type RenderProps = {
|
||||
upDirection?: UpDirection
|
||||
materialMode?: MaterialMode
|
||||
materialModes?: readonly MaterialMode[]
|
||||
'onUpdate:upDirection'?: (value: UpDirection | undefined) => void
|
||||
'onUpdate:materialMode'?: (value: MaterialMode | undefined) => void
|
||||
}
|
||||
|
||||
function renderControls(overrides: RenderProps = {}) {
|
||||
const result = render(ViewerModelControls, {
|
||||
props: {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
materialModes: ['original', 'normal', 'wireframe'],
|
||||
...overrides
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
function getOptions(select: HTMLElement) {
|
||||
return Array.from(select.querySelectorAll('option'))
|
||||
}
|
||||
|
||||
describe('ViewerModelControls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders both up direction and material mode selects by default', () => {
|
||||
renderControls()
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(2)
|
||||
expect(screen.getByText('Up direction')).toBeInTheDocument()
|
||||
expect(screen.getByText('Material mode')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the material mode select when materialModes is empty', () => {
|
||||
renderControls({ materialModes: [] })
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(1)
|
||||
expect(screen.queryByText('Material mode')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('up direction options', () => {
|
||||
it('exposes the seven supported directions', () => {
|
||||
renderControls()
|
||||
const [upDirectionSelect] = screen.getAllByRole('combobox')
|
||||
const options = getOptions(upDirectionSelect)
|
||||
|
||||
expect(options.map((o) => o.getAttribute('value'))).toEqual([
|
||||
'original',
|
||||
'-x',
|
||||
'+x',
|
||||
'-y',
|
||||
'+y',
|
||||
'-z',
|
||||
'+z'
|
||||
])
|
||||
})
|
||||
|
||||
it('localizes the "original" option label and uses raw axis labels for the rest', () => {
|
||||
renderControls()
|
||||
const [upDirectionSelect] = screen.getAllByRole('combobox')
|
||||
const options = getOptions(upDirectionSelect)
|
||||
|
||||
expect(options.map((o) => o.textContent?.trim())).toEqual([
|
||||
'Original',
|
||||
'-X',
|
||||
'+X',
|
||||
'-Y',
|
||||
'+Y',
|
||||
'-Z',
|
||||
'+Z'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('material mode options', () => {
|
||||
it('emits one option per materialModes entry with localized labels', () => {
|
||||
renderControls({ materialModes: ['original', 'normal', 'wireframe'] })
|
||||
const [, materialModeSelect] = screen.getAllByRole('combobox')
|
||||
const options = getOptions(materialModeSelect)
|
||||
|
||||
expect(options.map((o) => o.getAttribute('value'))).toEqual([
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
expect(options.map((o) => o.textContent?.trim())).toEqual([
|
||||
'Original',
|
||||
'Normal',
|
||||
'Wireframe'
|
||||
])
|
||||
})
|
||||
|
||||
it('includes pointCloud when the adapter exposes it (PLY)', () => {
|
||||
renderControls({
|
||||
materialModes: ['original', 'pointCloud', 'normal', 'wireframe']
|
||||
})
|
||||
const [, materialModeSelect] = screen.getAllByRole('combobox')
|
||||
const options = getOptions(materialModeSelect)
|
||||
|
||||
expect(options).toHaveLength(4)
|
||||
expect(options[1].textContent?.trim()).toBe('Point Cloud')
|
||||
expect(options[1].getAttribute('value')).toBe('pointCloud')
|
||||
})
|
||||
})
|
||||
|
||||
describe('v-model binding', () => {
|
||||
it('renders the initial upDirection as the selected option', () => {
|
||||
renderControls({ upDirection: '-z' })
|
||||
const [upDirectionSelect] = screen.getAllByRole('combobox')
|
||||
expect((upDirectionSelect as HTMLSelectElement).value).toBe('-z')
|
||||
})
|
||||
|
||||
it('renders the initial materialMode as the selected option', () => {
|
||||
renderControls({ materialMode: 'normal' })
|
||||
const [, materialModeSelect] = screen.getAllByRole('combobox')
|
||||
expect((materialModeSelect as HTMLSelectElement).value).toBe('normal')
|
||||
})
|
||||
|
||||
it('emits update:upDirection when a new direction is chosen', async () => {
|
||||
const listener = vi.fn()
|
||||
const { user } = renderControls({ 'onUpdate:upDirection': listener })
|
||||
const [upDirectionSelect] = screen.getAllByRole('combobox')
|
||||
|
||||
await user.selectOptions(upDirectionSelect, '+x')
|
||||
|
||||
expect(listener).toHaveBeenCalledWith('+x')
|
||||
})
|
||||
|
||||
it('emits update:materialMode when a new mode is chosen', async () => {
|
||||
const listener = vi.fn()
|
||||
const { user } = renderControls({ 'onUpdate:materialMode': listener })
|
||||
const [, materialModeSelect] = screen.getAllByRole('combobox')
|
||||
|
||||
await user.selectOptions(materialModeSelect, 'wireframe')
|
||||
|
||||
expect(listener).toHaveBeenCalledWith('wireframe')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideMaterialMode" class="flex flex-col gap-2">
|
||||
<div v-if="materialModes.length > 0" class="flex flex-col gap-2">
|
||||
<label>{{ $t('load3d.materialMode') }}</label>
|
||||
<Select
|
||||
v-model="materialMode"
|
||||
@@ -33,9 +33,8 @@ import type {
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
|
||||
hideMaterialMode?: boolean
|
||||
isPlyModel?: boolean
|
||||
const { materialModes = ['original', 'normal', 'wireframe'] } = defineProps<{
|
||||
materialModes?: readonly MaterialMode[]
|
||||
}>()
|
||||
|
||||
const upDirection = defineModel<UpDirection>('upDirection')
|
||||
@@ -51,23 +50,10 @@ const upDirectionOptions = [
|
||||
{ label: '+Z', value: '+z' }
|
||||
]
|
||||
|
||||
const materialModeOptions = computed(() => {
|
||||
const options = [
|
||||
{ label: t('load3d.materialModes.original'), value: 'original' }
|
||||
]
|
||||
|
||||
if (isPlyModel) {
|
||||
options.push({
|
||||
label: t('load3d.materialModes.pointCloud'),
|
||||
value: 'pointCloud'
|
||||
})
|
||||
}
|
||||
|
||||
options.push(
|
||||
{ label: t('load3d.materialModes.normal'), value: 'normal' },
|
||||
{ label: t('load3d.materialModes.wireframe'), value: 'wireframe' }
|
||||
)
|
||||
|
||||
return options
|
||||
})
|
||||
const materialModeOptions = computed(() =>
|
||||
materialModes.map((mode) => ({
|
||||
label: t(`load3d.materialModes.${mode}`),
|
||||
value: mode
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref, shallowRef } from 'vue'
|
||||
|
||||
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import type { Size } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -15,8 +16,8 @@ import {
|
||||
createMockLGraphNode
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3d', () => ({
|
||||
default: vi.fn()
|
||||
vi.mock('@/extensions/core/load3d/createLoad3d', () => ({
|
||||
createLoad3d: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
@@ -136,6 +137,14 @@ describe('useLoad3d', () => {
|
||||
exportModel: vi.fn().mockResolvedValue(undefined),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
getCurrentModelCapabilities: vi.fn().mockReturnValue({
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe']
|
||||
}),
|
||||
hasSkeleton: vi.fn().mockReturnValue(false),
|
||||
setShowSkeleton: vi.fn(),
|
||||
loadHDRI: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -157,10 +166,7 @@ describe('useLoad3d', () => {
|
||||
} as Partial<Load3d['renderer']> as Load3d['renderer']
|
||||
}
|
||||
|
||||
vi.mocked(Load3d).mockImplementation(function (this: Load3d) {
|
||||
Object.assign(this, mockLoad3d)
|
||||
return this
|
||||
})
|
||||
vi.mocked(createLoad3d).mockImplementation(() => mockLoad3d as Load3d)
|
||||
|
||||
mockToastStore = {
|
||||
addAlert: vi.fn()
|
||||
@@ -181,7 +187,7 @@ describe('useLoad3d', () => {
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(Load3d).toHaveBeenCalledWith(
|
||||
expect(createLoad3d).toHaveBeenCalledWith(
|
||||
containerRef,
|
||||
expect.objectContaining({
|
||||
width: 512,
|
||||
@@ -291,7 +297,7 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
vi.mocked(Load3d).mockImplementationOnce(function () {
|
||||
vi.mocked(createLoad3d).mockImplementationOnce(() => {
|
||||
throw new Error('Load3d creation failed')
|
||||
})
|
||||
|
||||
@@ -310,7 +316,7 @@ describe('useLoad3d', () => {
|
||||
|
||||
await composable.initializeLoad3d(null!)
|
||||
|
||||
expect(Load3d).not.toHaveBeenCalled()
|
||||
expect(createLoad3d).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should accept ref as parameter', () => {
|
||||
@@ -1029,7 +1035,7 @@ describe('useLoad3d', () => {
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
// Should not throw and should use defaults
|
||||
expect(Load3d).toHaveBeenCalled()
|
||||
expect(createLoad3d).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle background image with existing config', async () => {
|
||||
|
||||
@@ -5,8 +5,9 @@ import { getActivePinia } from 'pinia'
|
||||
import { ref, toRaw, watch } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import {
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
@@ -96,6 +97,15 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const isPreview = ref(false)
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
const canFitToViewer = ref(true)
|
||||
const canUseGizmo = ref(true)
|
||||
const canUseLighting = ref(true)
|
||||
const canExport = ref(true)
|
||||
const materialModes = ref<readonly MaterialMode[]>([
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
|
||||
const initializeLoad3d = async (containerRef: HTMLElement) => {
|
||||
const rawNode = toRaw(nodeRef.value)
|
||||
@@ -111,7 +121,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isPreview.value = true
|
||||
}
|
||||
|
||||
load3d = new Load3d(containerRef, {
|
||||
load3d = createLoad3d(containerRef, {
|
||||
width: widthWidget?.value as number | undefined,
|
||||
height: heightWidget?.value as number | undefined,
|
||||
// Provide dynamic dimension getter for reactive updates
|
||||
@@ -782,6 +792,16 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
loading.value = false
|
||||
isSplatModel.value = load3d?.isSplatModel() ?? false
|
||||
isPlyModel.value = load3d?.isPlyModel() ?? false
|
||||
const caps = load3d?.getCurrentModelCapabilities()
|
||||
canFitToViewer.value = caps?.fitToViewer ?? true
|
||||
canUseGizmo.value = caps?.gizmoTransform ?? true
|
||||
canUseLighting.value = caps?.lighting ?? true
|
||||
canExport.value = caps?.exportable ?? true
|
||||
materialModes.value = caps?.materialModes ?? [
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
]
|
||||
hasSkeleton.value = load3d?.hasSkeleton() ?? false
|
||||
applyGizmoConfigToLoad3d()
|
||||
isFirstModelLoad = false
|
||||
@@ -922,6 +942,11 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
canFitToViewer,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
canExport,
|
||||
materialModes,
|
||||
hasSkeleton,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
|
||||
@@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -28,8 +29,8 @@ vi.mock('@/i18n', () => ({
|
||||
t: vi.fn((key) => key)
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3d', () => ({
|
||||
default: vi.fn()
|
||||
vi.mock('@/extensions/core/load3d/createLoad3d', () => ({
|
||||
createLoad3d: vi.fn()
|
||||
}))
|
||||
|
||||
function createMockSceneManager(): Load3d['sceneManager'] {
|
||||
@@ -111,6 +112,14 @@ describe('useLoad3dViewer', () => {
|
||||
hasAnimations: vi.fn().mockReturnValue(false),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
getCurrentModelCapabilities: vi.fn().mockReturnValue({
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe']
|
||||
}),
|
||||
setGizmoEnabled: vi.fn(),
|
||||
setGizmoMode: vi.fn(),
|
||||
setBackgroundRenderMode: vi.fn(),
|
||||
@@ -142,12 +151,17 @@ describe('useLoad3dViewer', () => {
|
||||
} as Load3d['modelManager'],
|
||||
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
|
||||
setBackgroundRenderMode: vi.fn(),
|
||||
forceRender: vi.fn()
|
||||
forceRender: vi.fn(),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
getCurrentModelCapabilities: vi.fn().mockReturnValue({
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true
|
||||
})
|
||||
}
|
||||
|
||||
vi.mocked(Load3d).mockImplementation(function () {
|
||||
Object.assign(this, mockLoad3d)
|
||||
})
|
||||
vi.mocked(createLoad3d).mockImplementation(() => mockLoad3d as Load3d)
|
||||
|
||||
mockLoad3dService = {
|
||||
copyLoad3dState: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -177,7 +191,7 @@ describe('useLoad3dViewer', () => {
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
expect(Load3d).toHaveBeenCalledWith(containerRef, {
|
||||
expect(createLoad3d).toHaveBeenCalledWith(containerRef, {
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
getDimensions: undefined,
|
||||
@@ -219,7 +233,7 @@ describe('useLoad3dViewer', () => {
|
||||
})
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
vi.mocked(Load3d).mockImplementationOnce(function () {
|
||||
vi.mocked(createLoad3d).mockImplementationOnce(() => {
|
||||
throw new Error('Load3d creation failed')
|
||||
})
|
||||
|
||||
@@ -555,7 +569,7 @@ describe('useLoad3dViewer', () => {
|
||||
|
||||
await viewer.initializeViewer(null!, mockSourceLoad3d as Load3d)
|
||||
|
||||
expect(Load3d).not.toHaveBeenCalled()
|
||||
expect(createLoad3d).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle orthographic camera', async () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ref, toRaw, watch } from 'vue'
|
||||
import QuickLRU from '@alloc/quick-lru'
|
||||
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import type {
|
||||
AnimationItem,
|
||||
BackgroundRenderModeType,
|
||||
@@ -81,6 +82,26 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
const isStandaloneMode = ref(false)
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
const canFitToViewer = ref(true)
|
||||
const canUseGizmo = ref(true)
|
||||
const canUseLighting = ref(true)
|
||||
const canExport = ref(true)
|
||||
const materialModes = ref<readonly MaterialMode[]>([
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
|
||||
const captureAdapterFlags = (source: Load3d) => {
|
||||
isSplatModel.value = source.isSplatModel()
|
||||
isPlyModel.value = source.isPlyModel()
|
||||
const caps = source.getCurrentModelCapabilities()
|
||||
canFitToViewer.value = caps.fitToViewer
|
||||
canUseGizmo.value = caps.gizmoTransform
|
||||
canUseLighting.value = caps.lighting
|
||||
canExport.value = caps.exportable
|
||||
materialModes.value = caps.materialModes
|
||||
}
|
||||
|
||||
// Animation state
|
||||
const animations = ref<AnimationItem[]>([])
|
||||
@@ -314,7 +335,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
|
||||
const hasTargetDimensions = !!(width && height)
|
||||
|
||||
load3d = new Load3d(containerRef, {
|
||||
load3d = createLoad3d(containerRef, {
|
||||
width: width ? (toRaw(width).value as number) : undefined,
|
||||
height: height ? (toRaw(height).value as number) : undefined,
|
||||
getDimensions: hasTargetDimensions
|
||||
@@ -394,8 +415,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
}
|
||||
}
|
||||
|
||||
isSplatModel.value = source.isSplatModel()
|
||||
isPlyModel.value = source.isPlyModel()
|
||||
captureAdapterFlags(source)
|
||||
|
||||
initialState.value = {
|
||||
backgroundColor: backgroundColor.value,
|
||||
@@ -442,7 +462,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
|
||||
isStandaloneMode.value = true
|
||||
|
||||
load3d = new Load3d(containerRef, {
|
||||
load3d = createLoad3d(containerRef, {
|
||||
width: 800,
|
||||
height: 600,
|
||||
isViewerMode: true
|
||||
@@ -455,8 +475,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
await load3d.loadModel(modelUrl)
|
||||
currentModelUrl = modelUrl
|
||||
restoreStandaloneConfig(modelUrl)
|
||||
isSplatModel.value = load3d.isSplatModel()
|
||||
isPlyModel.value = load3d.isPlyModel()
|
||||
captureAdapterFlags(load3d)
|
||||
|
||||
isPreview.value = true
|
||||
|
||||
@@ -479,8 +498,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
await load3d.loadModel(modelUrl)
|
||||
currentModelUrl = modelUrl
|
||||
restoreStandaloneConfig(modelUrl)
|
||||
isSplatModel.value = load3d.isSplatModel()
|
||||
isPlyModel.value = load3d.isPlyModel()
|
||||
captureAdapterFlags(load3d)
|
||||
} catch (error) {
|
||||
console.error('Error loading model in standalone viewer:', error)
|
||||
useToastStore().addAlert('Failed to load 3D model')
|
||||
@@ -811,6 +829,11 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
isStandaloneMode,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
canFitToViewer,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
canExport,
|
||||
materialModes,
|
||||
|
||||
// Animation state
|
||||
animations,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,18 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
|
||||
|
||||
import { AnimationManager } from './AnimationManager'
|
||||
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 type { AnimationManager } from './AnimationManager'
|
||||
import type { CameraManager } from './CameraManager'
|
||||
import type { ControlsManager } from './ControlsManager'
|
||||
import type { EventManager } from './EventManager'
|
||||
import type { HDRIManager } from './HDRIManager'
|
||||
import type { GizmoManager } from './GizmoManager'
|
||||
import type { LightingManager } from './LightingManager'
|
||||
import type { LoaderManager } from './LoaderManager'
|
||||
import { ModelExporter } from './ModelExporter'
|
||||
import { RecordingManager } from './RecordingManager'
|
||||
import { SceneManager } from './SceneManager'
|
||||
import { SceneModelManager } from './SceneModelManager'
|
||||
import { ViewHelperManager } from './ViewHelperManager'
|
||||
import type { RecordingManager } from './RecordingManager'
|
||||
import type { SceneManager } from './SceneManager'
|
||||
import type { SceneModelManager } from './SceneModelManager'
|
||||
import type { ViewHelperManager } from './ViewHelperManager'
|
||||
import type {
|
||||
CameraState,
|
||||
CaptureResult,
|
||||
@@ -24,6 +22,30 @@ import type {
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from './interfaces'
|
||||
import {
|
||||
DEFAULT_MODEL_CAPABILITIES,
|
||||
type ModelAdapterCapabilities
|
||||
} from './ModelAdapter'
|
||||
import { attachContextMenuGuard } from './load3dContextMenuGuard'
|
||||
import type { RenderLoopHandle } from './load3dRenderLoop'
|
||||
import { startRenderLoop } from './load3dRenderLoop'
|
||||
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
|
||||
|
||||
export type Load3dDeps = {
|
||||
renderer: THREE.WebGLRenderer
|
||||
eventManager: EventManager
|
||||
sceneManager: SceneManager
|
||||
cameraManager: CameraManager
|
||||
controlsManager: ControlsManager
|
||||
lightingManager: LightingManager
|
||||
hdriManager: HDRIManager
|
||||
viewHelperManager: ViewHelperManager
|
||||
loaderManager: LoaderManager
|
||||
modelManager: SceneModelManager
|
||||
recordingManager: RecordingManager
|
||||
animationManager: AnimationManager
|
||||
gizmoManager: GizmoManager
|
||||
}
|
||||
|
||||
function positionThumbnailCamera(
|
||||
camera: THREE.PerspectiveCamera,
|
||||
@@ -47,10 +69,14 @@ function positionThumbnailCamera(
|
||||
class Load3d {
|
||||
renderer: THREE.WebGLRenderer
|
||||
protected clock: THREE.Clock
|
||||
protected animationFrameId: number | null = null
|
||||
private renderLoop: RenderLoopHandle | null = null
|
||||
private disposeContextMenuGuard: (() => void) | null = null
|
||||
private loadingPromise: Promise<void> | null = null
|
||||
private onContextMenuCallback?: (event: MouseEvent) => void
|
||||
private getDimensionsCallback?: () => { width: number; height: number } | null
|
||||
private readonly onContextMenuCallback?: (event: MouseEvent) => void
|
||||
private readonly getDimensionsCallback?: () => {
|
||||
width: number
|
||||
height: number
|
||||
} | null
|
||||
|
||||
eventManager: EventManager
|
||||
sceneManager: SceneManager
|
||||
@@ -75,13 +101,13 @@ class Load3d {
|
||||
targetAspectRatio: number = 1
|
||||
isViewerMode: boolean = false
|
||||
|
||||
private rightMouseStart: { x: number; y: number } = { x: 0, y: 0 }
|
||||
private rightMouseMoved: boolean = false
|
||||
private readonly dragThreshold: number = 5
|
||||
private contextMenuAbortController: AbortController | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
|
||||
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
|
||||
constructor(
|
||||
container: Element | HTMLElement,
|
||||
deps: Load3dDeps,
|
||||
options: Load3DOptions = {}
|
||||
) {
|
||||
this.clock = new THREE.Clock()
|
||||
this.isViewerMode = options.isViewerMode || false
|
||||
this.onContextMenuCallback = options.onContextMenu
|
||||
@@ -93,90 +119,19 @@ class Load3d {
|
||||
this.targetAspectRatio = options.width / options.height
|
||||
}
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
|
||||
this.renderer.setSize(300, 300)
|
||||
this.renderer.setClearColor(0x282828)
|
||||
this.renderer.autoClear = false
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
this.renderer.domElement.classList.add(
|
||||
'absolute',
|
||||
'inset-0',
|
||||
'h-full',
|
||||
'w-full',
|
||||
'outline-none'
|
||||
)
|
||||
container.appendChild(this.renderer.domElement)
|
||||
|
||||
this.eventManager = new EventManager()
|
||||
|
||||
this.sceneManager = new SceneManager(
|
||||
this.renderer,
|
||||
this.getActiveCamera.bind(this),
|
||||
this.getControls.bind(this),
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.cameraManager = new CameraManager(this.renderer, this.eventManager)
|
||||
|
||||
this.controlsManager = new ControlsManager(
|
||||
this.renderer,
|
||||
this.cameraManager.activeCamera,
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.cameraManager.setControls(this.controlsManager.controls)
|
||||
|
||||
this.lightingManager = new LightingManager(
|
||||
this.sceneManager.scene,
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.hdriManager = new HDRIManager(
|
||||
this.sceneManager.scene,
|
||||
this.renderer,
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.viewHelperManager = new ViewHelperManager(
|
||||
this.renderer,
|
||||
this.getActiveCamera.bind(this),
|
||||
this.getControls.bind(this),
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.modelManager = new SceneModelManager(
|
||||
this.sceneManager.scene,
|
||||
this.renderer,
|
||||
this.eventManager,
|
||||
this.getActiveCamera.bind(this),
|
||||
this.setupCamera.bind(this),
|
||||
this.setGizmo.bind(this)
|
||||
)
|
||||
|
||||
this.loaderManager = new LoaderManager(this.modelManager, this.eventManager)
|
||||
|
||||
this.recordingManager = new RecordingManager(
|
||||
this.sceneManager.scene,
|
||||
this.renderer,
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
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.renderer = deps.renderer
|
||||
this.eventManager = deps.eventManager
|
||||
this.sceneManager = deps.sceneManager
|
||||
this.cameraManager = deps.cameraManager
|
||||
this.controlsManager = deps.controlsManager
|
||||
this.lightingManager = deps.lightingManager
|
||||
this.hdriManager = deps.hdriManager
|
||||
this.viewHelperManager = deps.viewHelperManager
|
||||
this.loaderManager = deps.loaderManager
|
||||
this.modelManager = deps.modelManager
|
||||
this.recordingManager = deps.recordingManager
|
||||
this.animationManager = deps.animationManager
|
||||
this.gizmoManager = deps.gizmoManager
|
||||
|
||||
this.sceneManager.init()
|
||||
this.cameraManager.init()
|
||||
@@ -214,69 +169,12 @@ class Load3d {
|
||||
this.resizeObserver.observe(container)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize context menu on the Three.js canvas
|
||||
* Detects right-click vs right-drag to show menu only on click
|
||||
*/
|
||||
private initContextMenu(): void {
|
||||
const canvas = this.renderer.domElement
|
||||
|
||||
this.contextMenuAbortController = new AbortController()
|
||||
const { signal } = this.contextMenuAbortController
|
||||
|
||||
const mousedownHandler = (e: MouseEvent) => {
|
||||
if (e.button === 2) {
|
||||
this.rightMouseStart = { x: e.clientX, y: e.clientY }
|
||||
this.rightMouseMoved = false
|
||||
}
|
||||
}
|
||||
|
||||
const mousemoveHandler = (e: MouseEvent) => {
|
||||
if (e.buttons === 2) {
|
||||
if (
|
||||
exceedsClickThreshold(
|
||||
this.rightMouseStart,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
this.dragThreshold
|
||||
)
|
||||
) {
|
||||
this.rightMouseMoved = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contextmenuHandler = (e: MouseEvent) => {
|
||||
if (this.isViewerMode) return
|
||||
|
||||
const wasDragging =
|
||||
this.rightMouseMoved ||
|
||||
exceedsClickThreshold(
|
||||
this.rightMouseStart,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
this.dragThreshold
|
||||
)
|
||||
|
||||
this.rightMouseMoved = false
|
||||
|
||||
if (wasDragging) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
this.showNodeContextMenu(e)
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', mousedownHandler, { signal })
|
||||
canvas.addEventListener('mousemove', mousemoveHandler, { signal })
|
||||
canvas.addEventListener('contextmenu', contextmenuHandler, { signal })
|
||||
}
|
||||
|
||||
private showNodeContextMenu(event: MouseEvent): void {
|
||||
if (this.onContextMenuCallback) {
|
||||
this.onContextMenuCallback(event)
|
||||
}
|
||||
this.disposeContextMenuGuard = attachContextMenuGuard(
|
||||
this.renderer.domElement,
|
||||
(event) => this.onContextMenuCallback?.(event),
|
||||
{ isDisabled: () => this.isViewerMode }
|
||||
)
|
||||
}
|
||||
|
||||
getEventManager(): EventManager {
|
||||
@@ -323,7 +221,7 @@ class Load3d {
|
||||
return this.isViewerMode || (this.targetWidth > 0 && this.targetHeight > 0)
|
||||
}
|
||||
|
||||
forceRender(): void {
|
||||
private performFrame(): void {
|
||||
const delta = this.clock.getDelta()
|
||||
this.animationManager.update(delta)
|
||||
this.viewHelperManager.update(delta)
|
||||
@@ -336,7 +234,10 @@ class Load3d {
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
}
|
||||
|
||||
forceRender(): void {
|
||||
this.performFrame()
|
||||
this.INITIAL_RENDER_DONE = true
|
||||
}
|
||||
|
||||
@@ -354,22 +255,10 @@ class Load3d {
|
||||
}
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const containerAspectRatio = containerWidth / containerHeight
|
||||
|
||||
let renderWidth: number
|
||||
let renderHeight: number
|
||||
let offsetX: number = 0
|
||||
let offsetY: number = 0
|
||||
|
||||
if (containerAspectRatio > this.targetAspectRatio) {
|
||||
renderHeight = containerHeight
|
||||
renderWidth = renderHeight * this.targetAspectRatio
|
||||
offsetX = (containerWidth - renderWidth) / 2
|
||||
} else {
|
||||
renderWidth = containerWidth
|
||||
renderHeight = renderWidth / this.targetAspectRatio
|
||||
offsetY = (containerHeight - renderHeight) / 2
|
||||
}
|
||||
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
|
||||
{ width: containerWidth, height: containerHeight },
|
||||
this.targetAspectRatio
|
||||
)
|
||||
|
||||
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
|
||||
@@ -377,11 +266,10 @@ class Load3d {
|
||||
this.renderer.setClearColor(0x0a0a0a)
|
||||
this.renderer.clear()
|
||||
|
||||
this.renderer.setViewport(offsetX, offsetY, renderWidth, renderHeight)
|
||||
this.renderer.setScissor(offsetX, offsetY, renderWidth, renderHeight)
|
||||
this.renderer.setViewport(offsetX, offsetY, width, height)
|
||||
this.renderer.setScissor(offsetX, offsetY, width, height)
|
||||
|
||||
const renderAspectRatio = renderWidth / renderHeight
|
||||
this.cameraManager.updateAspectRatio(renderAspectRatio)
|
||||
this.cameraManager.updateAspectRatio(width / height)
|
||||
} else {
|
||||
// No aspect ratio constraint: fill the entire container
|
||||
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
|
||||
@@ -405,45 +293,11 @@ class Load3d {
|
||||
this.renderer.setScissorTest(false)
|
||||
}
|
||||
|
||||
private getActiveCamera(): THREE.Camera {
|
||||
return this.cameraManager.activeCamera
|
||||
}
|
||||
|
||||
private getControls() {
|
||||
return this.controlsManager.controls
|
||||
}
|
||||
|
||||
private setGizmo(model: THREE.Object3D): void {
|
||||
this.gizmoManager.setupForModel(model)
|
||||
}
|
||||
|
||||
private setupCamera(size: THREE.Vector3, center: THREE.Vector3): void {
|
||||
this.cameraManager.setupForModel(size, center)
|
||||
}
|
||||
|
||||
private startAnimation(): void {
|
||||
const animate = () => {
|
||||
this.animationFrameId = requestAnimationFrame(animate)
|
||||
|
||||
if (!this.isActive()) {
|
||||
return
|
||||
}
|
||||
|
||||
const delta = this.clock.getDelta()
|
||||
this.animationManager.update(delta)
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
|
||||
this.renderMainScene()
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
}
|
||||
|
||||
animate()
|
||||
this.renderLoop = startRenderLoop({
|
||||
tick: () => this.performFrame(),
|
||||
isActive: () => this.isActive()
|
||||
})
|
||||
}
|
||||
|
||||
updateStatusMouseOnNode(onNode: boolean): void {
|
||||
@@ -459,14 +313,14 @@ class Load3d {
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return (
|
||||
this.STATUS_MOUSE_ON_NODE ||
|
||||
this.STATUS_MOUSE_ON_SCENE ||
|
||||
this.STATUS_MOUSE_ON_VIEWER ||
|
||||
this.isRecording() ||
|
||||
!this.INITIAL_RENDER_DONE ||
|
||||
this.animationManager.isAnimationPlaying
|
||||
)
|
||||
return isLoad3dActive({
|
||||
mouseOnNode: this.STATUS_MOUSE_ON_NODE,
|
||||
mouseOnScene: this.STATUS_MOUSE_ON_SCENE,
|
||||
mouseOnViewer: this.STATUS_MOUSE_ON_VIEWER,
|
||||
recording: this.isRecording(),
|
||||
initialRenderDone: this.INITIAL_RENDER_DONE,
|
||||
animationPlaying: this.animationManager.isAnimationPlaying
|
||||
})
|
||||
}
|
||||
|
||||
async exportModel(format: string): Promise<void> {
|
||||
@@ -527,24 +381,16 @@ class Load3d {
|
||||
const containerHeight = this.renderer.domElement.clientHeight
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const containerAspectRatio = containerWidth / containerHeight
|
||||
|
||||
let renderWidth: number
|
||||
let renderHeight: number
|
||||
|
||||
if (containerAspectRatio > this.targetAspectRatio) {
|
||||
renderHeight = containerHeight
|
||||
renderWidth = renderHeight * this.targetAspectRatio
|
||||
} else {
|
||||
renderWidth = containerWidth
|
||||
renderHeight = renderWidth / this.targetAspectRatio
|
||||
}
|
||||
const { width, height } = computeLetterboxedViewport(
|
||||
{ width: containerWidth, height: containerHeight },
|
||||
this.targetAspectRatio
|
||||
)
|
||||
|
||||
this.sceneManager.updateBackgroundSize(
|
||||
this.sceneManager.backgroundTexture,
|
||||
this.sceneManager.backgroundMesh,
|
||||
renderWidth,
|
||||
renderHeight
|
||||
width,
|
||||
height
|
||||
)
|
||||
} else {
|
||||
// No aspect ratio constraints: fill container
|
||||
@@ -651,11 +497,18 @@ class Load3d {
|
||||
}
|
||||
|
||||
isSplatModel(): boolean {
|
||||
return this.modelManager.containsSplatMesh()
|
||||
return this.loaderManager.getCurrentAdapter()?.kind === 'splat'
|
||||
}
|
||||
|
||||
isPlyModel(): boolean {
|
||||
return this.modelManager.originalModel instanceof THREE.BufferGeometry
|
||||
return this.loaderManager.getCurrentAdapter()?.kind === 'pointCloud'
|
||||
}
|
||||
|
||||
getCurrentModelCapabilities(): ModelAdapterCapabilities {
|
||||
return (
|
||||
this.loaderManager.getCurrentAdapter()?.capabilities ??
|
||||
DEFAULT_MODEL_CAPABILITIES
|
||||
)
|
||||
}
|
||||
|
||||
clearModel(): void {
|
||||
@@ -742,21 +595,14 @@ class Load3d {
|
||||
}
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const containerAspectRatio = containerWidth / containerHeight
|
||||
let renderWidth: number
|
||||
let renderHeight: number
|
||||
|
||||
if (containerAspectRatio > this.targetAspectRatio) {
|
||||
renderHeight = containerHeight
|
||||
renderWidth = renderHeight * this.targetAspectRatio
|
||||
} else {
|
||||
renderWidth = containerWidth
|
||||
renderHeight = renderWidth / this.targetAspectRatio
|
||||
}
|
||||
const { width, height } = computeLetterboxedViewport(
|
||||
{ width: containerWidth, height: containerHeight },
|
||||
this.targetAspectRatio
|
||||
)
|
||||
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
this.cameraManager.handleResize(renderWidth, renderHeight)
|
||||
this.sceneManager.handleResize(renderWidth, renderHeight)
|
||||
this.cameraManager.handleResize(width, height)
|
||||
this.sceneManager.handleResize(width, height)
|
||||
} else {
|
||||
// No aspect ratio constraint: use container dimensions directly
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
@@ -903,16 +749,22 @@ class Load3d {
|
||||
}
|
||||
|
||||
public setGizmoEnabled(enabled: boolean): void {
|
||||
// Defensive guard: adapters that don't support gizmo transforms
|
||||
// (PLY point clouds, Gaussian splats) ignore enable requests even if
|
||||
// the caller forgot to check the capability.
|
||||
if (enabled && !this.getCurrentModelCapabilities().gizmoTransform) return
|
||||
this.gizmoManager.setEnabled(enabled)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public setGizmoMode(mode: GizmoMode): void {
|
||||
if (!this.getCurrentModelCapabilities().gizmoTransform) return
|
||||
this.gizmoManager.setMode(mode)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public resetGizmoTransform(): void {
|
||||
if (!this.getCurrentModelCapabilities().gizmoTransform) return
|
||||
this.gizmoManager.reset()
|
||||
this.forceRender()
|
||||
}
|
||||
@@ -922,6 +774,7 @@ class Load3d {
|
||||
rotation: { x: number; y: number; z: number },
|
||||
scale?: { x: number; y: number; z: number }
|
||||
): void {
|
||||
if (!this.getCurrentModelCapabilities().gizmoTransform) return
|
||||
this.gizmoManager.applyTransform(position, rotation, scale)
|
||||
this.forceRender()
|
||||
}
|
||||
@@ -945,10 +798,8 @@ class Load3d {
|
||||
this.resizeObserver = null
|
||||
}
|
||||
|
||||
if (this.contextMenuAbortController) {
|
||||
this.contextMenuAbortController.abort()
|
||||
this.contextMenuAbortController = null
|
||||
}
|
||||
this.disposeContextMenuGuard?.()
|
||||
this.disposeContextMenuGuard = null
|
||||
|
||||
this.renderer.forceContextLoss()
|
||||
const canvas = this.renderer.domElement
|
||||
@@ -958,9 +809,8 @@ class Load3d {
|
||||
})
|
||||
canvas.dispatchEvent(event)
|
||||
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId)
|
||||
}
|
||||
this.renderLoop?.stop()
|
||||
this.renderLoop = null
|
||||
|
||||
this.sceneManager.dispose()
|
||||
this.cameraManager.dispose()
|
||||
|
||||
424
src/extensions/core/load3d/LoaderManager.test.ts
Normal file
424
src/extensions/core/load3d/LoaderManager.test.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LoaderManager } from './LoaderManager'
|
||||
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
|
||||
import {
|
||||
makeEventManagerStub,
|
||||
makeModelManagerStub
|
||||
} from './__test__/managerStubs'
|
||||
|
||||
const { meshLoad, splatLoad, pointCloudLoad, getPLYEngineMock, addAlert } =
|
||||
vi.hoisted(() => ({
|
||||
meshLoad: vi.fn(),
|
||||
splatLoad: vi.fn(),
|
||||
pointCloudLoad: vi.fn(),
|
||||
getPLYEngineMock: vi.fn<() => string>(),
|
||||
addAlert: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./MeshModelAdapter', () => ({
|
||||
MeshModelAdapter: class {
|
||||
readonly kind = 'mesh' as const
|
||||
readonly extensions = ['stl', 'fbx', 'obj', 'gltf', 'glb'] as const
|
||||
readonly capabilities = {}
|
||||
load = meshLoad
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./PointCloudModelAdapter', () => ({
|
||||
PointCloudModelAdapter: class {
|
||||
readonly kind = 'pointCloud' as const
|
||||
readonly extensions = ['ply'] as const
|
||||
readonly capabilities = {}
|
||||
load = pointCloudLoad
|
||||
},
|
||||
getPLYEngine: () => getPLYEngineMock()
|
||||
}))
|
||||
|
||||
vi.mock('./SplatModelAdapter', () => ({
|
||||
SplatModelAdapter: class {
|
||||
readonly kind = 'splat' as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat'] as const
|
||||
readonly capabilities = {}
|
||||
load = splatLoad
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert })
|
||||
}))
|
||||
|
||||
type LoaderManagerInternals = {
|
||||
pickAdapter(extension: string): ModelAdapter | null
|
||||
}
|
||||
|
||||
function makeLoaderManager() {
|
||||
const modelManager = makeModelManagerStub()
|
||||
const eventManager = makeEventManagerStub()
|
||||
const lm = new LoaderManager(
|
||||
modelManager as unknown as ConstructorParameters<typeof LoaderManager>[0],
|
||||
eventManager
|
||||
)
|
||||
const internals = lm as unknown as LoaderManagerInternals
|
||||
return {
|
||||
lm,
|
||||
modelManager,
|
||||
eventManager,
|
||||
pick: internals.pickAdapter.bind(lm)
|
||||
}
|
||||
}
|
||||
|
||||
describe('LoaderManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getPLYEngineMock.mockReturnValue('three')
|
||||
meshLoad.mockResolvedValue(null)
|
||||
splatLoad.mockResolvedValue(null)
|
||||
pointCloudLoad.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
describe('getCurrentAdapter', () => {
|
||||
it('returns null before any model loads', () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
expect(lm.getCurrentAdapter()).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes the picked adapter after a successful load', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
})
|
||||
|
||||
it('resets to null at the start of a new load', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.xyz')
|
||||
expect(lm.getCurrentAdapter()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickAdapter', () => {
|
||||
it.each(['stl', 'fbx', 'obj', 'gltf', 'glb'])(
|
||||
'routes %s to the mesh adapter',
|
||||
(ext) => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick(ext)?.kind).toBe('mesh')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['spz', 'splat', 'ksplat'])(
|
||||
'routes %s to the splat adapter',
|
||||
(ext) => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick(ext)?.kind).toBe('splat')
|
||||
}
|
||||
)
|
||||
|
||||
it('routes .ply to the point-cloud adapter for the default three engine', () => {
|
||||
getPLYEngineMock.mockReturnValue('three')
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('ply')?.kind).toBe('pointCloud')
|
||||
})
|
||||
|
||||
it('routes .ply to the point-cloud adapter for the fastply engine', () => {
|
||||
getPLYEngineMock.mockReturnValue('fastply')
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('ply')?.kind).toBe('pointCloud')
|
||||
})
|
||||
|
||||
it('routes .ply to the splat adapter when the engine setting is sparkjs', () => {
|
||||
getPLYEngineMock.mockReturnValue('sparkjs')
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('ply')?.kind).toBe('splat')
|
||||
})
|
||||
|
||||
it('returns null for unknown extensions', () => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('xyz')).toBeNull()
|
||||
expect(pick('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadModel', () => {
|
||||
it('emits modelLoadingStart and records originalURL before dispatching', async () => {
|
||||
const { lm, eventManager, modelManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelLoadingStart',
|
||||
null
|
||||
)
|
||||
expect(modelManager.originalURL).toBe('api/view?filename=cube.glb')
|
||||
})
|
||||
|
||||
it('clears any existing model before routing to the adapter', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
const order: string[] = []
|
||||
modelManager.clearModel.mockImplementation(() => order.push('clear'))
|
||||
meshLoad.mockImplementationOnce(async () => {
|
||||
order.push('load')
|
||||
return null
|
||||
})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(order).toEqual(['clear', 'load'])
|
||||
})
|
||||
|
||||
it('derives originalFileName from an explicit originalFileName argument', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?filename=ignored.glb', 'uploads/my-cube.glb')
|
||||
|
||||
expect(modelManager.originalFileName).toBe('my-cube')
|
||||
})
|
||||
|
||||
it('derives originalFileName from the URL filename param when no override is given', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(modelManager.originalFileName).toBe('cube')
|
||||
})
|
||||
|
||||
it('falls back to "model" when the URL has no filename param', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?other=1')
|
||||
|
||||
expect(modelManager.originalFileName).toBe('model')
|
||||
})
|
||||
|
||||
it('alerts when the file extension cannot be determined', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
|
||||
await lm.loadModel('api/view?other=1')
|
||||
|
||||
expect(addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.couldNotDetermineFileType'
|
||||
)
|
||||
expect(modelManager.setupModel).not.toHaveBeenCalled()
|
||||
expect(meshLoad).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes setupModel the object returned by the adapter', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
const loaded = new THREE.Object3D()
|
||||
meshLoad.mockResolvedValueOnce(loaded)
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(modelManager.setupModel).toHaveBeenCalledWith(loaded)
|
||||
})
|
||||
|
||||
it('skips setupModel when the adapter returns null', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(null)
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(modelManager.setupModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits modelLoadingEnd when the load completes', async () => {
|
||||
const { lm, eventManager } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelLoadingEnd',
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards a decoded path and filename to the adapter', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel(
|
||||
'api/view?type=output&subfolder=nested%2Fdir&filename=cube.glb'
|
||||
)
|
||||
|
||||
expect(meshLoad).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
setOriginalModel: expect.any(Function),
|
||||
registerOriginalMaterial: expect.any(Function)
|
||||
}),
|
||||
'api/view?type=output&subfolder=nested%2Fdir&filename=',
|
||||
'cube.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults the path to type=input when no type param is given', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(meshLoad).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'api/view?type=input&subfolder=&filename=',
|
||||
'cube.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes .ply through the splat adapter when the engine setting is sparkjs', async () => {
|
||||
getPLYEngineMock.mockReturnValue('sparkjs')
|
||||
const { lm } = makeLoaderManager()
|
||||
splatLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=scan.ply')
|
||||
|
||||
expect(splatLoad).toHaveBeenCalled()
|
||||
expect(pointCloudLoad).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles adapter errors by alerting and still emitting modelLoadingEnd', async () => {
|
||||
const { lm, eventManager } = makeLoaderManager()
|
||||
const err = new Error('boom')
|
||||
meshLoad.mockRejectedValueOnce(err)
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelLoadingEnd',
|
||||
null
|
||||
)
|
||||
expect(addAlert).toHaveBeenCalledWith('toastMessages.errorLoadingModel')
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards the result of a stale load when a newer one has started', async () => {
|
||||
const { lm, modelManager, eventManager } = makeLoaderManager()
|
||||
|
||||
let resolveFirst!: (value: THREE.Object3D) => void
|
||||
const firstLoad = new Promise<THREE.Object3D>((r) => {
|
||||
resolveFirst = r
|
||||
})
|
||||
const firstModel = new THREE.Object3D()
|
||||
firstModel.name = 'first'
|
||||
const secondModel = new THREE.Object3D()
|
||||
secondModel.name = 'second'
|
||||
|
||||
meshLoad
|
||||
.mockImplementationOnce(() => firstLoad)
|
||||
.mockResolvedValueOnce(secondModel)
|
||||
|
||||
const firstPromise = lm.loadModel('api/view?filename=first.glb')
|
||||
const secondPromise = lm.loadModel('api/view?filename=second.glb')
|
||||
|
||||
resolveFirst(firstModel)
|
||||
|
||||
await Promise.all([firstPromise, secondPromise])
|
||||
|
||||
expect(modelManager.setupModel).toHaveBeenCalledTimes(1)
|
||||
expect(modelManager.setupModel).toHaveBeenCalledWith(secondModel)
|
||||
|
||||
const endEmits = eventManager.emitEvent.mock.calls.filter(
|
||||
([name]) => name === 'modelLoadingEnd'
|
||||
)
|
||||
expect(endEmits).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('logs and drops the load when the URL is missing a filename param', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
await lm.loadModel('api/view?type=output', 'uploads/file.glb')
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Missing filename in URL:',
|
||||
'api/view?type=output'
|
||||
)
|
||||
expect(modelManager.setupModel).not.toHaveBeenCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('proxies setOriginalModel and registerOriginalMaterial through the load context', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
let capturedCtx: ModelLoadContext | undefined
|
||||
meshLoad.mockImplementationOnce(async (ctx: ModelLoadContext) => {
|
||||
capturedCtx = ctx
|
||||
return new THREE.Object3D()
|
||||
})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BufferGeometry(),
|
||||
new THREE.MeshBasicMaterial()
|
||||
)
|
||||
const mat = new THREE.MeshStandardMaterial()
|
||||
capturedCtx!.setOriginalModel(mesh)
|
||||
capturedCtx!.registerOriginalMaterial(mesh, mat)
|
||||
|
||||
expect(modelManager.setOriginalModel).toHaveBeenCalledWith(mesh)
|
||||
expect(modelManager.originalMaterials.get(mesh)).toBe(mat)
|
||||
})
|
||||
|
||||
it('exposes modelManager.standardMaterial and materialMode via getters on the load context', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
modelManager.materialMode = 'wireframe'
|
||||
let capturedCtx: ModelLoadContext | undefined
|
||||
meshLoad.mockImplementationOnce(async (ctx: ModelLoadContext) => {
|
||||
capturedCtx = ctx
|
||||
return new THREE.Object3D()
|
||||
})
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(capturedCtx!.standardMaterial).toBe(modelManager.standardMaterial)
|
||||
expect(capturedCtx!.materialMode).toBe('wireframe')
|
||||
})
|
||||
|
||||
it('suppresses alerts and modelLoadingEnd when a stale load throws', async () => {
|
||||
const { lm, eventManager } = makeLoaderManager()
|
||||
|
||||
let rejectFirst!: (err: unknown) => void
|
||||
const firstLoad = new Promise<THREE.Object3D>((_, r) => {
|
||||
rejectFirst = r
|
||||
})
|
||||
|
||||
meshLoad
|
||||
.mockImplementationOnce(() => firstLoad)
|
||||
.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const firstPromise = lm.loadModel('api/view?filename=first.glb')
|
||||
const secondPromise = lm.loadModel('api/view?filename=second.glb')
|
||||
|
||||
rejectFirst(new Error('stale failure'))
|
||||
|
||||
await Promise.all([firstPromise, secondPromise])
|
||||
|
||||
expect(addAlert).not.toHaveBeenCalled()
|
||||
const endEmits = eventManager.emitEvent.mock.calls.filter(
|
||||
([name]) => name === 'modelLoadingEnd'
|
||||
)
|
||||
expect(endEmits).toHaveLength(1)
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,41 +1,28 @@
|
||||
import { SplatMesh } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
|
||||
// Use pre-bundled worker module (has all dependencies included)
|
||||
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds
|
||||
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { isPLYAsciiFormat } from '@/scripts/metadata/ply'
|
||||
|
||||
import { MeshModelAdapter } from './MeshModelAdapter'
|
||||
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
|
||||
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
|
||||
import { SplatModelAdapter } from './SplatModelAdapter'
|
||||
import {
|
||||
type EventManagerInterface,
|
||||
type LoaderManagerInterface,
|
||||
type ModelManagerInterface
|
||||
} from './interfaces'
|
||||
import { FastPLYLoader } from './loader/FastPLYLoader'
|
||||
|
||||
export class LoaderManager implements LoaderManagerInterface {
|
||||
gltfLoader: GLTFLoader
|
||||
objLoader: OBJLoader2Parallel
|
||||
mtlLoader: MTLLoader
|
||||
fbxLoader: FBXLoader
|
||||
stlLoader: STLLoader
|
||||
plyLoader: PLYLoader
|
||||
fastPlyLoader: FastPLYLoader
|
||||
|
||||
private modelManager: ModelManagerInterface
|
||||
private eventManager: EventManagerInterface
|
||||
private readonly modelManager: ModelManagerInterface
|
||||
private readonly eventManager: EventManagerInterface
|
||||
private currentLoadId: number = 0
|
||||
|
||||
private readonly meshAdapter: MeshModelAdapter
|
||||
private readonly splatAdapter: SplatModelAdapter
|
||||
private readonly pointCloudAdapter: PointCloudModelAdapter
|
||||
private _currentAdapter: ModelAdapter | null = null
|
||||
|
||||
constructor(
|
||||
modelManager: ModelManagerInterface,
|
||||
eventManager: EventManagerInterface
|
||||
@@ -43,18 +30,13 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
this.modelManager = modelManager
|
||||
this.eventManager = eventManager
|
||||
|
||||
this.gltfLoader = new GLTFLoader()
|
||||
this.objLoader = new OBJLoader2Parallel()
|
||||
// Set worker URL for Vite compatibility
|
||||
this.objLoader.setWorkerUrl(
|
||||
true,
|
||||
new URL(OBJLoader2WorkerUrl, import.meta.url)
|
||||
)
|
||||
this.mtlLoader = new MTLLoader()
|
||||
this.fbxLoader = new FBXLoader()
|
||||
this.stlLoader = new STLLoader()
|
||||
this.plyLoader = new PLYLoader()
|
||||
this.fastPlyLoader = new FastPLYLoader()
|
||||
this.meshAdapter = new MeshModelAdapter()
|
||||
this.splatAdapter = new SplatModelAdapter()
|
||||
this.pointCloudAdapter = new PointCloudModelAdapter()
|
||||
}
|
||||
|
||||
getCurrentAdapter(): ModelAdapter | null {
|
||||
return this._currentAdapter
|
||||
}
|
||||
|
||||
init(): void {}
|
||||
@@ -67,6 +49,7 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
try {
|
||||
this.eventManager.emitEvent('modelLoadingStart', null)
|
||||
|
||||
this._currentAdapter = null
|
||||
this.modelManager.clearModel()
|
||||
|
||||
this.modelManager.originalURL = url
|
||||
@@ -80,12 +63,9 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
} else {
|
||||
const filename = new URLSearchParams(url.split('?')[1]).get('filename')
|
||||
fileExtension = filename?.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (filename) {
|
||||
this.modelManager.originalFileName = filename.split('.')[0] || 'model'
|
||||
} else {
|
||||
this.modelManager.originalFileName = 'model'
|
||||
}
|
||||
this.modelManager.originalFileName = filename
|
||||
? filename.split('.')[0] || 'model'
|
||||
: 'model'
|
||||
}
|
||||
|
||||
if (!fileExtension) {
|
||||
@@ -113,26 +93,50 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
private pickAdapter(extension: string): ModelAdapter | null {
|
||||
if (this.meshAdapter.extensions.includes(extension as never)) {
|
||||
return this.meshAdapter
|
||||
}
|
||||
if (this.splatAdapter.extensions.includes(extension as never)) {
|
||||
return this.splatAdapter
|
||||
}
|
||||
if (this.pointCloudAdapter.extensions.includes(extension as never)) {
|
||||
return getPLYEngine() === 'sparkjs'
|
||||
? this.splatAdapter
|
||||
: this.pointCloudAdapter
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private createLoadContext(): ModelLoadContext {
|
||||
const mm = this.modelManager
|
||||
return {
|
||||
setOriginalModel: (model) => mm.setOriginalModel(model),
|
||||
registerOriginalMaterial: (mesh, material) =>
|
||||
mm.originalMaterials.set(mesh, material),
|
||||
get standardMaterial() {
|
||||
return mm.standardMaterial
|
||||
},
|
||||
get materialMode() {
|
||||
return mm.materialMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadModelInternal(
|
||||
url: string,
|
||||
fileExtension: string
|
||||
): Promise<THREE.Object3D | null> {
|
||||
let model: THREE.Object3D | null = null
|
||||
|
||||
const params = new URLSearchParams(url.split('?')[1])
|
||||
|
||||
const filename = params.get('filename')
|
||||
|
||||
if (!filename) {
|
||||
console.error('Missing filename in URL:', url)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const loadRootFolder = params.get('type') === 'output' ? 'output' : 'input'
|
||||
|
||||
const subfolder = params.get('subfolder') ?? ''
|
||||
|
||||
const path =
|
||||
'api/view?type=' +
|
||||
loadRootFolder +
|
||||
@@ -140,217 +144,10 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
encodeURIComponent(subfolder) +
|
||||
'&filename='
|
||||
|
||||
switch (fileExtension) {
|
||||
case 'stl':
|
||||
this.stlLoader.setPath(path)
|
||||
const geometry = await this.stlLoader.loadAsync(filename)
|
||||
this.modelManager.setOriginalModel(geometry)
|
||||
geometry.computeVertexNormals()
|
||||
const adapter = this.pickAdapter(fileExtension)
|
||||
if (!adapter) return null
|
||||
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
this.modelManager.standardMaterial
|
||||
)
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(mesh)
|
||||
model = group
|
||||
break
|
||||
|
||||
case 'fbx':
|
||||
this.fbxLoader.setPath(path)
|
||||
|
||||
const fbxModel = await this.fbxLoader.loadAsync(filename)
|
||||
|
||||
this.modelManager.setOriginalModel(fbxModel)
|
||||
model = fbxModel
|
||||
|
||||
fbxModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
this.modelManager.originalMaterials.set(child, child.material)
|
||||
|
||||
if (child instanceof THREE.SkinnedMesh) {
|
||||
child.frustumCulled = false
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'obj':
|
||||
if (this.modelManager.materialMode === 'original') {
|
||||
try {
|
||||
this.mtlLoader.setPath(path)
|
||||
|
||||
const mtlFileName = filename.replace(/\.obj$/, '.mtl')
|
||||
|
||||
const materials = await this.mtlLoader.loadAsync(mtlFileName)
|
||||
materials.preload()
|
||||
const materialsFromMtl =
|
||||
MtlObjBridge.addMaterialsFromMtlLoader(materials)
|
||||
this.objLoader.setMaterials(materialsFromMtl)
|
||||
} catch (e) {
|
||||
console.log(
|
||||
'No MTL file found or error loading it, continuing without materials'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// OBJLoader2Parallel uses Web Worker for parsing (non-blocking)
|
||||
const objUrl = path + encodeURIComponent(filename)
|
||||
model = await this.objLoader.loadAsync(objUrl)
|
||||
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
this.modelManager.originalMaterials.set(child, child.material)
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'gltf':
|
||||
case 'glb':
|
||||
this.gltfLoader.setPath(path)
|
||||
const gltf = await this.gltfLoader.loadAsync(filename)
|
||||
|
||||
this.modelManager.setOriginalModel(gltf)
|
||||
model = gltf.scene
|
||||
|
||||
gltf.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry.computeVertexNormals()
|
||||
this.modelManager.originalMaterials.set(child, child.material)
|
||||
|
||||
if (child instanceof THREE.SkinnedMesh) {
|
||||
child.frustumCulled = false
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'ply':
|
||||
model = await this.loadPLY(path, filename)
|
||||
break
|
||||
|
||||
case 'spz':
|
||||
case 'splat':
|
||||
case 'ksplat':
|
||||
model = await this.loadSplat(path, filename)
|
||||
break
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
private async fetchModelData(path: string, filename: string) {
|
||||
const route =
|
||||
'/' + path.replace(/^api\//, '') + encodeURIComponent(filename)
|
||||
const response = await api.fetchApi(route)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch model: ${response.status}`)
|
||||
}
|
||||
return response.arrayBuffer()
|
||||
}
|
||||
|
||||
private async loadSplat(
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
const arrayBuffer = await this.fetchModelData(path, filename)
|
||||
|
||||
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
|
||||
this.modelManager.setOriginalModel(splatMesh)
|
||||
const splatGroup = new THREE.Group()
|
||||
splatGroup.add(splatMesh)
|
||||
return splatGroup
|
||||
}
|
||||
|
||||
private async loadPLY(
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null> {
|
||||
const plyEngine = useSettingStore().get('Comfy.Load3D.PLYEngine') as string
|
||||
|
||||
if (plyEngine === 'sparkjs') {
|
||||
return this.loadSplat(path, filename)
|
||||
}
|
||||
|
||||
// Use Three.js PLYLoader or FastPLYLoader for point cloud PLY files
|
||||
const arrayBuffer = await this.fetchModelData(path, filename)
|
||||
|
||||
const isASCII = isPLYAsciiFormat(arrayBuffer)
|
||||
|
||||
let plyGeometry: THREE.BufferGeometry
|
||||
|
||||
if (isASCII && plyEngine === 'fastply') {
|
||||
plyGeometry = this.fastPlyLoader.parse(arrayBuffer)
|
||||
} else {
|
||||
this.plyLoader.setPath(path)
|
||||
plyGeometry = this.plyLoader.parse(arrayBuffer)
|
||||
}
|
||||
|
||||
this.modelManager.setOriginalModel(plyGeometry)
|
||||
plyGeometry.computeVertexNormals()
|
||||
|
||||
const hasVertexColors = plyGeometry.attributes.color !== undefined
|
||||
const materialMode = this.modelManager.materialMode
|
||||
|
||||
// Use Points rendering for pointCloud mode (better for point clouds)
|
||||
if (materialMode === 'pointCloud') {
|
||||
plyGeometry.computeBoundingSphere()
|
||||
if (plyGeometry.boundingSphere) {
|
||||
const center = plyGeometry.boundingSphere.center
|
||||
const radius = plyGeometry.boundingSphere.radius
|
||||
|
||||
plyGeometry.translate(-center.x, -center.y, -center.z)
|
||||
|
||||
if (radius > 0) {
|
||||
const scale = 1.0 / radius
|
||||
plyGeometry.scale(scale, scale, scale)
|
||||
}
|
||||
}
|
||||
|
||||
const pointMaterial = hasVertexColors
|
||||
? new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
: new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
color: 0xcccccc,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
|
||||
const plyPoints = new THREE.Points(plyGeometry, pointMaterial)
|
||||
this.modelManager.originalMaterials.set(
|
||||
plyPoints as unknown as THREE.Mesh,
|
||||
pointMaterial
|
||||
)
|
||||
|
||||
const plyGroup = new THREE.Group()
|
||||
plyGroup.add(plyPoints)
|
||||
return plyGroup
|
||||
}
|
||||
|
||||
// Use Mesh rendering for other modes
|
||||
let plyMaterial: THREE.Material
|
||||
|
||||
if (hasVertexColors) {
|
||||
plyMaterial = new THREE.MeshStandardMaterial({
|
||||
vertexColors: true,
|
||||
metalness: 0.0,
|
||||
roughness: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
} else {
|
||||
plyMaterial = this.modelManager.standardMaterial.clone()
|
||||
plyMaterial.side = THREE.DoubleSide
|
||||
}
|
||||
|
||||
const plyMesh = new THREE.Mesh(plyGeometry, plyMaterial)
|
||||
this.modelManager.originalMaterials.set(plyMesh, plyMaterial)
|
||||
|
||||
const plyGroup = new THREE.Group()
|
||||
plyGroup.add(plyMesh)
|
||||
return plyGroup
|
||||
this._currentAdapter = adapter
|
||||
return adapter.load(this.createLoadContext(), path, filename)
|
||||
}
|
||||
}
|
||||
|
||||
303
src/extensions/core/load3d/MeshModelAdapter.test.ts
Normal file
303
src/extensions/core/load3d/MeshModelAdapter.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { MeshModelAdapter } from './MeshModelAdapter'
|
||||
import type { ModelLoadContext } from './ModelAdapter'
|
||||
|
||||
// Capture the loader instances for per-test assertions.
|
||||
const stlLoaderStub = {
|
||||
setPath: vi.fn(),
|
||||
loadAsync: vi.fn<(filename: string) => Promise<THREE.BufferGeometry>>()
|
||||
}
|
||||
const fbxLoaderStub = {
|
||||
setPath: vi.fn(),
|
||||
loadAsync: vi.fn<(filename: string) => Promise<THREE.Object3D>>()
|
||||
}
|
||||
const gltfLoaderStub = {
|
||||
setPath: vi.fn(),
|
||||
loadAsync: vi.fn<(filename: string) => Promise<{ scene: THREE.Object3D }>>()
|
||||
}
|
||||
const mtlLoaderStub = {
|
||||
setPath: vi.fn(),
|
||||
loadAsync: vi.fn<(filename: string) => Promise<{ preload: () => void }>>()
|
||||
}
|
||||
const objLoaderStub = {
|
||||
setWorkerUrl: vi.fn(),
|
||||
setMaterials: vi.fn(),
|
||||
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
|
||||
}
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/STLLoader', () => ({
|
||||
STLLoader: class {
|
||||
setPath = stlLoaderStub.setPath
|
||||
loadAsync = stlLoaderStub.loadAsync
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/FBXLoader', () => ({
|
||||
FBXLoader: class {
|
||||
setPath = fbxLoaderStub.setPath
|
||||
loadAsync = fbxLoaderStub.loadAsync
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/GLTFLoader', () => ({
|
||||
GLTFLoader: class {
|
||||
setPath = gltfLoaderStub.setPath
|
||||
loadAsync = gltfLoaderStub.loadAsync
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/MTLLoader', () => ({
|
||||
MTLLoader: class {
|
||||
setPath = mtlLoaderStub.setPath
|
||||
loadAsync = mtlLoaderStub.loadAsync
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('wwobjloader2', () => ({
|
||||
OBJLoader2Parallel: class {
|
||||
setWorkerUrl = objLoaderStub.setWorkerUrl
|
||||
setMaterials = objLoaderStub.setMaterials
|
||||
loadAsync = objLoaderStub.loadAsync
|
||||
},
|
||||
MtlObjBridge: {
|
||||
addMaterialsFromMtlLoader: vi.fn().mockReturnValue([])
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('wwobjloader2/bundle/worker/module?url', () => ({
|
||||
default: 'mock-worker-url'
|
||||
}))
|
||||
|
||||
function makeContext(
|
||||
materialMode: ModelLoadContext['materialMode'] = 'original'
|
||||
): ModelLoadContext {
|
||||
return {
|
||||
setOriginalModel: vi.fn(),
|
||||
registerOriginalMaterial: vi.fn(),
|
||||
standardMaterial: new THREE.MeshStandardMaterial(),
|
||||
materialMode
|
||||
}
|
||||
}
|
||||
|
||||
function makeFbxLikeGroup(): THREE.Group {
|
||||
const group = new THREE.Group()
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(),
|
||||
new THREE.MeshStandardMaterial()
|
||||
)
|
||||
group.add(mesh)
|
||||
return group
|
||||
}
|
||||
|
||||
describe('MeshModelAdapter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('identity', () => {
|
||||
it('identifies as a mesh adapter with full capabilities', () => {
|
||||
const adapter = new MeshModelAdapter()
|
||||
expect(adapter.kind).toBe('mesh')
|
||||
expect(adapter.capabilities.fitToViewer).toBe(true)
|
||||
expect(adapter.capabilities.requiresMaterialRebuild).toBe(false)
|
||||
expect(adapter.capabilities.gizmoTransform).toBe(true)
|
||||
expect(adapter.capabilities.lighting).toBe(true)
|
||||
expect(adapter.capabilities.exportable).toBe(true)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
})
|
||||
|
||||
it('handles the expected mesh extensions', () => {
|
||||
const adapter = new MeshModelAdapter()
|
||||
expect([...adapter.extensions]).toEqual([
|
||||
'stl',
|
||||
'fbx',
|
||||
'obj',
|
||||
'gltf',
|
||||
'glb'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatch fallbacks', () => {
|
||||
it('returns null when the filename extension belongs to another adapter', async () => {
|
||||
const adapter = new MeshModelAdapter()
|
||||
const result = await adapter.load(makeContext(), '/path/', 'cloud.ply')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an unknown extension', async () => {
|
||||
const adapter = new MeshModelAdapter()
|
||||
const result = await adapter.load(makeContext(), '/path/', 'data.xyz')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a filename without an extension', async () => {
|
||||
const adapter = new MeshModelAdapter()
|
||||
const result = await adapter.load(makeContext(), '/path/', 'noextension')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('STL loader path', () => {
|
||||
it('loads STL geometry and wraps it in a Group with a Mesh child', async () => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)
|
||||
)
|
||||
stlLoaderStub.loadAsync.mockResolvedValue(geometry)
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const ctx = makeContext()
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view/', 'model.stl')
|
||||
|
||||
expect(stlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
|
||||
expect(stlLoaderStub.loadAsync).toHaveBeenCalledWith('model.stl')
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(geometry)
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(result!.children[0]).toBeInstanceOf(THREE.Mesh)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FBX loader path', () => {
|
||||
it('loads an FBX model and registers its mesh materials', async () => {
|
||||
const fbxModel = makeFbxLikeGroup()
|
||||
fbxLoaderStub.loadAsync.mockResolvedValue(fbxModel)
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const ctx = makeContext()
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view/', 'rig.fbx')
|
||||
|
||||
expect(fbxLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
|
||||
expect(fbxLoaderStub.loadAsync).toHaveBeenCalledWith('rig.fbx')
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(fbxModel)
|
||||
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(fbxModel)
|
||||
})
|
||||
|
||||
it('disables frustum culling on SkinnedMesh children', async () => {
|
||||
const group = new THREE.Group()
|
||||
const skinned = new THREE.SkinnedMesh(
|
||||
new THREE.BoxGeometry(),
|
||||
new THREE.MeshStandardMaterial()
|
||||
)
|
||||
skinned.frustumCulled = true
|
||||
group.add(skinned)
|
||||
fbxLoaderStub.loadAsync.mockResolvedValue(group)
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
await adapter.load(makeContext(), '/api/view/', 'animated.fbx')
|
||||
|
||||
expect(skinned.frustumCulled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OBJ loader path', () => {
|
||||
it('attempts the MTL sidecar in original material mode', async () => {
|
||||
mtlLoaderStub.loadAsync.mockResolvedValue({ preload: vi.fn() })
|
||||
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
await adapter.load(makeContext('original'), '/api/view/', 'cube.obj')
|
||||
|
||||
expect(mtlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
|
||||
expect(mtlLoaderStub.loadAsync).toHaveBeenCalledWith('cube.mtl')
|
||||
expect(objLoaderStub.setMaterials).toHaveBeenCalled()
|
||||
expect(objLoaderStub.loadAsync).toHaveBeenCalledWith('/api/view/cube.obj')
|
||||
})
|
||||
|
||||
it('swallows MTL load errors and continues without materials', async () => {
|
||||
mtlLoaderStub.loadAsync.mockRejectedValue(new Error('no mtl'))
|
||||
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const result = await adapter.load(
|
||||
makeContext('original'),
|
||||
'/api/view/',
|
||||
'cube.obj'
|
||||
)
|
||||
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(objLoaderStub.setMaterials).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the MTL attempt for non-original material modes', async () => {
|
||||
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
await adapter.load(makeContext('wireframe'), '/api/view/', 'cube.obj')
|
||||
|
||||
expect(mtlLoaderStub.loadAsync).not.toHaveBeenCalled()
|
||||
expect(objLoaderStub.loadAsync).toHaveBeenCalledWith('/api/view/cube.obj')
|
||||
})
|
||||
|
||||
it('registers materials for each mesh child', async () => {
|
||||
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const ctx = makeContext('wireframe')
|
||||
await adapter.load(ctx, '/api/view/', 'cube.obj')
|
||||
|
||||
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GLTF loader path', () => {
|
||||
it('loads a .glb and returns the scene with vertex normals computed', async () => {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(),
|
||||
new THREE.MeshStandardMaterial()
|
||||
)
|
||||
const computeNormals = vi.spyOn(mesh.geometry, 'computeVertexNormals')
|
||||
const scene = new THREE.Group()
|
||||
scene.add(mesh)
|
||||
const gltf = { scene }
|
||||
gltfLoaderStub.loadAsync.mockResolvedValue(gltf)
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
const ctx = makeContext()
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view/', 'scene.glb')
|
||||
|
||||
expect(gltfLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
|
||||
expect(gltfLoaderStub.loadAsync).toHaveBeenCalledWith('scene.glb')
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(gltf)
|
||||
expect(computeNormals).toHaveBeenCalled()
|
||||
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(scene)
|
||||
})
|
||||
|
||||
it('also handles .gltf filenames', async () => {
|
||||
gltfLoaderStub.loadAsync.mockResolvedValue({ scene: new THREE.Group() })
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
await adapter.load(makeContext(), '/api/view/', 'scene.gltf')
|
||||
|
||||
expect(gltfLoaderStub.loadAsync).toHaveBeenCalledWith('scene.gltf')
|
||||
})
|
||||
|
||||
it('disables frustum culling on SkinnedMesh children inside the scene', async () => {
|
||||
const scene = new THREE.Group()
|
||||
const skinned = new THREE.SkinnedMesh(
|
||||
new THREE.BoxGeometry(),
|
||||
new THREE.MeshStandardMaterial()
|
||||
)
|
||||
skinned.frustumCulled = true
|
||||
scene.add(skinned)
|
||||
gltfLoaderStub.loadAsync.mockResolvedValue({ scene })
|
||||
|
||||
const adapter = new MeshModelAdapter()
|
||||
await adapter.load(makeContext(), '/api/view/', 'rigged.glb')
|
||||
|
||||
expect(skinned.frustumCulled).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
154
src/extensions/core/load3d/MeshModelAdapter.ts
Normal file
154
src/extensions/core/load3d/MeshModelAdapter.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import * as THREE from 'three'
|
||||
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
|
||||
// Use pre-bundled worker module (has all dependencies included).
|
||||
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds.
|
||||
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
|
||||
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
|
||||
export class MeshModelAdapter implements ModelAdapter {
|
||||
readonly kind = 'mesh' as const
|
||||
readonly extensions = ['stl', 'fbx', 'obj', 'gltf', 'glb'] as const
|
||||
readonly capabilities: ModelAdapterCapabilities = {
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe']
|
||||
}
|
||||
|
||||
private readonly gltfLoader = new GLTFLoader()
|
||||
private readonly objLoader: OBJLoader2Parallel
|
||||
private readonly mtlLoader = new MTLLoader()
|
||||
private readonly fbxLoader = new FBXLoader()
|
||||
private readonly stlLoader = new STLLoader()
|
||||
|
||||
constructor() {
|
||||
this.objLoader = new OBJLoader2Parallel()
|
||||
this.objLoader.setWorkerUrl(
|
||||
true,
|
||||
new URL(OBJLoader2WorkerUrl, import.meta.url)
|
||||
)
|
||||
}
|
||||
|
||||
async load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null> {
|
||||
const extension = filename.split('.').pop()?.toLowerCase()
|
||||
switch (extension) {
|
||||
case 'stl':
|
||||
return this.loadSTL(ctx, path, filename)
|
||||
case 'fbx':
|
||||
return this.loadFBX(ctx, path, filename)
|
||||
case 'obj':
|
||||
return this.loadOBJ(ctx, path, filename)
|
||||
case 'gltf':
|
||||
case 'glb':
|
||||
return this.loadGLTF(ctx, path, filename)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async loadSTL(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
this.stlLoader.setPath(path)
|
||||
const geometry = await this.stlLoader.loadAsync(filename)
|
||||
ctx.setOriginalModel(geometry)
|
||||
geometry.computeVertexNormals()
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, ctx.standardMaterial)
|
||||
const group = new THREE.Group()
|
||||
group.add(mesh)
|
||||
return group
|
||||
}
|
||||
|
||||
private async loadFBX(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
this.fbxLoader.setPath(path)
|
||||
const fbxModel = await this.fbxLoader.loadAsync(filename)
|
||||
ctx.setOriginalModel(fbxModel)
|
||||
|
||||
fbxModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
ctx.registerOriginalMaterial(child, child.material)
|
||||
if (child instanceof THREE.SkinnedMesh) {
|
||||
child.frustumCulled = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return fbxModel
|
||||
}
|
||||
|
||||
private async loadOBJ(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
if (ctx.materialMode === 'original') {
|
||||
try {
|
||||
this.mtlLoader.setPath(path)
|
||||
const mtlFileName = filename.replace(/\.obj$/, '.mtl')
|
||||
const materials = await this.mtlLoader.loadAsync(mtlFileName)
|
||||
materials.preload()
|
||||
const materialsFromMtl =
|
||||
MtlObjBridge.addMaterialsFromMtlLoader(materials)
|
||||
this.objLoader.setMaterials(materialsFromMtl)
|
||||
} catch {
|
||||
console.log(
|
||||
'No MTL file found or error loading it, continuing without materials'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const objUrl = path + encodeURIComponent(filename)
|
||||
const model = await this.objLoader.loadAsync(objUrl)
|
||||
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
ctx.registerOriginalMaterial(child, child.material)
|
||||
}
|
||||
})
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
private async loadGLTF(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
this.gltfLoader.setPath(path)
|
||||
const gltf = await this.gltfLoader.loadAsync(filename)
|
||||
ctx.setOriginalModel(gltf)
|
||||
|
||||
gltf.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry.computeVertexNormals()
|
||||
ctx.registerOriginalMaterial(child, child.material)
|
||||
if (child instanceof THREE.SkinnedMesh) {
|
||||
child.frustumCulled = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return gltf.scene
|
||||
}
|
||||
}
|
||||
89
src/extensions/core/load3d/ModelAdapter.test.ts
Normal file
89
src/extensions/core/load3d/ModelAdapter.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { DEFAULT_MODEL_CAPABILITIES, fetchModelData } from './ModelAdapter'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('DEFAULT_MODEL_CAPABILITIES', () => {
|
||||
it('enables fit-to-viewer / gizmo / lighting / export by default', () => {
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.fitToViewer).toBe(true)
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.requiresMaterialRebuild).toBe(false)
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.gizmoTransform).toBe(true)
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.lighting).toBe(true)
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.exportable).toBe(true)
|
||||
expect([...DEFAULT_MODEL_CAPABILITIES.materialModes]).toEqual([
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchModelData', () => {
|
||||
const mockFetchApi = vi.mocked(api.fetchApi)
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetchApi.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns the arrayBuffer on a successful response', async () => {
|
||||
const buf = new ArrayBuffer(8)
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(buf)
|
||||
} as unknown as Response)
|
||||
|
||||
const result = await fetchModelData('api/view?...&filename=', 'model.glb')
|
||||
|
||||
expect(result).toBe(buf)
|
||||
})
|
||||
|
||||
it('throws with status code when the response is not ok', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(
|
||||
fetchModelData('api/view?type=input&subfolder=&filename=', 'missing.glb')
|
||||
).rejects.toThrow('Failed to fetch model: 404')
|
||||
})
|
||||
|
||||
it('strips the leading api/ prefix and encodes the filename', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0))
|
||||
} as unknown as Response)
|
||||
|
||||
await fetchModelData(
|
||||
'api/view?type=input&subfolder=&filename=',
|
||||
'a b c.ply'
|
||||
)
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(
|
||||
'/view?type=input&subfolder=&filename=a%20b%20c.ply'
|
||||
)
|
||||
})
|
||||
|
||||
it('prepends a single slash when the path has no api/ prefix', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0))
|
||||
} as unknown as Response)
|
||||
|
||||
await fetchModelData('custom?filename=', 'scene.splat')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/custom?filename=scene.splat')
|
||||
})
|
||||
})
|
||||
81
src/extensions/core/load3d/ModelAdapter.ts
Normal file
81
src/extensions/core/load3d/ModelAdapter.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type * as THREE from 'three'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import type { MaterialMode } from './interfaces'
|
||||
|
||||
export interface ModelLoadContext {
|
||||
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void
|
||||
registerOriginalMaterial(
|
||||
mesh: THREE.Mesh,
|
||||
material: THREE.Material | THREE.Material[]
|
||||
): void
|
||||
readonly standardMaterial: THREE.MeshStandardMaterial
|
||||
readonly materialMode: MaterialMode
|
||||
}
|
||||
|
||||
export type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
|
||||
|
||||
export interface ModelAdapterCapabilities {
|
||||
/**
|
||||
* Whether auto-normalize/centering on load and the explicit fit-to-viewer
|
||||
* action should run. Splats render self-sized and are placed at a fixed
|
||||
* camera distance instead.
|
||||
*/
|
||||
fitToViewer: boolean
|
||||
/**
|
||||
* Whether a material mode change must rebuild the scene object instead of
|
||||
* traversing the existing mesh tree. True for point-cloud PLY (Mesh <->
|
||||
* Points swap); false for regular meshes and self-rendering splats.
|
||||
*/
|
||||
requiresMaterialRebuild: boolean
|
||||
/**
|
||||
* Whether the gizmo transform UI (translate/rotate/scale) should be
|
||||
* exposed for this model type. False for formats whose renderer ignores
|
||||
* scene-graph transforms (Gaussian splat) or where transforming the
|
||||
* already-normalized output produces no useful result (PLY point cloud).
|
||||
*/
|
||||
gizmoTransform: boolean
|
||||
/** Whether scene-lighting controls apply. False for self-lit formats. */
|
||||
lighting: boolean
|
||||
/** Whether the model can be exported (GLB/OBJ/STL). */
|
||||
exportable: boolean
|
||||
/**
|
||||
* Material modes offered in the UI for this format. An empty array hides
|
||||
* the material-mode dropdown entirely.
|
||||
*/
|
||||
materialModes: readonly MaterialMode[]
|
||||
}
|
||||
|
||||
export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe']
|
||||
}
|
||||
|
||||
export interface ModelAdapter {
|
||||
readonly kind: ModelAdapterKind
|
||||
readonly extensions: readonly string[]
|
||||
readonly capabilities: ModelAdapterCapabilities
|
||||
load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null>
|
||||
}
|
||||
|
||||
export async function fetchModelData(
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<ArrayBuffer> {
|
||||
const route = '/' + path.replace(/^api\//, '') + encodeURIComponent(filename)
|
||||
const response = await api.fetchApi(route)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch model: ${response.status}`)
|
||||
}
|
||||
return response.arrayBuffer()
|
||||
}
|
||||
205
src/extensions/core/load3d/PointCloudModelAdapter.test.ts
Normal file
205
src/extensions/core/load3d/PointCloudModelAdapter.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ModelLoadContext } from './ModelAdapter'
|
||||
import * as ModelAdapterModule from './ModelAdapter'
|
||||
import {
|
||||
PointCloudModelAdapter,
|
||||
buildPointCloudForMaterialMode
|
||||
} from './PointCloudModelAdapter'
|
||||
|
||||
const mockSettingGet = vi.fn<(key: string) => unknown>()
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: mockSettingGet })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/metadata/ply', () => ({
|
||||
isPLYAsciiFormat: vi.fn().mockReturnValue(false)
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/PLYLoader', () => ({
|
||||
PLYLoader: class {
|
||||
setPath = vi.fn()
|
||||
parse = vi.fn(() => makePLYGeometry(false))
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./loader/FastPLYLoader', () => ({
|
||||
FastPLYLoader: class {
|
||||
parse = vi.fn(() => makePLYGeometry(false))
|
||||
}
|
||||
}))
|
||||
|
||||
function makePLYGeometry(withColors: boolean): THREE.BufferGeometry {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)
|
||||
)
|
||||
if (withColors) {
|
||||
geometry.setAttribute(
|
||||
'color',
|
||||
new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3)
|
||||
)
|
||||
}
|
||||
return geometry
|
||||
}
|
||||
|
||||
function makeContext(
|
||||
materialMode: ModelLoadContext['materialMode'] = 'original'
|
||||
): ModelLoadContext {
|
||||
return {
|
||||
setOriginalModel: vi.fn(),
|
||||
registerOriginalMaterial: vi.fn(),
|
||||
standardMaterial: new THREE.MeshStandardMaterial(),
|
||||
materialMode
|
||||
}
|
||||
}
|
||||
|
||||
describe('PointCloudModelAdapter', () => {
|
||||
beforeEach(() => {
|
||||
mockSettingGet.mockReset()
|
||||
})
|
||||
|
||||
describe('identity', () => {
|
||||
it('handles the ply extension', () => {
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
expect([...adapter.extensions]).toEqual(['ply'])
|
||||
})
|
||||
|
||||
it('identifies as pointCloud with rebuild + gizmo/fit disabled', () => {
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
expect(adapter.kind).toBe('pointCloud')
|
||||
expect(adapter.capabilities.fitToViewer).toBe(false)
|
||||
expect(adapter.capabilities.requiresMaterialRebuild).toBe(true)
|
||||
expect(adapter.capabilities.gizmoTransform).toBe(false)
|
||||
expect(adapter.capabilities.lighting).toBe(true)
|
||||
expect(adapter.capabilities.exportable).toBe(true)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([
|
||||
'original',
|
||||
'pointCloud',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('load', () => {
|
||||
beforeEach(() => {
|
||||
mockSettingGet.mockReturnValue('three')
|
||||
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(
|
||||
new ArrayBuffer(0)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a Group containing a Mesh for non-pointCloud modes', async () => {
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
const ctx = makeContext('original')
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
|
||||
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
const child = result!.children[0]
|
||||
expect(child).toBeInstanceOf(THREE.Mesh)
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns a Group containing Points when materialMode is pointCloud', async () => {
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
const ctx = makeContext('pointCloud')
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
|
||||
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
const child = result!.children[0]
|
||||
expect(child).toBeInstanceOf(THREE.Points)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPointCloudForMaterialMode', () => {
|
||||
function run(mode: Parameters<typeof buildPointCloudForMaterialMode>[1]) {
|
||||
const geometry = makePLYGeometry(false)
|
||||
const standardMaterial = new THREE.MeshStandardMaterial()
|
||||
const originalMaterials = new WeakMap<
|
||||
THREE.Mesh,
|
||||
THREE.Material | THREE.Material[]
|
||||
>()
|
||||
const group = buildPointCloudForMaterialMode(
|
||||
geometry,
|
||||
mode,
|
||||
standardMaterial,
|
||||
originalMaterials
|
||||
)
|
||||
return { group, originalMaterials }
|
||||
}
|
||||
|
||||
it('produces a Group with Points when mode is pointCloud', () => {
|
||||
const { group } = run('pointCloud')
|
||||
expect(group).toBeInstanceOf(THREE.Group)
|
||||
expect(group.children[0]).toBeInstanceOf(THREE.Points)
|
||||
})
|
||||
|
||||
it('produces a Mesh with MeshStandardMaterial for original mode', () => {
|
||||
const { group } = run('original')
|
||||
const mesh = group.children[0] as THREE.Mesh
|
||||
expect(mesh).toBeInstanceOf(THREE.Mesh)
|
||||
expect(mesh.material).toBeInstanceOf(THREE.MeshStandardMaterial)
|
||||
})
|
||||
|
||||
it('overrides the mesh material with MeshNormalMaterial for normal mode', () => {
|
||||
const { group } = run('normal')
|
||||
const mesh = group.children[0] as THREE.Mesh
|
||||
expect(mesh.material).toBeInstanceOf(THREE.MeshNormalMaterial)
|
||||
})
|
||||
|
||||
it('overrides the mesh material with wireframe MeshBasicMaterial', () => {
|
||||
const { group } = run('wireframe')
|
||||
const mesh = group.children[0] as THREE.Mesh
|
||||
expect(mesh.material).toBeInstanceOf(THREE.MeshBasicMaterial)
|
||||
expect((mesh.material as THREE.MeshBasicMaterial).wireframe).toBe(true)
|
||||
})
|
||||
|
||||
it('registers the mesh and its material in the originalMaterials WeakMap', () => {
|
||||
const { group, originalMaterials } = run('original')
|
||||
const mesh = group.children[0] as THREE.Mesh
|
||||
expect(originalMaterials.has(mesh)).toBe(true)
|
||||
expect(originalMaterials.get(mesh)).toBe(mesh.material)
|
||||
})
|
||||
|
||||
it('clones the input geometry instead of mutating it', () => {
|
||||
const geometry = makePLYGeometry(false)
|
||||
const standardMaterial = new THREE.MeshStandardMaterial()
|
||||
const originalMaterials = new WeakMap<
|
||||
THREE.Mesh,
|
||||
THREE.Material | THREE.Material[]
|
||||
>()
|
||||
const group = buildPointCloudForMaterialMode(
|
||||
geometry,
|
||||
'pointCloud',
|
||||
standardMaterial,
|
||||
originalMaterials
|
||||
)
|
||||
const points = group.children[0] as THREE.Points
|
||||
// pointCloud mode normalises the clone via translate+scale; the input
|
||||
// geometry must stay untouched.
|
||||
expect(points.geometry).not.toBe(geometry)
|
||||
})
|
||||
|
||||
it('uses vertex colors when the geometry has a color attribute', () => {
|
||||
const geometry = makePLYGeometry(true)
|
||||
const originalMaterials = new WeakMap<
|
||||
THREE.Mesh,
|
||||
THREE.Material | THREE.Material[]
|
||||
>()
|
||||
const group = buildPointCloudForMaterialMode(
|
||||
geometry,
|
||||
'pointCloud',
|
||||
new THREE.MeshStandardMaterial(),
|
||||
originalMaterials
|
||||
)
|
||||
const points = group.children[0] as THREE.Points
|
||||
expect((points.material as THREE.PointsMaterial).vertexColors).toBe(true)
|
||||
})
|
||||
})
|
||||
160
src/extensions/core/load3d/PointCloudModelAdapter.ts
Normal file
160
src/extensions/core/load3d/PointCloudModelAdapter.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import * as THREE from 'three'
|
||||
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { isPLYAsciiFormat } from '@/scripts/metadata/ply'
|
||||
|
||||
import {
|
||||
fetchModelData,
|
||||
type ModelAdapter,
|
||||
type ModelAdapterCapabilities,
|
||||
type ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
import type { MaterialMode } from './interfaces'
|
||||
import { FastPLYLoader } from './loader/FastPLYLoader'
|
||||
|
||||
export function getPLYEngine(): string {
|
||||
return useSettingStore().get('Comfy.Load3D.PLYEngine') as string
|
||||
}
|
||||
|
||||
export class PointCloudModelAdapter implements ModelAdapter {
|
||||
readonly kind = 'pointCloud' as const
|
||||
readonly extensions = ['ply'] as const
|
||||
readonly capabilities: ModelAdapterCapabilities = {
|
||||
fitToViewer: false,
|
||||
requiresMaterialRebuild: true,
|
||||
gizmoTransform: false,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'pointCloud', 'normal', 'wireframe']
|
||||
}
|
||||
|
||||
private readonly plyLoader = new PLYLoader()
|
||||
private readonly fastPlyLoader = new FastPLYLoader()
|
||||
|
||||
async load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null> {
|
||||
const arrayBuffer = await fetchModelData(path, filename)
|
||||
const isASCII = isPLYAsciiFormat(arrayBuffer)
|
||||
|
||||
const plyGeometry =
|
||||
isASCII && getPLYEngine() === 'fastply'
|
||||
? this.fastPlyLoader.parse(arrayBuffer)
|
||||
: (this.plyLoader.setPath(path), this.plyLoader.parse(arrayBuffer))
|
||||
|
||||
ctx.setOriginalModel(plyGeometry)
|
||||
plyGeometry.computeVertexNormals()
|
||||
|
||||
const hasVertexColors = plyGeometry.attributes.color !== undefined
|
||||
|
||||
if (ctx.materialMode === 'pointCloud') {
|
||||
return buildPointsGroup(ctx, plyGeometry, hasVertexColors)
|
||||
}
|
||||
|
||||
return buildMeshGroup(ctx, plyGeometry, hasVertexColors)
|
||||
}
|
||||
}
|
||||
|
||||
function buildPointsGroup(
|
||||
ctx: ModelLoadContext,
|
||||
geometry: THREE.BufferGeometry,
|
||||
hasVertexColors: boolean
|
||||
): THREE.Group {
|
||||
geometry.computeBoundingSphere()
|
||||
if (geometry.boundingSphere) {
|
||||
const { center, radius } = geometry.boundingSphere
|
||||
geometry.translate(-center.x, -center.y, -center.z)
|
||||
if (radius > 0) {
|
||||
const scale = 1.0 / radius
|
||||
geometry.scale(scale, scale, scale)
|
||||
}
|
||||
}
|
||||
|
||||
const pointMaterial = hasVertexColors
|
||||
? new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
: new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
color: 0xcccccc,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
|
||||
const points = new THREE.Points(geometry, pointMaterial)
|
||||
ctx.registerOriginalMaterial(points as unknown as THREE.Mesh, pointMaterial)
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(points)
|
||||
return group
|
||||
}
|
||||
|
||||
function buildMeshGroup(
|
||||
ctx: ModelLoadContext,
|
||||
geometry: THREE.BufferGeometry,
|
||||
hasVertexColors: boolean
|
||||
): THREE.Group {
|
||||
const material = hasVertexColors
|
||||
? new THREE.MeshStandardMaterial({
|
||||
vertexColors: true,
|
||||
metalness: 0.0,
|
||||
roughness: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
: ctx.standardMaterial.clone()
|
||||
|
||||
if (!hasVertexColors && material instanceof THREE.MeshStandardMaterial) {
|
||||
material.side = THREE.DoubleSide
|
||||
}
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
ctx.registerOriginalMaterial(mesh, material)
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(mesh)
|
||||
return group
|
||||
}
|
||||
|
||||
export function buildPointCloudForMaterialMode(
|
||||
originalGeometry: THREE.BufferGeometry,
|
||||
mode: MaterialMode,
|
||||
standardMaterial: THREE.MeshStandardMaterial,
|
||||
originalMaterials: WeakMap<THREE.Mesh, THREE.Material | THREE.Material[]>
|
||||
): THREE.Group {
|
||||
const geometry = originalGeometry.clone()
|
||||
const hasVertexColors = geometry.attributes.color !== undefined
|
||||
|
||||
const ctx: ModelLoadContext = {
|
||||
setOriginalModel: () => {},
|
||||
registerOriginalMaterial: (mesh, material) =>
|
||||
originalMaterials.set(mesh, material),
|
||||
standardMaterial,
|
||||
materialMode: mode
|
||||
}
|
||||
|
||||
if (mode === 'pointCloud') {
|
||||
return buildPointsGroup(ctx, geometry, hasVertexColors)
|
||||
}
|
||||
|
||||
const group = buildMeshGroup(ctx, geometry, hasVertexColors)
|
||||
|
||||
if (mode === 'normal' || mode === 'wireframe') {
|
||||
const mesh = group.children[0] as THREE.Mesh
|
||||
mesh.material =
|
||||
mode === 'normal'
|
||||
? new THREE.MeshNormalMaterial({
|
||||
flatShading: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
: new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
wireframe: true
|
||||
})
|
||||
}
|
||||
|
||||
return group
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import * as THREE from 'three'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
import {
|
||||
DEFAULT_MODEL_CAPABILITIES,
|
||||
type ModelAdapterCapabilities
|
||||
} from './ModelAdapter'
|
||||
import { SceneModelManager } from './SceneModelManager'
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
|
||||
function createMockRenderer(): THREE.WebGLRenderer {
|
||||
return {
|
||||
@@ -23,6 +27,7 @@ function createManager(
|
||||
overrides: {
|
||||
scene?: THREE.Scene
|
||||
eventManager?: EventManagerInterface
|
||||
capabilities?: ModelAdapterCapabilities
|
||||
} = {}
|
||||
) {
|
||||
const scene = overrides.scene ?? new THREE.Scene()
|
||||
@@ -32,6 +37,7 @@ function createManager(
|
||||
const getActiveCamera = () => camera
|
||||
const setupCamera = vi.fn()
|
||||
const setupGizmo = vi.fn()
|
||||
const capabilities = overrides.capabilities ?? DEFAULT_MODEL_CAPABILITIES
|
||||
|
||||
const manager = new SceneModelManager(
|
||||
scene,
|
||||
@@ -39,7 +45,8 @@ function createManager(
|
||||
eventManager,
|
||||
getActiveCamera,
|
||||
setupCamera,
|
||||
setupGizmo
|
||||
setupGizmo,
|
||||
() => capabilities
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -386,8 +393,17 @@ describe('SceneModelManager', () => {
|
||||
expect(renderer.outputColorSpace).toBe(THREE.SRGBColorSpace)
|
||||
})
|
||||
|
||||
it('delegates to handlePLYModeSwitch for BufferGeometry original model', async () => {
|
||||
const { manager, eventManager } = createManager()
|
||||
it('rebuilds the scene object when capability requiresMaterialRebuild is set', async () => {
|
||||
const { manager, eventManager } = createManager({
|
||||
capabilities: {
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: true,
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe']
|
||||
}
|
||||
})
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
@@ -575,29 +591,18 @@ describe('SceneModelManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('containsSplatMesh', () => {
|
||||
it('returns false when no model', () => {
|
||||
const { manager } = createManager()
|
||||
expect(manager.containsSplatMesh()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for regular model', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
expect(manager.containsSplatMesh()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for explicit null argument', () => {
|
||||
const { manager } = createManager()
|
||||
expect(manager.containsSplatMesh(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PLY mode switching', () => {
|
||||
const PLY_CAPABILITIES: ModelAdapterCapabilities = {
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: true,
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'pointCloud', 'normal', 'wireframe']
|
||||
}
|
||||
|
||||
function createPLYManager() {
|
||||
const ctx = createManager()
|
||||
const ctx = createManager({ capabilities: PLY_CAPABILITIES })
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
@@ -655,7 +660,9 @@ describe('SceneModelManager', () => {
|
||||
})
|
||||
|
||||
it('uses vertex colors when available', () => {
|
||||
const { manager, scene } = createManager()
|
||||
const { manager, scene } = createManager({
|
||||
capabilities: PLY_CAPABILITIES
|
||||
})
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { SplatMesh } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
import { type GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
|
||||
import {
|
||||
DEFAULT_MODEL_CAPABILITIES,
|
||||
type ModelAdapterCapabilities
|
||||
} from './ModelAdapter'
|
||||
import { buildPointCloudForMaterialMode } from './PointCloudModelAdapter'
|
||||
import {
|
||||
type EventManagerInterface,
|
||||
type MaterialMode,
|
||||
@@ -39,6 +43,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
private activeCamera: THREE.Camera
|
||||
private setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void
|
||||
private setupGizmo: (model: THREE.Object3D) => void
|
||||
private getCurrentCapabilities: () => ModelAdapterCapabilities
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
@@ -46,7 +51,9 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
eventManager: EventManagerInterface,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void,
|
||||
setupGizmo: (model: THREE.Object3D) => void
|
||||
setupGizmo: (model: THREE.Object3D) => void,
|
||||
getCurrentCapabilities: () => ModelAdapterCapabilities = () =>
|
||||
DEFAULT_MODEL_CAPABILITIES
|
||||
) {
|
||||
this.scene = scene
|
||||
this.renderer = renderer
|
||||
@@ -55,6 +62,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.setupCamera = setupCamera
|
||||
this.textureLoader = new THREE.TextureLoader()
|
||||
this.setupGizmo = setupGizmo
|
||||
this.getCurrentCapabilities = getCurrentCapabilities
|
||||
|
||||
this.normalMaterial = new THREE.MeshNormalMaterial({
|
||||
flatShading: false,
|
||||
@@ -104,23 +112,11 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
})
|
||||
}
|
||||
|
||||
private handlePLYModeSwitch(mode: MaterialMode): void {
|
||||
if (!(this.originalModel instanceof THREE.BufferGeometry)) {
|
||||
return
|
||||
}
|
||||
|
||||
const plyGeometry = this.originalModel.clone()
|
||||
const hasVertexColors = plyGeometry.attributes.color !== undefined
|
||||
|
||||
// Find and remove ALL MainModel instances by name to ensure deletion
|
||||
private removeAllMainModelsFromScene(): void {
|
||||
const oldMainModels: THREE.Object3D[] = []
|
||||
this.scene.traverse((obj) => {
|
||||
if (obj.name === 'MainModel') {
|
||||
oldMainModels.push(obj)
|
||||
}
|
||||
if (obj.name === 'MainModel') oldMainModels.push(obj)
|
||||
})
|
||||
|
||||
// Remove and dispose all found MainModels
|
||||
oldMainModels.forEach((oldModel) => {
|
||||
oldModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh || child instanceof THREE.Points) {
|
||||
@@ -134,99 +130,26 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
})
|
||||
this.scene.remove(oldModel)
|
||||
})
|
||||
}
|
||||
|
||||
private rebuildForMaterialMode(mode: MaterialMode): void {
|
||||
if (!(this.originalModel instanceof THREE.BufferGeometry)) return
|
||||
|
||||
this.removeAllMainModelsFromScene()
|
||||
this.currentModel = null
|
||||
|
||||
let newModel: THREE.Object3D
|
||||
|
||||
if (mode === 'pointCloud') {
|
||||
// Use Points rendering for point cloud mode
|
||||
plyGeometry.computeBoundingSphere()
|
||||
if (plyGeometry.boundingSphere) {
|
||||
const center = plyGeometry.boundingSphere.center
|
||||
const radius = plyGeometry.boundingSphere.radius
|
||||
|
||||
plyGeometry.translate(-center.x, -center.y, -center.z)
|
||||
|
||||
if (radius > 0) {
|
||||
const scale = 1.0 / radius
|
||||
plyGeometry.scale(scale, scale, scale)
|
||||
}
|
||||
}
|
||||
|
||||
const pointMaterial = hasVertexColors
|
||||
? new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
: new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
color: 0xcccccc,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
|
||||
const points = new THREE.Points(plyGeometry, pointMaterial)
|
||||
newModel = new THREE.Group()
|
||||
newModel.add(points)
|
||||
} else {
|
||||
// Use Mesh rendering for other modes
|
||||
let meshMaterial: THREE.Material = hasVertexColors
|
||||
? new THREE.MeshStandardMaterial({
|
||||
vertexColors: true,
|
||||
metalness: 0.0,
|
||||
roughness: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
: this.standardMaterial.clone()
|
||||
|
||||
if (
|
||||
!hasVertexColors &&
|
||||
meshMaterial instanceof THREE.MeshStandardMaterial
|
||||
) {
|
||||
meshMaterial.side = THREE.DoubleSide
|
||||
}
|
||||
|
||||
const mesh = new THREE.Mesh(plyGeometry, meshMaterial)
|
||||
this.originalMaterials.set(mesh, meshMaterial)
|
||||
|
||||
newModel = new THREE.Group()
|
||||
newModel.add(mesh)
|
||||
|
||||
// Apply the requested material mode
|
||||
if (mode === 'normal') {
|
||||
mesh.material = new THREE.MeshNormalMaterial({
|
||||
flatShading: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
} else if (mode === 'wireframe') {
|
||||
mesh.material = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
wireframe: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Double check: remove any remaining MainModel before adding new one
|
||||
const remainingMainModels: THREE.Object3D[] = []
|
||||
this.scene.traverse((obj) => {
|
||||
if (obj.name === 'MainModel') {
|
||||
remainingMainModels.push(obj)
|
||||
}
|
||||
})
|
||||
remainingMainModels.forEach((obj) => this.scene.remove(obj))
|
||||
|
||||
this.currentModel = newModel
|
||||
const newModel = buildPointCloudForMaterialMode(
|
||||
this.originalModel,
|
||||
mode,
|
||||
this.standardMaterial,
|
||||
this.originalMaterials
|
||||
)
|
||||
newModel.name = 'MainModel'
|
||||
|
||||
// Setup the new model
|
||||
if (mode === 'pointCloud') {
|
||||
this.scene.add(newModel)
|
||||
} else {
|
||||
if (mode !== 'pointCloud') {
|
||||
const box = new THREE.Box3().setFromObject(newModel)
|
||||
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
|
||||
@@ -237,9 +160,10 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
box.getSize(size)
|
||||
|
||||
newModel.position.set(-center.x, -box.min.y, -center.z)
|
||||
this.scene.add(newModel)
|
||||
}
|
||||
|
||||
this.scene.add(newModel)
|
||||
this.currentModel = newModel
|
||||
this.eventManager.emitEvent('materialModeChange', mode)
|
||||
}
|
||||
|
||||
@@ -250,9 +174,8 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
|
||||
this.materialMode = mode
|
||||
|
||||
// Handle PLY files specially - they need to be recreated for mode switch
|
||||
if (this.originalModel instanceof THREE.BufferGeometry) {
|
||||
this.handlePLYModeSwitch(mode)
|
||||
if (this.getCurrentCapabilities().requiresMaterialRebuild) {
|
||||
this.rebuildForMaterialMode(mode)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -492,13 +415,10 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.currentModel = model
|
||||
model.name = 'MainModel'
|
||||
|
||||
// Check if model is or contains a SplatMesh (3D Gaussian Splatting)
|
||||
const isSplatModel = this.containsSplatMesh(model)
|
||||
|
||||
if (isSplatModel) {
|
||||
// SplatMesh handles its own rendering, just add to scene
|
||||
if (!this.getCurrentCapabilities().fitToViewer) {
|
||||
// Models like Gaussian splats render self-sized; skip auto-normalize
|
||||
// and place the camera at a fixed distance instead.
|
||||
this.scene.add(model)
|
||||
// Set a default camera distance for splat models
|
||||
this.setupCamera(new THREE.Vector3(5, 5, 5), new THREE.Vector3(0, 2.5, 0))
|
||||
return
|
||||
}
|
||||
@@ -524,7 +444,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
}
|
||||
|
||||
fitToViewer(): void {
|
||||
if (!this.currentModel || this.containsSplatMesh()) return
|
||||
if (!this.currentModel || !this.getCurrentCapabilities().fitToViewer) return
|
||||
const model = this.currentModel
|
||||
|
||||
// Reset transform to compute from raw geometry (idempotent)
|
||||
@@ -557,17 +477,6 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.setupGizmo(model)
|
||||
}
|
||||
|
||||
containsSplatMesh(model?: THREE.Object3D | null): boolean {
|
||||
const target = model ?? this.currentModel
|
||||
if (!target) return false
|
||||
if (target instanceof SplatMesh) return true
|
||||
let found = false
|
||||
target.traverse((child) => {
|
||||
if (child instanceof SplatMesh) found = true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void {
|
||||
this.originalModel = model
|
||||
}
|
||||
|
||||
80
src/extensions/core/load3d/SplatModelAdapter.test.ts
Normal file
80
src/extensions/core/load3d/SplatModelAdapter.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ModelLoadContext } from './ModelAdapter'
|
||||
import * as ModelAdapterModule from './ModelAdapter'
|
||||
import { SplatModelAdapter } from './SplatModelAdapter'
|
||||
|
||||
const splatMeshCtor = vi.fn()
|
||||
|
||||
vi.mock('@sparkjsdev/spark', () => ({
|
||||
SplatMesh: class extends THREE.Object3D {
|
||||
constructor(opts: { fileBytes: ArrayBuffer }) {
|
||||
super()
|
||||
splatMeshCtor(opts)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
function makeContext(): ModelLoadContext {
|
||||
return {
|
||||
setOriginalModel: vi.fn(),
|
||||
registerOriginalMaterial: vi.fn(),
|
||||
standardMaterial: new THREE.MeshStandardMaterial(),
|
||||
materialMode: 'original'
|
||||
}
|
||||
}
|
||||
|
||||
describe('SplatModelAdapter', () => {
|
||||
beforeEach(() => {
|
||||
splatMeshCtor.mockReset()
|
||||
})
|
||||
|
||||
it('identifies as a splat adapter with every non-scene capability disabled', () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
expect(adapter.kind).toBe('splat')
|
||||
expect(adapter.capabilities.fitToViewer).toBe(false)
|
||||
expect(adapter.capabilities.requiresMaterialRebuild).toBe(false)
|
||||
expect(adapter.capabilities.gizmoTransform).toBe(false)
|
||||
expect(adapter.capabilities.lighting).toBe(false)
|
||||
expect(adapter.capabilities.exportable).toBe(false)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([])
|
||||
})
|
||||
|
||||
it('handles the Gaussian splat extensions', () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
expect([...adapter.extensions]).toEqual(['spz', 'splat', 'ksplat'])
|
||||
})
|
||||
|
||||
it('fetches the file, builds a SplatMesh, and wraps it in a Group', async () => {
|
||||
const buf = new ArrayBuffer(128)
|
||||
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(buf)
|
||||
|
||||
const adapter = new SplatModelAdapter()
|
||||
const ctx = makeContext()
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view?', 'scene.splat')
|
||||
|
||||
expect(ModelAdapterModule.fetchModelData).toHaveBeenCalledWith(
|
||||
'/api/view?',
|
||||
'scene.splat'
|
||||
)
|
||||
expect(splatMeshCtor).toHaveBeenCalledWith({ fileBytes: buf })
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(result.children).toHaveLength(1)
|
||||
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(result.children[0])
|
||||
})
|
||||
|
||||
it('propagates fetch errors', async () => {
|
||||
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockRejectedValue(
|
||||
new Error('Failed to fetch model: 500')
|
||||
)
|
||||
|
||||
const adapter = new SplatModelAdapter()
|
||||
await expect(
|
||||
adapter.load(makeContext(), '/api/view?', 'scene.splat')
|
||||
).rejects.toThrow('Failed to fetch model: 500')
|
||||
})
|
||||
})
|
||||
37
src/extensions/core/load3d/SplatModelAdapter.ts
Normal file
37
src/extensions/core/load3d/SplatModelAdapter.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { SplatMesh } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
|
||||
import {
|
||||
fetchModelData,
|
||||
type ModelAdapter,
|
||||
type ModelAdapterCapabilities,
|
||||
type ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
|
||||
export class SplatModelAdapter implements ModelAdapter {
|
||||
readonly kind = 'splat' as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat'] as const
|
||||
readonly capabilities: ModelAdapterCapabilities = {
|
||||
fitToViewer: false,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: false,
|
||||
lighting: false,
|
||||
exportable: false,
|
||||
materialModes: []
|
||||
}
|
||||
|
||||
async load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
const arrayBuffer = await fetchModelData(path, filename)
|
||||
|
||||
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
|
||||
ctx.setOriginalModel(splatMesh)
|
||||
|
||||
const splatGroup = new THREE.Group()
|
||||
splatGroup.add(splatMesh)
|
||||
return splatGroup
|
||||
}
|
||||
}
|
||||
269
src/extensions/core/load3d/__test__/managerStubs.ts
Normal file
269
src/extensions/core/load3d/__test__/managerStubs.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import * as THREE from 'three'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
import type { ModelAdapterCapabilities } from '@/extensions/core/load3d/ModelAdapter'
|
||||
import type {
|
||||
CameraState,
|
||||
CameraType,
|
||||
GizmoMode,
|
||||
MaterialMode
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
export function makeGizmoStub() {
|
||||
return {
|
||||
setEnabled: vi.fn(),
|
||||
setMode: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
applyTransform: vi.fn(),
|
||||
getTransform: vi.fn(() => ({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
})),
|
||||
setupForModel: vi.fn(),
|
||||
updateCamera: vi.fn(),
|
||||
detach: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
removeFromScene: vi.fn(),
|
||||
ensureHelperInScene: vi.fn(),
|
||||
isEnabled: vi.fn(() => false),
|
||||
getMode: vi.fn(() => 'translate' as GizmoMode)
|
||||
}
|
||||
}
|
||||
|
||||
export function makeSceneManagerStub() {
|
||||
return {
|
||||
captureScene: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
setBackgroundColor: vi.fn(),
|
||||
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
|
||||
removeBackgroundImage: vi.fn(),
|
||||
toggleGrid: vi.fn(),
|
||||
setBackgroundRenderMode: vi.fn(),
|
||||
handleResize: vi.fn(),
|
||||
renderBackground: vi.fn(),
|
||||
updateBackgroundSize: vi.fn(),
|
||||
scene: new THREE.Scene(),
|
||||
backgroundTexture: null as unknown,
|
||||
backgroundMesh: null as unknown,
|
||||
gridHelper: { visible: true }
|
||||
}
|
||||
}
|
||||
|
||||
export function makeCameraManagerStub() {
|
||||
const perspective = new THREE.PerspectiveCamera()
|
||||
return {
|
||||
activeCamera: perspective as THREE.Camera,
|
||||
perspectiveCamera: perspective,
|
||||
toggleCamera: vi.fn(),
|
||||
setupForModel: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
setFOV: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
getCameraState: vi.fn<() => CameraState>(() => ({
|
||||
position: new THREE.Vector3(0, 0, 10),
|
||||
target: new THREE.Vector3(0, 0, 0),
|
||||
zoom: 1,
|
||||
cameraType: 'perspective'
|
||||
})),
|
||||
getCurrentCameraType: vi.fn<() => CameraType>(() => 'perspective'),
|
||||
handleResize: vi.fn(),
|
||||
updateAspectRatio: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
export function makeControlsManagerStub() {
|
||||
return {
|
||||
controls: {
|
||||
target: new THREE.Vector3(0, 0, 0),
|
||||
update: vi.fn()
|
||||
},
|
||||
update: vi.fn(),
|
||||
updateCamera: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
export function makeLightingManagerStub() {
|
||||
return {
|
||||
setLightIntensity: vi.fn(),
|
||||
setHDRIMode: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
export function makeHDRIManagerStub() {
|
||||
return {
|
||||
loadHDRI: vi.fn().mockResolvedValue(undefined),
|
||||
setEnabled: vi.fn(),
|
||||
setShowAsBackground: vi.fn(),
|
||||
setIntensity: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
export function makeViewHelperManagerStub() {
|
||||
return {
|
||||
viewHelper: { render: vi.fn() },
|
||||
recreateViewHelper: vi.fn(),
|
||||
visibleViewHelper: vi.fn(),
|
||||
update: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
export function makeLoaderManagerStub(
|
||||
capabilities: ModelAdapterCapabilities | null = {
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe']
|
||||
},
|
||||
kind: 'mesh' | 'pointCloud' | 'splat' | null = 'mesh'
|
||||
) {
|
||||
const adapter =
|
||||
kind === null || capabilities === null
|
||||
? null
|
||||
: { kind, extensions: [], capabilities, load: vi.fn() }
|
||||
return {
|
||||
loadModel: vi.fn().mockResolvedValue(undefined),
|
||||
getCurrentAdapter: vi.fn(() => adapter),
|
||||
init: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
type ModelManagerStub = {
|
||||
fitToViewer: ReturnType<typeof vi.fn>
|
||||
clearModel: ReturnType<typeof vi.fn>
|
||||
setMaterialMode: ReturnType<typeof vi.fn>
|
||||
setUpDirection: ReturnType<typeof vi.fn>
|
||||
setShowSkeleton: ReturnType<typeof vi.fn>
|
||||
hasSkeleton: ReturnType<typeof vi.fn<() => boolean>>
|
||||
showSkeleton: boolean
|
||||
currentModel: THREE.Object3D | null
|
||||
originalModel: unknown
|
||||
originalFileName: string | null
|
||||
originalURL: string | null
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
setupModel: ReturnType<typeof vi.fn>
|
||||
setOriginalModel: ReturnType<typeof vi.fn>
|
||||
originalMaterials: WeakMap<THREE.Mesh, THREE.Material | THREE.Material[]>
|
||||
standardMaterial: THREE.MeshStandardMaterial
|
||||
materialMode: MaterialMode
|
||||
}
|
||||
|
||||
export function makeModelManagerStub(): ModelManagerStub {
|
||||
return {
|
||||
fitToViewer: vi.fn(),
|
||||
clearModel: vi.fn(),
|
||||
setMaterialMode: vi.fn(),
|
||||
setUpDirection: vi.fn(),
|
||||
setShowSkeleton: vi.fn(),
|
||||
hasSkeleton: vi.fn(() => false),
|
||||
showSkeleton: false,
|
||||
currentModel: null,
|
||||
originalModel: null,
|
||||
originalFileName: 'model',
|
||||
originalURL: null,
|
||||
dispose: vi.fn(),
|
||||
setupModel: vi.fn().mockResolvedValue(undefined),
|
||||
setOriginalModel: vi.fn(),
|
||||
originalMaterials: new WeakMap(),
|
||||
standardMaterial: new THREE.MeshStandardMaterial(),
|
||||
materialMode: 'original'
|
||||
}
|
||||
}
|
||||
|
||||
export function makeRecordingManagerStub() {
|
||||
return {
|
||||
startRecording: vi.fn().mockResolvedValue(undefined),
|
||||
stopRecording: vi.fn(),
|
||||
getIsRecording: vi.fn(() => false),
|
||||
getRecordingDuration: vi.fn(() => 0),
|
||||
getRecordingData: vi.fn<() => string | null>(() => null),
|
||||
exportRecording: vi.fn(),
|
||||
clearRecording: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
type AnimationManagerStub = {
|
||||
setupModelAnimations: ReturnType<typeof vi.fn>
|
||||
setAnimationSpeed: ReturnType<typeof vi.fn>
|
||||
updateSelectedAnimation: ReturnType<typeof vi.fn>
|
||||
toggleAnimation: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
animationClips: THREE.AnimationClip[]
|
||||
isAnimationPlaying: boolean
|
||||
getAnimationTime: ReturnType<typeof vi.fn<() => number>>
|
||||
getAnimationDuration: ReturnType<typeof vi.fn<() => number>>
|
||||
setAnimationTime: ReturnType<typeof vi.fn>
|
||||
init: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
export function makeAnimationManagerStub(): AnimationManagerStub {
|
||||
return {
|
||||
setupModelAnimations: vi.fn(),
|
||||
setAnimationSpeed: vi.fn(),
|
||||
updateSelectedAnimation: vi.fn(),
|
||||
toggleAnimation: vi.fn(),
|
||||
update: vi.fn(),
|
||||
animationClips: [],
|
||||
isAnimationPlaying: false,
|
||||
getAnimationTime: vi.fn(() => 0),
|
||||
getAnimationDuration: vi.fn(() => 0),
|
||||
setAnimationTime: vi.fn(),
|
||||
init: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
export function makeEventManagerStub() {
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
// jsdom/happy-dom report 0 for clientWidth/Height; override with deterministic
|
||||
// values so viewport-sensitive code (renderMainScene, handleResize) is testable.
|
||||
export function makeRendererStub(containerWidth = 800, containerHeight = 600) {
|
||||
const canvas = document.createElement('canvas')
|
||||
const parent = document.createElement('div')
|
||||
parent.appendChild(canvas)
|
||||
|
||||
const setClientSize = (el: HTMLElement, width: number, height: number) => {
|
||||
Object.defineProperty(el, 'clientWidth', {
|
||||
value: width,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(el, 'clientHeight', {
|
||||
value: height,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
setClientSize(parent, containerWidth, containerHeight)
|
||||
setClientSize(canvas, containerWidth, containerHeight)
|
||||
|
||||
return {
|
||||
domElement: canvas,
|
||||
setViewport: vi.fn(),
|
||||
setScissor: vi.fn(),
|
||||
setScissorTest: vi.fn(),
|
||||
setClearColor: vi.fn(),
|
||||
setSize: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
render: vi.fn(),
|
||||
forceContextLoss: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
parent
|
||||
}
|
||||
}
|
||||
292
src/extensions/core/load3d/createLoad3d.test.ts
Normal file
292
src/extensions/core/load3d/createLoad3d.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Stub every manager class so buildLoad3dDeps can exercise its wiring without
|
||||
// pulling in Three.js scene construction or WebGL. The stubs capture their
|
||||
// constructor args so the test can assert on how callbacks are wired.
|
||||
|
||||
type RecordArgs = (...args: unknown[]) => void
|
||||
const managerConstructors: Record<
|
||||
string,
|
||||
ReturnType<typeof vi.fn<RecordArgs>>
|
||||
> = {
|
||||
EventManager: vi.fn<RecordArgs>(),
|
||||
SceneManager: vi.fn<RecordArgs>(),
|
||||
CameraManager: vi.fn<RecordArgs>(),
|
||||
ControlsManager: vi.fn<RecordArgs>(),
|
||||
LightingManager: vi.fn<RecordArgs>(),
|
||||
HDRIManager: vi.fn<RecordArgs>(),
|
||||
ViewHelperManager: vi.fn<RecordArgs>(),
|
||||
SceneModelManager: vi.fn<RecordArgs>(),
|
||||
LoaderManager: vi.fn<RecordArgs>(),
|
||||
RecordingManager: vi.fn<RecordArgs>(),
|
||||
AnimationManager: vi.fn<RecordArgs>(),
|
||||
GizmoManager: vi.fn<RecordArgs>()
|
||||
}
|
||||
|
||||
vi.mock('./EventManager', () => ({
|
||||
EventManager: class {
|
||||
constructor(...args: unknown[]) {
|
||||
managerConstructors.EventManager(...args)
|
||||
}
|
||||
emitEvent = vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./SceneManager', () => ({
|
||||
SceneManager: class {
|
||||
scene = { name: 'scene' }
|
||||
constructor(...args: unknown[]) {
|
||||
managerConstructors.SceneManager(...args)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./CameraManager', () => ({
|
||||
CameraManager: class {
|
||||
activeCamera = { name: 'active-camera' }
|
||||
constructor(...args: unknown[]) {
|
||||
managerConstructors.CameraManager(...args)
|
||||
}
|
||||
setControls = vi.fn()
|
||||
setupForModel = vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./ControlsManager', () => ({
|
||||
ControlsManager: class {
|
||||
controls = { name: 'controls' }
|
||||
constructor(...args: unknown[]) {
|
||||
managerConstructors.ControlsManager(...args)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./LightingManager', () => ({
|
||||
LightingManager: class {
|
||||
constructor(...args: unknown[]) {
|
||||
managerConstructors.LightingManager(...args)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./HDRIManager', () => ({
|
||||
HDRIManager: class {
|
||||
constructor(...args: unknown[]) {
|
||||
managerConstructors.HDRIManager(...args)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./ViewHelperManager', () => ({
|
||||
ViewHelperManager: class {
|
||||
constructor(...args: unknown[]) {
|
||||
managerConstructors.ViewHelperManager(...args)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
let capturedGetCurrentCapabilities: (() => { fitToViewer: boolean }) | undefined
|
||||
|
||||
vi.mock('./SceneModelManager', () => ({
|
||||
SceneModelManager: class {
|
||||
constructor(...args: unknown[]) {
|
||||
managerConstructors.SceneModelManager(...args)
|
||||
capturedGetCurrentCapabilities =
|
||||
args[6] as typeof capturedGetCurrentCapabilities
|
||||
}
|
||||
setupForModel = vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
let capturedLoaderAdapter: { capabilities: unknown } | null = null
|
||||
vi.mock('./LoaderManager', () => ({
|
||||
LoaderManager: class {
|
||||
constructor(...args: unknown[]) {
|
||||
managerConstructors.LoaderManager(...args)
|
||||
}
|
||||
getCurrentAdapter = () => capturedLoaderAdapter
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./RecordingManager', () => ({
|
||||
RecordingManager: class {
|
||||
constructor(...args: unknown[]) {
|
||||
managerConstructors.RecordingManager(...args)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./AnimationManager', () => ({
|
||||
AnimationManager: class {
|
||||
constructor(...args: unknown[]) {
|
||||
managerConstructors.AnimationManager(...args)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
let capturedGizmoTransformCallback: (() => void) | undefined
|
||||
vi.mock('./GizmoManager', () => ({
|
||||
GizmoManager: class {
|
||||
constructor(...args: unknown[]) {
|
||||
managerConstructors.GizmoManager(...args)
|
||||
capturedGizmoTransformCallback =
|
||||
args[4] as typeof capturedGizmoTransformCallback
|
||||
}
|
||||
getTransform = vi.fn(() => ({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}))
|
||||
isEnabled = vi.fn(() => false)
|
||||
getMode = vi.fn(() => 'translate')
|
||||
setupForModel = vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three', () => ({
|
||||
WebGLRenderer: class {
|
||||
domElement = Object.assign(document.createElement('canvas'), {
|
||||
classList: { add: vi.fn() }
|
||||
})
|
||||
setSize = vi.fn()
|
||||
setClearColor = vi.fn()
|
||||
autoClear = false
|
||||
outputColorSpace = ''
|
||||
},
|
||||
SRGBColorSpace: 'srgb'
|
||||
}))
|
||||
|
||||
// Load3d itself is tested separately; stub its constructor to capture the
|
||||
// deps argument.
|
||||
const load3dCtor = vi.fn()
|
||||
vi.mock('./Load3d', () => ({
|
||||
default: class {
|
||||
constructor(
|
||||
container: Element | HTMLElement,
|
||||
deps: Record<string, unknown>,
|
||||
options?: Record<string, unknown>
|
||||
) {
|
||||
load3dCtor(container, deps, options)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { buildLoad3dDeps, createLoad3d } from './createLoad3d'
|
||||
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
return document.createElement('div')
|
||||
}
|
||||
|
||||
describe('buildLoad3dDeps', () => {
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(managerConstructors)) fn.mockClear()
|
||||
capturedGetCurrentCapabilities = undefined
|
||||
capturedGizmoTransformCallback = undefined
|
||||
capturedLoaderAdapter = null
|
||||
load3dCtor.mockClear()
|
||||
})
|
||||
|
||||
it('wires every manager in the graph', () => {
|
||||
const deps = buildLoad3dDeps(makeContainer())
|
||||
|
||||
expect(deps.renderer).toBeDefined()
|
||||
expect(deps.eventManager).toBeDefined()
|
||||
expect(deps.sceneManager).toBeDefined()
|
||||
expect(deps.cameraManager).toBeDefined()
|
||||
expect(deps.controlsManager).toBeDefined()
|
||||
expect(deps.lightingManager).toBeDefined()
|
||||
expect(deps.hdriManager).toBeDefined()
|
||||
expect(deps.viewHelperManager).toBeDefined()
|
||||
expect(deps.modelManager).toBeDefined()
|
||||
expect(deps.loaderManager).toBeDefined()
|
||||
expect(deps.recordingManager).toBeDefined()
|
||||
expect(deps.animationManager).toBeDefined()
|
||||
expect(deps.gizmoManager).toBeDefined()
|
||||
|
||||
// Each manager constructor is invoked exactly once.
|
||||
for (const [name, ctor] of Object.entries(managerConstructors)) {
|
||||
expect(ctor, `${name} ctor`).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('appends the renderer canvas to the container', () => {
|
||||
const container = makeContainer()
|
||||
buildLoad3dDeps(container)
|
||||
expect(container.querySelector('canvas')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('returns the default capabilities when no model has loaded yet', () => {
|
||||
buildLoad3dDeps(makeContainer())
|
||||
expect(capturedGetCurrentCapabilities).toBeDefined()
|
||||
expect(capturedGetCurrentCapabilities?.()).toEqual(
|
||||
DEFAULT_MODEL_CAPABILITIES
|
||||
)
|
||||
})
|
||||
|
||||
it('proxies the current adapter capabilities after a model loads', () => {
|
||||
buildLoad3dDeps(makeContainer())
|
||||
const customCapabilities = {
|
||||
fitToViewer: false,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: false,
|
||||
lighting: false,
|
||||
exportable: false,
|
||||
materialModes: []
|
||||
}
|
||||
capturedLoaderAdapter = { capabilities: customCapabilities }
|
||||
expect(capturedGetCurrentCapabilities?.()).toEqual(customCapabilities)
|
||||
})
|
||||
|
||||
it('emits a gizmoTransformChange event carrying the current gizmo state', () => {
|
||||
const deps = buildLoad3dDeps(makeContainer())
|
||||
const emit = vi.mocked(
|
||||
(deps.eventManager as { emitEvent: unknown }).emitEvent as never
|
||||
)
|
||||
|
||||
capturedGizmoTransformCallback?.()
|
||||
|
||||
expect(emit).toHaveBeenCalledWith(
|
||||
'gizmoTransformChange',
|
||||
expect.objectContaining({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 },
|
||||
enabled: false,
|
||||
mode: 'translate'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createLoad3d', () => {
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(managerConstructors)) fn.mockClear()
|
||||
load3dCtor.mockClear()
|
||||
})
|
||||
|
||||
it('builds deps and forwards them to the Load3d constructor', () => {
|
||||
const container = makeContainer()
|
||||
const options = { width: 512, height: 512 }
|
||||
|
||||
createLoad3d(container, options)
|
||||
|
||||
expect(load3dCtor).toHaveBeenCalledTimes(1)
|
||||
const [passedContainer, passedDeps, passedOptions] =
|
||||
load3dCtor.mock.calls[0]
|
||||
expect(passedContainer).toBe(container)
|
||||
expect(passedOptions).toBe(options)
|
||||
expect(passedDeps).toMatchObject({
|
||||
renderer: expect.any(Object),
|
||||
sceneManager: expect.any(Object),
|
||||
cameraManager: expect.any(Object),
|
||||
gizmoManager: expect.any(Object)
|
||||
})
|
||||
})
|
||||
|
||||
it('works without options', () => {
|
||||
createLoad3d(makeContainer())
|
||||
expect(load3dCtor).toHaveBeenCalledTimes(1)
|
||||
expect(load3dCtor.mock.calls[0][2]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
135
src/extensions/core/load3d/createLoad3d.ts
Normal file
135
src/extensions/core/load3d/createLoad3d.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { AnimationManager } from './AnimationManager'
|
||||
import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
import { EventManager } from './EventManager'
|
||||
import { GizmoManager } from './GizmoManager'
|
||||
import { HDRIManager } from './HDRIManager'
|
||||
import { LightingManager } from './LightingManager'
|
||||
import Load3d from './Load3d'
|
||||
import type { Load3dDeps } from './Load3d'
|
||||
import { LoaderManager } from './LoaderManager'
|
||||
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
|
||||
import { RecordingManager } from './RecordingManager'
|
||||
import { SceneManager } from './SceneManager'
|
||||
import { SceneModelManager } from './SceneModelManager'
|
||||
import { ViewHelperManager } from './ViewHelperManager'
|
||||
import type { Load3DOptions } from './interfaces'
|
||||
|
||||
function createRenderer(container: Element | HTMLElement): THREE.WebGLRenderer {
|
||||
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
|
||||
renderer.setSize(300, 300)
|
||||
renderer.setClearColor(0x282828)
|
||||
renderer.autoClear = false
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
renderer.domElement.classList.add(
|
||||
'absolute',
|
||||
'inset-0',
|
||||
'h-full',
|
||||
'w-full',
|
||||
'outline-none'
|
||||
)
|
||||
container.appendChild(renderer.domElement)
|
||||
return renderer
|
||||
}
|
||||
|
||||
export function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
|
||||
const renderer = createRenderer(container)
|
||||
const eventManager = new EventManager()
|
||||
|
||||
let cameraManager: CameraManager
|
||||
let controlsManager: ControlsManager
|
||||
let gizmoManager: GizmoManager
|
||||
|
||||
const getActiveCamera = (): THREE.Camera => cameraManager.activeCamera
|
||||
const getControls = () => controlsManager.controls
|
||||
|
||||
const sceneManager = new SceneManager(
|
||||
renderer,
|
||||
getActiveCamera,
|
||||
getControls,
|
||||
eventManager
|
||||
)
|
||||
|
||||
cameraManager = new CameraManager(renderer, eventManager)
|
||||
controlsManager = new ControlsManager(
|
||||
renderer,
|
||||
cameraManager.activeCamera,
|
||||
eventManager
|
||||
)
|
||||
cameraManager.setControls(controlsManager.controls)
|
||||
|
||||
const lightingManager = new LightingManager(sceneManager.scene, eventManager)
|
||||
const hdriManager = new HDRIManager(
|
||||
sceneManager.scene,
|
||||
renderer,
|
||||
eventManager
|
||||
)
|
||||
const viewHelperManager = new ViewHelperManager(
|
||||
renderer,
|
||||
getActiveCamera,
|
||||
getControls,
|
||||
eventManager
|
||||
)
|
||||
|
||||
let loaderManagerRef: LoaderManager
|
||||
const modelManager = new SceneModelManager(
|
||||
sceneManager.scene,
|
||||
renderer,
|
||||
eventManager,
|
||||
getActiveCamera,
|
||||
(size, center) => cameraManager.setupForModel(size, center),
|
||||
(model) => gizmoManager.setupForModel(model),
|
||||
() =>
|
||||
loaderManagerRef.getCurrentAdapter()?.capabilities ??
|
||||
DEFAULT_MODEL_CAPABILITIES
|
||||
)
|
||||
|
||||
const loaderManager = new LoaderManager(modelManager, eventManager)
|
||||
loaderManagerRef = loaderManager
|
||||
const recordingManager = new RecordingManager(
|
||||
sceneManager.scene,
|
||||
renderer,
|
||||
eventManager
|
||||
)
|
||||
const animationManager = new AnimationManager(eventManager)
|
||||
|
||||
gizmoManager = new GizmoManager(
|
||||
sceneManager.scene,
|
||||
renderer,
|
||||
controlsManager.controls,
|
||||
getActiveCamera,
|
||||
() => {
|
||||
const transform = gizmoManager.getTransform()
|
||||
eventManager.emitEvent('gizmoTransformChange', {
|
||||
...transform,
|
||||
enabled: gizmoManager.isEnabled(),
|
||||
mode: gizmoManager.getMode()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
renderer,
|
||||
eventManager,
|
||||
sceneManager,
|
||||
cameraManager,
|
||||
controlsManager,
|
||||
lightingManager,
|
||||
hdriManager,
|
||||
viewHelperManager,
|
||||
loaderManager,
|
||||
modelManager,
|
||||
recordingManager,
|
||||
animationManager,
|
||||
gizmoManager
|
||||
}
|
||||
}
|
||||
|
||||
export function createLoad3d(
|
||||
container: Element | HTMLElement,
|
||||
options?: Load3DOptions
|
||||
): Load3d {
|
||||
return new Load3d(container, buildLoad3dDeps(container), options)
|
||||
}
|
||||
@@ -3,11 +3,7 @@
|
||||
import type * as THREE from 'three'
|
||||
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import type { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
|
||||
import type { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
|
||||
import type { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import type { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import type { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import type { OBJLoader2Parallel } from 'wwobjloader2'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
|
||||
export type MaterialMode =
|
||||
| 'original'
|
||||
@@ -203,12 +199,6 @@ export interface ModelManagerInterface {
|
||||
}
|
||||
|
||||
export interface LoaderManagerInterface {
|
||||
gltfLoader: GLTFLoader
|
||||
objLoader: OBJLoader2Parallel
|
||||
mtlLoader: MTLLoader
|
||||
fbxLoader: FBXLoader
|
||||
stlLoader: STLLoader
|
||||
|
||||
init(): void
|
||||
dispose(): void
|
||||
loadModel(url: string, originalFileName?: string): Promise<void>
|
||||
|
||||
129
src/extensions/core/load3d/load3dContextMenuGuard.test.ts
Normal file
129
src/extensions/core/load3d/load3dContextMenuGuard.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { attachContextMenuGuard } from './load3dContextMenuGuard'
|
||||
|
||||
function rightMouse(type: string, x: number, y: number, buttons = 2) {
|
||||
const event = new MouseEvent(type, {
|
||||
button: 2,
|
||||
buttons,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
describe('attachContextMenuGuard', () => {
|
||||
let target: HTMLElement
|
||||
let onMenu: ReturnType<typeof vi.fn<(event: MouseEvent) => void>>
|
||||
let dispose: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
target = document.createElement('div')
|
||||
document.body.appendChild(target)
|
||||
onMenu = vi.fn<(event: MouseEvent) => void>()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
dispose?.()
|
||||
target.remove()
|
||||
})
|
||||
|
||||
it('invokes onMenu for a right-click without drag movement', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu)
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 100, 100))
|
||||
|
||||
expect(onMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preventDefault is called on the contextmenu event when menu fires', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu)
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 0, 0))
|
||||
const contextEvent = rightMouse('contextmenu', 0, 0)
|
||||
target.dispatchEvent(contextEvent)
|
||||
|
||||
expect(contextEvent.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses onMenu when the mouse moved past the drag threshold', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('mousemove', 120, 120))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 120, 120))
|
||||
|
||||
expect(onMenu).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still fires onMenu when the mouse moved within the drag threshold', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 10 })
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('mousemove', 103, 104))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 103, 104))
|
||||
|
||||
expect(onMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('detects a drag from start to contextmenu even without mousemove events', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 200, 200))
|
||||
|
||||
expect(onMenu).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets drag state between right-clicks', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('mousemove', 200, 200))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 200, 200))
|
||||
expect(onMenu).not.toHaveBeenCalled()
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 50, 50))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 50, 50))
|
||||
expect(onMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('ignores onMenu when isDisabled returns true', () => {
|
||||
let disabled = true
|
||||
dispose = attachContextMenuGuard(target, onMenu, {
|
||||
isDisabled: () => disabled
|
||||
})
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 10, 10))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
|
||||
expect(onMenu).not.toHaveBeenCalled()
|
||||
|
||||
disabled = false
|
||||
target.dispatchEvent(rightMouse('mousedown', 10, 10))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
|
||||
expect(onMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('stops listening after dispose', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu)
|
||||
dispose()
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 10, 10))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
|
||||
|
||||
expect(onMenu).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores mousemove events without the right button held', () => {
|
||||
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
|
||||
|
||||
target.dispatchEvent(rightMouse('mousedown', 100, 100))
|
||||
target.dispatchEvent(rightMouse('mousemove', 200, 200, 0))
|
||||
target.dispatchEvent(rightMouse('contextmenu', 100, 100))
|
||||
|
||||
expect(onMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
72
src/extensions/core/load3d/load3dContextMenuGuard.ts
Normal file
72
src/extensions/core/load3d/load3dContextMenuGuard.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
|
||||
|
||||
type ContextMenuGuardOptions = {
|
||||
isDisabled?: () => boolean
|
||||
dragThreshold?: number
|
||||
}
|
||||
|
||||
export function attachContextMenuGuard(
|
||||
target: HTMLElement,
|
||||
onMenu: (event: MouseEvent) => void,
|
||||
{ isDisabled = () => false, dragThreshold = 5 }: ContextMenuGuardOptions = {}
|
||||
): () => void {
|
||||
const abort = new AbortController()
|
||||
const { signal } = abort
|
||||
|
||||
let start = { x: 0, y: 0 }
|
||||
let moved = false
|
||||
|
||||
target.addEventListener(
|
||||
'mousedown',
|
||||
(e) => {
|
||||
if (e.button === 2) {
|
||||
start = { x: e.clientX, y: e.clientY }
|
||||
moved = false
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
target.addEventListener(
|
||||
'mousemove',
|
||||
(e) => {
|
||||
if (
|
||||
e.buttons === 2 &&
|
||||
exceedsClickThreshold(
|
||||
start,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
dragThreshold
|
||||
)
|
||||
) {
|
||||
moved = true
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
target.addEventListener(
|
||||
'contextmenu',
|
||||
(e) => {
|
||||
if (isDisabled()) return
|
||||
|
||||
const wasDragging =
|
||||
moved ||
|
||||
exceedsClickThreshold(
|
||||
start,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
dragThreshold
|
||||
)
|
||||
|
||||
moved = false
|
||||
|
||||
if (wasDragging) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onMenu(e)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
return () => abort.abort()
|
||||
}
|
||||
62
src/extensions/core/load3d/load3dRenderLoop.test.ts
Normal file
62
src/extensions/core/load3d/load3dRenderLoop.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { startRenderLoop } from './load3dRenderLoop'
|
||||
|
||||
describe('startRenderLoop', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('runs tick on each frame while isActive returns true', () => {
|
||||
const tick = vi.fn()
|
||||
const handle = startRenderLoop({ tick, isActive: () => true })
|
||||
|
||||
vi.advanceTimersToNextTimer()
|
||||
vi.advanceTimersToNextTimer()
|
||||
vi.advanceTimersToNextTimer()
|
||||
|
||||
expect(tick.mock.calls.length).toBeGreaterThanOrEqual(3)
|
||||
handle.stop()
|
||||
})
|
||||
|
||||
it('skips tick on frames where isActive returns false', () => {
|
||||
let active = false
|
||||
const tick = vi.fn()
|
||||
const handle = startRenderLoop({ tick, isActive: () => active })
|
||||
|
||||
vi.advanceTimersToNextTimer()
|
||||
vi.advanceTimersToNextTimer()
|
||||
expect(tick).not.toHaveBeenCalled()
|
||||
|
||||
active = true
|
||||
vi.advanceTimersToNextTimer()
|
||||
expect(tick).toHaveBeenCalledOnce()
|
||||
|
||||
handle.stop()
|
||||
})
|
||||
|
||||
it('stop halts further ticks', () => {
|
||||
const tick = vi.fn()
|
||||
const handle = startRenderLoop({ tick, isActive: () => true })
|
||||
|
||||
vi.advanceTimersToNextTimer()
|
||||
const callsBeforeStop = tick.mock.calls.length
|
||||
|
||||
handle.stop()
|
||||
vi.advanceTimersToNextTimer()
|
||||
vi.advanceTimersToNextTimer()
|
||||
|
||||
expect(tick.mock.calls.length).toBe(callsBeforeStop)
|
||||
})
|
||||
|
||||
it('is safe to call stop multiple times', () => {
|
||||
const handle = startRenderLoop({ tick: vi.fn(), isActive: () => true })
|
||||
|
||||
handle.stop()
|
||||
expect(() => handle.stop()).not.toThrow()
|
||||
})
|
||||
})
|
||||
32
src/extensions/core/load3d/load3dRenderLoop.ts
Normal file
32
src/extensions/core/load3d/load3dRenderLoop.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
type RenderLoopOptions = {
|
||||
tick: () => void
|
||||
isActive: () => boolean
|
||||
}
|
||||
|
||||
export type RenderLoopHandle = {
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export function startRenderLoop({
|
||||
tick,
|
||||
isActive
|
||||
}: RenderLoopOptions): RenderLoopHandle {
|
||||
let frameId: number | null = null
|
||||
|
||||
const loop = () => {
|
||||
frameId = requestAnimationFrame(loop)
|
||||
if (!isActive()) return
|
||||
tick()
|
||||
}
|
||||
|
||||
loop()
|
||||
|
||||
return {
|
||||
stop() {
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId)
|
||||
frameId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
src/extensions/core/load3d/load3dViewport.test.ts
Normal file
108
src/extensions/core/load3d/load3dViewport.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
|
||||
import type { Load3dActivityFlags } from './load3dViewport'
|
||||
|
||||
describe('computeLetterboxedViewport', () => {
|
||||
it('pillarboxes when the container is wider than the target aspect', () => {
|
||||
const viewport = computeLetterboxedViewport({ width: 800, height: 400 }, 1)
|
||||
|
||||
expect(viewport).toEqual({
|
||||
offsetX: 200,
|
||||
offsetY: 0,
|
||||
width: 400,
|
||||
height: 400
|
||||
})
|
||||
})
|
||||
|
||||
it('letterboxes when the container is taller than the target aspect', () => {
|
||||
const viewport = computeLetterboxedViewport({ width: 400, height: 800 }, 1)
|
||||
|
||||
expect(viewport).toEqual({
|
||||
offsetX: 0,
|
||||
offsetY: 200,
|
||||
width: 400,
|
||||
height: 400
|
||||
})
|
||||
})
|
||||
|
||||
it('fills the container when aspect ratios match exactly', () => {
|
||||
const viewport = computeLetterboxedViewport(
|
||||
{ width: 1024, height: 768 },
|
||||
1024 / 768
|
||||
)
|
||||
|
||||
expect(viewport.offsetX).toBe(0)
|
||||
expect(viewport.offsetY).toBe(0)
|
||||
expect(viewport.width).toBe(1024)
|
||||
expect(viewport.height).toBe(768)
|
||||
})
|
||||
|
||||
it('handles a wide target aspect inside a square container', () => {
|
||||
const viewport = computeLetterboxedViewport(
|
||||
{ width: 600, height: 600 },
|
||||
16 / 9
|
||||
)
|
||||
|
||||
expect(viewport.offsetX).toBe(0)
|
||||
expect(viewport.width).toBe(600)
|
||||
expect(viewport.height).toBeCloseTo(337.5)
|
||||
expect(viewport.offsetY).toBeCloseTo((600 - 337.5) / 2)
|
||||
})
|
||||
|
||||
it('handles a tall target aspect inside a square container', () => {
|
||||
const viewport = computeLetterboxedViewport(
|
||||
{ width: 600, height: 600 },
|
||||
9 / 16
|
||||
)
|
||||
|
||||
expect(viewport.offsetY).toBe(0)
|
||||
expect(viewport.height).toBe(600)
|
||||
expect(viewport.width).toBeCloseTo(337.5)
|
||||
expect(viewport.offsetX).toBeCloseTo((600 - 337.5) / 2)
|
||||
})
|
||||
|
||||
it('preserves the target aspect ratio in the returned rect', () => {
|
||||
const target = 16 / 9
|
||||
const wide = computeLetterboxedViewport(
|
||||
{ width: 1920, height: 500 },
|
||||
target
|
||||
)
|
||||
const tall = computeLetterboxedViewport(
|
||||
{ width: 500, height: 1920 },
|
||||
target
|
||||
)
|
||||
|
||||
expect(wide.width / wide.height).toBeCloseTo(target)
|
||||
expect(tall.width / tall.height).toBeCloseTo(target)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLoad3dActive', () => {
|
||||
const idle: Load3dActivityFlags = {
|
||||
mouseOnNode: false,
|
||||
mouseOnScene: false,
|
||||
mouseOnViewer: false,
|
||||
recording: false,
|
||||
initialRenderDone: true,
|
||||
animationPlaying: false
|
||||
}
|
||||
|
||||
it('is inactive once the first frame is rendered with nothing happening', () => {
|
||||
expect(isLoad3dActive(idle)).toBe(false)
|
||||
})
|
||||
|
||||
it('is active before the first frame renders', () => {
|
||||
expect(isLoad3dActive({ ...idle, initialRenderDone: false })).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['mouseOnNode'],
|
||||
['mouseOnScene'],
|
||||
['mouseOnViewer'],
|
||||
['recording'],
|
||||
['animationPlaying']
|
||||
] as const)('is active when %s is true', (flag) => {
|
||||
expect(isLoad3dActive({ ...idle, [flag]: true })).toBe(true)
|
||||
})
|
||||
})
|
||||
55
src/extensions/core/load3d/load3dViewport.ts
Normal file
55
src/extensions/core/load3d/load3dViewport.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
type Size = { width: number; height: number }
|
||||
|
||||
type LetterboxedViewport = {
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function computeLetterboxedViewport(
|
||||
container: Size,
|
||||
targetAspectRatio: number
|
||||
): LetterboxedViewport {
|
||||
const containerAspectRatio = container.width / container.height
|
||||
|
||||
if (containerAspectRatio > targetAspectRatio) {
|
||||
const height = container.height
|
||||
const width = height * targetAspectRatio
|
||||
return {
|
||||
offsetX: (container.width - width) / 2,
|
||||
offsetY: 0,
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
const width = container.width
|
||||
const height = width / targetAspectRatio
|
||||
return {
|
||||
offsetX: 0,
|
||||
offsetY: (container.height - height) / 2,
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
export type Load3dActivityFlags = {
|
||||
mouseOnNode: boolean
|
||||
mouseOnScene: boolean
|
||||
mouseOnViewer: boolean
|
||||
recording: boolean
|
||||
initialRenderDone: boolean
|
||||
animationPlaying: boolean
|
||||
}
|
||||
|
||||
export function isLoad3dActive(flags: Load3dActivityFlags): boolean {
|
||||
return (
|
||||
flags.mouseOnNode ||
|
||||
flags.mouseOnScene ||
|
||||
flags.mouseOnViewer ||
|
||||
flags.recording ||
|
||||
!flags.initialRenderDone ||
|
||||
flags.animationPlaying
|
||||
)
|
||||
}
|
||||
@@ -16,8 +16,10 @@ vi.mock('@/composables/useLoad3dViewer', () => ({
|
||||
handleBackgroundImageUpdate: vi.fn(),
|
||||
exportModel: vi.fn(),
|
||||
handleSeek: vi.fn(),
|
||||
isSplatModel: false,
|
||||
isPlyModel: false,
|
||||
canUseGizmo: true,
|
||||
canUseLighting: true,
|
||||
canExport: true,
|
||||
materialModes: ['original', 'normal', 'wireframe'],
|
||||
hasSkeleton: false,
|
||||
animations: [],
|
||||
playing: false,
|
||||
|
||||
@@ -44,8 +44,10 @@ onUnmounted(() => {
|
||||
v-model:model-config="viewer"
|
||||
v-model:camera-config="viewer"
|
||||
v-model:light-config="viewer"
|
||||
:is-splat-model="viewer.isSplatModel"
|
||||
:is-ply-model="viewer.isPlyModel"
|
||||
:can-use-gizmo="viewer.canUseGizmo"
|
||||
:can-use-lighting="viewer.canUseLighting"
|
||||
:can-export="viewer.canExport"
|
||||
:material-modes="viewer.materialModes"
|
||||
:has-skeleton="viewer.hasSkeleton"
|
||||
@update-background-image="viewer.handleBackgroundImageUpdate"
|
||||
@export-model="viewer.exportModel"
|
||||
|
||||
Reference in New Issue
Block a user