lecodes-cli 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/dist/index.js +6211 -3649
  2. package/package.json +12 -8
  3. package/runtime/materials/lightmap.mat +115 -139
  4. package/runtime/materials/particles-quad.mat +6 -2
  5. package/runtime/materials/terrain-lightmap.mat +123 -0
  6. package/runtime/materials/terrain.mat +36 -55
  7. package/runtime/sdk-types.json +1 -1
  8. package/runtime/web/VERSION.json +5 -0
  9. package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
  10. package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
  11. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  12. package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
  13. package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
  14. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
  15. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  16. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
  17. package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
  18. package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
  19. package/runtime/web/assets/map-CyBxOSV0.js +1 -0
  20. package/runtime/web/assets/map-XzoOFt0E.js +2 -0
  21. package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
  22. package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
  23. package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
  24. package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
  25. package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
  26. package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
  27. package/runtime/web/index.html +36 -0
  28. package/runtime/web/player.js +21 -0
  29. package/runtime/web-page/index.html +41 -0
  30. package/runtime/web-page/page.css +1 -0
  31. package/runtime/web-page/page.js +31 -0
  32. package/src/cli/help.ts +25 -0
  33. package/src/cli/index.ts +7 -7
  34. package/src/commands/app/android.ts +3 -1
  35. package/src/commands/app/desktop.ts +1 -1
  36. package/src/commands/app/desktopMac.ts +10 -9
  37. package/src/commands/app/icon.ts +9 -6
  38. package/src/commands/app/index.ts +3 -1
  39. package/src/commands/app/shared.ts +13 -5
  40. package/src/commands/app/templates/android.ts +10 -11
  41. package/src/commands/app/templates/ios.ts +1 -1
  42. package/src/commands/assets.ts +1 -1
  43. package/src/commands/compile.ts +15 -2
  44. package/src/commands/design/index.ts +4 -4
  45. package/src/commands/design/snapshot.ts +3 -3
  46. package/src/commands/desktop.ts +5 -1
  47. package/src/commands/dev.ts +82 -29
  48. package/src/commands/index.ts +64 -60
  49. package/src/commands/init/index.ts +11 -15
  50. package/src/commands/init/templates.ts +64 -21
  51. package/src/commands/lightmap.ts +459 -200
  52. package/src/commands/navmesh.ts +2 -2
  53. package/src/commands/plugin.ts +46 -0
  54. package/src/commands/render.ts +22 -16
  55. package/src/commands/scene.ts +6 -1
  56. package/src/commands/serve.ts +58 -0
  57. package/src/commands/shaders/new.ts +1 -1
  58. package/src/commands/test.ts +402 -398
  59. package/src/commands/update/index.ts +3 -3
  60. package/src/compile/designCompile.ts +1 -1
  61. package/src/compile/headlessBundle.ts +133 -133
  62. package/src/compile/nativeStack.ts +1 -1
  63. package/src/compile/projectCompile.ts +10 -2
  64. package/src/compile/sceneCompile.ts +8 -22
  65. package/src/declarations/lecodes-3d-editor.d.ts +4 -0
  66. package/src/declarations/lecodes-assets.d.ts +1 -1
  67. package/src/declarations/{lecodes-renderer.d.ts → lecodes-headless.d.ts} +3 -3
  68. package/src/dev/clientTemplates.ts +18 -14
  69. package/src/dev/devServer.ts +75 -38
  70. package/src/dev/iosDev.ts +180 -0
  71. package/src/dev/webRunner.ts +82 -14
  72. package/src/hosts/cmgenTool.ts +1 -1
  73. package/src/hosts/desktopRenderer.ts +98 -27
  74. package/src/hosts/desktopScript.ts +150 -35
  75. package/src/hosts/distRoot.ts +1 -1
  76. package/src/hosts/matcTool.ts +1 -1
  77. package/src/hosts/peerInstall.ts +5 -5
  78. package/src/hosts/peers.ts +2 -2
  79. package/src/index.ts +4 -1
  80. package/src/plugin/contract.ts +438 -0
  81. package/src/plugin/emitKotlin.ts +357 -0
  82. package/src/plugin/emitSdk.ts +259 -0
  83. package/src/plugin/emitSwift.ts +336 -0
  84. package/src/plugin/emitWeb.ts +383 -0
  85. package/src/plugin/generate.ts +157 -0
  86. package/src/plugin/lines.ts +28 -0
  87. package/src/plugin/lower.ts +231 -0
  88. package/src/plugin/manifest.ts +192 -0
  89. package/src/project/ignore.ts +164 -163
  90. package/runtime/materials/lightmap-baked-lite.mat +0 -180
  91. package/runtime/materials/lightmap-baked.mat +0 -181
  92. package/runtime/scene-harness.json +0 -1
  93. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  94. package/runtime/web/assets/basis-C64VHDVD.js +0 -1
  95. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  96. package/runtime/web/assets/createViewerLite-DHqTFhWB.js +0 -866
  97. package/runtime/web/assets/draco-BiISTFcR.js +0 -118
  98. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  99. package/runtime/web/assets/index-Q06c6oHx.js +0 -2
  100. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
  101. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
  102. package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
  103. package/runtime/web/embed.html +0 -32
  104. package/runtime/web/embed.js +0 -171
