mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-04 22:00:08 +02:00
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:
1 parent
fbf9519be0
commit
4034fd4ded
38 files changed
+7216
-5
No files matched your search
@@ -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
@@ -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
|
||||
} ];
|
||||
};
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "Blue", "kind": "recolor", "order": 2, "fur": ["#3a4250", "#b4bfcd"] }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "Cream", "kind": "recolor", "order": 3, "fur": ["#9c7650", "#fbf0dc"] }
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "Ginger", "kind": "base", "order": 0, "default": true }
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "Snow", "kind": "recolor", "order": 4, "fur": ["#b4b6be", "#ffffff"] }
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "Tuxedo", "kind": "recolor", "order": 1, "fur": ["#101014", "#4a4a52"], "white": ["chest", "muzzle", "paws"] }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
@@ -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')
|
||||
@@ -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"
|
||||
@@ -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}')
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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)
|
||||
@@ -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';
|
||||
})()
|
||||
Reference in new issue
Block a user