135 lines
4.5 KiB
TypeScript
135 lines
4.5 KiB
TypeScript
// ---------------------------------------------------------------------------
|
|
// Client-side folder validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants'
|
|
import { formatAssetFamilies, getAssetFamily } from '@/lib/asset-naming'
|
|
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 {
|
|
return typeof value === 'object' && value !== null
|
|
}
|
|
|
|
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)
|
|
|
|
if (family && targetParts.every(Boolean)) return null
|
|
|
|
const reversedParts = stem.split('_')
|
|
const reversedFamily = reversedParts.length > 1 ? getAssetFamily(reversedParts[reversedParts.length - 1]) : undefined
|
|
|
|
if (reversedFamily) {
|
|
const target = reversedParts.slice(0, -1).join('_')
|
|
return `Convention invalide : ${file.name}. Utilisez ${reversedFamily}_${target}.${file.name.split('.').pop()} pour cibler "${target}", ou ${reversedFamily}.${file.name.split('.').pop()} 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 {
|
|
return warnings
|
|
}
|
|
|
|
if (!isGltfJson(parsed)) return warnings
|
|
|
|
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<ValidationResult> {
|
|
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 }
|
|
}
|
|
|
|
const warnings = await getGltfWarnings(modelFiles[0], supportFiles)
|
|
|
|
return { ok: true, model: modelFiles[0], textures, warnings }
|
|
}
|