lecodes-sdk 1.0.0 → 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 (78) hide show
  1. package/dist/global.d.ts +18 -4
  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/g2/Node2D.d.ts +16 -0
  8. package/dist/types/g2/Sprite.d.ts +11 -1
  9. package/dist/types/gl/Camera.d.ts +15 -1
  10. package/dist/types/gl/Foliage.d.ts +47 -0
  11. package/dist/types/gl/Geometry.d.ts +24 -0
  12. package/dist/types/gl/Light.d.ts +25 -7
  13. package/dist/types/gl/Lightmap.d.ts +90 -60
  14. package/dist/types/gl/Material.d.ts +28 -20
  15. package/dist/types/gl/Model.d.ts +7 -5
  16. package/dist/types/gl/Node.d.ts +18 -0
  17. package/dist/types/gl/Particles.d.ts +40 -1
  18. package/dist/types/gl/Scene.d.ts +20 -0
  19. package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
  20. package/dist/types/gl/animation/Animator.d.ts +27 -0
  21. package/dist/types/gl/animation/DynamicBone.d.ts +19 -8
  22. package/dist/types/gl/animation/IK.d.ts +86 -30
  23. package/dist/types/gl/animation/Warp.d.ts +2 -1
  24. package/dist/types/gl/animation/core.d.ts +35 -4
  25. package/dist/types/gl/physics/Ragdoll.d.ts +87 -12
  26. package/dist/types/gl/terrain/Terrain.d.ts +4 -2
  27. package/dist/types/inject.d.ts +8 -2
  28. package/dist/types/scene/defineScene.d.ts +44 -32
  29. package/dist/types/ui/UIButton.d.ts +3 -1
  30. package/dist/types/ui/UIInput.d.ts +5 -1
  31. package/dist/types/ui/UINode.d.ts +24 -24
  32. package/dist/types.json +1 -1
  33. package/package.json +1 -1
  34. package/prompts/core-design.md +27 -4
  35. package/prompts/core.md +35 -6
  36. package/prompts/select.ts +19 -4
  37. package/src/animate/tween/Animation.ts +378 -0
  38. package/src/animate/tween/Timeline.ts +175 -0
  39. package/src/animate/tween/animateValue.ts +100 -0
  40. package/src/animate/tween/easing.ts +172 -0
  41. package/src/animate/tween/spec.ts +479 -0
  42. package/src/bridges.d.ts +226 -65
  43. package/src/compile/__tests__/assetMacro.test.ts +26 -0
  44. package/src/compile/__tests__/detectEntry.test.ts +19 -0
  45. package/src/compile/__tests__/serverSplit.test.ts +27 -0
  46. package/src/compile/bundler.ts +34 -4
  47. package/src/compile/compileProject.ts +31 -1
  48. package/src/compile/detectEntry.ts +8 -3
  49. package/src/compile/index.ts +2 -0
  50. package/src/compile/serverSplit.ts +9 -3
  51. package/src/g2/Node2D.ts +38 -0
  52. package/src/g2/Sprite.ts +20 -1
  53. package/src/gl/Camera.ts +34 -1
  54. package/src/gl/Foliage.ts +102 -0
  55. package/src/gl/Geometry.ts +393 -348
  56. package/src/gl/Light.ts +46 -16
  57. package/src/gl/Lightmap.ts +439 -275
  58. package/src/gl/Material.ts +59 -47
  59. package/src/gl/Model.ts +167 -156
  60. package/src/gl/Node.ts +39 -0
  61. package/src/gl/Particles.ts +61 -2
  62. package/src/gl/Scene.ts +34 -1
  63. package/src/gl/animation/AnimationClip.ts +52 -0
  64. package/src/gl/animation/Animator.ts +42 -2
  65. package/src/gl/animation/DynamicBone.ts +482 -459
  66. package/src/gl/animation/IK.ts +173 -152
  67. package/src/gl/animation/Playback.ts +5 -4
  68. package/src/gl/animation/Warp.ts +5 -2
  69. package/src/gl/animation/core.ts +65 -4
  70. package/src/gl/physics/Ragdoll.ts +451 -272
  71. package/src/gl/terrain/Terrain.ts +4 -2
  72. package/src/inject.ts +12 -2
  73. package/src/scene/defineScene.ts +72 -62
  74. package/src/ui/UIButton.ts +2 -2
  75. package/src/ui/UIInput.ts +3 -3
  76. package/src/ui/UINode.ts +61 -36
  77. package/dist/types/animate/animate.d.ts +0 -20
  78. package/src/animate/animate.ts +0 -238
