lecodes-cli 0.6.2 → 0.6.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lecodes-cli",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Clone, edit and push LeCodes projects from your machine.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,15 +20,17 @@
20
20
  "node": ">=18"
21
21
  },
22
22
  "scripts": {
23
+ "icon": "bun scripts/make-icon.ts",
23
24
  "vendor-runtime": "bun scripts/vendor-runtime.ts",
24
25
  "sync-peers": "bun scripts/sync-peer-versions.ts",
25
26
  "build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-renderer --external lecodes-design --banner=\"#!/usr/bin/env node\"",
27
+ "build:bin": "bun scripts/build-bin.ts",
26
28
  "typecheck": "tsc --noEmit",
27
29
  "dev": "bun run src/index.ts",
28
30
  "prepublishOnly": "bun run sync-peers && bun run build"
29
31
  },
30
32
  "dependencies": {
31
- "@letary/chisel": "^0.2.0"
33
+ "@letary/chisel": "^0.3.0"
32
34
  },
33
35
  "peerDependencies": {
34
36
  "lecodes-design": "^0.3.1",
@@ -45,6 +47,7 @@
45
47
  "devDependencies": {
46
48
  "lecodes-design": "workspace:*",
47
49
  "lecodes-renderer": "^0.4.1",
50
+ "qrcode-terminal": "^0.12.0",
48
51
  "sdk": "workspace:*"
49
52
  }
50
53
  }
@@ -10,3 +10,9 @@ export {
10
10
  type CompileOptions,
11
11
  type CompileFileType,
12
12
  } from "./compileProject"
