diff --git a/src/components/load3d/Load3DScene.vue b/src/components/load3d/Load3DScene.vue index d74e4aab6..86a6b82a9 100644 --- a/src/components/load3d/Load3DScene.vue +++ b/src/components/load3d/Load3DScene.vue @@ -76,7 +76,7 @@ const eventConfig = { emit('recordingStatusChange', value) } as const -watchEffect(async () => { +watchEffect(() => { if (load3d.value) { const rawLoad3d = toRaw(load3d.value) as Load3d @@ -86,10 +86,20 @@ watchEffect(async () => { rawLoad3d.setFOV(props.fov) rawLoad3d.toggleCamera(props.cameraType) rawLoad3d.togglePreview(props.showPreview) - await rawLoad3d.setBackgroundImage(props.backgroundImage) } }) +watch( + () => props.backgroundImage, + async (newValue) => { + if (load3d.value) { + const rawLoad3d = toRaw(load3d.value) as Load3d + + await rawLoad3d.setBackgroundImage(newValue) + } + } +) + watch( () => props.upDirection, (newValue) => { diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index dea888b24..92cd5bc6c 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -118,7 +118,11 @@ class Load3d { options ) - this.loaderManager = new LoaderManager(this.modelManager, this.eventManager) + this.loaderManager = new LoaderManager( + this.modelManager, + this.eventManager, + options + ) this.recordingManager = new RecordingManager( this.sceneManager.scene, diff --git a/src/extensions/core/load3d/LoaderManager.ts b/src/extensions/core/load3d/LoaderManager.ts index 24d9fffb5..6ea1c2477 100644 --- a/src/extensions/core/load3d/LoaderManager.ts +++ b/src/extensions/core/load3d/LoaderManager.ts @@ -1,15 +1,16 @@ import * as THREE from 'three' import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader' import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' -import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader' import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader' import { STLLoader } from 'three/examples/jsm/loaders/STLLoader' +import { OverrideMTLLoader } from '@/extensions/core/load3d/threejsOverride/OverrideMTLLoader' import { t } from '@/i18n' import { useToastStore } from '@/stores/toastStore' import { EventManagerInterface, + Load3DOptions, LoaderManagerInterface, ModelManagerInterface } from './interfaces' @@ -17,7 +18,7 @@ import { export class LoaderManager implements LoaderManagerInterface { gltfLoader: GLTFLoader objLoader: OBJLoader - mtlLoader: MTLLoader + mtlLoader: OverrideMTLLoader fbxLoader: FBXLoader stlLoader: STLLoader @@ -26,14 +27,21 @@ export class LoaderManager implements LoaderManagerInterface { constructor( modelManager: ModelManagerInterface, - eventManager: EventManagerInterface + eventManager: EventManagerInterface, + options: Load3DOptions ) { + let loadRootFolder = 'input' + + if (options && options.inputSpec?.isPreview) { + loadRootFolder = 'output' + } + this.modelManager = modelManager this.eventManager = eventManager this.gltfLoader = new GLTFLoader() this.objLoader = new OBJLoader() - this.mtlLoader = new MTLLoader() + this.mtlLoader = new OverrideMTLLoader(loadRootFolder) this.fbxLoader = new FBXLoader() this.stlLoader = new STLLoader() } @@ -122,7 +130,8 @@ export class LoaderManager implements LoaderManagerInterface { case 'obj': if (this.modelManager.materialMode === 'original') { - const mtlUrl = url.replace(/\.obj([^.]*$)/, '.mtl$1') + const mtlUrl = url.replace(/(filename=.*?)\.obj/, '$1.mtl') + try { const materials = await this.mtlLoader.loadAsync(mtlUrl) materials.preload() diff --git a/src/extensions/core/load3d/threejsOverride/OverrideMTLLoader.js b/src/extensions/core/load3d/threejsOverride/OverrideMTLLoader.js new file mode 100644 index 000000000..084e38425 --- /dev/null +++ b/src/extensions/core/load3d/threejsOverride/OverrideMTLLoader.js @@ -0,0 +1,513 @@ +import { + Color, + ColorManagement, + DefaultLoadingManager, + FileLoader, + FrontSide, + Loader, + LoaderUtils, + MeshPhongMaterial, + RepeatWrapping, + SRGBColorSpace, + TextureLoader, + Vector2 +} from 'three' + +/** + * A loader for the MTL format. + * + * The Material Template Library format (MTL) or .MTL File Format is a companion file format + * to OBJ that describes surface shading (material) properties of objects within one or more + * OBJ files. + * + * ```js + * const loader = new MTLLoader(); + * const materials = await loader.loadAsync( 'models/obj/male02/male02.mtl' ); + * + * const objLoader = new OBJLoader(); + * objLoader.setMaterials( materials ); + * ``` + * + * @augments Loader + * @three_import import { MTLLoader } from 'three/addons/loaders/MTLLoader.js'; + */ +class OverrideMTLLoader extends Loader { + constructor(loadRootFolder, manager) { + super(manager) + + this.loadRootFolder = loadRootFolder + } + + /** + * Starts loading from the given URL and passes the loaded MTL asset + * to the `onLoad()` callback. + * + * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI. + * @param {function(MaterialCreator)} onLoad - Executed when the loading process has been finished. + * @param {onProgressCallback} onProgress - Executed while the loading is in progress. + * @param {onErrorCallback} onError - Executed when errors occur. + */ + load(url, onLoad, onProgress, onError) { + const scope = this + + const path = this.path === '' ? LoaderUtils.extractUrlBase(url) : this.path + + const loader = new FileLoader(this.manager) + loader.setPath(this.path) + loader.setRequestHeader(this.requestHeader) + loader.setWithCredentials(this.withCredentials) + loader.load( + url, + function (text) { + try { + onLoad(scope.parse(text, path)) + } catch (e) { + if (onError) { + onError(e) + } else { + console.error(e) + } + + scope.manager.itemError(url) + } + }, + onProgress, + onError + ) + } + + /** + * Sets the material options. + * + * @param {MTLLoader~MaterialOptions} value - The material options. + * @return {MTLLoader} A reference to this loader. + */ + setMaterialOptions(value) { + this.materialOptions = value + return this + } + + /** + * Parses the given MTL data and returns the resulting material creator. + * + * @param {string} text - The raw MTL data as a string. + * @param {string} path - The URL base path. + * @return {MaterialCreator} The material creator. + */ + parse(text, path) { + const lines = text.split('\n') + let info = {} + const delimiter_pattern = /\s+/ + const materialsInfo = {} + + for (let i = 0; i < lines.length; i++) { + let line = lines[i] + line = line.trim() + + if (line.length === 0 || line.charAt(0) === '#') { + // Blank line or comment ignore + continue + } + + const pos = line.indexOf(' ') + + let key = pos >= 0 ? line.substring(0, pos) : line + key = key.toLowerCase() + + let value = pos >= 0 ? line.substring(pos + 1) : '' + value = value.trim() + + if (key === 'newmtl') { + // New material + + info = { name: value } + materialsInfo[value] = info + } else { + if (key === 'ka' || key === 'kd' || key === 'ks' || key === 'ke') { + const ss = value.split(delimiter_pattern, 3) + info[key] = [parseFloat(ss[0]), parseFloat(ss[1]), parseFloat(ss[2])] + } else { + info[key] = value + } + } + } + + const materialCreator = new OverrideMaterialCreator( + this.resourcePath || path, + this.materialOptions, + this.loadRootFolder + ) + materialCreator.setCrossOrigin(this.crossOrigin) + materialCreator.setManager(this.manager) + materialCreator.setMaterials(materialsInfo) + return materialCreator + } +} + +/** + * Material options of `MTLLoader`. + * + * @typedef {Object} MTLLoader~MaterialOptions + * @property {(FrontSide|BackSide|DoubleSide)} [side=FrontSide] - Which side to apply the material. + * @property {(RepeatWrapping|ClampToEdgeWrapping|MirroredRepeatWrapping)} [wrap=RepeatWrapping] - What type of wrapping to apply for textures. + * @property {boolean} [normalizeRGB=false] - Whether RGB colors should be normalized to `0-1` from `0-255`. + * @property {boolean} [ignoreZeroRGBs=false] - Ignore values of RGBs (Ka,Kd,Ks) that are all 0's. + */ + +class OverrideMaterialCreator { + constructor(baseUrl = '', options = {}, loadRootFolder) { + this.baseUrl = baseUrl + this.options = options + this.materialsInfo = {} + this.materials = {} + this.materialsArray = [] + this.nameLookup = {} + + this.loadRootFolder = loadRootFolder + + this.crossOrigin = 'anonymous' + + this.side = this.options.side !== undefined ? this.options.side : FrontSide + this.wrap = + this.options.wrap !== undefined ? this.options.wrap : RepeatWrapping + } + + setCrossOrigin(value) { + this.crossOrigin = value + return this + } + + setManager(value) { + this.manager = value + } + + setMaterials(materialsInfo) { + this.materialsInfo = this.convert(materialsInfo) + this.materials = {} + this.materialsArray = [] + this.nameLookup = {} + } + + convert(materialsInfo) { + if (!this.options) return materialsInfo + + const converted = {} + + for (const mn in materialsInfo) { + // Convert materials info into normalized form based on options + + const mat = materialsInfo[mn] + + const covmat = {} + + converted[mn] = covmat + + for (const prop in mat) { + let save = true + let value = mat[prop] + const lprop = prop.toLowerCase() + + switch (lprop) { + case 'kd': + case 'ka': + case 'ks': + // Diffuse color (color under white light) using RGB values + + if (this.options && this.options.normalizeRGB) { + value = [value[0] / 255, value[1] / 255, value[2] / 255] + } + + if (this.options && this.options.ignoreZeroRGBs) { + if (value[0] === 0 && value[1] === 0 && value[2] === 0) { + // ignore + + save = false + } + } + + break + + default: + break + } + + if (save) { + covmat[lprop] = value + } + } + } + + return converted + } + + preload() { + for (const mn in this.materialsInfo) { + this.create(mn) + } + } + + getIndex(materialName) { + return this.nameLookup[materialName] + } + + getAsArray() { + let index = 0 + + for (const mn in this.materialsInfo) { + this.materialsArray[index] = this.create(mn) + this.nameLookup[mn] = index + index++ + } + + return this.materialsArray + } + + create(materialName) { + if (this.materials[materialName] === undefined) { + this.createMaterial_(materialName) + } + + return this.materials[materialName] + } + + createMaterial_(materialName) { + // Create material + + const scope = this + const mat = this.materialsInfo[materialName] + const params = { + name: materialName, + side: this.side + } + + /** + * Override for ComfyUI api url + */ + function resolveURL(baseUrl, url, loadRootFolder) { + if (typeof url !== 'string' || url === '') return '' + + baseUrl = + baseUrl + + '/view?filename=' + + url + + '&type=' + + loadRootFolder + + '&subfolder=3d' + + return baseUrl + } + + function setMapForType(mapType, value) { + if (params[mapType]) return // Keep the first encountered texture + + const texParams = scope.getTextureParams(value, params) + const map = scope.loadTexture( + resolveURL(scope.baseUrl, texParams.url, scope.loadRootFolder) + ) + + map.repeat.copy(texParams.scale) + map.offset.copy(texParams.offset) + + map.wrapS = scope.wrap + map.wrapT = scope.wrap + + if (mapType === 'map' || mapType === 'emissiveMap') { + map.colorSpace = SRGBColorSpace + } + + params[mapType] = map + } + + for (const prop in mat) { + const value = mat[prop] + let n + + if (value === '') continue + + switch (prop.toLowerCase()) { + // Ns is material specular exponent + + case 'kd': + // Diffuse color (color under white light) using RGB values + + params.color = ColorManagement.toWorkingColorSpace( + new Color().fromArray(value), + SRGBColorSpace + ) + + break + + case 'ks': + // Specular color (color when light is reflected from shiny surface) using RGB values + params.specular = ColorManagement.toWorkingColorSpace( + new Color().fromArray(value), + SRGBColorSpace + ) + + break + + case 'ke': + // Emissive using RGB values + params.emissive = ColorManagement.toWorkingColorSpace( + new Color().fromArray(value), + SRGBColorSpace + ) + + break + + case 'map_kd': + // Diffuse texture map + + setMapForType('map', value) + + break + + case 'map_ks': + // Specular map + + setMapForType('specularMap', value) + + break + + case 'map_ke': + // Emissive map + + setMapForType('emissiveMap', value) + + break + + case 'norm': + setMapForType('normalMap', value) + + break + + case 'map_bump': + case 'bump': + // Bump texture map + + setMapForType('bumpMap', value) + + break + + case 'disp': + // Displacement texture map + + setMapForType('displacementMap', value) + + break + + case 'map_d': + // Alpha map + + setMapForType('alphaMap', value) + params.transparent = true + + break + + case 'ns': + // The specular exponent (defines the focus of the specular highlight) + // A high exponent results in a tight, concentrated highlight. Ns values normally range from 0 to 1000. + + params.shininess = parseFloat(value) + + break + + case 'd': + n = parseFloat(value) + + if (n < 1) { + params.opacity = n + params.transparent = true + } + + break + + case 'tr': + n = parseFloat(value) + + if (this.options && this.options.invertTrProperty) n = 1 - n + + if (n > 0) { + params.opacity = 1 - n + params.transparent = true + } + + break + + default: + break + } + } + + this.materials[materialName] = new MeshPhongMaterial(params) + return this.materials[materialName] + } + + getTextureParams(value, matParams) { + const texParams = { + scale: new Vector2(1, 1), + offset: new Vector2(0, 0) + } + + const items = value.split(/\s+/) + let pos + + pos = items.indexOf('-bm') + + if (pos >= 0) { + matParams.bumpScale = parseFloat(items[pos + 1]) + items.splice(pos, 2) + } + + pos = items.indexOf('-mm') + + if (pos >= 0) { + matParams.displacementBias = parseFloat(items[pos + 1]) + matParams.displacementScale = parseFloat(items[pos + 2]) + items.splice(pos, 3) + } + + pos = items.indexOf('-s') + + if (pos >= 0) { + texParams.scale.set( + parseFloat(items[pos + 1]), + parseFloat(items[pos + 2]) + ) + items.splice(pos, 4) // we expect 3 parameters here! + } + + pos = items.indexOf('-o') + + if (pos >= 0) { + texParams.offset.set( + parseFloat(items[pos + 1]), + parseFloat(items[pos + 2]) + ) + items.splice(pos, 4) // we expect 3 parameters here! + } + + texParams.url = items.join(' ').trim() + return texParams + } + + loadTexture(url, mapping, onLoad, onProgress, onError) { + const manager = + this.manager !== undefined ? this.manager : DefaultLoadingManager + let loader = manager.getHandler(url) + + if (loader === null) { + loader = new TextureLoader(manager) + } + + if (loader.setCrossOrigin) loader.setCrossOrigin(this.crossOrigin) + + const texture = loader.load(url, onLoad, onProgress, onError) + + if (mapping !== undefined) texture.mapping = mapping + + return texture + } +} + +export { OverrideMTLLoader } diff --git a/src/locales/en/main.json b/src/locales/en/main.json index dd071b34f..a4827df35 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1229,7 +1229,8 @@ "stopRecording": "Stop Recording", "exportRecording": "Export Recording", "clearRecording": "Clear Recording", - "resizeNodeMatchOutput": "Resize Node to match output" + "resizeNodeMatchOutput": "Resize Node to match output", + "loadingBackgroundImage": "Loading Background Image" }, "toastMessages": { "nothingToQueue": "Nothing to queue", diff --git a/src/locales/es/main.json b/src/locales/es/main.json index 63a532ed7..7bf170972 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -520,6 +520,7 @@ "fov": "FOV", "light": "Luz", "lightIntensity": "Intensidad de luz", + "loadingBackgroundImage": "Cargando imagen de fondo", "loadingModel": "Cargando modelo 3D...", "materialMode": "Modo de material", "materialModes": { diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 63a93bd9a..6fd8af209 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -520,6 +520,7 @@ "fov": "FOV", "light": "Lumière", "lightIntensity": "Intensité de la lumière", + "loadingBackgroundImage": "Chargement de l’image d’arrière-plan", "loadingModel": "Chargement du modèle 3D...", "materialMode": "Mode Matériel", "materialModes": { diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index f34b5744a..659ba39e2 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -520,6 +520,7 @@ "fov": "FOV", "light": "ライト", "lightIntensity": "光の強度", + "loadingBackgroundImage": "背景画像を読み込んでいます", "loadingModel": "3Dモデルを読み込んでいます...", "materialMode": "マテリアルモード", "materialModes": { diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index bcf6003dc..a5ff5568f 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -520,6 +520,7 @@ "fov": "FOV", "light": "빛", "lightIntensity": "조명 강도", + "loadingBackgroundImage": "배경 이미지 불러오는 중", "loadingModel": "3D 모델 로딩 중...", "materialMode": "재질 모드", "materialModes": { diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index e788576ee..5ec591974 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -520,6 +520,7 @@ "fov": "Угол обзора", "light": "Свет", "lightIntensity": "Интенсивность света", + "loadingBackgroundImage": "Загрузка фонового изображения", "loadingModel": "Загрузка 3D модели...", "materialMode": "Режим Материала", "materialModes": { diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index f26c4b2ad..61c68d299 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -520,6 +520,7 @@ "fov": "视场", "light": "灯光", "lightIntensity": "光照强度", + "loadingBackgroundImage": "正在加载背景图像", "loadingModel": "正在加载3D模型...", "materialMode": "材质模式", "materialModes": {