diff --git a/README.md b/README.md
index 450578a..5980461 100644
--- a/README.md
+++ b/README.md
@@ -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 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
+- 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
### Commit messages
@@ -188,6 +189,7 @@ components/
│ ├── FolderCard.tsx # Folder status card (Drive + Git)
│ ├── DriveStatusLine.tsx # Drive/Git status sub-line
│ ├── WarningBanner.tsx # Missing texture warnings
+│ ├── TextureDiagnosticsPanel.tsx # Texture loading/export diagnostics
│ ├── OverwriteConfirmModal.tsx # Diff confirmation dialog
│ ├── NoChangesModal.tsx # "No changes detected" dialog
│ ├── DriveErrorModal.tsx # "Drive failed, continue?" dialog
diff --git a/components/SceneViewer.tsx b/components/SceneViewer.tsx
index 52f795c..e0c4e92 100644
--- a/components/SceneViewer.tsx
+++ b/components/SceneViewer.tsx
@@ -243,7 +243,7 @@ function pickOpacityMap(
const genericIndex = entries.findIndex((entry) => entry.target === '')
if (genericIndex >= 0) return textures[genericIndex]
- return entries.length === 1 ? textures[0] : undefined
+ return undefined
}
function Model({
diff --git a/components/upload/FolderCard.tsx b/components/upload/FolderCard.tsx
index 1452f4c..69a03e9 100644
--- a/components/upload/FolderCard.tsx
+++ b/components/upload/FolderCard.tsx
@@ -4,6 +4,7 @@ import { formatBytes } from '@/lib/format-bytes'
import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon, WarningIcon } from '@/components/ui/icons'
import DriveStatusLine from './DriveStatusLine'
import WarningBanner from './WarningBanner'
+import TextureDiagnosticsPanel from './TextureDiagnosticsPanel'
const ModelViewer = dynamic(() => import('../ModelViewer'), { ssr: false })
@@ -101,12 +102,15 @@ 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'
}`}
>
-
+
+
+
+
)}
diff --git a/components/upload/FolderDropzone.tsx b/components/upload/FolderDropzone.tsx
index 45a054d..db70dd2 100644
--- a/components/upload/FolderDropzone.tsx
+++ b/components/upload/FolderDropzone.tsx
@@ -96,6 +96,7 @@ export default function FolderDropzone({
status: 'pending',
progress: 0,
warnings: validation.warnings,
+ textureReport: validation.textureReport,
modelUrl,
assetUrls,
viewerOpen: true,
diff --git a/components/upload/TextureDiagnosticsPanel.tsx b/components/upload/TextureDiagnosticsPanel.tsx
new file mode 100644
index 0000000..4be170f
--- /dev/null
+++ b/components/upload/TextureDiagnosticsPanel.tsx
@@ -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: ,
+ label: 'En attente',
+ tone: 'border-white/15 bg-white/5 text-gray-400',
+ },
+ ok: {
+ icon: ,
+ label: 'OK',
+ tone: 'border-green-500/30 bg-green-500/10 text-green-300',
+ },
+ warning: {
+ icon: ,
+ label: 'A verifier',
+ tone: 'border-yellow-500/30 bg-yellow-500/10 text-yellow-300',
+ },
+ error: {
+ icon: ,
+ 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 (
+
+ )
+}
diff --git a/lib/client-types.ts b/lib/client-types.ts
index 37ece8e..d90ffdb 100644
--- a/lib/client-types.ts
+++ b/lib/client-types.ts
@@ -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
viewerOpen?: boolean
warnings: string[]
+ textureReport: TextureDiagnosticReport
driveStatus?: DriveStatus
driveError?: string
}
diff --git a/lib/validate-folder.ts b/lib/validate-folder.ts
index fc5bbcf..314e933 100644
--- a/lib/validate-folder.ts
+++ b/lib/validate-folder.ts
@@ -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[] {
+ 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()
+
+ 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()
+
+ 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 {
const textures: TextureFile[] = []
const errors: string[] = []
@@ -111,8 +385,12 @@ export async function validateFolder(files: File[]): Promise {
}
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 {
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 }
}