Compare commits

...

1 Commits

Author SHA1 Message Date
Terry Jia
cfe1689863 FE-905 fix(load3d): cache scene capture so unchanged runs hit backend cache 2026-06-03 14:49:14 -04:00
6 changed files with 515 additions and 23 deletions

View File

@@ -3,7 +3,14 @@ import { nextTick, reactive, ref, shallowRef } from 'vue'
import type { Pinia } from 'pinia'
import { getActivePinia } from 'pinia'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import {
getLoad3dOutputCache,
isLoad3dSceneDirty,
markLoad3dSceneDirty,
nodeToLoad3dMap,
setLoad3dOutputCache,
useLoad3d
} from '@/composables/useLoad3d'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
@@ -1733,4 +1740,154 @@ describe('useLoad3d', () => {
expect(originalOnRemoved).toHaveBeenCalledTimes(1)
})
})
describe('scene dirty tracking', () => {
const fakeCache = {
image: 'threed/scene-1.png [temp]',
mask: 'threed/scene_mask-1.png [temp]',
normal: 'threed/scene_normal-1.png [temp]',
camera_info: null,
recording: '',
model_3d_info: []
}
it('treats an unseen node as dirty by default', () => {
const fresh = createMockLGraphNode({ properties: {} })
expect(isLoad3dSceneDirty(fresh)).toBe(true)
})
it('markLoad3dSceneDirty sets the node dirty', () => {
const fresh = createMockLGraphNode({ properties: {} })
setLoad3dOutputCache(fresh, fakeCache)
expect(isLoad3dSceneDirty(fresh)).toBe(false)
markLoad3dSceneDirty(fresh)
expect(isLoad3dSceneDirty(fresh)).toBe(true)
})
it('setLoad3dOutputCache stores the output and clears dirty', () => {
const fresh = createMockLGraphNode({ properties: {} })
setLoad3dOutputCache(fresh, fakeCache)
expect(getLoad3dOutputCache(fresh)).toBe(fakeCache)
expect(isLoad3dSceneDirty(fresh)).toBe(false)
})
it('two nodes keep independent dirty state', () => {
const a = createMockLGraphNode({ properties: {} })
const b = createMockLGraphNode({ properties: {} })
setLoad3dOutputCache(a, fakeCache)
expect(isLoad3dSceneDirty(a)).toBe(false)
expect(isLoad3dSceneDirty(b)).toBe(true)
markLoad3dSceneDirty(a)
expect(isLoad3dSceneDirty(a)).toBe(true)
expect(isLoad3dSceneDirty(b)).toBe(true)
})
it('markLoad3dSceneDirty on null is a no-op', () => {
expect(() => markLoad3dSceneDirty(null)).not.toThrow()
})
it('sceneConfig changes flip the node dirty', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
composable.sceneConfig.value.backgroundColor = '#ffffff'
await nextTick()
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('cameraChanged event marks the node dirty', async () => {
let cameraChangedHandler: ((state: unknown) => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'cameraChanged') {
cameraChangedHandler = handler as (state: unknown) => void
}
}
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
cameraChangedHandler!({ position: { x: 1, y: 2, z: 3 } })
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleStopRecording marks dirty when a recording was produced', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
vi.mocked(mockLoad3d.getRecordingDuration!).mockReturnValue(5)
composable.handleStopRecording()
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleStopRecording leaves dirty alone when no recording was produced', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
vi.mocked(mockLoad3d.getRecordingDuration!).mockReturnValue(0)
composable.handleStopRecording()
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
})
it('handleClearRecording marks dirty', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
composable.handleClearRecording()
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleSeek marks dirty when the animation has a duration', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
const calls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const match = calls.find(([event]) => event === 'animationProgressChange')
const animationProgressHandler = match![1] as (d: {
progress: number
currentTime: number
duration: number
}) => void
animationProgressHandler({ progress: 0, currentTime: 0, duration: 10 })
setLoad3dOutputCache(mockNode, fakeCache)
composable.handleSeek(50)
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
})
})

