lecodes-sdk 0.20.0 → 1.0.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 (114) hide show
  1. package/dist/global.d.ts +48 -5
  2. package/dist/inject.js +361 -260
  3. package/dist/types/audio/Bus.d.ts +45 -0
  4. package/dist/types/audio/Sound.d.ts +28 -0
  5. package/dist/types/audio/Voice.d.ts +27 -0
  6. package/dist/types/audio/audio.d.ts +83 -0
  7. package/dist/types/audio/support.d.ts +1 -0
  8. package/dist/types/canvas/Canvas.d.ts +2 -0
  9. package/dist/types/gl/DecalSet.d.ts +148 -0
  10. package/dist/types/gl/Geometry.d.ts +17 -0
  11. package/dist/types/gl/Light.d.ts +7 -0
  12. package/dist/types/gl/Lightmap.d.ts +9 -0
  13. package/dist/types/gl/Material.d.ts +90 -2
  14. package/dist/types/gl/Mesh.d.ts +18 -1
  15. package/dist/types/gl/Model.d.ts +34 -0
  16. package/dist/types/gl/Particles.d.ts +13 -0
  17. package/dist/types/gl/Scene.d.ts +23 -0
  18. package/dist/types/gl/Texture.d.ts +29 -1
  19. package/dist/types/gl/animation/AnimationClip.d.ts +25 -12
  20. package/dist/types/gl/animation/DynamicBone.d.ts +173 -0
  21. package/dist/types/gl/{IK.d.ts → animation/IK.d.ts} +4 -4
  22. package/dist/types/gl/{Locomotion.d.ts → animation/Locomotion.d.ts} +6 -6
  23. package/dist/types/gl/animation/core.d.ts +15 -10
  24. package/dist/types/gl/audio/AudioSource.d.ts +60 -0
  25. package/dist/types/gl/audio/AudioZone.d.ts +32 -0
  26. package/dist/types/gl/audio/SceneAudio.d.ts +11 -0
  27. package/dist/types/gl/{NavAgent.d.ts → nav/NavAgent.d.ts} +4 -4
  28. package/dist/types/gl/{NavMesh.d.ts → nav/NavMesh.d.ts} +4 -4
  29. package/dist/types/gl/{CharacterController.d.ts → physics/CharacterController.d.ts} +5 -5
  30. package/dist/types/gl/{Physics.d.ts → physics/Physics.d.ts} +6 -5
  31. package/dist/types/gl/{Ragdoll.d.ts → physics/Ragdoll.d.ts} +4 -4
  32. package/dist/types/gl/{Shape.d.ts → physics/Shape.d.ts} +3 -3
  33. package/dist/types/gl/{Trigger.d.ts → physics/Trigger.d.ts} +2 -2
  34. package/dist/types/gl/state.d.ts +0 -1
  35. package/dist/types/gl/{Terrain.d.ts → terrain/Terrain.d.ts} +6 -6
  36. package/dist/types/gl/{terrainMesh.d.ts → terrain/terrainMesh.d.ts} +1 -1
  37. package/dist/types/gl/vehicle/Vehicle.d.ts +300 -0
  38. package/dist/types/gl/vehicle/Wheel.d.ts +147 -0
  39. package/dist/types/inject.d.ts +33 -21
  40. package/dist/types/runtime/files.d.ts +24 -1
  41. package/dist/types/runtime/input.d.ts +11 -0
  42. package/dist/types/scene/defineScene.d.ts +10 -3
  43. package/dist/types/ui/UIImage.d.ts +15 -5
  44. package/dist/types.json +1 -1
  45. package/package.json +1 -1
  46. package/prompts/README.md +142 -142
  47. package/prompts/dist/2d-game.md +408 -197
  48. package/prompts/dist/3d-app.md +491 -166
  49. package/prompts/dist/ar-app.md +373 -163
  50. package/prompts/dist/design.md +83 -87
  51. package/prompts/dist/ui-app.md +325 -136
  52. package/src/audio/Bus.ts +102 -0
  53. package/src/audio/Sound.ts +96 -0
  54. package/src/audio/Voice.ts +102 -0
  55. package/src/audio/audio.ts +161 -0
  56. package/src/audio/support.ts +6 -0
  57. package/src/bridges.d.ts +279 -32
  58. package/src/canvas/Canvas.ts +21 -0
  59. package/src/compile/__tests__/compile.test.ts +11 -0
  60. package/src/compile/compileProject.ts +30 -15
  61. package/src/compile/header.ts +6 -3
  62. package/src/compile/index.ts +4 -0
  63. package/src/compile/sceneEditor.ts +42 -1
  64. package/src/core/Aspect.ts +33 -8
  65. package/src/g2/Scene2D.ts +7 -0
  66. package/src/gl/CameraPlace.ts +52 -52
  67. package/src/gl/DecalSet.ts +360 -0
  68. package/src/gl/Geometry.ts +348 -279
  69. package/src/gl/Light.ts +16 -0
  70. package/src/gl/Lightmap.ts +35 -7
  71. package/src/gl/Material.ts +173 -4
  72. package/src/gl/Mesh.ts +120 -83
  73. package/src/gl/Model.ts +33 -1
  74. package/src/gl/Node.ts +1 -1
  75. package/src/gl/Particles.ts +21 -3
  76. package/src/gl/Scene.ts +41 -7
  77. package/src/gl/Texture.ts +43 -3
  78. package/src/gl/animation/AnimationClip.ts +43 -20
  79. package/src/gl/animation/Animator.ts +4 -3
  80. package/src/gl/animation/DynamicBone.ts +459 -0
  81. package/src/gl/{IK.ts → animation/IK.ts} +4 -4
  82. package/src/gl/{Locomotion.ts → animation/Locomotion.ts} +7 -7
  83. package/src/gl/animation/core.ts +20 -15
  84. package/src/gl/audio/AudioSource.ts +113 -0
  85. package/src/gl/audio/AudioZone.ts +75 -0
  86. package/src/gl/audio/SceneAudio.ts +26 -0
  87. package/src/gl/{NavAgent.ts → nav/NavAgent.ts} +5 -5
  88. package/src/gl/{NavMesh.ts → nav/NavMesh.ts} +8 -8
  89. package/src/gl/{CharacterController.ts → physics/CharacterController.ts} +5 -5
  90. package/src/gl/{Physics.ts → physics/Physics.ts} +12 -5
  91. package/src/gl/{Ragdoll.ts → physics/Ragdoll.ts} +272 -270
  92. package/src/gl/{Shape.ts → physics/Shape.ts} +3 -3
  93. package/src/gl/{Trigger.ts → physics/Trigger.ts} +2 -2
  94. package/src/gl/{physicsEvents.ts → physics/physicsEvents.ts} +1 -1
  95. package/src/gl/scenarios.ts +291 -291
  96. package/src/gl/state.ts +1 -1
  97. package/src/gl/{Terrain.ts → terrain/Terrain.ts} +10 -10
  98. package/src/gl/{terrainMesh.ts → terrain/terrainMesh.ts} +1 -1
  99. package/src/gl/vehicle/Vehicle.ts +666 -0
  100. package/src/gl/vehicle/Wheel.ts +290 -0
  101. package/src/inject.ts +226 -212
  102. package/src/runtime/files.ts +32 -2
  103. package/src/runtime/input.ts +6 -1
  104. package/src/scene/defineScene.ts +26 -10
  105. package/src/scene/gizmos.ts +148 -148
  106. package/src/scene/level.ts +2 -2
  107. package/src/ui/UIImage.ts +21 -7
  108. package/dist/types/gl/Gearbox.d.ts +0 -86
  109. package/dist/types/gl/Vehicle.d.ts +0 -191
  110. package/dist/types/gl/Wheel.d.ts +0 -95
  111. package/src/gl/Gearbox.ts +0 -212
  112. package/src/gl/Vehicle.ts +0 -473
  113. package/src/gl/Wheel.ts +0 -240
  114. /package/dist/types/gl/{physicsEvents.d.ts → physics/physicsEvents.d.ts} +0 -0
