cozyclay 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +109 -0
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/bin/cozyclay.mjs +194 -0
  5. package/dist/ardy/cskel27-rest.json +557 -0
  6. package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  7. package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
  8. package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
  9. package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
  10. package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
  11. package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
  12. package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
  13. package/dist/assets/index-BW_S1YRy.js +4395 -0
  14. package/dist/assets/index-nRmmJgap.css +1 -0
  15. package/dist/demo/walk-then-stop.npz +0 -0
  16. package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
  17. package/dist/fonts/instrument-serif-latin.woff2 +0 -0
  18. package/dist/fonts/inter-latin.woff2 +0 -0
  19. package/dist/index.html +13 -0
  20. package/dist/models/x-bot-tpose.fbx +0 -0
  21. package/dist/models/y-bot-tpose.fbx +0 -0
  22. package/package.json +80 -0
  23. package/src/App.jsx +3840 -0
  24. package/src/ardy/client.js +140 -0
  25. package/src/ardy/convert.js +313 -0
  26. package/src/ardy/cskel27-neutral.js +39 -0
  27. package/src/ardy/cskel27.js +68 -0
  28. package/src/ardy/export.js +157 -0
  29. package/src/ardy/ik.js +610 -0
  30. package/src/ardy/npz.js +520 -0
  31. package/src/ardy/playback.js +414 -0
  32. package/src/ardy/prompt-clips.js +16 -0
  33. package/src/ardy/timeline-coordinates.js +17 -0
  34. package/src/ardy/timeline-resize.js +11 -0
  35. package/src/ardy/timeline.jsx +723 -0
  36. package/src/ardy/to-cskel27.js +205 -0
  37. package/src/ardy/waypoints.js +364 -0
  38. package/src/camera-follow.js +366 -0
  39. package/src/camera-move.js +286 -0
  40. package/src/controls.jsx +245 -0
  41. package/src/dualview.jsx +305 -0
  42. package/src/hierarchy-model.js +77 -0
  43. package/src/hierarchy-panel.jsx +365 -0
  44. package/src/history.js +90 -0
  45. package/src/main.jsx +10 -0
  46. package/src/object-catalog.jsx +90 -0
  47. package/src/object-gizmo.jsx +755 -0
  48. package/src/planview.jsx +653 -0
  49. package/src/poses.js +424 -0
  50. package/src/posestudio.jsx +811 -0
  51. package/src/props.jsx +302 -0
  52. package/src/room.jsx +64 -0
  53. package/src/scene-history.js +125 -0
  54. package/src/scene-objects.js +420 -0
  55. package/src/shot-authoring.js +113 -0
  56. package/src/shot.js +239 -0
  57. package/src/styles.css +5639 -0
  58. package/src/ui.jsx +391 -0
  59. package/src/use-render-activity.js +96 -0
  60. package/tools/ardy/BRIDGE.md +255 -0
  61. package/tools/ardy/README.md +136 -0
  62. package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
  63. package/tools/ardy/bridge.mjs +1427 -0
  64. package/tools/ardy/cclay_motion_edit.py +445 -0
  65. package/tools/ardy/cclay_sequence_generate.py +595 -0
  66. package/tools/ardy/dump-npz.py +205 -0
  67. package/tools/ardy/extract-rest.mjs +299 -0
  68. package/tools/ardy/npz.mjs +335 -0
  69. package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
  70. package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
  71. package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
  72. package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
  73. package/tools/ardy/pose-to-npz.mjs +106 -0
  74. package/tools/ardy/run-edit-on-box.sh +73 -0
  75. package/tools/ardy/run-on-box.sh +568 -0
  76. package/tools/ardy/run-sequence-on-box.sh +162 -0
  77. package/tools/ardy/visual-qa.mjs +188 -0
  78. package/tools/ardy/vq-car.mjs +52 -0
  79. package/tools/dev-full.mjs +29 -0
  80. package/tools/process-supervisor.mjs +63 -0
  81. package/tools/qa-browser.mjs +84 -0
  82. package/tools/qa-crop3.mjs +22 -0
  83. package/tools/qa-playview.mjs +49 -0
  84. package/tools/qa-screenshot.mjs +24 -0
  85. package/tools/qa-visual.mjs +51 -0
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env python3
2
+ """Dump one frame of an ARDY motion npz as a JSON document on stdout.
3
+
4
+ Runs under the ARDY box python (``~/ardy/.venv/bin/python``); it needs numpy
5
+ but no torch. Usage::
6
+
7
+ python dump-npz.py <motion.npz> <frame>
8
+
9
+ Emitted document (schema "ardy.frame.v1"):
10
+
11
+ {
12
+ "schema": "ardy.frame.v1",
13
+ "source_npz": "<basename of the npz>",
14
+ "frame": <requested frame index>,
15
+ "frames_total": <frame count of the motion>,
16
+ "members": {"<member name>": {"shape": [...], "dtype": "..."}, ...},
17
+ "local_rot_mats": [ 27 x [[3],[3],[3]] row-major ],
18
+ "posed_joints": [ 27 x [x, y, z] ]
19
+ }
20
+
21
+ Member names and axis order match the ARDY archives exactly as consumed by
22
+ CozyClay (blender-addon/cclay): ``local_rot_mats[frame, joint]`` is the
23
+ per-joint LOCAL rotation matrix in cskel27 order (Hips = joint 0), and
24
+ ``posed_joints[frame, joint]`` the joint center position. ``foot_contacts``
25
+ is optional and reported only when present. ``members`` lists every array in
26
+ the archive (sorted), including carried members like ``fps``, so a consumer
27
+ can see the exact contents without re-opening the npz.
28
+
29
+ Before emitting, every one of the 27 rotation matrices of the requested frame
30
+ is checked for finite entries, orthonormal rows/columns, and determinant +1,
31
+ with the same tolerance CozyClay's motion_retarget uses
32
+ (ROTATION_MATRIX_TOLERANCE = 1e-3 on squared norms, pairwise dots, and
33
+ determinant). On the first violation the matrix index and the failing
34
+ quantity are printed to stderr, the exit status is 1, and NO JSON is emitted:
35
+ a garbage rotation must not silently reach a downstream consumer.
36
+ """
37
+
38
+ import json
39
+ import math
40
+ import sys
41
+
42
+ import numpy as np
43
+
44
+ SCHEMA = "ardy.frame.v1"
45
+ JOINTS = 27
46
+ # Mirror of CozyClay motion_retarget.ROTATION_MATRIX_TOLERANCE: applies to
47
+ # squared row/column norms, pairwise dot products, and determinant, in
48
+ # float64. float32 ARDY serialization noise stays far below 1e-3.
49
+ TOLERANCE = 1e-3
50
+
51
+
52
+ def _fail(message):
53
+ """Report a fatal error on stderr and exit 1 without emitting JSON."""
54
+ print(f"dump-npz: {message}", file=sys.stderr)
55
+ sys.exit(1)
56
+
57
+
58
+ def _check_rotation(matrix, frame, joint):
59
+ """Return a list of violation strings for one 3x3 matrix, or [] if clean."""
60
+ violations = []
61
+ rows = [[float(matrix[i][j]) for j in range(3)] for i in range(3)]
62
+ columns = [[rows[i][j] for i in range(3)] for j in range(3)]
63
+ prefix = f"frame {frame} joint {joint}"
64
+ for label, vectors in (("row", rows), ("column", columns)):
65
+ for index, vector in enumerate(vectors):
66
+ squared_norm = sum(c * c for c in vector)
67
+ if not math.isfinite(squared_norm):
68
+ violations.append(
69
+ f"{prefix} {label} {index} has non-finite entries"
70
+ )
71
+ elif abs(squared_norm - 1.0) > TOLERANCE:
72
+ violations.append(
73
+ f"{prefix} {label} {index} squared norm {squared_norm:.6f} "
74
+ f"differs from 1.0 by more than {TOLERANCE}"
75
+ )
76
+ for first, second in ((0, 1), (0, 2), (1, 2)):
77
+ dot = sum(
78
+ vectors[first][i] * vectors[second][i] for i in range(3)
79
+ )
80
+ if not math.isfinite(dot):
81
+ violations.append(
82
+ f"{prefix} {label} {first}/{second} dot is non-finite"
83
+ )
84
+ elif abs(dot) > TOLERANCE:
85
+ violations.append(
86
+ f"{prefix} {label} {first}/{second} dot {dot:.6f} "
87
+ f"exceeds {TOLERANCE} in magnitude"
88
+ )
89
+ determinant = (
90
+ rows[0][0] * (rows[1][1] * rows[2][2] - rows[1][2] * rows[2][1])
91
+ - rows[0][1] * (rows[1][0] * rows[2][2] - rows[1][2] * rows[2][0])
92
+ + rows[0][2] * (rows[1][0] * rows[2][1] - rows[1][1] * rows[2][0])
93
+ )
94
+ if not math.isfinite(determinant):
95
+ violations.append(f"{prefix} determinant is non-finite")
96
+ elif abs(determinant - 1.0) > TOLERANCE:
97
+ violations.append(
98
+ f"{prefix} determinant {determinant:.6f} differs from 1.0 "
99
+ f"by more than {TOLERANCE}"
100
+ )
101
+ return violations
102
+
103
+
104
+ def main(argv):
105
+ if len(argv) != 3:
106
+ _fail(f"usage: {argv[0]} <motion.npz> <frame>")
107
+ npz_path, frame_text = argv[1], argv[2]
108
+ try:
109
+ frame = int(frame_text)
110
+ except ValueError:
111
+ _fail(f"frame must be an integer, got {frame_text!r}")
112
+ if frame < 0:
113
+ _fail(f"frame must be >= 0, got {frame}")
114
+
115
+ try:
116
+ data = np.load(npz_path, allow_pickle=False)
117
+ except Exception as exc:
118
+ _fail(f"cannot load {npz_path}: {exc}")
119
+
120
+ try:
121
+ if "local_rot_mats" not in data.files:
122
+ _fail(f"{npz_path} has no 'local_rot_mats' member")
123
+ if "posed_joints" not in data.files:
124
+ _fail(f"{npz_path} has no 'posed_joints' member")
125
+ rotations = data["local_rot_mats"]
126
+ joints = data["posed_joints"]
127
+ if rotations.shape[1:] != (JOINTS, 3, 3):
128
+ _fail(
129
+ f"local_rot_mats must have shape (F, {JOINTS}, 3, 3), "
130
+ f"got {rotations.shape}"
131
+ )
132
+ if joints.shape[1:] != (JOINTS, 3):
133
+ _fail(
134
+ f"posed_joints must have shape (F, {JOINTS}, 3), "
135
+ f"got {joints.shape}"
136
+ )
137
+ if joints.shape[0] != rotations.shape[0]:
138
+ _fail(
139
+ "local_rot_mats and posed_joints frame counts differ "
140
+ f"({rotations.shape[0]} vs {joints.shape[0]})"
141
+ )
142
+ frames_total = int(rotations.shape[0])
143
+ if frames_total < 1:
144
+ _fail(f"motion has no frames ({frames_total})")
145
+ if frame >= frames_total:
146
+ _fail(
147
+ f"frame {frame} out of range: motion has {frames_total} frames"
148
+ )
149
+
150
+ # Validate every rotation matrix of the requested frame BEFORE any
151
+ # JSON is written; a violation aborts with a stderr report and a
152
+ # non-zero exit status instead of emitting garbage.
153
+ violations = []
154
+ for joint in range(JOINTS):
155
+ violations.extend(_check_rotation(rotations[frame][joint], frame, joint))
156
+ for joint in range(JOINTS):
157
+ for axis in range(3):
158
+ if not math.isfinite(float(joints[frame][joint][axis])):
159
+ violations.append(
160
+ f"frame {frame} joint {joint} axis {axis} "
161
+ "position is not finite"
162
+ )
163
+ if violations:
164
+ for violation in violations[:20]:
165
+ print(f"dump-npz: VIOLATION {violation}", file=sys.stderr)
166
+ if len(violations) > 20:
167
+ print(
168
+ f"dump-npz: ... and {len(violations) - 20} more",
169
+ file=sys.stderr,
170
+ )
171
+ _fail(
172
+ f"refusing to emit {npz_path} frame {frame}: "
173
+ f"{len(violations)} rotation/position check(s) failed"
174
+ )
175
+
176
+ members = {}
177
+ for name in sorted(data.files):
178
+ member = data[name]
179
+ members[name] = {
180
+ "shape": [int(size) for size in member.shape],
181
+ "dtype": str(member.dtype),
182
+ }
183
+
184
+ document = {
185
+ "schema": SCHEMA,
186
+ "source_npz": npz_path.rsplit("/", 1)[-1],
187
+ "frame": frame,
188
+ "frames_total": frames_total,
189
+ "members": members,
190
+ "local_rot_mats": [
191
+ [[float(value) for value in row] for row in rotations[frame][joint]]
192
+ for joint in range(JOINTS)
193
+ ],
194
+ "posed_joints": [
195
+ [float(value) for value in joints[frame][joint]]
196
+ for joint in range(JOINTS)
197
+ ],
198
+ }
199
+ print(json.dumps(document, indent=2))
200
+ finally:
201
+ data.close()
202
+
203
+
204
+ if __name__ == "__main__":
205
+ main(sys.argv)
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Extract the cskel27 rest rotations (Rb) from a CozyClay T-pose FBX rig.
4
+ *
5
+ * Rb is the rotation part of each bone's rest (bind) transform in the
6
+ * armature root's space. The ARDY retarget feeds it into
7
+ * `basis = Rb^T @ L @ Rb` (CozyClay motion_retarget.py), where it is the
8
+ * Blender-side `bone.matrix_local.to_3x3()` -- the bone's rest orientation in
9
+ * armature space, NOT its parent-relative rotation and NOT its world
10
+ * orientation including the FBX object transform. Bind data comes from the
11
+ * skeleton's boneInverses (three.js FBXLoader builds those from the FBX
12
+ * TransformLink matrices, the authoritative bind), with the armature
13
+ * container's own world transform divided out.
14
+ *
15
+ * Usage: node tools/ardy/extract-rest.mjs
16
+ * Writes: public/ardy/cskel27-rest.json (x-bot rig), 27 entries in cskel27
17
+ * index order. Also extracts y-bot-tpose.fbx and prints the maximum
18
+ * per-joint angular difference between the two rigs' rest rotations.
19
+ *
20
+ * Exits non-zero if any emitted matrix fails the orthonormality + det checks
21
+ * (same tolerances as motion_retarget._validate_rotation_matrix).
22
+ */
23
+ import * as THREE from "three";
24
+ import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
25
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
+ import { dirname, resolve } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ // Project matcher primitive: strip every non-alphanumeric char, lowercase.
29
+ // Reused here so the FBX side and the pose studio can never drift apart.
30
+ import { normalizeBoneName } from "../../src/poses.js";
31
+
32
+ const HERE = dirname(fileURLToPath(import.meta.url));
33
+ const MODEL_DIR = resolve(HERE, "../../public/models");
34
+ const OUT_FILE = resolve(HERE, "../../public/ardy/cskel27-rest.json");
35
+
36
+ /** cskel27 joint order; array index == npz joint index. Verbatim from
37
+ * motion_retarget.CSKEL27_JOINTS. */
38
+ const CSKEL27_JOINTS = [
39
+ "Hips", "Spine", "Spine1", "Spine2", "Spine3", "Neck", "Head",
40
+ "RightShoulder", "RightArm", "RightForeArm", "RightHand",
41
+ "RightHandEnd", "RightHandThumb1",
42
+ "LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand",
43
+ "LeftHandEnd", "LeftHandThumb1",
44
+ "RightUpLeg", "RightLeg", "RightFoot", "RightToeBase",
45
+ "LeftUpLeg", "LeftLeg", "LeftFoot", "LeftToeBase",
46
+ ];
47
+
48
+ /** Mirrors ROTATION_MATRIX_TOLERANCE in CozyClay motion_retarget.py. */
49
+ const ROTATION_MATRIX_TOLERANCE = 1e-3;
50
+
51
+ // FBXLoader touches two browser globals on paths this script never exercises
52
+ // (embedded-texture blob URLs, camera aspect fallback). Keep the loader
53
+ // loadable under Node without pretending those paths work. No fetch/self shim
54
+ // is needed: parse() reads the buffer directly and FBXLoader never references
55
+ // `self`.
56
+ if (typeof globalThis.window === "undefined") {
57
+ globalThis.window = {
58
+ innerWidth: 1920,
59
+ innerHeight: 1080,
60
+ URL: {
61
+ createObjectURL(blob) {
62
+ if (typeof URL.createObjectURL === "function") {
63
+ return URL.createObjectURL(blob);
64
+ }
65
+ throw new Error(
66
+ "extract-rest: embedded FBX texture loading is not supported under Node"
67
+ );
68
+ },
69
+ },
70
+ };
71
+ }
72
+
73
+ /** The project's bone-matching rule (poses.js boneMatches): normalised names
74
+ * equal, or one is a suffix of the other (`mixamorigleftarm` vs `leftarm`). */
75
+ function matchesJoint(boneName, cskelName) {
76
+ const norm = normalizeBoneName(boneName);
77
+ const target = normalizeBoneName(cskelName);
78
+ return norm === target || norm.endsWith(target) || target.endsWith(norm);
79
+ }
80
+
81
+ /** Find the control skeleton of a parsed FBX root: the one whose root bone
82
+ * hangs off the armature container (a non-bone). Mixamo exports also nest an
83
+ * identity "skinned" copy of every bone under its control bone; those copy
84
+ * skeletons fail this test (their root's parent is a bone), so this rule
85
+ * picks the real hierarchy skeleton regardless of mesh order. */
86
+ function findControlSkeleton(root) {
87
+ const candidates = [];
88
+ root.traverse((object) => {
89
+ if (!object.isSkinnedMesh || !object.skeleton) return;
90
+ const skeleton = object.skeleton;
91
+ if (!skeleton.bones.length) return;
92
+ const rootBone = skeleton.bones.find(
93
+ (bone) => !bone.parent || !bone.parent.isBone
94
+ );
95
+ if (rootBone) candidates.push({ skeleton, rootBone });
96
+ });
97
+ if (!candidates.length) {
98
+ throw new Error("no skinned skeleton with a non-bone root found");
99
+ }
100
+ if (candidates.length > 1) {
101
+ console.warn(
102
+ `extract-rest: ${candidates.length} control skeletons; using the first`
103
+ );
104
+ }
105
+ return candidates[0];
106
+ }
107
+
108
+ /** Load a rig and index every bind bone by normalised name.
109
+ * Returns Map<normalised name, { name, bind }> where `bind` is the bone's
110
+ * rest (bind) transform in armature space as a Matrix4. */
111
+ function loadRig(rigName) {
112
+ const path = resolve(MODEL_DIR, `${rigName}.fbx`);
113
+ const buffer = readFileSync(path);
114
+ const arrayBuffer = buffer.buffer.slice(
115
+ buffer.byteOffset,
116
+ buffer.byteOffset + buffer.byteLength
117
+ );
118
+ const root = new FBXLoader().parse(arrayBuffer, path);
119
+ root.updateMatrixWorld(true);
120
+
121
+ const { skeleton, rootBone } = findControlSkeleton(root);
122
+ // Blender's matrix_local divides out the armature object's world
123
+ // transform; the FBX analogue is the skeleton root's non-bone parent.
124
+ const armatureWorld = rootBone.parent
125
+ ? rootBone.parent.matrixWorld
126
+ : new THREE.Matrix4();
127
+ const toArmature = armatureWorld.clone().invert();
128
+
129
+ const bindByName = new Map();
130
+ skeleton.bones.forEach((bone, index) => {
131
+ const bindGlobal = skeleton.boneInverses[index].clone().invert();
132
+ // Keep the full Matrix4: Quaternion.setFromRotationMatrix reads 4x4
133
+ // element indices, so a Matrix3 would silently produce NaN.
134
+ const bind = toArmature.clone().multiply(bindGlobal);
135
+ bindByName.set(normalizeBoneName(bone.name), { name: bone.name, bind });
136
+ });
137
+ return bindByName;
138
+ }
139
+
140
+ /** Column-major Matrix3 -> row-major 3x3 arrays. The JSON schema is
141
+ * row-major, matching motion_retarget's nested-list matrices. */
142
+ function rowMajor(m3) {
143
+ const e = m3.elements;
144
+ return [
145
+ [e[0], e[3], e[6]],
146
+ [e[1], e[4], e[7]],
147
+ [e[2], e[5], e[8]],
148
+ ];
149
+ }
150
+
151
+ /** Verify an orthonormal proper rotation exactly like
152
+ * motion_retarget._validate_rotation_matrix: every row and column must have
153
+ * squared norm 1 and pairwise dot 0 within the tolerance, and the determinant
154
+ * must be +1 within the tolerance. Returns null when valid, else a
155
+ * description of the failure. */
156
+ function validateRotationMatrix(rows) {
157
+ const tolerance = ROTATION_MATRIX_TOLERANCE;
158
+ const columns = [
159
+ [rows[0][0], rows[1][0], rows[2][0]],
160
+ [rows[0][1], rows[1][1], rows[2][1]],
161
+ [rows[0][2], rows[1][2], rows[2][2]],
162
+ ];
163
+ for (const vectors of [rows, columns]) {
164
+ for (const vector of vectors) {
165
+ const squaredNorm =
166
+ vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2];
167
+ if (
168
+ !Number.isFinite(squaredNorm) ||
169
+ Math.abs(squaredNorm - 1) > tolerance
170
+ ) {
171
+ return `squared norm ${squaredNorm}`;
172
+ }
173
+ }
174
+ for (const [first, second] of [[0, 1], [0, 2], [1, 2]]) {
175
+ const dot =
176
+ vectors[first][0] * vectors[second][0] +
177
+ vectors[first][1] * vectors[second][1] +
178
+ vectors[first][2] * vectors[second][2];
179
+ if (!Number.isFinite(dot) || Math.abs(dot) > tolerance) {
180
+ return `pairwise dot ${dot}`;
181
+ }
182
+ }
183
+ }
184
+ const determinant =
185
+ rows[0][0] * (rows[1][1] * rows[2][2] - rows[1][2] * rows[2][1]) -
186
+ rows[0][1] * (rows[1][0] * rows[2][2] - rows[1][2] * rows[2][0]) +
187
+ rows[0][2] * (rows[1][0] * rows[2][1] - rows[1][1] * rows[2][0]);
188
+ if (!Number.isFinite(determinant) || Math.abs(determinant - 1) > tolerance) {
189
+ return `determinant ${determinant}`;
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /** Orthonormalise a near-rotation matrix and emit it. The quaternion
195
+ * round-trip yields the closest proper rotation, so the emitted matrix always
196
+ * satisfies the rotation checks; the RAW bind matrix is validated first,
197
+ * which is the check that can actually fail (scale/shear/reflection would
198
+ * survive the tolerance and must not be shipped as a rest rotation). */
199
+ function restRotationRows(bind, jointName) {
200
+ const rawFailure = validateRotationMatrix(rowMajor(new THREE.Matrix3().setFromMatrix4(bind)));
201
+ if (rawFailure !== null) {
202
+ throw new Error(
203
+ `${jointName}: raw bind matrix is not a proper rotation (${rawFailure})`
204
+ );
205
+ }
206
+ const closest = new THREE.Matrix4()
207
+ .makeRotationFromQuaternion(new THREE.Quaternion().setFromRotationMatrix(bind));
208
+ const rows = rowMajor(new THREE.Matrix3().setFromMatrix4(closest));
209
+ const emittedFailure = validateRotationMatrix(rows);
210
+ if (emittedFailure !== null) {
211
+ throw new Error(
212
+ `${jointName}: orthonormalised rest rotation failed validation (${emittedFailure})`
213
+ );
214
+ }
215
+ return rows;
216
+ }
217
+
218
+ /** Angle in degrees between two rotations (the rotation of q1^-1 @ q2). */
219
+ function angleDegrees(q1, q2) {
220
+ const relative = q1.clone().invert().multiply(q2);
221
+ const axisLength = Math.hypot(relative.x, relative.y, relative.z);
222
+ return (2 * Math.atan2(axisLength, relative.w) * 180) / Math.PI;
223
+ }
224
+
225
+ function main() {
226
+ const xRig = loadRig("x-bot-tpose");
227
+
228
+ const joints = [];
229
+ const missing = [];
230
+ for (let index = 0; index < CSKEL27_JOINTS.length; index++) {
231
+ const name = CSKEL27_JOINTS[index];
232
+ let matchedBone = null;
233
+ let rest = null;
234
+ for (const entry of xRig.values()) {
235
+ if (matchesJoint(entry.name, name)) {
236
+ matchedBone = entry.name;
237
+ rest = restRotationRows(entry.bind, name);
238
+ break;
239
+ }
240
+ }
241
+ if (matchedBone === null) missing.push(name);
242
+ joints.push({ name, index, matched_bone: matchedBone, rest });
243
+ }
244
+
245
+ const json = {
246
+ schema: "cskel27.rest.v1",
247
+ rig: "x-bot-tpose",
248
+ joints,
249
+ missing,
250
+ };
251
+ mkdirSync(dirname(OUT_FILE), { recursive: true });
252
+ writeFileSync(OUT_FILE, `${JSON.stringify(json, null, 2)}\n`);
253
+
254
+ const resolved = joints.filter((joint) => joint.rest !== null).length;
255
+ console.log(`wrote ${OUT_FILE}`);
256
+ console.log(`resolved ${resolved}/${CSKEL27_JOINTS.length} cskel27 joints`);
257
+ for (const joint of joints) {
258
+ const match = joint.matched_bone ?? "MISSING";
259
+ console.log(` ${joint.index.toString().padStart(2)} ${joint.name.padEnd(15)} -> ${match}`);
260
+ }
261
+ console.log(`missing: ${missing.length ? missing.join(", ") : "none"}`);
262
+
263
+ // Same extraction against the y-bot rig; the gap between the two rigs'
264
+ // rest rotations is why poses travel as deltas, not absolute rotations.
265
+ const yRig = loadRig("y-bot-tpose");
266
+ let maxAngle = -1;
267
+ let maxJoint = null;
268
+ let compared = 0;
269
+ for (const joint of joints) {
270
+ if (joint.rest === null) continue;
271
+ const yEntry = yRig.get(normalizeBoneName(joint.matched_bone));
272
+ if (!yEntry) continue;
273
+ compared += 1;
274
+ const angle = angleDegrees(
275
+ new THREE.Quaternion().setFromRotationMatrix(
276
+ new THREE.Matrix4().setFromMatrix3(matrixFromRows(joint.rest))
277
+ ),
278
+ new THREE.Quaternion().setFromRotationMatrix(yEntry.bind)
279
+ );
280
+ if (angle > maxAngle) {
281
+ maxAngle = angle;
282
+ maxJoint = joint.name;
283
+ }
284
+ }
285
+ console.log(
286
+ `max per-joint rest rotation difference x-bot vs y-bot: ${maxAngle.toFixed(3)} deg (${maxJoint}, ${compared} joints compared)`
287
+ );
288
+ }
289
+
290
+ /** Row-major nested arrays -> Matrix3. Matrix3.set takes row-major arguments. */
291
+ function matrixFromRows(rows) {
292
+ return new THREE.Matrix3().set(
293
+ rows[0][0], rows[0][1], rows[0][2],
294
+ rows[1][0], rows[1][1], rows[1][2],
295
+ rows[2][0], rows[2][1], rows[2][2]
296
+ );
297
+ }
298
+
299
+ main();