lecodes-cli 2.4.5 → 2.4.7

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.
@@ -24,6 +24,7 @@ import {
24
24
  type AndroidRuntimePlugin, type AndroidShellIdentity, runtimeManifestTemplate, urlSchemeFilter, appLinksFilter,
25
25
  } from "./templates/android"
26
26
  import { GRADLEW_BAT, GRADLEW_SH } from "./templates/gradlew"
27
+ import { readServiceAccount, serviceAccountToken, uploadToPlay } from "./play"
27
28
 
28
29
  /* The Android arm of `lecodes app` — the mirror of the iOS flows in index.ts, over Gradle
29
30
  * instead of Xcode: init / sync / open / doctor checks as functions the shared command nodes call,
@@ -637,10 +638,9 @@ const buildAndroid = async (flags: FlagValues<typeof androidBuildFlags>): Promis
637
638
  }
638
639
  if (produced.length === 0) throw new CliError("Gradle finished but produced no .apk/.aab — check android/app/build/outputs.")
639
640
 
640
- const slug = flags.slug ?? slugify(config.name ?? basename(root))
641
641
  const outDir = resolve(root, flags.out ?? join(root, "dist"))
642
642
  mkdirSync(outDir, { recursive: true })
643
- const stem = `${slug}-${config.version ?? "1.0.0"}-${config.android.versionCode ?? 1}${configuration === "debug" ? "-debug" : ""}`
643
+ const stem = `${artifactStem(root, config, flags.slug)}${configuration === "debug" ? "-debug" : ""}`
644
644
  for (const src of produced) {
645
645
  const unsigned = configuration === "release" && !signing
646
646
  const dest = join(outDir, `${stem}${unsigned ? "-unsigned" : ""}${src.endsWith(".aab") ? ".aab" : ".apk"}`)
@@ -651,7 +651,7 @@ const buildAndroid = async (flags: FlagValues<typeof androidBuildFlags>): Promis
651
651
  success(configuration === "debug"
652
652
  ? "Debug build done — `adb install` the .apk to test (or `lecodes dev --android --apk <file>`)."
653
653
  : signing
654
- ? "Release build done — upload the .aab in the Play Console (the .apk side-loads for testing)."
654
+ ? "Release build done — `lecodes app upload android` sends the .aab to Play (the .apk side-loads for testing)."
655
655
  : "Unsigned release build done — sign it before it goes anywhere.")
656
656
  }
657
657
 
@@ -664,6 +664,84 @@ export const androidBuild = defineCommand({
664
664
  run: ({ flags }) => buildAndroid(flags),
665
665
  })
666
666
 
667
+ /** dist/'s name for a build of this app.json — `<slug>-<version>-<versionCode>`, so the upload
668
+ * finds the bundle of the versionCode app.json says, never an older one left in dist/. */
669
+ const artifactStem = (root: string, config: AppConfig, slug?: string): string =>
670
+ `${slug ?? slugify(config.name ?? basename(root))}-${config.version ?? "1.0.0"}-${config.android?.versionCode ?? 1}`
671
+
672
+ /* ---------------------------------------- upload ---------------------------------------- */
673
+
674
+ /** The project .env key the service account key is remembered under (CI sets it itself). */
675
+ export const PLAY_KEY_ENV = "LECODES_PLAY_SERVICE_ACCOUNT"
676
+
677
+ const androidUploadFlags = {
678
+ aab: str("the bundle (default: the dist/<name>-<version>-<versionCode>.aab `app build android` wrote)", { value: "<file>" }),
679
+ track: str("internal | alpha | beta | production | a closed track's name", { value: "<t>", default: "internal" }),
680
+ draft: bool("a draft release, rolled out in Play Console (the only kind a never-published app takes)"),
681
+ key: str("the service account's JSON key (remembered in the project .env)", { value: "<json>" }),
682
+ slug: str("file name stem of the default bundle, as given to `app build android --slug`", { value: "<name>" }),
683
+ }
684
+
685
+ /** `lecodes app upload android` — the bundle onto a Play track through one edit (play.ts). */
686
+ const uploadAndroid = async (flags: FlagValues<typeof androidUploadFlags>): Promise<void> => {
687
+ const root = projectRoot()
688
+ const config = readAppConfigAt(root)
689
+ if (!config?.android?.applicationId || !existsSync(join(root, "android"))) {
690
+ throw new CliError("No Android shell here — run `lecodes app init android` first.")
691
+ }
692
+ const packageName = config.android.applicationId
693
+
694
+ const aabPath = flags.aab ? resolve(flags.aab) : join(root, "dist", `${artifactStem(root, config, flags.slug)}.aab`)
695
+ if (!existsSync(aabPath)) {
696
+ throw new CliError(`No bundle at ${relative(root, aabPath) || aabPath} — run \`lecodes app build android\` first (it names the file by app.json's version + versionCode).`)
697
+ }
698
+
699
+ // The key: --key on first use, then the project .env — per project, not ~/.lecodes like the
700
+ // iOS key, because two apps may live in two developer accounts.
701
+ const given = flags.key
702
+ const keyPath = given ? resolve(given.startsWith("~/") ? join(homedir(), given.slice(2)) : given) : process.env[PLAY_KEY_ENV]
703
+ if (!keyPath) {
704
+ throw new CliError([
705
+ `A Google Play service account key is needed: --key <file.json> (remembered in the project .env as ${PLAY_KEY_ENV}).`,
706
+ " Google Cloud: enable the Google Play Android Developer API, then IAM → Service accounts → Keys → Add key → JSON;",
707
+ " Play Console → Users and permissions: invite the account's email with \"Release to testing tracks\" for the app.",
708
+ ].join("\n"))
709
+ }
710
+ if (!existsSync(keyPath)) throw new CliError(`Service account key not found: ${keyPath}`)
711
+ const key = readServiceAccount(keyPath)
712
+ if (given) {
713
+ upsertDotEnv(root, { [PLAY_KEY_ENV]: envValue(envPathFor(root, keyPath)) })
714
+ note(`Stored the key reference in .env (${PLAY_KEY_ENV})`)
715
+ }
716
+
717
+ const size = (statSync(aabPath).size / (1024 * 1024)).toFixed(1)
718
+ note(`Uploading ${relative(root, aabPath) || aabPath} (${size} MB) to ${packageName}, track ${flags.track}${flags.draft ? " as a draft" : ""} — as ${key.client_email}`)
719
+ const token = await serviceAccountToken(key)
720
+ const { versionCode } = await uploadToPlay(token, {
721
+ packageName, aab: readFileSync(aabPath), track: flags.track, status: flags.draft ? "draft" : "completed",
722
+ })
723
+
724
+ // A versionCode Play has seen is spent: app.json moves past it for the next build (`app upload
725
+ // ios` does the same with buildNumber). Never backwards — --aab may name an older bundle.
726
+ const fresh = readAppConfigAt(root)!
727
+ const next = Math.max(fresh.android!.versionCode ?? 1, versionCode + 1)
728
+ if (next !== fresh.android!.versionCode) {
729
+ fresh.android!.versionCode = next
730
+ writeJson(join(root, "app.json"), fresh)
731
+ }
732
+ success(`Uploaded versionCode ${versionCode} to the ${flags.track} track${flags.draft ? " as a draft — roll it out in Play Console" : ""}.`)
733
+ note(`android.versionCode is ${next} for the next build.`)
734
+ }
735
+
736
+ export const androidUpload = defineCommand({
737
+ name: "android",
738
+ summary: "Upload the .aab to a Google Play track (then bumps android.versionCode)",
739
+ description: `One edit of the Google Play Developer API — the bundle onto the track as one release, then the commit; Play changes only when every step went through. Authenticated with a Cloud service account's JSON key: --key on first use, then the project .env remembers it (${PLAY_KEY_ENV}; in CI set the variable to the key's path, e.g. a GitLab File variable). The app must already exist in Play Console under app.json android.applicationId (the API cannot create it), the account be invited there with "Release to testing tracks". A successful upload spends the versionCode, so app.json android.versionCode moves past it for the next build.`,
740
+ flags: androidUploadFlags,
741
+ examples: ["lecodes app upload android --key ~/keys/play.json", "lecodes app upload android --track internal", "lecodes app upload android --draft"],
742
+ run: ({ flags }) => uploadAndroid(flags),
743
+ })
744
+
667
745
  /* --------------------------------------- signing --------------------------------------- */
