feat: add texture diagnostics to viewer
This commit is contained in:
@@ -5,6 +5,20 @@ export interface TextureFile {
|
||||
file: File
|
||||
}
|
||||
|
||||
export type TextureDiagnosticSeverity = 'error' | 'warning'
|
||||
|
||||
export interface TextureDiagnosticIssue {
|
||||
severity: TextureDiagnosticSeverity
|
||||
title: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface TextureDiagnosticReport {
|
||||
status: 'idle' | 'ok' | 'warning' | 'error'
|
||||
summary: string
|
||||
issues: TextureDiagnosticIssue[]
|
||||
}
|
||||
|
||||
export type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
|
||||
|
||||
export interface FolderEntry {
|
||||
@@ -20,6 +34,7 @@ export interface FolderEntry {
|
||||
assetUrls: Record<string, string>
|
||||
viewerOpen?: boolean
|
||||
warnings: string[]
|
||||
textureReport: TextureDiagnosticReport
|
||||
driveStatus?: DriveStatus
|
||||
driveError?: string
|
||||
}
|
||||
|
||||
+291
-9
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user