diff --git a/flake.nix b/flake.nix index 6deb1e6..d54049d 100644 --- a/flake.nix +++ b/flake.nix @@ -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; }; diff --git a/modules/pet.nix b/modules/pet.nix new file mode 100644 index 0000000..b780727 --- /dev/null +++ b/modules/pet.nix @@ -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 /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// (model.json and its files) + }''; + description = '' + More models, next to the built-in ones (pet/models//): 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 + } ]; + }; +} diff --git a/modules/pet/bake.py b/modules/pet/bake.py new file mode 100644 index 0000000..28d9ab0 --- /dev/null +++ b/modules/pet/bake.py @@ -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/_.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() + diff --git a/modules/pet/bake_gltf.py b/modules/pet/bake_gltf.py new file mode 100644 index 0000000..5fc89ed --- /dev/null +++ b/modules/pet/bake_gltf.py @@ -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//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() diff --git a/modules/pet/check.nix b/modules/pet/check.nix new file mode 100644 index 0000000..b3ca626 --- /dev/null +++ b/modules/pet/check.nix @@ -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 diff --git a/modules/pet/cli.mjs b/modules/pet/cli.mjs new file mode 100644 index 0000000..4191a90 --- /dev/null +++ b/modules/pet/cli.mjs @@ -0,0 +1,128 @@ +// vr-pet show [--summon] | hide | status | models | model +// +// 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 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 '; +// 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 +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); +} diff --git a/modules/pet/core.js b/modules/pet/core.js new file mode 100644 index 0000000..a22bb47 --- /dev/null +++ b/modules/pet/core.js @@ -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) diff --git a/modules/pet/icon.sh b/modules/pet/icon.sh new file mode 100644 index 0000000..4fb6931 --- /dev/null +++ b/modules/pet/icon.sh @@ -0,0 +1,39 @@ +# steam-frame-nix-pet-icon (package.nix prepends runtime, state, icons, hicolor): points +# /steam-frame-nix/vr-pet/icon.png (tmpfs), where the "+" menu entry's icon +# (hicolor/256x256/apps/vr-pet.png) links, to icons/.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 diff --git a/modules/pet/index.py b/modules/pet/index.py new file mode 100644 index 0000000..5186396 --- /dev/null +++ b/modules/pet/index.py @@ -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/.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//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// 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/ 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: }, + "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/.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]) diff --git a/modules/pet/models/blue/model.json b/modules/pet/models/blue/model.json new file mode 100644 index 0000000..136df41 --- /dev/null +++ b/modules/pet/models/blue/model.json @@ -0,0 +1 @@ +{ "name": "Blue", "kind": "recolor", "order": 2, "fur": ["#3a4250", "#b4bfcd"] } diff --git a/modules/pet/models/cream/model.json b/modules/pet/models/cream/model.json new file mode 100644 index 0000000..8506f7a --- /dev/null +++ b/modules/pet/models/cream/model.json @@ -0,0 +1 @@ +{ "name": "Cream", "kind": "recolor", "order": 3, "fur": ["#9c7650", "#fbf0dc"] } diff --git a/modules/pet/models/dachshund/model.json b/modules/pet/models/dachshund/model.json new file mode 100644 index 0000000..61a8165 --- /dev/null +++ b/modules/pet/models/dachshund/model.json @@ -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] + } + } + } +} diff --git a/modules/pet/models/fox/model.json b/modules/pet/models/fox/model.json new file mode 100644 index 0000000..1bb356b --- /dev/null +++ b/modules/pet/models/fox/model.json @@ -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 } + } + } +} diff --git a/modules/pet/models/ginger/model.json b/modules/pet/models/ginger/model.json new file mode 100644 index 0000000..3f3ffb0 --- /dev/null +++ b/modules/pet/models/ginger/model.json @@ -0,0 +1 @@ +{ "name": "Ginger", "kind": "base", "order": 0, "default": true } diff --git a/modules/pet/models/shiba/model.json b/modules/pet/models/shiba/model.json new file mode 100644 index 0000000..67bdbf4 --- /dev/null +++ b/modules/pet/models/shiba/model.json @@ -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 } + } + } +} diff --git a/modules/pet/models/snow/model.json b/modules/pet/models/snow/model.json new file mode 100644 index 0000000..a890738 --- /dev/null +++ b/modules/pet/models/snow/model.json @@ -0,0 +1 @@ +{ "name": "Snow", "kind": "recolor", "order": 4, "fur": ["#b4b6be", "#ffffff"] } diff --git a/modules/pet/models/tuxedo/model.json b/modules/pet/models/tuxedo/model.json new file mode 100644 index 0000000..4f93888 --- /dev/null +++ b/modules/pet/models/tuxedo/model.json @@ -0,0 +1 @@ +{ "name": "Tuxedo", "kind": "recolor", "order": 1, "fur": ["#101014", "#4a4a52"], "white": ["chest", "muzzle", "paws"] } diff --git a/modules/pet/package.nix b/modules/pet/package.nix new file mode 100644 index 0000000..d5f0e14 --- /dev/null +++ b/modules/pet/package.nix @@ -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// 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//) 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 (/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//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//) 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: .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 /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 (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; +} diff --git a/modules/pet/preview/index.html b/modules/pet/preview/index.html new file mode 100644 index 0000000..8e2090d --- /dev/null +++ b/modules/pet/preview/index.html @@ -0,0 +1,67 @@ + + + + +VR Cat Preview + + + +
loading baked frames…
+
+

VR pet preview