668
746
 
669
747
  /** The four names the generated app/build.gradle.kts reads with System.getenv — the project
@@ -20,7 +20,7 @@ import {
20
20
  } from "./shared"
21
21
  import { isLocalPluginSpec, type PluginEntry } from "lecodes-sdk/compile"
22
22
  import {
23
- ANDROID_SDK_MIN_VERSION, ANDROID_SDK_REPO, androidBuild, doctorAndroidChecks, initAndroid,
23
+ ANDROID_SDK_MIN_VERSION, ANDROID_SDK_REPO, androidBuild, androidUpload, doctorAndroidChecks, initAndroid,
24
24
  keystore, openAndroid, shellSdkVersionAndroid, syncAndroid, type SyncArtifacts,
25
25
  } from "./android"
26
26
  import { desktopBuild, initDesktop } from "./desktop"
@@ -979,17 +979,8 @@ const iosUpload = defineCommand({
979
979
 
980
980
  const upload = defineCommand({
981
981
  name: "upload",
982
- summary: "Upload a build to the store: ios (App Store Connect)",
983
- commands: [
984
- iosUpload,
985
- // Not wired yet — kept as a node so `app upload android` says what to do instead of "unknown command".
986
- defineCommand({
987
- name: "android",
988
- summary: "Play upload (not wired yet)",
989
- hidden: true,
990
- run: () => { throw new CliError("Play upload isn't wired yet — upload android/app/build/outputs/bundle/release/*.aab in the Play Console (`lecodes app build android` produces it).") },
991
- }),
992
- ],
982
+ summary: "Upload a build to the store: ios (App Store Connect) | android (Google Play)",
983
+ commands: [iosUpload, androidUpload],
993
984
  })
994
985
 
995
986
  /* --------------------------------------- open ---------------------------------------- */
