[3d] support using image as background (#2657)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Terry Jia
2025-02-20 20:05:54 -05:00
committed by GitHub
parent 02d77002c9
commit c3c6ec627b
15 changed files with 475 additions and 72 deletions

View File

@@ -9,6 +9,7 @@
:fov="fov" :fov="fov"
:cameraType="cameraType" :cameraType="cameraType"
:showPreview="showPreview" :showPreview="showPreview"
:backgroundImage="backgroundImage"
@materialModeChange="listenMaterialModeChange" @materialModeChange="listenMaterialModeChange"
@backgroundColorChange="listenBackgroundColorChange" @backgroundColorChange="listenBackgroundColorChange"
@lightIntensityChange="listenLightIntensityChange" @lightIntensityChange="listenLightIntensityChange"
@@ -16,6 +17,7 @@
@cameraTypeChange="listenCameraTypeChange" @cameraTypeChange="listenCameraTypeChange"
@showGridChange="listenShowGridChange" @showGridChange="listenShowGridChange"
@showPreviewChange="listenShowPreviewChange" @showPreviewChange="listenShowPreviewChange"
@backgroundImageChange="listenBackgroundImageChange"
/> />
<Load3DControls <Load3DControls
:backgroundColor="backgroundColor" :backgroundColor="backgroundColor"
@@ -27,6 +29,8 @@
:showFOVButton="showFOVButton" :showFOVButton="showFOVButton"
:showPreviewButton="showPreviewButton" :showPreviewButton="showPreviewButton"
:cameraType="cameraType" :cameraType="cameraType"
:hasBackgroundImage="hasBackgroundImage"
@updateBackgroundImage="handleBackgroundImageUpdate"
@switchCamera="switchCamera" @switchCamera="switchCamera"
@toggleGrid="toggleGrid" @toggleGrid="toggleGrid"
@updateBackgroundColor="handleBackgroundColorChange" @updateBackgroundColor="handleBackgroundColorChange"
@@ -42,6 +46,7 @@ import { computed, ref } from 'vue'
import Load3DControls from '@/components/load3d/Load3DControls.vue' import Load3DControls from '@/components/load3d/Load3DControls.vue'
import Load3DScene from '@/components/load3d/Load3DScene.vue' import Load3DScene from '@/components/load3d/Load3DScene.vue'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
const props = defineProps<{ const props = defineProps<{
node: any node: any
@@ -57,6 +62,8 @@ const showLightIntensityButton = ref(true)
const fov = ref(75) const fov = ref(75)
const showFOVButton = ref(true) const showFOVButton = ref(true)
const cameraType = ref<'perspective' | 'orthographic'>('perspective') const cameraType = ref<'perspective' | 'orthographic'>('perspective')
const hasBackgroundImage = ref(false)
const backgroundImage = ref('')
const showPreviewButton = computed(() => { const showPreviewButton = computed(() => {
return !props.type.includes('Preview') return !props.type.includes('Preview')
@@ -89,6 +96,19 @@ const handleUpdateLightIntensity = (value: number) => {
node.value.properties['Light Intensity'] = lightIntensity.value node.value.properties['Light Intensity'] = lightIntensity.value
} }
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
hasBackgroundImage.value = false
backgroundImage.value = ''
node.value.properties['Background Image'] = ''
return
}
backgroundImage.value = await Load3dUtils.uploadFile(file)
node.value.properties['Background Image'] = backgroundImage.value
}
const handleUpdateFOV = (value: number) => { const handleUpdateFOV = (value: number) => {
fov.value = value fov.value = value
@@ -137,4 +157,12 @@ const listenShowGridChange = (value: boolean) => {
const listenShowPreviewChange = (value: boolean) => { const listenShowPreviewChange = (value: boolean) => {
showPreview.value = value showPreview.value = value
} }
const listenBackgroundImageChange = (value: string) => {
backgroundImage.value = value
if (backgroundImage.value && backgroundImage.value !== '') {
hasBackgroundImage.value = true
}
}
</script> </script>

View File

@@ -15,6 +15,7 @@
:playing="playing" :playing="playing"
:selectedSpeed="selectedSpeed" :selectedSpeed="selectedSpeed"
:selectedAnimation="selectedAnimation" :selectedAnimation="selectedAnimation"
:backgroundImage="backgroundImage"
@materialModeChange="listenMaterialModeChange" @materialModeChange="listenMaterialModeChange"
@backgroundColorChange="listenBackgroundColorChange" @backgroundColorChange="listenBackgroundColorChange"
@lightIntensityChange="listenLightIntensityChange" @lightIntensityChange="listenLightIntensityChange"
@@ -22,6 +23,7 @@
@cameraTypeChange="listenCameraTypeChange" @cameraTypeChange="listenCameraTypeChange"
@showGridChange="listenShowGridChange" @showGridChange="listenShowGridChange"
@showPreviewChange="listenShowPreviewChange" @showPreviewChange="listenShowPreviewChange"
@backgroundImageChange="listenBackgroundImageChange"
@animationListChange="animationListChange" @animationListChange="animationListChange"
/> />
<div class="absolute top-0 left-0 w-full h-full pointer-events-none"> <div class="absolute top-0 left-0 w-full h-full pointer-events-none">
@@ -35,6 +37,8 @@
:showFOVButton="showFOVButton" :showFOVButton="showFOVButton"
:showPreviewButton="showPreviewButton" :showPreviewButton="showPreviewButton"
:cameraType="cameraType" :cameraType="cameraType"
:hasBackgroundImage="hasBackgroundImage"
@updateBackgroundImage="handleBackgroundImageUpdate"
@switchCamera="switchCamera" @switchCamera="switchCamera"
@toggleGrid="toggleGrid" @toggleGrid="toggleGrid"
@updateBackgroundColor="handleBackgroundColorChange" @updateBackgroundColor="handleBackgroundColorChange"
@@ -60,6 +64,7 @@ import Load3DAnimationControls from '@/components/load3d/Load3DAnimationControls
import Load3DAnimationScene from '@/components/load3d/Load3DAnimationScene.vue' import Load3DAnimationScene from '@/components/load3d/Load3DAnimationScene.vue'
import Load3DControls from '@/components/load3d/Load3DControls.vue' import Load3DControls from '@/components/load3d/Load3DControls.vue'
import type { AnimationItem } from '@/extensions/core/load3d/Load3dAnimation' import type { AnimationItem } from '@/extensions/core/load3d/Load3dAnimation'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
const props = defineProps<{ const props = defineProps<{
node: any node: any
@@ -75,11 +80,13 @@ const showLightIntensityButton = ref(true)
const fov = ref(75) const fov = ref(75)
const showFOVButton = ref(true) const showFOVButton = ref(true)
const cameraType = ref<'perspective' | 'orthographic'>('perspective') const cameraType = ref<'perspective' | 'orthographic'>('perspective')
const hasBackgroundImage = ref(false)
const animations = ref<AnimationItem[]>([]) const animations = ref<AnimationItem[]>([])
const playing = ref(false) const playing = ref(false)
const selectedSpeed = ref(1) const selectedSpeed = ref(1)
const selectedAnimation = ref(0) const selectedAnimation = ref(0)
const backgroundImage = ref('')
const showPreviewButton = computed(() => { const showPreviewButton = computed(() => {
return !props.type.includes('Preview') return !props.type.includes('Preview')
@@ -112,6 +119,19 @@ const handleUpdateLightIntensity = (value: number) => {
node.value.properties['Light Intensity'] = lightIntensity.value node.value.properties['Light Intensity'] = lightIntensity.value
} }
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
hasBackgroundImage.value = false
backgroundImage.value = ''
node.value.properties['Background Image'] = ''
return
}
backgroundImage.value = await Load3dUtils.uploadFile(file)
node.value.properties['Background Image'] = backgroundImage.value
}
const handleUpdateFOV = (value: number) => { const handleUpdateFOV = (value: number) => {
fov.value = value fov.value = value
@@ -177,4 +197,12 @@ const listenShowGridChange = (value: boolean) => {
const listenShowPreviewChange = (value: boolean) => { const listenShowPreviewChange = (value: boolean) => {
showPreview.value = value showPreview.value = value
} }
const listenBackgroundImageChange = (value: string) => {
backgroundImage.value = value
if (backgroundImage.value && backgroundImage.value !== '') {
hasBackgroundImage.value = true
}
}
</script> </script>

View File

@@ -9,6 +9,7 @@
:cameraType="cameraType" :cameraType="cameraType"
:showPreview="showPreview" :showPreview="showPreview"
:extraListeners="animationListeners" :extraListeners="animationListeners"
:backgroundImage="backgroundImage"
@materialModeChange="listenMaterialModeChange" @materialModeChange="listenMaterialModeChange"
@backgroundColorChange="listenBackgroundColorChange" @backgroundColorChange="listenBackgroundColorChange"
@lightIntensityChange="listenLightIntensityChange" @lightIntensityChange="listenLightIntensityChange"
@@ -39,6 +40,7 @@ const props = defineProps<{
playing: boolean playing: boolean
selectedSpeed: number selectedSpeed: number
selectedAnimation: number selectedAnimation: number
backgroundImage: string
}>() }>()
const node = ref(props.node) const node = ref(props.node)

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="absolute top-2 left-2 flex flex-col gap-2 pointer-events-auto z-20" class="absolute top-2 left-2 flex flex-col pointer-events-auto z-20 bg-gray-700 bg-opacity-30 rounded-lg"
> >
<Button class="p-button-rounded p-button-text" @click="switchCamera"> <Button class="p-button-rounded p-button-text" @click="switchCamera">
<i <i
@@ -20,21 +20,60 @@
></i> ></i>
</Button> </Button>
<Button class="p-button-rounded p-button-text" @click="openColorPicker"> <div v-if="!hasBackgroundImage">
<i <Button class="p-button-rounded p-button-text" @click="openColorPicker">
class="pi pi-palette text-white text-lg" <i
v-tooltip.right="{ value: t('load3d.backgroundColor'), showDelay: 300 }" class="pi pi-palette text-white text-lg"
></i> v-tooltip.right="{
<input value: t('load3d.backgroundColor'),
type="color" showDelay: 300
ref="colorPickerRef" }"
:value="backgroundColor" ></i>
@input=" <input
updateBackgroundColor(($event.target as HTMLInputElement).value) type="color"
" ref="colorPickerRef"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none" :value="backgroundColor"
/> @input="
</Button> updateBackgroundColor(($event.target as HTMLInputElement).value)
"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
/>
</Button>
</div>
<div v-if="!hasBackgroundImage">
<Button class="p-button-rounded p-button-text" @click="openImagePicker">
<i
class="pi pi-image text-white text-lg"
v-tooltip.right="{
value: t('load3d.uploadBackgroundImage'),
showDelay: 300
}"
></i>
<input
type="file"
ref="imagePickerRef"
accept="image/*"
@change="uploadBackgroundImage"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
/>
</Button>
</div>
<div v-if="hasBackgroundImage">
<Button
class="p-button-rounded p-button-text"
@click="removeBackgroundImage"
>
<i
class="pi pi-times text-white text-lg"
v-tooltip.right="{
value: t('load3d.removeBackgroundImage'),
showDelay: 300
}"
></i>
</Button>
</div>
<div class="relative show-light-intensity" v-if="showLightIntensityButton"> <div class="relative show-light-intensity" v-if="showLightIntensityButton">
<Button <Button
@@ -123,6 +162,7 @@ const props = defineProps<{
showFOVButton: boolean showFOVButton: boolean
showPreviewButton: boolean showPreviewButton: boolean
cameraType: 'perspective' | 'orthographic' cameraType: 'perspective' | 'orthographic'
hasBackgroundImage?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -132,6 +172,7 @@ const emit = defineEmits<{
(e: 'updateLightIntensity', value: number): void (e: 'updateLightIntensity', value: number): void
(e: 'updateFOV', value: number): void (e: 'updateFOV', value: number): void
(e: 'togglePreview', value: boolean): void (e: 'togglePreview', value: boolean): void
(e: 'updateBackgroundImage', file: File | null): void
}>() }>()
const backgroundColor = ref(props.backgroundColor) const backgroundColor = ref(props.backgroundColor)
@@ -145,6 +186,8 @@ const fov = ref(props.fov)
const showFOV = ref(false) const showFOV = ref(false)
const showFOVButton = ref(props.showFOVButton) const showFOVButton = ref(props.showFOVButton)
const showPreviewButton = ref(props.showPreviewButton) const showPreviewButton = ref(props.showPreviewButton)
const hasBackgroundImage = ref(props.hasBackgroundImage)
const imagePickerRef = ref<HTMLInputElement | null>(null)
const switchCamera = () => { const switchCamera = () => {
emit('switchCamera') emit('switchCamera')
@@ -196,6 +239,26 @@ const closeSlider = (e: MouseEvent) => {
} }
} }
const openImagePicker = () => {
imagePickerRef.value?.click()
}
const uploadBackgroundImage = (event: Event) => {
const input = event.target as HTMLInputElement
hasBackgroundImage.value = true
if (input.files && input.files[0]) {
emit('updateBackgroundImage', input.files[0])
}
}
const removeBackgroundImage = () => {
hasBackgroundImage.value = false
emit('updateBackgroundImage', null)
}
watch( watch(
() => props.backgroundColor, () => props.backgroundColor,
(newValue) => { (newValue) => {
@@ -245,6 +308,13 @@ watch(
} }
) )
watch(
() => props.hasBackgroundImage,
(newValue) => {
hasBackgroundImage.value = newValue
}
)
onMounted(() => { onMounted(() => {
document.addEventListener('click', closeSlider) document.addEventListener('click', closeSlider)
}) })

View File

@@ -19,6 +19,7 @@ const props = defineProps<{
fov: number fov: number
cameraType: 'perspective' | 'orthographic' cameraType: 'perspective' | 'orthographic'
showPreview: boolean showPreview: boolean
backgroundImage: string
extraListeners?: Record<string, (value: any) => void> extraListeners?: Record<string, (value: any) => void>
}>() }>()
@@ -34,7 +35,8 @@ const eventConfig = {
fovChange: (value: number) => emit('fovChange', value), fovChange: (value: number) => emit('fovChange', value),
cameraTypeChange: (value: string) => emit('cameraTypeChange', value), cameraTypeChange: (value: string) => emit('cameraTypeChange', value),
showGridChange: (value: boolean) => emit('showGridChange', value), showGridChange: (value: boolean) => emit('showGridChange', value),
showPreviewChange: (value: boolean) => emit('showPreviewChange', value) showPreviewChange: (value: boolean) => emit('showPreviewChange', value),
backgroundImageChange: (value: string) => emit('backgroundImageChange', value)
} as const } as const
watchEffect(() => { watchEffect(() => {
@@ -47,6 +49,7 @@ watchEffect(() => {
rawLoad3d.setFOV(props.fov) rawLoad3d.setFOV(props.fov)
rawLoad3d.toggleCamera(props.cameraType) rawLoad3d.toggleCamera(props.cameraType)
rawLoad3d.togglePreview(props.showPreview) rawLoad3d.togglePreview(props.showPreview)
rawLoad3d.setBackgroundImage(props.backgroundImage)
} }
}) })
@@ -58,6 +61,7 @@ const emit = defineEmits<{
(e: 'cameraTypeChange', cameraType: string): void (e: 'cameraTypeChange', cameraType: string): void
(e: 'showGridChange', showGrid: boolean): void (e: 'showGridChange', showGrid: boolean): void
(e: 'showPreviewChange', showPreview: boolean): void (e: 'showPreviewChange', showPreview: boolean): void
(e: 'backgroundImageChange', backgroundImage: string): void
}>() }>()
const handleEvents = (action: 'add' | 'remove') => { const handleEvents = (action: 'add' | 'remove') => {

View File

@@ -9,6 +9,7 @@ import Load3DAnimation from '@/components/load3d/Load3DAnimation.vue'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration' import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation' import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useLoad3dService } from '@/services/load3dService' import { useLoad3dService } from '@/services/load3dService'
import { useToastStore } from '@/stores/toastStore' import { useToastStore } from '@/stores/toastStore'
@@ -71,14 +72,21 @@ app.registerExtension({
) as IStringWidget ) as IStringWidget
const uploadPath = await Load3dUtils.uploadFile( const uploadPath = await Load3dUtils.uploadFile(
useLoad3dService().getLoad3d(node), fileInput.files[0]
fileInput.files[0],
fileInput
).catch((error) => { ).catch((error) => {
console.error('File upload failed:', error) console.error('File upload failed:', error)
useToastStore().addAlert('File upload failed') useToastStore().addAlert('File upload failed')
}) })
const modelUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadPath),
'input'
)
)
await useLoad3dService().getLoad3d(node).loadModel(modelUrl)
if (uploadPath && modelWidget) { if (uploadPath && modelWidget) {
if (!modelWidget.options?.values?.includes(uploadPath)) { if (!modelWidget.options?.values?.includes(uploadPath)) {
modelWidget.options?.values?.push(uploadPath) modelWidget.options?.values?.push(uploadPath)
@@ -232,15 +240,23 @@ app.registerExtension({
const modelWidget = node.widgets?.find( const modelWidget = node.widgets?.find(
(w: IWidget) => w.name === 'model_file' (w: IWidget) => w.name === 'model_file'
) as IStringWidget ) as IStringWidget
const uploadPath = await Load3dUtils.uploadFile( const uploadPath = await Load3dUtils.uploadFile(
useLoad3dService().getLoad3d(node), fileInput.files[0]
fileInput.files[0],
fileInput
).catch((error) => { ).catch((error) => {
console.error('File upload failed:', error) console.error('File upload failed:', error)
useToastStore().addAlert('File upload failed') useToastStore().addAlert('File upload failed')
}) })
const modelUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadPath),
'input'
)
)
await useLoad3dService().getLoad3d(node).loadModel(modelUrl)
if (uploadPath && modelWidget) { if (uploadPath && modelWidget) {
if (!modelWidget.options?.values?.includes(uploadPath)) { if (!modelWidget.options?.values?.includes(uploadPath)) {
modelWidget.options?.values?.push(uploadPath) modelWidget.options?.values?.push(uploadPath)

View File

@@ -106,6 +106,10 @@ class Load3DConfiguration {
const fov = this.load3d.loadNodeProperty('FOV', 75) const fov = this.load3d.loadNodeProperty('FOV', 75)
this.load3d.setFOV(fov) this.load3d.setFOV(fov)
const backgroundImage = this.load3d.loadNodeProperty('Background Image', '')
this.load3d.setBackgroundImage(backgroundImage)
} }
private createModelUpdateHandler( private createModelUpdateHandler(

View File

@@ -1,5 +1,4 @@
import { LGraphNode } from '@comfyorg/litegraph' import { LGraphNode } from '@comfyorg/litegraph'
import Tooltip from 'primevue/tooltip'
import * as THREE from 'three' import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper' import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
@@ -9,6 +8,7 @@ import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader' import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader' import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { useToastStore } from '@/stores/toastStore' import { useToastStore } from '@/stores/toastStore'
interface Load3DOptions { interface Load3DOptions {
@@ -55,7 +55,17 @@ class Load3d {
showPreview: boolean = true showPreview: boolean = true
previewWidth: number = 120 previewWidth: number = 120
node: LGraphNode = {} as LGraphNode node: LGraphNode = {} as LGraphNode
private listeners: { [key: string]: Function[] } = {} listeners: { [key: string]: Function[] } = {}
backgroundScene: THREE.Scene
backgroundCamera: THREE.OrthographicCamera
backgroundMesh: THREE.Mesh | null = null
backgroundTexture: THREE.Texture | null = null
previewBackgroundScene: THREE.Scene
previewBackgroundCamera: THREE.OrthographicCamera
previewBackgroundMesh: THREE.Mesh | null = null
previewBackgroundTexture: THREE.Texture | null = null
constructor( constructor(
container: Element | HTMLElement, container: Element | HTMLElement,
@@ -75,8 +85,8 @@ class Load3d {
frustumSize / 2, frustumSize / 2,
frustumSize / 2, frustumSize / 2,
-frustumSize / 2, -frustumSize / 2,
0.1, 0.01,
1000 10000
) )
this.orthographicCamera.position.set(5, 5, 5) this.orthographicCamera.position.set(5, 5, 5)
@@ -90,6 +100,8 @@ class Load3d {
this.renderer.setClearColor(0x282828) this.renderer.setClearColor(0x282828)
this.renderer.autoClear = false this.renderer.autoClear = false
this.renderer.outputColorSpace = THREE.SRGBColorSpace
const rendererDomElement: HTMLCanvasElement = this.renderer.domElement const rendererDomElement: HTMLCanvasElement = this.renderer.domElement
container.appendChild(rendererDomElement) container.appendChild(rendererDomElement)
@@ -145,11 +157,145 @@ class Load3d {
this.createCapturePreview(container) this.createCapturePreview(container)
} }
this.backgroundScene = new THREE.Scene()
this.backgroundCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1)
this.previewBackgroundScene = this.backgroundScene.clone()
this.previewBackgroundCamera = this.backgroundCamera.clone()
const planeGeometry = new THREE.PlaneGeometry(2, 2)
const planeMaterial = new THREE.MeshBasicMaterial({
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
this.backgroundMesh = new THREE.Mesh(planeGeometry, planeMaterial)
this.backgroundMesh.position.set(0, 0, 0)
this.previewBackgroundMesh = this.backgroundMesh.clone()
this.backgroundScene.add(this.backgroundMesh)
this.previewBackgroundScene.add(this.previewBackgroundMesh)
this.handleResize() this.handleResize()
this.startAnimation() this.startAnimation()
} }
updateBackgroundSize(
backgroundTexture: THREE.Texture | null,
backgroundMesh: THREE.Mesh | null,
targetWidth: number,
targetHeight: number
) {
if (!backgroundTexture || !backgroundMesh) return
const material = backgroundMesh.material as THREE.MeshBasicMaterial
if (!material.map) return
const imageAspect =
backgroundTexture.image.width / backgroundTexture.image.height
const targetAspect = targetWidth / targetHeight
if (imageAspect > targetAspect) {
backgroundMesh.scale.set(imageAspect / targetAspect, 1, 1)
} else {
backgroundMesh.scale.set(1, targetAspect / imageAspect, 1)
}
material.needsUpdate = true
}
async setBackgroundImage(uploadPath: string) {
if (uploadPath === '') {
this.removeBackgroundImage()
return
}
let imageUrl = Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadPath)
)
if (!imageUrl.startsWith('/api')) {
imageUrl = '/api' + imageUrl
}
try {
const textureLoader = new THREE.TextureLoader()
const texture = await new Promise<THREE.Texture>((resolve, reject) => {
textureLoader.load(imageUrl, resolve, undefined, reject)
})
if (this.backgroundTexture) {
this.backgroundTexture.dispose()
}
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
}
texture.colorSpace = THREE.SRGBColorSpace
this.backgroundTexture = texture
this.previewBackgroundTexture = texture
const material = this.backgroundMesh?.material as THREE.MeshBasicMaterial
material.map = texture
material.needsUpdate = true
const material2 = this.previewBackgroundMesh
?.material as THREE.MeshBasicMaterial
material2.map = texture
material2.needsUpdate = true
this.backgroundMesh?.position.set(0, 0, 0)
this.previewBackgroundMesh?.position.set(0, 0, 0)
this.updateBackgroundSize(
this.previewBackgroundTexture,
this.previewBackgroundMesh,
this.targetWidth,
this.targetHeight
)
this.emitEvent('backgroundImageChange', uploadPath)
} catch (error) {
console.error('Error loading background image:', error)
}
}
removeBackgroundImage() {
if (this.backgroundMesh) {
const material = this.backgroundMesh.material as THREE.MeshBasicMaterial
material.map = null
material.needsUpdate = true
}
if (this.previewBackgroundMesh) {
const material2 = this.previewBackgroundMesh
.material as THREE.MeshBasicMaterial
material2.map = null
material2.needsUpdate = true
}
if (this.backgroundTexture) {
this.backgroundTexture.dispose()
this.backgroundTexture = null
}
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
this.previewBackgroundTexture = null
}
this.renderer.render(this.scene, this.activeCamera)
if (this.previewRenderer && this.previewCamera) {
this.previewRenderer.render(this.scene, this.previewCamera)
}
}
addEventListener(event: string, callback: Function) { addEventListener(event: string, callback: Function) {
if (!this.listeners[event]) { if (!this.listeners[event]) {
this.listeners[event] = [] this.listeners[event] = []
@@ -191,10 +337,14 @@ class Load3d {
createCapturePreview(container: Element | HTMLElement) { createCapturePreview(container: Element | HTMLElement) {
this.previewRenderer = new THREE.WebGLRenderer({ this.previewRenderer = new THREE.WebGLRenderer({
alpha: true, alpha: true,
antialias: true antialias: true,
preserveDrawingBuffer: true
}) })
this.previewRenderer.setSize(this.targetWidth, this.targetHeight) this.previewRenderer.setSize(this.targetWidth, this.targetHeight)
this.previewRenderer.setClearColor(0x282828) this.previewRenderer.setClearColor(0x282828)
this.previewRenderer.autoClear = false
this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace
this.previewContainer = document.createElement('div') this.previewContainer = document.createElement('div')
this.previewContainer.style.cssText = ` this.previewContainer.style.cssText = `
@@ -293,6 +443,7 @@ class Load3d {
;(this.previewCamera as THREE.PerspectiveCamera).fov = ( ;(this.previewCamera as THREE.PerspectiveCamera).fov = (
this.activeCamera as THREE.PerspectiveCamera this.activeCamera as THREE.PerspectiveCamera
).fov ).fov
;(this.previewCamera as THREE.PerspectiveCamera).updateProjectionMatrix()
} }
this.previewCamera.lookAt(this.controls.target) this.previewCamera.lookAt(this.controls.target)
@@ -300,6 +451,30 @@ class Load3d {
const previewHeight = const previewHeight =
(this.previewWidth * this.targetHeight) / this.targetWidth (this.previewWidth * this.targetHeight) / this.targetWidth
this.previewRenderer.setSize(this.previewWidth, previewHeight, false) this.previewRenderer.setSize(this.previewWidth, previewHeight, false)
this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace
this.previewRenderer.clear()
if (this.previewBackgroundMesh && this.previewBackgroundTexture) {
const material = this.previewBackgroundMesh
.material as THREE.MeshBasicMaterial
if (material.map) {
const currentToneMapping = this.previewRenderer.toneMapping
const currentExposure = this.previewRenderer.toneMappingExposure
this.previewRenderer.toneMapping = THREE.NoToneMapping
this.previewRenderer.render(
this.previewBackgroundScene,
this.previewBackgroundCamera
)
this.previewRenderer.toneMapping = currentToneMapping
this.previewRenderer.toneMappingExposure = currentExposure
}
}
this.previewRenderer.render(this.scene, this.previewCamera) this.previewRenderer.render(this.scene, this.previewCamera)
} }
@@ -313,9 +488,23 @@ class Load3d {
} }
setTargetSize(width: number, height: number) { setTargetSize(width: number, height: number) {
const oldAspect = this.targetWidth / this.targetHeight
this.targetWidth = width this.targetWidth = width
this.targetHeight = height this.targetHeight = height
this.updatePreviewSize() this.updatePreviewSize()
const newAspect = width / height
if (Math.abs(oldAspect - newAspect) > 0.001) {
this.updateBackgroundSize(
this.previewBackgroundTexture,
this.previewBackgroundMesh,
width,
height
)
}
if (this.previewRenderer && this.previewCamera) { if (this.previewRenderer && this.previewCamera) {
if (this.previewCamera instanceof THREE.PerspectiveCamera) { if (this.previewCamera instanceof THREE.PerspectiveCamera) {
this.previewCamera.aspect = width / height this.previewCamera.aspect = width / height
@@ -698,6 +887,22 @@ class Load3d {
} }
this.renderer.clear() this.renderer.clear()
if (this.backgroundMesh && this.backgroundTexture) {
const material = this.backgroundMesh.material as THREE.MeshBasicMaterial
if (material.map) {
const currentToneMapping = this.renderer.toneMapping
const currentExposure = this.renderer.toneMappingExposure
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.render(this.backgroundScene, this.backgroundCamera)
this.renderer.toneMapping = currentToneMapping
this.renderer.toneMappingExposure = currentExposure
}
}
this.controls.update() this.controls.update()
this.renderer.render(this.scene, this.activeCamera) this.renderer.render(this.scene, this.activeCamera)
this.viewHelper.render(this.renderer) this.viewHelper.render(this.renderer)
@@ -785,6 +990,24 @@ class Load3d {
cancelAnimationFrame(this.animationFrameId) cancelAnimationFrame(this.animationFrameId)
} }
if (this.backgroundTexture) {
this.backgroundTexture.dispose()
}
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
}
if (this.backgroundMesh) {
this.backgroundMesh.geometry.dispose()
;(this.backgroundMesh.material as THREE.Material).dispose()
}
if (this.previewBackgroundMesh) {
this.previewBackgroundMesh.geometry.dispose()
;(this.previewBackgroundMesh.material as THREE.Material).dispose()
}
this.controls.dispose() this.controls.dispose()
this.viewHelper.dispose() this.viewHelper.dispose()
this.renderer.dispose() this.renderer.dispose()
@@ -984,6 +1207,16 @@ class Load3d {
} }
this.renderer.setSize(width, height) this.renderer.setSize(width, height)
if (this.backgroundTexture && this.backgroundMesh) {
this.updateBackgroundSize(
this.backgroundTexture,
this.backgroundMesh,
width,
height
)
}
this.setTargetSize(this.targetWidth, this.targetHeight) this.setTargetSize(this.targetWidth, this.targetHeight)
} }
@@ -1007,6 +1240,8 @@ class Load3d {
new THREE.Color() new THREE.Color()
) )
const originalClearAlpha = this.renderer.getClearAlpha() const originalClearAlpha = this.renderer.getClearAlpha()
const originalToneMapping = this.renderer.toneMapping
const originalExposure = this.renderer.toneMappingExposure
this.renderer.setSize(width, height) this.renderer.setSize(width, height)
@@ -1023,7 +1258,29 @@ class Load3d {
this.orthographicCamera.updateProjectionMatrix() this.orthographicCamera.updateProjectionMatrix()
} }
if (this.backgroundTexture && this.backgroundMesh) {
this.updateBackgroundSize(
this.backgroundTexture,
this.backgroundMesh,
width,
height
)
}
this.renderer.clear() this.renderer.clear()
if (this.backgroundMesh && this.backgroundTexture) {
const material = this.backgroundMesh
.material as THREE.MeshBasicMaterial
if (material.map) {
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.render(this.backgroundScene, this.backgroundCamera)
this.renderer.toneMapping = originalToneMapping
this.renderer.toneMappingExposure = originalExposure
}
}
this.renderer.render(this.scene, this.activeCamera) this.renderer.render(this.scene, this.activeCamera)
const sceneData = this.renderer.domElement.toDataURL('image/png') const sceneData = this.renderer.domElement.toDataURL('image/png')

View File

@@ -169,12 +169,6 @@ class Load3dAnimation extends Load3d {
this.currentAnimation.update(delta) this.currentAnimation.update(delta)
} }
this.controls.update()
this.renderer.clear()
this.renderer.render(this.scene, this.activeCamera)
if (this.viewHelper.animating) { if (this.viewHelper.animating) {
this.viewHelper.update(delta) this.viewHelper.update(delta)
@@ -183,6 +177,25 @@ class Load3dAnimation extends Load3d {
} }
} }
this.renderer.clear()
if (this.backgroundMesh && this.backgroundTexture) {
const material = this.backgroundMesh.material as THREE.MeshBasicMaterial
if (material.map) {
const currentToneMapping = this.renderer.toneMapping
const currentExposure = this.renderer.toneMappingExposure
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.render(this.backgroundScene, this.backgroundCamera)
this.renderer.toneMapping = currentToneMapping
this.renderer.toneMappingExposure = currentExposure
}
}
this.controls.update()
this.renderer.render(this.scene, this.activeCamera)
this.viewHelper.render(this.renderer) this.viewHelper.render(this.renderer)
} }
animate() animate()

View File

@@ -28,11 +28,7 @@ class Load3dUtils {
return await resp.json() return await resp.json()
} }
static async uploadFile( static async uploadFile(file: File) {
load3d: Load3d,
file: File,
fileInput?: HTMLInputElement
) {
let uploadPath let uploadPath
try { try {
@@ -49,36 +45,11 @@ class Load3dUtils {
const data = await resp.json() const data = await resp.json()
let path = data.name let path = data.name
if (data.subfolder) path = data.subfolder + '/' + path if (data.subfolder) {
path = data.subfolder + '/' + path
}
uploadPath = path uploadPath = path
const modelUrl = api.apiURL(
this.getResourceURL(...this.splitFilePath(path), 'input')
)
await load3d.loadModel(modelUrl, file.name)
const fileExt = file.name.split('.').pop()?.toLowerCase()
if (fileExt === 'obj' && fileInput?.files) {
try {
const mtlFile = Array.from(fileInput.files).find((f) =>
f.name.toLowerCase().endsWith('.mtl')
)
if (mtlFile) {
const mtlFormData = new FormData()
mtlFormData.append('image', mtlFile)
mtlFormData.append('subfolder', '3d')
await api.fetchApi('/upload/image', {
method: 'POST',
body: mtlFormData
})
}
} catch (mtlError) {
console.warn('Failed to upload MTL file:', mtlError)
}
}
} else { } else {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText) useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
} }

View File

@@ -845,6 +845,8 @@
"backgroundColor": "Background Color", "backgroundColor": "Background Color",
"lightIntensity": "Light Intensity", "lightIntensity": "Light Intensity",
"fov": "FOV", "fov": "FOV",
"previewOutput": "Preview Output" "previewOutput": "Preview Output",
"uploadBackgroundImage": "Upload Background Image",
"removeBackgroundImage": "Remove Background Image"
} }
} }

View File

@@ -322,8 +322,10 @@
"fov": "FOV", "fov": "FOV",
"lightIntensity": "Intensité de la lumière", "lightIntensity": "Intensité de la lumière",
"previewOutput": "Aperçu de la sortie", "previewOutput": "Aperçu de la sortie",
"removeBackgroundImage": "Supprimer l'image de fond",
"showGrid": "Afficher la grille", "showGrid": "Afficher la grille",
"switchCamera": "Changer de caméra" "switchCamera": "Changer de caméra",
"uploadBackgroundImage": "Télécharger l'image de fond"
}, },
"maintenance": { "maintenance": {
"None": "Aucun", "None": "Aucun",

View File

@@ -322,8 +322,10 @@
"fov": "FOV", "fov": "FOV",
"lightIntensity": "光の強度", "lightIntensity": "光の強度",
"previewOutput": "出力のプレビュー", "previewOutput": "出力のプレビュー",
"removeBackgroundImage": "背景画像を削除",
"showGrid": "グリッドを表示", "showGrid": "グリッドを表示",
"switchCamera": "カメラを切り替える" "switchCamera": "カメラを切り替える",
"uploadBackgroundImage": "背景画像をアップロード"
}, },
"maintenance": { "maintenance": {
"None": "なし", "None": "なし",

View File

@@ -322,8 +322,10 @@
"fov": "Угол обзора", "fov": "Угол обзора",
"lightIntensity": "Интенсивность света", "lightIntensity": "Интенсивность света",
"previewOutput": "Предварительный просмотр", "previewOutput": "Предварительный просмотр",
"removeBackgroundImage": "Удалить фоновое изображение",
"showGrid": "Показать сетку", "showGrid": "Показать сетку",
"switchCamera": "Переключить камеру" "switchCamera": "Переключить камеру",
"uploadBackgroundImage": "Загрузить фоновое изображение"
}, },
"maintenance": { "maintenance": {
"None": "Нет", "None": "Нет",

View File

@@ -322,8 +322,10 @@
"fov": "视场", "fov": "视场",
"lightIntensity": "光照强度", "lightIntensity": "光照强度",
"previewOutput": "预览输出", "previewOutput": "预览输出",
"removeBackgroundImage": "移除背景图片",
"showGrid": "显示网格", "showGrid": "显示网格",
"switchCamera": "切换摄像头" "switchCamera": "切换摄像头",
"uploadBackgroundImage": "上传背景图片"
}, },
"maintenance": { "maintenance": {
"None": "无", "None": "无",