+
+
+ + +
+
+ + + +
+
+ + +
+ + + + + + + +
+ mouse moves the right hand (on the plane through the cat; wheel nearer/farther) · + stroke slowly over head/back to pet · swipe fast past it to startle · + press on the grip bar above the cat and move to drag it by the scruff (the laser in VR; wheel nearer/farther), release to drop · + right-drag look (double-click captures the mouse, Esc releases) · + press and release on the X below the grip bar to hide the cat, show brings it back · + ⋯ next to it opens the menu (coats and animals, Summon, Sit, Lie down, Sleep) · + WASD walk · Shift run · C crouch · V observer camera +
+
+
+ + + + diff --git a/modules/pet/preview/preview.js b/modules/pet/preview/preview.js new file mode 100644 index 0000000..753f298 --- /dev/null +++ b/modules/pet/preview/preview.js @@ -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//) 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//. +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); diff --git a/modules/pet/rig.py b/modules/pet/rig.py new file mode 100644 index 0000000..e6342d6 --- /dev/null +++ b/modules/pet/rig.py @@ -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(' 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)) diff --git a/modules/pet/skin.py b/modules/pet/skin.py new file mode 100644 index 0000000..23bfdd6 --- /dev/null +++ b/modules/pet/skin.py @@ -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//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) diff --git a/modules/pet/systemui.js b/modules/pet/systemui.js new file mode 100644 index 0000000..0b1e8e5 --- /dev/null +++ b/modules/pet/systemui.js @@ -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) => { 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', `${path}`); + let more = null, x = null; + if (ctl) { + more = div(ctl.content, 'sfui-vr-pet-btn'); + more.setAttribute('data-act', 'menu'); + icon(more, ''); + x = div(ctl.content, 'sfui-vr-pet-btn'); + x.setAttribute('data-act', 'close'); + icon(x, ''); + 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})`; +} diff --git a/modules/pet/tests/builtin-private/someone/model.json b/modules/pet/tests/builtin-private/someone/model.json new file mode 100644 index 0000000..ac39b54 --- /dev/null +++ b/modules/pet/tests/builtin-private/someone/model.json @@ -0,0 +1 @@ +{ "name": "Someone", "kind": "recolor", "fur": ["#203040", "#8090a0"], "private": true } diff --git a/modules/pet/tests/cli.test.mjs b/modules/pet/tests/cli.test.mjs new file mode 100644 index 0000000..5b94c81 --- /dev/null +++ b/modules/pet/tests/cli.test.mjs @@ -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 : 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'); diff --git a/modules/pet/tests/core.test.js b/modules/pet/tests/core.test.js new file mode 100644 index 0000000..cdb65e9 --- /dev/null +++ b/modules/pet/tests/core.test.js @@ -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 (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(); diff --git a/modules/pet/tests/gltf.test.py b/modules/pet/tests/gltf.test.py new file mode 100644 index 0000000..bf2c2b9 --- /dev/null +++ b/modules/pet/tests/gltf.test.py @@ -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') diff --git a/modules/pet/tests/icon.test.sh b/modules/pet/tests/icon.test.sh new file mode 100644 index 0000000..39b2bb5 --- /dev/null +++ b/modules/pet/tests/icon.test.sh @@ -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" diff --git a/modules/pet/tests/icons.test.py b/modules/pet/tests/icons.test.py new file mode 100644 index 0000000..32dfc16 --- /dev/null +++ b/modules/pet/tests/icons.test.py @@ -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}') diff --git a/modules/pet/tests/models.test.py b/modules/pet/tests/models.test.py new file mode 100644 index 0000000..3fbb13a --- /dev/null +++ b/modules/pet/tests/models.test.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Checks of the built model catalog (index.py, skin.py). +usage: models.test.py (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') diff --git a/modules/pet/tests/models/testcoat/coat.png b/modules/pet/tests/models/testcoat/coat.png new file mode 100644 index 0000000..58c860e Binary files /dev/null and b/modules/pet/tests/models/testcoat/coat.png differ diff --git a/modules/pet/tests/models/testcoat/model.json b/modules/pet/tests/models/testcoat/model.json new file mode 100644 index 0000000..eafdb5b --- /dev/null +++ b/modules/pet/tests/models/testcoat/model.json @@ -0,0 +1 @@ +{ "name": "Test Coat", "kind": "texture", "order": 5, "png": "coat.png", "thumb": "coat.png" } diff --git a/modules/pet/tests/models/testprivate/model.json b/modules/pet/tests/models/testprivate/model.json new file mode 100644 index 0000000..871b105 --- /dev/null +++ b/modules/pet/tests/models/testprivate/model.json @@ -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)" +} diff --git a/modules/pet/tests/sim.js b/modules/pet/tests/sim.js new file mode 100644 index 0000000..1dde8b0 --- /dev/null +++ b/modules/pet/tests/sim.js @@ -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 }; diff --git a/modules/pet/tests/species.test.js b/modules/pet/tests/species.test.js new file mode 100644 index 0000000..eed56e7 --- /dev/null +++ b/modules/pet/tests/species.test.js @@ -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 (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(); diff --git a/modules/pet/tests/systemui.test.js b/modules/pet/tests/systemui.test.js new file mode 100644 index 0000000..602a664 --- /dev/null +++ b/modules/pet/tests/systemui.test.js @@ -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 (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'); +})(); diff --git a/modules/pet/thumb.py b/modules/pet/thumb.py new file mode 100644 index 0000000..435d8fe --- /dev/null +++ b/modules/pet/thumb.py @@ -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) diff --git a/modules/pet/unpatch.js b/modules/pet/unpatch.js new file mode 100644 index 0000000..6c55eec --- /dev/null +++ b/modules/pet/unpatch.js @@ -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'; +})()