fix: resolve gltf companion assets reliably

This commit is contained in:
Tom Boullay
2026-04-27 23:01:29 +02:00
parent aeb0832409
commit 5556364601
5 changed files with 82 additions and 14 deletions
+1
View File
@@ -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. > 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. > 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. > 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) ### Production (Coolify / Docker)
+17 -4
View File
@@ -29,15 +29,28 @@ interface AlphaMapMaterial extends Material {
const alphaMapTextureCache = new WeakMap<Texture, Texture>() const alphaMapTextureCache = new WeakMap<Texture, Texture>()
function getRequestedFilename(requestedUrl: string) {
const cleanUrl = decodeURIComponent(requestedUrl.split(/[?#]/)[0] || '')
return cleanUrl.split(/[\\/]/).pop()?.toLowerCase()
}
function resolveSingleBinFallback(filename: string | undefined, assetUrls: Record<string, string>) {
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<string, string>) { function resolveAssetUrl(requestedUrl: string, assetUrls: Record<string, string>) {
if (requestedUrl.startsWith('blob:') || requestedUrl.startsWith('data:')) { if (requestedUrl.startsWith('data:')) {
return requestedUrl return requestedUrl
} }
const cleanUrl = decodeURIComponent(requestedUrl.split(/[?#]/)[0] || '') const filename = getRequestedFilename(requestedUrl)
const filename = cleanUrl.split(/[\\/]/).pop()?.toLowerCase() 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<string, string>) { function getOpacityMapEntries(assetUrls: Record<string, string>) {
+5 -5
View File
@@ -72,11 +72,11 @@ export default function FolderDropzone({
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const [isDragActive, setIsDragActive] = useState(false) const [isDragActive, setIsDragActive] = useState(false)
const processFiles = (files: File[], fallbackFolderName = 'folder') => { const processFiles = async (files: File[], fallbackFolderName = 'folder') => {
if (files.length === 0) return if (files.length === 0) return
const folderName = files[0].webkitRelativePath?.split('/')[0] || fallbackFolderName const folderName = files[0].webkitRelativePath?.split('/')[0] || fallbackFolderName
const validation = validateFolder(files) const validation = await validateFolder(files)
if (!validation.ok) { if (!validation.ok) {
onError(validation.errors.join(' | ')) onError(validation.errors.join(' | '))
@@ -107,7 +107,7 @@ export default function FolderDropzone({
const selected = e.target.files const selected = e.target.files
if (!selected || selected.length === 0) return if (!selected || selected.length === 0) return
processFiles(Array.from(selected)) void processFiles(Array.from(selected))
e.target.value = '' e.target.value = ''
} }
@@ -148,14 +148,14 @@ export default function FolderDropzone({
const rootEntry = directoryEntries[0] const rootEntry = directoryEntries[0]
const files = await collectEntryFiles(rootEntry) const files = await collectEntryFiles(rootEntry)
processFiles(files, rootEntry.name) await processFiles(files, rootEntry.name)
return return
} }
const droppedFiles = Array.from(e.dataTransfer.files) const droppedFiles = Array.from(e.dataTransfer.files)
if (droppedFiles.length === 0) return if (droppedFiles.length === 0) return
processFiles(droppedFiles) await processFiles(droppedFiles)
} catch { } catch {
onError('Impossible de lire le dossier depose') onError('Impossible de lire le dossier depose')
} }
+1 -1
View File
@@ -11,7 +11,7 @@ export default function WarningBanner({ warnings }: WarningBannerProps) {
<div className="mt-2 px-3 py-2 bg-yellow-900/20 border border-yellow-700/30 rounded-lg"> <div className="mt-2 px-3 py-2 bg-yellow-900/20 border border-yellow-700/30 rounded-lg">
<div className="flex items-center gap-2 text-xs text-yellow-400"> <div className="flex items-center gap-2 text-xs text-yellow-400">
<WarningIcon className="w-4 h-4" /> <WarningIcon className="w-4 h-4" />
<span>Textures manquantes : {warnings.join(', ')}</span> <span>{warnings.join(' ')}</span>
</div> </div>
</div> </div>
) )
+58 -4
View File
@@ -7,12 +7,64 @@ import type { TextureFile } from '@/lib/client-types'
const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS] 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). */ /** Discriminated union: either valid (with model) or invalid (with errors). */
export type ValidationResult = export type ValidationResult =
| { ok: true; model: File; textures: TextureFile[]; warnings: string[] } | { ok: true; model: File; textures: TextureFile[]; warnings: string[] }
| { ok: false; errors: 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<ValidationResult> {
const textures: TextureFile[] = [] const textures: TextureFile[] = []
const errors: string[] = [] const errors: string[] = []
@@ -29,12 +81,12 @@ export function validateFolder(files: File[]): ValidationResult {
return { ok: false, errors: ['Un seul fichier model.gltf est autorise'] } 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() const ext = f.name.slice(f.name.lastIndexOf('.')).toLowerCase()
return SUPPORT_FILE_EXT_ARRAY.includes(ext) return SUPPORT_FILE_EXT_ARRAY.includes(ext)
}) })
for (const tf of textureFiles) { for (const tf of supportFiles) {
textures.push({ name: tf.name, file: tf }) textures.push({ name: tf.name, file: tf })
} }
@@ -42,5 +94,7 @@ export function validateFolder(files: File[]): ValidationResult {
return { ok: false, errors } 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 }
} }