lecodes-viewer 0.19.0
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 +5 -0
- package/dist-embed/VERSION.json +5 -0
- package/dist-embed/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
- package/dist-embed/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/dist-embed/assets/basis-C64VHDVD.js +1 -0
- package/dist-embed/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/dist-embed/assets/createViewer2d-D12Wy3VF.js +1 -0
- package/dist-embed/assets/createViewerLite-CP-WHGLg.js +707 -0
- package/dist-embed/assets/creator-gl-BF2CQzeI.wasm +0 -0
- package/dist-embed/assets/creator-gl.js-B_0GrQRe.symbols +5379 -0
- package/dist-embed/assets/creator-ui-BX5Fvnbt.js +1 -0
- package/dist-embed/assets/creator-ui-CzKHLH2Q.wasm +0 -0
- package/dist-embed/assets/creator2d-BtQ-1_B0.wasm +0 -0
- package/dist-embed/assets/draco-BiISTFcR.js +118 -0
- package/dist-embed/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/dist-embed/assets/host-CWKB1WDd.js +162 -0
- package/dist-embed/assets/index-CIK8PnUA.js +2 -0
- package/dist-embed/assets/lit-DUE9rhY9.filamat +0 -0
- package/dist-embed/assets/loadCreatorUiNode-UAdhjRTd.js +1 -0
- package/dist-embed/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
- package/dist-embed/assets/particles-DxYDmI1O.filamat +0 -0
- package/dist-embed/assets/particles-quad-DwYo9yCK.filamat +0 -0
- package/dist-embed/assets/shadow-C9xJpqlY.filamat +0 -0
- package/dist-embed/assets/uberarchive-uXjRYa9p.bin +0 -0
- package/dist-embed/assets/unlit-pe4tFY5b.filamat +0 -0
- package/dist-embed/assets/unlit-transparent-D9OyaOBR.filamat +0 -0
- package/dist-embed/assets/video-eedvhMvS.filamat +0 -0
- package/dist-embed/assets/worker-Caf-yYEI.js +2 -0
- package/dist-embed/embed.html +48 -0
- package/dist-embed/embed.js +187 -0
- package/embed.html +48 -0
- package/package.json +35 -0
- package/src/assets/banner.png +0 -0
- package/src/assets/image2.png +0 -0
- package/src/assets/landing.mp4 +0 -0
- package/src/assets/neutral_ibl128.ktx +0 -0
- package/src/assets/neutral_ibl256.ktx +0 -0
- package/src/assets/output.mp4 +0 -0
- package/src/assets/output2.mp4 +0 -0
- package/src/assets/texture.png +0 -0
- package/src/assets/vue.svg +1 -0
- package/src/canvas/createCanvasHost.ts +2 -0
- package/src/createViewer.ts +103 -0
- package/src/embed.ts +515 -0
- package/src/host.ts +428 -0
- package/src/lite-test.ts +160 -0
- package/src/main.ts +77 -0
- package/src/sourcemapError.ts +2 -0
- package/src/ui/CuiProps.gen.ts +103 -0
- package/src/ui/UIScreen.ts +486 -0
- package/src/ui/createBareUI.ts +32 -0
- package/src/ui/createMeasureSystem.ts +220 -0
- package/src/ui/createNodeElement.ts +77 -0
- package/src/ui/createPagerSystem.ts +345 -0
- package/src/ui/createTouchSystem.ts +2 -0
- package/src/ui/createUI.ts +806 -0
- package/src/ui/creator-ui.js +14 -0
- package/src/ui/creator-ui.wasm +0 -0
- package/src/ui/defaultStyle.css +176 -0
- package/src/ui/layers.ts +9 -0
- package/src/ui/mountCanvasUI.ts +213 -0
- package/src/ui/nativeViewSystem.ts +91 -0
- package/src/ui/presentPlayer.ts +156 -0
- package/src/ui/props-apply.ts +403 -0
- package/src/ui/router.ts +2 -0
- package/src/ui/safeAreaSpec.ts +3 -0
- package/src/ui/shells.ts +37 -0
- package/src/ui/toasts.ts +2 -0
- package/src/ui/transitions.ts +250 -0
- package/src/ui/ui.d.ts +78 -0
- package/src/ui/vListSystem.ts +212 -0
- package/src/ui/viewScale.ts +2 -0
- package/src/utils/animate.ts +2 -0
- package/src/utils/createUtils.ts +2 -0
- package/src/utils/fetch.ts +3 -0
- package/src/utils/fonts.ts +2 -0
- package/src/utils/globals.ts +2 -0
- package/src/utils/imageNode.ts +260 -0
- package/src/utils/openFilePicker.ts +2 -0
- package/src/utils/svg.ts +2 -0
- package/src/utils/videoNode.ts +2 -0
- package/src/utils/ws.ts +2 -0
- package/src/viewer/createViewer.ts +993 -0
- package/src/viewer/creator-gl.js +14 -0
- package/src/viewer/creator-gl.js.symbols +5379 -0
- package/src/viewer/creator-gl.wasm +0 -0
- package/src/viewer/lit.filamat +0 -0
- package/src/viewer/mediaplayer.ts +3 -0
- package/src/viewer/particles-quad.filamat +0 -0
- package/src/viewer/particles.filamat +0 -0
- package/src/viewer/physics.ts +140 -0
- package/src/viewer/shadow.filamat +0 -0
- package/src/viewer/uberarchive.bin +0 -0
- package/src/viewer/unlit-transparent.filamat +0 -0
- package/src/viewer/unlit.filamat +0 -0
- package/src/viewer/validateGlb.ts +262 -0
- package/src/viewer/video.filamat +0 -0
- package/src/viewer/wasmCrashSymbolizer.ts +133 -0
- package/src/viewer2d/createViewer2d.ts +438 -0
- package/src/viewer2d/creator2d.js +14 -0
- package/src/viewer2d/creator2d.wasm +0 -0
- package/src/viewer2d/sprite2d.ts +80 -0
- package/src/vite-env.d.ts +1 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Thin, typed wrapper + helpers for the JoltPhysics bring-up in creator-gl.
|
|
2
|
+
//
|
|
3
|
+
// The actual entry points live on the viewer returned by `createViewer` (physicsCreateBox,
|
|
4
|
+
// physicsSetLinearVelocity, …). This file adds the things that don't belong in the raw
|
|
5
|
+
// passthroughs: the motion-type enum, a typed surface, and a runnable test scene.
|
|
6
|
+
//
|
|
7
|
+
// Ownership convention (mirror of creator.h):
|
|
8
|
+
// - Dynamic bodies: physics OWNS the transform. After physicsCreate*, do NOT call
|
|
9
|
+
// setPosition / setMatrix on the entity per frame — move it via velocity / impulse.
|
|
10
|
+
// - Kinematic bodies: move via physicsSetBodyPosition.
|
|
11
|
+
// - The fixed-step simulation + transform sync happen in C++ inside `_render`; nothing
|
|
12
|
+
// crosses the JS boundary per frame.
|
|
13
|
+
|
|
14
|
+
export const MotionType = {
|
|
15
|
+
Static: 0, // immovable (ground / walls)
|
|
16
|
+
Kinematic: 1, // moved by code via physicsSetBodyPosition, pushes dynamics
|
|
17
|
+
Dynamic: 2, // simulated: gravity, collisions, impulses
|
|
18
|
+
} as const
|
|
19
|
+
|
|
20
|
+
export type MotionType = (typeof MotionType)[keyof typeof MotionType]
|
|
21
|
+
|
|
22
|
+
// The physics slice of the createViewer() return value.
|
|
23
|
+
export interface PhysicsApi {
|
|
24
|
+
/** True only when creator-gl was built with -DCREATOR_GL_PHYSICS=ON. */
|
|
25
|
+
physicsHasSupport(): boolean
|
|
26
|
+
/** Gravity + max body count. maxBodies only takes effect before any body exists (it resizes
|
|
27
|
+
* the world); pass 0 to keep the current limit. Derived pair/contact/temp limits scale with it. */
|
|
28
|
+
physicsConfigure(gx: number, gy: number, gz: number, maxBodies: number): void
|
|
29
|
+
/** halfExtents = half the full box size on each axis. Returns a bodyId (0 = failure). */
|
|
30
|
+
physicsCreateBox(entityId: number, hx: number, hy: number, hz: number, motionType: MotionType, mass: number): number
|
|
31
|
+
physicsCreateSphere(entityId: number, radius: number, motionType: MotionType, mass: number): number
|
|
32
|
+
physicsCreateCylinder(entityId: number, halfHeight: number, radius: number, motionType: MotionType, mass: number): number
|
|
33
|
+
physicsSetLinearVelocity(bodyId: number, x: number, y: number, z: number): void
|
|
34
|
+
/** Writes the body's current linear velocity (3 floats) into `out`. */
|
|
35
|
+
physicsGetLinearVelocity(bodyId: number, out: Float32Array): void
|
|
36
|
+
physicsApplyImpulse(bodyId: number, x: number, y: number, z: number): void
|
|
37
|
+
/** Teleport / drive a kinematic or dynamic body. */
|
|
38
|
+
physicsSetBodyPosition(bodyId: number, x: number, y: number, z: number): void
|
|
39
|
+
physicsRemoveBody(bodyId: number): void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Minimal viewer surface this module needs (avoids importing the whole createViewer type).
|
|
43
|
+
type SceneViewer = PhysicsApi & {
|
|
44
|
+
createEntity(): number
|
|
45
|
+
addEntityToScene(sceneId: number, entityId: number): void
|
|
46
|
+
setPosition(entityId: number, x: number, y: number, z: number): void
|
|
47
|
+
setMesh(entityId: number, materialId: number, vertices: Float32Array, normals: Float32Array, indices: Uint16Array, uv: Float32Array, meshType: number): void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Geometry generators (so the rendered shape matches the collider exactly).
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export type MeshData = { vertices: Float32Array; normals: Float32Array; indices: Uint16Array; uv: Float32Array }
|
|
55
|
+
|
|
56
|
+
/** Axis-aligned box with half-extents (hx,hy,hz). 24 verts (hard normals), 36 indices. */
|
|
57
|
+
export function makeBoxMesh(hx: number, hy: number, hz: number): MeshData {
|
|
58
|
+
// 6 faces × 4 verts. Each face: { normal, 4 corners (ccw) }.
|
|
59
|
+
const faces: { n: [number, number, number]; c: [number, number, number][] }[] = [
|
|
60
|
+
{ n: [0, 0, 1], c: [[-hx, -hy, hz], [hx, -hy, hz], [hx, hy, hz], [-hx, hy, hz]] }, // +Z
|
|
61
|
+
{ n: [0, 0, -1], c: [[hx, -hy, -hz], [-hx, -hy, -hz], [-hx, hy, -hz], [hx, hy, -hz]] }, // -Z
|
|
62
|
+
{ n: [1, 0, 0], c: [[hx, -hy, hz], [hx, -hy, -hz], [hx, hy, -hz], [hx, hy, hz]] }, // +X
|
|
63
|
+
{ n: [-1, 0, 0], c: [[-hx, -hy, -hz], [-hx, -hy, hz], [-hx, hy, hz], [-hx, hy, -hz]] }, // -X
|
|
64
|
+
{ n: [0, 1, 0], c: [[-hx, hy, hz], [hx, hy, hz], [hx, hy, -hz], [-hx, hy, -hz]] }, // +Y
|
|
65
|
+
{ n: [0, -1, 0], c: [[-hx, -hy, -hz], [hx, -hy, -hz], [hx, -hy, hz], [-hx, -hy, hz]] }, // -Y
|
|
66
|
+
]
|
|
67
|
+
const vertices: number[] = [], normals: number[] = [], uv: number[] = [], indices: number[] = []
|
|
68
|
+
const faceUv = [[0, 0], [1, 0], [1, 1], [0, 1]]
|
|
69
|
+
faces.forEach((f, fi) => {
|
|
70
|
+
const base = fi * 4
|
|
71
|
+
f.c.forEach((p, i) => { vertices.push(p[0], p[1], p[2]); normals.push(f.n[0], f.n[1], f.n[2]); uv.push(faceUv[i][0], faceUv[i][1]) })
|
|
72
|
+
indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
|
|
73
|
+
})
|
|
74
|
+
return { vertices: new Float32Array(vertices), normals: new Float32Array(normals), indices: new Uint16Array(indices), uv: new Float32Array(uv) }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** UV sphere of the given radius. */
|
|
78
|
+
export function makeSphereMesh(radius: number, segments = 16, rings = 12): MeshData {
|
|
79
|
+
const vertices: number[] = [], normals: number[] = [], uv: number[] = [], indices: number[] = []
|
|
80
|
+
for (let y = 0; y <= rings; y++) {
|
|
81
|
+
const v = y / rings, theta = v * Math.PI
|
|
82
|
+
for (let x = 0; x <= segments; x++) {
|
|
83
|
+
const u = x / segments, phi = u * Math.PI * 2
|
|
84
|
+
const nx = Math.sin(theta) * Math.cos(phi), ny = Math.cos(theta), nz = Math.sin(theta) * Math.sin(phi)
|
|
85
|
+
vertices.push(nx * radius, ny * radius, nz * radius); normals.push(nx, ny, nz); uv.push(u, v)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const stride = segments + 1
|
|
89
|
+
for (let y = 0; y < rings; y++) for (let x = 0; x < segments; x++) {
|
|
90
|
+
const a = y * stride + x, b = a + stride
|
|
91
|
+
indices.push(a, b, a + 1, a + 1, b, b + 1)
|
|
92
|
+
}
|
|
93
|
+
return { vertices: new Float32Array(vertices), normals: new Float32Array(normals), indices: new Uint16Array(indices), uv: new Float32Array(uv) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Test scene — ground + a scatter of dynamic boxes & spheres that fall, collide and settle.
|
|
98
|
+
// `materialId` is a material-instance id the caller already created (material creation is
|
|
99
|
+
// app-specific). Returns the created entity ids (e.g. to remove them later).
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
export function createPhysicsTestScene(viewer: SceneViewer, sceneId: number, materialId: number): number[] {
|
|
102
|
+
if (!viewer.physicsHasSupport()) {
|
|
103
|
+
console.warn("[physics] creator-gl was built without physics (CREATOR_GL_PHYSICS=OFF)")
|
|
104
|
+
return []
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
viewer.physicsConfigure(0, -9.81, 0, 0)
|
|
108
|
+
const created: number[] = []
|
|
109
|
+
|
|
110
|
+
const addEntity = (mesh: MeshData, x: number, y: number, z: number): number => {
|
|
111
|
+
const e = viewer.createEntity()
|
|
112
|
+
viewer.setMesh(e, materialId, mesh.vertices, mesh.normals, mesh.indices, mesh.uv, 0)
|
|
113
|
+
viewer.addEntityToScene(sceneId, e)
|
|
114
|
+
viewer.setPosition(e, x, y, z) // body reads its initial pose from this transform
|
|
115
|
+
created.push(e)
|
|
116
|
+
return e
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Static ground: a wide flat box, top surface at y = 0.
|
|
120
|
+
const groundHalf: [number, number, number] = [12, 0.5, 12]
|
|
121
|
+
const ground = addEntity(makeBoxMesh(...groundHalf), 0, -0.5, 0)
|
|
122
|
+
viewer.physicsCreateBox(ground, ...groundHalf, MotionType.Static, 0)
|
|
123
|
+
|
|
124
|
+
// Dynamic boxes — dropped at varied heights/offsets so they collide and stack.
|
|
125
|
+
const boxMesh = makeBoxMesh(0.5, 0.5, 0.5)
|
|
126
|
+
for (let i = 0; i < 6; i++) {
|
|
127
|
+
const e = addEntity(boxMesh, (i - 2.5) * 0.6, 4 + i * 1.2, (i % 2) * 0.3)
|
|
128
|
+
viewer.physicsCreateBox(e, 0.5, 0.5, 0.5, MotionType.Dynamic, 1.0)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Dynamic spheres — one nudged sideways with an initial velocity.
|
|
132
|
+
const sphereMesh = makeSphereMesh(0.5)
|
|
133
|
+
for (let i = 0; i < 3; i++) {
|
|
134
|
+
const e = addEntity(sphereMesh, 2 + i * 0.4, 6 + i * 1.0, -1)
|
|
135
|
+
const body = viewer.physicsCreateSphere(e, 0.5, MotionType.Dynamic, 1.0)
|
|
136
|
+
if (i === 0) viewer.physicsSetLinearVelocity(body, -2, 0, 0)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return created
|
|
140
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Pre-flight GLB validator — runs in the browser and in Bun (no Node/DOM APIs, no deps).
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: the creator-gl wasm is shipped WITHOUT emscripten assertions, so when a
|
|
4
|
+
// model violates a filament hard-limit the C++ code calls abort() with no usable message
|
|
5
|
+
// (e.g. "bone count > 256", or a missing texture-decoder for webp). Validate the bytes here,
|
|
6
|
+
// BEFORE handing them to module._loadGlb(), and reject with a clear, actionable error instead.
|
|
7
|
+
//
|
|
8
|
+
// Mirrors the exact constraints filament/gltfio enforce at load time:
|
|
9
|
+
// - filament CONFIG_MAX_BONE_COUNT = 256 (skin.joints per renderable)
|
|
10
|
+
// - filament CONFIG_MAX_MORPH_TARGET_COUNT = 256 (morph targets per primitive)
|
|
11
|
+
// - gltfio texture providers registered in glb.cpp: image/png, image/jpeg, image/ktx2 only
|
|
12
|
+
// (webp / EXT_texture_webp has no provider -> load fails).
|
|
13
|
+
|
|
14
|
+
// ---- filament limits (keep in sync with EngineEnums.h) ----------------------------------------
|
|
15
|
+
export const MAX_BONE_COUNT = 256
|
|
16
|
+
export const MAX_MORPH_TARGET_COUNT = 256
|
|
17
|
+
|
|
18
|
+
// Texture formats with a provider registered in creator-gl (see packages/creator-gl/src/glb.cpp).
|
|
19
|
+
export const SUPPORTED_IMAGE_FORMATS = ['png', 'jpeg', 'ktx2'] as const
|
|
20
|
+
export type ImageFormat = (typeof SUPPORTED_IMAGE_FORMATS)[number] | 'webp' | 'unknown'
|
|
21
|
+
|
|
22
|
+
export interface GlbIssue {
|
|
23
|
+
level: 'error' | 'warning'
|
|
24
|
+
code: 'CONTAINER' | 'BONE_COUNT' | 'MORPH_COUNT' | 'TEXTURE_FORMAT'
|
|
25
|
+
message: string
|
|
26
|
+
detail?: Record<string, unknown>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GlbValidationResult {
|
|
30
|
+
ok: boolean
|
|
31
|
+
errors: GlbIssue[]
|
|
32
|
+
warnings: GlbIssue[]
|
|
33
|
+
summary: {
|
|
34
|
+
skins: number
|
|
35
|
+
maxJointCount: number
|
|
36
|
+
images: number
|
|
37
|
+
formats: Record<string, number>
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const GLB_MAGIC = 0x46546c67 // "glTF"
|
|
42
|
+
const CHUNK_JSON = 0x4e4f534a // "JSON"
|
|
43
|
+
const CHUNK_BIN = 0x004e4942 // "BIN\0"
|
|
44
|
+
|
|
45
|
+
class GlbParseError extends Error {}
|
|
46
|
+
|
|
47
|
+
export interface ParsedGlb {
|
|
48
|
+
json: any
|
|
49
|
+
bin: Uint8Array | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toBytes(data: ArrayBuffer | Uint8Array): Uint8Array {
|
|
53
|
+
return data instanceof Uint8Array ? data : new Uint8Array(data)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Container parse only (JSON chunk + BIN chunk); also accepts raw .gltf JSON bytes.
|
|
57
|
+
* Exported for reuse (backend asset-metadata extraction) — throws GlbParseError on bad input. */
|
|
58
|
+
export function parseGlb(bytes: Uint8Array): ParsedGlb {
|
|
59
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
60
|
+
|
|
61
|
+
// Accept a raw .gltf (JSON) too — useful for editor/CI paths. No BIN chunk in that case,
|
|
62
|
+
// so byte-sniffing of embedded images is skipped (uri/mimeType still validated).
|
|
63
|
+
if (bytes.byteLength < 12 || dv.getUint32(0, true) !== GLB_MAGIC) {
|
|
64
|
+
try {
|
|
65
|
+
return { json: JSON.parse(new TextDecoder().decode(bytes)), bin: null }
|
|
66
|
+
} catch {
|
|
67
|
+
throw new GlbParseError('Not a GLB (bad magic) and not parseable as glTF JSON')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const version = dv.getUint32(4, true)
|
|
72
|
+
if (version !== 2) throw new GlbParseError(`Unsupported GLB container version ${version} (expected 2)`)
|
|
73
|
+
|
|
74
|
+
let offset = 12
|
|
75
|
+
let json: any = null
|
|
76
|
+
let bin: Uint8Array | null = null
|
|
77
|
+
while (offset + 8 <= bytes.byteLength) {
|
|
78
|
+
const chunkLen = dv.getUint32(offset, true)
|
|
79
|
+
const chunkType = dv.getUint32(offset + 4, true)
|
|
80
|
+
const start = offset + 8
|
|
81
|
+
const end = start + chunkLen
|
|
82
|
+
if (end > bytes.byteLength) throw new GlbParseError(`Chunk at ${offset} overruns the file`)
|
|
83
|
+
if (chunkType === CHUNK_JSON) {
|
|
84
|
+
json = JSON.parse(new TextDecoder().decode(bytes.subarray(start, end)))
|
|
85
|
+
} else if (chunkType === CHUNK_BIN) {
|
|
86
|
+
bin = bytes.subarray(start, end)
|
|
87
|
+
}
|
|
88
|
+
offset = end // chunkLength already includes the 4-byte alignment padding (glTF spec)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!json) throw new GlbParseError('GLB has no JSON chunk')
|
|
92
|
+
return { json, bin }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- image format detection -------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
function mimeToFormat(mime: string): ImageFormat {
|
|
98
|
+
switch (mime) {
|
|
99
|
+
case 'image/png': return 'png'
|
|
100
|
+
case 'image/jpeg': return 'jpeg'
|
|
101
|
+
case 'image/ktx2': return 'ktx2'
|
|
102
|
+
case 'image/webp': return 'webp'
|
|
103
|
+
default: return 'unknown'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function extToFormat(ext: string): ImageFormat {
|
|
108
|
+
switch (ext) {
|
|
109
|
+
case 'png': return 'png'
|
|
110
|
+
case 'jpg':
|
|
111
|
+
case 'jpeg': return 'jpeg'
|
|
112
|
+
case 'ktx2': return 'ktx2'
|
|
113
|
+
case 'webp': return 'webp'
|
|
114
|
+
default: return 'unknown'
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sniffMagic(b: Uint8Array): ImageFormat {
|
|
119
|
+
if (b.length >= 4 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'png'
|
|
120
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpeg'
|
|
121
|
+
// RIFF....WEBP
|
|
122
|
+
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
|
|
123
|
+
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return 'webp'
|
|
124
|
+
// KTX2: 0xAB 'KTX 20' 0xBB 0x0D 0x0A 0x1A 0x0A
|
|
125
|
+
if (b.length >= 8 && b[0] === 0xab && b[1] === 0x4b && b[2] === 0x54 && b[3] === 0x58 &&
|
|
126
|
+
b[4] === 0x20 && b[5] === 0x32 && b[6] === 0x30 && b[7] === 0xbb) return 'ktx2'
|
|
127
|
+
return 'unknown'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function detectFormat(image: any, json: any, bin: Uint8Array | null): { format: ImageFormat; via: string } {
|
|
131
|
+
if (typeof image?.uri === 'string') {
|
|
132
|
+
const uri: string = image.uri
|
|
133
|
+
if (uri.startsWith('data:')) {
|
|
134
|
+
const m = /^data:([^;,]+)[;,]/.exec(uri)
|
|
135
|
+
const mime = (m?.[1] ?? '').toLowerCase()
|
|
136
|
+
return { format: mimeToFormat(mime), via: `data-uri "${mime || '?'}"` }
|
|
137
|
+
}
|
|
138
|
+
const ext = (uri.split('?')[0].split('#')[0].split('.').pop() ?? '').toLowerCase()
|
|
139
|
+
return { format: extToFormat(ext), via: `uri ".${ext}"` }
|
|
140
|
+
}
|
|
141
|
+
if (typeof image?.mimeType === 'string') {
|
|
142
|
+
const mime = image.mimeType.toLowerCase()
|
|
143
|
+
return { format: mimeToFormat(mime), via: `mimeType "${image.mimeType}"` }
|
|
144
|
+
}
|
|
145
|
+
if (typeof image?.bufferView === 'number' && bin) {
|
|
146
|
+
const bv = json.bufferViews?.[image.bufferView]
|
|
147
|
+
// embedded images live in buffer 0 (the BIN chunk) for GLB
|
|
148
|
+
if (bv && (bv.buffer ?? 0) === 0) {
|
|
149
|
+
const start = bv.byteOffset ?? 0
|
|
150
|
+
return { format: sniffMagic(bin.subarray(start, start + 16)), via: 'magic-bytes' }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { format: 'unknown', via: 'undetermined' }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Image indices actually referenced by a texture (standard source + ktx2/webp extension sources).
|
|
157
|
+
function collectUsedImages(json: any): Set<number> {
|
|
158
|
+
const used = new Set<number>()
|
|
159
|
+
for (const t of json.textures ?? []) {
|
|
160
|
+
if (typeof t.source === 'number') used.add(t.source)
|
|
161
|
+
const basisu = t.extensions?.KHR_texture_basisu
|
|
162
|
+
if (typeof basisu?.source === 'number') used.add(basisu.source)
|
|
163
|
+
const webp = t.extensions?.EXT_texture_webp
|
|
164
|
+
if (typeof webp?.source === 'number') used.add(webp.source)
|
|
165
|
+
}
|
|
166
|
+
return used
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- main entry -------------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
export function validateGlb(data: ArrayBuffer | Uint8Array): GlbValidationResult {
|
|
172
|
+
const errors: GlbIssue[] = []
|
|
173
|
+
const warnings: GlbIssue[] = []
|
|
174
|
+
const formats: Record<string, number> = {}
|
|
175
|
+
|
|
176
|
+
let parsed: ParsedGlb
|
|
177
|
+
try {
|
|
178
|
+
parsed = parseGlb(toBytes(data))
|
|
179
|
+
} catch (e) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
errors: [{ level: 'error', code: 'CONTAINER', message: (e as Error).message }],
|
|
183
|
+
warnings: [],
|
|
184
|
+
summary: { skins: 0, maxJointCount: 0, images: 0, formats: {} },
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const { json, bin } = parsed
|
|
188
|
+
|
|
189
|
+
// --- skins: joints per skin == filament mSkinningBoneCount, capped at CONFIG_MAX_BONE_COUNT ---
|
|
190
|
+
const skins: any[] = json.skins ?? []
|
|
191
|
+
let maxJointCount = 0
|
|
192
|
+
for (let i = 0; i < skins.length; i++) {
|
|
193
|
+
const n = skins[i]?.joints?.length ?? 0
|
|
194
|
+
maxJointCount = Math.max(maxJointCount, n)
|
|
195
|
+
if (n > MAX_BONE_COUNT) {
|
|
196
|
+
errors.push({
|
|
197
|
+
level: 'error',
|
|
198
|
+
code: 'BONE_COUNT',
|
|
199
|
+
message: `skin[${i}]${skins[i].name ? ` "${skins[i].name}"` : ''} has ${n} joints, exceeds filament limit of ${MAX_BONE_COUNT}`,
|
|
200
|
+
detail: { skin: i, name: skins[i].name, joints: n, limit: MAX_BONE_COUNT },
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- morph targets per primitive, capped at CONFIG_MAX_MORPH_TARGET_COUNT --------------------
|
|
206
|
+
for (let mi = 0; mi < (json.meshes?.length ?? 0); mi++) {
|
|
207
|
+
const mesh = json.meshes[mi]
|
|
208
|
+
for (let pi = 0; pi < (mesh.primitives?.length ?? 0); pi++) {
|
|
209
|
+
const n = mesh.primitives[pi]?.targets?.length ?? 0
|
|
210
|
+
if (n > MAX_MORPH_TARGET_COUNT) {
|
|
211
|
+
errors.push({
|
|
212
|
+
level: 'error',
|
|
213
|
+
code: 'MORPH_COUNT',
|
|
214
|
+
message: `mesh[${mi}]${mesh.name ? ` "${mesh.name}"` : ''} primitive ${pi} has ${n} morph targets, exceeds filament limit of ${MAX_MORPH_TARGET_COUNT}`,
|
|
215
|
+
detail: { mesh: mi, primitive: pi, targets: n, limit: MAX_MORPH_TARGET_COUNT },
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- textures: every image must decode with a registered provider (png/jpeg/ktx2) -----------
|
|
222
|
+
const images: any[] = json.images ?? []
|
|
223
|
+
const used = collectUsedImages(json)
|
|
224
|
+
for (let i = 0; i < images.length; i++) {
|
|
225
|
+
const { format, via } = detectFormat(images[i], json, bin)
|
|
226
|
+
formats[format] = (formats[format] ?? 0) + 1
|
|
227
|
+
const supported = (SUPPORTED_IMAGE_FORMATS as readonly string[]).includes(format)
|
|
228
|
+
if (supported) continue
|
|
229
|
+
|
|
230
|
+
const referenced = used.has(i)
|
|
231
|
+
const issue: GlbIssue = {
|
|
232
|
+
level: referenced ? 'error' : 'warning',
|
|
233
|
+
code: 'TEXTURE_FORMAT',
|
|
234
|
+
message:
|
|
235
|
+
`image[${i}]${images[i].name ? ` "${images[i].name}"` : ''} is ${format.toUpperCase()} ` +
|
|
236
|
+
`(${via})${referenced ? '' : ' [unreferenced]'} — only ${SUPPORTED_IMAGE_FORMATS.join('/')} are supported`,
|
|
237
|
+
detail: { image: i, format, via, referenced },
|
|
238
|
+
}
|
|
239
|
+
;(referenced ? errors : warnings).push(issue)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
ok: errors.length === 0,
|
|
244
|
+
errors,
|
|
245
|
+
warnings,
|
|
246
|
+
summary: { skins: skins.length, maxJointCount, images: images.length, formats },
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Human-readable report (string, so it works identically in browser console and Bun stdout).
|
|
251
|
+
export function formatReport(result: GlbValidationResult, label = 'GLB'): string {
|
|
252
|
+
const lines: string[] = []
|
|
253
|
+
const s = result.summary
|
|
254
|
+
lines.push(`${result.ok ? '✅ VALID' : '❌ INVALID'} ${label}`)
|
|
255
|
+
lines.push(
|
|
256
|
+
` skins=${s.skins} (max joints=${s.maxJointCount}/${MAX_BONE_COUNT}) ` +
|
|
257
|
+
`images=${s.images} [${Object.entries(s.formats).map(([k, v]) => `${k}:${v}`).join(', ') || 'none'}]`,
|
|
258
|
+
)
|
|
259
|
+
for (const e of result.errors) lines.push(` ✗ ${e.code}: ${e.message}`)
|
|
260
|
+
for (const w of result.warnings) lines.push(` ⚠ ${w.code}: ${w.message}`)
|
|
261
|
+
return lines.join('\n')
|
|
262
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Lazy crash symbolizer for the creator-gl wasm. The shipped wasm has no name section
|
|
2
|
+
// (see creator-gl/CMakeLists.txt: --emit-symbol-map), so a runtime trap surfaces as
|
|
3
|
+
// "RuntimeError: unreachable at wasm-function[1234]" with anonymous frames. The build deploys
|
|
4
|
+
// creator-gl.js.symbols (function index -> C++ name) next to the wasm; this module fetches that
|
|
5
|
+
// map ONLY after a crash has already happened and prints a decoded trace to the console. Happy
|
|
6
|
+
// path cost: zero — nothing is fetched or parsed until the first wasm frame appears in an error.
|
|
7
|
+
//
|
|
8
|
+
// The map is only valid for creator-gl.wasm. Frames from another wasm module (creator-ui,
|
|
9
|
+
// creator-2d) carry their own URL in the stack line and are left untouched; frames with no URL
|
|
10
|
+
// at all (Safari's "[wasm code]") are decoded anyway, since a creator-gl trap is by far the
|
|
11
|
+
// likeliest source, and marked with "?" so a misattributed name isn't taken as gospel.
|
|
12
|
+
|
|
13
|
+
import symbolsUrl from './creator-gl.js.symbols?url'
|
|
14
|
+
|
|
15
|
+
const WASM_FRAME = /wasm-function\[(\d+)\]/g
|
|
16
|
+
|
|
17
|
+
// index -> raw (still escaped) name. Parsed once, kept raw — escape decoding runs only on the
|
|
18
|
+
// handful of frames actually printed, not on all ~40k entries.
|
|
19
|
+
let mapPromise: Promise<Map<number, string> | null> | null = null
|
|
20
|
+
|
|
21
|
+
const loadSymbolMap = (): Promise<Map<number, string> | null> => {
|
|
22
|
+
if (!mapPromise) {
|
|
23
|
+
mapPromise = fetch(symbolsUrl)
|
|
24
|
+
.then(resp => {
|
|
25
|
+
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`)
|
|
26
|
+
return resp.text()
|
|
27
|
+
})
|
|
28
|
+
.then(text => {
|
|
29
|
+
const map = new Map<number, string>()
|
|
30
|
+
for (const line of text.split('\n')) {
|
|
31
|
+
const sep = line.indexOf(':')
|
|
32
|
+
if (sep > 0) map.set(Number(line.slice(0, sep)), line.slice(sep + 1))
|
|
33
|
+
}
|
|
34
|
+
return map
|
|
35
|
+
})
|
|
36
|
+
.catch(e => {
|
|
37
|
+
console.warn('[creator-gl] creator-gl.js.symbols unavailable, wasm frames stay anonymous:', e)
|
|
38
|
+
return null
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
return mapPromise
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The symbol map escapes non-identifier chars as \XX hex (emscripten format): \20 = space,
|
|
45
|
+
// \2c = comma, \28\29 = parens.
|
|
46
|
+
const decodeName = (raw: string): string =>
|
|
47
|
+
raw.replace(/\\([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
48
|
+
|
|
49
|
+
// Which wasm module does this stack line reference? Matches "creator-ui.wasm:",
|
|
50
|
+
// "creator-gl-B3xYz9.wasm)" etc., but not the ".wasm-function[N]" token itself (the lookahead
|
|
51
|
+
// rejects the "-f"). The name is matched loosely ("creator-gl" prefix) because Vite hashes prod
|
|
52
|
+
// asset filenames. null = no module URL in the line (Safari's "[wasm code]").
|
|
53
|
+
const MODULE_REF = /([\w.-]+)\.wasm(?=[^\w-]|$)/g
|
|
54
|
+
const lineModule = (line: string): 'ours' | 'other' | null => {
|
|
55
|
+
const refs = [...line.matchAll(MODULE_REF)].map(m => m[1]!)
|
|
56
|
+
if (refs.length === 0) return null
|
|
57
|
+
return refs.some(r => r.includes('creator-gl')) ? 'ours' : 'other'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Decode every creator-gl `wasm-function[N]` frame in a stack trace. Resolves to null when the
|
|
62
|
+
* stack has no wasm frames (or the symbol map can't be fetched) — callers use that to stay
|
|
63
|
+
* silent for ordinary JS errors.
|
|
64
|
+
*/
|
|
65
|
+
export const symbolizeWasmStack = async (stack: string): Promise<string | null> => {
|
|
66
|
+
WASM_FRAME.lastIndex = 0
|
|
67
|
+
if (!WASM_FRAME.test(stack)) return null
|
|
68
|
+
const map = await loadSymbolMap()
|
|
69
|
+
if (!map) return null
|
|
70
|
+
|
|
71
|
+
let decodedAny = false
|
|
72
|
+
const lines = stack.split('\n').map(line => {
|
|
73
|
+
WASM_FRAME.lastIndex = 0
|
|
74
|
+
if (!WASM_FRAME.test(line)) return line
|
|
75
|
+
const mod = lineModule(line)
|
|
76
|
+
// A frame naming a different wasm module belongs to that module's map, not ours. URL-less
|
|
77
|
+
// frames are decoded anyway (a creator-gl trap is by far the likeliest source) but marked "?".
|
|
78
|
+
if (mod === 'other') return line
|
|
79
|
+
return line.replace(WASM_FRAME, (frame, index) => {
|
|
80
|
+
const raw = map.get(Number(index))
|
|
81
|
+
if (raw === undefined) return frame
|
|
82
|
+
decodedAny = true
|
|
83
|
+
return `${mod === null ? '?' : ''}${decodeName(raw)} (${frame})`
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
return decodedAny ? lines.join('\n') : null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The display-friendly cut of symbolizeWasmStack for UI overlays: just the decoded C++ names of
|
|
91
|
+
* the creator-gl frames, top-first — no URLs, no JS glue frames. Empty when the stack has no
|
|
92
|
+
* decodable creator-gl frames (or the map can't be fetched).
|
|
93
|
+
*/
|
|
94
|
+
export const symbolizeWasmFrames = async (stack: string): Promise<string[]> => {
|
|
95
|
+
WASM_FRAME.lastIndex = 0
|
|
96
|
+
if (!WASM_FRAME.test(stack)) return []
|
|
97
|
+
const map = await loadSymbolMap()
|
|
98
|
+
if (!map) return []
|
|
99
|
+
const names: string[] = []
|
|
100
|
+
for (const line of stack.split('\n')) {
|
|
101
|
+
if (lineModule(line) === 'other') continue
|
|
102
|
+
for (const m of line.matchAll(WASM_FRAME)) {
|
|
103
|
+
const raw = map.get(Number(m[1]))
|
|
104
|
+
// libc++'s internal namespace is pure noise in a UI overlay.
|
|
105
|
+
if (raw !== undefined) names.push(decodeName(raw).replaceAll('std::__2::', 'std::'))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return names
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Fire-and-forget: print a decoded trace for `err` if it contains creator-gl wasm frames. */
|
|
112
|
+
export const symbolizeWasmError = (err: unknown): void => {
|
|
113
|
+
const stack = (err as { stack?: unknown } | null)?.stack
|
|
114
|
+
if (typeof stack !== 'string') return
|
|
115
|
+
symbolizeWasmStack(stack).then(decoded => {
|
|
116
|
+
if (decoded) console.error('[creator-gl] decoded wasm stack (via creator-gl.js.symbols):\n' + decoded)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let installed = false
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Catch wasm traps that escape to the top (the render rAF loop has no try/catch — an engine
|
|
124
|
+
* crash lands on the global 'error' event) and follow the browser's own report with a decoded
|
|
125
|
+
* trace. Errors swallowed by app-level try/catch never get here — those paths call
|
|
126
|
+
* symbolizeWasmError directly (see createGlb/loadGlb).
|
|
127
|
+
*/
|
|
128
|
+
export const installWasmCrashSymbolizer = (): void => {
|
|
129
|
+
if (installed) return
|
|
130
|
+
installed = true
|
|
131
|
+
addEventListener('error', e => symbolizeWasmError((e as ErrorEvent).error))
|
|
132
|
+
addEventListener('unhandledrejection', e => symbolizeWasmError((e as PromiseRejectionEvent).reason))
|
|
133
|
+
}
|