Compare commits

...

6 Commits

Author SHA1 Message Date
Kelly Yang
99890d7d94 test: assert modelLoadingEnd is emitted in suppressErrors adapter-throw path 2026-04-30 18:01:38 -07:00
Kelly Yang
da27a78a94 fix: emit modelLoadingEnd on no-extension early return to unblock loading state 2026-04-30 17:43:01 -07:00
Kelly Yang
9f0d4d9038 fix: remove comments from new E2E tests; add role=alert to error overlay 2026-04-30 17:30:24 -07:00
Kelly Yang
22270a0f81 fix: respect suppressErrors in the no-extension branch of LoaderManager
When the file extension cannot be determined, route through modelLoadError
instead of addAlert when suppressErrors is true — same pattern as the catch
block for adapter failures.
2026-04-30 16:41:02 -07:00
Kelly Yang
30cf5754eb fix: show inline error overlay for Preview3D model load failures
Instead of silently suppressing errors or showing toast notifications,
Preview3D nodes now display an inline error message overlay when a model
fails to load or no model is received from the connected node.
2026-04-29 17:18:26 -07:00
Kelly Yang
975b510486 fix: suppress Preview3D model-load error toasts
Preview3D nodes do not own their model file — the file is produced by a
connected Load3D node at execution time. When execution completes with no
output, or when the model URL is temporarily unreachable, the node was
incorrectly showing an error toast that confused users.

Add a suppressLoadErrors option to Load3DOptions and wire it through
LoaderManager so preview nodes silently swallow load failures. Also
remove the addAlert call in the onExecuted callback when filePath is
absent.

Adds unit test for the suppressed-error path in LoaderManager and two
E2E tests that verify no toast appears when onExecuted fires with an
empty result or a missing model file.
2026-04-29 13:08:28 -07:00
12 changed files with 225 additions and 12 deletions

View File

