lecodes-sdk 0.20.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/global.d.ts +35 -9
  2. package/dist/types/animate/tween/Animation.d.ts +69 -0
  3. package/dist/types/animate/tween/Timeline.d.ts +55 -0
  4. package/dist/types/animate/tween/animateValue.d.ts +27 -0
  5. package/dist/types/animate/tween/easing.d.ts +29 -0
  6. package/dist/types/animate/tween/spec.d.ts +178 -0
  7. package/dist/types/canvas/Canvas.d.ts +2 -0
  8. package/dist/types/g2/Node2D.d.ts +16 -0
  9. package/dist/types/g2/Sprite.d.ts +11 -1
  10. package/dist/types/gl/Camera.d.ts +15 -1
  11. package/dist/types/gl/DecalSet.d.ts +48 -3
  12. package/dist/types/gl/Foliage.d.ts +47 -0
  13. package/dist/types/gl/Geometry.d.ts +36 -0
  14. package/dist/types/gl/Light.d.ts +25 -7
  15. package/dist/types/gl/Lightmap.d.ts +90 -51
  16. package/dist/types/gl/Material.d.ts +32 -20
  17. package/dist/types/gl/Mesh.d.ts +7 -1
  18. package/dist/types/gl/Model.d.ts +41 -5
  19. package/dist/types/gl/Node.d.ts +18 -0
  20. package/dist/types/gl/Particles.d.ts +53 -1
  21. package/dist/types/gl/Scene.d.ts +21 -1
  22. package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
  23. package/dist/types/gl/animation/Animator.d.ts +27 -0
  24. package/dist/types/gl/animation/DynamicBone.d.ts +184 -0
  25. package/dist/types/gl/animation/IK.d.ts +109 -0
  26. package/dist/types/gl/{Locomotion.d.ts → animation/Locomotion.d.ts} +6 -6
  27. package/dist/types/gl/animation/Warp.d.ts +2 -1
  28. package/dist/types/gl/animation/core.d.ts +35 -4
  29. package/dist/types/gl/{AudioSource.d.ts → audio/AudioSource.d.ts} +6 -6
  30. package/dist/types/gl/{AudioZone.d.ts → audio/AudioZone.d.ts} +4 -4
  31. package/dist/types/gl/{SceneAudio.d.ts → audio/SceneAudio.d.ts} +1 -1
  32. package/dist/types/gl/{NavAgent.d.ts → nav/NavAgent.d.ts} +4 -4
  33. package/dist/types/gl/{NavMesh.d.ts → nav/NavMesh.d.ts} +4 -4
  34. package/dist/types/gl/{CharacterController.d.ts → physics/CharacterController.d.ts} +5 -5
  35. package/dist/types/gl/{Physics.d.ts → physics/Physics.d.ts} +6 -5
  36. package/dist/types/gl/physics/Ragdoll.d.ts +161 -0
  37. package/dist/types/gl/{Shape.d.ts → physics/Shape.d.ts} +3 -3
  38. package/dist/types/gl/{Trigger.d.ts → physics/Trigger.d.ts} +2 -2
  39. package/dist/types/gl/{Terrain.d.ts → terrain/Terrain.d.ts} +10 -8
  40. package/dist/types/gl/{terrainMesh.d.ts → terrain/terrainMesh.d.ts} +1 -1
  41. package/dist/types/gl/vehicle/Vehicle.d.ts +300 -0
  42. package/dist/types/gl/vehicle/Wheel.d.ts +147 -0
  43. package/dist/types/inject.d.ts +35 -27
  44. package/dist/types/runtime/files.d.ts +24 -1
  45. package/dist/types/scene/defineScene.d.ts +50 -31
  46. package/dist/types/ui/UIButton.d.ts +3 -1
  47. package/dist/types/ui/UIInput.d.ts +5 -1
  48. package/dist/types/ui/UINode.d.ts +24 -24
  49. package/dist/types.json +1 -1
  50. package/package.json +1 -1
  51. package/prompts/README.md +142 -142
  52. package/prompts/core-design.md +27 -4
  53. package/prompts/core.md +35 -6
  54. package/prompts/select.ts +19 -4
  55. package/src/animate/tween/Animation.ts +378 -0
  56. package/src/animate/tween/Timeline.ts +175 -0
  57. package/src/animate/tween/animateValue.ts +100 -0
  58. package/src/animate/tween/easing.ts +172 -0
  59. package/src/animate/tween/spec.ts +479 -0
  60. package/src/bridges.d.ts +1760 -1481
  61. package/src/canvas/Canvas.ts +21 -0
  62. package/src/compile/__tests__/assetMacro.test.ts +26 -0
  63. package/src/compile/__tests__/compile.test.ts +11 -0
  64. package/src/compile/__tests__/detectEntry.test.ts +19 -0
  65. package/src/compile/__tests__/serverSplit.test.ts +27 -0
  66. package/src/compile/bundler.ts +34 -4
  67. package/src/compile/compileProject.ts +31 -1
  68. package/src/compile/detectEntry.ts +8 -3
  69. package/src/compile/header.ts +6 -3
  70. package/src/compile/index.ts +3 -0
  71. package/src/compile/sceneEditor.ts +42 -1
  72. package/src/compile/serverSplit.ts +9 -3
  73. package/src/g2/Node2D.ts +38 -0
  74. package/src/g2/Sprite.ts +20 -1
  75. package/src/gl/Camera.ts +34 -1
  76. package/src/gl/DecalSet.ts +132 -5
  77. package/src/gl/Foliage.ts +102 -0
  78. package/src/gl/Geometry.ts +109 -0
  79. package/src/gl/Light.ts +46 -16
  80. package/src/gl/Lightmap.ts +440 -249
  81. package/src/gl/Material.ts +69 -36
  82. package/src/gl/Mesh.ts +120 -102
  83. package/src/gl/Model.ts +167 -124
  84. package/src/gl/Node.ts +40 -1
  85. package/src/gl/Particles.ts +82 -5
  86. package/src/gl/Scene.ts +35 -2
  87. package/src/gl/animation/AnimationClip.ts +52 -0
  88. package/src/gl/animation/Animator.ts +42 -2
  89. package/src/gl/animation/DynamicBone.ts +482 -0
  90. package/src/gl/animation/IK.ts +214 -0
  91. package/src/gl/{Locomotion.ts → animation/Locomotion.ts} +7 -7
  92. package/src/gl/animation/Playback.ts +5 -4
  93. package/src/gl/animation/Warp.ts +5 -2
  94. package/src/gl/animation/core.ts +65 -4
  95. package/src/gl/{AudioSource.ts → audio/AudioSource.ts} +7 -7
  96. package/src/gl/{AudioZone.ts → audio/AudioZone.ts} +75 -75
  97. package/src/gl/{SceneAudio.ts → audio/SceneAudio.ts} +2 -2
  98. package/src/gl/{NavAgent.ts → nav/NavAgent.ts} +5 -5
  99. package/src/gl/{NavMesh.ts → nav/NavMesh.ts} +8 -8
  100. package/src/gl/{CharacterController.ts → physics/CharacterController.ts} +5 -5
  101. package/src/gl/{Physics.ts → physics/Physics.ts} +12 -5
  102. package/src/gl/physics/Ragdoll.ts +451 -0
  103. package/src/gl/{Shape.ts → physics/Shape.ts} +3 -3
  104. package/src/gl/{Trigger.ts → physics/Trigger.ts} +45 -45
  105. package/src/gl/{physicsEvents.ts → physics/physicsEvents.ts} +1 -1
  106. package/src/gl/{Terrain.ts → terrain/Terrain.ts} +14 -12
  107. package/src/gl/{terrainMesh.ts → terrain/terrainMesh.ts} +1 -1
  108. package/src/gl/vehicle/Vehicle.ts +666 -0
  109. package/src/gl/vehicle/Wheel.ts +290 -0
  110. package/src/inject.ts +236 -224
  111. package/src/runtime/files.ts +32 -2
  112. package/src/scene/defineScene.ts +92 -66
  113. package/src/scene/level.ts +2 -2
  114. package/src/ui/UIButton.ts +2 -2
  115. package/src/ui/UIInput.ts +3 -3
  116. package/src/ui/UINode.ts +61 -36
  117. package/dist/types/animate/animate.d.ts +0 -20
  118. package/dist/types/gl/Gearbox.d.ts +0 -86
  119. package/dist/types/gl/IK.d.ts +0 -53
  120. package/dist/types/gl/Ragdoll.d.ts +0 -86
  121. package/dist/types/gl/Vehicle.d.ts +0 -191
  122. package/dist/types/gl/Wheel.d.ts +0 -95
  123. package/src/animate/animate.ts +0 -238
  124. package/src/gl/Gearbox.ts +0 -212
  125. package/src/gl/IK.ts +0 -193
  126. package/src/gl/Ragdoll.ts +0 -270
  127. package/src/gl/Vehicle.ts +0 -473
  128. package/src/gl/Wheel.ts +0 -240
  129. /package/dist/types/gl/{physicsEvents.d.ts → physics/physicsEvents.d.ts} +0 -0
