lecodes-sdk 0.20.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/global.d.ts +31 -0
  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/gl/AudioSource.d.ts +60 -0
  9. package/dist/types/gl/AudioZone.d.ts +32 -0
  10. package/dist/types/gl/DecalSet.d.ts +103 -0
  11. package/dist/types/gl/Geometry.d.ts +5 -0
  12. package/dist/types/gl/Light.d.ts +7 -0
  13. package/dist/types/gl/Material.d.ts +86 -2
  14. package/dist/types/gl/Mesh.d.ts +11 -0
  15. package/dist/types/gl/Scene.d.ts +23 -0
  16. package/dist/types/gl/SceneAudio.d.ts +11 -0
  17. package/dist/types/gl/Texture.d.ts +29 -1
  18. package/dist/types/gl/animation/AnimationClip.d.ts +25 -12
  19. package/dist/types/gl/animation/core.d.ts +15 -10
  20. package/dist/types/gl/state.d.ts +0 -1
  21. package/dist/types/inject.d.ts +10 -0
  22. package/dist/types/runtime/input.d.ts +11 -0
  23. package/dist/types/ui/UIImage.d.ts +15 -5
  24. package/dist/types.json +1 -1
  25. package/package.json +1 -1
  26. package/prompts/dist/2d-game.md +408 -197
  27. package/prompts/dist/3d-app.md +491 -166
  28. package/prompts/dist/ar-app.md +373 -163
  29. package/prompts/dist/design.md +83 -87
  30. package/prompts/dist/ui-app.md +325 -136
  31. package/src/audio/Bus.ts +102 -0
  32. package/src/audio/Sound.ts +96 -0
  33. package/src/audio/Voice.ts +102 -0
  34. package/src/audio/audio.ts +161 -0
  35. package/src/audio/support.ts +6 -0
  36. package/src/bridges.d.ts +1481 -1352
  37. package/src/compile/compileProject.ts +30 -15
  38. package/src/compile/index.ts +3 -0
  39. package/src/core/Aspect.ts +33 -8
  40. package/src/g2/Scene2D.ts +7 -0
  41. package/src/gl/AudioSource.ts +113 -0
  42. package/src/gl/AudioZone.ts +75 -0
  43. package/src/gl/CameraPlace.ts +52 -52
  44. package/src/gl/DecalSet.ts +233 -0
  45. package/src/gl/Geometry.ts +5 -0
  46. package/src/gl/Light.ts +16 -0
  47. package/src/gl/Lightmap.ts +3 -2
  48. package/src/gl/Material.ts +152 -4
  49. package/src/gl/Mesh.ts +20 -1
  50. package/src/gl/Ragdoll.ts +270 -270
  51. package/src/gl/Scene.ts +41 -7
  52. package/src/gl/SceneAudio.ts +26 -0
  53. package/src/gl/Texture.ts +43 -3
  54. package/src/gl/Trigger.ts +45 -45
  55. package/src/gl/Vehicle.ts +5 -5
  56. package/src/gl/animation/AnimationClip.ts +43 -20
  57. package/src/gl/animation/Animator.ts +4 -3
  58. package/src/gl/animation/core.ts +20 -15
  59. package/src/gl/scenarios.ts +291 -291
  60. package/src/gl/state.ts +1 -1
  61. package/src/inject.ts +12 -0
  62. package/src/runtime/input.ts +6 -1
  63. package/src/scene/gizmos.ts +148 -148
  64. package/src/ui/UIImage.ts +21 -7
package/src/gl/Scene.ts CHANGED
@@ -13,8 +13,10 @@ import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
13
13
  import type { FetchResponse } from "../runtime/fetch"
14
14
  import { _requestCameraPermission } from "../plugins/permission"
15
15
  import { Camera } from "./Camera"
16
+ import { SceneAudio } from "./SceneAudio"
16
17
  import { attachControls, type ControlsHandle, type ControlsOptions } from "./controls"
17
18
  import { Material } from "./Material"
19
+ import { Texture } from "./Texture"
18
20
  import { Node, nodeRegistry } from "./Node"
19
21
  import { glState } from "./state"
20
22
  import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
@@ -107,6 +109,9 @@ export type SceneOptions = {
107
109
  * takes over). Unset keeps the host default (desktop 4×, mobile/web off). The biggest single
108
110
  * fill-rate cost after resolution — turn it down on big screens before anything else. */
109
111
  antialias?: boolean | 2 | 4
112
+ /** Keep a stencil buffer for this scene (off by default: it costs memory and a clear per frame).
113
+ * Needed before any material's `stencil` test or write does anything. */
114
+ stencil?: boolean
110
115
  /** Render the 3D at this fraction of the viewport (0.25–1) and upscale; the UI stays at native
111
116
  * resolution. A fixed, predictable cut of per-pixel GPU work — `0.75` is ~45 % cheaper and
112
117
  * barely visible in motion, `0.5` quarters it. Headless renders ignore it. */
