fix: enforce asset naming convention

This commit is contained in:
Tom Boullay
2026-04-27 23:29:47 +02:00
parent b084c0e20e
commit fa77c484f9
4 changed files with 78 additions and 6 deletions
+11
View File
@@ -72,6 +72,16 @@ Access the app at `http://localhost:3000`
> 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. > 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.
### 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`: `baseColor`, `color`, `roughness`, `normal`, `normalOpengl`, `metallic`, `metalness`, `occlusionRoughnessMetallic`, `height`, `opacity`.
Valid examples: `color.png`, `baseColor_lampe.png`, `normalOpengl_cable1.png`, `opacity_lampe.png`.
Invalid examples: `lampe_opacity.png`, `cable1_base_color.png`, `normal_opengl_cable1.png`. Invalid or unknown asset names block the upload.
### Production (Coolify / Docker) ### Production (Coolify / Docker)
```bash ```bash
@@ -238,6 +248,7 @@ lib/
├── upload-staging.ts # Temporary server-side staging and prepared asset reuse ├── upload-staging.ts # Temporary server-side staging and prepared asset reuse
├── upload-lock.ts # Lightweight in-memory per-folder upload lock ├── upload-lock.ts # Lightweight in-memory per-folder upload lock
├── asset-classification.ts # Group assets by family for commit messages ├── asset-classification.ts # Group assets by family for commit messages
├── asset-naming.ts # Allowed asset families and naming convention helpers
├── commit-message.ts # Commit message builder ├── commit-message.ts # Commit message builder
├── parse-upload.ts # FormData parser + validation ├── parse-upload.ts # FormData parser + validation
├── validate-folder.ts # Client-side folder validation (discriminated union) ├── validate-folder.ts # Client-side folder validation (discriminated union)
+8 -5
View File
@@ -1,21 +1,24 @@
import { getAssetFamily } from './asset-naming'
export type AssetCategory = 'color' | 'roughness' | 'normal' | 'metalness' | 'assets' export type AssetCategory = 'color' | 'roughness' | 'normal' | 'metalness' | 'assets'
export function classifyAssetCategory(filename: string): AssetCategory { export function classifyAssetCategory(filename: string): AssetCategory {
const name = filename.toLowerCase().replace(/\.[^.]+$/, '') const name = filename.replace(/\.[^.]+$/, '')
const family = getAssetFamily(name.split('_')[0])
if (name.includes('base_color') || name.includes('_color') || name === 'color') { if (family === 'baseColor' || family === 'color') {
return 'color' return 'color'
} }
if (name.includes('roughness')) { if (family === 'roughness' || family === 'occlusionRoughnessMetallic') {
return 'roughness' return 'roughness'
} }
if (name.includes('normal')) { if (family === 'normal' || family === 'normalOpengl') {
return 'normal' return 'normal'
} }
if (name.includes('metallic') || name.includes('metalness')) { if (family === 'metallic' || family === 'metalness') {
return 'metalness' return 'metalness'
} }
+24
View File
@@ -0,0 +1,24 @@
export const ASSET_FAMILIES = [
'baseColor',
'color',
'roughness',
'normal',
'normalOpengl',
'metallic',
'metalness',
'occlusionRoughnessMetallic',
'height',
'opacity',
] as const
export type AssetFamily = typeof ASSET_FAMILIES[number]
const ASSET_FAMILY_BY_KEY = new Map(ASSET_FAMILIES.map((family) => [family.toLowerCase(), family]))
export function getAssetFamily(value: string): AssetFamily | undefined {
return ASSET_FAMILY_BY_KEY.get(value.toLowerCase())
}
export function formatAssetFamilies() {
return ASSET_FAMILIES.join(', ')
}
+35 -1
View File
@@ -3,6 +3,7 @@
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants' import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants'
import { formatAssetFamilies, getAssetFamily } from '@/lib/asset-naming'
import type { TextureFile } from '@/lib/client-types' 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]
@@ -33,6 +34,32 @@ function getReferencedBufferNames(gltf: GltfJson) {
.filter((filename): filename is string => Boolean(filename)) .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[]) { async function getGltfWarnings(model: File, supportFiles: File[]) {
const warnings: string[] = [] const warnings: string[] = []
let parsed: unknown let parsed: unknown
@@ -82,10 +109,17 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
} }
const supportFiles = files.filter((f) => { const supportFiles = files.filter((f) => {
const ext = f.name.slice(f.name.lastIndexOf('.')).toLowerCase() const ext = getFileExtension(f.name)
return SUPPORT_FILE_EXT_ARRAY.includes(ext) 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) { for (const tf of supportFiles) {
textures.push({ name: tf.name, file: tf }) textures.push({ name: tf.name, file: tf })
} }