lecodes-assets 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +240 -5
- package/dist/cli.js +7757 -683
- package/dist/index.js +7866 -675
- package/package.json +13 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli.ts +1039 -0
- package/src/doctor/doctor.ts +241 -0
- package/src/doctor/lightmapUv.ts +452 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +124 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +109 -0
- package/src/fbx/toDocument.ts +209 -0
- package/src/filmstrip.ts +263 -0
- package/src/index.ts +17 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +171 -0
- package/src/retarget/humanoid.ts +561 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2272 -0
- package/src/retarget/rig.ts +542 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +132 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +151 -0
- package/src/terrainPack.ts +169 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/materials.ts +593 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabs.ts +80 -0
- package/src/unity/scene.ts +363 -0
- package/src/unity/unpack.ts +130 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +114 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materials.ts +291 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +659 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +51 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/texture.ts +202 -0
- package/src/unreal/toDocument.ts +360 -0
- package/src/unreal/versions.ts +100 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Texture normalization for creator-gl (filament providers = png/jpeg/ktx2 only) + size budget.
|
|
2
|
+
// Split into PLAN (analysis, no mutation) and APPLY (sharp re-encode).
|
|
3
|
+
//
|
|
4
|
+
// Policy (format chosen purely by alpha — PNG only when alpha is needed, else JPG, to keep size
|
|
5
|
+
// small; webp→png for a 2048 image is ~5–10× larger than webp, so we avoid PNG unless required):
|
|
6
|
+
// - alpha present -> PNG (jpeg has no alpha channel)
|
|
7
|
+
// - no alpha -> JPG (normals/metalRough use quality 92 + 4:4:4 chroma)
|
|
8
|
+
// - webp -> re-encode per the rule above (filament has no webp provider)
|
|
9
|
+
// - max dim > 2048 -> downscale to fit 2048 + re-encode
|
|
10
|
+
// - opaque PNG (>=1024px) -> JPG (heavy opaque PNGs are wasteful)
|
|
11
|
+
// - ktx2 -> left untouched (supported; sharp can't handle it)
|
|
12
|
+
import sharp from 'sharp'
|
|
13
|
+
import type { Root, Texture } from '@gltf-transform/core'
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_MAX_SIZE = 2048
|
|
16
|
+
const HEAVY_PNG_MIN_DIM = 1024
|
|
17
|
+
|
|
18
|
+
export interface TexturePlan {
|
|
19
|
+
tex: Texture
|
|
20
|
+
name: string
|
|
21
|
+
roles: string
|
|
22
|
+
fromFmt: string
|
|
23
|
+
toFmt: 'jpeg' | 'png' | null // null = leave unchanged
|
|
24
|
+
resize: boolean
|
|
25
|
+
width: number
|
|
26
|
+
height: number
|
|
27
|
+
hasAlpha: boolean
|
|
28
|
+
isNormal: boolean
|
|
29
|
+
isData: boolean
|
|
30
|
+
category: 'lossless' | 'lossy' | null
|
|
31
|
+
reason: string
|
|
32
|
+
beforeBytes: number
|
|
33
|
+
afterBytes?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getTextureRoles(tex: Texture, root: Root) {
|
|
37
|
+
const set = new Set<string>()
|
|
38
|
+
for (const m of root.listMaterials()) {
|
|
39
|
+
if (m.getBaseColorTexture() === tex) set.add('base')
|
|
40
|
+
if (m.getEmissiveTexture() === tex) set.add('emissive')
|
|
41
|
+
if (m.getNormalTexture() === tex) set.add('normal')
|
|
42
|
+
if (m.getMetallicRoughnessTexture() === tex) set.add('metalRough')
|
|
43
|
+
if (m.getOcclusionTexture() === tex) set.add('occlusion')
|
|
44
|
+
}
|
|
45
|
+
return { roles: [...set].join('+') || 'unused', isNormal: set.has('normal'), isData: set.has('metalRough') || set.has('occlusion') }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function planTexture(tex: Texture, root: Root, maxSize = DEFAULT_MAX_SIZE, forceFmt?: 'jpeg' | 'png'): Promise<TexturePlan | null> {
|
|
49
|
+
const img = tex.getImage()
|
|
50
|
+
const mime = tex.getMimeType() || ''
|
|
51
|
+
const fromFmt = mime.replace('image/', '') || '?'
|
|
52
|
+
if (!img) return null
|
|
53
|
+
if (fromFmt.includes('ktx')) return null
|
|
54
|
+
|
|
55
|
+
const { roles, isNormal, isData } = getTextureRoles(tex, root)
|
|
56
|
+
let width = 0, height = 0, hasAlpha = false
|
|
57
|
+
try {
|
|
58
|
+
const buf = Buffer.from(img)
|
|
59
|
+
const meta = await sharp(buf).metadata()
|
|
60
|
+
width = meta.width || 0; height = meta.height || 0
|
|
61
|
+
hasAlpha = !(await sharp(buf).stats()).isOpaque
|
|
62
|
+
} catch {
|
|
63
|
+
return null // undecodable by sharp — leave it
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const resize = Math.max(width, height) > maxSize
|
|
67
|
+
let desired: 'jpeg' | 'png' = forceFmt ?? (hasAlpha ? 'png' : 'jpeg') // PNG only when alpha is needed
|
|
68
|
+
if (desired === 'jpeg' && hasAlpha) desired = 'png' // never drop a real alpha channel
|
|
69
|
+
|
|
70
|
+
let act = false, reason = 'ok'
|
|
71
|
+
if (forceFmt) {
|
|
72
|
+
if ((fromFmt as string) !== desired || resize || (fromFmt as string) === 'webp') { act = true; reason = `${fromFmt} → ${desired}${resize ? ` (≤${maxSize}px)` : ''}` }
|
|
73
|
+
}
|
|
74
|
+
else if (fromFmt === 'webp') { act = true; reason = `webp → ${desired}` }
|
|
75
|
+
else if (resize) { act = true; reason = `${width}×${height} → ≤${maxSize}px, ${desired}` }
|
|
76
|
+
else if (fromFmt === 'png' && desired === 'jpeg' && Math.max(width, height) >= HEAVY_PNG_MIN_DIM) { act = true; reason = `heavy opaque png → jpeg` }
|
|
77
|
+
|
|
78
|
+
const base = { tex, name: tex.getName() || '?', roles, fromFmt, resize, width, height, hasAlpha, isNormal, isData, beforeBytes: img.length }
|
|
79
|
+
if (!act) return { ...base, toFmt: null, category: null, reason }
|
|
80
|
+
|
|
81
|
+
// lossless only when staying/becoming PNG without a resize (no further pixel loss)
|
|
82
|
+
const category: 'lossless' | 'lossy' = desired === 'png' && !resize ? 'lossless' : 'lossy'
|
|
83
|
+
return { ...base, toFmt: desired, category, reason }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function applyTexturePlan(plan: TexturePlan, maxSize = DEFAULT_MAX_SIZE): Promise<void> {
|
|
87
|
+
if (!plan.toFmt) return
|
|
88
|
+
let pipe = sharp(Buffer.from(plan.tex.getImage()!))
|
|
89
|
+
if (plan.resize) pipe = pipe.resize({ width: maxSize, height: maxSize, fit: 'inside', withoutEnlargement: true })
|
|
90
|
+
if (plan.toFmt === 'jpeg') {
|
|
91
|
+
// normals / metal-rough / occlusion carry data, not colour: higher quality, no chroma subsampling
|
|
92
|
+
const dataLike = plan.isNormal || plan.isData
|
|
93
|
+
const out = await pipe.jpeg({ quality: dataLike ? 92 : 85, mozjpeg: true, chromaSubsampling: dataLike ? '4:4:4' : '4:2:0' }).toBuffer()
|
|
94
|
+
plan.tex.setImage(new Uint8Array(out)).setMimeType('image/jpeg')
|
|
95
|
+
plan.afterBytes = out.length
|
|
96
|
+
} else {
|
|
97
|
+
const out = await pipe.png({ compressionLevel: 9 }).toBuffer()
|
|
98
|
+
plan.tex.setImage(new Uint8Array(out)).setMimeType('image/png')
|
|
99
|
+
plan.afterBytes = out.length
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// MIRROR of packages/viewer/src/viewer/validateGlb.ts (kept in sync by hand — the viewer copy is the source of truth;
|
|
2
|
+
// duplicated so lecodes-assets stays publishable without a cross-package import).
|
|
3
|
+
// Pre-flight GLB validator — runs in the browser and in Bun (no Node/DOM APIs, no deps).
|
|
4
|
+
//
|
|
5
|
+
// Why this exists: the creator-gl wasm is shipped WITHOUT emscripten assertions, so when a
|
|
6
|
+
// model violates a filament hard-limit the C++ code calls abort() with no usable message
|
|
7
|
+
// (e.g. "bone count > 256", or a missing texture-decoder for webp). Validate the bytes here,
|
|
8
|
+
// BEFORE handing them to module._loadGlb(), and reject with a clear, actionable error instead.
|
|
9
|
+
//
|
|
10
|
+
// Mirrors the exact constraints filament/gltfio enforce at load time:
|
|
11
|
+
// - filament CONFIG_MAX_BONE_COUNT = 256 (skin.joints per renderable)
|
|
12
|
+
// - filament CONFIG_MAX_MORPH_TARGET_COUNT = 256 (morph targets per primitive)
|
|
13
|
+
// - gltfio texture providers registered in glb.cpp: image/png, image/jpeg, image/ktx2 only
|
|
14
|
+
// (webp / EXT_texture_webp has no provider -> load fails).
|
|
15
|
+
|
|
16
|
+
// ---- filament limits (keep in sync with EngineEnums.h) ----------------------------------------
|
|
17
|
+
export const MAX_BONE_COUNT = 256
|
|
18
|
+
export const MAX_MORPH_TARGET_COUNT = 256
|
|
19
|
+
|
|
20
|
+
// Texture formats with a provider registered in creator-gl (see packages/creator-gl/src/glb.cpp).
|
|
21
|
+
export const SUPPORTED_IMAGE_FORMATS = ['png', 'jpeg', 'ktx2'] as const
|
|
22
|
+
export type ImageFormat = (typeof SUPPORTED_IMAGE_FORMATS)[number] | 'webp' | 'unknown'
|
|
23
|
+
|
|
24
|
+
export interface GlbIssue {
|
|
25
|
+
level: 'error' | 'warning'
|
|
26
|
+
code: 'CONTAINER' | 'BONE_COUNT' | 'MORPH_COUNT' | 'TEXTURE_FORMAT'
|
|
27
|
+
message: string
|
|
28
|
+
detail?: Record<string, unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface GlbValidationResult {
|
|
32
|
+
ok: boolean
|
|
33
|
+
errors: GlbIssue[]
|
|
34
|
+
warnings: GlbIssue[]
|
|
35
|
+
summary: {
|
|
36
|
+
skins: number
|
|
37
|
+
maxJointCount: number
|
|
38
|
+
images: number
|
|
39
|
+
formats: Record<string, number>
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const GLB_MAGIC = 0x46546c67 // "glTF"
|
|
44
|
+
const CHUNK_JSON = 0x4e4f534a // "JSON"
|
|
45
|
+
const CHUNK_BIN = 0x004e4942 // "BIN\0"
|
|
46
|
+
|
|
47
|
+
class GlbParseError extends Error {}
|
|
48
|
+
|
|
49
|
+
export interface ParsedGlb {
|
|
50
|
+
json: any
|
|
51
|
+
bin: Uint8Array | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toBytes(data: ArrayBuffer | Uint8Array): Uint8Array {
|
|
55
|
+
return data instanceof Uint8Array ? data : new Uint8Array(data)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Container parse only (JSON chunk + BIN chunk); also accepts raw .gltf JSON bytes.
|
|
59
|
+
* Exported for reuse (backend asset-metadata extraction) — throws GlbParseError on bad input. */
|
|
60
|
+
export function parseGlb(bytes: Uint8Array): ParsedGlb {
|
|
61
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
62
|
+
|
|
63
|
+
// Accept a raw .gltf (JSON) too — useful for editor/CI paths. No BIN chunk in that case,
|
|
64
|
+
// so byte-sniffing of embedded images is skipped (uri/mimeType still validated).
|
|
65
|
+
if (bytes.byteLength < 12 || dv.getUint32(0, true) !== GLB_MAGIC) {
|
|
66
|
+
try {
|
|
67
|
+
return { json: JSON.parse(new TextDecoder().decode(bytes)), bin: null }
|
|
68
|
+
} catch {
|
|
69
|
+
throw new GlbParseError('Not a GLB (bad magic) and not parseable as glTF JSON')
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const version = dv.getUint32(4, true)
|
|
74
|
+
if (version !== 2) throw new GlbParseError(`Unsupported GLB container version ${version} (expected 2)`)
|
|
75
|
+
|
|
76
|
+
let offset = 12
|
|
77
|
+
let json: any = null
|
|
78
|
+
let bin: Uint8Array | null = null
|
|
79
|
+
while (offset + 8 <= bytes.byteLength) {
|
|
80
|
+
const chunkLen = dv.getUint32(offset, true)
|
|
81
|
+
const chunkType = dv.getUint32(offset + 4, true)
|
|
82
|
+
const start = offset + 8
|
|
83
|
+
const end = start + chunkLen
|
|
84
|
+
if (end > bytes.byteLength) throw new GlbParseError(`Chunk at ${offset} overruns the file`)
|
|
85
|
+
if (chunkType === CHUNK_JSON) {
|
|
86
|
+
json = JSON.parse(new TextDecoder().decode(bytes.subarray(start, end)))
|
|
87
|
+
} else if (chunkType === CHUNK_BIN) {
|
|
88
|
+
bin = bytes.subarray(start, end)
|
|
89
|
+
}
|
|
90
|
+
offset = end // chunkLength already includes the 4-byte alignment padding (glTF spec)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!json) throw new GlbParseError('GLB has no JSON chunk')
|
|
94
|
+
return { json, bin }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- image format detection -------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
function mimeToFormat(mime: string): ImageFormat {
|
|
100
|
+
switch (mime) {
|
|
101
|
+
case 'image/png': return 'png'
|
|
102
|
+
case 'image/jpeg': return 'jpeg'
|
|
103
|
+
case 'image/ktx2': return 'ktx2'
|
|
104
|
+
case 'image/webp': return 'webp'
|
|
105
|
+
default: return 'unknown'
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function extToFormat(ext: string): ImageFormat {
|
|
110
|
+
switch (ext) {
|
|
111
|
+
case 'png': return 'png'
|
|
112
|
+
case 'jpg':
|
|
113
|
+
case 'jpeg': return 'jpeg'
|
|
114
|
+
case 'ktx2': return 'ktx2'
|
|
115
|
+
case 'webp': return 'webp'
|
|
116
|
+
default: return 'unknown'
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sniffMagic(b: Uint8Array): ImageFormat {
|
|
121
|
+
if (b.length >= 4 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'png'
|
|
122
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpeg'
|
|
123
|
+
// RIFF....WEBP
|
|
124
|
+
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
|
|
125
|
+
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return 'webp'
|
|
126
|
+
// KTX2: 0xAB 'KTX 20' 0xBB 0x0D 0x0A 0x1A 0x0A
|
|
127
|
+
if (b.length >= 8 && b[0] === 0xab && b[1] === 0x4b && b[2] === 0x54 && b[3] === 0x58 &&
|
|
128
|
+
b[4] === 0x20 && b[5] === 0x32 && b[6] === 0x30 && b[7] === 0xbb) return 'ktx2'
|
|
129
|
+
return 'unknown'
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function detectFormat(image: any, json: any, bin: Uint8Array | null): { format: ImageFormat; via: string } {
|
|
133
|
+
if (typeof image?.uri === 'string') {
|
|
134
|
+
const uri: string = image.uri
|
|
135
|
+
if (uri.startsWith('data:')) {
|
|
136
|
+
const m = /^data:([^;,]+)[;,]/.exec(uri)
|
|
137
|
+
const mime = (m?.[1] ?? '').toLowerCase()
|
|
138
|
+
return { format: mimeToFormat(mime), via: `data-uri "${mime || '?'}"` }
|
|
139
|
+
}
|
|
140
|
+
const ext = (uri.split('?')[0].split('#')[0].split('.').pop() ?? '').toLowerCase()
|
|
141
|
+
return { format: extToFormat(ext), via: `uri ".${ext}"` }
|
|
142
|
+
}
|
|
143
|
+
if (typeof image?.mimeType === 'string') {
|
|
144
|
+
const mime = image.mimeType.toLowerCase()
|
|
145
|
+
return { format: mimeToFormat(mime), via: `mimeType "${image.mimeType}"` }
|
|
146
|
+
}
|
|
147
|
+
if (typeof image?.bufferView === 'number' && bin) {
|
|
148
|
+
const bv = json.bufferViews?.[image.bufferView]
|
|
149
|
+
// embedded images live in buffer 0 (the BIN chunk) for GLB
|
|
150
|
+
if (bv && (bv.buffer ?? 0) === 0) {
|
|
151
|
+
const start = bv.byteOffset ?? 0
|
|
152
|
+
return { format: sniffMagic(bin.subarray(start, start + 16)), via: 'magic-bytes' }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { format: 'unknown', via: 'undetermined' }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Image indices actually referenced by a texture (standard source + ktx2/webp extension sources).
|
|
159
|
+
function collectUsedImages(json: any): Set<number> {
|
|
160
|
+
const used = new Set<number>()
|
|
161
|
+
for (const t of json.textures ?? []) {
|
|
162
|
+
if (typeof t.source === 'number') used.add(t.source)
|
|
163
|
+
const basisu = t.extensions?.KHR_texture_basisu
|
|
164
|
+
if (typeof basisu?.source === 'number') used.add(basisu.source)
|
|
165
|
+
const webp = t.extensions?.EXT_texture_webp
|
|
166
|
+
if (typeof webp?.source === 'number') used.add(webp.source)
|
|
167
|
+
}
|
|
168
|
+
return used
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- main entry -------------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
export function validateGlb(data: ArrayBuffer | Uint8Array): GlbValidationResult {
|
|
174
|
+
const errors: GlbIssue[] = []
|
|
175
|
+
const warnings: GlbIssue[] = []
|
|
176
|
+
const formats: Record<string, number> = {}
|
|
177
|
+
|
|
178
|
+
let parsed: ParsedGlb
|
|
179
|
+
try {
|
|
180
|
+
parsed = parseGlb(toBytes(data))
|
|
181
|
+
} catch (e) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
errors: [{ level: 'error', code: 'CONTAINER', message: (e as Error).message }],
|
|
185
|
+
warnings: [],
|
|
186
|
+
summary: { skins: 0, maxJointCount: 0, images: 0, formats: {} },
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const { json, bin } = parsed
|
|
190
|
+
|
|
191
|
+
// --- skins: joints per skin == filament mSkinningBoneCount, capped at CONFIG_MAX_BONE_COUNT ---
|
|
192
|
+
const skins: any[] = json.skins ?? []
|
|
193
|
+
let maxJointCount = 0
|
|
194
|
+
for (let i = 0; i < skins.length; i++) {
|
|
195
|
+
const n = skins[i]?.joints?.length ?? 0
|
|
196
|
+
maxJointCount = Math.max(maxJointCount, n)
|
|
197
|
+
if (n > MAX_BONE_COUNT) {
|
|
198
|
+
errors.push({
|
|
199
|
+
level: 'error',
|
|
200
|
+
code: 'BONE_COUNT',
|
|
201
|
+
message: `skin[${i}]${skins[i].name ? ` "${skins[i].name}"` : ''} has ${n} joints, exceeds filament limit of ${MAX_BONE_COUNT}`,
|
|
202
|
+
detail: { skin: i, name: skins[i].name, joints: n, limit: MAX_BONE_COUNT },
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- morph targets per primitive, capped at CONFIG_MAX_MORPH_TARGET_COUNT --------------------
|
|
208
|
+
for (let mi = 0; mi < (json.meshes?.length ?? 0); mi++) {
|
|
209
|
+
const mesh = json.meshes[mi]
|
|
210
|
+
for (let pi = 0; pi < (mesh.primitives?.length ?? 0); pi++) {
|
|
211
|
+
const n = mesh.primitives[pi]?.targets?.length ?? 0
|
|
212
|
+
if (n > MAX_MORPH_TARGET_COUNT) {
|
|
213
|
+
errors.push({
|
|
214
|
+
level: 'error',
|
|
215
|
+
code: 'MORPH_COUNT',
|
|
216
|
+
message: `mesh[${mi}]${mesh.name ? ` "${mesh.name}"` : ''} primitive ${pi} has ${n} morph targets, exceeds filament limit of ${MAX_MORPH_TARGET_COUNT}`,
|
|
217
|
+
detail: { mesh: mi, primitive: pi, targets: n, limit: MAX_MORPH_TARGET_COUNT },
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- textures: every image must decode with a registered provider (png/jpeg/ktx2) -----------
|
|
224
|
+
const images: any[] = json.images ?? []
|
|
225
|
+
const used = collectUsedImages(json)
|
|
226
|
+
for (let i = 0; i < images.length; i++) {
|
|
227
|
+
const { format, via } = detectFormat(images[i], json, bin)
|
|
228
|
+
formats[format] = (formats[format] ?? 0) + 1
|
|
229
|
+
const supported = (SUPPORTED_IMAGE_FORMATS as readonly string[]).includes(format)
|
|
230
|
+
if (supported) continue
|
|
231
|
+
|
|
232
|
+
const referenced = used.has(i)
|
|
233
|
+
const issue: GlbIssue = {
|
|
234
|
+
level: referenced ? 'error' : 'warning',
|
|
235
|
+
code: 'TEXTURE_FORMAT',
|
|
236
|
+
message:
|
|
237
|
+
`image[${i}]${images[i].name ? ` "${images[i].name}"` : ''} is ${format.toUpperCase()} ` +
|
|
238
|
+
`(${via})${referenced ? '' : ' [unreferenced]'} — only ${SUPPORTED_IMAGE_FORMATS.join('/')} are supported`,
|
|
239
|
+
detail: { image: i, format, via, referenced },
|
|
240
|
+
}
|
|
241
|
+
;(referenced ? errors : warnings).push(issue)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
ok: errors.length === 0,
|
|
246
|
+
errors,
|
|
247
|
+
warnings,
|
|
248
|
+
summary: { skins: skins.length, maxJointCount, images: images.length, formats },
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Human-readable report (string, so it works identically in browser console and Bun stdout).
|
|
253
|
+
export function formatReport(result: GlbValidationResult, label = 'GLB'): string {
|
|
254
|
+
const lines: string[] = []
|
|
255
|
+
const s = result.summary
|
|
256
|
+
lines.push(`${result.ok ? '✅ VALID' : '❌ INVALID'} ${label}`)
|
|
257
|
+
lines.push(
|
|
258
|
+
` skins=${s.skins} (max joints=${s.maxJointCount}/${MAX_BONE_COUNT}) ` +
|
|
259
|
+
`images=${s.images} [${Object.entries(s.formats).map(([k, v]) => `${k}:${v}`).join(', ') || 'none'}]`,
|
|
260
|
+
)
|
|
261
|
+
for (const e of result.errors) lines.push(` ✗ ${e.code}: ${e.message}`)
|
|
262
|
+
for (const w of result.warnings) lines.push(` ⚠ ${w.code}: ${w.message}`)
|
|
263
|
+
return lines.join('\n')
|
|
264
|
+
}
|