@@ -128,6 +133,10 @@ export type SceneOptions = {
128
133
  * scene file — `env` is applied before the nodes build) and leaves already-loaded ones alone.
129
134
  * On the desktop host `CREATOR_TEXTURE_ANISOTROPY` overrides it, for tuning without a rebuild. */
130
135
  anisotropy?: number
136
+ /** Engine-wide cap on texture size, 0 / unset = none (see `Texture.maxSize`): a KTX2 above it
137
+ * loses its top mip levels on load, a glTF image is downsampled. Applied before this scene's
138
+ * assets load; like `anisotropy` it does not touch textures already loaded. */
139
+ maxTextureSize?: number
131
140
  /** The look on an HDR display (a screen with headroom above SDR white — Apple XDR panels, the
132
141
  * macOS host today); ignored on SDR. `strength` 0..1 is how much of the picture reaches for the
133
142
  * display's headroom (0 only what SDR clipped, 1 nearly everything; default 0.35). `paperWhite`
@@ -148,17 +157,16 @@ const installRenderSyncedFrames = (): void => {
148
157
  framesInstalled = true
149
158
  _installAspectFrames((early, late) => {
150
159
  if (typeof _creator.setEarlyUpdate !== "function") return false
151
- // the frame stamp invalidates every Node's local-matrix cache once per frame (physics / character
152
- // controllers / animation rewrite transforms natively between frames)
153
- _creator.setEarlyUpdate((dt: number) => { glState.frame++; early(dt) })
160
+ // (The per-phase read-cache stamp — `_phaseStamp` in core/Aspect.ts — advances inside the phase
161
+ // runners themselves, so it is right on this path and on the setLoop fallback alike.)
162
+ _creator.setEarlyUpdate(early)
154
163
  _creator.setLateUpdate(late)
155
164
  return true
156
165
  }, (fixed) => {
157
166
  // The fixed phase runs INSIDE the engine's substep loop (creator-physics' frame protocol) when the host has the
158
- // hook; each substep moves bodies natively, so the frame stamp bumps per step too. Feature-detected:
159
- // an older host returns false and the dispatcher steps its own 1/60 accumulator.
167
+ // hook. Feature-detected: an older host returns false and the dispatcher steps its own 1/60 accumulator.
160
168
  if (typeof _creator.setFixedUpdate !== "function") return false
161
- _creator.setFixedUpdate((dt: number) => { glState.frame++; fixed(dt) })
169
+ _creator.setFixedUpdate(fixed)
162
170
  return true
163
171
  })
164
172
  // Time.scale / Time.paused reach the engine's own clocks (physics, animators, particles) through
@@ -172,6 +180,8 @@ export class Scene implements Presentable {
172
180
  /** @internal the IBL intensity this scene was built with (Lightmap.load's ambient floor). */
173
181
  _environmentIntensity = 20000
174
182
  readonly camera: Camera
183
+ /** The listener + global 3D audio knobs (docs/audio-plan.md). */
184
+ readonly audio: SceneAudio = new SceneAudio()
175
185
 
176
186
  /** @internal touch listeners, read by the dispatch system. */
177
187
  readonly _clickListeners: Array<(ev: ClickEvent<Node | null>) => void> = []
@@ -220,9 +230,11 @@ export class Scene implements Presentable {
220
230
  // `false` must reach the host: desktop defaults to 4× MSAA, so an explicit off is a real change.
221
231
  _creator.setSceneMultiSampleAntiAliasing(this._id, a !== false, typeof a === "number" ? a : 4)
222
232
  }
233
+ if (options.stencil !== undefined) this.setStencil(options.stencil)
223
234
  // Before anything else in this constructor that could load a texture: the value has to be in
224
235
  // place by the time assets bind their samplers.
