import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants' import { formatAssetFamilies, getAssetFamily, getForbiddenAssetFamilyAlias } from '@/lib/asset-naming' import { getErrorMessage, isRecord } from '@/lib/guards' import type { TextureFile } from '@/lib/client-types' const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS] interface GltfBufferReference { uri?: unknown } interface GltfJson { buffers?: GltfBufferReference[] } /** Discriminated union: either valid (with model) or invalid (with errors). */ export type ValidationResult = | { ok: true; model: File; textures: TextureFile[]; warnings: string[] } | { ok: false; errors: string[] } function isGltfJson(value: unknown): value is GltfJson { if (!isRecord(value)) return false if (value.buffers === undefined) return true return Array.isArray(value.buffers) && value.buffers.every(isRecord) } 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()) .filter((filename): filename is string => Boolean(filename)) } function getFileExtension(filename: string) { return filename.slice(filename.lastIndexOf('.')).toLowerCase() } function getFileStem(filename: string) { return filename.replace(/\.[^.]+$/, '') } function getTextureNamingError(file: File) { const stem = getFileStem(file.name) const [prefix, ...targetParts] = stem.split('_') const family = getAssetFamily(prefix) const extension = file.name.split('.').pop() if (family && targetParts.every(Boolean)) return null const aliasSuggestion = getForbiddenAssetFamilyAlias(prefix) if (aliasSuggestion && targetParts.every(Boolean)) { const target = targetParts.join('_') return `Convention invalide : ${file.name}. Utilisez ${aliasSuggestion}_${target}.${extension} pour cibler "${target}", ou ${aliasSuggestion}.${extension} pour tout le modele.` } const reversedParts = stem.split('_') const reversedFamily = reversedParts.length > 1 ? getAssetFamily(reversedParts[reversedParts.length - 1]) : undefined const reversedAliasSuggestion = reversedParts.length > 1 ? getForbiddenAssetFamilyAlias(reversedParts[reversedParts.length - 1]) : undefined if (reversedFamily) { const target = reversedParts.slice(0, -1).join('_') return `Convention invalide : ${file.name}. Utilisez ${reversedFamily}_${target}.${extension} pour cibler "${target}", ou ${reversedFamily}.${extension} pour tout le modele.` } if (reversedAliasSuggestion) { const target = reversedParts.slice(0, -1).join('_') return `Convention invalide : ${file.name}. Utilisez ${reversedAliasSuggestion}_${target}.${extension} pour cibler "${target}", ou ${reversedAliasSuggestion}.${extension} pour tout le modele.` } return `Asset inconnu : ${file.name}. Familles autorisees : ${formatAssetFamilies()}. Utilisez asset.png pour tout le modele ou asset_objet.png pour cibler un objet.` } async function getGltfWarnings(model: File, supportFiles: File[]) { const warnings: string[] = [] let parsed: unknown try { parsed = JSON.parse(await model.text()) } catch { throw new Error('model.gltf contient un JSON invalide') } if (!isGltfJson(parsed)) { throw new Error('model.gltf a une structure invalide') } 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)) { if (!bufferName.endsWith('.bin') || supportFilenames.has(bufferName)) continue if (binFiles.length === 1) { warnings.push( `model.gltf reference ${bufferName} mais le dossier contient ${binFiles[0].name}. La preview peut utiliser ${binFiles[0].name}, mais l'upload final risque d'etre casse. Veillez changer le nom du fichier .bin pour ne pas casser l'export.`, ) continue } warnings.push(`model.gltf reference ${bufferName}, mais ce fichier .bin est absent du dossier.`) } return warnings } export async function validateFolder(files: File[]): Promise { const textures: TextureFile[] = [] const errors: string[] = [] const modelFiles = files.filter((f) => { const name = f.name.toLowerCase() return name === 'model.gltf' }) if (modelFiles.length === 0) { return { ok: false, errors: ['model.gltf manquant (obligatoire)'] } } if (modelFiles.length > 1) { return { ok: false, errors: ['Un seul fichier model.gltf est autorise'] } } const supportFiles = files.filter((f) => { const ext = getFileExtension(f.name) return SUPPORT_FILE_EXT_ARRAY.includes(ext) }) const textureNamingErrors = supportFiles .filter((file) => TEXTURE_EXTENSIONS.has(getFileExtension(file.name))) .map(getTextureNamingError) .filter((error): error is string => Boolean(error)) errors.push(...textureNamingErrors) for (const tf of supportFiles) { textures.push({ name: tf.name, file: tf }) } if (errors.length > 0) { return { ok: false, errors } } let warnings: string[] = [] try { warnings = await getGltfWarnings(modelFiles[0], supportFiles) } catch (err) { errors.push(getErrorMessage(err, 'model.gltf invalide')) } if (errors.length > 0) { return { ok: false, errors } } return { ok: true, model: modelFiles[0], textures, warnings } }