@@ -0,0 +1,107 @@
1
+ import { sign } from "node:crypto"
2
+ import { readFileSync } from "node:fs"
3
+ import { CliError } from "../../cli"
4
+
5
+ /* Google Play: the Developer API's one upload path, the v3 EDIT — the Android twin of `app upload
6
+ * ios`. An edit is a transaction: insert → upload the bundle → put it on a track → commit. Nothing
7
+ * reaches Play before the commit, so a failure anywhere leaves the app as it was (an uncommitted
8
+ * edit simply expires). The edit calls take a bare access token: a service account's JSON key
9
+ * produces one here (CI, a key nobody signs in with); a user's OAuth refresh token would be the
10
+ * other source (the platform's "Connect Google Play") and changes nothing below. */
11
+
12
+ const SCOPE = "https://www.googleapis.com/auth/androidpublisher"
13
+
14
+ /** `LECODES_PLAY_API_URL` points the edit calls at a fake (the tests). */
15
+ const apiBase = () => process.env.LECODES_PLAY_API_URL ?? "https://androidpublisher.googleapis.com"
16
+
17
+ /** The fields of a Cloud service account's JSON key the token exchange needs. */
18
+ export interface ServiceAccountKey {
19
+ client_email: string
20
+ private_key: string
21
+ token_uri?: string
22
+ }
23
+
24
+ export const readServiceAccount = (path: string): ServiceAccountKey => {
25
+ let key: Partial<ServiceAccountKey> & { type?: string }
26
+ try {
27
+ key = JSON.parse(readFileSync(path, "utf8"))
28
+ } catch {
29
+ throw new CliError(`${path} is not a JSON key file.`)
30
+ }
31
+ if (key.type !== "service_account" || !key.client_email || !key.private_key) {
32
+ throw new CliError(`${path} is not a service account key (Google Cloud → IAM → Service accounts → Keys → Add key → JSON).`)
33
+ }
34
+ return key as ServiceAccountKey
35
+ }
36
+
37
+ /** A service account's access token: a JWT signed with its key, traded at its token_uri (RFC 7523). */
38
+ export const serviceAccountToken = async (key: ServiceAccountKey, now = Math.floor(Date.now() / 1000)): Promise<string> => {
39
+ const tokenUri = key.token_uri ?? "https://oauth2.googleapis.com/token"
40
+ const part = (v: object) => Buffer.from(JSON.stringify(v)).toString("base64url")
41
+ const unsigned = `${part({ alg: "RS256", typ: "JWT" })}.${part({ iss: key.client_email, scope: SCOPE, aud: tokenUri, iat: now, exp: now + 3600 })}`
42
+ const signature = sign("RSA-SHA256", Buffer.from(unsigned), key.private_key).toString("base64url")
43
+ const res = await fetch(tokenUri, {
44
+ method: "POST",
45
+ headers: { "content-type": "application/x-www-form-urlencoded" },
46
+ body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: `${unsigned}.${signature}` }),
47
+ })
48
+ const body = await res.json().catch(() => ({})) as { access_token?: string, error?: string, error_description?: string }
49
+ if (!res.ok || !body.access_token) {
50
+ throw new CliError(`Google refused the key of ${key.client_email}: ${body.error_description ?? body.error ?? `HTTP ${res.status}`} (a deleted or rotated key fails here — make a new one).`)
51
+ }
52
+ return body.access_token
53
+ }
54
+
55
+ export interface PlayRelease {
56
+ packageName: string
57
+ aab: Uint8Array
58
+ /** internal | alpha | beta | production | a closed track's own name. */
59
+ track: string
60
+ /** draft = the only status an app that was never published accepts. */
61
+ status: "completed" | "draft"
62
+ }
63
+
64
+ /** What to do about the refusals a first setup runs into; Google's own message stays in front. */
65
+ const HINTS: [RegExp, (r: PlayRelease) => string][] = [
66
+ [/caller does not have permission|insufficient permissions/i, () => "invite the service account in Play Console → Users and permissions with \"Release to testing tracks\" for this app (a new grant can take hours to reach the API)"],
67
+ [/package not found/i, r => `Play Console has no app ${r.packageName} — create it there first (the API cannot); app.json android.applicationId must match`],
68
+ [/version code .*already been used/i, () => "bump app.json android.versionCode and build again"],
69
+ [/only releases with status draft/i, () => "the app was never published — upload with --draft, then roll the release out in Play Console"],
70
+ [/has not been used in project|is disabled/i, () => "enable the Google Play Android Developer API in the key's Cloud project"],
71
+ ]
72
+
73
+ const playError = (step: string, status: number, text: string, release: PlayRelease): CliError => {
74
+ let message = text
75
+ try { message = (JSON.parse(text) as { error?: { message?: string } }).error?.message ?? text } catch { /* not JSON: as is */ }
76
+ const hint = HINTS.find(([re]) => re.test(message))?.[1](release)
77
+ return new CliError(`Google Play refused the ${step} (HTTP ${status}): ${message.trim()}${hint ? `\n → ${hint}` : ""}`)
78
+ }
79
+
80
+ /** One edit: the bundle onto `track` as one release. Returns the versionCode Play read from it. */
81
+ export const uploadToPlay = async (token: string, release: PlayRelease): Promise<{ versionCode: number }> => {
82
+ const app = `applications/${encodeURIComponent(release.packageName)}`
83
+ const call = async <T>(step: string, method: string, path: string, body?: Uint8Array | object): Promise<T> => {
84
+ const bytes = body instanceof Uint8Array
85
+ const res = await fetch(`${apiBase()}/${path}`, {
86
+ method,
87
+ headers: {
88
+ authorization: `Bearer ${token}`,
89
+ ...(body ? { "content-type": bytes ? "application/octet-stream" : "application/json" } : {}),
90
+ },
91
+ body: bytes ? body : body ? JSON.stringify(body) : undefined,
92
+ })
93
+ const text = await res.text()
94
+ if (!res.ok) throw playError(step, res.status, text, release)
95
+ return (text ? JSON.parse(text) : {}) as T
96
+ }
97
+
98
+ const edit = await call<{ id: string }>("edit", "POST", `androidpublisher/v3/${app}/edits`, {})
99
+ const editPath = `androidpublisher/v3/${app}/edits/${encodeURIComponent(edit.id)}`
100
+ const bundle = await call<{ versionCode: number }>("bundle", "POST", `upload/${editPath}/bundles?uploadType=media`, release.aab)
101
+ await call("track", "PUT", `${editPath}/tracks/${encodeURIComponent(release.track)}`, {
102
+ track: release.track,
103
+ releases: [{ versionCodes: [String(bundle.versionCode)], status: release.status }],
104
+ })
105
+ await call("commit", "POST", `${editPath}:commit`)
106
+ return { versionCode: bundle.versionCode }
107
+ }
@@ -9,7 +9,7 @@ import { loadPeer } from "../hosts/peerInstall"
9
9
  */
