fix: normalize exported texture filenames

This commit is contained in:
Tom Boullay
2026-04-28 15:51:15 +02:00
parent 497b0853c5
commit 41e04002b8
9 changed files with 188 additions and 16 deletions
+9 -5
View File
@@ -44,10 +44,14 @@ The app runs on `http://localhost:3000` with hot reload. The upload API routes a
### Asset naming convention
Texture filenames must start with a known asset family. Use `asset.png` to apply a texture to the whole model, or `asset_object.png` to target a specific object.
Allowed families are defined in `lib/asset-naming.ts`: `color`, `diffuse`, `roughness`, `normal`, `metalness`, `height`, `opacity`.
Valid examples: `color.png`, `diffuse_lampe.png`, `normal_cable1.png`, `opacity_lampe.png`.
Invalid examples: `lampe_opacity.png`, `cable1_base_color.png`, `normal_opengl_cable1.png`, `metallic_pied.png`. Invalid or unknown asset names block the upload.
Texture filenames can either use the internal convention or common GLTF export suffixes. The Git payload is normalized automatically and `model.gltf` texture URIs are rewritten to match the normalized filenames. Drive archiving keeps the original artist files.
Internal convention: use `asset.png` to apply a texture to the whole model, or `asset_object.png` to target a specific object.
Allowed families are defined in `lib/asset-naming.ts`: `color`, `diffuse`, `roughness`, `normal`, `metalness`, `height`, `opacity`, `orm`, `ao`.
Valid internal examples: `color.png`, `diffuse_lampe.png`, `normal_cable1.png`, `opacity_lampe.png`.
Accepted export examples: `lampe_baseColor.png`, `lampe_base_color.png`, `lampe_normal_opengl.png`, `lampe_metallic.png`, `lampe_occlusionRoughnessMetallic.png`, `lampe_mixed_ao.png`.
Git normalization examples: `lampe_baseColor.png` becomes `color_lampe.png`, `lampe_metallic.png` becomes `metalness_lampe.png`, `lampe_occlusionRoughnessMetallic.png` becomes `orm_lampe.png`, and `lampe_mixed_ao.png` becomes `ao_lampe.png`.
Invalid or unknown asset names still block the upload.
### Upload flow: Drive first, then Git
@@ -262,4 +266,4 @@ The Docker image runs the Next.js app and server-side asset preparation in a sin
See [MIT](LICENSE) License
Copyright 2026 La Fabrik Durable. All rights reserved.
Copyright 2026 La Fabrik Durable. All rights reserved.
+4
View File
@@ -65,6 +65,10 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie
<span className="text-gray-500">Draw calls</span>
<span className="font-mono text-gray-200">{stats.drawCalls}</span>
</span>
<span className="flex justify-between gap-3">
<span className="text-gray-500">Children</span>
<span className="font-mono text-gray-200">{stats.childObjects}</span>
</span>
<span className="flex justify-between gap-3">
<span className="text-gray-500">Meshes</span>
<span className="font-mono text-gray-200">{stats.meshes}</span>
+5 -1
View File
@@ -7,8 +7,10 @@ import { useLoader } from '@react-three/fiber'
import { CanvasTexture, Mesh, TextureLoader } from 'three'
import type { Material, Object3D, Texture } from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { normalizeTextureFilename } from '@/lib/asset-naming'
export interface ModelStats {
childObjects: number
drawCalls: number
materials: number
meshes: number
@@ -55,7 +57,8 @@ function resolveAssetUrl(requestedUrl: string, assetUrls: Record<string, string>
function getOpacityMapEntries(assetUrls: Record<string, string>) {
return Object.entries(assetUrls).reduce<OpacityMapEntry[]>((entries, [filename, url]) => {
const match = filename.toLowerCase().match(/^opacity(?:[_-](.+))?\.(png|jpe?g|webp)$/)
const normalizedFilename = normalizeTextureFilename(filename) || filename
const match = normalizedFilename.toLowerCase().match(/^opacity(?:[_-](.+))?\.(png|jpe?g|webp)$/)
if (!match) return entries
@@ -188,6 +191,7 @@ function getModelStats(scene: Object3D, assetUrls: Record<string, string>): Mode
})
return {
childObjects: scene.children.length,
drawCalls,
materials: materials.size,
meshes,
+3 -1
View File
@@ -91,7 +91,9 @@ export default function UploadZone() {
{' '}par exemple <span className="font-mono text-gray-200">color_porte.jpg</span>,
{' '}<span className="font-mono text-gray-200">roughness_tuyaux.png</span>,
{' '}<span className="font-mono text-gray-200">normal_dashboard.webp</span>
{' '}ou <span className="font-mono text-gray-200">opacity_fenetre.png</span>
{' '}ou <span className="font-mono text-gray-200">opacity_fenetre.png</span>.
{' '}Les exports classiques comme <span className="font-mono text-gray-200">porte_baseColor.png</span>
{' '}ou <span className="font-mono text-gray-200">porte_normal_opengl.png</span> sont normalises automatiquement pour Git.
</p>
)}
+13 -1
View File
@@ -1,6 +1,6 @@
import { getAssetFamily } from './asset-naming'
export type AssetCategory = 'color' | 'diffuse' | 'roughness' | 'normal' | 'metalness' | 'opacity' | 'assets'
export type AssetCategory = 'color' | 'diffuse' | 'roughness' | 'normal' | 'metalness' | 'height' | 'opacity' | 'orm' | 'ao' | 'assets'
export function classifyAssetCategory(filename: string): AssetCategory {
const name = filename.replace(/\.[^.]+$/, '')
@@ -26,9 +26,21 @@ export function classifyAssetCategory(filename: string): AssetCategory {
return 'metalness'
}
if (family === 'height') {
return 'height'
}
if (family === 'opacity') {
return 'opacity'
}
if (family === 'orm') {
return 'orm'
}
if (family === 'ao') {
return 'ao'
}
return 'assets'
}
+77 -1
View File
@@ -6,6 +6,8 @@ export const ASSET_FAMILIES = [
'metalness',
'height',
'opacity',
'orm',
'ao',
] as const
export type AssetFamily = typeof ASSET_FAMILIES[number]
@@ -21,6 +23,23 @@ const FORBIDDEN_ASSET_FAMILY_ALIASES: ReadonlyMap<string, AssetFamily> = new Map
['occlusion_roughness_metallic', 'roughness'],
])
const EXPORTED_SUFFIX_ALIASES: Array<{ suffix: string; family: AssetFamily }> = [
{ suffix: 'occlusionroughnessmetallic', family: 'orm' },
{ suffix: 'occlusion_roughness_metallic', family: 'orm' },
{ suffix: 'normal_opengl', family: 'normal' },
{ suffix: 'normalopengl', family: 'normal' },
{ suffix: 'base_color', family: 'color' },
{ suffix: 'basecolor', family: 'color' },
{ suffix: 'mixed_ao', family: 'ao' },
{ suffix: 'metallic', family: 'metalness' },
{ suffix: 'roughness', family: 'roughness' },
{ suffix: 'normal', family: 'normal' },
{ suffix: 'height', family: 'height' },
{ suffix: 'opacity', family: 'opacity' },
{ suffix: 'diffuse', family: 'diffuse' },
{ suffix: 'color', family: 'color' },
]
export function getAssetFamily(value: string): AssetFamily | undefined {
return ASSET_FAMILY_BY_KEY.get(value.toLowerCase())
}
@@ -33,11 +52,68 @@ function getFileStem(filename: string) {
return filename.replace(/\.[^.]+$/, '')
}
function getFileExtension(filename: string) {
return filename.split('.').pop()?.toLowerCase() || ''
}
function normalizeTargetName(target: string) {
return target
.trim()
.replace(/[\s-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
}
function buildTextureFilename(family: AssetFamily, target: string, extension: string) {
const normalizedTarget = normalizeTargetName(target)
return `${family}${normalizedTarget ? `_${normalizedTarget}` : ''}.${extension}`
}
function getExportedTextureAlias(stem: string) {
const lowerStem = stem.toLowerCase()
for (const alias of EXPORTED_SUFFIX_ALIASES) {
const lowerSuffix = alias.suffix.toLowerCase()
if (lowerStem === lowerSuffix) {
return { family: alias.family, target: '' }
}
if (lowerStem.endsWith(`_${lowerSuffix}`)) {
return {
family: alias.family,
target: stem.slice(0, -lowerSuffix.length - 1),
}
}
}
return null
}
export function normalizeTextureFilename(filename: string) {
const stem = getFileStem(filename)
const extension = getFileExtension(filename)
const [prefix, ...targetParts] = stem.split('_')
const family = getAssetFamily(prefix)
if (family) {
return buildTextureFilename(family, targetParts.join('_'), extension)
}
const exportedAlias = getExportedTextureAlias(stem)
if (exportedAlias) {
return buildTextureFilename(exportedAlias.family, exportedAlias.target, extension)
}
return null
}
export function getTextureNamingError(filename: string) {
const stem = getFileStem(filename)
const [prefix, ...targetParts] = stem.split('_')
const family = getAssetFamily(prefix)
const extension = filename.split('.').pop()
const extension = getFileExtension(filename)
if (normalizeTextureFilename(filename)) return null
if (family && targetParts.every(Boolean)) return null
+4 -1
View File
@@ -62,11 +62,14 @@ export function buildCommitMessage(
roughness: '🪶 Textures (roughness)',
normal: '🧭 Textures (normal)',
metalness: '🔩 Textures (metalness)',
height: '⛰ Textures (height)',
opacity: '🪟 Textures (opacity)',
orm: '🧱 Textures (orm)',
ao: '🌑 Textures (ao)',
assets: '🧩 Assets',
}
for (const category of ['color', 'diffuse', 'roughness', 'normal', 'metalness', 'opacity', 'assets'] as const) {
for (const category of ['color', 'diffuse', 'roughness', 'normal', 'metalness', 'height', 'opacity', 'orm', 'ao', 'assets'] as const) {
const entries = grouped.get(category)
if (!entries || entries.length === 0) continue
lines.push('')
+71 -5
View File
@@ -1,5 +1,8 @@
import { extname } from 'path'
import { compressTextureBuffer } from '@/lib/texture-compression'
import { classifyAssetCategory } from '@/lib/asset-classification'
import { normalizeTextureFilename } from '@/lib/asset-naming'
import { TEXTURE_EXTENSIONS } from '@/lib/constants'
import { getModelAssetPath } from '@/lib/model-paths'
import type { ParsedFile, PreparedAssetSummary, PushFile } from '@/lib/types'
@@ -16,6 +19,65 @@ interface PrepareGitAssetsResult {
compressionError?: string
}
function getTextureFilenameMap(parsedFiles: ParsedFile[]) {
const filenameMap = new Map<string, string>()
const normalizedOwners = new Map<string, string>()
for (const file of parsedFiles) {
const ext = extname(file.filename).toLowerCase()
if (!TEXTURE_EXTENSIONS.has(ext)) continue
const normalizedFilename = normalizeTextureFilename(file.filename)
if (!normalizedFilename) continue
const normalizedKey = normalizedFilename.toLowerCase()
const existingOwner = normalizedOwners.get(normalizedKey)
if (existingOwner && existingOwner.toLowerCase() !== file.filename.toLowerCase()) {
throw new Error(`Textures en conflit apres normalisation : ${existingOwner} et ${file.filename} deviennent ${normalizedFilename}`)
}
filenameMap.set(file.filename.toLowerCase(), normalizedFilename)
normalizedOwners.set(normalizedKey, file.filename)
}
return filenameMap
}
function getReferencedFilename(uri: string) {
const cleanUri = decodeURIComponent(uri.split(/[?#]/)[0] || '')
return cleanUri.split(/[\\/]/).pop()?.toLowerCase()
}
function rewriteGltfUris(value: unknown, filenameMap: Map<string, string>): unknown {
if (Array.isArray(value)) {
return value.map((entry) => rewriteGltfUris(entry, filenameMap))
}
if (!value || typeof value !== 'object') return value
const rewritten: Record<string, unknown> = {}
for (const [key, entry] of Object.entries(value)) {
if (key === 'uri' && typeof entry === 'string') {
const filename = getReferencedFilename(entry)
rewritten[key] = filename ? filenameMap.get(filename) || entry : entry
continue
}
rewritten[key] = rewriteGltfUris(entry, filenameMap)
}
return rewritten
}
function prepareModelBuffer(buffer: Buffer, filenameMap: Map<string, string>) {
if (filenameMap.size === 0) return buffer
const parsed: unknown = JSON.parse(buffer.toString('utf-8'))
return Buffer.from(JSON.stringify(rewriteGltfUris(parsed, filenameMap), null, 2), 'utf-8')
}
export async function prepareGitAssets({
folderName,
parsedFiles,
@@ -25,22 +87,26 @@ export async function prepareGitAssets({
let modelFilename = ''
let compressed = false
let compressionError: string | undefined
const textureFilenameMap = getTextureFilenameMap(parsedFiles)
for (const pf of parsedFiles) {
let content = pf.buffer
let filename = pf.filename
if (pf.isModel) {
content = prepareModelBuffer(pf.buffer, textureFilenameMap)
modelFilename = pf.filename
assetSummaries.push({
filename: pf.filename,
filename,
kind: 'model',
compressed: false,
})
} else {
const category = classifyAssetCategory(pf.filename)
filename = textureFilenameMap.get(pf.filename.toLowerCase()) || pf.filename
const category = classifyAssetCategory(filename)
const textureResult = await compressTextureBuffer(pf.filename, pf.buffer)
const textureResult = await compressTextureBuffer(filename, pf.buffer)
content = textureResult.buffer
compressed ||= textureResult.compressed
@@ -49,7 +115,7 @@ export async function prepareGitAssets({
}
assetSummaries.push({
filename: pf.filename,
filename,
kind: category === 'assets' ? 'asset' : 'texture',
category,
compressed: textureResult.compressed,
@@ -57,7 +123,7 @@ export async function prepareGitAssets({
}
filesToPush.push({
path: getModelAssetPath(folderName, pf.filename),
path: getModelAssetPath(folderName, filename),
contentBase64: content.toString('base64'),
})
}
+2 -1
View File
@@ -139,9 +139,10 @@ async function readOriginalParsedFiles(stagingId: string, manifest: StagingManif
async function buildPreparedPushFiles(stagingId: string, manifest: StagingManifest): Promise<PushFile[]> {
const preparedDir = getPreparedDir(stagingId)
const preparedFiles = manifest.prepared?.assetSummaries || []
return Promise.all(
manifest.originals.map(async (file) => {
preparedFiles.map(async (file) => {
const buffer = await readFile(join(preparedDir, file.filename))
return {
path: getModelAssetPath(manifest.folderName, file.filename),