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.
Files changed (103) hide show
  1. package/README.md +5 -0
  2. package/dist-embed/VERSION.json +5 -0
  3. package/dist-embed/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
  4. package/dist-embed/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  5. package/dist-embed/assets/basis-C64VHDVD.js +1 -0
  6. package/dist-embed/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  7. package/dist-embed/assets/createViewer2d-D12Wy3VF.js +1 -0
  8. package/dist-embed/assets/createViewerLite-CP-WHGLg.js +707 -0
  9. package/dist-embed/assets/creator-gl-BF2CQzeI.wasm +0 -0
  10. package/dist-embed/assets/creator-gl.js-B_0GrQRe.symbols +5379 -0
  11. package/dist-embed/assets/creator-ui-BX5Fvnbt.js +1 -0
  12. package/dist-embed/assets/creator-ui-CzKHLH2Q.wasm +0 -0
  13. package/dist-embed/assets/creator2d-BtQ-1_B0.wasm +0 -0
  14. package/dist-embed/assets/draco-BiISTFcR.js +118 -0
  15. package/dist-embed/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  16. package/dist-embed/assets/host-CWKB1WDd.js +162 -0
  17. package/dist-embed/assets/index-CIK8PnUA.js +2 -0
  18. package/dist-embed/assets/lit-DUE9rhY9.filamat +0 -0
  19. package/dist-embed/assets/loadCreatorUiNode-UAdhjRTd.js +1 -0
  20. package/dist-embed/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
  21. package/dist-embed/assets/particles-DxYDmI1O.filamat +0 -0
  22. package/dist-embed/assets/particles-quad-DwYo9yCK.filamat +0 -0
  23. package/dist-embed/assets/shadow-C9xJpqlY.filamat +0 -0
  24. package/dist-embed/assets/uberarchive-uXjRYa9p.bin +0 -0
  25. package/dist-embed/assets/unlit-pe4tFY5b.filamat +0 -0
  26. package/dist-embed/assets/unlit-transparent-D9OyaOBR.filamat +0 -0
  27. package/dist-embed/assets/video-eedvhMvS.filamat +0 -0
  28. package/dist-embed/assets/worker-Caf-yYEI.js +2 -0
  29. package/dist-embed/embed.html +48 -0
  30. package/dist-embed/embed.js +187 -0
  31. package/embed.html +48 -0
  32. package/package.json +35 -0
  33. package/src/assets/banner.png +0 -0
  34. package/src/assets/image2.png +0 -0
  35. package/src/assets/landing.mp4 +0 -0
  36. package/src/assets/neutral_ibl128.ktx +0 -0
  37. package/src/assets/neutral_ibl256.ktx +0 -0
  38. package/src/assets/output.mp4 +0 -0
  39. package/src/assets/output2.mp4 +0 -0
  40. package/src/assets/texture.png +0 -0
  41. package/src/assets/vue.svg +1 -0
  42. package/src/canvas/createCanvasHost.ts +2 -0
  43. package/src/createViewer.ts +103 -0
  44. package/src/embed.ts +515 -0
  45. package/src/host.ts +428 -0
  46. package/src/lite-test.ts +160 -0
  47. package/src/main.ts +77 -0
  48. package/src/sourcemapError.ts +2 -0
  49. package/src/ui/CuiProps.gen.ts +103 -0
  50. package/src/ui/UIScreen.ts +486 -0
  51. package/src/ui/createBareUI.ts +32 -0
  52. package/src/ui/createMeasureSystem.ts +220 -0
  53. package/src/ui/createNodeElement.ts +77 -0
  54. package/src/ui/createPagerSystem.ts +345 -0
  55. package/src/ui/createTouchSystem.ts +2 -0
  56. package/src/ui/createUI.ts +806 -0
  57. package/src/ui/creator-ui.js +14 -0
  58. package/src/ui/creator-ui.wasm +0 -0
  59. package/src/ui/defaultStyle.css +176 -0
  60. package/src/ui/layers.ts +9 -0
  61. package/src/ui/mountCanvasUI.ts +213 -0
  62. package/src/ui/nativeViewSystem.ts +91 -0
  63. package/src/ui/presentPlayer.ts +156 -0
  64. package/src/ui/props-apply.ts +403 -0
  65. package/src/ui/router.ts +2 -0
  66. package/src/ui/safeAreaSpec.ts +3 -0
  67. package/src/ui/shells.ts +37 -0
  68. package/src/ui/toasts.ts +2 -0
  69. package/src/ui/transitions.ts +250 -0
  70. package/src/ui/ui.d.ts +78 -0
  71. package/src/ui/vListSystem.ts +212 -0
  72. package/src/ui/viewScale.ts +2 -0
  73. package/src/utils/animate.ts +2 -0
  74. package/src/utils/createUtils.ts +2 -0
  75. package/src/utils/fetch.ts +3 -0
  76. package/src/utils/fonts.ts +2 -0
  77. package/src/utils/globals.ts +2 -0
  78. package/src/utils/imageNode.ts +260 -0
  79. package/src/utils/openFilePicker.ts +2 -0
  80. package/src/utils/svg.ts +2 -0
  81. package/src/utils/videoNode.ts +2 -0
  82. package/src/utils/ws.ts +2 -0
  83. package/src/viewer/createViewer.ts +993 -0
  84. package/src/viewer/creator-gl.js +14 -0
  85. package/src/viewer/creator-gl.js.symbols +5379 -0
  86. package/src/viewer/creator-gl.wasm +0 -0
  87. package/src/viewer/lit.filamat +0 -0
  88. package/src/viewer/mediaplayer.ts +3 -0
  89. package/src/viewer/particles-quad.filamat +0 -0
  90. package/src/viewer/particles.filamat +0 -0
  91. package/src/viewer/physics.ts +140 -0
  92. package/src/viewer/shadow.filamat +0 -0
  93. package/src/viewer/uberarchive.bin +0 -0
  94. package/src/viewer/unlit-transparent.filamat +0 -0
  95. package/src/viewer/unlit.filamat +0 -0
  96. package/src/viewer/validateGlb.ts +262 -0
  97. package/src/viewer/video.filamat +0 -0
  98. package/src/viewer/wasmCrashSymbolizer.ts +133 -0
  99. package/src/viewer2d/createViewer2d.ts +438 -0
  100. package/src/viewer2d/creator2d.js +14 -0
  101. package/src/viewer2d/creator2d.wasm +0 -0
  102. package/src/viewer2d/sprite2d.ts +80 -0
  103. package/src/vite-env.d.ts +1 -0
Binary file
Binary file
@@ -0,0 +1,3 @@
1
+ // Moved to viewer-lite (shared media-player registry); re-export shim so viewer and the
2
+ // lite engine address the SAME player slots.
3
+ export * from "lecodes-viewer-lite/src/utils/mediaplayer"
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
@@ -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
+ }