lecodes-cli 0.20.2 → 1.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.
Files changed (105) hide show
  1. package/README.md +56 -57
  2. package/dist/index.js +5918 -5020
  3. package/package.json +7 -5
  4. package/runtime/materials/decal-relief.mat +12 -1
  5. package/runtime/materials/decal.mat +17 -3
  6. package/runtime/materials/lightmap-baked-lite.mat +6 -1
  7. package/runtime/materials/lightmap-baked.mat +6 -1
  8. package/runtime/materials/lightmap.mat +3 -0
  9. package/runtime/scene-harness.json +1 -1
  10. package/runtime/sdk-types.json +1 -1
  11. package/runtime/web/assets/createViewerLite-DHqTFhWB.js +866 -0
  12. package/runtime/web/assets/{index-BMt7AnC5.js → index-Q06c6oHx.js} +1 -1
  13. package/runtime/web/embed.js +1 -1
  14. package/src/api.ts +1 -302
  15. package/src/cli/args.ts +37 -0
  16. package/src/cli/command.ts +113 -0
  17. package/src/cli/errors.ts +13 -0
  18. package/src/cli/help.ts +133 -0
  19. package/src/cli/index.ts +7 -0
  20. package/src/cli/output.ts +102 -0
  21. package/src/cli/run.ts +106 -0
  22. package/src/commands/{appAndroid.ts → app/android.ts} +451 -42
  23. package/src/commands/{appDesktop.ts → app/desktop.ts} +63 -32
  24. package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +24 -18
  25. package/src/commands/{appIcon.ts → app/icon.ts} +2 -2
  26. package/src/commands/{app.ts → app/index.ts} +397 -289
  27. package/src/commands/{appShared.ts → app/shared.ts} +30 -39
  28. package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +62 -28
  29. package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
  30. package/src/commands/{appTemplates.ts → app/templates/ios.ts} +1 -1
  31. package/src/commands/assets.ts +23 -21
  32. package/src/commands/clone.ts +48 -51
  33. package/src/commands/compile.ts +62 -88
  34. package/src/commands/create.ts +46 -47
  35. package/src/commands/design/comments.ts +361 -0
  36. package/src/commands/design/context.ts +101 -0
  37. package/src/commands/design/index.ts +391 -0
  38. package/src/commands/design/snapshot.ts +134 -0
  39. package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
  40. package/src/commands/desktop.ts +90 -82
  41. package/src/commands/dev.ts +201 -176
  42. package/src/commands/diff.ts +39 -51
  43. package/src/commands/index.ts +60 -0
  44. package/src/commands/{init.ts → init/index.ts} +199 -191
  45. package/src/commands/install.ts +80 -67
  46. package/src/commands/lightmap.ts +203 -171
  47. package/src/commands/link.ts +87 -94
  48. package/src/commands/login.ts +25 -23
  49. package/src/commands/navmesh.ts +168 -138
  50. package/src/commands/pn.ts +201 -244
  51. package/src/commands/pull.ts +49 -56
  52. package/src/commands/push.ts +48 -54
  53. package/src/commands/render.ts +158 -99
  54. package/src/commands/scene.ts +58 -55
  55. package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
  56. package/src/commands/{shadersNew.ts → shaders/new.ts} +2 -2
  57. package/src/commands/shared.ts +79 -0
  58. package/src/commands/status.ts +22 -26
  59. package/src/commands/test.ts +398 -371
  60. package/src/commands/thumbs.ts +185 -175
  61. package/src/commands/update/index.ts +226 -0
  62. package/src/commands/{types.ts → update/types.ts} +31 -22
  63. package/src/compile/collect.ts +5 -5
  64. package/src/compile/collectLocal.ts +3 -3
  65. package/src/compile/designCompile.ts +3 -3
  66. package/src/compile/headlessBundle.ts +133 -129
  67. package/src/compile/projectCompile.ts +2 -2
  68. package/src/compile/sceneCompile.ts +10 -8
  69. package/src/compile/screenEntry.ts +123 -127
  70. package/src/compile/shaders.ts +3 -3
  71. package/src/dev/androidDev.ts +1 -1
  72. package/src/dev/devServer.ts +3 -3
  73. package/src/dev/webRunner.ts +1 -1
  74. package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +1 -1
  75. package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +6 -5
  76. package/src/{desktopScript.ts → hosts/desktopScript.ts} +1 -1
  77. package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
  78. package/src/{matcTool.ts → hosts/matcTool.ts} +1 -1
  79. package/src/{peerInstall.ts → hosts/peerInstall.ts} +2 -2
  80. package/src/{releases.ts → hosts/releases.ts} +1 -1
  81. package/src/index.ts +32 -480
  82. package/src/platform/api.ts +302 -0
  83. package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
  84. package/src/{config.ts → platform/config.ts} +1 -1
  85. package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
  86. package/src/{projectEnv.ts → project/env.ts} +33 -2
  87. package/src/{localFiles.ts → project/localFiles.ts} +1 -1
  88. package/src/{manifest.ts → project/manifest.ts} +1 -1
  89. package/src/{project.ts → project/materialize.ts} +2 -2
  90. package/src/project/paths.ts +20 -0
  91. package/src/{textDiff.ts → project/textDiff.ts} +1 -1
  92. package/src/{types.ts → project/types.ts} +0 -0
  93. package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
  94. package/src/commands/design.ts +0 -846
  95. package/src/commands/update.ts +0 -211
  96. package/src/util.ts +0 -146
  97. /package/src/commands/{projectTemplates.ts → init/templates.ts} +0 -0
  98. /package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +0 -0
  99. /package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +0 -0
  100. /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
  101. /package/src/{lecodes-renderer.d.ts → declarations/lecodes-renderer.d.ts} +0 -0
  102. /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
  103. /package/src/{peers.ts → hosts/peers.ts} +0 -0
  104. /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
  105. /package/src/{ignore.ts → project/ignore.ts} +0 -0
