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),