package/src/bridges.d.ts CHANGED
@@ -22,6 +22,11 @@ declare global {
22
22
  removeEntityFromScene(sceneId: number, entityId: number): void
23
23
  getSceneMaterial(sceneId: number): number
24
24
  warmRender(sceneId: number, onComplete: () => void): void
25
+ /** Compile every shader variant the scene's materials can need — sun / shadows / fog / skinning
26
+ * and the dynamic-light key — on the backend's compiler threads, then call back. Materials
27
+ * loaded later are queued in the background as they are created. Optional: a host without it
28
+ * resolves at once (the SDK feature-detects). */
29
+ precompileShaders?(sceneId: number, onComplete: () => void): void
25
30
 
26
31
  createEntity(): number
27
32
  cloneEntity(entityId: number): number
@@ -53,11 +58,19 @@ declare global {
53
58
  /** Punctual light. `intensity` is luminous POWER in lumens; `falloff` is the metres of
54
59
  * influence (filament's own default is 1 m, i.e. invisible, so it is always passed). */
55
60
  createPointLight?(entityId: number, intensity: number, color: number, falloff: number, castShadows: boolean): void
61
+ /** The lightmap bake takes this point light as a RECTANGLE of `width` x `height` metres (an area light: a ceiling
62
+ * panel) in the light's local XZ plane, emitting along its local -Y with the same lumens; 0, 0 = a point again. */
63
+ setLightBakeArea?(entityId: number, width: number, height: number): void
56
64
  /** Live intensity for any light (sun: lux, point: lumens) — a flash animates instead of rebuilding. */
57
65
  setLightIntensity?(entityId: number, intensity: number): void
58
66
  setLightColor?(entityId: number, color: number): void
59
67
  /** `iblFetchId` (optional, -1 = none) names the scene's own probe; without it the host looks for a project-wide `ibl.ktx`. */
60
68
  setDefaultIbl(sceneId: number, intensity: number, iblFetchId?: number): void
69
+ /** The environment's intensity in lux, live (`scene.environmentIntensity`) — `setDefaultIbl` is
70
+ * the only other way to change it and it rebuilds the cubemap and the IndirectLight from the
71
+ * ktx every call, so it can never carry a slider. No-op until the scene has an IBL. Optional:
72
+ * hosts that predate it keep whatever `setDefaultIbl` set at open. */
73
+ setEnvironmentIntensity?(sceneId: number, intensity: number): void
61
74
  setBloomOptions(sceneId: number, enabled: boolean, strength: number, quality: number): void
62
75
  setToneMapping?(sceneId: number, mode: number): void
63
76
  /** Exposure of the scene's camera — filament's physical model: `aperture` f-stops,
@@ -299,15 +312,19 @@ declare global {
299
312
  animatorSetBlend(animatorId: number, layer: number, dims: 1 | 2, slots: Uint16Array, positions: Float32Array, fade: number, speed: number, phases: Float32Array): void
300
313
  animatorSetBlendValue(animatorId: number, layer: number, x: number, y: number): void
301
314
  // Play a slot as the layer's one-shot, transitioned in over fadeIn (0 = cut) from whatever the layer
302
- // showed (playing a blend MEMBER brings the whole blend back instead). A non-looping one-shot hands the
303
- // layer back to the loop at the end, the hand-over firing fadeOut seconds BEFORE the end and the return
304
- // transition taking fadeOut; fadeOut < 0 = not given (cut back; on a loopless layer hold the last frame).
315
+ // showed (playing a blend MEMBER brings the whole blend back instead). A non-looping one-shot plays to its
316
+ // end and hands the layer back to the loop THERE, the return transition taking fadeOut from its last pose
317
+ // (0 = cut back; on a loopless layer hold the last frame).
305
318
  // restart = rewind even if already playing (a restart is a new source: it transitions from the pose shown).
306
319
  animatorPlay(animatorId: number, slot: number, loop: boolean, speed: number, fadeIn: number, fadeOut: number, restart: boolean): void
307
320
  // TURN WARP for the slot's current play (right after animatorPlay): the node's turn from the clip's baked heading is
308
321
  // scaled to `radians` total; the pose keeps its own turn, the extra pivots about the planted foot. enabled false =
309
322
  // the clip's own turn. Optional: older hosts lack it.
310
323
  animatorSlotSetTurn?(animatorId: number, slot: number, radians: number, enabled: boolean): void
324
+ // WINDOW of the slot's current play (right after animatorPlay): the one-shot runs [start, end] clip seconds — enters
325
+ // at start (at end when backwards) if the play rewound, completes + hands over at end, clip events outside never fire.
326
+ // end < 0 = the clip's end. Loops ignore it. Optional: older hosts lack it (the whole clip plays).
327
+ animatorSlotSetWindow?(animatorId: number, slot: number, start: number, end: number): void
311
328
  // Release over `fade` (a transition toward what is left — the loop, or the rest pose): one slot; a
312
329
  // layer's one-shot + loop (slot -1); everything (layer -1).
313
330
  animatorStop(animatorId: number, layer: number, slot: number, fade: number): void
@@ -440,6 +457,36 @@ declare global {
440
457
  // anchor xyz, target xyz] (world) — a debug overlay's beam under the foot. False = no such foot.
441
458
  animatorFootState?(animatorId: number, side: number, out: Float32Array): boolean
442
459
 
460
+ // ---- IK chains + sockets (creator-anim anchors.cpp: the gun-master rig's hands) ----
461
+ // The chains are solved in the engine's LATE pass — after the app's update(dt) phase, before the
462
+ // dynamic bones and the skin flush — on the joints as the app left them: a world-target chain reaches
463
+ // the point / rotation given (or its target NODES, read by the engine then), an ANCHORED chain rides
464
+ // a live socket, keeping the animated end bone's pose relative to the same socket on the take's
465
+ // donor and following the playing clips' anchor spans between sockets. Nothing per frame from the
466
+ // SDK. Optional: a host without them has inert IK (the SDK warns once).
467
+ // kind 0 two-bone (the chain = the end entity, its parent, its grandparent) / 1 look-at. 0 = the
468
+ // entity is no joint of the animator.
469
+ animatorIkCreate?(animatorId: number, kind: 0 | 1, endEntityId: number): number
470
+ animatorIkDestroy?(animatorId: number, ik: number): void
471
+ // CANIM_IK_* order: [target xyz, pole xyz, has pole, weight, rotation xyzw, rotation weight, look-at
472
+ // axis xyz, look-at limit°, enabled, anchor time s].
473
+ animatorIkSet?(animatorId: number, ik: number, params: Float32Array): void
474
+ // Nodes the engine reads every late pass (0 = none): the target's world position, the pole's, the
475
+ // rotation node's world rotation — in place of the fixed floats above.
476
+ animatorIkNodes?(animatorId: number, ik: number, targetEntity: number, poleEntity: number, rotationEntity: number): void
477
+ // Anchor the chain to the live socket ('' = a world-target chain).
478
+ animatorIkAnchor?(animatorId: number, ik: number, socket: string): void
479
+ // out ← [reach error m, anchor delta m, anchor delta rad, applied weight]. False = no such chain.
480
+ animatorIkState?(animatorId: number, ik: number, out: Float32Array): boolean
481
+ // A socket: set '' = live, else a donor's name; on joint `jointEntityId` (a bone of the animator). A
482
+ // NODE socket (nodeEntityId != 0) is that entity, read every late pass (its world → the joint's
483
+ // space); else trs = [t.xyz, r.xyzw] fixed in the joint's space. No node and null trs = remove.
484
+ animatorSetSocket?(animatorId: number, set: string, name: string, jointEntityId: number, nodeEntityId: number, trs: Float32Array | null): void
485
+ // A clip's anchor spans for one bone: the donor set, one '\n'-joined socket name per span ('' = the
486
+ // joint itself), spans = (from, to, rotation 0..1) per span in NORMALIZED time. Empty spans + donor
487
+ // '' = remove; a clip without data takes no part in the anchor blend.
488
+ setClipAnchors?(clipSetId: number, clip: number, bone: string, donor: string, sockets: string, spans: Float32Array): void
489
+
443
490
  // ---- locomotion: the movement model + the clip selector, both engine-side ----
444
491
  // The intent (`locoSetInput`) becomes a desired velocity; the simulated velocity springs toward it
445
492
  // one fixed substep at a time (`locoStep`, given the capsule's place), and `locoUpdate` — called
@@ -496,45 +543,107 @@ declare global {
496
543
 
497
544
  // JoltPhysics. motionType: 0 static / 1 kinematic / 2 dynamic.
498
545
  physicsHasSupport(): boolean
499
- /** Lightmap bake (docs/lightmap-plan.md) — a dev-time tool compiled into the desktop host only;
500
- * `lightmapBake` exists only where `lightmapHasSupport()` is true. Writes the raster (lightmap.png, an intermediate the CLI turns into lightmap.ktx2)
501
- * — one per atlas PAGE (`lightmap_<n>.png`, up to `maxPages` of `size` before the texel coarsens) —, the point lights' baked
502
- * irradiance (`lightmap-light[_<n>].png`, sRGB(E / lightScale), `lightRays` shadow rays toward a `lightRadius`-metre disc) when the
503
- * scene holds point lights, lightmap.bake (rects, pages, the baked lights) and lightmap.volume (`volumeCell` > 0)
504
- * into outDir (R = sun visibility, G = ambient occlusion) for the given static instances (GLB roots /
505
- * Mesh entities; `keysJoined` = one key per id, '
506
- '-joined). Synchronous; progress on stdout. */
546
+ /** Lightmap bake (packages/creator-bake) — a dev-time tool compiled into the desktop host only; `lightmapBake` exists
547
+ * only where `lightmapHasSupport()` is true. Describes the given static instances (GLB roots / Mesh entities;
548
+ * `keysJoined` = one key per id, '
549
+ '-joined), the scene's brightest sun, its point lights (spherical emitters of
550
+ * `lightRadius` metres) and its IBL to the bake, which traces them on the NVIDIA card — direct light plus
551
+ * `bounceRays` Lambertian paths of `bounces` vertices per texel (0 / 0 = direct only) with `patchSamples` next-event
552
+ * samples over the bright patches of a first direct pass — and writes
553
+ * `<outDir>/<stem>.bake` + `<stem>-light[_n].ktx2` (irradiance in lux / lightScale, BC6H) + `<stem>-aux[_n].ktx2`
554
+ * (sun / sky visibility + light direction); `split` adds `<stem>-direct[_n]` / `-indirect[_n]` page sets for the
555
+ * SDK's two views; `denoise` runs OptiX's AI denoiser over 0 nothing, 1 the indirect part, 2 + the sky and the lamps, 3 + the sun; `filter` = the texel's reconstruction filter radius in texels (1 = ±1 texel tent, 0.5 = within the texel, 0 = the centre alone);
556
+ * `probeSpacing` > 0 places REFLECTION PROBES on a grid of that many metres and writes `<stem>-probes.ktx2` (a BC6H atlas of octahedral
557
+ * maps: one column of roughness-level tiles per probe, captured with `probeSize`² cube faces and `probeRays` paths per texel; the scene's
558
+ * IBL cubemap is what an escaping ray sees; `probeIndoor` keeps only the probes under a roof (an open share of the sphere of at most 15 %) — the
559
+ * outdoors reflects the sky through the texel's baked sky visibility instead; `volumeSpacing` (metres, 0 = none) bakes THE LIGHT GRID for movers — `<name>.lgrid`, named in the .bake's `volume`: per cell
560
+ * in free space an ambient cube without the direct sun, links between neighbours a ray connects (what `setAmbientCube`
561
+ * is fed from); `minSize` (metres, 0 = off) leaves every instance smaller than that along every axis out of the atlas — an occluder
562
+ * only, `"small": true` + its `light` (an ambient cube baked where it stands) in the .bake, applied by lightmapObjectApply; `probeLayout` 0 = ROOMS (indoor points slide to their
563
+ * room's middle and merge: one probe per room), 1 = GRID; `probeBox` = 6 floats, min xyz + max xyz, keeps the grid inside that box — null = the
564
+ * receivers' bounds) and the grid into the .bake; `debugDir` (optional) gets the float layers per page. Synchronous; progress on stdout as
565
+ * "[lightmap] …" lines, "[lightmap] done" on success. */
507
566
  lightmapHasSupport?(): boolean
508
- /** Lightmap consumption (docs/lightmap-plan.md §3): the next createGlb takes lightmap.filamat (the material
509
- * behind that instance id) instead of the ubershader and keeps TEXCOORD_1; UINT32_MAX clears. */
510
- /** `maskedMaterialInstanceId` (2026-09-11): the tier's masked twin for the asset's alpha-MASK materials
511
- * (foliage cards); a host without it ignores the argument and keeps those on the ubershader. */
567
+ lightmapBake?(outDir: string, stem: string, entityIds: Uint32Array, keysJoined: string, size: number, texel: number, pages: number,
568
+ sunRays: number, skyRays: number, lightRays: number, lightRadius: number, sunAngleDeg: number, bias: number, seed: number,
569
+ bounceRays: number, bounces: number, patchSamples: number, split: boolean, denoise: number, filter: number,
570
+ probeSpacing: number, probeSize: number, probeRays: number, probeIndoor: boolean, probeLayout: number, probeBox: Float32Array | null, minSize: number, volumeSpacing: number, emissiveNits: number, detailTexels: number, debugDir?: string,
571
+ transmit?: string): boolean
572
+ /** Lightmap consumption: the next createGlb takes lightmap.filamat (the material behind that instance id) for its
573
+ * OPAQUE materials and the masked twin for its MASK materials instead of the ubershader, and keeps TEXCOORD_1;
574
+ * BLEND (glass) materials always keep the ubershader. 0xFFFFFFFF clears. The same call arms the foliage tier. */
512
575
  setNextGlbLightmapped?(materialInstanceId: number, maskedMaterialInstanceId?: number): void
513
- /** The level-wide knobs every lightmap-material instance shares (applied to the ones that exist and to every one
514
- * created afterwards): the sun-mask shadow math (ambientScale, sunStrength), how much baked AO applies (aoStrength,
515
- * 1 = all) and the light atlas' / volume's physical irradiance per encoded 1.0 (lightScale; 0 = no baked lights).
516
- * volumeLightScale = the light VOLUME's own scale (movers); omitted or < 0 = same as lightScale. lightGamma = the light
517
- * store's curve, texel = sRGB(pow(E / lightScale, 1 / lightGamma)) — 1 (default) for the linear v2 store, 2 for the
518
- * square-root store bakes write since 2026-08-31 (the .bake's `lightGamma`). */
519
- lightmapSetOptions?(ambientScale: number, sunStrength: number, aoStrength: number, lightScale: number, volumeLightScale?: number, lightGamma?: number): void
520
- /** Bind the atlas page + this instance's rect (uv1 * [sx, sy] + [ox, oy]) and, unless 0xFFFFFFFF, the matching page of
521
- * the light atlas; the sun / IBL terms come from filament's per-frame uniforms inside the shader. Returns how many
576
+ /** The level-wide scale every lightmap-material instance shares (present and future): lux per encoded 1.0 of the
577
+ * light atlas — the .bake's `lightScale` times whatever multiplier a debug knob wants. `emissiveNits` = the bake's
578
+ * radiance of emission 1.0 (the .bake's `emissiveNits`): over 0 the statics' glow is drawn scene-referred with it -
579
+ * a panel looks as bright as the light it gives; 0 = screen-referred. */
580
+ lightmapSetOptions?(lightScale: number, emissiveNits?: number): void
581
+ /** A MOVER'S SUN SHADOW ON THE BAKED STATICS: (dx, dy, dz) = TO the sun, (r, g, b) = its illuminance in lux, `on` =
582
+ * the level's aux pages end in the bake's SUN MAP (the .bake's `sunMap`; never true without it). The lightmap
583
+ * material takes the sun's part out of the baked light where the real-time shadow map says "in shadow". */
584
+ lightmapSunSet?(dx: number, dy: number, dz: number, r: number, g: number, b: number, on: boolean, levels?: number): void
585
+ /** The lightmap material's DATA views, level-wide: 0 = the picture, 1 lighting only (a white diffuse material under the camera's exposure), 2 albedo, 3 geometric normal,
586
+ * 4 shading normal, 5 sun visibility, 6 sky visibility, 7 the atlas texel checker (tinted per instance), 8 light
587
+ * direction, 9 false-colour log10 lux over [p1, p2], 10 relief (E on the shading normal / E), 13 what the specular
588
+ * reflects (the probe along the reflection, or the sky + baked light without one), 14 the probe map (grey = the probes' share of the reflection, tinted by the texel's pair), 15 the sky's path of the reflection alone, 16 the probes' path alone. */
589
+ lightmapDebug?(mode: number, p0?: number, p1?: number, p2?: number, p3?: number): void
590
+ /** The level's reflection probes, level-wide (every lightmap-material instance, present and future): the octahedral
591
+ * atlas as a loaded texture (`<stem>-probes.ktx2`, `Texture.load` like a page), its layout (`oct` = level 0's tile edge,
592
+ * `levels`, `perRow`), the probe count and the range the probe maps' records decode with (the .bake's `probes.encode`:
593
+ * lo xyz, size xyz). WHICH probes a pixel reflects is baked into the pages: every aux page carries its probe map
594
+ * under the page (per 4 x 4 block of texels the two probes those texels SEE — rays at bake time — and their weight).
595
+ * Returns the probe count; an invalid texture id clears. */
596
+ lightmapProbesSet?(atlasTextureId: number, oct: number, levels: number, perRow: number, count: number,
597
+ lox: number, loy: number, loz: number, sx: number, sy: number, sz: number): number
598
+ /** Bind the light page + the aux page + this instance's rect (uv1 * [sx, sy] + [ox, oy]) on every lightmap-material
599
+ * instance of the entity (a GLB instance's renderables, or a Mesh entity's own) and move those renderables to light
600
+ * channel 1 alone — a baked static takes nothing from the real-time sun and lamps (channel 0). Returns how many
522
601
  * material instances took it (0 = not loaded lightmapped). */
523
- lightmapApply?(entityId: number, textureId: number, sx: number, sy: number, ox: number, oy: number, lightTextureId: number): number
602
+ lightmapApply?(entityId: number, lightTextureId: number, auxTextureId: number, sx: number, sy: number, ox: number, oy: number, group?: number): number
603
+ /** A SMALL STATIC the atlas left out (the bake's `minSize`: `small` + `light` in the .bake): no rect — the light
604
+ * baked where it stands goes into its material parameters. `data` = 27 floats: the ambient cube — per side of its
605
+ * bounds (+x -x +y -y +z -z) the irradiance rgb in lux and the sky visibility — then probe a, probe b, a's weight
606
+ * (65535 = no probe, 65534 = the sky). The textures = any page of the level (the probes' records sit under every
607
+ * aux page). Returns the material instances that took it (0 = not loaded lightmapped). */
608
+ lightmapObjectApply?(entityId: number, lightTextureId: number, auxTextureId: number, data: Float32Array, group?: number): number
609
+ /** BAKED AMBIENT LIGHT for a mover, or anything on a standard or custom LIT shader: every renderable of the GLB
610
+ * instance (or the Mesh entity) takes its diffuse indirect light from `data`'s ambient cube instead of the scene's
611
+ * IBL, and the IBL's reflections through the sky visibility. `data` = 25 or 26 floats: six sides (+x -x +y -y +z -z) of
612
+ * irradiance rgb in lux + one unused float each, the sky visibility (0 a room .. 1 the open sky), then optionally the
613
+ * baked SUN visibility (1 when absent): the real-time directional light on it is multiplied by it — the baked
614
+ * statics' shadow, they cast none in real time while a light grid is loaded. Lamps stay as they are. Cheap per frame. `null` = back to the IBL. Returns the renderables touched. */
615
+ setAmbientCube?(entityId: number, data: Float32Array | null): number
616
+ /** THE LIGHT GRID for movers (`lecodes lightmap bake` writes `<name>.lgrid`, the .bake's `volume` names it):
617
+ * `lightVolumeLoad(fetchId)` hands the engine the fetched file (false = not a grid); `lightVolumeTrack(entityId, on)`
618
+ * marks a GLB instance root or a Mesh entity as a MOVER — every frame the engine gives it the ambient cube of the
619
+ * place it is at (`setAmbientCube`), blended from the reachable cells around it and eased over ~0.12 s;
620
+ * `fixed` = a STATIC the atlas left out (glass, a material the lightmap shader declines): sampled once a grid, and
621
+ * ignored when every primitive of it is baked;
622
+ * `lightVolumeSample(x, y, z)` = that cube at a point (26 floats, null = no grid or no valid cell near), for tools;
623
+ * `lightVolumeClear()` drops the grid and every mover's cube. */
624
+ lightVolumeLoad?(fetchId: number): boolean
625
+ lightVolumeTrack?(entityId: number, on: boolean, fixed?: boolean): void
626
+ lightVolumeClear?(): void
627
+ lightVolumeSample?(x: number, y: number, z: number): Float32Array | null
628
+ /** A light's channels (Filament's, 0..7). Every light is on channel 0 at creation; one that must reach BAKED statics
629
+ * too (an unbaked lamp, a muzzle flash) is added to channel 1. */
630
+ setLightChannel?(entityId: number, channel: number, enable: boolean): void
524
631
  /** Shadow flags on every renderable of a GLB instance (Mesh has setCastShadows/setReceiveShadows). */
525
632
  setGlbShadows?(entityId: number, cast: boolean, receive: boolean): void
526
- lightmapBake?(outDir: string, entityIds: Uint32Array, keysJoined: string, size: number, texel: number, sunRays: number, aoRays: number, aoDistance: number, bias: number, sunAngleDeg: number, volumeCell: number, maxPages: number, lightRays: number, lightRadius: number, bounceAlbedo?: number, bounceDistance?: number, bounceRays?: number, coplanarSkip?: number, denoise?: number): boolean
527
- /** Light VOLUME for dynamic objects (docs/lightmap-plan.md §9). `lightmapVolumeLoad` turns a fetched
528
- * lightmap.volume (`fetchSystemId` from `_creatorUtils.fetch`) into RGBA8 3D textures and returns its
529
- * header as JSON — `{"texture":id,"dims":[nx,ny,nz],"min":[x,y,z],"cell":m,"light"?:id,"faces"?:n}` (`light` = the
530
- * point lights' block: a version-3 volume's six ambient-cube faces (+X −X +Y −Y +Z −Z, `faces` 6), a version-2
531
- * volume's one directionless value (`faces` 1) — uploaded either way as six face slabs stacked along z, so the
532
- * light texture's depth is nz × 6) — or "" on failure. */
533
- lightmapVolumeLoad?(fetchSystemId: number): string
534
- /** Make those textures THE volume (`textureId` 0xFFFFFFFF clears; `lightTextureId` 0xFFFFFFFF = no baked lights):
535
- * every lightmap-material instance without an atlas rect — loaded already or later — samples it at the pixel's
536
- * world position (+ half a `cell` along the normal). `size` = dims × cell. Returns how many instances switched. */
537
- lightmapVolumeSet?(textureId: number, lightTextureId: number, minX: number, minY: number, minZ: number, sizeX: number, sizeY: number, sizeZ: number, cell: number): number
633
+ /** Foliage — the vegetation tier (`foliage.filamat` / `foliage-masked.filamat`, armed per model
634
+ * through setNextGlbLightmapped; the engine knows the tier by its `benders` parameter). Level-wide wind: direction on
635
+ * the ground (normalised by the host), strength = metres of sway at bendHeight, speed rad/s, gust 0..1. Applies to
636
+ * every foliage instance, present and future. */
637
+ foliageSetWind?(dirX: number, dirZ: number, strength: number, speed: number, gust?: number): void
638
+ /** Level-wide sway options: bendHeight = metres above the instance origin where the full sway / touch applies,
639
+ * touchStrength = metres a bender pushes, variation 0..1 = per-copy tint, sunWrap 0..1 = wrapped Lambert. */
640
+ foliageSetOptions?(bendHeight: number, touchStrength: number, variation: number, sunWrap: number): void
641
+ /** An entity whose live world position bends the vegetation within `radius` metres; <= 0 removes it. The host
642
+ * writes the 8 benders nearest the camera to the shader each frame. */
643
+ foliageSetBender?(entityId: number, radius: number): void
644
+ /** Distance fade for every instance of the entity's ASSET (present and future): the cards thin out between `start`
645
+ * and `end` metres from the camera and past `end` the LOD pass takes the instance out of the scene. end 0 = none. */
646
+ foliageSetFade?(entityId: number, start: number, end: number): void
538
647
  physicsConfigure(gx: number, gy: number, gz: number, maxBodies: number): void
539
648
  setInterpolation(enabled: boolean): void
540
649
  // Shape/Physics/Trigger aspects: build a shape once, create bodies from it.
@@ -699,25 +808,43 @@ declare global {
699
808
  vehicleBodyId(vehicleId: number): number
700
809
  // Ragdoll (Jolt Ragdoll): a Model's skeleton handed to physics — one dynamic body per listed bone
701
810
  // (a capsule from the bone's origin to the next joint) joined to its parent part by a swing-twist
702
- // constraint. Built ONCE from the pose the bones are in (that pose is the joints' neutral for the
703
- // limits) and kept out of the world until ragdollActivate, which takes the bones' CURRENT pose,
704
- // adds the bodies and gives every body one velocity; from then on the engine writes the bodies'
705
- // poses onto the bone entities every frame AFTER the animator (bones not listed keep their
706
- // animated local pose under the ragdolled parents — fingers, toes, a spine bone between two
707
- // parts), until ragdollDeactivate. Every body carries its bone entity as user data, so
708
- // physicsRaycast / contact events name the bone. Optional: a host without it has no ragdolls.
811
+ // constraint that carries a swing + twist MOTOR. Built ONCE from the pose the bones are in (that
812
+ // pose is the joints' neutral for the limits) and kept out of the world until ragdollActivate,
813
+ // which takes the bones' CURRENT pose and adds the bodies moving as the engine measured each bone
814
+ // over the last two frames (a swinging arm keeps swinging) plus a launch; from then on the engine
815
+ // writes the bodies' poses onto the bone entities every frame AFTER the animator, until
816
+ // ragdollDeactivate. POWERED (ragdollSetDrive): the motors pull every joint toward the relative
817
+ // rotation the ANIMATED pose shows (the engine hands the animator's pose to physics every frame,
818
+ // right after the animator wrote it and before the bodies overwrite it), and the root can be
819
+ // ANCHORED — the hips body kinematic on the animated hips — so a standing character stays in its
820
+ // animation while a hit jolts a limb and the motors bring it back; strength 0 with no anchor =
821
+ // limp. The skin joints under the hips that carry no part (the spine bones between two parts, the
822
+ // neck, the clavicles, fingers, toes) follow the animation while driven and HOLD their pose as the
823
+ // strength goes to 0 — a limp body never breathes with the loop playing underneath. Deactivating
824
+ // with a blend seeds the model's animator with the fallen pose, so whatever plays next
825
+ // transitions out of it (canimAnimatorSeedPose). Every body carries its bone entity as user
826
+ // data, so physicsRaycast / contact events name the bone. Optional: a host without it has no
827
+ // ragdolls.
709
828
  // bones[partCount * 2]: bone entity, `to` entity (0 = a leaf: `length` along the parent's line;
710
829
  // a leaf ROOT points along the model's up)
711
- // settings — header[12]: version(2), partCount, stride(12), friction, linearDamping,
830
+ // settings — header[16]: version(3), partCount, stride(12), friction, linearDamping,
712
831
  // angularDamping (0 = Jolt's own), collide (0 = everything, 1 = the STATIC world and
713
832
  // other such ragdolls only: dynamic bodies, character controllers and vehicles pass
714
- // through — Jolt's DEBRIS object/broadphase layer), freeze (1 = once every part is
715
- // asleep the bodies turn STATIC where they lie: the last pose keeps being written
716
- // onto the bones, raycasts still hit, nothing wakes them, the solver skips them;
717
- // ragdollActive reads false, ragdollDeactivate / ragdollActivate still work —
718
- // activate makes the parts dynamic again), freezeAfter (seconds in the world after which the freeze happens
719
- // regardless; 0 = no cap), reserved x3. A version-1 blob (header[8], stops after
720
- // angularDamping) is still accepted = collide everything, never freeze.
833
+ // through — Jolt's DEBRIS object/broadphase layer), freeze (1 = once every part of a
834
+ // LIMP body is asleep the bodies turn STATIC where they lie: the last pose keeps being
835
+ // written onto the bones, raycasts still hit, nothing wakes them, the solver skips
836
+ // them; ragdollActive reads false, ragdollDeactivate / ragdollActivate still work —
837
+ // activate makes the parts dynamic again), freezeAfter (seconds LIMP after which the
838
+ // freeze happens regardless; 0 = no cap), driveFrequency (Hz: the motors' position
839
+ // spring), driveDamping (ratio, 1 = critical), driveTorque (N·m per kg of the part:
840
+ // the motors' limit at strength 1), jointFriction (N·m per kg of the part: a torque
841
+ // that resists any joint motion, motor or not), limits (0 = the part cones below;
842
+ // 1 = LEARNED: at the first activation that finds clips bound to the model's
843
+ // animator the engine measures, per joint, how far the clips swing and twist it in
844
+ // its constraint frame and rebuilds the joints to those ranges — the motors never
845
+ // target a pose the limits forbid; no clips yet = the cones, measured at a later
846
+ // activation), limitsMargin (degrees added on each side of a learned range),
847
+ // reserved x1
721
848
  // + partCount * 12: parentIndex (-1 = the root; always < the part's own index), radius, length
722
849
  // (0 = up to the `to` bone), mass (kg; 0 = from the volume), swingDeg (cone
723
850
  // half-angle), twistDeg (half-angle), hinge (0/1), hingeAxisX/Y/Z (in the MODEL
@@ -725,15 +852,23 @@ declare global {
725
852
  // flat (2°) across the axis and [min, max] about it (a knee, an elbow)
726
853
  ragdollCreate?(rootEntityId: number, bones: Uint32Array, settings: Float32Array): number
727
854
  ragdollDestroy?(ragdollId: number): void
728
- /** Pose the bodies from the bones' current transforms, add them to the world, set every body's
729
- * linear velocity. Already active = re-wake + velocity. false = unknown id / no world. */
855
+ /** Pose the bodies from the bones' current transforms and add them to the world, each moving as
856
+ * its bone was measured over the last two frames, plus the launch (vx, vy, vz) on every body.
857
+ * Already active = re-wake + the launch. false = unknown id / no world. */
730
858
  ragdollActivate?(ragdollId: number, vx: number, vy: number, vz: number): boolean
731
- /** Take the bodies out of the world; the animator owns the bones again from the next frame. */
732
- ragdollDeactivate?(ragdollId: number): void
859
+ /** Take the bodies out of the world. The bones get the bodies' last pose written against the
860
+ * parents' CURRENT worlds (move the model node under the hips first), and with `blend` > 0 the
861
+ * model's animator is seeded with it: whatever plays next transitions from the fallen pose over
862
+ * `blend` seconds. */
863
+ ragdollDeactivate?(ragdollId: number, blend: number): void
733
864
  /** In the world AND at least one body still awake (a settled ragdoll reads false). */
734
865
  ragdollActive?(ragdollId: number): boolean
735
866
  /** The rigid body of part `index` (for physicsApplyImpulseAt / velocities). 0 if unknown. */
736
867
  ragdollBodyId?(ragdollId: number, index: number): number
868
+ /** The joint motors' strength (0..1 = the share of driveTorque; 0 = off = limp) and the root
869
+ * anchor (the hips body kinematic on the animated hips). Kept across activations; a driven or
870
+ * anchored body never freezes. */
871
+ ragdollSetDrive?(ragdollId: number, strength: number, anchor: boolean): void
737
872
  // Dynamic bones (the SDK's DynamicBone; creator-anim canimDyn* behind creator-gl): secondary motion
738
873
  // for tails, ears, hair and cloaks — a bone tree under one root simulated as Verlet particle chains
739
874
  // (gravity, wind, drag, stiffness toward the animated shape, an angle cone, capsule / sphere
@@ -786,8 +921,9 @@ declare global {
786
921
  dynamicBoneTargets?(id: number, rows: Float32Array | null): void
787
922
  /** A capsule a → b in the entity's local space (a == b = a sphere) that rides the entity — a bone
788
923
  * of the body the chains must not pass through. Returns a collider id (0 on failure). */
789
- dynamicBoneColliderCreate?(entityId: number, ax: number, ay: number, az: number, bx: number, by: number, bz: number, radius: number): number
790
- dynamicBoneColliderSet?(colliderId: number, ax: number, ay: number, az: number, bx: number, by: number, bz: number, radius: number): void
924
+ /** `sided` 1 = one-sided: while the capsule moves away from the chain's `side` it puts a bone it has run into over to that side instead of carrying it (an arm under a cape); 0 = pushes to the nearest surface. */
925
+ dynamicBoneColliderCreate?(entityId: number, ax: number, ay: number, az: number, bx: number, by: number, bz: number, radius: number, sided: number): number
926
+ dynamicBoneColliderSet?(colliderId: number, ax: number, ay: number, az: number, bx: number, by: number, bz: number, radius: number, sided: number): void
791
927
  dynamicBoneColliderDestroy?(colliderId: number): void
792
928
  // Legacy coupled shape+body (still used by the worker RigidBody).
793
929
  physicsCreateBox(entityId: number, hx: number, hy: number, hz: number, motionType: number, mass: number): number
@@ -1266,10 +1402,40 @@ declare global {
1266
1402
  removeNode(nodeId: number, childNode: any): void
1267
1403
  setContent(nodeId: number, oldChildren: any[], children: any[]): void
1268
1404
 
1269
- animateTo(nodeId: number, style: any): void
1270
- animateFrom(nodeId: number, style: any): void
1405
+ // Legacy one-shot style tweens, implemented by the hosts that animate JS-side (web, Apple) and
1406
+ // ABSENT on creator-pkg hosts since 2026-09-14 (they have the tween core below; the SDK's fallback
1407
+ // only calls these where tweenCreate is missing): from the current values to `style` (animateTo)
1408
+ // / from `style` to the current values (animateFrom), meta keys duration / delay / loop /
1409
+ // loopMode / commit / layer in the bag.
1410
+ animateTo?(nodeId: number, style: any): void
1411
+ animateFrom?(nodeId: number, style: any): void
1271
1412
  toast(msg: string): void
1272
1413
 
1414
+ // ---- Keyframe animation core (docs/timeline-plan.md; creator-tween) -------------------------
1415
+ // ONE evaluator for UI style props, 3D node transforms / lights / cameras and 2D sprites: the SDK
1416
+ // hands over a flat blob (layout in sdk/src/animate/tween/spec.ts — header, time-callbacks,
1417
+ // easing tables, tracks of keyframes; string-valued lanes index `strings`, a track's target lane
1418
+ // indexes `targets`: the element's `_id` handle for the UI domain, an entity / scene id for the
1419
+ // 3D and 2D domains) and gets an id.
1420
+ // Created = paused at t = 0, nothing applied. Ops: 0 play (from t = 0, a running one restarts),
1421
+ // 1 pause, 2 resume, 3 cancel (drop, values stay where they are, no commit), 4 finish (jump to
1422
+ // the end, commit, report finish). The host reports through the registered callback:
1423
+ // kind 0 = call (index = the blob's call slot, fired when playback crosses its time, in time
1424
+ // order, before finish), kind 1 = finish (the end was reached, one-shot only), kind 2 = a VALUE
1425
+ // track's frame (index = the track's target slot — unique per world, then the lanes as plain number arguments —
1426
+ // delivered synchronously after the evaluator's pass, never from inside it). Seek is in TOTAL
1427
+ // ms (iterations included); tweenGetTime reads the position inside the current iteration,
1428
+ // tweenGetElapsed the total. Clock 0 = host wall time (ui), 1 = game time (Time.scale, pause).
1429
+ // Optional: the SDK feature-detects tweenCreate and falls back to animateTo / animateFrom
1430
+ // (UI tracks, single values, timer clock) on hosts without it.
1431
+ tweenCreate?(data: Float32Array, strings: string[], targets: any[]): number
1432
+ tweenControl?(id: number, op: number): void
1433
+ tweenSeek?(id: number, ms: number): void
1434
+ tweenSetRate?(id: number, rate: number): void
1435
+ tweenGetTime?(id: number): number
1436
+ tweenGetElapsed?(id: number): number
1437
+ registerTweenEvent?(callback: (id: number, kind: number, index: number) => void): void
1438
+
1273
1439
  // Router over destinations. On hosts that implement openView (below), `screen`/`view` is any
1274
1440
  // view descriptor (not just a screen object) and the extra `transition` args apply; legacy
1275
1441
  // hosts receive screen objects only and ignore the extras.
@@ -1444,11 +1610,6 @@ declare global {
1444
1610
  * exit programmatically, iOS/desktop, register nothing and the call is a no-op). */
1445
1611
  quit?(): void
1446
1612
 
1447
- animate(callback: (val: number) => void, duration: number): number
1448
- stopAnimation(id: number): void
1449
- pauseAnimation(id: number): void
1450
- resumeAnimation(id: number): void
1451
-
1452
1613
  localStorageGetValue(key: string): string | null
1453
1614
  localStorageSetValue(key: string, value: string): void
1454
1615
  localStorageRemoveValue(key: string): void
@@ -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')`. */",
@@ -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