mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-01 22:09:55 +00:00
[3d] support mtl for obj file (#3933)
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
513
src/extensions/core/load3d/threejsOverride/OverrideMTLLoader.js
Normal file
513
src/extensions/core/load3d/threejsOverride/OverrideMTLLoader.js
Normal file
@@ -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 }
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -520,6 +520,7 @@
|
||||
"fov": "FOV",
|
||||
"light": "ライト",
|
||||
"lightIntensity": "光の強度",
|
||||
"loadingBackgroundImage": "背景画像を読み込んでいます",
|
||||
"loadingModel": "3Dモデルを読み込んでいます...",
|
||||
"materialMode": "マテリアルモード",
|
||||
"materialModes": {
|
||||
|
||||
@@ -520,6 +520,7 @@
|
||||
"fov": "FOV",
|
||||
"light": "빛",
|
||||
"lightIntensity": "조명 강도",
|
||||
"loadingBackgroundImage": "배경 이미지 불러오는 중",
|
||||
"loadingModel": "3D 모델 로딩 중...",
|
||||
"materialMode": "재질 모드",
|
||||
"materialModes": {
|
||||
|
||||
@@ -520,6 +520,7 @@
|
||||
"fov": "Угол обзора",
|
||||
"light": "Свет",
|
||||
"lightIntensity": "Интенсивность света",
|
||||
"loadingBackgroundImage": "Загрузка фонового изображения",
|
||||
"loadingModel": "Загрузка 3D модели...",
|
||||
"materialMode": "Режим Материала",
|
||||
"materialModes": {
|
||||
|
||||
@@ -520,6 +520,7 @@
|
||||
"fov": "视场",
|
||||
"light": "灯光",
|
||||
"lightIntensity": "光照强度",
|
||||
"loadingBackgroundImage": "正在加载背景图像",
|
||||
"loadingModel": "正在加载3D模型...",
|
||||
"materialMode": "材质模式",
|
||||
"materialModes": {
|
||||
|
||||
Reference in New Issue
Block a user