Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions browser_tests/tests/load3d/load3dLod.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect } from '@playwright/test'

import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'

test.describe('Load3D LOD', () => {
test(
'canvas pixel dimensions scale with ComfyUI canvas zoom level',
{ tag: '@smoke' },
async ({ comfyPage, load3d }) => {
await expect(load3d.canvas).toBeVisible()

await expect
.poll(() => load3d.canvas.evaluate((el: HTMLCanvasElement) => el.width))
.toBeGreaterThan(0)

const initialWidth = await load3d.canvas.evaluate(
(el: HTMLCanvasElement) => el.width
)

await comfyPage.page.evaluate(() => {
const node = window.app!.graph!.nodes[0]
window.app!.canvas.ds.scale = 2.0
node.onResize?.(node.size)
})
await comfyPage.nextFrame()

await expect
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: This assertion is currently failing in CI on this PR head. The log shows initialWidth is 374, and after canvasOps.zoom(-120, 5) the polled canvas.width is still 374 through all retries. Since this test is the regression coverage for the feature, we should fix the actual zoom-to-resize path (or the test setup if it is not exercising the real zoom path) before merging.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commits. The test now calls node.onResize(node.size) directly after setting ds.scale = 2.0, which invokes load3d.handleResize() via the onResize chain without relying on the initScaleSync → appScalePercentage → Vue watch path (which requires GraphCanvasMenu to be mounted). CI passes on the latest head.

.poll(() => load3d.canvas.evaluate((el: HTMLCanvasElement) => el.width))
.toBeGreaterThan(initialWidth)
}
)
})
85 changes: 84 additions & 1 deletion src/composables/useLoad3d.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref, shallowRef } from 'vue'
import { nextTick, reactive, ref, shallowRef } from 'vue'
import type { Pinia } from 'pinia'
import { getActivePinia } from 'pinia'

import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import Load3d from '@/extensions/core/load3d/Load3d'
Expand All @@ -9,6 +11,7 @@ import type { Size } from '@/lib/litegraph/src/interfaces'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import {
Expand Down Expand Up @@ -59,6 +62,18 @@ vi.mock('@/i18n', () => ({
t: vi.fn((key) => key)
}))

vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as Record<string, unknown>),
getActivePinia: vi.fn(() => null)
}
})

vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))

