'use client' 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 { CanvasTexture, Mesh, TextureLoader } from 'three' import type { Material, Object3D, Texture } from 'three' import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' import { normalizeTextureFilename } from '@/lib/asset-naming' export interface ModelStats { childObjects: number drawCalls: number materials: number meshes: number textures: number triangles: number } export interface ModelHierarchyNode { children: ModelHierarchyNode[] id: string name: string position: [number, number, number] rotation: [number, number, number] type: string visible: boolean } interface OpacityMapEntry { target: string url: string } interface AlphaMapMaterial extends Material { alphaMap: Texture | null transparent: boolean alphaTest: number } type AlphaImageSource = HTMLImageElement | HTMLCanvasElement | ImageBitmap const alphaMapTextureCache = new WeakMap() function getRequestedFilename(requestedUrl: string) { const cleanUrl = decodeURIComponent(requestedUrl.split(/[?#]/)[0] || '') return cleanUrl.split(/[\\/]/).pop()?.toLowerCase() } function resolveSingleBinFallback(filename: string | undefined, assetUrls: Record) { if (!filename?.endsWith('.bin')) return undefined const binEntries = Object.entries(assetUrls).filter(([assetName]) => assetName.endsWith('.bin')) return binEntries.length === 1 ? binEntries[0][1] : undefined } function resolveAssetUrl(requestedUrl: string, assetUrls: Record) { if (requestedUrl.startsWith('data:')) { return requestedUrl } const filename = getRequestedFilename(requestedUrl) const exactAssetUrl = filename ? assetUrls[filename] : undefined const fallbackBinUrl = resolveSingleBinFallback(filename, assetUrls) return exactAssetUrl || fallbackBinUrl || requestedUrl } function getOpacityMapEntries(assetUrls: Record) { return Object.entries(assetUrls).reduce((entries, [filename, url]) => { const normalizedFilename = normalizeTextureFilename(filename) || filename const match = normalizedFilename.toLowerCase().match(/^opacity(?:[_-](.+))?\.(png|jpe?g|webp)$/) if (!match) return entries entries.push({ target: match[1] || '', url }) return entries }, []) } function normalizeMatchName(name: string) { return name .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]/g, '') } function getMaterialName(material: Material) { return normalizeMatchName(material.name) } function getObjectName(mesh: Mesh) { 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 isAlphaImageSource(image: unknown): image is AlphaImageSource { return image instanceof HTMLImageElement || image instanceof HTMLCanvasElement || (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap) } function createAlphaMapTexture(texture: Texture) { const cachedTexture = alphaMapTextureCache.get(texture) if (cachedTexture) return cachedTexture const image = texture.image if (!isAlphaImageSource(image)) { 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 (!supportsAlphaMap(material)) return material.alphaMap = createAlphaMapTexture(texture) material.transparent = true material.alphaTest = 0.01 material.needsUpdate = true } function countGeometryTriangles(mesh: Mesh) { const geometry = mesh.geometry if (geometry.index) return Math.floor(geometry.index.count / 3) const position = geometry.getAttribute('position') return position ? Math.floor(position.count / 3) : 0 } function getMaterialCount(material: Material | Material[]) { return Array.isArray(material) ? material.length : 1 } function estimateMeshDrawCalls(mesh: Mesh) { if (mesh.geometry.groups.length > 0) return mesh.geometry.groups.length return getMaterialCount(mesh.material) } function getTextureCount(assetUrls: Record) { return Object.keys(assetUrls).filter((filename) => /\.(png|jpe?g|webp)$/i.test(filename)).length } function getModelStats(scene: Object3D, assetUrls: Record): ModelStats { const materials = new Set() let drawCalls = 0 let meshes = 0 let triangles = 0 scene.traverse((object) => { if (!isMesh(object) || !object.geometry || !object.material) return meshes += 1 triangles += countGeometryTriangles(object) drawCalls += estimateMeshDrawCalls(object) const meshMaterials = Array.isArray(object.material) ? object.material : [object.material] meshMaterials.forEach((material) => materials.add(material)) }) return { childObjects: scene.children.length, drawCalls, materials: materials.size, meshes, textures: getTextureCount(assetUrls), triangles, } } function roundTransformValue(value: number) { return Number(value.toFixed(4)) } function getObjectHierarchy(object: Object3D): ModelHierarchyNode { return { children: object.children.map(getObjectHierarchy), id: object.uuid, name: object.name || object.type, position: [ roundTransformValue(object.position.x), roundTransformValue(object.position.y), roundTransformValue(object.position.z), ], rotation: [ roundTransformValue(object.rotation.x), roundTransformValue(object.rotation.y), roundTransformValue(object.rotation.z), ], type: object.type, visible: object.visible, } } function pickOpacityMap( mesh: Mesh, material: Material, entries: OpacityMapEntry[], textures: Texture[], ) { const objectName = getObjectName(mesh) const materialName = getMaterialName(material) const targetedIndex = entries.findIndex((entry) => ( entry.target && ( objectName.includes(normalizeMatchName(entry.target)) || materialName.includes(normalizeMatchName(entry.target)) ) )) if (targetedIndex >= 0) return textures[targetedIndex] const genericIndex = entries.findIndex((entry) => entry.target === '') if (genericIndex >= 0) return textures[genericIndex] return entries.length === 1 ? textures[0] : undefined } function Model({ url, assetUrls, onStatsReady, onHierarchyReady, }: { url: string assetUrls: Record onStatsReady: (stats: ModelStats) => void onHierarchyReady: (hierarchy: ModelHierarchyNode) => void }) { const { scene } = useLoader(GLTFLoader, url, (loader) => { loader.manager.setURLModifier((requestedUrl) => resolveAssetUrl(requestedUrl, assetUrls)) }) const opacityMapEntries = getOpacityMapEntries(assetUrls) const opacityMaps = useLoader(TextureLoader, opacityMapEntries.map((entry) => entry.url)) useEffect(() => { onStatsReady(getModelStats(scene, assetUrls)) onHierarchyReady(getObjectHierarchy(scene)) }, [assetUrls, onHierarchyReady, onStatsReady, scene]) useEffect(() => { if (opacityMapEntries.length === 0) return scene.traverse((object) => { if (!isMesh(object) || !object.material) return const materials = Array.isArray(object.material) ? object.material : [object.material] materials.forEach((material) => { const opacityMap = pickOpacityMap(object, material, opacityMapEntries, opacityMaps) if (opacityMap) applyAlphaMap(material, opacityMap) }) }) }, [scene, opacityMapEntries, opacityMaps]) return } export default function SceneViewer({ url, assetUrls, onStatsReady, onHierarchyReady, }: { url: string assetUrls: Record onStatsReady: (stats: ModelStats) => void onHierarchyReady: (hierarchy: ModelHierarchyNode) => void }) { return ( ) }