feat: add texture diagnostics to viewer
This commit is contained in:
@@ -119,6 +119,7 @@ All files are uploaded to `VF/` (not just diffs), because the move operation emp
|
|||||||
- The server applies a lightweight per-folder lock on Drive and Git routes to avoid duplicate commits and concurrent writes
|
- The server applies a lightweight per-folder lock on Drive and Git routes to avoid duplicate commits and concurrent writes
|
||||||
- The folder is staged server-side so the browser sends the payload only once during the full upload flow
|
- The folder is staged server-side so the browser sends the payload only once during the full upload flow
|
||||||
- Invalid `model.gltf` JSON or malformed `buffers` entries block the upload before remote writes
|
- Invalid `model.gltf` JSON or malformed `buffers` entries block the upload before remote writes
|
||||||
|
- The client shows texture diagnostics before upload: missing GLTF image files, unsupported texture formats, duplicate flat filenames, unused textures, broad opacity maps, and all-transparent material exports
|
||||||
- Git LFS uploads are batched in groups of 100 objects to stay within the LFS Batch API limit
|
- Git LFS uploads are batched in groups of 100 objects to stay within the LFS Batch API limit
|
||||||
|
|
||||||
### Commit messages
|
### Commit messages
|
||||||
@@ -188,6 +189,7 @@ components/
|
|||||||
│ ├── FolderCard.tsx # Folder status card (Drive + Git)
|
│ ├── FolderCard.tsx # Folder status card (Drive + Git)
|
||||||
│ ├── DriveStatusLine.tsx # Drive/Git status sub-line
|
│ ├── DriveStatusLine.tsx # Drive/Git status sub-line
|
||||||
│ ├── WarningBanner.tsx # Missing texture warnings
|
│ ├── WarningBanner.tsx # Missing texture warnings
|
||||||
|
│ ├── TextureDiagnosticsPanel.tsx # Texture loading/export diagnostics
|
||||||
│ ├── OverwriteConfirmModal.tsx # Diff confirmation dialog
|
│ ├── OverwriteConfirmModal.tsx # Diff confirmation dialog
|
||||||
│ ├── NoChangesModal.tsx # "No changes detected" dialog
|
│ ├── NoChangesModal.tsx # "No changes detected" dialog
|
||||||
│ ├── DriveErrorModal.tsx # "Drive failed, continue?" dialog
|
│ ├── DriveErrorModal.tsx # "Drive failed, continue?" dialog
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ function pickOpacityMap(
|
|||||||
const genericIndex = entries.findIndex((entry) => entry.target === '')
|
const genericIndex = entries.findIndex((entry) => entry.target === '')
|
||||||
if (genericIndex >= 0) return textures[genericIndex]
|
if (genericIndex >= 0) return textures[genericIndex]
|
||||||
|
|
||||||
return entries.length === 1 ? textures[0] : undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function Model({
|
function Model({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { formatBytes } from '@/lib/format-bytes'
|
|||||||
import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon, WarningIcon } from '@/components/ui/icons'
|
import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon, WarningIcon } from '@/components/ui/icons'
|
||||||
import DriveStatusLine from './DriveStatusLine'
|
import DriveStatusLine from './DriveStatusLine'
|
||||||
import WarningBanner from './WarningBanner'
|
import WarningBanner from './WarningBanner'
|
||||||
|
import TextureDiagnosticsPanel from './TextureDiagnosticsPanel'
|
||||||
|
|
||||||
const ModelViewer = dynamic(() => import('../ModelViewer'), { ssr: false })
|
const ModelViewer = dynamic(() => import('../ModelViewer'), { ssr: false })
|
||||||
|
|
||||||
@@ -101,12 +102,15 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
|
|||||||
entry.viewerOpen ? 'max-h-[500px] opacity-100 mt-2' : 'max-h-0 opacity-0 pointer-events-none'
|
entry.viewerOpen ? 'max-h-[500px] opacity-100 mt-2' : 'max-h-0 opacity-0 pointer-events-none'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ModelViewer
|
<div className="grid gap-2 lg:grid-cols-[18rem_minmax(0,1fr)] lg:items-start">
|
||||||
url={entry.modelUrl}
|
<TextureDiagnosticsPanel report={entry.textureReport} />
|
||||||
assetUrls={entry.assetUrls}
|
<ModelViewer
|
||||||
filename={entry.modelFile.name}
|
url={entry.modelUrl}
|
||||||
size={formatBytes(entry.modelFile.size)}
|
assetUrls={entry.assetUrls}
|
||||||
/>
|
filename={entry.modelFile.name}
|
||||||
|
size={formatBytes(entry.modelFile.size)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export default function FolderDropzone({
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
warnings: validation.warnings,
|
warnings: validation.warnings,
|
||||||
|
textureReport: validation.textureReport,
|
||||||
modelUrl,
|
modelUrl,
|
||||||
assetUrls,
|
assetUrls,
|
||||||
viewerOpen: true,
|
viewerOpen: true,
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { CheckIcon, ChevronIcon, WarningIcon, XIcon } from '@/components/ui/icons'
|
||||||
|
import type { TextureDiagnosticReport } from '@/lib/client-types'
|
||||||
|
|
||||||
|
interface TextureDiagnosticsPanelProps {
|
||||||
|
report?: TextureDiagnosticReport
|
||||||
|
}
|
||||||
|
|
||||||
|
const idleReport: TextureDiagnosticReport = {
|
||||||
|
status: 'idle',
|
||||||
|
summary: 'Deposez un dossier pour analyser les textures du model.gltf.',
|
||||||
|
issues: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusStyles = {
|
||||||
|
idle: {
|
||||||
|
icon: <WarningIcon className="h-4 w-4" />,
|
||||||
|
label: 'En attente',
|
||||||
|
tone: 'border-white/15 bg-white/5 text-gray-400',
|
||||||
|
},
|
||||||
|
ok: {
|
||||||
|
icon: <CheckIcon className="h-4 w-4" />,
|
||||||
|
label: 'OK',
|
||||||
|
tone: 'border-green-500/30 bg-green-500/10 text-green-300',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: <WarningIcon className="h-4 w-4" />,
|
||||||
|
label: 'A verifier',
|
||||||
|
tone: 'border-yellow-500/30 bg-yellow-500/10 text-yellow-300',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: <XIcon className="h-4 w-4" />,
|
||||||
|
label: 'Probleme',
|
||||||
|
tone: 'border-red-500/30 bg-red-500/10 text-red-300',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueStyles = {
|
||||||
|
error: 'border-red-500/25 bg-red-500/10 text-red-200',
|
||||||
|
warning: 'border-yellow-500/25 bg-yellow-500/10 text-yellow-200',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TextureDiagnosticsPanel({
|
||||||
|
report,
|
||||||
|
}: TextureDiagnosticsPanelProps) {
|
||||||
|
const currentReport = report || idleReport
|
||||||
|
const style = statusStyles[currentReport.status]
|
||||||
|
const [isOpen, setIsOpen] = useState(true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="overflow-hidden rounded-xl border border-white/15 bg-black-800 text-xs text-gray-400 lg:max-h-[450px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((open) => !open)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<ChevronIcon className={`h-4 w-4 shrink-0 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||||
|
<span className="truncate text-sm font-semibold text-gray-100">Diagnostic textures</span>
|
||||||
|
</span>
|
||||||
|
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-1 ${style.tone}`}>
|
||||||
|
{style.icon}
|
||||||
|
{style.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="max-h-[380px] overflow-auto border-t border-white/10 px-4 py-3">
|
||||||
|
<p className="leading-relaxed">{currentReport.summary}</p>
|
||||||
|
|
||||||
|
{currentReport.issues.length > 0 ? (
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{currentReport.issues.map((issue, index) => (
|
||||||
|
<li
|
||||||
|
key={`${issue.title}-${index}`}
|
||||||
|
className={`rounded-xl border px-3 py-2 ${issueStyles[issue.severity]}`}
|
||||||
|
>
|
||||||
|
<p className="font-medium">{issue.title}</p>
|
||||||
|
<p className="mt-1 leading-relaxed text-gray-300">{issue.detail}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : currentReport.status === 'idle' ? (
|
||||||
|
<p className="mt-3 rounded-xl border border-white/10 bg-white/5 px-3 py-2 leading-relaxed">
|
||||||
|
Les resultats apparaitront ici apres selection du dossier.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 rounded-xl border border-white/10 bg-white/5 px-3 py-2 leading-relaxed">
|
||||||
|
Aucun probleme texture detecte cote app.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,20 @@ export interface TextureFile {
|
|||||||
file: File
|
file: File
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TextureDiagnosticSeverity = 'error' | 'warning'
|
||||||
|
|
||||||
|
export interface TextureDiagnosticIssue {
|
||||||
|
severity: TextureDiagnosticSeverity
|
||||||
|
title: string
|
||||||
|
detail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextureDiagnosticReport {
|
||||||
|
status: 'idle' | 'ok' | 'warning' | 'error'
|
||||||
|
summary: string
|
||||||
|
issues: TextureDiagnosticIssue[]
|
||||||
|
}
|
||||||
|
|
||||||
export type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
|
export type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
|
||||||
|
|
||||||
export interface FolderEntry {
|
export interface FolderEntry {
|
||||||
@@ -20,6 +34,7 @@ export interface FolderEntry {
|
|||||||
assetUrls: Record<string, string>
|
assetUrls: Record<string, string>
|
||||||
viewerOpen?: boolean
|
viewerOpen?: boolean
|
||||||
warnings: string[]
|
warnings: string[]
|
||||||
|
textureReport: TextureDiagnosticReport
|
||||||
driveStatus?: DriveStatus
|
driveStatus?: DriveStatus
|
||||||
driveError?: string
|
driveError?: string
|
||||||
}
|
}
|
||||||
|
|||||||
+291
-9
@@ -1,7 +1,7 @@
|
|||||||
import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants'
|
import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants'
|
||||||
import { getTextureNamingError } from '@/lib/asset-naming'
|
import { getTextureNamingError, normalizeTextureFilename } from '@/lib/asset-naming'
|
||||||
import { getErrorMessage, isRecord } from '@/lib/guards'
|
import { getErrorMessage, isRecord } from '@/lib/guards'
|
||||||
import type { TextureFile } from '@/lib/client-types'
|
import type { TextureDiagnosticIssue, TextureDiagnosticReport, TextureFile } from '@/lib/client-types'
|
||||||
|
|
||||||
const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS]
|
const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS]
|
||||||
|
|
||||||
@@ -9,12 +9,64 @@ interface GltfBufferReference {
|
|||||||
uri?: string
|
uri?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GltfImageReference {
|
||||||
|
uri?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GltfTextureReference {
|
||||||
|
source?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GltfTextureInfo {
|
||||||
|
index?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GltfPbrMaterial {
|
||||||
|
baseColorTexture?: GltfTextureInfo
|
||||||
|
metallicRoughnessTexture?: GltfTextureInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GltfMaterialReference {
|
||||||
|
name?: string
|
||||||
|
alphaMode?: string
|
||||||
|
pbrMetallicRoughness?: GltfPbrMaterial
|
||||||
|
normalTexture?: GltfTextureInfo
|
||||||
|
occlusionTexture?: GltfTextureInfo
|
||||||
|
emissiveTexture?: GltfTextureInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GltfMeshPrimitive {
|
||||||
|
material?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GltfMeshReference {
|
||||||
|
name?: string
|
||||||
|
primitives?: GltfMeshPrimitive[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GltfNodeReference {
|
||||||
|
name?: string
|
||||||
|
mesh?: number
|
||||||
|
}
|
||||||
|
|
||||||
interface GltfJson {
|
interface GltfJson {
|
||||||
buffers?: GltfBufferReference[]
|
buffers?: GltfBufferReference[]
|
||||||
|
images?: GltfImageReference[]
|
||||||
|
materials?: GltfMaterialReference[]
|
||||||
|
meshes?: GltfMeshReference[]
|
||||||
|
nodes?: GltfNodeReference[]
|
||||||
|
textures?: GltfTextureReference[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ValidationResult =
|
type ValidationResult =
|
||||||
| { ok: true; model: File; textures: TextureFile[]; warnings: string[] }
|
| {
|
||||||
|
ok: true
|
||||||
|
model: File
|
||||||
|
textures: TextureFile[]
|
||||||
|
warnings: string[]
|
||||||
|
textureReport: TextureDiagnosticReport
|
||||||
|
}
|
||||||
| { ok: false; errors: string[] }
|
| { ok: false; errors: string[] }
|
||||||
|
|
||||||
function isGltfBufferReference(value: unknown): value is GltfBufferReference {
|
function isGltfBufferReference(value: unknown): value is GltfBufferReference {
|
||||||
@@ -27,12 +79,30 @@ function isGltfJson(value: unknown): value is GltfJson {
|
|||||||
return Array.isArray(value.buffers) && value.buffers.every(isGltfBufferReference)
|
return Array.isArray(value.buffers) && value.buffers.every(isGltfBufferReference)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asRecordArray(value: unknown): Record<string, unknown>[] {
|
||||||
|
return Array.isArray(value) ? value.filter(isRecord) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeUri(uri: string) {
|
||||||
|
const cleanUri = uri.split(/[?#]/)[0] || ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(cleanUri)
|
||||||
|
} catch {
|
||||||
|
return cleanUri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReferencedFilename(uri: string) {
|
||||||
|
return decodeUri(uri).split(/[\\/]/).pop()?.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
function getReferencedBufferNames(gltf: GltfJson) {
|
function getReferencedBufferNames(gltf: GltfJson) {
|
||||||
return (gltf.buffers || [])
|
return (gltf.buffers || [])
|
||||||
.map((buffer) => (typeof buffer.uri === 'string' ? buffer.uri : undefined))
|
.map((buffer) => (typeof buffer.uri === 'string' ? buffer.uri : undefined))
|
||||||
.filter((uri): uri is string => typeof uri === 'string' && uri.length > 0)
|
.filter((uri): uri is string => typeof uri === 'string' && uri.length > 0)
|
||||||
.filter((uri) => !uri.startsWith('data:'))
|
.filter((uri) => !uri.startsWith('data:'))
|
||||||
.map((uri) => decodeURIComponent(uri.split(/[?#]/)[0] || '').split(/[\\/]/).pop()?.toLowerCase())
|
.map(getReferencedFilename)
|
||||||
.filter((filename): filename is string => Boolean(filename))
|
.filter((filename): filename is string => Boolean(filename))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +110,29 @@ function getFileExtension(filename: string) {
|
|||||||
return filename.slice(filename.lastIndexOf('.')).toLowerCase()
|
return filename.slice(filename.lastIndexOf('.')).toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGltfWarnings(model: File, supportFiles: File[]) {
|
function normalizeMatchName(name: string) {
|
||||||
const warnings: string[] = []
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOpacityTarget(filename: string) {
|
||||||
|
const normalizedFilename = normalizeTextureFilename(filename) || filename
|
||||||
|
const match = normalizedFilename.toLowerCase().match(/^opacity(?:[_-](.+))?\.(png|jpe?g|webp)$/)
|
||||||
|
return match ? match[1] || '' : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextureFiles(supportFiles: File[]) {
|
||||||
|
return supportFiles.filter((file) => TEXTURE_EXTENSIONS.has(getFileExtension(file.name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextureDisplayName(file: File) {
|
||||||
|
return file.webkitRelativePath || file.name
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseGltfModel(model: File) {
|
||||||
let parsed: unknown
|
let parsed: unknown
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -54,10 +145,15 @@ async function getGltfWarnings(model: File, supportFiles: File[]) {
|
|||||||
throw new Error('model.gltf a une structure invalide')
|
throw new Error('model.gltf a une structure invalide')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGltfWarnings(gltf: GltfJson, supportFiles: File[]) {
|
||||||
|
const warnings: string[] = []
|
||||||
const supportFilenames = new Set(supportFiles.map((file) => file.name.toLowerCase()))
|
const supportFilenames = new Set(supportFiles.map((file) => file.name.toLowerCase()))
|
||||||
const binFiles = supportFiles.filter((file) => file.name.toLowerCase().endsWith('.bin'))
|
const binFiles = supportFiles.filter((file) => file.name.toLowerCase().endsWith('.bin'))
|
||||||
|
|
||||||
for (const bufferName of getReferencedBufferNames(parsed)) {
|
for (const bufferName of getReferencedBufferNames(gltf)) {
|
||||||
if (!bufferName.endsWith('.bin') || supportFilenames.has(bufferName)) continue
|
if (!bufferName.endsWith('.bin') || supportFilenames.has(bufferName)) continue
|
||||||
|
|
||||||
if (binFiles.length === 1) {
|
if (binFiles.length === 1) {
|
||||||
@@ -73,6 +169,184 @@ async function getGltfWarnings(model: File, supportFiles: File[]) {
|
|||||||
return warnings
|
return warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getReferencedImageNames(gltf: GltfJson) {
|
||||||
|
return asRecordArray(gltf.images)
|
||||||
|
.map((image) => (typeof image.uri === 'string' ? image.uri : undefined))
|
||||||
|
.filter((uri): uri is string => typeof uri === 'string' && uri.length > 0)
|
||||||
|
.filter((uri) => !uri.startsWith('data:'))
|
||||||
|
.map(getReferencedFilename)
|
||||||
|
.filter((filename): filename is string => Boolean(filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelTargetNames(gltf: GltfJson) {
|
||||||
|
const names = new Set<string>()
|
||||||
|
|
||||||
|
for (const material of asRecordArray(gltf.materials)) {
|
||||||
|
if (typeof material.name === 'string') names.add(normalizeMatchName(material.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mesh of asRecordArray(gltf.meshes)) {
|
||||||
|
if (typeof mesh.name === 'string') names.add(normalizeMatchName(mesh.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of asRecordArray(gltf.nodes)) {
|
||||||
|
if (typeof node.name === 'string') names.add(normalizeMatchName(node.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...names].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIssue(
|
||||||
|
issues: TextureDiagnosticIssue[],
|
||||||
|
severity: TextureDiagnosticIssue['severity'],
|
||||||
|
title: string,
|
||||||
|
detail: string,
|
||||||
|
) {
|
||||||
|
issues.push({ severity, title, detail })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDuplicateFilenameIssues(files: File[], issues: TextureDiagnosticIssue[]) {
|
||||||
|
const filesByName = new Map<string, File[]>()
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const key = file.name.toLowerCase()
|
||||||
|
filesByName.set(key, [...(filesByName.get(key) || []), file])
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [filename, duplicates] of filesByName) {
|
||||||
|
if (duplicates.length < 2) continue
|
||||||
|
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
'warning',
|
||||||
|
'Nom de fichier duplique',
|
||||||
|
`${filename} existe plusieurs fois dans le dossier. La preview et le Git utilisent un dossier plat, donc une texture peut remplacer une autre.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOpacityIssues(gltf: GltfJson, textureFiles: File[], issues: TextureDiagnosticIssue[]) {
|
||||||
|
const targetNames = getModelTargetNames(gltf)
|
||||||
|
|
||||||
|
for (const file of textureFiles) {
|
||||||
|
const target = getOpacityTarget(file.name)
|
||||||
|
if (target === undefined) continue
|
||||||
|
|
||||||
|
if (target === '') {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
'warning',
|
||||||
|
'Opacity globale',
|
||||||
|
`${file.name} s'applique a tous les materiaux dans la preview. Si tout devient transparent, utilisez plutot un nom cible comme opacity_porte.png.`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTarget = normalizeMatchName(target)
|
||||||
|
const hasTarget = targetNames.some((name) => name.includes(normalizedTarget))
|
||||||
|
|
||||||
|
if (!hasTarget) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
'warning',
|
||||||
|
'Opacity cible introuvable',
|
||||||
|
`${file.name} cible "${target}", mais aucun noeud, mesh ou materiau du GLTF ne correspond. La map ne sera pas appliquee automatiquement.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTransparentMaterialIssues(gltf: GltfJson, issues: TextureDiagnosticIssue[]) {
|
||||||
|
const materials = asRecordArray(gltf.materials)
|
||||||
|
if (materials.length === 0) return
|
||||||
|
|
||||||
|
const transparentMaterials = materials.filter((material) => {
|
||||||
|
const alphaMode = typeof material.alphaMode === 'string' ? material.alphaMode.toUpperCase() : 'OPAQUE'
|
||||||
|
return alphaMode === 'BLEND' || alphaMode === 'MASK'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (transparentMaterials.length !== materials.length) return
|
||||||
|
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
'warning',
|
||||||
|
'Tous les materiaux sont transparents',
|
||||||
|
'Le GLTF declare tous ses materiaux en alphaMode BLEND ou MASK. Si ce n\'est pas voulu, le probleme vient probablement de l\'export du modele.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextureDiagnosticReport(gltf: GltfJson, supportFiles: File[]): TextureDiagnosticReport {
|
||||||
|
const issues: TextureDiagnosticIssue[] = []
|
||||||
|
const textureFiles = getTextureFiles(supportFiles)
|
||||||
|
const supportFilenames = new Set(supportFiles.map((file) => file.name.toLowerCase()))
|
||||||
|
const referencedImageNames = getReferencedImageNames(gltf)
|
||||||
|
const referencedImageSet = new Set(referencedImageNames)
|
||||||
|
|
||||||
|
for (const imageName of referencedImageNames) {
|
||||||
|
const extension = getFileExtension(imageName)
|
||||||
|
|
||||||
|
if (!TEXTURE_EXTENSIONS.has(extension)) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
'error',
|
||||||
|
'Format texture non supporte',
|
||||||
|
`${imageName} est reference par model.gltf, mais seuls .png, .jpg, .jpeg et .webp sont acceptes.`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supportFilenames.has(imageName)) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
'error',
|
||||||
|
'Texture referencee absente',
|
||||||
|
`${imageName} est utilisee par model.gltf mais absente du dossier. Ajoutez la texture ou re-exportez le modele.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDuplicateFilenameIssues(supportFiles, issues)
|
||||||
|
|
||||||
|
for (const file of textureFiles) {
|
||||||
|
if (referencedImageSet.has(file.name.toLowerCase()) || getOpacityTarget(file.name) !== undefined) continue
|
||||||
|
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
'warning',
|
||||||
|
'Texture non referencee',
|
||||||
|
`${getTextureDisplayName(file)} est dans le dossier, mais model.gltf ne la reference pas. Elle risque de ne pas apparaitre dans le viewer.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getOpacityIssues(gltf, textureFiles, issues)
|
||||||
|
getTransparentMaterialIssues(gltf, issues)
|
||||||
|
|
||||||
|
const hasErrors = issues.some((issue) => issue.severity === 'error')
|
||||||
|
const hasWarnings = issues.some((issue) => issue.severity === 'warning')
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
summary: 'Problemes detectes : certaines textures risquent de ne pas charger.',
|
||||||
|
issues,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasWarnings) {
|
||||||
|
return {
|
||||||
|
status: 'warning',
|
||||||
|
summary: 'Textures chargeables, mais certains points peuvent expliquer un rendu incorrect.',
|
||||||
|
issues,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
summary: 'Chargement textures OK. Si le rendu semble faux, le probleme vient probablement de l\'export ou du shading du modele.',
|
||||||
|
issues,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function validateFolder(files: File[]): Promise<ValidationResult> {
|
export async function validateFolder(files: File[]): Promise<ValidationResult> {
|
||||||
const textures: TextureFile[] = []
|
const textures: TextureFile[] = []
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
@@ -111,8 +385,12 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let warnings: string[] = []
|
let warnings: string[] = []
|
||||||
|
let textureReport: TextureDiagnosticReport | undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
warnings = await getGltfWarnings(modelFiles[0], supportFiles)
|
const gltf = await parseGltfModel(modelFiles[0])
|
||||||
|
warnings = getGltfWarnings(gltf, supportFiles)
|
||||||
|
textureReport = getTextureDiagnosticReport(gltf, supportFiles)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push(getErrorMessage(err, 'model.gltf invalide'))
|
errors.push(getErrorMessage(err, 'model.gltf invalide'))
|
||||||
}
|
}
|
||||||
@@ -121,5 +399,9 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
|
|||||||
return { ok: false, errors }
|
return { ok: false, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true, model: modelFiles[0], textures, warnings }
|
if (!textureReport) {
|
||||||
|
return { ok: false, errors: ['model.gltf invalide'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, model: modelFiles[0], textures, warnings, textureReport }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user