package/src/bridges.d.ts CHANGED
@@ -106,6 +106,8 @@ declare global {
106
106
  * flat skybox. */
107
107
  setSkyboxTexture?(sceneId: number, fetchId: number): void
108
108
  setSceneMultiSampleAntiAliasing(sceneId: number, enabled: boolean, scale: number): void
109
+ /** Filament View::setStencilBufferEnabled (Scene.stencil). Optional. */
110
+ setSceneStencil?(sceneId: number, enabled: boolean): void
109
111
  /** Render resolution: `renderScale` (0.25–1) = fixed 3D-buffer scale vs the viewport (the UI is
110
112
  * untouched; a host that owns the 3D texture resizes it, one rendering into the swapchain may
111
113
  * ignore it), `dynamicResolution` = engine-adaptive scaling under that down to `minScale`.
@@ -118,6 +120,23 @@ declare global {
118
120
  * anisotropy` does that automatically (a scene's env runs before its nodes build). Optional:
119
121
  * hosts that predate it keep isotropic filtering and the SDK skips the call. */
120
122
  setTextureAnisotropy?(level: number): void
123
+ /** Engine-wide cap on texture size (`Texture.maxSize` / `SceneOptions.maxTextureSize`): a
124
+ * KTX2 wider or taller than `size` loses its top mip levels on load, a glTF PNG/JPEG is
125
+ * downsampled; 0 = no cap. Reaches textures created AFTER the call — a loaded level keeps
126
+ * its textures, so a settings menu applies it on the next level load. `createTexture` flag
127
+ * 2 (FULL_SIZE) exempts one texture (lightmap pages). Optional: hosts that predate it load
128
+ * full-size textures and the SDK skips the call. */
129
+ setTextureMaxSize?(size: number): void
130
+ /** Depth-reading effects on / off (`scene.setDepthEffects`): soft particles (`depthFade`) and
131
+ * projected decals read the scene depth, which costs a half-res depth pre-pass of every opaque
132
+ * draw. Off = no pre-pass, hard-edged particles, decal sets draw nothing. Engine-wide, live.
133
+ * Optional: hosts that predate it keep the effects and the SDK skips the call. */
134
+ setDepthEffects?(enabled: boolean): void
135
+ /** LOD distance (`scene.setLodBias`): the LOD pass' screen-size thresholds × `bias` — 2 = every
136
+ * level switches at half the distance, 0.5 = full detail twice as far; 1 = the defaults
137
+ * (0.30 / 0.12 / 0.05 of the viewport height). Engine-wide, live, clamped 0.25..8. Optional:
138
+ * hosts without the LOD pass ignore it and the SDK skips the call. */
139
+ setLodBias?(bias: number): void
121
140
  setMaterialGlobalParameter(sceneId: number, i: number, x: number, y: number, z: number, w: number): void
122
141
  getCameraFov(sceneId: number, fovType: number): number
123
142
  /** The scene camera's projection: VERTICAL fov in degrees + near/far clip distances (defaults
@@ -136,7 +155,9 @@ declare global {
136
155
 
137
156
  /** `uv1` (optional, 2 floats per vertex) is the lightmap UV set (Geometry.uv1); absent → the
138
157
  * host duplicates `uv` into UV1. Hosts that predate the argument ignore it. */
139
- setMesh(entityId: number, materialId: number, vertices: Float32Array, normals: Float32Array, indices: Uint16Array, uv: Float32Array, meshType: number, uv1?: Float32Array): void
158
+ /** `colors` (Geometry.colors) is 4 bytes RGBA per vertex for a `requires: [color]` material;
159
+ * a host that predates it draws the mesh white. */
160
+ setMesh(entityId: number, materialId: number, vertices: Float32Array, normals: Float32Array, indices: Uint16Array, uv: Float32Array, meshType: number, uv1?: Float32Array, colors?: Uint8Array): void
140
161
  // Terrain (gl/Terrain.ts ↔ creator-gl/src/terrain.cpp, docs/terrain-plan.md §1.4): a heightmap grid of
141
162
  // sizeX × sizeZ samples `cellSize` apart (+X across columns, +Z across rows, height on +Y, sample 0 at
142
163
  // the node origin), drawn as one renderable per `chunk`×`chunk` cells on internal children of the
@@ -157,10 +178,26 @@ declare global {
157
178
  setInstancedMeshMaterial(entityId: number, materialId: number): void
158
179
  setInstancedMeshShadows(entityId: number, cast: boolean, receive: boolean): void
159
180
  setCulling(entityId: number, culling: boolean): void
181
+ /** Filament RenderableManager::setPriority — the coarse draw order, 0..7 (Mesh.renderPriority).
182
+ * Optional: an older host leaves everything at the default 4. */
183
+ setRenderPriority?(entityId: number, priority: number): void
184
+ /** Filament MaterialInstance::setDepthCulling / setDepthWrite — per-instance overrides of the
185
+ * depth state baked into the shader package (Material.depthTest / depthWrite). Optional. */
186
+ setMaterialDepthTest?(materialInstanceId: number, enable: boolean): void
187
+ setMaterialDepthWrite?(materialInstanceId: number, enable: boolean): void
188
+ /** Filament MaterialInstance::setCullingMode: 0 none (double-sided), 1 front, 2 back. Optional. */
189
+ setMaterialCulling?(materialInstanceId: number, mode: number): void
190
+ /** Filament MaterialInstance stencil state in one call (Material.stencil): `test` 0 always, 1 never,
191
+ * 2 less, 3 lessEqual, 4 greater, 5 greaterEqual, 6 equal, 7 notEqual; the ops 0 keep, 1 zero,
192
+ * 2 replace, 3 increment, 4 decrement, 5 invert. Optional. */
193
+ setMaterialStencil?(materialInstanceId: number, write: boolean, test: number, ref: number, onPass: number, onFail: number, onDepthFail: number, readMask: number, writeMask: number): void
160
194
  setCastShadows(entityId: number, culling: boolean): void
161
195
  setReceiveShadows(entityId: number, culling: boolean): void
162
196
 
163
- createTexture(systemId: number, onComplete: (id: number, width: number, height: number) => void, onReject: () => void): void
197
+ /** Decode a fetched image into a texture. `flags` (optional, Texture.load): bit 1 = LINEAR data
198
+ * (a normal map — store RGBA8, not sRGB); unset / absent = colour, sRGB. A host that ignores
199
+ * it loads colour correctly and normal maps wrongly. KTX2 decides by its own header. */
200
+ createTexture(systemId: number, onComplete: (id: number, width: number, height: number) => void, onReject: () => void, flags?: number): void
164
201
  // Texture from a baked _creatorCanvas surface (RGBA8, already rasterized — synchronous, no decode).
165
202
  createTextureFromCanvas(surfaceId: number): number
166
203
  updateTextureFromCanvas(texId: number, surfaceId: number): void
@@ -237,9 +274,11 @@ declare global {
237
274
  // interp 0 linear/1 step/2 cubic, comps, keyCount, times…, values…)*]. Returns a one-clip set id (0 = bad).
238
275
  createClipFromTracks(names: string, data: Float32Array): number
239
276
  getClipSetInfo(clipSetId: number): { name: string, duration: number, trackCount: number }[]
240
- // A sub-range [start, end] seconds of one clip as a NEW single-clip set (index 0), re-timed to 0;
241
- // interior keys copied, boundary values interpolated in at the cut points; events re-timed. 0 = bad id/range.
242
- sliceClip(clipSetId: number, clip: number, start: number, end: number): number
277
+ // A clip DERIVED from one of the set as a NEW single-clip set (index 0) — AnimationClip.from(clip, { mirror,
278
+ // from, to }): the mirror (left ↔ right on the set's own rig, the GLB's node tree: contacts swapped, heading
279
+ // negated), then the [start, end] window re-timed to 0 (start < 0 = whole, end < 0 = the clip's end; boundary
280
+ // values interpolated in, events re-timed). 0 = bad id / range, or a mirror asked of a set without a rig.
281
+ deriveClip(clipSetId: number, clip: number, mirror: boolean, start: number, end: number): number
243
282
  // Clip events on the CLIP: normalized times (sorted ascending). Every slot bound to the clip, in every
244
283
  // animator, fires slot event type 4 + i on crossing event i. Empty = clear.
245
284
  setClipEvents(clipSetId: number, clip: number, times: Float32Array): void
@@ -304,7 +343,7 @@ declare global {
304
343
  // One curve of a slot's clip at `time` seconds (< 0 = the slot's clock now): which 0 φ (-1 = no gait)
305
344
  // / 1 travel (m) / 2 yaw (rad, + = left) / 3 speed (m/s) / 4-5 unit travel direction x / z
306
345
  // (model space, held through stills — integrate dir × d(travel) for the root's 2D path).
307
- animatorSlotCurveAt(animatorId: number, slot: number, which: 0 | 1 | 2 | 3 | 4 | 5, time: number): number
346
+ animatorSlotCurveAt(animatorId: number, slot: number, which: 0 | 1 | 2 | 3 | 4 | 5 | 6, time: number): number
308
347
  // out ← [curve sample dt, total travel (m), mean speed (m/s), in-place flag]. False = unbound slot.
309
348
  animatorSlotCurveInfo(animatorId: number, slot: number, out: Float32Array): boolean
310
349
  animatorSlotTurn(animatorId: number, slot: number): number // the clip's total root yaw, rad
@@ -445,6 +484,12 @@ declare global {
445
484
  * [minX,minY,minZ, maxX,maxY,maxZ] — zeros for an empty / not-yet-loaded subtree. What
446
485
  * `Shape.fit()` measures. Optional: absent on hosts predating the binding. */
447
486
  computeBoundingBox?(entityId: number): Float32Array
487
+ /** DEBUG pick: the closest TRIANGLE under a screen point (logical px) among the loaded GLB instances —
488
+ * entityId = one Model's root, 0 = every instance — CPU-skinned with the joints' CURRENT pose, both
489
+ * faces. JSON string (`Model.pickTriangle` parses it: node, mesh, primitive, triangle, hit point,
490
+ * the three vertices with their raw JOINTS_0/WEIGHTS_0 pairs), "" on a miss. One full CPU skin per
491
+ * call — click-rate only. Optional: a debug tool, hosts may lack it. */
492
+ pickTriangle?(entityId: number, screenX: number, screenY: number): string
448
493
  setColliderFromMesh(entityId: number, meshEntityId: number, form: number): void
449
494
  setColliderBox(entityId: number, centerX: number, centerY: number, centerZ: number, sizeX: number, sizeY: number, sizeZ: number): void
450
495
  setColliderSphere(entityId: number, centerX: number, centerY: number, centerZ: number, radius: number): void
@@ -462,7 +507,9 @@ declare global {
462
507
  lightmapHasSupport?(): boolean
463
508
  /** Lightmap consumption (docs/lightmap-plan.md §3): the next createGlb takes lightmap.filamat (the material
464
509
  * behind that instance id) instead of the ubershader and keeps TEXCOORD_1; UINT32_MAX clears. */
465
- setNextGlbLightmapped?(materialInstanceId: number): void
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. */
512
+ setNextGlbLightmapped?(materialInstanceId: number, maskedMaterialInstanceId?: number): void
466
513
  /** The level-wide knobs every lightmap-material instance shares (applied to the ones that exist and to every one
467
514
  * created afterwards): the sun-mask shadow math (ambientScale, sunStrength), how much baked AO applies (aoStrength,
468
515
  * 1 = all) and the light atlas' / volume's physical irradiance per encoded 1.0 (lightScale; 0 = no baked lights).
@@ -567,43 +614,86 @@ declare global {
567
614
  characterSetPosition(charId: number, x: number, y: number, z: number): void
568
615
  // Vehicle (Jolt VehicleConstraint + WheeledVehicleController). One settings BLOB, so tuning knobs
569
616
  // never grow this ABI — layout (floats):
570
- // header[21]: version(5), mass, comAuto, comY,
571
- // engTorque, engMaxRpm, engIdleRpm, engInertia, engBraking, clutchStrength,
617
+ // header[34]: version(15), mass, comAuto, comY,
618
+ // engTorque, engMaxRpm, engIdleRpm (0 = no floor: the engine can stall, the game
619
+ // holds idle and cranks it), engInertia, engBraking, clutchStrength,
572
620
  // diffRatio (<= 0 = a fully OPEN differential),
573
621
  // antiRoll (the bar's stiffness as a FRACTION of the wheel spring; 0 = no bars),
574
622
  // maxTiltDeg,
575
- // steerLockDeg, steerAtSpeedDeg, steerSpeedMs, steerRateDeg (speed-sensitive
576
- // steering: the lock falls to steerAtSpeedDeg by steerSpeedMs and the wheels turn
577
- // no faster than steerRateDeg per second; 0/0/0 = the raw lock, instantly),
578
623
  // aeroDownforce, aeroDrag (each a fraction of the car's own WEIGHT at 30 m/s, scaled
579
624
  // by v² from there; 0/0 = no aero, Jolt's own behaviour),
625
+ // steerMode (0 = `steer` is the wheel angle as a fraction of the lock; 1 = `steer`
626
+ // is where the driver's HANDS aim a steering column the engine integrates every
627
+ // sub-step: I·θ̈ = T_hand + T_align·(1 − assist) + T_stop − damping·θ̇ − friction,
628
+ // T_hand = clamp(handStiffness·(steer·lock − θ), ±handTorque·steerForce),
629
+ // steerForce = the input's per-frame hold — the game's policy on when the hands let go,
630
+ // T_align = the steered tires' lateral force × (pneumatic trail collapsing to
631
+ // trailFloor × trail at the curve's peak + caster) — heavy at speed, light past
632
+ // the peak, self-centring, counter-steering in a slide),
633
+ // colInertia, colDamping, colFriction (the patch's dry friction, fades out by 1.5 m/s),
634
+ // colCaster, colTrail, colTrailFloor (the aligning torque's arms — read in BOTH
635
+ // modes; steerTorque is reported either way, the force-feedback signal),
636
+ // colHandTorque, colHandStiffness, colAssist (power steering),
637
+ // colRateDeg (the HANDS' top turning speed, deg/s — they cannot push a wheel that
638
+ // outruns them, they can still hold it; the free column is uncapped; 0 = none),
639
+ // colStopDeg, colStopTorque, colStopDamping (the end stop: over the last colStopDeg
640
+ // before the lock the rack pushes back colStopTorque × depth² N·m and damps by
641
+ // colStopDamping × depth N·m·s/rad — progressive, viscous rubber; 0 band = the
642
+ // hard clamp only),
643
+ // colBearing (the column's own dry friction, N·m, at any speed — rack and bearings),
644
+ // colHandRamp (seconds the hands' torque builds to colHandTorque over; 0 = instant),
645
+ // clutchCapacity (N·m the clutch passes before it slips, × the clutch scalar; 0 = Jolt's
646
+ // viscous clutch alone),
580
647
  // wheelCount, curveCount
581
648
  // + curveCount * 2: the engine's normalized torque curve (x = rpm/maxRpm, y = torque/maxTorque);
582
649
  // 0 points keeps Jolt's default
583
- // + wheelCount * 15: px, py, pz, radius, width, maxSteerDeg, drive, brakeTorque, handBrakeTorque,
584
- // axle, travel, stiffness, damping, grip, tireCurve (0 = road, 1 = arcade)
585
- // NO gear list and no wheel node ids: the gearbox is the SDK's (see vehicleSetTransmission) and the
586
- // SDK poses the wheel nodes itself. Chassis space is forward -Z / up +Y (matching node.forward);
650
+ // + per wheel, VARIABLE length: 15 fixed floats — px, py, pz, radius, width, maxSteerDeg,
651
+ // driven (the wheel's SHARE of the engine's torque: an axle's share is the sum
652
+ // of its two, the left/right split their ratio; every wheel 0 = no drive at
653
+ // all), axle, travel, stiffness, damping,
654
+ // traction (the longitudinal impulse clamp as a multiple of friction × load;
655
+ // 1 = the physical tire, Jolt's sample runs 10), circle (friction circle 0..1:
656
+ // the share of lateral capacity the longitudinal impulse in use takes away;
657
+ // 0 = the two axes independent), sideCount, forwardCount — then
658
+ // sideCount × (slip angle °, friction) and forwardCount × (slip ratio,
659
+ // friction). Tire curves are POINTS the SDK sends (its presets are SDK-side);
660
+ // 0 points keeps Jolt's own curve.
661
+ // In steerMode 0 the steering input is RAW: `steer` × each wheel's maxSteerDeg, per fixed step —
662
+ // any taper / rate limit is the game's. In mode 1 `steer` is where the hands aim the wheel.
663
+ // NO gear list, no pedals and no wheel node ids: the gearbox is the game's (a ratio + clutch in
664
+ // the input vector), the brakes are a torque per wheel in the same vector, and the game poses its
665
+ // wheel models itself from the state. Chassis space is forward -Z / up +Y (matching node.forward);
587
666
  // wheel positions are suspension attachment points in unscaled chassis space; `axle` pairs wheels
588
667
  // for the differentials + anti-roll bars.
589
668
  vehicleCreate(entityId: number, shapeId: number, settings: Float32Array): number
590
669
  vehicleDestroy(vehicleId: number): void
591
- /** forward/right in [-1,1], brake/handBrake in [0,1]. Sticky; any non-zero input wakes the car. */
592
- vehicleSetInput(vehicleId: number, forward: number, right: number, brake: number, handBrake: number): void
593
- /** The SDK's gearbox, latched and applied once per fixed step: `ratio` is the ONE ratio the car is
594
- * running (0 = neutral, negative = reverse — the engine never sees a gear list) and `clutch` is
595
- * the 0..1 shift envelope scaling the clutch in the coupled engine/wheel solve. */
596
- vehicleSetTransmission(vehicleId: number, ratio: number, clutch: number): void
597
- /** Re-apply the TUNABLE half of the blob (same layout) to a live car — differential, per-wheel grip,
598
- * engine torque/RPM/curve, clutch strength, steering lock/taper/rate, brake torques, tilt limit.
599
- * Structural values (mass, centre of mass, wheel geometry, driven wheels, suspension, anti-roll)
600
- * are ignored: those need a re-create. */
670
+ /** ONE input vector, latched and applied once per fixed step: [throttle 0..1, steer −1..1 (a
671
+ * fraction of the wheels' maxSteerDeg, applied as is; with a column, where the hands aim),
672
+ * steerForce 0..1 (with a column: how firmly the hands hold the wheel — the game's per-frame
673
+ * policy on letting go; without one, ignored), ratio (the ONE gear ratio the car runs: 0 =
674
+ * neutral, negative = reverse — the engine never sees a gear list), clutch 0..1 (scales the clutch
675
+ * in the coupled engine/wheel solve), brake_0 … brake_n (N·m of brake torque per wheel, this
676
+ * step)]. A shorter vector leaves the rest as it was. Sticky; a non-zero throttle, steer or brake
677
+ * wakes a sleeping car. */
678
+ vehicleSetInput(vehicleId: number, input: Float32Array): void
679
+ /** Re-apply the TUNABLE half of the blob (same layout) to a live car — differential, per-wheel tire
680
+ * curves / traction / circle, engine torque/RPM/curve, clutch strength + capacity, steer lock, the
681
+ * column, anti-roll stiffness, tilt limit. Structural values (mass, centre of mass, wheel geometry,
682
+ * the driven shares, suspension, whether an axle has a bar) are ignored: those need a re-create. */
601
683
  vehicleSetTuning?(vehicleId: number, settings: Float32Array): void
602
- /** out = [speed, rpm, wheelsInContact, vx, vy, vz] + per wheel
603
- * [contact, slipLong, slipAngleDeg, suspensionLength, steerDeg, spin]. The last three per wheel are
604
- * its VISUAL POSE — the SDK's Wheel aspect builds the node transform from them. */
684
+ /** out = [speed, rpm, wheelsInContact, vx, vy, vz, wx, wy, wz, steerDeg, steerTorque] (w = chassis
685
+ * angular velocity, rad/s; steerDeg = where the road wheels are, right-positive; steerTorque = the
686
+ * tires' self-aligning torque on the steering, N·m, + = pulls right — the force-feedback signal,
687
+ * reported in both steering modes)
688
+ * + per wheel [contact, slipLong, slipAngleDeg, suspensionLength, steerDeg, spin, fLat, fLong, vLat, vSlip, load]
689
+ * (fLat/fLong = the tire's forces, N, + = to its right / pushing the car forward; vLat/vSlip = the
690
+ * patch's sliding speeds, m/s, + = to the right / tread spinning up — force × speed is heat; load =
691
+ * the suspension's force on the wheel, N). slipLong is SIGNED (+ spinning up, − locking) and so is
692
+ * slipAngleDeg (+ the patch sliding to the tire's right). suspensionLength, steerDeg and spin are
693
+ * what a wheel model's pose is built from — by the game, the SDK only exposes them. */
605
694
  vehicleGetState(vehicleId: number, out: Float32Array): void
606
- /** Teleport upright and clear all motion (velocities, engine RPM, gear, wheel spin). */
695
+ /** Teleport upright and clear all motion (velocities, engine RPM, wheel spin); the input vector is
696
+ * zeroed — the SDK re-sends its own on the next early pass. */
607
697
  vehicleReset(vehicleId: number, x: number, y: number, z: number, qx: number, qy: number, qz: number, qw: number): void
608
698
  /** The chassis rigid body, for the plain body calls (physicsApplyImpulse, …). 0 if unknown. */
609
699
  vehicleBodyId(vehicleId: number): number
@@ -644,6 +734,61 @@ declare global {
644
734
  ragdollActive?(ragdollId: number): boolean
645
735
  /** The rigid body of part `index` (for physicsApplyImpulseAt / velocities). 0 if unknown. */
646
736
  ragdollBodyId?(ragdollId: number, index: number): number
737
+ // Dynamic bones (the SDK's DynamicBone; creator-anim canimDyn* behind creator-gl): secondary motion
738
+ // for tails, ears, hair and cloaks — a bone tree under one root simulated as Verlet particle chains
739
+ // (gravity, wind, drag, stiffness toward the animated shape, an angle cone, capsule / sphere
740
+ // colliders, a floor plane, bone length, neighbour links) in an engine stage AFTER the late phase
741
+ // and BEFORE the skin flush: the animator, an active ragdoll and late-phase JS bone writes are the
742
+ // input, the simulated local rotations the output. Bones the animator does not drive keep their
743
+ // bind pose as the target; driven ones follow their clip. Optional: a host without it shows the
744
+ // animation alone.
745
+ // bones[boneCount]: entity ids — the root first, every other bone a child of an earlier one.
746
+ // settings — header[28]: version(1), boneCount, boneStride(7), then the chain params: weight
747
+ // (0..1 animation → simulation; 0 = off, re-arms on the animated pose), follow (0..1 of
748
+ // the root's travel carried onto the particles; 0 = full whip), wind xyz (m/s², world),
749
+ // link (0..1 neighbour-link strength), rate (substep Hz, ≤ 4 substeps per frame),
750
+ // teleport (a root jump past this many metres resets the chain), floor (0/1), floor
751
+ // point xyz, floor normal xyz (the floor slots are overridden by dynamicBoneSetFloor),
752
+ // floorFriction (a Coulomb coefficient: a resting particle's slide loses up to friction · g · dt of
753
+ // speed per substep), iterations
754
+ // (constraint passes per substep, 1..8, default 4; a long rope may want 8), side (the
755
+ // cloth's outside, one-sided colliders: 0 none, 1 away from the root bone's axis, 2 the
756
+ // next xyz in the root bone's frame), side xyz, guideHold (0..1: guides re-applied after
757
+ // every constraint pass with this fraction of their weight; 0 = once before the passes),
758
+ // edges (1 = the bone segments and links collide with the capsules too, not only the particles),
759
+ // spin (> 0: the chain in the parent bone's rotating frame + its centrifugal / Euler forces × spin;
760
+ // 0 = the translational frame, no turn forces), spinInertia (s: the cloth's own rotation follows the body's with this time constant — behind on a start, past the back on a stop; 0 = glued)
761
+ // + boneCount × 7: radius (m), stiffness (0..1 per 1/60 s toward the animated shape), damping
762
+ // (0..1 velocity lost per 1/60 s), gravity (m/s² along world −Y), angleLimit (degrees off
763
+ // the animated direction, 0 = none), mass (relative, 0 = 1: a bone-length constraint
764
+ // moves its two ends in inverse proportion; the root is kinematic; < 0 = PINNED, the bone
765
+ // rides the animation), give (m, pinned bones: a soft pin — a collider may push the bone
766
+ // this far off its animated place, its local translation is written back too; 0 = hard)
767
+ // links: (a, b) bone index pairs held at their rest distance (a cloak's columns; two linked leaves
768
+ // link their virtual tips too, so a hem stays a hem); optional.
769
+ dynamicBoneCreate?(modelRootId: number, bones: Uint32Array, settings: Float32Array, links?: Uint16Array): number
770
+ dynamicBoneDestroy?(id: number): void
771
+ /** Retune a live chain: the same blob as create (the bone rows too when the count matches). */
772
+ dynamicBoneSet?(id: number, settings: Float32Array): void
773
+ /** The floor the particles stay above: mode 0 none / 1 the plane (point, normal) / 2 probe — the
774
+ * engine casts a ray from the chain root down every frame (the ground probe, the model root
775
+ * excluded) and uses the hit; no hit / no physics world = no floor that frame. */
776
+ dynamicBoneSetFloor?(id: number, mode: 0 | 1 | 2, x: number, y: number, z: number, nx: number, ny: number, nz: number): void
777
+ /** The next frame snaps the chain onto the animated pose (a teleport, a cut). */
778
+ dynamicBoneReset?(id: number): void
779
+ /** The colliders this chain collides with (dynamicBoneColliderCreate ids); replaces the list. */
780
+ dynamicBoneSetColliders?(id: number, colliderIds: Uint32Array): void
781
+ /** The particles' world positions (bones first, then the leaves' virtual tips), 3 floats each into
782
+ * `out`; returns the count written — a debug overlay. */
783
+ dynamicBoneParticles?(id: number, out: Float32Array): number
784
+ // guides: rows × 5 — bone index (in the chain's bone order), world x y z, weight — world points the
785
+ // bones' particles are drawn to before the constraints; null / empty clears; refreshed every frame
786
+ dynamicBoneTargets?(id: number, rows: Float32Array | null): void
787
+ /** A capsule a → b in the entity's local space (a == b = a sphere) that rides the entity — a bone
788
+ * 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
791
+ dynamicBoneColliderDestroy?(colliderId: number): void
647
792
  // Legacy coupled shape+body (still used by the worker RigidBody).
648
793
  physicsCreateBox(entityId: number, hx: number, hy: number, hz: number, motionType: number, mass: number): number
649
794
  physicsCreateSphere(entityId: number, radius: number, motionType: number, mass: number): number
@@ -681,6 +826,23 @@ declare global {
681
826
  spawnParticles(entityId: number, count: number): void
682
827
  setParticleSystemConfig(entityId: number, data: Float32Array): void
683
828
 
829
+ // Projected decals (creator-gl src/decals.h; SDK gl/DecalSet.ts). A set on an entity = one
830
+ // renderable of `capacity` (0 = 256) unit boxes drawn with the material instance (decal.filamat)
831
+ // — each box projects its atlas cell onto the opaque scene behind it through the scene depth
832
+ // buffer. A record is 27 floats in the SET entity's space: X Y Z axes scaled by the box's
833
+ // width / height / depth (Z = out of the surface), centre, atlas rect u0 v0 u1 v1, tint rgba,
834
+ // life (s, 0 = forever), fadeIn (s), fadeOut (s), capStart, capEnd (tilt of the image's bottom /
835
+ // top edge in unit space — mitred trail joints, 0 = square), alphaStart, alphaEnd (opacity
836
+ // multipliers at the bottom / top edge — a gradient along the image). addDecal returns the slot (0xFFFFFFFF = no
837
+ // set; a full set recycles its oldest); updateDecal keeps the slot's birth time. Optional:
838
+ // a host without them draws no decals (the SDK warns once).
839
+ createDecalSet?(entityId: number, materialInstanceId: number, capacity: number): void
840
+ addDecal?(entityId: number, record: Float32Array): number
841
+ updateDecal?(entityId: number, slot: number, record: Float32Array): void
842
+ removeDecal?(entityId: number, slot: number): void
843
+ clearDecals?(entityId: number): void
844
+ decalCount?(entityId: number): number
845
+
684
846
  createNoise(): number
685
847
  setNoiseFrequency(noiseId: number, frequency: number): void
686
848
  setNoiseOctaves(noiseId: number, octaves: number): void
@@ -960,6 +1122,72 @@ declare global {
960
1122
  crowdRead(crowdId: number, out: Float32Array): number
961
1123
  }
962
1124
 
1125
+ // ---- Game audio (creator-audio) ----------------------------------------------------------------
1126
+ // docs/audio-plan.md. Whole clips decoded up front, a fixed voice pool (play allocates nothing),
1127
+ // 3D sources the engine follows per frame, mixer buses with insert effects, reverb zones,
1128
+ // occlusion. OPTIONAL as a block: the SDK gates on `typeof _creatorAudio !== 'undefined' &&
1129
+ // hasSupport()` and stays inert (silent Sound, inert Voice) without it. Present on hosts built with
1130
+ // CREATOR_PKG_AUDIO (desktop first; Android / Apple pending — parity `audio-game`). The headless
1131
+ // renderer provides a RECORDER (plays are logged, nothing is mixed). Every parameter block is a
1132
+ // fixed-order Float32Array (creator-audio.h CAUD_SRC_* / the effect param orders).
1133
+ var _creatorAudio: {
1134
+ hasSupport(): boolean
1135
+ /** Decode the bytes behind a fetch system id (WAV / MP3 / FLAC / OGG Vorbis) on the engine's
1136
+ * loader thread; mono unless `stereo`. onDone(clipId, durationSeconds, channels). */
1137
+ loadClip(systemId: number, stereo: boolean, onDone: (clipId: number, duration: number, channels: number) => void, onReject: (message: string) => void): void
1138
+ releaseClip(clipId: number): void
1139
+ /** Bus id by name (-1 unknown); 0 master, 1 sfx, 2 music, 3 ui, 4 voice. */
1140
+ busId(name: string): number
1141
+ /** Creates (or finds) an app-defined bus under master. -1 when the 16 slots are full. */
1142
+ createBus(name: string): number
1143
+ setBusVolume(bus: number, volume: number): void
1144
+ setBusMuted(bus: number, muted: boolean): void
1145
+ /** kind 1 reverb [roomSize, damping, width, mix, preDelay] · 2 echo [delay, decay, mix] ·
1146
+ * 3 lowpass [cutoffHz]; an empty array turns the effect off. Changes are smoothed. */
1147
+ setBusEffect(bus: number, kind: number, params: Float32Array): void
1148
+ stopBus(bus: number, fade: number): void
1149
+ /** A 3D emitter. `attachSource` binds it to an entity the engine follows every frame;
1150
+ * `setSourcePosition` places an unbound one (playAt). */
1151
+ createSource(): number
1152
+ attachSource(sourceId: number, entityId: number): void
1153
+ /** [minDistance, maxDistance, rolloff (0 none 1 inverse 2 linear 3 exp), coneInnerDeg,
1154
+ * coneOuterDeg, coneOuterGain, doppler, spread, occlusion (0/1), bus]. */
1155
+ setSourceParams(sourceId: number, params: Float32Array): void
1156
+ setSourcePosition(sourceId: number, x: number, y: number, z: number): void
1157
+ stopSource(sourceId: number, fade: number): void
1158
+ sourceVoices(sourceId: number): number
1159
+ destroySource(sourceId: number): void
1160
+ /** The listener entity; 0 = the active scene camera (the default). */
1161
+ setListener(entityId: number): void
1162
+ setListenerOptions(dopplerFactor: number): void
1163
+ /** HRTF binaural rendering for the `maxVoices` nearest spatial voices (headphones); the rest keep panning. */
1164
+ setHrtf(enabled: boolean, maxVoices: number): void
1165
+ /** Whether the engine's time scale (Time.scale) also pitches the sfx bus. Default false (pause = mute only). */
1166
+ setTimeScalePitch(enabled: boolean): void
1167
+ /** → a voice token (0 = nothing played: the pool refused, the clip is not ready). bus -1 = the
1168
+ * source's bus (sfx for 2D). pan is 2D only. */
1169
+ play(clipId: number, sourceId: number, bus: number, volume: number, pitch: number, loop: boolean, priority: number, fadeIn: number, startAt: number, pan: number): number
1170
+ setVoiceVolume(token: number, volume: number): void
1171
+ setVoicePitch(token: number, pitch: number): void
1172
+ setVoicePan(token: number, pan: number): void
1173
+ stopVoice(token: number, fade: number): void
1174
+ voicePlaying(token: number): boolean
1175
+ voiceTime(token: number): number
1176
+ /** THE ended listener (single slot per JS world): called once per tick with the tokens of the
1177
+ * voices that ended since the previous tick — natural end, stop, steal. */
1178
+ setOnEnded(callback: ((tokens: Float32Array) => void) | null): void
1179
+ stopAll(fade: number): void
1180
+ /** A reverb volume: shape 0 box (dims = full size) / 1 sphere (dims[0] = diameter), in the
1181
+ * entity's local units; `blend` metres of crossfade inside the border; reverb params as for
1182
+ * setBusEffect kind 1. The listener inside blends the zone's reverb onto `bus`. */
1183
+ createZone(): number
1184
+ attachZone(zoneId: number, entityId: number): void
1185
+ setZone(zoneId: number, shape: number, dims: Float32Array, blend: number, bus: number, reverb: Float32Array): void
1186
+ destroyZone(zoneId: number): void
1187
+ /** [voicesPlaying, voicesMono, voicesStereo, stolen, clips, clipBytes, peak, sampleRate, listenerZone, zoneBlend, hrtfVoices]. */
1188
+ stats(): Float32Array
1189
+ }
1190
+
963
1191
  // ---- UI engine (creator-ui) ------------------------------------------------------------------
964
1192
  var _creatorUI: {
965
1193
  // Global back-press fallback (app.onBackPressed): a PROPERTY the SDK writes, not a call —
@@ -989,6 +1217,11 @@ declare global {
989
1217
  // Content-property push. Props: "value" (input/textarea text), "focus" (boolean — focus/blur
990
1218
  // the input, opening/dismissing the keyboard; rides this channel so programmatic focus needs
991
1219
  // no new ABI method), plus element-specific ones ("src", "text", …).
1220
+ // An image "src" (here and at creation) is a url string, { _id } (a host buffer), { svg },
1221
+ // { canvasSurface } (a baked Canvas), or { scene2d: sceneId } — a LIVE 2D scene the host draws
1222
+ // into the node's laid-out box every frame with the scene's own camera (native: c2dDrawSceneGL
1223
+ // into a per-node framebuffer; no intrinsic size, the scene needs no openScene). The engine's
1224
+ // simulation must still step each frame for it (native: c2dTick when nothing is presented).
992
1225
  updateNode(nodeId: number, prop: string, value: any): void
993
1226
  setSourceRect(nodeId: number, x: number, y: number, w: number, h: number): void
994
1227
 
@@ -1100,7 +1333,7 @@ declare global {
1100
1333
  disposeFetch(systemId: number): void
1101
1334
  // ---- Local filesystem (the SDK `files` global) ----------------------------------------------
1102
1335
  // The read/write/delete half of `fetchLocal`, for hosts where the app OWNS a filesystem
1103
- // (desktop). All five are OPTIONAL and stand or fall together: a sandboxed host (web, mobile)
1336
+ // (desktop). All seven are OPTIONAL and stand or fall together: a sandboxed host (web, mobile)
1104
1337
  // implements none of them and `files.supported` is false there.
1105
1338
  //
1106
1339
  // ASYNC, like `fetch`: file IO must not block the frame, so each takes an onComplete/onReject
@@ -1130,6 +1363,16 @@ declare global {
1130
1363
  deleteLocal?(path: string, recursive: boolean, onComplete: () => void, onReject: (err: Error) => void): void
1131
1364
  /** Create a directory and any missing parents. An existing directory is success. */
1132
1365
  mkdirLocal?(path: string, onComplete: () => void, onReject: (err: Error) => void): void
1366
+ /** List a directory (added 2026-09-11, same optional set). Settles with an array of entries —
1367
+ * `name` = basename, `path` = relative to the listed directory with `/` separators (equals
1368
+ * `name` unless `recursive`), `kind`, `size` in bytes (0 for a directory), `modified` in ms
1369
+ * since the epoch — SORTED by `path`, so two runs and two hosts agree. Symlinks are followed
1370
+ * for `kind`; anything that is neither file nor directory is skipped. A MISSING directory is a
1371
+ * null result (like the readers); a path that is a file, or an unreadable directory, rejects. */
1372
+ listLocal?(path: string, recursive: boolean, onComplete: (entries: { name: string; path: string; kind: "file" | "dir"; size: number; modified: number }[] | null) => void, onReject: (err: Error) => void): void
1373
+ /** One entry's metadata (`name`, `kind`, `size`, `modified` — see `listLocal`) for a file or a
1374
+ * directory, or null when there is nothing at `path`. */
1375
+ statLocal?(path: string, onComplete: (entry: { name: string; kind: "file" | "dir"; size: number; modified: number } | null) => void, onReject: (err: Error) => void): void
1133
1376
  openFilePicker(onComplete: (res: any) => void, onReject: () => void, multiple: boolean, accept?: string): void
1134
1377
  // Present the OS media share sheet for a host buffer (`systemId` — a File/response id), with an
1135
1378
  // optional caption. iOS: the share sheet also offers "Save to Files"/Photos. Web: saves (downloads)
@@ -1158,7 +1401,11 @@ declare global {
1158
1401
  /** Continuous input poll by channel id (SDK `InputChannel`): 0 MouseX, 1 MouseY (logical px,
1159
1402
  * last known cursor position inside the viewport), 2 MouseDX, 3 MouseDY (motion accumulated
1160
1403
  * during the PREVIOUS frame — unaccelerated/raw where the OS offers it, counts while locked),
1161
- * 4 WheelX, 5 WheelY (wheel notches, previous frame), 6 PointerLocked (0/1), 7 GamepadCount;
1404
+ * 4 WheelX, 5 WheelY (wheel notches, previous frame), 6 PointerLocked (0/1), 7 GamepadCount,
1405
+ * 8 PointerOnUI (0/1: the primary pointer went DOWN on something the UI claimed — an
1406
+ * interactive node, a scrollable, an editable, a modal backdrop — and is still held; stays 1
1407
+ * wherever the cursor goes until the release, so a drag that started on a HUD control never
1408
+ * becomes camera look; always 0 while pointer-locked and for touch-only hosts without a UI claim);
1162
1409
  * gamepad p at 16 + 16·p: +0 Connected, +1 LeftX, +2 LeftY, +3 RightX, +4 RightY (−1..1,
1163
1410
  * +Y = down like the web), +5 LeftTrigger, +6 RightTrigger (0..1). Unknown channel → 0.
1164
1411
  * "Previous frame" = the host's own frame boundary (the event pump / rAF / fixed step), so
@@ -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
@@ -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,
@@ -69,21 +69,22 @@ export type CompileOptions = {
69
69
  serverUrl?: string
70
70
  }
71
71
 
72
- export const compileProject = async (opts: CompileOptions): Promise<string> => {
73
- // Client bundle: every *.server.ts becomes its RPC/channel stub (server code stays server-side).
74
- const entries = stubServerEntries(opts.entries, opts.serverUrl ?? "")
75
- const entry = opts.entryOverride ?? detectEntry(entries, scanModulesChisel).entry
76
- if (!entry) {
77
- throw new Error("Couldn't find an entrypoint (no .ts/.js project file — name yours main.ts). Pass entryOverride.")
78
- }
72
+ /** The bundle + its map SEPARATELY (the platform deploy writes the map as a sibling `.js.map`; the
73
+ * preview inlines it) plus the entrypoint the compile used. `code` carries the metadata header
74
+ * unless `header: false`; `map` is already shifted past that header. */
75
+ export type CompileResult = { code: string, map: string | null, entry: string }
79
76
 
77
+ /** Turn compile entries into the bundler's module map — the per-kind rules of the deployable
78
+ * bundle (resource → URL export, shader → per-backend URL template, .svg → SvgSource, .json →
79
+ * data, code verbatim). Exported for callers that build a `libraries` map from entries. */
80
+ export const entriesToFiles = (entries: CompileEntry[], opts: { publicUrl: string, localAssets?: boolean }): Record<string, string> => {
80
81
  const files: Record<string, string> = {}
81
82
  for (const e of entries) {
82
83
  if (e.type === "resource") {
83
84
  if (opts.localAssets && !e.remote) {
84
85
  // The host resolves the asset by name at runtime (reads the local file → systemId); the
85
86
  // "id:" prefix is the host's local-resource URL scheme (parsed by the UI + 2D texture paths).
86
- const name = e.path.split(/[\\/]/).pop() ?? e.path
87
+ const name = e.path.split(/[\/]/).pop() ?? e.path
87
88
  files[e.path] = `export default ("id:" + _creatorUtils.fetchLocal(${JSON.stringify(name)}))`
88
89
  } else {
89
90
  files[e.path] = `export default "${opts.publicUrl}${e.fileSrc ?? ""}"`
@@ -98,7 +99,7 @@ export const compileProject = async (opts: CompileOptions): Promise<string> => {
98
99
  // desktop host reports "desktop" on Windows and "opengl" on Linux, iOS "metal".
99
100
  // Only entries the CLI actually compiled+staged carry `shadersLocal`; server artifacts
100
101
  // (URLs) and un-staged ones keep the URL forms below.
101
- const name = shaders[0].src.split(/[\\/]/).pop() ?? ""
102
+ const name = shaders[0].src.split(/[\/]/).pop() ?? ""
102
103
  const [prefix, suffix = ".filamat"] = name.split("_opengl")
103
104
  files[e.path] = `export default ("id:" + _creatorUtils.fetchLocal(${JSON.stringify(prefix + "_")} + _creator.backend + ${JSON.stringify(suffix)}))`
104
105
  } else if (shaders.length > 0) {
@@ -117,16 +118,30 @@ export const compileProject = async (opts: CompileOptions): Promise<string> => {
117
118
  files[e.path] = e.text ?? ""
118
119
  }
119
120
  }
121
+ return files
122
+ }
120
123
 
124
+ export const compileProjectWithMap = async (opts: CompileOptions): Promise<CompileResult> => {
125
+ // Client bundle: every *.server.ts becomes its RPC/channel stub (server code stays server-side).
126
+ const entries = stubServerEntries(opts.entries, opts.serverUrl ?? "")
127
+ const entry = opts.entryOverride ?? detectEntry(entries, scanModulesChisel).entry
128
+ if (!entry) {
129
+ throw new Error("Couldn't find an entrypoint (no .ts/.js project file — name yours main.ts). Pass entryOverride.")
130
+ }
131
+
132
+ const files = entriesToFiles(entries, opts)
121
133
  const { code, map } = await bundleProjectWithMap(
122
134
  entry, files, opts.format ?? "esm", opts.minify, opts.editor, opts.libraries, opts.sourcemap ?? true,
123
135
  )
124
- if (opts.header === false) {
125
- return map ? `${code}\n${inlineSourceMapComment(map)}` : code
126
- }
136
+ if (opts.header === false) return { code, map, entry }
127
137
  const header = buildHeader(opts.name, code, { icon: opts.icon, fonts: opts.fontsHeader, icons: opts.iconsHeader })
128
- if (!map) return header + code
129
- // Shift the map down past the header (prepended before the code); the inline comment lands last.
138
+ // Shift the map down past the header (prepended before the code).
130
139
  const headerLines = (header.match(/\n/g) ?? []).length
131
- return `${header}${code}\n${inlineSourceMapComment(offsetSourceMap(map, headerLines))}`
140
+ return { code: header + code, map: map ? offsetSourceMap(map, headerLines) : null, entry }
141
+ }
142
+
143
+ /** The bundle as ONE string, the map inlined at its end (the shipped/preview form). */
144
+ export const compileProject = async (opts: CompileOptions): Promise<string> => {
145
+ const { code, map } = await compileProjectWithMap(opts)
146
+ return map ? `${code}\n${inlineSourceMapComment(map)}` : code
132
147
  }
@@ -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
  }