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
package/src/host.ts ADDED
@@ -0,0 +1,428 @@
1
+ // The viewer host: engine bring-up and bundle execution, decoupled.
2
+ //
3
+ // `createHost` brings up the engines/bridges for a root element once; `host.run(code)` executes a
4
+ // compiled bundle against them and can be called repeatedly — each run gets its own timer scope and
5
+ // error attribution, so the scene editor can re-run a scene bundle without re-initializing Filament
6
+ // (and without stopping the editor-harness bundle running in the same host).
7
+ //
8
+ // `mountCreator` (createViewer.ts) is the one-shot convenience wrapper: header sniffing + one host +
9
+ // one run, returning the shape the Simulator and embed always used.
10
+
11
+ import { createViewer, type ViewerOptions } from './viewer/createViewer'
12
+ import { createCanvasHost } from './canvas/createCanvasHost'
13
+ import { putBuffer, getBuffer } from './utils/fetch'
14
+ import { createNavHostBridge } from 'lecodes-viewer-lite/src/gl/core/navBridge'
15
+ import { createInputHost } from "lecodes-viewer-lite/src/utils/input"
16
+ import { createUtils } from './utils/createUtils'
17
+ import { ensureFontsLoaded, loadBootFonts, type BootFont } from './utils/fonts'
18
+ import type { SafeAreaSpec } from './ui/safeAreaSpec'
19
+ import { createTimers } from './utils/globals'
20
+ import { viewScale } from './ui/viewScale'
21
+ import { extractInlineSourceMap, isNativeError, mapErrorStack, SourceMap, type MappedFrame, type PreviewError } from './sourcemapError'
22
+ import { symbolizeWasmFrames } from './viewer/wasmCrashSymbolizer'
23
+
24
+ export type CreatorMethods = Awaited<ReturnType<typeof createViewer>>
25
+
26
+ // A UI renderer factory: returns the `_creatorUI`-shaped object for a root element. The DOM renderer
27
+ // is `createUI`; `createCanvasUIRenderer` runs the canvas-ui package instead. The canvas host is
28
+ // threaded in so UIImage(canvas) can resolve a baked surface to an image.
29
+ export type UIFactory = (root: HTMLElement, canvasHost?: ReturnType<typeof createCanvasHost>) => Promise<any>
30
+
31
+ // Which engine(s) to bring up. `mountCreator` derives this from the compiler's header directives;
32
+ // callers that know their content (the scene editor is always pure 3D) pass it explicitly.
33
+ // gl — Filament 3D on one canvas
34
+ // gl-lite — the lightweight WebGL2 3D engine (viewer-lite, no Filament wasm) on one canvas
35
+ // 2d — creator-2d (sokol) on one canvas, 3D creator stubbed
36
+ // dual — both engines on separate canvases, openScene toggles visibility
37
+ // defer — 3D creator stubbed; the bundle brings the engine up via `createEngine()` (also used
38
+ // for UI-only projects, which simply never call it)
39
+ export type EngineMode = 'gl' | 'gl-lite' | '2d' | 'dual' | 'defer'
40
+
41
+ export type HostOptions = {
42
+ engine?: EngineMode
43
+ // UI renderer factory, or false for an engine-only host (no fonts preload, no `_creatorUI`).
44
+ ui?: UIFactory | false
45
+ // Override the measurement fonts to force-load before the first layout (default: the vendored
46
+ // variable Roboto — one face, all weights). Only meaningful when `ui` is set.
47
+ fonts?: string[]
48
+ // App-declared boot faces from the bundle's `// fonts:` header — loaded in parallel with the
49
+ // wasm/engine and awaited (≤2s budget) before the host returns. Only meaningful when `ui` is set.
50
+ bootFonts?: BootFont[]
51
+ preloads?: Set<string>
52
+ // Method overrides assigned onto the 3D creator (test/interception seam).
53
+ methods?: Partial<CreatorMethods>
54
+ // Wire window key listeners into `_creatorUtils.inputKey` (Input.key). Default true.
55
+ keyboard?: boolean
56
+ // Extra windows to also poll physical keys from, beyond the host's own `window`. The editor passes
57
+ // its parent window so the app sees keys before the user clicks into the (iframe) preview — the
58
+ // dual-window pattern the lite host already uses.
59
+ keyboardTargets?: Window[]
60
+ // The URL the app was opened with (app.launchUrl / the cold-start deep link). Warm links go
61
+ // through `_creatorUtils._emitAppEvent("url", url)`.
62
+ launchUrl?: string | null
63
+ // Device-emulation safe-area override (the Simulator's iOS/Android shells) — pins the insets
64
+ // per orientation and declares system-bar edges. Absent = the neutral web defaults.
65
+ safeArea?: SafeAreaSpec
66
+ // gl-lite only: clear to transparent instead of the skybox color (capture hosts — asset
67
+ // preview thumbnails composite over any page theme).
68
+ transparent?: boolean
69
+ // app.setOrientation sink: the embedder rotates its device frame ("landscape" | "portrait" |
70
+ // "auto"). Absent = best-effort screen.orientation.lock on the page (see createUtils).
71
+ onOrientation?: (mode: string) => void
72
+ }
73
+
74
+ export type RunOptions = {
75
+ // Called when this bundle throws, with the error mapped back to original source.
76
+ onError?: (error: PreviewError) => void
77
+ }
78
+
79
+ export type Run = {
80
+ // Whatever the bundle's final expression returned (bundles are IIFE-shaped scripts).
81
+ result: unknown
82
+ // Stop this run's timers/loops and stop attributing window errors to it. Engine state the bundle
83
+ // created (entities, scenes, screens) is NOT torn down here — that's the host's lifetime.
84
+ dispose(): void
85
+ }
86
+
87
+ type RunRecord = {
88
+ sourceMap: SourceMap | null
89
+ onError?: (error: PreviewError) => void
90
+ dispose(): void
91
+ }
92
+
93
+ export type Host = Awaited<ReturnType<typeof createHost>>
94
+
95
+ export const createHost = async (root: HTMLElement, options: HostOptions = {}) => {
96
+ const engineMode = options.engine ?? 'gl'
97
+ const preloads = options.preloads ?? new Set<string>()
98
+
99
+ // Logical (layout-px) size of the host root, and the resolution factor the engines render at.
100
+ // Under the simulator's CSS zoom the root's on-screen size is logical × zoom, so an engine's
101
+ // backing store must grow to real device pixels: factor = devicePixelRatio × viewScale (the visual
102
+ // scale recovered from the DOM). Recomputed every resize — DPR changes when a window moves between
103
+ // displays of different density. See docs/simulator-plan.md phase 3.
104
+ const logicalSize = (): [number, number] => [Math.max(1, root.clientWidth), Math.max(1, root.clientHeight)]
105
+ const resolutionFactor = (): number =>
106
+ (typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1) * viewScale(root)
107
+ // Size a backing canvas to device px (logical × factor) while pinning its CSS box to logical px, so
108
+ // CSS zoom scales it as vector geometry — never a resampled texture (the HiDPI + zoom crispness fix).
109
+ const sizeCanvas = (c: HTMLCanvasElement, w: number, h: number, factor: number) => {
110
+ c.width = Math.max(1, Math.round(w * factor))
111
+ c.height = Math.max(1, Math.round(h * factor))
112
+ c.style.width = w + "px"
113
+ c.style.height = h + "px"
114
+ }
115
+
116
+ // Start loading the measurement font in parallel with the wasm/engine; awaited before returning so
117
+ // the first run's layout measures text against the real font, not a fallback.
118
+ const fontsReady = options.ui ? ensureFontsLoaded(options.fonts) : undefined
119
+ // App-declared boot faces (`// fonts:` header): loaded alongside, awaited with a ≤2s budget —
120
+ // the boot hold that makes first paint use the app's font (a loser face swaps in late).
121
+ const bootFontsReady = options.ui ? loadBootFonts(options.bootFonts ?? []) : undefined
122
+
123
+ const canvas = document.createElement("canvas")
124
+ root.appendChild(canvas)
125
+ let extraCanvas: HTMLCanvasElement | undefined
126
+
127
+ let createEngine = () => {}
128
+
129
+ let _creator: CreatorMethods
130
+ // The creator-2d bridge the 2D SDK calls through. Undefined for pure-3D projects (the bundled 2D
131
+ // code only references it when `// 2d: enable` is set, so nothing touches it otherwise).
132
+ let _creator2d: any = undefined
133
+ // Core 2D canvas host (OffscreenCanvas). Shared by 2D/3D/UI; the engine wrappers read its baked
134
+ // surfaces to upload textures. Cheap to construct (no OffscreenCanvas until first draw/measure).
135
+ const canvasHost = createCanvasHost(putBuffer, getBuffer)
136
+ // Navigation meshes (docs/navmesh-plan.md): creator-nav's wasm behind `_creatorNav` (lazy).
137
+ const navBridge = createNavHostBridge(getBuffer)
138
+
139
+ if (engineMode === 'dual') {
140
+ // Bring up both engines on separate canvases; wrap openScene to toggle visibility.
141
+ const canvas2d = document.createElement("canvas")
142
+ root.appendChild(canvas2d)
143
+ extraCanvas = canvas2d
144
+ const [ lw, lh ] = logicalSize()
145
+ const factor = resolutionFactor()
146
+ for (const c of [ canvas, canvas2d ]) { sizeCanvas(c, lw, lh, factor); c.style.display = "none" }
147
+
148
+ const { createViewer2d } = await import("./viewer2d/createViewer2d")
149
+ _creator2d = await createViewer2d(canvas2d, {}, canvasHost)
150
+ _creator = await createViewer(canvas, { preloads, canvasHost })
151
+ if (options.methods) Object.assign(_creator, options.methods)
152
+
153
+ const show = (active: HTMLCanvasElement) => {
154
+ canvas.style.display = active === canvas ? "block" : "none"
155
+ canvas2d.style.display = active === canvas2d ? "block" : "none"
156
+ }
157
+ const open3d = _creator.openScene.bind(_creator)
158
+ _creator.openScene = (id: number) => { show(canvas); return open3d(id) }
159
+ const open2d = _creator2d.openScene.bind(_creator2d)
160
+ _creator2d.openScene = (id: number) => { show(canvas2d); return open2d(id) }
161
+ } else if (engineMode === '2d') {
162
+ const [ lw, lh ] = logicalSize()
163
+ sizeCanvas(canvas, lw, lh, resolutionFactor())
164
+ canvas.style.display = "none"
165
+
166
+ // Lazy chunk: creator2d.{js,wasm} load only for 2D projects (a 3D project pays nothing).
167
+ const { createViewer2d } = await import("./viewer2d/createViewer2d")
168
+ _creator2d = await createViewer2d(canvas, {}, canvasHost)
169
+
170
+ // Stub the 3D creator so the UI renderer, resize observer and teardown keep working without
171
+ // bringing up Filament (same shape as the deferred stub below).
172
+ _creator = {
173
+ raycastView() { return 0 },
174
+ _onResize() { },
175
+ _dispose() { }
176
+ } as any
177
+ } else if (engineMode === 'gl-lite') {
178
+ const [ lw, lh ] = logicalSize()
179
+ sizeCanvas(canvas, lw, lh, resolutionFactor())
180
+ canvas.style.display = "none"
181
+
182
+ // Lazy chunk: ogl + the viewer-lite package's engine load only when the lite engine is
183
+ // requested — and the Filament wasm is never fetched at all. Deep import keeps the chunk to
184
+ // the engine alone (the package root also carries the standalone lite host + UI renderer).
185
+ const { createViewerLite } = await import("lecodes-viewer-lite/src/gl/createViewerLite")
186
+ _creator = await createViewerLite(canvas, { preloads, canvasHost, transparent: options.transparent }) as unknown as CreatorMethods
187
+ if (options.methods) {
188
+ Object.assign(_creator, options.methods)
189
+ }
190
+ } else if (engineMode === 'defer') {
191
+ _creator = {
192
+ raycastView() { return 0 },
193
+ _onResize() { },
194
+ _dispose() {}
195
+ } as any
196
+ createEngine = async () => {
197
+ const [ lw, lh ] = logicalSize()
198
+ sizeCanvas(canvas, lw, lh, resolutionFactor())
199
+
200
+ canvas.style.display = "block"
201
+
202
+ const viewer = await createViewer(canvas, { preloads, canvasHost })
203
+ Object.assign(_creator, viewer)
204
+ // Re-apply method overrides after the real engine lands, so they win over its defaults.
205
+ if (options.methods) Object.assign(_creator, options.methods)
206
+ }
207
+ } else {
208
+ const [ lw, lh ] = logicalSize()
209
+ sizeCanvas(canvas, lw, lh, resolutionFactor())
210
+
211
+ canvas.style.display = "none"
212
+
213
+ _creator = await createViewer(canvas, { preloads, canvasHost })
214
+ if (options.methods) {
215
+ Object.assign(_creator, options.methods)
216
+ }
217
+ }
218
+
219
+ const _creatorUI = options.ui ? await options.ui(root, canvasHost) : undefined
220
+ const _creatorUtils = createUtils({ launchUrl: options.launchUrl, onOrientation: options.onOrientation })
221
+ _creatorUI?._setCreator(_creator)
222
+ // Before the ResizeObserver's initial fire, so the first applied layout already has the insets.
223
+ if (options.safeArea) _creatorUI?._setSafeAreaSpec?.(options.safeArea)
224
+
225
+ // Presentable navigation (docs/navigation-presentable-plan.md): hand the UI layer the scene
226
+ // canvases + engine activation hooks, so scenes present as destinations (openView) instead of
227
+ // living under the screens. Read `_creator.openScene` at call time — the defer mode fills the
228
+ // stub in later (createEngine), and dual mode's visibility-toggling wrapper stays honored.
229
+ _creatorUI?._setSceneHost?.({
230
+ scene3d: engineMode !== '2d'
231
+ ? { el: canvas, open: (id: number) => _creator.openScene(id), close: () => _creator.closeScene?.() }
232
+ : undefined,
233
+ scene2d: engineMode === '2d' || engineMode === 'dual'
234
+ ? { el: extraCanvas ?? canvas, open: (id: number) => _creator2d.openScene(id), close: () => _creator2d.closeScene?.() }
235
+ : undefined,
236
+ })
237
+
238
+ // Push a logical size + fresh resolution factor to every engine. The factor carries the device-pixel
239
+ // target; the DOM UI renderer ignores it (its width/height stay logical px).
240
+ const pushResize = (w: number, h: number) => {
241
+ const factor = resolutionFactor()
242
+ _creator?._onResize(w, h, factor)
243
+ _creator2d?._onResize(w, h, factor)
244
+ _creatorUI?._onResize(w, h, factor)
245
+ }
246
+
247
+ const resizeObserver = new ResizeObserver(([ entry ]) => {
248
+ // contentRect is layout px (zoom-independent) — the logical viewport the app lays out against.
249
+ pushResize(entry.contentRect.width, entry.contentRect.height)
250
+ })
251
+ resizeObserver.observe(root)
252
+
253
+ // Recompute + re-push the resolution factor without a layout change. A CSS-zoom change (the phone
254
+ // shrinking to fit a resized window) leaves the logical size fixed, so the ResizeObserver stays
255
+ // silent — but the factor (× zoom) moved, and the engines must re-size their backing stores.
256
+ const refreshResolution = () => {
257
+ const [ w, h ] = logicalSize()
258
+ pushResize(w, h)
259
+ }
260
+
261
+ // Input host (keys / mouse / gamepads → Input.*). Keys listen on this window plus any extra windows
262
+ // the embedder passes (the editor passes its parent — the app runs in the runner iframe, but keeps
263
+ // focus in the editor document until the user clicks the preview). Host-lifetime, like the window
264
+ // error listeners.
265
+ const inputHost = createInputHost({
266
+ windows: [window, ...(options.keyboardTargets ?? []).filter(w => w && w !== window)],
267
+ root, keyboard: options.keyboard !== false,
268
+ })
269
+ _creatorUtils.inputKey = inputHost.inputKey
270
+ _creatorUtils.inputRead = inputHost.inputRead
271
+ _creatorUtils.inputSetPointerLock = inputHost.inputSetPointerLock
272
+ _creatorUtils.registerInputEvent = inputHost.registerInputEvent
273
+
274
+ // ---- error reporting -------------------------------------------------------------------------
275
+ // Window listeners are shared across runs (installed lazily on the first run); each async error is
276
+ // attributed to the run whose source map its stack resolves into. Native (WASM engine) errors have
277
+ // no JS frames, so they go to the most recent run.
278
+
279
+ const runs: RunRecord[] = []
280
+ let windowListenersInstalled = false
281
+
282
+ const emitError = (record: RunRecord, e: unknown, frames: MappedFrame[], eventMessage?: string) => {
283
+ const native = frames.length === 0 && isNativeError(e)
284
+ if (native) {
285
+ // The engine (Emscripten) prints the real message to the console; the value that reaches JS is a
286
+ // pointer/abort code. Surface it as an engine error and keep whatever description we do have.
287
+ const raw = typeof e === "number" || typeof e === "bigint"
288
+ ? `code ${e}`
289
+ : e instanceof Error ? (e.message || e.name) : String(e)
290
+ const detail = [ eventMessage, raw ].filter((v): v is string => !!v).filter((v, i, a) => a.indexOf(v) === i).join("\n")
291
+ console.error(`[le.codes] native engine error — ${detail || raw} (see console for the engine's message)`)
292
+ const preview: PreviewError = { message: "Native engine error", detail, frames: [], native: true, stack: e instanceof Error ? e.stack : undefined }
293
+ record.onError?.(preview)
294
+ // Late enrichment: decode the trap's wasm frames through the lazy symbol map (fetched only
295
+ // now, on the first crash) and emit again. Consumers treat a follow-up error as an overlay
296
+ // update, so the C++ stack appears as soon as the map arrives.
297
+ if (preview.stack) {
298
+ void symbolizeWasmFrames(preview.stack).then(nativeFrames => {
299
+ if (nativeFrames.length > 0) record.onError?.({ ...preview, nativeFrames })
300
+ })
301
+ }
302
+ return
303
+ }
304
+
305
+ const message = e instanceof Error ? e.message : String(e)
306
+ if (frames.length > 0) {
307
+ const top = frames[0]!
308
+ console.error(`[le.codes] ${message}\n at ${top.source}:${top.line}:${top.column}${top.fn ? ` (${top.fn})` : ""}`)
309
+ } else {
310
+ console.error(`[le.codes] ${message}`)
311
+ }
312
+ record.onError?.({ message, frames, stack: e instanceof Error ? e.stack : undefined })
313
+ }
314
+
315
+ // Window events fire for the whole page, so only report what's clearly a preview bundle's: a JS
316
+ // error whose stack maps into one of the runs' source maps, or a native (WASM engine) error.
317
+ // `eventMessage` is the ErrorEvent's text, which for a native trap is often more descriptive than
318
+ // the raw thrown value.
319
+ const reportWindowError = (e: unknown, eventMessage?: string) => {
320
+ for (let i = runs.length - 1; i >= 0; i--) {
321
+ const record = runs[i]!
322
+ if (!record.sourceMap) continue
323
+ const frames = mapErrorStack(e, record.sourceMap)
324
+ if (frames.length > 0) return emitError(record, e, frames, eventMessage)
325
+ }
326
+ if (isNativeError(e)) {
327
+ const record = runs[runs.length - 1]
328
+ if (record) emitError(record, e, [], eventMessage)
329
+ }
330
+ }
331
+
332
+ const onWindowError = (ev: ErrorEvent) => reportWindowError(ev.error ?? ev.message, ev.message)
333
+ const onUnhandledRejection = (ev: PromiseRejectionEvent) => reportWindowError(ev.reason)
334
+
335
+ const ensureWindowListeners = () => {
336
+ if (windowListenersInstalled) return
337
+ windowListenersInstalled = true
338
+ window.addEventListener("error", onWindowError)
339
+ window.addEventListener("unhandledrejection", onUnhandledRejection)
340
+ }
341
+
342
+ // ---- run -------------------------------------------------------------------------------------
343
+
344
+ const run = (userCode: string, runOptions: RunOptions = {}): Run => {
345
+ ensureWindowListeners()
346
+
347
+ const timers = createTimers()
348
+ // The bundle carries an inline source map; the browser can't apply it to `new Function` frames,
349
+ // so error mapping happens host-side (see reportWindowError).
350
+ const sourceMap = extractInlineSourceMap(userCode)
351
+
352
+ let disposed = false
353
+ const dispose = () => {
354
+ if (disposed) return
355
+ disposed = true
356
+ const i = runs.indexOf(record)
357
+ if (i >= 0) runs.splice(i, 1)
358
+ timers.disposeAll()
359
+ }
360
+
361
+ const record: RunRecord = { sourceMap, onError: runOptions.onError, dispose }
362
+ runs.push(record)
363
+
364
+ const entries = {
365
+ _creator,
366
+ _creator2d,
367
+ _creatorUI,
368
+ _creatorUtils,
369
+ _creatorCanvas: canvasHost.bridge,
370
+ _creatorNav: navBridge,
371
+ ...timers.globals,
372
+ createEngine,
373
+ }
374
+
375
+ const func = new Function(...Object.keys(entries), userCode)
376
+ let result: unknown
377
+ try {
378
+ result = func(...Object.values(entries))
379
+ } catch (e) {
380
+ // Report the original location, then let it propagate as before. The record stays registered:
381
+ // whatever the bundle set up before throwing can still surface async errors.
382
+ emitError(record, e, sourceMap ? mapErrorStack(e, sourceMap) : [])
383
+ throw e
384
+ }
385
+
386
+ return { result, dispose }
387
+ }
388
+
389
+ const destroy = () => {
390
+ // Disconnect first: the observer callback closes over _creator/_creatorUI, so leaving it
391
+ // connected pins the WASM modules alive (and keeps firing into a torn-down engine).
392
+ resizeObserver.disconnect()
393
+
394
+ for (const record of [ ...runs ]) record.dispose()
395
+ if (windowListenersInstalled) {
396
+ window.removeEventListener("error", onWindowError)
397
+ window.removeEventListener("unhandledrejection", onUnhandledRejection)
398
+ }
399
+
400
+ _creator._dispose()
401
+ _creator2d?._dispose()
402
+ _creatorUI?._destroy?.()
403
+ root.removeChild(canvas)
404
+ if (extraCanvas) root.removeChild(extraCanvas)
405
+
406
+ _creatorUtils._disposeWebsockets()
407
+ _creatorUtils._disposeServices()
408
+ _creatorUtils._disposeAppEvents()
409
+
410
+ inputHost.dispose()
411
+ }
412
+
413
+ // Gate callers (a run's first openScreen triggers the first layout/measure) on the font being
414
+ // ready, so canvas-based text measurement never runs against a fallback font.
415
+ await fontsReady
416
+ await bootFontsReady
417
+
418
+ return {
419
+ _creator,
420
+ _creator2d,
421
+ _creatorUI,
422
+ _creatorUtils,
423
+ canvasHost,
424
+ run,
425
+ refreshResolution,
426
+ destroy,
427
+ }
428
+ }
@@ -0,0 +1,160 @@
1
+ // Dev-only smoke page for the lite 3D engine (lite-test.html → vite dev, port 12073).
2
+ // Runs a raw `_creator`-level bundle through mountCreator with the gl-lite engine override:
3
+ // lit/unlit materials, sun + shadows, IBL, a GLB with skeletal animation, and raycast picking.
4
+ // The window exposes __liteStatus for the headless-Chrome check.
5
+
6
+ import { mountCreator } from './createViewer'
7
+
8
+ const bundle = `// preload: lit.filamat;unlit.filamat;uberarchive
9
+ (function() {
10
+ const status = { frames: 0, raycast: -1, glb: 'pending', animations: [], errors: [] }
11
+ window.__liteStatus = status
12
+
13
+ const sceneId = _creator.createScene()
14
+
15
+ // camera: slightly above, looking down at the origin
16
+ const cameraId = _creator.createEntity()
17
+ _creator.attachCameraToAR(sceneId, cameraId, () => {})
18
+ _creator.setPosition(cameraId, 0, 2.2, 5)
19
+ _creator.setEulerAngles(cameraId, -16, 0, 0, 1)
20
+
21
+ // sun + ambient
22
+ const light = _creator.createEntity()
23
+ _creator.createSunLight(light, -0.6, -1, -0.4, 100000, 0xffffff, 1)
24
+ _creator.addEntityToScene(sceneId, light)
25
+ _creator.setDefaultIbl(sceneId, 20000)
26
+ _creator.setSkybox(sceneId, 0x24324e)
27
+
28
+ // --- geometry helpers ------------------------------------------------------------------------
29
+ const cube = (s) => {
30
+ const p = [], n = [], u = [], idx = []
31
+ const faces = [
32
+ [[ 1,0,0],[0,0,-1],[0,1,0]], [[-1,0,0],[0,0,1],[0,1,0]],
33
+ [[0, 1,0],[1,0,0],[0,0,-1]], [[0,-1,0],[1,0,0],[0,0,1]],
34
+ [[0,0, 1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]],
35
+ ]
36
+ for (let f = 0; f < 6; f++) {
37
+ const [nrm, ta, tb] = faces[f]
38
+ const base = p.length / 3
39
+ for (let i = 0; i < 4; i++) {
40
+ const a = (i === 1 || i === 2) ? 1 : -1
41
+ const b = (i >= 2) ? 1 : -1
42
+ p.push((nrm[0] + ta[0]*a + tb[0]*b) * s/2, (nrm[1] + ta[1]*a + tb[1]*b) * s/2, (nrm[2] + ta[2]*a + tb[2]*b) * s/2)
43
+ n.push(nrm[0], nrm[1], nrm[2])
44
+ u.push((a+1)/2, (b+1)/2)
45
+ }
46
+ idx.push(base, base+1, base+2, base, base+2, base+3)
47
+ }
48
+ return { p: new Float32Array(p), n: new Float32Array(n), u: new Float32Array(u), i: new Uint16Array(idx) }
49
+ }
50
+ const plane = (s) => ({
51
+ p: new Float32Array([-s,0,-s, s,0,-s, s,0,s, -s,0,s]),
52
+ n: new Float32Array([0,1,0, 0,1,0, 0,1,0, 0,1,0]),
53
+ u: new Float32Array([0,0, 1,0, 1,1, 0,1]),
54
+ i: new Uint16Array([0,2,1, 0,3,2]),
55
+ })
56
+
57
+ // --- ground (lit, white) ---------------------------------------------------------------------
58
+ const groundMat = _creator.createMaterial(_creatorUtils.fetchLocal('lit.filamat'))
59
+ _creator.setUniformRgb(groundMat, 'baseColor', 0xdddddd)
60
+ _creator.setUniformFloat(groundMat, 'roughness', 0.9)
61
+ const g = plane(6)
62
+ const ground = _creator.createEntity()
63
+ _creator.setMesh(ground, groundMat, g.p, g.n, g.i, g.u, 0)
64
+ _creator.setCastShadows(ground, false)
65
+ _creator.addEntityToScene(sceneId, ground)
66
+
67
+ // --- spinning lit cube (red, semi-rough) -------------------------------------------------------
68
+ const cubeMat = _creator.createMaterial(_creatorUtils.fetchLocal('lit.filamat'))
69
+ _creator.setUniformRgb(cubeMat, 'baseColor', 0xdd3333)
70
+ _creator.setUniformFloat(cubeMat, 'roughness', 0.35)
71
+ _creator.setUniformFloat(cubeMat, 'metallic', 0.0)
72
+ const c = cube(1)
73
+ const box = _creator.createEntity()
74
+ _creator.setMesh(box, cubeMat, c.p, c.n, c.i, c.u, 0)
75
+ _creator.setPosition(box, -1.2, 0.8, 0)
76
+ _creator.addEntityToScene(sceneId, box)
77
+ _creator.setColliderBox(box, 0, 0, 0, 1, 1, 1)
78
+
79
+ // --- metallic cube ------------------------------------------------------------------------------
80
+ const metalMat = _creator.createMaterial(_creatorUtils.fetchLocal('lit.filamat'))
81
+ _creator.setUniformRgb(metalMat, 'baseColor', 0xccccdd)
82
+ _creator.setUniformFloat(metalMat, 'roughness', 0.15)
83
+ _creator.setUniformFloat(metalMat, 'metallic', 1.0)
84
+ const metal = _creator.createEntity()
85
+ _creator.setMesh(metal, metalMat, c.p, c.n, c.i, c.u, 0)
86
+ _creator.setPosition(metal, 1.2, 0.5, 0)
87
+ _creator.setEulerAngles(metal, 0, 30, 0, 1)
88
+ _creator.addEntityToScene(sceneId, metal)
89
+
90
+ // --- unlit marker -------------------------------------------------------------------------------
91
+ const unlitMat = _creator.createMaterial(_creatorUtils.fetchLocal('unlit.filamat'))
92
+ _creator.setUniformRgb(unlitMat, 'baseColor', 0x33ff88)
93
+ const marker = _creator.createEntity()
94
+ _creator.setMesh(marker, unlitMat, cube(0.25).p, cube(0.25).n, cube(0.25).i, cube(0.25).u, 0)
95
+ _creator.setPosition(marker, 0, 2, -1)
96
+ _creator.addEntityToScene(sceneId, marker)
97
+
98
+ // --- GLB with skeletal animation ---------------------------------------------------------------
99
+ _creatorUtils.fetch(location.origin + '/lite-test.glb', {}, (systemId) => {
100
+ _creator.createGlb(systemId, (entityId) => {
101
+ status.glb = 'loaded'
102
+ _creator.setGlbCulling(entityId, false)
103
+ _creator.setPosition(entityId, 0, 0, 1.4)
104
+ _creator.setScale(entityId, 0.5, 0.5, 0.5)
105
+ _creator.addEntityToScene(sceneId, entityId)
106
+ const set = _creator.getGlbClipSet(entityId)
107
+ const anims = set ? _creator.getClipSetInfo(set) : []
108
+ status.animations = anims
109
+ if (anims.length > 0) { // what model.anim.play(0, { loop: true }) does
110
+ const a = _creator.animatorCreate(entityId)
111
+ const slot = _creator.animatorBind(a, set, 0, 0)
112
+ _creator.animatorPlay(a, slot, true, 1, 0, -1, true)
113
+ }
114
+ }, (e) => { status.glb = 'failed'; status.errors.push(String(e)) })
115
+ }, () => { status.glb = 'fetch-failed' })
116
+
117
+ // --- spin + probe --------------------------------------------------------------------------------
118
+ let angle = 0
119
+ _creator.setEarlyUpdate((dt) => {
120
+ angle += dt * 60
121
+ _creator.setEulerAngles(box, 0, angle, 0, 1)
122
+ status.frames++
123
+ if (status.frames === 30) {
124
+ // scan a screen grid — the red box has a collider, so some rays must return its entity id
125
+ const size = _creator.getDisplaySize()
126
+ const hits = new Set()
127
+ for (let fx = 0.05; fx < 1; fx += 0.1) {
128
+ for (let fy = 0.3; fy < 0.95; fy += 0.1) {
129
+ const hit = _creator.raycastView(size[0] * fx, size[1] * fy)
130
+ if (hit) hits.add(hit)
131
+ }
132
+ }
133
+ status.raycast = [...hits]
134
+ status.boxEntity = box
135
+ const m = new Float32Array(16)
136
+ _creator.getWorldMatrix(box, m)
137
+ status.boxWorldY = m[13]
138
+ }
139
+ })
140
+
141
+ _creator.openScene(sceneId)
142
+ })()
143
+ `
144
+
145
+ const root = document.getElementById('root')!
146
+ // ?engine=gl compares against the real Filament build; default is the lite engine.
147
+ const engineParam = new URLSearchParams(location.search).get('engine')
148
+ mountCreator(root, bundle, undefined, undefined, {
149
+ engine: engineParam === 'gl' ? undefined : 'gl-lite',
150
+ onError: (err) => {
151
+ ;(window as any).__liteStatus = (window as any).__liteStatus ?? {}
152
+ ;((window as any).__liteStatus.errors ??= []).push(err.message)
153
+ console.error('preview error', err)
154
+ },
155
+ }).then(() => {
156
+ console.log('[lite-test] mounted')
157
+ }).catch((e) => {
158
+ ;(window as any).__liteMountError = String(e)
159
+ console.error(e)
160
+ })
package/src/main.ts ADDED
@@ -0,0 +1,77 @@
1
+ // Scene-editor dev playground (`bun run dev` in packages/viewer, port 12073). Runs the compiled
2
+ // scene-editor harness + demo scene bundle against an engine-only host — the manual verification
3
+ // rig for the 3D scene editor. Defaults to the lite engine (like the editor); `?engine=gl` runs
4
+ // Filament instead.
5
+ //
6
+ // Rebuild the bundle after touching packages/projects/src/scene-editor*:
7
+ // bun packages/projects/build.mjs (needs chisel; LECODES_CHISEL_BIN for a local build)
8
+ //
9
+ // Interactions: click a HUD row = select (viewport picking is removed), drag = orbit,
10
+ // wheel = zoom, shift+drag / middle-drag = pan (host-driven), F = focus selection.
11
+
12
+ import { createHost } from "./createViewer"
13
+ import { createBareUI } from "./ui/createBareUI"
14
+ // @ts-ignore — built artifact (bun packages/projects/build.mjs)
15
+ import bundle from "../../projects/out/scene-editor-dev.js?raw"
16
+
17
+ const root = document.getElementById("app")!
18
+ const hud = document.getElementById("hud")!
19
+
20
+ const main = async () => {
21
+ const params = new URLSearchParams(location.search)
22
+ const filament = params.get("engine") === "gl"
23
+ const host = await createHost(root, {
24
+ engine: filament ? "gl" : "gl-lite",
25
+ ui: createBareUI,
26
+ preloads: filament ? new Set([ "lit.filamat", "unlit.filamat" ]) : new Set(),
27
+ })
28
+ // ?wire=only|overlay — the lite engine's wireframe display mode (editor host API)
29
+ const wire = params.get("wire")
30
+ if (wire === "only" || wire === "overlay") (host as any)._creator?._setWireframeMode?.(wire)
31
+
32
+ // the editor-host flow: edit flag → run the combined bundle → attach the registered handle
33
+ ;(globalThis as any).__lecodesSceneEdit = true
34
+ host.run(bundle, { onError: (e) => { hud.textContent = `error: ${e.message}` } })
35
+ const harness = (globalThis as any).__lecodesSceneHarness
36
+ const scenes = (globalThis as any).__lecodesScenes ?? []
37
+ if (!harness || scenes.length === 0) { hud.textContent = "bundle didn't install the harness — rebuild it (see src/main.ts)"; return }
38
+
39
+ const { nodes } = await harness.attach(scenes[scenes.length - 1])
40
+
41
+ let selected: string | null = null
42
+ const render = (status = "") => {
43
+ hud.innerHTML = ""
44
+ for (const name of nodes) {
45
+ const el = document.createElement("div")
46
+ el.className = "node" + (name === selected ? " sel" : "")
47
+ el.textContent = name
48
+ el.onclick = () => { selected = name; harness.select(name); render() }
49
+ hud.appendChild(el)
50
+ }
51
+ const hint = document.createElement("div")
52
+ hint.className = "hint"
53
+ hint.textContent = status || "click a row: select · drag: orbit · wheel: zoom · shift-drag: pan · F: focus"
54
+ hud.appendChild(hint)
55
+ }
56
+ render()
57
+
58
+ // fired only by plugin `editor.select` calls (viewport picking is removed)
59
+ harness.callbacks.onSelect = (name: string | null) => { selected = name; render() }
60
+
61
+ root.addEventListener("wheel", (e) => { e.preventDefault(); harness.zoom(Math.sign(e.deltaY)) }, { passive: false })
62
+ window.addEventListener("keydown", (e) => { if (e.code === "KeyF") harness.focus() })
63
+ // shift+drag / middle-drag pan. The capture listener swallows the pointerdown so the harness's
64
+ // touch system never starts its orbit track for the same drag (both would run otherwise).
65
+ let panning = false
66
+ root.addEventListener("pointerdown", (e) => {
67
+ if (e.shiftKey || e.button === 1) {
68
+ panning = true
69
+ e.preventDefault()
70
+ e.stopImmediatePropagation()
71
+ }
72
+ }, { capture: true })
73
+ window.addEventListener("pointermove", (e) => { if (panning && e.buttons) harness.pan(e.movementX, e.movementY) })
74
+ window.addEventListener("pointerup", () => { panning = false })
75
+ }
76
+
77
+ main()
@@ -0,0 +1,2 @@
1
+ // Moved to viewer-lite; re-export shim.
2
+ export * from "lecodes-viewer-lite/src/sourcemapError"