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.
- package/LICENSE +674 -0
- package/README.md +109 -0
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/bin/cozyclay.mjs +194 -0
- package/dist/ardy/cskel27-rest.json +557 -0
- package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
- package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
- package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
- package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
- package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
- package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
- package/dist/assets/index-BW_S1YRy.js +4395 -0
- package/dist/assets/index-nRmmJgap.css +1 -0
- package/dist/demo/walk-then-stop.npz +0 -0
- package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
- package/dist/fonts/instrument-serif-latin.woff2 +0 -0
- package/dist/fonts/inter-latin.woff2 +0 -0
- package/dist/index.html +13 -0
- package/dist/models/x-bot-tpose.fbx +0 -0
- package/dist/models/y-bot-tpose.fbx +0 -0
- package/package.json +80 -0
- package/src/App.jsx +3840 -0
- package/src/ardy/client.js +140 -0
- package/src/ardy/convert.js +313 -0
- package/src/ardy/cskel27-neutral.js +39 -0
- package/src/ardy/cskel27.js +68 -0
- package/src/ardy/export.js +157 -0
- package/src/ardy/ik.js +610 -0
- package/src/ardy/npz.js +520 -0
- package/src/ardy/playback.js +414 -0
- package/src/ardy/prompt-clips.js +16 -0
- package/src/ardy/timeline-coordinates.js +17 -0
- package/src/ardy/timeline-resize.js +11 -0
- package/src/ardy/timeline.jsx +723 -0
- package/src/ardy/to-cskel27.js +205 -0
- package/src/ardy/waypoints.js +364 -0
- package/src/camera-follow.js +366 -0
- package/src/camera-move.js +286 -0
- package/src/controls.jsx +245 -0
- package/src/dualview.jsx +305 -0
- package/src/hierarchy-model.js +77 -0
- package/src/hierarchy-panel.jsx +365 -0
- package/src/history.js +90 -0
- package/src/main.jsx +10 -0
- package/src/object-catalog.jsx +90 -0
- package/src/object-gizmo.jsx +755 -0
- package/src/planview.jsx +653 -0
- package/src/poses.js +424 -0
- package/src/posestudio.jsx +811 -0
- package/src/props.jsx +302 -0
- package/src/room.jsx +64 -0
- package/src/scene-history.js +125 -0
- package/src/scene-objects.js +420 -0
- package/src/shot-authoring.js +113 -0
- package/src/shot.js +239 -0
- package/src/styles.css +5639 -0
- package/src/ui.jsx +391 -0
- package/src/use-render-activity.js +96 -0
- package/tools/ardy/BRIDGE.md +255 -0
- package/tools/ardy/README.md +136 -0
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +1427 -0
- package/tools/ardy/cclay_motion_edit.py +445 -0
- package/tools/ardy/cclay_sequence_generate.py +595 -0
- package/tools/ardy/dump-npz.py +205 -0
- package/tools/ardy/extract-rest.mjs +299 -0
- package/tools/ardy/npz.mjs +335 -0
- package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
- package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
- package/tools/ardy/pose-to-npz.mjs +106 -0
- package/tools/ardy/run-edit-on-box.sh +73 -0
- package/tools/ardy/run-on-box.sh +568 -0
- package/tools/ardy/run-sequence-on-box.sh +162 -0
- package/tools/ardy/visual-qa.mjs +188 -0
- package/tools/ardy/vq-car.mjs +52 -0
- package/tools/dev-full.mjs +29 -0
- package/tools/process-supervisor.mjs +63 -0
- package/tools/qa-browser.mjs +84 -0
- package/tools/qa-crop3.mjs +22 -0
- package/tools/qa-playview.mjs +49 -0
- package/tools/qa-screenshot.mjs +24 -0
- 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();
|