mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
feat(load3d): add optional HDRI environment lighting to 3D preview nodes (#10818)
## Summary
Adds `HDRIManager` to load `.hdr/.exr` files as equirectangular
environment maps via **three.js** `RGBELoader/EXRLoader`
- Uploads HDRI files to the server via `/upload/image` API so they
persist across page reloads
- Restores HDRI state (enabled, **intensity**, **background**) from node
properties on reload
- Auto-enables "**Show as Background**" on successful upload for
immediate visual feedback
- Hides standard directional lights when HDRI is active; restores them
when disabled
- Hides the Light Intensity control while HDRI is active (lights have no
effect when HDRI overrides scene lighting)
- Limits HDRI availability to PBR-capable formats (.gltf, .glb, .fbx,
.obj); automatically disables when switching to an incompatible model
- Adds intensity slider and "**Show as Background**" toggle to the HDRI
panel
## How to Use HDRI Environment Lighting
1. Load a 3D model using a Load3D or Load3DViewer node (supported
formats: .gltf, .glb, .fbx, .obj)
2. Open the control panel → go to the Light tab
3. Click the globe icon to open the **HDRI panel**
4. Click Upload HDRI and select a` .hdr` or `.exr` file
5. The environment lighting applies automatically — the scene background
also updates to preview the panorama
6. Use the intensity slider to adjust the strength of the environment
lighting
7. Toggle Show as Background to show or hide the HDRI panorama behind
the model
## Screenshots
https://github.com/user-attachments/assets/1ec56ef0-853e-452f-ae2b-2474c9d0d781
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10818-feat-load3d-add-optional-HDRI-environment-lighting-to-3D-preview-nodes-3366d73d365081ea8c7ad9226b8b1e2f)
by [Unito](https://www.unito.io)
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> Adds new HDRI loading/rendering path and persists new
`LightConfig.hdri` state, touching Three.js rendering, file uploads, and
node property restoration. Risk is moderate due to new async flows and
potential compatibility/performance issues with model switching and
renderer settings.
>
> **Overview**
> Adds optional **HDRI environment lighting** to Load3D previews,
including a new `HDRIManager` that loads `.hdr`/`.exr` files into
Three.js environment/background and exposes controls for enable/disable,
background display, and intensity.
>
> Extends `LightConfig` with an `hdri` block that is persisted on nodes
and restored on reload; `useLoad3d` now uploads HDRI files, loads them
into `Load3d`, maps scene light intensity to HDRI intensity, and
auto-disables HDRI when the current model format doesn’t support it.
>
> Updates the UI to include embedded HDRI controls under the Light panel
(with dismissable overlays and icon updates), adjusts light intensity
behavior when HDRI is active, and adds tests/strings/utilities
(`getFilenameExtension`, `mapSceneLightIntensityToHdri`, new constants)
to support the feature.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b12c9722dc. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
---------
Co-authored-by: Terry Jia <terryjia88@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
148
src/components/load3d/controls/HDRIControls.vue
Normal file
148
src/components/load3d/controls/HDRIControls.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
223
src/extensions/core/load3d/HDRIManager.test.ts
Normal file
223
src/extensions/core/load3d/HDRIManager.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
142
src/extensions/core/load3d/HDRIManager.ts
Normal file
142
src/extensions/core/load3d/HDRIManager.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
25
src/extensions/core/load3d/Load3dUtils.test.ts
Normal file
25
src/extensions/core/load3d/Load3dUtils.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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(',')
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user