Compare commits

...

1 Commits

Author SHA1 Message Date
Terry Jia
439e88f956 FE-734: Wire Preview3D / SaveGLB IMAGE+MASK outputs via render bridge 2026-05-21 13:45:44 -04:00
11 changed files with 1015 additions and 7 deletions

View File

@@ -74,7 +74,12 @@ vi.mock('@/scripts/domWidget', () => ({
}))
vi.mock('@/scripts/api', () => ({
api: { apiURL: (p: string) => p }
api: {
apiURL: (p: string) => p,
addEventListener: vi.fn(),
addCustomEventListener: vi.fn(),
fetchApi: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({

View File

@@ -4,6 +4,8 @@ import Load3D from '@/components/load3d/Load3D.vue'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
// Side-effect import: registers the WS listener for preview3d.render_request.
import '@/extensions/core/load3d/renderBridge'
import type {
CameraConfig,
CameraState
@@ -79,7 +81,7 @@ async function handleModelUpload(files: FileList, node: LGraphNode) {
useLoad3d(node).waitForLoad3d((load3d) => {
try {
load3d.loadModel(modelUrl)
load3d.loadModel(modelUrl, undefined, { force: true })
} catch (error) {
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
}

View File

@@ -877,6 +877,235 @@ describe('Load3d', () => {
})
})
describe('captureSceneFixedCamera', () => {
function setupForFixedCapture() {
const cameraStub = {
toggleCamera: vi.fn(),
getCurrentCameraType: vi.fn().mockReturnValue('perspective'),
setCameraState: vi.fn()
}
const controlsStub = {
controls: { update: vi.fn() }
}
const captureResult = {
scene: 'data:image/png;base64,scene',
mask: 'data:image/png;base64,mask',
normal: 'data:image/png;base64,normal'
}
const sceneCaptureMock = vi.fn().mockResolvedValue(captureResult)
Object.assign(ctx.load3d, {
cameraManager: cameraStub,
controlsManager: controlsStub,
sceneManager: {
...ctx.sceneManager,
gridHelper: { visible: true },
captureScene: sceneCaptureMock
},
modelManager: {
...ctx.modelManager,
currentModel: new THREE.Object3D()
}
})
return { cameraStub, controlsStub, sceneCaptureMock, captureResult }
}
const makeCameraState = (
cameraType: 'perspective' | 'orthographic' = 'perspective'
) => ({
position: new THREE.Vector3(1, 2, 3),
target: new THREE.Vector3(0, 0, 0),
zoom: 1,
cameraType
})
it('throws when no model is loaded', async () => {
Object.assign(ctx.load3d, {
modelManager: { ...ctx.modelManager, currentModel: null }
})
await expect(
ctx.load3d.captureSceneFixedCamera(512, 512)
).rejects.toThrow('No model loaded for fixed-camera capture')
})
it('captures without setting any camera state when none is provided', async () => {
const { cameraStub, sceneCaptureMock, captureResult } =
setupForFixedCapture()
const result = await ctx.load3d.captureSceneFixedCamera(512, 512)
expect(result).toBe(captureResult)
expect(sceneCaptureMock).toHaveBeenCalledWith(512, 512)
expect(cameraStub.setCameraState).not.toHaveBeenCalled()
expect(cameraStub.toggleCamera).not.toHaveBeenCalled()
})
it('hides the grid during capture and restores its prior visibility afterward', async () => {
const { sceneCaptureMock } = setupForFixedCapture()
const visibilityDuringCapture: boolean[] = []
sceneCaptureMock.mockImplementation(async () => {
visibilityDuringCapture.push(
(ctx.load3d.sceneManager as { gridHelper: { visible: boolean } })
.gridHelper.visible
)
return { scene: 's', mask: 'm', normal: 'n' }
})
await ctx.load3d.captureSceneFixedCamera(512, 512)
expect(visibilityDuringCapture).toEqual([false])
expect(
(ctx.load3d.sceneManager as { gridHelper: { visible: boolean } })
.gridHelper.visible
).toBe(true)
})
it('applies cameraState before capture when provided', async () => {
const { cameraStub, sceneCaptureMock } = setupForFixedCapture()
const cameraState = makeCameraState()
const setCameraBeforeCapture: number[] = []
cameraStub.setCameraState.mockImplementation(() =>
setCameraBeforeCapture.push(Date.now())
)
let captureRan = false
sceneCaptureMock.mockImplementation(async () => {
captureRan = true
// At least one setCameraState must have happened before render.
expect(setCameraBeforeCapture).toHaveLength(1)
return { scene: 's', mask: 'm', normal: 'n' }
})
await ctx.load3d.captureSceneFixedCamera(512, 512, cameraState)
expect(captureRan).toBe(true)
})
it('re-applies cameraState AFTER capture (race-defense against concurrent thumbnail capture)', async () => {
const { cameraStub } = setupForFixedCapture()
const cameraState = makeCameraState()
await ctx.load3d.captureSceneFixedCamera(512, 512, cameraState)
// The bridge fix: setCameraState must be called twice — once
// before the capture and once again after, so any racing
// captureThumbnail's finally cannot leave the viewer stuck on
// its restored pre-thumbnail state.
expect(cameraStub.setCameraState).toHaveBeenCalledTimes(2)
expect(cameraStub.setCameraState).toHaveBeenNthCalledWith(1, cameraState)
expect(cameraStub.setCameraState).toHaveBeenNthCalledWith(2, cameraState)
})
it('toggles the camera type when cameraState requests a different type', async () => {
const { cameraStub } = setupForFixedCapture()
cameraStub.getCurrentCameraType.mockReturnValue('perspective')
const cameraState = makeCameraState('orthographic')
await ctx.load3d.captureSceneFixedCamera(512, 512, cameraState)
expect(cameraStub.toggleCamera).toHaveBeenCalledWith('orthographic')
})
it('does not toggle the camera when cameraState matches the current type', async () => {
const { cameraStub } = setupForFixedCapture()
cameraStub.getCurrentCameraType.mockReturnValue('perspective')
await ctx.load3d.captureSceneFixedCamera(
512,
512,
makeCameraState('perspective')
)
expect(cameraStub.toggleCamera).not.toHaveBeenCalled()
})
it('still runs the grid + controls restore in finally when captureScene throws', async () => {
const { sceneCaptureMock } = setupForFixedCapture()
sceneCaptureMock.mockRejectedValueOnce(new Error('render error'))
await expect(
ctx.load3d.captureSceneFixedCamera(512, 512)
).rejects.toThrow('render error')
expect(ctx.forceRender).toHaveBeenCalled()
expect(
(ctx.load3d.sceneManager as { gridHelper: { visible: boolean } })
.gridHelper.visible
).toBe(true)
})
})
describe('loadModel same-URL short-circuit', () => {
function setupForLoad(currentLoadedUrl: string | null, hasModel: boolean) {
const internal = vi.fn().mockResolvedValue(undefined)
Object.assign(ctx.load3d, {
_loadGeneration: 0,
loadingPromise: null,
currentLoadedUrl,
_loadModelInternal: internal,
modelManager: {
...ctx.modelManager,
currentModel: hasModel ? new THREE.Object3D() : null
}
})
return { internal }
}
it('does NOT call _loadModelInternal when URL matches and a model is loaded', async () => {
const { internal } = setupForLoad('/api/view?filename=a.glb', true)
await ctx.load3d.loadModel('/api/view?filename=a.glb')
expect(internal).not.toHaveBeenCalled()
})
it('still ticks _loadGeneration on short-circuit (callers tracking generation see the bump)', async () => {
setupForLoad('/api/view?filename=a.glb', true)
const baseline = ctx.load3d.currentLoadGeneration
await ctx.load3d.loadModel('/api/view?filename=a.glb')
expect(ctx.load3d.currentLoadGeneration).toBe(baseline + 1)
})
it('normalizes away the &rand=... cache-bust param so two URLs that only differ in rand still short-circuit', async () => {
const { internal } = setupForLoad(
'/api/view?filename=a.glb&rand=0.1',
true
)
await ctx.load3d.loadModel('/api/view?filename=a.glb&rand=0.9')
expect(internal).not.toHaveBeenCalled()
})
it('falls through to a full load when the URL genuinely differs', async () => {
const { internal } = setupForLoad('/api/view?filename=a.glb', true)
await ctx.load3d.loadModel('/api/view?filename=b.glb')
expect(internal).toHaveBeenCalledOnce()
})
it('falls through when there is no current model even if the URL matches', async () => {
const { internal } = setupForLoad('/api/view?filename=a.glb', false)
await ctx.load3d.loadModel('/api/view?filename=a.glb')
expect(internal).toHaveBeenCalledOnce()
})
it('falls through when force: true is passed even if the URL matches', async () => {
const { internal } = setupForLoad('/api/view?filename=a.glb', true)
await ctx.load3d.loadModel('/api/view?filename=a.glb', undefined, {
force: true
})
expect(internal).toHaveBeenCalledOnce()
})
})
describe('exportModel', () => {
beforeEach(() => {
cloneSkinnedMock.mockReset()

View File

@@ -74,6 +74,7 @@ class Load3d {
private renderLoop: RenderLoopHandle | null = null
private loadingPromise: Promise<void> | null = null
private _loadGeneration: number = 0
private currentLoadedUrl: string | null = null
private onContextMenuCallback?: (event: MouseEvent) => void
private getDimensionsCallback?: () => { width: number; height: number } | null
@@ -540,6 +541,18 @@ class Load3d {
): Promise<void> {
this._loadGeneration += 1
const normalize = (u: string | null | undefined) =>
typeof u === 'string' ? u.replace(/[?&]rand=[^&]*/g, '') : ''
if (
!options?.force &&
this.currentLoadedUrl !== null &&
normalize(this.currentLoadedUrl) === normalize(url) &&
this.modelManager.currentModel
) {
return
}
if (this.loadingPromise) {
try {
await this.loadingPromise
@@ -574,6 +587,7 @@ class Load3d {
this.gizmoManager.detach()
this.modelManager.clearModel()
this.animationManager.dispose()
this.currentLoadedUrl = null
await this.loaderManager.loadModel(url, originalFileName, options)
@@ -583,6 +597,7 @@ class Load3d {
this.modelManager.currentModel,
this.modelManager.originalModel
)
this.currentLoadedUrl = url
}
this.handleResize()
@@ -607,6 +622,7 @@ class Load3d {
this.gizmoManager.detach()
this.modelManager.clearModel()
this.adapterRef.current = null
this.currentLoadedUrl = null
this.forceRender()
}
@@ -802,6 +818,52 @@ class Load3d {
this.forceRender()
}
public async captureSceneFixedCamera(
width: number,
height: number,
cameraState?: CameraState
): Promise<CaptureResult> {
if (!this.modelManager.currentModel) {
throw new Error('No model loaded for fixed-camera capture')
}
const savedGridVisible = this.sceneManager.gridHelper.visible
try {
this.sceneManager.gridHelper.visible = false
if (cameraState) {
if (
this.cameraManager.getCurrentCameraType() !== cameraState.cameraType
) {
this.cameraManager.toggleCamera(cameraState.cameraType)
}
this.cameraManager.setCameraState(cameraState)
}
const result = await this.captureScene(width, height)
// Re-apply cameraState here, AFTER the await. useLoad3d's
// modelReady event handler kicks off its own captureThumbnail
// for asset-preview persistence concurrently with this call.
// captureScene's body is internally synchronous, but the
// await-continuations run as microtasks in registration order:
// captureThumbnail's continuation (its finally restores the
// pre-thumbnail camera state) runs BEFORE ours. Without this
// re-apply, that restore clobbers cameraState and the viewer
// snaps back to the model's default fit-to-bbox.
if (cameraState) {
this.cameraManager.setCameraState(cameraState)
}
return result
} finally {
this.sceneManager.gridHelper.visible = savedGridVisible
this.controlsManager.controls?.update()
this.forceRender()
}
}
public async captureThumbnail(
width: number = 256,
height: number = 256

View File

@@ -0,0 +1,45 @@
import * as THREE from 'three'
import { describe, expect, it } from 'vitest'
import { toCameraState } from '@/extensions/core/load3d/cameraInfo'
describe('toCameraState', () => {
it('builds Vector3 instances from plain {x,y,z} objects', () => {
const state = toCameraState({
position: { x: 1, y: 2, z: 3 },
target: { x: 4, y: 5, z: 6 },
zoom: 1.5,
cameraType: 'perspective'
})
expect(state.position).toBeInstanceOf(THREE.Vector3)
expect(state.target).toBeInstanceOf(THREE.Vector3)
expect(state.position.toArray()).toEqual([1, 2, 3])
expect(state.target.toArray()).toEqual([4, 5, 6])
})
it('passes zoom and cameraType through unchanged', () => {
const state = toCameraState({
position: { x: 0, y: 0, z: 0 },
target: { x: 0, y: 0, z: 0 },
zoom: 2,
cameraType: 'orthographic'
})
expect(state.zoom).toBe(2)
expect(state.cameraType).toBe('orthographic')
})
it('does not alias the input — mutating the result leaves the input intact', () => {
const input = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective' as const
}
const state = toCameraState(input)
state.position.set(99, 99, 99)
expect(input.position).toEqual({ x: 1, y: 2, z: 3 })
})
})

View File

@@ -0,0 +1,28 @@
/**
* Wire format for Load3DCamera.CameraInfo coming from the backend
* (Python `Load3DCamera.CameraInfo` TypedDict). Vectors arrive as plain
* {x, y, z} objects after JSON serialization.
*/
import * as THREE from 'three'
import type { CameraState, CameraType } from './interfaces'
export interface CameraInfoSerialized {
position: { x: number; y: number; z: number }
target: { x: number; y: number; z: number }
zoom: number
cameraType: CameraType
}
export function toCameraState(info: CameraInfoSerialized): CameraState {
return {
position: new THREE.Vector3(
info.position.x,
info.position.y,
info.position.z
),
target: new THREE.Vector3(info.target.x, info.target.y, info.target.z),
zoom: info.zoom,
cameraType: info.cameraType
}
}

View File

@@ -212,6 +212,12 @@ export interface LoadModelOptions {
* (e.g. shared workflows on a fresh machine).
*/
silentOnNotFound?: boolean
/**
* Bypass the "same URL already loaded" short-circuit. Use when the
* file may have been overwritten on disk under the same name and the
* caller actually wants a fresh fetch + setupForModel pass.
*/
force?: boolean
}
export interface LoaderManagerInterface {

View File

@@ -0,0 +1,357 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
addCustomEventListenerMock,
fetchApiMock,
getNodeByLocatorIdMock,
nodeToLoad3dMap,
configureForSaveMeshMock,
uploadTempImageMock
} = vi.hoisted(() => ({
addCustomEventListenerMock: vi.fn(),
fetchApiMock: vi.fn(),
getNodeByLocatorIdMock: vi.fn(),
nodeToLoad3dMap: new Map<unknown, unknown>(),
configureForSaveMeshMock: vi.fn(),
uploadTempImageMock: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: {
addCustomEventListener: addCustomEventListenerMock,
fetchApi: fetchApiMock
}
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: {} }
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByLocatorId: getNodeByLocatorIdMock
}))
vi.mock('@/composables/useLoad3d', () => ({
nodeToLoad3dMap
}))
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
default: class {
configureForSaveMesh = configureForSaveMeshMock
}
}))
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
uploadTempImage: uploadTempImageMock
}
}))
type RenderRequestPayload = {
render_id: string
node_id: string
file_path: string
type: 'input' | 'output' | 'temp'
width?: number
height?: number
camera_info?: {
position: { x: number; y: number; z: number }
target: { x: number; y: number; z: number }
zoom: number
cameraType: 'perspective' | 'orthographic'
} | null
}
async function loadRenderBridge() {
vi.resetModules()
addCustomEventListenerMock.mockClear()
await import('@/extensions/core/load3d/renderBridge')
}
function getRegisteredHandler(): (event: CustomEvent<unknown>) => void {
const call = addCustomEventListenerMock.mock.calls[0]
expect(call).toBeDefined()
expect(call[0]).toBe('preview3d.render_request')
return call[1] as (event: CustomEvent<unknown>) => void
}
function makeLoad3d(
overrides: Partial<{
whenLoadIdle: ReturnType<typeof vi.fn>
captureSceneFixedCamera: ReturnType<typeof vi.fn>
handleResize: ReturnType<typeof vi.fn>
}> = {}
) {
return {
whenLoadIdle:
overrides.whenLoadIdle ?? vi.fn().mockResolvedValue(undefined),
captureSceneFixedCamera:
overrides.captureSceneFixedCamera ??
vi.fn().mockResolvedValue({
scene: 'data:scene',
mask: 'data:mask',
normal: 'data:normal'
}),
handleResize: overrides.handleResize ?? vi.fn()
}
}
function makePayload(
overrides: Partial<RenderRequestPayload> = {}
): RenderRequestPayload {
return {
render_id: 'rid-1',
node_id: 'node-1',
file_path: '3d/mesh.glb',
type: 'output',
width: 512,
height: 512,
...overrides
}
}
async function dispatchAndFlush(
handler: (event: CustomEvent<unknown>) => void,
detail: RenderRequestPayload | null
) {
handler({ detail } as CustomEvent<unknown>)
// Let the fire-and-forget handler microtasks settle.
await new Promise((r) => setTimeout(r, 0))
}
describe('renderBridge', () => {
beforeEach(() => {
vi.clearAllMocks()
nodeToLoad3dMap.clear()
fetchApiMock.mockResolvedValue({ ok: true })
})
it('registers a "preview3d.render_request" listener at module load', async () => {
await loadRenderBridge()
expect(addCustomEventListenerMock).toHaveBeenCalledOnce()
expect(addCustomEventListenerMock.mock.calls[0][0]).toBe(
'preview3d.render_request'
)
})
it('ignores events without a render_id (does not POST a response)', async () => {
await loadRenderBridge()
const handler = getRegisteredHandler()
await dispatchAndFlush(handler, null)
handler({ detail: { render_id: '' } } as CustomEvent<unknown>)
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('POSTs an error when the node id cannot be resolved', async () => {
await loadRenderBridge()
getNodeByLocatorIdMock.mockReturnValue(null)
const handler = getRegisteredHandler()
await dispatchAndFlush(handler, makePayload())
expect(fetchApiMock).toHaveBeenCalledOnce()
const body = JSON.parse(fetchApiMock.mock.calls[0][1].body as string)
expect(body.render_id).toBe('rid-1')
expect(body.error).toContain('node node-1 not found')
})
it('POSTs an error when the node has no load3d instance', async () => {
await loadRenderBridge()
const node = { id: 1 }
getNodeByLocatorIdMock.mockReturnValue(node)
// intentionally don't add to nodeToLoad3dMap
const handler = getRegisteredHandler()
await dispatchAndFlush(handler, makePayload())
const body = JSON.parse(fetchApiMock.mock.calls[0][1].body as string)
expect(body.error).toContain('load3d instance not available')
})
it('captures, uploads, and POSTs image/mask paths on success', async () => {
await loadRenderBridge()
const node = { properties: {} }
const load3d = makeLoad3d()
getNodeByLocatorIdMock.mockReturnValue(node)
nodeToLoad3dMap.set(node, load3d)
uploadTempImageMock
.mockResolvedValueOnce({ name: 'scene_123.png' })
.mockResolvedValueOnce({ name: 'mask_123.png' })
const handler = getRegisteredHandler()
await dispatchAndFlush(handler, makePayload())
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'3d/mesh.glb',
{ silentOnNotFound: true }
)
expect(load3d.whenLoadIdle).toHaveBeenCalled()
expect(load3d.captureSceneFixedCamera).toHaveBeenCalledWith(
512,
512,
undefined
)
expect(uploadTempImageMock).toHaveBeenNthCalledWith(
1,
'data:scene',
'preview3d_scene'
)
expect(uploadTempImageMock).toHaveBeenNthCalledWith(
2,
'data:mask',
'preview3d_mask'
)
expect(load3d.handleResize).toHaveBeenCalled()
const body = JSON.parse(fetchApiMock.mock.calls[0][1].body as string)
expect(body).toEqual({
render_id: 'rid-1',
image: 'threed/scene_123.png [temp]',
mask: 'threed/mask_123.png [temp]'
})
})
it('passes upstream camera_info (priority 1) to captureSceneFixedCamera', async () => {
await loadRenderBridge()
const node = {
properties: {
// Should be ignored because payload.camera_info wins.
'Camera Config': {
state: {
position: { x: 99, y: 99, z: 99 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
}
}
}
}
const load3d = makeLoad3d()
getNodeByLocatorIdMock.mockReturnValue(node)
nodeToLoad3dMap.set(node, load3d)
uploadTempImageMock.mockResolvedValue({ name: 'x.png' })
const handler = getRegisteredHandler()
await dispatchAndFlush(
handler,
makePayload({
camera_info: {
position: { x: 1, y: 2, z: 3 },
target: { x: 4, y: 5, z: 6 },
zoom: 2,
cameraType: 'perspective'
}
})
)
const [w, h, cameraState] = load3d.captureSceneFixedCamera.mock.calls[0]
expect(w).toBe(512)
expect(h).toBe(512)
expect(cameraState.position.toArray()).toEqual([1, 2, 3])
expect(cameraState.target.toArray()).toEqual([4, 5, 6])
expect(cameraState.zoom).toBe(2)
})
it('falls back to node.properties Camera Config (priority 2) when no upstream camera_info', async () => {
await loadRenderBridge()
const node = {
properties: {
'Camera Config': {
state: {
position: { x: 7, y: 8, z: 9 },
target: { x: 1, y: 1, z: 1 },
zoom: 3,
cameraType: 'perspective'
}
}
}
}
const load3d = makeLoad3d()
getNodeByLocatorIdMock.mockReturnValue(node)
nodeToLoad3dMap.set(node, load3d)
uploadTempImageMock.mockResolvedValue({ name: 'x.png' })
const handler = getRegisteredHandler()
await dispatchAndFlush(handler, makePayload())
const cameraState = load3d.captureSceneFixedCamera.mock.calls[0][2]
expect(cameraState).toBeDefined()
expect(cameraState.position.toArray()).toEqual([7, 8, 9])
expect(cameraState.zoom).toBe(3)
})
it('passes undefined (priority 3) when neither input nor saved Camera Config exists', async () => {
await loadRenderBridge()
const node = { properties: {} }
const load3d = makeLoad3d()
getNodeByLocatorIdMock.mockReturnValue(node)
nodeToLoad3dMap.set(node, load3d)
uploadTempImageMock.mockResolvedValue({ name: 'x.png' })
const handler = getRegisteredHandler()
await dispatchAndFlush(handler, makePayload())
const cameraState = load3d.captureSceneFixedCamera.mock.calls[0][2]
expect(cameraState).toBeUndefined()
})
it('POSTs an error response when captureSceneFixedCamera throws', async () => {
await loadRenderBridge()
const node = { properties: {} }
const load3d = makeLoad3d({
captureSceneFixedCamera: vi.fn().mockRejectedValue(new Error('boom'))
})
getNodeByLocatorIdMock.mockReturnValue(node)
nodeToLoad3dMap.set(node, load3d)
const handler = getRegisteredHandler()
await dispatchAndFlush(handler, makePayload())
const body = JSON.parse(fetchApiMock.mock.calls[0][1].body as string)
expect(body.render_id).toBe('rid-1')
expect(body.error).toBe('boom')
})
it('falls back to the frontend default resolution when payload omits width/height', async () => {
await loadRenderBridge()
const node = { properties: {} }
const load3d = makeLoad3d()
getNodeByLocatorIdMock.mockReturnValue(node)
nodeToLoad3dMap.set(node, load3d)
uploadTempImageMock.mockResolvedValue({ name: 'x.png' })
const handler = getRegisteredHandler()
const payload = makePayload()
// Backend now omits width/height — frontend should default to 512.
delete payload.width
delete payload.height
await dispatchAndFlush(handler, payload)
expect(load3d.captureSceneFixedCamera).toHaveBeenCalledWith(
512,
512,
undefined
)
})
it('honors payload.type=input when constructing the load folder', async () => {
await loadRenderBridge()
const node = { properties: {} }
const load3d = makeLoad3d()
getNodeByLocatorIdMock.mockReturnValue(node)
nodeToLoad3dMap.set(node, load3d)
uploadTempImageMock.mockResolvedValue({ name: 'x.png' })
const handler = getRegisteredHandler()
await dispatchAndFlush(handler, makePayload({ type: 'input' }))
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'input',
'3d/mesh.glb',
{ silentOnNotFound: true }
)
})
})

View File

@@ -0,0 +1,134 @@
/**
* Round-trip render bridge for Preview3D / SaveGLB IMAGE+MASK outputs.
*
* The backend node sends a "preview3d.render_request" websocket event with
* {render_id, node_id, file_path, type, camera_info?}. We find the
* matching node, load the model, capture at the (frontend-defaulted)
* resolution with the derived camera state, upload the PNGs to /temp/,
* then POST {render_id, image, mask} back to /3d/render_response so the
* awaiting backend Future resolves.
*/
import { nodeToLoad3dMap } from '@/composables/useLoad3d'
import {
type CameraInfoSerialized,
toCameraState
} from '@/extensions/core/load3d/cameraInfo'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import type { CameraState } from '@/extensions/core/load3d/interfaces'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
interface RenderRequestPayload {
render_id: string
node_id: string
file_path: string
type: 'input' | 'output' | 'temp'
width?: number
height?: number
camera_info?: CameraInfoSerialized | null
}
const DEFAULT_RENDER_WIDTH = 512
const DEFAULT_RENDER_HEIGHT = 512
async function postResponse(
render_id: string,
body: { image?: string; mask?: string; error?: string }
) {
try {
await api.fetchApi('/3d/render_response', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ render_id, ...body })
})
} catch (error) {
// POST failure leaves the backend Future to time out on its own.
// Log for observability and debugging.
console.error(
`[Preview3D bridge] failed to POST render response for render_id=${render_id}:`,
error
)
}
}
async function handleRenderRequest(payload: RenderRequestPayload) {
const { render_id, node_id, file_path, type } = payload
const width = payload.width ?? DEFAULT_RENDER_WIDTH
const height = payload.height ?? DEFAULT_RENDER_HEIGHT
const node = getNodeByLocatorId(app.rootGraph, node_id)
if (!node) {
await postResponse(render_id, { error: `node ${node_id} not found` })
return
}
const load3d = nodeToLoad3dMap.get(node)
if (!load3d) {
// Realistic only if the node was deleted between queue and execute.
await postResponse(render_id, {
error: `load3d instance not available for node ${node_id}`
})
return
}
try {
const folder: 'input' | 'output' = type === 'input' ? 'input' : 'output'
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh(folder, file_path, { silentOnNotFound: true })
await load3d.whenLoadIdle()
// Camera priority:
// 1. payload.camera_info (upstream camera_info node input) — overwrites
// everything, including user's manual adjustments
// 2. node.properties['Camera Config'].state — the user's persisted manual
// adjustments (tracked by useLoad3d's cameraChanged handler)
// 3. undefined — leave the viewer at its current state (which after the
// load is the model's default fit-to-bbox)
let cameraState: CameraState | undefined
if (payload.camera_info) {
cameraState = toCameraState(payload.camera_info)
} else {
const savedConfig = node.properties['Camera Config'] as
| { state?: CameraInfoSerialized }
| undefined
if (savedConfig?.state) {
cameraState = toCameraState(savedConfig.state)
}
}
const result = await load3d.captureSceneFixedCamera(
width,
height,
cameraState
)
const [imageUpload, maskUpload] = await Promise.all([
Load3dUtils.uploadTempImage(result.scene, 'preview3d_scene'),
Load3dUtils.uploadTempImage(result.mask, 'preview3d_mask')
])
load3d.handleResize()
await postResponse(render_id, {
image: `threed/${imageUpload.name} [temp]`,
mask: `threed/${maskUpload.name} [temp]`
})
} catch (error) {
await postResponse(render_id, {
error: error instanceof Error ? error.message : String(error)
})
}
}
api.addCustomEventListener(
'preview3d.render_request',
(event: CustomEvent<unknown>) => {
const detail = event.detail as RenderRequestPayload | null
if (!detail || !detail.render_id) return
void handleRenderRequest(detail)
}
)

View File

@@ -340,4 +340,119 @@ describe('Comfy.SaveGLB.onNodeOutputsUpdated', () => {
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('re-applies camera_info when present even if the same model is already loaded', async () => {
const setCameraStateMock = vi.fn()
waitForLoad3dMock.mockImplementation((cb: (load3d: unknown) => void) => {
cb({
whenLoadIdle: () => Promise.resolve(),
captureThumbnail: vi.fn(),
setCameraState: setCameraStateMock
})
})
const ext = await loadSaveMeshExtensionFresh()
const node = makeNode({
properties: {
'Last Time Model File': 'sub/mesh.glb',
'Last Time Model Folder': 'output'
}
})
;(
node.widgets!.find((w) => w.name === 'image') as { value: string }
).value = 'sub/mesh.glb'
getNodeByLocatorIdMock.mockReturnValue(node)
ext.onNodeOutputsUpdated!({
'7': {
'3d': [{ filename: 'mesh.glb', subfolder: 'sub', type: 'output' }],
camera_info: [
{
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
}
]
}
} as never)
// The early-return short-circuit must NOT trigger when cameraInfo is
// present — we need a full path through to setCameraState.
expect(configureForSaveMeshMock).toHaveBeenCalledOnce()
// setCameraState lives in a whenLoadIdle().then() chain — let it settle.
await new Promise((r) => setTimeout(r, 0))
expect(setCameraStateMock).toHaveBeenCalledOnce()
const state = setCameraStateMock.mock.calls[0][0]
expect(state.position.toArray()).toEqual([1, 2, 3])
})
})
describe('Comfy.SaveGLB.onExecuted with camera_info', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('applies output.camera_info via setCameraState after the load idles', async () => {
const setCameraStateMock = vi.fn()
const fakeLoad3d = {
whenLoadIdle: () => Promise.resolve(),
captureThumbnail: vi.fn(),
setCameraState: setCameraStateMock
}
waitForLoad3dMock.mockImplementation((cb: (load3d: unknown) => void) => {
cb(fakeLoad3d)
})
onLoad3dReadyMock.mockImplementation((cb: (load3d: unknown) => void) => {
cb(fakeLoad3d)
})
const ext = await loadSaveMeshExtensionFresh()
const node = makeNode()
await ext.nodeCreated(node)
node.onExecuted!({
'3d': [{ filename: 'mesh.glb', subfolder: 'sub', type: 'output' }],
camera_info: [
{
position: { x: 9, y: 8, z: 7 },
target: { x: 0, y: 0, z: 0 },
zoom: 2,
cameraType: 'perspective'
}
]
})
await new Promise((r) => setTimeout(r, 0))
expect(setCameraStateMock).toHaveBeenCalledOnce()
const state = setCameraStateMock.mock.calls[0][0]
expect(state.position.toArray()).toEqual([9, 8, 7])
expect(state.zoom).toBe(2)
})
it('does NOT call setCameraState when output has no camera_info', async () => {
const setCameraStateMock = vi.fn()
const fakeLoad3d = {
whenLoadIdle: () => Promise.resolve(),
captureThumbnail: vi.fn(),
setCameraState: setCameraStateMock
}
waitForLoad3dMock.mockImplementation((cb: (load3d: unknown) => void) => {
cb(fakeLoad3d)
})
onLoad3dReadyMock.mockImplementation((cb: (load3d: unknown) => void) => {
cb(fakeLoad3d)
})
const ext = await loadSaveMeshExtensionFresh()
const node = makeNode()
await ext.nodeCreated(node)
node.onExecuted!({
'3d': [{ filename: 'mesh.glb', subfolder: 'sub', type: 'output' }]
})
await new Promise((r) => setTimeout(r, 0))
expect(setCameraStateMock).not.toHaveBeenCalled()
})
})

View File

@@ -2,6 +2,10 @@ import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import { useLoad3d } from '@/composables/useLoad3d'
import {
type CameraInfoSerialized,
toCameraState
} from '@/extensions/core/load3d/cameraInfo'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -15,6 +19,7 @@ import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
type SaveMeshOutput = NodeOutputWith<{
'3d'?: ResultItem[]
camera_info?: [CameraInfoSerialized]
}>
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
@@ -34,7 +39,11 @@ const inputSpec: CustomInputSpec = {
isPreview: true
}
function applySaveGLBOutput(node: LGraphNode, fileInfo: ResultItem): void {
function applySaveGLBOutput(
node: LGraphNode,
fileInfo: ResultItem,
cameraInfo?: CameraInfoSerialized
): void {
const filePath = (fileInfo.subfolder ?? '') + '/' + (fileInfo.filename ?? '')
const loadFolder = fileInfo.type as 'input' | 'output'
@@ -44,7 +53,8 @@ function applySaveGLBOutput(node: LGraphNode, fileInfo: ResultItem): void {
if (
modelWidget.value === filePath &&
node.properties['Last Time Model File'] === filePath &&
node.properties['Last Time Model Folder'] === loadFolder
node.properties['Last Time Model Folder'] === loadFolder &&
!cameraInfo
) {
return
}
@@ -60,6 +70,13 @@ function applySaveGLBOutput(node: LGraphNode, fileInfo: ResultItem): void {
silentOnNotFound: true
})
if (cameraInfo) {
void load3d
.whenLoadIdle()
.then(() => load3d.setCameraState(toCameraState(cameraInfo)))
.catch(() => {})
}
if (isAssetPreviewSupported()) {
const filename = fileInfo.filename ?? ''
void load3d
@@ -95,7 +112,8 @@ useExtensionService().registerExtension({
const node = getNodeByLocatorId(app.rootGraph, locatorId)
if (!node || node.constructor.comfyClass !== 'SaveGLB') continue
applySaveGLBOutput(node, fileInfo)
const cameraInfo = (output as SaveMeshOutput).camera_info?.[0]
applySaveGLBOutput(node, fileInfo, cameraInfo)
}
},
@@ -183,17 +201,24 @@ useExtensionService().registerExtension({
modelWidget.value = filePath
const config = new Load3DConfiguration(load3d, node.properties)
const loadFolder = fileInfo.type as 'input' | 'output'
node.properties['Last Time Model File'] = filePath
node.properties['Last Time Model Folder'] = loadFolder
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh(loadFolder, filePath, {
silentOnNotFound: true
})
const cameraInfo = output.camera_info?.[0]
if (cameraInfo) {
void load3d
.whenLoadIdle()
.then(() => load3d.setCameraState(toCameraState(cameraInfo)))
.catch(() => {})
}
if (isAssetPreviewSupported()) {
const filename = fileInfo.filename ?? ''