diff --git a/README.md b/README.md index d7cb0d3..fc8835e 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Access the app at `http://localhost:3000` > > Local 3D preview supports `model.gltf` folders by resolving dropped companion files such as `model.bin` and textures through local object URLs. > The preview also shows a small model stats helper with estimated draw calls, meshes, triangles, materials, and texture count. +> Opacity helper textures can be named `opacity.png` for the whole model or `opacity_part-name.png` to target a mesh/material whose name contains `part-name`; alpha-channel PNGs are converted to alpha maps for the preview. ### Production (Coolify / Docker) diff --git a/components/SceneViewer.tsx b/components/SceneViewer.tsx index 97c7d73..e17c3bb 100644 --- a/components/SceneViewer.tsx +++ b/components/SceneViewer.tsx @@ -4,8 +4,8 @@ import { Suspense, useEffect } from 'react' import { Canvas } from '@react-three/fiber' import { Stage, OrbitControls } from '@react-three/drei' import { useLoader } from '@react-three/fiber' -import type { Material, Mesh, Object3D, Texture } from 'three' -import { TextureLoader } from 'three' +import { CanvasTexture, Mesh, TextureLoader } from 'three' +import type { Material, Object3D, Texture } from 'three' import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' export interface ModelStats { @@ -21,6 +21,14 @@ interface OpacityMapEntry { url: string } +interface AlphaMapMaterial extends Material { + alphaMap: Texture | null + transparent: boolean + alphaTest: number +} + +const alphaMapTextureCache = new WeakMap() + function resolveAssetUrl(requestedUrl: string, assetUrls: Record) { if (requestedUrl.startsWith('blob:') || requestedUrl.startsWith('data:')) { return requestedUrl @@ -43,19 +51,84 @@ function getOpacityMapEntries(assetUrls: Record) { }, []) } +function normalizeMatchName(name: string) { + return name + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]/g, '') +} + function getMaterialName(material: Material) { - return material.name.toLowerCase() + return normalizeMatchName(material.name) } function getObjectName(mesh: Mesh) { - return mesh.name.toLowerCase() + return normalizeMatchName(mesh.name) +} + +function isMesh(object: Object3D): object is Mesh { + return object instanceof Mesh +} + +function supportsAlphaMap(material: Material): material is AlphaMapMaterial { + return 'alphaMap' in material +} + +function createAlphaMapTexture(texture: Texture) { + const cachedTexture = alphaMapTextureCache.get(texture) + if (cachedTexture) return cachedTexture + + const image = texture.image as unknown + const isImageBitmap = typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap + + if (!(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement || isImageBitmap)) { + texture.flipY = false + alphaMapTextureCache.set(texture, texture) + return texture + } + + const canvas = document.createElement('canvas') + canvas.width = image.width + canvas.height = image.height + + const context = canvas.getContext('2d') + if (!context) { + texture.flipY = false + alphaMapTextureCache.set(texture, texture) + return texture + } + + context.drawImage(image, 0, 0) + + const imageData = context.getImageData(0, 0, canvas.width, canvas.height) + const pixels = imageData.data + + for (let i = 0; i < pixels.length; i += 4) { + const alpha = pixels[i + 3] + const luminance = Math.round((pixels[i] * 0.2126) + (pixels[i + 1] * 0.7152) + (pixels[i + 2] * 0.0722)) + const opacity = alpha < 255 ? alpha : luminance + + pixels[i] = opacity + pixels[i + 1] = opacity + pixels[i + 2] = opacity + pixels[i + 3] = 255 + } + + context.putImageData(imageData, 0, 0) + + const alphaTexture = new CanvasTexture(canvas) + alphaTexture.flipY = false + alphaTexture.needsUpdate = true + alphaMapTextureCache.set(texture, alphaTexture) + + return alphaTexture } function applyAlphaMap(material: Material, texture: Texture) { - if (!('alphaMap' in material)) return + if (!supportsAlphaMap(material)) return - texture.flipY = false - material.alphaMap = texture + material.alphaMap = createAlphaMapTexture(texture) material.transparent = true material.alphaTest = 0.01 material.needsUpdate = true @@ -91,14 +164,13 @@ function getModelStats(scene: Object3D, assetUrls: Record): Mode let triangles = 0 scene.traverse((object) => { - const mesh = object as Mesh - if (!mesh.isMesh || !mesh.geometry || !mesh.material) return + if (!isMesh(object) || !object.geometry || !object.material) return meshes += 1 - triangles += countGeometryTriangles(mesh) - drawCalls += estimateMeshDrawCalls(mesh) + triangles += countGeometryTriangles(object) + drawCalls += estimateMeshDrawCalls(object) - const meshMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + const meshMaterials = Array.isArray(object.material) ? object.material : [object.material] meshMaterials.forEach((material) => materials.add(material)) }) @@ -120,7 +192,10 @@ function pickOpacityMap( const objectName = getObjectName(mesh) const materialName = getMaterialName(material) const targetedIndex = entries.findIndex((entry) => ( - entry.target && (objectName.includes(entry.target) || materialName.includes(entry.target)) + entry.target && ( + objectName.includes(normalizeMatchName(entry.target)) + || materialName.includes(normalizeMatchName(entry.target)) + ) )) if (targetedIndex >= 0) return textures[targetedIndex] @@ -154,12 +229,11 @@ function Model({ if (opacityMapEntries.length === 0) return scene.traverse((object) => { - const mesh = object as Mesh - if (!mesh.isMesh || !mesh.material) return + if (!isMesh(object) || !object.material) return - const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + const materials = Array.isArray(object.material) ? object.material : [object.material] materials.forEach((material) => { - const opacityMap = pickOpacityMap(mesh, material, opacityMapEntries, opacityMaps) + const opacityMap = pickOpacityMap(object, material, opacityMapEntries, opacityMaps) if (opacityMap) applyAlphaMap(material, opacityMap) }) })