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
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
|
+
}
|
package/src/lite-test.ts
ADDED
|
@@ -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()
|