fix: normalize exported texture filenames
This commit is contained in:
@@ -44,10 +44,14 @@ The app runs on `http://localhost:3000` with hot reload. The upload API routes a
|
|||||||
|
|
||||||
### Asset naming convention
|
### 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.
|
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.
|
||||||
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`.
|
Internal convention: use `asset.png` to apply a texture to the whole model, or `asset_object.png` to target a specific object.
|
||||||
Invalid examples: `lampe_opacity.png`, `cable1_base_color.png`, `normal_opengl_cable1.png`, `metallic_pied.png`. Invalid or unknown asset names block the upload.
|
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
|
### Upload flow: Drive first, then Git
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie
|
|||||||
<span className="text-gray-500">Draw calls</span>
|
<span className="text-gray-500">Draw calls</span>
|
||||||
<span className="font-mono text-gray-200">{stats.drawCalls}</span>
|
<span className="font-mono text-gray-200">{stats.drawCalls}</span>
|
||||||
</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="flex justify-between gap-3">
|
||||||
<span className="text-gray-500">Meshes</span>
|
<span className="text-gray-500">Meshes</span>
|
||||||
<span className="font-mono text-gray-200">{stats.meshes}</span>
|
<span className="font-mono text-gray-200">{stats.meshes}</span>
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { useLoader } from '@react-three/fiber'
|
|||||||
import { CanvasTexture, Mesh, TextureLoader } from 'three'
|
import { CanvasTexture, Mesh, TextureLoader } from 'three'
|
||||||
import type { Material, Object3D, Texture } from 'three'
|
import type { Material, Object3D, Texture } from 'three'
|
||||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||||
|
import { normalizeTextureFilename } from '@/lib/asset-naming'
|
||||||
|
|
||||||
export interface ModelStats {
|
export interface ModelStats {
|
||||||
|
childObjects: number
|
||||||
drawCalls: number
|
drawCalls: number
|
||||||
materials: number
|
materials: number
|
||||||
meshes: number
|
meshes: number
|
||||||
@@ -55,7 +57,8 @@ function resolveAssetUrl(requestedUrl: string, assetUrls: Record<string, string>
|
|||||||
|
|
||||||
function getOpacityMapEntries(assetUrls: Record<string, string>) {
|
function getOpacityMapEntries(assetUrls: Record<string, string>) {
|
||||||
return Object.entries(assetUrls).reduce<OpacityMapEntry[]>((entries, [filename, url]) => {
|
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
|
if (!match) return entries
|
||||||
|
|
||||||
@@ -188,6 +191,7 @@ function getModelStats(scene: Object3D, assetUrls: Record<string, string>): Mode
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
childObjects: scene.children.length,
|
||||||
drawCalls,
|
drawCalls,
|
||||||
materials: materials.size,
|
materials: materials.size,
|
||||||
meshes,
|
meshes,
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ export default function UploadZone() {
|
|||||||
{' '}par exemple <span className="font-mono text-gray-200">color_porte.jpg</span>,
|
{' '}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">roughness_tuyaux.png</span>,
|
||||||
{' '}<span className="font-mono text-gray-200">normal_dashboard.webp</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>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getAssetFamily } from './asset-naming'
|
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 {
|
export function classifyAssetCategory(filename: string): AssetCategory {
|
||||||
const name = filename.replace(/\.[^.]+$/, '')
|
const name = filename.replace(/\.[^.]+$/, '')
|
||||||
@@ -26,9 +26,21 @@ export function classifyAssetCategory(filename: string): AssetCategory {
|
|||||||
return 'metalness'
|
return 'metalness'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (family === 'height') {
|
||||||
|
return 'height'
|
||||||
|
}
|
||||||
|
|
||||||
if (family === 'opacity') {
|
if (family === 'opacity') {
|
||||||
return 'opacity'
|
return 'opacity'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (family === 'orm') {
|
||||||
|
return 'orm'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family === 'ao') {
|
||||||
|
return 'ao'
|
||||||
|
}
|
||||||
|
|
||||||
return 'assets'
|
return 'assets'
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-1
@@ -6,6 +6,8 @@ export const ASSET_FAMILIES = [
|
|||||||
'metalness',
|
'metalness',
|
||||||
'height',
|
'height',
|
||||||
'opacity',
|
'opacity',
|
||||||
|
'orm',
|
||||||
|
'ao',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type AssetFamily = typeof ASSET_FAMILIES[number]
|
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'],
|
['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 {
|
export function getAssetFamily(value: string): AssetFamily | undefined {
|
||||||
return ASSET_FAMILY_BY_KEY.get(value.toLowerCase())
|
return ASSET_FAMILY_BY_KEY.get(value.toLowerCase())
|
||||||
}
|
}
|
||||||
@@ -33,11 +52,68 @@ function getFileStem(filename: string) {
|
|||||||
return filename.replace(/\.[^.]+$/, '')
|
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) {
|
export function getTextureNamingError(filename: string) {
|
||||||
const stem = getFileStem(filename)
|
const stem = getFileStem(filename)
|
||||||
const [prefix, ...targetParts] = stem.split('_')
|
const [prefix, ...targetParts] = stem.split('_')
|
||||||
const family = getAssetFamily(prefix)
|
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
|
if (family && targetParts.every(Boolean)) return null
|
||||||
|
|
||||||
|
|||||||
@@ -62,11 +62,14 @@ export function buildCommitMessage(
|
|||||||
roughness: '🪶 Textures (roughness)',
|
roughness: '🪶 Textures (roughness)',
|
||||||
normal: '🧭 Textures (normal)',
|
normal: '🧭 Textures (normal)',
|
||||||
metalness: '🔩 Textures (metalness)',
|
metalness: '🔩 Textures (metalness)',
|
||||||
|
height: '⛰ Textures (height)',
|
||||||
opacity: '🪟 Textures (opacity)',
|
opacity: '🪟 Textures (opacity)',
|
||||||
|
orm: '🧱 Textures (orm)',
|
||||||
|
ao: '🌑 Textures (ao)',
|
||||||
assets: '🧩 Assets',
|
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)
|
const entries = grouped.get(category)
|
||||||
if (!entries || entries.length === 0) continue
|
if (!entries || entries.length === 0) continue
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { extname } from 'path'
|
||||||
import { compressTextureBuffer } from '@/lib/texture-compression'
|
import { compressTextureBuffer } from '@/lib/texture-compression'
|
||||||
import { classifyAssetCategory } from '@/lib/asset-classification'
|
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 { getModelAssetPath } from '@/lib/model-paths'
|
||||||
import type { ParsedFile, PreparedAssetSummary, PushFile } from '@/lib/types'
|
import type { ParsedFile, PreparedAssetSummary, PushFile } from '@/lib/types'
|
||||||
|
|
||||||
@@ -16,6 +19,65 @@ interface PrepareGitAssetsResult {
|
|||||||
compressionError?: string
|
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({
|
export async function prepareGitAssets({
|
||||||
folderName,
|
folderName,
|
||||||
parsedFiles,
|
parsedFiles,
|
||||||
@@ -25,22 +87,26 @@ export async function prepareGitAssets({
|
|||||||
let modelFilename = ''
|
let modelFilename = ''
|
||||||
let compressed = false
|
let compressed = false
|
||||||
let compressionError: string | undefined
|
let compressionError: string | undefined
|
||||||
|
const textureFilenameMap = getTextureFilenameMap(parsedFiles)
|
||||||
|
|
||||||
for (const pf of parsedFiles) {
|
for (const pf of parsedFiles) {
|
||||||
let content = pf.buffer
|
let content = pf.buffer
|
||||||
|
let filename = pf.filename
|
||||||
|
|
||||||
if (pf.isModel) {
|
if (pf.isModel) {
|
||||||
|
content = prepareModelBuffer(pf.buffer, textureFilenameMap)
|
||||||
modelFilename = pf.filename
|
modelFilename = pf.filename
|
||||||
|
|
||||||
assetSummaries.push({
|
assetSummaries.push({
|
||||||
filename: pf.filename,
|
filename,
|
||||||
kind: 'model',
|
kind: 'model',
|
||||||
compressed: false,
|
compressed: false,
|
||||||
})
|
})
|
||||||
} else {
|
} 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
|
content = textureResult.buffer
|
||||||
compressed ||= textureResult.compressed
|
compressed ||= textureResult.compressed
|
||||||
|
|
||||||
@@ -49,7 +115,7 @@ export async function prepareGitAssets({
|
|||||||
}
|
}
|
||||||
|
|
||||||
assetSummaries.push({
|
assetSummaries.push({
|
||||||
filename: pf.filename,
|
filename,
|
||||||
kind: category === 'assets' ? 'asset' : 'texture',
|
kind: category === 'assets' ? 'asset' : 'texture',
|
||||||
category,
|
category,
|
||||||
compressed: textureResult.compressed,
|
compressed: textureResult.compressed,
|
||||||
@@ -57,7 +123,7 @@ export async function prepareGitAssets({
|
|||||||
}
|
}
|
||||||
|
|
||||||
filesToPush.push({
|
filesToPush.push({
|
||||||
path: getModelAssetPath(folderName, pf.filename),
|
path: getModelAssetPath(folderName, filename),
|
||||||
contentBase64: content.toString('base64'),
|
contentBase64: content.toString('base64'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,9 +139,10 @@ async function readOriginalParsedFiles(stagingId: string, manifest: StagingManif
|
|||||||
|
|
||||||
async function buildPreparedPushFiles(stagingId: string, manifest: StagingManifest): Promise<PushFile[]> {
|
async function buildPreparedPushFiles(stagingId: string, manifest: StagingManifest): Promise<PushFile[]> {
|
||||||
const preparedDir = getPreparedDir(stagingId)
|
const preparedDir = getPreparedDir(stagingId)
|
||||||
|
const preparedFiles = manifest.prepared?.assetSummaries || []
|
||||||
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
manifest.originals.map(async (file) => {
|
preparedFiles.map(async (file) => {
|
||||||
const buffer = await readFile(join(preparedDir, file.filename))
|
const buffer = await readFile(join(preparedDir, file.filename))
|
||||||
return {
|
return {
|
||||||
path: getModelAssetPath(manifest.folderName, file.filename),
|
path: getModelAssetPath(manifest.folderName, file.filename),
|
||||||
|
|||||||
Reference in New Issue
Block a user