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
+2
View File
@@ -119,6 +119,7 @@ All files are uploaded to `VF/` (not just diffs), because the move operation emp
- The server applies a lightweight per-folder lock on Drive and Git routes to avoid duplicate commits and concurrent writes - The server applies a lightweight per-folder lock on Drive and Git routes to avoid duplicate commits and concurrent writes
- The folder is staged server-side so the browser sends the payload only once during the full upload flow - The folder is staged server-side so the browser sends the payload only once during the full upload flow
- Invalid `model.gltf` JSON or malformed `buffers` entries block the upload before remote writes - Invalid `model.gltf` JSON or malformed `buffers` entries block the upload before remote writes
- The client shows texture diagnostics before upload: missing GLTF image files, unsupported texture formats, duplicate flat filenames, unused textures, broad opacity maps, and all-transparent material exports
- Git LFS uploads are batched in groups of 100 objects to stay within the LFS Batch API limit - Git LFS uploads are batched in groups of 100 objects to stay within the LFS Batch API limit
### Commit messages ### Commit messages
@@ -188,6 +189,7 @@ components/
│ ├── FolderCard.tsx # Folder status card (Drive + Git) │ ├── FolderCard.tsx # Folder status card (Drive + Git)
│ ├── DriveStatusLine.tsx # Drive/Git status sub-line │ ├── DriveStatusLine.tsx # Drive/Git status sub-line
│ ├── WarningBanner.tsx # Missing texture warnings │ ├── WarningBanner.tsx # Missing texture warnings
│ ├── TextureDiagnosticsPanel.tsx # Texture loading/export diagnostics
│ ├── OverwriteConfirmModal.tsx # Diff confirmation dialog │ ├── OverwriteConfirmModal.tsx # Diff confirmation dialog
│ ├── NoChangesModal.tsx # "No changes detected" dialog │ ├── NoChangesModal.tsx # "No changes detected" dialog
│ ├── DriveErrorModal.tsx # "Drive failed, continue?" dialog │ ├── DriveErrorModal.tsx # "Drive failed, continue?" dialog
+1 -1
View File
@@ -243,7 +243,7 @@ function pickOpacityMap(
const genericIndex = entries.findIndex((entry) => entry.target === '') const genericIndex = entries.findIndex((entry) => entry.target === '')
if (genericIndex >= 0) return textures[genericIndex] if (genericIndex >= 0) return textures[genericIndex]
return entries.length === 1 ? textures[0] : undefined return undefined
} }
function Model({ function Model({
+4
View File
@@ -4,6 +4,7 @@ import { formatBytes } from '@/lib/format-bytes'
import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon, WarningIcon } from '@/components/ui/icons' import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon, WarningIcon } from '@/components/ui/icons'
import DriveStatusLine from './DriveStatusLine' import DriveStatusLine from './DriveStatusLine'
import WarningBanner from './WarningBanner' import WarningBanner from './WarningBanner'
import TextureDiagnosticsPanel from './TextureDiagnosticsPanel'
const ModelViewer = dynamic(() => import('../ModelViewer'), { ssr: false }) const ModelViewer = dynamic(() => import('../ModelViewer'), { ssr: false })
@@ -101,6 +102,8 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
entry.viewerOpen ? 'max-h-[500px] opacity-100 mt-2' : 'max-h-0 opacity-0 pointer-events-none' entry.viewerOpen ? 'max-h-[500px] opacity-100 mt-2' : 'max-h-0 opacity-0 pointer-events-none'
}`} }`}
> >
<div className="grid gap-2 lg:grid-cols-[18rem_minmax(0,1fr)] lg:items-start">
<TextureDiagnosticsPanel report={entry.textureReport} />
<ModelViewer <ModelViewer
url={entry.modelUrl} url={entry.modelUrl}
assetUrls={entry.assetUrls} assetUrls={entry.assetUrls}
@@ -108,6 +111,7 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
size={formatBytes(entry.modelFile.size)} size={formatBytes(entry.modelFile.size)}
/> />
</div> </div>
</div>
)} )}
</div> </div>
) )
+1
View File
@@ -96,6 +96,7 @@ export default function FolderDropzone({
status: 'pending', status: 'pending',
progress: 0, progress: 0,
warnings: validation.warnings, warnings: validation.warnings,
textureReport: validation.textureReport,
modelUrl, modelUrl,
assetUrls, assetUrls,
viewerOpen: true, viewerOpen: true,
@@ -0,0 +1,99 @@
'use client'
import { useState } from 'react'
import { CheckIcon, ChevronIcon, WarningIcon, XIcon } from '@/components/ui/icons'
import type { TextureDiagnosticReport } from '@/lib/client-types'
interface TextureDiagnosticsPanelProps {
report?: TextureDiagnosticReport
}
const idleReport: TextureDiagnosticReport = {
status: 'idle',
summary: 'Deposez un dossier pour analyser les textures du model.gltf.',
issues: [],
}
const statusStyles = {
idle: {
icon: <WarningIcon className="h-4 w-4" />,
label: 'En attente',
tone: 'border-white/15 bg-white/5 text-gray-400',
},
ok: {
icon: <CheckIcon className="h-4 w-4" />,
label: 'OK',
tone: 'border-green-500/30 bg-green-500/10 text-green-300',
},
warning: {
icon: <WarningIcon className="h-4 w-4" />,
label: 'A verifier',
tone: 'border-yellow-500/30 bg-yellow-500/10 text-yellow-300',
},
error: {
icon: <XIcon className="h-4 w-4" />,
label: 'Probleme',
tone: 'border-red-500/30 bg-red-500/10 text-red-300',
},
}
const issueStyles = {
error: 'border-red-500/25 bg-red-500/10 text-red-200',
warning: 'border-yellow-500/25 bg-yellow-500/10 text-yellow-200',
}
export default function TextureDiagnosticsPanel({
report,
}: TextureDiagnosticsPanelProps) {
const currentReport = report || idleReport
const style = statusStyles[currentReport.status]
const [isOpen, setIsOpen] = useState(true)
return (
<aside className="overflow-hidden rounded-xl border border-white/15 bg-black-800 text-xs text-gray-400 lg:max-h-[450px]">
<button
type="button"
onClick={() => setIsOpen((open) => !open)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-white/5"
>
<span className="flex min-w-0 items-center gap-2">
<ChevronIcon className={`h-4 w-4 shrink-0 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
<span className="truncate text-sm font-semibold text-gray-100">Diagnostic textures</span>
</span>
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-1 ${style.tone}`}>
{style.icon}
{style.label}
</span>
</button>
{isOpen && (
<div className="max-h-[380px] overflow-auto border-t border-white/10 px-4 py-3">
<p className="leading-relaxed">{currentReport.summary}</p>
{currentReport.issues.length > 0 ? (
<ul className="mt-3 space-y-2">
{currentReport.issues.map((issue, index) => (
<li
key={`${issue.title}-${index}`}
className={`rounded-xl border px-3 py-2 ${issueStyles[issue.severity]}`}
>
<p className="font-medium">{issue.title}</p>
<p className="mt-1 leading-relaxed text-gray-300">{issue.detail}</p>
</li>
))}
</ul>
) : currentReport.status === 'idle' ? (
<p className="mt-3 rounded-xl border border-white/10 bg-white/5 px-3 py-2 leading-relaxed">
Les resultats apparaitront ici apres selection du dossier.
</p>
) : (
<p className="mt-3 rounded-xl border border-white/10 bg-white/5 px-3 py-2 leading-relaxed">
Aucun probleme texture detecte cote app.
</p>
)}
</div>
)}
</aside>
)
}
+15
View File
@@ -5,6 +5,20 @@ export interface TextureFile {
file: File 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 type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
export interface FolderEntry { export interface FolderEntry {
@@ -20,6 +34,7 @@ export interface FolderEntry {
assetUrls: Record<string, string> assetUrls: Record<string, string>
viewerOpen?: boolean viewerOpen?: boolean
warnings: string[] warnings: string[]
textureReport: TextureDiagnosticReport
driveStatus?: DriveStatus driveStatus?: DriveStatus
driveError?: string driveError?: string
} }
+291 -9
View File
@@ -1,7 +1,7 @@
import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants' 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 { 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] const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS]
@@ -9,12 +9,64 @@ interface GltfBufferReference {
uri?: string 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 { interface GltfJson {
buffers?: GltfBufferReference[] buffers?: GltfBufferReference[]
images?: GltfImageReference[]
materials?: GltfMaterialReference[]
meshes?: GltfMeshReference[]
nodes?: GltfNodeReference[]
textures?: GltfTextureReference[]
} }
type ValidationResult = type ValidationResult =
| { ok: true; model: File; textures: TextureFile[]; warnings: string[] } | {
ok: true
model: File
textures: TextureFile[]
warnings: string[]
textureReport: TextureDiagnosticReport
}
| { ok: false; errors: string[] } | { ok: false; errors: string[] }
function isGltfBufferReference(value: unknown): value is GltfBufferReference { 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) 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) { function getReferencedBufferNames(gltf: GltfJson) {
return (gltf.buffers || []) return (gltf.buffers || [])
.map((buffer) => (typeof buffer.uri === 'string' ? buffer.uri : undefined)) .map((buffer) => (typeof buffer.uri === 'string' ? buffer.uri : undefined))
.filter((uri): uri is string => typeof uri === 'string' && uri.length > 0) .filter((uri): uri is string => typeof uri === 'string' && uri.length > 0)
.filter((uri) => !uri.startsWith('data:')) .filter((uri) => !uri.startsWith('data:'))
.map((uri) => decodeURIComponent(uri.split(/[?#]/)[0] || '').split(/[\\/]/).pop()?.toLowerCase()) .map(getReferencedFilename)
.filter((filename): filename is string => Boolean(filename)) .filter((filename): filename is string => Boolean(filename))
} }
@@ -40,8 +110,29 @@ function getFileExtension(filename: string) {
return filename.slice(filename.lastIndexOf('.')).toLowerCase() return filename.slice(filename.lastIndexOf('.')).toLowerCase()
} }
async function getGltfWarnings(model: File, supportFiles: File[]) { function normalizeMatchName(name: string) {
const warnings: 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 let parsed: unknown
try { try {
@@ -54,10 +145,15 @@ async function getGltfWarnings(model: File, supportFiles: File[]) {
throw new Error('model.gltf a une structure invalide') 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 supportFilenames = new Set(supportFiles.map((file) => file.name.toLowerCase()))
const binFiles = supportFiles.filter((file) => file.name.toLowerCase().endsWith('.bin')) 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 (!bufferName.endsWith('.bin') || supportFilenames.has(bufferName)) continue
if (binFiles.length === 1) { if (binFiles.length === 1) {
@@ -73,6 +169,184 @@ async function getGltfWarnings(model: File, supportFiles: File[]) {
return warnings 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> { export async function validateFolder(files: File[]): Promise<ValidationResult> {
const textures: TextureFile[] = [] const textures: TextureFile[] = []
const errors: string[] = [] const errors: string[] = []
@@ -111,8 +385,12 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
} }
let warnings: string[] = [] let warnings: string[] = []
let textureReport: TextureDiagnosticReport | undefined
try { try {
warnings = await getGltfWarnings(modelFiles[0], supportFiles) const gltf = await parseGltfModel(modelFiles[0])
warnings = getGltfWarnings(gltf, supportFiles)
textureReport = getTextureDiagnosticReport(gltf, supportFiles)
} catch (err) { } catch (err) {
errors.push(getErrorMessage(err, 'model.gltf invalide')) 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: 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 }
} }