@@ -1,163 +1,164 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
- import { join } from "node:path"
3
-
4
- /*
5
- * `.lecodesignore` support — keeps scratch files (temp probes, render screenshots, logs) out of
6
- * `status` / `push` so they never land in a checkpoint. Syntax is a practical subset of .gitignore:
7
- * - blank lines and `# comments` are skipped
8
- * - `!pattern` re-includes something a previous pattern ignored
9
- * - a trailing `/` matches directories only
10
- * - a leading `/` (or any embedded `/`) anchors the pattern to the project root
11
- * - `*` matches within a path segment, `**` across segments, `?` a single char
12
- * - a slash-free pattern (e.g. `_*`, `*.png`) matches a basename at any depth
13
- *
14
- * IMPORTANT: ignores apply to UNTRACKED files only. A file already in the project (recorded in the
15
- * manifest) is always tracked, even if it matches a pattern — so ignoring `*.png` can never make an
16
- * existing image asset silently disappear from the project. Pruning happens in `scanLocal`.
17
- */
18
-
19
- export type IgnoreMatcher = (key: string, isDir: boolean) => boolean
20
-
21
- type Rule = { re: RegExp, negate: boolean, dirOnly: boolean }
22
-
23
- // Always applied, even with no .lecodesignore — only patterns that are never real project assets, so
24
- // they're safe as invisible defaults. Everything asset-shaped (*.png, *.json, …) is opt-in via the
25
- // file written at clone time (see DEFAULT_IGNORE_FILE). `.artifacts/` is `lecodes test`'s
26
- // failure-frame dump dir (tests/.artifacts/) — regenerated scratch, never a project asset.
27
- // `dist/` is `lecodes app build desktop`'s default output (<root>/dist/<slug>, appDesktop.ts) —
28
- // same class, but it must be ignored for a second, load-bearing reason: that folder holds a COPY of
29
- // the host's runtime assets (lit.filamat, uberarchive.bin, …). Collected as project assets they get
30
- // staged next to the bundle, and onFetchLocal searches the bundle's dir BEFORE the host's assets dir
31
- // (main.cpp) — so the copy shadows the host's own. Harmless while every host was OpenGL and the
32
- // bytes matched; fatal once a build output can come from the Vulkan host, where the shadowing
33
- // lit.filamat is SPIR-V and Filament aborts with "not built for any of the OpenGL backend's
34
- // supported shader languages".
35
- // `.env` / `.env.*`: the project's LECODES_* config (projectEnv.ts) — it holds tokens and keystore
36
- // passwords, so it never leaves the machine whatever .lecodesignore says.
37
- const BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store", ".artifacts/", "dist/", ".env", ".env.*"]
38
-
39
- /** Template `.lecodesignore` written on clone — the explicit, editable "what we don't push" list. */
40
- export const DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
41
- # Only untracked files are affected — assets already in the project stay tracked
42
- # even if they match a pattern. Built-in defaults (node_modules, _*, *.log, .artifacts)
43
- # always apply.
44
-
45
- # Render / preview outputs (the default \`lecodes render --png\` target)
46
- screenshot.png
47
- shots/
48
-
49
- # Design-board comments live on the platform; design/comments/ is the local mirror of the
50
- # open ones (\`lecodes design comments pull\`) — read by Claude, but pushing it back as
51
- # project files would only duplicate the database.
52
- design/comments/
53
-
54
- # Image & other binary assets ARE pushed by default — they're real project resources.
55
- # Uncomment a pattern below only if you want to keep some untracked ones out of pushes.
56
- # *.png
57
-
58
- # Local notes & dumps (uncomment to ignore)
59
- # *.json
60
- # notes.md
61
- `
62
-
63
- /** Translate a glob (one path segment's worth of `*`/`?`/`**`) into a regex fragment. */
64
- const translate = (glob: string): string => {
65
- let re = ""
66
- for (let i = 0; i < glob.length; i++) {
67
- const ch = glob[i]
68
- if (ch === "*") {
69
- if (glob[i + 1] === "*") { re += ".*"; i++; if (glob[i + 1] === "/") i++ } // `**/` spans dirs
70
- else re += "[^/]*"
71
- } else if (ch === "?") re += "[^/]"
72
- else if (ch === "/") re += "/"
73
- else if ("\\^$.|+()[]{}".includes(ch)) re += "\\" + ch
74
- else re += ch
75
- }
76
- return re
77
- }
78
-
79
- const compile = (raw: string): Rule | null => {
80
- let p = raw.trim()
81
- if (!p || p.startsWith("#")) return null
82
- let negate = false
83
- if (p.startsWith("!")) { negate = true; p = p.slice(1) }
84
- let dirOnly = false
85
- if (p.endsWith("/")) { dirOnly = true; p = p.slice(0, -1) }
86
- const anchored = p.startsWith("/") || p.includes("/")
87
- if (p.startsWith("/")) p = p.slice(1)
88
- if (!p) return null
89
- const body = translate(p)
90
- // `(/.*)?$` lets a directory pattern also match files nested under it, matching gitignore behavior
91
- // even without relying on directory pruning.
92
- const re = anchored ? new RegExp("^/" + body + "(/.*)?$") : new RegExp("(^|/)" + body + "(/.*)?$")
93
- return { re, negate, dirOnly }
94
- }
95
-
96
- const buildMatcher = (rules: Rule[]): IgnoreMatcher => (key, isDir) => {
97
- let ignored = false
98
- for (const r of rules) {
99
- if (r.dirOnly && !isDir) continue // a `foo/` rule only matches dirs directly (files via prune)
100
- if (r.re.test(key)) ignored = !r.negate
101
- }
102
- return ignored
103
- }
104
-
105
- /** Compile a standalone glob list (same syntax as .lecodesignore lines, including `!` re-includes)
106
- * into a file-path matcher — used for app.json's `resources.remote` patterns. */
107
- export const buildGlobMatcher = (patterns: string[]): ((path: string) => boolean) => {
108
- // Matching is over FILE paths — normalize `videos/` (dir-only, would match nothing here) to
109
- // `videos`, whose `(/.*)?$` tail matches everything under the directory.
110
- const rules = patterns.map((p) => compile(p.trim().replace(/\/+$/, ""))).filter((r): r is Rule => r !== null)
111
- const match = buildMatcher(rules)
112
- // Anchored rules compile to `^/…` — they expect the canonical leading-slash key form.
113
- return (path) => match(path.startsWith("/") ? path : "/" + path, false)
114
- }
115
-
116
- /** Read `<root>/.lecodesignore` (if any) and combine with the built-in defaults into a matcher. */
117
- export const loadIgnore = (root: string): IgnoreMatcher => {
118
- const lines = [...BUILTIN_PATTERNS]
119
- const file = join(root, ".lecodesignore")
120
- if (existsSync(file)) {
121
- try { lines.push(...readFileSync(file, "utf8").split(/\r?\n/)) } catch { /* unreadable → defaults only */ }
122
- }
123
- const rules = lines.map(compile).filter((r): r is Rule => r !== null)
124
- return buildMatcher(rules)
125
- }
126
-
127
- /** Write the template `.lecodesignore` if the project doesn't have one yet (used on clone). */
128
- export const writeDefaultIgnore = (root: string) => {
129
- const file = join(root, ".lecodesignore")
130
- if (!existsSync(file)) writeFileSync(file, DEFAULT_IGNORE_FILE)
131
- }
132
-
133
- /* `.gitignore` for the project's OWN git repo. `.lecodes/` is per-checkout state — regenerable IDE
134
- * types (`lecodes update`) + `manifest.json`, this machine's push cursor (baseHeadId + per-file shas).
135
- * Committing it makes collaborators fight over the cursor and ship stale types, so every scaffold
136
- * path (init / clone / link) makes sure it's ignored. Render outputs mirror DEFAULT_IGNORE_FILE. */
137
- export const DEFAULT_GITIGNORE = `# LeCodes per-checkout state: generated IDE types + this machine's sync cursor
138
- # (.lecodes/manifest.json). Regenerate with \`lecodes update\` / re-attach with \`lecodes link\`.
139
- .lecodes/
140
-
141
- # Render / preview outputs and test failure frames
142
- screenshot.png
143
- shots/
144
- tests/.artifacts/
145
- tests/.shots/
146
-
147
- node_modules/
148
-
149
- dist/
150
- `
151
-
152
- /** Create `.gitignore` if missing; if it exists but doesn't ignore `.lecodes/`, append that rule.
153
- * Returns what happened so callers can log it ("created" | "updated" | null). */
154
- export const ensureGitignore = (root: string): "created" | "updated" | null => {
155
- const file = join(root, ".gitignore")
156
- if (!existsSync(file)) { writeFileSync(file, DEFAULT_GITIGNORE); return "created" }
157
- const text = readFileSync(file, "utf8")
158
- const covered = text.split(/\r?\n/).some(l => /^\/?\.lecodes\/?\s*$/.test(l.trim()))
159
- if (covered) return null
160
- const sep = text.length === 0 || text.endsWith("\n") ? "" : "\n"
161
- writeFileSync(file, `${text}${sep}\n# LeCodes per-checkout state (generated types + sync cursor) — not source\n.lecodes/\n`)
162
- return "updated"
163
- }
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+
4
+ /*
5
+ * `.lecodesignore` support — keeps scratch files (temp probes, render screenshots, logs) out of
6
+ * `status` / `push` so they never land in a checkpoint. Syntax is a practical subset of .gitignore:
7
+ * - blank lines and `# comments` are skipped
8
+ * - `!pattern` re-includes something a previous pattern ignored
9
+ * - a trailing `/` matches directories only
10
+ * - a leading `/` (or any embedded `/`) anchors the pattern to the project root
11
+ * - `*` matches within a path segment, `**` across segments, `?` a single char
12
+ * - a slash-free pattern (e.g. `_*`, `*.png`) matches a basename at any depth
13
+ *
14
+ * IMPORTANT: ignores apply to UNTRACKED files only. A file already in the project (recorded in the
15
+ * manifest) is always tracked, even if it matches a pattern — so ignoring `*.png` can never make an
16
+ * existing image asset silently disappear from the project. Pruning happens in `scanLocal`.
17
+ */
18
+
19
+ export type IgnoreMatcher = (key: string, isDir: boolean) => boolean
20
+
21
+ type Rule = { re: RegExp, negate: boolean, dirOnly: boolean }
22
+
23
+ // Always applied, even with no .lecodesignore — only patterns that are never real project assets, so
24
+ // they're safe as invisible defaults. Everything asset-shaped (*.png, *.json, …) is opt-in via the
25
+ // file written at clone time (see DEFAULT_IGNORE_FILE). `.artifacts/` is `lecodes test`'s
26
+ // failure-frame dump dir (tests/.artifacts/) — regenerated scratch, never a project asset.
27
+ // `dist/` is `lecodes app build desktop`'s default output (<root>/dist/<slug>, appDesktop.ts) —
28
+ // same class, but it must be ignored for a second, load-bearing reason: that folder holds a COPY of
29
+ // the host's runtime assets (lit.filamat, uberarchive.bin, …). Collected as project assets they get
30
+ // staged next to the bundle, and onFetchLocal searches the bundle's dir BEFORE the host's assets dir
31
+ // (main.cpp) — so the copy shadows the host's own. Harmless while every host was OpenGL and the
32
+ // bytes matched; fatal once a build output can come from the Vulkan host, where the shadowing
33
+ // lit.filamat is SPIR-V and Filament aborts with "not built for any of the OpenGL backend's
34
+ // supported shader languages".
35
+ // `.env` / `.env.*`: the project's LECODES_* config (projectEnv.ts) — it holds tokens and keystore
36
+ // passwords, so it never leaves the machine whatever .lecodesignore says.
37
+ // `.lightmap/` = a bake's float debug layers (`lecodes lightmap bake --debug-dir`): gigabytes the compile scan must never read
38
+ const BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store", ".artifacts/", "dist/", ".env", ".env.*", ".lightmap/"]
39
+
40
+ /** Template `.lecodesignore` written on clone — the explicit, editable "what we don't push" list. */
41
+ export const DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
42
+ # Only untracked files are affected — assets already in the project stay tracked
43
+ # even if they match a pattern. Built-in defaults (node_modules, _*, *.log, .artifacts)
44
+ # always apply.
45
+
46
+ # Render / preview outputs (the default \`lecodes render --png\` target)
47
+ screenshot.png
48
+ shots/
49
+
50
+ # Design-board comments live on the platform; design/comments/ is the local mirror of the
51
+ # open ones (\`lecodes design comments pull\`) — read by Claude, but pushing it back as
52
+ # project files would only duplicate the database.
53
+ design/comments/
54
+
55
+ # Image & other binary assets ARE pushed by default — they're real project resources.
56
+ # Uncomment a pattern below only if you want to keep some untracked ones out of pushes.
57
+ # *.png
58
+
59
+ # Local notes & dumps (uncomment to ignore)
60
+ # *.json
61
+ # notes.md
62
+ `
63
+
64
+ /** Translate a glob (one path segment's worth of `*`/`?`/`**`) into a regex fragment. */
65
+ const translate = (glob: string): string => {
66
+ let re = ""
67
+ for (let i = 0; i < glob.length; i++) {
68
+ const ch = glob[i]
69
+ if (ch === "*") {
70
+ if (glob[i + 1] === "*") { re += ".*"; i++; if (glob[i + 1] === "/") i++ } // `**/` spans dirs
71
+ else re += "[^/]*"
72
+ } else if (ch === "?") re += "[^/]"
73
+ else if (ch === "/") re += "/"
74
+ else if ("\\^$.|+()[]{}".includes(ch)) re += "\\" + ch
75
+ else re += ch
76
+ }
77
+ return re
78
+ }
79
+
80
+ const compile = (raw: string): Rule | null => {
81
+ let p = raw.trim()
82
+ if (!p || p.startsWith("#")) return null
83
+ let negate = false
84
+ if (p.startsWith("!")) { negate = true; p = p.slice(1) }
85
+ let dirOnly = false
86
+ if (p.endsWith("/")) { dirOnly = true; p = p.slice(0, -1) }
87
+ const anchored = p.startsWith("/") || p.includes("/")
88
+ if (p.startsWith("/")) p = p.slice(1)
89
+ if (!p) return null
90
+ const body = translate(p)
91
+ // `(/.*)?$` lets a directory pattern also match files nested under it, matching gitignore behavior
92
+ // even without relying on directory pruning.
93
+ const re = anchored ? new RegExp("^/" + body + "(/.*)?$") : new RegExp("(^|/)" + body + "(/.*)?$")
94
+ return { re, negate, dirOnly }
95
+ }
96
+
97
+ const buildMatcher = (rules: Rule[]): IgnoreMatcher => (key, isDir) => {
98
+ let ignored = false
99
+ for (const r of rules) {
100
+ if (r.dirOnly && !isDir) continue // a `foo/` rule only matches dirs directly (files via prune)
101
+ if (r.re.test(key)) ignored = !r.negate
102
+ }
103
+ return ignored
104
+ }
105
+
106
+ /** Compile a standalone glob list (same syntax as .lecodesignore lines, including `!` re-includes)
107
+ * into a file-path matcher — used for app.json's `resources.remote` patterns. */
108
+ export const buildGlobMatcher = (patterns: string[]): ((path: string) => boolean) => {
109
+ // Matching is over FILE paths — normalize `videos/` (dir-only, would match nothing here) to
110
+ // `videos`, whose `(/.*)?$` tail matches everything under the directory.
111
+ const rules = patterns.map((p) => compile(p.trim().replace(/\/+$/, ""))).filter((r): r is Rule => r !== null)
112
+ const match = buildMatcher(rules)
113
+ // Anchored rules compile to `^/…` — they expect the canonical leading-slash key form.
114
+ return (path) => match(path.startsWith("/") ? path : "/" + path, false)
115
+ }
116
+
117
+ /** Read `<root>/.lecodesignore` (if any) and combine with the built-in defaults into a matcher. */
118
+ export const loadIgnore = (root: string): IgnoreMatcher => {
119
+ const lines = [...BUILTIN_PATTERNS]
120
+ const file = join(root, ".lecodesignore")
121
+ if (existsSync(file)) {
122
+ try { lines.push(...readFileSync(file, "utf8").split(/\r?\n/)) } catch { /* unreadable → defaults only */ }
123
+ }
124
+ const rules = lines.map(compile).filter((r): r is Rule => r !== null)
125
+ return buildMatcher(rules)
126
+ }
127
+
128
+ /** Write the template `.lecodesignore` if the project doesn't have one yet (used on clone). */
129
+ export const writeDefaultIgnore = (root: string) => {
130
+ const file = join(root, ".lecodesignore")
131
+ if (!existsSync(file)) writeFileSync(file, DEFAULT_IGNORE_FILE)
132
+ }
133
+
134
+ /* `.gitignore` for the project's OWN git repo. `.lecodes/` is per-checkout state — regenerable IDE
135
+ * types (`lecodes update`) + `manifest.json`, this machine's push cursor (baseHeadId + per-file shas).
136
+ * Committing it makes collaborators fight over the cursor and ship stale types, so every scaffold
137
+ * path (init / clone / link) makes sure it's ignored. Render outputs mirror DEFAULT_IGNORE_FILE. */
138
+ export const DEFAULT_GITIGNORE = `# LeCodes per-checkout state: generated IDE types + this machine's sync cursor
139
+ # (.lecodes/manifest.json). Regenerate with \`lecodes update\` / re-attach with \`lecodes link\`.
140
+ .lecodes/
141
+
142
+ # Render / preview outputs and test failure frames
143
+ screenshot.png
144
+ shots/
145
+ tests/.artifacts/
146
+ tests/.shots/
147
+
148
+ node_modules/
149
+
150
+ dist/
151
+ `
152
+
153
+ /** Create `.gitignore` if missing; if it exists but doesn't ignore `.lecodes/`, append that rule.
154
+ * Returns what happened so callers can log it ("created" | "updated" | null). */
155
+ export const ensureGitignore = (root: string): "created" | "updated" | null => {
156
+ const file = join(root, ".gitignore")
157
+ if (!existsSync(file)) { writeFileSync(file, DEFAULT_GITIGNORE); return "created" }
158
+ const text = readFileSync(file, "utf8")
159
+ const covered = text.split(/\r?\n/).some(l => /^\/?\.lecodes\/?\s*$/.test(l.trim()))
160
+ if (covered) return null
161
+ const sep = text.length === 0 || text.endsWith("\n") ? "" : "\n"
162
+ writeFileSync(file, `${text}${sep}\n# LeCodes per-checkout state (generated types + sync cursor) — not source\n.lecodes/\n`)
163
+ return "updated"
164
+ }
@@ -1,180 +0,0 @@
1
- // lightmap-baked-lite: the lightmap material's CHEAPEST tier (SDK `Material.lightmapShading =
2
- // "baked-lite"`). lightmap-baked (the unlit twin with hand-written lighting) with the per-pixel texture
3
- // reads cut on the LEVEL: a static (the atlas path) reads base colour, emissive and the two atlas pages
4
- // only — no normal map (the geometric normal shades), no metallic/roughness map, no occlusion map (the
5
- // atlas' baked AO stays). A dynamic object (the volume path: weapons, hands, bots — few pixels) keeps
6
- // every read of lightmap-baked, so the gun in front of the camera looks the same on every tier.
7
- // Emissive MUST stay: the lamps and signs glow through it.
8
- //
9
- // Trap (2026-09-07): with the metallic/roughness map gone, `metallicFactor` alone is WRONG — every
10
- // pack material here declares metallicFactor 1 and lets the map carry the metalness, so the factor
11
- // made the whole level fully metallic (diffuse 0 → a dark scene, a dark rifle). Without the map a
12
- // static is a dielectric (metallic 0, roughness = the factor floored at 0.6).
13
- // Measured on fps-demo's shooting range at 1920x1080 (integrated GPU, LOW preset): a 3-read probe of
14
- // this shape ran +20 % over lightmap-baked (62–69 vs 48–59 fps); at 1280x720 only +3–5 % — texture
15
- // bandwidth is the remaining per-pixel bill at the target resolution.
16
- // Keep the parameter list IDENTICAL to lightmap.mat (see lightmap-baked.mat): every parameter is set
17
- // by creator-gl whether the shader reads it or not, and an undeclared uniform is a panic.
18
-
19
- material {
20
- name : lightmap_baked_lite,
21
- shadingModel : unlit,
22
- doubleSided : true,
23
- // opaque or masked: matc -TBLENDING (materials.json `variants`) — the masked twin is what the
24
- // lightmap provider hands a glTF MASK material (foliage cards), same shader otherwise
25
- blending : ${BLENDING},
26
- flipUV : false,
27
- requires : [ uv0, uv1, tangents ],
28
- parameters : [
29
- { type : float4, name : baseColorFactor },
30
- { type : sampler2d, name : baseColorMap },
31
- { type : float, name : metallicFactor },
32
- { type : float, name : roughnessFactor },
33
- { type : float3, name : emissiveFactor },
34
- { type : float, name : normalScale },
35
- { type : float, name : aoStrength },
36
- { type : sampler2d, name : normalMap },
37
- { type : float, name : hasNormalMap },
38
- { type : sampler2d, name : metallicRoughnessMap },
39
- { type : sampler2d, name : occlusionMap },
40
- { type : sampler2d, name : emissiveMap },
41
- { type : sampler2d, name : lightmap },
42
- { type : sampler2d, name : lightmapLight },
43
- { type : float, name : lightScale },
44
- { type : float, name : lightGamma },
45
- { type : float4, name : lightmapST },
46
- { type : float, name : ambientScale },
47
- { type : float, name : sunStrength },
48
- { type : float, name : bakedAo },
49
- { type : sampler3d, name : probeVolume },
50
- { type : sampler3d, name : probeLight },
51
- { type : float4, name : probeMin },
52
- { type : float4, name : probeInvSize }
53
- ]
54
- }
55
-
56
- fragment {
57
- // filament's SH irradiance (Lambert baked into the coefficients, like Irradiance_SphericalHarmonics)
58
- vec3 shIrradiance(const vec3 n) {
59
- return max(
60
- frameUniforms.iblSH[0].xyz
61
- + frameUniforms.iblSH[1].xyz * n.y + frameUniforms.iblSH[2].xyz * n.z + frameUniforms.iblSH[3].xyz * n.x
62
- + frameUniforms.iblSH[4].xyz * (n.y * n.x) + frameUniforms.iblSH[5].xyz * (n.y * n.z)
63
- + frameUniforms.iblSH[6].xyz * (3.0 * n.z * n.z - 1.0) + frameUniforms.iblSH[7].xyz * (n.z * n.x)
64
- + frameUniforms.iblSH[8].xyz * (n.x * n.x - n.y * n.y), 0.0);
65
- }
66
-
67
- // Karis' analytic DFG (UE4 mobile): the split-sum's E term without the LUT
68
- vec3 envBrdfApprox(const vec3 f0, float roughness, float NoV) {
69
- vec4 c0 = vec4(-1.0, -0.0275, -0.572, 0.022);
70
- vec4 c1 = vec4(1.0, 0.0425, 1.04, -0.04);
71
- vec4 r = roughness * c0 + c1;
72
- float a004 = min(r.x * r.x, exp2(-9.28 * NoV)) * r.x + r.y;
73
- vec2 ab = vec2(-1.04, 1.04) * a004 + r.zw;
74
- return f0 * ab.x + ab.y;
75
- }
76
-
77
- void material(inout MaterialInputs material) {
78
- prepareMaterial(material);
79
- vec4 base = texture(materialParams_baseColorMap, getUV0()) * materialParams.baseColorFactor;
80
- // a dynamic object (volume-lit) keeps lightmap-baked's reads; a static reads nothing but base,
81
- // emissive and the atlas — and without its metal/rough map it is a dielectric (see the header)
82
- bool dyn = materialParams.probeInvSize.w > 0.5;
83
- float metallic = 0.0;
84
- float roughness = max(materialParams.roughnessFactor, 0.6);
85
- float occ = 1.0;
86
- if (dyn) {
87
- vec2 mr = texture(materialParams_metallicRoughnessMap, getUV0()).bg;
88
- metallic = materialParams.metallicFactor * mr.x;
89
- roughness = materialParams.roughnessFactor * mr.y;
90
- occ = 1.0 + materialParams.aoStrength * (texture(materialParams_occlusionMap, getUV0()).r - 1.0);
91
- }
92
- vec3 glow = texture(materialParams_emissiveMap, getUV0()).rgb * materialParams.emissiveFactor;
93
- vec3 n = getWorldGeometricNormalVector();
94
- vec3 ns = n;
95
- if (dyn && materialParams.hasNormalMap > 0.5) {
96
- vec3 tn = texture(materialParams_normalMap, getUV0()).xyz * 2.0 - 1.0;
97
- tn.xy *= materialParams.normalScale;
98
- // unlit has no vertex tangent frame (MATERIAL_NEEDS_TBN is lit-only): build one from the
99
- // screen-space derivatives of position and uv0 (Schueler's cotangent frame)
100
- highp vec3 p = getWorldPosition();
101
- highp vec2 uv = getUV0();
102
- highp vec3 dp1 = dFdx(p), dp2 = dFdy(p);
103
- highp vec2 duv1 = dFdx(uv), duv2 = dFdy(uv);
104
- highp vec3 dp2perp = cross(dp2, n), dp1perp = cross(n, dp1);
105
- highp vec3 T = dp2perp * duv1.x + dp1perp * duv2.x;
106
- highp vec3 B = dp2perp * duv1.y + dp1perp * duv2.y;
107
- highp float invmax = inversesqrt(max(dot(T, T), dot(B, B)));
108
- ns = normalize(mat3(T * invmax, B * invmax, n) * tn);
109
- }
110
- vec3 v = getWorldViewVector();
111
- float NoV = max(dot(ns, v), 1e-4);
112
- vec3 luma = vec3(0.2126, 0.7152, 0.0722);
113
- float lg = materialParams.lightGamma > 0.0 ? materialParams.lightGamma : 1.0;
114
-
115
- // the baked lamps: the volume for a dynamic object, the atlas for a static — as lightmap.mat
116
- vec2 lm;
117
- vec3 baked;
118
- vec3 l;
119
- if (materialParams.probeInvSize.w > 0.5) {
120
- vec3 p = getUserWorldPosition() + n * materialParams.probeMin.w;
121
- vec3 uvw = (p - materialParams.probeMin.xyz) * materialParams.probeInvSize.xyz;
122
- lm = texture(materialParams_probeVolume, uvw).rg;
123
- float nz = materialParams.probeInvSize.w;
124
- float wz = clamp(uvw.z, 0.5 / nz, 1.0 - 0.5 / nz);
125
- vec3 fx = vec3(uvw.xy, ((ns.x >= 0.0 ? 0.0 : 1.0) + wz) / 6.0);
126
- vec3 fy = vec3(uvw.xy, ((ns.y >= 0.0 ? 2.0 : 3.0) + wz) / 6.0);
127
- vec3 fz = vec3(uvw.xy, ((ns.z >= 0.0 ? 4.0 : 5.0) + wz) / 6.0);
128
- vec3 ex = pow(texture(materialParams_probeLight, fx).rgb, vec3(lg));
129
- vec3 ey = pow(texture(materialParams_probeLight, fy).rgb, vec3(lg));
130
- vec3 ez = pow(texture(materialParams_probeLight, fz).rgb, vec3(lg));
131
- vec3 w2 = ns * ns;
132
- baked = (w2.x * ex + w2.y * ey + w2.z * ez) * materialParams.lightScale;
133
- vec3 dirE = vec3(sign(ns.x) * dot(ex, luma), sign(ns.y) * dot(ey, luma), sign(ns.z) * dot(ez, luma));
134
- l = dot(dirE, dirE) > 1e-12 ? normalize(dirE) : ns;
135
- } else {
136
- vec2 luv = getUV1() * materialParams.lightmapST.xy + materialParams.lightmapST.zw;
137
- lm = texture(materialParams_lightmap, luv).rg;
138
- baked = pow(texture(materialParams_lightmapLight, luv).rgb, vec3(lg)) * materialParams.lightScale;
139
- l = ns;
140
- }
141
- lm.g = mix(1.0, lm.g, materialParams.bakedAo);
142
- float ao = lm.g * occ;
143
- float vis = mix(1.0, lm.r, materialParams.sunStrength);
144
-
145
- vec3 diffuseColor = base.rgb * (1.0 - metallic);
146
- vec3 f0 = mix(vec3(0.04), base.rgb, metallic);
147
- vec3 E = envBrdfApprox(f0, roughness, NoV);
148
-
149
- vec3 ambD = diffuseColor * shIrradiance(ns) * (1.0 - E) * ao * frameUniforms.iblLuminance;
150
- vec3 ambS = E * shIrradiance(reflect(-v, ns)) * ao * frameUniforms.iblLuminance;
151
- vec3 color = (ambD + ambS) * materialParams.ambientScale;
152
-
153
- // the sun (a level with one): Lambert × the atlas' visibility mask; pre-exposed like filament's own
154
- float sunW = frameUniforms.lightColorIntensity.w;
155
- if (sunW > 0.0) {
156
- float NoL = max(dot(ns, frameUniforms.lightDirection), 0.0);
157
- color += diffuseColor * frameUniforms.lightColorIntensity.rgb * (sunW * NoL * vis / PI);
158
- }
159
-
160
- // the baked lamps: diffuse + the cheap specular lobe lightmap.mat also uses (physical units × exposure)
161
- {
162
- vec3 h = normalize(v + l);
163
- float NoL = max(dot(ns, l), 0.0);
164
- float NoH = max(dot(ns, h), 0.0);
165
- float LoH = max(dot(l, h), 0.0);
166
- float pr = max(roughness, 0.5);
167
- float a2 = pr * pr;
168
- a2 *= a2;
169
- float dd = NoH * NoH * (a2 - 1.0) + 1.0;
170
- float D = a2 / (PI * dd * dd);
171
- float V = 0.5 / max(NoL * sqrt(NoV * NoV * (1.0 - a2) + a2) + NoV * sqrt(NoL * NoL * (1.0 - a2) + a2), 1e-4);
172
- vec3 F = f0 + (1.0 - f0) * pow(1.0 - LoH, 5.0);
173
- color += (diffuseColor / PI + D * V * F) * baked * getExposure();
174
- }
175
-
176
- // the texture's alpha rides along: the masked twin (-TBLENDING=masked) cuts foliage cards on it,
177
- // the opaque one ignores it (an unlit opaque material writes no alpha)
178
- material.baseColor = vec4(color + glow, base.a);
179
- }
180
- }