13
+ export {
14
+ loadSceneHarness,
15
+ sceneEditorEntries,
16
+ SCENE_ENTRY_PATH,
17
+ SCENE_HARNESS_DIR,
18
+ } from "./sceneEditor"
@@ -0,0 +1,66 @@
1
+ // The scene-editor compile composition, shared by every host that compiles an edit-mode bundle
2
+ // for a .scene.ts file: the platform backend (src/scene/compileSceneEditor.ts), the standalone
3
+ // scene-editor dev server, and (later) the lecodes CLI. One bundle on purpose: chisel's
4
+ // method-granular DCE trims each bundle's SDK copy to its own calls, so a separately-compiled
5
+ // harness could not call scene methods on objects the scene bundle created.
6
+
7
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
8
+ import { join } from "node:path"
9
+ import type { CompileEntry } from "./compileProject"
10
+
11
+ /** Where the harness sources land in the compile's file map. The dotfolder can't collide with
12
+ * project assets (the tree forbids leading-dot names) and reads clearly in compile errors. */
13
+ export const SCENE_HARNESS_DIR = "./.scene-editor"
14
+
15
+ /** The virtual entrypoint path (`entryOverride`). */
16
+ export const SCENE_ENTRY_PATH = "./.scene-entry.ts"
17
+
18
+ /**
19
+ * Read the harness sources (packages/projects/src/scene-editor) from a directory:
20
+ * `LECODES_SCENE_HARNESS_DIR` env override first (deployments vendor the sources, like
21
+ * LECODES_SDK_INJECT does for the SDK), else the given fallback dir. Returns name → source.
22
+ */
23
+ export const loadSceneHarness = (fallbackDir?: string): Record<string, string> => {
24
+ const override = process.env.LECODES_SCENE_HARNESS_DIR
25
+ const dir = override && existsSync(override) ? override
26
+ : fallbackDir && existsSync(fallbackDir) ? fallbackDir
27
+ : null
28
+ if (!dir) throw new Error("scene-editor harness sources not found (set LECODES_SCENE_HARNESS_DIR)")
29
+ const out: Record<string, string> = {}
30
+ for (const f of readdirSync(dir)) {
31
+ if (f.endsWith(".ts")) out[f] = readFileSync(join(dir, f), "utf8")
32
+ }
33
+ return out
34
+ }
35
+
36
+ /**
37
+ * The extra entries that turn a project compile into a scene-editor compile: the harness modules
38
+ * plus a virtual entry that (1) sets the edit-mode flag in its own module — imports hoist, so the
39
+ * flag must run as a module, first — (2) imports the scene, which registers its handle on
40
+ * `__lecodesScenes`, and (3) imports the harness main, which installs `__lecodesSceneHarness`.
41
+ * FULL extensions everywhere — exact-match specifiers need no resolver guesswork on dotted names,
42
+ * so even the unpatched published chisel resolves the `.scene.ts` import.
43
+ *
44
+ * `scenePath` is the project path of the scene file ("./scenes/city.scene.ts"). Compile with
45
+ * `entryOverride: SCENE_ENTRY_PATH`, `header: false`, `format: "iife"`.
46
+ */
47
+ export const sceneEditorEntries = (scenePath: string, harness: Record<string, string>): CompileEntry[] => {
48
+ const entries: CompileEntry[] = Object.entries(harness).map(([ name, text ]) => ({
49
+ path: `${SCENE_HARNESS_DIR}/${name}`,
50
+ type: "text" as const,
51
+ text,
52
+ }))
53
+ entries.push({
54
+ path: `${SCENE_HARNESS_DIR}/.edit-mode.ts`,
55
+ type: "text",
56
+ text: `;(globalThis as any).__lecodesSceneEdit = true\nexport {}\n`,
57
+ })
58
+ entries.push({
59
+ path: SCENE_ENTRY_PATH,
60
+ type: "text",
61
+ text: `import "${SCENE_HARNESS_DIR}/.edit-mode.ts"\n`
62
+ + `import "${scenePath}"\n`
63
+ + `import "${SCENE_HARNESS_DIR}/main.ts"\n`,
64
+ })
65
+ return entries
66
+ }
@@ -0,0 +1,108 @@
1
+ // Field introspection for the scene-editor inspector: what's editable on an aspect, and with which
2
+ // editor widget. Engine-agnostic (works for 2D and 3D aspects alike).
3
+ //
4
+ // The editable surface of an aspect is its public class fields with defaults — the same convention
5
+ // aspects already follow for `node.aspect(Ctor, opts)` configuration. Editors are layered:
6
+ // 1. inference from the default value's shape (number → number field, '#rrggbb' → color, …), free
7
+ // for every user aspect with zero ceremony;
8
+ // 2. optional `static fields: FieldMeta<T>` on the class for ranges/labels/options/overrides
9
+ // (mirrors how `static aspect` declares the accessor name).
10
+
11
+ /** Editor widget kinds the inspector knows how to render. */
12
+ export type FieldEditor =
13
+ | "number" | "text" | "color" | "switch" | "select"
14
+ | "vec2" | "vec3" | "vec4"
15
+
16
+ export type FieldMetaEntry = {
17
+ /** Inspector label (default: the field name). */
18
+ label?: string
19
+ min?: number
20
+ max?: number
21
+ step?: number
22
+ /** Allowed values — renders a dropdown (editor "select"). */
23
+ options?: readonly (string | number)[]
24
+ /** Override the editor inferred from the default value. */
25
+ editor?: FieldEditor
26
+ /** Exclude the field from the inspector. */
27
+ hidden?: boolean
28
+ }
29
+
30
+ /** Declared on an aspect class as `static fields: FieldMeta<MyAspect> = { … }`. All optional. */
31
+ export type FieldMeta<T = unknown> = { [K in Extract<keyof T, string>]?: FieldMetaEntry }
32
+
33
+ /** One editable field: key + default value + resolved editor + merged metadata. */
34
+ export type FieldDescriptor = {
35
+ key: string
36
+ label: string
37
+ /** The default value (from a freshly constructed instance; undefined if only declared in meta). */
38
+ value: unknown
39
+ /** Resolved editor kind; undefined = no editor known (inspector shows it read-only). */
40
+ editor: FieldEditor | undefined
41
+ min?: number
42
+ max?: number
43
+ step?: number
44
+ options?: readonly (string | number)[]
45
+ }
46
+
47
+ export type AspectClassInfo = {
48
+ /** The accessor name (`static aspect`, injected by chisel for user aspects). */
49
+ name: string
50
+ className: string
51
+ fields: FieldDescriptor[]
52
+ }
53
+
54
+ // Instance fields every aspect inherits from the Aspect base — configuration plumbing, not content.
55
+ const BASE_FIELDS = new Set([ "node", "updateBeforePhysics", "order", "updateWhenVisible" ])
56
+
57
+ /** Infer the editor widget from a default value's shape. */
58
+ export const inferFieldEditor = (value: unknown): FieldEditor | undefined => {
59
+ if (typeof value === "boolean") return "switch"
60
+ if (typeof value === "number") return "number"
61
+ if (typeof value === "string") return value.startsWith("#") ? "color" : "text"
62
+ if (Array.isArray(value) && value.length >= 2 && value.length <= 4 && value.every((n) => typeof n === "number")) {
63
+ return value.length === 2 ? "vec2" : value.length === 3 ? "vec3" : "vec4"
64
+ }
65
+ return undefined
66
+ }
67
+
68
+ const cloneValue = (v: unknown): unknown => (Array.isArray(v) ? [ ...v ] : v)
69
+
70
+ /**
71
+ * Enumerate the editable fields of a class: construct a default instance, take its own enumerable
72
+ * non-underscore fields (minus the Aspect base plumbing), infer editors from the default values, and
73
+ * merge the class's optional `static fields` metadata (which can also add keys that have no runtime
74
+ * default, e.g. fields declared without an initializer).
75
+ */
76
+ export const describeFields = (ctor: abstract new () => unknown): FieldDescriptor[] => {
77
+ let defaults: Record<string, unknown> = {}
78
+ // Aspects have no-arg constructors by convention (the engine creates them via node.aspect()); a
79
+ // class that throws here just loses default-value inference, it doesn't break the inspector.
80
+ try { defaults = new (ctor as new () => Record<string, unknown>)() } catch { /* meta-only */ }
81
+
82
+ const meta = ((ctor as { fields?: FieldMeta }).fields ?? {}) as Record<string, FieldMetaEntry>
83
+
84
+ const keys = new Set<string>()
85
+ for (const k of Object.keys(defaults)) {
86
+ if (BASE_FIELDS.has(k) || k.startsWith("_")) continue
87
+ if (typeof defaults[k] === "function") continue
88
+ keys.add(k)
89
+ }
90
+ for (const k of Object.keys(meta)) keys.add(k)
91
+
92
+ const out: FieldDescriptor[] = []
93
+ for (const key of keys) {
94
+ const m = meta[key] ?? {}
95
+ if (m.hidden) continue
96
+ const value = defaults[key]
97
+ const editor = m.editor ?? (m.options ? "select" : inferFieldEditor(value))
98
+ out.push({ key, label: m.label ?? key, value: cloneValue(value), editor, min: m.min, max: m.max, step: m.step, options: m.options })
99
+ }
100
+ return out
101
+ }
102
+
103
+ /** Describe an aspect class for the inspector: accessor name + class name + editable fields. */
104
+ export const describeAspect = (ctor: abstract new () => unknown): AspectClassInfo => ({
105
+ name: (ctor as { aspect?: string }).aspect ?? "",
106
+ className: (ctor as { name?: string }).name ?? "",
107
+ fields: describeFields(ctor),
108
+ })
@@ -36,6 +36,14 @@ const applyUniform = (id: number, key: string, value: UniformValue): void => {
36
36
 
37
37
  export type MaterialColorOptions = { color?: ColorInput, map?: Texture | Canvas | null }
38
38
 
39
+ /** `Material.lit` options — PBR scalars on top of the color/map pair. */
40
+ export type LitMaterialOptions = MaterialColorOptions & {
41
+ /** Perceptual roughness, 0 (mirror) … 1 (matte). Unset = the shader's default. */
42
+ roughness?: number
43
+ /** Metallic factor, 0 (dielectric) … 1 (metal). Unset = the shader's default. */
44
+ metallic?: number
45
+ }
46
+
39
47
  export class Material {
40
48
  /** @internal native material-instance handle. */
41
49
  _id: number
@@ -84,12 +92,14 @@ export class Material {
84
92
  // --- factories ---
85
93
 
86
94
  /** PBR lit material. */
87
- static lit(options: MaterialColorOptions = {}): Material {
95
+ static lit(options: LitMaterialOptions = {}): Material {
88
96
  // The fetchLocal literal must be inline so the compiler's preload header captures the shader name.
89
97
  const m = new Material({ _id: _creatorUtils.fetchLocal("lit.filamat") } as any)
90
98
  m._colorKey = "baseColor"
91
99
  if (options.color !== undefined) m.color = options.color
92
100
  if (options.map) m.map = options.map
101
+ if (options.roughness !== undefined) m.uniforms.roughness = options.roughness
102
+ if (options.metallic !== undefined) m.uniforms.metallic = options.metallic
93
103
  return m
94
104
  }
95
105
 
@@ -66,11 +66,16 @@ export class Scene {
66
66
  if (parent) {
67
67
  this.camera = parent.camera
68
68
  this._id = _creator.createOverlayScene(parent._id)
69
+ // No camera re-attach: the native createOverlayScene wires the parent's camera into the
70
+ // overlay view. Re-attaching would (a) point camera._sceneId — the source for getRay /
71
+ // getViewDirection / fov — at the overlay's view, whose viewport/projection aren't
72
+ // maintained like the render view's, and (b) create a second native camera component on
73
+ // the shared entity. (Found via the scene editor: gizmo pick rays came out distorted.)
69
74
  } else {
70
75
  this.camera = new Camera()
71
76
  this._id = _creator.createScene()
77
+ this.camera._attach(this._id)
72
78
  }
73
- this.camera._attach(this._id)
74
79
 
75
80
  if (options.ibl !== false) _creator.setDefaultIbl(this._id, options.environmentIntensity ?? 20000)
76
81
  if (options.bloom) _creator.setBloomOptions(this._id, true, options.bloomIntensity ?? 0.2, 1)
@@ -195,3 +200,25 @@ export class ARScene<T extends ARMode = "default"> extends Scene {
195
200
  super.close()
196
201
  }
197
202
  }
203
+
204
+ // --- VR ---
205
+
206
+ /**
207
+ * A 3D scene rendered in a VR headset (OpenXR — e.g. Meta Quest). The headset drives the camera:
208
+ * every frame both eyes are rendered from the tracked head pose, so `scene.camera` position/lookAt
209
+ * have no effect while the scene is open. The world origin is at floor level (stage space) —
210
+ * author content with the ground at y = 0 and the user standing at the origin.
211
+ *
212
+ * `open()` rejects on hosts without a VR runtime ("VR is not supported on this device").
213
+ */
214
+ export class VRScene extends Scene {
215
+ override async open(): Promise<void> {
216
+ await new Promise<void>((res, rej) => _creator.launchVR(this._id, res, rej))
217
+ super.open()
218
+ }
219
+
220
+ override close(): void {
221
+ _creator.stopVR()
222
+ super.close()
223
+ }
224
+ }
@@ -5,12 +5,24 @@ import { ClickEvent, TouchStartEvent, type TrackHandler } from "../runtime/touch
5
5
  import { nodeRegistry } from "./Node"
6
6
  import { glState } from "./state"
7
7
 
8
+ // Hits inside a loaded model (GLB) land on sub-entities the user never created a Node for — walk up
9
+ // the transform hierarchy to the nearest entity someone actually holds (the model's Node).
10
+ const resolveHitNode = (entityId: number) => {
11
+ let node = nodeRegistry.get(entityId)
12
+ for (let id = entityId, i = 0; !node && i < 32; i++) {
13
+ id = _creator.getParent(id)
14
+ if (id === 0) break
15
+ node = nodeRegistry.get(id)
16
+ }
17
+ return node ?? null
18
+ }
19
+
8
20
  const onTouchEnd = (pointerId: number, clientX: number, clientY: number, entityId: number): TrackHandler | null => {
9
21
  const scene = glState.activeScene
10
22
  if (!scene) return null
11
23
  const ev = new ClickEvent<any>(clientX, clientY, pointerId)
12
24
  if (entityId > 0) {
13
- const node = nodeRegistry.get(entityId) ?? null
25
+ const node = resolveHitNode(entityId)
14
26
  ev.target = node
15
27
  if (node) node._dispatchEvent("click", ev)
16
28
  } else {
@@ -25,7 +37,7 @@ const onTouchStart = (pointerId: number, clientX: number, clientY: number, entit
25
37
  if (!scene) return null
26
38
  const ev = new TouchStartEvent<any>(clientX, clientY, pointerId)
27
39
  if (entityId > 0) {
28
- const node = nodeRegistry.get(entityId) ?? null
40
+ const node = resolveHitNode(entityId)
29
41
  ev.target = node
30
42
  if (node) {
31
43
  node._dispatchEvent("touchstart", ev)
@@ -25,6 +25,7 @@ export { Canvas, Bitmap } from "./canvas/Canvas"
25
25
 
26
26
  // ---- platform runtime (injected globals; the host provides the lower-level _creator* bridges) ----
27
27
  export { toast, SvgSource } from "./runtime/misc"
28
+ export { date, type DateValue, type DateInput, type Unit, type Locale } from "./runtime/datetime"
28
29
  export { fetch, fetchLocal, FormData, File } from "./runtime/fetch"
29
30
  export { localStorage } from "./runtime/storage"
30
31
  export { device } from "./runtime/device"
@@ -78,8 +79,13 @@ export { easeIn, easeOut, easeInOut } from "./animate/easings"
78
79
  // ---- plugins (optional, host-provided capabilities — see src/plugins/) ----
79
80
  export { QRScanner } from "./plugins/qr"
80
81
 
82
+ // ---- scenes as data (.scene.ts files — see src/scene/defineScene.ts) ----
83
+ export { defineScene, use, SceneHandle } from "./scene/defineScene"
84
+ export type { SceneDef, SceneNodeDef, MeshDef, LightDef, MaterialDef, AspectEntry, LoadedScene } from "./scene/defineScene"
85
+ export type { FieldMeta } from "./core/fields"
86
+
81
87
  // ---- 3D engine (creator-gl) ----
82
- export { Scene, ARScene } from "./gl/Scene"
88
+ export { Scene, ARScene, VRScene } from "./gl/Scene"
83
89
  export { Node } from "./gl/Node"
84
90
  export { Mesh } from "./gl/Mesh"
85
91
  export { Model } from "./gl/Model"