fix: resolve gltf companion assets reliably
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -29,15 +29,28 @@ interface AlphaMapMaterial extends Material {
|
||||
|
||||
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>) {
|
||||
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<string, string>) {
|
||||
|
||||
@@ -72,11 +72,11 @@ export default function FolderDropzone({
|
||||
const inputRef = useRef<HTMLInputElement>(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')
|
||||
}
|
||||
|
||||
@@ -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="flex items-center gap-2 text-xs text-yellow-400">
|
||||
<WarningIcon className="w-4 h-4" />
|
||||
<span>Textures manquantes : {warnings.join(', ')}</span>
|
||||
<span>{warnings.join(' ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+58
-4
@@ -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<ValidationResult> {
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user