View File

@@ -22,6 +22,7 @@ import type {
GizmoMode,
LightConfig,
MaterialMode,
Model3DInfo,
ModelConfig,
SceneConfig,
UpDirection
@@ -38,6 +39,38 @@ import { useLoad3dService } from '@/services/load3dService'
type Load3dReadyCallback = (load3d: Load3d) => void
export const nodeToLoad3dMap = new Map<LGraphNode, Load3d>()
export type Load3dCachedOutput = {
image: string
mask: string
normal: string
camera_info: CameraState | null
recording: string
model_3d_info: Model3DInfo
}
const load3dSceneDirty = new WeakMap<LGraphNode, boolean>()
const load3dOutputCache = new WeakMap<LGraphNode, Load3dCachedOutput>()
export const markLoad3dSceneDirty = (node: LGraphNode | null): void => {
if (!node) return
load3dSceneDirty.set(node, true)
}
export const isLoad3dSceneDirty = (node: LGraphNode): boolean =>
load3dSceneDirty.get(node) !== false
export const getLoad3dOutputCache = (
node: LGraphNode
): Load3dCachedOutput | undefined => load3dOutputCache.get(node)
export const setLoad3dOutputCache = (
node: LGraphNode,
output: Load3dCachedOutput
): void => {
load3dOutputCache.set(node, output)
load3dSceneDirty.set(node, false)
}
const pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
const persistentReadyCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
@@ -69,6 +102,11 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
let load3d: Load3d | null = null
let isFirstModelLoad = true
const markDirty = () => {
const rawNode = toRaw(nodeRef.value)
if (rawNode) markLoad3dSceneDirty(rawNode as LGraphNode)
}
const debouncedHandleResize = useDebounceFn(() => {
load3d?.handleResize()
}, 150)
@@ -368,6 +406,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (n) {
n.properties['Light Config'] = lightConfig.value
}
markDirty()
}
const waitForLoad3d = (callback: Load3dReadyCallback) => {
@@ -412,6 +451,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (nodeRef.value) {
nodeRef.value.properties['Scene Config'] = newValue
}
markDirty()
},
{ deep: true }
)
@@ -452,6 +492,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (nodeRef.value) {
nodeRef.value.properties['Model Config'] = newValue
}
markDirty()
},
{ deep: true }
)
@@ -486,6 +527,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
load3d.setFOV(newValue.fov)
load3d.setRetainViewOnReload(newValue.retainViewOnReload ?? false)
}
markDirty()
},
{ deep: true }
)
@@ -545,18 +587,21 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (load3d) {
load3d.toggleAnimation(newValue)
}
markDirty()
})
watch(selectedSpeed, (newValue) => {
if (load3d && newValue) {
load3d.setAnimationSpeed(newValue)
}
markDirty()
})
watch(selectedAnimation, (newValue) => {
if (load3d && newValue !== undefined) {
load3d.updateSelectedAnimation(newValue)
}
markDirty()
})
const handleMouseEnter = () => {
@@ -580,6 +625,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isRecording.value = false
recordingDuration.value = load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
if (hasRecording.value) markDirty()
}
}
@@ -596,6 +642,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
load3d.clearRecording()
hasRecording.value = false
recordingDuration.value = 0
markDirty()
}
}
@@ -603,6 +650,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (load3d && animationDuration.value > 0) {
const time = (progress / 100) * animationDuration.value
load3d.setAnimationTime(time)
markDirty()
}
}
@@ -934,6 +982,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
state: cameraState
}
}
markLoad3dSceneDirty(node)
}
},
gizmoTransformChange: (data: GizmoConfig) => {

View File

@@ -33,13 +33,27 @@ vi.mock('@/services/load3dService', () => ({
})
}))
vi.mock('@/composables/useLoad3d', () => ({
useLoad3d: () => ({
waitForLoad3d: waitForLoad3dMock,
onLoad3dReady: onLoad3dReadyMock
}),
nodeToLoad3dMap: new Map()
}))
vi.mock('@/composables/useLoad3d', () => {
const sceneDirty = new WeakMap<LGraphNode, boolean>()
const outputCache = new WeakMap<LGraphNode, unknown>()
return {
useLoad3d: () => ({
waitForLoad3d: waitForLoad3dMock,
onLoad3dReady: onLoad3dReadyMock
}),
nodeToLoad3dMap: new Map(),
markLoad3dSceneDirty: (node: LGraphNode | null) => {
if (!node) return
sceneDirty.set(node, true)
},
isLoad3dSceneDirty: (node: LGraphNode) => sceneDirty.get(node) !== false,
getLoad3dOutputCache: (node: LGraphNode) => outputCache.get(node),
setLoad3dOutputCache: (node: LGraphNode, value: unknown) => {
outputCache.set(node, value)
sceneDirty.set(node, false)
}
}
})
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
default: class {
@@ -440,13 +454,16 @@ describe('Comfy.Load3D.nodeCreated', () => {
await load3DExt.nodeCreated(node)
expect(configureMock).toHaveBeenCalledWith({
loadFolder: 'input',
modelWidget: widgets[0],
cameraState: undefined,
width: widgets[1],
height: widgets[2]
})
expect(configureMock).toHaveBeenCalledWith(
expect.objectContaining({
loadFolder: 'input',
modelWidget: widgets[0],
cameraState: undefined,
width: widgets[1],
height: widgets[2],
onSceneInvalidated: expect.any(Function)
})
)
})
it('attaches a serializeValue function to the scene widget', async () => {
@@ -610,3 +627,95 @@ describe('Comfy.Preview3D.onNodeOutputsUpdated', () => {
)
})
})
describe('Comfy.Load3D scene widget serializeValue caching', () => {
beforeEach(setupBaseMocks)
function makeFullFakeLoad3d() {
return {
getCurrentCameraType: vi.fn(() => 'perspective'),
cameraManager: { perspectiveCamera: { fov: 35 } },
getCameraState: vi.fn(() => ({ position: { x: 0, y: 0, z: 0 } })),
stopRecording: vi.fn(),
captureScene: vi.fn(async () => ({
scene: 'scene-data',
mask: 'mask-data',
normal: 'normal-data'
})),
handleResize: vi.fn(),
getModelInfo: vi.fn(() => null),
getRecordingData: vi.fn(() => null)
}
}
async function setup() {
const { load3DExt } = await loadExtensionsFresh()
const useLoad3dModule = await import('@/composables/useLoad3d')
const utilsModule = await import('@/extensions/core/load3d/Load3dUtils')
const uploadTempImage = utilsModule.default.uploadTempImage as ReturnType<
typeof vi.fn
>
let counter = 0
uploadTempImage.mockImplementation(
async (_data: unknown, kind: string) => ({
name: `${kind}-${++counter}.png`
})
)
const widgets: FakeWidget[] = [
{ name: 'model_file', value: 'm.glb' },
{ name: 'width', value: 256 },
{ name: 'height', value: 256 },
{ name: 'image', value: '' }
]
const node = makeLoad3DNode({ widgets, properties: {} })
useLoad3dModule.nodeToLoad3dMap.set(node, makeFullFakeLoad3d() as never)
await load3DExt.nodeCreated(node)
const serialize = widgets[3].serializeValue! as () => Promise<{
image: string
} | null>
return { node, serialize, uploadTempImage, useLoad3dModule }
}
it('reuses the cached output when the scene has not been dirtied', async () => {
const { node, serialize, uploadTempImage, useLoad3dModule } = await setup()
const first = await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(3)
expect(first?.image).toBe('threed/scene-1.png [temp]')
expect(useLoad3dModule.isLoad3dSceneDirty(node)).toBe(false)
expect(useLoad3dModule.getLoad3dOutputCache(node)).toBe(first)
const second = await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(3)
expect(second).toBe(first)
})
it('re-captures after the scene is marked dirty', async () => {
const { node, serialize, uploadTempImage, useLoad3dModule } = await setup()
await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(3)
useLoad3dModule.markLoad3dSceneDirty(node)
const refreshed = await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(6)
expect(refreshed?.image).toBe('threed/scene-4.png [temp]')
})
it('returns null when no load3d instance is registered for the node', async () => {
const { load3DExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [
{ name: 'model_file', value: 'm.glb' },
{ name: 'width', value: 256 },
{ name: 'height', value: 256 },
{ name: 'image', value: '' }
]
const node = makeLoad3DNode({ widgets })
await load3DExt.nodeCreated(node)
expect(await widgets[3].serializeValue!()).toBeNull()
})
})

View File

@@ -2,7 +2,15 @@ import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import {
type Load3dCachedOutput,
getLoad3dOutputCache,
isLoad3dSceneDirty,
markLoad3dSceneDirty,
nodeToLoad3dMap,
setLoad3dOutputCache,
useLoad3d
} from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type {
CameraConfig,
@@ -93,6 +101,8 @@ async function handleModelUpload(files: FileList, node: LGraphNode) {
modelWidget.value = uploadPath
}
markLoad3dSceneDirty(node)
} catch (error) {
console.error('Model upload failed:', error)
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
@@ -110,6 +120,7 @@ async function handleResourcesUpload(files: FileList, node: LGraphNode) {
: '3d'
await Load3dUtils.uploadMultipleFiles(files, subfolder)
markLoad3dSceneDirty(node)
} catch (error) {
console.error('Extra resources upload failed:', error)
useToastStore().addAlert(t('toastMessages.extraResourcesUploadFailed'))
@@ -305,6 +316,7 @@ useExtensionService().registerExtension({
if (modelWidget) {
modelWidget.value = LOAD3D_NONE_MODEL
}
markLoad3dSceneDirty(node)
})
}
@@ -363,7 +375,8 @@ useExtensionService().registerExtension({
modelWidget,
cameraState,
width,
height
height,
onSceneInvalidated: () => markLoad3dSceneDirty(node)
})
})
@@ -381,6 +394,11 @@ useExtensionService().registerExtension({
return null
}
if (!isLoad3dSceneDirty(node)) {
const cached = getLoad3dOutputCache(node)
if (cached) return cached
}
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
@@ -412,7 +430,7 @@ useExtensionService().registerExtension({
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
const returnVal = {
const returnVal: Load3dCachedOutput = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
@@ -429,9 +447,11 @@ useExtensionService().registerExtension({
const [recording] = await Promise.all([
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
])
returnVal['recording'] = `threed/${recording.name} [temp]`
returnVal.recording = `threed/${recording.name} [temp]`
}
setLoad3dOutputCache(node, returnVal)
return returnVal
}
}

View File

@@ -682,3 +682,138 @@ describe('Load3DConfiguration "none" model handling', () => {
})
})
})
describe('Load3DConfiguration.onSceneInvalidated', () => {
function makeLoad3dMock(): Load3d {
return {
loadModel: vi.fn().mockResolvedValue(undefined),
clearModel: vi.fn(),
setUpDirection: vi.fn(),
setMaterialMode: vi.fn(),
setTargetSize: vi.fn(),
setCameraState: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundColor: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setBackgroundRenderMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setLightIntensity: vi.fn(),
setHDRIIntensity: vi.fn(),
setHDRIAsBackground: vi.fn(),
setHDRIEnabled: vi.fn(),
emitModelReady: vi.fn()
} as unknown as Load3d
}
async function flush() {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
beforeEach(() => {
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view')
})
it('width.callback invokes onSceneInvalidated', async () => {
const onSceneInvalidated = vi.fn()
const width = { value: 1024 } as unknown as IBaseWidget
const height = { value: 1024 } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget: { value: 'none' } as unknown as IBaseWidget,
loadFolder: 'input',
width,
height,
onSceneInvalidated
})
await flush()
width.callback!(2048)
expect(onSceneInvalidated).toHaveBeenCalledTimes(1)
})
it('height.callback invokes onSceneInvalidated', async () => {
const onSceneInvalidated = vi.fn()
const width = { value: 1024 } as unknown as IBaseWidget
const height = { value: 1024 } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget: { value: 'none' } as unknown as IBaseWidget,
loadFolder: 'input',
width,
height,
onSceneInvalidated
})
await flush()
height.callback!(2048)
expect(onSceneInvalidated).toHaveBeenCalledTimes(1)
})
it('model_file widget callback invokes onSceneInvalidated after the model loads', async () => {
const onSceneInvalidated = vi.fn()
const modelWidget = { value: 'none' } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget,
loadFolder: 'input',
onSceneInvalidated
})
await flush()
modelWidget.value = 'model.glb'
await flush()
expect(onSceneInvalidated).toHaveBeenCalled()
})
it('preserves any pre-existing model widget callback alongside the invalidation hook', async () => {
const onSceneInvalidated = vi.fn()
const original = vi.fn()
const modelWidget = {
value: 'none',
callback: original
} as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget,
loadFolder: 'input',
onSceneInvalidated
})
await flush()
modelWidget.value = 'model.glb'
await flush()
expect(original).toHaveBeenCalledWith('model.glb')
expect(onSceneInvalidated).toHaveBeenCalled()
})
it('callbacks remain safe when onSceneInvalidated is omitted', async () => {
const width = { value: 1024 } as unknown as IBaseWidget
const height = { value: 1024 } as unknown as IBaseWidget
const modelWidget = { value: 'none' } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget,
loadFolder: 'input',
width,
height
})
await flush()
expect(() => width.callback!(2048)).not.toThrow()
expect(() => height.callback!(2048)).not.toThrow()
expect(() => {
modelWidget.value = 'model.glb'
}).not.toThrow()
})
})

