diff --git a/README.md b/README.md
index eb37f27..504b5fb 100644
--- a/README.md
+++ b/README.md
@@ -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.
\ No newline at end of file
+Copyright 2026 La Fabrik Durable. All rights reserved.
diff --git a/components/ModelViewer.tsx b/components/ModelViewer.tsx
index b87b6c1..50f599c 100644
--- a/components/ModelViewer.tsx
+++ b/components/ModelViewer.tsx
@@ -65,6 +65,10 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie
Draw calls
{stats.drawCalls}
+
+ Children
+ {stats.childObjects}
+
Meshes
{stats.meshes}
diff --git a/components/SceneViewer.tsx b/components/SceneViewer.tsx
index 1fce99d..95fbed6 100644
--- a/components/SceneViewer.tsx
+++ b/components/SceneViewer.tsx
@@ -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
function getOpacityMapEntries(assetUrls: Record) {
return Object.entries(assetUrls).reduce((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): Mode
})
return {
+ childObjects: scene.children.length,
drawCalls,
materials: materials.size,
meshes,
diff --git a/components/UploadZone.tsx b/components/UploadZone.tsx
index 56d9f54..02e3233 100644
--- a/components/UploadZone.tsx
+++ b/components/UploadZone.tsx
@@ -91,7 +91,9 @@ export default function UploadZone() {
{' '}par exemple color_porte.jpg,
{' '}roughness_tuyaux.png,
{' '}normal_dashboard.webp
- {' '}ou opacity_fenetre.png
+ {' '}ou opacity_fenetre.png.
+ {' '}Les exports classiques comme porte_baseColor.png
+ {' '}ou porte_normal_opengl.png sont normalises automatiquement pour Git.
)}
diff --git a/lib/asset-classification.ts b/lib/asset-classification.ts
index b052575..23eb8a1 100644
--- a/lib/asset-classification.ts
+++ b/lib/asset-classification.ts
@@ -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'
}
diff --git a/lib/asset-naming.ts b/lib/asset-naming.ts
index 56a6446..13110e0 100644
--- a/lib/asset-naming.ts
+++ b/lib/asset-naming.ts
@@ -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 = 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
diff --git a/lib/commit-message.ts b/lib/commit-message.ts
index fbe5e81..407cbe2 100644
--- a/lib/commit-message.ts
+++ b/lib/commit-message.ts
@@ -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('')
diff --git a/lib/prepare-git-assets.ts b/lib/prepare-git-assets.ts
index 1a6f1b3..2930efc 100644
--- a/lib/prepare-git-assets.ts
+++ b/lib/prepare-git-assets.ts
@@ -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()
+ const normalizedOwners = new Map()
+
+ 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): unknown {
+ if (Array.isArray(value)) {
+ return value.map((entry) => rewriteGltfUris(entry, filenameMap))
+ }
+
+ if (!value || typeof value !== 'object') return value
+
+ const rewritten: Record = {}
+
+ 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) {
+ 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'),
})
}
diff --git a/lib/upload-staging.ts b/lib/upload-staging.ts
index f55d33c..5a7b159 100644
--- a/lib/upload-staging.ts
+++ b/lib/upload-staging.ts
@@ -139,9 +139,10 @@ async function readOriginalParsedFiles(stagingId: string, manifest: StagingManif
async function buildPreparedPushFiles(stagingId: string, manifest: StagingManifest): Promise {
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),