lecodes-cli 0.19.0 → 0.19.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 +730 -235
- package/package.json +14 -13
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk-types.json +1 -1
- package/src/api.ts +302 -0
- package/src/browserAuth.ts +87 -0
- package/src/cmgenTool.ts +104 -0
- package/src/commands/app.ts +891 -0
- package/src/commands/appAndroid.ts +603 -0
- package/src/commands/appDesktop.ts +326 -0
- package/src/commands/appDesktopMac.ts +470 -0
- package/src/commands/appIcon.ts +187 -0
- package/src/commands/appShared.ts +448 -0
- package/src/commands/appTemplates.ts +883 -0
- package/src/commands/appTemplatesAndroid.ts +599 -0
- package/src/commands/appTemplatesGradlew.ts +9 -0
- package/src/commands/assets.ts +28 -0
- package/src/commands/clone.ts +60 -0
- package/src/commands/compile.ts +253 -0
- package/src/commands/create.ts +59 -0
- package/src/commands/design.ts +863 -0
- package/src/commands/designTemplates.ts +11 -0
- package/src/commands/desktop.ts +122 -0
- package/src/commands/dev.ts +214 -0
- package/src/commands/diff.ts +59 -0
- package/src/commands/init.ts +191 -0
- package/src/commands/install.ts +198 -0
- package/src/commands/lightmap.ts +290 -0
- package/src/commands/link.ts +147 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/navmesh.ts +225 -0
- package/src/commands/pn.ts +305 -0
- package/src/commands/projectTemplates.ts +129 -0
- package/src/commands/pull.ts +109 -0
- package/src/commands/push.ts +173 -0
- package/src/commands/render.ts +414 -0
- package/src/commands/scene.ts +148 -0
- package/src/commands/shaders.ts +157 -0
- package/src/commands/status.ts +29 -0
- package/src/commands/test.ts +370 -0
- package/src/commands/thumbs.ts +178 -0
- package/src/commands/types.ts +87 -0
- package/src/commands/update.ts +190 -0
- package/src/compile/assetIcons.ts +214 -0
- package/src/compile/collect.ts +46 -0
- package/src/compile/collectLocal.ts +37 -0
- package/src/compile/designCompile.ts +104 -0
- package/src/compile/fonts.ts +156 -0
- package/src/compile/headlessBundle.ts +129 -0
- package/src/compile/nativeStack.ts +21 -0
- package/src/compile/projectCompile.ts +109 -0
- package/src/compile/sceneCompile.ts +113 -0
- package/src/compile/screenEntry.ts +127 -0
- package/src/compile/shaders.ts +245 -0
- package/src/config.ts +42 -0
- package/src/designMeta.ts +35 -0
- package/src/desktopRenderer.ts +532 -0
- package/src/desktopScript.ts +276 -0
- package/src/dev/clientTemplates.ts +160 -0
- package/src/dev/devServer.ts +292 -0
- package/src/dev/wsServer.ts +144 -0
- package/src/distRoot.ts +20 -0
- package/src/ignore.ts +163 -0
- package/src/index.ts +491 -0
- package/src/lecodes-3d-editor.d.ts +41 -0
- package/src/lecodes-assets.d.ts +7 -0
- package/src/lecodes-design.d.ts +191 -0
- package/src/lecodes-renderer.d.ts +131 -0
- package/src/localFiles.ts +144 -0
- package/src/manifest.ts +46 -0
- package/src/matcTool.ts +137 -0
- package/src/peers.ts +40 -0
- package/src/project.ts +61 -0
- package/src/projectEnv.ts +94 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/releases.ts +125 -0
- package/src/serverDiff.ts +78 -0
- package/src/textDiff.ts +103 -0
- package/src/types.ts +0 -0
- package/src/util.ts +146 -0
- package/runtime/sdk/animate/animate.ts +0 -238
- package/runtime/sdk/animate/bezier.ts +0 -138
- package/runtime/sdk/animate/easings.ts +0 -126
- package/runtime/sdk/canvas/Canvas.ts +0 -305
- package/runtime/sdk/compile/aspectMacro.ts +0 -86
- package/runtime/sdk/compile/assetIconMacro.ts +0 -384
- package/runtime/sdk/compile/assetMacro.ts +0 -146
- package/runtime/sdk/compile/assetName.ts +0 -50
- package/runtime/sdk/compile/bundler.ts +0 -287
- package/runtime/sdk/compile/compileProject.ts +0 -129
- package/runtime/sdk/compile/detectEntry.ts +0 -128
- package/runtime/sdk/compile/fontMacro.ts +0 -459
- package/runtime/sdk/compile/fontRegistry.ts +0 -78
- package/runtime/sdk/compile/header.ts +0 -67
- package/runtime/sdk/compile/index.ts +0 -100
- package/runtime/sdk/compile/libraryImports.ts +0 -52
- package/runtime/sdk/compile/liteMaterial.ts +0 -247
- package/runtime/sdk/compile/sceneEditor.ts +0 -88
- package/runtime/sdk/compile/serverSplit.ts +0 -233
- package/runtime/sdk/compile/serverTypes.ts +0 -227
- package/runtime/sdk/compile/sfnt.ts +0 -98
- package/runtime/sdk/compile/shaderSchema.ts +0 -202
- package/runtime/sdk/compile/shaderTargets.ts +0 -81
- package/runtime/sdk/compile/sourcemap.ts +0 -25
- package/runtime/sdk/core/Aspect.ts +0 -568
- package/runtime/sdk/core/InspectorUI.ts +0 -212
- package/runtime/sdk/core/StateMachine.ts +0 -308
- package/runtime/sdk/core/color.ts +0 -66
- package/runtime/sdk/core/compWrite.ts +0 -42
- package/runtime/sdk/core/events.ts +0 -38
- package/runtime/sdk/core/fields.ts +0 -120
- package/runtime/sdk/core/registry.ts +0 -23
- package/runtime/sdk/core/signals.ts +0 -277
- package/runtime/sdk/core/time.ts +0 -84
- package/runtime/sdk/g2/Camera2D.ts +0 -40
- package/runtime/sdk/g2/CharacterController2D.ts +0 -276
- package/runtime/sdk/g2/Node2D.ts +0 -267
- package/runtime/sdk/g2/OneWay2D.ts +0 -66
- package/runtime/sdk/g2/Physics2D.ts +0 -346
- package/runtime/sdk/g2/Scene2D.ts +0 -209
- package/runtime/sdk/g2/Shape2D.ts +0 -259
- package/runtime/sdk/g2/Sprite.ts +0 -89
- package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
- package/runtime/sdk/g2/SpriteSheet.ts +0 -166
- package/runtime/sdk/g2/Texture2D.ts +0 -47
- package/runtime/sdk/g2/Tilemap.ts +0 -41
- package/runtime/sdk/g2/Tileset.ts +0 -71
- package/runtime/sdk/g2/Trigger2D.ts +0 -77
- package/runtime/sdk/g2/autotile.ts +0 -433
- package/runtime/sdk/g2/cells.ts +0 -91
- package/runtime/sdk/g2/defineScene2d.ts +0 -381
- package/runtime/sdk/g2/groups2d.ts +0 -106
- package/runtime/sdk/g2/loop.ts +0 -50
- package/runtime/sdk/g2/scenarios2d.ts +0 -69
- package/runtime/sdk/g2/touch.ts +0 -83
- package/runtime/sdk/gl/Camera.ts +0 -160
- package/runtime/sdk/gl/CameraPlace.ts +0 -52
- package/runtime/sdk/gl/CharacterController.ts +0 -238
- package/runtime/sdk/gl/Gearbox.ts +0 -212
- package/runtime/sdk/gl/Geometry.ts +0 -279
- package/runtime/sdk/gl/IK.ts +0 -193
- package/runtime/sdk/gl/InstancedMesh.ts +0 -132
- package/runtime/sdk/gl/Light.ts +0 -107
- package/runtime/sdk/gl/Lightmap.ts +0 -256
- package/runtime/sdk/gl/Material.ts +0 -260
- package/runtime/sdk/gl/Mesh.ts +0 -83
- package/runtime/sdk/gl/Model.ts +0 -124
- package/runtime/sdk/gl/NavAgent.ts +0 -337
- package/runtime/sdk/gl/NavMesh.ts +0 -397
- package/runtime/sdk/gl/Node.ts +0 -350
- package/runtime/sdk/gl/Noise.ts +0 -30
- package/runtime/sdk/gl/Particles.ts +0 -676
- package/runtime/sdk/gl/Physics.ts +0 -222
- package/runtime/sdk/gl/Plane.ts +0 -53
- package/runtime/sdk/gl/Ragdoll.ts +0 -270
- package/runtime/sdk/gl/Ray.ts +0 -16
- package/runtime/sdk/gl/Scene.ts +0 -486
- package/runtime/sdk/gl/Shape.ts +0 -409
- package/runtime/sdk/gl/Terrain.ts +0 -1092
- package/runtime/sdk/gl/Texture.ts +0 -63
- package/runtime/sdk/gl/Trigger.ts +0 -45
- package/runtime/sdk/gl/Vehicle.ts +0 -473
- package/runtime/sdk/gl/Wheel.ts +0 -240
- package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
- package/runtime/sdk/gl/animation/Animator.ts +0 -120
- package/runtime/sdk/gl/animation/Layer.ts +0 -31
- package/runtime/sdk/gl/animation/Loop.ts +0 -30
- package/runtime/sdk/gl/animation/Playback.ts +0 -44
- package/runtime/sdk/gl/animation/core.ts +0 -336
- package/runtime/sdk/gl/controls.ts +0 -95
- package/runtime/sdk/gl/physicsEvents.ts +0 -20
- package/runtime/sdk/gl/scenarios.ts +0 -291
- package/runtime/sdk/gl/state.ts +0 -6
- package/runtime/sdk/gl/terrainMesh.ts +0 -219
- package/runtime/sdk/gl/touch.ts +0 -68
- package/runtime/sdk/inject.ts +0 -197
- package/runtime/sdk/math/Mathf.ts +0 -118
- package/runtime/sdk/math/mat4.ts +0 -278
- package/runtime/sdk/math/quat.ts +0 -232
- package/runtime/sdk/math/vec.ts +0 -255
- package/runtime/sdk/net/codec.ts +0 -119
- package/runtime/sdk/net/core.ts +0 -384
- package/runtime/sdk/net/index.ts +0 -181
- package/runtime/sdk/net/replication.ts +0 -622
- package/runtime/sdk/plugins/camera.ts +0 -81
- package/runtime/sdk/plugins/geolocation.ts +0 -123
- package/runtime/sdk/plugins/oauth.ts +0 -61
- package/runtime/sdk/plugins/permission.ts +0 -7
- package/runtime/sdk/plugins/push.ts +0 -132
- package/runtime/sdk/plugins/qr.ts +0 -73
- package/runtime/sdk/plugins/service.ts +0 -47
- package/runtime/sdk/runtime/app.ts +0 -101
- package/runtime/sdk/runtime/appEvents.ts +0 -54
- package/runtime/sdk/runtime/channel.ts +0 -61
- package/runtime/sdk/runtime/clipboard.ts +0 -20
- package/runtime/sdk/runtime/datetime.ts +0 -329
- package/runtime/sdk/runtime/device.ts +0 -293
- package/runtime/sdk/runtime/fetch.ts +0 -77
- package/runtime/sdk/runtime/files.ts +0 -23
- package/runtime/sdk/runtime/input.ts +0 -175
- package/runtime/sdk/runtime/media.ts +0 -111
- package/runtime/sdk/runtime/misc.ts +0 -16
- package/runtime/sdk/runtime/net.ts +0 -36
- package/runtime/sdk/runtime/rpc.ts +0 -218
- package/runtime/sdk/runtime/service.ts +0 -83
- package/runtime/sdk/runtime/share.ts +0 -9
- package/runtime/sdk/runtime/storage.ts +0 -13
- package/runtime/sdk/runtime/touch.ts +0 -76
- package/runtime/sdk/scene/defineScene.ts +0 -1353
- package/runtime/sdk/scene/editorPlugins.ts +0 -110
- package/runtime/sdk/scene/gizmos.ts +0 -148
- package/runtime/sdk/scene/grammar.ts +0 -120
- package/runtime/sdk/scene/material.ts +0 -188
- package/runtime/sdk/server/auth/appConfig.ts +0 -12
- package/runtime/sdk/server/auth/global.ts +0 -80
- package/runtime/sdk/server/auth/host.ts +0 -318
- package/runtime/sdk/server/auth/models.ts +0 -83
- package/runtime/sdk/server/auth/types.ts +0 -50
- package/runtime/sdk/server/channel.ts +0 -56
- package/runtime/sdk/server/context.ts +0 -36
- package/runtime/sdk/server/db/defineDb.ts +0 -237
- package/runtime/sdk/server/db/fields.ts +0 -132
- package/runtime/sdk/server/db/httpTransport.ts +0 -93
- package/runtime/sdk/server/db/index.ts +0 -7
- package/runtime/sdk/server/db/marci/query.ts +0 -412
- package/runtime/sdk/server/db/types.ts +0 -202
- package/runtime/sdk/server/errors.ts +0 -12
- package/runtime/sdk/server/host.ts +0 -74
- package/runtime/sdk/server/inject.ts +0 -13
- package/runtime/sdk/server/runtime.ts +0 -133
- package/runtime/sdk/server/validate.ts +0 -87
- package/runtime/sdk/ui/NativeView.ts +0 -142
- package/runtime/sdk/ui/UI.ts +0 -39
- package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
- package/runtime/sdk/ui/UIButton.ts +0 -101
- package/runtime/sdk/ui/UIContainer.ts +0 -60
- package/runtime/sdk/ui/UIImage.ts +0 -83
- package/runtime/sdk/ui/UIInput.ts +0 -185
- package/runtime/sdk/ui/UIModal.ts +0 -139
- package/runtime/sdk/ui/UINode.ts +0 -830
- package/runtime/sdk/ui/UIPager.ts +0 -362
- package/runtime/sdk/ui/UIPopover.ts +0 -100
- package/runtime/sdk/ui/UIScreen.ts +0 -123
- package/runtime/sdk/ui/UIScrollable.ts +0 -87
- package/runtime/sdk/ui/UISpacer.ts +0 -14
- package/runtime/sdk/ui/UITabs.ts +0 -236
- package/runtime/sdk/ui/UIText.ts +0 -51
- package/runtime/sdk/ui/UIVideo.ts +0 -88
- package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
- package/runtime/sdk/ui/UIWidget.ts +0 -127
- package/runtime/sdk/ui/fonts.ts +0 -13
- package/runtime/sdk/ui/presentable.ts +0 -117
- package/runtime/sdk/ui/router.ts +0 -132
- package/runtime/sdk/ui/theme.ts +0 -84
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { dirname, resolve } from "node:path"
|
|
3
|
+
import { compileHeadlessBundle } from "../compile/headlessBundle"
|
|
4
|
+
import { screenTargetFromArgs } from "../compile/screenEntry"
|
|
5
|
+
import { findProjectRoot } from "../manifest"
|
|
6
|
+
import { buildBundle } from "./compile"
|
|
7
|
+
import { desktopDefaultViewport, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
|
|
8
|
+
import { runDesktopRender } from "../desktopRenderer"
|
|
9
|
+
import { evaluateDesktopRun, translateScenario } from "../desktopScript"
|
|
10
|
+
import { CliError, c, flagStr, flagBool, isNullSink, logErr, note, warnErr, type Args } from "../util"
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* `lecodes render` — compile the local project and render it headless. By default it emits a
|
|
14
|
+
* semantic JSON description (stdout or -o/--out); `--png [file]` also rasterizes a PNG. Rendering
|
|
15
|
+
* runs locally via the optional `lecodes-renderer` package (canvas-ui + the layout WASM, and the
|
|
16
|
+
* GPU-free creator-2d WASM for 2D scenes); PNG also needs @napi-rs/canvas. UI and 2D (creator-2d)
|
|
17
|
+
* scenes are both rendered — the 2D scene composites UNDER the UI. 3D (Filament) isn't rasterized,
|
|
18
|
+
* but the scene graph is recorded and serialized into the JSON output as `scene3d` (hierarchy,
|
|
19
|
+
* transforms, meshes, materials, lights, GLB structure + animations).
|
|
20
|
+
* --time advances the 2D simulation (physics/animation) to that instant.
|
|
21
|
+
*
|
|
22
|
+
* `lecodes render <file>` renders ONE screen module instead of the launched app: a file that
|
|
23
|
+
* default-exports a `UIScreen` (or a `(state) => UIScreen` function — pick the branch with
|
|
24
|
+
* `--state`), which is how every `lecodes design` screen is written. It compiles as part of the
|
|
25
|
+
* project (its imports and asset() files resolve normally), and a design screen picks up its board
|
|
26
|
+
* frame from the design folder's meta.json unless --width/--height say otherwise. A bare id
|
|
27
|
+
* ("login") is looked up in design/screens/ (--dir overrides the folder).
|
|
28
|
+
*
|
|
29
|
+
* `--script <file>` switches to an interactive run: the file lists steps (tap / type / scroll /
|
|
30
|
+
* wait / expect / screenshot / json) executed against a live session — see the scenario schema in
|
|
31
|
+
* lecodes-renderer (headless/scenario.ts). Step outputs land under --out-dir (default: the CWD);
|
|
32
|
+
* the run stops at the first failed step and dumps the failing frame next to them.
|
|
33
|
+
*
|
|
34
|
+
* Works in both server-backed projects (login) and local `lecodes init` projects (no login) — see
|
|
35
|
+
* compile/headlessBundle.ts. Resource assets (asset('./x.png')) resolve from the local working tree
|
|
36
|
+
* by default; use --remote-assets (or an explicit --public-url) to load them from the server
|
|
37
|
+
* instead, which then needs --wait-network.
|
|
38
|
+
*
|
|
39
|
+
* Snapshot timing flags:
|
|
40
|
+
* --settle <ms> max wait for promises/timers/fetch/image decode (ends early when idle)
|
|
41
|
+
* --wait-network let data fetches hit the real network (off by default → empty responses)
|
|
42
|
+
* --remote-assets load asset() resources from the server URL instead of the local disk
|
|
43
|
+
* --time <ms> render at this animation time (0 = the initial frame-0 state)
|
|
44
|
+
* --no-after-animations keep frame 0 instead of advancing animations to their resting state
|
|
45
|
+
* --timeout <ms> hard cap on the whole render before aborting (default 15000; a script
|
|
46
|
+
* run defaults to 15000 + 3000 per step)
|
|
47
|
+
* --safe-area t,r,b,l override the safe-area insets (px) the `safe-*` keywords resolve to
|
|
48
|
+
* --device <preset> safe-area preset: none | iphone | android (default: orientation-based).
|
|
49
|
+
* Presets also carry the edge KIND: android's status/nav bars are exact
|
|
50
|
+
* heights (comfort-* adds its knob past them); iphone insets are
|
|
51
|
+
* clearance zones (comfort-* floors). --safe-area alone = all clearance.
|
|
52
|
+
* --logs forward the app's console.log/error (to stderr) for logic debugging
|
|
53
|
+
* --clip <name|x,y,w,h> (--png) crop to a named node's layout rect or an explicit logical-px
|
|
54
|
+
* rect — a component / region render instead of the whole viewport
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
// Approximate device safe-area presets, portrait: insets [top, right, bottom, left] (px) + which
|
|
58
|
+
// edges are exact-height system BARS. `comfort-*` ADDS its knob past a bar edge (content at the
|
|
59
|
+
// inset touches the chrome) and floors (max) a clearance edge (breathing room built in).
|
|
60
|
+
const DEVICE_SAFE_AREA: Record<string, { insets: [number, number, number, number]; bars: ("top" | "right" | "bottom" | "left")[] }> = {
|
|
61
|
+
none: { insets: [0, 0, 0, 0], bars: [] },
|
|
62
|
+
iphone: { insets: [59, 0, 34, 0], bars: [] }, // Dynamic Island + home indicator (iPhone 14 Pro+) — clearance built in
|
|
63
|
+
android: { insets: [24, 0, 48, 0], bars: ["top", "bottom"] }, // status bar + 3-button navigation bar (matches the design board's shell)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type SafeAreaChoice = { safeArea: [number, number, number, number]; safeAreaBars: ("top" | "right" | "bottom" | "left")[] }
|
|
67
|
+
|
|
68
|
+
/** Resolve an explicit `t,r,b,l` string (wins; clearance edges) or a device preset, or undefined.
|
|
69
|
+
* Shared by the flag form (`--safe-area` / `--device`) and a script file's envelope fields. */
|
|
70
|
+
export const resolveSafeAreaChoice = (explicit: string | undefined, device: string | undefined): SafeAreaChoice | undefined => {
|
|
71
|
+
if (explicit) {
|
|
72
|
+
const n = explicit.split(",").map((s) => Number(s.trim()))
|
|
73
|
+
if (n.length === 4 && n.every((v) => Number.isFinite(v))) return { safeArea: n as [number, number, number, number], safeAreaBars: [] }
|
|
74
|
+
throw new CliError(`--safe-area expects "top,right,bottom,left" (4 numbers), got "${explicit}".`)
|
|
75
|
+
}
|
|
76
|
+
if (device) {
|
|
77
|
+
const preset = DEVICE_SAFE_AREA[device]
|
|
78
|
+
if (!preset) throw new CliError(`Unknown --device "${device}". Options: ${Object.keys(DEVICE_SAFE_AREA).join(", ")}.`)
|
|
79
|
+
return { safeArea: preset.insets, safeAreaBars: preset.bars }
|
|
80
|
+
}
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Import the optional renderer package or fail with the install hint. */
|
|
85
|
+
export const requireRenderer = async (): Promise<typeof import("lecodes-renderer/headless")> => {
|
|
86
|
+
try {
|
|
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
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A file-writing ScenarioIO rooted at `dir` (creates parent folders as needed). */
|
|
94
|
+
export const fileScenarioIO = (dir: string) => ({
|
|
95
|
+
write: (relPath: string, data: string | Uint8Array) => {
|
|
96
|
+
const abs = resolve(dir, relPath)
|
|
97
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
98
|
+
writeFileSync(abs, data)
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/** The parsed envelope of a scenario file: viewport/timing fields + the raw steps. `fixtures` is
|
|
103
|
+
* a canned-response map (URL pattern → { status?, json?, text? }) or a path to a JSON file of one. */
|
|
104
|
+
type ScenarioFile = {
|
|
105
|
+
name?: string
|
|
106
|
+
width?: number
|
|
107
|
+
height?: number
|
|
108
|
+
settle?: number
|
|
109
|
+
device?: string
|
|
110
|
+
safeArea?: string
|
|
111
|
+
fixtures?: Record<string, unknown> | string
|
|
112
|
+
steps: unknown
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Resolve a fixtures value — an inline map, or a string path to a JSON file holding one —
|
|
116
|
+
* relative to `baseDir`. Throws a CliError on bad shapes (with the offending pattern). */
|
|
117
|
+
export const resolveFixtures = (
|
|
118
|
+
value: Record<string, unknown> | string | undefined,
|
|
119
|
+
baseDir: string,
|
|
120
|
+
): import("lecodes-renderer/headless").Fixtures | undefined => {
|
|
121
|
+
if (value === undefined) return undefined
|
|
122
|
+
let map: unknown = value
|
|
123
|
+
if (typeof value === "string") {
|
|
124
|
+
const abs = resolve(baseDir, value)
|
|
125
|
+
try {
|
|
126
|
+
map = JSON.parse(readFileSync(abs, "utf8"))
|
|
127
|
+
} catch (e) {
|
|
128
|
+
throw new CliError(`Can't read fixtures "${value}": ${e instanceof Error ? e.message : String(e)}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (typeof map !== "object" || map === null || Array.isArray(map)) {
|
|
132
|
+
throw new CliError(`Fixtures must be an object mapping URL patterns to { status?, json?, text? }.`)
|
|
133
|
+
}
|
|
134
|
+
return map as import("lecodes-renderer/headless").Fixtures
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const readScenarioFile = (path: string): ScenarioFile => {
|
|
138
|
+
let raw: unknown
|
|
139
|
+
try {
|
|
140
|
+
raw = JSON.parse(readFileSync(resolve(path), "utf8"))
|
|
141
|
+
} catch (e) {
|
|
142
|
+
throw new CliError(`Can't read scenario "${path}": ${e instanceof Error ? e.message : String(e)}`)
|
|
143
|
+
}
|
|
144
|
+
if (Array.isArray(raw)) return { steps: raw } // a bare step array is a valid scenario file
|
|
145
|
+
if (typeof raw !== "object" || raw === null) throw new CliError(`Scenario "${path}" must be a JSON object with "steps" (or a bare step array).`)
|
|
146
|
+
const file = raw as ScenarioFile
|
|
147
|
+
if (file.steps === undefined) throw new CliError(`Scenario "${path}" has no "steps".`)
|
|
148
|
+
return file
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `--clip <name|x,y,w,h>`: a node name (its layout rect) or an explicit logical-px rect. */
|
|
152
|
+
const parseClip = (raw: string | undefined): string | { x: number, y: number, width: number, height: number } | undefined => {
|
|
153
|
+
if (raw === undefined) return undefined
|
|
154
|
+
const parts = raw.split(",").map((p) => p.trim())
|
|
155
|
+
if (parts.length === 4 && parts.every((p) => /^-?\d+(\.\d+)?$/.test(p))) {
|
|
156
|
+
const [x, y, width, height] = parts.map(Number)
|
|
157
|
+
if (width <= 0 || height <= 0) throw new CliError(`--clip: width and height must be positive (got "${raw}").`)
|
|
158
|
+
return { x, y, width, height }
|
|
159
|
+
}
|
|
160
|
+
if (parts.length === 1 && raw.trim() !== "") return raw.trim()
|
|
161
|
+
throw new CliError(`--clip: expected a node name or "x,y,width,height" (got "${raw}").`)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* `--desktop`: render through the NATIVE desktop host instead of the headless renderer — the real
|
|
165
|
+
* tgfx UI + Filament 3D + creator-2d pipeline, composited exactly as desktop users see it. PNG
|
|
166
|
+
* only (the host has no semantic-JSON serializer), no lecodes-renderer / @napi-rs/canvas needed.
|
|
167
|
+
* The host binary downloads on first use (see desktopRenderer.ts); Windows + Linux for now. */
|
|
168
|
+
const renderDesktop = async (args: Args) => {
|
|
169
|
+
// A project with a desktop block renders at its configured window (this IS the desktop host);
|
|
170
|
+
// the phone-portrait literal stays only for projects that never declared one.
|
|
171
|
+
let root: string
|
|
172
|
+
try { root = findProjectRoot(process.cwd()) } catch { root = process.cwd() }
|
|
173
|
+
const win = readAppConfigAt(root)?.desktop?.window
|
|
174
|
+
// `render <file>`: one screen module rather than the app (see screenEntry.ts) — the native host
|
|
175
|
+
// runs the same bundle, so a design screen renders through the real tgfx UI here.
|
|
176
|
+
const screen = screenTargetFromArgs(root, args)
|
|
177
|
+
const scriptFlag = flagStr(args, "script")
|
|
178
|
+
const scenario = scriptFlag !== undefined ? readScenarioFile(scriptFlag) : null
|
|
179
|
+
const width = Number(flagStr(args, "width")) || scenario?.width || screen?.frame?.[0] || win?.width || 390
|
|
180
|
+
const height = Number(flagStr(args, "height")) || scenario?.height || screen?.frame?.[1] || win?.height || 844
|
|
181
|
+
const frames = Number(flagStr(args, "frames")) || 60
|
|
182
|
+
const pngFlag = flagStr(args, "png") ?? "screenshot.png"
|
|
183
|
+
const logs = flagBool(args, "logs")
|
|
184
|
+
// --fixed-dt <ms>: virtual app clock (CREATOR_FIXED_DT). Scripted runs default to 16 so a held
|
|
185
|
+
// key / drag / wait means the same game time on every machine; plain renders keep the wall clock
|
|
186
|
+
// (their contract is "frame N", unchanged) unless asked.
|
|
187
|
+
const fixedDtFlag = flagStr(args, "fixed-dt")
|
|
188
|
+
const fixedDtMs = fixedDtFlag !== undefined ? Number(fixedDtFlag) : scenario ? 16 : 0
|
|
189
|
+
if (!Number.isFinite(fixedDtMs) || fixedDtMs < 0 || fixedDtMs > 1000) throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${fixedDtFlag}".`)
|
|
190
|
+
|
|
191
|
+
// Inspection camera: --camera x,y,z --look-at x,y,z [--fov N], or --view iso|top|front|side|back
|
|
192
|
+
// [--fov N] (auto-fitted to the scene's bounds). App input can't be trusted while it's on, so a
|
|
193
|
+
// scripted run sets it per step ({ "view": … } / { "camera": … }) instead of globally.
|
|
194
|
+
const { camera, view } = resolveInspectionCamera(args)
|
|
195
|
+
if ((camera || view) && scenario) throw new CliError("--camera/--view apply to a plain render; in a --script run use a { \"view\": \"iso\" } or { \"camera\": { \"at\", \"lookAt\" } } step before the screenshot.")
|
|
196
|
+
|
|
197
|
+
// One renderer decision: the host binary AND the staged .filamat variant (see appShared).
|
|
198
|
+
const desktopRenderer = resolveDesktopRenderer(root, args)
|
|
199
|
+
const { js, resources } = await buildBundle(args, undefined, {
|
|
200
|
+
localAssets: true, desktopRenderer,
|
|
201
|
+
extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
|
|
202
|
+
})
|
|
203
|
+
// The host writes PNGs but never creates folders — make every output dir up front.
|
|
204
|
+
mkdirSync(dirname(resolve(pngFlag)), { recursive: true })
|
|
205
|
+
|
|
206
|
+
if (scenario) {
|
|
207
|
+
// Scripted native run: translate the scenario into the host's command file, let the host drive
|
|
208
|
+
// it (it self-terminates), then read its markers back into per-step results.
|
|
209
|
+
const outDir = flagStr(args, "out-dir") ?? "."
|
|
210
|
+
const plan = translateScenario(scenario.steps, { outDir, headroomFrames: frames })
|
|
211
|
+
for (const st of plan.steps) if (st.screenshot) mkdirSync(dirname(st.screenshot), { recursive: true })
|
|
212
|
+
const timeoutMs = Number(flagStr(args, "timeout")) || (60000 + plan.steps.length * 5000)
|
|
213
|
+
note(`Running ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"} (native)…`)
|
|
214
|
+
const { output } = await runDesktopRender(js, resources, {
|
|
215
|
+
renderer: desktopRenderer,
|
|
216
|
+
width, height, frames: plan.frameCap, outPng: pngFlag, logs, timeoutMs, script: plan.text, fixedDtMs,
|
|
217
|
+
})
|
|
218
|
+
const result = evaluateDesktopRun(plan, output)
|
|
219
|
+
if (!result.loaded) {
|
|
220
|
+
throw new CliError("This desktop host doesn't support scripted input (CREATOR_SCRIPT) — it ran the frames and ignored the steps. Update it: lecodes desktop update (or point LECODES_DESKTOP_EXE at a newer build).")
|
|
221
|
+
}
|
|
222
|
+
for (const st of result.steps) {
|
|
223
|
+
logErr(st.ok ? `${c.green("✓")} ${st.desc}` : `${c.red("✗")} ${st.desc}\n ${c.red(st.error ?? "failed")}`)
|
|
224
|
+
for (const sk of st.skipped) logErr(c.dim(` – skipped: ${sk}`))
|
|
225
|
+
}
|
|
226
|
+
const skippedCount = result.steps.reduce((n, s) => n + s.skipped.length, 0)
|
|
227
|
+
if (skippedCount > 0) logErr(c.dim(` ${skippedCount} semantic check${skippedCount === 1 ? "" : "s"} skipped — the native host has no UI tree; run the same scenario headless to verify them.`))
|
|
228
|
+
if (!result.ended) warnErr("The host stopped before the script finished (frame cap / crash) — later steps are reported as incomplete.")
|
|
229
|
+
logErr(result.ok ? c.green(`✓ ${result.steps.length}/${result.steps.length} steps passed (native)`) : c.red(`✗ ${result.steps.filter((s) => !s.ok).length} of ${result.steps.length} steps failed (native)`))
|
|
230
|
+
note(`Wrote ${pngFlag} (${width}x${height}, native render, final frame)`)
|
|
231
|
+
process.exit(result.ok ? 0 : 1)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const timeoutMs = Number(flagStr(args, "timeout")) || 60000
|
|
235
|
+
note(screen ? `Rendering ${screen.label} (native)…` : "Rendering (native)…")
|
|
236
|
+
await runDesktopRender(js, resources, {
|
|
237
|
+
renderer: desktopRenderer,
|
|
238
|
+
width, height, frames, outPng: pngFlag, logs, timeoutMs, fixedDtMs, camera, view,
|
|
239
|
+
})
|
|
240
|
+
note(`Wrote ${pngFlag} (${width}x${height}, native render${view ? `, ${view.split(";")[0]} view` : camera ? ", custom camera" : ""})`)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const VIEW_NAMES = ["iso", "top", "front", "side", "back"]
|
|
244
|
+
const parseVec3Flag = (name: string, raw: string): string => {
|
|
245
|
+
const parts = raw.split(",").map((p) => p.trim())
|
|
246
|
+
if (parts.length !== 3 || parts.some((p) => !/^-?\d+(\.\d+)?$/.test(p))) throw new CliError(`--${name} wants "x,y,z" (got "${raw}").`)
|
|
247
|
+
return parts.join(",")
|
|
248
|
+
}
|
|
249
|
+
/** `--camera x,y,z --look-at x,y,z [--fov N]` → CREATOR_CAMERA; `--view name [--fov N]` → CREATOR_VIEW. */
|
|
250
|
+
const resolveInspectionCamera = (args: Args): { camera?: string, view?: string } => {
|
|
251
|
+
const cam = flagStr(args, "camera"), look = flagStr(args, "look-at"), view = flagStr(args, "view"), fov = flagStr(args, "fov")
|
|
252
|
+
if (fov !== undefined && !(Number(fov) > 0 && Number(fov) < 180)) throw new CliError(`--fov wants degrees in (0, 180), got "${fov}".`)
|
|
253
|
+
const fovSuffix = fov !== undefined ? `;${Number(fov)}` : ""
|
|
254
|
+
if (cam !== undefined || look !== undefined) {
|
|
255
|
+
if (view !== undefined) throw new CliError("Use either --camera/--look-at or --view, not both.")
|
|
256
|
+
if (cam === undefined || look === undefined) throw new CliError("--camera and --look-at go together: --camera x,y,z --look-at x,y,z")
|
|
257
|
+
return { camera: `${parseVec3Flag("camera", cam)};${parseVec3Flag("look-at", look)}${fovSuffix}` }
|
|
258
|
+
}
|
|
259
|
+
if (view !== undefined) {
|
|
260
|
+
if (!VIEW_NAMES.includes(view)) throw new CliError(`--view wants one of ${VIEW_NAMES.join(", ")} (got "${view}").`)
|
|
261
|
+
return { view: `${view}${fovSuffix}` }
|
|
262
|
+
}
|
|
263
|
+
if (fov !== undefined) throw new CliError("--fov goes with --camera/--look-at or --view.")
|
|
264
|
+
return {}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export const render = async (args: Args) => {
|
|
268
|
+
if (flagBool(args, "desktop")) return renderDesktop(args)
|
|
269
|
+
|
|
270
|
+
const scriptFlag = flagStr(args, "script")
|
|
271
|
+
const scenario = scriptFlag !== undefined ? readScenarioFile(scriptFlag) : null
|
|
272
|
+
|
|
273
|
+
// `render <file>`: render that one screen module instead of the app's own entrypoint.
|
|
274
|
+
let projectRoot: string
|
|
275
|
+
try { projectRoot = findProjectRoot(process.cwd()) } catch { projectRoot = process.cwd() }
|
|
276
|
+
const screen = screenTargetFromArgs(projectRoot, args)
|
|
277
|
+
|
|
278
|
+
// Hard timeout so a pathological scene (stuck fetch, runaway loop) can't hang the CLI. Scripts get
|
|
279
|
+
// a per-step allowance. Unref'd so it never itself keeps the process alive once we're done.
|
|
280
|
+
const stepCount = scenario && Array.isArray(scenario.steps) ? scenario.steps.length : 0
|
|
281
|
+
const timeoutMs = Number(flagStr(args, "timeout")) || (15000 + stepCount * 3000)
|
|
282
|
+
const killer = setTimeout(() => {
|
|
283
|
+
warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`)
|
|
284
|
+
process.exit(1)
|
|
285
|
+
}, timeoutMs)
|
|
286
|
+
killer.unref?.()
|
|
287
|
+
|
|
288
|
+
const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle(args, { screen })
|
|
289
|
+
const renderer = await requireRenderer()
|
|
290
|
+
|
|
291
|
+
// Flags override the scenario file's envelope; both fall back to the project default (a
|
|
292
|
+
// desktop-only app.json renders desktop-sized), then the phone-portrait literals.
|
|
293
|
+
// A design screen carries its own board frame (meta.json), which beats the app-wide default.
|
|
294
|
+
const projectViewport = desktopDefaultViewport(root)
|
|
295
|
+
const width = Number(flagStr(args, "width")) || scenario?.width || screen?.frame?.[0] || projectViewport?.width || 390
|
|
296
|
+
const height = Number(flagStr(args, "height")) || scenario?.height || screen?.frame?.[1] || projectViewport?.height || 844
|
|
297
|
+
|
|
298
|
+
// Shared snapshot-timing options (see the header comment). `--logs` forwards the app's console to
|
|
299
|
+
// stderr (kept off stdout so it never corrupts the JSON/PNG result there).
|
|
300
|
+
const settleStr = flagStr(args, "settle") ?? (scenario?.settle !== undefined ? String(scenario.settle) : undefined)
|
|
301
|
+
const timeStr = flagStr(args, "time")
|
|
302
|
+
const onConsole = flagBool(args, "logs")
|
|
303
|
+
? (level: string, text: string) => logErr(`${c.dim(`[${level}]`)} ${text}`)
|
|
304
|
+
: undefined
|
|
305
|
+
// Fixtures: the scenario file's (inline map or a path), with a `--fixtures <file>` flag merged
|
|
306
|
+
// over it (flag patterns win on collision). Validated here so a typo is a clean CLI error.
|
|
307
|
+
const envelopeFixtures = resolveFixtures(scenario?.fixtures, scriptFlag !== undefined ? dirname(resolve(scriptFlag)) : ".")
|
|
308
|
+
const flagFixtures = resolveFixtures(flagStr(args, "fixtures"), ".")
|
|
309
|
+
const fixtures = envelopeFixtures || flagFixtures ? { ...envelopeFixtures, ...flagFixtures } : undefined
|
|
310
|
+
if (fixtures) {
|
|
311
|
+
try {
|
|
312
|
+
renderer.validateFixtures(fixtures)
|
|
313
|
+
} catch (e) {
|
|
314
|
+
throw new CliError(e instanceof Error ? e.message : String(e))
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const timing = {
|
|
319
|
+
settleMs: settleStr !== undefined ? Number(settleStr) : undefined,
|
|
320
|
+
waitNetwork: flagBool(args, "wait-network"),
|
|
321
|
+
localAssets: useLocalAssets,
|
|
322
|
+
fixtures,
|
|
323
|
+
timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
|
|
324
|
+
afterAnimations: !flagBool(args, "no-after-animations"),
|
|
325
|
+
...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? scenario?.safeArea, flagStr(args, "device") ?? scenario?.device),
|
|
326
|
+
onConsole,
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Clean process exit: clear the timeout guard, flush the given output, then exit. Without this the
|
|
330
|
+
// process could linger (e.g. on stdout pipe back-pressure) even though the render is done.
|
|
331
|
+
const done = (flush: (cb: () => void) => void, code = 0) => flush(() => { clearTimeout(killer); process.exit(code) })
|
|
332
|
+
|
|
333
|
+
// --script: drive the compiled app through the scenario steps against a live session.
|
|
334
|
+
if (scenario) {
|
|
335
|
+
const steps = renderer.parseScenarioSteps(scenario.steps)
|
|
336
|
+
const outDir = flagStr(args, "out-dir") ?? "."
|
|
337
|
+
// Screenshots require pixels; otherwise rasterize opportunistically (napi installed → failure
|
|
338
|
+
// PNGs and real Skia text metrics, absent → deterministic HeadlessHost metrics).
|
|
339
|
+
const needsRaster = steps.some((s) => s.screenshot !== undefined)
|
|
340
|
+
note(`Running ${steps.length} step${steps.length === 1 ? "" : "s"}…`)
|
|
341
|
+
const session = await renderer.createSession(js, { width, height, ...timing, raster: needsRaster ? true : "auto" })
|
|
342
|
+
try {
|
|
343
|
+
const result = await renderer.runScenario(session, steps, fileScenarioIO(outDir))
|
|
344
|
+
for (const w of [ ...warnings, ...session.warnings ]) warnErr(w)
|
|
345
|
+
for (const st of result.steps) {
|
|
346
|
+
logErr(st.ok ? `${c.green("✓")} ${st.desc}` : `${c.red("✗")} ${st.desc}\n ${c.red(st.error ?? "failed")}`)
|
|
347
|
+
}
|
|
348
|
+
if (!result.ok && result.failureArtifacts.length > 0) {
|
|
349
|
+
logErr(c.dim(` failing frame: ${result.failureArtifacts.map((p) => resolve(outDir, p)).join(", ")}`))
|
|
350
|
+
}
|
|
351
|
+
logErr(result.ok ? c.green(`✓ ${result.steps.length}/${steps.length} steps passed`) : c.red(`✗ failed at step ${result.steps.length} of ${steps.length}`))
|
|
352
|
+
return done((cb) => cb(), result.ok ? 0 : 1)
|
|
353
|
+
} finally {
|
|
354
|
+
session.dispose()
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// --map [file]: draw the 3D scene's annotated top-down map (grid + XZ footprints + named
|
|
359
|
+
// markers) instead of a screenshot — the level-design view. Writes <file>.png + <file>.json
|
|
360
|
+
// (the same data, world-space, queryable). Default map.png. `--label-all` labels every object.
|
|
361
|
+
const mapFlag = flagStr(args, "map") ?? (flagBool(args, "map") ? "map.png" : undefined)
|
|
362
|
+
if (mapFlag !== undefined) {
|
|
363
|
+
note(`Rendering map…`)
|
|
364
|
+
const title = root.split(/[\\/]/).pop()
|
|
365
|
+
const { png, data, warnings: mapWarnings } = await renderer.renderToMap(js, {
|
|
366
|
+
...timing, maxPx: Number(flagStr(args, "max-px")) || undefined, title, labelAll: flagBool(args, "label-all"),
|
|
367
|
+
})
|
|
368
|
+
for (const w of [ ...warnings, ...mapWarnings ]) warnErr(w)
|
|
369
|
+
const pngPath = mapFlag.endsWith(".png") ? mapFlag : mapFlag + ".png"
|
|
370
|
+
mkdirSync(dirname(resolve(pngPath)), { recursive: true })
|
|
371
|
+
writeFileSync(resolve(pngPath), png)
|
|
372
|
+
const jsonPath = pngPath.slice(0, -4) + ".json"
|
|
373
|
+
writeFileSync(resolve(jsonPath), JSON.stringify(data, null, 2))
|
|
374
|
+
note(`Wrote ${pngPath} + ${jsonPath}`)
|
|
375
|
+
return done((cb) => cb())
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// --png [file]: rasterize a PNG (binary → always to a file, default screenshot.png).
|
|
379
|
+
const pngFlag = flagStr(args, "png") ?? (flagBool(args, "png") ? "screenshot.png" : undefined)
|
|
380
|
+
if (pngFlag !== undefined) {
|
|
381
|
+
note(screen ? `Rendering ${screen.label} (PNG)…` : "Rendering PNG…")
|
|
382
|
+
const clip = parseClip(flagStr(args, "clip"))
|
|
383
|
+
const { png, width: outW, height: outH, warnings: pngWarnings } = await renderer.renderToPng(js, {
|
|
384
|
+
width, height, scale: Number(flagStr(args, "scale")) || 2, ...timing, clip,
|
|
385
|
+
})
|
|
386
|
+
for (const w of [ ...warnings, ...pngWarnings ]) warnErr(w)
|
|
387
|
+
if (isNullSink(pngFlag)) {
|
|
388
|
+
note(`Discarded PNG (${pngFlag})`)
|
|
389
|
+
} else {
|
|
390
|
+
mkdirSync(dirname(resolve(pngFlag)), { recursive: true }) // --png shots/home.png: make the folder, like the native path does
|
|
391
|
+
writeFileSync(resolve(pngFlag), png)
|
|
392
|
+
note(`Wrote ${pngFlag} (${png.length} bytes, ${outW}x${outH}${clip !== undefined ? ", clipped" : ""})`)
|
|
393
|
+
}
|
|
394
|
+
return done((cb) => cb())
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
note(screen ? `Rendering ${screen.label}…` : "Rendering…")
|
|
398
|
+
const result = await renderer.renderToJson(js, { width, height, ...timing })
|
|
399
|
+
|
|
400
|
+
for (const w of [ ...warnings, ...result.warnings ]) warnErr(w)
|
|
401
|
+
|
|
402
|
+
const out = flagStr(args, "o", "out")
|
|
403
|
+
if (out && isNullSink(out)) {
|
|
404
|
+
note(`Discarded output (${out})`)
|
|
405
|
+
return done((cb) => cb())
|
|
406
|
+
}
|
|
407
|
+
if (out) {
|
|
408
|
+
writeFileSync(resolve(out), result.text)
|
|
409
|
+
note(`Wrote ${out} (${result.text.length} bytes)`)
|
|
410
|
+
return done((cb) => cb())
|
|
411
|
+
}
|
|
412
|
+
const text = result.text.endsWith("\n") ? result.text : result.text + "\n"
|
|
413
|
+
return done((cb) => process.stdout.write(text, cb))
|
|
414
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path"
|
|
3
|
+
import { findProjectRoot, readManifest, type Manifest } from "../manifest"
|
|
4
|
+
import { compileSceneEditor } from "../compile/sceneCompile"
|
|
5
|
+
import { localShaderArtifact } from "../compile/shaders"
|
|
6
|
+
import { openBrowser } from "../browserAuth"
|
|
7
|
+
import { CliError, c, flagBool, flagStr, log, note, warnErr, type Args } from "../util"
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* `lecodes scene [path]` — the 3D scene editor (lecodes-tools/packages/lecodes-3d-editor) hosted on a local project,
|
|
11
|
+
* exactly the way `lecodes design` hosts the design board. Starts scene-editor's node:http server,
|
|
12
|
+
* injects the CLI's compile closure (chisel + the vendored SDK + a vendored harness), and opens the
|
|
13
|
+
* browser. `.scene.ts` files are edited visually (tree + inspector) and written back to disk;
|
|
14
|
+
* external (IDE) edits hot-reload over SSE.
|
|
15
|
+
*
|
|
16
|
+
* Root resolution mirrors `design`: inside a cloned project the whole project is the compile input;
|
|
17
|
+
* anywhere else it runs standalone against the current directory (or a folder/scene passed as `path`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const DEFAULT_PORT = 4488
|
|
21
|
+
|
|
22
|
+
type SceneContext = {
|
|
23
|
+
root: string
|
|
24
|
+
/** Null in standalone mode: the scene lives outside any cloned lecodes project. */
|
|
25
|
+
manifest: Manifest | null
|
|
26
|
+
name: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The nearest ancestor (inclusive) holding an `app.json` — a local project's root — or null. */
|
|
30
|
+
const findAppRoot = (start: string): string | null => {
|
|
31
|
+
let dir = start
|
|
32
|
+
while (true) {
|
|
33
|
+
if (existsSync(join(dir, "app.json"))) return dir
|
|
34
|
+
const parent = dirname(dir)
|
|
35
|
+
if (parent === dir) return null
|
|
36
|
+
dir = parent
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The project a DIRECTORY belongs to: a cloned project's root, else the nearest ancestor with an
|
|
41
|
+
* app.json, else the directory itself. `lecodes thumbs` resolves its root this way — its
|
|
42
|
+
* positional args are targets, not the root, so it can't go through getContext. */
|
|
43
|
+
export const projectRootOf = (dir: string): string => {
|
|
44
|
+
try {
|
|
45
|
+
return findProjectRoot(dir)
|
|
46
|
+
} catch {
|
|
47
|
+
return findAppRoot(dir) ?? dir
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const getContext = (args: Args): SceneContext => {
|
|
52
|
+
let root: string
|
|
53
|
+
let manifest: Manifest | null
|
|
54
|
+
try {
|
|
55
|
+
root = findProjectRoot(process.cwd())
|
|
56
|
+
manifest = readManifest(root)
|
|
57
|
+
} catch {
|
|
58
|
+
// Standalone (a local project without a platform manifest): the current directory, or a
|
|
59
|
+
// folder / .scene.ts passed as the positional arg. The whole-project compile needs the PROJECT
|
|
60
|
+
// root — a scene file's imports (`../weaponRig`) resolve against it — so walk up to the nearest
|
|
61
|
+
// `app.json`; only without one does a scene path fall back to its containing dir.
|
|
62
|
+
root = process.cwd()
|
|
63
|
+
manifest = null
|
|
64
|
+
const pathArg = args._[0]
|
|
65
|
+
if (pathArg) {
|
|
66
|
+
const abs = resolve(process.cwd(), pathArg)
|
|
67
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) root = abs
|
|
68
|
+
else if (abs.endsWith(".scene.ts") && existsSync(abs)) root = findAppRoot(dirname(abs)) ?? dirname(abs)
|
|
69
|
+
else throw new CliError(`No such folder or .scene.ts: ${pathArg}`)
|
|
70
|
+
} else {
|
|
71
|
+
root = findAppRoot(root) ?? root
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { root, manifest, name: manifest?.name ?? basename(root) }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Register the scene server as a project-scoped MCP server so Claude Code auto-discovers its
|
|
78
|
+
* scene_state / check_scene tools. Merges into an existing .mcp.json (never clobbers a broken one).
|
|
79
|
+
* Ported from `lecodes design init`'s writer; keyed "lecodes-scene". */
|
|
80
|
+
const writeMcpConfig = (root: string, port: number) => {
|
|
81
|
+
const path = join(root, ".mcp.json")
|
|
82
|
+
const entry = { type: "http", url: `http://localhost:${port}/mcp` }
|
|
83
|
+
let config: { mcpServers?: Record<string, unknown> } = {}
|
|
84
|
+
if (existsSync(path)) {
|
|
85
|
+
try {
|
|
86
|
+
config = JSON.parse(readFileSync(path, "utf8"))
|
|
87
|
+
} catch {
|
|
88
|
+
warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-scene server by hand.")
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
config.mcpServers ??= {}
|
|
93
|
+
const existing = config.mcpServers["lecodes-scene"] as { url?: string } | undefined
|
|
94
|
+
if (existing?.url === entry.url) { note("kept .mcp.json (lecodes-scene MCP server)"); return }
|
|
95
|
+
config.mcpServers["lecodes-scene"] = entry
|
|
96
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
|
|
97
|
+
note(`${existing ? "updated" : "created"} .mcp.json (lecodes-scene MCP → ${entry.url})`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Recursively find the first-level count of `.scene.ts` files under root (skip node_modules/dotdirs). */
|
|
101
|
+
const countScenes = (dir: string): number => {
|
|
102
|
+
let n = 0
|
|
103
|
+
for (const name of readdirSync(dir)) {
|
|
104
|
+
if (name === "node_modules" || name.startsWith(".")) continue
|
|
105
|
+
const full = join(dir, name)
|
|
106
|
+
if (statSync(full).isDirectory()) n += countScenes(full)
|
|
107
|
+
else if (name.endsWith(".scene.ts")) n++
|
|
108
|
+
}
|
|
109
|
+
return n
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const scene = async (args: Args) => {
|
|
113
|
+
const ctx = getContext(args)
|
|
114
|
+
|
|
115
|
+
let sceneServer: typeof import("lecodes-3d-editor/server")
|
|
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
|
+
}
|
|
121
|
+
|
|
122
|
+
const port = Number(flagStr(args, "port")) || DEFAULT_PORT
|
|
123
|
+
|
|
124
|
+
if (countScenes(ctx.root) === 0) {
|
|
125
|
+
warnErr(`No .scene.ts file under ${ctx.root} yet — add one and the editor will pick it up.`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const server = await sceneServer.startSceneServer({
|
|
129
|
+
root: ctx.root,
|
|
130
|
+
port,
|
|
131
|
+
log: note,
|
|
132
|
+
compile: (scenePath: string) => compileSceneEditor({
|
|
133
|
+
root: ctx.root, manifest: ctx.manifest, name: ctx.name, scenePath,
|
|
134
|
+
}),
|
|
135
|
+
// live material patches load a shader's compiled artifact (matc'd for the Rendered mode,
|
|
136
|
+
// the lite JSON otherwise) from the local shader cache
|
|
137
|
+
shaderArtifact: (shaderText, platform) => localShaderArtifact(ctx.root, shaderText, platform),
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// Let Claude Code reach the running server over MCP (scene_state / check_scene), unless opted out.
|
|
141
|
+
if (!flagBool(args, "no-mcp")) writeMcpConfig(ctx.root, port)
|
|
142
|
+
|
|
143
|
+
log(`${c.bold("LeCodes Scene Editor")} — ${ctx.name}`)
|
|
144
|
+
log(`Editor: ${c.bold(server.url)}`)
|
|
145
|
+
log(`MCP: ${c.bold(server.url + "/mcp")}`)
|
|
146
|
+
log(`Watching: ${ctx.root} (scenes hot-reload on save; Ctrl+C to stop)`)
|
|
147
|
+
if (!flagBool(args, "no-open")) openBrowser(server.url)
|
|
148
|
+
}
|