feat: add texture diagnostics to viewer

This commit is contained in:
Tom Boullay
2026-05-17 16:49:24 +02:00
parent 3cfb3a21a9
commit 83b2b405b4
7 changed files with 419 additions and 16 deletions
+291 -9
View File
@@ -1,7 +1,7 @@
import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants'
import { getTextureNamingError } from '@/lib/asset-naming'
import { getTextureNamingError, normalizeTextureFilename } from '@/lib/asset-naming'
import { getErrorMessage, isRecord } from '@/lib/guards'
import type { TextureFile } from '@/lib/client-types'
import type { TextureDiagnosticIssue, TextureDiagnosticReport, TextureFile } from '@/lib/client-types'
const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS]
@@ -9,12 +9,64 @@ interface GltfBufferReference {
uri?: string
}
interface GltfImageReference {
uri?: string
name?: string
}
interface GltfTextureReference {
source?: number
}
interface GltfTextureInfo {
index?: number
}
interface GltfPbrMaterial {
baseColorTexture?: GltfTextureInfo
metallicRoughnessTexture?: GltfTextureInfo
}
interface GltfMaterialReference {
name?: string
alphaMode?: string
pbrMetallicRoughness?: GltfPbrMaterial
normalTexture?: GltfTextureInfo
occlusionTexture?: GltfTextureInfo
emissiveTexture?: GltfTextureInfo
}
interface GltfMeshPrimitive {
material?: number
}
interface GltfMeshReference {
name?: string
primitives?: GltfMeshPrimitive[]
}
interface GltfNodeReference {
name?: string
mesh?: number
}
interface GltfJson {
buffers?: GltfBufferReference[]
images?: GltfImageReference[]
materials?: GltfMaterialReference[]
meshes?: GltfMeshReference[]
nodes?: GltfNodeReference[]
textures?: GltfTextureReference[]
}
type ValidationResult =
| { ok: true; model: File; textures: TextureFile[]; warnings: string[] }
| {
ok: true
model: File
textures: TextureFile[]
warnings: string[]
textureReport: TextureDiagnosticReport
}
| { ok: false; errors: string[] }
function isGltfBufferReference(value: unknown): value is GltfBufferReference {
@@ -27,12 +79,30 @@ function isGltfJson(value: unknown): value is GltfJson {
return Array.isArray(value.buffers) && value.buffers.every(isGltfBufferReference)
}
function asRecordArray(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.filter(isRecord) : []
}
function decodeUri(uri: string) {
const cleanUri = uri.split(/[?#]/)[0] || ''
try {
return decodeURIComponent(cleanUri)
} catch {
return cleanUri
}
}
function getReferencedFilename(uri: string) {
return decodeUri(uri).split(/[\\/]/).pop()?.toLowerCase()
}
function getReferencedBufferNames(gltf: GltfJson) {
return (gltf.buffers || [])
.map((buffer) => (typeof buffer.uri === 'string' ? buffer.uri : undefined))
.filter((uri): uri is string => typeof uri === 'string' && uri.length > 0)
.filter((uri) => !uri.startsWith('data:'))
.map((uri) => decodeURIComponent(uri.split(/[?#]/)[0] || '').split(/[\\/]/).pop()?.toLowerCase())
.map(getReferencedFilename)
.filter((filename): filename is string => Boolean(filename))
}
@@ -40,8 +110,29 @@ function getFileExtension(filename: string) {
return filename.slice(filename.lastIndexOf('.')).toLowerCase()
}
async function getGltfWarnings(model: File, supportFiles: File[]) {
const warnings: string[] = []
function normalizeMatchName(name: string) {
return name
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]/g, '')
}
function getOpacityTarget(filename: string) {
const normalizedFilename = normalizeTextureFilename(filename) || filename
const match = normalizedFilename.toLowerCase().match(/^opacity(?:[_-](.+))?\.(png|jpe?g|webp)$/)
return match ? match[1] || '' : undefined
}
function getTextureFiles(supportFiles: File[]) {
return supportFiles.filter((file) => TEXTURE_EXTENSIONS.has(getFileExtension(file.name)))
}
function getTextureDisplayName(file: File) {
return file.webkitRelativePath || file.name
}
async function parseGltfModel(model: File) {
let parsed: unknown
try {
@@ -54,10 +145,15 @@ async function getGltfWarnings(model: File, supportFiles: File[]) {
throw new Error('model.gltf a une structure invalide')
}
return parsed
}
function getGltfWarnings(gltf: GltfJson, supportFiles: File[]) {
const warnings: string[] = []
const supportFilenames = new Set(supportFiles.map((file) => file.name.toLowerCase()))
const binFiles = supportFiles.filter((file) => file.name.toLowerCase().endsWith('.bin'))
for (const bufferName of getReferencedBufferNames(parsed)) {
for (const bufferName of getReferencedBufferNames(gltf)) {
if (!bufferName.endsWith('.bin') || supportFilenames.has(bufferName)) continue
if (binFiles.length === 1) {
@@ -73,6 +169,184 @@ async function getGltfWarnings(model: File, supportFiles: File[]) {
return warnings
}
function getReferencedImageNames(gltf: GltfJson) {
return asRecordArray(gltf.images)
.map((image) => (typeof image.uri === 'string' ? image.uri : undefined))
.filter((uri): uri is string => typeof uri === 'string' && uri.length > 0)
.filter((uri) => !uri.startsWith('data:'))
.map(getReferencedFilename)
.filter((filename): filename is string => Boolean(filename))
}
function getModelTargetNames(gltf: GltfJson) {
const names = new Set<string>()
for (const material of asRecordArray(gltf.materials)) {
if (typeof material.name === 'string') names.add(normalizeMatchName(material.name))
}
for (const mesh of asRecordArray(gltf.meshes)) {
if (typeof mesh.name === 'string') names.add(normalizeMatchName(mesh.name))
}
for (const node of asRecordArray(gltf.nodes)) {
if (typeof node.name === 'string') names.add(normalizeMatchName(node.name))
}
return [...names].filter(Boolean)
}
function addIssue(
issues: TextureDiagnosticIssue[],
severity: TextureDiagnosticIssue['severity'],
title: string,
detail: string,
) {
issues.push({ severity, title, detail })
}
function getDuplicateFilenameIssues(files: File[], issues: TextureDiagnosticIssue[]) {
const filesByName = new Map<string, File[]>()
for (const file of files) {
const key = file.name.toLowerCase()
filesByName.set(key, [...(filesByName.get(key) || []), file])
}
for (const [filename, duplicates] of filesByName) {
if (duplicates.length < 2) continue
addIssue(
issues,
'warning',
'Nom de fichier duplique',
`${filename} existe plusieurs fois dans le dossier. La preview et le Git utilisent un dossier plat, donc une texture peut remplacer une autre.`,
)
}
}
function getOpacityIssues(gltf: GltfJson, textureFiles: File[], issues: TextureDiagnosticIssue[]) {
const targetNames = getModelTargetNames(gltf)
for (const file of textureFiles) {
const target = getOpacityTarget(file.name)
if (target === undefined) continue
if (target === '') {
addIssue(
issues,
'warning',
'Opacity globale',
`${file.name} s'applique a tous les materiaux dans la preview. Si tout devient transparent, utilisez plutot un nom cible comme opacity_porte.png.`,
)
continue
}
const normalizedTarget = normalizeMatchName(target)
const hasTarget = targetNames.some((name) => name.includes(normalizedTarget))
if (!hasTarget) {
addIssue(
issues,
'warning',
'Opacity cible introuvable',
`${file.name} cible "${target}", mais aucun noeud, mesh ou materiau du GLTF ne correspond. La map ne sera pas appliquee automatiquement.`,
)
}
}
}
function getTransparentMaterialIssues(gltf: GltfJson, issues: TextureDiagnosticIssue[]) {
const materials = asRecordArray(gltf.materials)
if (materials.length === 0) return
const transparentMaterials = materials.filter((material) => {
const alphaMode = typeof material.alphaMode === 'string' ? material.alphaMode.toUpperCase() : 'OPAQUE'
return alphaMode === 'BLEND' || alphaMode === 'MASK'
})
if (transparentMaterials.length !== materials.length) return
addIssue(
issues,
'warning',
'Tous les materiaux sont transparents',
'Le GLTF declare tous ses materiaux en alphaMode BLEND ou MASK. Si ce n\'est pas voulu, le probleme vient probablement de l\'export du modele.',
)
}
function getTextureDiagnosticReport(gltf: GltfJson, supportFiles: File[]): TextureDiagnosticReport {
const issues: TextureDiagnosticIssue[] = []
const textureFiles = getTextureFiles(supportFiles)
const supportFilenames = new Set(supportFiles.map((file) => file.name.toLowerCase()))
const referencedImageNames = getReferencedImageNames(gltf)
const referencedImageSet = new Set(referencedImageNames)
for (const imageName of referencedImageNames) {
const extension = getFileExtension(imageName)
if (!TEXTURE_EXTENSIONS.has(extension)) {
addIssue(
issues,
'error',
'Format texture non supporte',
`${imageName} est reference par model.gltf, mais seuls .png, .jpg, .jpeg et .webp sont acceptes.`,
)
continue
}
if (!supportFilenames.has(imageName)) {
addIssue(
issues,
'error',
'Texture referencee absente',
`${imageName} est utilisee par model.gltf mais absente du dossier. Ajoutez la texture ou re-exportez le modele.`,
)
}
}
getDuplicateFilenameIssues(supportFiles, issues)
for (const file of textureFiles) {
if (referencedImageSet.has(file.name.toLowerCase()) || getOpacityTarget(file.name) !== undefined) continue
addIssue(
issues,
'warning',
'Texture non referencee',
`${getTextureDisplayName(file)} est dans le dossier, mais model.gltf ne la reference pas. Elle risque de ne pas apparaitre dans le viewer.`,
)
}
getOpacityIssues(gltf, textureFiles, issues)
getTransparentMaterialIssues(gltf, issues)
const hasErrors = issues.some((issue) => issue.severity === 'error')
const hasWarnings = issues.some((issue) => issue.severity === 'warning')
if (hasErrors) {
return {
status: 'error',
summary: 'Problemes detectes : certaines textures risquent de ne pas charger.',
issues,
}
}
if (hasWarnings) {
return {
status: 'warning',
summary: 'Textures chargeables, mais certains points peuvent expliquer un rendu incorrect.',
issues,
}
}
return {
status: 'ok',
summary: 'Chargement textures OK. Si le rendu semble faux, le probleme vient probablement de l\'export ou du shading du modele.',
issues,
}
}
export async function validateFolder(files: File[]): Promise<ValidationResult> {
const textures: TextureFile[] = []
const errors: string[] = []
@@ -111,8 +385,12 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
}
let warnings: string[] = []
let textureReport: TextureDiagnosticReport | undefined
try {
warnings = await getGltfWarnings(modelFiles[0], supportFiles)
const gltf = await parseGltfModel(modelFiles[0])
warnings = getGltfWarnings(gltf, supportFiles)
textureReport = getTextureDiagnosticReport(gltf, supportFiles)
} catch (err) {
errors.push(getErrorMessage(err, 'model.gltf invalide'))
}
@@ -121,5 +399,9 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
return { ok: false, errors }
}
return { ok: true, model: modelFiles[0], textures, warnings }
if (!textureReport) {
return { ok: false, errors: ['model.gltf invalide'] }
}
return { ok: true, model: modelFiles[0], textures, warnings, textureReport }
}