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 } }