reze-engine 0.54.16 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/engine.d.ts +210 -0
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +1063 -11
  4. package/dist/graph/presets/pool_floor.d.ts +3 -0
  5. package/dist/graph/presets/pool_floor.d.ts.map +1 -0
  6. package/dist/graph/presets/pool_floor.js +42 -0
  7. package/dist/graph/presets/water.d.ts +3 -0
  8. package/dist/graph/presets/water.d.ts.map +1 -0
  9. package/dist/graph/presets/water.js +31 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +8 -0
  13. package/dist/model.d.ts.map +1 -1
  14. package/dist/model.js +10 -0
  15. package/dist/overlay.d.ts +200 -0
  16. package/dist/overlay.d.ts.map +1 -0
  17. package/dist/overlay.js +806 -0
  18. package/dist/physics/autofit.d.ts +147 -0
  19. package/dist/physics/autofit.d.ts.map +1 -0
  20. package/dist/physics/autofit.js +501 -0
  21. package/dist/pmx-document.d.ts +218 -0
  22. package/dist/pmx-document.d.ts.map +1 -0
  23. package/dist/pmx-document.js +685 -0
  24. package/dist/shaders/passes/field-blit.d.ts +26 -0
  25. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  26. package/dist/shaders/passes/field-blit.js +65 -0
  27. package/dist/shaders/passes/ground-noise.d.ts +7 -0
  28. package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
  29. package/dist/shaders/passes/ground-noise.js +88 -0
  30. package/dist/shaders/passes/overlay.d.ts +3 -0
  31. package/dist/shaders/passes/overlay.d.ts.map +1 -0
  32. package/dist/shaders/passes/overlay.js +164 -0
  33. package/dist/shaders/passes/sim.d.ts +34 -0
  34. package/dist/shaders/passes/sim.d.ts.map +1 -0
  35. package/dist/shaders/passes/sim.js +169 -0
  36. package/dist/shaders/passes/wireframe.d.ts +2 -0
  37. package/dist/shaders/passes/wireframe.d.ts.map +1 -0
  38. package/dist/shaders/passes/wireframe.js +107 -0
  39. package/dist/shaders/score-api.d.ts +10 -0
  40. package/dist/shaders/score-api.d.ts.map +1 -0
  41. package/dist/shaders/score-api.js +114 -0
  42. package/package.json +1 -1
  43. package/src/engine.ts +1147 -13
  44. package/src/index.ts +50 -0
  45. package/src/model.ts +9 -0
  46. package/src/overlay.ts +1000 -0
  47. package/src/pmx-document.ts +882 -0
  48. package/src/shaders/passes/overlay.ts +166 -0
  49. package/src/shaders/passes/wireframe.ts +108 -0
