diff --git a/README.md b/README.md index 28fb2c8..b17043b 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Access the app at `http://localhost:3000` > Local 3D preview supports `model.gltf` folders by resolving dropped companion files such as `model.bin` and textures through local object URLs. > The preview also shows a small model stats helper with estimated draw calls, meshes, triangles, materials, and texture count. > Opacity helper textures can be named `opacity.png` for the whole model or `opacity_part-name.png` to target a mesh/material whose name contains `part-name`; alpha-channel PNGs are converted to alpha maps for the preview. +> If `model.gltf` references a missing `.bin` but the folder contains exactly one other `.bin`, the preview can use it as a local fallback and shows a warning because the final upload may still be broken until the `.bin` filename matches the GLTF reference. ### Production (Coolify / Docker) diff --git a/components/SceneViewer.tsx b/components/SceneViewer.tsx index e17c3bb..1fce99d 100644 --- a/components/SceneViewer.tsx +++ b/components/SceneViewer.tsx @@ -29,15 +29,28 @@ interface AlphaMapMaterial extends Material { const alphaMapTextureCache = new WeakMap() +function getRequestedFilename(requestedUrl: string) { + const cleanUrl = decodeURIComponent(requestedUrl.split(/[?#]/)[0] || '') + return cleanUrl.split(/[\\/]/).pop()?.toLowerCase() +} + +function resolveSingleBinFallback(filename: string | undefined, assetUrls: Record) { + if (!filename?.endsWith('.bin')) return undefined + + const binEntries = Object.entries(assetUrls).filter(([assetName]) => assetName.endsWith('.bin')) + return binEntries.length === 1 ? binEntries[0][1] : undefined +} + function resolveAssetUrl(requestedUrl: string, assetUrls: Record) { - if (requestedUrl.startsWith('blob:') || requestedUrl.startsWith('data:')) { + if (requestedUrl.startsWith('data:')) { return requestedUrl } - const cleanUrl = decodeURIComponent(requestedUrl.split(/[?#]/)[0] || '') - const filename = cleanUrl.split(/[\\/]/).pop()?.toLowerCase() + const filename = getRequestedFilename(requestedUrl) + const exactAssetUrl = filename ? assetUrls[filename] : undefined + const fallbackBinUrl = resolveSingleBinFallback(filename, assetUrls) - return filename ? assetUrls[filename] || requestedUrl : requestedUrl + return exactAssetUrl || fallbackBinUrl || requestedUrl } function getOpacityMapEntries(assetUrls: Record) { diff --git a/components/upload/FolderDropzone.tsx b/components/upload/FolderDropzone.tsx index 80db7bf..9731a50 100644 --- a/components/upload/FolderDropzone.tsx +++ b/components/upload/FolderDropzone.tsx @@ -72,11 +72,11 @@ export default function FolderDropzone({ const inputRef = useRef(null) const [isDragActive, setIsDragActive] = useState(false) - const processFiles = (files: File[], fallbackFolderName = 'folder') => { + const processFiles = async (files: File[], fallbackFolderName = 'folder') => { if (files.length === 0) return const folderName = files[0].webkitRelativePath?.split('/')[0] || fallbackFolderName - const validation = validateFolder(files) + const validation = await validateFolder(files) if (!validation.ok) { onError(validation.errors.join(' | ')) @@ -107,7 +107,7 @@ export default function FolderDropzone({ const selected = e.target.files if (!selected || selected.length === 0) return - processFiles(Array.from(selected)) + void processFiles(Array.from(selected)) e.target.value = '' } @@ -148,14 +148,14 @@ export default function FolderDropzone({ const rootEntry = directoryEntries[0] const files = await collectEntryFiles(rootEntry) - processFiles(files, rootEntry.name) + await processFiles(files, rootEntry.name) return } const droppedFiles = Array.from(e.dataTransfer.files) if (droppedFiles.length === 0) return - processFiles(droppedFiles) + await processFiles(droppedFiles) } catch { onError('Impossible de lire le dossier depose') } diff --git a/components/upload/WarningBanner.tsx b/components/upload/WarningBanner.tsx index 45b8f5a..c6b8ad5 100644 --- a/components/upload/WarningBanner.tsx +++ b/components/upload/WarningBanner.tsx @@ -11,7 +11,7 @@ export default function WarningBanner({ warnings }: WarningBannerProps) {
- Textures manquantes : {warnings.join(', ')} + {warnings.join(' ')}
) diff --git a/lib/validate-folder.ts b/lib/validate-folder.ts index 1576028..8b2ee08 100644 --- a/lib/validate-folder.ts +++ b/lib/validate-folder.ts @@ -7,12 +7,64 @@ 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[] } -export function validateFolder(files: File[]): ValidationResult { +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)) +} + +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 { const textures: TextureFile[] = [] const errors: string[] = [] @@ -29,12 +81,12 @@ export function validateFolder(files: File[]): ValidationResult { return { ok: false, errors: ['Un seul fichier model.gltf est autorise'] } } - const textureFiles = files.filter((f) => { + const supportFiles = files.filter((f) => { const ext = f.name.slice(f.name.lastIndexOf('.')).toLowerCase() return SUPPORT_FILE_EXT_ARRAY.includes(ext) }) - for (const tf of textureFiles) { + for (const tf of supportFiles) { textures.push({ name: tf.name, file: tf }) } @@ -42,5 +94,7 @@ export function validateFolder(files: File[]): ValidationResult { return { ok: false, errors } } - return { ok: true, model: modelFiles[0], textures, warnings: [] } + const warnings = await getGltfWarnings(modelFiles[0], supportFiles) + + return { ok: true, model: modelFiles[0], textures, warnings } }