225
- if (options.anisotropy !== undefined) _creator.setTextureAnisotropy?.(options.anisotropy)
236
+ if (options.anisotropy !== undefined) Texture.anisotropy = options.anisotropy
237
+ if (options.maxTextureSize !== undefined) Texture.maxSize = options.maxTextureSize
226
238
  if (options.hdr) {
227
239
  if (options.hdr.strength !== undefined) device.hdr.strength = options.hdr.strength
228
240
  if (options.hdr.paperWhite !== undefined) device.hdr.paperWhite = options.hdr.paperWhite
@@ -280,6 +292,28 @@ export class Scene implements Presentable {
280
292
  setAntialias(enabled: boolean, scale = 4): void {
281
293
  _creator.setSceneMultiSampleAntiAliasing(this._id, enabled, scale)
282
294
  }
295
+ /** Depth-reading effects on / off (a graphics-settings menu): soft particles and projected decals
296
+ * read the scene depth, which costs a depth pre-pass of every opaque draw (~12 % of a fill-bound
297
+ * frame). Off = hard-edged particles, no decals, no pre-pass. Engine-wide, live. */
298
+ setDepthEffects(enabled: boolean): void {
299
+ _creator.setDepthEffects?.(enabled)
300
+ }
301
+ /** LOD distance (a graphics-settings menu): the engine's LOD thresholds × `bias`. 2 = every level
302
+ * switches at half the distance (a model must look twice as big on screen to keep its detail),
303
+ * 0.5 = full detail twice as far, 1 = the defaults. Engine-wide, live. Only GLBs that carry
304
+ * `_LOD<n>` meshes (`lecodes assets doctor --lod`) have levels to switch. */
305
+ setLodBias(bias: number): void {
306
+ _creator.setLodBias?.(bias)
307
+ }
308
+ /** Runtime form of `bloom` / `bloomIntensity` (a graphics-settings menu). */
309
+ setBloom(enabled: boolean, intensity = 0.2): void {
310
+ _creator.setBloomOptions(this._id, enabled, intensity, 1)
311
+ }
312
+ /** The scene's stencil buffer on / off (see `SceneOptions.stencil`). */
313
+ setStencil(enabled: boolean): void {
314
+ if (_creator.setSceneStencil) _creator.setSceneStencil(this._id, enabled)
315
+ else if (enabled) console.warn("[scene] this host has no setSceneStencil — stencil ignored")
316
+ }
283
317
 
284
318
  add(...nodes: Node[]): this {
285
319
  for (const n of nodes) _creator.addEntityToScene(this._id, n.id)
@@ -0,0 +1,26 @@
1
+ // `scene.audio` (docs/audio-plan.md §2): the listener and the global 3D knobs. The listener is the
2
+ // scene's active camera by default — a `CameraPlace` switch or a follow rig changes nothing, the
3
+ // engine reads whatever camera it renders with. Set a node to listen from somewhere else (the
4
+ // player's head in a third-person game, so sounds pan around the character, not the camera).
5
+
6
+ import { audioSupported } from "../audio/support"
7
+ import type { Node } from "./Node"
8
+
9
+ export class SceneAudio {
10
+ private _listener: Node | null = null
11
+ private _doppler = 1
12
+
13
+ /** The node the engine listens from; null (default) = the active camera. */
14
+ get listener(): Node | null { return this._listener }
15
+ set listener(node: Node | null) {
16
+ this._listener = node
17
+ if (audioSupported) _creatorAudio.setListener(node ? node.id : 0)
18
+ }
19
+
20
+ /** Multiplies every source's doppler amount (0 = off everywhere). Default 1. */
21
+ get dopplerFactor(): number { return this._doppler }
22
+ set dopplerFactor(v: number) {
23
+ this._doppler = v
24
+ if (audioSupported) _creatorAudio.setListenerOptions(v)
25
+ }
26
+ }
package/src/gl/Texture.ts CHANGED
@@ -2,7 +2,41 @@
2
2
 
3
3
  import { fetch, type FetchResponse, type File } from "../runtime/fetch"
4
4
 
5
+ /** `Texture.load` options. */
6
+ export type TextureLoadOptions = {
7
+ /** `true` (default): colour, stored sRGB. `false`: data (normal map, mask, heightmap) — kept linear. */
8
+ srgb?: boolean
9
+ /** Ignore `Texture.maxSize` for this texture (a lightmap page, a lookup table). */
10
+ fullSize?: boolean
11
+ }
12
+ /** `_creator.createTexture` flags bit: linear data (creator-gl CREATOR_TEXTURE_LINEAR). */
13
+ const TEXTURE_FLAG_LINEAR = 1
14
+ /** `_creator.createTexture` flags bit: exempt from `Texture.maxSize` (creator-gl CREATOR_TEXTURE_FULL_SIZE). */
15
+ const TEXTURE_FLAG_FULL_SIZE = 2
16
+
5
17
  export class Texture {
18
+ static _maxSize = 0
19
+ /** Engine-wide cap on texture size (a "texture quality" setting), 0 = none. A KTX2 wider or
20
+ * taller than this loses its top mip levels on load (nothing resampled, less memory and
21
+ * bandwidth), a glTF PNG/JPEG is downsampled. Reaches textures loaded AFTER it is set — a loaded
22
+ * level keeps its textures — so set it up front (`SceneOptions.maxTextureSize`, or before the
23
+ * level loads) and apply a menu change on the next level load. Lightmap pages are exempt
24
+ * (`TextureLoadOptions.fullSize`). A host may pin it (desktop `CREATOR_TEXTURE_MAX_SIZE`). */
25
+ static _anisotropy = 2
26
+ /** Engine-wide anisotropic filtering, 1 (off) … 16 (default 2; `SceneOptions.anisotropy` sets it up
27
+ * front). A sampler is baked when its texture is bound, so like `maxSize` this reaches textures
28
+ * loaded AFTER it — set it before the level loads. Measured on a lightmapped interior at 720p:
29
+ * 4× costs ~20 % of the frame over 1× on an integrated GPU. A host may pin it. */
30
+ static get anisotropy(): number { return Texture._anisotropy }
31
+ static set anisotropy(level: number) {
32
+ Texture._anisotropy = level >= 1 ? Math.min(16, level) : 1
33
+ _creator.setTextureAnisotropy?.(Texture._anisotropy)
34
+ }
35
+ static get maxSize(): number { return Texture._maxSize }
36
+ static set maxSize(size: number) {
37
+ Texture._maxSize = size > 0 ? Math.floor(size) : 0
38
+ _creator.setTextureMaxSize?.(Texture._maxSize)
39
+ }
6
40
  /** @internal native texture handle. */
7
41
  readonly _id: number
8
42
  readonly width: number
@@ -47,17 +81,23 @@ export class Texture {
47
81
  _creator.updateTexturePixels?.(this._id, x, y, width, height, data)
48
82
  }
49
83
 
50
- static load(source: string | FetchResponse | File): Promise<Texture> {
84
+ /** Decode an image (PNG / JPG, or a KTX2 the core transcodes) into a texture. `srgb` (default
85
+ * true) says the bytes are COLOUR, stored sRGB so the GPU linearises them on sample; pass
86
+ * `false` for DATA — a normal map, a mask, a heightmap — which must come back as stored (a
87
+ * flat normal read through sRGB bends by ~35°). A KTX2 decides by its own header. Image
88
+ * textures get a mip chain (trilinear) on hosts that build one. */
89
+ static load(source: string | FetchResponse | File, options: TextureLoadOptions = {}): Promise<Texture> {
51
90
  if (typeof source === "string") {
52
91
  return fetch(source, { useOnce: true }).then((resp) => {
53
92
  if (resp.status >= 400) {
54
93
  return Promise.reject(new Error(`Failed to fetch texture from ${source}. HTTP ${resp.status}`))
55
94
  }
56
- return Texture.load(resp)
95
+ return Texture.load(resp, options)
57
96
  })
58
97
  }
98
+ const flags = (options.srgb === false ? TEXTURE_FLAG_LINEAR : 0) | (options.fullSize ? TEXTURE_FLAG_FULL_SIZE : 0)
59
99
  return new Promise<Texture>((resolve, reject) => {
60
- _creator.createTexture((source as any)._id, (id, w, h) => resolve(new Texture(w, h, id)), reject)
100
+ _creator.createTexture((source as any)._id, (id, w, h) => resolve(new Texture(w, h, id)), reject, flags)
61
101
  })
62
102
  }
63
103
  }
package/src/gl/Trigger.ts CHANGED
@@ -1,45 +1,45 @@
1
- // A trigger zone, as an aspect on a 3D Node. Requires a Shape (its geometry); creates a static SENSOR
2
- // body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
3
- // instead of colliding. A trigger does not block movement, and is still pointer-pickable.
4
- //
5
- // const goal = new Mesh(box(), material)
6
- // .aspect(Shape, { box: [1, 2, 1] })
7
- // .aspect(Trigger)
8
- // goal.addEventListener('enter', other => win(other))
9
- // goal.addEventListener('exit', other => …)
10
-
11
- import { Aspect } from "../core/Aspect"
12
- import { Shape } from "./Shape"
13
- import { ensurePhysicsEvents } from "./physicsEvents"
14
- import type { Node } from "./Node"
15
-
16
- export class Trigger extends Aspect<"trigger", Node> {
17
- static readonly aspect = "trigger"
18
-
19
- private _bodyId = 0
20
- /** Native body id (0 if no physics support). */
21
- get id(): number { return this._bodyId }
22
-
23
- onAttach(): void {
24
- if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
25
- const shape = this.node.get(Shape)
26
- if (!shape) {
27
- throw new Error("Trigger requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Trigger)")
28
- }
29
- const shapeId = shape._claim()
30
- this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, 0 /* static */, 0, true /* sensor */, false)
31
- shape._ownBody(this._bodyId)
32
- ensurePhysicsEvents() // routes overlap events → the node's 'enter' / 'exit'
33
- }
34
-
35
- onDetach(): void {
36
- if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
37
- const shape = this.node.get(Shape)
38
- if (shape) {
39
- shape._ownBody(0) // the Shape held OUR body id — forget it, or its own detach removes it twice (native crash)
40
- shape._recreatePickBody()
41
- }
42
- }
43
-
44
- // No moveTo(): `node.position = p` moves the node AND the sensor body it owns (see Node._xf).
45
- }
1
+ // A trigger zone, as an aspect on a 3D Node. Requires a Shape (its geometry); creates a static SENSOR
2
+ // body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
3
+ // instead of colliding. A trigger does not block movement, and is still pointer-pickable.
4
+ //
5
+ // const goal = new Mesh(box(), material)
6
+ // .aspect(Shape, { box: [1, 2, 1] })
7
+ // .aspect(Trigger)
8
+ // goal.addEventListener('enter', other => win(other))
9
+ // goal.addEventListener('exit', other => …)
10
+
11
+ import { Aspect } from "../core/Aspect"
12
+ import { Shape } from "./Shape"
13
+ import { ensurePhysicsEvents } from "./physicsEvents"
14
+ import type { Node } from "./Node"
15
+
16
+ export class Trigger extends Aspect<"trigger", Node> {
17
+ static readonly aspect = "trigger"
18
+
19
+ private _bodyId = 0
20
+ /** Native body id (0 if no physics support). */
21
+ get id(): number { return this._bodyId }
22
+
23
+ onAttach(): void {
24
+ if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
25
+ const shape = this.node.get(Shape)
26
+ if (!shape) {
27
+ throw new Error("Trigger requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Trigger)")
28
+ }
29
+ const shapeId = shape._claim()
30
+ this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, 0 /* static */, 0, true /* sensor */, false)
31
+ shape._ownBody(this._bodyId)
32
+ ensurePhysicsEvents() // routes overlap events → the node's 'enter' / 'exit'
33
+ }
34
+
35
+ onDetach(): void {
36
+ if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
37
+ const shape = this.node.get(Shape)
38
+ if (shape) {
39
+ shape._ownBody(0) // the Shape held OUR body id — forget it, or its own detach removes it twice (native crash)
40
+ shape._recreatePickBody()
41
+ }
42
+ }
43
+
44
+ // No moveTo(): `node.position = p` moves the node AND the sensor body it owns (see Node._xf).
45
+ }
package/src/gl/Vehicle.ts CHANGED
@@ -25,13 +25,12 @@
25
25
  //
26
26
  // Layering, and why it is where it is: docs/vehicle-rework-plan.md.
27
27
 
28
- import { Aspect } from "../core/Aspect"
28
+ import { Aspect, _phaseStamp } from "../core/Aspect"
29
29
  import type { FieldMeta } from "../core/fields"
30
30
  import { Mat4 } from "../math/mat4"
31
31
  import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
32
32
  import { cw, type QuatLike } from "../math/quat"
33
33
  import { Gizmos } from "../scene/gizmos"
34
- import { glState } from "./state"
35
34
  import { Gearbox } from "./Gearbox"
36
35
  import { Node } from "./Node"
37
36
  import { DEFAULT_FRICTION, Physics } from "./Physics"
@@ -314,12 +313,13 @@ export class Vehicle extends Aspect<"vehicle", Node> {
314
313
  if (this._id) _creator.vehicleSetTransmission(this._id, ratio, clamp(clutch, 0, 1))
315
314
  }
316
315
 
317
- // --- readouts (one native read per frame, shared by the car and every wheel) -------------------
316
+ // --- readouts (one native read per PHASE — early sees the pre-step state, late the settled one —
317
+ // shared by the car and every wheel; keyed on core/Aspect's _phaseStamp) -------------------
318
318
 
319
319
  private _read(): Float32Array {
320
- if (this._id && this._stateFrame !== glState.frame) {
320
+ if (this._id && this._stateFrame !== _phaseStamp.value) {
321
321
  _creator.vehicleGetState(this._id, this._state)
322
- this._stateFrame = glState.frame
322
+ this._stateFrame = _phaseStamp.value
323
323
  }
324
324
  return this._state
325
325
  }
@@ -10,7 +10,9 @@
10
10
  // AnimationClip.load(asset('./run.glb')),
11
11
  // ])
12
12
  // const slash = await AnimationClip.load(asset('./attacks.glb'), 'Slash') // one out of many
13
- // const bob = AnimationClip.from({ tracks: { Hips: { position: [[0, [0,0,0]], [1, [0,0.05,0]]] } } })
13
+ // const bob = AnimationClip.fromCurves({ tracks: { Hips: { position: [[0, [0,0,0]], [1, [0,0.05,0]]] } } })
14
+ // const stopM = AnimationClip.from(stop, { mirror: true }) // the other side of the body
15
+ // const kick = AnimationClip.from(take, { from: 0.2, to: 1.1 }) // a window of a longer take
14
16
 
15
17
  import { fetch, type FetchResponse } from "../../runtime/fetch"
16
18
  import type { Vec3Like } from "../../math/vec"
@@ -37,6 +39,20 @@ export type ClipDef = {
37
39
 
38
40
  export type ClipInfo = { name: string, duration: number, trackCount: number }
39
41
 
42
+ /** What `AnimationClip.from(clip, …)` derives from a clip. */
43
+ export type DeriveOptions = {
44
+ /** The clip MIRRORED — left ↔ right, the motion on the other side of the body (a stop that brakes on the
45
+ * left foot brakes on the right). Derived on the rig of the clip's own file (joints pair by name, the
46
+ * sagittal plane comes from the rest pose), so the result is the same on every model: its contacts,
47
+ * phase, root motion and heading are the mirrored ones, its name is `<name>_M`. Only clips from a GLB
48
+ * carry a rig; a curve-built clip cannot be mirrored. */
49
+ mirror?: boolean
50
+ /** Start of the window, seconds of the source (default 0). */
51
+ from?: number
52
+ /** End of the window, seconds of the source (default the clip's end). */
53
+ to?: number
54
+ }
55
+
40
56
  const PATH = { position: 0, rotation: 1, scale: 2 } as const
41
57
  const INTERP = { linear: 0, step: 1 } as const
42
58
 
@@ -90,8 +106,8 @@ export class AnimationClip {
90
106
 
91
107
  /** Mark a moment of the clip (SECONDS from its start) with an event name: `kick.addEvent(0.4, 'hit')`
92
108
  * → `anim.on('hit', (clip, layer) => …)` fires when the playhead crosses it, loops included.
93
- * Events are part of the clip: every model playing it gets them; `slice()` keeps the ones inside
94
- * the range, re-timed. Chainable. */
109
+ * Events are part of the clip: every model playing it gets them; `AnimationClip.from(clip, { from, to })`
110
+ * keeps the ones inside the window, re-timed. Chainable. */
95
111
  addEvent(time: number, name: string): this {
96
112
  this._events.push({ t: Math.min(Math.max(0, time), this.duration), name })
97
113
  this._events.sort((a, b) => a.t - b.t)
@@ -128,7 +144,7 @@ export class AnimationClip {
128
144
 
129
145
  /** Build a clip from curves in code — no DCC needed. Keys are `[time, value]`; a track binds to the
130
146
  * node of that name when the clip is used by an Animator (bones, or any child node). */
131
- static from(def: ClipDef): AnimationClip {
147
+ static fromCurves(def: ClipDef): AnimationClip {
132
148
  const names: string[] = []
133
149
  const data: number[] = [0, 0]
134
150
  let trackCount = 0
@@ -153,30 +169,37 @@ export class AnimationClip {
153
169
  if (def.duration !== undefined) duration = def.duration
154
170
  data[1] = duration
155
171
  const setId = _creator.createClipFromTracks(names.join("\n"), new Float32Array(data))
156
- if (setId === 0) throw new Error("AnimationClip.from: invalid track data")
172
+ if (setId === 0) throw new Error("AnimationClip.fromCurves: invalid track data")
157
173
  const info = readInfo(setId)[0] ?? { name: "clip", duration, trackCount }
158
174
  return new AnimationClip(setId, 0, info)
159
175
  }
160
176
 
161
- /** A sub-range of this clip as a NEW clip — `[start, end]` seconds of the source, re-timed to 0
162
- * (`end` defaults to the clip's end; array-slice semantics). Cut a too-long take down to the
163
- * action, or carve several sub-clips out of one packed timeline:
177
+ /** A NEW clip derived from `clip`: its mirror (`{ mirror: true }` — the other side of the body), a window
178
+ * of it (`{ from, to }` seconds of the source, re-timed to 0; array-slice semantics), or both. Cut a
179
+ * too-long take down to the action, carve several sub-clips out of one packed timeline, get the
180
+ * left-footed stop from the right-footed one:
164
181
  *
165
- * clips: { Kick: kick.slice(0.2, 1.1) }
182
+ * clips: { Kick: AnimationClip.from(kick, { from: 0.2, to: 1.1 }), StopM: AnimationClip.from(stop, { mirror: true }) }
166
183
  *
167
- * Keys outside the range are dropped and exact boundary values are interpolated in, so the
168
- * sliced clip starts and ends precisely on the source's pose at the cut points. Everything
169
- * downstream (blend spaces, events' 0–1 times, root motion, phase sync) sees a normal clip of
170
- * the new duration. */
171
- slice(start: number, end?: number): AnimationClip {
172
- const e = Math.min(end ?? this.duration, this.duration)
173
- const st = Math.max(0, start)
174
- const setId = _creator.sliceClip(this._set, this._index, st, e)
175
- if (setId === 0) throw new Error(`AnimationClip.slice: bad range ${start}${end !== undefined ? `–${end}` : ""} of '${this.name}' (${this.duration.toFixed(2)}s)`)
176
- const info = readInfo(setId)[0] ?? { name: this.name, duration: e - st, trackCount: this.trackCount }
184
+ * A window drops the keys outside it and interpolates exact boundary values in, so the clip starts and
185
+ * ends precisely on the source's pose at the cut points. Everything downstream (blend spaces, events'
186
+ * times, root motion, foot contacts, phase sync) sees a normal clip. */
187
+ static from(clip: AnimationClip, options: DeriveOptions): AnimationClip {
188
+ const mirror = options.mirror ?? false
189
+ const windowed = options.from !== undefined || options.to !== undefined
190
+ const st = windowed ? Math.max(0, options.from ?? 0) : -1
191
+ const e = windowed ? Math.min(options.to ?? clip.duration, clip.duration) : -1
192
+ const setId = _creator.deriveClip(clip._set, clip._index, mirror, st, e)
193
+ if (setId === 0) {
194
+ const what = windowed ? `bad range ${options.from ?? 0}–${options.to ?? clip.duration}` : "cannot mirror (no rig in the clip's file, or nothing to mirror)"
195
+ throw new Error(`AnimationClip.from: ${what} of '${clip.name}' (${clip.duration.toFixed(2)}s)`)
196
+ }
197
+ const info = readInfo(setId)[0] ?? { name: mirror ? `${clip.name}_M` : clip.name, duration: windowed ? e - st : clip.duration, trackCount: clip.trackCount }
177
198
  const out = new AnimationClip(setId, 0, info)
178
199
  // the engine re-timed the event TIMES; carry the names the same way
179
- out._events = this._events.filter((ev) => ev.t >= st - 1e-6 && ev.t <= e + 1e-6).map((ev) => ({ t: Math.min(Math.max(0, ev.t - st), e - st), name: ev.name }))
200
+ out._events = windowed
201
+ ? clip._events.filter((ev) => ev.t >= st - 1e-6 && ev.t <= e + 1e-6).map((ev) => ({ t: Math.min(Math.max(0, ev.t - st), e - st), name: ev.name }))
202
+ : clip._events.map((ev) => ({ ...ev }))
180
203
  return out
181
204
  }
182
205
 
@@ -5,15 +5,16 @@
5
5
  //
6
6
  // const hero = await Model.load(asset('./hero.glb'))
7
7
  // hero.anim.playLoop('Idle') // the loop: what the character rests on
8
- // await hero.anim.play('Jump', { fadeIn: 0.1, fadeOut: 0.2 }) // a one-shot over it, back to the loop after
8
+ // await hero.anim.play('Jump', { fade: 0.2 }) // a one-shot over it, back to the loop after
9
9
  //
10
10
  // const loco = hero.anim.playLoop({ Idle: 0, Walk: 2, Run: 6 }) // a blend space as the loop
11
11
  // loco.value = hero.controller.velocity.length // where we are on its axis
12
12
  // const upper = hero.anim.addLayer({ mask: 'Spine1' })
13
13
  // upper.playLoop('Aim') // arms aim, legs keep walking
14
14
  //
15
- // Every switch is an inertialized transition: the pose shown does not fade away, its difference to the
16
- // new source decays over `fade` (default 0.2 s; 0 = cut). A clip may be replaced at any time.
15
+ // Every switch is an inertialized transition: the pose shown (a loop, a one-shot, rest) does not fade
16
+ // away, its difference to the new source decays over `fade` — and no `fade` means a cut, never a
17
+ // default. A clip may be replaced at any time; a one-shot on a layer with no loop holds its last frame.
17
18
  //
18
19
  // The surface, top to bottom: clips → playing → root motion → the feet and the warp (`anim.feet`,
19
20
  // `anim.warp` — locomotion's post-processing of the pose) → level of detail → engine-facing.
@@ -18,19 +18,17 @@ import type { Node } from "../Node"
18
18
  /** A position on a blend axis (1D) or plane (2D). */
19
19
  export type BlendPosition = number | readonly [number, number]
20
20
 
21
- /** The transition time (seconds) a play / playLoop without `fade` uses. */
22
- export const DEFAULT_FADE = 0.2
23
-
24
21
  export type PlayOptions = {
25
- /** Transition seconds — how long the difference to the pose the layer SHOWED takes to decay
26
- * (default 0.2; `0` = cut). Also the transition back at the end unless `fadeOut` overrides it. */
22
+ /** Transition seconds — how long the difference to the pose the layer SHOWED (its loop, a one-shot,
23
+ * or rest) takes to decay. Default `0` = cut: a fade is always asked for, never implied. Also the
24
+ * transition back to the loop at the end unless `fadeOut` overrides it. */
27
25
  fade?: number
28
26
  /** Transition seconds for the way in only (overrides `fade`). */
29
27
  fadeIn?: number
30
- /** One-shots: the transition BACK at the end (overrides `fade`). It starts this long before the
31
- * clip ends — while it still plays — so the hand-over lands on the last pose, not after it. On a
32
- * layer with no loop to return to, giving `fadeOut` releases the layer at the end instead of
33
- * holding the last frame. */
28
+ /** One-shots: the transition BACK to the loop at the end (overrides `fade`; `0` = cut at the end).
29
+ * It starts this long before the clip ends — while it still plays — so the hand-over lands on the
30
+ * last pose, not after it. On a layer with no loop the clip HOLDS its last frame (a rock that broke
31
+ * stays broken) — `anim.stop({ fade })` is the way back to rest. */
34
32
  fadeOut?: number
35
33
  /** Playback rate for this clip (default 1). */
36
34
  speed?: number
@@ -52,8 +50,8 @@ export type PlayOptions = {
52
50
  export type StopOptions = { fade?: number }
53
51
 
54
52
  export type LoopOptions = {
55
- /** Transition seconds from whatever the layer shows — its previous loop, or a one-shot still
56
- * playing (default 0.2; `0` = cut). */
53
+ /** Transition seconds from whatever the layer shows — its previous loop, a one-shot still playing,
54
+ * or rest. Default `0` = cut: a loop wanting a transition asks for one (`{ fade: 0.2 }`). */
57
55
  fade?: number
58
56
  /** Playback rate of the loop's clips (default 1). */
59
57
  speed?: number
@@ -145,6 +143,12 @@ export type ClipInfo = {
145
143
  turnAt(time: number): number
146
144
  /** the root's own speed at `time`, m/s — how fast the clip is moving the body right there */
147
145
  speedAt(time: number): number
146
+ /** the PELVIS' yaw relative to the clip's heading at `time` (rad, + = left, wrapped to ±π): the stance twist
147
+ * the heading leaves out — an idle stands ~43° off the course it starts along, a stop's tail turns into that
148
+ * stance while the heading holds. A controller that switches clips mid-pose can keep the PELVIS continuous
149
+ * (turn the node by the difference between the two clips' values) where keeping the course would swing
150
+ * the body. Without a baked heading: the pelvis' yaw at frame 0 throughout. */
151
+ pelvisYawAt(time: number): number
148
152
  /** the unit travel direction at `time` ([x, z], model space, held through stills) — integrate
149
153
  * direction × d(travel) to reconstruct the root's 2D path (a treadmill display, a turn's arc) */
150
154
  directionAt(time: number): [number, number]
@@ -215,7 +219,7 @@ const ensureEvents = (): void => {
215
219
  if (eventsInstalled) return
216
220
  eventsInstalled = true
217
221
  _creator.setOnAnimatorEvent((animatorId, slot, type) => handlers.get(animatorId)?.(slot, type))
218
- _creator.setOnAnimatorStep((animatorId, side, x, y, z) => stepHandlers.get(animatorId)?.(side, x, y, z))
222
+ _creator.setOnAnimatorStep?.((animatorId, side, x, y, z) => stepHandlers.get(animatorId)?.(side, x, y, z))
219
223
  }
220
224
 
221
225
  const settled = (slot: SlotRec): PlaybackRec => {
@@ -364,8 +368,8 @@ export class Core {
364
368
  const s = this.slotFor(L, name, clip)
365
369
  if (!s) return new Playback(settled({ name, clip, slot: -1, layer: L, inBlend: false }), this)
366
370
  if (s.inBlend) { console.warn(`Animator: '${name}' is part of this layer's loop — use playLoop() to change what it rests on`); return new Playback(settled(s), this) }
367
- const fadeIn = o.fadeIn ?? o.fade ?? DEFAULT_FADE
368
- const fadeOut = o.fadeOut ?? o.fade ?? DEFAULT_FADE
371
+ const fadeIn = o.fadeIn ?? o.fade ?? 0
372
+ const fadeOut = o.fadeOut ?? o.fade ?? 0
369
373
  const phase = this.entryPhase(L, o.phase) // read BEFORE the play: what the layer shows now
370
374
  this.settleLayer(L) // the layer had one source: whatever it was, this play takes over from it
371
375
  let resolve = (_n: boolean): void => {}
@@ -399,7 +403,7 @@ export class Core {
399
403
  // ---- loop ----
400
404
  loop(L: LayerRec, def: LoopDef, o: LoopOptions): Loop | undefined {
401
405
  if (!this.ensure()) return undefined
402
- const fade = o.fade ?? DEFAULT_FADE
406
+ const fade = o.fade ?? 0
403
407
  const phase = this.entryPhase(L, o.phase) // read BEFORE the members replace the layer's source
404
408
  if (L.loop) { L.loop.dead = true; for (const m of L.loop.members) m.slot.inBlend = false; L.loop = undefined }
405
409
  this.settleLayer(L) // a one-shot on the layer is cut short by the new loop
@@ -576,6 +580,7 @@ export class Core {
576
580
  travelAt: (time: number) => _creator.animatorSlotCurveAt(id, slot, 1, time),
577
581
  turnAt: (time: number) => _creator.animatorSlotCurveAt(id, slot, 2, time),
578
582
  speedAt: (time: number) => _creator.animatorSlotCurveAt(id, slot, 3, time),
583
+ pelvisYawAt: (time: number) => { const v = _creator.animatorSlotCurveAt(id, slot, 6, time); return Math.atan2(Math.sin(v), Math.cos(v)) },
579
584
  directionAt: (time: number) => [ _creator.animatorSlotCurveAt(id, slot, 4, time), _creator.animatorSlotCurveAt(id, slot, 5, time) ] as [number, number],
580
585
  timeAtTurn: (yaw: number) => _creator.animatorSlotTimeAtTurn(id, slot, yaw),
581
586
  kneePoleAt: (side: "left" | "right", time: number) => {