lecodes-gl-lite 2.0.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 +27 -0
- package/package.json +33 -0
- package/src/assets/decoders/README.md +13 -0
- package/src/assets/decoders/basis_transcoder.js +19 -0
- package/src/assets/decoders/basis_transcoder.wasm +0 -0
- package/src/assets/decoders/draco_decoder.wasm +0 -0
- package/src/assets/decoders/draco_wasm_wrapper.js +117 -0
- package/src/assets/neutral_ibl128.ktx +0 -0
- package/src/core/animBridge.ts +51 -0
- package/src/core/boneNames.ts +22 -0
- package/src/core/frame.ts +230 -0
- package/src/core/glbStructure.ts +100 -0
- package/src/core/headlessBridge.ts +92 -0
- package/src/core/mapView.ts +195 -0
- package/src/core/math3d.ts +275 -0
- package/src/core/particleBridge.ts +154 -0
- package/src/core/physicsBridge.ts +305 -0
- package/src/core/recording.ts +59 -0
- package/src/core/sceneCore.ts +833 -0
- package/src/core/serialize.ts +144 -0
- package/src/gen/unsupported.gen.ts +212 -0
- package/src/index.ts +5 -0
- package/src/vendor/creator-anim/dist/creatorAnim.d.ts +123 -0
- package/src/vendor/creator-anim/dist/creatorAnim.js +14 -0
- package/src/vendor/creator-anim/js/index.ts +656 -0
- package/src/vendor/creator-nav/dist/creatorNav.d.ts +56 -0
- package/src/vendor/creator-nav/dist/creatorNav.js +14 -0
- package/src/vendor/creator-nav/js/index.ts +400 -0
- package/src/vendor/creator-particles/dist/creatorParticles.d.ts +43 -0
- package/src/vendor/creator-particles/dist/creatorParticles.js +14 -0
- package/src/vendor/creator-particles/js/index.ts +137 -0
- package/src/vendor/creator-physics/dist/creatorPhysics.d.ts +107 -0
- package/src/vendor/creator-physics/dist/creatorPhysics.js +14 -0
- package/src/vendor/creator-physics/js/index.ts +515 -0
- package/src/vite-env.d.ts +1 -0
- package/src/webgl/batching.ts +453 -0
- package/src/webgl/createViewerLite.ts +1627 -0
- package/src/webgl/customMaterial.ts +228 -0
- package/src/webgl/decoders/basis.ts +123 -0
- package/src/webgl/decoders/draco.ts +110 -0
- package/src/webgl/decoders/index.ts +68 -0
- package/src/webgl/decoders/worker.ts +45 -0
- package/src/webgl/editor.ts +1297 -0
- package/src/webgl/glb.ts +593 -0
- package/src/webgl/ibl.ts +137 -0
- package/src/webgl/shaders.ts +779 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Screen-space frame report — the part of scene3d that makes the JSON *evaluable*, not just
|
|
2
|
+
// inspectable (docs/gl-lite-core-plan.md phase 4). The rest of scene3d is world-space, so an AI
|
|
3
|
+
// reading it must do the camera math mentally to know what the frame looks like. This projects every
|
|
4
|
+
// visible mesh / model / particles entity's world AABB through the camera (world matrix + fov +
|
|
5
|
+
// viewport) into a screen rect, so composition is readable at a glance.
|
|
6
|
+
//
|
|
7
|
+
// It is serializer-level math over state the core already holds: NO bridges.d.ts method, no parity
|
|
8
|
+
// impact — world→screen deliberately never becomes a bridge call (creator-gl only exposes the inverse,
|
|
9
|
+
// getCameraViewDirection). Honest limits, mirrored in the output docs: depth order is a *hint* (true
|
|
10
|
+
// occlusion needs rasterization — that's the WebGL backend's job, screenshot + _dumpScene3d side by
|
|
11
|
+
// side), and headless does not *sample* animation poses, so rects reflect the rest pose.
|
|
12
|
+
|
|
13
|
+
import { invertTRS, lookAtView, multiply, perspective } from "./math3d"
|
|
14
|
+
import type { Ent, SceneCore, SceneRec } from "./sceneCore"
|
|
15
|
+
|
|
16
|
+
const round = (v: number, digits: number): number => {
|
|
17
|
+
const f = 10 ** digits
|
|
18
|
+
const r = Math.round(v * f) / f
|
|
19
|
+
return r === 0 ? 0 : r
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type FrameObject = {
|
|
23
|
+
name?: string
|
|
24
|
+
/** Absolute path of named ancestors, root→self ("ball/bead") — the entity's identity now that
|
|
25
|
+
* scene node names are only sibling-unique. Unnamed intermediate ancestors appear as `~`
|
|
26
|
+
* (leading unnamed containers are dropped), so two same-named nodes under different unnamed
|
|
27
|
+
* parents never collapse into one path. Always present on named objects. */
|
|
28
|
+
path?: string
|
|
29
|
+
kind: "mesh" | "model" | "particles"
|
|
30
|
+
/** Screen rect [x, y, w, h] in px (top-left origin) from projecting the world AABB's 8 corners. */
|
|
31
|
+
rect?: [number, number, number, number]
|
|
32
|
+
/** Screen center [x, y] in px. */
|
|
33
|
+
center?: [number, number]
|
|
34
|
+
/** Camera-space forward distance of the AABB center. Negative = behind the camera. */
|
|
35
|
+
depth: number
|
|
36
|
+
/** Fraction of the viewport the on-screen rect covers (0..1). */
|
|
37
|
+
coverage: number
|
|
38
|
+
/** Entirely off the viewport. */
|
|
39
|
+
offscreen?: boolean
|
|
40
|
+
/** Straddles a viewport edge or the near plane — the rect is a partial/approximate view. */
|
|
41
|
+
clipped?: boolean
|
|
42
|
+
/** Entirely behind the camera (no rect). */
|
|
43
|
+
behindCamera?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type Frame = {
|
|
47
|
+
viewport: { width: number, height: number }
|
|
48
|
+
/** Notes on how to read this section (depth is a hint; rest-pose only headless). */
|
|
49
|
+
note: string
|
|
50
|
+
objects: FrameObject[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Local-space AABB corners (world-transformed) for an entity that carries a mesh, else [].
|
|
54
|
+
* (exported for the top-down map serializer — mapView.ts) */
|
|
55
|
+
export const meshWorldCorners = (core: SceneCore, e: Ent): number[][] => {
|
|
56
|
+
const size = e.mesh?.size
|
|
57
|
+
if (!size) return []
|
|
58
|
+
const c = e.mesh?.center ?? [0, 0, 0]
|
|
59
|
+
const hx = size[0] / 2, hy = size[1] / 2, hz = size[2] / 2
|
|
60
|
+
const w = core.worldMatrixF(e.id)
|
|
61
|
+
const out: number[][] = []
|
|
62
|
+
for (let i = 0; i < 8; i++) {
|
|
63
|
+
const x = c[0] + (i & 1 ? hx : -hx)
|
|
64
|
+
const y = c[1] + (i & 2 ? hy : -hy)
|
|
65
|
+
const z = c[2] + (i & 4 ? hz : -hz)
|
|
66
|
+
out.push([
|
|
67
|
+
w[0] * x + w[4] * y + w[8] * z + w[12],
|
|
68
|
+
w[1] * x + w[5] * y + w[9] * z + w[13],
|
|
69
|
+
w[2] * x + w[6] * y + w[10] * z + w[14],
|
|
70
|
+
])
|
|
71
|
+
}
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** World-space AABB corners over a whole subtree (for a model root: all its glTF part meshes). */
|
|
76
|
+
const subtreeWorldCorners = (core: SceneCore, id: number): number[][] => {
|
|
77
|
+
const acc: number[][] = []
|
|
78
|
+
const walk = (nid: number): void => {
|
|
79
|
+
const e = core.entities.get(nid)
|
|
80
|
+
if (!e) return
|
|
81
|
+
acc.push(...meshWorldCorners(core, e))
|
|
82
|
+
for (const c of e.children) walk(c)
|
|
83
|
+
}
|
|
84
|
+
walk(id)
|
|
85
|
+
return acc
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** World-space AABB over every mesh/model in the scene (+ overlays), or null when nothing is
|
|
89
|
+
* renderable yet — backs the auto-fitted inspection views (`view: "iso"`), like creator-gl's
|
|
90
|
+
* getCurrentSceneBounds natively. */
|
|
91
|
+
export function sceneWorldBounds(core: SceneCore, scene: SceneRec): { min: number[], max: number[] } | null {
|
|
92
|
+
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]
|
|
93
|
+
let any = false
|
|
94
|
+
const memberScenes: SceneRec[] = [scene, ...scene.overlays.map((id) => core.scenes.get(id)).filter((s): s is SceneRec => !!s)]
|
|
95
|
+
const seen = new Set<number>()
|
|
96
|
+
const visit = (id: number): void => {
|
|
97
|
+
if (seen.has(id) || !core.entities.has(id)) return
|
|
98
|
+
seen.add(id)
|
|
99
|
+
const e = core.ent(id)
|
|
100
|
+
if (e.visible) {
|
|
101
|
+
for (const c of meshWorldCorners(core, e)) {
|
|
102
|
+
for (let i = 0; i < 3; i++) { if (c[i] < min[i]) min[i] = c[i]; if (c[i] > max[i]) max[i] = c[i] }
|
|
103
|
+
any = true
|
|
104
|
+
}
|
|
105
|
+
for (const ch of e.children) visit(ch)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const s of memberScenes) for (const id of s.entities) visit(id)
|
|
109
|
+
return any ? { min, max } : null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function computeFrame(core: SceneCore, scene: SceneRec): Frame | undefined {
|
|
113
|
+
const camId = scene.camera
|
|
114
|
+
const override = core.cameraOverride()
|
|
115
|
+
if (!override && (camId === undefined || !core.entities.has(camId))) return undefined
|
|
116
|
+
|
|
117
|
+
const W = core.width, H = core.height
|
|
118
|
+
// the scene's own projection (setCameraProjection), so a custom fov moves the reported rects —
|
|
119
|
+
// unless an inspection camera is set, which then owns both the view and (optionally) the fov.
|
|
120
|
+
const { fov: sceneFov, near, far } = core.projectionOf(scene.id)
|
|
121
|
+
const fov = override?.fov ?? sceneFov
|
|
122
|
+
const view = override ? lookAtView(override.eye, override.target) : invertTRS(core.worldMatrixF(camId!))
|
|
123
|
+
const vp = multiply(new Float32Array(16), perspective(fov, W / H, near, far), view)
|
|
124
|
+
|
|
125
|
+
/** Project a world point → screen px + forward depth (clip w). */
|
|
126
|
+
const project = (x: number, y: number, z: number): { sx: number, sy: number, cw: number, inFront: boolean } => {
|
|
127
|
+
const cw = vp[3] * x + vp[7] * y + vp[11] * z + vp[15]
|
|
128
|
+
const cx = vp[0] * x + vp[4] * y + vp[8] * z + vp[12]
|
|
129
|
+
const cy = vp[1] * x + vp[5] * y + vp[9] * z + vp[13]
|
|
130
|
+
const inFront = cw > 1e-4
|
|
131
|
+
if (!inFront) return { sx: 0, sy: 0, cw, inFront }
|
|
132
|
+
return { sx: ((cx / cw) * 0.5 + 0.5) * W, sy: (1 - ((cy / cw) * 0.5 + 0.5)) * H, cw, inFront }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Collect every entity in the active scene + its overlays (members and their descendants).
|
|
136
|
+
const isVisible = (id: number): boolean => {
|
|
137
|
+
for (let cur = id; cur !== 0; cur = core.ent(cur).parent) if (!core.ent(cur).visible) return false
|
|
138
|
+
return true
|
|
139
|
+
}
|
|
140
|
+
const memberScenes: SceneRec[] = [scene, ...scene.overlays.map((id) => core.scenes.get(id)).filter((s): s is SceneRec => !!s)]
|
|
141
|
+
const seen = new Set<number>()
|
|
142
|
+
const candidates: number[] = []
|
|
143
|
+
const gather = (id: number): void => {
|
|
144
|
+
if (seen.has(id) || !core.entities.has(id)) return
|
|
145
|
+
seen.add(id)
|
|
146
|
+
candidates.push(id)
|
|
147
|
+
for (const c of core.ent(id).children) gather(c)
|
|
148
|
+
}
|
|
149
|
+
for (const s of memberScenes) for (const id of s.entities) gather(id)
|
|
150
|
+
|
|
151
|
+
const entries: { obj: FrameObject, id: number }[] = []
|
|
152
|
+
for (const id of candidates) {
|
|
153
|
+
const e = core.ent(id)
|
|
154
|
+
if (!isVisible(id)) continue
|
|
155
|
+
const kind: FrameObject["kind"] | null = e.model ? "model" : (e.mesh && !e.light) ? "mesh" : e.particles ? "particles" : null
|
|
156
|
+
if (!kind) continue
|
|
157
|
+
|
|
158
|
+
// World corners: mesh → its own AABB; model → subtree aggregate; particles → its origin point.
|
|
159
|
+
let corners: number[][]
|
|
160
|
+
if (kind === "model") corners = subtreeWorldCorners(core, id)
|
|
161
|
+
else if (kind === "mesh") corners = meshWorldCorners(core, e)
|
|
162
|
+
else { const w = core.worldMatrixF(id); corners = [[w[12], w[13], w[14]]] }
|
|
163
|
+
if (corners.length === 0) continue // a mesh/model with no bounds contributes nothing to the frame
|
|
164
|
+
|
|
165
|
+
// World AABB center (representative point for depth).
|
|
166
|
+
let cxMin = Infinity, cyMin = Infinity, czMin = Infinity, cxMax = -Infinity, cyMax = -Infinity, czMax = -Infinity
|
|
167
|
+
for (const c of corners) {
|
|
168
|
+
if (c[0] < cxMin) cxMin = c[0]; if (c[0] > cxMax) cxMax = c[0]
|
|
169
|
+
if (c[1] < cyMin) cyMin = c[1]; if (c[1] > cyMax) cyMax = c[1]
|
|
170
|
+
if (c[2] < czMin) czMin = c[2]; if (c[2] > czMax) czMax = c[2]
|
|
171
|
+
}
|
|
172
|
+
const centerProj = project((cxMin + cxMax) / 2, (cyMin + cyMax) / 2, (czMin + czMax) / 2)
|
|
173
|
+
|
|
174
|
+
// Project corners; the screen bbox comes from the in-front ones.
|
|
175
|
+
let anyBehind = false, anyFront = false
|
|
176
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
|
177
|
+
for (const c of corners) {
|
|
178
|
+
const p = project(c[0], c[1], c[2])
|
|
179
|
+
if (!p.inFront) { anyBehind = true; continue }
|
|
180
|
+
anyFront = true
|
|
181
|
+
if (p.sx < minX) minX = p.sx; if (p.sx > maxX) maxX = p.sx
|
|
182
|
+
if (p.sy < minY) minY = p.sy; if (p.sy > maxY) maxY = p.sy
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const obj: FrameObject = { kind, depth: round(centerProj.cw, 3), coverage: 0 }
|
|
186
|
+
if (e.name) obj.name = e.name
|
|
187
|
+
|
|
188
|
+
if (!anyFront) {
|
|
189
|
+
obj.behindCamera = true
|
|
190
|
+
entries.push({ obj, id })
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const rectX = minX, rectY = minY, rectW = Math.max(0, maxX - minX), rectH = Math.max(0, maxY - minY)
|
|
195
|
+
obj.rect = [round(rectX, 1), round(rectY, 1), round(rectW, 1), round(rectH, 1)]
|
|
196
|
+
if (centerProj.inFront) obj.center = [round(centerProj.sx, 1), round(centerProj.sy, 1)]
|
|
197
|
+
|
|
198
|
+
const ix0 = Math.max(0, rectX), iy0 = Math.max(0, rectY)
|
|
199
|
+
const ix1 = Math.min(W, rectX + rectW), iy1 = Math.min(H, rectY + rectH)
|
|
200
|
+
const interArea = Math.max(0, ix1 - ix0) * Math.max(0, iy1 - iy0)
|
|
201
|
+
obj.coverage = round(interArea / (W * H), 4)
|
|
202
|
+
|
|
203
|
+
const insideFully = rectX >= 0 && rectY >= 0 && rectX + rectW <= W && rectY + rectH <= H
|
|
204
|
+
if (interArea === 0) obj.offscreen = true
|
|
205
|
+
else if (anyBehind || !insideFully) obj.clipped = true
|
|
206
|
+
entries.push({ obj, id })
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Every named object carries its ancestor `path` (root→self) — names are sibling-unique, so
|
|
210
|
+
// the path is the identity. Unnamed intermediates become `~` (leading ones dropped) so distinct
|
|
211
|
+
// unnamed parents never merge two same-named children into one path.
|
|
212
|
+
const namedPath = (id: number): string => {
|
|
213
|
+
const parts: string[] = []
|
|
214
|
+
for (let cur = id; cur !== 0; cur = core.ent(cur).parent) parts.unshift(core.ent(cur).name || "~")
|
|
215
|
+
while (parts.length > 0 && parts[0] === "~") parts.shift()
|
|
216
|
+
return parts.join("/")
|
|
217
|
+
}
|
|
218
|
+
for (const { obj, id } of entries) {
|
|
219
|
+
if (obj.name) obj.path = namedPath(id)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Prominence sort: coverage desc, then nearest first.
|
|
223
|
+
entries.sort((a, b) => b.obj.coverage - a.obj.coverage || a.obj.depth - b.obj.depth)
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
viewport: { width: W, height: H },
|
|
227
|
+
note: (override ? "INSPECTION camera (not the app's) — " : "") + "screen px, top-left origin; depth is a camera-distance hint, not true occlusion; rects are rest-pose (headless doesn't sample animation)",
|
|
228
|
+
objects: entries.map((e) => e.obj),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// GL-free GLB (binary glTF 2.0) structure parser for the lite 3D scene core. Reads ONLY the JSON
|
|
2
|
+
// chunk — enough to mirror what gltfio gives the SDK: the node hierarchy (names + local transforms),
|
|
3
|
+
// which nodes carry meshes (and their POSITION accessor bounds), and the animation clip list with
|
|
4
|
+
// durations. No geometry/texture decoding: the core records structure; each backend does its own
|
|
5
|
+
// heavy load (headless records nothing more; the WebGL backend additionally runs OGL's GLTFLoader
|
|
6
|
+
// and attaches drawables to these same entities by glTF node index — both derive from this same JSON,
|
|
7
|
+
// so indices align). Moved here from lecodes-headless/src/g3/glb.ts.
|
|
8
|
+
|
|
9
|
+
import { composeTRS, identity, type Mat4 } from "./math3d"
|
|
10
|
+
|
|
11
|
+
export type GlbNode = {
|
|
12
|
+
name?: string
|
|
13
|
+
children: number[]
|
|
14
|
+
/** Local transform (column-major), composed from the node's matrix or TRS. */
|
|
15
|
+
matrix: Mat4
|
|
16
|
+
/** Present when the node carries a mesh: POSITION bounds when the accessors declare min/max. */
|
|
17
|
+
mesh?: { primitives: number, min?: [number, number, number], max?: [number, number, number] }
|
|
18
|
+
/** The node is a joint of some skin — what a name-bound Animator prefers when names collide. */
|
|
19
|
+
skinJoint?: true
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type GlbAnimation = { name: string, duration: number }
|
|
23
|
+
|
|
24
|
+
export type GlbStructure = {
|
|
25
|
+
nodes: GlbNode[]
|
|
26
|
+
/** Indices (into `nodes`) of the default scene's root nodes. */
|
|
27
|
+
roots: number[]
|
|
28
|
+
animations: GlbAnimation[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const GLB_MAGIC = 0x46546c67 // 'glTF'
|
|
32
|
+
const CHUNK_JSON = 0x4e4f534a // 'JSON'
|
|
33
|
+
|
|
34
|
+
/** Parse a GLB buffer's JSON chunk into a node/animation structure. Returns null when the buffer
|
|
35
|
+
* isn't a valid GLB (the caller then falls back to an opaque model placeholder). */
|
|
36
|
+
export const parseGlbStructure = (buf: ArrayBuffer): GlbStructure | null => {
|
|
37
|
+
try {
|
|
38
|
+
if (buf.byteLength < 20) return null
|
|
39
|
+
const dv = new DataView(buf)
|
|
40
|
+
if (dv.getUint32(0, true) !== GLB_MAGIC) return null
|
|
41
|
+
const chunkLen = dv.getUint32(12, true)
|
|
42
|
+
if (dv.getUint32(16, true) !== CHUNK_JSON || 20 + chunkLen > buf.byteLength) return null
|
|
43
|
+
const json = JSON.parse(new TextDecoder("utf-8").decode(new Uint8Array(buf, 20, chunkLen)))
|
|
44
|
+
|
|
45
|
+
const accessors: any[] = json.accessors ?? []
|
|
46
|
+
const meshes: any[] = json.meshes ?? []
|
|
47
|
+
|
|
48
|
+
const nodes: GlbNode[] = (json.nodes ?? []).map((n: any) => {
|
|
49
|
+
let matrix: Mat4
|
|
50
|
+
if (Array.isArray(n.matrix) && n.matrix.length === 16) {
|
|
51
|
+
matrix = Float32Array.from(n.matrix) // glTF matrices are column-major, same as ours
|
|
52
|
+
} else if (n.translation || n.rotation || n.scale) {
|
|
53
|
+
matrix = composeTRS(n.translation ?? [0, 0, 0], n.rotation ?? [0, 0, 0, 1], n.scale ?? [1, 1, 1])
|
|
54
|
+
} else {
|
|
55
|
+
matrix = identity()
|
|
56
|
+
}
|
|
57
|
+
const node: GlbNode = { children: n.children ?? [], matrix }
|
|
58
|
+
if (typeof n.name === "string" && n.name.length > 0) node.name = n.name
|
|
59
|
+
if (n.mesh != null && meshes[n.mesh]) {
|
|
60
|
+
const prims: any[] = meshes[n.mesh].primitives ?? []
|
|
61
|
+
// Union the POSITION bounds over the primitives (accessor min/max are required for POSITION).
|
|
62
|
+
let min: [number, number, number] | undefined
|
|
63
|
+
let max: [number, number, number] | undefined
|
|
64
|
+
for (const p of prims) {
|
|
65
|
+
const acc = accessors[p.attributes?.POSITION]
|
|
66
|
+
if (!acc || !Array.isArray(acc.min) || !Array.isArray(acc.max)) continue
|
|
67
|
+
if (!min || !max) {
|
|
68
|
+
min = [acc.min[0], acc.min[1], acc.min[2]]
|
|
69
|
+
max = [acc.max[0], acc.max[1], acc.max[2]]
|
|
70
|
+
} else {
|
|
71
|
+
for (let i = 0; i < 3; i++) {
|
|
72
|
+
min[i] = Math.min(min[i], acc.min[i])
|
|
73
|
+
max[i] = Math.max(max[i], acc.max[i])
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
node.mesh = { primitives: prims.length, min, max }
|
|
78
|
+
}
|
|
79
|
+
return node
|
|
80
|
+
})
|
|
81
|
+
for (const skin of json.skins ?? []) for (const j of skin.joints ?? []) if (nodes[j]) nodes[j].skinJoint = true
|
|
82
|
+
|
|
83
|
+
// Animation duration = the max end time over the channels' sampler input accessors (min/max are
|
|
84
|
+
// required on animation inputs by the glTF spec; missing → 0).
|
|
85
|
+
const animations: GlbAnimation[] = (json.animations ?? []).map((a: any, i: number) => {
|
|
86
|
+
let duration = 0
|
|
87
|
+
for (const s of a.samplers ?? []) {
|
|
88
|
+
const inMax = accessors[s.input]?.max?.[0]
|
|
89
|
+
if (typeof inMax === "number" && inMax > duration) duration = inMax
|
|
90
|
+
}
|
|
91
|
+
return { name: typeof a.name === "string" ? a.name : `animation_${i}`, duration }
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const sceneIndex = json.scene ?? 0
|
|
95
|
+
const roots: number[] = json.scenes?.[sceneIndex]?.nodes ?? []
|
|
96
|
+
return { nodes, roots, animations }
|
|
97
|
+
} catch {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The headless backend for the lite 3D scene core (lecodes-headless). Assembles the GL-free
|
|
2
|
+
// SceneCore + the recording method groups + the scene3d serializer into the same handle the old
|
|
3
|
+
// g3/Scene3dRecorder exposed — a drop-in for lecodes-headless/src/headless/index.ts. No GL objects
|
|
4
|
+
// exist here at all: the backend supplies only fresh identity matrices, and every GL hook is absent,
|
|
5
|
+
// so the recorded state IS the core state. Bun-testable with no browser; the animation and physics
|
|
6
|
+
// runtimes (creator-anim / creator-physics wasm) load on demand — a scene that never touches them
|
|
7
|
+
// needs neither.
|
|
8
|
+
|
|
9
|
+
import { identity } from "./math3d"
|
|
10
|
+
import { createSceneCore, type SceneBackend, type SceneCoreOptions } from "./sceneCore"
|
|
11
|
+
import { createRecording } from "./recording"
|
|
12
|
+
import { serialize } from "./serialize"
|
|
13
|
+
import { sceneWorldBounds } from "./frame"
|
|
14
|
+
import { computeMapView } from "./mapView"
|
|
15
|
+
import { unsupported } from "../gen/unsupported.gen"
|
|
16
|
+
// The global `Handle` the contract's creators return (declared with the tree bridge): without it
|
|
17
|
+
// gl.d.ts reads `Handle` as any and the `satisfies` below cannot see a creator returning a number.
|
|
18
|
+
import type {} from "lecodes-sdk/bridges/tree"
|
|
19
|
+
|
|
20
|
+
export type HeadlessBridgeOptions = Pick<SceneCoreOptions, "getBuffer" | "getSourceName" | "width" | "height">
|
|
21
|
+
|
|
22
|
+
export function createHeadlessBridge(opts: HeadlessBridgeOptions) {
|
|
23
|
+
// Headless backend: no GL. Every entity gets a fresh identity matrix; the core does the rest.
|
|
24
|
+
const backend: SceneBackend = { allocMatrix: () => identity() }
|
|
25
|
+
|
|
26
|
+
const core = createSceneCore(backend, { ...opts, fov: 60 })
|
|
27
|
+
const recording = createRecording(core)
|
|
28
|
+
|
|
29
|
+
const bridge = {
|
|
30
|
+
...unsupported, // the contract's completeness layer (generated) under the core + the recorder
|
|
31
|
+
backend: "headless",
|
|
32
|
+
...core.methods,
|
|
33
|
+
...recording.methods,
|
|
34
|
+
// The OWNED creators (docs/owned.md) hand the SDK a Handle and it puts `_h.id` on the wire. The core
|
|
35
|
+
// numbers everything and the lite engine keeps no owned records (a drop frees nothing), so the
|
|
36
|
+
// handle is a plain `{ id }` over the core's id — the wrap creator-gl's generated bridge does.
|
|
37
|
+
createEntity: (): Handle => ({ id: core.methods.createEntity() }),
|
|
38
|
+
cloneEntity: (entityId: number): Handle => ({ id: core.methods.cloneEntity(entityId) }),
|
|
39
|
+
createMaterial: (systemId: number): Handle => ({ id: core.methods.createMaterial(systemId) }),
|
|
40
|
+
// Never null: a name this engine has no program for draws unlit (buildProgramFor), as it always did.
|
|
41
|
+
builtinMaterial: (name: string): Handle | null => ({ id: core.methods.createMaterialNamed(name) }),
|
|
42
|
+
createTexture(systemId: number, onComplete: (texture: Handle, width: number, height: number) => void, onReject: () => void): void {
|
|
43
|
+
core.methods.createTexture(systemId, (id, width, height) => onComplete({ id }, width, height), onReject)
|
|
44
|
+
},
|
|
45
|
+
createTexturePixels: (width: number, height: number, channels: number): Handle => ({ id: core.methods.createTexturePixels(width, height, channels) }),
|
|
46
|
+
createGlb(systemId: number, onComplete: (root: Handle | null) => void, onReject: () => void): void {
|
|
47
|
+
core.methods.createGlb(systemId, (root) => onComplete({ id: root }), onReject)
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
bridge satisfies typeof _creator
|
|
51
|
+
|
|
52
|
+
/** Inspection camera (the headless twin of `lecodes render --desktop --camera/--view`): a fixed
|
|
53
|
+
* eye + look-at, a named view auto-fitted to the scene's bounds (bounding sphere + margin — the
|
|
54
|
+
* same fit creator-gl's host uses), or null to clear. Returns false for a named view when the
|
|
55
|
+
* scene has nothing renderable yet (the override is then cleared). */
|
|
56
|
+
const setInspectionCamera = (spec: InspectionCameraSpec | null): boolean => {
|
|
57
|
+
if (spec === null) { core.setCameraOverride(null); return true }
|
|
58
|
+
if ("eye" in spec) { core.setCameraOverride({ eye: spec.eye, target: spec.target, fov: spec.fov }); return true }
|
|
59
|
+
const scene = core.scenes.get(core.activeSceneId())
|
|
60
|
+
const b = scene ? sceneWorldBounds(core, scene) : null
|
|
61
|
+
if (!b) { core.setCameraOverride(null); return false }
|
|
62
|
+
const c: [number, number, number] = [(b.min[0] + b.max[0]) / 2, (b.min[1] + b.max[1]) / 2, (b.min[2] + b.max[2]) / 2]
|
|
63
|
+
const r = Math.max(0.01, 0.5 * Math.hypot(b.max[0] - b.min[0], b.max[1] - b.min[1], b.max[2] - b.min[2]))
|
|
64
|
+
const fov = spec.fov ?? core.fov
|
|
65
|
+
const dist = r / Math.sin((fov * 0.5 * Math.PI) / 180) * 1.15
|
|
66
|
+
const dir = VIEW_DIRS[spec.view]
|
|
67
|
+
const len = Math.hypot(dir[0], dir[1], dir[2])
|
|
68
|
+
core.setCameraOverride({ eye: [c[0] + dir[0] / len * dist, c[1] + dir[1] / len * dist, c[2] + dir[2] / len * dist], target: c, fov: spec.fov })
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
bridge,
|
|
74
|
+
hasActiveScene: core.hasActiveScene,
|
|
75
|
+
simulate: core.simulate,
|
|
76
|
+
serialize: () => serialize(core, core.physicsConfig()),
|
|
77
|
+
/** Top-down map data of the active scene (mapView.ts) — null when nothing is on the map. */
|
|
78
|
+
mapView: () => computeMapView(core),
|
|
79
|
+
setInspectionCamera,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type InspectionView = "iso" | "top" | "front" | "side" | "back"
|
|
84
|
+
export type InspectionCameraSpec =
|
|
85
|
+
| { eye: [number, number, number], target: [number, number, number], fov?: number }
|
|
86
|
+
| { view: InspectionView, fov?: number }
|
|
87
|
+
// Same directions as the desktop host's applyInspectionCamera (main.cpp) — keep in step.
|
|
88
|
+
const VIEW_DIRS: Record<InspectionView, [number, number, number]> = {
|
|
89
|
+
iso: [1, 0.8, 1], top: [0, 1, 0], front: [0, 0, 1], side: [1, 0, 0], back: [0, 0, -1],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type HeadlessBridge = ReturnType<typeof createHeadlessBridge>
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Top-down map serializer for the lite 3D scene core — the data half of `lecodes render --map`.
|
|
2
|
+
// Walks the ACTIVE scene (overlays excluded: they hold viewmodels/HUD 3D, not the world) and
|
|
3
|
+
// projects every visible mesh/model onto the XZ plane as a convex-hull footprint, plus named
|
|
4
|
+
// empties as point markers, the sun direction and the app camera. Pure serializer-level math over
|
|
5
|
+
// core state (like frame.ts) — no bridge methods, no parity impact. The PNG drawing lives in
|
|
6
|
+
// lecodes-headless (headless/mapRender.ts); this side stays GL-free and Bun-testable.
|
|
7
|
+
|
|
8
|
+
import { getEulerYXZDeg } from "./math3d"
|
|
9
|
+
import { meshWorldCorners } from "./frame"
|
|
10
|
+
import type { Ent, SceneCore, SceneRec } from "./sceneCore"
|
|
11
|
+
|
|
12
|
+
export type MapObject = {
|
|
13
|
+
/** Named ancestor path root→self; `__generated` containers (make() output) are elided. */
|
|
14
|
+
path: string
|
|
15
|
+
kind: "mesh" | "model"
|
|
16
|
+
/** Convex-hull footprint on the XZ plane, world units (≥ 3 points). */
|
|
17
|
+
hull: [number, number][]
|
|
18
|
+
/** World-Y range [min, max] — height banding / "is this a floor or a wall". */
|
|
19
|
+
y: [number, number]
|
|
20
|
+
/** Footprint area in m² (hull shoelace) — the drawer's label/decimation heuristic. */
|
|
21
|
+
area: number
|
|
22
|
+
/** Mesh entities: the material's base color when it has one. */
|
|
23
|
+
color?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type MapPoint = {
|
|
27
|
+
path: string
|
|
28
|
+
kind: "empty" | "light" | "particles"
|
|
29
|
+
x: number
|
|
30
|
+
z: number
|
|
31
|
+
y: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type MapCamera = { x: number, z: number, y: number, yaw: number }
|
|
35
|
+
|
|
36
|
+
export type MapView = {
|
|
37
|
+
/** World XZ extent over everything on the map. */
|
|
38
|
+
bounds: { min: [number, number], max: [number, number] }
|
|
39
|
+
objects: MapObject[]
|
|
40
|
+
points: MapPoint[]
|
|
41
|
+
camera?: MapCamera
|
|
42
|
+
/** Sun direction projected to XZ (normalized), when a sun light with a horizontal component exists. */
|
|
43
|
+
sun?: { dx: number, dz: number }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const round = (v: number, digits = 3): number => {
|
|
47
|
+
const f = 10 ** digits
|
|
48
|
+
const r = Math.round(v * f) / f
|
|
49
|
+
return r === 0 ? 0 : r
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Monotone-chain convex hull over XZ points (n is tiny — 8 per box, a few hundred per model). */
|
|
53
|
+
const convexHull = (pts: [number, number][]): [number, number][] => {
|
|
54
|
+
const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1])
|
|
55
|
+
if (p.length <= 2) return p
|
|
56
|
+
const cross = (o: [number, number], a: [number, number], b: [number, number]) =>
|
|
57
|
+
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
|
|
58
|
+
const lower: [number, number][] = []
|
|
59
|
+
for (const pt of p) {
|
|
60
|
+
while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, pt) <= 0) lower.pop()
|
|
61
|
+
lower.push(pt)
|
|
62
|
+
}
|
|
63
|
+
const upper: [number, number][] = []
|
|
64
|
+
for (let i = p.length - 1; i >= 0; i--) {
|
|
65
|
+
const pt = p[i]!
|
|
66
|
+
while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, pt) <= 0) upper.pop()
|
|
67
|
+
upper.push(pt)
|
|
68
|
+
}
|
|
69
|
+
lower.pop(); upper.pop()
|
|
70
|
+
return [...lower, ...upper]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const hullArea = (hull: [number, number][]): number => {
|
|
74
|
+
let a = 0
|
|
75
|
+
for (let i = 0; i < hull.length; i++) {
|
|
76
|
+
const [x1, z1] = hull[i]!
|
|
77
|
+
const [x2, z2] = hull[(i + 1) % hull.length]!
|
|
78
|
+
a += x1 * z2 - x2 * z1
|
|
79
|
+
}
|
|
80
|
+
return Math.abs(a) / 2
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function computeMapView(core: SceneCore, scene?: SceneRec): MapView | null {
|
|
84
|
+
const s = scene ?? core.scenes.get(core.activeSceneId())
|
|
85
|
+
if (!s) return null
|
|
86
|
+
|
|
87
|
+
const hex = (packed: number): string => "#" + (packed >>> 0).toString(16).padStart(6, "0")
|
|
88
|
+
const meshColor = (e: Ent): string | undefined => {
|
|
89
|
+
const m = core.materials.get(e.materials[0]!)
|
|
90
|
+
const u = m?.uniforms["color"] ?? m?.uniforms["baseColor"]
|
|
91
|
+
return u && (u.kind === "rgb" || u.kind === "rgba") ? hex(u.kind === "rgba" ? (u.v >>> 8) : u.v) : undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const namedPath = (id: number): string => {
|
|
95
|
+
const parts: string[] = []
|
|
96
|
+
for (let cur = id; cur !== 0; cur = core.ent(cur).parent) {
|
|
97
|
+
const n = core.ent(cur).name
|
|
98
|
+
if (n && n !== "__generated") parts.unshift(n)
|
|
99
|
+
}
|
|
100
|
+
return parts.join("/") || "~"
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const objects: MapObject[] = []
|
|
104
|
+
const points: MapPoint[] = []
|
|
105
|
+
let sun: MapView["sun"]
|
|
106
|
+
const min: [number, number] = [Infinity, Infinity]
|
|
107
|
+
const max: [number, number] = [-Infinity, -Infinity]
|
|
108
|
+
const grow = (x: number, z: number): void => {
|
|
109
|
+
if (x < min[0]) min[0] = x
|
|
110
|
+
if (z < min[1]) min[1] = z
|
|
111
|
+
if (x > max[0]) max[0] = x
|
|
112
|
+
if (z > max[1]) max[1] = z
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const cornersToObject = (id: number, kind: "mesh" | "model", corners: number[][], color?: string): void => {
|
|
116
|
+
if (corners.length === 0) return
|
|
117
|
+
let yMin = Infinity, yMax = -Infinity
|
|
118
|
+
const xz: [number, number][] = []
|
|
119
|
+
for (const c of corners) {
|
|
120
|
+
xz.push([c[0]!, c[2]!])
|
|
121
|
+
if (c[1]! < yMin) yMin = c[1]!
|
|
122
|
+
if (c[1]! > yMax) yMax = c[1]!
|
|
123
|
+
grow(c[0]!, c[2]!)
|
|
124
|
+
}
|
|
125
|
+
const hull = convexHull(xz).map(([x, z]) => [round(x), round(z)] as [number, number])
|
|
126
|
+
if (hull.length < 3) return
|
|
127
|
+
objects.push({
|
|
128
|
+
path: namedPath(id), kind, hull, y: [round(yMin), round(yMax)],
|
|
129
|
+
area: round(hullArea(hull), 2), ...(color !== undefined && { color }),
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** All mesh corners of a model subtree (bones and empties inside contribute nothing). */
|
|
134
|
+
const subtreeCorners = (id: number): number[][] => {
|
|
135
|
+
const acc: number[][] = []
|
|
136
|
+
const walk = (nid: number): void => {
|
|
137
|
+
const e = core.entities.get(nid)
|
|
138
|
+
if (!e || !e.visible) return
|
|
139
|
+
acc.push(...meshWorldCorners(core, e))
|
|
140
|
+
for (const c of e.children) walk(c)
|
|
141
|
+
}
|
|
142
|
+
walk(id)
|
|
143
|
+
return acc
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const seen = new Set<number>()
|
|
147
|
+
const visit = (id: number): void => {
|
|
148
|
+
if (seen.has(id) || !core.entities.has(id)) return
|
|
149
|
+
seen.add(id)
|
|
150
|
+
const e = core.ent(id)
|
|
151
|
+
if (!e.visible) return
|
|
152
|
+
if (e.model) {
|
|
153
|
+
cornersToObject(id, "model", subtreeCorners(id))
|
|
154
|
+
return // a model's internals belong to the model — no per-bone points/objects
|
|
155
|
+
}
|
|
156
|
+
if (e.light) {
|
|
157
|
+
const d = e.light.direction
|
|
158
|
+
if (d && (Math.abs(d[0]!) > 1e-4 || Math.abs(d[2]!) > 1e-4)) {
|
|
159
|
+
const len = Math.hypot(d[0]!, d[2]!)
|
|
160
|
+
sun ??= { dx: round(d[0]! / len), dz: round(d[2]! / len) }
|
|
161
|
+
} else {
|
|
162
|
+
const w = core.worldMatrixF(id)
|
|
163
|
+
points.push({ path: namedPath(id), kind: "light", x: round(w[12]!), z: round(w[14]!), y: round(w[13]!) })
|
|
164
|
+
}
|
|
165
|
+
} else if (e.mesh) {
|
|
166
|
+
cornersToObject(id, "mesh", meshWorldCorners(core, e), meshColor(e))
|
|
167
|
+
} else if (e.particles) {
|
|
168
|
+
const w = core.worldMatrixF(id)
|
|
169
|
+
points.push({ path: namedPath(id), kind: "particles", x: round(w[12]!), z: round(w[14]!), y: round(w[13]!) })
|
|
170
|
+
} else if (e.name && e.name !== "__generated" && id !== s.camera) {
|
|
171
|
+
// a NAMED empty is intent (a spawn, a waypoint, a folder) — put it on the map
|
|
172
|
+
const w = core.worldMatrixF(id)
|
|
173
|
+
const p: MapPoint = { path: namedPath(id), kind: "empty", x: round(w[12]!), z: round(w[14]!), y: round(w[13]!) }
|
|
174
|
+
points.push(p)
|
|
175
|
+
grow(p.x, p.z)
|
|
176
|
+
}
|
|
177
|
+
for (const c of e.children) visit(c)
|
|
178
|
+
}
|
|
179
|
+
for (const id of s.entities) visit(id)
|
|
180
|
+
|
|
181
|
+
let camera: MapCamera | undefined
|
|
182
|
+
if (s.camera !== undefined && core.entities.has(s.camera)) {
|
|
183
|
+
const w = core.worldMatrixF(s.camera)
|
|
184
|
+
const yaw = getEulerYXZDeg(w)[1]!
|
|
185
|
+
camera = { x: round(w[12]!), z: round(w[14]!), y: round(w[13]!), yaw: Math.round(yaw * 100) / 100 }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (min[0] === Infinity) return null
|
|
189
|
+
return {
|
|
190
|
+
bounds: { min: [round(min[0]), round(min[1])], max: [round(max[0]), round(max[1])] },
|
|
191
|
+
objects, points,
|
|
192
|
+
...(camera && { camera }),
|
|
193
|
+
...(sun && { sun }),
|
|
194
|
+
}
|
|
195
|
+
}
|