Compare commits

...

2 Commits

Author SHA1 Message Date
Terry Jia
b3c67e826a test: expand load3d unit test coverage 2026-04-23 22:49:52 -04:00
Terry Jia
c0feaad4f6 refactor: modularize Load3d with capability-driven UX 2026-04-23 09:55:05 -04:00
39 changed files with 5182 additions and 884 deletions

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

@@ -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 () => {

View File

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

View File

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

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

View File

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

View File

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