mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 01:00:13 +02:00
A SteamVR dashboard patch (vr-pet) drawing a baked 3D pet next to the windows: the Toon Cat in five coats, a Shiba Inu, a Fox and a Dachshund, plus models from steamFrame.pet.extraModels. It walks around, follows, sits, lies and sleeps, can be picked up by its grip bar, petted and switched in its menu; its spot, pose and model are saved state. Also the vr-pet command, "Pet" in the "+" menu with the current model's icon (steam-frame-nix-pet-icon), the flake outputs pet-models, pet-icons and pet-preview, and the check pet. A built-in model with "private": true fails evaluation; private models belong in extraModels.
167 lines
6.3 KiB
Python
167 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Checks of bake_gltf.py's reshaping ("proportions": per-bone scale / rot /
|
|
aim, IK followers) and "colors", on a tiny synthetic skinned glTF.
|
|
usage: gltf.test.py (exit 1 on failure)
|
|
Run by the flake check `pet` (package.nix `tests`).
|
|
|
|
The rig: root -> A (at the origin, pointing up +y) -> B (1 up); IK (a child
|
|
of the root, 2 up: the tip of B) that follows B. One vertex per bone,
|
|
weighted fully to it: a (0.1, 0.5, 0) on A, b (0, 1.5, 0) on B, c (0, 2, 0)
|
|
on IK, d (-0.1, 0.5, 0) on A.
|
|
"""
|
|
import json
|
|
import os
|
|
import struct
|
|
import sys
|
|
import tempfile
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
|
|
import bake_gltf # noqa: E402
|
|
|
|
failed = 0
|
|
|
|
|
|
def test(name):
|
|
def run(f):
|
|
global failed
|
|
try:
|
|
f()
|
|
print(f'ok {name}')
|
|
except (AssertionError, SystemExit) as e:
|
|
failed += 1
|
|
print(f'FAIL {name}: {e}')
|
|
return run
|
|
|
|
|
|
def rig_gltf(d):
|
|
P = np.array([[0.1, 0.5, 0], [0, 1.5, 0], [0, 2, 0], [-0.1, 0.5, 0]], np.float32)
|
|
N = np.tile(np.array([[0, 0, 1]], np.float32), (4, 1))
|
|
J = np.array([[0, 0, 0, 0], [1, 0, 0, 0], [2, 0, 0, 0], [0, 0, 0, 0]], np.uint16)
|
|
W = np.array([[1, 0, 0, 0]] * 4, np.float32)
|
|
I = np.array([0, 1, 2, 0, 2, 3], np.uint16)
|
|
rest = {0: (0, 0, 0), 1: (0, 1, 0), 2: (0, 2, 0)} # joints' world positions
|
|
IBM = np.concatenate([np.linalg.inv(np.array([[1, 0, 0, x], [0, 1, 0, y], [0, 0, 1, z], [0, 0, 0, 1]], np.float32)).T.ravel()
|
|
for x, y, z in rest.values()]).astype(np.float32)
|
|
blobs = [P, N, J, W, I, IBM]
|
|
views, acc, off, buf = [], [], 0, b''
|
|
kinds = [('VEC3', 5126, 4), ('VEC3', 5126, 4), ('VEC4', 5123, 4), ('VEC4', 5126, 4), ('SCALAR', 5123, 6), ('MAT4', 5126, 3)]
|
|
for arr, (typ, ct, n) in zip(blobs, kinds):
|
|
raw = arr.tobytes()
|
|
views.append({'buffer': 0, 'byteOffset': off, 'byteLength': len(raw)})
|
|
a = {'bufferView': len(views) - 1, 'componentType': ct, 'count': n, 'type': typ}
|
|
if arr is P:
|
|
a['min'], a['max'] = P.min(0).tolist(), P.max(0).tolist()
|
|
acc.append(a)
|
|
raw += b'\0' * (-len(raw) % 4)
|
|
buf += raw
|
|
off += len(raw)
|
|
j = {
|
|
'asset': {'version': '2.0'}, 'scene': 0, 'scenes': [{'nodes': [0, 4]}],
|
|
'nodes': [{'name': 'root', 'children': [1, 3]}, {'name': 'A', 'children': [2]},
|
|
{'name': 'B', 'translation': [0, 1, 0]}, {'name': 'IK', 'translation': [0, 2, 0]},
|
|
{'name': 'mesh', 'mesh': 0, 'skin': 0}],
|
|
'skins': [{'joints': [1, 2, 3], 'inverseBindMatrices': 5}],
|
|
'meshes': [{'primitives': [{'attributes': {'POSITION': 0, 'NORMAL': 1, 'JOINTS_0': 2, 'WEIGHTS_0': 3},
|
|
'indices': 4, 'material': 0}]}],
|
|
'materials': [{'name': 'Fur', 'pbrMetallicRoughness': {'baseColorFactor': [1, 0, 0, 1]}}],
|
|
'buffers': [{'uri': 'rig.bin', 'byteLength': len(buf)}], 'bufferViews': views, 'accessors': acc,
|
|
}
|
|
open(os.path.join(d, 'rig.bin'), 'wb').write(buf)
|
|
path = os.path.join(d, 'rig.gltf')
|
|
json.dump(j, open(path, 'w'))
|
|
return path
|
|
|
|
|
|
tmp = tempfile.mkdtemp()
|
|
PATH = rig_gltf(tmp)
|
|
|
|
|
|
def posed(proportions=None, follow=None):
|
|
m = bake_gltf.Model(PATH)
|
|
if proportions is not None:
|
|
m.reshape(proportions, follow)
|
|
p, n, _ = m.posed(m.rest)
|
|
return p, n
|
|
|
|
|
|
def near(a, b, what):
|
|
assert np.allclose(a, b, atol=1e-5), f'{what}: {np.round(a, 4).tolist()} != {b}'
|
|
|
|
|
|
@test('no proportions: the rest pose as modelled')
|
|
def _():
|
|
p, _ = posed()
|
|
near(p, [[0.1, 0.5, 0], [0, 1.5, 0], [0, 2, 0], [-0.1, 0.5, 0]], 'rest')
|
|
|
|
|
|
@test('scale: the bone\'s own skin along its axes; its child moves, not scaled itself')
|
|
def _():
|
|
p, _ = posed({'A': {'scale': [3, 2, 1]}})
|
|
near(p[0], [0.3, 1.0, 0], 'a (on A, scaled)')
|
|
near(p[1], [0, 2.5, 0], 'b (on B: B moved to 2, half a unit above it, unscaled)')
|
|
near(p[2], [0, 2, 0], 'c (IK, not following: stays)')
|
|
|
|
|
|
@test('follow: an IK bone keeps its place relative to the bone, the offset scaled with it')
|
|
def _():
|
|
p, _ = posed({'A': {'scale': [1, 2, 1]}}, {'IK': 'B'})
|
|
near(p[2], [0, 3, 0], 'c follows B (moved up 1)')
|
|
p, _ = posed({'B': {'scale': [1, 0.5, 1]}}, {'IK': 'B'})
|
|
near(p[1], [0, 1.25, 0], 'b (B shortened)')
|
|
near(p[2], [0, 1.5, 0], 'c at the shortened tip of B')
|
|
|
|
|
|
@test('aim / rot: the rest pose turned in the body\'s axes, children with it')
|
|
def _():
|
|
p, _ = posed({'A': {'aim': [1, 0, 0]}}, {'IK': 'B'})
|
|
near(p[0], [0.5, -0.1, 0], 'a (A aimed along +x)')
|
|
near(p[1], [1.5, 0, 0], 'b (B carried)')
|
|
near(p[2], [2, 0, 0], 'c (following B)')
|
|
p, _ = posed({'A': {'rot': [0, 0, -90]}})
|
|
near(p[1], [1.5, 0, 0], 'b (A turned -90 about z)')
|
|
|
|
|
|
@test('proportions survive the clip\'s own rotation of the bone')
|
|
def _():
|
|
m = bake_gltf.Model(PATH)
|
|
m.reshape({'A': {'scale': [1, 2, 1]}, 'B': {'rot': [0, 0, -90]}})
|
|
pose = [list(x) for x in m.rest]
|
|
pose[m['A']][1] = np.array([0, 0, np.sin(np.pi / 4), np.cos(np.pi / 4)]) # the clip turns A +90 about z
|
|
p, _, _ = m.posed(pose)
|
|
near(p[0], [-1.0, 0.1, 0], 'a (A pointing -x, twice as long)')
|
|
near(p[1], [-2, 0.5, 0], 'b (B at -2, turned back up by its own -90)')
|
|
|
|
|
|
@test('normals stay unit length and perpendicular under a non-uniform scale')
|
|
def _():
|
|
p, n = posed({'A': {'scale': [3, 1, 0.5]}})
|
|
assert np.allclose(np.linalg.norm(n, axis=1), 1, atol=1e-6), n
|
|
|
|
|
|
@test('invalid proportions fail naming the bone / field')
|
|
def _():
|
|
for props, want in [({'Nope': {'scale': [1, 1, 1]}}, "no bone 'Nope'"),
|
|
({'A': {'scale': [1, 0, 1]}}, '"scale" must be three numbers'),
|
|
({'B': {'aim': [1, 0, 0]}}, '"aim" needs a bone with a child')]:
|
|
try:
|
|
bake_gltf.Model(PATH).reshape(props)
|
|
except SystemExit as e:
|
|
assert want in str(e), f'{want!r} not in {e}'
|
|
else:
|
|
raise AssertionError(f'{props}: no error')
|
|
|
|
|
|
@test('colors: "#rrggbb" (sRGB) -> linear, and back in the palette')
|
|
def _():
|
|
near(bake_gltf.linear('#ffffff'), [1, 1, 1], 'white')
|
|
near(bake_gltf.linear('#000000'), [0, 0, 0], 'black')
|
|
c = bake_gltf.linear('#3a2b24')
|
|
near(bake_gltf.srgb(c) * 255, [0x3a, 0x2b, 0x24], 'round trip')
|
|
|
|
|
|
if failed:
|
|
print(f'{failed} failed')
|
|
sys.exit(1)
|
|
print('all passed')
|