lecodes-sdk 2.4.0 → 2.4.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 (52) hide show
  1. package/dist/types/core/Aspect.d.ts +34 -5
  2. package/dist/types/g2/OneWay2D.d.ts +0 -1
  3. package/dist/types/g2/Physics2D.d.ts +8 -6
  4. package/dist/types/g2/Scene2D.d.ts +1 -1
  5. package/dist/types/g2/Shape2D.d.ts +43 -15
  6. package/dist/types/gl/Model.d.ts +3 -2
  7. package/dist/types/gl/Scene.d.ts +1 -1
  8. package/dist/types/gl/animation/DynamicBone.d.ts +99 -35
  9. package/dist/types/gl/animation/IK.d.ts +0 -1
  10. package/dist/types/gl/nav/NavAgent.d.ts +3 -2
  11. package/dist/types/gl/physics/Physics.d.ts +4 -5
  12. package/dist/types/gl/physics/Shape.d.ts +40 -15
  13. package/dist/types/gl/vehicle/Vehicle.d.ts +54 -35
  14. package/dist/types/gl/vehicle/Wheel.d.ts +31 -10
  15. package/dist/types/plugins/gen/ar/contract.d.ts +152 -0
  16. package/dist/types/plugins/gen/ar/sdk/ar.d.ts +47 -21
  17. package/dist/types/plugins/gen/ar/sdk/ar.gen.d.ts +17 -3
  18. package/dist/types/plugins/gen/audio-recorder/contract.d.ts +64 -0
  19. package/dist/types/plugins/gen/browser/contract.d.ts +108 -0
  20. package/dist/types/plugins/gen/camera/contract.d.ts +27 -0
  21. package/dist/types/plugins/gen/geolocation/contract.d.ts +52 -0
  22. package/dist/types/plugins/gen/map/contract.d.ts +144 -0
  23. package/dist/types/plugins/gen/push/contract.d.ts +83 -0
  24. package/dist/types/plugins/gen/qr-scanner/contract.d.ts +16 -0
  25. package/dist/types/plugins/gen/speech/contract.d.ts +67 -0
  26. package/dist/types/plugins/gen/webrtc/contract.d.ts +342 -0
  27. package/dist/types/version.d.ts +1 -1
  28. package/dist/types.json +1 -1
  29. package/package.json +1 -1
  30. package/src/bridges/gl.d.ts +5 -3
  31. package/src/compile/compileProject.ts +45 -10
  32. package/src/compile/index.ts +7 -0
  33. package/src/compile/liteMaterial.ts +6 -1
  34. package/src/compile/webHalf.ts +255 -0
  35. package/src/core/Aspect.ts +118 -35
  36. package/src/g2/OneWay2D.ts +0 -2
  37. package/src/g2/Physics2D.ts +8 -16
  38. package/src/g2/Scene2D.ts +1 -1
  39. package/src/g2/Shape2D.ts +51 -22
  40. package/src/gl/Model.ts +3 -2
  41. package/src/gl/Scene.ts +1 -1
  42. package/src/gl/animation/DynamicBone.ts +549 -486
  43. package/src/gl/animation/IK.ts +0 -2
  44. package/src/gl/nav/NavAgent.ts +14 -12
  45. package/src/gl/physics/Physics.ts +4 -5
  46. package/src/gl/physics/Shape.ts +49 -23
  47. package/src/gl/vehicle/Vehicle.ts +102 -58
  48. package/src/gl/vehicle/Wheel.ts +45 -11
  49. package/src/plugins/gen/ar/contract.d.ts +35 -1
  50. package/src/plugins/gen/ar/sdk/ar.gen.ts +25 -6
  51. package/src/plugins/gen/ar/sdk/ar.ts +102 -35
  52. package/src/version.ts +1 -1
