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/package.json CHANGED
@@ -43,7 +43,7 @@
43
43
  "marcidb-embedded": "^0.15.0",
44
44
  "sharp": "^0.35.2"
45
45
  },
46
- "version": "2.4.7",
46
+ "version": "2.4.9",
47
47
  "files": [
48
48
  "src",
49
49
  "dist",
@@ -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
+ }
@@ -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
- ): Promise<{ code: string, map: string | null, diagnostics: string[] }> => {
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
- if (assetRefs.length > 0) {
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
- ): Promise<{ code: string, map: string | null, diagnostics: string[] }> => bundleProjectChisel(entrypoint, files, format, sourcemap, minify, editor, libraries, inject)
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
- export type CompileResult = { code: string, map: string | null, entry: string }
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). */
@@ -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
- * lightmap: { data, light, … } } })`, `Terrain.create({ layers: [{ albedo }], normals })`, a material's
40
- * `map` / `shader`, `Level.place({ build })`. The value may sit in an array, a ternary or an arrow's body. */
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", "light", "aux", "direct", "indirect", "probes", "volume",
44
+ "map", "shader", "build", "data",
44
45
  ],
45
46
  }