@@ -1,24 +1,26 @@
1
- import { getAccount } from "../api"
2
- import { DEFAULT_API, loadConfig, normalizeApiUrl, saveConfig } from "../config"
3
- import { browserLogin } from "../browserAuth"
4
- import { flagStr, success, c, type Args } from "../util"
1
+ import { c, defineCommand, str, success } from "../cli"
2
+ import { getAccount } from "../platform/api"
3
+ import { browserLogin } from "../platform/browserAuth"
4
+ import { DEFAULT_API, loadConfig, normalizeApiUrl, saveConfig } from "../platform/config"
5
5
 
6
- /*
7
- * `lecodes login` — authorize this machine and store a personal access token in ~/.lecodes.
8
- * - default: open the browser, sign in / authorize on le.codes, capture the token.
9
- * - `--token <pat>`: store a token created in the web UI (headless / CI).
10
- * - `--api <url>`: API origin (defaults to https://le.codes).
11
- * - `--web <url>`: app origin for the browser flow (defaults to the API origin).
12
- */
13
- export const login = async (args: Args) => {
14
- const config = loadConfig()
15
- const apiUrl = normalizeApiUrl(flagStr(args, "api") ?? config.apiUrl ?? DEFAULT_API)
16
- const webBase = normalizeApiUrl(flagStr(args, "web") ?? apiUrl)
17
-
18
- const token = flagStr(args, "token") ?? await browserLogin(webBase)
19
-
20
- // Verify the token works against the API before persisting it.
21
- const account = await getAccount(apiUrl, token)
22
- saveConfig({ apiUrl, token })
23
- success(`Logged in as ${c.bold(account.email)} on ${apiUrl}`)
24
- }
6
+ /* `lecodes login` — authorize this machine; the token lands in ~/.lecodes/config.json. */
7
+ export default defineCommand({
8
+ name: "login",
9
+ summary: "Authorize in the browser and store an access token",
10
+ description: "Opens the browser to sign in / authorize on le.codes and captures the token. `--token` stores a token created in the web UI instead (headless / CI). LECODES_API / LECODES_TOKEN in the environment override the stored values.",
11
+ flags: {
12
+ token: str("store a personal access token from the web UI instead of the browser flow", { value: "<pat>" }),
13
+ api: str(`API origin (default: the stored one, else ${DEFAULT_API})`, { value: "<url>" }),
14
+ web: str("app origin for the browser flow (default: the API origin)", { value: "<url>" }),
15
+ },
16
+ run: async ({ flags }) => {
17
+ const config = loadConfig()
18
+ const apiUrl = normalizeApiUrl(flags.api ?? config.apiUrl ?? DEFAULT_API)
19
+ const webBase = normalizeApiUrl(flags.web ?? apiUrl)
20
+ const token = flags.token ?? await browserLogin(webBase)
21
+ // Verify the token works against the API before persisting it.
22
+ const account = await getAccount(apiUrl, token)
23
+ saveConfig({ apiUrl, token })
24
+ success(`Logged in as ${c.bold(account.email)} on ${apiUrl}`)
25
+ },
26
+ })
@@ -1,11 +1,11 @@
1
1
  import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"