@@ -1,486 +1,549 @@
1
- // DynamicBone — secondary motion for tails, ears, hair, ropes and cloaks, simulated in the engine.
2
- // Attach to the ROOT bone of a chain; the bone's whole subtree becomes Verlet particle chains that
3
- // swing, lag and settle under gravity, wind and the character's motion, pulled back toward the pose
4
- // the animation gives them, kept out of the body by capsule colliders and above the floor.
5
- //
6
- // const tail = fox.bone('tail')!
7
- // tail.aspect(DynamicBone, { stiffness: [0.6, 0.15], damping: 0.15, gravity: 2, angleLimit: 50 })
8
- // fox.bone('earend')!.aspect(DynamicBone, { stiffness: 0.8, angleLimit: 25 })
9
- // fox.bone('Hips')!.aspect(DynamicBoneCollider, { radius: 0.12, to: 'chest' }) // a capsule the tail can't enter
10
- // tail.dynamicBone.weight = cutscene ? 0 : 1 // blend to the animation
11
- // tail.dynamicBone.floor = hit.point // the ground under it
12
- // tail.dynamicBone.reset() // after a teleport
13
- //
14
- // Per-bone values (`radius`, `stiffness`, `damping`, `gravity`, `angleLimit`) take one number for the
15
- // whole chain or a `[root, tip]` pair interpolated down the chain — a tail is stiff at the base and
16
- // loose at the tip. A cloak is one aspect on the common parent of its columns with `link` > 0: the
17
- // bones at the same depth of neighbouring columns are tied at their rest distance.
18
- //
19
- // The simulation runs natively in a stage of its own after the late phase and before the skin flush
20
- // (docs/parity: gl-dynamic-bones): the animator, an active ragdoll and late-phase aspects (IK) are
21
- // its input; every field of this aspect is live — the values are pushed when they change.
22
-
23
- import { Aspect } from "../../core/Aspect"
24
- import type { FieldMeta } from "../../core/fields"
25
- import { Vec3, cx, cy, cz, type Vec3Like } from "../../math/vec"
26
- import { Node } from "../Node"
27
- import { Model } from "../Model"
28
- import { HUMANOID, humanoidParts } from "../physics/Ragdoll"
29
-
30
- /** One value for the chain, or `[root, tip]` interpolated down it by depth. */
31
- export type DynamicBoneCurve = number | readonly [root: number, tip: number]
32
- /** `'none'`, `'probe'` (a ray from the chain root down against the physics world, every frame), a
33
- * height (a horizontal plane at that world y) or a world point (a plane through it, `floorNormal` up). */
34
- export type DynamicBoneFloor = "none" | "probe" | number | Vec3Like
35
- /** `'auto'` = every DynamicBoneCollider under the model; `'humanoid'` = those plus capsules generated
36
- * on the standard humanoid bones (hips, spine, head, limbs — the Ragdoll's layout); `'none'`; or the
37
- * nodes carrying the colliders to use. */
38
- export type DynamicBoneColliders = "auto" | "humanoid" | "none" | Node[]
39
- /** The cloth's SIDE of every collider marked `oneSided`: `'none'` = no side, every collider pushes a bone to
40
- * its nearest surface (tails, ropes); `'auto'` = away from the root bone's own axis (a cloak's strips round
41
- * a spine face out from it); a vector = a fixed direction in the root bone's frame (a cape hanging BEHIND
42
- * the arms: the body's backward). A one-sided collider that is moving AWAY from that side — an arm swinging
43
- * forward under the cape — puts a bone it has run into over to the side instead of carrying it: the arm
44
- * passes through the cloth and the cape hangs behind it again, instead of being dragged round the body.
45
- * Still, or moving toward the cloth, it pushes like any other collider, so cloth draped on an arm at rest
46
- * stays where it is. Two-sided colliders (the body's) never read the side. */
47
- export type DynamicBoneSide = "none" | "auto" | Vec3Like
48
-
49
- const VERSION = 1
50
- const PARAMS = 25 // CANIM_DYN_COUNT
51
- const HEADER = 3 + PARAMS
52
- const STRIDE = 7 // CANIM_DYN_BONE_STRIDE
53
- const FLOOR_NONE = 0, FLOOR_PLANE = 1, FLOOR_PROBE = 2
54
-
55
- let warned = false
56
- const supported = (): boolean => {
57
- if (DynamicBone.supported) return true
58
- if (!warned) { warned = true; console.warn("DynamicBone: this host has no dynamic bones (DynamicBone.supported is false) — the animation shows as is") }
59
- return false
60
- }
61
-
62
- const at = (c: DynamicBoneCurve, t: number): number => typeof c === "number" ? c : c[0] + (c[1] - c[0]) * t
63
-
64
- /** The model a bone belongs to: the topmost Model ancestor, else the topmost ancestor. */
65
- const modelOf = (n: Node): Node => {
66
- let top: Node = n, model: Node | null = null
67
- for (let p: Node | null = n; p; p = p.parent) { top = p; if (p instanceof Model) model = p }
68
- return model ?? top
69
- }
70
-
71
- /** A capsule (or a sphere) riding a bone — what dynamic bones stay out of. Attach to a body bone:
72
- * `hips.aspect(DynamicBoneCollider, { radius: 0.12, to: 'Spine' })`. The capsule runs from
73
- * `offset` (the bone's origin by default) to the `to` bone's origin, or to `end` (both in the bone's
74
- * local space); neither = a sphere at `offset`. Picked up by every DynamicBone on the same model
75
- * with `colliders: 'auto'` / `'humanoid'`, or listed explicitly. */
76
- export class DynamicBoneCollider extends Aspect<"dynamicBoneCollider", Node> {
77
- static readonly aspect = "dynamicBoneCollider"
78
-
79
- /** Radius in metres, in the world: the bone's scale does not touch it (`offset` / `end` are in the bone's own units). */
80
- radius = 0.1
81
- /** Start of the capsule in the bone's local space (default: the bone's origin). */
82
- offset: Vec3Like = [ 0, 0, 0 ]
83
- /** End of the capsule in the bone's local space (a sphere when neither `end` nor `to` is set). */
84
- end?: Vec3Like
85
- /** The bone the capsule reaches (its origin), instead of `end`. */
86
- to?: string
87
- /** ONE-SIDED: the cloth belongs on the chain's `side` of this capsule (see DynamicBone.side). While the capsule
88
- * moves away from that side it puts a bone it has run into over to the side instead of carrying it — an arm
89
- * under a cape may push the cape back, never drag it forward round the body. Needs a `side` on the DynamicBone. */
90
- oneSided = false
91
-
92
- static fields: FieldMeta<DynamicBoneCollider> = {
93
- radius: { min: 0, max: 2, step: 0.005 },
94
- offset: { hidden: true },
95
- end: { hidden: true },
96
- to: { hidden: true },
97
- oneSided: { label: "One-sided" },
98
- }
99
-
100
- private _id = 0
101
-
102
- /** @internal the engine's collider id (0 when the host has none). */
103
- get _colliderId(): number { return this._id }
104
-
105
- private _segment(): [Vec3, Vec3] {
106
- const a = new Vec3(this.offset)
107
- if (this.to) {
108
- const target = modelOf(this.node).bone(this.to)
109
- if (!target) throw new Error(`DynamicBoneCollider: no bone '${this.to}' (the 'to' of '${this.node.name}')`)
110
- const b = this.node.worldMatrix.invert().transformPoint(target.worldPosition)
111
- return [ a, b ]
112
- }
113
- return [ a, this.end ? new Vec3(this.end) : a ]
114
- }
115
-
116
- onAttach(): void {
117
- if (!supported()) return
118
- const [ a, b ] = this._segment()
119
- this._id = _creator.dynamicBoneColliderCreate!(this.node.id, a.x, a.y, a.z, b.x, b.y, b.z, this.radius, this.oneSided ? 1 : 0)
120
- DynamicBone._collidersChanged()
121
- }
122
-
123
- onReconfigure(): void {
124
- if (!this._id) return
125
- const [ a, b ] = this._segment()
126
- _creator.dynamicBoneColliderSet!(this._id, a.x, a.y, a.z, b.x, b.y, b.z, this.radius, this.oneSided ? 1 : 0)
127
- }
128
-
129
- onDetach(): void {
130
- if (this._id) { _creator.dynamicBoneColliderDestroy!(this._id); this._id = 0; DynamicBone._collidersChanged() }
131
- }
132
- }
133
-
134
- export class DynamicBone extends Aspect<"dynamicBone", Node> {
135
- static readonly aspect = "dynamicBone"
136
-
137
- /** Whether this host simulates dynamic bones. False, never a throw, on Web Lite and in headless
138
- * tests; there the aspect attaches, warns once, and `chain` stays empty. */
139
- static get supported(): boolean { return typeof _creator !== "undefined" && !!_creator && typeof _creator.dynamicBoneCreate === "function" }
140
-
141
- /** Only these bones (by name) join the chain — the root always does, a bone left out takes its
142
- * subtree with it. Default: the root's whole subtree. */
143
- bones?: string[]
144
- /** Depth limit under the root (0 = no limit). */
145
- depth = 0
146
- /** Particle radius for the collisions, metres. */
147
- radius: DynamicBoneCurve = 0.02
148
- /** 0..1 — how fast a bone returns to the animated shape (per 1/60 s). 0 = a rope, 1 = rigid. */
149
- stiffness: DynamicBoneCurve = 0.3
150
- /** 0..1 — velocity lost per 1/60 s. 0.02 swings like a rope, 0.1 settles like a tail, 0.3 is honey. */
151
- damping: DynamicBoneCurve = 0.1
152
- /** m/s² downward. A hanging rest pose feels none of it (the bone length cancels it); a pose that
153
- * sticks out droops by as much as `stiffness` lets it. */
154
- gravity: DynamicBoneCurve = 0
155
- /** Max deviation from the animated direction, degrees per bone (0 = free). */
156
- angleLimit: DynamicBoneCurve = 0
157
- /** Relative mass per bone. A bone-length constraint moves its two ends in inverse proportion to
158
- * their masses (the root is kinematic): `[3, 1]` makes a tail's base carry its tip, `1` shares
159
- * evenly like a rope. */
160
- mass: DynamicBoneCurve = 1
161
- /** Bones (by name) that ride the animation exactly while their subtrees still simulate: a sheet's
162
- * several roots under one anchor (a cloak's seven strips under the spine), so ONE chain owns them all
163
- * and `link` ties neighbouring strips — neighbours in THIS order, so list the strips round the ring
164
- * (front-left … back … front-right); the front stays open. */
165
- pinned?: string[]
166
- /** Metres a PINNED bone may be pushed off its animated place by a collider — a soft pin. Its particle
167
- * collides and takes the edge pushes like a free bone (the strip below hangs from where it IS) and is
168
- * drawn back to the animation when the collider leaves; the bone's local translation follows. A hard
169
- * pin (0) at a capsule's edge made the first free bone below it jitter: the edge push on a segment
170
- * with an immovable end is a lever, and a touch near the root threw the child out. 2–3 cm for a
171
- * cloak's roots under a shoulder capsule. */
172
- pinGive = 0
173
- /** 0..1 of the character's travel the chain takes rigidly (0 = the full whip on a dash, 1 = it
174
- * moves with the body and only the pose's own motion swings it). */
175
- follow = 0
176
- /** World wind, m/s². */
177
- wind: Vec3Like = [ 0, 0, 0 ]
178
- /** The BODY'S TURN, ≥ 0. Above 0 the chain lives in the parent bone's ROTATING frame: `damping` acts
179
- * against the body's rigid motion (its travel plus its spin), so a heavily damped cloak RIDES a turn —
180
- * sweeps round with the back — instead of standing in the world while the character spins under it and
181
- * being dragged round after. The turn's inertia comes back as the frame's forces, scaled by this value:
182
- * a centrifugal push `ω² · r` along each bone's animated direction from the axis (the hem, farthest
183
- * from the spine, flies out and, on the chain's length, up). The frame's own physics is built in: the
184
- * whip back when the turn starts and forward when it stops, Coriolis. 1 = the physical push; more = a
185
- * bigger fling. With low damping the frame reproduces a free particle (nothing is counted twice);
186
- * an animation's spine sway is ~1 % of a 900°/s turn. A rotation over 90° in one frame (a respawn
187
- * facing) counts as a snap, not a spin. 0 = the plain translational frame, no turn forces. */
188
- spin = 0
189
- /** With `spin`: the cloth's INERTIA against the body's turn, seconds — the cloth's own rotation follows the
190
- * body's with this time constant. It falls behind when a turn starts (by about ω·τ: 45° at 900°/s and 0.05),
191
- * rides once caught up, and keeps turning past the back when the body stops (the same angle, eased out over
192
- * τ). Smooth by construction. 0 = glued to the turn; large = the cloth stays in the world while the body spins. */
193
- spinInertia = 0
194
- /** 0..1 strength of the ties between neighbouring columns at the same depth (a cloak). */
195
- link = 0
196
- /** 0..1 blend from the animation (0) to the simulation (1). At 0 nothing is simulated and the
197
- * chain re-arms on the animated pose when it comes back. */
198
- weight = 1
199
- /** Substep rate, Hz (≤ 4 substeps per frame). */
200
- rate = 60
201
- /** A root jump longer than this in one frame (metres) resets the chain instead of whipping it. */
202
- teleport = 1
203
- /** What the particles stay above: `'none'`, `'probe'`, a height, or a world point. */
204
- floor: DynamicBoneFloor = "none"
205
- /** The floor plane's normal when `floor` is a point. */
206
- floorNormal: Vec3Like = [ 0, 1, 0 ]
207
- /** Friction against the floor, a Coulomb coefficient: a resting bone's slide loses up to
208
- * `floorFriction · gravity · dt` of speed per substep (0 = ice, 1 = a rubber sole). While a bone
209
- * rests on the floor or a collider, `stiffness` and `angleLimit` let go of it: the surface's
210
- * reaction outranks the spring, or a hem folded on the floor would run away along it. */
211
- floorFriction = 0.5
212
- /** Constraint passes per substep (1..8, default 4). Each pass shares every bone length between its
213
- * ends, so a pull needs passes to travel down a long chain: a long rope may want 8, a short tail is
214
- * fine with 2. */
215
- iterations = 4
216
- /** What the chain collides with (see DynamicBoneColliders). */
217
- colliders: DynamicBoneColliders = "auto"
218
- /** The cloth's outside — makes the colliders one-sided (see DynamicBoneSide). */
219
- side: DynamicBoneSide = "none"
220
- /** How firmly `guide()` targets are HELD through the constraint passes, 0..1. At 0 a guide is applied once
221
- * before the passes and the length / link passes may drag the bone back toward its un-guided neighbours
222
- * within the same frame; at 0.5 it is re-pulled after every pass with half its weight (converges with the
223
- * passes), at 1 with its full weight (a target the bone length cannot reach then jitters). */
224
- guideHold = 0
225
- /** The chain's EDGES collide with the capsules too — every bone segment and every link, not only the bones'
226
- * particles: a capsule thinner than a bone's length (a forearm) no longer passes through the cloth between
227
- * two bones. Off = particles only. */
228
- edges = true
229
-
230
- static fields: FieldMeta<DynamicBone> = {
231
- depth: { min: 0, max: 32, step: 1 },
232
- radius: { min: 0, max: 1, step: 0.005 },
233
- stiffness: { min: 0, max: 1, step: 0.01 },
234
- damping: { min: 0, max: 1, step: 0.01 },
235
- gravity: { min: 0, max: 30, step: 0.1 },
236
- angleLimit: { label: "Angle limit (°)", min: 0, max: 180, step: 1 },
237
- mass: { min: 0.1, max: 10, step: 0.1 },
238
- follow: { min: 0, max: 1, step: 0.01 },
239
- link: { min: 0, max: 1, step: 0.01 },
240
- weight: { min: 0, max: 1, step: 0.01 },
241
- rate: { min: 30, max: 240, step: 10 },
242
- teleport: { min: 0, max: 20, step: 0.1 },
243
- floorFriction: { label: "Floor friction", min: 0, max: 1, step: 0.01 },
244
- iterations: { min: 1, max: 8, step: 1 },
245
- bones: { hidden: true },
246
- pinned: { hidden: true },
247
- pinGive: { label: "Pin give (m)", min: 0, max: 0.2, step: 0.005 },
248
- wind: { hidden: true },
249
- spin: { min: 0, max: 4, step: 0.05 },
250
- spinInertia: { label: "Spin inertia (s)", min: 0, max: 0.3, step: 0.005 },
251
- floor: { hidden: true },
252
- floorNormal: { hidden: true },
253
- colliders: { hidden: true },
254
- side: { hidden: true },
255
- guideHold: { min: 0, max: 1, step: 0.05 },
256
- edges: { hidden: true },
257
- }
258
-
259
- private _id = 0
260
- private _chain: Node[] = []
261
- private _depths: number[] = []
262
- private _blob = new Float32Array(0)
263
- private _sent = new Float32Array(0)
264
- private _floorKey = ""
265
- private _colliderKey = ""
266
- private _collidersDirty = true
267
- private _collidersRef: DynamicBoneColliders | null = null
268
- private _own: number[] = [] // colliders generated for 'humanoid' (destroyed with the aspect)
269
- private _colliderSet: (Node | "auto" | "humanoid" | "none")[] | null = null
270
-
271
- /** The bones of the chain, the root first. */
272
- get chain(): readonly Node[] { return this._chain }
273
-
274
- onAttach(): void {
275
- if (!supported()) return
276
- this._collect()
277
- if (this._chain.length === 0) return
278
- const n = this._chain.length
279
- const bones = new Uint32Array(n)
280
- for (let i = 0; i < n; i++) bones[i] = this._chain[i]!.id
281
- this._blob = new Float32Array(HEADER + n * STRIDE)
282
- this._fill(this._blob)
283
- this._id = _creator.dynamicBoneCreate!(modelOf(this.node).id, bones, this._blob, this._links())
284
- if (!this._id) { console.warn(`DynamicBone: the host refused the chain under '${this.node.name}'`); return }
285
- this._sent = this._blob.slice()
286
- this._floorKey = ""; this._colliderKey = ""; this._collidersDirty = true
287
- this._pushFloor()
288
- this._pushColliders()
289
- }
290
-
291
- onReconfigure(): void { this._sync() }
292
-
293
- /** Every field is live: what changed since the last frame is pushed here, before the engine's stage. */
294
- update(): void { this._sync() }
295
-
296
- onDetach(): void {
297
- if (this._id) { _creator.dynamicBoneDestroy!(this._id); this._id = 0 }
298
- for (const id of this._own) _creator.dynamicBoneColliderDestroy!(id)
299
- this._own = []
300
- this._chain = []
301
- }
302
-
303
- /** Snap the chain onto the animated pose next frame (a teleport, a camera cut). */
304
- reset(): void { if (this._id) _creator.dynamicBoneReset!(this._id) }
305
-
306
- /** GUIDES: world points the bones' particles are drawn to each substep before the constraints —
307
- * weight 1 = there before them, the rest of the chain hangs, collides and links as before (a cape's
308
- * strips riding the arms). Replaces the previous set; an empty list clears. A late-phase script
309
- * (after the animator, before the engine's chain stage) refreshes it every frame. */
310
- guide(rows: readonly { bone: Node, at: Vec3Like, weight: number }[]): void {
311
- if (!this._id) return
312
- const out: number[] = []
313
- for (const r of rows) {
314
- const i = this._chain.indexOf(r.bone)
315
- if (i < 0) continue
316
- out.push(i, cx(r.at), cy(r.at), cz(r.at), r.weight)
317
- }
318
- _creator.dynamicBoneTargets?.(this._id, out.length ? new Float32Array(out) : null)
319
- }
320
-
321
- /** The particles' world positions (the bones, then the leaves' virtual tips) — for a debug draw. */
322
- get particles(): Vec3[] {
323
- if (!this._id) return []
324
- const out = new Float32Array((this._chain.length * 2) * 3)
325
- const n = _creator.dynamicBoneParticles!(this._id, out)
326
- const pts: Vec3[] = []
327
- for (let i = 0; i < n; i++) pts.push(new Vec3(out[i * 3]!, out[i * 3 + 1]!, out[i * 3 + 2]!))
328
- return pts
329
- }
330
-
331
- // ---- internals ----------------------------------------------------------------------------------
332
-
333
- private _collect(): void {
334
- const chain: Node[] = [], depths: number[] = []
335
- const only = this.bones ? new Set(this.bones) : null
336
- const walk = (n: Node, d: number): void => {
337
- chain.push(n); depths.push(d)
338
- if (this.depth > 0 && d >= this.depth) return
339
- for (const c of n.children) {
340
- if (only && !only.has(c.name)) continue
341
- if (c.has(DynamicBone) || c.has(DynamicBoneCollider)) continue // another chain / a body bone
342
- walk(c, d + 1)
343
- }
344
- }
345
- walk(this.node, 0)
346
- this._chain = chain; this._depths = depths
347
- }
348
-
349
- private _fill(b: Float32Array): void {
350
- const n = this._chain.length
351
- let maxDepth = 0
352
- for (const d of this._depths) if (d > maxDepth) maxDepth = d
353
- b[0] = VERSION; b[1] = n; b[2] = STRIDE
354
- b[3] = this.weight; b[4] = this.follow
355
- b[5] = cx(this.wind); b[6] = cy(this.wind); b[7] = cz(this.wind)
356
- b[8] = this.link; b[9] = this.rate; b[10] = this.teleport
357
- b[11] = 0; b[12] = 0; b[13] = 0; b[14] = 0; b[15] = 0; b[16] = 1; b[17] = 0 // the floor goes through setFloor
358
- b[18] = this.floorFriction
359
- b[19] = Math.max(1, Math.min(8, Math.round(this.iterations)))
360
- const side = this.side
361
- const sideVec = side === "none" || side === "auto" ? null : side
362
- b[20] = side === "none" ? 0 : side === "auto" ? 1 : 2
363
- b[21] = sideVec ? cx(sideVec) : 0; b[22] = sideVec ? cy(sideVec) : 0; b[23] = sideVec ? cz(sideVec) : 0
364
- b[24] = Math.max(0, Math.min(1, this.guideHold))
365
- b[25] = this.edges ? 1 : 0
366
- b[26] = Math.max(0, this.spin)
367
- b[27] = Math.max(0, this.spinInertia)
368
- const pin = this.pinned?.length ? new Set(this.pinned) : null
369
- for (let i = 0; i < n; i++) {
370
- const t = maxDepth > 0 ? this._depths[i]! / maxDepth : 0
371
- const o = HEADER + i * STRIDE
372
- b[o] = at(this.radius, t); b[o + 1] = at(this.stiffness, t); b[o + 2] = at(this.damping, t)
373
- b[o + 3] = at(this.gravity, t); b[o + 4] = at(this.angleLimit, t)
374
- const isPinned = !!pin && i > 0 && pin.has(this._chain[i]!.name)
375
- b[o + 5] = isPinned ? -1 : at(this.mass, t) // mass < 0 = pinned
376
- b[o + 6] = isPinned ? Math.max(0, this.pinGive) : 0
377
- }
378
- }
379
-
380
- /** Neighbour links: the bones at the same depth tied pairwise, the columns in the order of `pinned` (a cloak's
381
- * strips listed round the ring) — columns not listed there follow in tree order. Tree order alone is the
382
- * engine's child order, which for a GLB is the file's node order REVERSED (Filament prepends each child), so
383
- * strips added to a rig later were tied across the body instead of to their neighbours: the ermine's cloak
384
- * had one true neighbour pair out of eight, the rest were rods through the chest. */
385
- private _links(): Uint16Array | undefined {
386
- if (this.link <= 0) return undefined
387
- const index = new Map<Node, number>(this._chain.map((n, i) => [ n, i ]))
388
- const column = new Int32Array(this._chain.length).fill(-1) // per bone: the chain index of its depth-1 ancestor
389
- for (let i = 1; i < this._chain.length; i++) {
390
- const p = index.get(this._chain[i]!.parent!) ?? 0
391
- column[i] = this._depths[i] === 1 ? i : column[p]!
392
- }
393
- const ring = this.pinned ?? []
394
- const key = (i: number): number => { const k = ring.indexOf(this._chain[column[i]!]!.name); return k < 0 ? ring.length + column[i]! : k }
395
- const byDepth = new Map<number, number[]>()
396
- this._depths.forEach((d, i) => { if (d > 0) (byDepth.get(d) ?? byDepth.set(d, []).get(d)!).push(i) })
397
- const pairs: number[] = []
398
- for (const row of byDepth.values()) {
399
- row.sort((a, b) => key(a) - key(b))
400
- for (let k = 0; k + 1 < row.length; k++) pairs.push(row[k]!, row[k + 1]!)
401
- }
402
- return pairs.length ? new Uint16Array(pairs) : undefined
403
- }
404
-
405
- private _sync(): void {
406
- if (!this._id) return
407
- this._fill(this._blob)
408
- let changed = false
409
- for (let i = 0; i < this._blob.length; i++) if (this._blob[i] !== this._sent[i]) { changed = true; break }
410
- if (changed) { _creator.dynamicBoneSet!(this._id, this._blob); this._sent.set(this._blob) }
411
- this._pushFloor()
412
- this._pushColliders()
413
- }
414
-
415
- private _pushFloor(): void {
416
- const f = this.floor
417
- let key: string, mode: 0 | 1 | 2 = FLOOR_NONE, x = 0, y = 0, z = 0, nx = 0, ny = 1, nz = 0
418
- if (f === "probe") { mode = FLOOR_PROBE; key = "probe" }
419
- else if (f === "none" || f === undefined || f === null) { key = "none" }
420
- else if (typeof f === "number") { mode = FLOOR_PLANE; y = f; key = `y${f}` }
421
- else {
422
- mode = FLOOR_PLANE; x = cx(f); y = cy(f); z = cz(f)
423
- nx = cx(this.floorNormal); ny = cy(this.floorNormal); nz = cz(this.floorNormal)
424
- key = `p${x},${y},${z},${nx},${ny},${nz}`
425
- }
426
- if (key === this._floorKey) return
427
- this._floorKey = key
428
- _creator.dynamicBoneSetFloor!(this._id, mode, x, y, z, nx, ny, nz)
429
- }
430
-
431
- private _pushColliders(): void {
432
- // re-listed only when a collider came or went, or the field was reassigned (per frame otherwise)
433
- if (!this._collidersDirty && this._collidersRef === this.colliders) return
434
- this._collidersDirty = false
435
- this._collidersRef = this.colliders
436
- const ids: number[] = []
437
- const model = modelOf(this.node)
438
- const c = this.colliders
439
- if (c === "auto" || c === "humanoid") {
440
- for (const col of Aspect.all(DynamicBoneCollider)) if (col._colliderId && modelOf(col.node) === model) ids.push(col._colliderId)
441
- if (c === "humanoid") { if (this._own.length === 0) this._makeHumanoid(model); ids.push(...this._own) }
442
- } else if (Array.isArray(c)) {
443
- for (const n of c) {
444
- const col = n.get(DynamicBoneCollider)
445
- if (col?._colliderId) ids.push(col._colliderId)
446
- }
447
- }
448
- const key = ids.join(",")
449
- if (key === this._colliderKey) return
450
- this._colliderKey = key
451
- _creator.dynamicBoneSetColliders!(this._id, new Uint32Array(ids))
452
- }
453
-
454
- /** Capsules on the standard humanoid bones, sized like the Ragdoll's parts. */
455
- private _makeHumanoid(model: Node): void {
456
- const find = (role: string): string | null => {
457
- for (const name of HUMANOID[role] ?? []) if (model.bone(name)) return name
458
- return null
459
- }
460
- let parts: ReturnType<typeof humanoidParts>
461
- try { parts = humanoidParts(find) } catch { return } // no hips: not a humanoid, nothing generated
462
- for (const p of parts) {
463
- const bone = model.bone(p.bone)
464
- if (!bone) continue
465
- const inv = bone.worldMatrix.invert()
466
- let end: Vec3
467
- const to = p.to ? model.bone(p.to) : null
468
- if (to) end = inv.transformPoint(to.worldPosition)
469
- else {
470
- // a leaf: along the line from the parent part's origin through this bone
471
- const parentBone = p.parent ? model.bone(p.parent) : null
472
- const dir = parentBone ? bone.worldPosition.sub(parentBone.worldPosition) : new Vec3(0, 1, 0)
473
- const local = inv.transformDirection(dir)
474
- const len = local.length()
475
- end = len > 1e-6 ? local.scale((p.length ?? 0.2) / len) : new Vec3(0, p.length ?? 0.2, 0)
476
- }
477
- const id = _creator.dynamicBoneColliderCreate!(bone.id, 0, 0, 0, end.x, end.y, end.z, p.radius ?? 0.05, 0)
478
- if (id) this._own.push(id)
479
- }
480
- }
481
-
482
- /** @internal a collider attached / detached: every chain that collects them re-lists. */
483
- static _collidersChanged(): void {
484
- for (const d of Aspect.all(DynamicBone)) if (d._id && (d.colliders === "auto" || d.colliders === "humanoid")) { d._collidersDirty = true; d._pushColliders() }
485
- }
486
- }
1
+ // DynamicBone — secondary motion for tails, ears, hair, ropes and cloaks, simulated in the engine.
2
+ // Attach to the ROOT bone of a chain; the bone's whole subtree becomes Verlet particle chains that
3
+ // swing, lag and settle under gravity, wind and the character's motion, pulled back toward the pose
4
+ // the animation gives them, kept out of the body by capsule colliders and above the floor.
5
+ //
6
+ // const tail = fox.bone('tail')!
7
+ // tail.aspect(DynamicBone, { stiffness: [0.6, 0.15], damping: 0.15, gravity: 2, angleLimit: 50 })
8
+ // fox.bone('earend')!.aspect(DynamicBone, { stiffness: 0.8, angleLimit: 25 })
9
+ // fox.bone('Hips')!.aspect(DynamicBoneCollider, { radius: 0.12, to: 'chest' }) // a capsule the tail can't enter
10
+ // tail.dynamicBone.weight = cutscene ? 0 : 1 // blend to the animation
11
+ // tail.dynamicBone.floor = hit.point // the ground under it
12
+ // tail.dynamicBone.reset() // after a teleport
13
+ //
14
+ // Per-bone values (`radius`, `stiffness`, `damping`, `gravity`, `angleLimit`) take one number for the
15
+ // whole chain or a `[root, tip]` pair interpolated down the chain — a tail is stiff at the base and
16
+ // loose at the tip. A cloak is one aspect on the common parent of its columns with `link` > 0: the
17
+ // bones at the same depth of neighbouring columns are tied at their rest distance.
18
+ //
19
+ // The simulation runs natively in a stage of its own after the late phase and before the skin flush
20
+ // (docs/parity: gl-dynamic-bones): the animator, an active ragdoll and late-phase aspects (IK) are
21
+ // its input; every field of this aspect is live — a write is pushed before the next step.
22
+
23
+ import { Aspect } from "../../core/Aspect"
24
+ import type { FieldMeta } from "../../core/fields"
25
+ import { Vec3, cx, cy, cz, type Vec3Like } from "../../math/vec"
26
+ import { Node } from "../Node"
27
+ import { Model } from "../Model"
28
+ import { HUMANOID, humanoidParts } from "../physics/Ragdoll"
29
+
30
+ /** One value for the chain, or `[root, tip]` interpolated down it by depth. */
31
+ export type DynamicBoneCurve = number | readonly [root: number, tip: number]
32
+ /** `'none'`, `'probe'` (a ray from the chain root down against the physics world, every frame), a
33
+ * height (a horizontal plane at that world y) or a world point (a plane through it, `floorNormal` up). */
34
+ export type DynamicBoneFloor = "none" | "probe" | number | Vec3Like
35
+ /** `'auto'` = every DynamicBoneCollider under the model; `'humanoid'` = those plus capsules generated
36
+ * on the standard humanoid bones (hips, spine, head, limbs — the Ragdoll's layout); `'none'`; or the
37
+ * nodes carrying the colliders to use. */
38
+ export type DynamicBoneColliders = "auto" | "humanoid" | "none" | Node[]
39
+ /** The cloth's SIDE of every collider marked `oneSided`: `'none'` = no side, every collider pushes a bone to
40
+ * its nearest surface (tails, ropes); `'auto'` = away from the root bone's own axis (a cloak's strips round
41
+ * a spine face out from it); a vector = a fixed direction in the root bone's frame (a cape hanging BEHIND
42
+ * the arms: the body's backward). A one-sided collider that is moving AWAY from that side — an arm swinging
43
+ * forward under the cape — puts a bone it has run into over to the side instead of carrying it: the arm
44
+ * passes through the cloth and the cape hangs behind it again, instead of being dragged round the body.
45
+ * Still, or moving toward the cloth, it pushes like any other collider, so cloth draped on an arm at rest
46
+ * stays where it is. Two-sided colliders (the body's) never read the side. */
47
+ export type DynamicBoneSide = "none" | "auto" | Vec3Like
48
+
49
+ const VERSION = 1
50
+ const PARAMS = 25 // CANIM_DYN_COUNT
51
+ const HEADER = 3 + PARAMS
52
+ const STRIDE = 7 // CANIM_DYN_BONE_STRIDE
53
+ const FLOOR_NONE = 0, FLOOR_PLANE = 1, FLOOR_PROBE = 2
54
+
55
+ let warned = false
56
+ const supported = (): boolean => {
57
+ if (DynamicBone.supported) return true
58
+ if (!warned) { warned = true; console.warn("DynamicBone: this host has no dynamic bones (DynamicBone.supported is false) — the animation shows as is") }
59
+ return false
60
+ }
61
+
62
+ const at = (c: DynamicBoneCurve, t: number): number => typeof c === "number" ? c : c[0] + (c[1] - c[0]) * t
63
+
64
+ /** The model a bone belongs to: the topmost Model ancestor, else the topmost ancestor. */
65
+ const modelOf = (n: Node): Node => {
66
+ let top: Node = n, model: Node | null = null
67
+ for (let p: Node | null = n; p; p = p.parent) { top = p; if (p instanceof Model) model = p }
68
+ return model ?? top
69
+ }
70
+
71
+ /** A capsule (or a sphere) riding a bone — what dynamic bones stay out of. Attach to a body bone:
72
+ * `hips.aspect(DynamicBoneCollider, { radius: 0.12, to: 'Spine' })`. The capsule runs from
73
+ * `offset` (the bone's origin by default) to the `to` bone's origin, or to `end` (both in the bone's
74
+ * local space); neither = a sphere at `offset`. Picked up by every DynamicBone on the same model
75
+ * with `colliders: 'auto'` / `'humanoid'`, or listed explicitly. */
76
+ export class DynamicBoneCollider extends Aspect<"dynamicBoneCollider", Node> {
77
+ static readonly aspect = "dynamicBoneCollider"
78
+
79
+ private _radius: number = 0.1
80
+ /** Radius in metres, in the world: the bone's scale does not touch it (`offset` / `end` are in the bone's own units). */
81
+ get radius(): number { return this._radius }
82
+ set radius(v: number) { this._radius = v; this.dirty() }
83
+ private _offset: Vec3Like = [ 0, 0, 0 ]
84
+ /** Start of the capsule in the bone's local space (default: the bone's origin). */
85
+ get offset(): Vec3Like { return this._offset }
86
+ set offset(v: Vec3Like) { this._offset = v; this.dirty() }
87
+ private _end?: Vec3Like
88
+ /** End of the capsule in the bone's local space (a sphere when neither `end` nor `to` is set). */
89
+ get end(): Vec3Like | undefined { return this._end }
90
+ set end(v: Vec3Like | undefined) { this._end = v; this.dirty() }
91
+ private _to?: string
92
+ /** The bone the capsule reaches (its origin), instead of `end`. */
93
+ get to(): string | undefined { return this._to }
94
+ set to(v: string | undefined) { this._to = v; this.dirty() }
95
+ private _oneSided: boolean = false
96
+ /** ONE-SIDED: the cloth belongs on the chain's `side` of this capsule (see DynamicBone.side). While the capsule
97
+ * moves away from that side it puts a bone it has run into over to the side instead of carrying it — an arm
98
+ * under a cape may push the cape back, never drag it forward round the body. Needs a `side` on the DynamicBone. */
99
+ get oneSided(): boolean { return this._oneSided }
100
+ set oneSided(v: boolean) { this._oneSided = v; this.dirty() }
101
+
102
+ static fields: FieldMeta<DynamicBoneCollider> = {
103
+ radius: { min: 0, max: 2, step: 0.005 },
104
+ offset: { hidden: true },
105
+ end: { hidden: true },
106
+ to: { hidden: true },
107
+ oneSided: { label: "One-sided" },
108
+ }
109
+
110
+ private _id = 0
111
+
112
+ /** @internal the engine's collider id (0 when the host has none). */
113
+ get _colliderId(): number { return this._id }
114
+
115
+ private _segment(): [Vec3, Vec3] {
116
+ const a = new Vec3(this.offset)
117
+ if (this.to) {
118
+ const target = modelOf(this.node).bone(this.to)
119
+ if (!target) throw new Error(`DynamicBoneCollider: no bone '${this.to}' (the 'to' of '${this.node.name}')`)
120
+ const b = this.node.worldMatrix.invert().transformPoint(target.worldPosition)
121
+ return [ a, b ]
122
+ }
123
+ return [ a, this.end ? new Vec3(this.end) : a ]
124
+ }
125
+
126
+ onAttach(): void {
127
+ if (!supported()) return
128
+ const [ a, b ] = this._segment()
129
+ this._id = _creator.dynamicBoneColliderCreate!(this.node.id, a.x, a.y, a.z, b.x, b.y, b.z, this.radius, this.oneSided ? 1 : 0)
130
+ DynamicBone._collidersChanged()
131
+ }
132
+
133
+ /** A field changed: re-describe the capsule to the engine. Every field is live. */
134
+ protected flush(): void {
135
+ if (!this._id) return
136
+ const [ a, b ] = this._segment()
137
+ _creator.dynamicBoneColliderSet!(this._id, a.x, a.y, a.z, b.x, b.y, b.z, this.radius, this.oneSided ? 1 : 0)
138
+ }
139
+
140
+ onDetach(): void {
141
+ if (this._id) { _creator.dynamicBoneColliderDestroy!(this._id); this._id = 0; DynamicBone._collidersChanged() }
142
+ }
143
+ }
144
+
145
+ export class DynamicBone extends Aspect<"dynamicBone", Node> {
146
+ static readonly aspect = "dynamicBone"
147
+
148
+ /** Whether this host simulates dynamic bones. False, never a throw, on Web Lite and in headless
149
+ * tests; there the aspect attaches, warns once, and `chain` stays empty. */
150
+ static get supported(): boolean { return typeof _creator !== "undefined" && !!_creator && typeof _creator.dynamicBoneCreate === "function" }
151
+
152
+ private _bones?: string[]
153
+ /** Only these bones (by name) join the chain — the root always does, a bone left out takes its
154
+ * subtree with it. Default: the root's whole subtree. */
155
+ get bones(): string[] | undefined { return this._bones }
156
+ set bones(v: string[] | undefined) { this._bones = v; this.dirty() }
157
+ private _depth: number = 0
158
+ /** Depth limit under the root (0 = no limit). */
159
+ get depth(): number { return this._depth }
160
+ set depth(v: number) { this._depth = v; this.dirty() }
161
+ private _radius: DynamicBoneCurve = 0.02
162
+ /** Particle radius for the collisions, metres. */
163
+ get radius(): DynamicBoneCurve { return this._radius }
164
+ set radius(v: DynamicBoneCurve) { this._radius = v; this.dirty() }
165
+ private _stiffness: DynamicBoneCurve = 0.3
166
+ /** 0..1 — how fast a bone returns to the animated shape (per 1/60 s). 0 = a rope, 1 = rigid. */
167
+ get stiffness(): DynamicBoneCurve { return this._stiffness }
168
+ set stiffness(v: DynamicBoneCurve) { this._stiffness = v; this.dirty() }
169
+ private _damping: DynamicBoneCurve = 0.1
170
+ /** 0..1 — velocity lost per 1/60 s. 0.02 swings like a rope, 0.1 settles like a tail, 0.3 is honey. */
171
+ get damping(): DynamicBoneCurve { return this._damping }
172
+ set damping(v: DynamicBoneCurve) { this._damping = v; this.dirty() }
173
+ private _gravity: DynamicBoneCurve = 0
174
+ /** m/s² downward. A hanging rest pose feels none of it (the bone length cancels it); a pose that
175
+ * sticks out droops by as much as `stiffness` lets it. */
176
+ get gravity(): DynamicBoneCurve { return this._gravity }
177
+ set gravity(v: DynamicBoneCurve) { this._gravity = v; this.dirty() }
178
+ private _angleLimit: DynamicBoneCurve = 0
179
+ /** Max deviation from the animated direction, degrees per bone (0 = free). */
180
+ get angleLimit(): DynamicBoneCurve { return this._angleLimit }
181
+ set angleLimit(v: DynamicBoneCurve) { this._angleLimit = v; this.dirty() }
182
+ private _mass: DynamicBoneCurve = 1
183
+ /** Relative mass per bone. A bone-length constraint moves its two ends in inverse proportion to
184
+ * their masses (the root is kinematic): `[3, 1]` makes a tail's base carry its tip, `1` shares
185
+ * evenly like a rope. */
186
+ get mass(): DynamicBoneCurve { return this._mass }
187
+ set mass(v: DynamicBoneCurve) { this._mass = v; this.dirty() }
188
+ private _pinned?: string[]
189
+ /** Bones (by name) that ride the animation exactly while their subtrees still simulate: a sheet's
190
+ * several roots under one anchor (a cloak's seven strips under the spine), so ONE chain owns them all
191
+ * and `link` ties neighbouring strips — neighbours in THIS order, so list the strips round the ring
192
+ * (front-left … back … front-right); the front stays open. */
193
+ get pinned(): string[] | undefined { return this._pinned }
194
+ set pinned(v: string[] | undefined) { this._pinned = v; this.dirty() }
195
+ private _pinGive: number = 0
196
+ /** Metres a PINNED bone may be pushed off its animated place by a collider — a soft pin. Its particle
197
+ * collides and takes the edge pushes like a free bone (the strip below hangs from where it IS) and is
198
+ * drawn back to the animation when the collider leaves; the bone's local translation follows. A hard
199
+ * pin (0) at a capsule's edge made the first free bone below it jitter: the edge push on a segment
200
+ * with an immovable end is a lever, and a touch near the root threw the child out. 2–3 cm for a
201
+ * cloak's roots under a shoulder capsule. */
202
+ get pinGive(): number { return this._pinGive }
203
+ set pinGive(v: number) { this._pinGive = v; this.dirty() }
204
+ private _follow: number = 0
205
+ /** 0..1 of the character's travel the chain takes rigidly (0 = the full whip on a dash, 1 = it
206
+ * moves with the body and only the pose's own motion swings it). */
207
+ get follow(): number { return this._follow }
208
+ set follow(v: number) { this._follow = v; this.dirty() }
209
+ private _wind: Vec3Like = [ 0, 0, 0 ]
210
+ /** World wind, m/s². */
211
+ get wind(): Vec3Like { return this._wind }
212
+ set wind(v: Vec3Like) { this._wind = v; this.dirty() }
213
+ private _spin: number = 0
214
+ /** The BODY'S TURN, ≥ 0. Above 0 the chain lives in the parent bone's ROTATING frame: `damping` acts
215
+ * against the body's rigid motion (its travel plus its spin), so a heavily damped cloak RIDES a turn —
216
+ * sweeps round with the back — instead of standing in the world while the character spins under it and
217
+ * being dragged round after. The turn's inertia comes back as the frame's forces, scaled by this value:
218
+ * a centrifugal push `ω² · r` along each bone's animated direction from the axis (the hem, farthest
219
+ * from the spine, flies out and, on the chain's length, up). The frame's own physics is built in: the
220
+ * whip back when the turn starts and forward when it stops, Coriolis. 1 = the physical push; more = a
221
+ * bigger fling. With low damping the frame reproduces a free particle (nothing is counted twice);
222
+ * an animation's spine sway is ~1 % of a 900°/s turn. A rotation over 90° in one frame (a respawn
223
+ * facing) counts as a snap, not a spin. 0 = the plain translational frame, no turn forces. */
224
+ get spin(): number { return this._spin }
225
+ set spin(v: number) { this._spin = v; this.dirty() }
226
+ private _spinInertia: number = 0
227
+ /** With `spin`: the cloth's INERTIA against the body's turn, seconds — the cloth's own rotation follows the
228
+ * body's with this time constant. It falls behind when a turn starts (by about ω·τ: 45° at 900°/s and 0.05),
229
+ * rides once caught up, and keeps turning past the back when the body stops (the same angle, eased out over
230
+ * τ). Smooth by construction. 0 = glued to the turn; large = the cloth stays in the world while the body spins. */
231
+ get spinInertia(): number { return this._spinInertia }
232
+ set spinInertia(v: number) { this._spinInertia = v; this.dirty() }
233
+ private _link: number = 0
234
+ /** 0..1 strength of the ties between neighbouring columns at the same depth (a cloak). */
235
+ get link(): number { return this._link }
236
+ set link(v: number) { this._link = v; this.dirty() }
237
+ private _weight: number = 1
238
+ /** 0..1 blend from the animation (0) to the simulation (1). At 0 nothing is simulated and the
239
+ * chain re-arms on the animated pose when it comes back. */
240
+ get weight(): number { return this._weight }
241
+ set weight(v: number) { this._weight = v; this.dirty() }
242
+ private _rate: number = 60
243
+ /** Substep rate, Hz (≤ 4 substeps per frame). */
244
+ get rate(): number { return this._rate }
245
+ set rate(v: number) { this._rate = v; this.dirty() }
246
+ private _teleport: number = 1
247
+ /** A root jump longer than this in one frame (metres) resets the chain instead of whipping it. */
248
+ get teleport(): number { return this._teleport }
249
+ set teleport(v: number) { this._teleport = v; this.dirty() }
250
+ private _floor: DynamicBoneFloor = "none"
251
+ /** What the particles stay above: `'none'`, `'probe'`, a height, or a world point. */
252
+ get floor(): DynamicBoneFloor { return this._floor }
253
+ set floor(v: DynamicBoneFloor) { this._floor = v; this.dirty() }
254
+ private _floorNormal: Vec3Like = [ 0, 1, 0 ]
255
+ /** The floor plane's normal when `floor` is a point. */
256
+ get floorNormal(): Vec3Like { return this._floorNormal }
257
+ set floorNormal(v: Vec3Like) { this._floorNormal = v; this.dirty() }
258
+ private _floorFriction: number = 0.5
259
+ /** Friction against the floor, a Coulomb coefficient: a resting bone's slide loses up to
260
+ * `floorFriction · gravity · dt` of speed per substep (0 = ice, 1 = a rubber sole). While a bone
261
+ * rests on the floor or a collider, `stiffness` and `angleLimit` let go of it: the surface's
262
+ * reaction outranks the spring, or a hem folded on the floor would run away along it. */
263
+ get floorFriction(): number { return this._floorFriction }
264
+ set floorFriction(v: number) { this._floorFriction = v; this.dirty() }
265
+ private _iterations: number = 4
266
+ /** Constraint passes per substep (1..8, default 4). Each pass shares every bone length between its
267
+ * ends, so a pull needs passes to travel down a long chain: a long rope may want 8, a short tail is
268
+ * fine with 2. */
269
+ get iterations(): number { return this._iterations }
270
+ set iterations(v: number) { this._iterations = v; this.dirty() }
271
+ private _colliders: DynamicBoneColliders = "auto"
272
+ /** What the chain collides with (see DynamicBoneColliders). */
273
+ get colliders(): DynamicBoneColliders { return this._colliders }
274
+ set colliders(v: DynamicBoneColliders) { this._colliders = v; this.dirty() }
275
+ private _side: DynamicBoneSide = "none"
276
+ /** The cloth's outside — makes the colliders one-sided (see DynamicBoneSide). */
277
+ get side(): DynamicBoneSide { return this._side }
278
+ set side(v: DynamicBoneSide) { this._side = v; this.dirty() }
279
+ private _guideHold: number = 0
280
+ /** How firmly `guide()` targets are HELD through the constraint passes, 0..1. At 0 a guide is applied once
281
+ * before the passes and the length / link passes may drag the bone back toward its un-guided neighbours
282
+ * within the same frame; at 0.5 it is re-pulled after every pass with half its weight (converges with the
283
+ * passes), at 1 with its full weight (a target the bone length cannot reach then jitters). */
284
+ get guideHold(): number { return this._guideHold }
285
+ set guideHold(v: number) { this._guideHold = v; this.dirty() }
286
+ private _edges: boolean = true
287
+ /** The chain's EDGES collide with the capsules too — every bone segment and every link, not only the bones'
288
+ * particles: a capsule thinner than a bone's length (a forearm) no longer passes through the cloth between
289
+ * two bones. Off = particles only. */
290
+ get edges(): boolean { return this._edges }
291
+ set edges(v: boolean) { this._edges = v; this.dirty() }
292
+
293
+ static fields: FieldMeta<DynamicBone> = {
294
+ depth: { min: 0, max: 32, step: 1 },
295
+ radius: { min: 0, max: 1, step: 0.005 },
296
+ stiffness: { min: 0, max: 1, step: 0.01 },
297
+ damping: { min: 0, max: 1, step: 0.01 },
298
+ gravity: { min: 0, max: 30, step: 0.1 },
299
+ angleLimit: { label: "Angle limit (°)", min: 0, max: 180, step: 1 },
300
+ mass: { min: 0.1, max: 10, step: 0.1 },
301
+ follow: { min: 0, max: 1, step: 0.01 },
302
+ link: { min: 0, max: 1, step: 0.01 },
303
+ weight: { min: 0, max: 1, step: 0.01 },
304
+ rate: { min: 30, max: 240, step: 10 },
305
+ teleport: { min: 0, max: 20, step: 0.1 },
306
+ floorFriction: { label: "Floor friction", min: 0, max: 1, step: 0.01 },
307
+ iterations: { min: 1, max: 8, step: 1 },
308
+ bones: { hidden: true },
309
+ pinned: { hidden: true },
310
+ pinGive: { label: "Pin give (m)", min: 0, max: 0.2, step: 0.005 },
311
+ wind: { hidden: true },
312
+ spin: { min: 0, max: 4, step: 0.05 },
313
+ spinInertia: { label: "Spin inertia (s)", min: 0, max: 0.3, step: 0.005 },
314
+ floor: { hidden: true },
315
+ floorNormal: { hidden: true },
316
+ colliders: { hidden: true },
317
+ side: { hidden: true },
318
+ guideHold: { min: 0, max: 1, step: 0.05 },
319
+ edges: { hidden: true },
320
+ }
321
+
322
+ private _id = 0
323
+ private _chain: Node[] = []
324
+ private _depths: number[] = []
325
+ private _blob = new Float32Array(0)
326
+ private _sent = new Float32Array(0)
327
+ private _floorKey = ""
328
+ private _colliderKey = ""
329
+ private _collidersDirty = true
330
+ private _collidersRef: DynamicBoneColliders | null = null
331
+ private _own: number[] = [] // colliders generated for 'humanoid' (destroyed with the aspect)
332
+ private _colliderSet: (Node | "auto" | "humanoid" | "none")[] | null = null
333
+
334
+ /** The bones of the chain, the root first. */
335
+ get chain(): readonly Node[] { return this._chain }
336
+
337
+ onAttach(): void {
338
+ if (!supported()) return
339
+ this._collect()
340
+ if (this._chain.length === 0) return
341
+ const n = this._chain.length
342
+ const bones = new Uint32Array(n)
343
+ for (let i = 0; i < n; i++) bones[i] = this._chain[i]!.id
344
+ this._blob = new Float32Array(HEADER + n * STRIDE)
345
+ this._fill(this._blob)
346
+ this._id = _creator.dynamicBoneCreate!(modelOf(this.node).id, bones, this._blob, this._links())
347
+ if (!this._id) { console.warn(`DynamicBone: the host refused the chain under '${this.node.name}'`); return }
348
+ this._sent = this._blob.slice()
349
+ this._floorKey = ""; this._colliderKey = ""; this._collidersDirty = true
350
+ this._pushFloor()
351
+ this._pushColliders()
352
+ }
353
+
354
+ /** Every field is live: a write marks the chain dirty and what changed is pushed here, once, before
355
+ * the next step (the floor and the collider list are each keyed, so a point written every frame
356
+ * costs one compare). */
357
+ protected flush(): void { this._sync() }
358
+
359
+ onDetach(): void {
360
+ if (this._id) { _creator.dynamicBoneDestroy!(this._id); this._id = 0 }
361
+ for (const id of this._own) _creator.dynamicBoneColliderDestroy!(id)
362
+ this._own = []
363
+ this._chain = []
364
+ }
365
+
366
+ /** Snap the chain onto the animated pose next frame (a teleport, a camera cut). */
367
+ reset(): void { if (this._id) _creator.dynamicBoneReset!(this._id) }
368
+
369
+ /** GUIDES: world points the bones' particles are drawn to each substep before the constraints —
370
+ * weight 1 = there before them, the rest of the chain hangs, collides and links as before (a cape's
371
+ * strips riding the arms). Replaces the previous set; an empty list clears. A late-phase script
372
+ * (after the animator, before the engine's chain stage) refreshes it every frame. */
373
+ guide(rows: readonly { bone: Node, at: Vec3Like, weight: number }[]): void {
374
+ if (!this._id) return
375
+ const out: number[] = []
376
+ for (const r of rows) {
377
+ const i = this._chain.indexOf(r.bone)
378
+ if (i < 0) continue
379
+ out.push(i, cx(r.at), cy(r.at), cz(r.at), r.weight)
380
+ }
381
+ _creator.dynamicBoneTargets?.(this._id, out.length ? new Float32Array(out) : null)
382
+ }
383
+
384
+ /** The particles' world positions (the bones, then the leaves' virtual tips) — for a debug draw. */
385
+ get particles(): Vec3[] {
386
+ if (!this._id) return []
387
+ const out = new Float32Array((this._chain.length * 2) * 3)
388
+ const n = _creator.dynamicBoneParticles!(this._id, out)
389
+ const pts: Vec3[] = []
390
+ for (let i = 0; i < n; i++) pts.push(new Vec3(out[i * 3]!, out[i * 3 + 1]!, out[i * 3 + 2]!))
391
+ return pts
392
+ }
393
+
394
+ // ---- internals ----------------------------------------------------------------------------------
395
+
396
+ private _collect(): void {
397
+ const chain: Node[] = [], depths: number[] = []
398
+ const only = this.bones ? new Set(this.bones) : null
399
+ const walk = (n: Node, d: number): void => {
400
+ chain.push(n); depths.push(d)
401
+ if (this.depth > 0 && d >= this.depth) return
402
+ for (const c of n.children) {
403
+ if (only && !only.has(c.name)) continue
404
+ if (c.has(DynamicBone) || c.has(DynamicBoneCollider)) continue // another chain / a body bone
405
+ walk(c, d + 1)
406
+ }
407
+ }
408
+ walk(this.node, 0)
409
+ this._chain = chain; this._depths = depths
410
+ }
411
+
412
+ private _fill(b: Float32Array): void {
413
+ const n = this._chain.length
414
+ let maxDepth = 0
415
+ for (const d of this._depths) if (d > maxDepth) maxDepth = d
416
+ b[0] = VERSION; b[1] = n; b[2] = STRIDE
417
+ b[3] = this.weight; b[4] = this.follow
418
+ b[5] = cx(this.wind); b[6] = cy(this.wind); b[7] = cz(this.wind)
419
+ b[8] = this.link; b[9] = this.rate; b[10] = this.teleport
420
+ b[11] = 0; b[12] = 0; b[13] = 0; b[14] = 0; b[15] = 0; b[16] = 1; b[17] = 0 // the floor goes through setFloor
421
+ b[18] = this.floorFriction
422
+ b[19] = Math.max(1, Math.min(8, Math.round(this.iterations)))
423
+ const side = this.side
424
+ const sideVec = side === "none" || side === "auto" ? null : side
425
+ b[20] = side === "none" ? 0 : side === "auto" ? 1 : 2
426
+ b[21] = sideVec ? cx(sideVec) : 0; b[22] = sideVec ? cy(sideVec) : 0; b[23] = sideVec ? cz(sideVec) : 0
427
+ b[24] = Math.max(0, Math.min(1, this.guideHold))
428
+ b[25] = this.edges ? 1 : 0
429
+ b[26] = Math.max(0, this.spin)
430
+ b[27] = Math.max(0, this.spinInertia)
431
+ const pin = this.pinned?.length ? new Set(this.pinned) : null
432
+ for (let i = 0; i < n; i++) {
433
+ const t = maxDepth > 0 ? this._depths[i]! / maxDepth : 0
434
+ const o = HEADER + i * STRIDE
435
+ b[o] = at(this.radius, t); b[o + 1] = at(this.stiffness, t); b[o + 2] = at(this.damping, t)
436
+ b[o + 3] = at(this.gravity, t); b[o + 4] = at(this.angleLimit, t)
437
+ const isPinned = !!pin && i > 0 && pin.has(this._chain[i]!.name)
438
+ b[o + 5] = isPinned ? -1 : at(this.mass, t) // mass < 0 = pinned
439
+ b[o + 6] = isPinned ? Math.max(0, this.pinGive) : 0
440
+ }
441
+ }
442
+
443
+ /** Neighbour links: the bones at the same depth tied pairwise, the columns in the order of `pinned` (a cloak's
444
+ * strips listed round the ring) — columns not listed there follow in tree order. Tree order alone is the
445
+ * engine's child order, which for a GLB is the file's node order REVERSED (Filament prepends each child), so
446
+ * strips added to a rig later were tied across the body instead of to their neighbours: the ermine's cloak
447
+ * had one true neighbour pair out of eight, the rest were rods through the chest. */
448
+ private _links(): Uint16Array | undefined {
449
+ if (this.link <= 0) return undefined
450
+ const index = new Map<Node, number>(this._chain.map((n, i) => [ n, i ]))
451
+ const column = new Int32Array(this._chain.length).fill(-1) // per bone: the chain index of its depth-1 ancestor
452
+ for (let i = 1; i < this._chain.length; i++) {
453
+ const p = index.get(this._chain[i]!.parent!) ?? 0
454
+ column[i] = this._depths[i] === 1 ? i : column[p]!
455
+ }
456
+ const ring = this.pinned ?? []
457
+ const key = (i: number): number => { const k = ring.indexOf(this._chain[column[i]!]!.name); return k < 0 ? ring.length + column[i]! : k }
458
+ const byDepth = new Map<number, number[]>()
459
+ this._depths.forEach((d, i) => { if (d > 0) (byDepth.get(d) ?? byDepth.set(d, []).get(d)!).push(i) })
460
+ const pairs: number[] = []
461
+ for (const row of byDepth.values()) {
462
+ row.sort((a, b) => key(a) - key(b))
463
+ for (let k = 0; k + 1 < row.length; k++) pairs.push(row[k]!, row[k + 1]!)
464
+ }
465
+ return pairs.length ? new Uint16Array(pairs) : undefined
466
+ }
467
+
468
+ private _sync(): void {
469
+ if (!this._id) return
470
+ this._fill(this._blob)
471
+ let changed = false
472
+ for (let i = 0; i < this._blob.length; i++) if (this._blob[i] !== this._sent[i]) { changed = true; break }
473
+ if (changed) { _creator.dynamicBoneSet!(this._id, this._blob); this._sent.set(this._blob) }
474
+ this._pushFloor()
475
+ this._pushColliders()
476
+ }
477
+
478
+ private _pushFloor(): void {
479
+ const f = this.floor
480
+ let key: string, mode: 0 | 1 | 2 = FLOOR_NONE, x = 0, y = 0, z = 0, nx = 0, ny = 1, nz = 0
481
+ if (f === "probe") { mode = FLOOR_PROBE; key = "probe" }
482
+ else if (f === "none" || f === undefined || f === null) { key = "none" }
483
+ else if (typeof f === "number") { mode = FLOOR_PLANE; y = f; key = `y${f}` }
484
+ else {
485
+ mode = FLOOR_PLANE; x = cx(f); y = cy(f); z = cz(f)
486
+ nx = cx(this.floorNormal); ny = cy(this.floorNormal); nz = cz(this.floorNormal)
487
+ key = `p${x},${y},${z},${nx},${ny},${nz}`
488
+ }
489
+ if (key === this._floorKey) return
490
+ this._floorKey = key
491
+ _creator.dynamicBoneSetFloor!(this._id, mode, x, y, z, nx, ny, nz)
492
+ }
493
+
494
+ private _pushColliders(): void {
495
+ // re-listed only when a collider came or went, or the field was reassigned (per frame otherwise)
496
+ if (!this._collidersDirty && this._collidersRef === this.colliders) return
497
+ this._collidersDirty = false
498
+ this._collidersRef = this.colliders
499
+ const ids: number[] = []
500
+ const model = modelOf(this.node)
501
+ const c = this.colliders
502
+ if (c === "auto" || c === "humanoid") {
503
+ for (const col of Aspect.all(DynamicBoneCollider)) if (col._colliderId && modelOf(col.node) === model) ids.push(col._colliderId)
504
+ if (c === "humanoid") { if (this._own.length === 0) this._makeHumanoid(model); ids.push(...this._own) }
505
+ } else if (Array.isArray(c)) {
506
+ for (const n of c) {
507
+ const col = n.get(DynamicBoneCollider)
508
+ if (col?._colliderId) ids.push(col._colliderId)
509
+ }
510
+ }
511
+ const key = ids.join(",")
512
+ if (key === this._colliderKey) return
513
+ this._colliderKey = key
514
+ _creator.dynamicBoneSetColliders!(this._id, new Uint32Array(ids))
515
+ }
516
+
517
+ /** Capsules on the standard humanoid bones, sized like the Ragdoll's parts. */
518
+ private _makeHumanoid(model: Node): void {
519
+ const find = (role: string): string | null => {
520
+ for (const name of HUMANOID[role] ?? []) if (model.bone(name)) return name
521
+ return null
522
+ }
523
+ let parts: ReturnType<typeof humanoidParts>
524
+ try { parts = humanoidParts(find) } catch { return } // no hips: not a humanoid, nothing generated
525
+ for (const p of parts) {
526
+ const bone = model.bone(p.bone)
527
+ if (!bone) continue
528
+ const inv = bone.worldMatrix.invert()
529
+ let end: Vec3
530
+ const to = p.to ? model.bone(p.to) : null
531
+ if (to) end = inv.transformPoint(to.worldPosition)
532
+ else {
533
+ // a leaf: along the line from the parent part's origin through this bone
534
+ const parentBone = p.parent ? model.bone(p.parent) : null
535
+ const dir = parentBone ? bone.worldPosition.sub(parentBone.worldPosition) : new Vec3(0, 1, 0)
536
+ const local = inv.transformDirection(dir)
537
+ const len = local.length()
538
+ end = len > 1e-6 ? local.scale((p.length ?? 0.2) / len) : new Vec3(0, p.length ?? 0.2, 0)
539
+ }
540
+ const id = _creator.dynamicBoneColliderCreate!(bone.id, 0, 0, 0, end.x, end.y, end.z, p.radius ?? 0.05, 0)
541
+ if (id) this._own.push(id)
542
+ }
543
+ }
544
+
545
+ /** @internal a collider attached / detached: every chain that collects them re-lists. */
546
+ static _collidersChanged(): void {
547
+ for (const d of Aspect.all(DynamicBone)) if (d._id && (d.colliders === "auto" || d.colliders === "humanoid")) { d._collidersDirty = true; d._pushColliders() }
548
+ }
549
+ }