View File

@@ -23,6 +23,14 @@ type Load3DConfigurationSettings = {
height?: IBaseWidget
bgImagePath?: string
silentOnNotFound?: boolean
/**
* Called when a user-driven change to one of the wired widgets
* (model_file, width, height) makes the previously captured scene stale.
* Backend caching covers these inputs by themselves; this hook lets the
* caller invalidate any frontend-side capture cache so the next serialize
* re-renders at the new state.
*/
onSceneInvalidated?: () => void
}
const ANNOTATED_FILENAME_PATTERN = / \[(input|output|temp)\]$/
@@ -63,22 +71,33 @@ class Load3DConfiguration {
setting.modelWidget,
setting.loadFolder,
setting.cameraState,
setting.silentOnNotFound ?? false
setting.silentOnNotFound ?? false,
setting.onSceneInvalidated
)
this.setupTargetSize(
setting.width,
setting.height,
setting.onSceneInvalidated
)
this.setupTargetSize(setting.width, setting.height)
this.setupDefaultProperties(setting.bgImagePath)
}
private setupTargetSize(width?: IBaseWidget, height?: IBaseWidget) {
private setupTargetSize(
width?: IBaseWidget,
height?: IBaseWidget,
onSceneInvalidated?: () => void
) {
if (width && height) {
this.load3d.setTargetSize(width.value as number, height.value as number)
width.callback = (value: number) => {
this.load3d.setTargetSize(value, height.value as number)
onSceneInvalidated?.()
}
height.callback = (value: number) => {
this.load3d.setTargetSize(width.value as number, value)
onSceneInvalidated?.()
}
}
}
@@ -103,7 +122,8 @@ class Load3DConfiguration {
modelWidget: IBaseWidget,
loadFolder: string,
cameraState?: CameraState,
silentOnNotFound: boolean = false
silentOnNotFound: boolean = false,
onSceneInvalidated?: () => void
) {
const onModelWidgetUpdate = this.createModelUpdateHandler(
loadFolder,
@@ -137,6 +157,8 @@ class Load3DConfiguration {
if (originalCallback) {
originalCallback(value)
}
onSceneInvalidated?.()
}
}