lecodes-sdk 2.4.7 → 2.4.9
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/dist/global.d.ts +11 -0
- package/dist/host.d.ts +13 -2
- package/dist/types/gl/Camera.d.ts +100 -13
- package/dist/types/gl/Light.d.ts +7 -2
- package/dist/types/gl/Lightmap.d.ts +45 -25
- package/dist/types/gl/Material.d.ts +16 -2
- package/dist/types/gl/Node.d.ts +13 -0
- package/dist/types/gl/Particles.d.ts +1 -1
- package/dist/types/gl/ReflectionProbe.d.ts +87 -0
- package/dist/types/gl/RenderTexture.d.ts +29 -0
- package/dist/types/gl/Scene.d.ts +21 -0
- package/dist/types/inject.d.ts +5 -0
- package/dist/types/scene/defineScene.d.ts +15 -54
- package/dist/types/ui/tree.d.ts +1 -0
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/src/bridges/gl.d.ts +34 -0
- package/src/compile/assetMacro.ts +2 -2
- package/src/compile/bakeMacro.ts +243 -0
- package/src/compile/bundler.ts +38 -6
- package/src/compile/compileProject.ts +13 -7
- package/src/compile/index.ts +2 -1
- package/src/compile/resourceSlots.ts +4 -3
- package/src/gl/Camera.ts +198 -25
- package/src/gl/Light.ts +280 -274
- package/src/gl/Lightmap.ts +114 -81
- package/src/gl/Material.ts +36 -2
- package/src/gl/Node.ts +61 -0
- package/src/gl/Particles.ts +1 -1
- package/src/gl/ReflectionProbe.ts +141 -0
- package/src/gl/RenderTexture.ts +93 -0
- package/src/gl/Scene.ts +54 -2
- package/src/host.d.ts +13 -2
- package/src/inject.ts +5 -0
- package/src/scene/defineScene.ts +26 -59
- package/src/ui/tree.ts +1 -0
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/src/bridges/gl.d.ts
CHANGED
|
@@ -184,6 +184,10 @@ declare global {
|
|
|
184
184
|
* not the roughness-prefiltered `_ibl.ktx` beside it. Optional: hosts that predate it keep the
|
|
185
185
|
* flat skybox. */
|
|
186
186
|
setSkyboxTexture?(sceneId: u32, fetchId: bytes): void
|
|
187
|
+
/** Optional. Light the scene from a CUBEMAP texture (a render texture's cube, creator.h setEnvironmentTexture): the
|
|
188
|
+
* IBL reflects it, its irradiance and intensity stay; no roughness prefiltering, so only mirror-like reflections are
|
|
189
|
+
* right. The engine holds the texture while it lights; setDefaultIbl replaces it. */
|
|
190
|
+
setEnvironmentTexture?(sceneId: u32, textureId: u32): void
|
|
187
191
|
/** Temporal anti-aliasing (Filament's TAA) for the scene and its overlays: on, MSAA and FXAA are off for the
|
|
188
192
|
* view; `feedback` = the history's share (0.12 default, higher = less ghosting, less smoothing), `sharpness` =
|
|
189
193
|
* the post-TAA sharpen (0–1), `upscale` = the fraction the 3D is rendered at and TAA-upscaled from (0.5–1,
|
|
@@ -325,6 +329,30 @@ declare global {
|
|
|
325
329
|
* re-uploads a sub-rectangle with the creation channel count. Optional (Texture.fromPixels throws). @args data width height channels srgb @count pixelBytes=data @owned GlTexture */
|
|
326
330
|
createTexturePixels?(width: u32, height: u32, channels: u32, data: Uint8Array, srgb: boolean): Handle
|
|
327
331
|
updateTexturePixels?(textureId: u32, x: u32, y: u32, width: u32, height: u32, data: Uint8Array): void
|
|
332
|
+
/** Optional. A RENDER TEXTURE, what a render view draws into (creator.h createRenderTexture): `flags` bit 1 = CUBEMAP
|
|
333
|
+
* (`width` = the edge, `height` ignored), bit 2 = HDR (R11F_G11F_B10F linear light, for lighting); neither = an LDR
|
|
334
|
+
* display texture (SRGB8_A8, the tone-mapped picture); bit 4 = PREFILTER, with both others only (flags 7, a
|
|
335
|
+
* power-of-two edge of 16 at least): a probe filtered for roughness — the id is the 5-level filtered cube, its views
|
|
336
|
+
* draw a raw one behind it, filtered on the GPU at the frame after each completed cube with `samples` samples
|
|
337
|
+
* (0 = 256; read for PREFILTER only). Otherwise an ordinary texture id (setUniformTexture, setSceneBackground); its
|
|
338
|
+
* texel rows run as OpenGL renders, on every backend (a 2D texture's first row = the picture's bottom). 0xFFFFFFFF
|
|
339
|
+
* on failure. @owned GlTexture */
|
|
340
|
+
createRenderTexture?(width: u32, height: u32, flags: u32, samples: u32): Handle
|
|
341
|
+
/** Optional. A RENDER VIEW of the scene into a render texture, from node `cameraEntityId` (its world pose, read every
|
|
342
|
+
* frame; a cube takes its position only): `layerMask` = the layers it sees (setLayer), `fov` vertical degrees (a cube
|
|
343
|
+
* is 90), `flags` bit 1 = no shadows. Drawn each frame its scene is presented, before the scene's own view; freeing the
|
|
344
|
+
* texture takes its views with it. 0 on failure. @owned GlRenderView */
|
|
345
|
+
createRenderView?(sceneId: u32, textureId: u32, cameraEntityId: u32, layerMask: u8, fov: f32, near: f32, far: f32, flags: u32): Handle
|
|
346
|
+
/** Optional. `everyNth` 1 = every frame, N = one render per N frames drawn, counted afresh from this call (the next
|
|
347
|
+
* frame renders); `facesPerFrame` 1..6 for a cube (round-robin). */
|
|
348
|
+
setRenderViewOptions?(renderViewId: u32, enabled: boolean, everyNth: u32, facesPerFrame: u32): void
|
|
349
|
+
/** Optional. The view's projection, live (createRenderView's fov / near / far were the initial values): a 2D view's
|
|
350
|
+
* vertical fov in degrees and clip planes; a cube ignores `fov` (90) and takes the planes. Unknown id = no-op. */
|
|
351
|
+
setRenderViewProjection?(renderViewId: u32, fov: f32, near: f32, far: f32): void
|
|
352
|
+
/** Optional. RGBA8 bytes of a rectangle of a render texture as the next frame leaves it — rows in the texture's order
|
|
353
|
+
* (the first is texel row `y`), `face` 0..5 = +X -X +Y -Y +Z -Z of a cube (0 for 2D), an HDR one clamped to 0..1
|
|
354
|
+
* (linear); null when refused. A GPU readback, for checks and tools. @custom */
|
|
355
|
+
readRenderTexturePixels?(textureId: u32, face: u32, x: u32, y: u32, width: u32, height: u32, onDone: (pixels: Uint8Array | null) => void): void
|
|
328
356
|
|
|
329
357
|
/** A material INSTANCE of these .filamat bytes (an app's own shader, fetched); the material itself is deduplicated by content in the engine. @c createMaterialInstanceFromBytes @owned GlMaterial @throw createMaterial: no .filamat bytes behind this fetch id */
|
|
330
358
|
createMaterial(systemId: bytes): Handle
|
|
@@ -355,6 +383,12 @@ declare global {
|
|
|
355
383
|
|
|
356
384
|
setVisible(entityId: u32, visible: boolean): void
|
|
357
385
|
isVisible(entityId: u32): boolean
|
|
386
|
+
/** Optional. Visibility layers: the entity's explicit layer bits (u8), inherited by its subtree down to a
|
|
387
|
+
* descendant with its own; 0 clears them (inherit again). Nothing set = 0x01. A view draws a renderable
|
|
388
|
+
* when their layers share a bit (the scene's own view sees all). A host without it has no layers. @c setEntityLayer */
|
|
389
|
+
setLayer?(entityId: u32, mask: u8): void
|
|
390
|
+
/** Optional. The entity's effective layer bits: its own, else the nearest ancestor's, else 0x01. @c getEntityLayer */
|
|
391
|
+
getLayer?(entityId: u32): u8
|
|
358
392
|
|
|
359
393
|
/** @custom */
|
|
360
394
|
createGlb(systemId: bytes, onComplete: (root: Handle | null) => void, onReject: () => void): void
|
|
@@ -22,7 +22,7 @@ export const hasAssetMacro = (src: string): boolean => src.includes("asset(")
|
|
|
22
22
|
* Not a full lexer: a regex LITERAL containing `asset(` would still be seen as code. That case
|
|
23
23
|
* doesn't occur in practice and behaves exactly as it did before this masking existed.
|
|
24
24
|
*/
|
|
25
|
-
const maskNonCode = (src: string, onString?: (start: number, end: number) => void): string => {
|
|
25
|
+
export const maskNonCode = (src: string, onString?: (start: number, end: number) => void): string => {
|
|
26
26
|
const out = src.split("")
|
|
27
27
|
const blank = (from: number, to: number) => {
|
|
28
28
|
for (let k = from; k < to && k < out.length; k++) if (out[k] !== "\n") out[k] = " "
|
|
@@ -97,7 +97,7 @@ export const CODE_EXT = /\.([tj]sx?|mjs|cjs|json)$/i
|
|
|
97
97
|
// What precedes an import specifier: the literal is a module path, never a resource reference.
|
|
98
98
|
const IMPORT_BEFORE = /(?:\bfrom|\bimport|\bimport\s*\(|\brequire\s*\()\s*$/
|
|
99
99
|
|
|
100
|
-
const normalizePath = (path: string): string => {
|
|
100
|
+
export const normalizePath = (path: string): string => {
|
|
101
101
|
const out: string[] = []
|
|
102
102
|
for (const part of path.split("/")) {
|
|
103
103
|
if (part === "" || part === ".") continue
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// The compile-time `bake('./assets/lightmap/level.bake', { …options })` macro — a level's ONE reference to its bake
|
|
2
|
+
// (gl/Lightmap.ts). `lecodes lightmap bake` writes `<stem>.bake`, a JSON manifest that names every file it wrote next to
|
|
3
|
+
// it (`light[]`, `aux[]`, `direct[]`, `indirect[]`, `probes.file`, `volume.file`, `uv1`). The macro reads that manifest at
|
|
4
|
+
// compile time and rewrites the call IN PLACE to the literal `Lightmap.load` takes, every file an `asset()` call that the
|
|
5
|
+
// asset pass desugars right after — so the files are staged and resolved exactly as asset() ones are:
|
|
6
|
+
//
|
|
7
|
+
// bake('../assets/range/lightmap/range.bake', { emissiveBake: false })
|
|
8
|
+
// → { data: asset("/assets/range/lightmap/range.bake"), path: `assets/range/lightmap/range.bake`,
|
|
9
|
+
// light: [asset("/assets/range/lightmap/range-light.ktx2")], aux: [ … ], probes: asset( … ), volume: asset( … ),
|
|
10
|
+
// uv1: asset( … ), ...{ emissiveBake: false } }
|
|
11
|
+
//
|
|
12
|
+
// No `.bake` yet (before the first bake) is no error: `{ data: null, path: `…`, light: [], aux: [], ...options }` — the
|
|
13
|
+
// level runs unbaked and says so, and under `lecodes lightmap bake` that path is the target the bake writes. A file the
|
|
14
|
+
// manifest names that is not in the project fails the compile of an entry that reaches the call (the bundler, the way
|
|
15
|
+
// a missing asset() does). The bundler also reports every reached bake() (`bakes`): the CLI's bake target.
|
|
16
|
+
//
|
|
17
|
+
// The path resolves like a bare path literal does: `./` and `../` from the file first, then from the project root; `/x`
|
|
18
|
+
// from the root; `a/b.bake` from the root, then the file. `path` comes out as a TEMPLATE literal on purpose — the bare
|
|
19
|
+
// path pass (collectPathLiterals) would read a quoted `.bake` path as that file's asset. The options must be an object
|
|
20
|
+
// literal; its source text is copied as written (file-local identifiers stay valid: the expansion sits where the call
|
|
21
|
+
// stood), and the expansion keeps the call's line breaks, so the bundle's source map still matches the file.
|
|
22
|
+
//
|
|
23
|
+
// A file that declares its own `bake` (a function, a const, an import) is left alone — real scoping wins, as with
|
|
24
|
+
// font() — and so is a method or function DEFINITION named bake (`bake() { … }`: a block follows its parameters).
|
|
25
|
+
|
|
26
|
+
import { maskNonCode, normalizePath } from "./assetMacro"
|
|
27
|
+
import { escapeTemplate } from "./assetIconMacro"
|
|
28
|
+
|
|
29
|
+
/** One `bake()` call: its target and what the manifest named. */
|
|
30
|
+
export type BakeMacroRef = {
|
|
31
|
+
/** The `.bake`, project-relative (no leading slash) — what the def's `path` says. */
|
|
32
|
+
path: string
|
|
33
|
+
/** The manifest is among the compile's files (false = before the first bake). */
|
|
34
|
+
exists: boolean
|
|
35
|
+
/** The call's line in its file. */
|
|
36
|
+
line: number
|
|
37
|
+
/** The path as written in the call. */
|
|
38
|
+
written: string
|
|
39
|
+
/** Root paths the manifest names that are not among the compile's files. */
|
|
40
|
+
missing: string[]
|
|
41
|
+
/** Set when the compile has the manifest but no way to read it (no `readResource`): the call compiled unbaked. */
|
|
42
|
+
unread?: true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The manifest's text by its ROOT path (`/assets/lightmap/level.bake`), or null when this compile cannot read it. */
|
|
46
|
+
export type BakeManifestReader = (rootPath: string) => string | null | Promise<string | null>
|
|
47
|
+
|
|
48
|
+
// A call at a code position (the lookbehind excludes `Lightmap.bake(…)` and `x.bake`); matched over the MASK, so a
|
|
49
|
+
// `bake(` inside a string or a comment is never seen.
|
|
50
|
+
const BAKE_CALL = /(?<![.\w$])bake\s*\(/g
|
|
51
|
+
/** A file that declares/imports its own `bake` — the macro must not touch it. */
|
|
52
|
+
const DECLARES_BAKE = /\b(?:function|const|let|var|class)\s+bake\b|\bimport\s+(?:\{[^}]*\bbake\b[^}]*\}|bake)\s+from\b/
|
|
53
|
+
|
|
54
|
+
/** What follows the parameters of a definition: an optional return type on the same line, then its block or arrow. */
|
|
55
|
+
const DEFINITION_TAIL = /^\s*(?::[^;{}=()\n]*)?(?:\{|=>)/
|
|
56
|
+
|
|
57
|
+
/** Cheap pre-check so the scan does not run over every file. */
|
|
58
|
+
export const hasBakeMacro = (src: string): boolean => src.includes("bake")
|
|
59
|
+
|
|
60
|
+
/** The value of a whole-string literal (`"x"`, `'x'`, plain `` `x` ``), or null if `text` is not one. */
|
|
61
|
+
const stringLiteralValue = (text: string): string | null => {
|
|
62
|
+
const t = text.trim()
|
|
63
|
+
if (t.length < 2) return null
|
|
64
|
+
const q = t[0]
|
|
65
|
+
if ((q !== "\"" && q !== "'" && q !== "`") || t[t.length - 1] !== q) return null
|
|
66
|
+
const inner = t.slice(1, -1)
|
|
67
|
+
if (q === "`" && inner.includes("${")) return null
|
|
68
|
+
if (/[\r\n]/.test(inner)) return null
|
|
69
|
+
for (let i = 0; i < inner.length; i++) {
|
|
70
|
+
if (inner[i] === "\\") { i++; continue }
|
|
71
|
+
if (inner[i] === q) return null // two literals, not one
|
|
72
|
+
}
|
|
73
|
+
return inner.replace(/\\(.)/g, "$1")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Index of the bracket that closes the one at `open` over the mask (strings and comments are blank there), or -1. */
|
|
77
|
+
const closeOf = (mask: string, open: number): number => {
|
|
78
|
+
let depth = 0
|
|
79
|
+
for (let i = open; i < mask.length; i++) {
|
|
80
|
+
const c = mask[i]
|
|
81
|
+
if (c === "(" || c === "[" || c === "{") depth++
|
|
82
|
+
else if (c === ")" || c === "]" || c === "}") { depth--; if (depth === 0) return i }
|
|
83
|
+
}
|
|
84
|
+
return -1
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The top-level argument spans of the call whose parens are at `open` / `close`. */
|
|
88
|
+
const argSpans = (mask: string, open: number, close: number): [number, number][] => {
|
|
89
|
+
const spans: [number, number][] = []
|
|
90
|
+
let depth = 0
|
|
91
|
+
let start = open + 1
|
|
92
|
+
for (let i = open + 1; i < close; i++) {
|
|
93
|
+
const c = mask[i]
|
|
94
|
+
if (c === "(" || c === "[" || c === "{") depth++
|
|
95
|
+
else if (c === ")" || c === "]" || c === "}") depth--
|
|
96
|
+
else if (c === "," && depth === 0) { spans.push([ start, i ]); start = i + 1 }
|
|
97
|
+
}
|
|
98
|
+
spans.push([ start, close ])
|
|
99
|
+
return spans
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const newlines = (s: string): number => {
|
|
103
|
+
let n = 0
|
|
104
|
+
for (let i = 0; i < s.length; i++) if (s[i] === "\n") n++
|
|
105
|
+
return n
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const stringArray = (v: unknown): string[] | null =>
|
|
109
|
+
Array.isArray(v) && v.every((x) => typeof x === "string") ? v as string[] : null
|
|
110
|
+
|
|
111
|
+
type Manifest = { light: string[], aux: string[], direct?: string[], indirect?: string[], probes?: string, volume?: string, uv1?: string }
|
|
112
|
+
|
|
113
|
+
/** The file lists of a manifest's JSON — null when it is not one (`light` / `aux` are what every bake writes). */
|
|
114
|
+
const readManifest = (json: unknown): Manifest | null => {
|
|
115
|
+
if (!json || typeof json !== "object") return null
|
|
116
|
+
const m = json as Record<string, unknown>
|
|
117
|
+
const light = stringArray(m.light), aux = stringArray(m.aux)
|
|
118
|
+
if (!light || !aux) return null
|
|
119
|
+
const file = (v: unknown): string | undefined =>
|
|
120
|
+
typeof v === "string" && v !== "" ? v
|
|
121
|
+
: v && typeof v === "object" && typeof (v as { file?: unknown }).file === "string" ? (v as { file: string }).file
|
|
122
|
+
: undefined
|
|
123
|
+
return {
|
|
124
|
+
light, aux,
|
|
125
|
+
direct: stringArray(m.direct) ?? undefined,
|
|
126
|
+
indirect: stringArray(m.indirect) ?? undefined,
|
|
127
|
+
probes: file(m.probes),
|
|
128
|
+
volume: file(m.volume),
|
|
129
|
+
uv1: file(m.uv1),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Expand every `bake()` call of one file (`filePath`: its root path, `/src/level.scene.ts`). `exists(rootPath)` says
|
|
135
|
+
* whether the compile has that file, `read(rootPath)` gives a manifest's text. Throws on a malformed call (a dynamic
|
|
136
|
+
* path, a path that is not a `.bake`, options that are not an object literal) or a manifest that is not one.
|
|
137
|
+
*/
|
|
138
|
+
export const expandBakeMacros = async (
|
|
139
|
+
src: string, filePath: string, exists: (rootPath: string) => boolean, read?: BakeManifestReader,
|
|
140
|
+
): Promise<{ src: string, bakes: BakeMacroRef[] }> => {
|
|
141
|
+
if (!hasBakeMacro(src)) return { src, bakes: [] }
|
|
142
|
+
const strings: [number, number][] = []
|
|
143
|
+
const mask = maskNonCode(src, (s, e) => { strings.push([ s, e ]) })
|
|
144
|
+
if (DECLARES_BAKE.test(mask)) return { src, bakes: [] }
|
|
145
|
+
// an argument's parts: the mask blanks comments AND string literals, `strings` says where the literals were
|
|
146
|
+
const stringsIn = ([ a, b ]: [number, number]) => strings.filter(([ s, e ]) => s >= a && e <= b)
|
|
147
|
+
const blankArg = (span: [number, number]): boolean => mask.slice(span[0], span[1]).trim() === "" && stringsIn(span).length === 0
|
|
148
|
+
const literalOf = (span: [number, number]): string => {
|
|
149
|
+
const inside = stringsIn(span)
|
|
150
|
+
if (inside.length === 1 && mask.slice(span[0], span[1]).trim() === "") return src.slice(inside[0][0], inside[0][1])
|
|
151
|
+
return src.slice(span[0], span[1]).trim() // a template literal, or no literal at all (the error names it)
|
|
152
|
+
}
|
|
153
|
+
const dir = filePath.slice(0, filePath.lastIndexOf("/"))
|
|
154
|
+
const at = (index: number): string => `${filePath.replace(/^\//, "")}:${newlines(src.slice(0, index)) + 1}`
|
|
155
|
+
const bakes: BakeMacroRef[] = []
|
|
156
|
+
const parts: string[] = []
|
|
157
|
+
let done = 0
|
|
158
|
+
for (const m of mask.matchAll(BAKE_CALL)) {
|
|
159
|
+
const start = m.index ?? 0
|
|
160
|
+
if (start < done) continue // inside an expanded call's arguments
|
|
161
|
+
const open = start + m[0].length - 1
|
|
162
|
+
const close = closeOf(mask, open)
|
|
163
|
+
if (close < 0) throw new Error(`bake(): unclosed call (${at(start)})`)
|
|
164
|
+
const spans = argSpans(mask, open, close)
|
|
165
|
+
const pathText = literalOf(spans[0])
|
|
166
|
+
const written = stringLiteralValue(pathText)
|
|
167
|
+
// a DEFINITION named bake (a method, a function, an object's method shorthand): no string literal for a first
|
|
168
|
+
// argument, and a block after its parameters (past a return type) — `bake() {`, `bake(x: number): void {`
|
|
169
|
+
if (written === null && DEFINITION_TAIL.test(mask.slice(close + 1, close + 160))) continue
|
|
170
|
+
if (written === null) throw new Error(`bake() expects a string-literal path like bake('./assets/lightmap/level.bake'), found \`${pathText || "()"}\` (${at(start)})`)
|
|
171
|
+
if (!/\.bake$/i.test(written)) throw new Error(`bake('${written}'): the path names a .bake file — what \`lecodes lightmap bake\` writes (${at(start)})`)
|
|
172
|
+
// a trailing comma leaves an empty last span; anything else past the options is one argument too many
|
|
173
|
+
if (spans.slice(2).some((s) => !blankArg(s))) throw new Error(`bake('${written}'): one path and one options object at most (${at(start)})`)
|
|
174
|
+
let options: [number, number] | null = null
|
|
175
|
+
if (spans.length > 1 && !blankArg(spans[1])) {
|
|
176
|
+
const [ a, b ] = spans[1]
|
|
177
|
+
const code = mask.slice(a, b)
|
|
178
|
+
const lead = a + (code.length - code.trimStart().length)
|
|
179
|
+
const tail = b - (code.length - code.trimEnd().length)
|
|
180
|
+
if (mask[lead] !== "{" || closeOf(mask, lead) !== tail - 1) {
|
|
181
|
+
throw new Error(`bake('${written}', options): options must be an object literal like { emissiveBake: false } (${at(start)})`)
|
|
182
|
+
}
|
|
183
|
+
options = [ lead, tail ]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// the target: as a bare path literal resolves; the first candidate when the file is not there yet
|
|
187
|
+
const relative = written.startsWith("./") || written.startsWith("../")
|
|
188
|
+
const rooted = written.startsWith("/")
|
|
189
|
+
const candidates = rooted ? [ normalizePath(written) ]
|
|
190
|
+
: relative ? [ normalizePath(`${dir}/${written}`), normalizePath(written) ]
|
|
191
|
+
: [ normalizePath(written), normalizePath(`${dir}/${written}`) ]
|
|
192
|
+
const found = candidates.find(exists)
|
|
193
|
+
const target = found ?? candidates[0]
|
|
194
|
+
const ref: BakeMacroRef = { path: target.slice(1), exists: found !== undefined, line: newlines(src.slice(0, start)) + 1, written, missing: [] }
|
|
195
|
+
|
|
196
|
+
let manifest: Manifest | null = null
|
|
197
|
+
if (found !== undefined) {
|
|
198
|
+
const body = read ? await read(found) : null
|
|
199
|
+
if (body === null) ref.unread = true
|
|
200
|
+
else {
|
|
201
|
+
let json: unknown
|
|
202
|
+
try { json = JSON.parse(body.replace(/^\uFEFF/, "")) } catch (e) {
|
|
203
|
+
throw new Error(`bake('${written}'): ${ref.path} is not a bake manifest (${e instanceof Error ? e.message : String(e)}) — rebake with \`lecodes lightmap bake\` (${at(start)})`)
|
|
204
|
+
}
|
|
205
|
+
manifest = readManifest(json)
|
|
206
|
+
if (!manifest) throw new Error(`bake('${written}'): ${ref.path} is not a bake manifest (no light / aux pages) — rebake with \`lecodes lightmap bake\` (${at(start)})`)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// every name in the manifest is relative to the .bake's folder
|
|
211
|
+
const bakeDir = target.slice(0, target.lastIndexOf("/"))
|
|
212
|
+
const ref1 = (name: string): string => {
|
|
213
|
+
const p = normalizePath(`${bakeDir}/${name}`)
|
|
214
|
+
if (!exists(p) && !ref.missing.includes(p)) ref.missing.push(p)
|
|
215
|
+
return `asset(${JSON.stringify(p)})`
|
|
216
|
+
}
|
|
217
|
+
const list = (names: string[]): string => `[${names.map(ref1).join(", ")}]`
|
|
218
|
+
const fields = [ `path: \`${escapeTemplate(ref.path)}\`` ]
|
|
219
|
+
if (manifest) {
|
|
220
|
+
fields.unshift(`data: asset(${JSON.stringify(target)})`)
|
|
221
|
+
fields.push(`light: ${list(manifest.light)}`, `aux: ${list(manifest.aux)}`)
|
|
222
|
+
if (manifest.direct) fields.push(`direct: ${list(manifest.direct)}`)
|
|
223
|
+
if (manifest.indirect) fields.push(`indirect: ${list(manifest.indirect)}`)
|
|
224
|
+
if (manifest.probes) fields.push(`probes: ${ref1(manifest.probes)}`)
|
|
225
|
+
if (manifest.volume) fields.push(`volume: ${ref1(manifest.volume)}`)
|
|
226
|
+
if (manifest.uv1) fields.push(`uv1: ${ref1(manifest.uv1)}`)
|
|
227
|
+
} else {
|
|
228
|
+
fields.unshift("data: null")
|
|
229
|
+
fields.push("light: []", "aux: []")
|
|
230
|
+
}
|
|
231
|
+
// the call's line breaks, kept where they were: before the options, and after them
|
|
232
|
+
const head = `{ ${fields.join(", ")}`
|
|
233
|
+
const expansion = options
|
|
234
|
+
? `${head}, ${"\n".repeat(newlines(src.slice(start, options[0])))}...${src.slice(options[0], options[1])}${"\n".repeat(newlines(src.slice(options[1], close + 1)))} }`
|
|
235
|
+
: `${head}${"\n".repeat(newlines(src.slice(start, close + 1)))} }`
|
|
236
|
+
parts.push(src.slice(done, start), expansion)
|
|
237
|
+
done = close + 1
|
|
238
|
+
bakes.push(ref)
|
|
239
|
+
}
|
|
240
|
+
if (bakes.length === 0) return { src, bakes }
|
|
241
|
+
parts.push(src.slice(done))
|
|
242
|
+
return { src: parts.join(""), bakes }
|
|
243
|
+
}
|
package/src/compile/bundler.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { createRequire } from "node:module"
|
|
|
4
4
|
import { dirname, join, relative } from "node:path"
|
|
5
5
|
import { fileURLToPath } from "node:url"
|
|
6
6
|
import { collectAssetMacros, collectPathLiterals, desugarAssetMacros, hasAssetMacro, type AssetMacroRef } from "./assetMacro"
|
|
7
|
+
import { expandBakeMacros, hasBakeMacro, type BakeMacroRef, type BakeManifestReader } from "./bakeMacro"
|
|
7
8
|
import { scanModulesFallback, type ScanModule } from "./detectEntry"
|
|
8
9
|
import { hasAspectExtends, injectAspectNames } from "./aspectMacro"
|
|
9
10
|
import { hasImportStatement, rewriteLibraryImports } from "./libraryImports"
|
|
@@ -19,6 +20,10 @@ import { chiselVocab } from "../chisel"
|
|
|
19
20
|
|
|
20
21
|
export type MemoryFiles = Record<string, string>
|
|
21
22
|
|
|
23
|
+
/** A `bake()` the bundle reaches (bakeMacro.ts): the level's `.bake`, project-relative, and whether it exists yet —
|
|
24
|
+
* what `lecodes lightmap bake` writes to. */
|
|
25
|
+
export type BundleBake = { path: string, exists: boolean }
|
|
26
|
+
|
|
22
27
|
/** Locate the SDK inject entry: an explicit override (LECODES_SDK_INJECT — used by the published CLI,
|
|
23
28
|
* which vendors the SDK source), else the source in-repo. chisel needs the SOURCE tree (not a bundle). */
|
|
24
29
|
const resolveInject = (): string => {
|
|
@@ -201,15 +206,20 @@ const bundleProjectChisel = async (
|
|
|
201
206
|
editor?: boolean,
|
|
202
207
|
libraries?: Record<string, MemoryFiles>,
|
|
203
208
|
inject: string = CLIENT_INJECT,
|
|
204
|
-
|
|
209
|
+
readResource?: BakeManifestReader,
|
|
210
|
+
): Promise<{ code: string, map: string | null, diagnostics: string[], bakes: BundleBake[] }> => {
|
|
205
211
|
const entry = normalize(entrypoint)
|
|
206
212
|
const slugs = new Set(Object.keys(libraries ?? {}))
|
|
213
|
+
// The compile's files by root path (the editor keys them "./x", the CLI "/x") — what bake() resolves against.
|
|
214
|
+
const rootKeys = new Map(Object.keys(files).map((k) => [ normalize(k), k ]))
|
|
207
215
|
// Per-file source transforms (project files only — SDK aspects declare their own `static aspect`):
|
|
208
216
|
// - rewrite bare library imports (`from "acme-ui"`) → the injected `/__lib/<slug>/…` tree
|
|
217
|
+
// - expand `bake('./x.bake', { … })` → the literal its manifest lists, every file an asset() call (bakeMacro.ts)
|
|
209
218
|
// - desugar `asset('./x')` → a default import that resolves to the synthetic asset module
|
|
210
219
|
// - inject `static aspect = '<name>'` from `class … extends Aspect<'<name>', …>`
|
|
211
220
|
const projFiles: MemoryFiles = {}
|
|
212
221
|
const assetRefs: { file: string, ref: AssetMacroRef }[] = []
|
|
222
|
+
const bakeRefs: { file: string, ref: BakeMacroRef }[] = []
|
|
213
223
|
// What the module scanner sees: import-rewritten, NOT yet asset-desugared (a desugared asset is a
|
|
214
224
|
// default import of a non-module — the fallback scanner drops it, chisel's scan would not).
|
|
215
225
|
const scanSources: MemoryFiles = {}
|
|
@@ -219,6 +229,11 @@ const bundleProjectChisel = async (
|
|
|
219
229
|
if (/\.[tj]sx?$/.test(path)) {
|
|
220
230
|
if (hasImportStatement(src)) src = rewriteLibraryImports(src, slugs)
|
|
221
231
|
scanSources[path] = src
|
|
232
|
+
if (hasBakeMacro(src)) {
|
|
233
|
+
const expanded = await expandBakeMacros(src, path, (rootPath) => rootKeys.has(rootPath), readResource)
|
|
234
|
+
for (const ref of expanded.bakes) bakeRefs.push({ file: path, ref })
|
|
235
|
+
src = expanded.src
|
|
236
|
+
}
|
|
222
237
|
// `asset('…')` calls, then the bare path literals that name a resource of the project (the
|
|
223
238
|
// same reference without the macro); both desugar to the import the author could have written.
|
|
224
239
|
const macros = hasAssetMacro(src) ? collectAssetMacros(src, path) : []
|
|
@@ -255,8 +270,22 @@ const bundleProjectChisel = async (
|
|
|
255
270
|
}
|
|
256
271
|
}
|
|
257
272
|
|
|
258
|
-
|
|
273
|
+
// What the entry reaches is checked: a missing asset, a bake whose manifest names a file that is not there. The bakes
|
|
274
|
+
// it reaches are reported (the CLI's bake target); a manifest this compile could not read compiled unbaked.
|
|
275
|
+
const bakes: BundleBake[] = []
|
|
276
|
+
const notes: string[] = []
|
|
277
|
+
if (assetRefs.length > 0 || bakeRefs.length > 0) {
|
|
259
278
|
const reachable = reachableFrom(entry, scanSources)
|
|
279
|
+
const reached = bakeRefs.filter(({ file }) => reachable.has(file))
|
|
280
|
+
const incomplete = reached.filter(({ ref }) => ref.missing.length > 0)
|
|
281
|
+
if (incomplete.length > 0) {
|
|
282
|
+
throw new Error(incomplete.map(({ file, ref }) =>
|
|
283
|
+
`bake('${ref.written}') (${file.replace(/^\//, "")}:${ref.line}): ${ref.path} names files that are not there: ${ref.missing.map((p) => p.replace(/^\//, "")).join(", ")} — rebake with \`lecodes lightmap bake\``).join("\n"))
|
|
284
|
+
}
|
|
285
|
+
for (const { file, ref } of reached) {
|
|
286
|
+
if (!bakes.some((b) => b.path === ref.path)) bakes.push({ path: ref.path, exists: ref.exists })
|
|
287
|
+
if (ref.unread) notes.push(`${file.replace(/^\//, "")}:${ref.line}: bake('${ref.written}'): this compile cannot read ${ref.path} — the level runs unbaked`)
|
|
288
|
+
}
|
|
260
289
|
assertAssetsExist(assetRefs.filter(({ file }) => reachable.has(file)), projFiles)
|
|
261
290
|
}
|
|
262
291
|
|
|
@@ -308,9 +337,9 @@ const bundleProjectChisel = async (
|
|
|
308
337
|
// Non-fatal build-time findings (`path:line:col: message`) — today: particle curve chains the
|
|
309
338
|
// engine would reject at set time. Warn so they're visible in the CLI / server log; the bundle
|
|
310
339
|
// itself is unaffected (chisel leaves an invalid chain as real calls).
|
|
311
|
-
const diagnostics = parsed.diagnostics ?? []
|
|
340
|
+
const diagnostics = [ ...notes, ...(parsed.diagnostics ?? []) ]
|
|
312
341
|
for (const d of diagnostics) console.warn(`warning: ${d}`)
|
|
313
|
-
return { code: parsed.code, map: parsed.map ?? null, diagnostics }
|
|
342
|
+
return { code: parsed.code, map: parsed.map ?? null, diagnostics, bakes }
|
|
314
343
|
}
|
|
315
344
|
|
|
316
345
|
/**
|
|
@@ -326,7 +355,9 @@ export const bundleProject = async (
|
|
|
326
355
|
/** Like `bundleProject`, but also returns chisel's source map (and any build-time `diagnostics`).
|
|
327
356
|
* The map's `sources` are the original module paths and its generated positions are relative to
|
|
328
357
|
* `code` (line 1 = first line of `code`). Pass `sourcemap: false` to skip map generation entirely —
|
|
329
|
-
* `map` then comes back null.
|
|
358
|
+
* `map` then comes back null. `readResource` reads a resource's text by its root path — the
|
|
359
|
+
* manifest a `bake()` names; without one a bake() compiles unbaked (a diagnostic says so). `bakes`
|
|
360
|
+
* = the bake() targets the entry reaches. */
|
|
330
361
|
export const bundleProjectWithMap = async (
|
|
331
362
|
entrypoint: string,
|
|
332
363
|
files: MemoryFiles,
|
|
@@ -336,4 +367,5 @@ export const bundleProjectWithMap = async (
|
|
|
336
367
|
libraries?: Record<string, MemoryFiles>,
|
|
337
368
|
sourcemap = true,
|
|
338
369
|
inject?: string,
|
|
339
|
-
|
|
370
|
+
readResource?: BakeManifestReader,
|
|
371
|
+
): Promise<{ code: string, map: string | null, diagnostics: string[], bakes: BundleBake[] }> => bundleProjectChisel(entrypoint, files, format, sourcemap, minify, editor, libraries, inject, readResource)
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// (the backend resolves asset URLs from the DB, the CLI from local metadata — same function, the
|
|
4
4
|
// URLs are passed in as `publicUrl` + per-entry `fileSrc`/`shaders`).
|
|
5
5
|
|
|
6
|
-
import { bundleProjectWithMap, scanModulesChisel, type MemoryFiles } from "./bundler"
|
|
6
|
+
import { bundleProjectWithMap, scanModulesChisel, type BundleBake, type MemoryFiles } from "./bundler"
|
|
7
|
+
import type { BakeManifestReader } from "./bakeMacro"
|
|
7
8
|
import { detectEntry } from "./detectEntry"
|
|
8
9
|
import { buildHeader } from "./header"
|
|
9
10
|
import { inlineSourceMapComment, offsetSourceMap } from "./sourcemap"
|
|
@@ -75,12 +76,17 @@ export type CompileOptions = {
|
|
|
75
76
|
* files never reach the client bundle either way; with no URL, calling an endpoint fails with
|
|
76
77
|
* "server not deployed". */
|
|
77
78
|
serverUrl?: string
|
|
79
|
+
/** The TEXT of a resource entry by its root path (`/assets/lightmap/level.bake`) — how `bake()` reads the manifest
|
|
80
|
+
* it names at compile time (the CLI: the file on disk). Without one, or null for a file, that bake() compiles to an
|
|
81
|
+
* unbaked level (a warning says so). */
|
|
82
|
+
readResource?: BakeManifestReader
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
/** The bundle + its map SEPARATELY (the platform deploy writes the map as a sibling `.js.map`; the
|
|
81
86
|
* preview inlines it) plus the entrypoint the compile used. `code` carries the metadata header
|
|
82
|
-
* unless `header: false`; `map` is already shifted past that header.
|
|
83
|
-
|
|
87
|
+
* unless `header: false`; `map` is already shifted past that header. `bakes` = the `bake()` targets
|
|
88
|
+
* the entry reaches (bakeMacro.ts). */
|
|
89
|
+
export type CompileResult = { code: string, map: string | null, entry: string, bakes: BundleBake[] }
|
|
84
90
|
|
|
85
91
|
/** The basename a local resource would be looked up by. */
|
|
86
92
|
export const localAssetBase = (path: string): string => path.split(/[\\/]/).pop() ?? path
|
|
@@ -253,8 +259,8 @@ export const compileProjectWithMap = async (opts: CompileOptions): Promise<Compi
|
|
|
253
259
|
}
|
|
254
260
|
|
|
255
261
|
const files = entriesToFiles(entries, opts)
|
|
256
|
-
const { code, map } = await bundleProjectWithMap(
|
|
257
|
-
entry, files, opts.format ?? "esm", opts.minify, opts.editor, opts.libraries, opts.sourcemap ?? true,
|
|
262
|
+
const { code, map, bakes } = await bundleProjectWithMap(
|
|
263
|
+
entry, files, opts.format ?? "esm", opts.minify, opts.editor, opts.libraries, opts.sourcemap ?? true, undefined, opts.readResource,
|
|
258
264
|
)
|
|
259
265
|
const plugins = opts.plugins ?? pluginsOfAppJson(entries)
|
|
260
266
|
if (map && opts.onWarning) {
|
|
@@ -263,11 +269,11 @@ export const compileProjectWithMap = async (opts: CompileOptions): Promise<Compi
|
|
|
263
269
|
opts.onWarning(`The app uses ${missing.map(id => `"${id}"`).join(", ")} and app.json does not list ${missing.length === 1 ? "it" : "them"}: a plugin that is not listed is not there — in a shell built from this project and in the browser alike. Add to app.json: "plugins": { ${missing.map(id => `"${id}": "*"`).join(", ")} }`)
|
|
264
270
|
}
|
|
265
271
|
}
|
|
266
|
-
if (opts.header === false) return { code, map, entry }
|
|
272
|
+
if (opts.header === false) return { code, map, entry, bakes }
|
|
267
273
|
const header = buildHeader(opts.name, code, { icon: opts.icon, fonts: opts.fontsHeader, icons: opts.iconsHeader, plugins })
|
|
268
274
|
// Shift the map down past the header (prepended before the code).
|
|
269
275
|
const headerLines = (header.match(/\n/g) ?? []).length
|
|
270
|
-
return { code: header + code, map: map ? offsetSourceMap(map, headerLines) : null, entry }
|
|
276
|
+
return { code: header + code, map: map ? offsetSourceMap(map, headerLines) : null, entry, bakes }
|
|
271
277
|
}
|
|
272
278
|
|
|
273
279
|
/** The bundle as ONE string, the map inlined at its end (the shipped/preview form). */
|
package/src/compile/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The public entry of the SDK's bundle build system. The backend, CLI, and local renderer import
|
|
2
2
|
// from here instead of re-forking a bundler. (Node + Bun safe — no top-level host globals.)
|
|
3
3
|
|
|
4
|
-
export { bundleProject, bundleProjectWithMap, scanModulesChisel, CLIENT_INJECT, SERVER_INJECT, type MemoryFiles } from "./bundler"
|
|
4
|
+
export { bundleProject, bundleProjectWithMap, scanModulesChisel, CLIENT_INJECT, SERVER_INJECT, type MemoryFiles, type BundleBake } from "./bundler"
|
|
5
|
+
export { expandBakeMacros, type BakeMacroRef, type BakeManifestReader } from "./bakeMacro"
|
|
5
6
|
export {
|
|
6
7
|
compileServerBundle,
|
|
7
8
|
clientPublishes,
|
|
@@ -36,10 +36,11 @@ export const resourceSlots = {
|
|
|
36
36
|
assignments: [ "src", "lut" ],
|
|
37
37
|
/** The keys of an object literal whose value is a file: `.style({ bgImage })`, `UITabs({ id: { icon } })`,
|
|
38
38
|
* `defineSpriteSheet({ image })`, `defineScene({ nodes: { x: { model } }, env: { ibl, skybox: { texture },
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
39
|
+
* navmesh: { data } } })`, `Terrain.create({ layers: [{ albedo }], normals })`, a material's `map` / `shader`,
|
|
40
|
+
* `Level.place({ build })`. The value may sit in an array, a ternary or an arrow's body. (A level's bake is no
|
|
41
|
+
* slot: `bake()` reads its files off the manifest.) */
|
|
41
42
|
keys: [
|
|
42
43
|
"bgImage", "backgroundImage", "icon", "image", "texture", "model", "ibl", "lut", "albedo", "normals",
|
|
43
|
-
"map", "shader", "build", "data",
|
|
44
|
+
"map", "shader", "build", "data",
|
|
44
45
|
],
|
|
45
46
|
}
|