Merge branch 'main' into test/image-crop-e2e-test

This commit is contained in:
Kelly Yang
2026-04-12 20:00:55 -07:00
committed by GitHub
34 changed files with 1533 additions and 267 deletions

View File

@@ -150,7 +150,7 @@
"playwright/no-element-handle": "error",
"playwright/no-eval": "error",
"playwright/no-focused-test": "error",
"playwright/no-force-option": "off",
"playwright/no-force-option": "error",
"playwright/no-networkidle": "error",
"playwright/no-page-pause": "error",
"playwright/no-skipped-test": "error",

View File

@@ -351,7 +351,7 @@ export class AssetsSidebarTab extends SidebarTab {
async dismissToasts() {
const closeButtons = this.page.locator('.p-toast-close-button')
for (const btn of await closeButtons.all()) {
await btn.click({ force: true }).catch(() => {})
await btn.click().catch(() => {})
}
// Wait for all toast elements to fully animate out and detach from DOM
await expect(this.page.locator('.p-toast-message'))

View File

@@ -71,7 +71,7 @@ export class Topbar {
async closeWorkflowTab(tabName: string) {
const tab = this.getWorkflowTab(tabName)
await tab.hover()
await tab.locator('.close-button').click({ force: true })
await tab.locator('.close-button').click()
}
getSaveDialog(): Locator {

View File

@@ -151,6 +151,7 @@ export class BuilderSelectHelper {
const widgetLocator = this.comfyPage.vueNodes
.getNodeLocator(String(nodeRef.id))
.getByLabel(widgetName, { exact: true })
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await widgetLocator.click({ force: true })
await this.comfyPage.nextFrame()
}
@@ -199,6 +200,7 @@ export class BuilderSelectHelper {
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
String(nodeRef.id)
)
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await nodeLocator.click({ force: true })
await this.comfyPage.nextFrame()
}

View File

@@ -74,6 +74,51 @@ export class CanvasHelper {
await this.nextFrame()
}
/**
* Convert a canvas-element-relative position to absolute page coordinates.
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
* cause Playwright's actionability check to fail on the canvas locator.
*/
private async toAbsolute(position: Position): Promise<Position> {
const box = await this.canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
return { x: box.x + position.x, y: box.y + position.y }
}
/**
* Click at canvas-element-relative coordinates using `page.mouse.click()`.
* Bypasses Playwright's actionability checks on the canvas locator, which
* can fail when Vue-rendered DOM nodes overlay the `<canvas>` element.
*/
async mouseClickAt(
position: Position,
options?: {
button?: 'left' | 'right' | 'middle'
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
}
): Promise<void> {
const abs = await this.toAbsolute(position)
const modifiers = options?.modifiers ?? []
for (const mod of modifiers) await this.page.keyboard.down(mod)
try {
await this.page.mouse.click(abs.x, abs.y, {
button: options?.button
})
} finally {
for (const mod of modifiers) await this.page.keyboard.up(mod)
}
await this.nextFrame()
}
/**
* Double-click at canvas-element-relative coordinates using `page.mouse`.
*/
async mouseDblclickAt(position: Position): Promise<void> {
const abs = await this.toAbsolute(position)
await this.page.mouse.dblclick(abs.x, abs.y)
await this.nextFrame()
}
async clickEmptySpace(): Promise<void> {
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
await this.nextFrame()

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
@@ -356,7 +355,11 @@ export class NodeReference {
}
async click(
position: 'title' | 'collapse',
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
options?: {
button?: 'left' | 'right' | 'middle'
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
moveMouseToEmptyArea?: boolean
}
) {
let clickPos: Position
switch (position) {
@@ -377,12 +380,7 @@ export class NodeReference {
delete options.moveMouseToEmptyArea
}
await this.comfyPage.canvas.click({
...options,
position: clickPos,
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt(clickPos, options)
if (moveMouseToEmptyArea) {
await this.comfyPage.canvasOps.moveMouseToEmptyArea()
}
@@ -499,31 +497,18 @@ export class NodeReference {
await expect(async () => {
// Try just clicking the enter button first
await this.comfyPage.canvas.click({
position: { x: 250, y: 250 },
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
await this.comfyPage.canvas.click({
position: subgraphButtonPos,
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt(subgraphButtonPos)
if (await checkIsInSubgraph()) return
for (const position of clickPositions) {
// Clear any selection first
await this.comfyPage.canvas.click({
position: { x: 250, y: 250 },
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
// Double-click to enter subgraph
await this.comfyPage.canvas.dblclick({ position, force: true })
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseDblclickAt(position)
if (await checkIsInSubgraph()) return
}

View File

@@ -29,7 +29,7 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
const helpButton = comfyPage.selectionToolbox.getByTestId('info-button')
await expect(helpButton).toBeVisible()
await helpButton.click({ force: true })
await helpButton.click()
await comfyPage.nextFrame()
return comfyPage.page.getByTestId('properties-panel')

View File

@@ -49,7 +49,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const deleteButton = comfyPage.page.getByTestId('delete-button')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await deleteButton.click()
await comfyPage.nextFrame()
await expect
@@ -65,7 +65,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const infoButton = comfyPage.page.getByTestId('info-button')
await expect(infoButton).toBeVisible()
await infoButton.click({ force: true })
await infoButton.click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
@@ -98,7 +98,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const deleteButton = comfyPage.page.getByTestId('delete-button')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await deleteButton.click()
await comfyPage.nextFrame()
await expect
@@ -120,7 +120,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const bypassButton = comfyPage.page.getByTestId('bypass-button')
await expect(bypassButton).toBeVisible()
await bypassButton.click({ force: true })
await bypassButton.click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
@@ -128,7 +128,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
BYPASS_CLASS
)
await bypassButton.click({ force: true })
await bypassButton.click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
@@ -147,7 +147,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
'convert-to-subgraph-button'
)
await expect(convertButton).toBeVisible()
await convertButton.click({ force: true })
await convertButton.click()
await comfyPage.nextFrame()
// KSampler should be gone, replaced by a subgraph node
@@ -175,7 +175,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
'convert-to-subgraph-button'
)
await expect(convertButton).toBeVisible()
await convertButton.click({ force: true })
await convertButton.click()
await comfyPage.nextFrame()
await expect
@@ -200,13 +200,14 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
const frameButton = comfyPage.page.getByRole('button', {
name: /Frame Nodes/i
})
await expect(frameButton).toBeVisible()
await comfyPage.page
await expect(
comfyPage.selectionToolbox.getByRole('button', {
name: /Frame Nodes/i
})
).toBeVisible()
await comfyPage.selectionToolbox
.getByRole('button', { name: /Frame Nodes/i })
.click({ force: true })
.click()
await comfyPage.nextFrame()
await expect

View File

@@ -62,7 +62,7 @@ test.describe(
return
}
await moreOptionsBtn.click({ force: true })
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisibleAfterClick = await comfyPage.page
@@ -126,9 +126,7 @@ test.describe(
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
await openMoreOptions(comfyPage)
await comfyPage.page
.getByText('Rename', { exact: true })
.click({ force: true })
await comfyPage.page.getByText('Rename', { exact: true }).click()
const input = comfyPage.page.locator(
'.group-title-editor.node-title-editor .editable-text input'
)
@@ -153,11 +151,7 @@ test.describe(
await comfyPage.nextFrame()
}
await comfyPage.page
.locator('#graph-canvas')
.click({ position: { x: 0, y: 50 }, force: true })
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt({ x: 0, y: 50 })
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeHidden()

View File

@@ -199,12 +199,7 @@ test.describe(
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
position: widgetPos,
button: 'right',
force: true
})
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
// Look for the Promote Widget menu entry
const promoteEntry = comfyPage.page
@@ -235,12 +230,7 @@ test.describe(
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
position: widgetPos,
button: 'right',
force: true
})
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
@@ -266,12 +256,7 @@ test.describe(
const stepsWidget2 = await ksampler2.getWidget(2)
const widgetPos2 = await stepsWidget2.getPosition()
await comfyPage.canvas.click({
position: widgetPos2,
button: 'right',
force: true
})
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt(widgetPos2, { button: 'right' })
const unpromoteEntry = comfyPage.page
.locator('.litemenu-entry')

View File

@@ -94,6 +94,7 @@ async function connectSlots(
const fromLoc = slotLocator(page, from.nodeId, from.index, false)
const toLoc = slotLocator(page, to.nodeId, to.index, true)
await expectVisibleAll(fromLoc, toLoc)
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
await fromLoc.dragTo(toLoc, { force: true })
await nextFrame()
}
@@ -192,6 +193,7 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true)
await expectVisibleAll(outputSlot, inputSlot)
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
await outputSlot.dragTo(inputSlot, { force: true })
await comfyPage.nextFrame()
@@ -218,6 +220,7 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true)
await expectVisibleAll(outputSlot, inputSlot)
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
await outputSlot.dragTo(inputSlot, { force: true })
await comfyPage.nextFrame()

View File

@@ -22,10 +22,8 @@ test.describe('Vue Integer Widget', () => {
const initialValue = Number(await controls.input.inputValue())
// Verify widget is disabled when linked
await controls.incrementButton.click({ force: true })
await expect(controls.input).toHaveValue(initialValue.toString())
await controls.decrementButton.click({ force: true })
await expect(controls.incrementButton).toBeDisabled()
await expect(controls.decrementButton).toBeDisabled()
await expect(controls.input).toHaveValue(initialValue.toString())
await expect(seedWidget).toBeVisible()

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.2",
"version": "1.44.3",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -27,6 +27,7 @@
:has-skeleton="hasSkeleton"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
@update-hdri-file="handleHDRIFileUpdate"
/>
<AnimationControls
v-if="animations && animations.length > 0"
@@ -139,6 +140,7 @@ const {
handleClearRecording,
handleSeek,
handleBackgroundImageUpdate,
handleHDRIFileUpdate,
handleExportModel,
handleModelDrop,
cleanup

View File

@@ -6,19 +6,21 @@
@pointerup.stop
@wheel.stop
>
<div class="show-menu relative">
<div class="relative">
<Button
ref="menuTriggerRef"
variant="textonly"
size="icon"
:aria-label="$t('menu.showMenu')"
class="rounded-full"
@click="toggleMenu"
>
<i class="pi pi-bars text-lg text-base-foreground" />
<i class="icon-[lucide--menu] text-lg text-base-foreground" />
</Button>
<div
v-show="isMenuOpen"
ref="menuPanelRef"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
>
<div class="flex flex-col">
@@ -42,7 +44,6 @@
</div>
</div>
</div>
<div v-show="activeCategory" class="rounded-lg bg-smoke-700/30">
<SceneControls
v-if="showSceneControls"
@@ -51,6 +52,9 @@
v-model:background-image="sceneConfig!.backgroundImage"
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
v-model:fov="cameraConfig!.fov"
:hdri-active="
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
"
@update-background-image="handleBackgroundImageUpdate"
/>
@@ -70,11 +74,19 @@
v-model:fov="cameraConfig!.fov"
/>
<LightControls
v-if="showLightControls"
v-model:light-intensity="lightConfig!.intensity"
v-model:material-mode="modelConfig!.materialMode"
/>
<div v-if="showLightControls" class="flex flex-col">
<LightControls
v-model:light-intensity="lightConfig!.intensity"
v-model:material-mode="modelConfig!.materialMode"
v-model:hdri-config="lightConfig!.hdri"
/>
<HDRIControls
v-model:hdri-config="lightConfig!.hdri"
:has-background-image="!!sceneConfig?.backgroundImage"
@update-hdri-file="handleHDRIFileUpdate"
/>
</div>
<ExportControls
v-if="showExportControls"
@@ -85,10 +97,12 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, ref } from 'vue'
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
import HDRIControls from '@/components/load3d/controls/HDRIControls.vue'
import LightControls from '@/components/load3d/controls/LightControls.vue'
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
@@ -117,6 +131,17 @@ const cameraConfig = defineModel<CameraConfig>('cameraConfig')
const lightConfig = defineModel<LightConfig>('lightConfig')
const isMenuOpen = ref(false)
const menuPanelRef = ref<HTMLElement | null>(null)
const menuTriggerRef = ref<InstanceType<typeof Button> | null>(null)
useDismissableOverlay({
isOpen: isMenuOpen,
getOverlayEl: () => menuPanelRef.value,
getTriggerEl: () => menuTriggerRef.value?.$el ?? null,
onDismiss: () => {
isMenuOpen.value = false
}
})
const activeCategory = ref<string>('scene')
const categoryLabels: Record<string, string> = {
scene: 'load3d.scene',
@@ -160,21 +185,26 @@ const selectCategory = (category: string) => {
isMenuOpen.value = false
}
const categoryIcons = {
scene: 'icon-[lucide--image]',
model: 'icon-[lucide--box]',
camera: 'icon-[lucide--camera]',
light: 'icon-[lucide--sun]',
export: 'icon-[lucide--download]'
} as const
const getCategoryIcon = (category: string) => {
const icons = {
scene: 'pi pi-image',
model: 'pi pi-box',
camera: 'pi pi-camera',
light: 'pi pi-sun',
export: 'pi pi-download'
}
// @ts-expect-error fixme ts strict error
return `${icons[category]} text-base-foreground text-lg`
const icon =
category in categoryIcons
? categoryIcons[category as keyof typeof categoryIcons]
: 'icon-[lucide--circle]'
return cn(icon, 'text-lg text-base-foreground')
}
const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
(e: 'exportModel', format: string): void
(e: 'updateHdriFile', file: File | null): void
}>()
const handleBackgroundImageUpdate = (file: File | null) => {
@@ -185,19 +215,7 @@ const handleExportModel = (format: string) => {
emit('exportModel', format)
}
const closeSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.show-menu')) {
isMenuOpen.value = false
}
const handleHDRIFileUpdate = (file: File | null) => {
emit('updateHdriFile', file)
}
onMounted(() => {
document.addEventListener('click', closeSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeSlider)
})
</script>

View File

@@ -0,0 +1,148 @@
<template>
<div v-if="!hasBackgroundImage || hdriConfig?.hdriPath" class="flex flex-col">
<Button
v-tooltip.right="{
value: hdriConfig?.hdriPath
? $t('load3d.hdri.changeFile')
: $t('load3d.hdri.uploadFile'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="
hdriConfig?.hdriPath
? $t('load3d.hdri.changeFile')
: $t('load3d.hdri.uploadFile')
"
@click="triggerFileInput"
>
<i class="icon-[lucide--upload] text-lg text-base-foreground" />
</Button>
<template v-if="hdriConfig?.hdriPath">
<Button
v-tooltip.right="{
value: $t('load3d.hdri.label'),
showDelay: 300
}"
size="icon"
variant="textonly"
:class="
cn('rounded-full', hdriConfig?.enabled && 'ring-2 ring-white/50')
"
:aria-label="$t('load3d.hdri.label')"
@click="toggleEnabled"
>
<i class="icon-[lucide--globe] text-lg text-base-foreground" />
</Button>
<Button
v-tooltip.right="{
value: $t('load3d.hdri.showAsBackground'),
showDelay: 300
}"
size="icon"
variant="textonly"
:class="
cn(
'rounded-full',
hdriConfig?.showAsBackground && 'ring-2 ring-white/50'
)
"
:aria-label="$t('load3d.hdri.showAsBackground')"
@click="toggleShowAsBackground"
>
<i class="icon-[lucide--image] text-lg text-base-foreground" />
</Button>
<Button
v-tooltip.right="{
value: $t('load3d.hdri.removeFile'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.hdri.removeFile')"
@click="onRemoveHDRI"
>
<i class="icon-[lucide--x] text-lg text-base-foreground" />
</Button>
</template>
<input
ref="fileInputRef"
type="file"
class="hidden"
:accept="SUPPORTED_HDRI_EXTENSIONS_ACCEPT"
@change="onFileChange"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import {
SUPPORTED_HDRI_EXTENSIONS,
SUPPORTED_HDRI_EXTENSIONS_ACCEPT
} from '@/extensions/core/load3d/constants'
import type { HDRIConfig } from '@/extensions/core/load3d/interfaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const { hasBackgroundImage = false } = defineProps<{
hasBackgroundImage?: boolean
}>()
const hdriConfig = defineModel<HDRIConfig>('hdriConfig')
const emit = defineEmits<{
(e: 'updateHdriFile', file: File | null): void
}>()
const fileInputRef = ref<HTMLInputElement | null>(null)
function triggerFileInput() {
fileInputRef.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
input.value = ''
if (file) {
const ext = `.${file.name.split('.').pop()?.toLowerCase() ?? ''}`
if (!SUPPORTED_HDRI_EXTENSIONS.has(ext)) {
useToastStore().addAlert(t('toastMessages.unsupportedHDRIFormat'))
return
}
}
emit('updateHdriFile', file)
}
function toggleEnabled() {
if (!hdriConfig.value) return
hdriConfig.value = {
...hdriConfig.value,
enabled: !hdriConfig.value.enabled
}
}
function toggleShowAsBackground() {
if (!hdriConfig.value) return
hdriConfig.value = {
...hdriConfig.value,
showAsBackground: !hdriConfig.value.showAsBackground
}
}
function onRemoveHDRI() {
emit('updateHdriFile', null)
}
</script>

View File

@@ -1,7 +1,24 @@
<template>
<div class="flex flex-col">
<div v-if="showLightIntensityButton" class="show-light-intensity relative">
<div
v-if="embedded && showIntensityControl"
class="flex w-[200px] flex-col gap-2 rounded-lg bg-black/50 p-3 shadow-lg"
>
<span class="text-sm font-medium text-base-foreground">{{
$t('load3d.lightIntensity')
}}</span>
<Slider
:model-value="sliderValue"
class="w-full"
:min="sliderMin"
:max="sliderMax"
:step="sliderStep"
@update:model-value="onSliderUpdate"
/>
</div>
<div v-else-if="showIntensityControl" class="relative">
<Button
ref="triggerRef"
v-tooltip.right="{
value: $t('load3d.lightIntensity'),
showDelay: 300
@@ -12,19 +29,20 @@
:aria-label="$t('load3d.lightIntensity')"
@click="toggleLightIntensity"
>
<i class="pi pi-sun text-lg text-base-foreground" />
<i class="icon-[lucide--sun] text-lg text-base-foreground" />
</Button>
<div
v-show="showLightIntensity"
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg"
style="width: 150px"
ref="panelRef"
class="absolute top-0 left-12 w-[200px] rounded-lg bg-black/50 p-3 shadow-lg"
>
<Slider
v-model="lightIntensity"
:model-value="sliderValue"
class="w-full"
:min="lightIntensityMinimum"
:max="lightIntensityMaximum"
:step="lightAdjustmentIncrement"
:min="sliderMin"
:max="sliderMax"
:step="sliderStep"
@update:model-value="onSliderUpdate"
/>
</div>
</div>
@@ -32,20 +50,30 @@
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { MaterialMode } from '@/extensions/core/load3d/interfaces'
import Slider from '@/components/ui/slider/Slider.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import type {
HDRIConfig,
MaterialMode
} from '@/extensions/core/load3d/interfaces'
import { useSettingStore } from '@/platform/settings/settingStore'
const lightIntensity = defineModel<number>('lightIntensity')
const materialMode = defineModel<MaterialMode>('materialMode')
const hdriConfig = defineModel<HDRIConfig | undefined>('hdriConfig')
const showLightIntensityButton = computed(
() => materialMode.value === 'original'
const { embedded = false } = defineProps<{
embedded?: boolean
}>()
const usesHdriIntensity = computed(
() => !!hdriConfig.value?.hdriPath?.length && !!hdriConfig.value?.enabled
)
const showLightIntensity = ref(false)
const showIntensityControl = computed(() => materialMode.value === 'original')
const lightIntensityMaximum = useSettingStore().get(
'Comfy.Load3D.LightIntensityMaximum'
@@ -57,23 +85,49 @@ const lightAdjustmentIncrement = useSettingStore().get(
'Comfy.Load3D.LightAdjustmentIncrement'
)
const sliderMin = computed(() =>
usesHdriIntensity.value ? 0 : lightIntensityMinimum
)
const sliderMax = computed(() =>
usesHdriIntensity.value ? 5 : lightIntensityMaximum
)
const sliderStep = computed(() =>
usesHdriIntensity.value ? 0.1 : lightAdjustmentIncrement
)
const sliderValue = computed(() => {
if (usesHdriIntensity.value) {
return [hdriConfig.value?.intensity ?? 1]
}
return [lightIntensity.value ?? lightIntensityMinimum]
})
const showLightIntensity = ref(false)
const panelRef = ref<HTMLElement | null>(null)
const triggerRef = ref<InstanceType<typeof Button> | null>(null)
useDismissableOverlay({
isOpen: showLightIntensity,
getOverlayEl: () => panelRef.value,
getTriggerEl: () => triggerRef.value?.$el ?? null,
onDismiss: () => {
showLightIntensity.value = false
}
})
function toggleLightIntensity() {
showLightIntensity.value = !showLightIntensity.value
}
function closeLightSlider(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.show-light-intensity')) {
showLightIntensity.value = false
function onSliderUpdate(value: number[] | undefined) {
if (!value?.length) return
const next = value[0]
if (usesHdriIntensity.value) {
const h = hdriConfig.value
if (!h) return
hdriConfig.value = { ...h, intensity: next }
} else {
lightIntensity.value = next
}
}
onMounted(() => {
document.addEventListener('click', closeLightSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeLightSlider)
})
</script>

View File

@@ -11,53 +11,55 @@
<i class="pi pi-table text-lg text-base-foreground" />
</Button>
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.backgroundColor'),
showDelay: 300
}"
variant="textonly"
size="icon"
class="rounded-full"
:aria-label="$t('load3d.backgroundColor')"
@click="openColorPicker"
>
<i class="pi pi-palette text-lg text-base-foreground" />
<input
ref="colorPickerRef"
type="color"
:value="backgroundColor"
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
@input="
updateBackgroundColor(($event.target as HTMLInputElement).value)
"
/>
</Button>
</div>
<template v-if="!hdriActive">
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.backgroundColor'),
showDelay: 300
}"
variant="textonly"
size="icon"
class="rounded-full"
:aria-label="$t('load3d.backgroundColor')"
@click="openColorPicker"
>
<i class="pi pi-palette text-lg text-base-foreground" />
<input
ref="colorPickerRef"
type="color"
:value="backgroundColor"
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
@input="
updateBackgroundColor(($event.target as HTMLInputElement).value)
"
/>
</Button>
</div>
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
showDelay: 300
}"
variant="textonly"
size="icon"
class="rounded-full"
:aria-label="$t('load3d.uploadBackgroundImage')"
@click="openImagePicker"
>
<i class="pi pi-image text-lg text-base-foreground" />
<input
ref="imagePickerRef"
type="file"
accept="image/*"
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
@change="uploadBackgroundImage"
/>
</Button>
</div>
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
showDelay: 300
}"
variant="textonly"
size="icon"
class="rounded-full"
:aria-label="$t('load3d.uploadBackgroundImage')"
@click="openImagePicker"
>
<i class="pi pi-image text-lg text-base-foreground" />
<input
ref="imagePickerRef"
type="file"
accept="image/*"
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
@change="uploadBackgroundImage"
/>
</Button>
</div>
</template>
<div v-if="hasBackgroundImage">
<Button
@@ -112,6 +114,10 @@ import Button from '@/components/ui/button/Button.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@/utils/tailwindUtil'
const { hdriActive = false } = defineProps<{
hdriActive?: boolean
}>()
const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
}>()

View File

@@ -2,6 +2,7 @@ import { ref, watch } from 'vue'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
import type { Point } from '@/extensions/core/maskeditor/types'
import { rgbToHsl } from '@/utils/colorUtil'
const getPixelAlpha = (
data: Uint8ClampedArray,
@@ -47,39 +48,8 @@ const rgbToHSL = (
g: number,
b: number
): { h: number; s: number; l: number } => {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0
let s = 0
const l = (max + min) / 2
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0)
break
case g:
h = (b - r) / d + 2
break
case b:
h = (r - g) / d + 4
break
}
h /= 6
}
return {
h: h * 360,
s: s * 100,
l: l * 100
}
const hsl = rgbToHsl({ r, g, b })
return { h: hsl.h * 360, s: hsl.s * 100, l: hsl.l * 100 }
}
const rgbToLab = (rgb: {

View File

@@ -23,7 +23,17 @@ vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
splitFilePath: vi.fn(),
getResourceURL: vi.fn(),
uploadFile: vi.fn()
uploadFile: vi.fn(),
mapSceneLightIntensityToHdri: vi.fn(
(scene: number, min: number, max: number) => {
const span = max - min
const t = span > 0 ? (scene - min) / span : 0
const clampedT = Math.min(1, Math.max(0, t))
const mapped = clampedT * 5
const minHdri = 0.25
return Math.min(5, Math.max(minHdri, mapped))
}
)
}
}))
@@ -72,7 +82,13 @@ describe('useLoad3d', () => {
state: null
},
'Light Config': {
intensity: 5
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
},
'Resource Folder': ''
},
@@ -122,6 +138,11 @@ describe('useLoad3d', () => {
isPlyModel: vi.fn().mockReturnValue(false),
hasSkeleton: vi.fn().mockReturnValue(false),
setShowSkeleton: vi.fn(),
loadHDRI: vi.fn().mockResolvedValue(undefined),
setHDRIEnabled: vi.fn(),
setHDRIAsBackground: vi.fn(),
setHDRIIntensity: vi.fn(),
clearHDRI: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
remove: vi.fn(),
@@ -167,7 +188,13 @@ describe('useLoad3d', () => {
fov: 75
})
expect(composable.lightConfig.value).toEqual({
intensity: 5
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
})
expect(composable.isRecording.value).toBe(false)
expect(composable.hasRecording.value).toBe(false)
@@ -476,7 +503,7 @@ describe('useLoad3d', () => {
await nextTick()
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(10)
expect(mockNode.properties['Light Config']).toEqual({
expect(mockNode.properties['Light Config']).toMatchObject({
intensity: 10
})
})
@@ -912,6 +939,97 @@ describe('useLoad3d', () => {
})
})
describe('hdri controls', () => {
it('should call setHDRIEnabled when hdriConfig.enabled changes', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, enabled: true }
}
await nextTick()
expect(mockLoad3d.setHDRIEnabled).toHaveBeenCalledWith(true)
})
it('should call setHDRIAsBackground when hdriConfig.showAsBackground changes', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, showAsBackground: true }
}
await nextTick()
expect(mockLoad3d.setHDRIAsBackground).toHaveBeenCalledWith(true)
})
it('should call setHDRIIntensity when hdriConfig.intensity changes', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, intensity: 2.5 }
}
await nextTick()
expect(mockLoad3d.setHDRIIntensity).toHaveBeenCalledWith(2.5)
})
it('should upload file, load HDRI and update hdriConfig', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/env.hdr')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/view?filename=env.hdr'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/view?filename=env.hdr'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const file = new File([''], 'env.hdr', { type: 'image/x-hdr' })
await composable.handleHDRIFileUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
expect(mockLoad3d.loadHDRI).toHaveBeenCalledWith(
'http://localhost/view?filename=env.hdr'
)
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('3d/env.hdr')
expect(composable.lightConfig.value.hdri!.enabled).toBe(true)
})
it('should clear HDRI when file is null', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: {
enabled: true,
hdriPath: '3d/env.hdr',
showAsBackground: true,
intensity: 1
}
}
await composable.handleHDRIFileUpdate(null)
expect(mockLoad3d.clearHDRI).toHaveBeenCalled()
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
expect(composable.lightConfig.value.hdri!.enabled).toBe(false)
})
})
describe('edge cases', () => {
it('should handle null node ref', () => {
const nodeRef = ref(null)

View File

@@ -1,6 +1,7 @@
import type { MaybeRef } from 'vue'
import { toRef } from '@vueuse/core'
import { getActivePinia } from 'pinia'
import { nextTick, ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
@@ -24,6 +25,7 @@ import type {
import { t } from '@/i18n'
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 { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -58,8 +60,15 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
})
const lightConfig = ref<LightConfig>({
intensity: 5
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
})
const lastNonHdriLightIntensity = ref(lightConfig.value.intensity)
const isRecording = ref(false)
const hasRecording = ref(false)
@@ -185,8 +194,45 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
const savedLightConfig = node.properties['Light Config'] as LightConfig
const savedHdriEnabled = savedLightConfig?.hdri?.enabled ?? false
if (savedLightConfig) {
lightConfig.value = savedLightConfig
lightConfig.value = {
intensity: savedLightConfig.intensity ?? lightConfig.value.intensity,
hdri: {
...lightConfig.value.hdri!,
...savedLightConfig.hdri,
enabled: false
}
}
lastNonHdriLightIntensity.value = lightConfig.value.intensity
}
const hdri = lightConfig.value.hdri
let hdriLoaded = false
if (hdri?.hdriPath) {
const hdriUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(hdri.hdriPath),
'input'
)
)
try {
await load3d.loadHDRI(hdriUrl)
hdriLoaded = true
} catch (error) {
console.warn('Failed to restore HDRI:', error)
lightConfig.value = {
...lightConfig.value,
hdri: { ...lightConfig.value.hdri!, hdriPath: '', enabled: false }
}
}
}
if (hdriLoaded && savedHdriEnabled) {
lightConfig.value = {
...lightConfig.value,
hdri: { ...lightConfig.value.hdri!, enabled: true }
}
}
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
@@ -213,6 +259,39 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
} else if (cameraStateToRestore) {
load3d.setCameraState(cameraStateToRestore)
}
applySceneConfigToLoad3d()
applyLightConfigToLoad3d()
}
const applySceneConfigToLoad3d = () => {
if (!load3d) return
const cfg = sceneConfig.value
load3d.toggleGrid(cfg.showGrid)
if (!lightConfig.value.hdri?.enabled) {
load3d.setBackgroundColor(cfg.backgroundColor)
}
if (cfg.backgroundRenderMode) {
load3d.setBackgroundRenderMode(cfg.backgroundRenderMode)
}
}
const applyLightConfigToLoad3d = () => {
if (!load3d) return
const cfg = lightConfig.value
load3d.setLightIntensity(cfg.intensity)
const hdri = cfg.hdri
if (!hdri) return
load3d.setHDRIIntensity(hdri.intensity)
load3d.setHDRIAsBackground(hdri.showAsBackground)
load3d.setHDRIEnabled(hdri.enabled)
}
const persistLightConfigToNode = () => {
const n = nodeRef.value
if (n) {
n.properties['Light Config'] = lightConfig.value
}
}
const getModelUrl = (modelPath: string): string | null => {
@@ -260,22 +339,44 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
watch(
sceneConfig,
async (newValue) => {
if (load3d && nodeRef.value) {
(newValue) => {
if (nodeRef.value) {
nodeRef.value.properties['Scene Config'] = newValue
load3d.toggleGrid(newValue.showGrid)
load3d.setBackgroundColor(newValue.backgroundColor)
await load3d.setBackgroundImage(newValue.backgroundImage || '')
if (newValue.backgroundRenderMode) {
load3d.setBackgroundRenderMode(newValue.backgroundRenderMode)
}
}
},
{ deep: true }
)
watch(
() => sceneConfig.value.showGrid,
(showGrid) => {
load3d?.toggleGrid(showGrid)
}
)
watch(
() => sceneConfig.value.backgroundColor,
(color) => {
if (!load3d || lightConfig.value.hdri?.enabled) return
load3d.setBackgroundColor(color)
}
)
watch(
() => sceneConfig.value.backgroundImage,
async (image) => {
if (!load3d) return
await load3d.setBackgroundImage(image || '')
}
)
watch(
() => sceneConfig.value.backgroundRenderMode,
(mode) => {
if (mode) load3d?.setBackgroundRenderMode(mode)
}
)
watch(
modelConfig,
(newValue) => {
@@ -302,14 +403,54 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
)
watch(
lightConfig,
(newValue) => {
if (load3d && nodeRef.value) {
nodeRef.value.properties['Light Config'] = newValue
load3d.setLightIntensity(newValue.intensity)
() => lightConfig.value.intensity,
(intensity) => {
if (!load3d || !nodeRef.value) return
if (!lightConfig.value.hdri?.enabled) {
lastNonHdriLightIntensity.value = intensity
}
},
{ deep: true }
persistLightConfigToNode()
load3d.setLightIntensity(intensity)
}
)
watch(
() => lightConfig.value.hdri?.intensity,
(intensity) => {
if (!load3d || !nodeRef.value) return
if (intensity === undefined) return
persistLightConfigToNode()
load3d.setHDRIIntensity(intensity)
}
)
watch(
() => lightConfig.value.hdri?.showAsBackground,
(show) => {
if (!load3d || !nodeRef.value) return
if (show === undefined) return
persistLightConfigToNode()
load3d.setHDRIAsBackground(show)
}
)
watch(
() => lightConfig.value.hdri?.enabled,
(enabled, prevEnabled) => {
if (!load3d || !nodeRef.value) return
if (enabled === undefined) return
if (enabled && prevEnabled === false) {
lastNonHdriLightIntensity.value = lightConfig.value.intensity
}
if (!enabled && prevEnabled === true) {
lightConfig.value = {
...lightConfig.value,
intensity: lastNonHdriLightIntensity.value
}
}
persistLightConfigToNode()
load3d.setHDRIEnabled(enabled)
}
)
watch(playing, (newValue) => {
@@ -377,6 +518,98 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
const handleHDRIFileUpdate = async (file: File | null) => {
const capturedLoad3d = load3d
if (!capturedLoad3d) return
if (!file) {
lightConfig.value = {
...lightConfig.value,
hdri: {
...lightConfig.value.hdri!,
hdriPath: '',
enabled: false,
showAsBackground: false
}
}
capturedLoad3d.clearHDRI()
return
}
const resourceFolder =
(nodeRef.value?.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
if (!uploadedPath) {
return
}
// Re-validate: node may have been removed during upload
if (load3d !== capturedLoad3d) return
const hdriUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadedPath),
'input'
)
)
try {
loading.value = true
loadingMessage.value = t('load3d.loadingHDRI')
await capturedLoad3d.loadHDRI(hdriUrl)
if (load3d !== capturedLoad3d) return
let sceneMin = 1
let sceneMax = 10
if (getActivePinia() != null) {
const settingStore = useSettingStore()
sceneMin = settingStore.get(
'Comfy.Load3D.LightIntensityMinimum'
) as number
sceneMax = settingStore.get(
'Comfy.Load3D.LightIntensityMaximum'
) as number
}
const mappedHdriIntensity = Load3dUtils.mapSceneLightIntensityToHdri(
lightConfig.value.intensity,
sceneMin,
sceneMax
)
lightConfig.value = {
...lightConfig.value,
hdri: {
...lightConfig.value.hdri!,
hdriPath: uploadedPath,
enabled: true,
showAsBackground: true,
intensity: mappedHdriIntensity
}
}
} catch (error) {
console.error('Failed to load HDRI:', error)
capturedLoad3d.clearHDRI()
lightConfig.value = {
...lightConfig.value,
hdri: {
...lightConfig.value.hdri!,
hdriPath: '',
enabled: false,
showAsBackground: false
}
}
useToastStore().addAlert(t('toastMessages.failedToLoadHDRI'))
} finally {
loading.value = false
loadingMessage.value = ''
}
}
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
sceneConfig.value.backgroundImage = ''
@@ -642,6 +875,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
handleClearRecording,
handleSeek,
handleBackgroundImageUpdate,
handleHDRIFileUpdate,
handleExportModel,
handleModelDrop,
cleanup

View File

@@ -0,0 +1,223 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HDRIManager } from './HDRIManager'
import Load3dUtils from './Load3dUtils'
const { mockFromEquirectangular, mockDisposePMREM } = vi.hoisted(() => ({
mockFromEquirectangular: vi.fn(),
mockDisposePMREM: vi.fn()
}))
vi.mock('./Load3dUtils', () => ({
default: {
getFilenameExtension: vi.fn()
}
}))
vi.mock('three', async (importOriginal) => {
const actual = await importOriginal<typeof THREE>()
class MockPMREMGenerator {
compileEquirectangularShader = vi.fn()
fromEquirectangular = mockFromEquirectangular
dispose = mockDisposePMREM
}
return { ...actual, PMREMGenerator: MockPMREMGenerator }
})
vi.mock('three/examples/jsm/loaders/EXRLoader', () => {
class EXRLoader {
load(
_url: string,
resolve: (t: THREE.Texture) => void,
_onProgress: undefined,
_reject: (e: unknown) => void
) {
resolve(new THREE.DataTexture(new Uint8Array(4), 1, 1))
}
}
return { EXRLoader }
})
vi.mock('three/examples/jsm/loaders/RGBELoader', () => {
class RGBELoader {
load(
_url: string,
resolve: (t: THREE.Texture) => void,
_onProgress: undefined,
_reject: (e: unknown) => void
) {
resolve(new THREE.DataTexture(new Uint8Array(4), 1, 1))
}
}
return { RGBELoader }
})
function makeMockEventManager() {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
}
}
describe('HDRIManager', () => {
let scene: THREE.Scene
let eventManager: ReturnType<typeof makeMockEventManager>
let manager: HDRIManager
beforeEach(() => {
vi.clearAllMocks()
scene = new THREE.Scene()
eventManager = makeMockEventManager()
mockFromEquirectangular.mockReturnValue({
texture: new THREE.Texture(),
dispose: vi.fn()
})
manager = new HDRIManager(scene, {} as THREE.WebGLRenderer, eventManager)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initial state', () => {
it('starts disabled with default intensity', () => {
expect(manager.isEnabled).toBe(false)
expect(manager.showAsBackground).toBe(false)
expect(manager.intensity).toBe(1)
})
})
describe('loadHDRI', () => {
it('loads .exr files without error', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('exr')
await expect(
manager.loadHDRI('http://example.com/env.exr')
).resolves.toBeUndefined()
})
it('loads .hdr files without error', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await expect(
manager.loadHDRI('http://example.com/env.hdr')
).resolves.toBeUndefined()
})
it('applies to scene immediately when already enabled', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
manager.setEnabled(true)
// No texture loaded yet so scene.environment stays null
expect(scene.environment).toBeNull()
await manager.loadHDRI('http://example.com/env.hdr')
expect(scene.environment).not.toBeNull()
})
it('does not apply to scene when disabled', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
expect(scene.environment).toBeNull()
})
})
describe('setEnabled', () => {
it('applies environment map to scene when enabled after loading', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
expect(scene.environment).not.toBeNull()
expect(eventManager.emitEvent).toHaveBeenCalledWith('hdriChange', {
enabled: true,
showAsBackground: false
})
})
it('removes environment map from scene when disabled', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
manager.setEnabled(false)
expect(scene.environment).toBeNull()
expect(eventManager.emitEvent).toHaveBeenLastCalledWith('hdriChange', {
enabled: false,
showAsBackground: false
})
})
})
describe('setIntensity', () => {
it('updates scene intensity when enabled', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
manager.setIntensity(2.5)
expect(scene.environmentIntensity).toBe(2.5)
expect(manager.intensity).toBe(2.5)
})
it('stores intensity without applying when disabled', () => {
manager.setIntensity(3)
expect(manager.intensity).toBe(3)
expect(scene.environmentIntensity).not.toBe(3)
})
})
describe('setShowAsBackground', () => {
it('sets scene background texture when enabled and showing as background', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
manager.setShowAsBackground(true)
expect(scene.background).not.toBeNull()
})
it('clears scene background when showAsBackground is false', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
manager.setShowAsBackground(true)
manager.setShowAsBackground(false)
expect(scene.background).toBeNull()
})
})
describe('clear', () => {
it('removes HDRI from scene and resets state', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
manager.clear()
expect(manager.isEnabled).toBe(false)
expect(scene.environment).toBeNull()
})
})
describe('dispose', () => {
it('disposes PMREMGenerator', () => {
manager.dispose()
expect(mockDisposePMREM).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,142 @@
import * as THREE from 'three'
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
import Load3dUtils from './Load3dUtils'
import type { EventManagerInterface } from './interfaces'
export class HDRIManager {
private scene: THREE.Scene
private renderer: THREE.WebGLRenderer
private pmremGenerator: THREE.PMREMGenerator
private eventManager: EventManagerInterface
private hdriTexture: THREE.Texture | null = null
private envMapTarget: THREE.WebGLRenderTarget | null = null
private _isEnabled: boolean = false
private _showAsBackground: boolean = false
private _intensity: number = 1
get isEnabled() {
return this._isEnabled
}
get showAsBackground() {
return this._showAsBackground
}
get intensity() {
return this._intensity
}
constructor(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
eventManager: EventManagerInterface
) {
this.scene = scene
this.renderer = renderer
this.pmremGenerator = new THREE.PMREMGenerator(renderer)
this.pmremGenerator.compileEquirectangularShader()
this.eventManager = eventManager
}
async loadHDRI(url: string): Promise<void> {
const ext = Load3dUtils.getFilenameExtension(url)
let newTexture: THREE.Texture
if (ext === 'exr') {
newTexture = await new Promise<THREE.Texture>((resolve, reject) => {
new EXRLoader().load(url, resolve, undefined, reject)
})
} else {
newTexture = await new Promise<THREE.Texture>((resolve, reject) => {
new RGBELoader().load(url, resolve, undefined, reject)
})
}
newTexture.mapping = THREE.EquirectangularReflectionMapping
const newEnvMapTarget = this.pmremGenerator.fromEquirectangular(newTexture)
// Dispose old resources only after the new one is ready
this.hdriTexture?.dispose()
this.envMapTarget?.dispose()
this.hdriTexture = newTexture
this.envMapTarget = newEnvMapTarget
if (this._isEnabled) {
this.applyToScene()
}
}
setEnabled(enabled: boolean): void {
this._isEnabled = enabled
if (enabled) {
if (this.envMapTarget) {
this.applyToScene()
}
} else {
this.removeFromScene()
}
}
setShowAsBackground(show: boolean): void {
this._showAsBackground = show
if (this._isEnabled && this.envMapTarget) {
this.applyToScene()
}
}
setIntensity(intensity: number): void {
this._intensity = intensity
if (this._isEnabled) {
this.scene.environmentIntensity = intensity
}
}
private applyToScene(): void {
const envMap = this.envMapTarget?.texture
if (!envMap) return
this.scene.environment = envMap
this.scene.environmentIntensity = this._intensity
this.scene.background = this._showAsBackground ? this.hdriTexture : null
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
this.renderer.toneMappingExposure = 1.0
this.eventManager.emitEvent('hdriChange', {
enabled: this._isEnabled,
showAsBackground: this._showAsBackground
})
}
private removeFromScene(): void {
this.scene.environment = null
if (this.scene.background === this.hdriTexture) {
this.scene.background = null
}
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 1.0
this.eventManager.emitEvent('hdriChange', {
enabled: false,
showAsBackground: this._showAsBackground
})
}
private clearResources(): void {
this.removeFromScene()
this.hdriTexture?.dispose()
this.envMapTarget?.dispose()
this.hdriTexture = null
this.envMapTarget = null
}
clear(): void {
this.clearResources()
this._isEnabled = false
}
dispose(): void {
this.clearResources()
this.pmremGenerator.dispose()
}
}

View File

@@ -10,6 +10,7 @@ export class LightingManager implements LightingManagerInterface {
currentIntensity: number = 3
private scene: THREE.Scene
private eventManager: EventManagerInterface
private lightMultipliers = new Map<THREE.Light, number>()
constructor(scene: THREE.Scene, eventManager: EventManagerInterface) {
this.scene = scene
@@ -25,59 +26,53 @@ export class LightingManager implements LightingManagerInterface {
this.scene.remove(light)
})
this.lights = []
this.lightMultipliers.clear()
}
setupLights(): void {
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5)
this.scene.add(ambientLight)
this.lights.push(ambientLight)
const addLight = (light: THREE.Light, multiplier: number) => {
this.scene.add(light)
this.lights.push(light)
this.lightMultipliers.set(light, multiplier)
}
addLight(new THREE.AmbientLight(0xffffff, 0.5), 0.5)
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8)
mainLight.position.set(0, 10, 10)
this.scene.add(mainLight)
this.lights.push(mainLight)
addLight(mainLight, 0.8)
const backLight = new THREE.DirectionalLight(0xffffff, 0.5)
backLight.position.set(0, 10, -10)
this.scene.add(backLight)
this.lights.push(backLight)
addLight(backLight, 0.5)
const leftFillLight = new THREE.DirectionalLight(0xffffff, 0.3)
leftFillLight.position.set(-10, 0, 0)
this.scene.add(leftFillLight)
this.lights.push(leftFillLight)
addLight(leftFillLight, 0.3)
const rightFillLight = new THREE.DirectionalLight(0xffffff, 0.3)
rightFillLight.position.set(10, 0, 0)
this.scene.add(rightFillLight)
this.lights.push(rightFillLight)
addLight(rightFillLight, 0.3)
const bottomLight = new THREE.DirectionalLight(0xffffff, 0.2)
bottomLight.position.set(0, -10, 0)
this.scene.add(bottomLight)
this.lights.push(bottomLight)
addLight(bottomLight, 0.2)
}
setLightIntensity(intensity: number): void {
this.currentIntensity = intensity
this.lights.forEach((light) => {
if (light instanceof THREE.DirectionalLight) {
if (light === this.lights[1]) {
light.intensity = intensity * 0.8
} else if (light === this.lights[2]) {
light.intensity = intensity * 0.5
} else if (light === this.lights[5]) {
light.intensity = intensity * 0.2
} else {
light.intensity = intensity * 0.3
}
} else if (light instanceof THREE.AmbientLight) {
light.intensity = intensity * 0.5
}
light.intensity = intensity * (this.lightMultipliers.get(light) ?? 1)
})
this.eventManager.emitEvent('lightIntensityChange', intensity)
}
setHDRIMode(hdriActive: boolean): void {
this.lights.forEach((light) => {
light.visible = !hdriActive
})
}
reset(): void {}
}

View File

@@ -3,6 +3,7 @@ import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
CameraConfig,
CameraState,
HDRIConfig,
LightConfig,
ModelConfig,
SceneConfig
@@ -113,6 +114,7 @@ class Load3DConfiguration {
const lightConfig = this.loadLightConfig()
this.applyLightConfig(lightConfig)
if (lightConfig.hdri) this.applyHDRISettings(lightConfig.hdri)
}
private loadSceneConfig(): SceneConfig {
@@ -140,13 +142,27 @@ class Load3DConfiguration {
}
private loadLightConfig(): LightConfig {
const hdriDefaults: HDRIConfig = {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
if (this.properties && 'Light Config' in this.properties) {
return this.properties['Light Config'] as LightConfig
const saved = this.properties['Light Config'] as Partial<LightConfig>
return {
intensity:
saved.intensity ??
(useSettingStore().get('Comfy.Load3D.LightIntensity') as number),
hdri: { ...hdriDefaults, ...(saved.hdri ?? {}) }
}
}
return {
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity')
} as LightConfig
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity') as number,
hdri: hdriDefaults
}
}
private loadModelConfig(): ModelConfig {
@@ -190,6 +206,15 @@ class Load3DConfiguration {
this.load3d.setLightIntensity(config.intensity)
}
private applyHDRISettings(config: HDRIConfig) {
if (!config.hdriPath) return
this.load3d.setHDRIIntensity(config.intensity)
this.load3d.setHDRIAsBackground(config.showAsBackground)
if (config.enabled) {
this.load3d.setHDRIEnabled(true)
}
}
private applyModelConfig(config: ModelConfig) {
this.load3d.setUpDirection(config.upDirection)
this.load3d.setMaterialMode(config.materialMode)

View File

@@ -6,6 +6,7 @@ import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
import { EventManager } from './EventManager'
import { HDRIManager } from './HDRIManager'
import { LightingManager } from './LightingManager'
import { LoaderManager } from './LoaderManager'
import { ModelExporter } from './ModelExporter'
@@ -54,6 +55,7 @@ class Load3d {
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
hdriManager: HDRIManager
viewHelperManager: ViewHelperManager
loaderManager: LoaderManager
modelManager: SceneModelManager
@@ -126,6 +128,12 @@ class Load3d {
this.eventManager
)
this.hdriManager = new HDRIManager(
this.sceneManager.scene,
this.renderer,
this.eventManager
)
this.viewHelperManager = new ViewHelperManager(
this.renderer,
this.getActiveCamera.bind(this),
@@ -635,6 +643,33 @@ class Load3d {
this.forceRender()
}
async loadHDRI(url: string): Promise<void> {
await this.hdriManager.loadHDRI(url)
this.forceRender()
}
setHDRIEnabled(enabled: boolean): void {
this.hdriManager.setEnabled(enabled)
this.lightingManager.setHDRIMode(enabled)
this.forceRender()
}
setHDRIAsBackground(show: boolean): void {
this.hdriManager.setShowAsBackground(show)
this.forceRender()
}
setHDRIIntensity(intensity: number): void {
this.hdriManager.setIntensity(intensity)
this.forceRender()
}
clearHDRI(): void {
this.hdriManager.clear()
this.lightingManager.setHDRIMode(false)
this.forceRender()
}
setTargetSize(width: number, height: number): void {
this.targetWidth = width
this.targetHeight = height
@@ -858,6 +893,7 @@ class Load3d {
this.cameraManager.dispose()
this.controlsManager.dispose()
this.lightingManager.dispose()
this.hdriManager.dispose()
this.viewHelperManager.dispose()
this.loaderManager.dispose()
this.modelManager.dispose()

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
describe('Load3dUtils.mapSceneLightIntensityToHdri', () => {
it('maps scene slider low end to a small positive HDRI intensity', () => {
expect(Load3dUtils.mapSceneLightIntensityToHdri(1, 1, 10)).toBe(0.25)
expect(Load3dUtils.mapSceneLightIntensityToHdri(10, 1, 10)).toBe(5)
})
it('maps midpoint proportionally', () => {
expect(Load3dUtils.mapSceneLightIntensityToHdri(5.5, 1, 10)).toBeCloseTo(
2.5
)
})
it('clamps scene ratio and HDRI ceiling', () => {
expect(Load3dUtils.mapSceneLightIntensityToHdri(-10, 1, 10)).toBe(0.25)
expect(Load3dUtils.mapSceneLightIntensityToHdri(100, 1, 10)).toBe(5)
})
it('uses minimum HDRI when span is zero', () => {
expect(Load3dUtils.mapSceneLightIntensityToHdri(3, 5, 5)).toBe(0.25)
})
})

View File

@@ -89,6 +89,15 @@ class Load3dUtils {
return uploadPath
}
static getFilenameExtension(url: string): string | undefined {
const queryString = url.split('?')[1]
if (queryString) {
const filename = new URLSearchParams(queryString).get('filename')
if (filename) return filename.split('.').pop()?.toLowerCase()
}
return url.split('?')[0].split('.').pop()?.toLowerCase()
}
static splitFilePath(path: string): [string, string] {
const folder_separator = path.lastIndexOf('/')
if (folder_separator === -1) {
@@ -122,6 +131,19 @@ class Load3dUtils {
await Promise.all(uploadPromises)
}
static mapSceneLightIntensityToHdri(
sceneIntensity: number,
sceneMin: number,
sceneMax: number
): number {
const span = sceneMax - sceneMin
const t = span > 0 ? (sceneIntensity - sceneMin) / span : 0
const clampedT = Math.min(1, Math.max(0, t))
const mapped = clampedT * 5
const minHdri = 0.25
return Math.min(5, Math.max(minHdri, mapped))
}
}
export default Load3dUtils

View File

@@ -16,3 +16,9 @@ export const SUPPORTED_EXTENSIONS = new Set([
])
export const SUPPORTED_EXTENSIONS_ACCEPT = [...SUPPORTED_EXTENSIONS].join(',')
export const SUPPORTED_HDRI_EXTENSIONS = new Set(['.hdr', '.exr'])
export const SUPPORTED_HDRI_EXTENSIONS_ACCEPT = [
...SUPPORTED_HDRI_EXTENSIONS
].join(',')

View File

@@ -47,6 +47,14 @@ export interface CameraConfig {
export interface LightConfig {
intensity: number
hdri?: HDRIConfig
}
export interface HDRIConfig {
enabled: boolean
hdriPath: string
showAsBackground: boolean
intensity: number
}
export interface EventCallback<T = unknown> {

View File

@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeLayout } from '@/renderer/core/layout/types'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
const TEST_NODE_TYPE = 'test/CloneZIndex' as const
class TestNode extends LGraphNode {
static override type = TEST_NODE_TYPE
constructor(title?: string) {
super(title ?? TEST_NODE_TYPE)
this.type = TEST_NODE_TYPE
}
}
function createCanvas(graph: LGraph): LGraphCanvas {
const el = document.createElement('canvas')
el.width = 800
el.height = 600
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} satisfies Partial<CanvasRenderingContext2D>
el.getContext = vi
.fn()
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
el.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
return new LGraphCanvas(el, graph, { skip_render: true })
}
function createLayoutEntry(node: LGraphNode, zIndex: number) {
const nodeId = String(node.id)
const layout: NodeLayout = {
id: nodeId,
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex,
visible: true,
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
}
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.Canvas,
actor: 'test'
})
}
function setZIndex(nodeId: string, zIndex: number, previousZIndex: number) {
layoutStore.applyOperation({
type: 'setNodeZIndex',
entity: 'node',
nodeId,
zIndex,
previousZIndex,
timestamp: Date.now(),
source: LayoutSource.Canvas,
actor: 'test'
})
}
describe('cloned node z-index in Vue renderer', () => {
let graph: LGraph
let canvas: LGraphCanvas
let previousVueNodesMode: boolean
beforeEach(() => {
vi.clearAllMocks()
previousVueNodesMode = LiteGraph.vueNodesMode
LiteGraph.vueNodesMode = true
LiteGraph.registerNodeType(TEST_NODE_TYPE, TestNode)
graph = new LGraph()
canvas = createCanvas(graph)
LGraphCanvas.active_canvas = canvas
layoutStore.initializeFromLiteGraph([])
// Simulate Vue runtime: create layout entries when nodes are added
graph.onNodeAdded = (node: LGraphNode) => {
createLayoutEntry(node, 0)
}
})
afterEach(() => {
LiteGraph.vueNodesMode = previousVueNodesMode
})
it('places cloned nodes above the original node z-index', () => {
const originalNode = new TestNode()
originalNode.pos = [100, 100]
originalNode.size = [200, 100]
graph.add(originalNode)
const originalNodeId = String(originalNode.id)
setZIndex(originalNodeId, 5, 0)
const originalLayout = layoutStore.getNodeLayoutRef(originalNodeId).value
expect(originalLayout?.zIndex).toBe(5)
// Clone the node via cloneNodes (same path as right-click > clone)
const result = LGraphCanvas.cloneNodes([originalNode])
expect(result).toBeDefined()
expect(result!.created.length).toBe(1)
const clonedNode = result!.created[0] as LGraphNode
const clonedNodeId = String(clonedNode.id)
// The cloned node should have a z-index higher than the original
const clonedLayout = layoutStore.getNodeLayoutRef(clonedNodeId).value
expect(clonedLayout).toBeDefined()
expect(clonedLayout!.zIndex).toBeGreaterThan(originalLayout!.zIndex)
})
it('assigns distinct sequential z-indices when cloning multiple nodes', () => {
const nodeA = new TestNode()
nodeA.pos = [100, 100]
nodeA.size = [200, 100]
graph.add(nodeA)
setZIndex(String(nodeA.id), 3, 0)
const nodeB = new TestNode()
nodeB.pos = [400, 100]
nodeB.size = [200, 100]
graph.add(nodeB)
setZIndex(String(nodeB.id), 7, 0)
const result = LGraphCanvas.cloneNodes([nodeA, nodeB])
expect(result).toBeDefined()
expect(result!.created.length).toBe(2)
const clonedA = result!.created[0] as LGraphNode
const clonedB = result!.created[1] as LGraphNode
const layoutA = layoutStore.getNodeLayoutRef(String(clonedA.id)).value!
const layoutB = layoutStore.getNodeLayoutRef(String(clonedB.id)).value!
// Both cloned nodes should be above the highest original (z-index 7)
expect(layoutA.zIndex).toBeGreaterThan(7)
expect(layoutB.zIndex).toBeGreaterThan(7)
// Each cloned node should have a distinct z-index
expect(layoutA.zIndex).not.toBe(layoutB.zIndex)
})
})

View File

@@ -7,6 +7,7 @@ import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -4270,6 +4271,17 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
layoutStore.batchUpdateNodeBounds(newPositions)
// Bring cloned/pasted nodes to front so they render above the originals
const allNodes = layoutStore.getAllNodes().value
let maxZIndex = 0
for (const [, layout] of allNodes) {
if (layout.zIndex > maxZIndex) maxZIndex = layout.zIndex
}
const { setNodeZIndex } = useLayoutMutations()
for (let i = 0; i < newPositions.length; i++) {
setNodeZIndex(newPositions[i].nodeId, maxZIndex + i + 1)
}
this.selectItems(created)
forEachNode(graph, (n) => n.onGraphConfigured?.())
forEachNode(graph, (n) => n.onAfterGraphConfigured?.())

View File

@@ -1988,7 +1988,16 @@
"openIn3DViewer": "Open in 3D Viewer",
"dropToLoad": "Drop 3D model to load",
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl, .ply, .spz, .splat, .ksplat)",
"uploadingModel": "Uploading 3D model..."
"uploadingModel": "Uploading 3D model...",
"loadingHDRI": "Loading HDRI...",
"hdri": {
"label": "HDRI Environment",
"uploadFile": "Upload HDRI (.hdr, .exr)",
"changeFile": "Change HDRI",
"removeFile": "Remove HDRI",
"showAsBackground": "Show as Background",
"intensity": "Intensity"
}
},
"imageCrop": {
"loading": "Loading...",
@@ -2083,7 +2092,9 @@
"failedToUpdateMaterialMode": "Failed to update material mode",
"failedToUpdateEdgeThreshold": "Failed to update edge threshold",
"failedToUploadBackgroundImage": "Failed to upload background image",
"failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}"
"failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}",
"failedToLoadHDRI": "Failed to load HDRI file",
"unsupportedHDRIFormat": "Unsupported file format. Please upload a .hdr or .exr file."
},
"nodeErrors": {
"render": "Node Render Error",

View File

@@ -36,7 +36,7 @@ export function isTransparent(color: string) {
return false
}
function rgbToHsl({ r, g, b }: RGB): HSL {
export function rgbToHsl({ r, g, b }: RGB): HSL {
r /= 255
g /= 255
b /= 255