lecodes-cli 0.20.0 → 0.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1065 -577
- package/package.json +7 -7
- package/runtime/materials/decal-relief.mat +178 -0
- package/runtime/materials/decal.mat +172 -0
- package/runtime/materials/lightmap-baked-lite.mat +175 -0
- package/runtime/materials/lightmap-baked.mat +176 -0
- package/runtime/materials/lightmap.mat +181 -0
- package/runtime/materials/lit.mat +43 -0
- package/runtime/materials/particles-quad.mat +179 -0
- package/runtime/materials/particles.mat +129 -0
- package/runtime/materials/shadow.mat +21 -0
- package/runtime/materials/terrain.mat +134 -0
- package/runtime/materials/unlit-transparent.mat +42 -0
- package/runtime/materials/unlit.mat +44 -0
- package/runtime/materials/video.mat +31 -0
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/assets/{createViewerLite-BvHobVhp.js → createViewerLite-Ct_PZdof.js} +22 -22
- package/runtime/web/assets/{index-CfNCCBjN.js → index-BMt7AnC5.js} +1 -1
- package/runtime/web/embed.js +2 -2
- package/src/cmgenTool.ts +17 -9
- package/src/commands/appDesktop.ts +12 -2
- package/src/commands/appShared.ts +48 -0
- package/src/commands/assets.ts +3 -7
- package/src/commands/design.ts +9 -26
- package/src/commands/desktop.ts +3 -0
- package/src/commands/dev.ts +42 -0
- package/src/commands/projectTemplates.ts +13 -1
- package/src/commands/render.ts +4 -8
- package/src/commands/scene.ts +2 -6
- package/src/commands/shaders.ts +17 -1
- package/src/commands/shadersNew.ts +133 -0
- package/src/commands/thumbs.ts +3 -6
- package/src/commands/update.ts +42 -21
- package/src/desktopRenderer.ts +17 -0
- package/src/desktopScript.ts +1 -1
- package/src/dev/androidDev.ts +154 -0
- package/src/dev/devServer.ts +8 -2
- package/src/index.ts +21 -4
- package/src/matcTool.ts +31 -19
- package/src/peerInstall.ts +160 -0
- package/src/peers.ts +48 -14
- package/src/projectEnv.ts +4 -0
- package/src/releases.ts +16 -0
package/src/cmgenTool.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs"
|
|
2
2
|
import { homedir } from "node:os"
|
|
3
3
|
import { join, resolve } from "node:path"
|
|
4
|
-
import { downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
|
|
4
|
+
import { downloadReleaseArchive, fetchReleaseProduct, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
|
|
5
5
|
import { CliError, note } from "./util"
|
|
6
6
|
|
|
7
7
|
/*
|
|
@@ -20,7 +20,8 @@ import { CliError, note } from "./util"
|
|
|
20
20
|
* stays network-free and still works standalone against a local build.
|
|
21
21
|
*
|
|
22
22
|
* No version pin, same reasoning as matc: the KTX1 layout is tied to the vendored Filament and
|
|
23
|
-
* changes rarely, so resolution takes the newest cached copy, else downloads
|
|
23
|
+
* changes rarely, so resolution takes the newest cached copy, else downloads the newest release
|
|
24
|
+
* PUBLISHED FOR THIS PLATFORM (see releases.ts newestForPlatform).
|
|
24
25
|
*/
|
|
25
26
|
|
|
26
27
|
const exeName = () => (process.platform === "win32" ? "cmgen.exe" : "cmgen")
|
|
@@ -63,15 +64,21 @@ export const installedCmgenVersions = (): string[] => {
|
|
|
63
64
|
export const fetchCmgenManifest = (): Promise<{ latest: string, versions: ManifestVersion[] }> =>
|
|
64
65
|
fetchReleaseProduct("cmgen", "cmgen (environment tool)")
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
/** The published cmgen this platform should get: the newest version carrying a build for it (see
|
|
68
|
+
* newestForPlatform — a release often ships one platform, so `latest` is the wrong question). */
|
|
69
|
+
const pickCmgen = (versions: ManifestVersion[]): { version: string, file: ManifestFile } => {
|
|
67
70
|
const key = platformKey()
|
|
68
|
-
const
|
|
69
|
-
if (!
|
|
71
|
+
const hit = newestForPlatform(versions, key)
|
|
72
|
+
if (!hit) {
|
|
70
73
|
throw new CliError(
|
|
71
|
-
`cmgen
|
|
74
|
+
`cmgen isn't published for ${key} — build filament's host tools locally and point ` +
|
|
72
75
|
`LECODES_CMGEN at the binary.`,
|
|
73
76
|
)
|
|
74
77
|
}
|
|
78
|
+
return hit
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const downloadCmgen = async (version: string, file: ManifestFile, dest: string): Promise<void> => {
|
|
75
82
|
note(`Downloading the environment tool (cmgen ${version}, ${(file.size / 1024 / 1024).toFixed(1)} MB)…`)
|
|
76
83
|
await downloadReleaseArchive(file, {
|
|
77
84
|
cacheRoot: cacheRoot(),
|
|
@@ -95,9 +102,10 @@ export const resolveCmgenExeCached = (): string | null => {
|
|
|
95
102
|
export const resolveCmgenExe = async (): Promise<string> => {
|
|
96
103
|
const cached = resolveCmgenExeCached()
|
|
97
104
|
if (cached) return cached
|
|
98
|
-
const {
|
|
99
|
-
const
|
|
100
|
-
|
|
105
|
+
const { versions } = await fetchCmgenManifest()
|
|
106
|
+
const { version, file } = pickCmgen(versions)
|
|
107
|
+
const dir = join(cacheRoot(), `cmgen-${version}`)
|
|
108
|
+
await downloadCmgen(version, file, dir)
|
|
101
109
|
const exe = findCmgenIn(dir)
|
|
102
110
|
if (!exe) throw new CliError(`cmgen cache at ${dir} is broken — delete the folder and retry.`)
|
|
103
111
|
return exe
|
|
@@ -199,8 +199,18 @@ export const buildDesktop = async (args: Args) => {
|
|
|
199
199
|
note(` assets: ${assetCount} files, ${mb(assetBytes)} → ${ASSETS_DIR}/`)
|
|
200
200
|
|
|
201
201
|
// 3) Pruned app.json — the host (≥1.3.0) reads name + desktop.window next to app.js, so the
|
|
202
|
-
// folder carries its own window config (title/size/min/resizable/fullscreen)
|
|
203
|
-
|
|
202
|
+
// folder carries its own window config (title/size/min/resizable/fullscreen); a newer host also
|
|
203
|
+
// reads desktop.mouseEmulateTouch + desktop.virtualKeyboard there (an older one skips the
|
|
204
|
+
// unknown fields).
|
|
205
|
+
const desktop = config.desktop
|
|
206
|
+
writeJson(join(out, "app.json"), {
|
|
207
|
+
name,
|
|
208
|
+
...(desktop ? { desktop: {
|
|
209
|
+
window: desktop.window,
|
|
210
|
+
...(desktop.mouseEmulateTouch !== undefined ? { mouseEmulateTouch: desktop.mouseEmulateTouch } : {}),
|
|
211
|
+
...(desktop.virtualKeyboard !== undefined ? { virtualKeyboard: desktop.virtualKeyboard } : {}),
|
|
212
|
+
} } : {}),
|
|
213
|
+
})
|
|
204
214
|
|
|
205
215
|
// 4) Window/taskbar icon (host ≥1.3.0 loads app.ico next to the exe at runtime).
|
|
206
216
|
if (config.icon) {
|
|
@@ -77,12 +77,60 @@ export interface AppConfig {
|
|
|
77
77
|
/** 3D buffer cap in px, e.g. [1920, 1080]: on a bigger window/monitor the 3D renders at most
|
|
78
78
|
* this size (aspect kept) — the per-machine answer to "fullscreen on a 4K display is slow". */
|
|
79
79
|
maxRenderSize?: [number, number],
|
|
80
|
+
/** The mouse button acts as a finger: a left click-drag scrolls + flings like touch (the wheel
|
|
81
|
+
* always scrolls, clicks are unaffected; a button inside a scrollable then gets the touch-style
|
|
82
|
+
* press feedback delay). For testing the touch experience without a touchscreen, or an app
|
|
83
|
+
* driven by a mouse-like device. `desktop run` forwards it as CREATOR_MOUSE_TOUCH (a set env
|
|
84
|
+
* wins); a built folder's host reads it from the staged app.json. Headless render/test ignore it. */
|
|
85
|
+
mouseEmulateTouch?: boolean,
|
|
86
|
+
/** On-screen keyboard for EVERY input/textarea (touch kiosks, a screen without a keyboard).
|
|
87
|
+
* `true` or a block; `desktop run` forwards it as CREATOR_VIRTUAL_KEYBOARD (a set env wins),
|
|
88
|
+
* a built folder's host reads it from the staged app.json. Headless render/test ignore it. */
|
|
89
|
+
virtualKeyboard?: boolean | DesktopVirtualKeyboard,
|
|
80
90
|
/** The macOS .app build (`lecodes app build desktop` on a Mac) — identity + signing +
|
|
81
91
|
* notarization. See appDesktopMac.ts for the whole story. */
|
|
82
92
|
macos?: MacDesktopConfig,
|
|
83
93
|
}
|
|
84
94
|
}
|
|
85
95
|
|
|
96
|
+
/** One style slot of the desktop on-screen keyboard — the SDK's style words, the subset the drawn
|
|
97
|
+
* keyboard reads. Unset fields keep the theme preset. */
|
|
98
|
+
export interface DesktopKeyboardSlot {
|
|
99
|
+
backgroundColor?: string
|
|
100
|
+
color?: string
|
|
101
|
+
borderRadius?: number
|
|
102
|
+
fontSize?: number
|
|
103
|
+
/** A family the app registered with `font()` / `Fonts`, so labels match the UI. */
|
|
104
|
+
fontFamily?: string
|
|
105
|
+
/** `board` only: band inset / key spacing (logical px). */
|
|
106
|
+
padding?: number
|
|
107
|
+
gap?: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** app.json `desktop.virtualKeyboard` as a block. */
|
|
111
|
+
export interface DesktopVirtualKeyboard {
|
|
112
|
+
/** Off/on; an object without it counts as on. */
|
|
113
|
+
enabled?: boolean
|
|
114
|
+
/** Layouts in order, the first is the start one; the language key appears with two or more.
|
|
115
|
+
* Shipped: "en", "ru". */
|
|
116
|
+
layouts?: ("en" | "ru")[]
|
|
117
|
+
/** Band height: ≤ 1 = a fraction of the window (default 0.32), > 1 = logical px. */
|
|
118
|
+
height?: number
|
|
119
|
+
/** Cap on the KEY AREA's width in logical px — on a wide kiosk the keys would otherwise stretch
|
|
120
|
+
* across the whole window. The band itself always spans the window; the keys sit centred in it. */
|
|
121
|
+
maxWidth?: number
|
|
122
|
+
/** Colour preset the style slots override. */
|
|
123
|
+
theme?: "dark" | "light"
|
|
124
|
+
style?: {
|
|
125
|
+
board?: DesktopKeyboardSlot
|
|
126
|
+
key?: DesktopKeyboardSlot
|
|
127
|
+
/** A held key (and an active shift). Inherits `key` first. */
|
|
128
|
+
keyPressed?: DesktopKeyboardSlot
|
|
129
|
+
/** Shift / backspace / enter / page keys. Inherits `key` first. */
|
|
130
|
+
keySpecial?: DesktopKeyboardSlot
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
86
134
|
/** app.json `desktop.macos`. Only NAMES live here (identity, keychain profile) — the certificate
|
|
87
135
|
* and the notarization password stay in the keychain. */
|
|
88
136
|
export interface MacDesktopConfig {
|
package/src/commands/assets.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type Args } from "../util"
|
|
2
2
|
import { resolveCmgenExe } from "../cmgenTool"
|
|
3
|
+
import { loadPeer } from "../peerInstall"
|
|
3
4
|
|
|
4
5
|
/*
|
|
5
6
|
* `lecodes assets <convert|probe|doctor|sky> …` — the LeCodes asset pipeline (packages/lecodes-assets):
|
|
@@ -9,12 +10,7 @@ import { resolveCmgenExe } from "../cmgenTool"
|
|
|
9
10
|
* forwarded untouched (`assets` gets `_raw` from main). See docs/animation-plan.md §2.9.
|
|
10
11
|
*/
|
|
11
12
|
export const assets = async (args: Args & { _raw?: string[] }) => {
|
|
12
|
-
|
|
13
|
-
try {
|
|
14
|
-
mod = await import("lecodes-assets/cli")
|
|
15
|
-
} catch {
|
|
16
|
-
throw new CliError("The asset pipeline isn't installed. Run: npm install -g lecodes-assets")
|
|
17
|
-
}
|
|
13
|
+
const mod = await loadPeer<typeof import("lecodes-assets/cli")>("lecodes-assets", "cli", { for: "lecodes assets" })
|
|
18
14
|
const raw = args._raw ?? args._
|
|
19
15
|
// `sky` shells out to filament's cmgen, which the asset package deliberately never downloads
|
|
20
16
|
// itself (it stays network-free and usable standalone). The CLI owns release artifacts, so it
|
package/src/commands/design.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { compileDesignScreen } from "../compile/designCompile"
|
|
|
5
5
|
import { openBrowser } from "../browserAuth"
|
|
6
6
|
import { CLAUDE_MD, DESIGN_MACROS_DTS, HOME_SCREEN_TS, META_JSON, META_JSON_DESKTOP, README_HINT, SPEC_MD, TABS_TS, TOKENS_TS } from "./designTemplates"
|
|
7
7
|
import { CliError, c, flagBool, flagStr, log, logErr, note, warnErr, type Args } from "../util"
|
|
8
|
+
import { loadPeer } from "../peerInstall"
|
|
8
9
|
import { loadConfig, normalizeApiUrl, requireApiUrl, requireToken } from "../config"
|
|
9
10
|
import {
|
|
10
11
|
createDesignComment, deleteDesignComment, deleteDesignCommentMessage, disableDesignShare,
|
|
@@ -87,12 +88,7 @@ const loadDesignServer = async (
|
|
|
87
88
|
if (!existsSync(ctx.designDir)) {
|
|
88
89
|
throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`)
|
|
89
90
|
}
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
mod = await import("lecodes-design/server")
|
|
93
|
-
} catch {
|
|
94
|
-
throw new CliError("The design canvas isn't installed. Run: npm install -g lecodes-design")
|
|
95
|
-
}
|
|
91
|
+
const mod = await loadPeer<typeof import("lecodes-design/server")>("lecodes-design", "server", { for: "lecodes design" })
|
|
96
92
|
const missing = needs.filter((name) => typeof mod[name] !== "function")
|
|
97
93
|
if (missing.length) {
|
|
98
94
|
throw new CliError(
|
|
@@ -236,23 +232,14 @@ const snapshot = async (ctx: DesignContext, args: Args) => {
|
|
|
236
232
|
const allStates = flagBool(args, "all-states")
|
|
237
233
|
if (stateFlag !== undefined && allStates) throw new CliError("Use either --state <name> or --all-states, not both.")
|
|
238
234
|
|
|
239
|
-
|
|
240
|
-
try {
|
|
241
|
-
renderer = await import("lecodes-renderer/headless")
|
|
242
|
-
} catch {
|
|
243
|
-
throw new CliError("The renderer isn't installed. Run: npm install -g lecodes-renderer")
|
|
244
|
-
}
|
|
235
|
+
const renderer = await loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes design render" })
|
|
245
236
|
|
|
246
237
|
// Enumerating / validating states parses the screen source — that discovery lives in the design
|
|
247
238
|
// package. The canonical-only path (no flag) needs no discovery, so base snapshot stays lean.
|
|
248
239
|
let discover: ((source: string) => { states: string[], canonical: string }) | null = null
|
|
249
240
|
if (stateFlag !== undefined || allStates) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
discover = (src) => { const d = ds.discoverScreenStates(src); return { states: d.states, canonical: d.canonical } }
|
|
253
|
-
} catch {
|
|
254
|
-
throw new CliError("Rendering specific states needs the design canvas. Run: npm install -g lecodes-design")
|
|
255
|
-
}
|
|
241
|
+
const ds = await loadPeer<typeof import("lecodes-design/server")>("lecodes-design", "server", { for: "lecodes design render --state" })
|
|
242
|
+
discover = (src) => { const d = ds.discoverScreenStates(src); return { states: d.states, canonical: d.canonical } }
|
|
256
243
|
}
|
|
257
244
|
|
|
258
245
|
// A stuck screen (runaway loop) shouldn't hang the CLI forever.
|
|
@@ -330,8 +317,8 @@ const serve = async (ctx: DesignContext, args: Args) => {
|
|
|
330
317
|
|
|
331
318
|
// The MCP render_screen tool renders headless through lecodes-renderer, injected here so the
|
|
332
319
|
// design server keeps no dependency on it. Absent renderer → the tool returns an install hint.
|
|
333
|
-
|
|
334
|
-
|
|
320
|
+
// Opportunistic: never triggers an install from inside the MCP server (the tool reports the hint).
|
|
321
|
+
const renderer = await loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes design (render_screen)", install: false })
|
|
335
322
|
const renderScreen = renderer
|
|
336
323
|
? async (id: string, format: "json" | "png" | "both", state?: string) => {
|
|
337
324
|
const r = renderer!
|
|
@@ -740,12 +727,8 @@ export const flushDesignComments = async (args: Args): Promise<CommentFlush | nu
|
|
|
740
727
|
const platform = optionalPlatform(ctx)
|
|
741
728
|
if (!platform) return null
|
|
742
729
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
designServer = await import("lecodes-design/server")
|
|
746
|
-
} catch {
|
|
747
|
-
return null
|
|
748
|
-
}
|
|
730
|
+
const designServer = await loadPeer<typeof import("lecodes-design/server")>("lecodes-design", "server", { for: "lecodes design", install: false })
|
|
731
|
+
if (!designServer) return null
|
|
749
732
|
// `settleQueued` must take `delivered` (4 params): settling a half-failed push with the older
|
|
750
733
|
// signature would drop the writes that DIDN'T go through instead of retrying them.
|
|
751
734
|
if (typeof designServer.queuedThreads !== "function") return null
|
package/src/commands/desktop.ts
CHANGED
|
@@ -71,6 +71,7 @@ const run = async (args: Args) => {
|
|
|
71
71
|
minHeight: win?.minHeight,
|
|
72
72
|
resizable: win?.resizable,
|
|
73
73
|
title: flagStr(args, "title") ?? win?.title ?? appCfg?.name,
|
|
74
|
+
storageScope: appCfg?.name,
|
|
74
75
|
// --fullscreen forces it on; --windowed forces it off (over app.json); else the config decides.
|
|
75
76
|
fullscreen: flagBool(args, "fullscreen") ? true : flagBool(args, "windowed") ? false : win?.fullscreen,
|
|
76
77
|
scale: resolveScale(flagStr(args, "scale"), win?.scale),
|
|
@@ -78,6 +79,8 @@ const run = async (args: Args) => {
|
|
|
78
79
|
// from app.json only. Both are desktop-level (the machine/app), not window-level.
|
|
79
80
|
renderScale: resolveRenderScale(flagStr(args, "render-scale"), appCfg?.desktop?.renderScale),
|
|
80
81
|
maxRenderSize: appCfg?.desktop?.maxRenderSize,
|
|
82
|
+
mouseEmulateTouch: appCfg?.desktop?.mouseEmulateTouch,
|
|
83
|
+
virtualKeyboard: appCfg?.desktop?.virtualKeyboard,
|
|
81
84
|
// --fps: the host's own corner counter. A flag and not app.json on purpose — it is something you
|
|
82
85
|
// switch on while looking at a frame, not a property of the app.
|
|
83
86
|
fps: flagBool(args, "fps"),
|
package/src/commands/dev.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { startDevServer } from "../dev/devServer"
|
|
|
8
8
|
import { resolveWebRunnerDir } from "../dev/webRunner"
|
|
9
9
|
import { openBrowser } from "../browserAuth"
|
|
10
10
|
import { runDesktopDev } from "../desktopRenderer"
|
|
11
|
+
import { androidCrashLines, launchOnAndroid, type AndroidDevSession } from "../dev/androidDev"
|
|
11
12
|
import { readAppConfigAt } from "./appShared"
|
|
12
13
|
import { resolveScale } from "./desktop"
|
|
13
14
|
import { CliError, c, flagBool, flagStr, log, note, toKey, warnErr, type Args } from "../util"
|
|
@@ -160,6 +161,7 @@ export const dev = async (args: Args) => {
|
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
const debug = process.env.LECODES_DEV_DEBUG === "1"
|
|
164
|
+
const underBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
|
|
163
165
|
let timer: NodeJS.Timeout | null = null
|
|
164
166
|
const onChange = (file: string | null) => {
|
|
165
167
|
if (debug) note(`[watch] event: ${file ?? "(null)"} → ${shouldRecompile(file) ? "recompile" : "skip"}`)
|
|
@@ -210,6 +212,46 @@ export const dev = async (args: Args) => {
|
|
|
210
212
|
openBrowser(webLocal)
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
// --android: open the dev URL in the LeCodes app on the phone (adb; see dev/androidDev.ts). With
|
|
216
|
+
// --time <s> the run is bounded — screenshot to --png, force-stop, exit — which is the shape a
|
|
217
|
+
// scripted check wants; without it the app runs until Ctrl+C, and Ctrl+C force-stops it (a
|
|
218
|
+
// device run has no frame cap, and a 3D scene left rendering heats a charging phone).
|
|
219
|
+
if (flagBool(args, "android")) {
|
|
220
|
+
const seconds = Number(flagStr(args, "time")) || 0
|
|
221
|
+
const png = flagStr(args, "png")
|
|
222
|
+
// Under Bun the websocket channel never delivers (see the warning above): the phone would sit on
|
|
223
|
+
// "Connected — loading the project…" forever. The path-form poll url works on both runtimes.
|
|
224
|
+
const deviceUrl = `http://127.0.0.1:${port}${server.url.slice(origin.length)}`.replace(/\/dev\.js$/, underBun ? "/dev-poll.js" : "/dev.js")
|
|
225
|
+
let session: AndroidDevSession
|
|
226
|
+
try {
|
|
227
|
+
session = launchOnAndroid({ port, url: deviceUrl, serial: flagStr(args, "serial"), apk: flagStr(args, "apk"), packageName: flagStr(args, "package") })
|
|
228
|
+
} catch (e) {
|
|
229
|
+
server.close(); watcher?.close()
|
|
230
|
+
throw e
|
|
231
|
+
}
|
|
232
|
+
note(`Android: ${session.serial} → ${deviceUrl} (adb reverse; device logs stream here as [device:log])`)
|
|
233
|
+
const finish = (code: number) => {
|
|
234
|
+
session.stop()
|
|
235
|
+
server.close(); watcher?.close()
|
|
236
|
+
process.exit(code)
|
|
237
|
+
}
|
|
238
|
+
process.on("SIGINT", () => { note("Stopping the app on the phone…"); finish(0) })
|
|
239
|
+
if (seconds > 0) {
|
|
240
|
+
setTimeout(() => {
|
|
241
|
+
const crashes = androidCrashLines(session)
|
|
242
|
+
if (png) {
|
|
243
|
+
if (session.screenshot(png)) note(`Wrote ${png} (device screenshot after ${seconds} s)`)
|
|
244
|
+
else warnErr(`Screenshot failed (adb screencap).`)
|
|
245
|
+
}
|
|
246
|
+
if (crashes.length > 0) {
|
|
247
|
+
warnErr(`The app crashed on the phone:\n${crashes.join("\n")}`)
|
|
248
|
+
finish(1)
|
|
249
|
+
}
|
|
250
|
+
finish(0)
|
|
251
|
+
}, seconds * 1000)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
213
255
|
// --desktop: open the dev URL in the native host too (same server, same hot reload — the host
|
|
214
256
|
// speaks the websocket channel). Window config comes from app.json's desktop block. The window
|
|
215
257
|
// closing does NOT stop the server: phones stay connected, and re-running with --desktop is the
|
|
@@ -16,7 +16,7 @@ from \`main.ts\`.
|
|
|
16
16
|
|
|
17
17
|
\`\`\`
|
|
18
18
|
lecodes render # semantic JSON of the launched UI (structure, text, names, values)
|
|
19
|
-
lecodes render --png shot.png # pixels (
|
|
19
|
+
lecodes render --png shot.png # pixels (the renderer + @napi-rs/canvas install themselves on first use; --yes skips the prompt)
|
|
20
20
|
lecodes render --png card.png --clip card # ONE component/region: a node name or x,y,w,h
|
|
21
21
|
lecodes render src/screens/cart.ts # ONE screen module, without booting the app: any file that
|
|
22
22
|
# \`export default\`s a UIScreen (or a (state) => UIScreen —
|
|
@@ -36,6 +36,11 @@ lecodes render --desktop --script <file> # the same scenario on the NATIVE host
|
|
|
36
36
|
lecodes test # all tests/*.flow.json + design-derived flows; run before "done"
|
|
37
37
|
lecodes test --desktop # the file flows on the NATIVE host (3D/Jolt/real GPU; coordinate
|
|
38
38
|
# flows only — design flows stay headless)
|
|
39
|
+
lecodes dev --android --time 12 --png phone.png # the REAL phone over adb (USB): launches the app on
|
|
40
|
+
# the dev url via adb reverse, streams device logs as [device:log],
|
|
41
|
+
# screenshots after 12 s, force-stops, exit 1 on a crash. --serial
|
|
42
|
+
# <id> with several devices, --apk <file> to install a build first.
|
|
43
|
+
# Without --time it runs until Ctrl+C (which stops the app).
|
|
39
44
|
\`\`\`
|
|
40
45
|
|
|
41
46
|
Iterate with \`--script\` scenarios and keep the useful ones as \`tests/<name>.flow.json\` — they
|
|
@@ -90,6 +95,13 @@ channel for game state that has no UI.
|
|
|
90
95
|
with \`"fixtures": "fixtures.json"\` in its envelope.
|
|
91
96
|
- \`fill\` fills every empty input — use it before tapping a disabled-until-filled submit.
|
|
92
97
|
|
|
98
|
+
## 3D scenes
|
|
99
|
+
|
|
100
|
+
- A HUD over a \`Scene\` is a widget ATTACHED to it: \`const hud = UIWidget(...).style({ left: 16,
|
|
101
|
+
bottom: 16, pointerEvents: "none" }); hud.attachTo(scene); hud.show()\`. Never a \`UIScreen\`:
|
|
102
|
+
a screen is a destination, so \`UIScreen(...).open()\` after \`scene.open()\` CLOSES the scene
|
|
103
|
+
(the host logs "3D scene closed" and you see only the screen) — no background makes it transparent.
|
|
104
|
+
|
|
93
105
|
## SDK documentation
|
|
94
106
|
|
|
95
107
|
\`.lecodes/types/\` is the exact API surface (signatures only; generated and gitignored — run
|
package/src/commands/render.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolv
|
|
|
8
8
|
import { runDesktopRender } from "../desktopRenderer"
|
|
9
9
|
import { evaluateDesktopRun, translateScenario } from "../desktopScript"
|
|
10
10
|
import { CliError, c, flagStr, flagBool, isNullSink, logErr, note, warnErr, type Args } from "../util"
|
|
11
|
+
import { loadPeer } from "../peerInstall"
|
|
11
12
|
|
|
12
13
|
/*
|
|
13
14
|
* `lecodes render` — compile the local project and render it headless. By default it emits a
|
|
@@ -81,14 +82,9 @@ export const resolveSafeAreaChoice = (explicit: string | undefined, device: stri
|
|
|
81
82
|
return undefined
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
/**
|
|
85
|
-
export const requireRenderer = async (): Promise<typeof import("lecodes-renderer/headless")> =>
|
|
86
|
-
|
|
87
|
-
return await import("lecodes-renderer/headless")
|
|
88
|
-
} catch {
|
|
89
|
-
throw new CliError("The renderer isn't installed. Run: npm install -g lecodes-renderer")
|
|
90
|
-
}
|
|
91
|
-
}
|
|
85
|
+
/** The optional renderer package — installed into ~/.lecodes/peers on first use (peerInstall.ts). */
|
|
86
|
+
export const requireRenderer = async (): Promise<typeof import("lecodes-renderer/headless")> =>
|
|
87
|
+
loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes render" })
|
|
92
88
|
|
|
93
89
|
/** A file-writing ScenarioIO rooted at `dir` (creates parent folders as needed). */
|
|
94
90
|
export const fileScenarioIO = (dir: string) => ({
|
package/src/commands/scene.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { compileSceneEditor } from "../compile/sceneCompile"
|
|
|
5
5
|
import { localShaderArtifact } from "../compile/shaders"
|
|
6
6
|
import { openBrowser } from "../browserAuth"
|
|
7
7
|
import { CliError, c, flagBool, flagStr, log, note, warnErr, type Args } from "../util"
|
|
8
|
+
import { loadPeer } from "../peerInstall"
|
|
8
9
|
|
|
9
10
|
/*
|
|
10
11
|
* `lecodes scene [path]` — the 3D scene editor (lecodes-tools/packages/lecodes-3d-editor) hosted on a local project,
|
|
@@ -112,12 +113,7 @@ const countScenes = (dir: string): number => {
|
|
|
112
113
|
export const scene = async (args: Args) => {
|
|
113
114
|
const ctx = getContext(args)
|
|
114
115
|
|
|
115
|
-
|
|
116
|
-
try {
|
|
117
|
-
sceneServer = await import("lecodes-3d-editor/server")
|
|
118
|
-
} catch {
|
|
119
|
-
throw new CliError("The scene editor isn't installed. Run: npm install -g lecodes-3d-editor")
|
|
120
|
-
}
|
|
116
|
+
const sceneServer = await loadPeer<typeof import("lecodes-3d-editor/server")>("lecodes-3d-editor", "server", { for: "lecodes scene" })
|
|
121
117
|
|
|
122
118
|
const port = Number(flagStr(args, "port")) || DEFAULT_PORT
|
|
123
119
|
|
package/src/commands/shaders.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs"
|
|
2
|
+
import { relative } from "node:path"
|
|
2
3
|
import { findProjectRoot, readManifest, type Manifest } from "../manifest"
|
|
3
4
|
import { collectLocalEntries } from "../compile/collectLocal"
|
|
4
5
|
import {
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
import { fetchMatcManifest, installedMatcVersions, resolveMatcExe, resolveMatcExeCached, updateMatc } from "../matcTool"
|
|
9
10
|
import { semverNewer } from "../releases"
|
|
10
11
|
import { CliError, c, flagStr, log, note, warnErr, type Args } from "../util"
|
|
12
|
+
import { engineMaterialNames, writeNewShader } from "./shadersNew"
|
|
11
13
|
import { parseShaderSchema, type AnyShaderPlatform } from "lecodes-sdk/compile"
|
|
12
14
|
|
|
13
15
|
/*
|
|
@@ -17,6 +19,9 @@ import { parseShaderSchema, type AnyShaderPlatform } from "lecodes-sdk/compile"
|
|
|
17
19
|
* compile compile every shader in the project into .lecodes/shaders (downloads matc on
|
|
18
20
|
* first use). `--platform a,b` compiles only those targets.
|
|
19
21
|
* update download the newest published matc (run after an engine upgrade)
|
|
22
|
+
* new <name> a fresh .mat + its .material.ts asset in the current dir (--dir elsewhere): the
|
|
23
|
+
* starter template, or `--ref lit|unlit|particles|…` = a copy of that engine source
|
|
24
|
+
* to start from (shadersNew.ts); --force overwrites
|
|
20
25
|
* matc (Filament's material compiler) caches in ~/.lecodes/matc/; LECODES_MATC overrides it.
|
|
21
26
|
*
|
|
22
27
|
* A shader compiles to one artifact per target: `opengl` + `metal` (mobile profile, Android/Apple),
|
|
@@ -148,10 +153,21 @@ const compile = async (args: Args) => {
|
|
|
148
153
|
note(`${shaders.length} shader${shaders.length === 1 ? "" : "s"} compiled${scope} → ${shadersCacheDir(root)}`)
|
|
149
154
|
}
|
|
150
155
|
|
|
156
|
+
const newShader = (args: Args) => {
|
|
157
|
+
const name = args._[1]
|
|
158
|
+
if (!name) throw new CliError(`lecodes shaders new <name> [--ref ${engineMaterialNames().join("|") || "lit"}] [--dir <folder>] [--force]`)
|
|
159
|
+
const res = writeNewShader({ name, ref: flagStr(args, "ref"), dir: flagStr(args, "dir"), force: args.flags.force === true })
|
|
160
|
+
const rel = (p: string) => relative(process.cwd(), p)
|
|
161
|
+
log(`${rel(res.mat)} ${c.dim(res.ref ? `(from the engine's ${res.ref}.mat)` : "(the starter template)")}`)
|
|
162
|
+
log(`${rel(res.asset)} ${c.dim("(defineMaterial asset — a scene takes it as `material:`)")}`)
|
|
163
|
+
note(`next: edit it, then \`lecodes shaders compile\` (or just \`lecodes dev\`); in code: Material.load(asset('./${name.replace(/\.mat$/, "")}.mat'))`)
|
|
164
|
+
}
|
|
165
|
+
|
|
151
166
|
export const shaders = async (args: Args) => {
|
|
152
167
|
const sub = args._[0]
|
|
153
168
|
if (sub === undefined) return status()
|
|
154
169
|
if (sub === "compile") return compile(args)
|
|
155
170
|
if (sub === "update") return updateMatc()
|
|
156
|
-
|
|
171
|
+
if (sub === "new") return newShader(args)
|
|
172
|
+
throw new CliError(`Unknown subcommand "shaders ${sub}". Use \`lecodes shaders\` (status), \`lecodes shaders compile [--platform a,b]\`, \`lecodes shaders new <name> [--ref lit]\` or \`lecodes shaders update\`.`)
|
|
157
173
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// `lecodes shaders new <name> [--ref lit] [--dir assets/shaders] [--force]` — a fresh .mat in the
|
|
2
|
+
// project, ready to compile: either the starter template (an unlit material with an annotated
|
|
3
|
+
// parameter of each kind) or a COPY of one of the engine's own sources (`--ref lit`, `--ref unlit`,
|
|
4
|
+
// `--ref particles`…) renamed after the project's file — the way to get vertex colours, an emissive
|
|
5
|
+
// term or a second map onto an otherwise standard material without touching the engine. A
|
|
6
|
+
// `<name>.material.ts` asset goes next to it (defineMaterial with the parameters the .mat declares).
|
|
7
|
+
//
|
|
8
|
+
// The engine's sources come from the CLI's runtime/materials (vendored at build time from
|
|
9
|
+
// creator-gl/materials/src, see scripts/vendor-runtime.ts); in the monorepo the source dir itself
|
|
10
|
+
// is used, so a dev CLI sees the sources as they are.
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
13
|
+
import { basename, join, relative, resolve } from "node:path"
|
|
14
|
+
import { parseShaderSchema } from "lecodes-sdk/compile"
|
|
15
|
+
import { distRoot } from "../distRoot"
|
|
16
|
+
import { CliError } from "../util"
|
|
17
|
+
|
|
18
|
+
/** where the engine's .mat sources are, or null when this CLI carries none */
|
|
19
|
+
export const engineMaterialsDir = (): string | null => {
|
|
20
|
+
const candidates = [
|
|
21
|
+
join(distRoot, "runtime", "materials"),
|
|
22
|
+
join(distRoot, "..", "creator-gl", "materials", "src"),
|
|
23
|
+
]
|
|
24
|
+
for (const c of candidates) if (existsSync(join(c, "unlit.mat"))) return c
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** the sources a `--ref` may name (the common set every host ships; the AR camera ones are not materials to start from) */
|
|
29
|
+
export const engineMaterialNames = (): string[] => {
|
|
30
|
+
const dir = engineMaterialsDir()
|
|
31
|
+
if (!dir) return []
|
|
32
|
+
return readdirSync(dir).filter((f) => f.endsWith(".mat") && !f.startsWith("camera") && f !== "shader360.mat").map((f) => f.slice(0, -4)).sort()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const NAME_RE = /^[a-z][a-z0-9_-]*$/i
|
|
36
|
+
/** the `name :` inside the .mat — an identifier (Filament's rule), so a dash in the file name becomes an underscore */
|
|
37
|
+
const matName = (name: string): string => name.replace(/-/g, "_")
|
|
38
|
+
|
|
39
|
+
/** the starter: unlit, one parameter of each kind with the editor annotations spelled out */
|
|
40
|
+
export const starterMat = (name: string): string => `// ${name}.mat — a custom material (lecodes shaders new).
|
|
41
|
+
//
|
|
42
|
+
// Filament's material language: the \`material\` block declares the parameters and the render state,
|
|
43
|
+
// \`fragment\` computes the surface. Read it with \`Material.load(asset('./${name}.mat'))\` or the
|
|
44
|
+
// ${name}.material.ts asset beside it; set parameters with material.set('tint', '#ff8800').
|
|
45
|
+
// \`lecodes shaders compile\` (or \`lecodes dev\`) compiles it for every host into .lecodes/shaders.
|
|
46
|
+
//
|
|
47
|
+
// The comment on a parameter's line is what the scene editor shows for it:
|
|
48
|
+
// @color a colour picker (also inferred from a name like tint / color / emissive)
|
|
49
|
+
// @range(min, max) a slider, @step(s) its step, @default(v) the value a new material starts with
|
|
50
|
+
// the rest of the line is the tooltip
|
|
51
|
+
|
|
52
|
+
material {
|
|
53
|
+
name : ${matName(name)},
|
|
54
|
+
shadingModel : unlit,
|
|
55
|
+
parameters : [
|
|
56
|
+
{ type : float3, name : tint }, // @color @default(#ffffff) the flat colour
|
|
57
|
+
{ type : float, name : glow }, // @range(0, 2) @step(0.05) @default(1) brightness multiplier
|
|
58
|
+
{ type : sampler2d, name : map } // the texture, white when unset
|
|
59
|
+
],
|
|
60
|
+
requires : [
|
|
61
|
+
uv0
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fragment {
|
|
66
|
+
void material(inout MaterialInputs material) {
|
|
67
|
+
prepareMaterial(material);
|
|
68
|
+
vec4 base = texture(materialParams_map, getUV0());
|
|
69
|
+
material.baseColor.rgb = base.rgb * materialParams.tint * materialParams.glow;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
`
|
|
73
|
+
|
|
74
|
+
/** the engine's source renamed after the file, with a header saying where it came from */
|
|
75
|
+
export const refMat = (name: string, ref: string, source: string): string => {
|
|
76
|
+
const renamed = source.replace(/(\bname\s*:\s*)([A-Za-z_][A-Za-z0-9_]*)/, `$1${matName(name)}`)
|
|
77
|
+
return `// ${name}.mat — started from the engine's ${ref}.mat (lecodes shaders new --ref ${ref}); edit freely,
|
|
78
|
+
// the engine keeps its own. \`lecodes shaders compile\` builds it for every host into .lecodes/shaders.
|
|
79
|
+
// Load it with Material.load(asset('./${name}.mat')) or the ${name}.material.ts asset beside it.
|
|
80
|
+
|
|
81
|
+
${renamed}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** the defineMaterial asset for a .mat: every non-sampler parameter with the value the schema gives it */
|
|
85
|
+
export const materialAsset = (name: string, mat: string): string => {
|
|
86
|
+
const schema = parseShaderSchema(mat)
|
|
87
|
+
const lines: string[] = []
|
|
88
|
+
for (const p of schema.params) {
|
|
89
|
+
if (p.editor === "texture") { lines.push(` // ${p.name}: asset('./texture.png'), // a sampler takes an image asset`); continue }
|
|
90
|
+
if (p.default === null || p.default === undefined) continue
|
|
91
|
+
lines.push(` ${p.name}: ${JSON.stringify(p.default)},${p.tooltip ? ` // ${p.tooltip}` : ""}`)
|
|
92
|
+
}
|
|
93
|
+
return `// ${name}.material.ts — the material as a project asset: ONE shared instance a scene file takes as
|
|
94
|
+
// \`material: ${camel(name)}\` and code loads with \`await ${camel(name)}.load()\`. The parameters and their
|
|
95
|
+
// widgets come from the annotations in ${name}.mat.
|
|
96
|
+
export default defineMaterial({
|
|
97
|
+
shader: asset('./${name}.mat'),
|
|
98
|
+
params: {
|
|
99
|
+
${lines.join("\n")}
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
`
|
|
103
|
+
}
|
|
104
|
+
const camel = (name: string): string => name.replace(/[-_]+(.)/g, (_, ch: string) => ch.toUpperCase())
|
|
105
|
+
|
|
106
|
+
export type NewShaderOptions = { name: string, ref?: string, dir?: string, force?: boolean, cwd?: string }
|
|
107
|
+
export type NewShaderResult = { mat: string, asset: string, ref: string | null }
|
|
108
|
+
|
|
109
|
+
/** Write `<name>.mat` and `<name>.material.ts`; returns the paths. Throws a CliError on a bad name,
|
|
110
|
+
* an unknown --ref, or files already there (unless `force`). */
|
|
111
|
+
export const writeNewShader = (o: NewShaderOptions): NewShaderResult => {
|
|
112
|
+
const name = o.name.endsWith(".mat") ? o.name.slice(0, -4) : o.name
|
|
113
|
+
if (!NAME_RE.test(name)) throw new CliError(`"${o.name}" is not a shader name — letters, digits, - and _ (starting with a letter), e.g. hologram or rim-light`)
|
|
114
|
+
const dir = resolve(o.cwd ?? process.cwd(), o.dir ?? ".")
|
|
115
|
+
const matPath = join(dir, `${name}.mat`), assetPath = join(dir, `${name}.material.ts`)
|
|
116
|
+
if (!o.force) for (const p of [ matPath, assetPath ]) if (existsSync(p)) throw new CliError(`${relative(o.cwd ?? process.cwd(), p) || basename(p)} exists — pick another name or pass --force to overwrite`)
|
|
117
|
+
|
|
118
|
+
let mat: string
|
|
119
|
+
let ref: string | null = null
|
|
120
|
+
if (o.ref) {
|
|
121
|
+
ref = o.ref.endsWith(".mat") ? o.ref.slice(0, -4) : o.ref
|
|
122
|
+
const srcDir = engineMaterialsDir()
|
|
123
|
+
if (!srcDir) throw new CliError("this CLI carries no engine material sources (runtime/materials) — --ref needs a CLI built with them")
|
|
124
|
+
const known = engineMaterialNames()
|
|
125
|
+
if (!known.includes(ref)) throw new CliError(`no engine material "${ref}". Known: ${known.join(", ")}`)
|
|
126
|
+
mat = refMat(name, ref, readFileSync(join(srcDir, `${ref}.mat`), "utf8"))
|
|
127
|
+
} else mat = starterMat(name)
|
|
128
|
+
|
|
129
|
+
mkdirSync(dir, { recursive: true })
|
|
130
|
+
writeFileSync(matPath, mat)
|
|
131
|
+
writeFileSync(assetPath, materialAsset(name, mat))
|
|
132
|
+
return { mat: matPath, asset: assetPath, ref }
|
|
133
|
+
}
|
package/src/commands/thumbs.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process"
|
|
|
3
3
|
import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"
|
|
4
4
|
import { tmpdir } from "node:os"
|
|
5
5
|
import { extname, join, relative, resolve } from "node:path"
|
|
6
|
+
import { loadPeer } from "../peerInstall"
|
|
6
7
|
import { projectRootOf } from "./scene"
|
|
7
8
|
import { CliError, c, endProgress, flagBool, flagStr, log, note, progress, toKey, warnErr, type Args } from "../util"
|
|
8
9
|
|
|
@@ -102,12 +103,8 @@ const collect = (dir: string): string[] => {
|
|
|
102
103
|
export const thumbs = async (args: Args) => {
|
|
103
104
|
const root = projectRootOf(process.cwd())
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
sceneServer = await import("lecodes-3d-editor/server")
|
|
108
|
-
} catch {
|
|
109
|
-
throw new CliError("The scene editor isn't installed (it owns the preview cache). Run: npm install -g lecodes-3d-editor")
|
|
110
|
-
}
|
|
106
|
+
// The scene editor owns the preview cache — installed into ~/.lecodes/peers on first use.
|
|
107
|
+
const sceneServer = await loadPeer<typeof import("lecodes-3d-editor/server")>("lecodes-3d-editor", "server", { for: "lecodes thumbs" })
|
|
111
108
|
|
|
112
109
|
const size = Number(flagStr(args, "size")) || DEFAULT_SIZE
|
|
113
110
|
const force = flagBool(args, "force")
|