10
10
  export default defineCommand({
11
11
  name: "assets",
12
- summary: "The asset pipeline: convert / probe / doctor / retarget / unpack / scene / sky / terrain-pack …",
12
+ summary: "The asset pipeline: convert / probe / doctor / unwrap / retarget / unpack / scene / sky / terrain-pack …",
13
13
  usage: "<command> [args…]",
14
14
  passthrough: true,
15
15
  examples: ["lecodes assets convert hero.fbx --clips anims/*.fbx -o hero.glb", "lecodes assets doctor hero.glb --fix --ktx2", "lecodes assets --help"],
@@ -1,10 +1,11 @@
1
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
1
+ import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs"
2
2
  import { tmpdir } from "node:os"
3
3
  import { basename, dirname, join, relative, resolve } from "node:path"
4
4
  import { deflateSync } from "node:zlib"
5
- import { buildBundle } from "./compile"
6
- import { readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
5
+ import { buildBundle, type BundleResource } from "./compile"
6
+ import { readAppConfigAt } from "./app/shared"
7
7
  import { runDesktopRender } from "../hosts/desktopRenderer"
8
+ import { loadPeer } from "../hosts/peerInstall"
8
9
  import { CliError, bool, c, defineCommand, log, logErr, note, num, str, warnErr } from "../cli"
9
10
  import { bundleFlags, bundleOptions, localProject } from "./shared"
10
11
 
@@ -12,17 +13,22 @@ import { bundleFlags, bundleOptions, localProject } from "./shared"
12
13
  * `lecodes lightmap` — the baked lighting of a level (engines/bake).
13
14
  *
14
15
  * bake compile the app with a synthetic entry that puts it in BAKE mode (`globalThis.__lecodesLightmap`),
15
- * run it in the native desktop host; when the app calls `Lightmap.load(...)` — the point where its
16
+ * run it in the bake host (lecodes-bake); when the app calls `Lightmap.load(...)` — the point where its
16
17
  * scene is complete — the SDK calls `_creator.lightmapBake` instead of loading: the host describes
17
18
  * every registered static to creator-bake, which traces the sun, the sky and the lamps on the
18
- * NVIDIA card and writes <out>/<name>.bake + <name>-light[_n].ktx2 + <name>-aux[_n].ktx2 itself
19
+ * GPU (--tracer: OptiX on NVIDIA, else Vulkan ray query on any card with hardware ray tracing) and
20
+ * writes <out>/<name>.bake + <name>-light[_n].ktx2 + <name>-aux[_n].ktx2 itself
19
21
  * (no toktx, no PNG intermediates). "[lightmap] done" is the success marker.
20
22
  * stats the per-instance numbers a .bake carries (mean lux, sun / sky visibility, texels, rect)
21
23
  * diff the noise of a bake per instance: two bakes with different --seed compared texel by texel
22
24
  * inspect PNGs from the float layers a `bake --debug-dir` kept: whole pages or one instance's rect
23
25
  *
24
- * Only a desktop host built with the CUDA toolkit has the bake — a local build via LECODES_DESKTOP_EXE.
25
- * The props need lightmap UVs first: `lecodes assets doctor assets/props/*.glb --lightmap-uv`.
26
+ * The bake is not in the shipped desktop / server hosts: it runs in lecodes-bake (hosts/desktop CREATOR_DESKTOP_BAKE),
27
+ * the windowless host with the bake built in — resolved by hosts/desktopRenderer.ts resolveBakeExe (LECODES_BAKE_EXE
28
+ * for a local build, else the published release product "bake").
29
+ * Lightmap UVs: before the host runs, every GLB the app references that has none is unwrapped IN PLACE (lecodes-assets
30
+ * `unwrapGlbFiles`, what `lecodes assets unwrap` runs — the pre-step below); `--no-unwrap` skips it. A level in one GLB
31
+ * that wants groups or a zone is unwrapped by hand first (`lecodes assets unwrap --groups`), and then left alone here.
26
32
  */
27
33
 
28
34
  const range = (name: string, value: number, min: number, max: number): number => {
@@ -31,7 +37,7 @@ const range = (name: string, value: number, min: number, max: number): number =>
31
37
  }
32
38
 
33
39
  type BakeInstance = { key: string, receiver: boolean, page?: number, rect?: number[], st?: number[], texels?: number, area?: number, charts?: number, mean?: number[], sunVis?: number, skyVis?: number, share?: number }
34
- type BakeData = { version: number, size: number, pages: number, texel: number, lightScale: number, light: string[], aux: string[], direct?: string[], indirect?: string[], probes?: { file: string, size: number, levels: number, count: number, spacing: number } | null, volume?: { file: string, n: number[], cell: number[], valid: number } | null, texels?: number, instances: BakeInstance[], sun?: unknown, sky?: unknown, lights?: unknown[] }
40
+ type BakeData = { version: number, size: number, pages: number, texel: number, lightScale: number, light: string[], aux: string[], direct?: string[], indirect?: string[], probes?: { file: string, size: number, levels: number, count: number, spacing: number } | null, volume?: { file: string, n: number[], cell: number[], valid: number } | null, uv1?: string | null, texels?: number, instances: BakeInstance[], sun?: unknown, sky?: unknown, lights?: unknown[] }
35
41
 
36
42
  const readBake = (file: string): BakeData => {
37
43
  if (!existsSync(file)) throw new CliError(`No bake at ${file} — run \`lecodes lightmap bake\` first.`)
@@ -52,6 +58,23 @@ const probeBoxOf = (s: string): [number, number, number, number, number, number]
52
58
  return [ Math.min(v[0], v[3]), Math.min(v[1], v[4]), Math.min(v[2], v[5]), Math.max(v[0], v[3]), Math.max(v[1], v[4]), Math.max(v[2], v[5]) ]
53
59
  }
54
60
 
61
+ /** Every `--cube` on the line (`--cube a --cube b`, `--cube=a`): the argv grammar keeps a repeated flag's LAST value, so
62
+ * the patterns are read off argv by its own rule — a value is the next token unless it starts with "-" (a negative
63
+ * number aside). Each must compile as a regular expression here too (the bake's std::regex has the last word). */
64
+ const cubePatternsOf = (argv: string[]): string[] => {
65
+ const out: string[] = []
66
+ for (let i = 0; i < argv.length; i++) {
67
+ const a = argv[i]
68
+ if (a === "--") break
69
+ if (a.startsWith("--cube=")) out.push(a.slice("--cube=".length))
70
+ else if (a === "--cube" && i + 1 < argv.length && (!argv[i + 1].startsWith("-") || /^-(\d|\.\d)/.test(argv[i + 1]))) out.push(argv[++i])
71
+ }
72
+ for (const p of out) {
73
+ try { new RegExp(p) } catch (e) { throw new CliError(`bad --cube pattern: ${p} (${e instanceof Error ? e.message : String(e)})`) }
74
+ }
75
+ return out.filter((p) => p !== "")
76
+ }
77
+
55
78
  /** A .bake with no pages and no scale: enough for the compiler to stage the files and for Lightmap.load to
56
79
  * no-op with a "rebake" warning. The two KTX2 placeholders are 4x4 pages of the right formats. */
57
80
  const placeholderBake = (stem: string): string => JSON.stringify({ version: 4, placeholder: true, size: 4, pages: 0, texel: 0, lightScale: 0, light: [ `${stem}-light.ktx2` ], aux: [ `${stem}-aux.ktx2` ], instances: [] }) + "\n"
@@ -82,12 +105,81 @@ const ensurePlaceholders = (outDir: string, stem: string): void => {
82
105
  if (missing(probes)) writeFileSync(probes, ktx2Placeholder(143)) // a scene may list the probes before the first bake wrote them
83
106
  const grid = join(outDir, `${stem}.lgrid`)
84
107
  if (missing(grid)) writeFileSync(grid, "NONE") // ... and the light grid: the engine refuses it with a "rebake" line
108
+ const uv1 = join(outDir, `${stem}-uv1.bin`)
109
+ if (missing(uv1)) writeFileSync(uv1, "NONE") // ... and the packed uv1: a placeholder .bake names none, so nothing reads it
110
+ }
111
+
112
+ // ---- the unwrap pre-step --------------------------------------------------------------------------
113
+ // The bake traces TEXCOORD_1, and a raw import has none: before the host runs, the GLBs the bundle names (asset()
114
+ // compiles to `_creatorFetch.local("<name>")` — the reference list `app build desktop` ships by) whose JSON shows a
115
+ // primitive the unwrap would lay out without one are unwrapped IN PLACE by lecodes-assets (`unwrapGlbFiles`, the
116
+ // `lecodes assets unwrap` code). Nothing is rebuilt after it: the bundle names the files and carries none of their bytes,
117
+ // and the run dir links them only when the host starts. A file that has TEXCOORD_1 is never opened past its JSON.
118
+
119
+ type GltfJson = {
120
+ nodes?: { mesh?: number, skin?: number }[]
121
+ meshes?: { primitives?: { mode?: number, indices?: number, attributes?: Record<string, number> }[] }[]
122
+ accessors?: { count?: number }[]
123
+ }
124
+
125
+ /** A GLB's JSON chunk — the 20 header bytes and the chunk itself, never the binary one — or null when it is not a GLB. */
126
+ const readGlbJson = (file: string): GltfJson | null => {
127
+ let fd: number | undefined
128
+ try {
129
+ fd = openSync(file, "r")
130
+ const head = Buffer.alloc(20)
131
+ if (readSync(fd, head, 0, 20, 0) !== 20) return null
132
+ if (head.readUInt32LE(0) !== 0x46546c67 || head.readUInt32LE(16) !== 0x4e4f534a) return null // "glTF", then a "JSON" chunk
133
+ const json = Buffer.alloc(head.readUInt32LE(12))
134
+ if (readSync(fd, json, 0, json.length, 20) !== json.length) return null
135
+ return JSON.parse(json.toString("utf8")) as GltfJson
136
+ } catch {
137
+ return null
138
+ } finally {
139
+ if (fd !== undefined) closeSync(fd)
140
+ }
141
+ }
142
+
143
+ /** A primitive the unwrap lays out (a triangle list with a triangle in it, on a mesh no skinned node draws) without
144
+ * TEXCOORD_1 — lecodes-assets' planUnwrap verdict, read off the JSON. */
145
+ const lacksLightmapUv = (g: GltfJson): boolean => {
146
+ const skinned = new Set((g.nodes ?? []).filter((n) => n.skin !== undefined && n.mesh !== undefined).map((n) => n.mesh))
147
+ const count = (accessor: number | undefined): number => (accessor === undefined ? 0 : g.accessors?.[accessor]?.count ?? 0)
148
+ return (g.meshes ?? []).some((mesh, i) => !skinned.has(i) && (mesh.primitives ?? []).some((p) => {
149
+ const pos = count(p.attributes?.POSITION)
150
+ return (p.mode ?? 4) === 4 && pos > 0 && (p.indices === undefined ? pos : count(p.indices)) >= 3 && p.attributes?.TEXCOORD_1 === undefined
151
+ }))
152
+ }
153
+
154
+ /** The project GLBs the bundle names. */
155
+ const referencedGlbs = (js: string, resources: BundleResource[]): string[] => {
156
+ const wanted = new Set([ ...js.matchAll(/(?:fetchLocal|_creatorFetch\.local)\("([^"]+)"\)/g) ].map((m) => m[1]))
157
+ return [ ...new Set(resources.filter((r) => !r.remote && !r.shader && r.absPath && /\.glb$/i.test(r.name) && wanted.has(r.name)).map((r) => r.absPath!)) ]
158
+ }
159
+
160
+ const unwrapReferenced = async (js: string, resources: BundleResource[], root: string, texel: number): Promise<void> => {
161
+ const glbs = referencedGlbs(js, resources)
162
+ const raw = glbs.filter((f) => { const g = readGlbJson(f); return g !== null && lacksLightmapUv(g) })
163
+ if (raw.length === 0) { note(`Lightmap uvs: the ${glbs.length} GLB(s) the app references carry them`); return }
164
+ note(`Unwrapping ${raw.length} of the ${glbs.length} GLB(s) the app references — no lightmap uvs; written in place (--no-unwrap skips)…`)
165
+ const assets = await loadPeer<typeof import("lecodes-assets/cli")>("lecodes-assets", "cli", { for: "lecodes lightmap bake (lightmap uvs)" })
166
+ const t0 = performance.now()
167
+ const rel = (f: string): string => relative(root, f).replace(/\\/g, "/")
168
+ const reports = await assets.unwrapGlbFiles(raw, {
169
+ texel,
170
+ onFile: (r) => {
171
+ if (r.status === "failed") warnErr(`${rel(r.input)}: ${r.summary} — it bakes without lightmap uvs`)
172
+ else logErr(c.dim(` ${r.status === "unwrapped" ? "unwrapped " : ""}${rel(r.input)}: ${r.summary}`))
173
+ },
174
+ })
175
+ const count = (s: string): number => reports.filter((r) => r.status === s).length
176
+ note(`Lightmap uvs: ${count("unwrapped")} file(s) unwrapped${count("skipped") ? `, ${count("skipped")} had nothing to unwrap` : ""}${count("failed") ? `, ${count("failed")} failed` : ""} in ${((performance.now() - t0) / 1000).toFixed(1)} s`)
85
177
  }
86
178
 
87
179
  const bake = defineCommand({
88
180
  name: "bake",
89
181
  summary: "Bake the sun, the sky and the lamps into a lux atlas for the level's statics",
90
- description: "Runs the app in the desktop host in bake mode: the statics are what the app registers with Lightmap.add (a scene file does it for every node no Physics aspect moves), the bake fires at its Lightmap.load call. Writes <out>/<name>.bake + <name>-light[_n].ktx2 (irradiance, BC6H) + <name>-aux[_n].ktx2 (sky visibility, the directional share and direction of the light) + <name>-probes.ktx2 (the reflection probes: a cubemap of the level's radiance per probe, prefiltered per roughness; --probe-spacing 0 = none). Needs a desktop host built with the CUDA toolkit and an NVIDIA card (LECODES_DESKTOP_EXE for a local build).\n\nProps need lightmap UVs first: lecodes assets doctor assets/props/*.glb --lightmap-uv",
182
+ description: "Runs the app in bake mode in lecodes-bake — the windowless host with the bake built in (no physics, 2D, audio, net or navmesh), downloaded on first use like the desktop host; LECODES_BAKE_EXE points at a local build (hosts/desktop build.ps1 -Bake). The statics are what the app registers with Lightmap.add (a scene file does it for every node no Physics aspect moves), the bake fires at its Lightmap.load call. Writes <out>/<name>.bake + <name>-light[_n].ktx2 (irradiance, BC6H) + <name>-aux[_n].ktx2 (sky visibility, the directional share and direction of the light) + <name>-probes.ktx2 (the reflection probes: a cubemap of the level's radiance per probe, prefiltered per roughness; --probe-spacing 0 = none). Needs a GPU with hardware ray tracing (--tracer).\n\nLightmap UVs: the GLBs the app references that have none (TEXCOORD_1) are unwrapped first, IN PLACE \u2014 the same as lecodes assets unwrap, run on just those files; a file that has them is left alone, --no-unwrap skips the step. A whole level in one GLB that wants --groups / --zone is unwrapped by hand first (lecodes assets unwrap --help).",
91
183
  flags: {
92
184
  out: str("where the atlas lands", { value: "<dir>", default: "assets/lightmap" }),
93
185
  name: str("file stem — one per baked level", { value: "<stem>", default: "lightmap" }),
@@ -103,7 +195,9 @@ const bake = defineCommand({
103
195
  bounces: num("path vertices at most (Russian roulette from the third); 0 = direct light only", { default: 3 }),
104
196
  "patch-samples": num("next-event samples per texel over the BRIGHT PATCHES of a first direct pass (sunlit floors, the ceiling around a lamp) — what keeps the bounce in dark rooms from arriving as impulses (every path vertex takes one more); 0 = the cosine paths alone", { default: 64 }),
105
197
  split: bool("also write <name>-direct[_n].ktx2 / <name>-indirect[_n].ktx2 — the pages the \"direct\" / \"indirect\" views bind (list them as env.lightmap.direct / .indirect)"),
106
- denoise: str("OptiX's AI denoiser over: indirect = the bounce alone (the range 2026-09-16: bounce noise 57 % -> 23 % of the mean, per-instance means unchanged), no-sun = + the sky and the lamps (the sky through a small opening: 0..3 hits of 256 rays, a texel is one of four values), all = + the sun (its shadow edges too: a sunlit pillar's mean moved 6 %), none. School, two seeds: no-sun took the sky 1.8 % -> 0.5 %, the lamps 43 % -> 21 %", { value: "<none|indirect|no-sun|all>", default: "no-sun" }),
198
+ denoise: str("the AI denoiser (--denoiser) over: indirect = the bounce alone (the range 2026-09-16: bounce noise 57 % -> 23 % of the mean, per-instance means unchanged), no-sun = + the sky and the lamps (the sky through a small opening: 0..3 hits of 256 rays, a texel is one of four values), all = + the sun (its shadow edges too: a sunlit pillar's mean moved 6 %), none. School, two seeds: no-sun took the sky 1.8 % -> 0.5 %, the lamps 43 % -> 21 %", { value: "<none|indirect|no-sun|all>", default: "no-sun" }),
199
+ tracer: str("which GPU API traces: auto = OptiX where an NVIDIA driver with OptiX opens, else Vulkan ray query — any card with hardware ray tracing (RTX, Radeon RX 6000+, Arc) with its driver alone; optix / vulkan by name (the bake fails when it cannot open)", { value: "<auto|optix|vulkan>", default: "auto" }),
200
+ denoiser: str("which AI denoiser --denoise runs: auto = OptiX's on an NVIDIA card, else Intel Open Image Denoise (its CUDA / HIP / CPU device, the DLLs next to the host); optix / oidn by name. Independent of --tracer; one that cannot open is a warning and the pages stay as traced", { value: "<auto|optix|oidn>", default: "auto" }),
107
201
  "light-radius": num("lamps: the emitter's radius (m) — soft shadows, no 1/d² singularity", { default: 0.05 }),
108
202
  "sun-angle": num("sun angular diameter (deg)", { default: 0.53 }),
109
203
  bias: num("ray start offset from the surface (m)", { default: 0.01 }),
@@ -115,6 +209,9 @@ const bake = defineCommand({
115
209
  "probe-indoor": bool("keep only the probes under a roof — those whose open share of the sphere (rays from the point that meet nothing) is at most 15 %, holes in the geometry or not: outdoor statics reflect the sky through their baked sky visibility instead — an environment map without the ground, but no outdoor probe can reach indoors and the atlas shrinks"),
116
210
  "probe-layout": str("rooms = every indoor point of the grid slides to the middle of its room and the points of one room merge: one probe per room, far from the walls, its box the room (stations every ~8 m along a room over 12 m), outdoor points stay on the grid; grid = everything stays on the grid. Either way the pages carry which probes each block of texels sees (rays at bake time)", { value: "<rooms|grid>", default: "rooms" }),
117
211
  "min-texels": num("instances smaller than this many ATLAS TEXELS along every axis (x --texel: 12 at 5 cm = 0.6 m) get no place in the atlas: they still cast their baked shadow and bounce, their own light is baked where they stand (six sides, no atlas texels) — cartridges, boxes, pipe elbows, a fire extinguisher: a baked shadow does not read on them and their tight charts are where the seams are. 0 = everything bakes; --dry-run lists what falls under it", { default: 12 }),
212
+ "thin-texels": num("ROUND AND NARROW objects leave the atlas like --min-texels: an instance none of whose lightmap charts is wider than this many texels across its short side (at --texel) — a railing's tubes, a cable, a pipe run, blinds, a gun barrel. A strip of 1-3 texels around a tube cannot hold the light's change around it and its uv seam shows as a step (the range's railing: two texels 5 mm apart on the seam, one lit, one shadowed); the ambient cube baked where it stands is smooth. A flat object has a wide chart somewhere and stays. 0 = off; --dry-run lists what falls under it", { default: 4 }),
213
+ cube: str("BAKE AS CUBES BY NAME: a regular expression (ECMAScript, case-sensitive) searched in every record's key — <node path>@<ordinal>, one record per mesh of a model, so SM_pipe takes every pipe node and @1$ the second mesh of each. A record that matches leaves the atlas like a --min-texels / --thin-texels one, whatever its size or charts: an occluder whose own light is baked where it stands as ambient cubes (in slabs along a long one, --object-slab) — a pipe run, a fence, a prop whose lightmap seams show. Repeat the flag (--cube SM_pipe --cube SM_fence) or join with | in one (--cube \"SM_pipe|SM_fence\", quoted for the shell); a pattern that starts with - goes as --cube=<regex>. The scene's env.lightmap.cube adds its own patterns, a node's lightmap: 'cube' marks one node by hand; --dry-run lists what matches", { value: "<regex>" }),
214
+ "object-slab": num("A LONG object the atlas left out (--min-texels, --thin-texels, --cube) takes its light in SLABS of this many metres along the longest axis of its bounds — round(length / this) ambient cubes, 16 at most — a 6 m pipe run, a railing along a wall: one end under a lamp, the other in the dark, where one cube gave all of it the mean. The shader blends the two slabs around each pixel along that axis. 0 = one cube an object", { value: "<m>", default: 1 }),
118
215
  "emissive-nits": num("EMISSIVE SURFACES LIGHT THE LEVEL: cd/m² of emission 1.0 (emissive factor x strength x map) — every lit region of an emissive surface (a panel drawn in a ceiling's texture, a screen, a sign) bakes as a rectangle lamp. Default: what the level's camera makes of 1.0, 1.2 x 2^EV100, so a panel lights as brightly as it looks. 0 = emissive surfaces light nothing"),
119
216
  "detail-texels": num("A SMALL SURFACE GETS A FINER TEXEL: an instance whose surface is under this many atlas texels (area / texel²) is laid out denser by sqrt(this / its texels), 3x at most — a pipe of 1 m is ~300 texels at 5 cm, its charts a few texels wide and every chart edge a step; at 2000 it bakes at ~2 cm. 0 = off", { default: 2000 }),
120
217
  volume: num("THE LIGHT GRID for movers: the cell size in metres (0.75 of it tall) — per cell in free space the baked ambient light (sky, lamps, every bounce; not the direct sun, which stays real-time on movers), links between cells a ray connects so nothing blends through a wall; written as <name>.lgrid, list it as env.lightmap.volume. 0 = none", { default: 1 }),
@@ -123,22 +220,27 @@ const bake = defineCommand({
123
220
  "dry-run": bool("only the atlas LAYOUT: pages, texel size, the largest rects — no tracing, nothing written (seconds, not minutes; the host still loads the level)"),
124
221
  frames: num("host frames to run before giving up — load headroom", { default: 900 }),
125
222
  timeout: num("host time cap (ms; default: none)"),
126
- width: num("host window width (default: app.json desktop.window, else 1280)"),
127
- height: num("host window height (default: app.json desktop.window, else 720)"),
128
- renderer: str("desktop host + shader variant (default: app.json desktop.renderer, else gl)", { value: "<gl|vulkan>" }),
223
+ width: num("the bake host's virtual viewport width — it has no window; nothing in the bake depends on it (default: app.json desktop.window, else 1280)"),
224
+ height: num("the bake host's virtual viewport height (default: app.json desktop.window, else 720)"),
225
+ renderer: str("ignored: the bake host is one binary (lecodes-bake, GL shaders on Filament's NOOP backend); kept so older scripts still parse", { value: "<gl|vulkan>" }),
129
226
  logs: bool("forward the host's output"),
227
+ "no-unwrap": bool("leave the referenced GLBs as they are: no lightmap unwrap of those without TEXCOORD_1 (they bake without lightmap uvs)"),
130
228
  },
131
229
  examples: [ "lecodes lightmap bake", "lecodes lightmap bake --entry school.ts --name school --texel 0.04 --pages 4 --debug-dir .lightmap/school --logs" ],
132
- run: async ({ flags }) => {
230
+ run: async ({ flags, argv }) => {
133
231
  const { root } = localProject()
134
232
  const outDir = resolve(root, flags.out)
135
233
  const stem = flags.name
234
+ const cube = cubePatternsOf(argv)
136
235
  if (!/^[A-Za-z0-9_-]+$/.test(stem)) throw new CliError(`--name wants a plain file stem (letters, digits, - and _), got "${stem}".`)
137
236
  const entryFile = flags.entry
138
237
  if (!existsSync(resolve(root, entryFile))) {
139
238
  throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the scene and calls Lightmap.load).`)
140
239
  }
141
240
  const debugDir = flags["debug-dir"] ? resolve(root, flags["debug-dir"]) : ""
241
+ if (!["none", "indirect", "no-sun", "all"].includes(flags.denoise)) throw new CliError(`--denoise wants none, indirect, no-sun or all, got "${flags.denoise}".`)
242
+ if (!["auto", "optix", "vulkan"].includes(flags.tracer)) throw new CliError(`--tracer wants auto, optix or vulkan, got "${flags.tracer}".`)
243
+ if (!["auto", "optix", "oidn"].includes(flags.denoiser)) throw new CliError(`--denoiser wants auto, optix or oidn, got "${flags.denoiser}".`)
142
244
  const cfg = {
143
245
  outDir: outDir.replace(/\\/g, "/"),
144
246
  stem,
@@ -157,6 +259,8 @@ const bake = defineCommand({
157
259
  patchSamples: range("patch-samples", flags["patch-samples"], 0, 1024),
158
260
  split: !!flags.split,
159
261
  denoise: flags.denoise,
262
+ tracer: flags.tracer,
263
+ denoiser: flags.denoiser,
160
264
  filter: range("filter", flags.filter, 0, 4),
161
265
  probeSpacing: range("probe-spacing", flags["probe-spacing"], 0, 100),
162
266
  probeSize: range("probe-size", flags["probe-size"], 16, 128),
@@ -164,6 +268,9 @@ const bake = defineCommand({
164
268
  probeIndoor: !!flags["probe-indoor"],
165
269
  probeLayout: probeLayoutOf(flags["probe-layout"]),
166
270
  minTexels: range("min-texels", flags["min-texels"], 0, 10000),
271
+ thinTexels: range("thin-texels", flags["thin-texels"], 0, 64),
272
+ objectSlab: range("object-slab", flags["object-slab"], 0, 100),
273
+ ...(cube.length ? { cube } : {}),
167
274
  volumeSpacing: range("volume", flags.volume, 0, 100),
168
275
  detailTexels: range("detail-texels", flags["detail-texels"], 0, 1e7),
169
276
  ...(flags["emissive-nits"] !== undefined ? { emissiveNits: range("emissive-nits", flags["emissive-nits"], 0, 1e7) } : {}),
@@ -187,12 +294,13 @@ const bake = defineCommand({
187
294
  ensurePlaceholders(outDir, stem)
188
295
  if (debugDir) mkdirSync(debugDir, { recursive: true })
189
296
 
190
- const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
297
+ const desktopRenderer = "gl" as const // lecodes-bake is the GL tree on Filament's NOOP backend: `_creator.backend` "desktop" fetches the GL shaders
191
298
  const { js, resources } = await buildBundle(bundleOptions(flags), {
192
299
  localAssets: true, desktopRenderer,
193
300
  extraEntries: [{ path: entryPath, type: "text", text: entryText }],
194
301
  entryOverride: entryPath,
195
302
  })
303
+ if (!flags["no-unwrap"]) await unwrapReferenced(js, resources, root, cfg.texel)
196
304
 
197
305
  // The host writes the bake itself; the PNG the render driver insists on goes to a scratch dir.
198
306
  const scratch = mkdtempSync(join(tmpdir(), "lecodes-lightmap-"))
@@ -204,7 +312,7 @@ const bake = defineCommand({
204
312
  try {
205
313
  const timeoutMs = flags.timeout || 24 * 3600 * 1000
206
314
  const res = await runDesktopRender(js, resources, {
207
- renderer: desktopRenderer, width, height, frames, outPng, logs: flags.logs, timeoutMs,
315
+ host: "bake", renderer: desktopRenderer, width, height, frames, outPng, logs: flags.logs, timeoutMs,
208
316
  stopOnLine: (chunk) => chunk.includes("[lightmap] done") || chunk.includes("[lightmap] error"),
209
317
  })
210
318
  output = res.output
@@ -223,7 +331,7 @@ const bake = defineCommand({
223
331
  logErr(text.startsWith("error") ? c.red(`✗ ${text}`) : text.startsWith("warning") ? c.yellow(` ${text}`) : c.dim(` ${text}`))
224
332
  }
225
333
  if (flags["dry-run"] && /\[lightmap\] done/.test(output)) {
226
- for (const l of lines) if (/atlas \d+ x|larger rect|largest rect|do not fit|charts touch/.test(l)) logErr(l.replace(/^\[lightmap\] /, " "))
334
+ for (const l of lines) if (/atlas \d+ x|packed per file|largest rect|do not fit|fill:|--cube|lightmap: 'cube'/.test(l)) logErr(l.replace(/^\[lightmap\] /, " "))
227
335
  logErr(c.green("✓ layout only — nothing written"))
228
336
  return
229
337
  }
@@ -236,13 +344,15 @@ const bake = defineCommand({
236
344
  const probeFiles = data.probes ? ` + ${data.probes.file} (${data.probes.count} probe(s) of ${data.probes.size}² at ${data.probes.spacing} m)` : ""
237
345
  const gridFile = data.volume ? ` + ${data.volume.file} (light grid ${data.volume.n.join(" x ")}, ${data.volume.valid} cell(s) in free space)` : ""
238
346
  const probeDef = (data.probes ? `, probes: asset('<rel>/${rel(data.probes.file)}')` : "") + (data.volume ? `, volume: asset('<rel>/${rel(data.volume.file)}')` : "")
239
- logErr(c.green(`✓ baked → ${relative(root, outDir) || "."}/${stem}.bake + ${data.light.join(", ")} + ${data.aux.join(", ")}${splitFiles}${probeFiles}${gridFile}`))
347
+ + (data.uv1 ? `, uv1: asset('<rel>/${rel(data.uv1)}')` : "")
348
+ const uv1File = data.uv1 ? ` + ${data.uv1} (the packed lightmap uv1 — list it as env.lightmap.uv1, the rects map it)` : ""
349
+ logErr(c.green(`✓ baked → ${relative(root, outDir) || "."}/${stem}.bake + ${data.light.join(", ")} + ${data.aux.join(", ")}${splitFiles}${probeFiles}${gridFile}${uv1File}`))
240
350
  logErr(c.dim(` scene file: env.lightmap: { data: asset('<rel>/${rel(`${stem}.bake`)}'), light: ${assets(data.light)}, aux: ${assets(data.aux)}${splitDef}${probeDef} } — asset() paths are relative to the scene file`))
241
351
  if (debugDir) logErr(c.dim(` debug layers: ${relative(root, debugDir)} (lecodes lightmap inspect --bake ${relative(root, join(outDir, `${stem}.bake`)).replace(/\\/g, "/")} --debug-dir ${relative(root, debugDir).replace(/\\/g, "/")})`))
242
352
  return
243
353
  }
244
- if (lines.some((l) => /no bake module/.test(l))) {
245
- throw new CliError("This desktop host has no bake module. Build hosts/desktop with the CUDA toolkit installed and point LECODES_DESKTOP_EXE at build/lecodes-desktop.exe.")
354
+ if (/\[lightmap\] error: this host has no bake module/.test(output)) { // console.error: the host prefixes the line ("[ERROR] [lightmap] …")
355
+ throw new CliError("This host has no bake module — the bake runs in lecodes-bake. Point LECODES_BAKE_EXE at it (hosts/desktop: build.ps1 -Bake → build-bake/lecodes-bake.exe), or unset it to use the published one.")
246
356
  }
247
357
  if (lines.some((l) => /error/.test(l))) throw new CliError("The bake failed — see the lines above.")
248
358
  warnErr("The host ran to its frame cap without baking. Make sure the app calls Lightmap.load(...) after the static scene is built, and skips menus when Lightmap.baking is true. More load headroom: --frames <n>. Host output: --logs.")
@@ -1,7 +1,34 @@
1
- // Types for the optional `lecodes-assets` package's CLI entry (lazy-loaded by the `assets` command).
2
- // The real package is installed separately; this lets the dynamic import typecheck without it
3
- // present. Keep in sync with tools/assets/src/cli.ts.
1
+ // Types for the optional `lecodes-assets` package's CLI entry (lazy-loaded by the `assets` command
2
+ // and `lightmap bake`'s unwrap pre-step). The real package is installed separately; this lets the
3
+ // dynamic import typecheck without it present. Keep in sync with tools/assets/src/cli.ts.
4
4
  declare module "lecodes-assets/cli" {
5
- /** Runs `convert | probe | doctor` with the given argv (sub-command first). Resolves to the exit code. */
5
+ /** Runs one `lecodes assets` command line (sub-command first). Resolves to the exit code. */
6
6
  export function runAssetsCli(argv: string[]): Promise<number>
7
+
8
+ /** One file's outcome (tools/assets src/unwrap UnwrapFileReport, the fields the CLI reads). */
9
+ export interface UnwrapFileReport {
10
+ input: string
11
+ /** where the unwrapped file went; null when nothing was written */
12
+ output: string | null
13
+ status: "unwrapped" | "skipped" | "failed"
14
+ reason?: string
15
+ /** one line: what the unwrap made of the file */
16
+ summary: string
17
+ ms: number
18
+ }
19
+ /** The lightmap unwrap (TEXCOORD_1, xatlas) of whole GLB files, IN PLACE unless `out` says where; a file that
20
+ * carries TEXCOORD_1 is skipped unless `force`. Never throws for a file's sake: a failure is its report. */
21
+ export function unwrapGlbFiles(files: string[], opts?: {
22
+ force?: boolean
23
+ out?: string
24
+ /** the bake's texel (m) the fixed chart padding is measured in */
25
+ texel?: number
26
+ iterations?: number
27
+ groups?: number
28
+ zone?: string
29
+ zoneNear?: number
30
+ zoneMax?: number
31
+ zoneUp?: number
32
+ onFile?: (r: UnwrapFileReport) => void
33
+ }): Promise<UnwrapFileReport[]>
7
34
  }