2
2
  import { tmpdir } from "node:os"
3
3
  import { basename, join, relative, resolve } from "node:path"
4
- import { findProjectRoot } from "../manifest"
5
4
  import { buildBundle } from "./compile"
6
- import { readAppConfigAt, resolveDesktopRenderer } from "./appShared"
7
- import { runDesktopRender } from "../desktopRenderer"
8
- import { CliError, c, flagStr, flagBool, logErr, note, warnErr, type Args } from "../util"
5
+ import { readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
6
+ import { runDesktopRender } from "../hosts/desktopRenderer"
7
+ import { CliError, bool, c, defineCommand, logErr, note, num, str, warnErr } from "../cli"
8
+ import { bundleFlags, bundleOptions, localProject } from "./shared"
9
9
  import { loadCreatorNav, NavBuilder, readNgeo, navMeshTriangles, type BuildParams } from "lecodes-creator-nav"
10
10
 
11
11
  /*
@@ -24,13 +24,16 @@ import { loadCreatorNav, NavBuilder, readNgeo, navMeshTriangles, type BuildParam
24
24
  * lets the bake parameters iterate without a host rebuild, and is the same code a future headless
25
25
  * bake reuses. The desktop host must know the dump (built from packages/desktop, or a release that
26
26
  * ships it: LECODES_DESKTOP_EXE for a local build until then).
27
+ *
28
+ * The agent size (--radius/--height/--climb/--slope) defaults to what the scene declares — the
29
+ * host's `[navmesh] config` line carries it — so those flags have no static default.
27
30
  */
28
31
 
29
- const num = (args: Args, key: string, fallback: number, min: number, max: number): number => {
30
- const raw = flagStr(args, key)
31
- if (raw === undefined) return fallback
32
- const n = Number(raw)
33
- if (!Number.isFinite(n) || n < min || n > max) throw new CliError(`--${key} wants a number in ${min}..${max}, got "${raw}".`)
32
+ /** A numeric flag Recast gets: the framework parses it, the range is checked here. `fallback` is
33
+ * the scene's value for the agent-size flags. */
34
+ const range = (name: string, value: number | undefined, fallback: number, min: number, max: number): number => {
35
+ const n = value ?? fallback
36
+ if (n < min || n > max) throw new CliError(`--${name} wants a number in ${min}..${max}, got "${n}".`)
34
37
  return n
35
38
  }
36
39
 
@@ -85,141 +88,168 @@ const writeObj = (path: string, tris: Float32Array): void => {
85
88
  writeFileSync(path, lines.join("\n") + "\n")
86
89
  }
87
90
 
88
- const bake = async (args: Args): Promise<void> => {
89
- let root: string
90
- try { root = findProjectRoot(process.cwd()) } catch { root = process.cwd() }
91
- const outDir = resolve(root, flagStr(args, "out") ?? "assets/nav")
92
- const entryFlag = flagStr(args, "entry")
93
- const entryFile = entryFlag ?? "main.ts"
94
- if (!existsSync(resolve(root, entryFile))) {
95
- throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the level: a scene file with env.navmesh, or code calling NavMesh.load).`)
96
- }
97
- const name = flagStr(args, "name") ?? guessName(root) ?? "scene"
98
- const frames = num(args, "frames", 900, 30, 100000)
99
- const win = readAppConfigAt(root)?.desktop?.window
100
- const width = Number(flagStr(args, "width")) || win?.width || 1280
101
- const height = Number(flagStr(args, "height")) || win?.height || 720
102
- const logs = flagBool(args, "logs")
103
- const keepGeometry = flagBool(args, "keep-geometry")
104
- const obj = flagBool(args, "obj")
105
-
106
- // The navmesh file must exist BEFORE the compile: the scene references it through asset(), and a
107
- // missing asset is a broken bundle. The first bake starts from a placeholder the real file overwrites.
108
- mkdirSync(outDir, { recursive: true })
109
- const navmeshPath = join(outDir, `${name}.navmesh`)
110
- if (!existsSync(navmeshPath)) writeFileSync(navmeshPath, placeholderLnav())
91
+ const bake = defineCommand({
92
+ name: "bake",
93
+ summary: "Build the level's navigation mesh for NavAgent / NavMesh",
94
+ description: "The desktop host dumps every STATIC body's collision triangles at the scene's env.navmesh (or NavMesh.load) point, then Recast builds the tiled mesh here → <out>/<name>.navmesh. The agent size defaults to what the scene declares. Needs a desktop host with the geometry dump (LECODES_DESKTOP_EXE for a local build).",
95
+ flags: {
96
+ out: str("where it lands", { value: "<dir>", default: "assets/nav" }),
97
+ name: str("file name (default: the scene file that declares navmesh:, else scene)", { value: "<n>" }),
98
+ ...bundleFlags,
99
+ entry: str("the file that builds the level: a scene file with env.navmesh, or code calling NavMesh.load", { value: "<file>", default: "main.ts" }),
100
+ radius: num("agent radius (m; default: the scene's)"),
101
+ height: num("agent height (m; default: the scene's)"),
102
+ climb: num("agent max step height (m; default: the scene's)"),
103
+ slope: num("agent max walkable slope (deg; default: the scene's)"),
104
+ cell: num("voxel size (m)", { default: 0.25 }),
105
+ "cell-height": num("voxel height (m)", { default: 0.1 }),
106
+ tile: num("tile size (cells; 0 = one tile)", { default: 48 }),
107
+ "region-min": num("min region area (cells²)", { default: 8 }),
108
+ "region-merge": num("regions smaller than this merge (cells²)", { default: 20 }),
109
+ "edge-max": num("max polygon edge length (voxels)", { default: 12 }),
110
+ "edge-error": num("max edge simplification error (voxels)", { default: 1.3 }),
111
+ detail: num("detail mesh sample distance (voxels)", { default: 6 }),
112
+ "detail-error": num("detail mesh max error (voxels)", { default: 1 }),
113
+ obj: bool("also write <name>.navmesh.obj to inspect the polygons"),
114
+ "keep-geometry": bool("keep the host's <name>.navgeo dump"),
115
+ frames: num("host frames to run before giving up — load headroom", { default: 900 }),
116
+ timeout: num("host time cap (ms)", { default: 600000 }),
117
+ width: num("host window width (default: app.json desktop.window, else 1280)"),
118
+ renderer: str("desktop host + shader variant (default: app.json desktop.renderer, else gl)", { value: "<gl|vulkan>" }),
119
+ logs: bool("forward the host's output"),
120
+ },
121
+ examples: ["lecodes navmesh bake", "lecodes navmesh bake --name yard --cell 0.2 --obj --logs"],
122
+ run: async ({ flags }) => {
123
+ const { root } = localProject()
124
+ const outDir = resolve(root, flags.out)
125
+ const entryFile = flags.entry
126
+ if (!existsSync(resolve(root, entryFile))) {
127
+ throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the level: a scene file with env.navmesh, or code calling NavMesh.load).`)
128
+ }
129
+ const name = flags.name ?? guessName(root) ?? "scene"
130
+ const frames = range("frames", flags.frames, 900, 30, 100000)
131
+ const win = readAppConfigAt(root)?.desktop?.window
132
+ const width = flags.width || win?.width || 1280
133
+ const height = win?.height || 720
134
+ const logs = flags.logs
111
135
 
112
- const importPath = "./" + entryFile.replace(/\\/g, "/").replace(/\.tsx?$/, "")
113
- const entryPath = "/.navmesh-entry.ts"
114
- const cfg = { outDir: outDir.replace(/\\/g, "/"), name }
115
- const entryText = `globalThis.__lecodesNavmesh = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
136
+ // The navmesh file must exist BEFORE the compile: the scene references it through asset(), and a
137
+ // missing asset is a broken bundle. The first bake starts from a placeholder the real file overwrites.
138
+ mkdirSync(outDir, { recursive: true })
139
+ const navmeshPath = join(outDir, `${name}.navmesh`)
140
+ if (!existsSync(navmeshPath)) writeFileSync(navmeshPath, placeholderLnav())
116
141
 
117
- const desktopRenderer = resolveDesktopRenderer(root, args)
118
- const { js, resources } = await buildBundle(args, undefined, {
119
- localAssets: true, desktopRenderer,
120
- extraEntries: [{ path: entryPath, type: "text", text: entryText }],
121
- entryOverride: entryPath,
122
- })
142
+ const importPath = "./" + entryFile.replace(/\\/g, "/").replace(/\.tsx?$/, "")
143
+ const entryPath = "/.navmesh-entry.ts"
144
+ const cfg = { outDir: outDir.replace(/\\/g, "/"), name }
145
+ const entryText = `globalThis.__lecodesNavmesh = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
123
146
 
124
- const scratch = mkdtempSync(join(tmpdir(), "lecodes-navmesh-"))
125
- const outPng = join(scratch, "frame.png")
126
- note(`Dumping the level's static collision geometry through the desktop host → ${relative(root, outDir) || "."}/${name}.navgeo…`)
127
- let output = ""
128
- try {
129
- const timeoutMs = Number(flagStr(args, "timeout")) || 600000
130
- const res = await runDesktopRender(js, resources, {
131
- renderer: desktopRenderer, width, height, frames, outPng, logs, timeoutMs,
132
- stopOnLine: (chunk) => chunk.includes("[navmesh] done") || chunk.includes("[navmesh] error"),
147
+ const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
148
+ const { js, resources } = await buildBundle(bundleOptions(flags), {
149
+ localAssets: true, desktopRenderer,
150
+ extraEntries: [{ path: entryPath, type: "text", text: entryText }],
151
+ entryOverride: entryPath,
133
152
  })
134
- output = res.output
135
- } catch (e) {
136
- const msg = e instanceof Error ? e.message : String(e)
137
- if (!/\[navmesh\] done/.test(msg)) throw e
138
- output = msg
139
- } finally {
140
- rmSync(scratch, { recursive: true, force: true })
141
- }
142
153
 
143
- // the markers are console.log lines from the SDK — the host prefixes them ("[DEBUG] [navmesh] …")
144
- const lines = output.split(/\r?\n/).filter((l) => l.includes("[navmesh]")).map((l) => l.slice(l.indexOf("[navmesh]")))
145
- let config: BakeConfigLine | null = null
146
- let triangles = 0
147
- for (const l of lines) {
148
- const text = l.slice("[navmesh]".length).trim()
149
- if (text === "done") continue
150
- if (text.startsWith("config ")) { try { config = JSON.parse(text.slice(7)) as BakeConfigLine } catch { /* reported below */ } continue }
151
- const geo = /^geometry (\d+) triangles/.exec(text)
152
- if (geo) triangles = Number(geo[1])
153
- logErr(text.startsWith("error") ? c.red(`✗ ${text}`) : c.dim(` ${text}`))
154
- }
155
- if (!/\[navmesh\] done/.test(output)) {
156
- if (lines.some((l) => /cannot dump collision geometry/.test(l))) {
157
- throw new CliError("This desktop host has no collision-geometry dump. Build packages/desktop and point LECODES_DESKTOP_EXE at build/lecodes-desktop.exe, or update: lecodes desktop update.")
154
+ const scratch = mkdtempSync(join(tmpdir(), "lecodes-navmesh-"))
155
+ const outPng = join(scratch, "frame.png")
156
+ note(`Dumping the level's static collision geometry through the desktop host → ${relative(root, outDir) || "."}/${name}.navgeo…`)
157
+ let output = ""
158
+ try {
159
+ const res = await runDesktopRender(js, resources, {
160
+ renderer: desktopRenderer, width, height, frames, outPng, logs, timeoutMs: flags.timeout || 600000,
161
+ stopOnLine: (chunk) => chunk.includes("[navmesh] done") || chunk.includes("[navmesh] error"),
162
+ })
163
+ output = res.output
164
+ } catch (e) {
165
+ const msg = e instanceof Error ? e.message : String(e)
166
+ if (!/\[navmesh\] done/.test(msg)) throw e
167
+ output = msg
168
+ } finally {
169
+ rmSync(scratch, { recursive: true, force: true })
158
170
  }
159
- if (lines.some((l) => /error/.test(l))) throw new CliError("The bake failed — see the lines above.")
160
- warnErr("The host ran to its frame cap without dumping geometry. Make sure the level declares `env.navmesh` (a scene file) or calls NavMesh.load(...) after its static bodies exist, and skips menus when NavMesh.baking is true. More load headroom: --frames <n>. Host output: --logs.")
161
- throw new CliError("Nothing was baked.")
162
- }
163
- if (!config) throw new CliError("The host reported a dump but no `[navmesh] config` line — SDK/host mismatch?")
164
- const geoPath = join(outDir, `${name}.navgeo`)
165
- if (!existsSync(geoPath)) throw new CliError(`The host reported a dump but ${geoPath} is missing.`)
166
171
 
167
- // ---- Recast (wasm) ----
168
- const geo = readNgeo(new Uint8Array(readFileSync(geoPath)))
169
- const params: BuildParams = {
170
- agentRadius: num(args, "radius", config.agent.radius, 0.05, 10),
171
- agentHeight: num(args, "height", config.agent.height, 0.1, 20),
172
- agentMaxClimb: num(args, "climb", config.agent.climb, 0, 10),
173
- agentMaxSlope: num(args, "slope", config.agent.slope, 0, 89),
174
- cellSize: num(args, "cell", 0.25, 0.01, 5),
175
- cellHeight: num(args, "cell-height", 0.1, 0.01, 5),
176
- tileSize: num(args, "tile", 48, 0, 1024),
177
- regionMinSize: num(args, "region-min", 8, 0, 1000),
178
- regionMergeSize: num(args, "region-merge", 20, 0, 1000),
179
- edgeMaxLen: num(args, "edge-max", 12, 0, 1000),
180
- edgeMaxError: num(args, "edge-error", 1.3, 0.1, 10),
181
- detailSampleDist: num(args, "detail", 6, 0, 100),
182
- detailMaxError: num(args, "detail-error", 1, 0, 100),
183
- }
184
- note(`Building the navmesh: ${geo.triangleCount} triangles from ${geo.bodyCount} static bodies, agent r ${params.agentRadius} / h ${params.agentHeight} / climb ${params.agentMaxClimb} / slope ${params.agentMaxSlope}°, cell ${params.cellSize} m…`)
185
- const mod = await loadCreatorNav()
186
- const builder = new NavBuilder(mod, params)
187
- let result
188
- try {
189
- builder.addSoup(geo.triangles, geo.areas)
190
- const meta = {
191
- scene: name, areas: config.areas, costs: config.costs, bakedAt: new Date().toISOString(),
192
- params, triangles: geo.triangleCount, bodies: geo.bodyCount,
172
+ // the markers are console.log lines from the SDK — the host prefixes them ("[DEBUG] [navmesh] …")
173
+ const lines = output.split(/\r?\n/).filter((l) => l.includes("[navmesh]")).map((l) => l.slice(l.indexOf("[navmesh]")))
174
+ let config: BakeConfigLine | null = null
175
+ let triangles = 0
176
+ for (const l of lines) {
177
+ const text = l.slice("[navmesh]".length).trim()
178
+ if (text === "done") continue
179
+ if (text.startsWith("config ")) { try { config = JSON.parse(text.slice(7)) as BakeConfigLine } catch { /* reported below */ } continue }
180
+ const geo = /^geometry (\d+) triangles/.exec(text)
181
+ if (geo) triangles = Number(geo[1])
182
+ logErr(text.startsWith("error") ? c.red(`✗ ${text}`) : c.dim(` ${text}`))
193
183
  }
194
- let lastTile = -1
195
- result = builder.run(meta, (tile, count, message) => {
196
- if (message) logErr(c.dim(` recast: ${message}`))
197
- else if (count > 8 && tile !== lastTile && (tile + 1) % Math.max(1, Math.floor(count / 8)) === 0) { lastTile = tile; logErr(c.dim(` tile ${tile + 1}/${count}`)) }
198
- })
199
- } finally {
200
- builder.destroy()
201
- }
202
- if (result.polys === 0) {
203
- throw new CliError("The build produced no polygons — nothing walkable for this agent. Check the cell size against the level's scale, the slope, and that the floor is a STATIC body (Shape + Physics { motion: 'static' }).")
204
- }
205
- writeFileSync(navmeshPath, result.data)
206
- if (obj) {
207
- const objPath = join(outDir, `${name}.navmesh.obj`)
208
- writeObj(objPath, navMeshTriangles(mod, result.data))
209
- logErr(c.dim(` wrote ${relative(root, objPath)} (the polygon mesh, for inspection)`))
210
- }
211
- if (!keepGeometry) unlinkSync(geoPath)
212
- const kb = Math.round(statSync(navmeshPath).size / 1024)
213
- const b = result.bounds
214
- logErr(c.dim(` ${result.tiles} tiles, ${result.polys} polygons, ${(b.max[0] - b.min[0]).toFixed(0)} × ${(b.max[2] - b.min[2]).toFixed(0)} m, ${result.time.toFixed(1)} s`))
215
- logErr(c.green(`✓ baked → ${relative(root, navmeshPath).replace(/\\/g, "/")} (${kb} KB)`))
216
- if (!config.data) {
217
- logErr(c.dim(` load it with env.navmesh: { data: asset('<rel>/${basename(navmeshPath)}') } in the scene file, or NavMesh.load(scene, { data: asset(...) })`))
218
- }
219
- }
184
+ if (!/\[navmesh\] done/.test(output)) {
185
+ if (lines.some((l) => /cannot dump collision geometry/.test(l))) {
186
+ throw new CliError("This desktop host has no collision-geometry dump. Build packages/desktop and point LECODES_DESKTOP_EXE at build/lecodes-desktop.exe, or update: lecodes desktop update.")
187
+ }
188
+ if (lines.some((l) => /error/.test(l))) throw new CliError("The bake failed — see the lines above.")
189
+ warnErr("The host ran to its frame cap without dumping geometry. Make sure the level declares `env.navmesh` (a scene file) or calls NavMesh.load(...) after its static bodies exist, and skips menus when NavMesh.baking is true. More load headroom: --frames <n>. Host output: --logs.")
190
+ throw new CliError("Nothing was baked.")
191
+ }
192
+ if (!config) throw new CliError("The host reported a dump but no `[navmesh] config` line — SDK/host mismatch?")
193
+ const geoPath = join(outDir, `${name}.navgeo`)
194
+ if (!existsSync(geoPath)) throw new CliError(`The host reported a dump but ${geoPath} is missing.`)
220
195
 
221
- export const navmesh = async (args: Args): Promise<void> => {
222
- const sub = args._[0]
223
- if (sub === "bake") return bake({ ...args, _: args._.slice(1) })
224
- throw new CliError("Usage: lecodes navmesh bake [--out assets/nav] [--name <scene>] [--entry main.ts] [--radius 0.4] [--height 1.8] [--climb 0.4] [--slope 50] [--cell 0.25] [--cell-height 0.1] [--tile 48] [--obj] [--keep-geometry] [--frames 900] [--logs]")
225
- }
196
+ // ---- Recast (wasm) ----
197
+ const geo = readNgeo(new Uint8Array(readFileSync(geoPath)))
198
+ const params: BuildParams = {
199
+ agentRadius: range("radius", flags.radius, config.agent.radius, 0.05, 10),
200
+ agentHeight: range("height", flags.height, config.agent.height, 0.1, 20),
201
+ agentMaxClimb: range("climb", flags.climb, config.agent.climb, 0, 10),
202
+ agentMaxSlope: range("slope", flags.slope, config.agent.slope, 0, 89),
203
+ cellSize: range("cell", flags.cell, 0.25, 0.01, 5),
204
+ cellHeight: range("cell-height", flags["cell-height"], 0.1, 0.01, 5),
205
+ tileSize: range("tile", flags.tile, 48, 0, 1024),
206
+ regionMinSize: range("region-min", flags["region-min"], 8, 0, 1000),
207
+ regionMergeSize: range("region-merge", flags["region-merge"], 20, 0, 1000),
208
+ edgeMaxLen: range("edge-max", flags["edge-max"], 12, 0, 1000),
209
+ edgeMaxError: range("edge-error", flags["edge-error"], 1.3, 0.1, 10),
210
+ detailSampleDist: range("detail", flags.detail, 6, 0, 100),
211
+ detailMaxError: range("detail-error", flags["detail-error"], 1, 0, 100),
212
+ }
213
+ note(`Building the navmesh: ${geo.triangleCount} triangles from ${geo.bodyCount} static bodies, agent r ${params.agentRadius} / h ${params.agentHeight} / climb ${params.agentMaxClimb} / slope ${params.agentMaxSlope}°, cell ${params.cellSize} m…`)
214
+ const mod = await loadCreatorNav()
215
+ const builder = new NavBuilder(mod, params)
216
+ let result
217
+ try {
218
+ builder.addSoup(geo.triangles, geo.areas)
219
+ const meta = {
220
+ scene: name, areas: config.areas, costs: config.costs, bakedAt: new Date().toISOString(),
221
+ params, triangles: geo.triangleCount, bodies: geo.bodyCount,
222
+ }
223
+ let lastTile = -1
224
+ result = builder.run(meta, (tile, count, message) => {
225
+ if (message) logErr(c.dim(` recast: ${message}`))
226
+ else if (count > 8 && tile !== lastTile && (tile + 1) % Math.max(1, Math.floor(count / 8)) === 0) { lastTile = tile; logErr(c.dim(` tile ${tile + 1}/${count}`)) }
227
+ })
228
+ } finally {
229
+ builder.destroy()
230
+ }
231
+ if (result.polys === 0) {
232
+ throw new CliError("The build produced no polygons — nothing walkable for this agent. Check the cell size against the level's scale, the slope, and that the floor is a STATIC body (Shape + Physics { motion: 'static' }).")
233
+ }
234
+ writeFileSync(navmeshPath, result.data)
235
+ if (flags.obj) {
236
+ const objPath = join(outDir, `${name}.navmesh.obj`)
237
+ writeObj(objPath, navMeshTriangles(mod, result.data))
238
+ logErr(c.dim(` wrote ${relative(root, objPath)} (the polygon mesh, for inspection)`))
239
+ }
240
+ if (!flags["keep-geometry"]) unlinkSync(geoPath)
241
+ const kb = Math.round(statSync(navmeshPath).size / 1024)
242
+ const b = result.bounds
243
+ logErr(c.dim(` ${result.tiles} tiles, ${result.polys} polygons, ${(b.max[0] - b.min[0]).toFixed(0)} × ${(b.max[2] - b.min[2]).toFixed(0)} m, ${result.time.toFixed(1)} s`))
244
+ logErr(c.green(`✓ baked → ${relative(root, navmeshPath).replace(/\\/g, "/")} (${kb} KB)`))
245
+ if (!config.data) {
246
+ logErr(c.dim(` load it with env.navmesh: { data: asset('<rel>/${basename(navmeshPath)}') } in the scene file, or NavMesh.load(scene, { data: asset(...) })`))
247
+ }
248
+ },
249
+ })
250
+
251
+ export default defineCommand({
252
+ name: "navmesh",
253
+ summary: "Build the level's navigation mesh (bake)",
254
+ commands: [bake],
255
+ })