describe('useLoad3d', () => {
let mockLoad3d: Partial<Load3d>
let mockNode: LGraphNode
Expand All @@ -67,6 +82,7 @@ describe('useLoad3d', () => {
beforeEach(() => {
vi.clearAllMocks()
nodeToLoad3dMap.clear()
vi.mocked(getActivePinia).mockReturnValue(null as unknown as Pinia)

mockNode = createMockLGraphNode({
properties: {
Expand Down Expand Up @@ -334,6 +350,73 @@ describe('useLoad3d', () => {

expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
})

it('passes getZoomScale callback to createLoad3d', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')

await composable.initializeLoad3d(containerRef)

expect(createLoad3d).toHaveBeenCalledWith(
containerRef,
expect.objectContaining({ getZoomScale: expect.any(Function) })
)
})
})

describe('zoom watcher', () => {
it('calls load3d.handleResize after debounce when canvas appScalePercentage changes', async () => {
vi.useFakeTimers()

const canvasStore = reactive({ appScalePercentage: 100 })
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
vi.mocked(useCanvasStore).mockReturnValue(
canvasStore as unknown as ReturnType<typeof useCanvasStore>
)

const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)

vi.mocked(mockLoad3d.handleResize!).mockClear()

canvasStore.appScalePercentage = 200
await nextTick()
expect(mockLoad3d.handleResize).not.toHaveBeenCalled()

vi.advanceTimersByTime(150)
expect(mockLoad3d.handleResize).toHaveBeenCalledOnce()

vi.useRealTimers()
})

it('debounces rapid zoom changes into a single handleResize call', async () => {
vi.useFakeTimers()

const canvasStore = reactive({ appScalePercentage: 100 })
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
vi.mocked(useCanvasStore).mockReturnValue(
canvasStore as unknown as ReturnType<typeof useCanvasStore>
)

const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)

vi.mocked(mockLoad3d.handleResize!).mockClear()

canvasStore.appScalePercentage = 150
await nextTick()
canvasStore.appScalePercentage = 200
await nextTick()
canvasStore.appScalePercentage = 250
await nextTick()

vi.advanceTimersByTime(150)
expect(mockLoad3d.handleResize).toHaveBeenCalledOnce()

vi.useRealTimers()
})
})

describe('preserves existing node callbacks through initializeLoad3d', () => {
Expand Down
13 changes: 12 additions & 1 deletion src/composables/useLoad3d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { MaybeRef } from 'vue'

import { toRef } from '@vueuse/core'
import { toRef, useDebounceFn } from '@vueuse/core'
import { getActivePinia } from 'pinia'
import { ref, toRaw, watch } from 'vue'

Expand Down Expand Up @@ -31,6 +31,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useLoad3dService } from '@/services/load3dService'
Expand All @@ -44,6 +45,15 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
let load3d: Load3d | null = null
let isFirstModelLoad = true

const debouncedHandleResize = useDebounceFn(() => {
load3d?.handleResize()
}, 150)

watch(
() => (getActivePinia() ? useCanvasStore().appScalePercentage : 0),
debouncedHandleResize
)

const sceneConfig = ref<SceneConfig>({
showGrid: true,
backgroundColor: '#000000',
Expand Down Expand Up @@ -132,6 +142,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
height: heightWidget.value as number
})
: undefined,
getZoomScale: () => app.canvas?.ds?.scale ?? 1,
onContextMenu: (event) => {
const menuOptions = app.canvas.getNodeMenuOptions(node)
new LiteGraph.ContextMenu(menuOptions, {
Expand Down
66 changes: 65 additions & 1 deletion src/extensions/core/load3d/Load3d.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ describe('Load3d', () => {
const sceneResize = vi.fn()

Object.assign(ctx.load3d, {
renderer: { domElement: canvas, setSize },
renderer: { domElement: canvas, setSize, setPixelRatio: vi.fn() },
targetWidth: 400,
targetHeight: 200,
targetAspectRatio: 2,
Expand Down Expand Up @@ -383,6 +383,70 @@ describe('Load3d', () => {
expect(args[2]).toBe(800)
expect(args[3]).toBe(400)
})

it('handleResize calls setPixelRatio with the value returned by getZoomScaleCallback', () => {
delete (ctx.load3d as { handleResize?: unknown }).handleResize

const parent = document.createElement('div')
Object.defineProperty(parent, 'clientWidth', {
value: 400,
configurable: true
})
Object.defineProperty(parent, 'clientHeight', {
value: 400,
configurable: true
})
const canvas = document.createElement('canvas')
parent.appendChild(canvas)

const setPixelRatio = vi.fn()

Object.assign(ctx.load3d, {
renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio },
getZoomScaleCallback: () => 2.5,
targetWidth: 0,
targetHeight: 0,
isViewerMode: false,
cameraManager: { ...ctx.cameraManager, handleResize: vi.fn() },
sceneManager: { ...ctx.sceneManager, handleResize: vi.fn() }
})

ctx.load3d.handleResize()

expect(setPixelRatio).toHaveBeenCalledWith(2.5)
})

it('handleResize defaults to pixelRatio 1 when no getZoomScaleCallback is provided', () => {
delete (ctx.load3d as { handleResize?: unknown }).handleResize

const parent = document.createElement('div')
Object.defineProperty(parent, 'clientWidth', {
value: 400,
configurable: true
})
Object.defineProperty(parent, 'clientHeight', {
value: 400,
configurable: true
})
const canvas = document.createElement('canvas')
parent.appendChild(canvas)

const setPixelRatio = vi.fn()

Object.assign(ctx.load3d, {
renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio },
getZoomScaleCallback: undefined,
targetWidth: 0,
targetHeight: 0,
isViewerMode: false,
cameraManager: { ...ctx.cameraManager, handleResize: vi.fn() },
sceneManager: { ...ctx.sceneManager, handleResize: vi.fn() }
})

ctx.load3d.handleResize()

expect(setPixelRatio).toHaveBeenCalledWith(1)
})
})

describe('render loop wiring', () => {
Expand Down
7 changes: 7 additions & 0 deletions src/extensions/core/load3d/Load3d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ class Load3d {

private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined

constructor(
container: Element | HTMLElement,
Expand All @@ -111,6 +112,7 @@ class Load3d {
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
this.getDimensionsCallback = options.getDimensions
this.getZoomScaleCallback = options.getZoomScale

if (options.width && options.height) {
this.targetWidth = options.width
Expand Down Expand Up @@ -635,6 +637,11 @@ class Load3d {
const containerWidth = parentElement.clientWidth
const containerHeight = parentElement.clientHeight

// Scale pixel density to match the graph zoom level so the 3D scene
// renders at the correct resolution when the canvas is zoomed in or out.
const zoomScale = this.getZoomScaleCallback?.() ?? 1
this.renderer.setPixelRatio(Math.min(zoomScale, 3))

if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
Expand Down
Loading
Loading