lecodes-cli 0.16.2 → 0.17.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lecodes-cli",
3
- "version": "0.16.2",
3
+ "version": "0.17.1",
4
4
  "dependencies": {
5
5
  "@letary/chisel": "^0.8.0",
6
6
  "jimp": "^1.6.1"
@@ -11,11 +11,12 @@
11
11
  "qrcode-terminal": "^0.12.0",
12
12
  "lecodes-3d-editor": "workspace:*",
13
13
  "lecodes-assets": "workspace:*",
14
- "sdk": "workspace:*"
14
+ "sdk": "workspace:*",
15
+ "viewer-lite": "workspace:*"
15
16
  },
16
17
  "peerDependencies": {
17
18
  "lecodes-design": "^0.8.1",
18
- "lecodes-renderer": "^0.8.4",
19
+ "lecodes-renderer": "^0.8.6",
19
20
  "lecodes-3d-editor": "^0.3.0",
20
21
  "lecodes-assets": "^0.1.0"
21
22
  },
@@ -394,7 +394,8 @@ export class Animator extends Aspect<"animator", Node> {
394
394
  /** Default crossfade seconds for play()/stop(). */
395
395
  fade = 0.2
396
396
  /** Root motion: strip the root bone's horizontal travel from the pose and move the NODE with it
397
- * instead (or drive its CharacterController when it has one) — for clips whose hips actually travel
397
+ * instead (or drive its CharacterController — on this node or an ancestor — when it has one; the
398
+ * vertical part stays on the bone, so a jump's height is the clip's) — for clips whose hips actually travel
398
399
  * (Mixamo without "In Place", lunges, rolls). `true` = auto-detect the root bone (the shallowest bone
399
400
  * a clip translates), or a bone name. Read at attach. */
400
401
  rootMotion?: boolean | string
@@ -528,8 +529,15 @@ export class Animator extends Aspect<"animator", Node> {
528
529
  if (this.rootMotion) this._applyRootMotion(dt)
529
530
  }
530
531
 
532
+ /** The CharacterController root motion feeds: on this node, or on the nearest ANCESTOR — the usual
533
+ * rig is a capsule node holding Shape + CharacterController with the model parented under it,
534
+ * shifted down so its feet sit at the capsule's bottom (the controller centers the shape on its node). */
531
535
  private _controller(): CharacterController | undefined {
532
- return (this.node as unknown as { controller?: CharacterController }).controller
536
+ for (let n: Node | null = this.node; n; n = n.parent) {
537
+ const c = (n as unknown as { controller?: CharacterController }).controller
538
+ if (c) return c
539
+ }
540
+ return undefined
533
541
  }
534
542
 
535
543
  /** Root motion (last native frame's delta, node-local, horizontal): move the node, or feed the
@@ -1,170 +1,174 @@
1
- // IK — precise character actions as late-phase aspects on BONES (docs/animation-plan.md §2.5).
2
- // They run after the animation wrote the pose (base anim or Animator) and before the skin is
3
- // flushed, so their bone writes land in the skin. Attach to the END bone of the chain; the chain is
4
- // walked up through parents — one instance per end effector, any Node hierarchy works.
5
- //
6
- // const foot = hero.bone('LeftFoot')!
7
- // foot.aspect(IK.TwoBone, { target: footTarget, pole: kneeHint }) // upLeg → leg → foot
8
- // foot.ik.weight = grounded ? 1 : 0 // blend in/out
9
- // hero.bone('Head')!.aspect(IK.LookAt, { target: camera, limit: 70 }) // head tracks the camera
10
- //
11
- // Pure SDK/JS: a handful of world-matrix reads + two quaternion writes per solve. A native solver can
12
- // slot in later behind the same API.
13
-
14
- import { Aspect } from "../core/Aspect"
15
- import { Vec3, type Vec3Like } from "../math/vec"
16
- import { Quat } from "../math/quat"
17
- import type { Node } from "./Node"
18
-
19
- type Target = Node | Vec3Like
20
-
21
- const targetPos = (t: Target | undefined): Vec3 | null => {
22
- if (t === undefined || t === null) return null
23
- const n = t as Node
24
- if (typeof (n as { worldPosition?: unknown }).worldPosition === "object" && typeof n.id === "number") return n.worldPosition
25
- return new Vec3(t as Vec3Like)
26
- }
27
-
28
- /** World rotation of a node (parent chain composed natively). */
29
- const worldRot = (n: Node): Quat => n.worldMatrix.rotation
30
-
31
- /** Write a WORLD rotation to a node by converting through the parent's world rotation. */
32
- const setWorldRot = (n: Node, q: Quat): void => {
33
- const p = n.parent
34
- n.quaternion = p ? worldRot(p).invert().mul(q).normalize() : q.normalize()
35
- }
36
-
37
- const clampAngle = (q: Quat, maxRad: number): Quat => {
38
- // angle of a unit quaternion = 2·acos(|w|)
39
- const w = Math.min(1, Math.abs(q.w))
40
- const ang = 2 * Math.acos(w)
41
- if (ang <= maxRad || ang < 1e-6) return q
42
- const s = Math.sqrt(1 - w * w)
43
- const axis = new Vec3(q.x / s, q.y / s, q.z / s)
44
- return Quat.fromAxisAngle(axis, q.w < 0 ? -maxRad : maxRad)
45
- }
46
-
47
- /** Two-bone analytic IK (limbs). Attach to the END bone: `foot.aspect(IK.TwoBone, { target })`
48
- * solves upper (grandparent) + mid (parent) so the end reaches `target`; `pole` steers the bend
49
- * (knee/elbow) — a Node or world point. */
50
- class TwoBone extends Aspect<"ik", Node> {
51
- static readonly aspect = "ik"
52
-
53
- /** Where the end bone should be (Node or world position). */
54
- target?: Target
55
- /** Bend hint — the mid joint is pulled toward it (Node or world position). */
56
- pole?: Target
57
- /** 0–1 contribution (blend in/out, e.g. foot planting only while grounded). */
58
- weight = 1
59
- /** Solve every frame (default). Set false to drive `solve()` yourself. */
60
- enabled = true
61
-
62
- update(): void {
63
- if (this.enabled) this.solve()
64
- }
65
-
66
- /** One solve at the current pose. Safe to call manually (e.g. from a later-ordered aspect). */
67
- solve(): void {
68
- const t = targetPos(this.target)
69
- if (!t || this.weight <= 0) return
70
- const end = this.node
71
- const mid = end.parent
72
- const root = mid?.parent
73
- if (!mid || !root) return
74
-
75
- const w = Math.min(1, this.weight)
76
- const midLocal0 = mid.quaternion
77
- const rootLocal0 = root.quaternion
78
-
79
- const a = root.worldPosition, b = mid.worldPosition, c = end.worldPosition
80
- const l1 = a.distanceTo(b), l2 = b.distanceTo(c)
81
- if (l1 < 1e-6 || l2 < 1e-6) return
82
- const eps = 1e-4
83
- const toT = t.sub(a)
84
- const d = Math.min(Math.max(toT.length(), eps), l1 + l2 - eps)
85
-
86
- // 1. bend the mid joint to the angle the target distance demands (law of cosines)
87
- const ba = a.sub(b), bc = c.sub(b)
88
- const cur = ba.angle(bc)
89
- const want = Math.acos(Math.min(1, Math.max(-1, (l1 * l1 + l2 * l2 - d * d) / (2 * l1 * l2))))
90
- let axis = bc.cross(ba)
91
- if (axis.lengthSq() < 1e-10) {
92
- // straight limb: bend toward the pole (or any perpendicular)
93
- const p = targetPos(this.pole)
94
- const ref = p ? p.sub(b) : new Vec3(0, 0, 1)
95
- axis = ba.cross(ref)
96
- if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(0, 1, 0))
97
- if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(1, 0, 0))
98
- }
99
- axis = axis.normalize()
100
- // rotating mid by (want - cur) about `axis` opens/closes the angle between ba and bc
101
- const midWorld = worldRot(mid)
102
- setWorldRot(mid, Quat.fromAxisAngle(axis, want - cur).mul(midWorld))
103
-
104
- // 2. swing the root so the end lands on the target direction
105
- const c2 = end.worldPosition
106
- const rootWorld = worldRot(root)
107
- setWorldRot(root, Quat.fromTo(c2.sub(a), toT).mul(rootWorld))
108
-
109
- // 3. pole: spin the root about the root→target axis so the mid joint lies toward the pole
110
- const p = targetPos(this.pole)
111
- if (p) {
112
- const dir = toT.normalize()
113
- const b2 = mid.worldPosition
114
- const midProj = b2.sub(a).sub(dir.scale(b2.sub(a).dot(dir)))
115
- const poleProj = p.sub(a).sub(dir.scale(p.sub(a).dot(dir)))
116
- if (midProj.lengthSq() > 1e-10 && poleProj.lengthSq() > 1e-10) {
117
- setWorldRot(root, Quat.fromTo(midProj, poleProj).mul(worldRot(root)))
118
- }
119
- }
120
-
121
- // 4. weight: blend from the animated pose to the solved one
122
- if (w < 1) {
123
- root.quaternion = rootLocal0.slerp(root.quaternion, w)
124
- mid.quaternion = midLocal0.slerp(mid.quaternion, w)
125
- }
126
- }
127
- }
128
-
129
- /** Aim a bone at a target (head / eyes / turret). Attach to the bone itself:
130
- * `head.aspect(IK.LookAt, { target: camera, limit: 70 })`. `axis` is the bone's LOCAL forward
131
- * (the direction that should point at the target) — rigs differ; default −Z, Mixamo heads look
132
- * along +Z of the head bone in most exports, so pass `axis: [0, 0, 1]` there if it faces backwards. */
133
- class LookAt extends Aspect<"lookAt", Node> {
134
- static readonly aspect = "lookAt"
135
-
136
- target?: Target
137
- /** The bone's local axis that should point at the target. */
138
- axis: Vec3Like = [ 0, 0, -1 ]
139
- /** Max deflection from the animated direction, in degrees (default 80). */
140
- limit = 80
141
- /** 0–1 contribution. */
142
- weight = 1
143
- enabled = true
144
-
145
- update(): void {
146
- if (this.enabled) this.solve()
147
- }
148
-
149
- solve(): void {
150
- const t = targetPos(this.target)
151
- if (!t || this.weight <= 0) return
152
- const bone = this.node
153
- const w = Math.min(1, this.weight)
154
- const local0 = bone.quaternion
155
- const from = bone.worldPosition
156
- const dir = t.sub(from)
157
- if (dir.lengthSq() < 1e-10) return
158
- const rot = worldRot(bone)
159
- const current = new Vec3(this.axis).rotate(rot)
160
- let delta = Quat.fromTo(current, dir)
161
- delta = clampAngle(delta, (this.limit * Math.PI) / 180)
162
- setWorldRot(bone, delta.mul(rot))
163
- if (w < 1) bone.quaternion = local0.slerp(bone.quaternion, w)
164
- }
165
- }
166
-
167
- /** Inverse kinematics aspects — attach to bones (see file header). */
168
- export const IK = { TwoBone, LookAt }
169
- export type IKTwoBone = TwoBone
170
- export type IKLookAt = LookAt
1
+ // IK — precise character actions as late-phase aspects on BONES (docs/animation-plan.md §2.5).
2
+ // They run after the animation wrote the pose (base anim or Animator) and before the skin is
3
+ // flushed, so their bone writes land in the skin. Attach to the END bone of the chain; the chain is
4
+ // walked up through parents — one instance per end effector, any Node hierarchy works.
5
+ //
6
+ // const foot = hero.bone('LeftFoot')!
7
+ // foot.aspect(IK.TwoBone, { target: footTarget, pole: kneeHint }) // upLeg → leg → foot
8
+ // foot.ik.weight = grounded ? 1 : 0 // blend in/out
9
+ // hero.bone('Head')!.aspect(IK.LookAt, { target: camera, limit: 70 }) // head tracks the camera
10
+ //
11
+ // Pure SDK/JS: a handful of world-matrix reads + two quaternion writes per solve. A native solver can
12
+ // slot in later behind the same API.
13
+
14
+ import { Aspect } from "../core/Aspect"
15
+ import { Vec3, type Vec3Like } from "../math/vec"
16
+ import { Quat } from "../math/quat"
17
+ import type { Node } from "./Node"
18
+
19
+ type Target = Node | Vec3Like
20
+
21
+ const targetPos = (t: Target | undefined): Vec3 | null => {
22
+ if (t === undefined || t === null) return null
23
+ const n = t as Node
24
+ if (typeof (n as { worldPosition?: unknown }).worldPosition === "object" && typeof n.id === "number") return n.worldPosition
25
+ return new Vec3(t as Vec3Like)
26
+ }
27
+
28
+ /** World rotation of a node (parent chain composed natively). */
29
+ const worldRot = (n: Node): Quat => n.worldMatrix.rotation
30
+
31
+ /** Write a WORLD rotation to a node by converting through the parent's world rotation. */
32
+ const setWorldRot = (n: Node, q: Quat): void => {
33
+ const p = n.parent
34
+ n.quaternion = p ? worldRot(p).invert().mul(q).normalize() : q.normalize()
35
+ }
36
+
37
+ const clampAngle = (q: Quat, maxRad: number): Quat => {
38
+ // angle of a unit quaternion = 2·acos(|w|)
39
+ const w = Math.min(1, Math.abs(q.w))
40
+ const ang = 2 * Math.acos(w)
41
+ if (ang <= maxRad || ang < 1e-6) return q
42
+ const s = Math.sqrt(1 - w * w)
43
+ const axis = new Vec3(q.x / s, q.y / s, q.z / s)
44
+ return Quat.fromAxisAngle(axis, q.w < 0 ? -maxRad : maxRad)
45
+ }
46
+
47
+ /** Two-bone analytic IK (limbs). Attach to the END bone: `foot.aspect(IK.TwoBone, { target })`
48
+ * solves upper (grandparent) + mid (parent) so the end reaches `target`; `pole` steers the bend
49
+ * (knee/elbow) — a Node or world point. */
50
+ class TwoBone extends Aspect<"ik", Node> {
51
+ static readonly aspect = "ik"
52
+
53
+ /** Where the end bone should be (Node or world position). */
54
+ target?: Target
55
+ /** Bend hint — the mid joint is pulled toward it (Node or world position). */
56
+ pole?: Target
57
+ /** 0–1 contribution (blend in/out, e.g. foot planting only while grounded). */
58
+ weight = 1
59
+ /** Solve every frame (default). Set false to drive `solve()` yourself. */
60
+ enabled = true
61
+
62
+ update(): void {
63
+ if (this.enabled) this.solve()
64
+ }
65
+
66
+ /** One solve at the current pose. Safe to call manually (e.g. from a later-ordered aspect). */
67
+ solve(): void {
68
+ const t = targetPos(this.target)
69
+ if (!t || this.weight <= 0) return
70
+ const end = this.node
71
+ const mid = end.parent
72
+ const root = mid?.parent
73
+ if (!mid || !root) return
74
+
75
+ const w = Math.min(1, this.weight)
76
+ const midLocal0 = mid.quaternion
77
+ const rootLocal0 = root.quaternion
78
+
79
+ const a = root.worldPosition, b = mid.worldPosition, c = end.worldPosition
80
+ const l1 = a.distanceTo(b), l2 = b.distanceTo(c)
81
+ if (l1 < 1e-6 || l2 < 1e-6) return
82
+ const eps = 1e-4
83
+ const toT = t.sub(a)
84
+ const d = Math.min(Math.max(toT.length(), eps), l1 + l2 - eps)
85
+
86
+ // 1. bend the mid joint to the angle the target distance demands (law of cosines)
87
+ const ba = a.sub(b), bc = c.sub(b)
88
+ const cur = ba.angle(bc)
89
+ const want = Math.acos(Math.min(1, Math.max(-1, (l1 * l1 + l2 * l2 - d * d) / (2 * l1 * l2))))
90
+ // a positive rotation about ba × bc moves `bc` AWAY from `ba`, i.e. opens the joint — so rotating the
91
+ // mid bone by (want − cur) about it opens/closes exactly as needed. (bc × ba has the opposite sense:
92
+ // with it every already-bent limb — which is every animated limb — bends the wrong way; the straight
93
+ // case below picks its axis as ba × ref, the same sense as ba × bc for a limb barely bent toward ref.)
94
+ let axis = ba.cross(bc)
95
+ if (axis.lengthSq() < 1e-10) {
96
+ // straight limb: bend toward the pole (or any perpendicular)
97
+ const p = targetPos(this.pole)
98
+ const ref = p ? p.sub(b) : new Vec3(0, 0, 1)
99
+ axis = ba.cross(ref)
100
+ if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(0, 1, 0))
101
+ if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(1, 0, 0))
102
+ }
103
+ axis = axis.normalize()
104
+ // rotating mid by (want - cur) about `axis` opens/closes the angle between ba and bc
105
+ const midWorld = worldRot(mid)
106
+ setWorldRot(mid, Quat.fromAxisAngle(axis, want - cur).mul(midWorld))
107
+
108
+ // 2. swing the root so the end lands on the target direction
109
+ const c2 = end.worldPosition
110
+ const rootWorld = worldRot(root)
111
+ setWorldRot(root, Quat.fromTo(c2.sub(a), toT).mul(rootWorld))
112
+
113
+ // 3. pole: spin the root about the root→target axis so the mid joint lies toward the pole
114
+ const p = targetPos(this.pole)
115
+ if (p) {
116
+ const dir = toT.normalize()
117
+ const b2 = mid.worldPosition
118
+ const midProj = b2.sub(a).sub(dir.scale(b2.sub(a).dot(dir)))
119
+ const poleProj = p.sub(a).sub(dir.scale(p.sub(a).dot(dir)))
120
+ if (midProj.lengthSq() > 1e-10 && poleProj.lengthSq() > 1e-10) {
121
+ setWorldRot(root, Quat.fromTo(midProj, poleProj).mul(worldRot(root)))
122
+ }
123
+ }
124
+
125
+ // 4. weight: blend from the animated pose to the solved one
126
+ if (w < 1) {
127
+ root.quaternion = rootLocal0.slerp(root.quaternion, w)
128
+ mid.quaternion = midLocal0.slerp(mid.quaternion, w)
129
+ }
130
+ }
131
+ }
132
+
133
+ /** Aim a bone at a target (head / eyes / turret). Attach to the bone itself:
134
+ * `head.aspect(IK.LookAt, { target: camera, limit: 70 })`. `axis` is the bone's LOCAL forward
135
+ * (the direction that should point at the target) — rigs differ; default −Z, Mixamo heads look
136
+ * along +Z of the head bone in most exports, so pass `axis: [0, 0, 1]` there if it faces backwards. */
137
+ class LookAt extends Aspect<"lookAt", Node> {
138
+ static readonly aspect = "lookAt"
139
+
140
+ target?: Target
141
+ /** The bone's local axis that should point at the target. */
142
+ axis: Vec3Like = [ 0, 0, -1 ]
143
+ /** Max deflection from the animated direction, in degrees (default 80). */
144
+ limit = 80
145
+ /** 0–1 contribution. */
146
+ weight = 1
147
+ enabled = true
148
+
149
+ update(): void {
150
+ if (this.enabled) this.solve()
151
+ }
152
+
153
+ solve(): void {
154
+ const t = targetPos(this.target)
155
+ if (!t || this.weight <= 0) return
156
+ const bone = this.node
157
+ const w = Math.min(1, this.weight)
158
+ const local0 = bone.quaternion
159
+ const from = bone.worldPosition
160
+ const dir = t.sub(from)
161
+ if (dir.lengthSq() < 1e-10) return
162
+ const rot = worldRot(bone)
163
+ const current = new Vec3(this.axis).rotate(rot)
164
+ let delta = Quat.fromTo(current, dir)
165
+ delta = clampAngle(delta, (this.limit * Math.PI) / 180)
166
+ setWorldRot(bone, delta.mul(rot))
167
+ if (w < 1) bone.quaternion = local0.slerp(bone.quaternion, w)
168
+ }
169
+ }
170
+
171
+ /** Inverse kinematics aspects — attach to bones (see file header). */
172
+ export const IK = { TwoBone, LookAt }
173
+ export type IKTwoBone = TwoBone
174
+ export type IKLookAt = LookAt
@@ -0,0 +1,132 @@
1
+ // One geometry drawn many times — grass tufts, crates, debris, coins, a crowd of identical props —
2
+ // with a transform per instance and a handful of draw calls. Hardware instancing (Filament's
3
+ // InstanceBuffer): the engine applies each instance's local matrix, so any regular material works.
4
+ //
5
+ // Transforms live in a CPU-side Float32Array (16 floats per instance, column-major, local to this
6
+ // node). `setAt` / `hideAt` / `setMatrixAt` edit it and track the dirty range; `commit()` uploads
7
+ // that range in one bridge call — call it once per frame after your edits, not per instance.
8
+ // The native side splits the instances into chunks of the platform's per-draw cap (64 native,
9
+ // 8 on WebGL) on child entities, and keeps a bounding box per chunk so frustum culling stays on.
10
+
11
+ import { Mat4, type Mat4Like } from "../math/mat4"
12
+ import { Quat, type QuatLike } from "../math/quat"
13
+ import { cx, cy, cz, type Vec3Like } from "../math/vec"
14
+ import type { Geometry } from "./Geometry"
15
+ import { Material } from "./Material"
16
+ import type { MeshOptions } from "./Mesh"
17
+ import { Node } from "./Node"
18
+
19
+ /** One instance's placement, local to the InstancedMesh node. Omitted fields mean identity. */
20
+ export type InstanceTransform = {
21
+ position?: Vec3Like
22
+ /** Quaternion; wins over `eulerAngles`. */
23
+ rotation?: QuatLike
24
+ /** Degrees (YXZ), like `Node.eulerAngles`. */
25
+ eulerAngles?: Vec3Like
26
+ scale?: Vec3Like | number
27
+ }
28
+
29
+ const DEG = Math.PI / 180
30
+
31
+ export class InstancedMesh extends Node {
32
+ /** Instance capacity, fixed at construction. Unset instances are hidden. */
33
+ readonly count: number
34
+ private _geometry: Geometry
35
+ private _material: Material
36
+ private _data: Float32Array
37
+ private _lo = Infinity
38
+ private _hi = -1
39
+
40
+ constructor(geometry: Geometry, count: number, options: MeshOptions = {}) {
41
+ super()
42
+ if (!(count >= 1)) throw new Error("InstancedMesh: count must be ≥ 1")
43
+ this.count = Math.floor(count)
44
+ this._geometry = geometry
45
+ this._material = options.material ?? Material.unlit()
46
+ this._data = new Float32Array(16 * this.count)
47
+ _creator.createInstancedMesh(this.id, Material.idOf(this._material), geometry.vertices, geometry.normals, geometry.indices, geometry.uv, this.count)
48
+ if (options.position) this.position = options.position
49
+ if (options.eulerAngles) this.eulerAngles = options.eulerAngles
50
+ if (options.scale !== undefined) this.scale = typeof options.scale === "number" ? [ options.scale, options.scale, options.scale ] : options.scale
51
+ if (options.name !== undefined) this.name = options.name
52
+ if (options.castShadows !== undefined || options.receiveShadows !== undefined) {
53
+ _creator.setInstancedMeshShadows(this.id, options.castShadows ?? true, options.receiveShadows ?? true)
54
+ }
55
+ }
56
+
57
+ override get geometry(): Geometry { return this._geometry }
58
+
59
+ get material(): Material { return this._material }
60
+ set material(m: Material) {
61
+ this._material = m
62
+ _creator.setInstancedMeshMaterial(this.id, Material.idOf(m))
63
+ }
64
+
65
+ /** Shadow flags for all instances (both at once — the engine sets them per draw). */
66
+ setShadows(cast: boolean, receive = cast): this {
67
+ _creator.setInstancedMeshShadows(this.id, cast, receive)
68
+ return this
69
+ }
70
+
71
+ private _touch(i: number): void {
72
+ if (i < this._lo) this._lo = i
73
+ if (i > this._hi) this._hi = i
74
+ }
75
+
76
+ /** Place instance `i`. Pending until `commit()`. */
77
+ setAt(i: number, t: InstanceTransform): this {
78
+ if (i < 0 || i >= this.count) throw new RangeError(`InstancedMesh: index ${i} out of 0..${this.count - 1}`)
79
+ const rot = t.rotation ?? (t.eulerAngles
80
+ ? Quat.fromEuler(cx(t.eulerAngles) * DEG, cy(t.eulerAngles) * DEG, cz(t.eulerAngles) * DEG)
81
+ : Quat.identity)
82
+ const m = Mat4.compose(t.position ?? [ 0, 0, 0 ], rot, t.scale ?? 1).toArray()
83
+ this._data.set(m, i * 16)
84
+ this._touch(i)
85
+ return this
86
+ }
87
+
88
+ /** Place instance `i` from a 4×4 column-major matrix (local to this node). */
89
+ setMatrixAt(i: number, m: Mat4Like): this {
90
+ if (i < 0 || i >= this.count) throw new RangeError(`InstancedMesh: index ${i} out of 0..${this.count - 1}`)
91
+ const a = m instanceof Mat4 ? m.toArray() : m
92
+ for (let k = 0; k < 16; k++) this._data[i * 16 + k] = a[k]
93
+ this._touch(i)
94
+ return this
95
+ }
96
+
97
+ /** Hide instance `i` (a zero matrix — no geometry is emitted for it). */
98
+ hideAt(i: number): this {
99
+ if (i < 0 || i >= this.count) throw new RangeError(`InstancedMesh: index ${i} out of 0..${this.count - 1}`)
100
+ this._data.fill(0, i * 16, i * 16 + 16)
101
+ this._touch(i)
102
+ return this
103
+ }
104
+
105
+ /** Bulk write: `matrices` holds 16 floats per instance, starting at instance `offset`. */
106
+ setMatrices(matrices: ArrayLike<number>, offset = 0): this {
107
+ const n = Math.min(Math.floor(matrices.length / 16), this.count - offset)
108
+ if (n <= 0) return this
109
+ this._data.set(Array.prototype.slice.call(matrices, 0, n * 16), offset * 16)
110
+ this._touch(offset)
111
+ this._touch(offset + n - 1)
112
+ return this
113
+ }
114
+
115
+ /** The 4×4 of instance `i` as stored (a copy). */
116
+ matrixAt(i: number): Mat4 {
117
+ return Mat4.from(this._data.subarray(i * 16, i * 16 + 16))
118
+ }
119
+
120
+ /** Upload every change since the last commit (one bridge call covering the dirty range). */
121
+ commit(): this {
122
+ if (this._hi < this._lo) return this
123
+ const lo = this._lo, hi = this._hi
124
+ this._lo = Infinity
125
+ this._hi = -1
126
+ _creator.setInstanceTransforms(this.id, this._data.subarray(lo * 16, (hi + 1) * 16), lo)
127
+ return this
128
+ }
129
+
130
+ /** Whether edits are waiting for `commit()`. */
131
+ get dirty(): boolean { return this._hi >= this._lo }
132
+ }