@@ -35,6 +35,11 @@ const OP = {
35
35
  DRAW_IMAGE: 30,
36
36
  } as const
37
37
 
38
+ // Above this many buffered values, a buffer that is STILL growing between bakes is a leak, not a
39
+ // drawing (a dense static picture is a few thousand; a 15 Hz graph without reset() passes this in
40
+ // seconds and doubles its frame cost every minute).
41
+ const CMD_GROWTH_WARN = 20000
42
+
38
43
  const ALIGN = { left: 0, center: 1, right: 2, start: 3, end: 4 } as const
39
44
  const BASELINE = { alphabetic: 0, top: 1, middle: 2, bottom: 3, hanging: 4, ideographic: 5 } as const
40
45
  const JOIN = { miter: 0, round: 1, bevel: 2 } as const
@@ -76,6 +81,11 @@ export class Canvas {
76
81
  // also refreshes the on-screen image node(s) via _creatorUI — the UI consumer the 2D/3D texture
77
82
  // hooks below don't cover. Stays a bare flag so a non-UI canvas never references the UI bridge.
78
83
  private _uiBound = false
84
+ // Growth watch: the command buffer is a RECORDING that every bake replays in full, so a canvas
85
+ // redrawn every frame without `reset()` gets slower with every update. `_bakeSurface` compares the
86
+ // buffer against the previous bake and warns once when it keeps growing past the threshold.
87
+ private _lastBakeLen = 0
88
+ private _warnedGrowth = false
79
89
 
80
90
  constructor(width: number, height: number, opts: { pixelRatio?: number } = {}) {
81
91
  this.width = width
@@ -198,6 +208,17 @@ export class Canvas {
198
208
 
199
209
  /** @internal — rasterize the recorded commands into the surface; returns the surface id. */
200
210
  _bakeSurface(): number {
211
+ const len = this._cmd.length
212
+ if (!this._warnedGrowth && len > CMD_GROWTH_WARN && this._lastBakeLen > 0 && len > this._lastBakeLen) {
213
+ this._warnedGrowth = true
214
+ console.warn(
215
+ `[canvas] ${this.width}×${this.height}: the command buffer keeps growing (${len} values, ` +
216
+ `${this._lastBakeLen} at the previous bake) — a Canvas records every call and each update() ` +
217
+ `replays all of it, so a redraw that never calls reset() gets slower every frame. ` +
218
+ `Call canvas.reset() before redrawing (or toBitmap() + reset() to flatten a drawing).`,
219
+ )
220
+ }
221
+ this._lastBakeLen = len
201
222
  const buf = new Float32Array(this._cmd)
202
223
  this._surfaceId = _creatorCanvas.rasterize(this._surfaceId, this.width, this.height, this.pixelRatio, buf, buf.length, this._refs)
203
224
  return this._surfaceId
@@ -34,6 +34,32 @@ test("the same asset present in the entries compiles to its URL", async () => {
34
34
  expect(js).not.toContain("./assets/lightmap.bake")
35
35
  })
36
36
 
37
+ // Only the modules the bundle reaches are checked: a project keeps one entry per level, and a
38
+ // level whose art is not on this machine must not fail an entry that never imports it.
39
+ test("a missing asset in a module the entry never imports does not fail the compile", async () => {
40
+ const js = await compileProject({
41
+ name: "Unreached",
42
+ publicUrl: "https://le.codes",
43
+ entries: [
44
+ { path: "/main.ts", type: "text", text: "const scene = new Scene()\nconsole.log(scene)\n" },
45
+ { path: "/levels/lost.ts", type: "text", text: "export const lost = asset('./lost.glb')\n" },
46
+ ],
47
+ })
48
+ expect(js).not.toContain("lost.glb")
49
+ })
50
+
51
+ test("the same missing asset fails once the entry imports its module", async () => {
52
+ const compile = compileProject({
53
+ name: "Reached",
54
+ publicUrl: "https://le.codes",
55
+ entries: [
56
+ { path: "/main.ts", type: "text", text: "import { lost } from './levels/lost'\nconsole.log(lost)\n" },
57
+ { path: "/levels/lost.ts", type: "text", text: "export const lost = asset('./lost.glb')\n" },
58
+ ],
59
+ })
60
+ await expect(compile).rejects.toThrow("asset not found: ./lost.glb (levels/lost.ts:1)")
61
+ })
62
+
37
63
  test("a commented-out asset() is not an asset at all", () => {
38
64
  const src = [
39
65
  "/** The atlas image — write `asset('./TileSet.png')`. */",
@@ -39,6 +39,17 @@ test("a 2D project (new Scene2D) is marked 2d: enable and gl: disable", async ()
39
39
  expect(js).toContain("_creator2d.createScene")
40
40
  })
41
41
 
42
+ test("a 3D project with a Canvas is NOT marked 2d: enable (Canvas keeps a `_creator2d.` texture path)", async () => {
43
+ const js = await compileProject({
44
+ name: "Car",
45
+ publicUrl: "https://le.codes",
46
+ entries: [ { path: "/main.ts", type: "text", text: "const c = new Canvas(64, 64)\nc.update()\nnew Scene().open()\n" } ],
47
+ })
48
+ expect(js).toContain("_creator2d.") // the canvas 2D-texture path survives tree-shaking
49
+ expect(js).not.toContain("// 2d: enable") // …but no Scene2D → the 2D engine is not in use
50
+ expect(js).not.toContain("// gl: disable")
51
+ })
52
+
42
53
  test("2D and 3D bundles are DISJOINT (tree-shake drops the other engine)", async () => {
43
54
  const twoD = await compileProject({
44
55
  name: "2D", publicUrl: "https://le.codes", header: false,
@@ -130,3 +130,22 @@ test("compileProject auto-detects the importer as entry and runs the registratio
130
130
  expect(js).toContain("app-side-effect")
131
131
  expect(js).toContain("register-side-effect")
132
132
  })
133
+
134
+ test("design/ screens are never the entry — a design-only project has none, app code next to a design wins", () => {
135
+ // A Figma import: screens on the board, shared modules, no app yet. The largest root used to win.
136
+ const designOnly = detectEntry([
137
+ text("/design/screens/a4-40.ts", "import { at } from '../shared/figma'\nexport default UIScreen([])\n" + "// big\n".repeat(50)),
138
+ text("/design/shared/figma.ts", "export const at = () => ({})\n"),
139
+ text("/app.server.ts", "export function ping() { return 1 }\n"),
140
+ ])
141
+ expect(designOnly.entry).toBe("")
142
+ expect(designOnly.roots).toEqual([])
143
+
144
+ // The same project once the app exists: main.ts is the entry however small it is.
145
+ const withApp = detectEntry([
146
+ text("/design/screens/a4-40.ts", "export default UIScreen([])\n" + "// big\n".repeat(50)),
147
+ text("/main.ts", "Router.init(home)\n"),
148
+ ])
149
+ expect(withApp.entry).toBe("/main.ts")
150
+ expect(withApp.roots).toEqual(["/main.ts"])
151
+ })
@@ -0,0 +1,27 @@
1
+ import { test, expect } from "bun:test"
2
+ import { compileServerBundle } from "../serverSplit"
3
+
4
+ // The server bundle's file map is the whole project (a server module may import shared code), and
5
+ // the bundler checks every `asset()` in that map. Seen live 2026-09-17: a Figma-exported design next
6
+ // to app.server.ts failed the SERVER build with "asset not found: ../assets/figma/…" because the
7
+ // map had the screens but not the binaries. Binaries are stubbed, design/ stays out.
8
+
9
+ const text = (path: string, t: string) => ({ path, type: "text" as const, text: t })
10
+ const res = (path: string) => ({ path, type: "resource" as const, fileSrc: "/uploads/x.bin" })
11
+
12
+ test("a client screen's asset() does not break the server bundle", async () => {
13
+ const out = await compileServerBundle({
14
+ validate: false,
15
+ entries: [
16
+ text("/app.server.ts", "export const ping = async () => 1\n"),
17
+ text("/main.ts", "import { ping } from './app.server'\nconst hero = asset('./hero.png')\nping()\n"),
18
+ res("/hero.png"),
19
+ text("/design/screens/a4-40.ts", "export default UIScreen([ UIImage(asset('../assets/figma/a4-40/rectangle.png')), UIImage(asset('../assets/figma/a4-40/logo.svg')) ])\n"),
20
+ res("/design/assets/figma/a4-40/rectangle.png"),
21
+ text("/design/assets/figma/a4-40/logo.svg", "<svg/>"),
22
+ ],
23
+ })
24
+ expect(out).not.toBeNull()
25
+ expect(out!.code).toContain("ping")
26
+ expect(Object.keys(out!.manifest.modules)).toEqual(["app.server.ts"])
27
+ })
@@ -4,7 +4,7 @@ import { createRequire } from "node:module"
4
4
  import { dirname, join, relative } from "node:path"
5
5
  import { fileURLToPath } from "node:url"
6
6
  import { collectAssetMacros, desugarAssetMacros, hasAssetMacro, type AssetMacroRef } from "./assetMacro"
7
- import type { ScanModule } from "./detectEntry"
7
+ import { scanModulesFallback, type ScanModule } from "./detectEntry"
8
8
  import { hasAspectExtends, injectAspectNames } from "./aspectMacro"
9
9
  import { hasImportStatement, rewriteLibraryImports } from "./libraryImports"
10
10
 
@@ -132,8 +132,30 @@ export const scanModulesChisel = (files: MemoryFiles): Record<string, ScanModule
132
132
  }
133
133
 
134
134
  /**
135
- * Every `asset('…')` must name a file that's actually in the compile input — a missing one is a
136
- * COMPILE ERROR, never a fallback.
135
+ * The modules the bundle will actually contain: everything reachable from `entry` over static
136
+ * imports (chisel's scan, else the regex scanner — same graph detectEntry roots on). `sources` are
137
+ * the post-library-rewrite, pre-desugar texts, so a bare `from "acme-ui"` already points into
138
+ * `/__lib/<slug>/…` and follows through. A dynamic `import()` is not an edge for either scanner.
139
+ */
140
+ const reachableFrom = (entry: string, sources: MemoryFiles): Set<string> => {
141
+ const graph = scanModulesChisel(sources) ?? scanModulesFallback(sources)
142
+ const seen = new Set<string>([ entry ])
143
+ const queue = [ entry ]
144
+ while (queue.length > 0) {
145
+ const file = queue.pop()!
146
+ for (const target of graph[file]?.imports ?? []) {
147
+ if (!seen.has(target)) { seen.add(target); queue.push(target) }
148
+ }
149
+ }
150
+ return seen
151
+ }
152
+
153
+ /**
154
+ * Every `asset('…')` in a module the bundle REACHES must name a file that's actually in the
155
+ * compile input — a missing one is a COMPILE ERROR, never a fallback. Modules the entry never
156
+ * imports are not checked: a project keeps one entry per level, and a level whose GLBs are not on
157
+ * this machine (gitignored art) must not fail the compile of an entry that never loads it — it
158
+ * fails its own compile, with the same message (fps-demo demo-logo.ts vs chernobyl, 2026-09-17).
137
159
  *
138
160
  * chisel used to turn an asset import with no backing module into `const x = "<the specifier>"`:
139
161
  * a relative source path nothing resolves at runtime, so the app got a silently broken reference
@@ -173,11 +195,15 @@ const bundleProjectChisel = async (
173
195
  // - inject `static aspect = '<name>'` from `class … extends Aspect<'<name>', …>`
174
196
  const projFiles: MemoryFiles = {}
175
197
  const assetRefs: { file: string, ref: AssetMacroRef }[] = []
198
+ // What the module scanner sees: import-rewritten, NOT yet asset-desugared (a desugared asset is a
199
+ // default import of a non-module — the fallback scanner drops it, chisel's scan would not).
200
+ const scanSources: MemoryFiles = {}
176
201
  for (const [ k, v ] of Object.entries(files)) {
177
202
  const path = normalize(k)
178
203
  let src = v
179
204
  if (/\.[tj]sx?$/.test(path)) {
180
205
  if (hasImportStatement(src)) src = rewriteLibraryImports(src, slugs)
206
+ scanSources[path] = src
181
207
  if (hasAssetMacro(src)) {
182
208
  const found = collectAssetMacros(src, path)
183
209
  for (const ref of found) assetRefs.push({ file: path, ref })
@@ -198,6 +224,7 @@ const bundleProjectChisel = async (
198
224
  const path = normalize(`/__lib/${slug}/${k}`)
199
225
  let src = v
200
226
  if (/\.[tj]sx?$/.test(path)) {
227
+ scanSources[path] = src
201
228
  if (hasAssetMacro(src)) {
202
229
  const found = collectAssetMacros(src, path)
203
230
  for (const ref of found) assetRefs.push({ file: path, ref })
@@ -209,7 +236,10 @@ const bundleProjectChisel = async (
209
236
  }
210
237
  }
211
238
 
212
- assertAssetsExist(assetRefs, projFiles)
239
+ if (assetRefs.length > 0) {
240
+ const reachable = reachableFrom(entry, scanSources)
241
+ assertAssetsExist(assetRefs.filter(({ file }) => reachable.has(file)), projFiles)
242
+ }
213
243
 
214
244
  const sdkFiles = collectSdkFiles(resolveSdkSrc())
215
245
  // Shipped bundles minify by default; edit-mode bundles pass `false` — mangling erases class
@@ -74,17 +74,47 @@ export type CompileOptions = {
74
74
  * unless `header: false`; `map` is already shifted past that header. */
75
75
  export type CompileResult = { code: string, map: string | null, entry: string }
76
76
 
77
+ /** The basename a local resource would be looked up by. */
78
+ export const localAssetBase = (path: string): string => path.split(/[\\/]/).pop() ?? path
79
+
80
+ /**
81
+ * Runtime lookup names for `localAssets` resources — what the bundle passes to `fetchLocal` and what
82
+ * a host caller must copy the file to.
83
+ *
84
+ * The basename, unless two project files SHARE one: then every sharer takes its whole root-relative
85
+ * path with the separators folded into `__`. A flat name space is fine until it isn't, and when it
86
+ * isn't the failure is silent and absurd — the files are staged into one directory, so the last one
87
+ * copied answers for every `asset()` that ends in that name. Which one wins depends on the scan
88
+ * order, i.e. on nothing. (2026-09-17: a viewer project carried two asset packs that each ship
89
+ * `SM_Sign_01..03.glb`; the school's floor-standing "CLEANING IN PROGRESS" A-frame was drawn inside
90
+ * the shooting range's wall, where a flat 4 mm plate belonged.)
91
+ */
92
+ export const localAssetNames = (entries: readonly { path: string, type: string }[]): Map<string, string> => {
93
+ const seen = new Map<string, number>()
94
+ for (const e of entries) if (e.type === "resource") {
95
+ const b = localAssetBase(e.path)
96
+ seen.set(b, (seen.get(b) ?? 0) + 1)
97
+ }
98
+ const names = new Map<string, string>()
99
+ for (const e of entries) if (e.type === "resource") {
100
+ const b = localAssetBase(e.path)
101
+ names.set(e.path, (seen.get(b) ?? 0) > 1 ? e.path.replace(/^[./]+/, "").replace(/[\\/]/g, "__") : b)
102
+ }
103
+ return names
104
+ }
105
+
77
106
  /** Turn compile entries into the bundler's module map — the per-kind rules of the deployable
78
107
  * bundle (resource → URL export, shader → per-backend URL template, .svg → SvgSource, .json →
79
108
  * data, code verbatim). Exported for callers that build a `libraries` map from entries. */
80
109
  export const entriesToFiles = (entries: CompileEntry[], opts: { publicUrl: string, localAssets?: boolean }): Record<string, string> => {
81
110
  const files: Record<string, string> = {}
111
+ const localNames = localAssetNames(entries)
82
112
  for (const e of entries) {
83
113
  if (e.type === "resource") {
84
114
  if (opts.localAssets && !e.remote) {
85
115
  // The host resolves the asset by name at runtime (reads the local file → systemId); the
86
116
  // "id:" prefix is the host's local-resource URL scheme (parsed by the UI + 2D texture paths).
87
- const name = e.path.split(/[\/]/).pop() ?? e.path
117
+ const name = localNames.get(e.path) ?? localAssetBase(e.path)
88
118
  files[e.path] = `export default ("id:" + _creatorUtils.fetchLocal(${JSON.stringify(name)}))`
89
119
  } else {
90
120
  files[e.path] = `export default "${opts.publicUrl}${e.fileSrc ?? ""}"`
@@ -37,9 +37,14 @@ export type DetectedEntry = {
37
37
  export const ENTRY_CONVENTION = "/main.ts"
38
38
 
39
39
  const isCode = (path: string) => /\.[tj]s$/.test(path) && !path.endsWith(".d.ts")
40
- /** Never entries: editor plugins and server modules (docs/backend-plan.md — a `*.server.ts` nobody
41
- * imports is still not the app; server files compile into their own bundle). */
42
- const isNonEntry = (path: string) => path.endsWith(".editor.ts") || /\.server\.[tj]s$/.test(path)
40
+ /** Never entries: editor plugins, server modules (docs/backend-plan.md — a `*.server.ts` nobody
41
+ * imports is still not the app; server files compile into their own bundle) and anything under
42
+ * `design/` — the board's screens are import-graph roots by construction (nothing imports a screen
43
+ * file; the board runs them), so without this a project whose app code is still to come had its
44
+ * largest design screen picked as the app and failed deep inside it ("asset not found:
45
+ * ../assets/figma/…"). A design-only project has no entry, and says so. */
46
+ const isNonEntry = (path: string) =>
47
+ path.endsWith(".editor.ts") || /\.server\.[tj]s$/.test(path) || /^\/design\//.test(path)
43
48
 
44
49
  const normalize = (path: string): string => {
45
50
  const parts: string[] = []
@@ -33,8 +33,11 @@ export const buildHeader = (name: string, code: string, opts: HeaderOptions = {}
33
33
  else if (code.includes("_creator.createEngine")) header += `// gl: defer\n`
34
34
 
35
35
  // 2D (creator-2d) — non-exclusive: emitted alongside `gl: disable` so hosts skip Filament and a
36
- // 2D-aware host brings up the lightweight sokol engine instead.
37
- if (code.includes("_creator2d.")) header += `// 2d: enable\n`
36
+ // 2D-aware host brings up the lightweight sokol engine instead. Keyed on the scene constructor
37
+ // (like GL above), NOT on any `_creator2d.` reference: Canvas carries a 2D-texture path
38
+ // (`canvas.texture()` → `_creator2d.createTextureFromCanvas`) that chisel keeps in a pure-3D
39
+ // bundle, and nothing 2D works without a Scene2D anyway.
40
+ if (code.includes("_creator2d.createScene")) header += `// 2d: enable\n`
38
41
 
39
42
  if (code.includes("_creator.createARController")) header += `// ar: enable\n`
40
43
 
@@ -61,7 +64,7 @@ export const buildHeader = (name: string, code: string, opts: HeaderOptions = {}
61
64
  /** Same engine signals the header uses, for callers that need the project kind (e.g. icon type). */
62
65
  export const detectProjectKind = (code: string): "ar" | "ui" | "2d" | "3d" => {
63
66
  if (code.includes("_creator.createARController")) return "ar"
64
- if (code.includes("_creator2d.")) return "2d"
67
+ if (code.includes("_creator2d.createScene")) return "2d"
65
68
  if (!code.includes("_creator.")) return "ui"
66
69
  return "3d"
67
70
  }
@@ -65,6 +65,8 @@ export {
65
65
  compileProject,
66
66
  compileProjectWithMap,
67
67
  entriesToFiles,
68
+ localAssetNames,
69
+ localAssetBase,
68
70
  type CompileEntry,
69
71
  type CompileOptions,
70
72
  type CompileFileType,
@@ -73,6 +75,7 @@ export {
73
75
  export {
74
76
  loadSceneHarness,
75
77
  sceneEditorEntries,
78
+ sceneImportsOf,
76
79
  SCENE_ENTRY_PATH,
77
80
  SCENE_HARNESS_DIR,
78
81
  } from "./sceneEditor"
@@ -47,10 +47,44 @@ export const loadSceneHarness = (fallbackDir?: string): Record<string, string> =
47
47
  * the project's file list — `.editor.ts` entries are picked out of it. Compile with
48
48
  * `entryOverride: SCENE_ENTRY_PATH`, `header: false`, `format: "iife"`, `editor: true`.
49
49
  */
50
+ /** Resolve a relative import specifier against a project path ("./src/map.scene.ts" + "./map/x.scene"
51
+ * → "./src/map/x.scene"). */
52
+ const resolveRelative = (fromPath: string, spec: string): string => {
53
+ const segs = (p: string) => p.split("/").filter((s) => s !== "" && s !== ".")
54
+ const out = segs(fromPath).slice(0, -1)
55
+ for (const seg of segs(spec)) {
56
+ if (seg === "..") out.pop()
57
+ else out.push(seg)
58
+ }
59
+ return "./" + out.join("/")
60
+ }
61
+
62
+ /**
63
+ * The other scene files a scene file imports as prefabs (`import x from './x.scene'`), as project
64
+ * paths present in `projectPaths`. The edit bundle registers their handles BY PATH
65
+ * (`__lecodesSceneFiles`), so the editor can live-patch a `prefab:` node — add, duplicate, undo —
66
+ * without a compile: the doc sees `prefab: X` as an identifier, the host maps it through the
67
+ * file's import table to this path, and the harness resolves the path to the running handle.
68
+ */
69
+ export const sceneImportsOf = (sceneText: string, scenePath: string, projectPaths: string[]): string[] => {
70
+ const known = new Set(projectPaths)
71
+ const out: string[] = []
72
+ const re = /^\s*import\s+[A-Za-z_$][\w$]*\s+from\s+["']([^"']+\.scene(?:\.ts)?)["']/gm
73
+ for (const m of sceneText.matchAll(re)) {
74
+ const spec = m[1]
75
+ if (!spec.startsWith(".")) continue
76
+ let p = resolveRelative(scenePath, spec)
77
+ if (!p.endsWith(".ts")) p += ".ts"
78
+ if (known.has(p) && !out.includes(p)) out.push(p)
79
+ }
80
+ return out
81
+ }
82
+
50
83
  export const sceneEditorEntries = (
51
84
  scenePath: string,
52
85
  harness: Record<string, string>,
53
86
  projectPaths: string[] = [],
87
+ sceneImports: string[] = [],
54
88
  ): CompileEntry[] => {
55
89
  const entries: CompileEntry[] = Object.entries(harness).map(([ name, text ]) => ({
56
90
  path: `${SCENE_HARNESS_DIR}/${name}`,
@@ -74,15 +108,22 @@ export const sceneEditorEntries = (
74
108
  const materialImports = materialPaths.map((p, i) => `import __mat${i} from "${p}"\n`).join("")
75
109
  const materialRegister = materialPaths.length === 0 ? ""
76
110
  : `;(globalThis as any).__lecodesMaterials = { ${materialPaths.map((p, i) => `${JSON.stringify(p)}: __mat${i}`).join(", ")} }\n`
111
+ // The scene's prefab imports, registered by project path (see sceneImportsOf) — the same module
112
+ // instances the scene itself imported, so a live-patched prefab node is the same handle.
113
+ const sceneImportLines = sceneImports.map((p, i) => `import __scn${i} from "${p}"\n`).join("")
114
+ const sceneRegister = sceneImports.length === 0 ? ""
115
+ : `;(globalThis as any).__lecodesSceneFiles = { ${sceneImports.map((p, i) => `${JSON.stringify(p)}: __scn${i}`).join(", ")} }\n`
77
116
  entries.push({
78
117
  path: SCENE_ENTRY_PATH,
79
118
  type: "text",
80
119
  text: `import "${SCENE_HARNESS_DIR}/.edit-mode.ts"\n`
81
120
  + materialImports
121
+ + sceneImportLines
82
122
  + `import "${scenePath}"\n`
83
123
  + editorImports
84
124
  + `import "${SCENE_HARNESS_DIR}/main.ts"\n`
85
- + materialRegister,
125
+ + materialRegister
126
+ + sceneRegister,
86
127
  })
87
128
  return entries
88
129
  }
@@ -210,11 +210,17 @@ export type CompileServerOptions = {
210
210
  export const compileServerBundle = async (opts: CompileServerOptions): Promise<{ code: string, manifest: ServerManifest } | null> => {
211
211
  const modules = scanServerModules(opts.entries)
212
212
  if (Object.keys(modules).length === 0) return null
213
+ // The server bundle sees the whole project map (a server module may import shared code), but the
214
+ // bundler's asset check runs over EVERY file in the map — so binaries and svgs must be present as
215
+ // stubs, or a client screen's `asset("./x.png")` fails the SERVER build ("asset not found", seen
216
+ // with a Figma-exported design next to app.server.ts). The board's `design/` files are never
217
+ // server code and stay out entirely.
213
218
  const files: MemoryFiles = {}
214
219
  for (const e of opts.entries) {
215
- if (e.type !== "text") continue
216
- if (e.path.toLowerCase().endsWith(".svg")) continue
217
- files[normalizePath(e.path)] = e.text ?? ""
220
+ const path = normalizePath(e.path)
221
+ if (/^\/design\//.test(path)) continue
222
+ if (e.type !== "text" || path.toLowerCase().endsWith(".svg")) { files[path] = `export default ""`; continue }
223
+ files[path] = e.text ?? ""
218
224
  }
219
225
  let params = opts.params
220
226
  if (!params && opts.validate !== false) {
package/src/g2/Node2D.ts CHANGED
@@ -16,6 +16,19 @@ import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
16
16
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
17
17
  import { ensurePointerEvents } from "./touch"
18
18
  import { ensurePhysicsEvents } from "./loop"
19
+ import type { Animation } from "../animate/tween/Animation"
20
+ import { CLOCK_GAME, DOM_NODE2D, floatValue, vec2Value, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
21
+ import { tweenBag } from "../animate/tween/Timeline"
22
+
23
+ /** Animatable transform props of a 2D node; an array of values is a keyframe list. */
24
+ export type Node2DTweenProps = {
25
+ x?: number | number[],
26
+ y?: number | number[],
27
+ position?: Vec2Like | Vec2Like[],
28
+ /** Degrees. */
29
+ rotation?: number | number[],
30
+ scale?: number | Vec2Like | (number | Vec2Like)[],
31
+ }
19
32
 
20
33
  // re-exported so existing `import { Vec2 } from "./Node2D"` sites keep working; the canonical home is math/.
21
34
  export { Vec2, type Vec2Like }
@@ -135,6 +148,31 @@ export class Node2D extends AspectHost<Node2DEvents> implements CompWriter {
135
148
  _creator2d.setScale(this.id, sx, sy)
136
149
  }
137
150
 
151
+ // --- keyframe animation (docs/timeline-plan.md) ---
152
+ /** Tween the transform — `node.animateTo({ x: 120, rotation: 90, duration: 400, easing: 'outBack' })`;
153
+ * arrays are keyframes. Game clock unless `clock: 'ui'`. Returns the {@link Animation} handle. */
154
+ animateTo(props: Node2DTweenProps & TweenMeta): Animation {
155
+ return tweenBag(this, props as Record<string, unknown>, true)
156
+ }
157
+ /** Tween FROM the given values to the node's current transform. */
158
+ animateFrom(props: Node2DTweenProps & TweenMeta): Animation {
159
+ return tweenBag(this, props as Record<string, unknown>, false)
160
+ }
161
+ /** @internal keyframe-core target protocol */
162
+ readonly _tweenClock: number = CLOCK_GAME
163
+ /** @internal */
164
+ _tweenChannel(prop: string): TweenChannel | null {
165
+ const id = () => this.id
166
+ switch (prop) {
167
+ case "x": return { domain: DOM_NODE2D, id, value: floatValue, commit: (v) => { this._x = v as number } }
168
+ case "y": return { domain: DOM_NODE2D, id, value: floatValue, commit: (v) => { this._y = v as number } }
169
+ case "position": return { domain: DOM_NODE2D, id, value: vec2Value, commit: (v) => { this._x = cx(v as Vec2Like); this._y = cy(v as Vec2Like) } }
170
+ case "rotation": return { domain: DOM_NODE2D, id, value: floatValue, commit: (v) => { this._rotation = v as number } }
171
+ case "scale": return { domain: DOM_NODE2D, id, value: vec2Value, commit: (v) => { const s = typeof v === "number" ? [v, v] : v as Vec2Like; this._sx = cx(s); this._sy = cy(s) } }
172
+ default: return null
173
+ }
174
+ }
175
+
138
176
  /** Draw layer (int). Higher layers render on top. */
139
177
  get layer(): number { return this._layer }
140
178
  set layer(v: number) { this._layer = v; _creator2d.setLayer(this.id, v) }
package/src/g2/Sprite.ts CHANGED
@@ -5,8 +5,16 @@
5
5
  import { Color, type ColorInput } from "../core/color"
6
6
  import { Vec2, type Vec2Like } from "../math/vec"
7
7
  import { Canvas, isCanvas } from "../canvas/Canvas"
8
- import { Node2D } from "./Node2D"
8
+ import { Node2D, type Node2DTweenProps } from "./Node2D"
9
9
  import type { Texture2D } from "./Texture2D"
10
+ import { DOM_SPRITE2D, colorValue, floatValue, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
11
+ import type { Animation } from "../animate/tween/Animation"
12
+
13
+ /** Animatable sprite props on top of the 2D transform. */
14
+ export type SpriteTweenProps = Node2DTweenProps & {
15
+ opacity?: number | number[],
16
+ color?: ColorInput | ColorInput[],
17
+ }
10
18
 
11
19
  /** Anything that can back a sprite: a texture, or a Canvas (baked to a texture on assign). */
12
20
  export type TextureSource = Texture2D | Canvas
@@ -76,6 +84,17 @@ export class Sprite extends Node2D {
76
84
  return this
77
85
  }
78
86
 
87
+ /** Tween `opacity` / `color` and the transform — see {@link Node2D.animateTo}. */
88
+ override animateTo(props: SpriteTweenProps & TweenMeta): Animation { return super.animateTo(props) }
89
+ override animateFrom(props: SpriteTweenProps & TweenMeta): Animation { return super.animateFrom(props) }
90
+ /** @internal */
91
+ override _tweenChannel(prop: string): TweenChannel | null {
92
+ const id = () => this.id
93
+ if (prop === "opacity") return { domain: DOM_SPRITE2D, id, value: floatValue, commit: (v) => { this._opacity = v as number } }
94
+ if (prop === "color") return { domain: DOM_SPRITE2D, id, value: (v) => colorValue(v, Color.toRgba01), commit: (v) => { this._color = v as ColorInput } }
95
+ return super._tweenChannel(prop)
96
+ }
97
+
79
98
  get color(): ColorInput { return this._color }
80
99
  set color(c: ColorInput) { this._color = c; const [ r, g, b ] = Color.toRgb01(c); _creator2d.setSpriteColor(this.id, r, g, b) }
81
100
 
package/src/gl/Camera.ts CHANGED
@@ -3,8 +3,17 @@
3
3
 
4
4
  import { Vec3 } from "../math/vec"
5
5
  import { Aspect } from "../core/Aspect"
6
- import { Node } from "./Node"
6
+ import { Node, type NodeTweenProps } from "./Node"
7
7
  import { Ray } from "./Ray"
8
+ import { DOM_GL_CAMERA, floatValue, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
9
+ import type { Animation } from "../animate/tween/Animation"
10
+
11
+ /** Animatable camera props on top of the node transform. */
12
+ export type CameraTweenProps = NodeTweenProps & {
13
+ fov?: number | number[],
14
+ near?: number | number[],
15
+ far?: number | number[],
16
+ }
8
17
 
9
18
  /** @internal `camera.follow(node)`: copies the node's world pose onto the camera every LATE frame
10
19
  * (order 1000 — after every other aspect has moved things). One per camera; lives on the target. */
@@ -24,6 +33,24 @@ export const CAMERA_DEFAULTS = { fov: 60, near: 0.01, far: 1000 }
24
33
 
25
34
  export class Camera extends Node {
26
35
  private _sceneId = -1
36
+
37
+ /** Tween `fov` / `near` / `far` (a zoom, a dolly) and the transform — see {@link Node.animateTo}. */
38
+ override animateTo(props: CameraTweenProps & TweenMeta): Animation { return super.animateTo(props) }
39
+ override animateFrom(props: CameraTweenProps & TweenMeta): Animation { return super.animateFrom(props) }
40
+ /** @internal The projection channels address the SCENE (one camera per scene on the host). */
41
+ override _tweenChannel(prop: string): TweenChannel | null {
42
+ if (prop === "fov" || prop === "near" || prop === "far") {
43
+ const id = () => (this._sceneId < 0 ? 0 : this._sceneId + 1) // scene ids start at 0; 0 = unaddressable
44
+ const commit = (v: unknown) => {
45
+ if (prop === "fov") this._fov = v as number
46
+ else if (prop === "near") this._near = v as number
47
+ else this._far = v as number
48
+ this._projSet = true
49
+ }
50
+ return { domain: DOM_GL_CAMERA, id, value: floatValue, commit }
51
+ }
52
+ return super._tweenChannel(prop)
53
+ }
27
54
  /** Live projection matrix (updated by the host when the viewport/fov changes). */
28
55
  readonly projectionMatrix = new Float32Array(16)
29
56
 
@@ -57,6 +84,12 @@ export class Camera extends Node {
57
84
  * adaptation when the sun swings into view (drop it, and the sun stays at the display's peak
58
85
  * while everything else darkens). Scene light follows it; particle `emissive` is post-exposure
59
86
  * and does not, so a fireball keeps its on-screen brightness. Cheap to animate every frame. */
87
+ /** The camera's EV100 with the compensation in: log2(aperture² / shutterSpeed x 100 / iso) - compensation. Sunny 16 is
88
+ * ~15; a lit surface of luminance L lands on screen at L / (1.2 x 2^ev100). */
89
+ get ev100(): number {
90
+ const e = this._exposure
91
+ return Math.log2((e.aperture * e.aperture) / e.shutterSpeed * 100 / e.iso) - this._ev
92
+ }
60
93
  get exposureCompensation(): number { return this._ev }
61
94
  set exposureCompensation(stops: number) {
62
95
  this._ev = Number.isFinite(stops) ? stops : 0