package/src/overlay.ts ADDED
@@ -0,0 +1,1000 @@
1
+ // The data behind the overlay pass: what an overlay primitive IS, the unit
2
+ // wireframes the pass draws, and the three builders that turn a posed model into
3
+ // a list of them.
4
+ //
5
+ // The builders are pure — (model, physics, options) -> OverlayPrimitive[] — so a
6
+ // host can call them itself, print the result, diff two poses, or hand a list
7
+ // straight to setOverlay without the engine's live layers in the way. The pass
8
+ // is one caller of them.
9
+ //
10
+ // Everything they emit is WORLD space, root transform included, so a list is
11
+ // meaningful on its own.
12
+
13
+ import { Quat, Vec3, Mat4 } from "./math"
14
+ import { RigidbodyShape, RigidbodyType } from "./physics/types"
15
+ import type { Rigidbody } from "./physics/types"
16
+ import type { Model } from "./model"
17
+ import type { RezePhysics } from "./physics"
18
+
19
+ export type RGBA = readonly [number, number, number, number]
20
+ export type Vec3Tuple = readonly [number, number, number]
21
+
22
+ /** The unit wireframes the overlay pass carries, each a list of line segments. */
23
+ export type OverlayShape =
24
+ | "line"
25
+ | "dashedLine"
26
+ | "axes"
27
+ | "circle"
28
+ | "dot"
29
+ | "link"
30
+ | "sphere"
31
+ | "box"
32
+ | "capsule"
33
+ | "solidSphere"
34
+ | "solidBox"
35
+ | "solidCapsule"
36
+
37
+ export const OVERLAY_SHAPES: readonly OverlayShape[] = [
38
+ "line",
39
+ "dashedLine",
40
+ "axes",
41
+ "circle",
42
+ "dot",
43
+ "link",
44
+ "sphere",
45
+ "box",
46
+ "capsule",
47
+ "solidSphere",
48
+ "solidBox",
49
+ "solidCapsule",
50
+ ]
51
+
52
+ /**
53
+ * One wireframe copy. `scale` is in world units and means half-extents for
54
+ * `box`, radius for `sphere` and `capsule`, and (width, length, width) for
55
+ * `line`, `dashedLine` and `axes`.
56
+ */
57
+ export interface OverlayPrimitive {
58
+ shape: OverlayShape
59
+ /** World position of the shape's origin. */
60
+ position: Vec3Tuple
61
+ /** World rotation, quaternion xyzw. Default identity. */
62
+ rotation?: readonly [number, number, number, number]
63
+ /** World-unit size, per the shape's own reading above. 1e-4–1e4. Default [1,1,1]. */
64
+ scale?: Vec3Tuple
65
+ /** Capsule half-height along local +Y, world units. 0–1e4. Ignored by every
66
+ * other shape. Default 0. */
67
+ extent?: number
68
+ /** Linear rgba, each 0–1. */
69
+ color: RGBA
70
+ /** Stroke width in device pixels. Ignored by the filled shapes (`circle`,
71
+ * `dot`), whose weight is in their geometry. Defaults to OVERLAY_STYLE.strokePx. */
72
+ thickness?: number
73
+ }
74
+
75
+ /** The shapes that are solid geometry rather than stroked outlines. They draw
76
+ * through their own pipeline: no depth write, no culling, so a translucent
77
+ * volume reads as a volume and never hides the rig behind it. */
78
+ export const OVERLAY_SOLID_SHAPES: ReadonlySet<OverlayShape> = new Set<OverlayShape>([
79
+ "solidSphere",
80
+ "solidBox",
81
+ "solidCapsule",
82
+ ])
83
+
84
+ /** Floats per instance in the pass's instance stream. */
85
+ export const OVERLAY_INSTANCE_FLOATS = 16
86
+ /** Interleaved pos(3), dir(3), caps(2), side(1), t(1), mode(1) per vertex. */
87
+ export const OVERLAY_VERTEX_FLOATS = 11
88
+
89
+ /** Radius of the marker's solid centre, as a fraction of the ring's. */
90
+ const DOT_RADIUS = 0.42
91
+ /** Segments in a marker's ring and centre. They are small, always face the
92
+ * camera, and there are only a few hundred, so smoothness is nearly free. */
93
+ const MARKER_SEG = 48
94
+
95
+ /** Segments around a sphere's great circles and a capsule's rings. */
96
+ const RING_SEG = 32
97
+ /** Segments in one half-circle of a capsule cap (ring → pole → ring). */
98
+ const CAP_SEG = 16
99
+ /** Dashes in a dashed unit line. */
100
+ const DASHES = 7
101
+
102
+ export interface OverlayGeometry {
103
+ vertices: Float32Array<ArrayBuffer>
104
+ /** Where each shape's vertices start, and how many it has. */
105
+ ranges: Record<OverlayShape, { first: number; count: number }>
106
+ }
107
+
108
+ /** Builds every unit wireframe back to back in one line-list vertex buffer. */
109
+ export function buildOverlayShapes(): OverlayGeometry {
110
+ const v: number[] = []
111
+ const ranges = {} as Record<OverlayShape, { first: number; count: number }>
112
+
113
+ // Placement mode for the shape being emitted — see `placeWorld` in the shader.
114
+ let b = 0
115
+ // A stroked segment: 6 vertices carrying each endpoint, the offset to the
116
+ // other, both cap signs, which side of the quad it is on, and its parameter
117
+ // along the segment. c0/c1 are the capsule cap signs — see the shader.
118
+ const seg = (p0: Vec3Tuple, p1: Vec3Tuple, c0 = 0, c1 = 0) => {
119
+ const dx = p1[0] - p0[0],
120
+ dy = p1[1] - p0[1],
121
+ dz = p1[2] - p0[2]
122
+ // prettier-ignore
123
+ v.push(
124
+ p0[0], p0[1], p0[2], dx, dy, dz, c0, c1, -1, 0, b,
125
+ p0[0], p0[1], p0[2], dx, dy, dz, c0, c1, 1, 0, b,
126
+ p1[0], p1[1], p1[2], -dx, -dy, -dz, c1, c0, -1, 1, b,
127
+ p0[0], p0[1], p0[2], dx, dy, dz, c0, c1, 1, 0, b,
128
+ p1[0], p1[1], p1[2], -dx, -dy, -dz, c1, c0, 1, 1, b,
129
+ p1[0], p1[1], p1[2], -dx, -dy, -dz, c1, c0, -1, 1, b,
130
+ )
131
+ }
132
+ // A filled triangle, for the shapes that are geometry rather than strokes.
133
+ const tri = (p0: Vec3Tuple, p1: Vec3Tuple, p2: Vec3Tuple) => {
134
+ for (const p of [p0, p1, p2]) v.push(p[0], p[1], p[2], 0, 0, 0, 0, 0, 0, 0, b)
135
+ }
136
+ // A filled triangle whose vertices carry their own cap signs — the solid
137
+ // capsule needs it, since one quad of its wall straddles both hemispheres.
138
+ const triC = (p0: Vec3Tuple, c0: number, p1: Vec3Tuple, c1: number, p2: Vec3Tuple, c2: number) => {
139
+ v.push(p0[0], p0[1], p0[2], 0, 0, 0, c0, 0, 0, 0, b)
140
+ v.push(p1[0], p1[1], p1[2], 0, 0, 0, c1, 0, 0, 0, b)
141
+ v.push(p2[0], p2[1], p2[2], 0, 0, 0, c2, 0, 0, 0, b)
142
+ }
143
+ // One rim vertex of the marker's ring: a direction, and which rim it is on.
144
+ const rim = (d: Vec3Tuple, side: number) => {
145
+ v.push(d[0], d[1], d[2], 0, 0, 0, 0, 0, side, 0, b)
146
+ }
147
+ const open = (shape: OverlayShape) => {
148
+ ranges[shape] = { first: v.length / OVERLAY_VERTEX_FLOATS, count: 0 }
149
+ }
150
+ const close = (shape: OverlayShape) => {
151
+ ranges[shape].count = v.length / OVERLAY_VERTEX_FLOATS - ranges[shape].first
152
+ }
153
+
154
+ // line — origin to +Y. scale.y is its length; point it with the rotation.
155
+ open("line")
156
+ seg([0, 0, 0], [0, 1, 0])
157
+ close("line")
158
+
159
+ // dashedLine — the same run, broken up. A dash is geometry here rather than a
160
+ // fragment test, which is what a line pass can express without a shader for it.
161
+ open("dashedLine")
162
+ for (let i = 0; i < DASHES; i++) {
163
+ const a = i / DASHES
164
+ const b = (i + 0.5) / DASHES
165
+ seg([0, a, 0], [0, b, 0])
166
+ }
167
+ close("dashedLine")
168
+
169
+ // axes — a cross through the origin, one segment per axis, ±scale.
170
+ open("axes")
171
+ seg([-1, 0, 0], [1, 0, 0])
172
+ seg([0, -1, 0], [0, 1, 0])
173
+ seg([0, 0, -1], [0, 0, 1])
174
+ close("axes")
175
+
176
+ // circle — the bone marker's ring, facing the camera. Triangles between an
177
+ // inner and an outer rim, which the shader offsets radially in SCREEN space:
178
+ // the radius is world, the stroke is the same pixel width as the links, and
179
+ // the two meet without a step. `side` names the rim, -1 inner and +1 outer.
180
+ b = 4
181
+ open("circle")
182
+ for (let i = 0; i < MARKER_SEG; i++) {
183
+ const a0 = (i / MARKER_SEG) * Math.PI * 2
184
+ const a1 = ((i + 1) / MARKER_SEG) * Math.PI * 2
185
+ const d0: Vec3Tuple = [Math.cos(a0), Math.sin(a0), 0]
186
+ const d1: Vec3Tuple = [Math.cos(a1), Math.sin(a1), 0]
187
+ rim(d0, -1)
188
+ rim(d0, 1)
189
+ rim(d1, 1)
190
+ rim(d0, -1)
191
+ rim(d1, 1)
192
+ rim(d1, -1)
193
+ }
194
+ close("circle")
195
+
196
+ // dot — the marker's solid centre. A plain camera-facing fan: mode 3, NOT the
197
+ // ring's 4, whose radial offset is undefined at a fan's centre vertex.
198
+ b = 3
199
+ open("dot")
200
+ for (let i = 0; i < MARKER_SEG; i++) {
201
+ const a0 = (i / MARKER_SEG) * Math.PI * 2
202
+ const a1 = ((i + 1) / MARKER_SEG) * Math.PI * 2
203
+ tri([0, 0, 0], [Math.cos(a0), Math.sin(a0), 0], [Math.cos(a1), Math.sin(a1), 0])
204
+ }
205
+ close("dot")
206
+ b = 2
207
+
208
+ // link — the two lines from a marker's edge to its child. scale.x is the
209
+ // half-width at the base (the marker's radius), scale.y the distance to the
210
+ // child, and the rotation points +Y at it. Single segments, so there are no
211
+ // joins: the stroke can be as thick as it likes.
212
+ open("link")
213
+ seg([-1, 0, 0], [0, 1, 0])
214
+ seg([1, 0, 0], [0, 1, 0])
215
+ close("link")
216
+ b = 0
217
+
218
+ // sphere — three great circles of radius 1.
219
+ open("sphere")
220
+ for (let plane = 0; plane < 3; plane++) {
221
+ for (let i = 0; i < RING_SEG; i++) {
222
+ const a0 = (i / RING_SEG) * Math.PI * 2
223
+ const a1 = ((i + 1) / RING_SEG) * Math.PI * 2
224
+ const c0 = Math.cos(a0),
225
+ s0 = Math.sin(a0)
226
+ const c1 = Math.cos(a1),
227
+ s1 = Math.sin(a1)
228
+ if (plane === 0) seg([0, c0, s0], [0, c1, s1])
229
+ else if (plane === 1) seg([s0, 0, c0], [s1, 0, c1])
230
+ else seg([c0, s0, 0], [c1, s1, 0])
231
+ }
232
+ }
233
+ close("sphere")
234
+
235
+ // box — the 12 edges of a cube of half-extent 1.
236
+ open("box")
237
+ for (let axis = 0; axis < 3; axis++) {
238
+ for (let a = -1; a <= 1; a += 2) {
239
+ for (let b = -1; b <= 1; b += 2) {
240
+ const p0: number[] = [0, 0, 0]
241
+ const p1: number[] = [0, 0, 0]
242
+ p0[axis] = -1
243
+ p1[axis] = 1
244
+ p0[(axis + 1) % 3] = p1[(axis + 1) % 3] = a
245
+ p0[(axis + 2) % 3] = p1[(axis + 2) % 3] = b
246
+ seg(p0 as unknown as Vec3Tuple, p1 as unknown as Vec3Tuple)
247
+ }
248
+ }
249
+ }
250
+ close("box")
251
+
252
+ // capsule — radius 1, with both hemispheres AT the origin and the cap sign
253
+ // saying which way `extent` pushes them. Nothing here is scaled along Y, so a
254
+ // long thin body keeps round caps.
255
+ open("capsule")
256
+ for (const capSign of [1, -1]) {
257
+ for (let i = 0; i < RING_SEG; i++) {
258
+ const a0 = (i / RING_SEG) * Math.PI * 2
259
+ const a1 = ((i + 1) / RING_SEG) * Math.PI * 2
260
+ seg([Math.cos(a0), 0, Math.sin(a0)], [Math.cos(a1), 0, Math.sin(a1)], capSign, capSign)
261
+ }
262
+ for (let plane = 0; plane < 2; plane++) {
263
+ for (let i = 0; i < CAP_SEG; i++) {
264
+ const a0 = (i / CAP_SEG) * Math.PI
265
+ const a1 = ((i + 1) / CAP_SEG) * Math.PI
266
+ const c0 = Math.cos(a0),
267
+ s0 = Math.sin(a0) * capSign
268
+ const c1 = Math.cos(a1),
269
+ s1 = Math.sin(a1) * capSign
270
+ if (plane === 0) seg([c0, s0, 0], [c1, s1, 0], capSign, capSign)
271
+ else seg([0, s0, c0], [0, s1, c1], capSign, capSign)
272
+ }
273
+ }
274
+ }
275
+ // The cylinder's four side lines. Both endpoints are the same point on the
276
+ // ring — the cap signs are what separate them, one pushed to +extent and one
277
+ // to -extent, so the line is exactly as long as the body is.
278
+ for (const [x, z] of [
279
+ [1, 0],
280
+ [0, 1],
281
+ [-1, 0],
282
+ [0, -1],
283
+ ]) {
284
+ seg([x, 0, z], [x, 0, z], 1, -1)
285
+ }
286
+ close("capsule")
287
+
288
+ // ── Solid volumes ────────────────────────────────────────────────
289
+ // Mode 5: placed in world like their wireframe twins, but filled, so they
290
+ // skip the stroke extrusion. Same size conventions — half-extents for the
291
+ // box, radius for the sphere, radius plus `extent` for the capsule.
292
+ b = 5
293
+ const LAT = 10
294
+ const LON = 24
295
+
296
+ open("solidSphere")
297
+ for (let y = 0; y < LAT; y++) {
298
+ const p0 = (y / LAT) * Math.PI - Math.PI / 2
299
+ const p1 = ((y + 1) / LAT) * Math.PI - Math.PI / 2
300
+ for (let x = 0; x < LON; x++) {
301
+ const t0 = (x / LON) * Math.PI * 2
302
+ const t1 = ((x + 1) / LON) * Math.PI * 2
303
+ const at = (phi: number, theta: number): Vec3Tuple => [
304
+ Math.cos(phi) * Math.cos(theta),
305
+ Math.sin(phi),
306
+ Math.cos(phi) * Math.sin(theta),
307
+ ]
308
+ tri(at(p0, t0), at(p1, t0), at(p1, t1))
309
+ tri(at(p0, t0), at(p1, t1), at(p0, t1))
310
+ }
311
+ }
312
+ close("solidSphere")
313
+
314
+ open("solidBox")
315
+ for (let axis = 0; axis < 3; axis++) {
316
+ for (const face of [-1, 1]) {
317
+ const corner = (u: number, w: number): Vec3Tuple => {
318
+ const q: number[] = [0, 0, 0]
319
+ q[axis] = face
320
+ q[(axis + 1) % 3] = u
321
+ q[(axis + 2) % 3] = w
322
+ return q as unknown as Vec3Tuple
323
+ }
324
+ tri(corner(-1, -1), corner(1, -1), corner(1, 1))
325
+ tri(corner(-1, -1), corner(1, 1), corner(-1, 1))
326
+ }
327
+ }
328
+ close("solidBox")
329
+
330
+ open("solidCapsule")
331
+ for (let x = 0; x < LON; x++) {
332
+ const t0 = (x / LON) * Math.PI * 2
333
+ const t1 = ((x + 1) / LON) * Math.PI * 2
334
+ const r0: Vec3Tuple = [Math.cos(t0), 0, Math.sin(t0)]
335
+ const r1: Vec3Tuple = [Math.cos(t1), 0, Math.sin(t1)]
336
+ // The wall: one quad from the top cap's rim to the bottom cap's.
337
+ triC(r0, 1, r0, -1, r1, -1)
338
+ triC(r0, 1, r1, -1, r1, 1)
339
+ // The two hemispheres, each entirely on its own cap.
340
+ for (const capSign of [1, -1]) {
341
+ for (let y = 0; y < LAT; y++) {
342
+ const p0 = (y / LAT) * (Math.PI / 2)
343
+ const p1 = ((y + 1) / LAT) * (Math.PI / 2)
344
+ const at = (phi: number, theta: number): Vec3Tuple => [
345
+ Math.cos(phi) * Math.cos(theta),
346
+ Math.sin(phi) * capSign,
347
+ Math.cos(phi) * Math.sin(theta),
348
+ ]
349
+ triC(at(p0, t0), capSign, at(p1, t0), capSign, at(p1, t1), capSign)
350
+ triC(at(p0, t0), capSign, at(p1, t1), capSign, at(p0, t1), capSign)
351
+ }
352
+ }
353
+ }
354
+ close("solidCapsule")
355
+ b = 0
356
+
357
+ return { vertices: new Float32Array(v), ranges }
358
+ }
359
+
360
+ /** Packs one primitive into the pass's instance layout at `offset` floats. */
361
+ export function writeOverlayInstance(p: OverlayPrimitive, out: Float32Array, offset: number): void {
362
+ const r = p.rotation ?? IDENTITY_ROT
363
+ const s = p.scale ?? UNIT_SCALE
364
+ out[offset + 0] = r[0]
365
+ out[offset + 1] = r[1]
366
+ out[offset + 2] = r[2]
367
+ out[offset + 3] = r[3]
368
+ out[offset + 4] = p.position[0]
369
+ out[offset + 5] = p.position[1]
370
+ out[offset + 6] = p.position[2]
371
+ out[offset + 7] = p.extent ?? 0
372
+ out[offset + 8] = s[0]
373
+ out[offset + 9] = s[1]
374
+ out[offset + 10] = s[2]
375
+ out[offset + 11] = p.thickness ?? OVERLAY_STYLE.strokePx
376
+ out[offset + 12] = p.color[0]
377
+ out[offset + 13] = p.color[1]
378
+ out[offset + 14] = p.color[2]
379
+ out[offset + 15] = p.color[3]
380
+ }
381
+
382
+ const IDENTITY_ROT: readonly [number, number, number, number] = [0, 0, 0, 1]
383
+ const UNIT_SCALE: Vec3Tuple = [1, 1, 1]
384
+
385
+ /**
386
+ * Every bone's marker position in world space, root transform included.
387
+ *
388
+ * THE SAME points boneOverlay puts markers at, which is the whole reason it is a
389
+ * function rather than a loop inside the picker: a hit test that recomputes
390
+ * where a marker "should" be is a hit test that drifts away from the one drawn.
391
+ */
392
+ export function boneMarkerPositions(model: Model, out?: Float32Array): Float32Array {
393
+ const world = model.getWorldMatrices()
394
+ const dst = out && out.length >= world.length * 3 ? out : new Float32Array(world.length * 3)
395
+ const root = rootTransform(model)
396
+ const local = new Vec3(0, 0, 0)
397
+ for (let i = 0; i < world.length; i++) {
398
+ const m = world[i].values
399
+ if (root.identity) {
400
+ dst[i * 3] = m[12]
401
+ dst[i * 3 + 1] = m[13]
402
+ dst[i * 3 + 2] = m[14]
403
+ continue
404
+ }
405
+ local.setXYZ(m[12] * root.scale, m[13] * root.scale, m[14] * root.scale)
406
+ const r = Quat.rotateVec(root.rotation, local)
407
+ dst[i * 3] = r.x + root.position.x
408
+ dst[i * 3 + 1] = r.y + root.position.y
409
+ dst[i * 3 + 2] = r.z + root.position.z
410
+ }
411
+ return dst
412
+ }
413
+
414
+ /**
415
+ * How big this model is, from its bone positions — the reference every default
416
+ * size here is a fraction of, so one set of numbers suits a 1-unit model and a
417
+ * 100-unit one.
418
+ */
419
+ function skeletonExtent(world: readonly Mat4[]): number {
420
+ let minY = Infinity,
421
+ maxY = -Infinity,
422
+ minX = Infinity,
423
+ maxX = -Infinity
424
+ for (const m of world) {
425
+ const v = m.values
426
+ if (v[12] < minX) minX = v[12]
427
+ if (v[12] > maxX) maxX = v[12]
428
+ if (v[13] < minY) minY = v[13]
429
+ if (v[13] > maxY) maxY = v[13]
430
+ }
431
+ return Math.max(maxY - minY, maxX - minX, 1e-4)
432
+ }
433
+
434
+ // ──────────────────────────────────────────────────────────────────
435
+ // Bones
436
+
437
+ /** What a bone is driven BY, which is what the colour says. Decided most
438
+ * specific first: selected, ik, physics, plain.
439
+ *
440
+ * Four, not the six this started with. Append and twist had classes of their
441
+ * own and are plain now: a colour has to earn a category, and on a 147-bone
442
+ * skeleton those two spent their distinction on bones nobody was hunting for.
443
+ * Both are still on the Bone, for a panel to show where it matters. */
444
+ export type BoneClass = "plain" | "ik" | "physics" | "selected"
445
+
446
+ /**
447
+ * The overlay's colours, and the only place they live.
448
+ *
449
+ * NOT an option a host passes. An overlay means the same thing in every product
450
+ * that draws one — orange is a bone the solver drives, whichever app you are
451
+ * looking at — and a colour each app picks for itself is a colour that drifts.
452
+ * A host that wants its own look builds its own list through setOverlay.
453
+ *
454
+ * Every value is one step of the same ramp (Tailwind's 500), so the classes read
455
+ * as a set and hue is the only thing that separates them.
456
+ *
457
+ * The rig is one colour and selection is another, and that is the whole scheme.
458
+ * Every class that had a hue of its own lost it: on a 147-bone skeleton the
459
+ * distinctions were spent on bones nobody was hunting for, and the panel is
460
+ * where those belong. The classes remain so a panel can filter on them.
461
+ *
462
+ * Red is spent entirely on `selected`, the one distinction that must never be
463
+ * ambiguous, and green is not used at all — red-green being the confusion most
464
+ * colour blindness causes.
465
+ *
466
+ * A physics-driven bone and a dynamic body share orange on purpose. They are the
467
+ * same fact seen twice — the solver owns this — and colouring them apart would
468
+ * invent a difference that is not there.
469
+ *
470
+ * The CATEGORIES are MMD's, straight out of the PMX file — IK chains, 付与親,
471
+ * 軸制限, a body's physics mode. The colours are not: MMD has no canonical
472
+ * palette for them.
473
+ */
474
+ /**
475
+ * Every number the overlay's LOOK depends on, in one place beside the colours,
476
+ * and for the same reason: an overlay should read the same in every product that
477
+ * draws one, so none of this is an option a host passes.
478
+ *
479
+ * A host that wants its own look builds its own list through setOverlay.
480
+ */
481
+ export const OVERLAY_STYLE = {
482
+ /** Stroke width for the rig, in DEVICE pixels — half this on screen at 2x. */
483
+ strokePx: 2,
484
+ /** Stroke width for the mesh wireframe. Thinner: there are tens of thousands
485
+ * of edges and they are a ground for the rig, not the subject. */
486
+ meshStrokePx: 2,
487
+ /** How much heavier the selected item draws, on both stroke and marker. */
488
+ selectedScale: 1.6,
489
+
490
+ /** Bone marker radius for a bone that governs the whole model, as a fraction
491
+ * of the skeleton's extent — so one number suits any model scale. */
492
+ markerSize: 0.005,
493
+ /** The reach a full-size marker corresponds to, as a fraction of the extent. */
494
+ markerReference: 0.2,
495
+ /** Marker radius is held between these multiples of markerSize. */
496
+ markerMin: 0.55,
497
+ markerMax: 1.0,
498
+ /** Floor on a leaf's reach, world units, so a tip still has a marker. */
499
+ minReach: 0.25,
500
+ /** Joint cross arm length, as a fraction of the skeleton's extent. */
501
+ jointSize: 0.006,
502
+
503
+ /** Stroke for the material borders drawn over the mesh wireframe. */
504
+ seamStrokePx: 3,
505
+ /** Stroke for the material under the pointer — the same self-occluding reveal
506
+ * a pick draws, layered over the section-wide view rather than replacing it,
507
+ * and heavier than either a pick's own edges or a seam so it still pops out
508
+ * of a view that already has two other things drawn in it. */
509
+ hoverStrokePx: 4,
510
+ /** How strongly the triangulation draws under those borders.
511
+ *
512
+ * Both at full strength is one undifferentiated field of yellow — forty
513
+ * thousand edges saying what the model is made of the way a page of hex says
514
+ * what a file contains. The borders are the answer and the triangulation is
515
+ * the ground it reads against, so the ground steps back. */
516
+ meshAlpha: 0.55,
517
+ } as const
518
+
519
+ export const DEFAULT_BONE_PALETTE: Record<BoneClass, RGBA> = {
520
+ /** Nothing in particular drives it — most of the skeleton, so it is the colour
521
+ * the whole overlay reads as. */
522
+ plain: [0.231, 0.51, 0.965, 1], // blue-500
523
+ /** Has an IK chain (ikLinks): the leg IK bones and friends. Blue, the same as
524
+ * plain — a colour of its own bought nothing here. Leg IK sits on the floor
525
+ * and reaches almost nothing, so these are the smallest markers in the rig
526
+ * and mostly overlapped by the foot bones they share a place with; no hue
527
+ * rescues that. If they need to stand out later it is their SIZE that has to
528
+ * change, since a control bone's length means nothing to begin with. */
529
+ ik: [0.231, 0.51, 0.965, 1], // blue-500
530
+ /** A dynamic rigidbody moves it — hair, skirt, anything the solver owns. Blue,
531
+ * the same as plain: which bones the solver owns is the rigidbody layer's
532
+ * question, and it already answers it. Kept as a class so a panel can pick it
533
+ * out, spending no colour on it here. */
534
+ physics: [0.231, 0.51, 0.965, 1], // blue-500
535
+ /** The one you picked. Red is spent entirely here and used nowhere else, so
536
+ * nothing on screen can be mistaken for a selection. */
537
+ selected: [0.937, 0.267, 0.267, 1], // red-500
538
+ }
539
+
540
+ export interface BoneOverlayOptions {
541
+ /** Name of the bone drawn in the `selected` colour. */
542
+ selected?: string | null
543
+ /** Bone names to draw. Omit for all of them. */
544
+ include?: readonly string[]
545
+ }
546
+
547
+ /**
548
+ * The skeleton, MMD's way: a ring with a solid centre at every bone, and two
549
+ * lines from that ring converging on the child's.
550
+ *
551
+ * Ring and centre are filled geometry facing the camera, so a marker is a circle
552
+ * from any angle and can be heavy without a stroke wide enough to tear itself
553
+ * apart. The two link lines leave the ring at its edge — the taper is what says
554
+ * which way the bone points, where a single line between centres would not.
555
+ */
556
+ export function boneOverlay(model: Model, options: BoneOverlayOptions = {}): OverlayPrimitive[] {
557
+ const bones = model.getSkeleton().bones
558
+ const world = model.getWorldMatrices()
559
+ const palette = DEFAULT_BONE_PALETTE
560
+ const include = options.include ? new Set(options.include) : null
561
+ const extent = skeletonExtent(world)
562
+ const markerSize = extent * OVERLAY_STYLE.markerSize
563
+
564
+ const physicsBones = new Set<number>()
565
+ for (const rb of model.getRigidbodies()) {
566
+ if (rb.type !== RigidbodyType.Static && rb.boneIndex >= 0) physicsBones.add(rb.boneIndex)
567
+ }
568
+
569
+ // Marker radius per bone, from its REACH: the longest chain still hanging off
570
+ // it, head to furthest descendant.
571
+ //
572
+ // Not the bone's own length, which is what this measured before. A bone's own
573
+ // length says nothing about its place in the rig — every segment of a hair
574
+ // strand is the same short segment, so a strand's ROOT scored no larger than
575
+ // its tip, and nothing stopped a child's segment being longer than its
576
+ // parent's and drawing bigger. Reach cannot do either: a parent's reach is its
577
+ // child's plus the step between them, so a marker never grows going down a
578
+ // chain, and a bone that governs an arm, a strand or a whole side of the body
579
+ // is marked for what it carries rather than for the gap to its first child.
580
+ const depth = new Int32Array(bones.length)
581
+ for (let i = 0; i < bones.length; i++) {
582
+ let d = 0
583
+ let c = bones[i].parentIndex
584
+ // Guarded: a malformed file can cycle, and this must not hang on one.
585
+ while (c >= 0 && c < bones.length && d < bones.length) {
586
+ d++
587
+ c = bones[c].parentIndex
588
+ }
589
+ depth[i] = d
590
+ }
591
+ const deepestFirst = Array.from(bones.keys()).sort((a, b) => depth[b] - depth[a])
592
+ const reach = new Float32Array(bones.length)
593
+ for (const i of deepestFirst) {
594
+ const p = bones[i].parentIndex
595
+ if (p < 0 || p >= world.length || i >= world.length) continue
596
+ const m = world[p].values
597
+ const cm = world[i].values
598
+ const step = Math.hypot(cm[12] - m[12], cm[13] - m[13], cm[14] - m[14])
599
+ if (reach[i] + step > reach[p]) reach[p] = reach[i] + step
600
+ }
601
+
602
+ // Square-rooted, so the spread stays legible: without it the root bone's reach
603
+ // is the whole model and everything below it collapses into the floor.
604
+ const sizes = new Float32Array(bones.length)
605
+ const referenceReach = Math.max(extent * OVERLAY_STYLE.markerReference, 1e-6)
606
+ for (let i = 0; i < bones.length; i++) {
607
+ const r = Math.sqrt(Math.max(reach[i], OVERLAY_STYLE.minReach) / referenceReach)
608
+ sizes[i] = markerSize * Math.min(Math.max(r, OVERLAY_STYLE.markerMin), OVERLAY_STYLE.markerMax)
609
+ }
610
+
611
+ // MMD stacks control bones on one point — 全ての親, センター and グルーブ all sit
612
+ // at the hips. Sized per bone they draw as a nest of rings, so a position gets
613
+ // ONE marker, the largest asked for there. Links are still drawn for every
614
+ // bone: it is the markers that overlap, not the connections.
615
+ const grid = Math.max(extent * 1e-4, 1e-6)
616
+ const key = (m: Float32Array) =>
617
+ `${Math.round(m[12] / grid)},${Math.round(m[13] / grid)},${Math.round(m[14] / grid)}`
618
+ const markerAt = new Map<string, number>()
619
+ for (let i = 0; i < bones.length && i < world.length; i++) {
620
+ if (include && !include.has(bones[i].name)) continue
621
+ const k = key(world[i].values)
622
+ const held = markerAt.get(k)
623
+ if (held === undefined || sizes[i] > sizes[held]) markerAt.set(k, i)
624
+ }
625
+
626
+ const root = rootTransform(model)
627
+ const out: OverlayPrimitive[] = []
628
+ const head = new Vec3(0, 0, 0)
629
+ const tail = new Vec3(0, 0, 0)
630
+ const dir = new Vec3(0, 0, 0)
631
+
632
+ for (let i = 0; i < bones.length && i < world.length; i++) {
633
+ const bone = bones[i]
634
+ if (include && !include.has(bone.name)) continue
635
+ const m = world[i].values
636
+ head.setXYZ(m[12], m[13], m[14])
637
+
638
+ const selected = options.selected != null && bone.name === options.selected
639
+ const cls: BoneClass = selected
640
+ ? "selected"
641
+ : bone.ikLinks && bone.ikLinks.length > 0
642
+ ? "ik"
643
+ : physicsBones.has(i)
644
+ ? "physics"
645
+ : "plain"
646
+ const color = palette[cls]
647
+ const size = selected ? sizes[i] * OVERLAY_STYLE.selectedScale : sizes[i]
648
+ const stroke = selected ? OVERLAY_STYLE.strokePx * OVERLAY_STYLE.selectedScale : OVERLAY_STYLE.strokePx
649
+
650
+ // One link per bone, from its PARENT down to it — so every parent-child
651
+ // edge is drawn exactly once and branches all show.
652
+ //
653
+ // Not "from this bone to its first child", which is what this did and what
654
+ // made a thigh point at the wrong place: children are ordered by bone INDEX,
655
+ // not by anatomy, so 左足's children[0] is 左腿物理, a physics bone hanging
656
+ // off the thigh, and 左ひざ — the actual knee — came second. There is no
657
+ // ordering of children that is reliably the continuation. Drawing all of
658
+ // them removes the need to guess.
659
+ const parent = bone.parentIndex
660
+ if (parent >= 0 && parent < world.length) {
661
+ const pm = world[parent].values
662
+ tail.setXYZ(pm[12], pm[13], pm[14])
663
+ const span = Math.hypot(head.x - tail.x, head.y - tail.y, head.z - tail.z)
664
+ if (span > 1e-5) {
665
+ dir.setXYZ((head.x - tail.x) / span, (head.y - tail.y) / span, (head.z - tail.z) / span)
666
+ const rot = Quat.fromUnitVectors(UP, dir)
667
+ out.push(
668
+ applyRoot(root, {
669
+ shape: "link",
670
+ // The taper starts at the PARENT's marker and narrows onto this one,
671
+ // so its base half-width is the parent's radius.
672
+ position: [tail.x, tail.y, tail.z],
673
+ rotation: [rot.x, rot.y, rot.z, rot.w],
674
+ scale: [sizes[parent], span, sizes[parent]],
675
+ color,
676
+ thickness: stroke,
677
+ }),
678
+ )
679
+ }
680
+ }
681
+ // Only the bone that owns this position draws the marker.
682
+ if (markerAt.get(key(m)) !== i) continue
683
+ out.push(
684
+ applyRoot(root, {
685
+ shape: "circle",
686
+ position: [head.x, head.y, head.z],
687
+ scale: [size, size, size],
688
+ color,
689
+ // The same stroke the links carry, so they read as one line.
690
+ thickness: stroke,
691
+ }),
692
+ )
693
+ out.push(
694
+ applyRoot(root, {
695
+ shape: "dot",
696
+ position: [head.x, head.y, head.z],
697
+ scale: [size * DOT_RADIUS, size * DOT_RADIUS, size * DOT_RADIUS],
698
+ color,
699
+ }),
700
+ )
701
+ }
702
+ return out
703
+ }
704
+
705
+ // ──────────────────────────────────────────────────────────────────
706
+ // Rigidbodies
707
+
708
+ export type RigidbodyClass = "static" | "dynamic" | "selected"
709
+
710
+ /** Solid volumes, so alpha does the work a stroke used to: low enough to see the
711
+ * body through them and the rig behind them. Cool follows its bone, warm is
712
+ * driven by the solver. */
713
+ export const DEFAULT_RIGIDBODY_PALETTE: Record<RigidbodyClass, RGBA> = {
714
+ static: [0.055, 0.647, 0.914, 0.28], // sky-500
715
+ dynamic: [0.976, 0.451, 0.086, 0.38], // orange-500
716
+ selected: [0.937, 0.267, 0.267, 0.55], // red-500
717
+ }
718
+
719
+ /** The joint cross, the dashed lines to the bodies it holds, and its selection. */
720
+ export const DEFAULT_JOINT_PALETTE = {
721
+ cross: [0.055, 0.647, 0.914, 1] as RGBA, // sky-500
722
+ link: [0.055, 0.647, 0.914, 0.5] as RGBA, // sky-500
723
+ selected: [0.937, 0.267, 0.267, 1] as RGBA, // red-500
724
+ }
725
+
726
+ /** The mesh wireframe. Yellow, because it has to carry over pale skin and dark
727
+ * cloth alike and the rig owns the blues and greens. */
728
+ // yellow-400, opaque. yellow-500 measured correct on the canvas and still read
729
+ // as gold: a 2px stroke through 4x MSAA is mostly partial-coverage pixels, so a
730
+ // dense mesh of lines lands well under the flat swatch it is cut from. The step
731
+ // up pays that back rather than the pipeline being wrong.
732
+ export const DEFAULT_VERTEX_COLOR: RGBA = [0.98, 0.8, 0.082, 1]
733
+
734
+ export interface RigidbodyOverlayOptions {
735
+ /** Name of the body drawn in the `selected` colour. */
736
+ selected?: string | null
737
+ /** Body names to draw. Omit for all of them. */
738
+ include?: readonly string[]
739
+ }
740
+
741
+ /**
742
+ * The sphere, box or capsule each rigidbody actually is, where it actually is.
743
+ *
744
+ * With physics running the transforms come from the simulation, so a body drawn
745
+ * here is the body the solver collides — which is the point of drawing them
746
+ * while tuning cloth. Without it they fall back to the bone-driven bind pose.
747
+ */
748
+ export function rigidbodyOverlay(
749
+ model: Model,
750
+ physics: RezePhysics | null,
751
+ options: RigidbodyOverlayOptions = {},
752
+ ): OverlayPrimitive[] {
753
+ const bodies = model.getRigidbodies()
754
+ const palette = DEFAULT_RIGIDBODY_PALETTE
755
+ const include = options.include ? new Set(options.include) : null
756
+ const root = rootTransform(model)
757
+
758
+ const pos = new Vec3(0, 0, 0)
759
+ const rot = new Quat(0, 0, 0, 1)
760
+ const out: OverlayPrimitive[] = []
761
+
762
+ for (let i = 0; i < bodies.length; i++) {
763
+ const rb = bodies[i]
764
+ if (include && !include.has(rb.name)) continue
765
+ resolveBodyTransform(model, physics, i, pos, rot)
766
+
767
+ const selected = options.selected != null && rb.name === options.selected
768
+ const cls: RigidbodyClass = selected ? "selected" : rb.type === RigidbodyType.Static ? "static" : "dynamic"
769
+ const color = palette[cls]
770
+
771
+ if (rb.shape === RigidbodyShape.Sphere) {
772
+ const r = rb.size.x
773
+ out.push(
774
+ applyRoot(root, {
775
+ shape: "solidSphere",
776
+ position: [pos.x, pos.y, pos.z],
777
+ rotation: [rot.x, rot.y, rot.z, rot.w],
778
+ scale: [r, r, r],
779
+ color,
780
+ }),
781
+ )
782
+ } else if (rb.shape === RigidbodyShape.Box) {
783
+ out.push(
784
+ applyRoot(root, {
785
+ shape: "solidBox",
786
+ position: [pos.x, pos.y, pos.z],
787
+ rotation: [rot.x, rot.y, rot.z, rot.w],
788
+ // PMX box size is half-extents already.
789
+ scale: [rb.size.x, rb.size.y, rb.size.z],
790
+ color,
791
+ }),
792
+ )
793
+ } else {
794
+ const r = rb.size.x
795
+ out.push(
796
+ applyRoot(root, {
797
+ shape: "solidCapsule",
798
+ position: [pos.x, pos.y, pos.z],
799
+ rotation: [rot.x, rot.y, rot.z, rot.w],
800
+ scale: [r, r, r],
801
+ // PMX capsule size.y is the CYLINDER's length, caps not included.
802
+ extent: rb.size.y * 0.5,
803
+ color,
804
+ }),
805
+ )
806
+ }
807
+ }
808
+ return out
809
+ }
810
+
811
+ // ──────────────────────────────────────────────────────────────────
812
+ // Joints
813
+
814
+ export interface JointOverlayOptions {
815
+ /** Name of the joint drawn in the `selected` colour. */
816
+ selected?: string | null
817
+ /** Draw the dashed lines to the bodies a joint constrains. Default true. */
818
+ links?: boolean
819
+ /** Joint names to draw. Omit for all of them. */
820
+ include?: readonly string[]
821
+ }
822
+
823
+ /**
824
+ * An axis cross at each joint, plus a dashed line to each body it constrains.
825
+ *
826
+ * The cross is where and how the constraint frame sits; the two dashed lines
827
+ * are the pair it holds together, which is the question actually being asked of
828
+ * a skirt with two hundred of them. Dashed, because a relationship should not
829
+ * read as a thing.
830
+ */
831
+ export function jointOverlay(
832
+ model: Model,
833
+ physics: RezePhysics | null,
834
+ options: JointOverlayOptions = {},
835
+ ): OverlayPrimitive[] {
836
+ const joints = model.getJoints()
837
+ const bodies = model.getRigidbodies()
838
+ const extent = skeletonExtent(model.getWorldMatrices())
839
+ const size = extent * OVERLAY_STYLE.jointSize
840
+ const color = DEFAULT_JOINT_PALETTE.cross
841
+ const linkColor = DEFAULT_JOINT_PALETTE.link
842
+ const selectedColor = DEFAULT_JOINT_PALETTE.selected
843
+ const links = options.links ?? true
844
+ const include = options.include ? new Set(options.include) : null
845
+ const root = rootTransform(model)
846
+
847
+ const jointPos = new Vec3(0, 0, 0)
848
+ const bodyPos = new Vec3(0, 0, 0)
849
+ const bodyRot = new Quat(0, 0, 0, 1)
850
+ const out: OverlayPrimitive[] = []
851
+
852
+ for (const joint of joints) {
853
+ if (include && !include.has(joint.name)) continue
854
+ const selected = options.selected != null && joint.name === options.selected
855
+ const crossColor = selected ? selectedColor : color
856
+
857
+ // A joint's frame is authored in bind pose, so drawing it there would leave
858
+ // every joint on a simulating skirt behind. Carry it on body A instead:
859
+ // local = bindA⁻¹ · bindJoint, posed = poseA · local.
860
+ jointPos.setXYZ(joint.position.x, joint.position.y, joint.position.z)
861
+ const rot = Quat.fromEuler(joint.rotation.x, joint.rotation.y, joint.rotation.z)
862
+ const a = joint.rigidbodyIndexA
863
+ if (a >= 0 && a < bodies.length) {
864
+ resolveBodyTransform(model, physics, a, bodyPos, bodyRot)
865
+ const bind = bodies[a]
866
+ const bindRot = Quat.fromEuler(bind.shapeRotation.x, bind.shapeRotation.y, bind.shapeRotation.z)
867
+ const localPos = Quat.rotateVecInv(
868
+ bindRot,
869
+ new Vec3(
870
+ joint.position.x - bind.shapePosition.x,
871
+ joint.position.y - bind.shapePosition.y,
872
+ joint.position.z - bind.shapePosition.z,
873
+ ),
874
+ )
875
+ const localRot = Quat.conjugateInto(bindRot, new Quat(0, 0, 0, 1)).multiply(rot)
876
+ const carried = Quat.rotateVec(bodyRot, localPos)
877
+ jointPos.setXYZ(bodyPos.x + carried.x, bodyPos.y + carried.y, bodyPos.z + carried.z)
878
+ rot.set(bodyRot.multiply(localRot).normalize())
879
+ }
880
+
881
+ out.push(
882
+ applyRoot(root, {
883
+ shape: "axes",
884
+ position: [jointPos.x, jointPos.y, jointPos.z],
885
+ rotation: [rot.x, rot.y, rot.z, rot.w],
886
+ scale: [size, size, size],
887
+ color: crossColor,
888
+ thickness: selected ? OVERLAY_STYLE.strokePx * OVERLAY_STYLE.selectedScale : OVERLAY_STYLE.strokePx,
889
+ }),
890
+ )
891
+
892
+ if (!links) continue
893
+ for (const idx of [joint.rigidbodyIndexA, joint.rigidbodyIndexB]) {
894
+ if (idx < 0 || idx >= bodies.length) continue
895
+ resolveBodyTransform(model, physics, idx, bodyPos, bodyRot)
896
+ const line = lineBetween(
897
+ [jointPos.x, jointPos.y, jointPos.z],
898
+ [bodyPos.x, bodyPos.y, bodyPos.z],
899
+ selected ? selectedColor : linkColor,
900
+ true,
901
+ )
902
+ if (line) {
903
+ line.thickness = OVERLAY_STYLE.strokePx
904
+ out.push(applyRoot(root, line))
905
+ }
906
+ }
907
+ }
908
+ return out
909
+ }
910
+
911
+ /** A segment from `a` to `b`, solid or dashed. Null when the two coincide. */
912
+ export function lineBetween(a: Vec3Tuple, b: Vec3Tuple, color: RGBA, dashed = false): OverlayPrimitive | null {
913
+ const dx = b[0] - a[0],
914
+ dy = b[1] - a[1],
915
+ dz = b[2] - a[2]
916
+ const length = Math.hypot(dx, dy, dz)
917
+ if (length < 1e-6) return null
918
+ const dir = new Vec3(dx / length, dy / length, dz / length)
919
+ const rot = Quat.fromUnitVectors(UP, dir)
920
+ return {
921
+ shape: dashed ? "dashedLine" : "line",
922
+ position: a,
923
+ rotation: [rot.x, rot.y, rot.z, rot.w],
924
+ scale: [1, length, 1],
925
+ color,
926
+ }
927
+ }
928
+
929
+ const UP = new Vec3(0, 1, 0)
930
+
931
+ // ──────────────────────────────────────────────────────────────────
932
+ // Shared
933
+
934
+ /**
935
+ * Where body `index` is right now: the simulation's answer when physics is
936
+ * running, the bone-driven bind transform when it is not.
937
+ */
938
+ function resolveBodyTransform(
939
+ model: Model,
940
+ physics: RezePhysics | null,
941
+ index: number,
942
+ outPos: Vec3,
943
+ outRot: Quat,
944
+ ): void {
945
+ if (physics) {
946
+ // RigidBodyStore is [...model bodies, floor], so a model index is a store
947
+ // index and needs no mapping.
948
+ const store = physics.getStore()
949
+ if (index < store.count) {
950
+ const i3 = index * 3
951
+ const i4 = index * 4
952
+ outPos.setXYZ(store.positions[i3], store.positions[i3 + 1], store.positions[i3 + 2])
953
+ outRot.setXYZW(store.orientations[i4], store.orientations[i4 + 1], store.orientations[i4 + 2], store.orientations[i4 + 3])
954
+ return
955
+ }
956
+ }
957
+ const rb: Rigidbody = model.getRigidbodies()[index]
958
+ const world = model.getWorldMatrices()
959
+ if (rb.bodyOffsetMatrix && rb.boneIndex >= 0 && rb.boneIndex < world.length) {
960
+ const m = world[rb.boneIndex].multiply(rb.bodyOffsetMatrix)
961
+ outPos.setXYZ(m.values[12], m.values[13], m.values[14])
962
+ outRot.set(m.toQuat().normalize())
963
+ return
964
+ }
965
+ outPos.setXYZ(rb.shapePosition.x, rb.shapePosition.y, rb.shapePosition.z)
966
+ outRot.set(Quat.fromEuler(rb.shapeRotation.x, rb.shapeRotation.y, rb.shapeRotation.z))
967
+ }
968
+
969
+ interface RootTransform {
970
+ identity: boolean
971
+ position: Vec3
972
+ rotation: Quat
973
+ scale: number
974
+ }
975
+
976
+ /** The model's scene placement, which bone and body transforms do NOT carry —
977
+ * getSkinMatrices applies it on the way to the GPU, so an overlay has to too. */
978
+ function rootTransform(model: Model): RootTransform {
979
+ const p = model.position
980
+ const r = model.rotation
981
+ const s = model.scale
982
+ const identity =
983
+ p.x === 0 && p.y === 0 && p.z === 0 && r.x === 0 && r.y === 0 && r.z === 0 && r.w === 1 && s === 1
984
+ return { identity, position: p, rotation: r, scale: s }
985
+ }
986
+
987
+ function applyRoot(root: RootTransform, p: OverlayPrimitive): OverlayPrimitive {
988
+ if (root.identity) return p
989
+ const local = new Vec3(p.position[0] * root.scale, p.position[1] * root.scale, p.position[2] * root.scale)
990
+ const rotated = Quat.rotateVec(root.rotation, local)
991
+ const rot = p.rotation ?? IDENTITY_ROT
992
+ const worldRot = root.rotation.multiply(new Quat(rot[0], rot[1], rot[2], rot[3])).normalize()
993
+ const s = p.scale ?? UNIT_SCALE
994
+ p.position = [rotated.x + root.position.x, rotated.y + root.position.y, rotated.z + root.position.z]
995
+ p.rotation = [worldRot.x, worldRot.y, worldRot.z, worldRot.w]
996
+ p.scale = [s[0] * root.scale, s[1] * root.scale, s[2] * root.scale]
997
+ if (p.extent) p.extent = p.extent * root.scale
998
+ return p
999
+ }
1000
+