VR pet: a cat or dog in SteamVR's scene (steamFrame.pet)

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.
This commit is contained in:
Pierre Kisters committed 2026-10-01 04:33:39 +02:00
1 parent fbf9519be0
commit 4034fd4ded
38 files changed
+7216 -5

No files matched your search

+20 -5
View File
@@ -40,6 +40,7 @@
launchers = ./modules/launchers.nix;
docker = ./modules/docker.nix;
screenshots = ./modules/screenshots.nix;
pet = ./modules/pet.nix;
};
# Former attribute names, kept so existing imports keep working (not in
# `default`, which imports each module once under its current name).
@@ -49,15 +50,22 @@
};
systems = [ "aarch64-linux" "x86_64-linux" ];
forSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
# The VR pet's build (defaults: no extraModels), for its packages and check.
petFor = pkgs: import ./modules/pet/package.nix { inherit pkgs; };
in {
homeManagerModules = modules // aliases // {
default = { imports = builtins.attrValues modules; };
};
# Removes what steam-frame-nix wrote outside the Nix store (install.sh
# cleanup): nix run github:lhns/steam-frame-nix#cleanup -- --all
packages = forSystems (pkgs: {
# cleanup: removes what steam-frame-nix wrote outside the Nix store
# (install.sh cleanup): nix run github:lhns/steam-frame-nix#cleanup -- --all
# pet-*: the VR pet's baked cat (pet-models), its "+" menu icons
# (pet-icons) and its desktop preview (nix run …#pet-preview), docs/pet.md.
packages = forSystems (pkgs: let pet = petFor pkgs; in {
cleanup = pkgs.callPackage ./modules/cleanup/package.nix { };
pet-models = pet.models;
pet-icons = pet.icons;
pet-preview = pet.preview;
});
apps = nixpkgs.lib.mapAttrs (_: p: {
cleanup = {
@@ -65,20 +73,27 @@
program = "${p.cleanup}/bin/steam-frame-nix-cleanup";
meta.description = "Remove what steam-frame-nix wrote outside the Nix store";
};
pet-preview = {
type = "app";
program = "${p.pet-preview}/bin/vr-pet-preview";
meta.description = "The VR pet in a browser (http://127.0.0.1:8765/)";
};
}) self.packages;
# Tests of the VR keyboard (text model, corrector, swipe decoder on the
# default German + English dictionary), the controller geometry and touch
# typing, the extra keys' xdotool allowlist, the Jellyfin mpv shim, the
# Firefox wrapper, the launchers, install.sh cleanup, the
# applications.menu link, the portal config and the screenshots account
# detection: nix flake check
# applications.menu link, the portal config, the screenshots account
# detection and the VR pet (core, SteamVR adapter, CLI, models, icons):
# nix flake check
checks = forSystems (pkgs: {
applications-menu = import ./modules/applications-menu/check.nix { inherit pkgs; };
cleanup = import ./modules/cleanup/check.nix { inherit pkgs; };
firefox = import ./modules/firefox/check.nix { inherit pkgs; };
jellyfin = import ./modules/jellyfin/check.nix { inherit pkgs; };
launchers = import ./modules/launchers/check.nix { inherit pkgs; };
pet = import ./modules/pet/check.nix { inherit pkgs; };
portal = import ./modules/portal/check.nix { inherit pkgs; };
screenshots = import ./modules/screenshots/check.nix { inherit pkgs; };
vr-keyboard = import ./modules/vr-keyboard/check.nix { inherit pkgs; };
+150
View File
@@ -0,0 +1,150 @@
# A 3D pet in SteamVR's scene, next to the windows (docs/pet.md): the Toon
# Cat in five coats, a Shiba Inu, a Fox, a Dachshund, and your own models
# (pet.extraModels). SteamVR dashboard patch "vr-pet" (pet/systemui.js +
# pet/core.js, built by pet/package.nix; "systemui" on 127.0.0.1:8087),
# registered only when enabled; enables steamvr-debugger.nix (one SteamVR
# restart the first time).
#
# Also, when enabled: the `vr-pet` command (pet/cli.mjs), the "+" menu entry
# "Pet" (vr-pet.desktop) and its icon: hicolor/256x256/apps/vr-pet.png links
# to <session.runtimeDir>/steam-frame-nix/vr-pet/icon.png (tmpfs), which
# steam-frame-nix-pet-icon (pet/icon.sh; on switch, at login and when the
# patch's state file changes: path unit) points to the current model's icon.
# Off removes the patch, the command, the entry and the units; the next
# switch's cleanup removes the tmpfs link. The saved state
# (ui-patches/vr-pet.json) is kept until `steam-frame-nix-cleanup --all`.
{ config, lib, pkgs, ... }:
let
cfg = config.steamFrame.pet;
dataDir = lib.removePrefix "${config.home.homeDirectory}/" config.xdg.dataHome; # .local/share
inherit (lib) mkOption mkEnableOption types;
runtimeDir = config.steamFrame.session.runtimeDir;
stateFile = "${config.xdg.stateHome}/steam-frame-nix/ui-patches/vr-pet.json";
pkg = import ./pet/package.nix {
inherit pkgs lib runtimeDir stateFile;
hicolor = "${config.xdg.dataHome}/icons/hicolor";
inherit (cfg) mount fps extraModels defaultModel;
inherit (import ./steam-ui-patches/lib { inherit pkgs; }) finders hooks;
catOptions = cfg.options // { demo = cfg.debug.demo; };
};
in {
imports = [ ./cleanup.nix ./steam-ui-patches.nix ./steamvr-debugger.nix ];
options.steamFrame.pet = {
enable = mkEnableOption ''
the VR pet in SteamVR's dashboard scene, the `vr-pet` command and the
"+" menu entry "Pet" (docs/pet.md). Builds its models on the first
switch (about 0.5 GB in the store, no Blender for the built-in ones)'';
options = mkOption {
type = types.attrsOf types.anything;
default = { };
example = { walkSpeed = 0.3; follow = 3; };
description = "Behaviour options of pet/core.js (DEFAULTS there).";
};
extraModels = mkOption {
type = types.attrsOf (types.either types.path (types.attrsOf types.anything));
default = { };
example = lib.literalExpression ''{
calico = { name = "Calico"; png = ./calico.png; }; # a coat: the cat with this texture
corgi = ./pets/corgi; # a folder like pet/models/<id>/ (model.json and its files)
}'';
description = ''
More models, next to the built-in ones (pet/models/<id>/): each a
folder with a model.json (and the files it names), or its spec as an
attrset (files as paths; without "kind" a coat with its own "png", a
64 x 64 texture laid out like the Toon Cat's cat.png: start from an
edited copy of `nix build github:lhns/steam-frame-nix#pet-models`/cat.png).
The spec fields and a full example: docs/pet-models.md. A model may
be "private": true (personal use only, never published); the built-in
ones may not. Listed in the
pet's menu and `vr-pet models` by "order" (built-in coats 0-4,
animals 10-19; default 100), then name.
'';
};
defaultModel = mkOption {
type = types.nullOr types.str;
default = null;
example = "shiba";
description = ''
The model shown until another is picked (its id); null: the one whose
spec says "default": true (the Ginger cat).
'';
};
fps = mkOption {
type = types.ints.between 4 60;
default = 24;
description = ''
Animation frames per second (baked frames and scene graph updates;
slow breathing loops use 1/2 or 1/3 of it). More is smoother and
costs more frames kept loaded in vrcompositor and more scene graph
updates.
'';
};
mount = mkOption {
type = types.enum [ "dynamic" "all" ];
default = "dynamic";
description = ''
Which baked frames stay mounted in SteamVR's scene graph (each one
is drawn): only the clips the cat needs now or next, or all of them
(pet/systemui.js).
'';
};
debug = {
demo = mkEnableOption "the demo tour (cycles through all poses and activities)";
};
};
config = lib.mkIf cfg.enable {
home.packages = [ pkg.cli ];
steamFrame.cleanup.keep = [ "pet" ];
# The dashboard "+" menu's entry ("Pet", package.nix desktopEntry) and
# its icon: a link to steam-frame-nix-pet-icon's link to the current model's icon.
xdg.dataFile."applications/vr-pet.desktop".source = pkg.desktopEntry;
xdg.dataFile."icons/hicolor/256x256/apps/vr-pet.png".source =
config.lib.file.mkOutOfStoreSymlink "${runtimeDir}/steam-frame-nix/vr-pet/icon.png";
systemd.user.services.steam-frame-nix-pet-icon = {
Unit.Description = "steam-frame-nix: the VR pet's \"+\" menu icon (the current model's)";
Service = {
Type = "oneshot";
ExecStart = lib.getExe pkg.iconLink;
};
Install.WantedBy = [ "default.target" ];
};
systemd.user.paths.steam-frame-nix-pet-icon = {
Unit.Description = "steam-frame-nix: update the VR pet's \"+\" menu icon when the model changes";
Path = {
PathChanged = [ stateFile ];
Unit = "steam-frame-nix-pet-icon.service";
};
Install.WantedBy = [ "default.target" ];
};
steamFrame.session.services.start = [ "steam-frame-nix-pet-icon.path" ];
# On switch: the icon link (new icons, a new link). GTK (Steam) rescans
# an icon theme only when its directory's mtime changes: steam-frame-nix-pet-icon
# bumps it when its link changed, this when the hicolor link is new.
home.activation.steamFrameNixPetIcon = lib.hm.dag.entryAfter [ "writeBoundary" "linkGeneration" "steamFrameNixCleanup" ] ''
if [[ -v DRY_RUN ]]; then
echo "would run: ${lib.getExe pkg.iconLink}"
else
${lib.getExe pkg.iconLink} || true
hicolor=${lib.escapeShellArg "${dataDir}/icons/hicolor"}
f=$hicolor/256x256/apps/vr-pet.png
if [[ -d $HOME/$hicolor && ! -L ''${oldGenPath:-/nonexistent}/home-files/$f ]]; then
touch "$HOME/$hicolor"
fi
fi
'';
steamFrame.uiPatches.patches = [ {
name = "vr-pet";
endpoint = "http://127.0.0.1:8087";
target.title = "systemui";
patch = pkg.patch;
unpatch = ./pet/unpatch.js;
state = true; # spot, pose, model, hidden
} ];
};
}
+626
View File
@@ -0,0 +1,626 @@
#!/usr/bin/env python3
"""Bake the VR pet: pose a skinned glTF cat per animation frame and write one
static SteamVR render model (OBJ + one texture) per frame.
usage: bake.py toon_cat.glb tuxedo.gltf out-dir height-m fps
(glTF reading, quaternions, rigs, skinning and OBJ writing: rig.py.)
SteamVR render models are static, so every frame of every clip is its own OBJ
(vertices skinned on the CPU here, at build time). The clips (clips() below)
come from three kinds of sources:
native the Toon Cat's own animation (its in-place walk)
retargeted Tuxedo Cat Animated 2.0's clips, transferred bone by bone:
body bones take the world-space rotation change (relative to
the rest pose) of the corresponding source bone (7-bone spine
-> 3 + neck), tail/ears the per-joint bend in the bone's own
frame (4-bone tail spread over 5 bones), root motion scaled by
hip height
hand-keyed key poses (per-joint bends in the body frame, "aim" a bone at a
world direction, whole-body rotation) interpolated with
smoothstep, plus procedural breathing / sway on top
Clips: walk (native); sitdown, sit, standup (retargeted); idle, walkstart,
liedown, lie, curl, sleep, dangle, and for interaction fall, land (squash),
shake, stretch (play bow), startle (arched back, puffed tail), groom (paw
licking, sitting), purr / sitpurr / liepurr with purrin / sitpurrin /
liepurrin into them (petted: eyes closed, head tilted into the hand) (all
hand-keyed). One-shot clips start and end in their pose's rest key.
Every frame except the dangle is put on the floor (lowest vertex at y = 0);
the dangle hangs from the scruff (the origin is the back of the neck).
Output: out-dir/<clip>_<i>.obj, cat.mtl, cat.png, frames.json
{"height": m, "vertices": n, "walkSpeed": m/s of the walk at its fps,
"zones": {scruff|head|back|chin|tailBase: [x, y, z] on the standing cat},
"poseZones": {pose: zones in that pose's rest frame (stand, sit, lie,
sleep, fall; dangle relative to the scruff)},
"clips": {clip: {"frames": n, "fps": f, "loop": bool,
"source": "native|retargeted|hand-keyed"}}}
The cat faces +Z, centred on x/z (rest pose), standing on y = 0; the dangle
frames have the scruff at the origin instead.
"""
import json, math, os, sys
import numpy as np
from rig import (QI, Rig, Skinned, mat_q, qbetween, qeuler, qinv, qmul, qnorm, qrot, qscale, slerp, smooth,
png_from, walk_speed as gait_speed, write_mtl, write_obj)
# ------------------------------------------------------------- the cat ----
class Cat(Skinned):
"""The Toon Cat: mesh (rig.Skinned), texture, and pose solving."""
TAIL = ['tail', 'tail.01', 'tail.02', 'tail.03', 'tail.end']
SPINE = ['torso', 'spine.01', 'spine.02', 'neck', 'head']
LEGS = {s: [f'thigh.B.{s}', f'leg.upper.B.{s}', f'leg.lower.B.{s}', f'foot.B.{s}'] for s in 'LR'}
ARMS = {'L': ['leg.upper.F.L', 'leg.lower.FL', 'foot.F.L'], 'R': ['leg.upper.F.R', 'leg.lower.F.R', 'foot.F.R']}
def __init__(self, path):
super().__init__(path)
j, binc = self.j, self.bin
img = j['images'][0]
bv = j['bufferViews'][img['bufferView']]
self.texture = binc[bv.get('byteOffset', 0):bv.get('byteOffset', 0) + bv['byteLength']]
self.bone_dir = {i: self.rest_world[i][:3, :3] @ [0, 1, 0] for i in range(len(self.nodes))}
self.bone_dir = {i: d / np.linalg.norm(d) for i, d in self.bone_dir.items()}
self.joints = set(self.skin['joints'])
# Neutral standing legs (the rest pose is mid-stride): world directions
# of the leg bones; retargeted and standing poses are relative to these.
NEUTRAL = {
'thigh.B.L': [0.03, -0.97, 0.22], 'leg.upper.B.L': [0, -0.77, -0.64],
'leg.lower.B.L': [0, -0.97, 0.25], 'foot.B.L': [0, -0.09, 1],
'leg.upper.F.L': [0.01, -1, 0.06], 'leg.lower.FL': [0, -1, 0.1], 'foot.F.L': [0, -0.21, 0.98],
}
def neutral(self):
out = {}
for n, v in self.NEUTRAL.items():
m = n.replace('.L', '.R').replace('FL', 'F.R')
for name, d in ((n, v), (m, [-v[0], v[1], v[2]])):
i = self[name]
out[i] = qbetween(self.bone_dir[i], np.asarray(d, float))
return out
def solve(self, key):
"""Local TRS pose from a key: {'A': {bone: world delta from the
neutral pose}, 'Q': {bone: bend in the parent's rest frame}, 'aim':
{bone: world direction, or (direction, weight)}, 'scale': {bone:
xyz}, 'body': whole-body rotation (world)}. Per joint: D = (body * A
* neutral, or D_parent) * Q, then aimed; the joint's world rotation
is D * rest."""
if not hasattr(self, '_neutral'):
self._neutral = self.neutral()
A = {self[k]: v for k, v in key.get('A', {}).items()}
Q = {self[k]: v for k, v in key.get('Q', {}).items()}
AIM = {}
for k, v in key.get('aim', {}).items():
d, w = v if isinstance(v, tuple) else (v, 1.0)
AIM[self[k]] = (np.asarray(d, float), w)
SC = {self[k]: np.asarray(v, float) for k, v in key.get('scale', {}).items()}
body = key.get('body', QI)
pose = [list(p) for p in self.rest]
D, R = {}, {}
top = self['torso']
for i in self.order:
p = self.parent.get(i)
if i == top or i in A:
d = qmul(body, qmul(A.get(i, QI), self._neutral.get(i, QI)))
elif p in D:
d = D[p]
else:
continue
d = qmul(d, Q.get(i, QI))
if i in AIM:
cur = qrot(qmul(d, self.rest_rot[i]), [0, 1, 0])
aim, w = AIM[i]
aim = aim / np.linalg.norm(aim)
if w < 1:
aim = (1 - w) * cur + w * aim
d = qmul(qbetween(cur, aim), d)
D[i] = d
R[i] = qmul(d, self.rest_rot[i])
pr = R[p] if p in R else self.rest_rot[p]
pose[i][1] = qnorm(qmul(qinv(pr), R[i]))
if i in SC:
pose[i][2] = self.rest[i][2] * SC[i]
return pose
def pose_of(self, key):
return key['pose'] if 'pose' in key else self.solve(key)
# ------------------------------------------------------------ retarget ----
class Retarget:
"""Tuxedo Cat -> Toon Cat. Both face +Z with Y up in world space."""
SUF = '_metarig.004'
BODY = { # world-space rotation change of the source bone
'torso': 'spine.005', 'spine.01': 'spine.007', 'spine.02': 'spine.009', 'neck': 'spine.010', 'head': 'Head',
'thigh.B.L': 'thigh.L', 'leg.upper.B.L': 'shin.L', 'leg.lower.B.L': 'foot.L', 'foot.B.L': 'toe.L',
'thigh.B.R': 'thigh.R', 'leg.upper.B.R': 'shin.R', 'leg.lower.B.R': 'foot.R', 'foot.B.R': 'toe.R',
'leg.upper.F.L': 'front_thigh.L', 'leg.lower.FL': 'front_shin.L', 'foot.F.L': 'front_foot.L',
'leg.upper.F.R': 'front_thigh.R', 'leg.lower.F.R': 'front_shin.R', 'foot.F.R': 'front_foot.R',
}
BEND = {'ear.L': ['Ear.L'], 'ear.R': ['Ear.R']} # bend in the bone's frame
TAIL_SRC = ['TailBase', 'Tail2', 'Tail3', 'TailTip']
def __init__(self, cat, src):
self.cat, self.src = cat, src
s = lambda n: src[n + self.SUF]
self.s = s
self.root = s('Root')
self.hip_ratio = cat.rest_world[cat['torso']][1, 3] / src.rest_world[self.root][1, 3]
def bend(self, g_rot, i):
"""Source joint bend in its own rest bone frame."""
src = self.src
p = src.parent[i]
dp = qmul(g_rot[p], qinv(src.rest_rot[p]))
di = qmul(g_rot[i], qinv(src.rest_rot[i]))
rel = qmul(qinv(dp), di)
return qmul(qinv(src.rest_rot[i]), qmul(rel, src.rest_rot[i]))
def key(self, anim, t):
src, cat, s = self.src, self.cat, self.s
g = src.world(src.sample(anim, t))
g_rot = [mat_q(m[:3, :3]) for m in g]
A = {tb: qmul(g_rot[s(sb)], qinv(src.rest_rot[s(sb)])) for tb, sb in self.BODY.items()}
Q = {}
def to_target(bf, tb):
r = cat.rest_rot[cat[tb]]
return qmul(r, qmul(bf, qinv(r)))
for tb, sbs in self.BEND.items():
Q[tb] = to_target(self.bend(g_rot, s(sbs[0])), tb)
bends = [self.bend(g_rot, s(n)) for n in self.TAIL_SRC]
n = len(Cat.TAIL)
for k, tb in enumerate(Cat.TAIL):
x = k * (len(bends) - 1) / (n - 1)
a = int(min(x, len(bends) - 2))
Q[tb] = to_target(qscale(slerp(bends[a], bends[a + 1], x - a), len(bends) / n), tb)
# Tail joints follow their parent (no A): drop the source's world
# change there, the bends carry the motion.
off = (g[self.root][:3, 3] - src.rest_world[self.root][:3, 3]) * self.hip_ratio
# The Tuxedo's front legs are longer: when it sits up (torso pitched
# up) they'd reach forward; pull them under the chest instead.
fwd = qrot(A['torso'], [0, 0, 1])
w = smooth(math.degrees(math.asin(max(-1.0, min(1.0, fwd[1])))) / 35)
aim = {}
if w > 0:
for side, sx in (('L', 1), ('R', -1)):
up, lo, ft = Cat.ARMS[side]
aim.update({up: ([0.02 * sx, -1, 0.02], w), lo: ([0.02 * sx, -1, 0.08], w), ft: ([0, -0.2, 1], w)})
return {'A': A, 'Q': Q, 'aim': aim, 'offset': off}
# ----------------------------------------------------------- hand keys ----
def blend(cat, k0, k1, u):
"""Interpolate two keys: slerp of the solved local rotations, lerp of
scales and offsets."""
p0, p1 = cat.pose_of(k0), cat.pose_of(k1)
pose = [[t0, slerp(r0, r1, u), (1 - u) * np.asarray(s0) + u * np.asarray(s1)]
for (t0, r0, s0), (_, r1, s1) in zip(p0, p1)]
off = (1 - u) * np.asarray(k0.get('offset', [0, 0, 0]), float) + u * np.asarray(k1.get('offset', [0, 0, 0]), float)
return {'pose': pose, 'offset': off}
def add(k, extra):
"""Layer relative bends (Q) and scales on top of a key."""
out = dict(k)
q = dict(k.get('Q', {}))
for n, v in extra.get('Q', {}).items():
q[n] = qmul(q.get(n, QI), v)
out['Q'] = q
sc = dict(k.get('scale', {}))
for n, v in extra.get('scale', {}).items():
sc[n] = np.asarray(sc.get(n, [1, 1, 1]), float) * v
out['scale'] = sc
return out
def breathe(t, period, depth=1.0, blink=None):
"""Breathing (chest), a little head and ear life, tail sway."""
w = 2 * math.pi * t / period
b = math.sin(w) * depth
q = {
'spine.01': qeuler(x=-1.2 * b), 'spine.02': qeuler(x=1.2 * b),
}
sc = {'spine.01': [1 + 0.02 * b, 1, 1 + 0.03 * b], 'spine.02': [1 + 0.025 * b, 1, 1 + 0.03 * b]}
if blink is not None:
sc['eye.L'] = sc['eye.R'] = [1, 1, blink]
return {'Q': q, 'scale': sc}
LEG_BONES = [b for s in 'LR' for b in Cat.LEGS[s] + Cat.ARMS[s]]
STAND = {'A': {b: QI for b in LEG_BONES}}
# Aims are world directions (the cat faces +Z, Y up, X is its left).
DOWN = [0, -1, 0]
def loaf():
"""Lying on the belly, paws tucked under (the "loaf"), head up."""
return {
'offset': [0, 0, 0],
'Q': {'torso': qeuler(x=0), 'spine.01': qeuler(x=0), 'neck': qeuler(x=-8), 'head': qeuler(x=6),
'ear.L': qeuler(), 'ear.R': qeuler()},
'aim': {
'leg.upper.F.L': [0, -0.35, -1], 'leg.lower.FL': [0.05, -0.2, 1], 'foot.F.L': [0, -0.1, 1],
'leg.upper.F.R': [0, -0.35, -1], 'leg.lower.F.R': [-0.05, -0.2, 1], 'foot.F.R': [0, -0.1, 1],
'thigh.B.L': [0.25, -0.3, 1], 'leg.upper.B.L': [0.3, -0.2, -1], 'leg.lower.B.L': [0.1, -0.1, 1], 'foot.B.L': [0, -0.1, 1],
'thigh.B.R': [-0.25, -0.3, 1], 'leg.upper.B.R': [-0.3, -0.2, -1], 'leg.lower.B.R': [-0.1, -0.1, 1], 'foot.B.R': [0, -0.1, 1],
# tail on the floor, wrapped along the left side
'tail': [0.25, -0.7, -1], 'tail.01': [0.8, -0.25, -0.5], 'tail.02': [1, -0.05, 0.2],
'tail.03': [0.6, 0, 1], 'tail.end': [0.25, 0, 1],
},
}
def curled():
"""Asleep on its left side, curled up (belly in, nose to the tail),
eyes closed."""
c = 34 # flexion per spine joint
return {
'body': qeuler(z=-78),
'Q': {'torso': qeuler(x=-10), 'spine.01': qeuler(x=c), 'spine.02': qeuler(x=c), 'neck': qeuler(x=c + 10),
'head': qeuler(x=25, z=15),
'tail': qeuler(x=70), 'tail.01': qeuler(x=40), 'tail.02': qeuler(x=40), 'tail.03': qeuler(x=35),
'tail.end': qeuler(x=25),
'ear.L': qeuler(x=15), 'ear.R': qeuler(x=15),
'leg.upper.F.L': qeuler(x=-30, z=8), 'leg.lower.FL': qeuler(x=110), 'foot.F.L': qeuler(x=30),
'leg.upper.F.R': qeuler(x=-20, z=-8), 'leg.lower.F.R': qeuler(x=115), 'foot.F.R': qeuler(x=30),
'thigh.B.L': qeuler(x=-95, z=6), 'leg.upper.B.L': qeuler(x=70), 'leg.lower.B.L': qeuler(x=-50),
'thigh.B.R': qeuler(x=-85, z=-6), 'leg.upper.B.R': qeuler(x=70), 'leg.lower.B.R': qeuler(x=-50)},
'scale': {'eye.L': [1, 0.15, 1], 'eye.R': [1, 0.15, 1]},
}
def dangle(t=0.0):
"""Held by the scruff: body hangs down, legs limp, spine slightly curled,
tail down, head up. Swings a little."""
sw = math.sin(2 * math.pi * t / 2.0)
limp = lambda s: [0.02 * s, -1, 0.12]
return {
'body': qmul(qeuler(z=4 * sw), qeuler(x=-72)),
'Q': {'spine.01': qeuler(x=6), 'spine.02': qeuler(x=6), 'neck': qeuler(x=0),
'ear.L': qeuler(x=-15), 'ear.R': qeuler(x=-15),
'tail': qeuler(x=0)},
'aim': {
'leg.upper.F.L': limp(1), 'leg.lower.FL': limp(1), 'foot.F.L': [0, -1, 0.5],
'leg.upper.F.R': limp(-1), 'leg.lower.F.R': limp(-1), 'foot.F.R': [0, -1, 0.5],
'thigh.B.L': [0.1, -1, 0.35], 'leg.upper.B.L': limp(1), 'leg.lower.B.L': limp(1), 'foot.B.L': [0, -1, 0.5],
'thigh.B.R': [-0.1, -1, 0.35], 'leg.upper.B.R': limp(-1), 'leg.lower.B.R': limp(-1), 'foot.B.R': [0, -1, 0.5],
'neck': [0, 0.85, 0.5], 'head': [0, 0.85, 0.5], # face forward
'tail': [0.05 * sw, -1, -0.15], 'tail.01': [0.1 * sw, -1, -0.05], 'tail.02': [0.15 * sw, -1, 0.05],
'tail.03': [0.2 * sw, -1, 0.15], 'tail.end': [0.2 * sw, -0.8, 0.3],
},
}
def legs(front=None, back=None, sides='LR'):
"""Aims for both front legs (upper, lower, foot) and/or both back legs
(thigh, upper, lower, foot), given for the left side (x mirrored)."""
out = {}
for s in sides:
m = 1 if s == 'L' else -1
mir = lambda v: [v[0] * m, v[1], v[2]]
if front:
for bone, v in zip(Cat.ARMS[s], front):
out[bone] = mir(v)
if back:
for bone, v in zip(Cat.LEGS[s], back):
out[bone] = mir(v)
return out
def crouch():
"""Landing squash: legs folded, body low, head down, ears back."""
return {
'A': {b: QI for b in LEG_BONES},
'Q': {'neck': qeuler(x=12), 'head': qeuler(x=6), 'ear.L': qeuler(x=-25, z=20), 'ear.R': qeuler(x=-25, z=-20),
'tail': qeuler(x=-10)},
'aim': legs(front=[[0.08, -0.5, -0.87], [0, -0.75, 0.66], [0, -0.1, 1]],
back=[[0.08, -0.45, 0.9], [0, -0.3, -0.95], [0, -0.85, 0.5], [0, -0.1, 1]]),
}
def fall_key(t):
"""Falling (let go): legs stretched down to the floor, tail up for
balance, ears back; the legs paddle a little."""
w = math.sin(2 * math.pi * t / 0.5)
return {
'A': {b: QI for b in LEG_BONES},
'Q': {'neck': qeuler(x=-8), 'head': qeuler(x=-6), 'ear.L': qeuler(x=-30, z=25), 'ear.R': qeuler(x=-30, z=-25)},
'aim': {**legs(front=[[0.12, -0.9, 0.35 + 0.08 * w], [0.05, -0.9, 0.3], [0, -0.6, 0.8]],
back=[[0.1, -0.9, 0.25 - 0.08 * w], [0.05, -0.95, -0.2], [0, -1, 0.15], [0, -0.6, 0.8]]),
'tail': [0, 0.5, -1], 'tail.01': [0, 0.9, -0.5], 'tail.02': [0, 1, -0.1],
'tail.03': [0, 0.8, 0.4], 'tail.end': [0, 0.5, 0.8]},
}
def stretch_key():
"""Play-bow stretch: front legs out in front, chest down, rear up, head
up, tail up."""
return {
'body': qeuler(x=22),
'Q': {'neck': qeuler(x=-30), 'head': qeuler(x=-18), 'ear.L': qeuler(x=-10), 'ear.R': qeuler(x=-10), 'mouth': qeuler(x=18)},
'aim': {**legs(front=[[0.1, -0.3, 1], [0.05, -0.2, 1], [0, -0.1, 1]],
back=[[0.05, -0.97, 0.2], [0, -0.8, -0.6], [0, -0.97, 0.2], [0, -0.1, 1]]),
'tail': [0, 0.6, -1], 'tail.01': [0, 0.95, -0.3], 'tail.02': [0, 1, 0.1], 'tail.03': [0, 0.8, 0.5],
'tail.end': [0, 0.4, 0.9]},
'scale': {'eye.L': [1, 0.25, 1], 'eye.R': [1, 0.25, 1]},
}
def startle_key():
"""Startled: back arched, legs stiff, ears flat, tail puffed up."""
return {
'A': {b: QI for b in LEG_BONES},
'Q': {'torso': qeuler(x=-28), 'spine.01': qeuler(x=22), 'spine.02': qeuler(x=38), 'neck': qeuler(x=-34),
'head': qeuler(x=-6), 'ear.L': qeuler(x=-45, z=35), 'ear.R': qeuler(x=-45, z=-35)},
'aim': {**legs(front=[[0.05, -1, 0.15], [0, -1, 0.1], [0, -0.2, 1]],
back=[[0.05, -0.95, -0.2], [0, -0.9, -0.4], [0, -1, 0.1], [0, -0.1, 1]]),
'tail': [0, 0.9, -0.4], 'tail.01': [0, 1, -0.1], 'tail.02': [0, 1, 0], 'tail.03': [0, 1, 0.1],
'tail.end': [0, 1, 0.1]},
'scale': {'tail': [1.7, 1, 1.7], 'eye.L': [1.15, 1.15, 1], 'eye.R': [1.15, 1.15, 1]},
}
def purr_mods(t, T, sway=1.0):
"""Being petted: eyes closed, head raised and tilted into the hand,
rubbing slowly (layer on a base key with add())."""
w = 2 * math.pi * t / T
return {'Q': {'neck': qeuler(x=-10, y=5 * math.sin(w) * sway), 'head': qeuler(x=-10, z=12 + 4 * math.sin(w), y=6 * math.sin(w) * sway),
'ear.L': qeuler(x=-8), 'ear.R': qeuler(x=-8)},
'scale': {'eye.L': [1, 0.12, 1], 'eye.R': [1, 0.12, 1]}}
def idle_key(t):
"""Standing, breathing; looks around a little, ears twitch, tail sways."""
T = 4.0
w = 2 * math.pi * t / T
k = add(STAND, breathe(t, T / 2))
look = math.sin(w) * 10
k['Q'].update({
'neck': qmul(k['Q'].get('neck', QI), qeuler(y=look * 0.5, x=2 * math.sin(2 * w))),
'head': qeuler(y=look * 0.6, z=4 * math.sin(w + 1)),
'tail': qeuler(y=8 * math.sin(w)), 'tail.01': qeuler(y=8 * math.sin(w - 0.6)),
'tail.02': qeuler(y=10 * math.sin(w - 1.2)), 'tail.03': qeuler(y=12 * math.sin(w - 1.8)),
'tail.end': qeuler(y=14 * math.sin(w - 2.4)),
})
tw = max(0.0, math.sin(4 * w)) ** 8 # quick ear twitch
k['Q']['ear.L'] = qeuler(z=-12 * tw * (math.sin(w) > 0))
k['Q']['ear.R'] = qeuler(z=12 * tw * (math.sin(w) <= 0))
blink = 1 - 0.85 * max(0.0, 1 - abs(t - 2.6) / 0.12)
k['scale']['eye.L'] = k['scale']['eye.R'] = [1, blink, 1]
return k
# ----------------------------------------------------------------- bake ----
class Baker:
def __init__(self, cat_path, tux_path, height, fps):
self.cat = Cat(cat_path)
self.tux = Rig(tux_path)
self.rt = Retarget(self.cat, self.tux)
self.fps = fps
rest, _, g = self.cat.skinned(self.cat.rest)
lo, hi = rest.min(axis=0), rest.max(axis=0)
self.s = height / (hi[1] - lo[1])
self.o = np.array([(lo[0] + hi[0]) / 2, 0.0, (lo[2] + hi[2]) / 2])
self.height = height
# Scruff: back of the neck, the top of the skin the neck bone moves.
cat = self.cat
k = cat.skin['joints'].index(cat['neck'])
mine = rest[cat.J[np.arange(len(cat.J)), cat.W.argmax(axis=1)] == k]
self.scruff = mine[mine[:, 1].argmax()].copy()
self.scruff[0] = g[cat['neck']][0, 3] # on the midline
def mesh(self, key, ground=True):
"""Key -> (positions, normals) in metres."""
cat = self.cat
pose = cat.pose_of(key)
p, n, g = cat.skinned(pose)
p = p + np.asarray(key.get('offset', [0, 0, 0]), float)
if ground:
p = (p - self.o) * self.s
p[:, 1] -= p[:, 1].min()
else: # hangs from the scruff (moved with the body)
p = (p - self.scruff_now(g)) * self.s
return p, n
def zones(self, key=None, ground=True):
"""Points on the cat in `key`'s pose (default: standing; metres, in
the frame's space as mesh() writes it) for interaction: scruff (where
the dangle hangs from), head top, back, chin, tail base."""
cat = self.cat
key = STAND if key is None else key
p, _, g = cat.skinned(cat.pose_of(key))
off = np.asarray(key.get('offset', [0, 0, 0]), float)
p = p + off
def top_of(joint, r=0.1):
j = g[cat[joint]][:3, 3] + off
near = p[np.linalg.norm(p[:, [0, 2]] - j[[0, 2]], axis=1) < r * self.height / self.s]
return np.array([j[0], near[:, 1].max(), j[2]]) if len(near) else j
pts = {'scruff': self.scruff_now(g) + off, 'head': top_of('head', 0.08), 'back': top_of('spine.01'),
'chin': g[cat['mouth']][:3, 3] + off, 'tailBase': g[cat['tail']][:3, 3] + off}
if ground:
lift = -((p - self.o) * self.s)[:, 1].min()
conv = lambda v: (v - self.o) * self.s + [0, lift, 0]
else:
sc = self.scruff_now(g) + off
conv = lambda v: (v - sc) * self.s
return {k: [round(float(x), 4) for x in conv(v)] for k, v in pts.items()}
def scruff_now(self, g):
"""Scruff point carried by the neck joint."""
cat = self.cat
i = cat['neck']
local = np.linalg.inv(cat.rest_world[i]) @ np.array([*self.scruff, 1])
return (g[i] @ local)[:3]
def clips(b):
"""name -> (list of keys, fps, loop, source, ground)."""
cat, tux, rt, fps = b.cat, b.tux, b.rt, b.fps
out = {}
def frames(dur, f=fps):
return max(1, int(round(dur * f)))
# Slow loops (breathing) at fewer frames per second: fewer frames to keep
# mounted in SteamVR's scene graph and fewer scene graph updates.
slow, sleepy = fps / 2, fps / 3
# native walk
an = cat.clip(cat.j['animations'][0]['name'])
d = cat.duration(an)
n = frames(d)
out['walk'] = ([{'pose': cat.sample(an, i * d / n)} for i in range(n)], n / d, True, 'native', True)
# into / out of the walk (the walk loop starts mid-stride)
n = max(4, frames(0.3))
out['walkstart'] = ([blend(cat, STAND, out['walk'][0][0], smooth(i / (n - 1))) for i in range(n)], fps, False,
'hand-keyed', True)
# retargeted Tuxedo clips
def retarget(name, loop, f=fps):
a = tux.clip(name)
d = tux.duration(a)
n = frames(d, f)
ks = [rt.key(a, i * d / (n if loop else n - 1)) for i in range(n)]
return ks, (n if loop else n - 1) / d
for name, src, loop in [('sitdown', 'SitDown', False), ('sit', 'IdleSit', True), ('standup', 'StandUp', False)]:
ks, f = retarget(src, loop, slow if loop else fps)
out[name] = (ks, f, loop, 'retargeted', True)
# hand-keyed
T = 4.0
n = frames(T, slow)
out['idle'] = ([idle_key(i * T / n) for i in range(n)], n / T, True, 'hand-keyed', True)
lie = loaf()
n = frames(1.2)
out['liedown'] = ([blend(cat, STAND, lie, smooth(i / (n - 1))) for i in range(n)], fps, False, 'hand-keyed', True)
T = 3.0
n = frames(T, slow)
out['lie'] = ([add(lie, breathe(i * T / n, T)) for i in range(n)], n / T, True, 'hand-keyed', True)
cu = curled()
n = frames(1.5)
out['curl'] = ([blend(cat, lie, cu, smooth(i / (n - 1))) for i in range(n)], fps, False, 'hand-keyed', True)
T = 4.0
n = frames(T, sleepy)
out['sleep'] = ([add(cu, breathe(i * T / n, T, 1.4)) for i in range(n)], n / T, True, 'hand-keyed', True)
T = 2.0
n = frames(T, slow)
out['dangle'] = ([dangle(i * T / n) for i in range(n)], n / T, True, 'hand-keyed', False)
# Dropped, petted, startled, idle life. One-shot actions start and end in their pose's rest key (STAND, or the
# sit loop's first key for the groom); pose loops' first keys likewise.
def seq(keys, dur, f=fps):
"""Keyed sequence [(time 0..1, key)] -> frames (smoothstep between)."""
n = frames(dur, f)
out = []
for i in range(n):
u = i / (n - 1)
k = next(j for j in range(len(keys) - 1) if keys[j + 1][0] >= u)
(t0, k0), (t1, k1) = keys[k], keys[k + 1]
out.append(blend(cat, k0, k1, smooth((u - t0) / max(1e-6, t1 - t0))))
return out, (n - 1) / dur
T = 0.5
n = frames(T, slow)
out['fall'] = ([fall_key(i * T / n) for i in range(n)], n / T, True, 'hand-keyed', True)
cr = crouch()
ks, f = seq([(0, cr), (0.25, cr), (1, STAND)], 0.6)
out['land'] = (ks, f, False, 'hand-keyed', True)
def shake(t, T=1.0):
env = math.sin(math.pi * min(1.0, t / T)) ** 1.5
w = 2 * math.pi * 5.5 * t
a = 14 * env * math.sin(w)
k = {'A': {b: QI for b in LEG_BONES},
'Q': {'torso': qeuler(z=0.4 * a), 'spine.01': qeuler(z=0.6 * a), 'spine.02': qeuler(z=0.8 * a),
'neck': qeuler(z=1.2 * a), 'head': qeuler(z=1.6 * a, x=-4 * env),
'ear.L': qeuler(z=-1.5 * a), 'ear.R': qeuler(z=-1.5 * a),
'tail': qeuler(y=-a), 'tail.01': qeuler(y=-a), 'tail.02': qeuler(y=-a)},
'scale': {'eye.L': [1, 1 - 0.6 * env, 1], 'eye.R': [1, 1 - 0.6 * env, 1]}}
return k
n = frames(1.0)
out['shake'] = ([shake(i / (n - 1)) for i in range(n)], (n - 1) / 1.0, False, 'hand-keyed', True)
st = stretch_key()
ks, f = seq([(0, STAND), (0.3, st), (0.7, st), (1, STAND)], 3.0, slow)
out['stretch'] = (ks, f, False, 'hand-keyed', True)
sk = startle_key()
ks, f = seq([(0, STAND), (0.15, sk), (0.7, sk), (1, STAND)], 1.0)
out['startle'] = (ks, f, False, 'hand-keyed', True)
# Grooming while sitting: right front paw up to the mouth, licked (head
# bobs), then down again.
sitk = rt.key(tux.clip('IdleSit'), 0)
def groom_key(bob):
k = add(sitk, {'Q': {'neck': qeuler(x=26 + 10 * bob, y=-14), 'head': qeuler(x=10 + 8 * bob, z=-10),
'ear.L': qeuler(x=-6), 'ear.R': qeuler(x=-6)},
'scale': {'eye.L': [1, 0.35, 1], 'eye.R': [1, 0.35, 1]}})
k['aim'] = {**k.get('aim', {}), 'leg.upper.F.R': [-0.12, 0.05, 1], 'leg.lower.F.R': [0.1, 1, 0.15],
'foot.F.R': [0.35, 0.2, -0.9]}
return k
g0, g1 = groom_key(0), groom_key(1)
ks, f = seq([(0, sitk), (0.15, g0), (0.25, g1), (0.35, g0), (0.45, g1), (0.55, g0), (0.65, g1), (0.75, g0),
(0.85, g0), (1, sitk)], 4.0, slow)
out['groom'] = (ks, f, False, 'hand-keyed', True)
# Petted: purr poses (loops) on the standing, sitting and lying cat, and
# the short transitions into them.
T = 3.0
happy = {**STAND, 'aim': {'tail': [0, 0.7, -0.7], 'tail.01': [0, 1, -0.15], 'tail.02': [0, 1, 0.1],
'tail.03': [0, 0.85, 0.5], 'tail.end': [0, 0.3, 1]}} # tail up: a happy cat
for name, base, pose in (('purr', happy, STAND), ('sitpurr', sitk, sitk), ('liepurr', lie, lie)):
n = frames(T, sleepy)
loop = [add(base, add(purr_mods(i * T / n, T), breathe(i * T / n, T / 2, 0.8))) for i in range(n)]
out[name] = (loop, n / T, True, 'hand-keyed', True)
m = max(4, frames(0.4))
out[name + 'in'] = ([blend(cat, pose, loop[0], smooth(i / (m - 1))) for i in range(m)], fps, False, 'hand-keyed', True)
return out
def pose_keys(b):
"""pose -> (its rest key, grounded): where the core looks for zones."""
return {'stand': (STAND, True), 'sit': (b.rt.key(b.tux.clip('IdleSit'), 0), True), 'lie': (loaf(), True),
'sleep': (curled(), True), 'fall': (fall_key(0), True), 'dangle': (dangle(0), False)}
def walk_speed(b):
"""Ground speed of the native walk (m/s at its own fps)."""
return gait_speed(b.cat, b.cat.j['animations'][0], ('foot.F.L', 'foot.B.L', 'foot.F.R', 'foot.B.R'), b.s)
def main():
cat_path, tux_path, out, height, fps = sys.argv[1], sys.argv[2], sys.argv[3], float(sys.argv[4]), float(sys.argv[5])
os.makedirs(out, exist_ok=True)
b = Baker(cat_path, tux_path, height, fps)
open(os.path.join(out, 'cat.png'), 'wb').write(png_from(b.cat.texture))
write_mtl(out)
uv = np.stack([b.cat.UV[:, 0], 1 - b.cat.UV[:, 1]], axis=1)
meta = {}
for name, (keys, f, loop, source, ground) in clips(b).items():
for i, k in enumerate(keys):
p, n = b.mesh(k, ground)
write_obj(os.path.join(out, f'{name}_{i}.obj'), p, n, uv, b.cat.F)
meta[name] = {'frames': len(keys), 'fps': round(f, 4), 'loop': loop, 'source': source}
json.dump({'height': height, 'vertices': len(b.cat.P), 'walkSpeed': round(walk_speed(b), 3),
'zones': b.zones(), 'poseZones': {k: b.zones(key, ground) for k, (key, ground) in pose_keys(b).items()},
'clips': meta}, open(os.path.join(out, 'frames.json'), 'w'), indent=1)
if __name__ == '__main__':
main()
+512
View File
@@ -0,0 +1,512 @@
#!/usr/bin/env python3
"""Bake another animal for the VR pet from a rigged, animated glTF: the same
output as bake.py (one static OBJ per frame, cat.png, cat.mtl, frames.json),
driven by the model's spec (models/<id>/model.json, kind "gltf").
usage: bake_gltf.py model.json model.glb out-dir fps
The spec's fields (clips, keys, layers, hang, lift, proportions, colors,
texture, zones, follow, feet) and how they map: docs/pet-models.md.
Fails (with a message) on an unknown clip or bone, and when a frame has more
than FACE_MAX triangles (vrcompositor draws every mounted frame).
"""
import base64, io, json, math, os, sys
import numpy as np
from PIL import Image
from rig import QI, Rig, accessor, mat_q, qbetween, qeuler, qinv, qmat, qmul, qnorm, slerp, smooth, walk_speed, write_mtl, write_obj
FACE_MAX = 3000
# The core's pose loops (core.js LOOP): a glTF clip under one of these names loops.
LOOPS = {'idle', 'walk', 'sit', 'lie', 'sleep', 'purr', 'sitpurr', 'liepurr', 'dangle', 'fall'}
# Grounded rest frames of the core's poses (poseZones): pose -> its loop clip.
POSES = {'stand': 'idle', 'sit': 'sit', 'lie': 'lie', 'sleep': 'sleep', 'fall': 'fall', 'dangle': 'dangle'}
SHADE = (0.72, 1.0) # palette ramp: floor .. top of the standing animal
def fail(msg):
raise SystemExit(f'bake_gltf: {msg}')
def linear(hexc):
"""'#rrggbb' (sRGB, as a colour picker shows it) -> linear RGB (glTF's baseColorFactor)."""
if not (isinstance(hexc, str) and len(hexc) == 7 and hexc[0] == '#'):
fail(f'colors: {hexc!r} is not "#rrggbb"')
c = np.array([int(hexc[i:i + 2], 16) / 255 for i in (1, 3, 5)])
return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
def srgb(c):
c = np.clip(np.asarray(c, float), 0, 1)
return np.where(c <= 0.0031308, c * 12.92, 1.055 * c ** (1 / 2.4) - 0.055)
class Model(Rig):
"""Every mesh of the glTF's scene: skinned (JOINTS_0 / WEIGHTS_0 and the
node's skin) or carried rigidly by its node; per vertex its material."""
def __init__(self, path):
self.own, self.turn, self.follow = {}, {}, {} # (reshape)
super().__init__(path)
j, binc = self.j, self.bin
scene = j['scenes'][j.get('scene', 0)]
under = set()
def visit(i):
under.add(i)
for c in self.nodes[i].get('children', []):
visit(c)
for i in scene['nodes']:
visit(i)
self.parts, base, F = [], 0, []
for ni in sorted(under):
n = self.nodes[ni]
if 'mesh' not in n:
continue
skin = j['skins'][n['skin']] if 'skin' in n else None
ibm = accessor(j, binc, skin['inverseBindMatrices']).reshape(-1, 4, 4).transpose(0, 2, 1) if skin else None
for prim in j['meshes'][n['mesh']]['primitives']:
if prim.get('mode', 4) != 4:
continue
at = prim['attributes']
P = accessor(j, binc, at['POSITION'])
if 'NORMAL' not in at:
fail(f'mesh {n["mesh"]}: no normals')
idx = accessor(j, binc, prim['indices']).astype(int).reshape(-1, 3) if 'indices' in prim else np.arange(len(P)).reshape(-1, 3)
part = {'node': ni, 'skin': skin, 'ibm': ibm, 'P': P, 'N': accessor(j, binc, at['NORMAL']),
'UV': accessor(j, binc, at['TEXCOORD_0']) if 'TEXCOORD_0' in at else None,
'mat': prim.get('material')}
if skin:
part['J'] = accessor(j, binc, at['JOINTS_0']).astype(int)
W = accessor(j, binc, at['WEIGHTS_0'])
part['W'] = W / W.sum(axis=1, keepdims=True)
part['dom'] = np.array(skin['joints'])[part['J'][np.arange(len(P)), part['W'].argmax(axis=1)]] if skin else np.full(len(P), ni)
self.parts.append(part)
F.append(idx + base)
base += len(P)
if not self.parts:
fail('no triangle meshes in the scene')
self.F = np.concatenate(F)
self.nverts = base
self.dom = np.concatenate([pt['dom'] for pt in self.parts]) # per vertex: the node that moves it most
def reshape(self, proportions, follow=None):
"""Change the animal's build for every pose (the spec's
"proportions": {bone: {"scale": [x, y, z], "rot": [x, y, z],
"aim": [x, y, z]}}):
scale the skin the bone moves, in the bone's own axes (y: along
the bone), from its joint; its children's joints move with
it, but they are not scaled themselves (so a long back
does not make long legs)
rot / aim the bone's rest pose turned (degrees about the body's
axes) or aimed, as in a key (a floppy ear, a straight tail);
kept in every clip, on top of the clip's own motion
Bones outside the chains ("follow": IK targets the feet are skinned
to) stay where they were relative to the bone they follow, the
offset scaled with it (shorter legs keep their paws), turned as in
the clip (a paw stays flat on the floor). A reshaped walk's speed
counts only the feet moving back (rig.walk_speed back_only)."""
for name, p in proportions.items():
if name not in self.idx:
fail(f'proportions: no bone {name!r} in the rig')
if 'scale' in p:
sc = p['scale']
if not (isinstance(sc, list) and len(sc) == 3 and all(isinstance(x, (int, float)) and 0 < x <= 10 for x in sc)):
fail(f'proportions: {name}: "scale" must be three numbers > 0')
self.own[self[name]] = np.asarray(sc, float)
self.follow = {self[k]: self[v] for k, v in (follow or {}).items()}
# Rest offsets, parents first (a child turns with its parent), in
# the body's axes as a key's (Baker.turned).
rest = [list(p) for p in self.rest]
for i in self.order:
p = proportions.get(self.nodes[i].get('name'))
if not p or ('rot' not in p and 'aim' not in p):
continue
g = Rig.world(self, rest)
wp = mat_q(g[self.parent[i]][:3, :3]) if i in self.parent else QI
if 'rot' in p:
e = qeuler(*p['rot'])
rest[i][1] = qnorm(qmul(qinv(wp), qmul(e, qmul(wp, rest[i][1]))))
if 'aim' in p:
g = Rig.world(self, rest)
kids = self.nodes[i].get('children', [])
if not kids:
fail(f'proportions: {self.nodes[i].get("name")}: "aim" needs a bone with a child')
e = qbetween(g[kids[0]][:3, 3] - g[i][:3, 3], np.asarray(p['aim'], float))
rest[i][1] = qnorm(qmul(qinv(wp), qmul(e, qmul(wp, rest[i][1]))))
self.turn[i] = qnorm(qmul(qinv(self.rest[i][1]), rest[i][1]))
# A follower after the bone it follows (and its children after it).
order, seen = [], set()
def visit(i):
if i in seen:
return
seen.add(i)
for d in (self.parent.get(i), self.follow.get(i)):
if d is not None:
visit(d)
order.append(i)
for i in self.order:
visit(i)
self.order = order
def world(self, pose):
"""World matrices of the joints (reshaped: see reshape; a joint's
own scale is not in them, posed() adds it)."""
if not (self.own or self.turn):
return super().world(pose)
return self.chain(pose, super().world(pose) if self.follow else None)
def chain(self, pose, orig):
out = [None] * len(self.nodes)
for i in self.order:
t, r, s = pose[i]
p = self.parent.get(i)
t = np.asarray(t, float) * self.own[p] if p in self.own else t
if i in self.turn:
r = qmul(r, self.turn[i])
m = np.eye(4)
m[:3, :3] = qmat(r) * s
m[:3, 3] = t
out[i] = out[p] @ m if p is not None else m
if i in self.follow:
f = self.follow[i]
x = np.linalg.inv(orig[f]) @ orig[i]
if f in self.own:
x[:3, 3] *= self.own[f]
out[i] = orig[i].copy() # (turned as in the clip: a paw stays flat)
out[i][:3, 3] = (out[f] @ x)[:3, 3]
return out
def posed(self, pose):
"""Positions and normals of every vertex in `pose` (glTF units)."""
g = self.world(pose)
if self.own:
g = list(g)
for i, sc in self.own.items():
g[i] = g[i] @ np.diag([*sc, 1.0])
Ps, Ns = [], []
for pt in self.parts:
if pt['skin']:
jm = np.array([g[jt] @ pt['ibm'][k] for k, jt in enumerate(pt['skin']['joints'])])
m = np.einsum('vk,vkij->vij', pt['W'], jm[pt['J']])
p = np.einsum('vij,vj->vi', m[:, :3, :3], pt['P']) + m[:, :3, 3]
# (normals: the inverse transpose, for the non-uniform scales)
nm = np.linalg.inv(m[:, :3, :3]).transpose(0, 2, 1) if self.own else m[:, :3, :3]
n = np.einsum('vij,vj->vi', nm, pt['N'])
else:
m = g[pt['node']]
p = pt['P'] @ m[:3, :3].T + m[:3, 3]
n = pt['N'] @ m[:3, :3].T
Ps.append(p)
Ns.append(n / (np.linalg.norm(n, axis=1, keepdims=True) + 1e-12))
return np.concatenate(Ps), np.concatenate(Ns), g
def lifted(u):
"""A one-shot's height above the floor (share of its "lift") at u = 0..1."""
return smooth(u / 0.25) * smooth((1 - u) / 0.25)
def lerp_pose(a, b, u):
return [[(1 - u) * np.asarray(ta) + u * np.asarray(tb), slerp(ra, rb, u), (1 - u) * np.asarray(sa) + u * np.asarray(sb)]
for (ta, ra, sa), (tb, rb, sb) in zip(a, b)]
class Baker:
def __init__(self, spec, path, fps):
self.spec, self.fps = spec, fps
self.dir = os.path.dirname(os.path.abspath(path))
self.m = m = Model(path)
m.reshape(spec.get('proportions', {}), spec.get('follow', {}))
self.anims = {a.get('name'): a for a in m.j['animations']}
for k, v in spec['clips'].items():
for name in self.sources(v):
if name not in self.anims and name not in spec.get('keys', {}) and name not in spec['clips']:
fail(f'clip {k}: no clip or key {name!r} (the glTF has: {", ".join(sorted(self.anims))})')
for bone in self.bones_used():
if bone not in m.idx:
fail(f'no bone {bone!r} in the rig')
if 'idle' not in spec['clips'] or 'walk' not in spec['clips']:
fail('an animal needs at least the clips idle and walk')
self.keys, self.memo = {}, {}
self.stand = self.pose('idle')
p, _, g = m.posed(self.stand)
lo, hi = p.min(axis=0), p.max(axis=0)
self.s = spec['height'] / (hi[1] - lo[1])
self.o = np.array([(lo[0] + hi[0]) / 2, 0.0, (lo[2] + hi[2]) / 2])
self.y0 = lo[1]
self.shade = np.clip((p[:, 1] - lo[1]) / (hi[1] - lo[1]), 0, 1) # per vertex: height in the standing pose
z = spec['zones']
# The scruff: the top of the skin its bone moves (on the midline), carried by the bone.
sb = m[z['scruff'] if isinstance(z['scruff'], str) else z['scruff']['bone']]
mine = p[m.dom == sb]
if not len(mine):
fail(f'the scruff bone {m.nodes[sb].get("name")} moves no vertices')
top = mine[mine[:, 1].argmax()].copy()
top[0] = g[sb][0, 3]
self.scruff = (sb, np.linalg.inv(g[sb]) @ np.array([*top, 1]))
@staticmethod
def sources(v):
if isinstance(v, str):
return [v]
return [x for x in [v.get('clip'), v.get('key'), v.get('reverse')] + list(v.get('blend', [])) if x]
def bones_used(self):
s = self.spec
out = [b for k in s.get('keys', {}).values() for b in [*k.get('rot', {}), *k.get('aim', {})]]
out += [lay['bone'] for v in s['clips'].values() if isinstance(v, dict) for lay in v.get('layers', [])]
out += [z if isinstance(z, str) else z['bone'] for z in s['zones'].values()] + list(s.get('proportions', {}))
return out + s.get('feet', []) + [b for kv in s.get('follow', {}).items() for b in kv]
# ---- poses ----
def sample(self, name, t):
return self.m.sample(self.anims[name], t)
def turned(self, pose, rot, aim=None):
"""`pose` with bones turned about the body's axes and / or aimed (see
the header), parents first."""
m, aim = self.m, aim or {}
out = [list(p) for p in pose]
for i in m.order:
name = m.nodes[i].get('name')
if name not in rot and name not in aim:
continue
g = m.world(out)
wp = mat_q(g[m.parent[i]][:3, :3]) if i in m.parent else QI
if name in rot:
e = qeuler(*rot[name])
if name in aim:
if name in rot:
out[i][1] = qnorm(qmul(qinv(wp), qmul(e, qmul(wp, out[i][1]))))
g = m.world(out)
c = m.nodes[i]['children'][0]
cur = g[c][:3, 3] - g[i][:3, 3]
e = qbetween(cur, np.asarray(aim[name], float))
out[i][1] = qnorm(qmul(qinv(wp), qmul(e, qmul(wp, out[i][1]))))
# Bones outside the chains (IK targets the feet are skinned to) move
# with the bone they follow.
# (Unreshaped: reshape() moves them with their reshaped bone.)
follow = self.spec.get('follow', {})
if follow and (rot or aim):
g0, g1 = Rig.world(m, pose), Rig.world(m, out)
for ik, bone in follow.items():
i, b = m[ik], m[bone]
w = g1[b] @ np.linalg.inv(g0[b]) @ g0[i]
local = np.linalg.inv(g1[m.parent[i]]) @ w if i in m.parent else w
sc = np.linalg.norm(local[:3, :3], axis=0)
out[i] = [local[:3, 3].copy(), mat_q(local[:3, :3]), sc]
return out
def key(self, name):
if name not in self.keys:
k = self.spec['keys'][name]
self.keys[name] = self.turned(self.sample(k['from'], k.get('at', 0)), k.get('rot', {}), k.get('aim', {}))
return self.keys[name]
def pose(self, name):
"""The first frame of a clip or key of the spec."""
if name in self.spec.get('keys', {}) and name not in self.spec['clips']:
return self.key(name)
return self.frames(name)[0][0]
def frames(self, name):
"""name -> ([pose per frame], fps, loop, source)."""
if name in self.memo:
return self.memo[name]
v = self.spec['clips'][name]
if isinstance(v, str):
v = {'clip': v}
loop = v.get('loop', name in LOOPS)
fps = self.fps
if 'blend' in v:
a, b = (self.pose(x) for x in v['blend'])
n = max(4, round(v.get('seconds', 0.6) * fps))
out = ([lerp_pose(a, b, smooth(i / (n - 1))) for i in range(n)], fps, False, 'hand-keyed')
elif 'reverse' in v:
ps, f, _, src = self.frames(v['reverse'])
out = (ps[::-1], f, False, src)
elif 'key' in v or 'hold' in v:
if 'key' in v:
base, src = self.key(v['key']), 'hand-keyed'
else:
a = self.anims[v['clip']]
t = self.m.duration(a) if v['hold'] == 'last' else float(v['hold'])
base, src = self.sample(v['clip'], t), 'gltf'
T = v.get('seconds', 3.0)
lay = v.get('layers', [])
n = max(1, round(T * v.get('fps', fps / 2))) if lay else 1 # slow loops at half the fps (as the cat's)
out = ([self.layered(base, lay, i * T / n) for i in range(n)], n / T, True, src)
else:
a = self.anims[v['clip']]
t0, t1 = v.get('start', 0.0), v.get('end', self.m.duration(a))
d = t1 - t0
n = max(1, round(d * v.get('fps', fps)))
div = n if loop else max(1, n - 1)
out = ([self.layered(self.sample(v['clip'], t0 + i * d / div), v.get('layers', []), i * d / div) for i in range(n)],
div / d, loop, 'gltf')
self.memo[name] = out
return out
def layered(self, pose, layers, t):
if not layers:
return pose
rot, out = {}, [list(p) for p in pose]
for lay in layers:
w = math.sin(2 * math.pi * (t / lay.get('period', 1.0) + lay.get('phase', 0)))
if 'rot' in lay:
rot[lay['bone']] = [a * w for a in lay['rot']]
if 'scale' in lay:
i = self.m[lay['bone']]
out[i][2] = np.asarray(out[i][2]) * (1 + w * np.asarray(lay['scale'], float))
return self.turned(out, rot)
# ---- geometry ----
def top(self, p, g, bone, r=0.1):
"""The top of the skin above a joint (within r x the height on the floor plane)."""
j = g[bone][:3, 3]
rr = r * (p[:, 1].max() - p[:, 1].min())
near = p[np.linalg.norm(p[:, [0, 2]] - j[[0, 2]], axis=1) < rr]
return np.array([j[0], near[:, 1].max(), j[2]]) if len(near) else j
def mesh(self, pose, hang=False):
"""Pose -> (positions (m), normals, bones' world matrices, origin (glTF units))."""
p, n, g = self.m.posed(pose)
if hang:
b, local = self.scruff
org = (g[b] @ local)[:3]
return (p - org) * self.s, n, g, org
org = np.array([self.o[0], p[:, 1].min(), self.o[2]])
return (p - org) * self.s, n, g, org
def zones(self, pose, hang=False):
p, _, g = self.m.posed(pose)
_, _, _, org = self.mesh(pose, hang)
out = {}
for k, z in self.spec['zones'].items():
bone = self.m[z if isinstance(z, str) else z['bone']]
if k == 'scruff':
v = (g[self.scruff[0]] @ self.scruff[1])[:3]
elif isinstance(z, dict) and z.get('at') == 'joint':
v = g[bone][:3, 3]
else:
v = self.top(p, g, bone)
out[k] = [round(float(x), 4) for x in (v - org) * self.s]
return out
# ---- texture ----
def atlas(self):
"""-> (PNG image, per-vertex UVs): the palette (flat colours, shaded
by height) with the materials' base colour textures stacked below."""
j, m = self.m.j, self.m
mats = j.get('materials', [])
colors = {k: linear(v) for k, v in self.spec.get('colors', {}).items()}
for k in colors:
if k not in [x.get('name') for x in mats]:
fail(f'colors: no material {k!r} (the glTF has: {", ".join(str(x.get("name")) for x in mats)})')
used = sorted({pt['mat'] for pt in m.parts}, key=lambda x: -1 if x is None else x)
flat, tex = [], []
for mi in used:
pbr = mats[mi].get('pbrMetallicRoughness', {}) if mi is not None else {}
if 'baseColorTexture' in pbr:
tex.append(mi)
else:
flat.append(mi)
cw, rows = 8, 64
W = max(64, 1 << math.ceil(math.log2(max(1, len(flat)) * cw)))
tiles, uv = [], {}
pal = np.zeros((rows, W, 3))
for k, mi in enumerate(flat):
c = mats[mi].get('pbrMetallicRoughness', {}).get('baseColorFactor', [0.8, 0.8, 0.8, 1]) if mi is not None else [0.8] * 4
if mi is not None and mats[mi].get('name') in colors:
c = colors[mats[mi]['name']]
ramp = np.linspace(SHADE[1], SHADE[0], rows)[:, None] * np.asarray(c[:3]) # row 0 (top of the image): lit
pal[:, k * cw:(k + 1) * cw] = srgb(ramp)[:, None, :]
uv[mi] = ('flat', (k * cw + cw / 2) / W)
tiles.append(Image.fromarray((pal * 255).round().astype(np.uint8), 'RGB'))
for mi in tex:
pbr = mats[mi]['pbrMetallicRoughness']
im = self.image(j['textures'][pbr['baseColorTexture']['index']]['source']).convert('RGB')
tw = int(self.spec.get('texture', 256)) # px wide in the atlas
im = im.resize((tw, max(1, round(im.height * tw / im.width))), Image.LANCZOS)
f = srgb(np.asarray(pbr.get('baseColorFactor', [1, 1, 1, 1])[:3]))
tiles.append(Image.fromarray((np.asarray(im) * f).round().astype(np.uint8), 'RGB'))
uv[mi] = ('tex', len(tiles) - 1)
TW = max(t.width for t in tiles)
TH = sum(t.height for t in tiles)
img = Image.new('RGB', (TW, TH))
y0s, y = [], 0
for t in tiles:
img.paste(t.resize((TW, t.height), Image.NEAREST) if t.width != TW else t, (0, y))
y0s.append(y)
y += t.height
UV, base = np.zeros((m.nverts, 2)), 0
for pt in m.parts:
n = len(pt['P'])
kind, x = uv[pt['mat']]
if kind == 'flat':
h = self.shade[base:base + n]
v_img = (0.5 + (1 - h) * (rows - 1)) / TH # from the image's top
UV[base:base + n] = np.stack([np.full(n, x), 1 - v_img], axis=1)
else:
if pt['UV'] is None:
fail(f'material {pt["mat"]}: a texture but no TEXCOORD_0')
t = tiles[x]
u = np.clip(pt['UV'][:, 0], 0, 1)
v_img = (y0s[x] + np.clip(pt['UV'][:, 1], 0, 1) * t.height) / TH
UV[base:base + n] = np.stack([u, 1 - v_img], axis=1)
base += n
return img, UV
def image(self, i):
im = self.m.j['images'][i]
if 'bufferView' in im:
bv = self.m.j['bufferViews'][im['bufferView']]
return Image.open(io.BytesIO(self.m.bin[bv.get('byteOffset', 0):bv.get('byteOffset', 0) + bv['byteLength']]))
if im['uri'].startswith('data:'):
return Image.open(io.BytesIO(base64.b64decode(im['uri'].split(',', 1)[1])))
return Image.open(os.path.join(self.dir, im['uri']))
def main():
spec_path, glb, out, fps = sys.argv[1], sys.argv[2], sys.argv[3], float(sys.argv[4])
spec = json.load(open(spec_path))
b = Baker(spec, glb, fps)
faces = len(b.m.F)
if faces > FACE_MAX:
fail(f'{faces} triangles per frame, more than {FACE_MAX}: decimate the model first (e.g. Blender\'s Decimate modifier)')
os.makedirs(out, exist_ok=True)
img, uv = b.atlas()
img.save(os.path.join(out, 'cat.png'), optimize=True)
write_mtl(out)
meta = {}
for name, v in spec['clips'].items():
poses, f, loop, source = b.frames(name)
hang = isinstance(v, dict) and v.get('hang', False)
lift = v.get('lift', 0) if isinstance(v, dict) else 0
for i, pose in enumerate(poses):
p, n, _, _ = b.mesh(pose, hang)
if lift:
p[:, 1] += lift * (1.0 if loop else lifted(i / max(1, len(poses) - 1)))
write_obj(os.path.join(out, f'{name}_{i}.obj'), p, n, uv, b.m.F)
meta[name] = {'frames': len(poses), 'fps': round(f, 4), 'loop': loop, 'source': source}
walk = spec['clips']['walk']
walk = walk if isinstance(walk, str) else walk['clip']
hang = lambda c: isinstance(spec['clips'][c], dict) and spec['clips'][c].get('hang', False)
frames = {
'height': spec['height'], 'vertices': b.m.nverts, 'faces': faces,
'walkSpeed': round(walk_speed(b.m, b.anims[walk], spec['feet'], b.s, back_only=bool(b.m.own)), 3),
'zones': b.zones(b.stand),
'poseZones': {p: b.zones(b.frames(c)[0][0], hang(c)) for p, c in POSES.items() if c in spec['clips']},
'clips': meta,
}
if 'species' in spec:
frames['species'] = spec['species']
json.dump(frames, open(os.path.join(out, 'frames.json'), 'w'), indent=1)
print(f'{spec.get("name", "?")}: {faces} triangles, {b.m.nverts} vertices per frame, '
f'{sum(c["frames"] for c in meta.values())} frames, walk {frames["walkSpeed"]} m/s')
if __name__ == '__main__':
main()
+7
View File
@@ -0,0 +1,7 @@
# The VR pet's tests (package.nix `tests`): the core with scripted hands,
# the SteamVR adapter in a fake systemui page, the CLI, the model catalog
# (also a private model added like extraModels, and the refusal of a private
# built-in one), the glTF reshaping, the "+" menu's icons, entry and icon
# link. Bakes the built-in models (no Blender).
{ pkgs }:
(import ./package.nix { inherit pkgs; }).tests
+128
View File
@@ -0,0 +1,128 @@
// vr-pet show [--summon] | hide | status | models | model <id>
//
// Shows / hides the VR pet (systemui.js) from outside SteamVR, e.g. the "+"
// menu's Pet entry (vr-pet.desktop runs `vr-pet show`). Talks to the
// systemui page over CEF DevTools (127.0.0.1:8087: /json/list, the target
// titled "systemui", one Runtime.evaluate) and calls only the patch's own API,
// window.__sfuiPet.api (its version checked); nothing else of the page is
// touched.
//
// show the cat back at its spot while that is within 3 m and in view,
// else 1 m in front of you (a shown cat: the same rule)
// --summon always 1 m in front of you
// hide the cat leaves the scene (as its close button)
// status JSON: hidden, pose, spot, distance, model, ...
// models the models, the cat's coats and the other animals (* the
// current one): id and name
// model <id> switch to that model (kept across restarts; a hidden pet
// switches too and is shown as that)
//
// Exit: 0 done, 2 SteamVR / DevTools unreachable (or timeout, 3 s), 3 the cat
// is not injected or has another API version, 4 error, usage or an unknown
// model.
export const API_VERSION = 2;
export const ENDPOINT = 'http://127.0.0.1:8087';
export const TITLE = 'systemui';
export const EXIT = { ok: 0, unreachable: 2, notInjected: 3, error: 4 };
const USAGE = 'usage: vr-pet show [--summon] | hide | status | models | model <id>';
// CLI command -> API method
const METHOD = { show: 'show', hide: 'hide', status: 'status', models: 'models', model: 'setModel' };
// Thrown by a transport: SteamVR / DevTools not reachable (exit 2).
export class Unreachable extends Error {}
// The expression evaluated in the page: only window.__sfuiPet.api.
export function expression(method, args = {}) {
return `(async () => {
const api = window.__sfuiPet?.api;
if (!api) return { status: 'not-injected' };
if (api.version !== ${API_VERSION}) return { status: 'version', version: api.version };
return { status: 'ok', value: await api[${JSON.stringify(method)}](${JSON.stringify(args)}) };
})()`;
}
// Default transport: fetch + the WebSocket of node >= 22.
export const cdp = {
async list(endpoint, signal) {
let r;
try { r = await fetch(`${endpoint}/json/list`, { signal }); } catch (e) { throw new Unreachable(`${endpoint}: ${e.cause?.code ?? e.message}`); }
if (!r.ok) throw new Unreachable(`${endpoint}/json/list: HTTP ${r.status}`);
return r.json();
},
evaluate(target, expr, signal) {
return new Promise((resolve, reject) => {
let ws;
try { ws = new WebSocket(target.webSocketDebuggerUrl); } catch (e) { reject(new Unreachable(e.message)); return; }
let opened = false;
const done = (f, v) => { try { ws.close(); } catch { /* closed */ } f(v); };
signal?.addEventListener('abort', () => done(reject, new Unreachable('timeout')));
ws.onopen = () => {
opened = true;
ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { expression: expr, awaitPromise: true, returnByValue: true } }));
};
ws.onmessage = (m) => {
let msg;
try { msg = JSON.parse(m.data); } catch { return; }
if (msg.id === 1) done(resolve, msg);
};
ws.onerror = () => done(reject, opened ? new Error('DevTools connection error') : new Unreachable('DevTools websocket refused'));
ws.onclose = () => reject(new Unreachable('DevTools websocket closed'));
});
},
};
// argv (without node and script) -> { code, out: [lines], err: [lines] }
export async function run(argv, { transport = cdp, endpoint = ENDPOINT, timeoutMs = 3000 } = {}) {
const out = [], err = [];
const [cmd, ...rest] = argv;
const flags = new Set(rest);
const known = { show: ['--summon'], hide: [], status: [], models: [] };
const idOk = cmd === 'model' && rest.length === 1 && /^[A-Za-z0-9_-]+$/.test(rest[0]);
if (!idOk && (!(cmd in known) || rest.some((f) => !known[cmd].includes(f)))) { err.push(USAGE); return { code: EXIT.error, out, err }; }
const args = cmd === 'show' ? { summon: flags.has('--summon') } : cmd === 'model' ? { id: rest[0] } : {};
const ac = new AbortController();
let timer;
const timeout = new Promise((_, no) => { timer = setTimeout(() => { ac.abort(); no(new Unreachable(`no answer within ${timeoutMs / 1000} s`)); }, timeoutMs); });
try {
const reply = await Promise.race([(async () => {
const targets = await transport.list(endpoint, ac.signal);
const target = Array.isArray(targets) && targets.find((t) => t.title === TITLE && t.webSocketDebuggerUrl);
if (!target) throw new Unreachable(`no "${TITLE}" page at ${endpoint} (SteamVR not running?)`);
return transport.evaluate(target, expression(METHOD[cmd], args), ac.signal);
})(), timeout]);
const ex = reply?.result?.exceptionDetails;
if (reply?.error || ex) {
err.push(`vr-pet: ${reply.error?.message ?? ex.exception?.description ?? ex.text ?? 'exception'}`);
return { code: EXIT.error, out, err };
}
const v = reply?.result?.result?.value;
if (v?.status === 'not-injected') { err.push('vr-pet: the cat is not in SteamVR (patch not injected yet?)'); return { code: EXIT.notInjected, out, err }; }
if (v?.status === 'version') { err.push(`vr-pet: the cat's API is version ${v.version}, this is ${API_VERSION}`); return { code: EXIT.notInjected, out, err }; }
if (v?.status !== 'ok') { err.push(`vr-pet: unexpected reply ${JSON.stringify(reply?.result?.result ?? reply)}`); return { code: EXIT.error, out, err }; }
const r = v.value ?? {};
if (r.error) { err.push(`vr-pet: ${r.error}${r.models ? ` (models: ${r.models.join(', ')})` : ''}`); return { code: EXIT.error, out, err }; }
if (cmd === 'status') out.push(JSON.stringify(r));
else if (cmd === 'models') {
const w = Math.max(0, ...(r.models ?? []).map((m) => m.id.length)); // names in one column
for (const m of r.models ?? []) out.push(`${m.id === r.current ? '*' : ' '} ${m.id.padEnd(w)} ${m.name}`);
}
else if (cmd === 'model') out.push(r.was === r.model ? `vr-pet: already ${r.name}` : `vr-pet: now ${r.name}${r.hidden ? ' (hidden; shown as that)' : ''}`);
else if (cmd === 'hide') out.push(r.was === 'hidden' ? 'vr-pet: already hidden' : 'vr-pet: hidden');
else out.push(`vr-pet: ${r.was === 'hidden' ? 'shown' : 'already shown'}${r.summoned ? ' (summoned in front of you)' : ''}`);
return { code: EXIT.ok, out, err };
} catch (e) {
if (e instanceof Unreachable) { err.push(`vr-pet: ${e.message}`); return { code: EXIT.unreachable, out, err }; }
err.push(`vr-pet: ${e.message ?? e}`);
return { code: EXIT.error, out, err };
} finally {
clearTimeout(timer);
}
}
// node cli.mjs <args>
if (import.meta.url === `file://${process.argv[1]}`) {
const r = await run(process.argv.slice(2));
for (const l of r.out) console.log(l);
for (const l of r.err) console.error(l);
process.exit(r.code);
}
+746
View File
@@ -0,0 +1,746 @@
// VR pet core: behaviour, movement, interaction and clip playback,
// independent of where the cat is drawn. The adapters (systemui.js for
// SteamVR's dashboard, preview/preview.js for a desktop browser, tests/ for
// node) implement the world interface:
//
// world.head() -> { x, y, z, fx, fz } | null head position (standing
// space: metres, y up, floor at 0) and its forward
// direction on the floor (unit)
// world.hands() -> { left, right }, each { x, y, z, vx, vy, vz, ... }
// | null: controller position (m) and velocity (m/s)
// (petting, startle)
// world.held() -> null | { source: 'laser', hand: 'left' | 'right' }
// (optional x, y, z, vx, vy, vz: the grab point, else
// world.hands()[hand]): the cat is being held by the
// scruff (the adapters: the laser drag of the grip bar
// above it). The adapter decides; the core only
// reacts: dangle while held (the offset between grab
// point and scruff at the grab is kept), fall and land
// on release.
// world.heldPose() -> null | { x, y, z, q } (optional) where the held cat
// really is: an adapter whose compositor carries the
// held cat (systemui.js: its root parented to the hand) reports its
// world pose, also on the tick it is let go, so the
// fall starts from there. Without it the core moves the
// cat with the grab point.
// world.show(clip, frame, { x, y, z, yaw, q }) draw baked frame `frame` of
// `clip` at that spot (yaw: radians about +y, 0 = the
// cat faces +z; q: the full orientation, a quaternion
// [w, x, y, z]: the yaw, or a held / falling cat's tilt)
// world.prefetch(clips) (optional) the clips needed now or soon (a Set,
// most urgent first)
// world.ready(clip) -> bool (optional) whether the clip can be shown yet;
// playback holds the current frame until it is
// world.load() -> saved object | undefined; world.save(object)
// world.log(...args)
//
// Baked data (frames.json from bake.py / bake_gltf.py): clips { name: {
// frames, fps, loop } }, walkSpeed (m/s of the walk clip at its fps), zones /
// poseZones (scruff, head, back, chin, tailBase per pose, in the frames' own
// space), optional species { actions: { name: { pose, weight, cooldown } } }
// (an animal's own idle actions, chosen like the cat's groom or stretch).
//
// Other animals (species): the graph below is the cat's; an animal gets what
// its baked clips allow (NEAREST, logged once; the fallbacks for model
// authors: docs/pet-models.md, "Clips").
//
// Poses and the clips between them (a transition played backwards is the
// reverse transition):
//
// stand (idle) --sitdown--> sit --standup--> stand
// stand --liedown--> lie --curl--> sleep (and back, reversed)
// stand --walkstart--> walk (and back, reversed, when
// the walk cycle is at 0)
// stand --purrin--> purr, sit --sitpurrin--> sitpurr,
// lie --liepurrin--> liepurr (petted; and back)
// dangle (held by the scruff), fall (dropped): entered without a
// transition; landing plays `land` and ends standing.
//
// Actions: one-shot clips that start and end in a pose's rest frame:
// groom (sit), stretch, shake, startle, land (stand). The cat walks the pose
// graph to the action's pose first.
//
// Behaviour: rests in a pose for a while, then wanders to a random spot near
// the user (never through them), sits, grooms, stretches (above all after
// waking), shakes itself, lies down, falls asleep after a long calm, turns to
// face the user now and then, follows when the user is more than `follow`
// metres away, reappears in front of the user beyond `summon`.
//
// Interaction (thresholds below):
// scruff grab world.held() (see above): lifted by the scruff, dangles
// from the grab point until released (carried by the adapter,
// world.heldPose()), then falls with gravity from where it
// was (keeping a little of the grab point's swing; a tilted
// cat rights itself in the air), lands (squash) and shakes
// itself, grooms or walks off.
// petting a free hand resting or stroking slowly on the
// head / back / scruff for PET_TIME: the cat purrs (eyes
// closed, leaning into the hand) in its pose (standing,
// sitting or lying; a sleeping cat wakes up to lie), as long
// as strokes keep coming, PURR_LINGER after the last one.
// startle a fast hand close to the cat: it arches its back (standing
// up first if it sat or lay) and backs off, away from the
// hand; no purring for WARY_TIME after that.
//
var VrPetCore = (() => {
// ---- interaction thresholds ----
const PET_RADIUS = 0.10; // m: controller to head / back / scruff counts as touching
const PET_MAX_SPEED = 0.4; // m/s: slower is a stroke (or a resting hand)
const PET_TIME = 0.6; // s of touching before the cat purrs
const PET_DECAY = 0.5; // touching time lost per second without touching
const PURR_LINGER = 2.5; // s: purring goes on this long after the last touch
const STARTLE_SPEED = 1.0; // m/s: a hand this fast ...
const STARTLE_RADIUS = 0.18; // m: ... this close to any part of the cat startles it
const STARTLE_COOLDOWN = 4; // s between startles
const WARY_TIME = 6; // s after a startle without purring
const FLEE_DISTANCE = 0.9; // m: backs off this far from the hand
const FLEE_SPEED = 1.8; // x walkSpeed while backing off
const GRAVITY = 9.81; // m/s²
const THROW = 0.3; // share of the grabber's velocity a dropped cat keeps
const THROW_MAX = 1.2; // m/s
const RIGHT_TIME = 0.3; // s: a cat dropped tilted rights itself in the air in this time
// Clips kept loaded all the time (world.prefetch): grab, drop and landing
// must show at once. Near a hand only: the purr (a hand this close to the
// head / back) and the startle (a hand this close moving fast).
const ALWAYS = ['dangle', 'fall', 'land'];
const PURR_PREP = 0.3; // m
const STARTLE_PREP = 0.6; // m, above STARTLE_PREP_SPEED m/s
const STARTLE_PREP_SPEED = 0.5;
const LOOKAHEAD = 1.2; // s: the next idle choice is made this early (its clips load meanwhile)
const COOLDOWN = { groom: 40, stretch: 60, shake: 50 }; // s between idle actions of a kind
const REVEAL_KEEP = 3; // m: a cat shown again (reveal()) stays at its spot within this of the user
const EDGES = [
['stand', 'sit', 'sitdown', 1], ['sit', 'stand', 'standup', 1],
['stand', 'lie', 'liedown', 1], ['lie', 'stand', 'liedown', -1],
['lie', 'sleep', 'curl', 1], ['sleep', 'lie', 'curl', -1],
['stand', 'walk', 'walkstart', 1], ['walk', 'stand', 'walkstart', -1],
['stand', 'purr', 'purrin', 1], ['purr', 'stand', 'purrin', -1],
['sit', 'sitpurr', 'sitpurrin', 1], ['sitpurr', 'sit', 'sitpurrin', -1],
['lie', 'liepurr', 'liepurrin', 1], ['liepurr', 'lie', 'liepurrin', -1],
];
const LOOP = { stand: 'idle', sit: 'sit', lie: 'lie', sleep: 'sleep', walk: 'walk', dangle: 'dangle', fall: 'fall',
purr: 'purr', sitpurr: 'sitpurr', liepurr: 'liepurr' };
const SNAP = ['dangle', 'fall']; // entered / left without a transition
const RESTING = ['stand', 'sit', 'lie', 'sleep'];
// The resting pose a pose counts as (saving, zones, purring).
const BASE = { stand: 'stand', sit: 'sit', lie: 'lie', sleep: 'sleep', walk: 'stand', purr: 'stand', sitpurr: 'sit',
liepurr: 'lie', dangle: 'stand', fall: 'stand' };
const PURR = { stand: 'purr', sit: 'sitpurr', lie: 'liepurr', sleep: 'liepurr' };
const ACTIONS = { groom: 'sit', stretch: 'stand', shake: 'stand', startle: 'stand', land: 'stand' };
const DEFAULTS = {
walkSpeed: 0.25, // m/s
turnRate: 2.2, // rad/s while walking
near: 0.8, // m: follow stops this far from the user
follow: 2.5, // m: farther away, the cat follows
wakeDistance: 4, // m: a sleeping cat follows only beyond this
wanderRadius: 2, // m around the user
personal: 0.55, // m: paths keep this far from the user
summon: 8, // m: farther away, it reappears in front of the user
lieAfter: 40, // s of calm before it may lie down
sleepAfter: 70, // s of calm before it may fall asleep
dangleHeight: 0.45, // m: scruff height of the forced (debug) dangle
demo: false, // cycle through all poses/activities (debug)
};
const wrap = (a) => Math.atan2(Math.sin(a), Math.cos(a));
const rnd = (a, b) => a + Math.random() * (b - a);
const pick = (weights) => { // { key: weight } -> key
let t = Math.random() * Object.values(weights).reduce((s, w) => s + w, 0);
for (const [k, w] of Object.entries(weights)) if ((t -= w) < 0) return k;
return Object.keys(weights)[0];
};
const dist3 = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
// ---- poses: { p: [x, y, z], q: [w, x, y, z] } (rotation first, then p) ----
const qmul = (a, b) => [
a[0] * b[0] - a[1] * b[1] - a[2] * b[2] - a[3] * b[3],
a[0] * b[1] + a[1] * b[0] + a[2] * b[3] - a[3] * b[2],
a[0] * b[2] - a[1] * b[3] + a[2] * b[0] + a[3] * b[1],
a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + a[3] * b[0]];
const qconj = (q) => [q[0], -q[1], -q[2], -q[3]];
const qnorm = (q) => { const l = Math.hypot(...q) || 1; return q.map((v) => v / l); };
const qrot = (q, v) => { const r = qmul(qmul(q, [0, v[0], v[1], v[2]]), qconj(q)); return [r[1], r[2], r[3]]; };
const qyaw = (yaw) => [Math.cos(yaw / 2), 0, Math.sin(yaw / 2), 0]; // about +y
// Heading of the cat's +z (its nose) on the floor; `fallback` when it points straight up or down.
const yawOf = (q, fallback = 0) => { const f = qrot(q, [0, 0, 1]); return Math.hypot(f[0], f[2]) < 1e-3 ? fallback : Math.atan2(f[0], f[2]); };
const nlerp = (a, b, t) => {
const s = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3] < 0 ? -1 : 1;
return qnorm(a.map((v, i) => v + (s * b[i] - v) * t));
};
const compose = (a, b) => { const r = qrot(a.q, b.p); return { p: [a.p[0] + r[0], a.p[1] + r[1], a.p[2] + r[2]], q: qnorm(qmul(a.q, b.q)) }; };
const invert = (a) => { const c = qconj(a.q), r = qrot(c, a.p); return { p: [-r[0], -r[1], -r[2]], q: c }; };
const relative = (parent, child) => compose(invert(parent), child); // child in the parent's frame
const qdiffDeg = (a, b) => 2 * Math.acos(Math.min(1, Math.abs(a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]))) * 180 / Math.PI;
const math = { qmul, qconj, qnorm, qrot, qyaw, yawOf, nlerp, compose, invert, relative, qdiffDeg };
// Where a pose without its loop goes instead (other animals).
const NEAREST = { sit: 'stand', lie: 'stand', sleep: 'lie', purr: 'stand', sitpurr: 'sit', liepurr: 'lie', dangle: 'fall', fall: 'stand' };
function routeEdges(from, to, edges = EDGES) { // BFS over the edges -> list of edges
const prev = { [from]: null }, queue = [from];
while (queue.length) {
const p = queue.shift();
if (p === to) break;
for (const e of edges) if (e[0] === p && !(e[1] in prev)) { prev[e[1]] = e; queue.push(e[1]); }
}
if (!(to in prev)) return null;
const path = [];
for (let p = to; prev[p]; p = prev[p][0]) path.unshift(prev[p]);
return path;
}
function create(world, userOpts, baked) {
const o = { ...DEFAULTS, ...userOpts };
const clips = baked.clips;
// ---- the species: what its baked clips allow (the cat: everything) ----
const logged = new Set();
const logOnce = (...a) => { const k = a.join(' '); if (!logged.has(k)) { logged.add(k); world.log?.(...a); } };
const has = (c) => !!clips[c];
const avail = (p) => has(LOOP[p]); // a pose is there when its loop is baked
const nearest = (p) => { let q = p; while (q && !avail(q)) q = NEAREST[q]; return q ?? 'stand'; };
const loop = {}; // pose -> the clip it shows (its own loop, else the nearest pose's)
for (const p of Object.keys(LOOP)) {
loop[p] = LOOP[nearest(p)];
if (loop[p] !== LOOP[p]) logOnce('no clip', LOOP[p], '->', loop[p]);
}
const edges = EDGES.filter((e) => avail(e[0]) && avail(e[1]));
const route = (from, to) => routeEdges(from, to, edges);
const spActions = Object.entries(baked.species?.actions ?? {});
const actions = { ...ACTIONS, ...Object.fromEntries(spActions.map(([k, a]) => [k, a.pose ?? 'stand'])) };
const cooldown = { ...COOLDOWN, ...Object.fromEntries(spActions.map(([k, a]) => [k, a.cooldown ?? 30])) };
const zonesOf = (pose) => baked.poseZones?.[pose] ?? baked.poseZones?.[BASE[pose]] ?? baked.zones ?? {};
const cat = {
x: 0, z: 0, yaw: 0, y: 0,
pose: 'stand', // pose reached (or being left, during an edge)
edge: null, // [from, to, clip, dir] being played
path: [], // edges still to play
goal: 'stand', // pose wanted
clip: 'idle', f: 0, // clip playing and its (fractional) frame
action: null, // { name, then } one-shot clip playing
pending: null, // { name, then } action waiting for its pose
activity: 'rest', until: rnd(3, 8), calm: 0,
waypoints: [], placed: false, forced: null,
time: 0, // s since start (cooldowns)
held: null, // { source, hand, off: [dx, dy, dz], v } held by the scruff
fall: null, // { vx, vy, vz, q0, t } dropped, in the air (q0: tilt at the release)
tilt: null, // full orientation [w, x, y, z] while held / righting itself, else the yaw
upcoming: null, // the next idle choice, made LOOKAHEAD early
petT: 0, lastPet: -1e9, purring: false, wary: -1e9, startleAt: -1e9,
lastAct: { groom: 0, stretch: 0, shake: 0, ...Object.fromEntries(spActions.map(([k]) => [k, 0])) }, woke: false,
near: [], // per hand: distances, speed (debug readout)
};
const saved = world.load?.();
if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.z)) {
Object.assign(cat, { x: saved.x, z: saved.z, yaw: +saved.yaw || 0, placed: true });
if (RESTING.includes(saved.pose)) { const p = nearest(saved.pose); Object.assign(cat, { pose: p, goal: p, clip: loop[p] }); }
}
const save = () => world.save?.({ x: +cat.x.toFixed(3), z: +cat.z.toFixed(3), yaw: +cat.yaw.toFixed(3), pose: BASE[cat.goal] ?? 'stand' });
const log = (...a) => world.log?.(...a);
// ---- pose graph / clip playback ----
function goTo(pose) {
if (cat.action) endAction(false);
if (!SNAP.includes(pose)) pose = nearest(pose); // (dangle, fall: states; their clip may be another's)
cat.goal = pose;
if (SNAP.includes(pose) || SNAP.includes(cat.pose)) { // no transitions
cat.edge = null; cat.path = []; cat.pose = pose; cat.clip = loop[pose]; cat.f = 0;
return;
}
if (!cat.edge) plan();
}
function plan() {
cat.path = cat.pose === cat.goal ? [] : (route(cat.pose, cat.goal) ?? []);
startNext();
}
function startNext(cycleDone = false) {
const e = cat.path[0];
if (!e) { if (cat.clip !== loop[cat.pose]) { cat.clip = loop[cat.pose]; cat.f = 0; } return; }
if (e[2] === 'walkstart' && e[3] < 0 && cat.clip === 'walk' && !cycleDone) return; // wait for the cycle to come round
cat.path.shift();
cat.edge = e;
if (!has(e[2])) { logOnce('no clip', e[2], '(cut)'); arrive(); return; } // no transition clip: a cut
cat.clip = e[2];
cat.f = e[3] > 0 ? 0 : clips[e[2]].frames - 1;
}
// The edge being played is done: its pose reached.
function arrive() {
const from = cat.edge[0];
cat.pose = cat.edge[1]; cat.edge = null;
if (from === 'lie' && cat.pose === 'stand') cat.woke = true;
if (cat.pose !== cat.goal) plan(); else { cat.clip = loop[cat.pose]; cat.f = 0; }
if (RESTING.includes(cat.pose)) save();
}
const settled = () => !cat.edge && cat.path.length === 0 && cat.pose === cat.goal;
const busy = () => !!(cat.action || cat.pending);
// Actions: play `name` once in its pose (walking the pose graph there
// first), then call `then`.
function startAction(name, then) {
if (!clips[name]) { then?.(); return; }
if (name in cat.lastAct) cat.lastAct[name] = cat.time;
cat.pending = { name, then };
if (cat.pose !== actions[name] || !settled()) goTo(actions[name]);
else runPending();
}
function runPending() {
const p = cat.pending;
if (!p || cat.pose !== actions[p.name] || !settled()) return;
cat.pending = null;
cat.action = p; cat.clip = p.name; cat.f = 0;
log('action', p.name);
}
function endAction(done) {
const a = cat.action;
cat.action = null;
cat.clip = loop[cat.pose]; cat.f = 0;
if (done) a?.then?.();
}
function cancelActions() { cat.pending = null; if (cat.action) endAction(false); }
let anticipate = new Set();
const routeClips = (from, to, out) => { for (const e of route(from, to) ?? []) { out.add(e[2]); out.add(loop[e[1]]); } };
// The clips needed now or soon, most urgent first.
function wanted() {
const out = new Set([cat.clip]);
if (cat.edge) out.add(cat.edge[2]);
for (const e of cat.path) { out.add(e[2]); out.add(loop[e[1]]); }
out.add(loop[cat.pose]); out.add(loop[cat.goal]);
if (cat.pending) { routeClips(cat.pose, actions[cat.pending.name], out); out.add(cat.pending.name); }
for (const c of anticipate) out.add(c);
const u = cat.activity === 'rest' && cat.upcoming;
if (u === 'wander' || u === 'turn') { out.add('walkstart'); out.add('walk'); }
else if (u in actions) { routeClips(cat.pose, actions[u], out); out.add(u); }
else if (u && u !== 'stay') routeClips(cat.pose, u, out);
for (const c of ALWAYS) out.add(c);
for (const c of out) if (!clips[c]) out.delete(c);
return out;
}
function play(dt, rate) {
world.prefetch?.(wanted());
if (world.ready && !world.ready(cat.clip)) return; // not loaded yet: hold
const c = clips[cat.clip];
const step = dt * c.fps * rate;
if (cat.action) {
cat.f += step;
if (cat.f >= c.frames - 1) { cat.f = c.frames - 1; endAction(true); }
} else if (cat.edge) {
cat.f += step * cat.edge[3];
if (cat.f >= c.frames - 1 || cat.f <= 0) {
cat.f = Math.max(0, Math.min(c.frames - 1, cat.f));
arrive();
}
} else {
const before = cat.f;
cat.f = (cat.f + step) % c.frames;
if (cat.clip === 'walk' && cat.path[0]?.[2] === 'walkstart' && cat.f < before) { cat.f = 0; startNext(true); }
}
}
const frame = () => {
const n = clips[cat.clip].frames;
return Math.max(0, Math.min(n - 1, cat.edge?.[3] < 0 ? Math.ceil(cat.f) : Math.floor(cat.f)));
};
// ---- geometry ----
// The cat's orientation, and a point of the current frame's space (zones) in the world.
const orient = () => cat.tilt ?? qyaw(cat.yaw);
const toWorld = (p) => {
if (cat.tilt) { const r = qrot(cat.tilt, p); return { x: cat.x + r[0], y: cat.y + r[1], z: cat.z + r[2] }; }
const c = Math.cos(cat.yaw), s = Math.sin(cat.yaw);
return { x: cat.x + p[0] * c + p[2] * s, y: cat.y + p[1], z: cat.z - p[0] * s + p[2] * c };
};
const zone = (name, pose = cat.pose) => { const z = zonesOf(pose)[name]; return z ? toWorld(z) : null; };
// ---- behaviour ----
function summon(h, d = o.near * 1.3) { // d metres in front of the user, facing them
cat.x = h.x + h.fx * d; cat.z = h.z + h.fz * d; cat.y = 0;
cat.yaw = Math.atan2(h.x - cat.x, h.z - cat.z); cat.placed = true;
cat.waypoints = []; setActivity('rest', rnd(2, 5)); goTo(SNAP.includes(cat.pose) ? 'stand' : cat.pose);
save(); log('summoned', cat.x.toFixed(2), cat.z.toFixed(2));
}
// A path to (tx, tz) that keeps `personal` metres from the user at (ux, uz).
function pathTo(tx, tz, ux, uz) {
const dx = tx - cat.x, dz = tz - cat.z, L2 = dx * dx + dz * dz || 1;
const u = Math.max(0, Math.min(1, ((ux - cat.x) * dx + (uz - cat.z) * dz) / L2));
const cx = cat.x + u * dx, cz = cat.z + u * dz;
const d = Math.hypot(cx - ux, cz - uz);
if (u > 0.05 && u < 0.95 && d < o.personal) { // detour around the user
let nx = cx - ux, nz = cz - uz;
if (d < 1e-3) { nx = -dz; nz = dx; }
const nl = Math.hypot(nx, nz) || 1, r = o.personal + 0.25;
return [[ux + nx / nl * r, uz + nz / nl * r], [tx, tz]];
}
return [[tx, tz]];
}
function setActivity(a, until) {
if (a !== cat.activity) log('activity', a);
cat.activity = a; cat.until = until ?? 0; cat.upcoming = null;
}
function wander(h) {
for (let i = 0; i < 12; i++) {
const a = rnd(0, 2 * Math.PI), r = rnd(o.near + 0.1, o.wanderRadius);
const tx = h.x + Math.sin(a) * r, tz = h.z + Math.cos(a) * r;
if (Math.hypot(tx - cat.x, tz - cat.z) < 0.5) continue;
cat.waypoints = pathTo(tx, tz, h.x, h.z);
setActivity('wander'); goTo('walk');
return true;
}
return false;
}
function follow(h) {
const dx = cat.x - h.x, dz = cat.z - h.z, d = Math.hypot(dx, dz) || 1;
cat.waypoints = pathTo(h.x + dx / d * o.near, h.z + dz / d * o.near, h.x, h.z);
if (cat.activity !== 'follow') { setActivity('follow'); goTo('walk'); }
}
function faceUser() {
cat.waypoints = []; cat.faceUntil = 2.5; setActivity('turn'); goTo('walk');
}
const restAfter = (a, b) => () => setActivity('rest', rnd(a, b));
const ready = (name) => !!clips[name] && cat.time - cat.lastAct[name] > cooldown[name];
function act(name) { setActivity('action'); startAction(name, restAfter(4, 10)); }
// The next idle step, chosen LOOKAHEAD before the rest ends (its clips
// are prefetched meanwhile, wanted()).
function choose(err) {
const calm = cat.calm;
const a = (p) => (avail(p) ? 1 : 0); // (another animal: only its poses)
let w = null;
switch (cat.pose) {
case 'stand': w = { wander: 3, sit: 2.5 * a('sit'), turn: Math.abs(err) > 0.8 ? 3 : 0.3, lie: calm > o.lieAfter ? 2.5 * a('lie') : 0, stay: 1.5,
stretch: ready('stretch') ? (cat.woke ? 8 : 1.5) : 0, shake: ready('shake') ? 0.4 : 0 }; break;
case 'sit': w = { stand: 1.5, lie: (calm > o.lieAfter * 0.6 ? 2 : 0.3) * a('lie'), stay: 2, groom: ready('groom') ? 3 : 0,
stretch: ready('stretch') ? 0.6 : 0 }; break;
case 'lie': w = { sleep: calm > o.sleepAfter ? 3 * a('sleep') : 0, stand: 1, stay: 2 }; break;
case 'sleep': return 'lie';
default: return 'stand';
}
for (const [k, s] of spActions) if ((s.pose ?? 'stand') === cat.pose) w[k] = ready(k) ? (s.weight ?? 1) : 0; // the animal's own
return pick(w);
}
function rest(dt, h, dist, err) {
cat.until -= dt;
if (cat.until > 0) {
if (cat.until < LOOKAHEAD && !cat.upcoming) cat.upcoming = choose(err);
return;
}
const next = cat.upcoming ?? choose(err);
cat.upcoming = null;
cat.woke = false;
log('rest ->', next);
if (next === 'wander') { if (!wander(h)) setActivity('rest', rnd(2, 5)); return; }
if (next === 'turn') { faceUser(); return; }
if (next === 'stay') { setActivity('rest', rnd(4, 10)); return; }
if (next in actions) { act(next); return; }
goTo(next);
setActivity('rest', next === 'sleep' ? rnd(60, 180) : next === 'lie' ? rnd(15, 40) : rnd(6, 14));
}
// ---- interaction ----
function grab(by, p) {
const s = cat.held ? { x: cat.x, y: cat.y, z: cat.z } : zone('scruff'); // dangle frames hang from the scruff
cancelActions();
// (A tilted cat, caught while righting itself, keeps its tilt.)
Object.assign(cat, { held: { source: by.source, hand: by.hand, off: [s.x - p.x, s.y - p.y, s.z - p.z], v: [0, 0, 0] },
fall: null, purring: false, petT: 0, waypoints: [], x: s.x, y: s.y, z: s.z });
goTo('dangle'); setActivity('held');
log('grabbed', by.source, by.hand ?? '');
}
// Where the attaching adapter really holds the cat (world.heldPose()).
function followHeld() {
const hp = world.heldPose?.();
if (!hp) return false;
Object.assign(cat, { x: hp.x, y: hp.y, z: hp.z, yaw: yawOf(hp.q, cat.yaw) });
cat.tilt = hp.q;
return true;
}
function release() {
followHeld(); // the hand's pose now: the fall starts where the cat is seen
const v = cat.held.v.map((x) => x * THROW);
const hv = Math.hypot(v[0], v[2]);
if (hv > THROW_MAX) { v[0] *= THROW_MAX / hv; v[2] *= THROW_MAX / hv; }
// Keep the scruff (the dangle frames' origin) where it is: the fall
// frames stand on their feet, turned like the held cat.
const s = zonesOf('fall').scruff ?? [0, 0.2, 0.06];
const q = orient(), r = qrot(q, s);
log('released', `from ${cat.y.toFixed(2)} m`);
// (fresh: shown where it was released first; the fall starts next tick)
Object.assign(cat, { held: null, fall: { vx: v[0], vy: Math.min(v[1], 1), vz: v[2], q0: cat.tilt, t: 0, fresh: true },
x: cat.x - r[0], z: cat.z - r[2], y: Math.max(0, cat.y - r[1]) });
goTo('fall'); setActivity('falling');
}
function land() {
Object.assign(cat, { fall: null, y: 0, tilt: null });
goTo('stand');
save(); // where it was dropped
setActivity('landed');
startAction('land', afterLanding);
log('landed');
}
function afterLanding() {
const next = pick({ shake: has('shake') ? 2 : 0, groom: has('groom') ? 1.2 : 0, wander: 1.5 });
log('after landing ->', next);
if (next === 'wander') { const h = world.head(); if (h && wander(h)) return; }
act(next === 'wander' ? 'shake' : next);
}
function startle(g) {
Object.assign(cat, { petT: 0, purring: false, wary: cat.time + WARY_TIME, startleAt: cat.time, waypoints: [] });
cancelActions();
setActivity('startled');
if (['stand', 'walk', 'purr'].includes(cat.pose) || cat.edge?.[0] === 'stand' || cat.edge?.[0] === 'walk') {
cat.edge = null; cat.path = []; cat.pose = cat.goal = 'stand'; // jolt: no transition
}
startAction('startle', () => flee(g));
log('startled', g.side ?? '', Math.hypot(g.vx, g.vy, g.vz).toFixed(2), 'm/s');
}
// Away from the hand, but not onto the user: the first direction (straight
// away, then turning further) whose end stays `near` from the user.
function flee(g) {
const h = world.head();
let dx = cat.x - g.x, dz = cat.z - g.z;
const d = Math.hypot(dx, dz);
if (d < 1e-3) { dx = -Math.sin(cat.yaw); dz = -Math.cos(cat.yaw); } else { dx /= d; dz /= d; }
let tx = cat.x + dx * FLEE_DISTANCE, tz = cat.z + dz * FLEE_DISTANCE;
for (const a of [0, 0.8, -0.8, 1.6, -1.6, 2.4, -2.4]) {
const c = Math.cos(a), s = Math.sin(a), ex = dx * c + dz * s, ez = -dx * s + dz * c;
const x = cat.x + ex * FLEE_DISTANCE, z = cat.z + ez * FLEE_DISTANCE;
if (!h || Math.hypot(x - h.x, z - h.z) >= o.near) { tx = x; tz = z; break; }
}
cat.waypoints = h ? pathTo(tx, tz, h.x, h.z) : [[tx, tz]];
setActivity('flee'); goTo('walk');
}
function startPurr() {
const to = PURR[BASE[cat.pose]] ?? 'purr';
cat.purring = true; cat.waypoints = [];
setActivity('petted'); goTo(to);
log('purring', to);
}
function stopPurr() {
cat.purring = false; cat.petT = 0;
goTo(BASE[cat.goal] ?? 'stand');
setActivity('rest', rnd(4, 10));
log('purring stopped');
}
// Held (world.held()), petting and startle (the free hands).
function interact(dt) {
const hs = world.hands?.() ?? {};
const by = cat.forced ? null : world.held?.() ?? null;
const hands = ['left', 'right'].filter((k) => hs[k]).map((k) => ({ side: k, vx: 0, vy: 0, vz: 0, ...hs[k] }));
if (by) {
const p = Number.isFinite(by.x) ? { vx: 0, vy: 0, vz: 0, ...by } : hands.find((h) => h.side === by.hand);
if (p) {
if (!cat.held || cat.held.hand !== by.hand || cat.held.source !== by.source) grab(by, p); // a new grab
if (!followHeld()) {
const [ox, oy, oz] = cat.held.off;
cat.x = p.x + ox; cat.y = p.y + oy; cat.z = p.z + oz;
}
cat.held.v = [p.vx, p.vy, p.vz];
}
anticipate = new Set();
cat.near = [];
return;
}
if (cat.held) release();
const zs = zonesOf(cat.pose);
const pts = Object.fromEntries(Object.entries(zs).map(([k, p]) => [k, toWorld(p)]));
cat.near = [];
let contact = false, fast = null, petNear = false, fastNear = false;
for (const g of hands) {
const dScruff = pts.scruff ? dist3(g, pts.scruff) : Infinity;
const dPet = Math.min(...['head', 'back', 'scruff'].map((k) => (pts[k] ? dist3(g, pts[k]) : Infinity)));
const dAny = Math.min(dPet, ...Object.values(pts).map((p) => dist3(g, p)));
const speed = Math.hypot(g.vx, g.vy, g.vz);
cat.near.push({ side: g.side, dScruff, dPet, speed });
if (dPet < PURR_PREP) petNear = true;
if (dAny < STARTLE_PREP && speed > STARTLE_PREP_SPEED) fastNear = true;
if (dAny < STARTLE_RADIUS && speed > STARTLE_SPEED) fast = g;
else if (dPet < PET_RADIUS && speed < PET_MAX_SPEED) contact = true;
}
const purr = PURR[BASE[cat.pose]] ?? 'purr';
anticipate = new Set([...(petNear ? [`${purr}in`, purr] : []), ...(fastNear ? ['startle'] : [])]);
if (cat.fall || cat.forced || cat.pending?.name === 'land' || cat.action?.name === 'land') return;
if (fast && cat.time - cat.startleAt > STARTLE_COOLDOWN) { startle(fast); return; }
if (contact) { cat.petT = Math.min(PET_TIME * 2, cat.petT + dt); cat.lastPet = cat.time; }
else cat.petT = Math.max(0, cat.petT - PET_DECAY * dt);
const canPurr = cat.time > cat.wary && !busy() && cat.activity !== 'startled' && cat.activity !== 'flee' && avail(purr);
if (!cat.purring && cat.petT >= PET_TIME && canPurr) startPurr();
else if (cat.purring && cat.time - cat.lastPet > PURR_LINGER) stopPurr();
}
// Demo (debug): a fixed tour of everything.
const DEMO = ['stand', 'sit', 'groom', 'stand', 'stretch', 'wander', 'lie', 'sleep', 'purr', 'lie', 'stand', 'turn',
'startle', 'drop', 'dangle', 'stand', 'follow'];
let demoI = -1, demoT = 0;
function command(cmd, h) {
log('command', cmd);
cat.forced = null;
cancelActions();
cat.held = null; cat.tilt = null; // until world.held() grabs again
if (cmd === 'dangle') {
Object.assign(cat, { forced: 'dangle', fall: null, purring: false });
goTo('dangle'); cat.y = o.dangleHeight; return;
}
if (SNAP.includes(cat.pose)) { cat.fall = null; cat.y = 0; goTo('stand'); }
if (cmd === 'summon') { if (h) summon(h, 1); return; }
if (cmd === 'drop') { Object.assign(cat, { fall: { vx: 0, vy: 0, vz: 0, q0: null, t: 0 }, y: 0.8, purring: false }); goTo('fall'); setActivity('falling'); return; }
if (cmd === 'purr') { if (avail(PURR[BASE[cat.pose]] ?? 'purr')) { cat.lastPet = cat.time + 6; startPurr(); } return; }
if (cmd === 'startle') {
const g = { x: cat.x + Math.sin(cat.yaw) * 0.2, y: 0.2, z: cat.z + Math.cos(cat.yaw) * 0.2, vx: 2, vy: 0, vz: 0 };
startle(g); return;
}
if (cmd in actions) { if (has(cmd)) act(cmd); else logOnce('no clip', cmd, '(command ignored)'); return; }
if (cmd === 'wander') { if (h) wander(h); return; }
if (cmd === 'follow') { if (h) { cat.x = h.x + 3.2 * h.fx; cat.z = h.z + 3.2 * h.fz; follow(h); } return; }
if (cmd === 'turn') { if (h) { cat.yaw = wrap(cat.yaw + Math.PI * 0.8); faceUser(); } return; }
if (RESTING.includes(cmd)) { cat.purring = false; cat.waypoints = []; goTo(cmd); setActivity('rest', 20); }
}
// Put down whatever holds it (the laser drag, a forced dangle; falling:
// landed at once) and stand where it is (adapter: hiding the cat,
// switching to another animal).
function settle() {
if (!cat.held && !cat.fall && !cat.forced) return false;
if (SNAP.includes(cat.pose) && !cat.fall) { // dangling: its origin is the scruff; stand below it
const s = zonesOf('stand').scruff ?? [0, 0, 0], r = qrot(qyaw(cat.yaw), s);
cat.x -= r[0]; cat.z -= r[2];
}
command('stand', world.head());
save();
return true;
}
function tick(dt) {
dt = Math.min(0.5, Math.max(0, dt));
cat.time += dt;
const h = world.head();
let rate = 1;
interact(dt);
if (cat.fall?.fresh) cat.fall.fresh = false;
else if (cat.fall) {
const f = cat.fall;
f.t += dt;
if (f.q0) { // right itself: from the tilt at the release to upright (its heading kept)
const u = Math.min(1, f.t / RIGHT_TIME);
cat.tilt = u < 1 ? nlerp(f.q0, qyaw(cat.yaw), u * u * (3 - 2 * u)) : null;
}
f.vy -= GRAVITY * dt;
cat.x += f.vx * dt; cat.y += f.vy * dt; cat.z += f.vz * dt;
if (cat.y <= 0) land();
}
const free = !cat.held && !cat.fall && !cat.forced;
if (h && free) {
if (!cat.placed) summon(h);
const dx = h.x - cat.x, dz = h.z - cat.z, dist = Math.hypot(dx, dz);
if (dist > o.summon) summon(h);
const toUser = Math.atan2(dx, dz), err = wrap(toUser - cat.yaw);
if (o.demo) {
demoT -= dt;
if (demoT <= 0) { demoI = (demoI + 1) % DEMO.length; demoT = DEMO[demoI] === 'sleep' ? 12 : 9; command(DEMO[demoI], h); }
}
const moving = ['wander', 'follow', 'turn', 'flee'].includes(cat.activity);
cat.calm = moving || cat.purring ? 0 : cat.calm + dt;
if (!o.demo && !cat.purring && !busy() && !['startled', 'flee', 'landed'].includes(cat.activity)) {
const far = cat.pose === 'sleep' ? o.wakeDistance : o.follow;
if (dist > far && cat.activity !== 'follow') follow(h);
else if (cat.activity === 'follow' && dist > o.near + 0.3) follow(h); // keep re-aiming
}
if (cat.activity === 'rest' && settled() && !busy() && !o.demo) rest(dt, h, dist, err);
// Walking: steer along the waypoints (or turn toward the user).
if (cat.pose === 'walk' && !cat.edge && cat.clip === 'walk') {
const wp = cat.waypoints[0];
let heading = toUser, speed = 0;
const fast = cat.activity === 'flee' ? FLEE_SPEED : 1;
if (wp) {
const wx = wp[0] - cat.x, wz = wp[1] - cat.z, wd = Math.hypot(wx, wz);
heading = Math.atan2(wx, wz);
speed = o.walkSpeed * fast * Math.min(1, wd / 0.15 + 0.3);
if (wd < 0.06) cat.waypoints.shift();
}
const e = wrap(heading - cat.yaw);
cat.yaw = wrap(cat.yaw + Math.sign(e) * Math.min(Math.abs(e), o.turnRate * fast * dt));
if (Math.abs(e) > 0.9) speed *= 0.2; // mostly turn on the spot
if (cat.path[0]?.[2] === 'walkstart') speed *= 0.5; // stopping
cat.x += Math.sin(cat.yaw) * speed * dt; cat.z += Math.cos(cat.yaw) * speed * dt;
rate = Math.max(0.6, speed / (baked.walkSpeed || 0.2));
const done = cat.activity === 'turn' ? (Math.abs(e) < 0.1 || (cat.faceUntil -= dt) < 0) : !cat.waypoints.length;
if (done && cat.goal === 'walk') {
goTo('stand'); setActivity('rest', cat.activity === 'flee' ? rnd(3, 6) : rnd(5, 12)); save();
}
} else if (cat.pose === 'walk' && cat.edge) {
cat.x += Math.sin(cat.yaw) * o.walkSpeed * 0.3 * dt; cat.z += Math.cos(cat.yaw) * o.walkSpeed * 0.3 * dt;
}
} else if (!h && free) cat.calm += dt;
play(dt, rate);
if (cat.pending && !cat.held && !cat.fall) runPending();
if (!cat.held && !cat.fall && !SNAP.includes(cat.pose)) cat.y = 0;
if (!cat.held && !cat.fall) cat.tilt = null;
world.show(cat.clip, frame(), { x: cat.x, y: cat.y, z: cat.z, yaw: cat.yaw, q: orient() });
}
return {
tick,
command: (cmd) => command(cmd, world.head()),
settle,
save, // world.save() the spot and pose now (adapter: handing them to another animal's core)
state: () => ({ pose: cat.pose, goal: cat.goal, clip: cat.clip, frame: frame(), activity: cat.activity,
edge: cat.edge?.[2] ?? null, action: cat.action?.name ?? null, pending: cat.pending?.name ?? null,
x: cat.x, y: cat.y, z: cat.z, yaw: cat.yaw, calm: cat.calm, time: cat.time, waypoints: cat.waypoints.slice(),
forced: cat.forced, held: cat.held && { source: cat.held.source, hand: cat.held.hand }, falling: !!cat.fall, q: orient(),
purring: cat.purring, petT: cat.petT, wary: Math.max(0, cat.wary - cat.time), near: cat.near.slice(),
}),
zone: (name) => zone(name),
options: o,
// What this animal can do: its poses, actions and every clip the core may ask for.
species: () => ({ poses: Object.keys(LOOP).filter(avail), actions: Object.keys(actions).filter(has),
clips: [...new Set([...Object.values(loop), ...edges.map((e) => e[2]).filter(has), ...Object.keys(actions).filter(has)])] }),
};
}
// A hidden cat shown again (adapter): 'stay' at its spot (spot: { x, z })
// while that is within `keep` m of the user (head: world.head()) on the
// floor and in view (the adapter's test), else 'summon' (1 m in front of the
// user); force: always 'summon'. No head pose: 'stay'.
function reveal(spot, head, { inView = true, force = false, keep = REVEAL_KEEP } = {}) {
if (force) return 'summon';
if (!head || !spot || !Number.isFinite(spot.x) || !Number.isFinite(spot.z)) return 'stay';
return Math.hypot(spot.x - head.x, spot.z - head.z) > keep || !inView ? 'summon' : 'stay';
}
// ---- the controls above the pet, shared by the adapters ----
// systemui.js draws them as SteamVR panels, preview.js imitates them: the
// grip bar (its bottom `lift` m above the frames' origin for the 0.30 m
// cat, liftOf(): another animal's height + 0.10 m; liftDangle over a
// dangling one's scruff), the controls row (⋯ X) gapM below it, the ⋯
// menu gapM above it and forwardM nearer (rows in px of its texture).
const HANDLE = { widthM: 0.11, widthPx: 132, heightPx: 30, lift: 0.40, liftDangle: 0.15 };
const CONTROLS = { widthM: 0.08, widthPx: 96, heightPx: 48, gapM: 0.008 };
const MENU = { widthM: 0.13, widthPx: 240, gapM: 0.01, forwardM: 0.02, padPx: 8, modelRowPx: 48, cmdRowPx: 44, sepPx: 9, leaveMs: 1000 };
const ui = {
HANDLE, CONTROLS, MENU,
MENU_CMDS: [['summon', 'Summon'], ['sit', 'Sit'], ['lie', 'Lie down'], ['sleep', 'Sleep']],
AFTER_DRAG_MS: 300, // buttons stay inert this long after a drag
liftOf: (height = 0.30) => HANDLE.lift + (height - 0.30),
// The menu's models (catalog.order), the ones a separator goes before
// (a new group: the coats, the animals) and its height in px.
menuLayout(catalog) {
const ids = catalog.order ?? Object.keys(catalog.models);
const breaks = ids.filter((id, i) => i > 0 && catalog.models[id].group !== catalog.models[ids[i - 1]].group);
const px = MENU.padPx * 2 + ids.length * MENU.modelRowPx + (breaks.length + 1) * MENU.sepPx + ui.MENU_CMDS.length * MENU.cmdRowPx;
return { ids, breaks, px };
},
// Whether the pet at `at` (its feet) is in view from `eye` looking along
// the unit vector `fwd`: within 65° (plus the pet's own size).
inView(eye, fwd, at) {
const d = [at.x - eye[0], at.y + 0.15 - eye[1], at.z - eye[2]];
const l = Math.hypot(...d) || 1;
const deg = Math.acos(Math.max(-1, Math.min(1, (fwd[0] * d[0] + fwd[1] * d[1] + fwd[2] * d[2]) / l))) * 180 / Math.PI;
return deg < 65 + Math.atan2(0.35, l) * 180 / Math.PI;
},
};
const THRESHOLDS = { PET_RADIUS, PET_MAX_SPEED, PET_TIME, PURR_LINGER, STARTLE_SPEED,
STARTLE_RADIUS, STARTLE_COOLDOWN, WARY_TIME, GRAVITY, THROW, COOLDOWN, REVEAL_KEEP };
return { create, reveal, EDGES, LOOP, ACTIONS, THRESHOLDS, math, ui };
})();
if (typeof module === 'object' && module.exports) module.exports = VrPetCore; // node (tests)
+39
View File
@@ -0,0 +1,39 @@
# steam-frame-nix-pet-icon (package.nix prepends runtime, state, icons, hicolor): points
# <runtime>/steam-frame-nix/vr-pet/icon.png (tmpfs), where the "+" menu entry's icon
# (hicolor/256x256/apps/vr-pet.png) links, to icons/<model>.png of the pet's
# current model: the "model" of the patch's state file, else (no file, no
# model, a model without an icon, e.g. a removed one) icons/default.txt's.
# The link is replaced atomically (temp link + rename, under a lock) and only
# when its target changes; then hicolor's mtime is bumped, so a running Steam
# rescans its icons (GTK rereads a theme only when its directory changed).
dir=$runtime/steam-frame-nix/vr-pet
link=$dir/icon.png
log() { echo "steam-frame-nix-pet-icon: $*" >&2; }
if [[ ! -d $runtime ]]; then
log "$runtime doesn't exist (outer session not running?); skipped"
exit 0
fi
default=$(<"$icons/default.txt")
model=''
if [[ -f $state ]]; then
model=$(jq -r '.model? // empty | strings' "$state" 2>/dev/null) || model=''
fi
if [[ -z $model ]]; then
model=$default
elif [[ ! $model =~ ^[A-Za-z0-9_-]+$ || ! -f $icons/$model.png ]]; then
log "no icon for model ${model@Q}; using the default's ($default)"
model=$default
fi
target=$icons/$model.png
mkdir -p "$dir"
exec 9>"$dir/.lock"
flock 9
[[ -L $link && $(readlink "$link") == "$target" ]] && exit 0
rm -f "$dir"/.icon.tmp.*
ln -sfn "$target" "$dir/.icon.tmp.$$"
mv -fT "$dir/.icon.tmp.$$" "$link"
log "$model: $link -> $target"
if [[ -d $hicolor ]]; then touch "$hicolor"; fi
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Build the VR pet's model catalog: one directory per model and the index
(models.json) that the SteamVR patch embeds (package.nix `catalog`).
usage: index.py spec.json base-dir out-dir
index.py --icons spec.json catalog-dir icons-dir [px]
(the "+" menu icons: icons-dir/<id>.png, px square (256), the
same pictures as the thumbs; icons-dir/default.txt: the default's
id; package.nix `icons`)
spec.json (package.nix: every models/<id>/model.json and steamFrame.pet.extraModels,
their files resolved to store paths; a gltf model's "baked": bake_gltf.py's
output):
{ "default": id | null, "models": [ { "id", "name", "kind", ... }, ... ] }
The kinds and fields: docs/pet-models.md. An invalid spec fails the build
with a message naming the model and the field (validate()).
The coats (recolor, texture) get out-dir/<id>/ with their own cat.png and
cat.mtl and copies of the base's frames (*.obj, frames.json): an OBJ's
`mtllib cat.mtl` is looked up next to the OBJ, so the same frames load with
that model's texture. Copies, not symlinks: whether SteamVR's loader
resolves a symlinked OBJ's mtllib next to the link or next to its target
could not be checked (the headset in standby loads nothing), and a copy
works either way; the store's auto-optimise hardlinks the identical copies,
so they cost no space. An animal's directory is its bake (out-dir/<id> a
link to it, for the preview); its frames are a frame set of their own.
out-dir/models.json:
{ "default": id, "order": [ids],
"frameSets": { name: <frames.json> },
"models": { id: { "name", "kind", "group", "dir", "sub", "frames", "private", "thumb" } } }
dir: the model's directory (absolute); sub: its name under out-dir (null
for the base); frames: its frame set (the base's id for the cat and its
coats, the animal's id for an animal); group: "coats" or "animals" (the
menu puts a line between groups); private: the spec's "private" (personal
use only: a model of a character someone else owns, whose "rights" says
so; extraModels only, never a built-in model); thumb: a
48 px render of the sitting animal (thumb.py; standing without a sit; an
animal's "thumbFrame", e.g. "idle_0", when that shows it better) or the
spec's own "thumb", as a data: URI.
"""
import base64
import io
import json
import os
import re
import shutil
import sys
import tempfile
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import skin # noqa: E402
import thumb # noqa: E402
THUMB_PX = 48
ICON_PX = 256 # the "+" menu icons (icons())
KINDS = ('base', 'recolor', 'texture', 'gltf')
HEX = re.compile(r'^#[0-9a-fA-F]{6}$')
class SpecError(SystemExit):
pass
def validate(specs, default=None):
"""The specs (list) and the chosen default: a list of problems, each
naming the model and the field ([] when fine)."""
errs = []
ids = [m.get('id') for m in specs]
for i in sorted({i for i in ids if ids.count(i) > 1}):
errs.append(f'{i}: two models with this id')
for m in specs:
mid = m.get('id')
e = lambda msg: errs.append(f'{mid}: {msg}')
if not isinstance(mid, str) or not re.match(r'^[A-Za-z0-9_-]+$', mid or ''):
e('the id (folder name) must be letters, digits, - or _')
if not isinstance(m.get('name'), str) or not m['name'].strip():
e('"name" (the menu\'s text) is missing')
kind = m.get('kind')
if kind not in KINDS:
e(f'"kind" must be one of {", ".join(KINDS)} (is {kind!r})')
if 'order' in m and not isinstance(m['order'], (int, float)):
e('"order" must be a number')
if 'default' in m and not isinstance(m['default'], bool):
e('"default" must be true or false')
if 'private' in m and not isinstance(m['private'], bool):
e('"private" must be true or false')
if 'rights' in m and m.get('private') is not True:
e('"rights" (a character someone else owns) needs "private": true (personal use only, never published)')
if kind == 'recolor':
fur = m.get('fur')
if not (isinstance(fur, list) and len(fur) == 2 and all(isinstance(c, str) and HEX.match(c) for c in fur)):
e('"fur" must be two colours ["#rrggbb" (dark), "#rrggbb" (light)]')
for w in m.get('white', []):
if w not in skin.PATTERNS:
e(f'"white": unknown marking {w!r} (known: {", ".join(skin.PATTERNS)})')
if kind == 'texture' and not (isinstance(m.get('png'), str) and os.path.isfile(m['png'])):
e('"png" must be a PNG file (its path in the model\'s folder)')
if kind == 'gltf':
b = m.get('baked')
if not (isinstance(b, str) and os.path.isfile(os.path.join(b, 'frames.json'))):
e('no baked frames (bake_gltf.py)')
if not isinstance(m.get('height'), (int, float)) or not 0.05 <= m['height'] <= 2:
e('"height" (m, standing) must be a number between 0.05 and 2')
tf = m.get('thumbFrame')
if tf is not None and not (isinstance(b, str) and isinstance(tf, str) and os.path.isfile(os.path.join(b, tf + '.obj'))):
e(f'"thumbFrame": no baked frame {tf!r} (a clip and its frame number, e.g. "idle_0")')
bases = [m['id'] for m in specs if m.get('kind') == 'base']
if len(bases) > 1:
errs.append(f'more than one kind "base": {", ".join(bases)}')
if any(m.get('kind') in ('recolor', 'texture') for m in specs) and not bases:
errs.append('coats (kind recolor / texture) need the base model (kind "base")')
flagged = [m['id'] for m in specs if m.get('default') is True]
if default is None:
if len(flagged) != 1:
errs.append(f'exactly one model needs "default": true (found {len(flagged)}: {", ".join(flagged) or "none"}; '
'or set steamFrame.pet.defaultModel)')
elif default not in ids:
errs.append(f'steamFrame.pet.defaultModel {default!r} is not a model ({", ".join(i for i in ids if i)})')
return errs
def thumb_png(m, d, px):
"""PNG bytes of model m's picture, px square: the spec's own "thumb"
(scaled, centred) or thumb.py's render of its sitting frame in d (its
"thumbFrame", else sit_0, else idle_0)."""
if m.get('thumb'):
im = Image.open(m['thumb']).convert('RGBA')
im.thumbnail((px, px), Image.LANCZOS)
sq = Image.new('RGBA', (px, px), (0, 0, 0, 0))
sq.alpha_composite(im, ((px - im.width) // 2, (px - im.height) // 2))
buf = io.BytesIO()
sq.save(buf, 'PNG', optimize=True)
return buf.getvalue()
pose = next(f for f in (m.get('thumbFrame', '') + '.obj', 'sit_0.obj', 'idle_0.obj') if os.path.isfile(os.path.join(d, f)))
with tempfile.TemporaryDirectory() as t:
out = os.path.join(t, 't.png')
thumb.render(os.path.join(d, pose), os.path.join(d, 'cat.png'), out, px)
return open(out, 'rb').read()
def thumb_uri(m, d):
return 'data:image/png;base64,' + base64.b64encode(thumb_png(m, d, THUMB_PX)).decode()
def icons(spec_path, catalog, out, px=ICON_PX):
"""The "+" menu icons: out/<id>.png (px square, as the thumbs) for every
model of the catalog built from spec_path, and out/default.txt (the
default model's id)."""
spec = json.load(open(spec_path))
index = json.load(open(os.path.join(catalog, 'models.json')))
os.makedirs(out, exist_ok=True)
for m in spec['models']:
with open(os.path.join(out, m['id'] + '.png'), 'wb') as f:
f.write(thumb_png(m, index['models'][m['id']]['dir'], px))
with open(os.path.join(out, 'default.txt'), 'w') as f:
f.write(index['default'] + '\n')
def main(spec_path, base, out):
spec = json.load(open(spec_path))
errs = validate(spec['models'], spec.get('default'))
if errs:
raise SpecError('invalid VR pet model spec (docs/pet-models.md):\n ' + '\n '.join(errs))
default = spec.get('default') or next(m['id'] for m in spec['models'] if m.get('default') is True)
os.makedirs(out, exist_ok=True)
base_id = next((m['id'] for m in spec['models'] if m['kind'] == 'base'), None)
frame_sets = {}
if base_id:
frame_sets[base_id] = json.load(open(os.path.join(base, 'frames.json')))
shared = sorted(f for f in os.listdir(base) if f.endswith('.obj')) + ['frames.json']
models, order = {}, []
for m in sorted(spec['models'], key=lambda m: (m.get('order', 100), m['name'])):
mid, kind = m['id'], m['kind']
if kind == 'base':
d, sub, frames = base, None, base_id
elif kind == 'gltf':
d, sub, frames = m['baked'], mid, mid
os.symlink(d, os.path.join(out, mid))
frame_sets[mid] = json.load(open(os.path.join(d, 'frames.json')))
else:
d, sub, frames = os.path.join(out, mid), mid, base_id
os.makedirs(d)
png = os.path.join(d, 'cat.png')
if kind == 'recolor':
img, _ = skin.recolor(Image.open(os.path.join(base, 'cat.png')), m)
img.save(png, optimize=True)
else:
Image.open(m['png']).convert('RGB').save(png, optimize=True)
shutil.copyfile(os.path.join(base, 'cat.mtl'), os.path.join(d, 'cat.mtl'))
for f in shared:
shutil.copyfile(os.path.join(base, f), os.path.join(d, f))
models[mid] = {'name': m['name'], 'kind': kind, 'group': m.get('group', 'animals' if kind == 'gltf' else 'coats'),
'dir': d, 'sub': sub, 'frames': frames, 'private': m.get('private', False),
'thumb': thumb_uri(m, d)}
order.append(mid)
json.dump({'default': default, 'order': order, 'frameSets': frame_sets, 'models': models},
open(os.path.join(out, 'models.json'), 'w'), separators=(',', ':'))
if __name__ == '__main__':
if sys.argv[1] == '--icons':
icons(*sys.argv[2:5], *[int(a) for a in sys.argv[5:6]])
else:
main(*sys.argv[1:4])
+1
View File
@@ -0,0 +1 @@
{ "name": "Blue", "kind": "recolor", "order": 2, "fur": ["#3a4250", "#b4bfcd"] }
+1
View File
@@ -0,0 +1 @@
{ "name": "Cream", "kind": "recolor", "order": 3, "fur": ["#9c7650", "#fbf0dc"] }
+39
View File
@@ -0,0 +1,39 @@
{
"name": "Dachshund",
"extends": "shiba",
"order": 12,
"credit": "Dachshund: the Shiba Inu from the Ultimate Animated Animal Pack by Quaternius, CC0 1.0 (https://quaternius.com/packs/ultimateanimatedanimals.html), re-proportioned and recoloured",
"height": 0.27,
"colors": { "Main": "#3a2b24", "Main_Light": "#c07236" },
"thumbFrame": "idle_0",
"proportions": {
"Back": { "scale": [1, 1.25, 1] },
"Torso": { "scale": [1, 1.45, 1] },
"Torso2": { "scale": [1.05, 1.45, 1.12] },
"Torso3": { "scale": [1, 1.25, 1.05] },
"FrontUpperLeg.L": { "scale": [1, 0.42, 1] }, "FrontUpperLeg.R": { "scale": [1, 0.42, 1] },
"FrontLowerLeg.L": { "scale": [1, 0.45, 1] }, "FrontLowerLeg.R": { "scale": [1, 0.45, 1] },
"BackUpperLeg.L": { "scale": [1, 0.46, 1] }, "BackUpperLeg.R": { "scale": [1, 0.46, 1] },
"BackLowerLeg.L": { "scale": [1, 0.5, 1] }, "BackLowerLeg.R": { "scale": [1, 0.5, 1] },
"Head": { "scale": [1, 1.3, 1] },
"Ear1.L": { "rot": [0, 0, -20] }, "Ear1.R": { "rot": [0, 0, 20] },
"Ear2.L": { "scale": [1.25, 1.45, 1.25], "aim": [0.5, -0.85, 0.15] },
"Ear2.R": { "scale": [1.25, 1.45, 1.25], "aim": [-0.5, -0.85, 0.15] },
"Tail1": { "aim": [0, 0.35, -1] },
"Tail2": { "aim": [0, 0.2, -1], "scale": [0.8, 2.2, 0.8] },
"Tail3": { "aim": [0, 0.1, -1], "scale": [0.45, 0.35, 0.45] }
},
"keys": {
"sit": {
"from": "Idle",
"rot": { "Back": [-17, 0, 0], "Neck3": [12, 0, 0], "Tail1": [30, 0, 0] },
"aim": {
"FrontUpperLeg.L": [0.03, -1, 0.05], "FrontUpperLeg.R": [-0.03, -1, 0.05],
"FrontLowerLeg.L": [0, -1, 0.08], "FrontLowerLeg.R": [0, -1, 0.08],
"BackLeg.L": [0.2, -0.3, 1], "BackLeg.R": [-0.2, -0.3, 1],
"BackUpperLeg.L": [0.05, -0.35, -1], "BackUpperLeg.R": [-0.05, -0.35, -1],
"BackLowerLeg.L": [0.05, -0.2, 1], "BackLowerLeg.R": [-0.05, -0.2, 1]
}
}
}
}
+101
View File
@@ -0,0 +1,101 @@
{
"name": "Fox",
"kind": "gltf",
"order": 11,
"credit": "Fox from the Ultimate Animated Animal Pack by Quaternius, CC0 1.0 (https://quaternius.com/packs/ultimateanimatedanimals.html, mirrored at https://poly.pizza/m/Bc97C66HKi)",
"src": {
"url": "https://static.poly.pizza/e18e86df-1692-48d8-ac6e-1e25ab4ad574.glb",
"hash": "sha256-mRd3HhQUxGVXsBHLqu5t6CqCdgUAgiejZMOGLwvmLdw="
},
"height": 0.32,
"feet": ["FrontLowerLeg.L_end", "FrontLowerLeg.R_end", "BackLowerLeg.L_end", "BackLowerLeg.R_end"],
"follow": {
"IKFrontLeg.L": "FrontLowerLeg.L", "IKFrontLeg.R": "FrontLowerLeg.R",
"IKBackLeg.L": "BackLowerLeg.L", "IKBackLeg.R": "BackLowerLeg.R"
},
"zones": {
"scruff": "Neck2", "head": "Head", "back": "Torso2",
"chin": { "bone": "Head", "at": "joint" }, "tailBase": { "bone": "Tail1", "at": "joint" }
},
"keys": {
"stand": { "from": "Idle" },
"sit": {
"from": "Idle",
"rot": { "Back": [-38, 0, 0], "Neck1": [6, 0, 0], "Neck2": [2, 0, 0], "Neck3": [24, 0, 0], "Tail1": [55, 30, 0], "Tail3": [0, 22, 0], "Tail5": [0, 25, 0], "Tail7": [0, 25, 0] },
"aim": {
"FrontUpperLeg.L": [0.03, -1, 0.05], "FrontUpperLeg.R": [-0.03, -1, 0.05],
"FrontLowerLeg.L": [0, -1, 0.08], "FrontLowerLeg.R": [0, -1, 0.08],
"BackLeg.L": [0.2, -0.3, 1], "BackLeg.R": [-0.2, -0.3, 1],
"BackUpperLeg.L": [0.05, -0.35, -1], "BackUpperLeg.R": [-0.05, -0.35, -1],
"BackLowerLeg.L": [0.05, -0.2, 1], "BackLowerLeg.R": [-0.05, -0.2, 1]
}
},
"lie": {
"from": "Idle",
"rot": { "Neck1": [8, 0, 0], "Tail1": [20, 20, 0], "Tail3": [0, 22, 0], "Tail5": [0, 22, 0], "Tail7": [0, 22, 0] },
"aim": {
"FrontUpperLeg.L": [0.05, -1, -0.45], "FrontUpperLeg.R": [-0.05, -1, -0.45],
"FrontLowerLeg.L": [0.02, -0.12, 1], "FrontLowerLeg.R": [-0.02, -0.12, 1],
"BackLeg.L": [0.55, -0.55, 0.8], "BackLeg.R": [-0.55, -0.55, 0.8],
"BackUpperLeg.L": [0.15, -0.35, -1], "BackUpperLeg.R": [-0.15, -0.35, -1],
"BackLowerLeg.L": [0.05, -0.1, 1], "BackLowerLeg.R": [-0.05, -0.1, 1]
}
},
"sleep": {
"from": "Idle",
"rot": { "Neck1": [32, 0, 0], "Neck2": [24, -4, -2], "Neck3": [-26, 10, 12], "Ear2.L": [20, 0, 0], "Ear2.R": [20, 0, 0],
"Tail1": [20, 35, 0], "Tail3": [0, 35, 0], "Tail5": [0, 35, 0], "Tail7": [0, 30, 0] },
"aim": {
"FrontUpperLeg.L": [0.05, -1, -0.45], "FrontUpperLeg.R": [-0.05, -1, -0.45],
"FrontLowerLeg.L": [0.02, -0.12, 1], "FrontLowerLeg.R": [-0.02, -0.12, 1],
"BackLeg.L": [0.55, -0.55, 0.8], "BackLeg.R": [-0.55, -0.55, 0.8],
"BackUpperLeg.L": [0.15, -0.35, -1], "BackUpperLeg.R": [-0.15, -0.35, -1],
"BackLowerLeg.L": [0.05, -0.1, 1], "BackLowerLeg.R": [-0.05, -0.1, 1]
}
},
"dangle": {
"from": "Idle",
"rot": { "Body": [-62, 0, 0], "Neck1": [14, 0, 0], "Neck2": [4, 0, 0], "Neck3": [32, 0, 0], "Tail1": [40, 0, 0] },
"aim": {
"FrontUpperLeg.L": [0.05, -1, 0.15], "FrontUpperLeg.R": [-0.05, -1, 0.15],
"FrontLowerLeg.L": [0.02, -1, 0.2], "FrontLowerLeg.R": [-0.02, -1, 0.2],
"BackLeg.L": [0.08, -1, 0.3], "BackLeg.R": [-0.08, -1, 0.3],
"BackUpperLeg.L": [0.03, -1, -0.1], "BackUpperLeg.R": [-0.03, -1, -0.1],
"BackLowerLeg.L": [0, -1, 0.25], "BackLowerLeg.R": [0, -1, 0.25]
}
}
},
"clips": {
"idle": { "clip": "Idle", "fps": 12 },
"walk": "Walk",
"walkstart": { "blend": ["stand", "walk"], "seconds": 0.3 },
"sit": { "key": "sit", "seconds": 3, "layers": [{ "bone": "Torso2", "scale": [0.025, 0, 0.025], "period": 3 }] },
"sitdown": { "blend": ["stand", "sit"], "seconds": 0.9 },
"standup": { "reverse": "sitdown" },
"lie": { "key": "lie", "seconds": 3, "layers": [{ "bone": "Torso2", "scale": [0.03, 0, 0.03], "period": 3 }] },
"liedown": { "blend": ["stand", "lie"], "seconds": 1.1 },
"sleep": { "key": "sleep", "seconds": 4, "fps": 8, "layers": [{ "bone": "Torso2", "scale": [0.04, 0, 0.04], "period": 4 }] },
"curl": { "blend": ["lie", "sleep"], "seconds": 1.2 },
"purr": { "key": "stand", "seconds": 1.2, "fps": 24, "layers": [
{ "bone": "Tail1", "rot": [0, 22, 0], "period": 0.4 }, { "bone": "Tail2", "rot": [0, 14, 0], "period": 0.4, "phase": 0.9 },
{ "bone": "Neck2", "rot": [0, 0, 3], "period": 1.2 }, { "bone": "Neck3", "rot": [0, 0, 7], "period": 1.2 }] },
"sitpurr": { "key": "sit", "seconds": 1.2, "fps": 24, "layers": [
{ "bone": "Tail1", "rot": [0, 22, 0], "period": 0.4 }, { "bone": "Tail2", "rot": [0, 14, 0], "period": 0.4, "phase": 0.9 },
{ "bone": "Neck2", "rot": [0, 0, 3], "period": 1.2 }, { "bone": "Neck3", "rot": [0, 0, 7], "period": 1.2 }] },
"liepurr": { "key": "lie", "seconds": 1.2, "fps": 24, "layers": [
{ "bone": "Tail1", "rot": [0, 22, 0], "period": 0.4 }, { "bone": "Tail2", "rot": [0, 14, 0], "period": 0.4, "phase": 0.9 },
{ "bone": "Neck2", "rot": [0, 0, 3], "period": 1.2 }, { "bone": "Neck3", "rot": [0, 0, 7], "period": 1.2 }] },
"dangle": { "key": "dangle", "hang": true, "seconds": 2, "layers": [{ "bone": "Body", "rot": [0, 0, 4], "period": 2 }] },
"fall": { "clip": "Jump_ToIdle", "hold": 0.5 },
"land": { "clip": "Jump_ToIdle", "start": 0.5 },
"startle": { "clip": "Idle_HitReact_Left", "loop": false },
"sniff": { "clip": "Eating", "fps": 12, "loop": false },
"look": { "clip": "Idle_2_HeadLow", "fps": 12, "loop": false }
},
"species": {
"actions": {
"sniff": { "pose": "stand", "weight": 1.2, "cooldown": 45 },
"look": { "pose": "stand", "weight": 1, "cooldown": 30 }
}
}
}
+1
View File
@@ -0,0 +1 @@
{ "name": "Ginger", "kind": "base", "order": 0, "default": true }
+100
View File
@@ -0,0 +1,100 @@
{
"name": "Shiba Inu",
"kind": "gltf",
"order": 10,
"credit": "Shiba Inu from the Ultimate Animated Animal Pack by Quaternius, CC0 1.0 (https://quaternius.com/packs/ultimateanimatedanimals.html, mirrored at https://poly.pizza/m/y4wdQpg767)",
"src": {
"url": "https://static.poly.pizza/ba6d0ee3-bcc0-4ef0-9d3c-a3e245b41c77.glb",
"hash": "sha256-nL1PHhL4UCQSbqtqLFcGwJkZbfGhDbdObItnusP/9Uo="
},
"height": 0.35,
"feet": ["FrontLowerLeg.L_end", "FrontLowerLeg.R_end", "BackLowerLeg.L_end", "BackLowerLeg.R_end"],
"follow": {
"IKFrontLeg.L": "FrontLowerLeg.L", "IKFrontLeg.R": "FrontLowerLeg.R",
"IKBackLeg.L": "BackLowerLeg.L", "IKBackLeg.R": "BackLowerLeg.R"
},
"zones": {
"scruff": "Neck2", "head": "Head", "back": "Torso2",
"chin": { "bone": "Head", "at": "joint" }, "tailBase": { "bone": "Tail1", "at": "joint" }
},
"keys": {
"stand": { "from": "Idle" },
"sit": {
"from": "Idle",
"rot": { "Back": [-38, 0, 0], "Neck1": [8, 0, 0], "Neck2": [6, 0, 0], "Neck3": [18, 0, 0], "Tail1": [30, 0, 0] },
"aim": {
"FrontUpperLeg.L": [0.03, -1, 0.05], "FrontUpperLeg.R": [-0.03, -1, 0.05],
"FrontLowerLeg.L": [0, -1, 0.08], "FrontLowerLeg.R": [0, -1, 0.08],
"BackLeg.L": [0.2, -0.3, 1], "BackLeg.R": [-0.2, -0.3, 1],
"BackUpperLeg.L": [0.05, -0.35, -1], "BackUpperLeg.R": [-0.05, -0.35, -1],
"BackLowerLeg.L": [0.05, -0.2, 1], "BackLowerLeg.R": [-0.05, -0.2, 1]
}
},
"lie": {
"from": "Idle",
"rot": { "Neck1": [8, 0, 0] },
"aim": {
"FrontUpperLeg.L": [0.05, -1, -0.45], "FrontUpperLeg.R": [-0.05, -1, -0.45],
"FrontLowerLeg.L": [0.02, -0.12, 1], "FrontLowerLeg.R": [-0.02, -0.12, 1],
"BackLeg.L": [0.55, -0.55, 0.8], "BackLeg.R": [-0.55, -0.55, 0.8],
"BackUpperLeg.L": [0.15, -0.35, -1], "BackUpperLeg.R": [-0.15, -0.35, -1],
"BackLowerLeg.L": [0.05, -0.1, 1], "BackLowerLeg.R": [-0.05, -0.1, 1]
}
},
"sleep": {
"from": "Idle",
"rot": { "Neck1": [32, 0, 0], "Neck2": [24, -4, -2], "Neck3": [-26, 10, 12], "Ear2.L": [20, 0, 0], "Ear2.R": [20, 0, 0] },
"aim": {
"FrontUpperLeg.L": [0.05, -1, -0.45], "FrontUpperLeg.R": [-0.05, -1, -0.45],
"FrontLowerLeg.L": [0.02, -0.12, 1], "FrontLowerLeg.R": [-0.02, -0.12, 1],
"BackLeg.L": [0.55, -0.55, 0.8], "BackLeg.R": [-0.55, -0.55, 0.8],
"BackUpperLeg.L": [0.15, -0.35, -1], "BackUpperLeg.R": [-0.15, -0.35, -1],
"BackLowerLeg.L": [0.05, -0.1, 1], "BackLowerLeg.R": [-0.05, -0.1, 1]
}
},
"dangle": {
"from": "Idle",
"rot": { "Body": [-62, 0, 0], "Neck1": [16, 0, 0], "Neck2": [8, 0, 0], "Neck3": [26, 0, 0], "Tail1": [40, 0, 0] },
"aim": {
"FrontUpperLeg.L": [0.05, -1, 0.15], "FrontUpperLeg.R": [-0.05, -1, 0.15],
"FrontLowerLeg.L": [0.02, -1, 0.2], "FrontLowerLeg.R": [-0.02, -1, 0.2],
"BackLeg.L": [0.08, -1, 0.3], "BackLeg.R": [-0.08, -1, 0.3],
"BackUpperLeg.L": [0.03, -1, -0.1], "BackUpperLeg.R": [-0.03, -1, -0.1],
"BackLowerLeg.L": [0, -1, 0.25], "BackLowerLeg.R": [0, -1, 0.25]
}
}
},
"clips": {
"idle": { "clip": "Idle", "fps": 12 },
"walk": "Walk",
"walkstart": { "blend": ["stand", "walk"], "seconds": 0.3 },
"sit": { "key": "sit", "seconds": 3, "layers": [{ "bone": "Torso2", "scale": [0.025, 0, 0.025], "period": 3 }] },
"sitdown": { "blend": ["stand", "sit"], "seconds": 0.9 },
"standup": { "reverse": "sitdown" },
"lie": { "key": "lie", "seconds": 3, "layers": [{ "bone": "Torso2", "scale": [0.03, 0, 0.03], "period": 3 }] },
"liedown": { "blend": ["stand", "lie"], "seconds": 1.1 },
"sleep": { "key": "sleep", "seconds": 4, "fps": 8, "layers": [{ "bone": "Torso2", "scale": [0.04, 0, 0.04], "period": 4 }] },
"curl": { "blend": ["lie", "sleep"], "seconds": 1.2 },
"purr": { "key": "stand", "seconds": 1.2, "fps": 24, "layers": [
{ "bone": "Tail1", "rot": [0, 22, 0], "period": 0.4 }, { "bone": "Tail2", "rot": [0, 14, 0], "period": 0.4, "phase": 0.9 },
{ "bone": "Neck2", "rot": [0, 0, 3], "period": 1.2 }, { "bone": "Neck3", "rot": [0, 0, 7], "period": 1.2 }] },
"sitpurr": { "key": "sit", "seconds": 1.2, "fps": 24, "layers": [
{ "bone": "Tail1", "rot": [0, 22, 0], "period": 0.4 }, { "bone": "Tail2", "rot": [0, 14, 0], "period": 0.4, "phase": 0.9 },
{ "bone": "Neck2", "rot": [0, 0, 3], "period": 1.2 }, { "bone": "Neck3", "rot": [0, 0, 7], "period": 1.2 }] },
"liepurr": { "key": "lie", "seconds": 1.2, "fps": 24, "layers": [
{ "bone": "Tail1", "rot": [0, 22, 0], "period": 0.4 }, { "bone": "Tail2", "rot": [0, 14, 0], "period": 0.4, "phase": 0.9 },
{ "bone": "Neck2", "rot": [0, 0, 3], "period": 1.2 }, { "bone": "Neck3", "rot": [0, 0, 7], "period": 1.2 }] },
"dangle": { "key": "dangle", "hang": true, "seconds": 2, "layers": [{ "bone": "Body", "rot": [0, 0, 4], "period": 2 }] },
"fall": { "clip": "Jump_ToIdle", "hold": 0.5 },
"land": { "clip": "Jump_ToIdle", "start": 0.5 },
"startle": { "clip": "Idle_HitReact_Left", "loop": false },
"sniff": { "clip": "Eating", "fps": 12, "loop": false },
"look": { "clip": "Idle_2_HeadLow", "fps": 12, "loop": false }
},
"species": {
"actions": {
"sniff": { "pose": "stand", "weight": 1.2, "cooldown": 45 },
"look": { "pose": "stand", "weight": 1, "cooldown": 30 }
}
}
}
+1
View File
@@ -0,0 +1 @@
{ "name": "Snow", "kind": "recolor", "order": 4, "fur": ["#b4b6be", "#ffffff"] }
+1
View File
@@ -0,0 +1 @@
{ "name": "Tuxedo", "kind": "recolor", "order": 1, "fur": ["#101014", "#4a4a52"], "white": ["chest", "muzzle", "paws"] }
+295
View File
@@ -0,0 +1,295 @@
# VR pet build: the cat's baked frames (bake.py, glTF helpers in rig.py),
# the model catalog (index.py: models/<id>/ and extraModels; coats by
# skin.py, animals by bake_gltf.py, thumbnails by thumb.py), the SteamVR
# patch (core.js + systemui.js), the `vr-pet` CLI (cli.mjs), the "+" menu
# entry and its icons (steam-frame-nix-pet-icon: icon.sh), the desktop preview (preview/)
# and the tests (tests/, the flake check pet). System-independent
# inputs (pinned fetchurl, Python + numpy/pillow), so it also builds on
# x86_64. Credits and licences: README.md, "Credits".
{ pkgs, lib ? pkgs.lib
, height ? 0.30 # m, standing height of the cat (ears)
, fps ? 24 # baked frames per second, and core ticks per second
, catOptions ? { } # core.js options (DEFAULTS there)
, mount ? "dynamic" # systemui.js: "dynamic" | "all"
, finders ? null # steam-frame-nix's lib/finders.js and lib/hooks.js
, hooks ? null # (for the SteamVR patch)
, extraModels ? { } # id -> a model folder (as models/<id>/) or a spec (attrset; files as paths)
, defaultModel ? null # the model shown first (null: the spec with "default": true)
, runtimeDir ? "/run/user/1000" # steam-frame-nix-pet-icon: its link's tmpfs (<runtimeDir>/steam-frame-nix/vr-pet)
, stateFile ? "/nonexistent" # steam-frame-nix-pet-icon: the patch's state file (its "model")
, hicolor ? "/nonexistent" # steam-frame-nix-pet-icon: the hicolor dir whose mtime it bumps
}:
let
toonCat = pkgs.fetchurl {
name = "toon_cat_free.glb"; # Omabuarts, CC-BY 4.0
url = "https://raw.githubusercontent.com/DevTakao/threejs-cat/97b71575ebd84ff32c50785563bb6b97a9b87012/assets/models/toon_cat_free.glb";
hash = "sha256-92KR9dN9YPxY6ABqERGllglYVF/E2VkpiGgq06mq74E=";
};
tuxedo = "https://raw.githubusercontent.com/xialin-he/xialin-he.github.io/054a1f088cdd19544a8fd5d6d5eaad11e8b0d08e/public/tuxedo_cat_animated_2.0";
tuxedoGltf = pkgs.fetchurl { # DreamNoms, CC-BY 4.0
name = "tuxedo-scene.gltf";
url = "${tuxedo}/scene.gltf";
hash = "sha256-BLwRrWZL65yEKE+4pOYw3lrun8ZUrw5HqeM3ol96HoI=";
};
tuxedoBin = pkgs.fetchurl {
name = "tuxedo-scene.bin";
url = "${tuxedo}/scene.bin";
hash = "sha256-iiRZRV2GxilBDUvz6tUAzT3z5+Vqr/AkzQ6wFZFfTqw=";
};
threeTgz = pkgs.fetchurl { # MIT
url = "https://registry.npmjs.org/three/-/three-0.170.0.tgz";
hash = "sha256-SmCKNV3KunLg5Tg83IFDA/W2BgtDwjjN9pMtzraZI40=";
};
python = pkgs.python3.withPackages (p: [ p.numpy p.pillow ]);
# Copies these files of this folder into src/ (a step's scripts import each
# other; only the files it runs, so other edits don't rebuild it).
srcOf = names: "mkdir -p src\n" + lib.concatMapStrings (n: "cp ${./. + "/${n}"} src/${n}\n") names;
models = pkgs.runCommand "vr-pet-models" { nativeBuildInputs = [ python ]; } ''
mkdir tuxedo
cp ${tuxedoGltf} tuxedo/scene.gltf
cp ${tuxedoBin} tuxedo/scene.bin
${srcOf [ "bake.py" "rig.py" ]}
python3 src/bake.py ${toonCat} tuxedo/scene.gltf $out ${toString height} ${toString fps}
cp ${credits} $out/CREDITS.md
'';
# Next to the baked frames (derived from CC-BY 4.0 models); the full
# attributions: README.md, "Credits".
credits = pkgs.writeText "CREDITS.md" ''
Based on "Toon Cat FREE" by Omabuarts Studio
(https://sketchfab.com/3d-models/toon-cat-free-b2bd1ee7858444bda366110a2d960386)
and "Tuxedo Cat Animated 2.0" by DreamNoms
(https://sketchfab.com/3d-models/tuxedo-cat-animated-20-783fcb78b55b4394a212c2b6392e1113),
licensed under CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/);
modified: retargeted and hand-keyed animations, baked per frame.
Credits: https://github.com/lhns/steam-frame-nix#credits
'';
# The model catalog: one folder per model, models/<id>/model.json (and the
# files it names), found here by readDir (no list of ids anywhere), plus
# extraModels (a folder like those, or a spec as an attrset); built by
# index.py into one directory per model and models.json (the index the
# patch embeds). The kinds and fields: docs/pet-models.md. An animal's bake
# is a derivation of its own (its spec, its glTF, bake_gltf.py, rig.py), so
# editing one model's spec rebakes only that model (and those extending it).
specError = id: msg: throw "vr-pet model ${id}: ${msg} (see docs/pet-models.md)";
# id -> folder -> { id, spec, file: the spec's store path, at: a file of the folder }
readModel = id: dir:
let f = dir + "/model.json"; in
if !(builtins.pathExists f) then specError id "no model.json in ${toString dir}"
else { inherit id; spec = builtins.fromJSON (builtins.readFile f); file = f; at = name: dir + "/${name}"; };
discover = root: lib.mapAttrsToList (id: _: readModel id (root + "/${id}"))
(lib.filterAttrs (_: t: t == "directory") (builtins.readDir root));
# The built-in models (models/<id>/) are published with steam-frame-nix:
# one whose spec says "private": true (a character someone else owns,
# personal use only) fails evaluation. Private models go into extraModels,
# from the user's own configuration.
builtinOf = root: let
ms = discover root;
private = map (m: m.id) (lib.filter (m: m.spec.private or false == true) ms);
in if private != [ ] then throw ("vr-pet: built-in model(s) ${lib.concatStringsSep ", " private} say \"private\": true;"
+ " private models belong in steamFrame.pet.extraModels of your own configuration,"
+ " never in modules/pet/models/ (docs/pet-models.md, \"Private models\")")
else ms;
extra = lib.mapAttrsToList (id: m:
if builtins.isPath m || builtins.isString m then readModel id m
else let spec = { kind = "texture"; order = 100; } // m; in
{ inherit id spec; file = pkgs.writeText "${id}-model.json" (builtins.toJSON spec); at = p: p; }) extraModels;
# "extends" (docs/pet-models.md): the other spec with this one's fields on
# top, an object field entry by entry; files looked up here, then there.
extend = all: m:
if !(m.spec ? extends) then m else
let
b = extend all (lib.findFirst (x: x.id == m.spec.extends)
(specError m.id "\"extends\": no model ${builtins.toJSON m.spec.extends}") all);
over = n: v: if builtins.isAttrs v && builtins.isAttrs (b.spec.${n} or null) then b.spec.${n} // v else v;
spec = builtins.removeAttrs (builtins.removeAttrs b.spec [ "default" "thumb" ] // lib.mapAttrs over m.spec) [ "extends" ];
in {
inherit (m) id;
inherit spec;
file = pkgs.writeText "${m.id}-model.json" (builtins.toJSON spec);
at = name: if builtins.pathExists (m.at name) then m.at name else b.at name;
};
extendAll = all: map (extend all) all;
# A spec with its files as store paths (index.py checks the rest).
resolve = { id, spec, file, at }:
let kind = spec.kind or (specError id "no \"kind\""); in
spec // { inherit id; } // (
if kind == "texture" then { png = "${at (spec.png or (specError id "kind texture needs \"png\""))}"; }
else if kind == "gltf" then { baked = "${bakeGltf id file (spec.src or (specError id "kind gltf needs \"src\"")) at}"; }
else { }) // lib.optionalAttrs (spec ? thumb) { thumb = "${at spec.thumb}"; };
# An animal's frames: bake_gltf.py on its glTF (gltfOf).
bakeGltf = id: file: src: at:
let glb = gltfOf id src at; in
pkgs.runCommand "vr-pet-${id}" { nativeBuildInputs = [ python ]; } ''
${srcOf [ "bake_gltf.py" "rig.py" ]}
python3 src/bake_gltf.py ${file} ${glb} $out ${toString fps}
'';
# An animal's glTF from its "src" (docs/pet-models.md): a file of its
# folder, {url, hash} (fetched) or {path}; optionally unzipped ("unzip",
# a fetched one's hash is of the unpacked files) and turned into a glTF by
# a Blender script ("blend", "blender": a derivation of its own, so it
# reruns only when its source or script changes).
gltfOf = id: src: at:
if !(builtins.isAttrs src) then at src
else let
zip = src ? unzip;
unzip = ''${pkgs.unzip}/bin/unzip -q "$zip" ${lib.escapeShellArg (src.unzip or "")} -d "$out" || [ $? -le 1 ]'';
got = if src ? path then at src.path
else pkgs.fetchurl ({ name = if zip || src ? blender then "${id}-src" else "${id}.glb"; inherit (src) url hash; }
// lib.optionalAttrs zip { downloadToTemp = true; recursiveHash = true; postFetch = "zip=$downloadedFile; ${unzip}"; });
unpacked = if zip && src ? path then pkgs.runCommand "${id}-src" { } "zip=${got}; ${unzip}" else got;
input = if src ? blend then "${unpacked}/${src.blend}" else unpacked;
in if src ? blender then
pkgs.runCommand "${id}.glb" { nativeBuildInputs = [ pkgs.blender ]; } ''
export HOME=$TMPDIR
blender -b ${lib.escapeShellArg input} --python-exit-code 1 --python ${at src.blender} -- $out
test -s $out
''
else input;
specOf = specs: pkgs.writeText "vr-pet-models-spec.json" (builtins.toJSON { default = defaultModel; models = map resolve specs; });
catalogOf = specs: let
spec = specOf specs;
in pkgs.runCommand "vr-pet-catalog" { nativeBuildInputs = [ python ]; } ''
${srcOf [ "index.py" "skin.py" "thumb.py" ]}
python3 src/index.py ${spec} ${models} $out
'';
specs = extendAll (builtinOf ./models ++ extra);
catalog = catalogOf specs;
# (tests: the same with the model folders of tests/models added, as
# extraModels: a coat and a private one)
testCatalog = catalogOf (extendAll (builtinOf ./models ++ extra ++ discover ./tests/models));
# (tests: a private built-in model, tests/builtin-private, is refused)
privateRefused = !(builtins.tryEval (builtinOf ./tests/builtin-private)).success;
options = {
inherit fps mount;
cat = catOptions;
};
# ((opts, catalog, find, hooks) => { core; adapter; return vrPetSystemui(…); })
# (opts, models.json, finders.js, hooks.js)
patch = assert finders != null && hooks != null; pkgs.runCommand "vr-pet.js" { } ''
{ echo '((opts, baked, find, hooks) => {'
cat ${./core.js} ${./systemui.js}
echo 'return vrPetSystemui(opts, baked, find, hooks);'
echo '})('
echo ${lib.escapeShellArg (builtins.toJSON options)}
echo ','
cat ${catalog}/models.json
echo ','
cat ${finders}
echo ','
cat ${hooks}
echo ')'; } > $out
'';
# The "+" menu entry's icons: <id>.png for every model of the catalog (its
# thumbnail's picture, 256 px, transparent) and default.txt (the default
# model's id); steam-frame-nix-pet-icon links the current model's.
icons = pkgs.runCommand "vr-pet-icons" { nativeBuildInputs = [ python ]; } ''
${srcOf [ "index.py" "skin.py" "thumb.py" ]}
python3 src/index.py --icons ${specOf specs} ${catalog} $out
'';
# steam-frame-nix-pet-icon (icon.sh): points <runtimeDir>/steam-frame-nix/vr-pet/icon.png at the icon
# of the model in the patch's state file (stateFile); bumps hicolor's
# mtime when it changed. All four overridable by environment (the tests).
iconLink = pkgs.writeShellApplication {
name = "steam-frame-nix-pet-icon";
runtimeInputs = [ pkgs.coreutils pkgs.jq pkgs.util-linux ];
text = ''
runtime=''${VR_PET_RUNTIME_DIR:-${lib.escapeShellArg runtimeDir}}
state=''${VR_PET_STATE:-${lib.escapeShellArg stateFile}}
icons=''${VR_PET_ICONS:-${icons}}
hicolor=''${VR_PET_HICOLOR:-${lib.escapeShellArg hicolor}}
'' + builtins.readFile ./icon.sh;
};
# The "+" menu entry: shows the pet (never hides it; the launcher's
# debounce keeps a double click from launching twice); its icon is
# steam-frame-nix-pet-icon's link.
desktopEntry = pkgs.writeText "vr-pet.desktop" ''
[Desktop Entry]
Type=Application
Name=Pet
Comment=Bring the VR pet back
Exec=${cli}/bin/vr-pet show
Icon=vr-pet
Terminal=false
Categories=Game;Amusement;
'';
# vr-pet show [--summon] | hide | status | models | model <id> (cli.mjs). Also run by Steam's
# "+" menu, hence no inherited LD_PRELOAD / LD_LIBRARY_PATH; its output also
# goes to the journal (vr-pet).
cli = pkgs.writeShellApplication {
name = "vr-pet";
runtimeInputs = [ pkgs.nodejs pkgs.systemd ];
text = ''
unset LD_PRELOAD LD_LIBRARY_PATH
rc=0
out=$(node ${./cli.mjs} "$@" 2>&1) || rc=$?
if [[ -n $out ]]; then printf '%s\n' "$out"; fi
{ printf 'vr-pet %s: ' "$*"; printf '%s\n' "''${out:-(no output)} (exit $rc)"; } | systemd-cat -t vr-pet 2>/dev/null || true
exit "$rc"
'';
};
three = pkgs.runCommand "three-0.170.0" { } ''
mkdir $out
tar -xzf ${threeTgz} --strip-components=1 -C $out package/build/three.module.js package/LICENSE
mv $out/build/three.module.js $out/
rmdir $out/build
'';
site = pkgs.runCommand "vr-pet-preview-site" { } ''
mkdir $out
cp ${./preview/index.html} $out/index.html
cp ${./preview/preview.js} $out/preview.js
cp ${./core.js} $out/core.js
echo ${lib.escapeShellArg (builtins.toJSON { inherit fps; cat = catOptions; })} > $out/config.json
ln -s ${models} $out/models
ln -s ${catalog} $out/catalog
ln -s ${three} $out/three
'';
# Headless tests (flake check `pet`): the core with scripted hands
# (core, species: every frame set of the catalog), the SteamVR adapter in a
# fake systemui page (systemui), the CLI with a fake DevTools transport,
# the catalog (models), the glTF reshaping (gltf), the "+" menu's icons,
# entry and steam-frame-nix-pet-icon (icons, entry, icon link), the
# refusal of a private built-in model (private).
tests = pkgs.runCommand "pet-tests" { nativeBuildInputs = [ pkgs.nodejs python pkgs.desktop-file-utils iconLink ]; } ''
mkdir -p src/tests
${srcOf [ "core.js" "systemui.js" "unpatch.js" "cli.mjs" "skin.py" "index.py" "thumb.py" "bake_gltf.py" "rig.py"
"tests/sim.js" "tests/core.test.js" "tests/species.test.js" "tests/systemui.test.js" "tests/cli.test.mjs"
"tests/models.test.py" "tests/gltf.test.py" ]}
cp -r ${./models} src/models
{ echo '# core'; node src/tests/core.test.js ${models}/frames.json; } > core.log || { cat core.log; exit 1; }
{ echo '# species'; node src/tests/species.test.js ${catalog}/models.json; } > species.log || { cat species.log; exit 1; }
{ echo '# systemui'; node src/tests/systemui.test.js ${catalog}/models.json; } > systemui.log || { cat systemui.log; exit 1; }
{ echo '# cli'; node src/tests/cli.test.mjs; } > cli.log || { cat cli.log; exit 1; }
{ echo '# models'; python3 src/tests/models.test.py ${catalog} ${models} ${testCatalog}; } > models.log || { cat models.log; exit 1; }
{ echo '# gltf'; python3 src/tests/gltf.test.py; } > gltf.log || { cat gltf.log; exit 1; }
{ echo '# icons'; python3 ${./tests/icons.test.py} ${./models} ${icons}; } > icons.log || { cat icons.log; exit 1; }
{ echo '# entry'; desktop-file-validate ${desktopEntry} && echo 'ok desktop-file-validate vr-pet.desktop'; } > entry.log 2>&1 || { cat entry.log; exit 1; }
{ echo '# icon link'; bash ${./tests/icon.test.sh}; } > link.log 2>&1 || { cat link.log; exit 1; }
{ echo '# private'; ${if privateRefused then "echo 'ok a private built-in model (tests/builtin-private) fails evaluation'"
else "echo 'FAIL a private built-in model (tests/builtin-private) was not refused'; exit 1"}; } > private.log || { cat private.log; exit 1; }
cat core.log species.log systemui.log cli.log models.log gltf.log icons.log entry.log link.log private.log | tee $out
'';
preview = pkgs.writeShellApplication {
name = "vr-pet-preview";
runtimeInputs = [ pkgs.python3 ];
text = ''
port="''${1:-8765}"
echo "VR pet preview: http://127.0.0.1:$port/ (Ctrl+C stops)"
exec python3 -m http.server --bind 127.0.0.1 --directory ${site} "$port"
'';
};
in {
inherit models catalog patch site preview tests icons iconLink desktopEntry cli;
}
+67
View File
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>VR Cat Preview</title>
<style>
:root { color-scheme: dark; --bg: #15171a; --panel: #1f2328e6; --fg: #e6e6e6; --dim: #9aa0a6; --accent: #f0a04b; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font: 13px/1.4 system-ui, sans-serif; overflow: hidden; }
canvas { display: block; }
#panel { position: fixed; top: 10px; right: 10px; width: 430px; max-height: calc(100% - 20px); overflow-x: hidden; overflow-y: auto; background: var(--panel);
border-radius: 8px; padding: 10px 12px; box-sizing: border-box; }
#panel h1 { font-size: 14px; margin: 0 0 6px; color: var(--accent); }
#panel label { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
#panel select, #panel button { font: inherit; }
#panel .row { display: flex; gap: 6px; align-items: center; margin: 6px 0; flex-wrap: wrap; }
#state { white-space: pre; font: 12px/1.35 ui-monospace, monospace; color: var(--fg); background: #0005; padding: 6px; border-radius: 4px; }
#help { color: var(--dim); font-size: 12px; margin-top: 8px; }
#help b { color: var(--fg); font-weight: 600; }
#loading { position: fixed; inset: 0; display: grid; place-items: center; font-size: 16px; color: var(--dim); }
#log { white-space: pre-wrap; font: 11px/1.3 ui-monospace, monospace; color: var(--dim); max-height: 120px; overflow: auto; }
</style>
</head>
<body>
<div id="loading">loading baked frames…</div>
<div id="panel">
<h1>VR pet preview</h1>
<div id="state"></div>
<div class="row">
<select id="cmd">
<option>stand</option><option>sit</option><option>lie</option><option>sleep</option>
<option>wander</option><option>follow</option><option>turn</option><option>groom</option>
<option>stretch</option><option>shake</option><option>purr</option><option>startle</option><option>drop</option>
<option>dangle</option><option>summon</option>
</select>
<button id="go">force</button>
</div>
<div class="row">
<button id="show">show</button>
<label><input id="summonshow" type="checkbox"> summon on show</label>
<span id="hiddenv"></span>
</div>
<div class="row">
<select id="clip"><option value="">clip: from the core</option></select>
<select id="model"></select>
</div>
<label>time scale <input id="speed" type="range" min="0" max="4" step="0.1" value="1"> <span id="speedv">1.0</span></label>
<label><input id="demo" type="checkbox"> demo tour</label>
<label><input id="handmode" type="checkbox" checked> hand on the mouse cursor (H)</label>
<label><input id="reach" type="checkbox" checked> petting reach (green)</label>
<label><input id="zones" type="checkbox"> zones (scruff, head, back, chin, tail)</label>
<label><input id="paths" type="checkbox" checked> waypoints / follow radius</label>
<label><input id="third" type="checkbox"> observer camera (V)</label>
<div id="help">
<b>mouse</b> moves the right hand (on the plane through the cat; <b>wheel</b> nearer/farther) ·
stroke <b>slowly</b> over head/back to pet · swipe <b>fast</b> past it to startle ·
<b>press on the grip bar</b> above the cat and move to drag it by the scruff (the laser in VR; <b>wheel</b> nearer/farther), release to drop ·
<b>right-drag</b> look (<b>double-click</b> captures the mouse, Esc releases) ·
press and release on the <b>X</b> below the grip bar to hide the cat, <b>show</b> brings it back ·
<b>⋯</b> next to it opens the menu (coats and animals, Summon, Sit, Lie down, Sleep) ·
<b>WASD</b> walk · <b>Shift</b> run · <b>C</b> crouch · <b>V</b> observer camera
</div>
<div id="log"></div>
</div>
<script src="core.js"></script>
<script type="module" src="preview.js"></script>
</body>
</html>
+581
View File
@@ -0,0 +1,581 @@
// VR pet, desktop adapter: runs the same core (core.js, global VrPetCore)
// with the same baked frames (models/: OBJ per frame + cat.png +
// frames.json) in a browser with three.js, with a fake user (head = camera)
// and a debug panel. Served by `nix run .#pet-preview` (package.nix).
//
// The right hand ("hand mode", H) follows the mouse cursor on the vertical
// plane through the cat that faces the camera (wheel: nearer / farther), so
// mouse speed is hand speed: stroke slowly over the back or head to pet,
// swipe fast past the cat to startle it. Above the cat: the grip bar and,
// below it, the controls row (⋯ and X) as in SteamVR (VrPetCore.ui's sizes
// and lifts, systemui.js' looks; always shown here). Press the
// left button on the bar and move the mouse to drag the cat by the scruff
// like SteamVR's laser (the grab point stays on the mouse ray at its
// distance; wheel: nearer / farther), release to drop it. Buttons act on a
// press and a release on them (inert during a drag and 300 ms after, as in
// SteamVR): X hides the cat (no ticks), "show" brings it back by the same
// rule (core reveal(): at its spot within 3 m and in view, else summoned;
// "summon on show" forces it); ⋯ opens the menu above the bar (the coats
// from the catalog, catalog/models.json, then Summon, Sit, Lie down, Sleep),
// closed by a pick, a press elsewhere or 1 s with the pointer away. The
// model: also the "model" dropdown; kept in the session like the spot. A
// coat swaps the texture; another animal (a frame set of its own, loaded
// on first use from catalog/<id>/) gets its frames and a new core at the
// same spot, as in SteamVR (systemui.js setModel()).
import * as THREE from './three/three.module.js';
const $ = (id) => document.getElementById(id);
const config = await fetch('config.json').then((r) => r.json()).catch(() => ({ fps: 12, cat: {} }));
// The models (index.py's models.json): the cat's frames in models/, the coats
// (their textures) and the animals (their frames) in catalog/<sub>/.
const catalog = await fetch('catalog/models.json').then((r) => r.json());
const dirOf = (id) => (catalog.models[id].sub ? `catalog/${catalog.models[id].sub}` : 'models');
// A frame set's baked data and where its OBJs are (the cat's: models/).
const setOf = (id) => catalog.models[id].frames;
const setDir = (set) => dirOf(catalog.order.find((id) => setOf(id) === set && (catalog.models[id].kind === 'base' || catalog.models[id].kind === 'gltf')));
let baked;
// ---------------------------------------------------------------- scene ----
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(devicePixelRatio);
renderer.setSize(innerWidth, innerHeight);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x2a2e33);
scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 1.6));
const sun = new THREE.DirectionalLight(0xffffff, 1.4);
sun.position.set(2, 4, 1);
scene.add(sun);
const floor = new THREE.Mesh(new THREE.CircleGeometry(12, 64).rotateX(-Math.PI / 2), new THREE.MeshLambertMaterial({ color: 0x3b4148 }));
scene.add(floor);
const grid = new THREE.GridHelper(24, 48, 0x59616b, 0x4a5058);
grid.position.y = 0.001;
scene.add(grid);
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.02, 100);
const observer = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.02, 100);
addEventListener('resize', () => {
renderer.setSize(innerWidth, innerHeight);
for (const c of [camera, observer]) { c.aspect = innerWidth / innerHeight; c.updateProjectionMatrix(); }
});
// ------------------------------------------------------------- frames ----
const textures = {};
const textureOf = async (id) => {
if (!textures[id]) {
textures[id] = await new THREE.TextureLoader().loadAsync(`${dirOf(id)}/cat.png`);
textures[id].colorSpace = THREE.SRGBColorSpace;
}
return textures[id];
};
const material = new THREE.MeshLambertMaterial({ map: null, side: THREE.DoubleSide });
function parseObj(text) { // bake.py's OBJs: v/vt/vn share indices (f a/a/a)
const v = [], vt = [], vn = [], idx = [];
for (const line of text.split('\n')) {
const p = line.split(' ');
if (p[0] === 'v') v.push(+p[1], +p[2], +p[3]);
else if (p[0] === 'vt') vt.push(+p[1], +p[2]);
else if (p[0] === 'vn') vn.push(+p[1], +p[2], +p[3]);
else if (p[0] === 'f') for (let k = 1; k <= 3; k++) idx.push(parseInt(p[k], 10) - 1);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(v, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(vt, 2));
g.setAttribute('normal', new THREE.Float32BufferAttribute(vn, 3));
g.setIndex(idx);
return g;
}
// Every frame set's geometry, loaded on first use: set -> clip -> [geometry].
const geoSets = {};
let geos = {};
async function loadSet(set) {
if (geoSets[set]) return geoSets[set];
const bk = catalog.frameSets[set], dir = setDir(set), out = {};
let total = 0, loaded = 0;
for (const c of Object.values(bk.clips)) total += c.frames;
const note = document.createElement('div');
note.id = 'loading';
document.body.appendChild(note);
await Promise.all(Object.entries(bk.clips).map(async ([name, c]) => {
out[name] = await Promise.all(Array.from({ length: c.frames }, async (_, i) => {
const g = parseObj(await (await fetch(`${dir}/${name}_${i}.obj`)).text());
note.textContent = `loading baked frames (${set})… ${++loaded}/${total}`;
return g;
}));
}));
note.remove();
return (geoSets[set] = out);
}
$('loading').remove();
const catRoot = new THREE.Group();
const catMesh = new THREE.Mesh(new THREE.BufferGeometry(), material);
catRoot.add(catMesh);
scene.add(catRoot);
// Zones (on the standing animal) and waypoints.
const zoneGroup = new THREE.Group();
const zoneColors = { scruff: 0xff4d6d, head: 0x4dabf7, back: 0x69db7c, chin: 0xffd43b, tailBase: 0xda77f2 };
const drawZones = () => {
zoneGroup.clear();
for (const [k, p] of Object.entries(baked.zones ?? {})) {
const m = new THREE.Mesh(new THREE.SphereGeometry(0.018, 12, 8), new THREE.MeshBasicMaterial({ color: zoneColors[k] ?? 0xffffff, transparent: true, opacity: 0.8, depthTest: false }));
m.position.set(...p);
zoneGroup.add(m);
}
};
catRoot.add(zoneGroup);
const pathLine = new THREE.Line(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: 0xf0a04b }));
scene.add(pathLine);
const ring = (r, color) => {
const m = new THREE.Mesh(new THREE.RingGeometry(r - 0.01, r + 0.01, 64).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.5 }));
m.position.y = 0.003;
scene.add(m);
return m;
};
// ----------------------------------------- grip bar, controls and menu ----
const UI = VrPetCore.ui, { HANDLE, CONTROLS, MENU, MENU_CMDS } = UI;
const HANDLE_H = HANDLE.widthM * HANDLE.heightPx / HANDLE.widthPx; // m
const PX = 2; // canvas pixels per texture pixel
const spriteOf = (w, h, draw, scale) => {
const c = document.createElement('canvas');
c.width = w; c.height = h;
draw(c.getContext('2d'), w, h);
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: t, transparent: true, depthTest: false }));
s.scale.set(scale[0], scale[1], 1);
s.center.set(0.5, 0); // bottom-centre origin, like the panels' origin [0, -1]
s.renderOrder = 10;
s.redraw = (f) => { const g = c.getContext('2d'); g.clearRect(0, 0, w, h); f(g, w, h); t.needsUpdate = true; };
scene.add(s);
return s;
};
const roundRect = (g, x, y, w, h, r) => { g.beginPath(); g.roundRect(x, y, w, h, r); g.fill(); };
const barSprite = spriteOf(264, 60, (g, w, h) => {
g.fillStyle = 'rgba(40, 44, 52, 0.55)'; roundRect(g, w * 0.08 - 4, h * 0.31 - 4, w * 0.84 + 8, h * 0.38 + 8, h);
g.fillStyle = 'rgba(235, 238, 242, 0.92)'; roundRect(g, w * 0.08, h * 0.31, w * 0.84, h * 0.38, h);
}, [HANDLE.widthM, HANDLE_H]);
// The controls: one #23262E section (.6 opacity until hovered), ⋯ left, X right;
// a hovered button #3D4450, a pressed one #67707b.
const ctlSprite = spriteOf(CONTROLS.widthPx * PX, CONTROLS.heightPx * PX, () => {}, [CONTROLS.widthM, CONTROLS.widthM * CONTROLS.heightPx / CONTROLS.widthPx]);
const drawControls = (hover, pressed) => ctlSprite.redraw((g, w, h) => {
g.save();
g.beginPath(); g.roundRect(0, 0, w, h, 10 * PX); g.clip();
g.globalAlpha = hover ? 1 : 0.6;
g.fillStyle = '#23262E'; g.fillRect(0, 0, w, h);
['menu', 'close'].forEach((b, i) => {
if (pressed === b || hover === b) { g.fillStyle = pressed === b ? '#67707b' : '#3D4450'; g.fillRect(i * w / 2, 0, w / 2, h); }
});
g.strokeStyle = '#fff'; g.fillStyle = '#fff'; g.lineCap = 'round'; g.lineWidth = 2.6 / 24 * h * 0.6;
const cx = w * 0.75, cy = h / 2, r = h * 0.18;
g.beginPath(); g.moveTo(cx - r, cy - r); g.lineTo(cx + r, cy + r); g.moveTo(cx + r, cy - r); g.lineTo(cx - r, cy + r); g.stroke();
for (const dx of [-1, 0, 1]) { g.beginPath(); g.arc(w * 0.25 + dx * h * 0.2, cy, h * 0.045, 0, 2 * Math.PI); g.fill(); }
g.restore();
});
drawControls(null, null);
// The menu (the stock popup look): the models (a separator between the coats
// and the animals), a separator, the commands.
const { breaks: groupBreaks, px: menuPx } = UI.menuLayout(catalog);
const menuRows = [], menuSeps = []; // rows { y0, y1, model | cmd }; separator y
{
let y = MENU.padPx;
for (const id of catalog.order) {
if (groupBreaks.includes(id)) { menuSeps.push(y + 4); y += MENU.sepPx; }
menuRows.push({ y0: y, y1: y + MENU.modelRowPx, model: id }); y += MENU.modelRowPx;
}
menuSeps.push(y + 4);
y += MENU.sepPx;
for (const [cmd, label] of MENU_CMDS) { menuRows.push({ y0: y, y1: y + MENU.cmdRowPx, cmd, label }); y += MENU.cmdRowPx; }
}
const thumbs = Object.fromEntries(await Promise.all(catalog.order.map(async (id) => {
const im = new Image(); im.src = catalog.models[id].thumb; await im.decode().catch(() => {}); return [id, im];
})));
const menuSprite = spriteOf(MENU.widthPx * PX, menuPx * PX, () => {}, [MENU.widthM, MENU.widthM * menuPx / MENU.widthPx]);
menuSprite.visible = false;
const drawMenu = (hover, pressed) => menuSprite.redraw((g, w) => {
g.save();
g.scale(PX, PX);
g.fillStyle = '#2d3239'; roundRect(g, 0, 0, MENU.widthPx, menuPx, 10);
g.font = '22px "Motiva Sans", Arial, sans-serif'; g.textBaseline = 'middle';
menuRows.forEach((r, i) => {
if (pressed === i || hover === i) { g.fillStyle = pressed === i ? '#67707b' : '#3d4450'; g.fillRect(0, r.y0, MENU.widthPx, r.y1 - r.y0); }
const cy = (r.y0 + r.y1) / 2;
g.fillStyle = '#fff';
if (r.model) {
g.drawImage(thumbs[r.model], 20, cy - 16, 32, 32);
g.fillText(catalog.models[r.model].name, 20 + 32 + 16, cy);
if (r.model === modelId) { g.fillStyle = '#1a9fff'; g.font = 'bold 22px Arial, sans-serif'; g.fillText('✓', MENU.widthPx - 24 - 16, cy); g.font = '22px "Motiva Sans", Arial, sans-serif'; }
} else g.fillText(r.label, 20, cy);
});
g.fillStyle = 'rgba(255, 255, 255, 0.07)';
for (const y of menuSeps) g.fillRect(0, y, MENU.widthPx, 1);
g.restore();
void w;
});
// ----------------------------------------------------------- fake user ----
const user = { x: 0, z: 1.6, yaw: 0, pitch: -0.35, height: 1.6, crouch: false, handDist: 0.45, depth: 0 };
const mouse = { x: 0, y: 0, inside: false }; // NDC
const planeAt = new THREE.Vector3(0, 0.2, 0); // the hand plane's anchor (hand mode)
let handJump = true; // the hand teleported (no velocity from it)
const headMesh = new THREE.Mesh(new THREE.SphereGeometry(0.1, 16, 12), new THREE.MeshLambertMaterial({ color: 0xdddddd }));
const noseMesh = new THREE.Mesh(new THREE.ConeGeometry(0.03, 0.08, 8).rotateX(-Math.PI / 2), new THREE.MeshLambertMaterial({ color: 0xf0a04b }));
noseMesh.position.z = -0.12;
headMesh.add(noseMesh);
scene.add(headMesh);
const handGeo = new THREE.BoxGeometry(0.05, 0.04, 0.12);
const hands = {
left: new THREE.Mesh(handGeo, new THREE.MeshLambertMaterial({ color: 0x8899aa })),
right: new THREE.Mesh(handGeo, new THREE.MeshLambertMaterial({ color: 0x8899aa })),
};
scene.add(hands.left, hands.right);
const lastHand = { left: new THREE.Vector3(), right: new THREE.Vector3() };
const handVel = { left: new THREE.Vector3(), right: new THREE.Vector3() }; // smoothed like a tracker's
// Pet helpers: the petting reach around head, back and scruff.
const wire = (r, color) => new THREE.Mesh(new THREE.SphereGeometry(r, 16, 10), new THREE.MeshBasicMaterial({ color, wireframe: true, transparent: true, opacity: 0.25, depthTest: false }));
const reach = { pet: ['head', 'back', 'scruff'].map(() => wire(VrPetCore.THRESHOLDS.PET_RADIUS, 0x69db7c)) };
scene.add(...reach.pet);
const followRing = ring(1, 0x4dabf7), nearRing = ring(1, 0x69db7c);
const keys = new Set();
addEventListener('keydown', (e) => {
if (e.target.tagName === 'SELECT' || e.target.tagName === 'INPUT') return;
keys.add(e.code);
if (e.code === 'KeyC') user.crouch = !user.crouch;
if (e.code === 'KeyV') { $('third').checked = !$('third').checked; }
if (e.code === 'KeyH') { $('handmode').checked = !$('handmode').checked; handJump = true; }
});
addEventListener('keyup', (e) => keys.delete(e.code));
// Mouse: look with the mouse captured (double-click; Esc releases) or with the
// right button dragged (left button too without hand mode).
renderer.domElement.addEventListener('dblclick', () => renderer.domElement.requestPointerLock?.());
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
addEventListener('mousemove', (e) => {
if (e.target === renderer.domElement) {
if (!mouse.inside) handJump = true;
mouse.x = e.clientX / innerWidth * 2 - 1; mouse.y = -(e.clientY / innerHeight) * 2 + 1; mouse.inside = true;
}
const look = document.pointerLockElement === renderer.domElement ||
(e.target === renderer.domElement && (e.buttons & 2 || (e.buttons & 1 && !$('handmode').checked && !barDrag)));
if (!look) return;
user.yaw -= e.movementX * 0.0025;
user.pitch = Math.max(-1.4, Math.min(1.4, user.pitch - e.movementY * 0.0025));
});
renderer.domElement.addEventListener('mouseleave', () => { mouse.inside = false; handJump = true; });
$('handmode').addEventListener('change', () => { handJump = true; });
addEventListener('wheel', (e) => {
if (barDrag) barDrag.dist = Math.max(0.3, Math.min(5, barDrag.dist - Math.sign(e.deltaY) * 0.1)); // the thumbstick's push / pull
else if ($('handmode').checked) user.depth = Math.max(-0.4, Math.min(0.4, user.depth + Math.sign(e.deltaY) * 0.02));
else user.handDist = Math.max(0.15, Math.min(1.2, user.handDist - Math.sign(e.deltaY) * 0.04));
}, { passive: true });
const raycaster = new THREE.Raycaster();
function updateUser(dt) {
const run = keys.has('ShiftLeft') || keys.has('ShiftRight') ? 2.5 : 1.2;
const f = (keys.has('KeyW') ? 1 : 0) - (keys.has('KeyS') ? 1 : 0), s = (keys.has('KeyD') ? 1 : 0) - (keys.has('KeyA') ? 1 : 0);
// camera looks along -Z at yaw 0
user.x += (-Math.sin(user.yaw) * f + Math.cos(user.yaw) * s) * run * dt;
user.z += (-Math.cos(user.yaw) * f - Math.sin(user.yaw) * s) * run * dt;
const h = user.crouch ? 0.9 : user.height;
camera.position.set(user.x, h, user.z);
camera.rotation.set(user.pitch, user.yaw, 0, 'YXZ');
headMesh.position.copy(camera.position);
headMesh.quaternion.copy(camera.quaternion);
// Right hand: on the cursor (hand mode) or in front of the view; left hand at the side.
const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
let placed = false;
if ($('handmode').checked && document.pointerLockElement !== renderer.domElement && mouse.inside) {
// The vertical plane through the cat (0.2 m up), facing the camera, moved
// by the wheel; kept where it was while the cat is held or falling (it
// would follow the hand along the ray).
const st = cat.state(), flat = new THREE.Vector3(fwd.x, 0, fwd.z).normalize();
if (!st.held && !st.falling) planeAt.set(st.x, 0.2, st.z);
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(flat, planeAt.clone().addScaledVector(flat, user.depth));
raycaster.setFromCamera(new THREE.Vector2(mouse.x, mouse.y), camera);
const hit = raycaster.ray.intersectPlane(plane, new THREE.Vector3());
if (hit && hit.distanceTo(camera.position) < 6) { hands.right.position.copy(hit); placed = true; }
}
if (!placed) hands.right.position.copy(camera.position).addScaledVector(fwd, user.handDist).addScaledVector(right, 0.12).add(new THREE.Vector3(0, -0.15, 0));
hands.right.quaternion.copy(camera.quaternion);
hands.left.position.copy(camera.position).addScaledVector(right, -0.25).add(new THREE.Vector3(0, -0.55, 0)).addScaledVector(fwd, 0.1);
hands.left.quaternion.copy(camera.quaternion);
for (const k of ['left', 'right']) {
if (handJump) handVel[k].set(0, 0, 0); // cursor entered / mode switched: no swing
else if (dt > 0) handVel[k].lerp(new THREE.Vector3().subVectors(hands[k].position, lastHand[k]).divideScalar(dt), Math.min(1, dt / 0.06));
lastHand[k].copy(hands[k].position);
}
handJump = false;
}
// ---------------------------------------------------------- the world ----
let saved;
try { saved = JSON.parse(sessionStorage.getItem('vr-pet') ?? 'null') ?? undefined; } catch { saved = undefined; }
// Merged writes, as systemui.js (the core saves its keys, hide / show `hidden`).
const store = (v) => { saved = { ...saved, ...v }; try { sessionStorage.setItem('vr-pet', JSON.stringify(saved)); } catch { /* private mode */ } };
let hidden = saved?.hidden === true;
let modelId = catalog.models[saved?.model] ? saved.model : catalog.default;
const logLines = [];
let forcedClip = '', forcedT = 0;
const world = {
head() {
const f = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
const l = Math.hypot(f.x, f.z) || 1;
return { x: camera.position.x, y: camera.position.y, z: camera.position.z, fx: f.x / l, fz: f.z / l };
},
hands: () => handsNow,
held: () => heldNow,
show(clip, i, at) {
if (forcedClip) { clip = forcedClip; i = Math.floor(forcedT * baked.clips[clip].fps) % baked.clips[clip].frames; }
catMesh.geometry = geos[clip]?.[i] ?? catMesh.geometry;
placed = { p: [at.x, at.y, at.z], q: at.q ?? VrPetCore.math.qyaw(at.yaw) };
shownClip = clip; shownFrame = i;
},
load: () => saved,
save: (v) => store(v),
log: (...a) => {
logLines.push(`${(performance.now() / 1000).toFixed(1)} ${a.join(' ')}`);
if (logLines.length > 40) logLines.shift();
$('log').textContent = logLines.slice().reverse().join('\n');
},
};
let shownClip = 'idle', shownFrame = 0;
let placed = null;
function placeCat() {
if (!placed) return;
catRoot.position.set(...placed.p);
catRoot.quaternion.set(placed.q[1], placed.q[2], placed.q[3], placed.q[0]);
}
let handsNow = { left: null, right: null }, heldNow = null;
const sampleHands = () => {
const h = (k) => ({ x: hands[k].position.x, y: hands[k].position.y, z: hands[k].position.z, vx: handVel[k].x, vy: handVel[k].y, vz: handVel[k].z });
return { left: h('left'), right: h('right') };
};
// The laser drag (systemui.js' drag of the grip bar): the grab point on the
// mouse ray at `dist` from the camera; velocity from its motion.
let barDrag = null, lastDragEnd = -Infinity; // { dist, point, prev }
const dragPoint = (dt) => {
pointer.setFromCamera(new THREE.Vector2(mouse.x, mouse.y), camera);
const q = pointer.ray.at(barDrag.dist, new THREE.Vector3());
const prev = barDrag.point ?? q;
barDrag.point = q;
return { x: q.x, y: Math.max(0.05, q.y), z: q.z, vx: dt > 0 ? (q.x - prev.x) / dt : 0, vy: dt > 0 ? (q.y - prev.y) / dt : 0, vz: dt > 0 ? (q.z - prev.z) / dt : 0 };
};
let cat = null; // the core (a new one for another animal: setModel())
const TH = VrPetCore.THRESHOLDS;
// Readout: what the hand does to the cat, against the thresholds.
function interaction(st) {
const n = st.near.find((x) => x.side === 'right');
const f = (v, d = 2) => (Number.isFinite(v) ? v.toFixed(d) : '-');
const onPet = n && n.dPet < TH.PET_RADIUS;
const verdict = st.held ? `held (${st.held.source} ${st.held.hand})`
: !n ? '-'
: n.speed > TH.STARTLE_SPEED && n.dPet < TH.STARTLE_RADIUS + 0.1 ? 'FAST: startles'
: onPet && n.speed < TH.PET_MAX_SPEED ? 'petting' : onPet ? 'too fast to pet' : '';
return [
`hand R ${barDrag ? `DRAGGING the bar (${barDrag.dist.toFixed(1)} m)` : 'free'}`,
` speed ${f(n?.speed)} m/s (pet < ${TH.PET_MAX_SPEED}, startle > ${TH.STARTLE_SPEED})`,
` pet ${f(n?.dPet)} m (< ${TH.PET_RADIUS}; startle < ${TH.STARTLE_RADIUS})`,
` -> ${verdict}`,
`petting ${'#'.repeat(Math.round(Math.min(1, st.petT / TH.PET_TIME) * 12)).padEnd(12, '.')} ${f(st.petT, 1)}/${TH.PET_TIME} s${st.purring ? ' PURRING' : ''}${st.wary > 0 ? ` wary ${f(st.wary, 1)} s` : ''}`,
`action ${st.action ?? '-'}${st.pending ? ` (next ${st.pending})` : ''}${st.falling ? ' FALLING' : ''} y ${f(st.y)}`,
];
}
$('go').onclick = () => cat.command($('cmd').value);
// Hide / show (systemui.js hide() / show()).
const inView = (st) => UI.inView(camera.position.toArray(), new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion).toArray(), { x: st.x, y: 0, z: st.z });
function hideCat() {
if (hidden) return;
barDrag = null;
cat.settle();
closeMenu();
hidden = true; store({ hidden: true });
world.log('hidden');
}
function showCat() {
const st = cat.state(), h = world.head();
const r = VrPetCore.reveal(st, h, { inView: inView(st), force: $('summonshow').checked });
if (r === 'summon') cat.command('summon');
if (hidden) { hidden = false; store({ hidden: false }); }
world.log('shown', r === 'summon' ? 'summoned' : 'at its spot');
}
$('show').onclick = showCat;
const pointer = new THREE.Raycaster();
// What the pointer is on: { target: 'bar' | 'menu' | 'close' | 'row', row } or null.
const hit = (e) => {
if (hidden) return null;
pointer.setFromCamera(new THREE.Vector2(e.clientX / innerWidth * 2 - 1, -(e.clientY / innerHeight) * 2 + 1), camera);
if (menuSprite.visible) {
const [h] = pointer.intersectObject(menuSprite);
if (h) {
const y = (1 - h.uv.y) * menuPx, row = menuRows.findIndex((r) => y >= r.y0 && y < r.y1);
return { target: row >= 0 ? 'row' : 'menuBox', row };
}
}
const [c] = pointer.intersectObject(ctlSprite);
if (c) return { target: c.uv.x < 0.5 ? 'menu' : 'close' };
if (pointer.intersectObject(barSprite).length) return { target: 'bar' };
return null;
};
const same = (a, b) => a && b && a.target === b.target && a.row === b.row;
// Buttons (⋯, X, menu rows): act on a press and a release on the same one,
// not while dragged or just after (systemui.js).
let armed = null, hovered = null, leaveTimer = null;
const dragBusy = () => !!barDrag || performance.now() - lastDragEnd < UI.AFTER_DRAG_MS;
const redraw = () => {
const hb = hovered && ['menu', 'close'].includes(hovered.target) ? hovered.target : null;
drawControls(hb, armed && ['menu', 'close'].includes(armed.target) ? armed.target : null);
if (menuSprite.visible) drawMenu(hovered?.target === 'row' ? hovered.row : null, armed?.target === 'row' ? armed.row : null);
};
const openMenu = () => { menuSprite.visible = true; clearTimeout(leaveTimer); redraw(); world.log('menu open'); };
const closeMenu = () => { if (!menuSprite.visible) return; menuSprite.visible = false; clearTimeout(leaveTimer); world.log('menu closed'); };
async function setModel(id) {
if (!catalog.models[id]) return;
const set = setOf(id);
if (catalog.frameSets[set] !== baked) { // another animal (or the first model): its frames, a new core at the same spot
geos = await loadSet(set);
if (cat) {
barDrag = null;
cat.settle();
cat.save(); // the spot and pose go over to the new core
}
baked = catalog.frameSets[set];
cat = VrPetCore.create(world, { ...config.cat }, baked);
cat.options.demo = $('demo').checked;
catMesh.geometry = geos.idle[0];
shownClip = 'idle'; shownFrame = 0; forcedClip = '';
drawZones();
$('clip').length = 1;
for (const name of Object.keys(baked.clips)) $('clip').add(new Option(`clip: ${name} (${baked.clips[name].source}, ${baked.clips[name].frames})`, name));
}
modelId = id;
material.map = await textureOf(id);
material.needsUpdate = true;
store({ model: id });
$('model').value = id;
world.log('model', id);
}
function act(h) {
if (h.target === 'close') hideCat();
else if (h.target === 'menu') { if (menuSprite.visible) closeMenu(); else openMenu(); }
else if (h.target === 'row') {
const r = menuRows[h.row];
closeMenu();
if (r.model) setModel(r.model); else { cat.command(r.cmd); world.log('menu', r.cmd); }
}
}
renderer.domElement.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
const h = hit(e);
if (menuSprite.visible && (!h || h.target === 'bar')) closeMenu(); // a press elsewhere
if (h && ['menu', 'close', 'row'].includes(h.target)) { armed = dragBusy() ? null : h; redraw(); return; }
const st = cat.state();
if (h?.target === 'bar' && !st.falling) {
const sc = cat.zone('scruff');
barDrag = { dist: sc ? camera.position.distanceTo(new THREE.Vector3(sc.x, sc.y, sc.z)) : 1, point: null };
armed = null;
world.log('drag');
}
});
addEventListener('mouseup', (e) => {
const was = armed;
armed = null;
if (barDrag) { barDrag = null; lastDragEnd = performance.now(); world.log('drag end'); }
else if (was && e.target === renderer.domElement && same(hit(e), was) && !dragBusy()) act(was);
redraw();
}, true);
renderer.domElement.addEventListener('mousemove', (e) => {
const h = hit(e);
if (armed && !same(h, armed)) armed = null; // left the pressed button
hovered = h && !dragBusy() ? h : null;
// The menu closes MENU.leaveMs after the pointer left it and the controls.
const onUs = h && h.target !== 'bar';
if (onUs) { clearTimeout(leaveTimer); leaveTimer = null; }
else if (menuSprite.visible && !leaveTimer) leaveTimer = setTimeout(() => { leaveTimer = null; closeMenu(); }, MENU.leaveMs);
redraw();
});
for (const id of catalog.order) $('model').add(new Option(`${catalog.models[id].group === 'animals' ? 'animal' : 'coat'}: ${catalog.models[id].name}`, id));
$('model').onchange = () => setModel($('model').value);
await setModel(modelId);
$('clip').onchange = () => { forcedClip = $('clip').value; forcedT = 0; };
$('speed').oninput = () => { $('speedv').textContent = (+$('speed').value).toFixed(1); };
$('demo').onchange = () => { cat.options.demo = $('demo').checked; };
window.__vrPetPreview = { get cat() { return cat; }, camera, user, openMenu, closeMenu, setModel, hideCat, showCat }; // debugging (DevTools)
// ---------------------------------------------------------------- loop ----
const tickDt = 1 / (config.fps || 12);
let acc = 0, last = performance.now(), fpsN = 0, fpsT = 0, fps = 0;
function frame(now) {
const real = Math.min(0.1, (now - last) / 1000);
last = now;
updateUser(real);
const scale = +$('speed').value;
acc += real * scale;
forcedT += real * scale;
const step = (dt) => { // same order as systemui.js: sample, the drag, tick
handsNow = sampleHands();
heldNow = barDrag ? { source: 'laser', hand: 'right', ...dragPoint(dt) } : null;
};
if (hidden) acc = 0; // hidden: no ticks (as in SteamVR)
while (acc >= tickDt) { step(tickDt); cat.tick(tickDt); acc -= tickDt; } // same fixed step as in SteamVR
if (scale === 0 && !hidden) { step(0); cat.tick(0); }
placeCat();
catRoot.visible = !hidden;
// Grip bar, controls below it and the menu above it (world up; the dangle frames' origin is the scruff).
const lift = shownClip === 'dangle' ? HANDLE.liftDangle : UI.liftOf(baked.height);
barSprite.position.copy(catRoot.position).add(new THREE.Vector3(0, lift, 0));
ctlSprite.position.copy(barSprite.position).add(new THREE.Vector3(0, -(CONTROLS.gapM + ctlSprite.scale.y), 0));
const toCam = new THREE.Vector3().subVectors(camera.position, barSprite.position).setY(0).normalize();
menuSprite.position.copy(barSprite.position).add(new THREE.Vector3(0, HANDLE_H + MENU.gapM, 0)).addScaledVector(toCam, MENU.forwardM);
barSprite.visible = ctlSprite.visible = !hidden;
if (hidden) menuSprite.visible = false;
$('hiddenv').textContent = hidden ? 'hidden' : '';
const st = cat.state();
zoneGroup.visible = $('zones').checked && shownClip === 'idle';
// Reach spheres: petting (head, back, scruff).
const showReach = $('reach').checked && !hidden;
['head', 'back', 'scruff'].forEach((k, i) => {
const z = cat.zone(k);
reach.pet[i].visible = showReach && !!z && !st.held && !st.falling;
if (z) reach.pet[i].position.set(z.x, z.y, z.z);
});
const showPaths = $('paths').checked;
pathLine.visible = followRing.visible = nearRing.visible = showPaths;
if (showPaths) {
const pts = [new THREE.Vector3(st.x, 0.01, st.z), ...st.waypoints.map(([x, z]) => new THREE.Vector3(x, 0.01, z))];
pathLine.geometry.setFromPoints(pts);
const h = world.head();
followRing.position.set(h.x, 0.003, h.z); followRing.scale.setScalar(cat.options.follow);
nearRing.position.set(h.x, 0.003, h.z); nearRing.scale.setScalar(cat.options.near);
}
fpsN++; fpsT += real;
if (fpsT > 0.5) { fps = fpsN / fpsT; fpsN = 0; fpsT = 0; }
const h = world.head();
$('state').textContent = [
`pose ${st.pose}${st.edge ? ` (${st.edge} -> ${st.goal})` : st.goal !== st.pose ? ` -> ${st.goal}` : ''}`,
`activity ${st.activity}${st.forced ? ` (forced ${st.forced})` : ''}`,
`clip ${shownClip} ${shownFrame}/${baked.clips[shownClip].frames - 1}${forcedClip ? ' (forced)' : ''}`,
`calm ${st.calm.toFixed(0)} s`,
`cat ${st.x.toFixed(2)} ${st.z.toFixed(2)} yaw ${(st.yaw * 180 / Math.PI).toFixed(0)}°`,
`user ${Math.hypot(h.x - st.x, h.z - st.z).toFixed(2)} m away`,
...interaction(st),
`render ${fps.toFixed(0)} fps, core ${(config.fps || 12)} Hz`,
].join('\n');
headMesh.visible = $('third').checked;
let cam = camera;
if ($('third').checked) { // behind and above the user, looking at the cat
const back = new THREE.Vector3(Math.sin(user.yaw), 0, Math.cos(user.yaw));
observer.position.set(user.x + back.x * 2.2, 2.4, user.z + back.z * 2.2);
observer.lookAt((user.x + st.x) / 2, 0.2, (user.z + st.z) / 2);
cam = observer;
}
renderer.render(scene, cam);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""glTF rigs for the VR pet's bakes (bake.py: the Toon Cat; bake_gltf.py: other
animals): reading glTF / GLB (accessors), quaternions, node hierarchies
posed from their animation clips (Rig), skinning on the CPU (Skinned),
writing a posed frame as a static OBJ (write_obj, write_mtl), the ground
speed of a walk clip (walk_speed) and smoothstep (smooth).
"""
import io, json, math, os, struct
import numpy as np
# ---------------------------------------------------------------- glTF ----
COMP = {5120: np.int8, 5121: np.uint8, 5122: np.int16, 5123: np.uint16, 5125: np.uint32, 5126: np.float32}
NCOMP = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4, 'MAT4': 16}
def load(path):
b = open(path, 'rb').read()
if b[:4] == b'glTF':
off, j, binc = 12, None, None
while off < len(b):
ln, typ = struct.unpack('<II', b[off:off + 8])
chunk = b[off + 8:off + 8 + ln]
if typ == 0x4E4F534A:
j = json.loads(chunk)
elif typ == 0x004E4942:
binc = chunk
off += 8 + ln
return j, binc
j = json.loads(b)
return j, open(os.path.join(os.path.dirname(path), j['buffers'][0]['uri']), 'rb').read()
def accessor(j, binc, i):
a = j['accessors'][i]
bv = j['bufferViews'][a['bufferView']]
dt = np.dtype(COMP[a['componentType']])
n, c = a['count'], NCOMP[a['type']]
start = bv.get('byteOffset', 0) + a.get('byteOffset', 0)
stride = bv.get('byteStride') or dt.itemsize * c
raw = np.frombuffer(binc, dtype=np.uint8, count=stride * (n - 1) + dt.itemsize * c, offset=start)
rows = np.lib.stride_tricks.as_strided(raw, shape=(n, dt.itemsize * c), strides=(stride, 1))
out = np.ascontiguousarray(rows).view(dt).reshape(n, c).astype(np.float64)
if a.get('normalized') and dt != np.float32:
out /= np.iinfo(dt).max
return out
# ---------------------------------------------------------- quaternions ----
# (x, y, z, w), as glTF.
QI = np.array([0.0, 0.0, 0.0, 1.0])
def qmul(a, b):
ax, ay, az, aw = a
bx, by, bz, bw = b
return np.array([aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz])
def qinv(q):
return np.array([-q[0], -q[1], -q[2], q[3]])
def qnorm(q):
return q / np.linalg.norm(q)
def qmat(q):
x, y, z, w = q
return np.array([
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]])
def mat_q(m):
m = m / np.linalg.norm(m, axis=0) # drop (uniform) scale
t = np.trace(m)
if t > 0:
s = math.sqrt(t + 1) * 2
q = [(m[2, 1] - m[1, 2]) / s, (m[0, 2] - m[2, 0]) / s, (m[1, 0] - m[0, 1]) / s, s / 4]
elif m[0, 0] > m[1, 1] and m[0, 0] > m[2, 2]:
s = math.sqrt(1 + m[0, 0] - m[1, 1] - m[2, 2]) * 2
q = [s / 4, (m[0, 1] + m[1, 0]) / s, (m[0, 2] + m[2, 0]) / s, (m[2, 1] - m[1, 2]) / s]
elif m[1, 1] > m[2, 2]:
s = math.sqrt(1 + m[1, 1] - m[0, 0] - m[2, 2]) * 2
q = [(m[0, 1] + m[1, 0]) / s, s / 4, (m[1, 2] + m[2, 1]) / s, (m[0, 2] - m[2, 0]) / s]
else:
s = math.sqrt(1 + m[2, 2] - m[0, 0] - m[1, 1]) * 2
q = [(m[0, 2] + m[2, 0]) / s, (m[1, 2] + m[2, 1]) / s, s / 4, (m[1, 0] - m[0, 1]) / s]
return qnorm(np.array(q))
def qaxis(axis, deg):
a = np.asarray(axis, float)
a = a / np.linalg.norm(a)
h = math.radians(deg) / 2
return np.array([*(a * math.sin(h)), math.cos(h)])
def qeuler(x=0.0, y=0.0, z=0.0):
"""Degrees about the body axes X (left, pitch: + = nose down), Y (up,
yaw: + = turn left), Z (forward, roll), applied Z, then X, then Y."""
return qmul(qaxis([0, 1, 0], y), qmul(qaxis([1, 0, 0], x), qaxis([0, 0, 1], z)))
def qrot(q, v):
return qmat(q) @ v
def qbetween(a, b):
a = a / np.linalg.norm(a)
b = b / np.linalg.norm(b)
c = np.cross(a, b)
d = float(np.dot(a, b))
if d < -0.999999:
ax = np.cross(a, [1, 0, 0])
if np.linalg.norm(ax) < 1e-6:
ax = np.cross(a, [0, 1, 0])
return qaxis(ax, 180)
return qnorm(np.array([*c, 1 + d]))
def slerp(a, b, u):
a, b = np.asarray(a, float), np.asarray(b, float)
d = float(np.dot(a, b))
if d < 0:
b, d = -b, -d
if d > 0.9995:
return qnorm(a + u * (b - a))
th = math.acos(d)
return qnorm((math.sin((1 - u) * th) * a + math.sin(u * th) * b) / math.sin(th))
def qscale(q, s): # s times the rotation angle
return slerp(QI, q, s)
def smooth(u):
"""Smoothstep of u, clamped to 0..1."""
u = min(1.0, max(0.0, u))
return u * u * (3 - 2 * u)
# ---------------------------------------------------------------- rigs ----
class Rig:
def __init__(self, path):
self.j, self.bin = j, binc = load(path)
self.nodes = j['nodes']
self.parent = {c: i for i, n in enumerate(self.nodes) for c in n.get('children', [])}
self.idx = {}
for i, n in enumerate(self.nodes):
self.idx.setdefault(n.get('name', ''), i)
self.idx.setdefault(n.get('name', '').rsplit('_', 1)[0], i) # "spine.01_012" -> "spine.01"
self.order = []
seen = set()
def visit(i):
if i in seen:
return
if i in self.parent:
visit(self.parent[i])
seen.add(i)
self.order.append(i)
for i in range(len(self.nodes)):
visit(i)
self.rest = [self.trs(n) for n in self.nodes]
self.rest_world = self.world(self.rest)
self.rest_rot = [mat_q(m[:3, :3]) for m in self.rest_world]
@staticmethod
def trs(n):
if 'matrix' in n:
m = np.array(n['matrix']).reshape(4, 4).T
s = np.linalg.norm(m[:3, :3], axis=0)
return (m[:3, 3].copy(), mat_q(m[:3, :3]), s)
return (np.array(n.get('translation', [0, 0, 0]), float), np.array(n.get('rotation', [0, 0, 0, 1]), float),
np.array(n.get('scale', [1, 1, 1]), float))
def world(self, pose):
out = [None] * len(self.nodes)
for i in self.order:
t, r, s = pose[i]
m = np.eye(4)
m[:3, :3] = qmat(r) * s
m[:3, 3] = t
out[i] = out[self.parent[i]] @ m if i in self.parent else m
return out
def __getitem__(self, name):
return self.idx[name]
def clip(self, name):
return next(a for a in self.j['animations'] if a.get('name') == name)
def sample(self, anim, t):
"""Local TRS of all nodes at time t of animation `anim`."""
pose = [list(p) for p in self.rest]
for c in anim['channels']:
path = c['target']['path']
if path not in ('translation', 'rotation', 'scale'):
continue
s = anim['samplers'][c['sampler']]
times = accessor(self.j, self.bin, s['input'])[:, 0]
vals = accessor(self.j, self.bin, s['output'])
if s.get('interpolation') == 'CUBICSPLINE':
vals = vals[1::3]
if t <= times[0]:
v = vals[0]
elif t >= times[-1]:
v = vals[-1]
else:
k = int(np.searchsorted(times, t)) - 1
u = (t - times[k]) / (times[k + 1] - times[k])
if s.get('interpolation') == 'STEP':
v = vals[k]
elif path == 'rotation':
v = slerp(vals[k], vals[k + 1], u)
else:
v = vals[k] + u * (vals[k + 1] - vals[k])
pose[c['target']['node']][('translation', 'rotation', 'scale').index(path)] = np.array(v)
return pose
def duration(self, anim):
return max(float(accessor(self.j, self.bin, anim['samplers'][c['sampler']]['input'])[-1, 0]) for c in anim['channels'])
class Skinned(Rig):
"""A rig with one skinned mesh (the first mesh node with a skin): its
vertices (P, N, UV (None without TEXCOORD_0), J, W), faces F and the
skin's inverse bind matrices; skinned(pose) poses them."""
def __init__(self, path):
super().__init__(path)
j, binc = self.j, self.bin
mnode = next(i for i, n in enumerate(self.nodes) if 'mesh' in n and 'skin' in n)
self.skin = j['skins'][self.nodes[mnode]['skin']]
self.ibm = accessor(j, binc, self.skin['inverseBindMatrices']).reshape(-1, 4, 4).transpose(0, 2, 1)
P, N, UV, J, W, F = [], [], [], [], [], []
base = 0
for prim in j['meshes'][self.nodes[mnode]['mesh']]['primitives']:
at = prim['attributes']
p = accessor(j, binc, at['POSITION'])
P.append(p)
N.append(accessor(j, binc, at['NORMAL']))
if 'TEXCOORD_0' in at:
UV.append(accessor(j, binc, at['TEXCOORD_0']))
J.append(accessor(j, binc, at['JOINTS_0']).astype(int))
W.append(accessor(j, binc, at['WEIGHTS_0']))
F.append(accessor(j, binc, prim['indices']).astype(int).reshape(-1, 3) + base)
base += len(p)
self.P, self.N, self.J, self.W, self.F = map(np.concatenate, (P, N, J, W, F))
self.UV = np.concatenate(UV) if len(UV) == len(P) else None
self.W = self.W / self.W.sum(axis=1, keepdims=True)
def skinned(self, pose):
g = self.world(pose)
jm = np.array([g[jt] @ self.ibm[k] for k, jt in enumerate(self.skin['joints'])])
m = np.einsum('vk,vkij->vij', self.W, jm[self.J])
p = np.einsum('vij,vj->vi', m[:, :3, :3], self.P) + m[:, :3, 3]
n = np.einsum('vij,vj->vi', m[:, :3, :3], self.N)
n /= np.linalg.norm(n, axis=1, keepdims=True) + 1e-12
return p, n, g
# ---------------------------------------------------------------- output ----
def png_from(data):
from PIL import Image
buf = io.BytesIO()
Image.open(io.BytesIO(data)).convert('RGB').save(buf, 'PNG')
return buf.getvalue()
def walk_speed(rig, anim, feet, scale, n=100, back_only=False):
"""Ground speed of a walk clip (m/s at its own speed; the rig scaled by
`scale`): how fast the feet move backwards (-z) while on the floor
(back_only: and only while they move back, for feet that lift off low,
as a reshaped rig's short legs)."""
d = rig.duration(anim)
speeds = []
for foot in feet:
g = [rig.world(rig.sample(anim, i * d / n))[rig[foot]][:3, 3] * scale for i in range(n)]
y = np.array([p[1] for p in g])
z = np.array([p[2] for p in g])
v = np.diff(z) / (d / n)
low = y[:-1] < y.min() + 0.004
if back_only:
low &= v < 0
speeds.append(-v[low].mean())
return float(np.mean(speeds))
def write_mtl(out):
"""cat.mtl: the one material of every frame (`mtllib cat.mtl`), its texture cat.png."""
with open(os.path.join(out, 'cat.mtl'), 'w') as f:
f.write('newmtl cat\nKa 1 1 1\nKd 1 1 1\nKs 0 0 0\nillum 1\nmap_Kd cat.png\n')
def write_obj(path, p, n, uv, F):
with open(path, 'w') as f:
f.write('mtllib cat.mtl\no cat\n')
f.write(''.join(f'v {a:.4f} {b:.4f} {c:.4f}\n' for a, b, c in p))
f.write(''.join(f'vt {a:.4f} {b:.4f}\n' for a, b in uv))
f.write(''.join(f'vn {a:.3f} {b:.3f} {c:.3f}\n' for a, b, c in n))
f.write('usemtl cat\n')
f.write(''.join(f'f {a}/{a}/{a} {b}/{b}/{b} {c}/{c}/{c}\n' for a, b, c in F + 1))
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Recolour the Toon Cat's texture (bake.py's cat.png) into another coat.
usage: skin.py base.png model.json out.png
model.json (models/<id>/model.json, kind "recolor"):
{ "name": "Tuxedo", "kind": "recolor",
"fur": ["#18181c", "#55555e"], dark and light end of the coat
"white": ["chest", "muzzle", "paws"] } optional white markings
The texture is a gradient atlas (64 x 64): each vertex samples one texel,
and the body's texels are laid out like a side view of the cat (columns
along its length, rows by height), each column a dark-to-light ramp that
bakes the shading in. Fur texels are the ginger ones (hue 0.03-0.14,
saturation > 0.15); each is replaced by the new coat's ramp at the same
brightness (its place between the darkest and lightest fur texel), so the
baked shading stays. White markings are texel rectangles of that side view
(PATTERNS, checked against renders of the baked frames), painted from a
white ramp at the same brightness. Everything else (eyes, nose, mouth,
whisker and inner-ear swatches) is kept as it is.
"""
import json
import sys
import numpy as np
from PIL import Image
HUE = (0.03, 0.14)
SAT = 0.15
# White markings, texel rectangles (x0, x1, y0, y1 inclusive) of the Toon
# Cat's 64 x 64 atlas: columns 0-4 face / throat, 5-6 chest and front legs,
# 9-11 hind legs; rows ~14 (floor) to ~40 (head top).
PATTERNS = {
'muzzle': [(0, 3, 24, 45)], # chin, mouth, muzzle and cheeks up between the eyes
'chest': [(4, 4, 22, 34), (5, 5, 20, 33), (6, 6, 20, 30), (7, 8, 22, 29)], # throat, chest, front legs' fronts, belly
'paws': [(5, 6, 13, 19), (9, 11, 13, 18)], # front and hind paws
}
WHITE = ('#dcdcdf', '#ffffff')
def rgb(c):
c = c.lstrip('#')
return np.array([int(c[i:i + 2], 16) for i in (0, 2, 4)], dtype=float) / 255
def hsv(a):
mx, mn = a.max(-1), a.min(-1)
d = mx - mn
s = np.where(mx > 0, d / np.maximum(mx, 1e-9), 0)
r, g, b = a[..., 0], a[..., 1], a[..., 2]
dd = np.maximum(d, 1e-9)
h = np.where(mx == r, ((g - b) / dd) % 6, np.where(mx == g, (b - r) / dd + 2, (r - g) / dd + 4)) / 6
return np.where(d > 0, h, 0), s, mx
def luma(a):
return a @ np.array([0.2126, 0.7152, 0.0722])
def fur_mask(a):
h, s, _ = hsv(a)
return (h >= HUE[0]) & (h <= HUE[1]) & (s > SAT)
def recolor(base, spec):
a = np.asarray(base.convert('RGB')).astype(float) / 255
fur = fur_mask(a)
lum = luma(a)
lo, hi = lum[fur].min(), lum[fur].max()
t = np.clip((lum - lo) / (hi - lo), 0, 1)[..., None]
dark, light = (rgb(c) for c in spec['fur'])
out = a.copy()
out[fur] = (dark + (light - dark) * t)[fur]
marks = np.zeros(fur.shape, bool)
for name in spec.get('white', []):
for x0, x1, y0, y1 in PATTERNS[name]:
marks[y0:y1 + 1, x0:x1 + 1] = True
marks &= fur
wd, wl = (rgb(c) for c in WHITE)
out[marks] = (wd + (wl - wd) * t)[marks]
return Image.fromarray((np.clip(out, 0, 1) * 255).round().astype(np.uint8), 'RGB'), fur
if __name__ == '__main__':
base, spec_path, out = sys.argv[1:4]
spec = json.load(open(spec_path))
img, _ = recolor(Image.open(base), spec)
img.save(out, optimize=True)
+922
View File
@@ -0,0 +1,922 @@
// VR pet, SteamVR adapter: draws the core's pet (core.js) in SteamVR's
// systemui page (DevTools 127.0.0.1:8087). steam-ui-patches injects
// ((opts, baked, find, hooks) => { <core.js> <this file> return vrPetSystemui(…); })(…)
// every 15 s (find, hooks: steam-frame-nix's finders.js, hooks.js); an
// unchanged pet returns "unchanged".
//
// Frames: systemui builds vrcompositor's scene graph from its DOM, and a
// "rendermodel" node draws an OBJ (absolute path). Render models are static,
// so every baked frame is its own OBJ under one world-locked root (standing
// space, y = 0 the floor). vrcompositor loads a model when its node appears
// (3-8 ms each, one after the other), drops it when it goes, reloads it when
// its source changes, and draws every mounted one (hidden ones at scale ~0
// too). So frames are mounted gradually and kept while they may be needed:
// opts.mount "dynamic" the clips the core prefetches, LOADS_PER_TICK frames
// per tick, most urgent clip first; a clip shows once
// mounted SETTLE_MS; unused clips stay KEEP_MS, fewer
// beyond CAP frames (actions first, then the least
// recently used);
// "all" every frame (gradually), all the time.
// Sends: every change resends the whole scene graph (~65 kB). The scene graph
// module's send walks the DOM (~16 ms), the public forceLayoutUpdate also
// re-measures and repaints the page (at 12 Hz it lagged the dashboard), so
// the pet resends the dashboard's last message itself with only its subtree
// replaced (fastSend); the DOM is kept in sync for the dashboard's own sends.
// At most one send per tick, only on a change, deferred while the head turns
// fast (TURN_*) or the pet is out of view (VIEW_DEFER_MS; mounts still go
// out); a drag's start and end at once. Input is read-only: the head and hand
// poses once per tick, the laser's mouse events on the pet's own panels; no
// buttons, nothing synthesized.
//
// Laser drag: a grip bar panel above the pet (only-visible-with-laser, a grab
// handle like a window's). Pressing it parents the root to that hand
// (parent-path; the hand is in the event's CapsLock / NumLock state, as in
// SteamVR's GrabHandle) at the grab point's pose in the controller's frame,
// so vrcompositor carries it rigidly every display frame, like SteamVR's
// floating windows (the dashboard's grab-transform would re-aim it at the
// head instead). A grab-scale under the root (its event panel the bar) is the
// thumbstick push; on release its distance is asked (SGQueryService) and the
// pet falls from there. The body (frames and bar) is set once per tick:
// upright below the grab point, turned with the controller's heading.
// Controls: ⋯ and X below the bar, the ⋯ menu above it (the models, then
// Summon / Sit / Lie down / Sleep); sizes in VrPetCore.ui. Their textures are
// regions of this page (window.__sfuiRegions, a shared allocator, if there
// is one; else free spots clear of the other panels and the keyboard strip's
// band). Buttons act only on a press and a release both on them (a stray
// release does nothing), never during a drag or AFTER_DRAG_MS after. The
// menu's panel is in the scene graph only while open, but its DOM is laid out
// all along, so its embedded-UV slot is valid when it opens (no layout then).
//
// window.__sfuiPet (S):
// api { version, show, hide, status, models, setModel } for cli.mjs
// cat the core: cat.command('sit' | 'summon' | …), cat.state() (DevTools)
// debug: stats(), log, drag('left' | 'right' | null), where(id) (a node's
// pose in vrcompositor), dragTrace (4 Hz while dragged), pause(), resume()
// Hidden: no timers, no sends; `hidden` and `model` are kept in the patch's
// store next to the core's spot and pose, so a fresh page builds a hidden pet
// without putting it in the page.
function vrPetSystemui(opts, catalog, find, hooks) {
const VERSION = 16;
const G = window;
const KEY = 'vr-pet';
const old = G.__sfuiPet;
const sig = JSON.stringify([VERSION, opts, catalog]);
// (a hidden cat's root is out of the page on purpose: unchanged too)
if (old?.sig === sig && (old.hidden || old.root?.isConnected)) return 'unchanged';
if (typeof G.VRHTML?.NextSGID !== 'function' || typeof G.VRHTML?.GetPose !== 'function' || typeof G.forceLayoutUpdate !== 'function')
return 'SteamVR internals changed, no cat: missing VRHTML.NextSGID / VRHTML.GetPose / forceLayoutUpdate';
const M = VrPetCore.math, UI = VrPetCore.ui;
try { old?.dispose?.(); } catch { /* gone */ }
// Scene graph updates. Fast path: resend the dashboard's last scene graph
// message (captured, with the other patches' rewrites already applied) with
// only the cat's subtree replaced, straight to the mailbox's websocket; no
// DOM walk. Slow path (until a message is captured, and whenever the cat's
// root is not in it): the dashboard's own send function. Last resort:
// forceLayoutUpdate.
const PREFIX = 'mailbox_send vrcompositor_systemlayer ';
const HEAD = PREFIX + '{"type":"update_scene_graph"';
let slowSend = null, sender = 'forceLayoutUpdate (fallback)', proto = null;
try {
const req = find.getWebpackRequire('webpackChunkvrwebui');
const mod = find.findModule(req, { includes: ['update_scene_graph', 'sg_mailbox'] }, 'scene graph module');
slowSend = find.findExport(mod.exports, { type: 'function', includes: ['update_scene_graph'] }, 'scene graph send').value;
sender = 'scene graph send';
const mb = find.findModule(req, { includes: ['"mailbox_send "', 'WebSocketSend(', '"vrcompositor_systemlayer"'] }, 'mailbox module');
proto = find.findExport(mb.exports, { type: 'class', protoMethods: ['SendMessage', 'SendMessageAndWaitForResponse', 'RegisterHandler'] }, 'Mailbox').value.prototype;
} catch (e) { sender += `: ${e.message}`; }
// The scene graph mailbox's websocket (its WebSocketSend is a read-only
// bound method, so the socket's send is wrapped; re-wrapped on reconnect).
const cap = { sock: null, send: null, str: null, parsed: null, node: null, pre: '', post: '', seen: 0 };
const capture = (mailbox) => {
const sock = mailbox?.m_wsWebSocketToServer;
if (!sock || cap.sock === sock || typeof sock.send !== 'function') return;
const orig = sock.send.__sfuiPetOrig ?? sock.send;
const wrapper = function (str) {
if (typeof str === 'string' && str.startsWith(HEAD)) G.__sfuiPet?.onSceneGraph?.(str);
return orig.apply(this, arguments);
};
wrapper.__sfuiPetOrig = orig;
sock.send = wrapper;
Object.assign(cap, { sock, send: (str) => sock.readyState === 1 && (orig.call(sock, str), true) });
};
if (proto && hooks) {
hooks.before(proto, 'SendMessage', 'vr-pet', function (args) {
cap.seen++;
if (args[1]?.type === 'update_scene_graph' && cap.sock !== this.m_wsWebSocketToServer) capture(this);
});
}
let sends = 0, fast = 0;
let fastSend = () => false; // set below, once the cat's nodes exist
const update = () => {
sends++;
if (fastSend()) { fast++; return; }
slowSend ? slowSend() : G.forceLayoutUpdate();
};
const HIDDEN = '0.0001 0.0001 0.0001', SHOWN = '1 1 1';
const S = G.__sfuiPet = { version: VERSION, sig, log: [], hidden: false };
const log = (...a) => { S.log.push([Math.round(performance.now()), ...a]); if (S.log.length > 80) S.log.shift(); };
const setAttrs = (e, o) => { for (const [k, v] of Object.entries(o)) e.setAttribute(k, String(v)); };
// The persistent value (steam-ui-patches' store): the core's spot and pose
// plus `hidden`; every write merges (the core's save() writes its own keys only).
const stored = () => { try { const v = G.__sfuiStore?.get?.(KEY); return v && typeof v === 'object' ? v : {}; } catch { return {}; } };
const store = (patch) => { try { G.__sfuiStore?.set?.(KEY, { ...stored(), ...patch }); } catch { /* no store */ } };
const startHidden = stored().hidden === true;
// ---- the model (catalog: index.py's models.json; its frame set is the core's baked data) ----
const models = catalog.models;
let modelId = models[stored().model] ? stored().model : catalog.default;
const unknownModel = stored().model != null && !models[stored().model] ? stored().model : null;
let baked = catalog.frameSets[models[modelId].frames];
// ---- scene graph: root (position + yaw) > one transform per frame > rendermodel ----
const root = document.createElement('vsg-transform');
root.id = 'sfui-vr-pet';
setAttrs(root, { translation: '0 0 0', rotation: '1 0 0 0', scale: '1 1 1', sgid: G.VRHTML.NextSGID() });
// ---- laser drag: root (carried: parent-path the hand) > push (grab-scale) > turn > frames, and > barBody > the handle ----
let overlayKey = 'system.systemui';
try { overlayKey = G.VRHTML.VROverlay.ThisOverlayKey() || overlayKey; } catch { /* default */ }
// The thumbstick push / pull while dragged: a grab-scale, as SteamVR's
// floating windows (min / max distance from the hand, scroll speed).
const PUSH = { 'min-distance': 0.25, 'max-distance': 5, 'scroll-speed': 10 };
const PUSHED_ID = 'sfui-vr-pet-pushed';
const xform = (id) => {
const t = document.createElement('vsg-transform');
setAttrs(t, { translation: '0 0 0', rotation: '1 0 0 0', scale: '1 1 1', sgid: G.VRHTML.NextSGID() });
t.sg = { type: 'transform', properties: { sgid: +t.getAttribute('sgid'), translation: [0, 0, 0], rotation: [1, 0, 0, 0], scale: [1, 1, 1] }, children: [] };
if (id) { t.id = id; t.sg.properties.id = `${overlayKey}::${id}`; }
return t;
};
// (no DOM tag of its own: a node built by buildNode, its children walked)
const push = document.createElement('vsg-node');
const pushSgid = G.VRHTML.NextSGID();
let pushGen = 0;
push.setAttribute('sgid', String(pushSgid));
push.sg = { type: 'grab-scale', properties: { sgid: pushSgid, 'is-active': false, 'reset-generation': 0, ...PUSH }, children: [] };
push.buildNode = (ctx) => [ctx, { type: 'grab-scale', properties: { ...push.sg.properties } }];
root.appendChild(push);
// `pushed`: where the push has the carried frame (asked on release);
// `turn` (the frames) and `barBody` (the handle): the cat's upright body,
// identity unless dragged (then below the grab point, see dragBody()).
const pushed = xform(PUSHED_ID), turn = xform(), barBody = xform();
push.appendChild(pushed);
push.appendChild(turn);
push.appendChild(barBody);
let bodyQ = [1, 0, 0, 0], bodyP = [0, 0, 0];
const setBody = (q, p) => { // -> changed
const home = q[0] === 1 && p.every((v) => v === 0);
if (!(home && (bodyQ[0] !== 1 || bodyP.some((v) => v !== 0)))
&& M.qdiffDeg(q, bodyQ) < 0.2 && Math.hypot(...p.map((v, k) => v - bodyP[k])) < 5e-4) return false;
bodyQ = q; bodyP = p;
for (const t of [turn, barBody]) {
t.setAttribute('rotation', fmt(q, 5)); t.setAttribute('translation', fmt(p, 5));
t.sg.properties.rotation = q; t.sg.properties.translation = p;
}
return true;
};
const setShown = (xf, on) => { xf.setAttribute('scale', on ? SHOWN : HIDDEN); xf.sg.properties.scale = on ? [1, 1, 1] : [1e-4, 1e-4, 1e-4]; };
let frames = {}; // clip -> [transform]
// One transform + rendermodel per baked frame of the model's frame set.
const buildFrames = (dir) => {
frames = {};
for (const [name, c] of Object.entries(baked.clips)) {
frames[name] = [];
for (let i = 0; i < c.frames; i++) {
const xf = document.createElement('vsg-transform');
setAttrs(xf, { translation: '0 0 0', rotation: '1 0 0 0', scale: HIDDEN, sgid: G.VRHTML.NextSGID() });
const node = document.createElement('vsg-node');
const sgid = G.VRHTML.NextSGID();
node.setAttribute('sgid', String(sgid));
const rm = { type: 'rendermodel', properties: { sgid, source: `${dir}/${name}_${i}.obj`, culling: 0 } };
node.buildNode = (ctx) => [ctx, { type: rm.type, properties: { ...rm.properties } }];
xf.appendChild(node);
// The same node as the dashboard's DOM walk builds it, for the fast path.
xf.sg = { type: 'transform', properties: { sgid: +xf.getAttribute('sgid'), translation: [0, 0, 0], rotation: [1, 0, 0, 0], scale: [1e-4, 1e-4, 1e-4], 'parent-id': null },
children: [rm] };
xf.rm = rm; xf.file = `${name}_${i}.obj`; // (its source: the model's directory + file)
frames[name].push(xf);
}
}
};
buildFrames(models[modelId].dir);
// ---- mounted frames (see the header) ----
const LOADS_PER_TICK = 3, SETTLE_MS = 150, KEEP_MS = 60000;
const all = opts.mount === 'all';
const CAP = all ? Infinity : 170;
// one-shot actions (the cat's, an animal's own): evicted before the poses' clips
const rareOf = () => new Set([...Object.keys(VrPetCore.ACTIONS), ...Object.keys(baked.species?.actions ?? {})]);
let RARE = rareOf();
const mounted = new Map(); // clip -> { n: frames mounted (in order), last: time of the last mount, used }
let want = new Set(), nMounted = 0, loads = 0, evictions = 0;
function pump(now) { // mount the wanted clips' next frames, most urgent clip first
let budget = LOADS_PER_TICK, changed = false;
for (const name of want) {
let m = mounted.get(name);
if (!m) mounted.set(name, m = { n: 0, last: now, used: now });
m.used = now;
const fr = frames[name];
while (budget > 0 && m.n < fr.length) { turn.appendChild(fr[m.n++]); m.last = now; budget--; nMounted++; loads++; changed = true; }
}
return changed;
}
function evict(now) { // unused clips after KEEP_MS; beyond CAP actions first, then the least recently used
const idle = [...mounted].filter(([name]) => !want.has(name) && !frames[name].includes(shown))
.sort((a, b) => RARE.has(b[0]) - RARE.has(a[0]) || a[1].used - b[1].used);
let changed = false;
for (const [name, m] of idle) {
if (now - m.used < KEEP_MS && nMounted <= CAP) continue;
for (let i = 0; i < m.n; i++) frames[name][i].remove();
nMounted -= m.n; evictions++;
mounted.delete(name);
changed = true;
}
return changed;
}
const rootSgid = +root.getAttribute('sgid');
S.onSceneGraph = (str) => { cap.str = str; };
// A captured message is parsed once into the text before and after the
// pet's node (cap.pre / cap.post); a send serializes only the pet's node.
const HOLE = '"__sfuiPetNode__"';
fastSend = () => {
if (!cap.send || !cap.str || !root.isConnected) return false;
if (cap.parsed !== cap.str) {
cap.parsed = cap.str;
cap.node = null;
const msg = JSON.parse(cap.str.slice(PREFIX.length));
msg.retired_sgids = [];
if (msg.scene_graph?.properties && 'eDashboardRelatch' in msg.scene_graph.properties)
msg.scene_graph.properties.eDashboardRelatch = 0; // None: never repeat a relatch request
const walk = (n, parent, k) => {
if (cap.node || !n || typeof n !== 'object') return;
if (Array.isArray(n)) { n.forEach((c, i) => walk(c, n, i)); return; }
if (n.properties?.sgid === rootSgid) { cap.node = n; parent[k] = JSON.parse(HOLE); return; }
if (n.children) walk(n.children);
};
walk(msg.scene_graph);
if (cap.node) [cap.pre, cap.post] = JSON.stringify(msg).split(HOLE);
}
if (!cap.node) return false;
const p = cap.node.properties;
p.translation = lastT.split(' ').map(Number);
p.rotation = lastR.split(' ').map(Number);
const pp = root.getAttribute('parent-path');
if (pp) p['parent-path'] = pp; else delete p['parent-path'];
turn.sg.children = [...turn.children].map((xf) => xf.sg).filter(Boolean);
barBody.sg.children = handle ? [handle.sg()] : [];
push.sg.children = [pushed.sg, turn.sg, barBody.sg];
cap.node.children = [push.sg];
return cap.send(PREFIX + cap.pre + JSON.stringify(cap.node) + cap.post);
};
// ---- the grip bar, the controls and the menu (sizes: VrPetCore.ui) ----
const { HANDLE, CONTROLS, MENU } = UI;
const liftOf = () => UI.liftOf(baked.height);
// drag: { hand, phase: 'drag' | 'ending' (let go) | 'asking' (how far
// pushed?) | 'done', w0: the pet's pose at the grab, rel: the grab point in
// the controller's frame, push, want: the body's orientation } while the
// laser drags the pet
let drag = null;
// A button's press (X, ⋯, the menu's rows): armed by a press on it (not
// while dragged or just after), carried to the release by the window's
// capture listener, which runs before the button's own mouseup: it acts
// only if the release is on the button that was pressed.
let armed = null, upArmed = null, dragEndAt = -Infinity;
const dragBusy = () => !!drag || performance.now() - dragEndAt < UI.AFTER_DRAG_MS;
const handSide = (e) => ({ 2: 'left', 3: 'right' })[(e.getModifierState?.('CapsLock') ? 1 : 0) | (e.getModifierState?.('NumLock') ? 2 : 0)];
// SteamVR's scene graph query service (looked up once).
let sgq = null;
const sgQuery = () => {
if (sgq) return sgq;
if (G.SGQueryService?.requestSGTransform) return (sgq = G.SGQueryService);
try {
const req = find.getWebpackRequire('webpackChunkvrwebui');
const mod = find.findModule(req, { includes: ['requestSGTransform', '"sgqueryservice"'] }, 'scene graph query module');
return (sgq = find.findExport(mod.exports, { type: 'class', protoMethods: ['requestSGTransform'] }, 'SGQueryService').value.getInstance());
} catch (e) { log('no SGQueryService', String(e)); return null; }
};
// A promise, rejected after ms (its timer cleared once it settles).
const within = (promise, ms) => {
let timer;
return Promise.race([promise, new Promise((_, no) => { timer = setTimeout(() => no(new Error('timeout')), ms); })])
.finally(() => clearTimeout(timer));
};
// Where vrcompositor has a node of ours: its world pose (standing space).
const where = (id, timeoutMs = 600) => {
const q = sgQuery();
if (!q) return Promise.reject(new Error('no SGQueryService'));
return within(q.requestSGTransform(`${overlayKey}::${id}`, 0).then(toPose), timeoutMs);
};
// How far the thumbstick pushed the carried frame (m, along its -Z): the
// pushed origin in the root's frame, as SteamVR's floating windows ask it.
const pushOf = (timeoutMs = 250) => {
const q = sgQuery();
if (typeof q?.requestSGTransformRelative !== 'function') return Promise.reject(new Error('no SGQueryService'));
return within(q.requestSGTransformRelative(`${overlayKey}::${root.id}`, `${overlayKey}::${PUSHED_ID}`, 0).then((t) => {
const z = -t?.translation?.z;
if (!Number.isFinite(z)) throw new Error('no push');
S.pushSeen = { z: +z.toFixed(4), scale: t.scale ? [t.scale.x, t.scale.y, t.scale.z].map((v) => +(+v).toFixed(4)) : null };
return z;
}), timeoutMs);
};
const startDrag = (side) => {
if (!side || drag || !handle || S.hidden || S.cat?.state().falling) return; // (a falling cat is not caught)
drag = { hand: side, phase: 'drag', w0: null, rel: null, push: 0, want: null, psi0: null };
disarm(); barActive(true);
log('drag', side);
};
const endDrag = () => {
barActive(false);
if (drag?.phase !== 'drag') return;
drag.phase = 'ending'; dragEndAt = performance.now(); log('drag end');
};
// The bar's dragging look ([data-active], as the stock GrabHandle's ForceActive): from the press to the release.
const barActive = (on) => { const el = handle?.barContent; if (on) el?.setAttribute('data-active', ''); else el?.removeAttribute('data-active'); };
const disarm = () => { armed?.removeAttribute('data-armed'); armed = null; };
// A button (X, ⋯, a menu row): press and release on it -> act() (not while dragged or just after).
const button = (el, act) => {
el.addEventListener('mousedown', () => { disarm(); if (!dragBusy()) { armed = el; el.setAttribute('data-armed', ''); } });
el.addEventListener('mouseup', () => { const ok = upArmed === el; upArmed = null; if (ok && !dragBusy()) act(); });
el.addEventListener('mouseleave', () => { if (armed === el) disarm(); });
};
const div = (parent, css, text) => {
const e = document.createElement('div');
if (css) e.setAttribute('class', css);
if (text != null) e.textContent = text;
parent.appendChild(e);
return e;
};
const inside = (t, el) => { for (let n = t; n; n = n.parentNode) if (n === el) return true; return false; };
function makeHandle() {
const app = G.SGApp;
if (typeof app?.addEmbeddedPanelUVs !== 'function') { log('no drag handle: SGApp.addEmbeddedPanelUVs missing'); return null; }
// Page regions for the textures: the bar with the controls centred below
// it (vr-pet:bar), the menu (vr-pet:menu). Claimed from the shared page
// region allocator when there is one (window.__sfuiRegions);
// else free spots of the page (from the bottom right, 4 px apart) clear of
// the page's other panels (SGApp.m_mapPanels' non-external ones and every
// [vsg-type=panel]), the keyboard suggestions strip's band (the bottom
// 120 px, up to 1706 px across; it is only in the page while shown) and
// row 0 (the embedded-data scanline).
const R = G.__sfuiRegions ?? null;
const claimed = [];
const GAP = 4, STRIP = { w: 1706, h: 120 };
const rs = [];
const occupy = (x, y, w, h) => { if (w > 0 && h > 0) rs.push({ left: x, top: y, right: x + w, bottom: y + h }); };
const ours = (name) => String(name ?? '').startsWith('sfui-vr-pet');
const mp = app.m_mapPanels;
for (const fp of (mp?.values ? [...mp.values()] : Object.values(mp ?? {}))) {
if (!fp || fp.isExternal || fp.props?.overlay_key || ours(fp.props?.debug_name)) continue;
let r = null;
try { r = fp.getCurrentRootElement?.()?.getBoundingClientRect?.(); } catch { /* gone */ }
if (r?.width > 0 && r?.height > 0) occupy(r.left ?? r.x, r.top ?? r.y, r.width, r.height);
else if (fp.m_Rect) occupy(fp.m_Rect.x, fp.m_Rect.y, fp.m_Rect.width, fp.m_Rect.height);
}
for (const p of document.querySelectorAll('[vsg-type="panel"]')) {
if (ours(p.id)) continue;
const r = p.getBoundingClientRect();
occupy(r.left, r.top, r.width, r.height);
}
occupy(0, innerHeight - STRIP.h, Math.min(STRIP.w, innerWidth), STRIP.h);
occupy(0, 0, innerWidth, 1);
const freeSpot = (W, H) => {
for (let y = innerHeight - H - GAP; y >= GAP; y -= 8) {
for (let x = innerWidth - W - GAP; x >= GAP; x -= 8) {
if (!rs.some((r) => r.top < y + H + GAP && r.bottom > y - GAP && r.left < x + W + GAP && r.right > x - GAP)) {
occupy(x, y, W, H);
return { x, y };
}
}
}
return null;
};
// -> { x, y } of the region (onMove(spot): the allocator moved it)
const region = (owner, W, H, onMove) => {
if (R) {
const at = (r) => r && { x: r.x ?? r.left, y: r.y ?? r.top };
try {
const r = at(R.claim(owner, W, H, { onMove: (nr) => { const s = at(nr); if (s) onMove(s); } }));
claimed.push(owner);
if (!r) log(`${owner}: no region`);
return r;
} catch (e) { log(`regions.claim ${owner}`, String(e)); }
}
return freeSpot(W, H);
};
const moved = () => { try { G.forceLayoutUpdate(); } catch { /* next layout */ } };
const moveTo = (el, x, y) => { if (el) Object.assign(el.style, { left: `${x}px`, top: `${y}px` }); };
let bar = null, ctl = null, menu = null, menuOpen = false;
const W = Math.max(HANDLE.widthPx, CONTROLS.widthPx), H = HANDLE.heightPx + 4 + CONTROLS.heightPx;
const barSpot = (s) => ({ x: s.x + (W - HANDLE.widthPx) / 2, y: s.y }), ctlSpot = (s) => ({ x: s.x + (W - CONTROLS.widthPx) / 2, y: s.y + HANDLE.heightPx + 4 });
const spot = region('vr-pet:bar', W, H, (s) => {
if (bar) { const b = barSpot(s); moveTo(bar.content, b.x, b.y); }
if (ctl) { const c = ctlSpot(s); moveTo(ctl.content, c.x, c.y); }
log('bar region moved', s.x, s.y); moved();
});
if (!spot) { log('no drag handle: no free spot'); return null; }
const { ids, breaks, px: menuPx } = UI.menuLayout(catalog);
const mspot = region('vr-pet:menu', MENU.widthPx, menuPx, (s) => { if (menu) moveTo(menu.content, s.x, s.y); log('menu region moved', s.x, s.y); moved(); });
if (!mspot) log('no menu: no free spot');
// An embedded panel: `content` (fixed at `rect` of the page) is its
// texture, registered in SGApp's embedded-UV table. With `when`: in the
// scene graph only while when() (its node stays in the page, so its slot
// is measured all along).
const embed = (id, rect, props, when = null) => {
const content = document.createElement('div');
content.id = `${id}-content`;
Object.assign(content.style, { position: 'fixed', left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.w}px`, height: `${rect.h}px`,
display: 'flex', alignItems: 'center', justifyContent: 'center', boxSizing: 'border-box', zIndex: 1, pointerEvents: 'auto' });
for (const t of ['mousedown', 'mouseup', 'click', 'dblclick', 'pointerdown', 'pointerup', 'contextmenu', 'wheel'])
content.addEventListener(t, (e) => e.stopPropagation()); // nothing reaches the dashboard's handlers
const node = document.createElement('vsg-node');
node.id = id;
node.setAttribute('vsg-type', 'panel');
const sgid = G.VRHTML.NextSGID();
node.setAttribute('sgid', String(sgid));
node.appendChild(content);
const fp = { // what SGApp's embedded-UV table reads of a panel
props: { debug_name: id }, isExternal: false, m_Rect: { x: 0, y: 0, width: 0, height: 0 }, idx: undefined,
getSGID: () => sgid, getEmbeddedIndex: () => fp.idx, getCurrentRootElement: () => node,
updateLayoutValues() { const r = content.getBoundingClientRect(); fp.m_Rect = { x: r.x, y: r.y, width: r.width, height: r.height }; },
};
fp.idx = app.addEmbeddedPanelUVs(fp);
if (fp.idx == null) { log(`${id}: no embedded UV slot`); return null; }
const panel = () => {
const r = content.getBoundingClientRect(), PW = innerWidth, PH = innerHeight;
return { type: 'panel', properties: {
id: `${overlayKey}::${id}`, sgid, key: overlayKey, debug_name: id, origin: [0, -1], visibility: 0, 'lasermouse-filtering': 0,
'can-take-keyboard-focus': false, 'sort-depth-bias': -1, 'scale-index': 0, ...props,
'embedded-uv-index': fp.idx, uv_min: [r.x / PW, r.y / PH], uv_max: [(r.x + r.width) / PW, (r.y + r.height) / PH],
} };
};
node.buildNode = (ctx) => (when && !when() ? [ctx, null] : [{ ...ctx, currentPanel: fp, bInsideReparentedPanel: false }, panel()]);
return { content, node, sgid, panel, dispose() { content.remove(); try { app.removeEmbeddedPanelUVs(fp); } catch { /* gone */ } } };
};
// The grip bar (like SteamVR's window grab handles): the push's event panel.
bar = embed('sfui-vr-pet-handle', { ...barSpot(spot), w: HANDLE.widthPx, h: HANDLE.heightPx },
{ width: HANDLE.widthM, interactive: true, scrollable: true, 'only-visible-with-laser': true, 'is-grab-handle': true, 'hide-laser-when-clicking': true });
if (!bar) { for (const o of claimed) { try { R.release?.(o); } catch { /* gone */ } } return null; }
div(bar.content, 'sfui-vr-pet-grip');
bar.content.addEventListener('mousedown', (e) => startDrag(handSide(e)));
// Every release (capture phase: before the target's own handlers, even
// those that stop it): hand the armed button over to its mouseup, end a drag.
const onUp = () => { upArmed = armed; disarm(); endDrag(); };
G.addEventListener('mouseup', onUp, true);
// The controls row below the bar: ⋯ (the menu) and X (hide). The stock
// window bar's look (legacydashboardframecontrols_*: one #23262E section,
// buttons hover #3D4450, pressed #67707b, .6 opacity until hovered).
ctl = embed('sfui-vr-pet-controls', { ...ctlSpot(spot), w: CONTROLS.widthPx, h: CONTROLS.heightPx },
{ width: CONTROLS.widthM, interactive: true, scrollable: false, 'only-visible-with-laser': true, 'is-grab-handle': false, 'hide-laser-when-clicking': false });
const css = document.createElement('style');
// The grip: the stock GrabHandleBar (grabhandle_*: a 12 px #3d4450 pill at
// 1/1.1 width; hovered or dragged (ForceActive): white at full width, .08 s).
css.textContent = '.sfui-vr-pet-grip{width:84%;height:12px;background-color:var(--dashboard-control-bar-button-color-a,#3d4450);border-radius:20px;'
+ 'transition:transform .08s ease-out,background-color .08s ease-out;transform-origin:center;transform:scaleX(calc(1 / 1.1))}'
+ '#sfui-vr-pet-handle-content:hover .sfui-vr-pet-grip,#sfui-vr-pet-handle-content[data-active] .sfui-vr-pet-grip{background-color:#fff;transform:scaleX(1)}'
+ '#sfui-vr-pet-controls-content{background:#23262E;border-radius:10px;opacity:.6;transition:opacity .4s ease-out;overflow:hidden}'
+ '#sfui-vr-pet-controls-content:hover{opacity:1;transition:opacity 0s}'
+ '.sfui-vr-pet-btn{flex:1;height:100%;display:flex;align-items:center;justify-content:center}'
+ '.sfui-vr-pet-btn:hover{background:#3D4450}.sfui-vr-pet-btn[data-armed]{background:#67707b}'
+ '#sfui-vr-pet-menu-content{display:block;background:#2d3239;border-radius:10px;overflow:hidden;color:#fff;'
+ `font:22px/1.2 "Motiva Sans",Arial,sans-serif;padding:${MENU.padPx}px 0}`
+ '.sfui-vr-pet-row{display:flex;align-items:center;gap:16px;padding:8px 24px 8px 20px;box-sizing:border-box;white-space:nowrap}'
+ '.sfui-vr-pet-row:hover{background:#3d4450}.sfui-vr-pet-row[data-armed]{background:#67707b}'
+ '.sfui-vr-pet-row img{width:32px;height:32px;flex:none}.sfui-vr-pet-name{flex:1}'
+ '.sfui-vr-pet-check{color:#1a9fff;font-weight:bold;visibility:hidden}.sfui-vr-pet-check[data-on]{visibility:visible}'
+ '.sfui-vr-pet-sep{height:1px;margin:4px 0;background:rgba(255,255,255,.07)}';
document.head?.appendChild(css);
const icon = (el, path) => el.insertAdjacentHTML?.('beforeend', `<svg width="60%" height="60%" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round">${path}</svg>`);
let more = null, x = null;
if (ctl) {
more = div(ctl.content, 'sfui-vr-pet-btn');
more.setAttribute('data-act', 'menu');
icon(more, '<path d="M5 12h.01M12 12h.01M19 12h.01" stroke-width="3.6"/>');
x = div(ctl.content, 'sfui-vr-pet-btn');
x.setAttribute('data-act', 'close');
icon(x, '<path d="M6 6L18 18M18 6L6 18"/>');
button(x, () => hide());
button(more, () => (menuOpen ? closeMenu() : openMenu()));
}
// The ⋯ menu: the models (thumbnail, name, a check on the current one), then commands.
menu = mspot && ctl && embed('sfui-vr-pet-menu', { x: mspot.x, y: mspot.y, w: MENU.widthPx, h: menuPx },
{ width: MENU.widthM, interactive: true, scrollable: false, 'only-visible-with-laser': true, 'is-grab-handle': false, 'hide-laser-when-clicking': false },
() => menuOpen);
const checks = {};
if (menu) {
menu.content.style.display = 'block'; // rows one below the other (embed() centres a flex row)
menu.content.style.visibility = 'hidden'; // while closed (laid out all along: its UV slot stays valid)
for (const id of ids) {
if (breaks.includes(id)) div(menu.content, 'sfui-vr-pet-sep');
const row = div(menu.content, 'sfui-vr-pet-row');
row.setAttribute('data-model', id);
row.style.height = `${MENU.modelRowPx}px`;
const img = document.createElement('img');
img.setAttribute('src', models[id].thumb ?? '');
row.appendChild(img);
div(row, 'sfui-vr-pet-name', models[id].name);
checks[id] = div(row, 'sfui-vr-pet-check', '✓');
button(row, () => { closeMenu(); setModel({ id }); });
}
div(menu.content, 'sfui-vr-pet-sep');
for (const [cmd, label] of UI.MENU_CMDS) {
const row = div(menu.content, 'sfui-vr-pet-row', label);
row.setAttribute('data-cmd', cmd);
row.style.height = `${MENU.cmdRowPx}px`;
button(row, () => { closeMenu(); S.cat.command(cmd); log('menu', cmd); });
}
}
const markModel = () => { for (const [id, c] of Object.entries(checks)) { if (id === modelId) c.setAttribute('data-on', ''); else c.removeAttribute('data-on'); } };
markModel();
// Closing: a press outside the menu and the controls (capture: every
// press on this page), or the laser away from both for MENU.leaveMs.
let leaveTimer = null;
const stay = () => { clearTimeout(leaveTimer); leaveTimer = null; };
const leave = () => { stay(); if (menuOpen) leaveTimer = setTimeout(() => { leaveTimer = null; closeMenu(); }, MENU.leaveMs); };
for (const c of [ctl?.content, menu?.content].filter(Boolean)) { c.addEventListener('mouseenter', stay); c.addEventListener('mouseleave', leave); }
const onDown = (e) => { if (menuOpen && !inside(e.target, menu?.content) && !inside(e.target, ctl?.content)) closeMenu(); };
G.addEventListener('mousedown', onDown, true);
// lift above the cat > facing the user > the bar; the controls CTL_Y below
// it, the menu (in the scene graph while open) MENU_Y above it and MENU.forwardM nearer
const outer = document.createElement('vsg-transform'), facing = document.createElement('vsg-head-facing-transform');
const osgid = G.VRHTML.NextSGID(), fsgid = G.VRHTML.NextSGID();
let lift = liftOf();
setAttrs(outer, { translation: `0 ${lift} 0`, rotation: '1 0 0 0', scale: '1 1 1', sgid: osgid });
facing.setAttribute('sgid', String(fsgid));
facing.appendChild(bar.node);
const sub = (y, z, node) => { // a transform under `facing` holding one panel
const t = document.createElement('vsg-transform'), sgid = G.VRHTML.NextSGID();
setAttrs(t, { translation: `0 ${y} ${z}`, rotation: '1 0 0 0', scale: '1 1 1', sgid });
t.appendChild(node);
return { t, sg: (panel) => ({ type: 'transform', properties: { sgid, translation: [0, y, z], rotation: [1, 0, 0, 0], scale: [1, 1, 1] }, children: [panel] }) };
};
const ctlH = CONTROLS.widthM * CONTROLS.heightPx / CONTROLS.widthPx;
const CTL_Y = -(CONTROLS.gapM + ctlH); // its bottom (origin [0, -1]): its top gapM below the bar
const MENU_Y = HANDLE.widthM * HANDLE.heightPx / HANDLE.widthPx + MENU.gapM; // its bottom: gapM above the bar's top
const below = ctl && sub(CTL_Y, 0, ctl.node);
if (below) facing.appendChild(below.t);
// The menu's transform stays in the page (its panel is built only while
// open); opening / closing shows / hides its content and sends once.
const above = menu && sub(MENU_Y, MENU.forwardM, menu.node);
if (above) facing.appendChild(above.t);
const openMenu = () => {
if (menuOpen || !above || S.hidden) return;
menuOpen = true; markModel(); stay();
menu.content.style.visibility = '';
update();
log('menu open');
};
const closeMenu = ({ send = true } = {}) => {
stay();
if (!menuOpen) return;
menuOpen = false; disarm();
menu.content.style.visibility = 'hidden';
if (send) update();
log('menu closed');
};
outer.appendChild(facing);
barBody.appendChild(outer);
return {
sgid: bar.sgid, barContent: bar.content,
closeMenu, markModel, isOpen: () => menuOpen, lift: () => lift,
setLift(y) { // -> changed
if (y === lift) return false;
lift = y; outer.setAttribute('translation', `0 ${y} 0`);
return true;
},
sg: () => ({ type: 'transform', properties: { sgid: osgid, translation: [0, lift, 0], rotation: [1, 0, 0, 0], scale: [1, 1, 1] },
children: [{ type: 'head-facing-transform', properties: { sgid: fsgid },
children: [bar.panel(), ...(below ? [below.sg(ctl.panel())] : []), ...(menuOpen ? [above.sg(menu.panel())] : [])] }] }),
dispose() {
stay(); outer.remove(); bar.dispose(); ctl?.dispose(); menu?.dispose(); css.remove();
for (const o of claimed) { try { R.release?.(o); } catch { /* gone */ } }
G.removeEventListener('mouseup', onUp, true); G.removeEventListener('mousedown', onDown, true);
},
};
}
let handle = null;
try { handle = makeHandle(); } catch (e) { log('no drag handle', String(e)); }
// once (when shown): the page writes the panels' UV slots
const layoutHandle = () => { if (handle) { try { G.forceLayoutUpdate(); } catch { /* next layout */ } } };
// ---- world interface ----
const pose = (path) => {
try {
const [p] = G.VRHTML.GetPose(path, 1); // standing space
return p?.bPoseIsValid ? p : null;
} catch { return null; }
};
const toPose = (xf) => ({ p: [xf.translation.x, xf.translation.y, xf.translation.z], q: [xf.rotation.w, xf.rotation.x, xf.rotation.y, xf.rotation.z] });
let handPoses = { left: null, right: null }; // { p, q } per hand, this tick
// The hands for petting and startling: position and velocity only.
const readHands = () => {
handPoses = { left: null, right: null };
const hand = (side) => {
const p = pose(`/user/hand/${side}`);
if (!p) return null;
handPoses[side] = toPose(p.xfDeviceToAbsoluteTracking);
const t = p.xfDeviceToAbsoluteTracking.translation, v = p.vVelocity ?? { x: 0, y: 0, z: 0 };
return { x: t.x, y: t.y, z: t.z, vx: v.x, vy: v.y, vz: v.z };
};
return { left: hand('left'), right: hand('right') };
};
let hands = { left: null, right: null }, held = null; // sampled once per tick (run below); held: the laser drag
let shown = null, lastT = '', lastR = '', dirty = false;
// Send gating: head pose once per tick; the head's turn rate; the cat in view.
const TURN_DEG_S = 60, TURN_DEFER_MS = 250, VIEW_DEFER_MS = 1000;
let headPose = null, headPrev = null, turnRate = 0, lastSend = 0;
const deferred = { turn: 0, view: 0 };
const readHead = (dt) => {
const p = pose('/user/head');
headPrev = headPose;
headPose = p ? toPose(p.xfDeviceToAbsoluteTracking) : null;
if (headPose && headPrev && dt > 0) {
const d = Math.abs(headPose.q.reduce((s, v, i) => s + v * headPrev.q[i], 0));
turnRate = 2 * Math.acos(Math.min(1, d)) * 180 / Math.PI / dt;
} else turnRate = 0;
};
const inView = (at) => !headPose || UI.inView(headPose.p, M.qrot(headPose.q, [0, 0, -1]), at);
const fmt = (v, d) => v.map((x) => +x.toFixed(d)).join(' ');
const heading = (q) => { const f = M.qrot(q, [0, 0, -1]); return Math.hypot(f[0], f[2]) > 0.05 ? Math.atan2(f[0], f[2]) : null; };
// The carried frame F (the root while dragged: parent-path the hand, its
// pose rel = hand⁻¹ · F0, as SteamVR's floating windows): F0 at the bar's
// origin (the grab point) when pressed, its -Z along the line from the
// controller (the push's direction), level. vrcompositor draws it rigidly
// in the controller's frame, so the grab point stays put on the laser.
const lookFrom = (from, to) => {
const d = to.map((v, k) => v - from[k]), l = Math.hypot(...d) || 1;
const pitch = Math.asin(Math.max(-1, Math.min(1, d[1] / l)));
return M.qnorm(M.qmul(M.qyaw(Math.atan2(-d[0], -d[2])), [Math.cos(pitch / 2), Math.sin(pitch / 2), 0, 0]));
};
const carried = (h, d = drag) => M.compose(M.compose(h, d.rel), { p: [0, 0, -d.push], q: [1, 0, 0, 0] });
// The dragged cat's body under F: upright, its orientation at the grab
// turned by the laser hand's heading change since; its root lift straight
// below the grab point (the bar's origin stays at F's). From this tick's
// hand pose (vrcompositor turns F with the controller every frame; this
// undoes it once per tick). -> { q, p (in F), F, want } or null (no pose)
const dragBody = () => {
const h = handPoses[drag.hand];
if (!h) return null;
drag.lastHand = h;
const psi = heading(h.q);
if (psi != null && drag.psi0 == null) drag.psi0 = psi;
if (psi != null) drag.want = M.qmul(M.qyaw(psi - drag.psi0), drag.w0.q);
const want = drag.want ?? drag.w0.q, F = carried(h), c = M.qconj(F.q);
return { q: M.qnorm(M.qmul(c, want)), p: M.qrot(c, [0, -handle.lift(), 0]), F, want };
};
// Back in the world: no parent, the push inactive and reset (next drag from 0).
const endCarry = () => {
root.removeAttribute('parent-path');
const pp = push.sg.properties;
pp['is-active'] = false; pp['reset-generation'] = ++pushGen; delete pp['event-panel-sgid']; delete pp['base-distance'];
setBody([1, 0, 0, 0], [0, 0, 0]);
};
const world = {
prefetch(clips) {
want = all ? new Set([...clips, ...Object.keys(frames)]) : new Set([...clips].filter((c) => frames[c]));
},
ready(clip) {
const m = mounted.get(clip);
return !!m && m.n === frames[clip]?.length && performance.now() - m.last >= SETTLE_MS;
},
head() {
if (!headPose) return null;
const [x, y, z] = headPose.p, f = M.qrot(headPose.q, [0, 0, -1]); // forward (-Z) on the floor plane
const fl = Math.hypot(f[0], f[2]) || 1;
return { x, y, z, fx: f[0] / fl, fz: f[2] / fl };
},
hands: () => hands,
held: () => held,
heldPose() {
if (!drag?.rel) return null;
// where it is drawn: the carried frame (this tick's hand, the last
// push; on release the push asked then), the root lift below it
const h = handPoses[drag.hand] ?? drag.lastHand, F = carried(h);
return { x: F.p[0], y: F.p[1] - handle.lift(), z: F.p[2], q: drag.want ?? drag.w0.q };
},
show(clip, i, at) {
if (S.hidden) return;
const now = performance.now();
const mounts = pump(now) | evict(now);
let changed = dirty || !!mounts;
dirty = false;
const xf = world.ready(clip) ? frames[clip]?.[i] : null; // else: keep the last frame
if (xf && xf !== shown) {
if (shown) setShown(shown, false);
setShown(xf, true);
shown = xf; changed = true;
}
// The handle just above the cat: over the scruff of a dangling cat (the dangle frames hang from it).
if (handle?.setLift(frames.dangle?.includes(shown) ? HANDLE.liftDangle : liftOf())) changed = true;
// World pose; dragged: the root carried by the hand (rel, constant).
const W = { p: [at.x, at.y, at.z], q: at.q ?? M.qyaw(at.yaw) };
let dragChanged = false;
if (drag && !drag.w0) {
drag.w0 = W; dragChanged = true;
const h = handPoses[drag.hand];
if (h) { // the grab point (the bar's origin) rigid in the controller's frame from here
const g = M.compose(W, { p: [0, handle.lift(), 0], q: [1, 0, 0, 0] }).p;
drag.rel = M.compose(M.invert(h), { p: g, q: lookFrom(h.p, g) });
drag.lastHand = h;
root.setAttribute('parent-path', `/user/hand/${drag.hand}`);
Object.assign(push.sg.properties, { 'is-active': true, 'event-panel-sgid': handle.sgid, 'base-distance': +Math.hypot(...drag.rel.p).toFixed(4) });
log('carried', drag.hand, `${Math.hypot(...drag.rel.p).toFixed(3)} m from the controller`);
} else { drag.phase = 'done'; log('drag: no hand pose'); }
} else if (drag?.phase === 'ending') { // ask how far it was pushed; carried until the answer (let go there)
drag.phase = 'asking';
const d = drag;
if (d.rel) queueMicrotask(() => pushOf().then((z) => { d.push = z; }, (e) => log('drag: push?', String(e))).finally(() => { d.phase = 'done'; }));
else d.phase = 'done';
} else if (drag?.phase === 'done' && !held) { // released (the core falls from heldPose()): back in the world, there
if (drag.rel) endCarry();
log('dragged to', fmt(W.p, 3), `push ${drag.push.toFixed(3)}`);
drag = null; dragChanged = true; barActive(false);
}
const carry = drag?.rel;
const place = { pose: carry ? drag.rel : W, changed: dragChanged };
// Carried: the body upright below the grab point, turned with the controller's heading.
if (carry) { const b = dragBody(); if (b && setBody(b.q, b.p)) changed = true; }
const T = fmt(place.pose.p, 4), R = fmt(place.pose.q, 5); // w x y z
if (T !== lastT || R !== lastR) {
root.setAttribute('translation', T); root.setAttribute('rotation', R);
lastT = T; lastR = R; changed = true;
}
if (!root.isConnected) { document.body.appendChild(root); changed = true; }
if (!changed) return;
// Defer (keep it dirty) while the head turns fast or the cat is out of view (not while dragged).
if (!place.changed && !drag) {
if (turnRate > TURN_DEG_S && now - lastSend < TURN_DEFER_MS) { deferred.turn++; dirty = true; return; }
if (!mounts && now - lastSend < VIEW_DEFER_MS && !inView(at)) { deferred.view++; dirty = true; return; }
}
lastSend = now;
update();
},
load: () => { try { return G.__sfuiStore?.get?.(KEY); } catch { return undefined; } },
save: (v) => store(v),
log,
};
// ---- run ----
S.root = root;
S.frames = frames;
S.cat = VrPetCore.create(world, opts.cat, baked);
if (unknownModel) log('unknown stored model', unknownModel, '->', modelId);
S.drag = (side) => { if (side) startDrag(side); else endDrag(); return !!drag; }; // debug
S.where = (id) => where(id); // debug
// While dragged: the push (thumbstick), asked once per tick (heldPose());
// also a trace (debug, 4 Hz): [ms, the grab point as computed (hand · rel ·
// push), as vrcompositor has it (the pushed origin), laser hand pose, push].
S.dragTrace = [];
let traceAt = 0;
const askPush = (now) => {
if (drag?.phase !== 'drag' || !drag.rel || drag.asking) return;
const d = drag, h = handPoses[d.hand], r = (v) => v.map((x) => +x.toFixed(3));
d.asking = true;
const trace = now - traceAt >= 250 && h;
if (trace) traceAt = now;
Promise.all([pushOf().then((z) => { if (drag === d) d.push = z; }, () => {}), trace ? where(PUSHED_ID).catch(() => null) : null]).then(([, w]) => {
if (!trace) return;
S.dragTrace.push([Math.round(now), r(carried(h, d).p), w ? r(w.p) : null, r(h.p), r(h.q), +d.push.toFixed(3)]);
if (S.dragTrace.length > 80) S.dragTrace.shift();
}).finally(() => { d.asking = false; });
};
let last = performance.now();
const run = () => {
const now = performance.now();
try {
readHead((now - last) / 1000);
hands = readHands();
held = drag && drag.phase !== 'done' ? { source: 'laser', hand: drag.hand } : null;
S.cat.tick((now - last) / 1000);
askPush(now);
} catch (e) { log('tick', String(e)); }
last = now;
};
const start = () => { last = performance.now(); S.timer = setInterval(run, 1000 / opts.fps); };
// ---- hide / show (the X; the "+" menu's Pet entry via cli.mjs) ----
// hide(): the cat leaves the scene at once: a drag is let go, the
// core stands where it is (a dragged cat: below where it was carried), every frame
// is unmounted, one scene graph send without it; no timers, no sends until
// shown again. Persisted (hidden: true), so a SteamVR restart keeps it hidden.
// Every frame out of the scene graph (sent with the next update()).
const unmountAll = () => {
for (const [name, m] of mounted) for (let i = 0; i < m.n; i++) frames[name][i].remove();
mounted.clear(); nMounted = 0;
if (shown) { setShown(shown, false); shown = null; }
};
// Let go of a drag and of anything that holds the pet: the core stands it where it is.
const letGo = () => {
if (drag) { if (drag.rel) endCarry(); drag = null; barActive(false); }
disarm(); upArmed = null; held = null;
try { S.cat.settle(); } catch (e) { log('settle', String(e)); }
};
const hide = () => {
if (S.hidden) return { hidden: true, was: 'hidden' };
S.hidden = true;
clearInterval(S.timer); S.timer = null;
letGo();
handle?.closeMenu({ send: false }); // (the send below goes without it)
unmountAll();
want = new Set();
root.remove();
update();
store({ hidden: true });
log('hidden');
return { hidden: true, was: 'shown' };
};
// show({ summon }): back at its spot while that is near and in view, else
// 1 m in front of the user (core reveal(); summon: always). Idempotent: a
// shown cat only gets the same rule (not while held or falling).
const show = ({ summon = false } = {}) => {
const reveal = () => {
const st = S.cat.state(), h = world.head();
const r = VrPetCore.reveal(st, h, { inView: inView({ x: st.x, y: 0, z: st.z }), force: !!summon });
if (r === 'summon' && h) { S.cat.command('summon'); return true; }
return false;
};
if (!S.hidden) {
const st = S.cat.state();
const summoned = !st.held && !st.falling && !drag && reveal();
return { hidden: false, was: 'shown', summoned };
}
S.hidden = false;
held = null; drag = null;
endCarry(); // (also a fresh push)
lastT = ''; lastR = ''; dirty = true;
readHead(0);
const summoned = reveal();
store({ hidden: false });
document.body.appendChild(root);
layoutHandle();
start();
run(); // frames mount gradually from here (LOADS_PER_TICK per tick)
log('shown', summoned ? 'summoned' : 'at its spot');
return { hidden: false, was: 'hidden', summoned };
};
const status = () => {
const st = S.cat.state(), h = world.head();
return { hidden: !!S.hidden, pose: st.pose, activity: st.activity, x: +st.x.toFixed(3), z: +st.z.toFixed(3),
distance: h ? +Math.hypot(st.x - h.x, st.z - h.z).toFixed(2) : null, held: st.held, falling: st.falling,
dragging: !!drag, frames: nMounted, sends, version: VERSION, model: modelId };
};
// ---- models (coats and animals) ----
const listModels = () => ({ current: modelId, default: catalog.default,
models: (catalog.order ?? Object.keys(models)).map((id) => ({ id, name: models[id].name })) });
const setModel = ({ id } = {}) => {
const m = models[id];
if (!m) return { error: `unknown model ${JSON.stringify(id)}`, model: modelId, models: listModels().models.map((x) => x.id) };
const was = modelId;
if (id === was) return { model: id, was, name: m.name };
const other = catalog.frameSets[m.frames] !== baked; // another animal: its own frames and core
modelId = id;
if (!S.hidden) unmountAll(); // the old model out at once (sent below), then the new frames load gradually
if (other) {
letGo();
S.cat.save(); // its spot and heading (and pose, if the new one has it) go over to the new core
baked = catalog.frameSets[m.frames];
RARE = rareOf();
buildFrames(m.dir);
S.frames = frames;
want = new Set();
S.cat = VrPetCore.create(world, opts.cat, baked);
} else {
for (const fr of Object.values(frames)) for (const xf of fr) xf.rm.properties.source = `${m.dir}/${xf.file}`;
}
store({ model: id });
handle?.markModel();
log('model', id, other ? `(frames ${m.frames})` : '');
if (S.hidden) return { model: id, was, name: m.name, hidden: true };
update();
dirty = true;
return { model: id, was, name: m.name };
};
// Stable API for cli.mjs (bump API_VERSION on incompatible changes).
const API_VERSION = 2;
S.api = { version: API_VERSION, show, hide, status, models: listModels, setModel };
S.pause = () => { clearInterval(S.timer); S.timer = null; }; // debug
S.resume = () => { if (!S.timer && !S.hidden) start(); };
S.dispose = () => {
clearInterval(S.timer); root.remove(); handle?.dispose();
if (proto && hooks) hooks.remove(proto, 'SendMessage', 'vr-pet');
if (cap.sock?.send?.__sfuiPetOrig) cap.sock.send = cap.sock.send.__sfuiPetOrig;
delete S.onSceneGraph;
slowSend ? slowSend() : G.forceLayoutUpdate();
};
S.stats = () => ({ sender, sends, fast, deferred: { ...deferred }, captured: !!cap.str, hookCalls: cap.seen, wrapped: !!cap.sock?.send?.__sfuiPetOrig,
mounted: [...mounted].map(([k, m]) => (m.n === frames[k].length ? k : `${k} ${m.n}/${frames[k].length}`)), frames: nMounted, loads, evictions,
turnRate: Math.round(turnRate), rootSent: cap.node && { ...cap.node.properties },
handle: !!handle, menuOpen: !!handle?.isOpen(), drag: drag && { hand: drag.hand, phase: drag.phase, push: drag.push },
carry: root.getAttribute('parent-path'), pushSent: { ...push.sg.properties }, pushSeen: S.pushSeen ?? null, hidden: !!S.hidden });
// Hidden (stored): everything is built, nothing is in the page, no timers, no sends.
if (startHidden) { S.hidden = true; return 'hidden'; }
document.body.appendChild(root);
layoutHandle();
start();
readHead(0);
S.cat.tick(0);
return `patched (${modelId}, ${Object.values(baked.clips).reduce((s, c) => s + c.frames, 0)} frames, mount ${opts.mount}, via ${sender})`;
}
@@ -0,0 +1 @@
{ "name": "Someone", "kind": "recolor", "fur": ["#203040", "#8090a0"], "private": true }
+131
View File
@@ -0,0 +1,131 @@
// Tests of the vr-pet CLI (cli.mjs) with a fake DevTools transport.
// usage: node cli.test.mjs (exit 1 on failure)
// Run by the flake check `pet` (package.nix `tests`).
import { run, expression, API_VERSION, EXIT, Unreachable } from '../cli.mjs';
const TARGETS = [
{ title: 'Steam', webSocketDebuggerUrl: 'ws://x/steam' },
{ title: 'systemui', webSocketDebuggerUrl: 'ws://x/systemui' },
];
// A transport answering `value` (the page's reply object) for the systemui target.
const fake = ({ targets = TARGETS, value, reply, listError, evalError, hang } = {}) => {
const calls = [];
return {
calls,
async list(endpoint) { calls.push(['list', endpoint]); if (listError) throw listError; return targets; },
async evaluate(target, expr) {
calls.push(['evaluate', target.title, expr]);
if (hang) return new Promise(() => {});
if (evalError) throw evalError;
return reply ?? { id: 1, result: { result: { type: 'object', value } } };
},
};
};
// The page side of the expression, evaluated against a fake window.
const evalIn = async (expr, window) => new Function('window', `return ${expr};`)(window);
let failed = 0;
const tests = [];
const test = (name, f) => tests.push([name, f]);
const assert = (c, msg) => { if (!c) throw new Error(msg); };
test('usage errors exit 4', async () => {
for (const argv of [[], ['toggle'], ['show', '--now'], ['hide', '--summon'], ['status', 'x'], ['model'], ['model', 'a', 'b'], ['model', '"x'], ['models', 'x']]) {
const t = fake();
const r = await run(argv, { transport: t });
assert(r.code === EXIT.error && r.code === 4 && /usage/.test(r.err[0]), `${argv.join(' ')}: ${r.code}`);
assert(t.calls.length === 0, 'no DevTools call on a usage error');
}
});
test('show: evaluates only __sfuiPet.api.show on the systemui page', async () => {
const t = fake({ value: { status: 'ok', value: { hidden: false, was: 'hidden', summoned: false } } });
const r = await run(['show'], { transport: t });
assert(r.code === 0 && r.out[0] === 'vr-pet: shown', `${r.code} ${r.out}`);
const [, title, expr] = t.calls[1];
assert(title === 'systemui', `target ${title}`);
assert(/window\.__sfuiPet\?\.api/.test(expr) && /api\["show"\]\(\{"summon":false\}\)/.test(expr), expr);
assert(!/document|VRHTML|click|dispatch/.test(expr), 'nothing but the api');
const s = await run(['show', '--summon'], { transport: fake({ value: { status: 'ok', value: { was: 'shown', summoned: true } } }) });
assert(s.code === 0 && /already shown \(summoned/.test(s.out[0]), s.out[0]);
});
test('the expression: not injected, wrong version, ok (against a fake page)', async () => {
const e = expression('show', { summon: true });
assert((await evalIn(e, {})).status === 'not-injected', 'not injected');
assert((await evalIn(e, { __sfuiPet: { api: { version: API_VERSION + 1 } } })).status === 'version', 'version');
let got;
const w = { __sfuiPet: { api: { version: API_VERSION, show: async (a) => { got = a; return { was: 'hidden' }; } } } };
const v = await evalIn(e, w);
assert(v.status === 'ok' && v.value.was === 'hidden' && got.summon === true, JSON.stringify(v));
});
test('hide and status', async () => {
let r = await run(['hide'], { transport: fake({ value: { status: 'ok', value: { hidden: true, was: 'shown' } } }) });
assert(r.code === 0 && r.out[0] === 'vr-pet: hidden', r.out[0]);
r = await run(['hide'], { transport: fake({ value: { status: 'ok', value: { hidden: true, was: 'hidden' } } }) });
assert(r.out[0] === 'vr-pet: already hidden', r.out[0]);
r = await run(['status'], { transport: fake({ value: { status: 'ok', value: { hidden: false, pose: 'sit' } } }) });
assert(r.code === 0 && JSON.parse(r.out[0]).pose === 'sit', r.out[0]);
});
test('unreachable: no endpoint, no systemui page, timeout -> 2', async () => {
let r = await run(['status'], { transport: fake({ listError: new Unreachable('refused') }) });
assert(r.code === 2 && /refused/.test(r.err[0]), `refused: ${r.code}`);
r = await run(['status'], { transport: fake({ listError: new Error('bug') }) });
assert(r.code === EXIT.error, `a transport bug: ${r.code}`);
r = await run(['status'], { transport: fake({ targets: [TARGETS[0]] }) });
assert(r.code === 2 && /no "systemui" page/.test(r.err[0]), `${r.code} ${r.err}`);
r = await run(['status'], { transport: fake({ targets: {} }) });
assert(r.code === 2, `bad list: ${r.code}`);
const t0 = Date.now();
r = await run(['status'], { transport: fake({ hang: true }), timeoutMs: 50 });
assert(r.code === 2 && /no answer/.test(r.err[0]) && Date.now() - t0 < 1000, `timeout: ${r.code} ${r.err}`);
// the real transport against a closed port
r = await run(['status'], { endpoint: 'http://127.0.0.1:9', timeoutMs: 2000 });
assert(r.code === 2, `closed port: ${r.code} ${r.err}`);
});
test('not injected / wrong version -> 3; page exception -> 4', async () => {
let r = await run(['show'], { transport: fake({ value: { status: 'not-injected' } }) });
assert(r.code === 3, `not injected: ${r.code}`);
r = await run(['show'], { transport: fake({ value: { status: 'version', version: 9 } }) });
assert(r.code === 3 && /version 9/.test(r.err[0]), `version: ${r.code} ${r.err}`);
r = await run(['show'], { transport: fake({ reply: { id: 1, result: { exceptionDetails: { text: 'Uncaught', exception: { description: 'TypeError: boom' } } } } }) });
assert(r.code === 4 && /boom/.test(r.err[0]), `exception: ${r.code} ${r.err}`);
r = await run(['show'], { transport: fake({ reply: { id: 1, error: { message: 'Target closed' } } }) });
assert(r.code === 4, `protocol error: ${r.code}`);
r = await run(['show'], { transport: fake({ value: 'unchanged' }) });
assert(r.code === 4, `odd reply: ${r.code}`);
});
test('models: lists the coats, the current one starred, names aligned past the longest id; only api.models is called', async () => {
const t = fake({ value: { status: 'ok', value: { current: 'tuxedo', default: 'ginger', models: [{ id: 'ginger', name: 'Ginger' }, { id: 'tuxedo', name: 'Tuxedo' }, { id: 'dachshund', name: 'Dachshund' }] } } });
const r = await run(['models'], { transport: t });
const want = [' ginger Ginger', '* tuxedo Tuxedo', ' dachshund Dachshund'];
assert(r.code === 0 && JSON.stringify(r.out) === JSON.stringify(want), JSON.stringify(r.out));
assert(/api\["models"\]\(\{\}\)/.test(t.calls[1][2]), t.calls[1][2]);
});
test('model <id>: api.setModel({ id }); unknown -> 4 with the list; API version 2', async () => {
assert(API_VERSION === 2, `API_VERSION ${API_VERSION}`);
const t = fake({ value: { status: 'ok', value: { model: 'blue', was: 'ginger', name: 'Blue' } } });
let r = await run(['model', 'blue'], { transport: t });
assert(r.code === 0 && r.out[0] === 'vr-pet: now Blue', `${r.code} ${r.out}`);
assert(/api\["setModel"\]\(\{"id":"blue"\}\)/.test(t.calls[1][2]), t.calls[1][2]);
r = await run(['model', 'blue'], { transport: fake({ value: { status: 'ok', value: { model: 'blue', was: 'blue', name: 'Blue' } } }) });
assert(r.code === 0 && r.out[0] === 'vr-pet: already Blue', r.out[0]);
r = await run(['model', 'blue'], { transport: fake({ value: { status: 'ok', value: { model: 'blue', was: 'ginger', name: 'Blue', hidden: true } } }) });
assert(/hidden/.test(r.out[0]), r.out[0]);
r = await run(['model', 'lion'], { transport: fake({ value: { status: 'ok', value: { error: 'unknown model "lion"', model: 'blue', models: ['ginger', 'blue'] } } }) });
assert(r.code === 4 && /unknown model "lion" \(models: ginger, blue\)/.test(r.err[0]), `${r.code} ${r.err}`);
// an old page (API 1): not ours to call
r = await run(['models'], { transport: fake({ value: { status: 'version', version: 1 } }) });
assert(r.code === 3, `old API: ${r.code}`);
});
for (const [name, f] of tests) {
try { await f(); console.log(`ok ${name}`); } catch (e) { failed++; console.log(`FAIL ${name}: ${e.stack}`); }
}
if (failed) { console.log(`${failed} failed`); process.exit(1); }
console.log('all passed');
+269
View File
@@ -0,0 +1,269 @@
// Headless tests of the cat's behaviour core (core.js) with scripted hands
// (the world: sim.js).
// usage: node core.test.js <frames.json> (exit 1 on failure)
// Run by the flake check `pet` (package.nix `tests`).
'use strict';
const fs = require('fs');
const { Core, DT, seed, sim: simOf, test, assert, near, done } = require('./sim.js');
const baked = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const T = Core.THRESHOLDS;
const sim = (s, opts) => simOf(baked, s, { opts });
const M = Core.math;
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
const qdiff = (a, b) => 1 - Math.abs(a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]); // 0: same rotation
// ---------------------------------------------------------------------------
test('held by the scruff (laser drag) -> lift -> release -> fall -> land -> shake/groom/walk off', () => {
const s = sim(1);
s.run(1);
const sc = s.zone('scruff');
assert(sc && near(sc.y, baked.poseZones.stand.scruff[1], 0.01), `scruff at standing height (${sc?.y})`);
// The grab point on the scruff, then pressed.
s.setScript((t) => ({ x: sc.x, y: sc.y + 0.03, z: sc.z, press: 0 }));
s.run(0.3);
s.setScript((t) => ({ x: sc.x, y: sc.y + 0.03, z: sc.z, press: 1 }));
let st = s.run(0.1);
assert(st.held?.source === 'laser' && st.pose === 'dangle' && st.clip === 'dangle', `held and dangling (${JSON.stringify(st.held)} ${st.pose})`);
// Lift 0.5 m in 1 s: the scruff follows the hand, offset kept.
const t0 = s.t;
s.setScript((t) => ({ x: sc.x, y: sc.y + 0.03 + 0.5 * Math.min(1, (t - t0) / 1), z: sc.z, press: 1 }));
st = s.run(1.2);
assert(near(st.y, sc.y + 0.5, 0.01) && near(st.x, sc.x, 0.01) && near(st.z, sc.z, 0.01), `scruff follows the hand (y ${st.y.toFixed(3)})`);
// Release: falls, lands within the fall time, plays `land`, then an after-landing activity.
s.setScript(() => ({ x: sc.x, y: sc.y + 0.53, z: sc.z, press: 0 }));
const clips = new Set();
let landedAt = null, minY = Infinity;
st = s.run(0.05);
assert(st.falling && st.pose === 'fall' && !st.held, `falling after release (${st.pose})`);
const startY = st.y;
s.run(1.5, (x, t) => { clips.add(x.clip); minY = Math.min(minY, x.y); if (landedAt === null && !x.falling) landedAt = t; });
const tFall = Math.sqrt(2 * startY / T.GRAVITY);
assert(landedAt !== null && landedAt - t0 - 1.2 < tFall + 0.15, `landed in time (fall from ${startY.toFixed(2)} m, ${(landedAt - t0 - 1.2).toFixed(2)} s, free fall ${tFall.toFixed(2)} s)`);
assert(minY >= 0, 'never below the floor');
assert(clips.has('land'), `land clip played (${[...clips]})`);
st = s.run(3, (x) => clips.add(x.clip));
assert(['shake', 'groom', 'walk', 'sitdown', 'walkstart'].some((c) => clips.has(c)), `shakes, grooms or walks off after landing (${[...clips]})`);
});
test('slow petting on the back -> purr; hand away -> stops', () => {
const s = sim(4);
s.run(0.5);
// Stroke from the scruff to the back and back, 3 cm above, ~0.15 m/s.
s.setScript((t) => {
const a = s.zone('scruff'), b = s.zone('back'), u = 0.5 - 0.5 * Math.cos(t * 2.2);
return { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u + 0.03, z: a.z + (b.z - a.z) * u, press: 0 };
});
let purredAt = null;
const t0 = s.t;
let st = s.run(2.5, (x, t) => { if (purredAt === null && x.purring) purredAt = t - t0; });
assert(purredAt !== null && purredAt >= T.PET_TIME - 0.05 && purredAt < T.PET_TIME + 0.4, `purrs after ~${T.PET_TIME} s of strokes (${purredAt})`);
assert(['purr', 'sitpurr', 'liepurr'].includes(st.pose) && ['purr', 'sitpurr', 'liepurr'].includes(st.clip), `purr pose and loop (${st.pose}/${st.clip})`);
assert(st.activity === 'petted', `activity petted (${st.activity})`);
// Repeated strokes keep it going.
st = s.run(4, (x) => assert(x.purring, 'stopped purring while stroked'));
// Hand away: stops after PURR_LINGER, back to the base pose.
s.setScript(() => ({ x: 0.6, y: 1.1, z: 1.4, press: 0 }));
st = s.run(T.PURR_LINGER - 0.3);
assert(st.purring, 'lingers a moment');
st = s.run(1.5);
assert(!st.purring && ['stand', 'sit', 'lie'].includes(st.goal), `stopped, back to ${st.goal}`);
});
test('petting a sitting cat -> sitpurr; a sleeping cat wakes to liepurr', () => {
for (const [pose, want] of [['sit', 'sitpurr'], ['sleep', 'liepurr']]) {
const s = sim(5);
s.run(0.2);
s.cat.command(pose);
s.run(5);
assert(s.cat.state().pose === pose, `${pose} reached (${s.cat.state().pose})`);
s.setScript(s.toward(() => { const h = s.zone('head'); return { x: h.x, y: h.y + 0.04, z: h.z }; }));
const st = s.run(4);
assert(st.pose === want && st.purring, `${pose} -> ${want} (${st.pose})`);
}
});
test('fast swipe past the cat -> startle, backs off away from the hand, wary', () => {
const s = sim(6);
s.run(1);
const hd = s.zone('head');
const t0 = s.t, from = { x: hd.x - 0.6, z: hd.z };
// 1.5 m/s sideways swipe through the head, 5 cm above it.
s.setScript((t) => ({ x: from.x + 1.5 * (t - t0), y: hd.y + 0.05, z: hd.z, press: 0 }));
const clips = new Set();
let st = s.run(0.8, (x) => clips.add(x.clip));
assert(clips.has('startle'), `startle clip played (${[...clips]})`);
const c0 = { x: st.x, z: st.z };
s.setScript(() => null);
st = s.run(5, (x) => clips.add(x.clip));
assert(clips.has('walk'), 'walks off');
const moved = Math.hypot(st.x - c0.x, st.z - c0.z);
assert(moved > 0.4, `backed off (${moved.toFixed(2)} m)`);
// Wary: slow petting right after does not make it purr.
const s2 = sim(6);
s2.run(1);
const h2 = s2.zone('head'), t2 = s2.t;
s2.setScript((t) => ({ x: h2.x - 0.6 + 1.5 * (t - t2), y: h2.y + 0.05, z: h2.z, press: 0 }));
s2.run(0.6);
s2.setScript(s2.toward(() => { const h = s2.zone('back'); return { x: h.x, y: h.y + 0.03, z: h.z }; }));
s2.run(3, (x) => assert(!x.purring, 'purred while wary'));
});
test('slow hand near the cat does not startle; resting hand counts as petting', () => {
const s = sim(7);
s.run(1);
const b = s.zone('back');
s.setScript(() => ({ x: b.x, y: b.y + 0.04, z: b.z, press: 0 }));
const clips = new Set();
const st = s.run(2, (x) => clips.add(x.clip));
assert(!clips.has('startle'), 'startled by a still hand');
assert(st.purring, 'a resting hand on the back makes it purr');
});
test('idle life: grooms and stretches over a calm half hour', () => {
const s = sim(9);
const clips = new Map();
s.run(30 * 60, (x) => clips.set(x.clip, (clips.get(x.clip) ?? 0) + 1));
const starts = (a) => s.logs.filter((l) => l.endsWith(`action ${a}`)).length;
const n = { groom: starts('groom'), stretch: starts('stretch'), shake: starts('shake') };
assert(n.groom >= 1 && n.stretch >= 1, `groom / stretch in 30 min (${JSON.stringify(n)})`);
// Cooldowns hold: no two grooms closer than their cooldown.
const times = s.logs.filter((l) => l.endsWith('action groom')).map((l) => parseFloat(l));
for (let i = 1; i < times.length; i++) assert(times[i] - times[i - 1] >= T.COOLDOWN.groom, `groom cooldown (${times[i] - times[i - 1]} s)`);
console.log(` 30 min: ${JSON.stringify(n)} actions, clips ${[...clips.keys()].join(' ')}`);
});
test('pose maths: relative / compose round trip; yaw', () => {
seed(11);
const r = () => Math.random() * 2 - 1;
for (let i = 0; i < 200; i++) {
const hand = { p: [r(), 1 + r(), r()], q: M.qnorm([r(), r(), r(), r()]) };
const w = { p: [r(), r() + 0.5, r()], q: M.qnorm([r(), r(), r(), r()]) };
const back = M.compose(hand, M.relative(hand, w));
assert(dist(back.p, w.p) < 1e-9 && qdiff(back.q, w.q) < 1e-12, `round trip ${i}: ${JSON.stringify(back)} vs ${JSON.stringify(w)}`);
}
// yaw: qyaw / yawOf and the zones' rotation (toWorld) agree
for (const y of [0, 0.7, -2.1, 3]) {
assert(near(M.yawOf(M.qyaw(y)), y, 1e-9), `yawOf(qyaw(${y}))`);
const v = M.qrot(M.qyaw(y), [1, 0, 0]);
assert(near(v[0], Math.cos(y), 1e-9) && near(v[2], -Math.sin(y), 1e-9), 'qyaw turns like toWorld');
}
});
test('carried by the adapter (heldPose, the laser drag): followed, released where it is drawn, lands upright', () => {
const s = sim(12);
s.run(1);
const sc = s.zone('scruff');
const st0 = s.cat.state();
// The carried pose starts where the cat's root is (the dangle frames hang from the scruff).
const w0 = { p: [sc.x, sc.y, sc.z], q: M.qyaw(st0.yaw) };
const at = { x: sc.x + 0.2, y: sc.y + 0.8, z: sc.z - 0.4 }; // the laser hand, away from the cat
s.setScript(() => ({ ...at, press: 1, carried: w0 }));
let st = s.run(DT);
const g = s.shown[s.shown.length - 1];
assert(st.held?.source === 'laser' && g.clip === 'dangle', `held and dangling (${g.clip})`);
assert(dist([g.x, g.y, g.z], w0.p) < 1e-9, 'drawn where the compositor has it at the grab');
// Carried along a path, turned 90° about the vertical: the core follows exactly.
const t0 = s.t;
const pathAt = (t) => { const u = Math.min(1, (t - t0) / 1); return { p: [w0.p[0] + 1.2 * u, w0.p[1] + 0.3 * u, w0.p[2] - 0.5 * u], q: M.qmul(M.qyaw(Math.PI / 2 * u), w0.q) }; };
s.setScript((t) => ({ ...at, press: 1, carried: pathAt(t) }));
s.run(1.1, (x, t) => {
const w = pathAt(t);
assert(dist([x.x, x.y, x.z], w.p) < 1e-9 && qdiff(x.q, w.q) < 1e-12, 'the core follows the carried pose');
});
const last = pathAt(s.t);
// Let go: the fall frame's scruff where the cat was drawn, same turn.
s.setScript(() => ({ ...at, press: 0, carried: last }));
st = s.run(DT);
const r = s.shown[s.shown.length - 1];
assert(st.falling && r.clip === 'fall', `released into the fall (${r.clip})`);
const fs = M.qrot(r.q, baked.poseZones.fall.scruff);
const scruffAfter = [r.x + fs[0], r.y + fs[1], r.z + fs[2]];
assert(dist(scruffAfter, last.p) < 1e-9, `release continuity: scruff ${dist(scruffAfter, last.p)} m off`);
assert(qdiff(r.q, last.q) < 1e-9, 'release continuity: orientation kept');
// Lands upright.
st = s.run(1.5);
assert(!st.falling && qdiff(st.q, M.qyaw(st.yaw)) < 1e-12, `upright after landing (${st.q})`);
});
test('summon: 1 m in front of the user, facing them', () => {
const s = sim(13);
s.run(1);
s.cat.command('wander');
s.run(2);
s.cat.command('summon');
const st = s.run(DT);
// user at (0, 1.6) looking toward -z
assert(near(st.x, 0, 1e-6) && near(st.z, 0.6, 1e-6), `in front (${st.x.toFixed(3)}, ${st.z.toFixed(3)})`);
assert(near(Math.cos(st.yaw), 1, 1e-6), `facing the user (yaw ${st.yaw.toFixed(3)})`);
});
test('reveal: stays near and in view, else summoned; forced', () => {
const h = { x: 0, y: 1.6, z: 0, fx: 0, fz: -1 };
const K = T.REVEAL_KEEP;
assert(K === 3, `keep radius 3 m (${K})`);
assert(Core.reveal({ x: 0, z: -2 }, h, { inView: true }) === 'stay', 'near, in view: stay');
assert(Core.reveal({ x: 0, z: -K + 0.01 }, h) === 'stay', 'just inside: stay');
assert(Core.reveal({ x: 0, z: -K - 0.01 }, h) === 'summon', 'beyond 3 m: summon');
assert(Core.reveal({ x: 0, z: -1 }, h, { inView: false }) === 'summon', 'out of view: summon');
assert(Core.reveal({ x: 0, z: -1 }, h, { force: true }) === 'summon', 'forced: summon');
assert(Core.reveal({ x: 0, z: -9 }, null) === 'stay', 'no head pose: stay');
assert(Core.reveal({ x: 0, z: -1 }, { ...h, y: 0.5 }) === 'stay', 'floor distance (head height ignored)');
});
test('settle: a held, falling or forced cat stands where it is (dangling: below the scruff)', () => {
// held (laser drag)
const s = sim(21);
s.run(1);
const st0 = s.cat.state(), sc = s.zone('scruff');
s.setScript(() => ({ x: sc.x, y: sc.y + 0.03, z: sc.z, press: 0 }));
s.run(0.3);
s.setScript(() => ({ x: sc.x, y: sc.y + 0.03, z: sc.z, press: 1 }));
assert(s.run(0.1).held, 'held');
assert(s.cat.settle() === true, 'settled');
let st = s.cat.state();
assert(!st.held && !st.falling && !st.forced && st.pose === 'stand' && st.y === 0, `standing on the floor (${st.pose} y ${st.y})`);
assert(near(st.x, st0.x, 1e-6) && near(st.z, st0.z, 1e-6), `where it was lifted from (${st.x} ${st.z} vs ${st0.x} ${st0.z})`);
// falling
const f = sim(22);
f.run(1);
f.cat.command('drop');
assert(f.run(0.1).falling, 'falling');
assert(f.cat.settle() === true && !f.cat.state().falling && f.cat.state().y === 0, 'landed at once');
// forced dangle
const d = sim(23);
d.run(1);
d.cat.command('dangle');
d.run(0.1);
assert(d.cat.settle() === true && !d.cat.state().forced && d.cat.state().pose === 'stand', 'forced dangle: standing');
// free: nothing to do
const n = sim(24);
n.run(1);
assert(n.cat.settle() === false, 'a free cat: unchanged');
});
test('save(): the spot and the resting pose a pose counts as (purring while sitting: sit)', () => {
const saves = [];
const cat = Core.create({ head: () => null, show() {}, save: (v) => saves.push(v) }, {}, baked);
cat.command('sit');
cat.save();
assert(saves.length === 1 && saves[0].pose === 'sit' && Number.isFinite(saves[0].x), JSON.stringify(saves));
});
test('ui: the bar\'s lift by height, the menu layout, the view test', () => {
const { ui } = Core;
assert(near(ui.liftOf(), 0.40, 1e-12) && near(ui.liftOf(0.35), 0.45, 1e-12), 'lift: 0.40 for the cat, height + 0.10');
const cat = { order: ['a', 'b', 'c'], models: { a: { group: 'coats' }, b: { group: 'coats' }, c: { group: 'animals' } } };
const l = ui.menuLayout(cat);
assert(l.breaks.join() === 'c' && l.px === 8 * 2 + 3 * 48 + 2 * 9 + 4 * 44, JSON.stringify(l));
const eye = [0, 1.6, 0], fwd = [0, 0, -1];
assert(ui.inView(eye, fwd, { x: 0, y: 0, z: -2 }) && !ui.inView(eye, fwd, { x: 0, y: 0, z: 2 }), 'ahead in view, behind not');
});
test('every clip the core can ask for is baked', () => {
const need = new Set([...Object.values(Core.LOOP), ...Core.EDGES.map((e) => e[2]), 'groom', 'stretch', 'shake', 'startle', 'land']);
for (const c of need) assert(baked.clips[c], `missing clip ${c}`);
for (const p of ['stand', 'sit', 'lie', 'sleep', 'fall', 'dangle']) assert(baked.poseZones?.[p]?.scruff, `missing zones for ${p}`);
});
done();
+166
View File
@@ -0,0 +1,166 @@
#!/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')
+46
View File
@@ -0,0 +1,46 @@
# steam-frame-nix-pet-icon (icon.sh) on a fake runtime dir, state file, icons and hicolor:
# no runtime dir is a no-op; the state's model is linked, else the default
# (no file, no "model", a model without an icon, invalid JSON); an unchanged
# target is neither rewritten nor bumps hicolor; a changed one does.
set -euo pipefail
fail() { echo "FAIL: $*" >&2; exit 1; }
cd "$(mktemp -d)"
mkdir icons hicolor
touch icons/ginger.png icons/shiba.png icons/fox.png
echo ginger > icons/default.txt
export VR_PET_RUNTIME_DIR=$PWD/run VR_PET_STATE=$PWD/state/vr-pet.json VR_PET_ICONS=$PWD/icons VR_PET_HICOLOR=$PWD/hicolor
L=$VR_PET_RUNTIME_DIR/steam-frame-nix/vr-pet/icon.png
old() { touch -d '2000-01-01' hicolor; }
bumped() { [ "$(stat -c %Y hicolor)" -gt 946684800 ]; }
expect() { [ "$(readlink "$L")" = "$PWD/icons/$1.png" ] || fail "$2: $(readlink "$L" || echo no link)"; echo "ok $2"; }
out=$(steam-frame-nix-pet-icon 2>&1); [[ $out == *"doesn't exist"* ]] || fail "no runtime dir: $out"
[ ! -e run ] || fail "runtime dir created"
echo "ok no runtime dir: skipped"
mkdir run state
old; steam-frame-nix-pet-icon 2>/dev/null; expect ginger "no state file: the default"
bumped || fail "first link: hicolor not bumped"; echo "ok first link bumps hicolor"
echo '{"x":1,"hidden":true,"model":"shiba"}' > state/vr-pet.json
old; steam-frame-nix-pet-icon 2>/dev/null; expect shiba "the state's model"
bumped || fail "change: hicolor not bumped"; echo "ok change bumps hicolor"
echo '{"x":2,"hidden":false,"model":"shiba"}' > state/vr-pet.json
old; out=$(steam-frame-nix-pet-icon 2>&1); [ -z "$out" ] || fail "unchanged: $out"
expect shiba "unchanged model"
! bumped || fail "unchanged: hicolor bumped"; echo "ok unchanged: no rewrite, no bump"
echo '{"model":"fox"}' > state/vr-pet.json; steam-frame-nix-pet-icon 2>/dev/null; expect fox "switch to another model"
echo '{"model":"unicorn"}' > state/vr-pet.json
out=$(steam-frame-nix-pet-icon 2>&1); [[ $out == *"no icon for model 'unicorn'"* ]] || fail "unknown model: no warning: $out"; expect ginger "unknown model: the default"
echo '{"model":"fox"}' > state/vr-pet.json; steam-frame-nix-pet-icon 2>/dev/null
echo '{"x":1}' > state/vr-pet.json; steam-frame-nix-pet-icon 2>/dev/null; expect ginger "no model in the state: the default"
echo '{"model":"../fox"}' > state/vr-pet.json; steam-frame-nix-pet-icon 2>/dev/null; expect ginger "a model that isn't an id: the default"
echo '{"model":"fox"}' > state/vr-pet.json; steam-frame-nix-pet-icon 2>/dev/null
echo '{"model":' > state/vr-pet.json; steam-frame-nix-pet-icon 2>/dev/null; expect ginger "invalid JSON: the default"
echo '[1]' > state/vr-pet.json; steam-frame-nix-pet-icon 2>/dev/null; expect ginger "not an object: the default"
rm -rf hicolor; echo '{"model":"fox"}' > state/vr-pet.json; steam-frame-nix-pet-icon 2>/dev/null; expect fox "no hicolor dir: still linked"
[ ! -e hicolor ] || fail "hicolor created"
ls -A run/steam-frame-nix/vr-pet | grep -q tmp && fail "temp link left"
echo "ok no temp links left"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""The "+" menu icons (package.nix `icons`): one per model folder, 256 px
square, transparent around a visible animal, and default.txt naming one.
usage: icons.test.py models-dir icons-dir
"""
import os
import sys
from PIL import Image
models, icons = sys.argv[1:3]
ids = sorted(d for d in os.listdir(models) if os.path.isdir(os.path.join(models, d)))
pngs = sorted(f[:-4] for f in os.listdir(icons) if f.endswith('.png'))
assert pngs == ids, f'icons {pngs} != model folders {ids}'
for i in ids:
im = Image.open(os.path.join(icons, i + '.png'))
assert im.size == (256, 256) and im.mode == 'RGBA', f'{i}: {im.size} {im.mode}'
a = im.getchannel('A')
assert a.getpixel((0, 0)) == 0, f'{i}: corner not transparent'
assert sum(a.histogram()[129:]) > 256 * 256 // 10, f'{i}: hardly anything drawn'
default = open(os.path.join(icons, 'default.txt')).read().strip()
assert default in ids, f'default.txt: {default!r}'
print(f'ok icons: {len(ids)} ({", ".join(ids)}), 256 px; default {default}')
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Checks of the built model catalog (index.py, skin.py).
usage: models.test.py <catalog dir> <base dir: bake.py's output> <test catalog dir> (exit 1 on failure)
(the test catalog: the same models plus tests/models/, a model folder added
without any other change)
Run by the flake check `pet` (package.nix `tests`).
"""
import base64
import io
import json
import os
import sys
import numpy as np
from PIL import Image
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
import index # noqa: E402
import skin # noqa: E402
from bake_gltf import FACE_MAX # noqa: E402
catalog, base, test_catalog = sys.argv[1:4]
base_dir = base
failed = 0
def test(name):
def run(f):
global failed
try:
f()
print(f'ok {name}')
except AssertionError as e:
failed += 1
print(f'FAIL {name}: {e}')
return run
idx = json.load(open(os.path.join(catalog, 'models.json')))
objs = sorted(f for f in os.listdir(base) if f.endswith('.obj'))
tex = lambda d: np.asarray(Image.open(os.path.join(d, 'cat.png')).convert('RGB')).astype(int)
@test('index: default, order, the five cats, one frame set, 48 px thumbs')
def _():
assert idx['default'] in idx['models'], idx['default']
assert idx['order'][0] == 'ginger' and set(idx['order']) == set(idx['models']), idx['order']
names = [idx['models'][k]['name'] for k in idx['order']]
for n in ['Ginger', 'Tuxedo', 'Blue', 'Cream', 'Snow']:
assert n in names, f'{n} missing ({names})'
for k, m in idx['models'].items():
assert m['frames'] in idx['frameSets'], f'{k}: frame set {m["frames"]}'
assert m['thumb'].startswith('data:image/png;base64,'), f'{k}: thumb'
im = Image.open(io.BytesIO(base64.b64decode(m['thumb'].split(',', 1)[1])))
assert im.size == (48, 48) and im.mode == 'RGBA', f'{k}: thumb {im.size} {im.mode}'
assert idx['models']['ginger']['dir'] == base and idx['models']['ginger']['sub'] is None, 'ginger: the baked frames'
@test('model dirs: own cat.png + cat.mtl, every frame a plain copy of the base\'s')
def _():
for k, m in idx['models'].items():
if m['kind'] not in ('recolor', 'texture'): # (the coats; an animal has frames of its own)
continue
d = m['dir']
assert d == os.path.join(catalog, m['sub']), f'{k}: dir {d}'
for f in ['cat.png', 'cat.mtl']:
p = os.path.join(d, f)
assert os.path.isfile(p) and not os.path.islink(p), f'{k}: own {f}'
assert open(os.path.join(d, 'cat.mtl')).read() == open(os.path.join(base, 'cat.mtl')).read(), f'{k}: mtl'
assert 'map_Kd cat.png' in open(os.path.join(d, 'cat.mtl')).read(), f'{k}: mtl names cat.png'
for f in objs + ['frames.json']:
p = os.path.join(d, f)
assert not os.path.islink(p) and os.path.isfile(p), f'{k}: {f} not a plain file'
for f in objs[::25] + ['frames.json']:
assert open(os.path.join(d, f), 'rb').read() == open(os.path.join(base, f), 'rb').read(), f'{k}: {f} differs'
first = open(os.path.join(d, objs[0])).readline().strip()
assert first == 'mtllib cat.mtl', f'{k}: relative mtllib ({first})'
@test('recolours: >= 90 % of the fur texels changed, nothing else')
def _():
b = tex(base)
fur = skin.fur_mask(b.astype(float) / 255)
for k, m in idx['models'].items():
if m['kind'] != 'recolor':
continue
t = tex(m['dir'])
changed = (t != b).any(-1)
assert changed[fur].mean() >= 0.9, f'{k}: {changed[fur].mean():.2f} of the fur changed'
assert not changed[~fur].any(), f'{k}: {changed[~fur].sum()} other texels changed'
@test('the coats: Tuxedo dark with white chest / muzzle / paws; Blue blue-grey; Cream warm pale; Snow near white')
def _():
b = tex(base)
fur = skin.fur_mask(b.astype(float) / 255)
mean = lambda k, mask: tex(idx['models'][k]['dir'])[mask].mean(0)
marks = np.zeros(fur.shape, bool)
for name in ['chest', 'muzzle', 'paws']:
for x0, x1, y0, y1 in skin.PATTERNS[name]:
marks[y0:y1 + 1, x0:x1 + 1] = True
marks &= fur
body = fur & ~marks
t = mean('tuxedo', body)
assert t.max() < 60, f'tuxedo body dark ({t})'
w = mean('tuxedo', marks)
assert w.min() > 220 and w.max() - w.min() < 10, f'tuxedo marks white ({w})'
bl = mean('blue', fur)
assert bl[2] > bl[0] + 10 and 90 < bl.mean() < 190, f'blue-grey ({bl})'
c = mean('cream', fur)
assert c[0] > c[2] + 25 and c.mean() > 170, f'cream ({c})'
s = mean('snow', fur)
assert s.min() > 215 and s.max() - s.min() < 15, f'snow ({s})'
@test('animals: frames of their own, <= 3000 triangles per frame, one texture, zones per pose')
def _():
animals = [k for k, m in idx['models'].items() if m['kind'] == 'gltf']
assert {'shiba', 'fox'} <= set(animals), animals
for k in animals:
m = idx['models'][k]
d, fs = m['dir'], idx['frameSets'][m['frames']]
assert m['frames'] == k and m['group'] == 'animals', f'{k}: frames {m["frames"]}, group {m["group"]}'
assert os.path.realpath(os.path.join(catalog, m['sub'])) == os.path.realpath(d), f'{k}: {m["sub"]} -> {d}'
assert json.load(open(os.path.join(d, 'frames.json'))) == fs, f'{k}: frame set = its frames.json'
mtl = open(os.path.join(d, 'cat.mtl')).read()
assert mtl.count('map_Kd') == 1 and 'map_Kd cat.png' in mtl and os.path.isfile(os.path.join(d, 'cat.png')), f'{k}: one texture'
objs = sorted(f for f in os.listdir(d) if f.endswith('.obj'))
assert len(objs) == sum(c['frames'] for c in fs['clips'].values()), f'{k}: {len(objs)} OBJs'
worst = 0
for f in objs:
lines = open(os.path.join(d, f)).read().split('\n')
assert lines[0] == 'mtllib cat.mtl', f'{k}/{f}: {lines[0]}'
assert sum(ln.startswith('usemtl ') for ln in lines) == 1, f'{k}/{f}: one material'
worst = max(worst, sum(ln.startswith('f ') for ln in lines))
assert 0 < worst <= FACE_MAX, f'{k}: {worst} triangles in a frame'
for name, c in fs['clips'].items():
for i in range(c['frames']):
assert os.path.isfile(os.path.join(d, f'{name}_{i}.obj')), f'{k}: {name}_{i}.obj'
loops = {'stand': 'idle', 'sit': 'sit', 'lie': 'lie', 'sleep': 'sleep', 'fall': 'fall', 'dangle': 'dangle'}
for pose, clip in loops.items():
if clip in fs['clips']:
z = fs['poseZones'].get(pose, {})
assert all(n in z for n in ('scruff', 'head', 'back')), f'{k}: zones of {pose} ({z})'
assert 0.2 <= fs['height'] <= 0.6 and fs['walkSpeed'] > 0.05, f'{k}: height {fs["height"]}, walk {fs["walkSpeed"]}'
print(f' {k}: {worst} triangles, {len(objs)} frames, {len(fs["clips"])} clips')
def extent(d, frame):
v = np.array([[float(x) for x in ln.split()[1:4]] for ln in open(os.path.join(d, frame)) if ln.startswith('v ')])
return v.max(0) - v.min(0)
@test('the Dachshund: the Shiba\'s clips and species ("extends"), long and low, black and tan')
def _():
dm, sm = idx['models']['dachshund'], idx['models']['shiba']
df, sf = idx['frameSets']['dachshund'], idx['frameSets']['shiba']
assert dm['name'] == 'Dachshund' and dm['group'] == 'animals', dm
assert set(df['clips']) == set(sf['clips']), sorted(set(df['clips']) ^ set(sf['clips']))
assert df.get('species') == sf.get('species'), 'species'
assert idx['order'].index('dachshund') > idx['order'].index('shiba'), idx['order']
de, se = extent(dm['dir'], 'idle_0.obj'), extent(sm['dir'], 'idle_0.obj')
dr, sr = de[2] / de[1], se[2] / se[1] # length / height, standing
assert dr > 1.4 * sr, f'length / height {dr:.2f} (the Shiba {sr:.2f})'
assert abs(de[1] - 0.27) < 0.005, f'height {de[1]:.3f}'
t = tex(dm['dir']).reshape(-1, 3)
for c in ['#3a2b24', '#c07236']:
want = np.array([int(c[i:i + 2], 16) for i in (1, 3, 5)])
assert np.abs(t - want).max(1).min() <= 2, f'{c} not in the palette'
print(f' dachshund: length / height {dr:.2f} (shiba {sr:.2f}), walk {df["walkSpeed"]} m/s')
@test('invalid specs: the build fails naming the model and the field')
def _():
good = {'id': 'x', 'name': 'X', 'kind': 'recolor', 'fur': ['#000000', '#ffffff']}
base = {'id': 'b', 'name': 'B', 'kind': 'base', 'default': True}
assert index.validate([base, good]) == [], index.validate([base, good])
cases = [
({**good, 'kind': 'dragon'}, 'x: "kind"'),
({**good, 'name': ''}, 'x: "name"'),
({**good, 'fur': ['red', '#ffffff']}, 'x: "fur"'),
({**good, 'white': ['stripes']}, "x: \"white\": unknown marking 'stripes'"),
({**good, 'kind': 'texture'}, 'x: "png"'),
({**good, 'kind': 'gltf', 'height': 0.3}, 'x: no baked frames'),
({**good, 'kind': 'gltf', 'height': 0.3, 'thumbFrame': 'nope_0'}, "x: \"thumbFrame\": no baked frame 'nope_0'"),
({**good, 'id': 'a b'}, 'the id (folder name)'),
({**good, 'private': 'yes'}, 'x: "private" must be true or false'),
({**good, 'rights': 'Someone else'}, 'x: "rights" (a character someone else owns) needs "private": true'),
({**good, 'rights': 'Someone else', 'private': False}, 'x: "rights"'),
]
for spec, want in cases:
errs = index.validate([base, spec])
assert any(want in e for e in errs), f'{want!r} not in {errs}'
assert index.validate([base, {**good, 'rights': 'Someone else', 'private': True}]) == []
assert any('exactly one model needs "default": true' in e for e in index.validate([{**base, 'default': False}, good]))
assert any('two models with this id' in e for e in index.validate([base, good, good]))
assert any("defaultModel 'nope'" in e for e in index.validate([base, good], 'nope'))
# the build step itself: exit != 0 with the message
import subprocess
import tempfile
with tempfile.TemporaryDirectory() as t:
p = os.path.join(t, 'spec.json')
json.dump({'default': None, 'models': [base, {**good, 'fur': ['#000']}]}, open(p, 'w'))
r = subprocess.run([sys.executable, index.__file__, p, base_dir, os.path.join(t, 'out')], capture_output=True, text=True)
assert r.returncode != 0 and 'x: "fur" must be two colours' in r.stderr, (r.returncode, r.stderr)
@test('built-in models: none private (package.nix refuses one), every model of the catalog public')
def _():
root = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'models')
for d in sorted(os.listdir(root)):
spec = json.load(open(os.path.join(root, d, 'model.json')))
assert spec.get('private', False) is False and 'rights' not in spec, f'{d}: private'
assert all(m['private'] is False for m in idx['models'].values()), [k for k, m in idx['models'].items() if m['private']]
@test('model folders added (tests/models: testcoat, testprivate) show up in the index, nothing else changed')
def _():
t = json.load(open(os.path.join(test_catalog, 'models.json')))
assert set(t['models']) == set(idx['models']) | {'testcoat', 'testprivate'}, sorted(t['models'])
m = t['models']['testcoat']
assert m['name'] == 'Test Coat' and m['kind'] == 'texture' and m['group'] == 'coats', m
assert t['order'].index('testcoat') > t['order'].index('snow') and t['order'].index('testcoat') < t['order'].index('shiba'), t['order']
im = Image.open(io.BytesIO(base64.b64decode(m['thumb'].split(',', 1)[1])))
assert im.size == (48, 48), im.size
assert t['default'] == idx['default'] and t['frameSets'].keys() == idx['frameSets'].keys()
got = np.asarray(Image.open(os.path.join(m['dir'], 'cat.png')).convert('RGB'))
assert got.shape == (64, 64, 3), got.shape
assert m['private'] is False, m
@test('a private model added like an extraModels folder (tests/models/testprivate) builds and is marked private')
def _():
t = json.load(open(os.path.join(test_catalog, 'models.json')))
m = t['models']['testprivate']
assert m['private'] is True and m['kind'] == 'recolor' and m['group'] == 'coats', m
assert os.path.isfile(os.path.join(m['dir'], 'cat.png')), m['dir']
if failed:
print(f'{failed} failed')
sys.exit(1)
print('all passed')
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1 @@
{ "name": "Test Coat", "kind": "texture", "order": 5, "png": "coat.png", "thumb": "coat.png" }
@@ -0,0 +1,6 @@
{
"name": "Test Private", "kind": "recolor", "order": 6,
"fur": ["#203040", "#8090a0"],
"private": true,
"rights": "An example: a character someone else owns, for personal use only (never published)"
}
+97
View File
@@ -0,0 +1,97 @@
// The scripted world of the core's tests (core.test.js, species.test.js):
// the user at (0, 1.6) looking toward -z, a right hand moved by a script,
// deterministic randomness, every clip shown and prefetched recorded.
'use strict';
const path = require('path');
const Core = require(path.join(__dirname, '..', 'core.js'));
const HZ = 24, DT = 1 / HZ;
// Deterministic randomness (mulberry32); SEED_OFFSET=n: other random runs.
function seed(s) {
s += +process.env.SEED_OFFSET || 0;
Math.random = () => {
s |= 0; s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// A core on `baked` in the scripted world. setScript(f): the right hand
// follows f(t, api) -> { x, y, z, press, carried } | null (velocity by finite
// differences, like a tracked controller's). press >= 0.5: the laser drags
// the pet by the scruff (world.held(), as systemui.js' drag), the grab point
// the hand's position; with `carried` ({ p, q }: where the adapter's
// compositor has it, systemui.js' root carried by the hand)
// world.heldPose() reports that. A shown clip or frame that is not baked
// throws.
function sim(baked, seedValue, { opts = {}, saved } = {}) {
seed(seedValue);
const head = { x: 0, y: 1.6, z: 1.6, fx: 0, fz: -1 };
let script = () => null, t = 0, prev = null, hand = null, held = null, carried = null;
const shown = [], asked = new Set(), logs = [];
const world = {
head: () => head,
hands: () => ({ left: null, right: hand }),
held: () => held,
heldPose: () => (carried ? { x: carried.p[0], y: carried.p[1], z: carried.p[2], q: carried.q } : null),
show: (clip, frame, at) => {
if (!baked.clips[clip] || frame < 0 || frame >= baked.clips[clip].frames) throw new Error(`shown ${clip} ${frame}: not baked`);
shown.push({ t, clip, frame, ...at });
},
prefetch: (clips) => { for (const c of clips) asked.add(c); },
ready: () => true,
load: () => saved, save: () => {},
log: (...a) => logs.push(`${t.toFixed(2)} ${a.join(' ')}`),
};
const cat = Core.create(world, opts, baked);
const api = {
cat, shown, asked, logs,
get t() { return t; },
setScript(f) { script = f; prev = null; },
run(seconds, check) {
for (let end = t + seconds - 1e-9; t < end;) {
t += DT;
const p = script(t, api);
hand = p ? { x: p.x, y: p.y, z: p.z,
vx: prev ? (p.x - prev.x) / DT : 0, vy: prev ? (p.y - prev.y) / DT : 0, vz: prev ? (p.z - prev.z) / DT : 0 } : null;
prev = p;
carried = p?.press >= 0.5 && p.carried ? p.carried : (p?.carried && held ? p.carried : null); // (read on the release tick too)
held = p?.press >= 0.5 ? (p.carried ? { source: 'laser', hand: 'right' } : { source: 'laser', hand: 'right', ...hand }) : null;
cat.tick(DT);
check?.(cat.state(), t);
}
return cat.state();
},
zone: (n) => cat.zone(n),
clipsShown: () => new Set(shown.map((s) => s.clip)),
// A hand moving toward `target()` at most `speed` m/s (no teleports).
toward(target, speed = 0.3, press = 0) {
let at = null;
return () => {
const g = target();
if (!at) at = { ...g };
const d = Math.hypot(g.x - at.x, g.y - at.y, g.z - at.z), k = d > speed * DT ? speed * DT / d : 1;
at = { x: at.x + (g.x - at.x) * k, y: at.y + (g.y - at.y) * k, z: at.z + (g.z - at.z) * k };
return { ...at, press: typeof press === 'function' ? press() : press };
};
},
};
cat.tick(DT); // placed in front of the user
cat.command('stand'); // calm start: rests 20 s
return api;
}
// A synchronous test runner: test(name, f), then done().
let failed = 0;
function test(name, f) {
try { f(); console.log(`ok ${name}`); } catch (e) { failed++; console.log(`FAIL ${name}: ${e.stack}`); }
}
function assert(c, msg) { if (!c) throw new Error(msg); }
const near = (a, b, tol) => Math.abs(a - b) <= tol;
function done() {
if (failed) { console.log(`${failed} failed`); process.exit(1); }
console.log('all passed');
}
module.exports = { Core, DT, seed, sim, test, assert, near, done };
+129
View File
@@ -0,0 +1,129 @@
// Headless tests of the behaviour core (core.js) with other animals: a
// synthetic species (the cat's frames without sit, groom, dangle, ...), and
// every frame set of the model catalog (the cat, the animals): the core only
// ever asks for clips that are baked (the world: sim.js).
// usage: node species.test.js <models.json> (exit 1 on failure)
// Run by the flake check `pet` (package.nix `tests`).
'use strict';
const fs = require('fs');
const { sim, test, assert, done } = require('./sim.js');
const catalog = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); // index.py's models.json
// The cat's frame set with clips taken away (and an action of its own).
const catBaked = catalog.frameSets[catalog.models[catalog.order.find((id) => catalog.models[id].kind === 'base')].frames];
function without(names, extra = {}) {
const b = JSON.parse(JSON.stringify(catBaked));
for (const n of names) delete b.clips[n];
delete b.poseZones.dangle;
Object.assign(b, extra);
return b;
}
const NO_SIT = ['sit', 'sitdown', 'standup', 'sitpurr', 'sitpurrin', 'groom', 'dangle'];
const synthetic = () => {
const b = without(NO_SIT);
b.clips.sniff = { ...b.clips.stretch }; // (an action of its own: any one-shot clip standing)
b.species = { actions: { sniff: { pose: 'stand', weight: 4, cooldown: 20 } } };
return b;
};
// A slow stroke along the back for `s` seconds (the hand follows the cat).
const stroke = (a) => (t) => { const b = a.cat.zone('back'); return b && { x: b.x + 0.02 * Math.sin(t * 3), y: b.y + 0.03, z: b.z, press: 0 }; };
// ---------------------------------------------------------------------------
test('synthetic species (no sit, groom, dangle): a calm hour uses only its clips, its own action', () => {
const b = synthetic();
const s = sim(b, 31);
const sp = s.cat.species();
assert(!sp.poses.includes('sit') && sp.poses.includes('lie') && sp.actions.includes('sniff') && !sp.actions.includes('groom'),
`species: ${JSON.stringify(sp)}`);
assert(sp.clips.every((c) => b.clips[c]), `species clips all baked (${sp.clips.filter((c) => !b.clips[c])})`);
s.run(60 * 60, (x) => assert(x.pose !== 'sit' && x.goal !== 'sit', `sat (${x.pose} / ${x.goal})`));
const shown = s.clipsShown();
for (const c of shown) assert(b.clips[c], `showed ${c}`);
for (const c of s.asked) assert(b.clips[c], `prefetched ${c}`);
const n = s.logs.filter((l) => l.endsWith('action sniff')).length;
assert(n >= 1, `sniffs now and then (${n})`);
assert(shown.has('lie') || shown.has('walk'), `lives (${[...shown]})`);
});
test('synthetic species: commands fall back (sit -> stand), missing actions ignored, its action runs', () => {
const s = sim(synthetic(), 32);
s.run(1);
s.cat.command('sit');
let st = s.run(3);
assert(st.pose === 'stand' && st.goal === 'stand', `sit -> stand (${st.pose})`);
s.cat.command('groom');
st = s.run(0.5);
assert(st.action === null && st.pending === null, `groom ignored (${st.action})`);
assert(s.logs.some((l) => l.includes('no clip groom')), 'logged');
s.cat.command('sniff');
st = s.run(0.2);
assert(st.action === 'sniff' || st.pending === 'sniff', `sniff runs (${st.action}/${st.pending})`);
s.cat.command('lie');
st = s.run(4);
assert(st.pose === 'lie', `lies (${st.pose})`);
});
test('synthetic species: dragged by the laser without a dangle clip (shows the fall loop), lands', () => {
const s = sim(synthetic(), 33);
s.run(1);
s.setScript(() => ({ x: 0.3, y: 1.0, z: 0.5, press: 1 }));
let st = s.run(1);
const last = s.shown[s.shown.length - 1];
assert(st.held && st.pose === 'dangle' && last.clip === 'fall', `held, the fall loop shown (${st.pose} ${last.clip})`);
s.setScript(() => null);
st = s.run(2);
assert(!st.held && !st.falling && st.y === 0, `landed (${st.pose} ${st.y})`);
});
test('synthetic species: petting purrs (its purr loop); without purr loops petting does nothing', () => {
const s = sim(synthetic(), 34);
s.run(1);
s.setScript(stroke(s));
let st = s.run(3);
assert(st.purring && st.pose === 'purr', `purrs (${st.pose})`);
const none = sim(without([...NO_SIT, 'purr', 'purrin', 'liepurr', 'liepurrin']), 35);
none.run(1);
none.setScript(stroke(none));
none.run(4, (x) => assert(!x.purring, 'purred without a purr loop'));
assert(!none.cat.species().poses.includes('purr'), 'no purr pose');
});
test('a missing transition is a cut; a saved pose it lacks loads as the nearest', () => {
const s = sim(without(['liedown']), 36);
s.run(1);
s.cat.command('lie');
const st = s.run(1);
assert(st.pose === 'lie' && !s.clipsShown().has('liedown'), `lies at once (${st.pose})`);
assert(s.logs.some((l) => l.includes('no clip liedown (cut)')), 'logged');
const t = sim(synthetic(), 37, { saved: { x: 0.2, z: 0.4, yaw: 1, pose: 'sit' } });
assert(t.cat.state().pose === 'stand' && t.cat.state().x === 0.2, `saved sit -> stand at its spot (${t.cat.state().pose})`);
});
// Every frame set of the catalog: the cat and each animal.
for (const [name, baked] of Object.entries(catalog.frameSets)) {
test(`${name}: every clip the core can ask for is baked; commands, petting, a drag, an hour`, () => {
const s = sim(baked, 41);
const sp = s.cat.species();
for (const c of sp.clips) assert(baked.clips[c], `core may ask for ${c}: not baked`);
for (const p of sp.poses) {
if (['stand', 'sit', 'lie', 'sleep', 'fall', 'dangle'].includes(p)) assert(baked.poseZones?.[p]?.scruff, `zones of ${p}`);
}
const cmds = ['sit', 'lie', 'sleep', 'stand', 'groom', 'stretch', 'shake', 'purr', 'startle', 'drop', 'dangle', 'summon', 'wander', 'follow', 'turn',
...Object.keys(baked.species?.actions ?? {})];
for (const c of cmds) { s.cat.command(c); s.run(6); }
s.cat.command('stand');
s.run(2);
s.setScript(stroke(s));
const st = s.run(3);
assert(st.purring === sp.poses.includes('purr'), `petting: purring ${st.purring}`);
s.setScript((t) => (t % 60 < 1.5 ? { x: 0.3, y: 1.0, z: 0.5, press: 1 } : null)); // a drag every minute
s.run(60 * 60);
for (const c of s.clipsShown()) assert(baked.clips[c], `showed ${c}`);
for (const c of s.asked) assert(baked.clips[c], `prefetched ${c}`);
const poses = new Set(s.shown.map((x) => x.clip));
console.log(` ${name}: poses ${sp.poses.join(' ')}; actions ${sp.actions.join(' ')}; shown ${poses.size}/${Object.keys(baked.clips).length} clips`);
});
}
done();
+985
View File
@@ -0,0 +1,985 @@
// Headless tests of the SteamVR adapter (systemui.js) in a fake systemui page:
// a minimal DOM, VRHTML (poses, sgids), SGApp's embedded-UV table, the scene
// graph module (slow send) and mailbox (captured socket: fast send), the
// steam-ui-patches store, and fake timers (virtual clock).
// usage: node systemui.test.js <models.json> (exit 1 on failure)
// Run by the flake check `pet` (package.nix `tests`).
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const catalog = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); // index.py's models.json
const baked0 = catalog.frameSets[catalog.models[catalog.default].frames];
const src = ['core.js', 'systemui.js'].map((f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8')).join('\n');
const PREFIX = 'mailbox_send vrcompositor_systemlayer ';
// ---- fake DOM ----
class El {
constructor(tag, doc) { this.tagName = tag.toUpperCase(); this.doc = doc; this.attrs = {}; this.children = []; this.parent = null; this.style = {}; this.listeners = {}; this.id = ''; this.textContent = ''; }
setAttribute(k, v) { this.attrs[k] = String(v); }
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
removeAttribute(k) { delete this.attrs[k]; }
hasAttribute(k) { return k in this.attrs; }
appendChild(c) { c.remove(); c.parent = this; this.children.push(c); return c; }
remove() { if (this.parent) { this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null; } }
replaceWith(n) { const p = this.parent, i = p.children.indexOf(this); n.remove(); p.children[i] = n; n.parent = p; this.parent = null; }
get firstChild() { return this.children[0] ?? null; }
get parentNode() { return this.parent; }
get isConnected() { let e = this; while (e.parent) e = e.parent; return e === this.doc.body; }
addEventListener(t, f) { (this.listeners[t] ??= []).push(f); }
removeEventListener(t, f) { this.listeners[t] = (this.listeners[t] ?? []).filter((g) => g !== f); }
getBoundingClientRect() {
if (!this.isConnected || this.style.position !== 'fixed') return { x: 0, y: 0, left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0 };
const x = parseFloat(this.style.left), y = parseFloat(this.style.top), w = parseFloat(this.style.width), h = parseFloat(this.style.height);
return { x, y, left: x, top: y, width: w, height: h, right: x + w, bottom: y + h };
}
*walk() { yield this; for (const c of this.children) yield* c.walk(); }
// A laser event, as the browser runs it: window's capture listeners first
// (whatever the target does), then the target and its ancestors, then
// window's bubbling listeners, unless stopped.
dispatch(type, mods = {}) {
const e = { type, target: this, stopped: false, stopPropagation() { this.stopped = true; }, getModifierState: (k) => !!mods[k] };
const win = this.doc.win.listeners[type] ?? [];
for (const [f, capture] of win) if (capture) f(e);
for (let n = this; n && !e.stopped; n = n.parent) for (const f of n.listeners[type] ?? []) f(e);
if (!e.stopped) for (const [f, capture] of win) if (!capture) f(e);
return e;
}
}
function page({ store = {}, sgQuery = true, setup = null } = {}) {
// virtual clock and timers
const clock = { now: 0, id: 0, timers: new Map(), scheduled: 0 };
const setT = (f, ms, every) => { const id = ++clock.id; clock.scheduled++; clock.timers.set(id, { f, at: clock.now + Math.max(0, ms || 0), every: every ? Math.max(1, ms) : 0 }); return id; };
const advance = async (ms) => {
const end = clock.now + ms;
for (;;) {
let next = null;
for (const [id, t] of clock.timers) if (t.at <= end && (!next || t.at < next[1].at)) next = [id, t];
if (!next) break;
const [id, t] = next;
clock.now = t.at;
if (t.every) t.at += t.every; else clock.timers.delete(id);
t.f();
await new Promise((r) => setImmediate(r)); // promise callbacks (where()) between timers
}
clock.now = end;
await new Promise((r) => setImmediate(r));
};
const doc = { };
const win = { listeners: {}, addEventListener(t, f, capture = false) { (this.listeners[t] ??= []).push([f, !!(capture?.capture ?? capture)]); },
removeEventListener(t, f) { this.listeners[t] = (this.listeners[t] ?? []).filter(([g]) => g !== f); } };
doc.win = win;
doc.all = []; // every element made (also those out of the page: a hidden cat's)
doc.createElement = (t) => { const e = new El(t, doc); doc.all.push(e); return e; };
doc.body = new El('body', doc);
doc.head = new El('head', doc);
doc.querySelectorAll = (sel) => {
const m = /^\[([\w-]+)="([^"]+)"\]$/.exec(sel);
return [...doc.body.walk()].filter((e) => m && e.getAttribute(m[1]) === m[2]);
};
// A dashboard panel at the top of the page (the free spot must avoid it).
const dash = doc.createElement('vsg-node');
dash.setAttribute('vsg-type', 'panel');
const dashContent = doc.createElement('div');
Object.assign(dashContent.style, { position: 'fixed', left: '0px', top: '0px', width: '1280px', height: '600px' });
dash.getBoundingClientRect = () => dashContent.getBoundingClientRect();
dash.appendChild(dashContent);
doc.body.appendChild(dash);
const stats = { slow: 0, fast: 0, force: 0, get total() { return this.slow + this.fast + this.force; }, lastFast: null };
let sgid = 1000;
const poses = { '/user/head': { p: [0, 1.6, 1.6], q: [1, 0, 0, 0] }, '/user/hand/left': null, '/user/hand/right': null };
const nodePoses = {}; // sg query answers by id (default: its pose in the DOM, as vrcompositor has it at rest)
const embedded = [];
// The scene graph mailbox and its socket; the dashboard's send (slow path)
// walks the DOM: our root's node is in the message only while connected.
const sock = { readyState: 1, send(str) { if (str.startsWith(PREFIX)) stats.fast++; stats.lastSent = str; } };
class Mailbox { SendMessage() {} SendMessageAndWaitForResponse() {} RegisterHandler() {} }
const mailbox = new Mailbox();
mailbox.m_wsWebSocketToServer = sock;
const slowSend = function update_scene_graph() {
stats.slow++; stats.fast--; // the socket counts it too
const root = [...doc.body.children].find((e) => e.id === 'sfui-vr-pet');
mailbox.SendMessage('vrcompositor_systemlayer', { type: 'update_scene_graph' });
const msg = { type: 'update_scene_graph', scene_graph: { properties: { eDashboardRelatch: 0 }, children: root ? [{ type: 'transform', properties: { id: `system.systemui::${root.id}`, sgid: +root.getAttribute('sgid') }, children: [] }] : [] } };
mailbox.m_wsWebSocketToServer.send(PREFIX + JSON.stringify(msg));
};
const req = {};
const find = {
getWebpackRequire: () => req,
findModule(r, spec, label) {
if (label === 'scene graph module') return { exports: { send: slowSend } };
if (label === 'mailbox module') return { exports: { Mailbox } };
throw new Error(`no ${label}`);
},
findExport(exports, spec) {
const v = Object.values(exports).find((x) => typeof x === 'function');
return { value: v };
},
};
const hooks = {
before(obj, m, name, f) { const orig = obj[m]; obj[m] = function (...a) { f.call(this, a); return orig.apply(this, a); }; obj[m].__orig = orig; },
remove(obj, m) { if (obj[m].__orig) obj[m] = obj[m].__orig; },
};
const xf = (p) => ({ translation: { x: p.p[0], y: p.p[1], z: p.p[2] }, rotation: { w: p.q[0], x: p.q[1], y: p.q[2], z: p.q[3] } });
const G = {
window: null, document: doc, innerWidth: 1280, innerHeight: 1440,
performance: { now: () => clock.now },
setTimeout: (f, ms) => setT(f, ms, false), clearTimeout: (id) => clock.timers.delete(id),
setInterval: (f, ms) => setT(f, ms, true), clearInterval: (id) => clock.timers.delete(id),
queueMicrotask, Promise, JSON, Math, Object, Array, Set, Map, Number, String, Error, console,
addEventListener: (t, f, c) => win.addEventListener(t, f, c), removeEventListener: (t, f, c) => win.removeEventListener(t, f, c),
forceLayoutUpdate: () => { stats.force++; G.SGApp.updateAllPanelBounds(); },
VRHTML: {
NextSGID: () => ++sgid,
GetPose: (p) => [poses[p] ? { bPoseIsValid: true, xfDeviceToAbsoluteTracking: xf(poses[p]), vVelocity: { x: 0, y: 0, z: 0 } } : { bPoseIsValid: false }],
VROverlay: { ThisOverlayKey: () => 'system.systemui' },
},
// SGApp: its panels by sgid (the embedded ones too); a layout measures
// every panel (updateLayoutValues: its m_Rect, the slot's rect).
SGApp: {
m_mapPanels: new Map(),
addEmbeddedPanelUVs(fp) { embedded.push(fp); this.m_mapPanels.set(fp.getSGID(), fp); return embedded.length - 1; },
removeEmbeddedPanelUVs(fp) { const i = embedded.indexOf(fp); if (i >= 0) embedded[i] = null; this.m_mapPanels.delete(fp.getSGID()); },
updateAllPanelBounds() { this.m_mapPanels.forEach((fp) => fp.updateLayoutValues?.()); },
},
__sfuiStore: { data: { 'vr-pet': store }, get(n) { return this.data[n]; }, set(n, v) { this.data[n] = v; return true; } },
};
// A node's world pose from the DOM's translation / rotation attributes.
const domPose = (id) => {
const local = id.split('::').pop();
let e = [...doc.body.walk()].find((n) => n.id === local), w = { p: [0, 0, 0], q: [1, 0, 0, 0] };
for (; e && e !== doc.body; e = e.parent) {
const t = e.getAttribute('translation'), r = e.getAttribute('rotation');
if (t || r) w = qc({ p: t ? t.split(' ').map(Number) : [0, 0, 0], q: r ? r.split(' ').map(Number) : [1, 0, 0, 0] }, w);
}
return w;
};
// vrcompositor, as far as the cat relies on it: the world poses of the last
// sent scene graph. A transform with a parent-path: that device's pose · its
// own, rigidly (its ancestors ignored), as SteamVR's floating windows use it;
// a grab-scale: its children pushed along its -Z by `vrc.push` (the
// thumbstick) while active, back to 0 on a new reset-generation.
const vrc = { push: 0, gens: new Map() };
vrc.world = () => {
const out = new Map();
const msg = stats.lastSent ? JSON.parse(stats.lastSent.slice(PREFIX.length)) : null;
const visit = (n, parent) => {
if (!n || typeof n !== 'object') return;
if (Array.isArray(n)) { n.forEach((c) => visit(c, parent)); return; }
const P = n.properties ?? {}, local = { p: P.translation ?? [0, 0, 0], q: P.rotation ?? [1, 0, 0, 0] };
const dev = P['parent-path'] ? poses[P['parent-path']] : null;
let w = parent;
if (n.type === 'transform') w = qc(dev ?? parent, local);
else if (n.type === 'grab-scale') {
if (vrc.gens.has(P.sgid) && vrc.gens.get(P.sgid) !== P['reset-generation']) vrc.push = 0;
vrc.gens.set(P.sgid, P['reset-generation']);
w = P['is-active'] ? qc(parent, { p: [0, 0, -vrc.push], q: [1, 0, 0, 0] }) : parent;
}
if (P.sgid != null) out.set(P.sgid, w);
if (P.id) out.set(P.id, w);
visit(n.children, w);
};
visit(msg?.scene_graph, { p: [0, 0, 0], q: [1, 0, 0, 0] });
return out;
};
const sgPoseOf = (id) => nodePoses[id] ?? vrc.world().get(id) ?? domPose(id);
if (sgQuery) {
G.SGQueryService = {
requestSGTransform: (id) => Promise.resolve(xf(sgPoseOf(id))),
requestSGTransformRelative: (from, id) => Promise.resolve({ ...xf(qc(qinv(sgPoseOf(from)), sgPoseOf(id))), scale: { x: 1, y: 1, z: 1 } }),
};
}
G.window = G;
const ctx = vm.createContext(G);
vm.runInContext(`${src}\n;globalThis.vrPetSystemui = vrPetSystemui;`, ctx);
const opts = { fps: 24, mount: 'dynamic', cat: {} };
const inject = () => ctx.vrPetSystemui(opts, catalog, find, hooks);
setup?.(G, doc);
return {
G, doc, clock, advance, stats, poses, nodePoses, domPose, vrc, embedded, mailbox, sock, inject,
// the patch's unpatch expression (unpatch.js), as the injector runs it when the service stops
unpatch: () => vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'unpatch.js'), 'utf8'), ctx),
listeners: () => Object.values(win.listeners).reduce((n, l) => n + l.length, 0),
get S() { return G.__sfuiPet; },
get stored() { return G.__sfuiStore.data['vr-pet']; },
activeTimers: () => clock.timers.size,
// A layout (the page's own, not a send), and whether a panel's embedded-UV slot holds a rect.
layout: () => G.SGApp.updateAllPanelBounds(),
slotValid: (fp) => !!fp && fp.idx != null && !fp.isExternal && fp.m_Rect.width > 0 && fp.m_Rect.height > 0,
fpOf: (id) => G.SGApp.m_mapPanels.get(+(doc.all.find((e) => e.id === id)?.getAttribute('sgid'))),
// A laser click on an element (mousedown, mouseup, click; hand in the modifier state).
click(el, hand = 'right') {
const mods = hand === 'left' ? { NumLock: true } : { CapsLock: true, NumLock: true };
el.dispatch('mousedown', mods); el.dispatch('mouseup', mods); return el.dispatch('click', mods);
},
byId: (id) => [...doc.body.walk()].find((e) => e.id === id) ?? doc.all.find((e) => e.id === id) ?? null,
byAttr: (k, v) => doc.all.find((e) => e.getAttribute(k) === v) ?? null,
dash: dashContent,
lastMsg: () => (stats.lastSent ? JSON.parse(stats.lastSent.slice(PREFIX.length)) : null),
};
}
// Pose maths (w x y z quaternions): compose, invert, rotate.
const qm = (a, b) => [a[0] * b[0] - a[1] * b[1] - a[2] * b[2] - a[3] * b[3], a[0] * b[1] + a[1] * b[0] + a[2] * b[3] - a[3] * b[2],
a[0] * b[2] - a[1] * b[3] + a[2] * b[0] + a[3] * b[1], a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + a[3] * b[0]];
const qconj = (q) => [q[0], -q[1], -q[2], -q[3]];
const qr = (q, v) => qm(qm(q, [0, ...v]), qconj(q)).slice(1);
const qaxis = (ax, deg) => { const h = deg * Math.PI / 360; return [Math.cos(h), ...ax.map((v) => v * Math.sin(h))]; };
function qc(a, b) { const r = qr(a.q, b.p); return { p: a.p.map((v, k) => v + r[k]), q: qm(a.q, b.q) }; }
const qinv = (a) => { const c = qconj(a.q); return { p: qr(c, a.p).map((v) => -v), q: c }; };
const sgPose = (n) => ({ p: n.properties.translation ?? [0, 0, 0], q: n.properties.rotation ?? [1, 0, 0, 0] });
let failed = 0;
const tests = [];
const test = (name, f) => tests.push([name, f]);
function assert(c, msg) { if (!c) throw new Error(msg); }
const near = (a, b, tol) => Math.abs(a - b) <= tol;
// Find a node by sgid in a scene graph message.
function findSg(n, pred) {
if (!n || typeof n !== 'object') return null;
if (Array.isArray(n)) { for (const c of n) { const r = findSg(c, pred); if (r) return r; } return null; }
if (pred(n)) return n;
return findSg(n.children, pred) ?? findSg(n.scene_graph, pred);
}
// A page with the cat running for `s` seconds (frames mounted, fast path captured).
async function running(s = 3, o) {
const p = page(o);
const r = p.inject();
assert(/^patched/.test(r), `injected (${r})`);
await p.advance(s * 1000);
return p;
}
// ---------------------------------------------------------------------------
test('injects: root in the page, timer, fast sends, api', async () => {
const p = await running();
assert(p.S.root.isConnected, 'root in the page');
assert(p.S.timer != null, 'timer running');
assert(p.stats.fast > 0, `fast sends (${JSON.stringify(p.S.stats().deferred)}, fast ${p.stats.fast})`);
assert(p.S.api?.version === 2 && ['show', 'hide', 'status', 'models', 'setModel'].every((k) => typeof p.S.api[k] === 'function'), 'api { version, show, hide, status, models, setModel }');
assert(p.inject() === 'unchanged', 're-inject of a shown cat: unchanged');
});
test('controls row (⋯ X): centred below the bar, laser-only, not a grab handle, in the fast send', async () => {
const p = await running();
const msg = p.lastMsg();
const sg = (id) => findSg(msg.scene_graph, (n) => n.properties?.id === `system.systemui::${id}`);
const bar = sg('sfui-vr-pet-handle'), x = sg('sfui-vr-pet-controls');
assert(bar && x, 'bar and controls panels in the fast send');
assert(!sg('sfui-vr-pet-menu'), 'no menu while closed');
assert(p.byAttr('data-act', 'menu') && p.byAttr('data-act', 'close'), 'buttons ⋯ and X');
assert(!findSg(msg.scene_graph, (n) => n.type === 'grab-transform'), 'no grab-transform (the dashboard\'s drag: not rigid on the laser)');
const push = findSg(msg.scene_graph, (n) => n.type === 'grab-scale');
assert(push && push.properties['is-active'] === false && !('event-panel-sgid' in push.properties), 'the push: inactive, no event panel at rest');
assert(findSg(push, (n) => n === x) && findSg(push, (n) => n === bar), 'the bar and the X hang under the push (carried with the drag)');
const lift = findSg(push, (n) => n.type === 'transform' && findSg(n.children, (c) => c === bar) && n.properties.translation[1] > 0.3);
assert(near(lift.properties.translation[1], 0.40, 1e-9), `lift 0.40 (${lift.properties.translation})`);
const facing = lift.children[0];
assert(facing.type === 'head-facing-transform' && facing.children[0] === bar, 'bar under the head-facing transform');
const below = facing.children[1];
assert(below.type === 'transform' && below.children[0] === x, 'X under the same head-facing transform');
const XP = x.properties, BP = bar.properties;
assert(near(below.properties.translation[1], -(0.008 + 0.04), 1e-9) && below.properties.translation[0] === 0, `controls 8 mm below the bar, centred (${below.properties.translation})`);
assert(XP.width === 0.08 && XP.origin[0] === 0 && XP.origin[1] === -1, 'controls 0.08 m, bottom-centre origin');
assert(XP['only-visible-with-laser'] === true && XP['is-grab-handle'] === false && XP['hide-laser-when-clicking'] === false && XP.interactive === true,
'X: laser-only, no grab handle, laser kept, interactive');
assert(BP['is-grab-handle'] === true, 'bar: grab handle');
// Textures: one free spot, the X's rect centred below the bar's, not overlapping, below the dashboard's panel.
const bu = [BP.uv_min, BP.uv_max].map(([u, v]) => [u * 1280, v * 1440]), xu = [XP.uv_min, XP.uv_max].map(([u, v]) => [u * 1280, v * 1440]);
assert(xu[0][1] >= bu[1][1], 'X texture below the bar texture');
assert(near((xu[0][0] + xu[1][0]) / 2, (bu[0][0] + bu[1][0]) / 2, 1e-6), 'X texture centred under the bar texture');
assert(near(xu[1][0] - xu[0][0], 96, 1e-6) && near(xu[1][1] - xu[0][1], 48, 1e-6), 'controls texture 96 x 48 px');
assert(bu[0][1] >= 600, 'free spot below the dashboard panel');
assert(p.embedded.length === 3 && XP['embedded-uv-index'] !== BP['embedded-uv-index'], 'three embedded UV slots (bar, controls, menu)');
// Dangling: the handle drops to liftDangle.
p.S.cat.command('dangle');
await p.advance(500);
const d = findSg(p.lastMsg().scene_graph, (n) => n.type === 'transform' && n.properties.sgid === lift.properties.sgid);
assert(near(d.properties.translation[1], 0.15, 1e-9), `dangle lift 0.15 (${d.properties.translation})`);
});
test('X click hides: one send, then no sends and no timers for 10 s; persisted', async () => {
const p = await running();
const before = p.stats.total, sched = p.clock.scheduled;
const e = p.click(p.byAttr('data-act', 'close'));
assert(e.stopped, 'the click does not reach the dashboard');
assert(p.S.hidden && !p.S.root.isConnected, 'hidden, root out of the page');
assert(p.stats.total - before === 1, `one send (${p.stats.total - before})`);
const m = p.lastMsg();
assert(!findSg(m.scene_graph, (n) => n.properties?.sgid === +p.S.root.getAttribute('sgid')), 'that send has no cat');
assert(p.S.stats().frames === 0 && ![...p.S.root.walk()].some((n) => n.buildNode?.({})?.[1]?.type === 'rendermodel'), 'frames unmounted');
assert(p.activeTimers() === 0, `no timers (${p.activeTimers()})`);
await p.advance(10000);
assert(p.stats.total - before === 1, `still one send after 10 s (${p.stats.total - before})`);
assert(p.clock.scheduled === sched, `no timer scheduled while hidden (${p.clock.scheduled - sched})`);
assert(p.stored.hidden === true && Number.isFinite(p.stored.x), `stored hidden with the spot (${JSON.stringify(p.stored)})`);
assert(p.inject() === 'unchanged', 're-inject while hidden: unchanged (not resurrected)');
assert(!p.S.root.isConnected, 'still out of the page');
assert(p.S.api.hide().was === 'hidden', 'hide again: already hidden');
assert(p.stats.total - before === 1, 'hide again: no send');
});
test('show: back at its spot (near, in view), gradual remount, fresh mover, persisted', async () => {
const p = await running();
p.S.cat.command('sit'); // stays put (a wandering cat's stored spot lags behind)
await p.advance(4000);
const at = p.S.cat.state();
const gen = p.S.stats().pushSent['reset-generation'];
p.S.api.hide();
const r = p.S.api.show();
assert(r.was === 'hidden' && !r.summoned && !p.S.hidden, `shown (${JSON.stringify(r)})`);
const st = p.S.cat.state();
assert(near(st.x, at.x, 1e-9) && near(st.z, at.z, 1e-9), 'at its spot');
assert(p.S.root.isConnected && p.S.timer != null, 'root and timer back');
assert(p.S.stats().pushSent['reset-generation'] > gen && !p.S.stats().carry, 'a fresh push (reset), not carried');
assert(p.S.stats().frames <= 3, `frames mount gradually (${p.S.stats().frames} on the first tick)`);
await p.advance(3000);
assert(p.S.stats().frames > 3, 'frames mounted again');
assert(p.stored.hidden === false && near(p.stored.x, p.S.cat.state().x, 0.5), `stored hidden: false, spot kept (${JSON.stringify(p.stored)})`);
const again = p.S.api.show();
assert(again.was === 'shown' && !again.summoned, 'show again: idempotent');
assert(p.inject() === 'unchanged', 'shown again: unchanged');
});
test('show: summoned when far or out of view, or forced', async () => {
const p = await running();
const head = p.poses['/user/head'];
// far: the cat 5 m away
p.S.api.hide();
p.poses['/user/head'] = { p: [0, 1.6, 6.6], q: [1, 0, 0, 0] };
let r = p.S.api.show();
let st = p.S.cat.state();
assert(r.summoned && near(st.x, 0, 1e-6) && near(st.z, 5.6, 1e-6), `far: summoned 1 m in front (${st.x}, ${st.z})`);
// behind the user: near but out of view
await p.advance(500);
p.S.api.hide();
p.poses['/user/head'] = { p: [0, 1.6, st.z - 1.2], q: [1, 0, 0, 0] }; // stepped past it, looking away
r = p.S.api.show();
assert(r.summoned, 'out of view: summoned');
// forced
await p.advance(500);
p.S.api.hide();
r = p.S.api.show({ summon: true });
assert(r.summoned, '--summon: summoned');
p.poses['/user/head'] = head;
});
test('X during a laser drag: inert (also released over it, and 300 ms after)', async () => {
const p = await running();
p.poses['/user/hand/right'] = { p: [0.2, 1.2, 1.2], q: [1, 0, 0, 0] };
const R = { CapsLock: true, NumLock: true };
const bar = p.byId('sfui-vr-pet-handle-content'), x = p.byAttr('data-act', 'close');
const at = p.S.cat.state();
assert(!bar.hasAttribute('data-active') && bar.children[0]?.getAttribute('class') === 'sfui-vr-pet-grip', 'the bar: idle look, the grip');
bar.dispatch('mousedown', R);
await p.advance(300);
assert(p.S.stats().drag?.phase === 'drag' && p.S.stats().carry === '/user/hand/right', `dragging (${JSON.stringify(p.S.stats().drag)})`);
assert(p.S.cat.state().held?.source === 'laser', 'held by the laser');
assert(bar.hasAttribute('data-active'), 'the bar: dragging look during the drag');
// a press and release on the X while dragging (the other hand's laser): nothing
p.click(x, 'left');
assert(!p.S.hidden && p.S.stats().drag, 'X during the drag: still shown, still dragged');
assert(!x.hasAttribute('data-armed'), 'X not armed during the drag');
// the drag released over the X: ends the drag, does not hide
x.dispatch('mouseup', R); x.dispatch('click', R);
assert(!p.S.hidden, 'drag released over the X: shown');
// right after the drag: still inert
await p.advance(100);
p.click(x);
assert(!p.S.hidden, 'X 100 ms after the drag: shown');
await p.advance(1500);
assert(!p.S.stats().drag, 'drag over');
assert(!bar.hasAttribute('data-active'), 'the bar: dragging look gone after the release');
void at;
});
test('api.hide during a laser drag: drag cancelled, stands where it was carried (the hand still: the start); show: a fresh push', async () => {
const p = await running();
p.poses['/user/hand/right'] = { p: [0.2, 1.2, 1.2], q: [1, 0, 0, 0] };
const at = p.S.cat.state();
const gen = p.S.stats().pushSent['reset-generation'];
p.byId('sfui-vr-pet-handle-content').dispatch('mousedown', { CapsLock: true, NumLock: true });
await p.advance(300);
assert(p.S.stats().drag?.phase === 'drag' && p.S.stats().carry, 'dragging, carried');
p.S.api.hide();
const st = p.S.cat.state();
assert(p.S.hidden && !p.S.stats().drag && p.S.root.getAttribute('parent-path') === null && !p.S.stats().pushSent['is-active'],
'drag cancelled (root back in the world, push inactive)');
assert(!st.held && !st.falling && st.pose === 'stand', `settled (${st.pose}, held ${JSON.stringify(st.held)})`);
// (settle(): a dangling cat stands below its scruff, up to the scruff's
// offset from the feet away from where it was picked up)
const off = Math.hypot(...baked0.poseZones.stand.scruff.filter((_, k) => k !== 1)) + 0.02;
assert(Math.hypot(st.x - at.x, st.z - at.z) < off, `at the drag's start (${st.x.toFixed(3)} ${st.z.toFixed(3)} vs ${at.x.toFixed(3)} ${at.z.toFixed(3)})`);
await p.advance(2000);
p.S.api.show();
assert(p.S.stats().pushSent['reset-generation'] > gen && !p.S.stats().carry, 'fresh push, not carried');
await p.advance(500);
assert(!p.S.cat.state().held, 'not held after show');
});
// A laser drag of the bar by the right hand, pressed at H0, checked against
// the vrcompositor model (p.vrc): dr.move(H) sets the hand, runs 300 ms and
// checks what is drawn: the bar's origin fixed in the controller's frame (its
// place on the laser and its distance; pushed along the line from the
// controller by the push), the cat upright, its scruff straight below the
// bar, turned by the controller's heading since the grab. dr.release() lets
// go: the fall starts where it was drawn, the root back in the world there.
async function pressBar(p, H0 = { p: [0.2, 1.2, 1.2], q: [1, 0, 0, 0] }) {
p.poses['/user/hand/right'] = H0;
p.byId('sfui-vr-pet-handle-content').dispatch('mousedown', { CapsLock: true, NumLock: true });
await p.advance(500);
assert(p.S.stats().drag?.phase === 'drag' && p.S.stats().carry === '/user/hand/right', 'dragging, carried by the right hand');
const heading = (q) => { const f = qr(q, [0, 0, -1]); return Math.atan2(f[0], f[2]) * 180 / Math.PI; };
const wrap = (a) => ((a + 540) % 360) - 180;
const drawn = (w) => {
const sg = p.lastMsg().scene_graph;
const frame = findSg(sg, (n) => n.type === 'transform' && n.properties.scale?.[0] === 1 && n.children?.[0]?.type === 'rendermodel');
const outer = findSg(sg, (n) => n.type === 'transform' && n.children?.[0]?.type === 'head-facing-transform');
return { bar: w.get('system.systemui::sfui-vr-pet-handle').p, cat: w.get(frame.properties.sgid), lift: outer.properties.translation[1] };
};
const dr = { H0, g0: null, cat0: null, last: null };
dr.check = (H, w, label, { body = true, push = 0 } = {}) => {
const d = drawn(w);
const g = qc(qinv(H), { p: d.bar, q: [1, 0, 0, 0] }).p; // the bar's origin in the controller's frame
dr.g0 ??= g;
const l0 = Math.hypot(...dr.g0), want = dr.g0.map((v) => v * (1 + push / l0));
const off = Math.hypot(...g.map((v, k) => v - want[k]));
assert(off < 0.002, `${label}: the bar keeps its place on the laser (${(off * 1000).toFixed(1)} mm off in the controller's frame; ${Math.hypot(...g).toFixed(3)} m from it, want ${Math.hypot(...want).toFixed(3)})`);
if (!body) return d;
assert(near(d.lift, 0.15, 1e-9), `${label}: dangling, the bar at liftDangle (${d.lift})`);
dr.cat0 ??= { yaw: heading(d.cat.q), hand: heading(H.q) };
const up = qr(d.cat.q, [0, 1, 0]);
assert(up[1] > Math.cos(Math.PI / 180), `${label}: the cat upright (tilt ${(Math.acos(Math.min(1, up[1])) * 180 / Math.PI).toFixed(1)} deg)`);
const dx = Math.hypot(d.cat.p[0] - d.bar[0], d.cat.p[2] - d.bar[2]);
assert(dx < 0.002 && near(d.bar[1] - d.cat.p[1], 0.15, 0.002), `${label}: the scruff straight below the bar (${(dx * 1000).toFixed(1)} mm aside, ${(d.bar[1] - d.cat.p[1]).toFixed(3)} m below)`);
const dyaw = wrap(heading(d.cat.q) - dr.cat0.yaw - (heading(H.q) - dr.cat0.hand));
assert(Math.abs(dyaw) < 1, `${label}: turned by the controller's heading (off by ${dyaw.toFixed(1)} deg)`);
dr.last = d;
return d;
};
dr.check(H0, p.vrc.world(), 'grabbed', { body: false });
dr.move = async (H, label, o) => { p.poses['/user/hand/right'] = H; await p.advance(300); return dr.check(H, p.vrc.world(), label, o); };
dr.release = async () => {
const d = dr.last;
p.byId('sfui-vr-pet-handle-content').dispatch('mouseup', { CapsLock: true, NumLock: true });
await p.advance(1000 / 24 * 3);
const st = p.S.cat.state(); // (falling, or landed already when let go low)
assert(!st.held && !p.S.stats().drag && Math.hypot(st.x - d.cat.p[0], st.z - d.cat.p[2]) < 0.15,
`released below where it was drawn (${st.x.toFixed(3)} ${st.z.toFixed(3)} vs ${d.cat.p[0].toFixed(3)} ${d.cat.p[2].toFixed(3)})`);
const root = p.vrc.world().get('system.systemui::sfui-vr-pet');
assert(!p.S.stats().carry && Math.hypot(root.p[0] - st.x, root.p[2] - st.z) < 0.05, `the root back in the world at the cat (${root.p.map((v) => v.toFixed(3))})`);
};
return dr;
}
// The drag twist test for a model: the cat (the default) or another animal
// (switched to first; its bar stands higher, the grab point is the same).
const twistTest = (model) => async () => {
const p = await running();
if (model !== catalog.default) {
assert(catalog.models[model], `${model} in the catalog (${catalog.order})`);
const r = p.S.api.setModel({ id: model });
assert(r.model === model && !r.error, JSON.stringify(r));
await p.advance(4000);
}
const dr = await pressBar(p);
const tw = [[[0, 1, 0], 40], [[1, 0, 0], 35], [[0, 0, 1], 70], [[1, 0, 0], -50], [[0, 1, 0], -60], [[0, 0, 1], -80]];
let H = dr.H0;
for (let i = 0; i <= tw.length * 2; i++) {
// twists about the controller (yaw, pitch, roll), each step also carried along a little
if (i > 0) { const [ax, deg] = tw[(i - 1) % tw.length]; H = { p: H.p.map((v, k) => v + [0.03, -0.02, -0.04][k]), q: qm(H.q, qaxis(ax, deg)) }; }
await dr.move(H, `step ${i}`);
}
// let go: the fall starts where it was drawn
await dr.release();
};
test('laser drag: the grab point stays put on the laser through controller twists; the cat hangs upright below it, turned by the heading', twistTest(catalog.default));
test('laser drag of the Shiba: the grab point stays put through the twists, it hangs upright below it', twistTest('shiba'));
test('laser drag, pure pitch ±40°: the bar keeps its place on the laser and its distance from the controller (also between ticks)', async () => {
const p = await running();
const dr = await pressBar(p, { p: [0.1, 1.1, 1.0], q: qaxis([1, 0, 0], -30) }); // pointing down at the cat
// tilted at the wrist (10 cm behind the controller's origin, 3 cm below):
// the controller's origin moves a little too, as a real tilt does
const wrist = qc(dr.H0, { p: [0, -0.03, 0.10], q: [1, 0, 0, 0] });
for (const deg of [10, 20, 30, 40, 20, 0, -10, -20, -30, -40, -20, 0, 40, -40, 0]) {
const H = qc(qc(wrist, { p: [0, 0, 0], q: qaxis([1, 0, 0], deg) }), qc(qinv(wrist), dr.H0));
// as vrcompositor draws it with the new pose, before the next tick
p.poses['/user/hand/right'] = H;
dr.check(H, p.vrc.world(), `pitch ${deg}° (before the tick)`, { body: false });
await dr.move(H, `pitch ${deg}°`);
}
await dr.release();
});
test('laser drag: the thumbstick push moves it along the laser; let go, it falls from the pushed point; the push resets', async () => {
const p = await running();
const dr = await pressBar(p);
const gen = p.S.stats().pushSent['reset-generation'];
assert(p.S.stats().pushSent['is-active'] === true && p.S.stats().pushSent['base-distance'] > 0.3,
`push active while dragged, its base distance the grab point's (${JSON.stringify(p.S.stats().pushSent)})`);
const bar = findSg(p.lastMsg().scene_graph, (n) => n.properties?.id === 'system.systemui::sfui-vr-pet-handle');
assert(p.S.stats().pushSent['event-panel-sgid'] === bar.properties.sgid, 'the push\'s event panel: the bar');
p.vrc.push = 0.6; // pushed away 0.6 m (vrcompositor, the thumbstick)
const H = { p: dr.H0.p.map((v, k) => v + [0.05, 0, -0.05][k]), q: qm(dr.H0.q, qaxis([0, 1, 0], 25)) };
await dr.move(H, 'pushed', { push: 0.6 });
assert(near(p.S.stats().drag.push, 0.6, 1e-3),`the push known (${p.S.stats().drag.push})`);
await dr.release();
assert(p.S.stats().pushSent['reset-generation'] > gen && p.S.stats().pushSent['is-active'] === false && !p.S.stats().carry, 'push reset, inactive; not carried');
assert(p.vrc.push === 0, 'vrcompositor: the push back to 0');
});
test('X: press and release on it hide; a press released elsewhere or leaving it does not', async () => {
const p = await running();
const R = { CapsLock: true, NumLock: true };
const x = p.byAttr('data-act', 'close'), bar = p.byId('sfui-vr-pet-handle-content');
// pressed, released elsewhere (the bar)
x.dispatch('mousedown', R);
assert(x.hasAttribute('data-armed'), 'pressed look while pressed');
bar.dispatch('mouseup', R);
assert(!x.hasAttribute('data-armed'), 'pressed look gone on release');
x.dispatch('click', R);
assert(!p.S.hidden, 'released elsewhere: shown (a stray click does nothing)');
// pressed, left, released on it
x.dispatch('mousedown', R); x.dispatch('mouseleave', R); x.dispatch('mouseup', R); x.dispatch('click', R);
assert(!p.S.hidden, 'left before the release: shown');
// a release on it without a press on it
x.dispatch('mouseup', R);
assert(!p.S.hidden, 'release without a press: shown');
// press and release on it
const e = p.click(x);
assert(p.S.hidden && e.stopped, 'press + release on the X: hidden (the click does not reach the dashboard)');
});
test('only the laser drag holds it: hands at the scruff do not, a falling cat is not caught', async () => {
const p = page();
p.inject();
await p.advance(500);
const sc = p.S.cat.zone('scruff');
p.poses['/user/hand/left'] = { p: [sc.x, sc.y, sc.z], q: [1, 0, 0, 0] };
p.poses['/user/hand/right'] = { p: [sc.x, sc.y + 0.02, sc.z], q: [1, 0, 0, 0] };
await p.advance(2000);
assert(!p.S.cat.state().held && p.S.root.getAttribute('parent-path') === null, 'not held, root in the world');
// a falling cat: a press on the bar does not catch it
p.poses['/user/hand/left'] = p.poses['/user/hand/right'] = null;
p.S.cat.command('drop');
await p.advance(100);
assert(p.S.cat.state().falling, 'falling');
p.byId('sfui-vr-pet-handle-content').dispatch('mousedown', { CapsLock: true, NumLock: true });
assert(!p.S.stats().drag, 'no drag of a falling cat');
});
test('fresh page with a stored hidden cat: built but not in the page, no timers, no sends', async () => {
const p = page({ store: { x: 0.3, z: 0.2, yaw: 0, pose: 'sit', hidden: true } });
const r = p.inject();
assert(r === 'hidden', `result hidden (${r})`);
assert(p.S.hidden && !p.S.root.isConnected, 'root out of the page');
assert(p.activeTimers() === 0 && p.stats.total === 0, `no timers (${p.activeTimers()}), no sends (${p.stats.total})`);
await p.advance(10000);
assert(p.stats.total === 0 && p.activeTimers() === 0, 'still nothing after 10 s');
assert(p.inject() === 'unchanged', 'next injection: unchanged');
const s = p.S.api.show();
assert(s.was === 'hidden' && !s.summoned, `shown at its spot (${JSON.stringify(s)})`);
assert(near(p.S.cat.state().x, 0.3, 1e-9) && p.S.cat.state().pose === 'sit', 'stored spot and pose');
await p.advance(2000);
assert(p.stats.total > 0 && p.S.root.isConnected, 'sending once shown');
assert(p.stored.hidden === false && p.stored.pose === 'sit', `store: hidden false, pose kept (${JSON.stringify(p.stored)})`);
});
test('store round-trip: core saves merge, hidden kept', async () => {
const p = await running();
p.S.api.hide();
assert(p.stored.hidden === true, 'hidden stored');
const saved = { ...p.stored };
// A new page (SteamVR restart) seeded from that value.
const q = page({ store: saved });
assert(q.inject() === 'hidden', 'restart: still hidden');
q.S.api.show();
await q.advance(1000);
q.S.cat.command('sit');
await q.advance(4000);
assert(q.stored.hidden === false && q.stored.pose === 'sit', `core save merged (${JSON.stringify(q.stored)})`);
assert(q.S.api.status().hidden === false && q.S.api.status().pose === 'sit', 'status');
});
// The menu: its panel in the last message, its transform.
const menuSg = (p) => findSg(p.lastMsg().scene_graph, (n) => n.properties?.id === 'system.systemui::sfui-vr-pet-menu');
const menuXf = (p) => findSg(p.lastMsg().scene_graph, (n) => n.type === 'transform' && n.children?.[0]?.properties?.id === 'system.systemui::sfui-vr-pet-menu');
const checkOf = (p, id) => p.byAttr('data-model', id).children.find((c) => c.getAttribute('class') === 'sfui-vr-pet-check');
test('⋯ opens the menu above the bar (one send), again closes it (one send)', async () => {
const p = await running();
const more = p.byAttr('data-act', 'menu');
let n = p.stats.total;
p.click(more);
assert(p.S.stats().menuOpen && p.stats.total - n === 1, `open, one send (${p.stats.total - n})`);
const msg = p.lastMsg();
const m = findSg(msg.scene_graph, (x) => x.properties?.id === 'system.systemui::sfui-vr-pet-menu');
const t = findSg(msg.scene_graph, (x) => x.type === 'transform' && x.children?.[0] === m);
assert(m && t && menuXf(p), 'menu panel in the send');
const M = m.properties;
assert(near(t.properties.translation[1], 0.11 * 30 / 132 + 0.01, 1e-9) && near(t.properties.translation[2], 0.02, 1e-9), `1 cm above the bar, 2 cm nearer (${t.properties.translation})`);
assert(M.width === 0.13 && M.origin[1] === -1 && M['only-visible-with-laser'] === true && M.interactive === true && M['is-grab-handle'] === false, 'menu panel props');
const facing = findSg(msg.scene_graph, (x) => x.type === 'head-facing-transform');
assert(facing.children.includes(t), 'under the head-facing transform (carried with the drag)');
const rows = catalog.order.map((id) => p.byAttr('data-model', id));
assert(rows.every(Boolean), 'a row per coat');
const img = rows[0].children.find((c) => c.tagName === 'IMG');
assert(img?.getAttribute('src').startsWith('data:image/png;base64,'), 'thumbnail');
assert(checkOf(p, catalog.default).hasAttribute('data-on') && !checkOf(p, 'tuxedo').hasAttribute('data-on'), 'check on the current coat');
assert(['summon', 'sit', 'lie', 'sleep'].every((c) => p.byAttr('data-cmd', c)), 'Summon, Sit, Lie, Sleep');
// a line between the coats and the animals, one before the commands
const kids = p.byId('sfui-vr-pet-menu-content').children, cls = (e) => e.getAttribute('class');
const seps = kids.map((e, i) => (cls(e) === 'sfui-vr-pet-sep' ? i : -1)).filter((i) => i >= 0);
const groups = catalog.order.map((id) => catalog.models[id].group);
assert(seps.length === new Set(groups).size, `a separator per group (${seps.length}, groups ${[...new Set(groups)]})`);
for (const i of seps.slice(0, -1)) {
const before = kids[i - 1].getAttribute('data-model'), after = kids[i + 1].getAttribute('data-model');
assert(catalog.models[before].group !== catalog.models[after].group, `between groups (${before} | ${after})`);
}
n = p.stats.total;
p.click(more);
assert(!p.S.stats().menuOpen && p.stats.total - n === 1 && !menuSg(p), 'closed, one send, gone from the scene graph');
const content = p.byId('sfui-vr-pet-menu-content');
assert(content.isConnected && content.style.visibility === 'hidden', 'closed: still in the page (its transform too), hidden');
assert(p.byId('sfui-vr-pet-menu').parent.parent?.tagName === 'VSG-HEAD-FACING-TRANSFORM', 'its transform under the head-facing one');
});
test("the menu's UV slot is valid when it opens, without a layout at open", async () => {
const p = page();
assert(/^patched/.test(p.inject()), 'injected');
const menu = p.byId('sfui-vr-pet-menu-content'), fp = p.fpOf('sfui-vr-pet-menu');
assert(fp && p.slotValid(fp), `valid from the first layout (${JSON.stringify(fp?.m_Rect)})`);
await p.advance(3000);
assert(!p.S.stats().menuOpen && menu.isConnected && menu.style.visibility === 'hidden', 'closed: connected, hidden');
const r = menu.getBoundingClientRect();
assert(r.width === 240 && r.height > 0, `a non-zero rect (${r.width} x ${r.height})`);
p.layout();
assert(p.slotValid(fp) && fp.m_Rect.width === r.width && fp.m_Rect.height === r.height, 'valid after a layout while closed');
const more = p.byAttr('data-act', 'menu');
let n = p.stats.total, f = p.stats.force;
p.click(more);
assert(p.S.stats().menuOpen && p.stats.total - n === 1 && p.stats.force === f, `open: one send, no forced layout (${p.stats.total - n}, force ${p.stats.force - f})`);
assert(menu.style.visibility === '' && p.slotValid(fp), 'visible, slot valid');
const M = menuSg(p).properties;
assert(M['embedded-uv-index'] === fp.idx && near(M.uv_max[0] - M.uv_min[0], 240 / 1280, 1e-9), 'its panel: the slot, its rect');
n = p.stats.total;
p.click(more);
assert(!p.S.stats().menuOpen && p.stats.total - n === 1 && p.stats.force === f && !menuSg(p), 'close: one send, out of the scene graph');
assert(menu.isConnected && menu.style.visibility === 'hidden', 'close: hidden, still connected');
p.layout();
assert(p.slotValid(fp), 'still valid while closed');
p.click(more);
assert(p.S.stats().menuOpen && menuSg(p) && p.slotValid(fp) && p.stats.force === f, 'reopened: in the send, valid');
});
// The page rects (left, top, right, bottom) of our textures: the bar with the controls, the menu.
const rectOf = (p, id) => { const r = p.byId(id).getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; };
const overlaps = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
const ownRects = (p) => {
const b = rectOf(p, 'sfui-vr-pet-handle-content'), c = rectOf(p, 'sfui-vr-pet-controls-content');
return { bar: { left: Math.min(b.left, c.left), top: b.top, right: Math.max(b.right, c.right), bottom: c.bottom }, menu: rectOf(p, 'sfui-vr-pet-menu-content') };
};
test('page regions: clear of foreign panels, the keyboard strip band, each other; inside the page', async () => {
const foreign = [];
const p = page({ setup(G, doc) {
const fp = (name, rect, extra = {}) => {
const el = doc.createElement('div');
const f = { props: { debug_name: name }, isExternal: false, m_Rect: rect, getSGID: () => name, getEmbeddedIndex: () => 900, getCurrentRootElement: () => el, ...extra };
G.SGApp.m_mapPanels.set(name, f);
return f;
};
// where the bar would go (bottom right, above the band), measured by m_Rect only (its root 0 x 0)
foreign.push(fp('foreign-bar-spot', { x: 1080, y: 1150, width: 200, height: 160 }).m_Rect);
// a panel with a root element rect, where the menu would go next
const el = doc.createElement('div');
Object.assign(el.style, { position: 'fixed', left: '700px', top: '640px', width: '300px', height: '500px' });
doc.body.appendChild(el);
fp('foreign-menu-spot', { x: 0, y: 0, width: 0, height: 0 }, { getCurrentRootElement: () => el });
foreign.push({ x: 700, y: 640, width: 300, height: 500 });
// ignored: an external panel and another overlay's popup covering the page
fp('external', { x: 0, y: 0, width: 1280, height: 1440 }, { isExternal: true });
fp('popup', { x: 0, y: 0, width: 1280, height: 1440 }, { props: { debug_name: 'popup', overlay_key: 'valve.steam.gamepadui.bar' } });
// a panel node in the keyboard strip band
const strip = doc.createElement('vsg-node');
strip.setAttribute('vsg-type', 'panel');
const sc = doc.createElement('div');
Object.assign(sc.style, { position: 'fixed', left: '2px', top: '1326px', width: '1274px', height: '110px' });
strip.getBoundingClientRect = () => sc.getBoundingClientRect();
strip.appendChild(sc);
doc.body.appendChild(strip);
foreign.push({ x: 2, y: 1326, width: 1274, height: 110 });
} });
assert(/^patched/.test(p.inject()), 'injected');
await p.advance(1000);
const { bar, menu } = ownRects(p);
const band = { left: 0, top: 1440 - 120, right: 1280, bottom: 1440 };
const others = [...foreign.map((r) => ({ left: r.x, top: r.y, right: r.x + r.width, bottom: r.y + r.height })), band, { left: 0, top: 0, right: 1280, bottom: 600 }];
for (const [name, r] of Object.entries({ bar, menu })) {
assert(r.right > r.left && r.left >= 0 && r.top >= 1 && r.right <= 1280 && r.bottom <= 1440, `${name} inside the page (${JSON.stringify(r)})`);
for (const o of others) assert(!overlaps(r, o), `${name} ${JSON.stringify(r)} clear of ${JSON.stringify(o)}`);
}
assert(!overlaps(bar, menu), `bar and menu apart (${JSON.stringify(bar)} ${JSON.stringify(menu)})`);
assert(p.slotValid(p.fpOf('sfui-vr-pet-menu')) && p.slotValid(p.fpOf('sfui-vr-pet-handle')), 'slots valid');
});
test('page regions: claimed from a shared allocator when there is one; moved by it; released on dispose', async () => {
const claims = new Map(), released = [];
const p = page({ setup(G) {
G.__sfuiRegions = {
claim(owner, w, h, o) { const r = { x: owner === 'vr-pet:bar' ? 40 : 300, y: 700, w, h }; claims.set(owner, { r, o }); return r; },
release(owner) { released.push(owner); },
};
} });
assert(/^patched/.test(p.inject()), 'injected');
await p.advance(500);
assert(claims.has('vr-pet:bar') && claims.has('vr-pet:menu'), `claimed (${[...claims.keys()]})`);
const b = claims.get('vr-pet:bar').r;
assert(near(rectOf(p, 'sfui-vr-pet-handle-content').top, 700, 1e-9) && near(rectOf(p, 'sfui-vr-pet-controls-content').bottom, 700 + b.h, 1e-9), 'the bar and controls in the bar claim');
assert(near(rectOf(p, 'sfui-vr-pet-menu-content').left, 300, 1e-9), 'the menu in its claim');
claims.get('vr-pet:menu').o.onMove({ x: 500, y: 100, w: 240, h: 10 });
assert(near(rectOf(p, 'sfui-vr-pet-menu-content').left, 500, 1e-9) && near(rectOf(p, 'sfui-vr-pet-menu-content').top, 100, 1e-9), 'moved by onMove');
assert(p.fpOf('sfui-vr-pet-menu').m_Rect.x === 500, 'laid out again after the move');
p.S.dispose();
assert(released.includes('vr-pet:bar') && released.includes('vr-pet:menu'), `released (${released})`);
});
test('menu picks: a coat (setModel, check moves), a command; each closes the menu', async () => {
const p = await running();
const more = p.byAttr('data-act', 'menu');
p.click(more);
p.click(p.byAttr('data-model', 'cream'));
assert(!p.S.stats().menuOpen && p.S.api.status().model === 'cream' && p.stored.model === 'cream', 'coat picked, closed');
assert(checkOf(p, 'cream').hasAttribute('data-on') && !checkOf(p, catalog.default).hasAttribute('data-on'), 'check moved');
await p.advance(500);
p.click(more);
p.click(p.byAttr('data-cmd', 'sit'));
assert(!p.S.stats().menuOpen, 'closed after the command');
await p.advance(4000);
assert(p.S.cat.state().pose === 'sit', `sits (${p.S.cat.state().pose})`);
// a row pressed but released on another does nothing
p.click(more);
const R = { CapsLock: true, NumLock: true };
p.byAttr('data-cmd', 'lie').dispatch('mousedown', R);
p.byAttr('data-cmd', 'sleep').dispatch('mouseup', R);
assert(p.S.stats().menuOpen && p.S.cat.state().goal === 'sit', 'press on one row, release on another: nothing');
});
test('menu closes: a press elsewhere on the page, 1 s after the laser left, on hide; ⋯ inert while dragged', async () => {
const p = await running();
const more = p.byAttr('data-act', 'menu'), menu = p.byId('sfui-vr-pet-menu-content'), ctl = p.byId('sfui-vr-pet-controls-content');
const R = { CapsLock: true, NumLock: true };
p.click(more);
p.dash.dispatch('mousedown', R);
assert(!p.S.stats().menuOpen, 'a press on a dashboard panel closes it');
p.click(more);
p.byAttr('data-model', 'blue').dispatch('mousedown', R);
assert(p.S.stats().menuOpen, 'a press inside does not');
p.byAttr('data-model', 'blue').dispatch('mouseleave', R); // (disarmed)
menu.dispatch('mouseleave', R);
await p.advance(900);
assert(p.S.stats().menuOpen, 'still open 0.9 s after the laser left');
ctl.dispatch('mouseenter', R);
await p.advance(500);
assert(p.S.stats().menuOpen, 'back on the controls: stays');
ctl.dispatch('mouseleave', R);
await p.advance(1100);
assert(!p.S.stats().menuOpen, 'closed 1 s after the laser left');
p.click(more);
const n = p.stats.total;
p.S.api.hide();
assert(!p.S.stats().menuOpen && p.stats.total - n === 1, `hide: closed, one send (${p.stats.total - n})`);
p.S.api.show();
await p.advance(1000);
assert(!p.S.stats().menuOpen && !menuSg(p), 'shown again: closed');
p.poses['/user/hand/right'] = { p: [0.2, 1.2, 1.2], q: [1, 0, 0, 0] };
p.byId('sfui-vr-pet-handle-content').dispatch('mousedown', R);
await p.advance(200);
p.click(more, 'left');
assert(!p.S.stats().menuOpen, '⋯ during a drag: nothing');
});
// The rendermodel sources in the last message (mounted frames).
const sources = (p) => {
const out = [];
const walk = (n) => { if (!n || typeof n !== 'object') return; if (Array.isArray(n)) { n.forEach(walk); return; } if (n.type === 'rendermodel') out.push(n.properties.source); walk(n.children); };
walk(p.lastMsg()?.scene_graph);
return out;
};
const dirOf = (id) => catalog.models[id].dir;
test('models: the catalog, default coat mounted; api v2', async () => {
const p = await running();
assert(p.S.api.version === 2 && typeof p.S.api.models === 'function' && typeof p.S.api.setModel === 'function', 'api v2 { models, setModel }');
const l = p.S.api.models();
assert(l.current === catalog.default && l.models.length === catalog.order.length && l.models[0].id === catalog.order[0], JSON.stringify(l));
const src = sources(p);
assert(src.length > 0 && src.every((x) => x.startsWith(`${dirOf(catalog.default)}/`)), `default coat's frames (${src[0]})`);
assert(p.S.api.status().model === catalog.default, 'status.model');
});
test('setModel: one send without frames, then gradual remount from the new dir; stored; the core goes on', async () => {
const p = await running();
const before = p.stats.total, st0 = p.S.cat.state();
const r = p.S.api.setModel({ id: 'tuxedo' });
assert(r.model === 'tuxedo' && r.was === catalog.default && !r.error, JSON.stringify(r));
assert(p.stats.total - before === 1 && sources(p).length === 0, `one send without frames (${p.stats.total - before}, ${sources(p).length})`);
assert(p.stored.model === 'tuxedo', `stored (${JSON.stringify(p.stored)})`);
await p.advance(1000 / 24 + 1);
const n1 = p.S.stats().frames;
assert(n1 > 0 && n1 <= 3, `at most 3 mounts per tick (${n1})`);
await p.advance(3000);
const src = sources(p);
assert(src.length > 3 && src.every((x) => x.startsWith(`${dirOf('tuxedo')}/`)), `tuxedo's frames (${src.length}, ${src.find((x) => !x.startsWith(dirOf('tuxedo')))})`);
const st = p.S.cat.state();
assert(Math.hypot(st.x - st0.x, st.z - st0.z) < 1.5 && p.S.api.status().model === 'tuxedo', 'same cat (spot), model in status');
// the slow path (the dashboard's DOM walk) builds the same source
const node = [...p.S.root.walk()].find((n) => n.buildNode?.({})?.[1]?.type === 'rendermodel');
assert(node.buildNode({})[1].properties.source.startsWith(`${dirOf('tuxedo')}/`), 'DOM node source');
const again = p.S.api.setModel({ id: 'tuxedo' });
assert(again.was === 'tuxedo', 'same model: no-op');
});
test('setModel: unknown -> error, unchanged; hidden -> only stored, shown with it', async () => {
const p = await running();
const n = p.stats.total;
const bad = p.S.api.setModel({ id: 'lion' });
assert(bad.error && bad.model === catalog.default && p.stats.total === n, `unknown: error, no send (${JSON.stringify(bad)})`);
p.S.api.hide();
const sent = p.stats.total;
const r = p.S.api.setModel({ id: 'snow' });
assert(r.model === 'snow' && r.hidden && p.stats.total === sent && p.activeTimers() === 0, 'hidden: no send, no timers');
assert(p.stored.model === 'snow' && p.stored.hidden === true, JSON.stringify(p.stored));
p.S.api.show();
await p.advance(3000);
assert(sources(p).length > 0 && sources(p).every((x) => x.startsWith(`${dirOf('snow')}/`)), 'shown as snow');
});
test('stored model at injection; an unknown stored model falls back to the default (logged)', async () => {
const p = page({ store: { model: 'blue' } });
assert(/^patched \(blue,/.test(p.inject()), 'patched with blue');
await p.advance(2000);
assert(sources(p).every((x) => x.startsWith(`${dirOf('blue')}/`)) && sources(p).length > 0, 'blue frames');
const q = page({ store: { model: 'dragon' } });
assert(new RegExp(`^patched \\(${catalog.default},`).test(q.inject()), 'default');
assert(q.S.log.some((l) => l.includes('unknown stored model')), 'logged');
});
// Another animal (a frame set of its own): the first one in the catalog.
const animal = catalog.order.find((id) => catalog.models[id].frames !== catalog.models[catalog.default].frames);
// The grip bar's lift above the frames' origin in the last message.
const liftSent = (p) => findSg(p.lastMsg().scene_graph, (n) => n.type === 'transform' && n.children?.[0]?.type === 'head-facing-transform')?.properties.translation[1];
test('setModel to another animal: its own frames and core at the same spot, one send, gradual remount, bar at its height', async () => {
assert(animal, `an animal in the catalog (${catalog.order})`);
const p = await running();
p.S.cat.command('sit');
await p.advance(4000);
const st0 = p.S.cat.state(), core0 = p.S.cat, before = p.stats.total;
assert(near(liftSent(p), 0.40, 1e-9), `cat: bar 0.40 m up (${liftSent(p)})`);
const r = p.S.api.setModel({ id: animal });
assert(r.model === animal && !r.error, JSON.stringify(r));
assert(p.stats.total - before === 1 && sources(p).length === 0, `one send without frames (${p.stats.total - before})`);
const bk = catalog.frameSets[catalog.models[animal].frames];
assert(p.S.cat !== core0 && Object.keys(p.S.frames).sort().join() === Object.keys(bk.clips).sort().join(), 'its frames, a new core');
const st = p.S.cat.state();
assert(near(st.x, st0.x, 1e-3) && near(st.z, st0.z, 1e-3) && near(st.yaw, st0.yaw, 1e-3), 'same spot and heading');
assert(st.pose === (bk.clips.sit ? 'sit' : 'stand'), `its pose if it has it (${st.pose})`);
assert(p.stored.model === animal, 'stored');
for (let i = 0; i < 20 && !p.S.stats().frames; i++) await p.advance(5); // up to the first tick that mounts
assert(p.S.stats().frames > 0 && p.S.stats().frames <= 3, `at most 3 mounts per tick (${p.S.stats().frames})`);
await p.advance(4000);
const src = sources(p);
assert(src.length > 3 && src.every((x) => x.startsWith(`${dirOf(animal)}/`) && bk.clips[x.slice(dirOf(animal).length + 1).replace(/_\d+\.obj$/, '')]),
`its frames (${src.length}, ${src.find((x) => !x.startsWith(dirOf(animal)))})`);
assert(near(liftSent(p), bk.height + 0.10, 1e-9), `bar at its height + 0.10 m (${liftSent(p)})`);
assert(p.S.api.status().model === animal, 'status.model');
// the menu's commands drive the new core
p.click(p.byAttr('data-act', 'menu'));
p.click(p.byAttr('data-cmd', 'lie'));
await p.advance(5000);
assert(p.S.cat.state().pose === 'lie', `lies (${p.S.cat.state().pose})`);
// and back to the cat
const back = p.S.api.setModel({ id: catalog.default });
await p.advance(4000);
assert(back.model === catalog.default && sources(p).every((x) => x.startsWith(`${dirOf(catalog.default)}/`)) && sources(p).length > 3, 'back to the cat');
assert(near(liftSent(p), 0.40, 1e-9), 'bar 0.40 m up again');
});
test('setModel to another animal during a laser drag: the drag is let go, it stands where it was lifted', async () => {
const p = await running();
const st0 = p.S.cat.state();
p.poses['/user/hand/right'] = { p: [0.2, 1.2, 1.2], q: [1, 0, 0, 0] };
p.byId('sfui-vr-pet-handle-content').dispatch('mousedown', { CapsLock: true, NumLock: true });
await p.advance(300);
assert(p.S.stats().drag, 'dragging');
p.S.api.setModel({ id: animal });
const st = p.S.cat.state();
assert(!p.S.stats().drag && !st.held && !st.falling && st.pose === 'stand', `let go, standing (${st.pose})`);
assert(Math.hypot(st.x - st0.x, st.z - st0.z) < 0.2, 'where it was lifted');
await p.advance(3000);
assert(sources(p).length > 0, 'shown');
});
test('setModel to another animal while hidden: switched, no sends; shown as it; stored at injection', async () => {
const p = await running();
p.S.api.hide();
const n = p.stats.total;
const r = p.S.api.setModel({ id: animal });
assert(r.hidden && p.stats.total === n && p.activeTimers() === 0, 'hidden: no send, no timers');
p.S.api.show();
await p.advance(3000);
assert(sources(p).length > 0 && sources(p).every((x) => x.startsWith(`${dirOf(animal)}/`)), `shown as ${animal}`);
const q = page({ store: { model: animal } });
assert(new RegExp(`^patched \\(${animal},`).test(q.inject()), 'patched with it');
await q.advance(3000);
assert(sources(q).every((x) => x.startsWith(`${dirOf(animal)}/`)) && sources(q).length > 0, 'its frames');
});
test('re-injection of a new version: the old one disposed (no second listener, timer, hook, style or panel)', async () => {
const p = await running();
const base = { listeners: p.listeners(), timers: p.activeTimers(), styles: p.doc.head.children.length, slots: p.embedded.filter(Boolean).length };
const old = p.S;
old.sig = 'an older version';
assert(/^patched/.test(p.inject()), 'injected again');
await p.advance(2000);
assert(p.S !== old && !old.root.isConnected, 'the old root gone');
const now = { listeners: p.listeners(), timers: p.activeTimers(), styles: p.doc.head.children.length, slots: p.embedded.filter(Boolean).length };
assert(JSON.stringify(now) === JSON.stringify(base), `the same as one injection (${JSON.stringify(now)} vs ${JSON.stringify(base)})`);
assert(p.doc.body.children.filter((e) => e.id === 'sfui-vr-pet').length === 1, 'one root');
const send = Object.getPrototypeOf(p.mailbox).SendMessage;
assert(send.__orig && !send.__orig.__orig, 'SendMessage hooked once');
assert(p.sock.send.__sfuiPetOrig && !p.sock.send.__sfuiPetOrig.__sfuiPetOrig, 'the socket wrapped once');
assert(p.stats.fast > 0 && p.S.stats().captured, 'fast sends again');
});
test('unpatch.js during a drag: the pet, its listeners, timers, hook, socket wrapper, style and panels gone; one send without it', async () => {
const p = await running();
const R = { CapsLock: true, NumLock: true };
p.poses['/user/hand/right'] = { p: [0.2, 1.2, 1.2], q: [1, 0, 0, 0] };
p.byId('sfui-vr-pet-handle-content').dispatch('mousedown', R);
p.click(p.byAttr('data-act', 'menu'), 'left');
await p.advance(300);
const root = p.S.root, n = p.stats.total;
assert(p.unpatch() === 'unpatched' && !('__sfuiPet' in p.G), 'unpatched, global gone');
assert(!root.isConnected && p.listeners() === 0 && p.activeTimers() === 0, `nothing left (${p.listeners()} listeners, ${p.activeTimers()} timers)`);
assert(!Object.getPrototypeOf(p.mailbox).SendMessage.__orig && !p.sock.send.__sfuiPetOrig, 'hook and socket wrapper removed');
assert(p.doc.head.children.length === 0 && p.embedded.every((e) => e === null), 'style and embedded panels removed');
assert(!p.doc.all.some((e) => e.id?.startsWith('sfui-vr-pet') && e.isConnected), 'no element of ours in the page');
assert(p.stats.total - n === 1 && !findSg(p.lastMsg().scene_graph, (x) => x.properties?.sgid === +root.getAttribute('sgid')), 'one send without the pet');
await p.advance(5000);
assert(p.stats.total - n === 1, 'no sends after');
});
(async () => {
for (const [name, f] of tests) {
try { await f(); console.log(`ok ${name}`); } catch (e) { failed++; console.log(`FAIL ${name}: ${e.stack}`); }
}
if (failed) { console.log(`${failed} failed`); process.exit(1); }
console.log('all passed');
})();
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Render the VR pet's app icon: one baked frame (OBJ + texture from bake.py)
as a three-quarter view from slightly above, lit, depth-buffered, on a
transparent background; rendered at SS x the size, downscaled, cropped to
the cat and centred on a square.
usage: thumb.py frame.obj texture.png out.png [size]
Software rasterizer (numpy): perspective camera, per-pixel barycentric
interpolation of UVs and normals, a warm key light, a cool fill, ambient and
a rim light, a soft contact shadow under the paws.
"""
import sys
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
SS = 2 # supersampling (512 px for a 256 px icon)
AZIMUTH = 32 # degrees from the cat's front (+Z) toward its left (+X)
ELEVATION = 16 # degrees above the horizon
FOV = 24 # degrees (a long lens: little distortion)
MARGIN = 0.06 # share of the icon left around the cat
OUTLINE = 1 # px (at the icon's size) of dark outline
def load_obj(path):
v, vt, vn, f = [], [], [], []
for line in open(path):
p = line.split()
if not p:
continue
if p[0] == 'v':
v.append([float(x) for x in p[1:4]])
elif p[0] == 'vt':
vt.append([float(x) for x in p[1:3]])
elif p[0] == 'vn':
vn.append([float(x) for x in p[1:4]])
elif p[0] == 'f':
f.append([[int(i) - 1 if i else -1 for i in (c.split('/') + ['', ''])[:3]] for c in p[1:4]])
return np.array(v), np.array(vt), np.array(vn), np.array(f)
def normalize(a):
return a / np.maximum(np.linalg.norm(a, axis=-1, keepdims=True), 1e-9)
def look_at(eye, target):
fwd = normalize(target - eye)
right = normalize(np.cross(fwd, [0.0, 1.0, 0.0]))
up = np.cross(right, fwd)
return np.stack([right, up, -fwd]) # rows: camera x, y, z (looking along -z)
def render(obj, tex_path, out, size=256):
V, VT, VN, F = load_obj(obj)
tex = np.asarray(Image.open(tex_path).convert('RGBA')).astype(np.float32) / 255
th, tw = tex.shape[:2]
R = size * SS
lo, hi = V.min(0), V.max(0)
centre = (lo + hi) / 2
radius = np.linalg.norm(hi - lo) / 2
az, el = np.radians(AZIMUTH), np.radians(ELEVATION)
dirn = np.array([np.sin(az) * np.cos(el), np.sin(el), np.cos(az) * np.cos(el)])
dist = radius / np.sin(np.radians(FOV) / 2) * 1.05
eye = centre + dirn * dist
rot = look_at(eye, centre)
cam = (V - eye) @ rot.T # camera space
f = 1 / np.tan(np.radians(FOV) / 2)
sx = (cam[:, 0] / -cam[:, 2] * f * 0.5 + 0.5) * R
sy = (0.5 - cam[:, 1] / -cam[:, 2] * f * 0.5) * R
depth = -cam[:, 2]
# lights (world space, pointing toward the light)
key = normalize(np.array([0.6, 0.9, 0.7]))
fill = normalize(np.array([-0.8, 0.3, 0.4]))
view = normalize(eye - centre)
zbuf = np.full((R, R), np.inf)
rgb = np.zeros((R, R, 3))
alpha = np.zeros((R, R))
for tri in F:
vi, ti, ni = tri[:, 0], tri[:, 1], tri[:, 2]
x, y, z = sx[vi], sy[vi], depth[vi]
x0, x1 = int(max(0, np.floor(x.min()))), int(min(R - 1, np.ceil(x.max())))
y0, y1 = int(max(0, np.floor(y.min()))), int(min(R - 1, np.ceil(y.max())))
if x1 < x0 or y1 < y0:
continue
area = (x[1] - x[0]) * (y[2] - y[0]) - (x[2] - x[0]) * (y[1] - y[0])
if abs(area) < 1e-12:
continue
px, py = np.meshgrid(np.arange(x0, x1 + 1) + 0.5, np.arange(y0, y1 + 1) + 0.5)
w0 = ((x[1] - px) * (y[2] - py) - (x[2] - px) * (y[1] - py)) / area
w1 = ((x[2] - px) * (y[0] - py) - (x[0] - px) * (y[2] - py)) / area
w2 = 1 - w0 - w1
inside = (w0 >= -1e-6) & (w1 >= -1e-6) & (w2 >= -1e-6)
if not inside.any():
continue
# perspective-correct weights
iz = w0 / z[0] + w1 / z[1] + w2 / z[2]
zz = 1 / iz
pw = np.stack([w0 / z[0], w1 / z[1], w2 / z[2]], -1) * zz[..., None]
sub = zbuf[y0:y1 + 1, x0:x1 + 1]
m = inside & (zz < sub)
if not m.any():
continue
w = pw[m]
uv = w @ VT[ti] if ti[0] >= 0 else np.zeros((len(w), 2))
n = normalize(w @ VN[ni]) if ni[0] >= 0 else np.tile(normalize(np.cross(V[vi[1]] - V[vi[0]], V[vi[2]] - V[vi[0]])), (len(w), 1))
if (n @ view).mean() < 0: # a back-facing normal (double-sided): flip
n = -n
tx = np.clip((uv[:, 0] % 1) * (tw - 1), 0, tw - 1)
ty = np.clip((1 - uv[:, 1] % 1) * (th - 1), 0, th - 1)
c = bilinear(tex, tx, ty)[:, :3]
diff = np.clip(n @ key, 0, 1)
wrap = np.clip((n @ fill + 0.3) / 1.3, 0, 1)
rim = np.clip(1 - np.abs(n @ view), 0, 1) ** 3
up = np.clip(n[:, 1] * 0.5 + 0.5, 0, 1)
light = (0.30 + 0.12 * up)[:, None] * np.array([0.93, 0.96, 1.0]) \
+ (0.80 * diff)[:, None] * np.array([1.0, 0.95, 0.86]) \
+ (0.20 * wrap)[:, None] * np.array([0.75, 0.85, 1.0])
col = c * light + (0.22 * rim)[:, None] * np.array([1.0, 0.97, 0.9])
sub[m] = zz[m]
rgb[y0:y1 + 1, x0:x1 + 1][m] = np.clip(col, 0, 1)
alpha[y0:y1 + 1, x0:x1 + 1][m] = 1
img = Image.fromarray((np.dstack([rgb, alpha]) * 255).round().astype(np.uint8), 'RGBA')
# a thin dark outline (reads on dark and light menus)
# (thin parts such as the whiskers opened away first: no dark blobs there)
sil = img.getchannel('A').filter(ImageFilter.MinFilter(5)).filter(ImageFilter.MaxFilter(5))
sil = sil.filter(ImageFilter.MaxFilter(2 * OUTLINE * SS + 1))
line = Image.new('RGBA', (R, R), (40, 26, 18, 0))
line.putalpha(sil.point(lambda a: int(a * 0.85)))
line.alpha_composite(img)
img = line
# contact shadow: a soft ellipse on the floor under the body
shadow = Image.new('L', (R, R), 0)
# (its footprint: the lower third, without the tail reaching back)
low = V[(V[:, 1] < lo[1] + (hi[1] - lo[1]) * 0.33)]
low = low[low[:, 2] > np.percentile(low[:, 2], 25)]
if len(low):
fc = (low.min(0) + low.max(0)) / 2
rx, rz = (low.max(0) - low.min(0))[[0, 2]] * 0.62
ring = np.array([[fc[0] + np.cos(a) * rx, lo[1], fc[2] + np.sin(a) * rz] for a in np.linspace(0, 2 * np.pi, 48)])
rc = (ring - eye) @ rot.T
pts = [((p[0] / -p[2] * f * 0.5 + 0.5) * R, (0.5 - p[1] / -p[2] * f * 0.5) * R) for p in rc]
ImageDraw.Draw(shadow).polygon(pts, fill=95)
shadow = shadow.filter(ImageFilter.GaussianBlur(R * 0.015))
base = Image.new('RGBA', (R, R), (20, 16, 24, 0))
base.putalpha(shadow)
base.alpha_composite(img)
img = base
# crop to the cat (and shadow), centre on a square with a margin, downscale
bbox = img.getchannel('A').point(lambda a: 255 if a > 8 else 0).getbbox()
img = img.crop(bbox)
side = int(max(img.size) / (1 - 2 * MARGIN))
sq = Image.new('RGBA', (side, side), (0, 0, 0, 0))
sq.alpha_composite(img, ((side - img.size[0]) // 2, (side - img.size[1]) // 2))
sq.resize((size, size), Image.LANCZOS).save(out, optimize=True)
def bilinear(tex, x, y):
x0, y0 = np.floor(x).astype(int), np.floor(y).astype(int)
x1, y1 = np.minimum(x0 + 1, tex.shape[1] - 1), np.minimum(y0 + 1, tex.shape[0] - 1)
fx, fy = (x - x0)[:, None], (y - y0)[:, None]
return (tex[y0, x0] * (1 - fx) * (1 - fy) + tex[y0, x1] * fx * (1 - fy)
+ tex[y1, x0] * (1 - fx) * fy + tex[y1, x1] * fx * fy)
if __name__ == '__main__':
render(sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]) if len(sys.argv) > 4 else 256)
+6
View File
@@ -0,0 +1,6 @@
// Removes the cat (systemui.js) from SteamVR's systemui page.
(() => {
try { window.__sfuiPet?.dispose?.(); } catch { /* gone */ }
delete window.__sfuiPet;
return 'unpatched';
})()