@@ -1,9 +1,54 @@
import { expect } from '@playwright/test'
import {
preview3dPipelineTest as test,
Preview3DPipelineContext
} from '@e2e/fixtures/helpers/Preview3DPipelineFixture'
test.describe('Preview3D execution flow', { tag: ['@slow', '@node'] }, () => {
test('Preview3D shows inline error and no toast when execution output has no model file', async ({
preview3dPipeline: pipeline
}) => {
await pipeline.comfyPage.nextFrame()
await pipeline.comfyPage.page.evaluate((previewId) => {
const node = window.app!.graph.getNodeById(Number(previewId))
node?.onExecuted?.({ result: [] })
}, Preview3DPipelineContext.previewNodeId)
await pipeline.comfyPage.nextFrame()
await expect(
pipeline.comfyPage.page.getByTestId('load3d-error-overlay')
).toBeVisible()
await expect(
pipeline.comfyPage.page.locator('.p-toast-message')
).toHaveCount(0)
})
test('Preview3D shows inline error and no toast when model file cannot be loaded', async ({
preview3dPipeline: pipeline
}) => {
await pipeline.comfyPage.nextFrame()
const responsePromise = pipeline.comfyPage.page.waitForResponse((r) =>
r.url().includes('nonexistent_preview_model.glb')
)
await pipeline.comfyPage.page.evaluate((previewId) => {
const node = window.app!.graph.getNodeById(Number(previewId))
node?.onExecuted?.({ result: ['nonexistent_preview_model.glb'] })
}, Preview3DPipelineContext.previewNodeId)
await responsePromise
await pipeline.comfyPage.nextFrame()
await expect(
pipeline.comfyPage.page.getByTestId('load3d-error-overlay')
).toBeVisible()
await expect(
pipeline.comfyPage.page.locator('.p-toast-message')
).toHaveCount(0)
})
test('Preview3D loads model from execution output', async ({
preview3dPipeline: pipeline
}) => {

View File

@@ -13,6 +13,7 @@
:cleanup="cleanup"
:loading="loading"
:loading-message="loadingMessage"
:load-error="loadError"
:on-model-drop="isPreview ? undefined : handleModelDrop"
:is-preview="isPreview"
/>
@@ -143,6 +144,7 @@ const {
// other state
isRecording,
isPreview,
loadError,
canFitToViewer,
canUseGizmo,
canUseLighting,

View File

@@ -45,6 +45,7 @@ vi.mock('@/components/common/LoadingOverlay.vue', () => ({
type RenderOpts = {
loading?: boolean
loadingMessage?: string
loadError?: string | null
isPreview?: boolean
onModelDrop?: (file: File) => void | Promise<void>
initializeLoad3d?: (container: HTMLElement) => Promise<void>
@@ -62,6 +63,7 @@ function renderComponent(opts: RenderOpts = {}) {
cleanup,
loading: opts.loading ?? false,
loadingMessage: opts.loadingMessage ?? '',
loadError: opts.loadError ?? null,
onModelDrop: opts.onModelDrop,
isPreview: opts.isPreview ?? false
}
@@ -150,4 +152,31 @@ describe('Load3DScene', () => {
dragState.capturedOptions!.onModelDrop!(file)
).resolves.toBeUndefined()
})
describe('error overlay', () => {
it('shows the error overlay when loadError is set and not loading', () => {
renderComponent({ loadError: 'No model received from connected node' })
expect(screen.getByTestId('load3d-error-overlay')).toBeInTheDocument()
expect(
screen.getByText('No model received from connected node')
).toBeInTheDocument()
})
it('hides the error overlay when loadError is null', () => {
renderComponent({ loadError: null })
expect(
screen.queryByTestId('load3d-error-overlay')
).not.toBeInTheDocument()
})
it('hides the error overlay while loading even if loadError is set', () => {
renderComponent({ loadError: 'some error', loading: true })
expect(
screen.queryByTestId('load3d-error-overlay')
).not.toBeInTheDocument()
})
})
})

View File

@@ -16,6 +16,21 @@
@drop.prevent.stop="handleDrop"
>
<LoadingOverlay :loading="loading" :loading-message="loadingMessage" />
<Transition name="fade">
<div
v-if="loadError && !loading"
role="alert"
class="pointer-events-none absolute inset-0 z-40 flex items-center justify-center"
data-testid="load3d-error-overlay"
>
<div
class="mx-4 flex items-center gap-2 rounded-lg bg-error/10 px-4 py-3 text-sm text-error"
>
<i class="pi pi-exclamation-triangle shrink-0" />
{{ loadError }}
</div>
</div>
</Transition>
<div
v-if="!isPreview && isDragging"
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@@ -40,6 +55,7 @@ const props = defineProps<{
cleanup: () => void
loading: boolean
loadingMessage: string
loadError?: string | null
onModelDrop?: (file: File) => void | Promise<void>
isPreview: boolean
}>()

View File

@@ -755,7 +755,8 @@ describe('useLoad3d', () => {
'recordingStatusChange',
'animationListChange',
'animationProgressChange',
'cameraChanged'
'cameraChanged',
'modelLoadError'
]
expectedEvents.forEach((event) => {
@@ -815,6 +816,34 @@ describe('useLoad3d', () => {
expect(composable.loadingMessage.value).toBe('')
})
it('modelLoadError event sets loadError and modelLoadingStart clears it', async () => {
let modelLoadErrorHandler: ((msg: string) => void) | undefined
let modelLoadingStartHandler: (() => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'modelLoadError') {
modelLoadErrorHandler = handler as (msg: string) => void
} else if (event === 'modelLoadingStart') {
modelLoadingStartHandler = handler as () => void
}
}
)
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
expect(composable.loadError.value).toBeNull()
modelLoadErrorHandler?.('No model received from connected node')
expect(composable.loadError.value).toBe(
'No model received from connected node'
)
modelLoadingStartHandler?.()
expect(composable.loadError.value).toBeNull()
})
it('should handle recordingStatusChange event', async () => {
let recordingStatusHandler: ((status: boolean) => void) | undefined

View File

@@ -94,6 +94,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const animationDuration = ref(0)
const loading = ref(false)
const loadingMessage = ref('')
const loadError = ref<string | null>(null)
const isPreview = ref(false)
const isSplatModel = ref(false)
const isPlyModel = ref(false)
@@ -132,6 +133,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
height: heightWidget.value as number
})
: undefined,
suppressLoadErrors: isPreview.value,
onContextMenu: (event) => {
const menuOptions = app.canvas.getNodeMenuOptions(node)
new LiteGraph.ContextMenu(menuOptions, {
@@ -774,6 +776,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
modelLoadingStart: () => {
loadingMessage.value = t('load3d.loadingModel')
loading.value = true
loadError.value = null
if (!isFirstModelLoad) {
modelConfig.value = {
upDirection: 'original',
@@ -882,6 +885,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
modelConfig.value.gizmo.enabled = data.enabled
modelConfig.value.gizmo.mode = data.mode
}
},
modelLoadError: (message: string) => {
loadError.value = message
}
} as const
@@ -942,6 +948,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
lightConfig,
isRecording,
isPreview,
loadError,
isSplatModel,
isPlyModel,
canFitToViewer,

View File

@@ -510,9 +510,11 @@ useExtensionService().registerExtension({
const filePath = result?.[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
console.error(t('toastMessages.unableToGetModelFilePath'))
load3d.eventManager.emitEvent(
'modelLoadError',
t('load3d.noModelReceived')
)
}
const cameraState = result?.[1]

View File

@@ -333,18 +333,49 @@ describe('LoaderManager', () => {
expect(modelManager.originalFileName).toBe('model')
})
it('alerts when the file extension cannot be determined', async () => {
const { lm, modelManager } = makeLoaderManager()
it('alerts and emits modelLoadingEnd when the file extension cannot be determined', async () => {
const { lm, modelManager, eventManager } = makeLoaderManager()
await lm.loadModel('api/view?other=1')
expect(addAlert).toHaveBeenCalledWith(
'toastMessages.couldNotDetermineFileType'
)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'modelLoadingEnd',
null
)
expect(modelManager.setupModel).not.toHaveBeenCalled()
expect(meshLoad).not.toHaveBeenCalled()
})
it('emits modelLoadError and modelLoadingEnd (no toast) when suppressErrors is true and extension cannot be determined', async () => {
const modelManager = makeModelManagerStub()
const eventManager = makeEventManagerStub()
const lm = new LoaderManager(
modelManager as unknown as ConstructorParameters<
typeof LoaderManager
>[0],
eventManager,
undefined,
undefined,
true
)
await lm.loadModel('api/view?other=1')
expect(addAlert).not.toHaveBeenCalled()
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'modelLoadError',
'toastMessages.couldNotDetermineFileType'
)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'modelLoadingEnd',
null
)
expect(modelManager.setupModel).not.toHaveBeenCalled()
})
it('passes setupModel the object returned by the adapter', async () => {
const { lm, modelManager } = makeLoaderManager()
const loaded = new THREE.Object3D()
@@ -521,6 +552,34 @@ describe('LoaderManager', () => {
expect(capturedCtx!.materialMode).toBe('wireframe')
})
it('emits modelLoadError and skips toast when suppressErrors is true and the adapter throws', async () => {
const modelManager = makeModelManagerStub()
const eventManager = makeEventManagerStub()
const lm = new LoaderManager(
modelManager as unknown as ConstructorParameters<
typeof LoaderManager
>[0],
eventManager,
undefined,
undefined,
true
)
meshLoad.mockRejectedValueOnce(new Error('boom'))
vi.spyOn(console, 'error').mockImplementation(() => {})
await lm.loadModel('api/view?filename=cube.glb')
expect(addAlert).not.toHaveBeenCalled()
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'modelLoadError',
expect.any(String)
)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'modelLoadingEnd',
null
)
})
it('suppresses alerts and modelLoadingEnd when a stale load throws', async () => {
const { lm, eventManager } = makeLoaderManager()

View File

@@ -31,18 +31,21 @@ export class LoaderManager implements LoaderManagerInterface {
private readonly eventManager: EventManagerInterface
private readonly adapters: ModelAdapter[]
private readonly adapterRef: AdapterRef
private readonly suppressErrors: boolean
private currentLoadId: number = 0
constructor(
modelManager: ModelManagerInterface,
eventManager: EventManagerInterface,
adapters?: readonly ModelAdapter[],
adapterRef?: AdapterRef
adapterRef?: AdapterRef,
suppressErrors: boolean = false
) {
this.modelManager = modelManager
this.eventManager = eventManager
this.adapters = adapters ? [...adapters] : defaultAdapters()
this.adapterRef = adapterRef ?? createAdapterRef()
this.suppressErrors = suppressErrors
}
getCurrentAdapter(): ModelAdapter | null {
@@ -79,7 +82,13 @@ export class LoaderManager implements LoaderManagerInterface {
}
if (!fileExtension) {
useToastStore().addAlert(t('toastMessages.couldNotDetermineFileType'))
const message = t('toastMessages.couldNotDetermineFileType')
if (this.suppressErrors) {
this.eventManager.emitEvent('modelLoadError', message)
} else {
useToastStore().addAlert(message)
}
this.eventManager.emitEvent('modelLoadingEnd', null)
return
}
@@ -105,7 +114,14 @@ export class LoaderManager implements LoaderManagerInterface {
if (loadId === this.currentLoadId) {
this.eventManager.emitEvent('modelLoadingEnd', null)
console.error('Error loading model:', error)
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
if (this.suppressErrors) {
this.eventManager.emitEvent(
'modelLoadError',
t('toastMessages.errorLoadingModel')
)
} else {
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
}
}
}
}

View File

@@ -34,7 +34,10 @@ function createRenderer(container: Element | HTMLElement): THREE.WebGLRenderer {
return renderer
}
function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
function buildLoad3dDeps(
container: Element | HTMLElement,
options?: Load3DOptions
): Load3dDeps {
const renderer = createRenderer(container)
const eventManager = new EventManager()
// Shared mutable handle: LoaderManager writes the active adapter on each
@@ -94,7 +97,8 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
modelManager,
eventManager,
undefined,
adapterRef
adapterRef,
options?.suppressLoadErrors ?? false
)
const recordingManager = new RecordingManager(
sceneManager.scene,
@@ -140,5 +144,5 @@ export function createLoad3d(
container: Element | HTMLElement,
options?: Load3DOptions
): Load3d {
return new Load3d(container, buildLoad3dDeps(container), options)
return new Load3d(container, buildLoad3dDeps(container, options), options)
}

View File

@@ -82,6 +82,9 @@ export interface Load3DOptions {
// Optional context menu callback
onContextMenu?: (event: MouseEvent) => void
// Suppress model-load error toasts (e.g. for preview nodes that don't own their model file)
suppressLoadErrors?: boolean
}
export interface CaptureResult {

View File

@@ -1951,6 +1951,7 @@
"tiledMode": "Tiled",
"panoramaMode": "Panorama",
"loadingModel": "Loading 3D Model...",
"noModelReceived": "No model received from connected node",
"upDirection": "Up Direction",
"materialMode": "Material Mode",
"showSkeleton": "Show Skeleton",