mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 01:00:13 +02:00
A SteamVR dashboard patch (vr-pet) drawing a baked 3D pet next to the windows: the Toon Cat in five coats, a Shiba Inu, a Fox and a Dachshund, plus models from steamFrame.pet.extraModels. It walks around, follows, sits, lies and sleeps, can be picked up by its grip bar, petted and switched in its menu; its spot, pose and model are saved state. Also the vr-pet command, "Pet" in the "+" menu with the current model's icon (steam-frame-nix-pet-icon), the flake outputs pet-models, pet-icons and pet-preview, and the check pet. A built-in model with "private": true fails evaluation; private models belong in extraModels.
207 lines
9.9 KiB
Python
207 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Build the VR pet's model catalog: one directory per model and the index
|
|
(models.json) that the SteamVR patch embeds (package.nix `catalog`).
|
|
|
|
usage: index.py spec.json base-dir out-dir
|
|
index.py --icons spec.json catalog-dir icons-dir [px]
|
|
(the "+" menu icons: icons-dir/<id>.png, px square (256), the
|
|
same pictures as the thumbs; icons-dir/default.txt: the default's
|
|
id; package.nix `icons`)
|
|
|
|
spec.json (package.nix: every models/<id>/model.json and steamFrame.pet.extraModels,
|
|
their files resolved to store paths; a gltf model's "baked": bake_gltf.py's
|
|
output):
|
|
{ "default": id | null, "models": [ { "id", "name", "kind", ... }, ... ] }
|
|
The kinds and fields: docs/pet-models.md. An invalid spec fails the build
|
|
with a message naming the model and the field (validate()).
|
|
|
|
The coats (recolor, texture) get out-dir/<id>/ with their own cat.png and
|
|
cat.mtl and copies of the base's frames (*.obj, frames.json): an OBJ's
|
|
`mtllib cat.mtl` is looked up next to the OBJ, so the same frames load with
|
|
that model's texture. Copies, not symlinks: whether SteamVR's loader
|
|
resolves a symlinked OBJ's mtllib next to the link or next to its target
|
|
could not be checked (the headset in standby loads nothing), and a copy
|
|
works either way; the store's auto-optimise hardlinks the identical copies,
|
|
so they cost no space. An animal's directory is its bake (out-dir/<id> a
|
|
link to it, for the preview); its frames are a frame set of their own.
|
|
|
|
out-dir/models.json:
|
|
{ "default": id, "order": [ids],
|
|
"frameSets": { name: <frames.json> },
|
|
"models": { id: { "name", "kind", "group", "dir", "sub", "frames", "private", "thumb" } } }
|
|
dir: the model's directory (absolute); sub: its name under out-dir (null
|
|
for the base); frames: its frame set (the base's id for the cat and its
|
|
coats, the animal's id for an animal); group: "coats" or "animals" (the
|
|
menu puts a line between groups); private: the spec's "private" (personal
|
|
use only: a model of a character someone else owns, whose "rights" says
|
|
so; extraModels only, never a built-in model); thumb: a
|
|
48 px render of the sitting animal (thumb.py; standing without a sit; an
|
|
animal's "thumbFrame", e.g. "idle_0", when that shows it better) or the
|
|
spec's own "thumb", as a data: URI.
|
|
"""
|
|
import base64
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from PIL import Image
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import skin # noqa: E402
|
|
import thumb # noqa: E402
|
|
|
|
THUMB_PX = 48
|
|
ICON_PX = 256 # the "+" menu icons (icons())
|
|
KINDS = ('base', 'recolor', 'texture', 'gltf')
|
|
HEX = re.compile(r'^#[0-9a-fA-F]{6}$')
|
|
|
|
|
|
class SpecError(SystemExit):
|
|
pass
|
|
|
|
|
|
def validate(specs, default=None):
|
|
"""The specs (list) and the chosen default: a list of problems, each
|
|
naming the model and the field ([] when fine)."""
|
|
errs = []
|
|
ids = [m.get('id') for m in specs]
|
|
for i in sorted({i for i in ids if ids.count(i) > 1}):
|
|
errs.append(f'{i}: two models with this id')
|
|
for m in specs:
|
|
mid = m.get('id')
|
|
e = lambda msg: errs.append(f'{mid}: {msg}')
|
|
if not isinstance(mid, str) or not re.match(r'^[A-Za-z0-9_-]+$', mid or ''):
|
|
e('the id (folder name) must be letters, digits, - or _')
|
|
if not isinstance(m.get('name'), str) or not m['name'].strip():
|
|
e('"name" (the menu\'s text) is missing')
|
|
kind = m.get('kind')
|
|
if kind not in KINDS:
|
|
e(f'"kind" must be one of {", ".join(KINDS)} (is {kind!r})')
|
|
if 'order' in m and not isinstance(m['order'], (int, float)):
|
|
e('"order" must be a number')
|
|
if 'default' in m and not isinstance(m['default'], bool):
|
|
e('"default" must be true or false')
|
|
if 'private' in m and not isinstance(m['private'], bool):
|
|
e('"private" must be true or false')
|
|
if 'rights' in m and m.get('private') is not True:
|
|
e('"rights" (a character someone else owns) needs "private": true (personal use only, never published)')
|
|
if kind == 'recolor':
|
|
fur = m.get('fur')
|
|
if not (isinstance(fur, list) and len(fur) == 2 and all(isinstance(c, str) and HEX.match(c) for c in fur)):
|
|
e('"fur" must be two colours ["#rrggbb" (dark), "#rrggbb" (light)]')
|
|
for w in m.get('white', []):
|
|
if w not in skin.PATTERNS:
|
|
e(f'"white": unknown marking {w!r} (known: {", ".join(skin.PATTERNS)})')
|
|
if kind == 'texture' and not (isinstance(m.get('png'), str) and os.path.isfile(m['png'])):
|
|
e('"png" must be a PNG file (its path in the model\'s folder)')
|
|
if kind == 'gltf':
|
|
b = m.get('baked')
|
|
if not (isinstance(b, str) and os.path.isfile(os.path.join(b, 'frames.json'))):
|
|
e('no baked frames (bake_gltf.py)')
|
|
if not isinstance(m.get('height'), (int, float)) or not 0.05 <= m['height'] <= 2:
|
|
e('"height" (m, standing) must be a number between 0.05 and 2')
|
|
tf = m.get('thumbFrame')
|
|
if tf is not None and not (isinstance(b, str) and isinstance(tf, str) and os.path.isfile(os.path.join(b, tf + '.obj'))):
|
|
e(f'"thumbFrame": no baked frame {tf!r} (a clip and its frame number, e.g. "idle_0")')
|
|
bases = [m['id'] for m in specs if m.get('kind') == 'base']
|
|
if len(bases) > 1:
|
|
errs.append(f'more than one kind "base": {", ".join(bases)}')
|
|
if any(m.get('kind') in ('recolor', 'texture') for m in specs) and not bases:
|
|
errs.append('coats (kind recolor / texture) need the base model (kind "base")')
|
|
flagged = [m['id'] for m in specs if m.get('default') is True]
|
|
if default is None:
|
|
if len(flagged) != 1:
|
|
errs.append(f'exactly one model needs "default": true (found {len(flagged)}: {", ".join(flagged) or "none"}; '
|
|
'or set steamFrame.pet.defaultModel)')
|
|
elif default not in ids:
|
|
errs.append(f'steamFrame.pet.defaultModel {default!r} is not a model ({", ".join(i for i in ids if i)})')
|
|
return errs
|
|
|
|
|
|
def thumb_png(m, d, px):
|
|
"""PNG bytes of model m's picture, px square: the spec's own "thumb"
|
|
(scaled, centred) or thumb.py's render of its sitting frame in d (its
|
|
"thumbFrame", else sit_0, else idle_0)."""
|
|
if m.get('thumb'):
|
|
im = Image.open(m['thumb']).convert('RGBA')
|
|
im.thumbnail((px, px), Image.LANCZOS)
|
|
sq = Image.new('RGBA', (px, px), (0, 0, 0, 0))
|
|
sq.alpha_composite(im, ((px - im.width) // 2, (px - im.height) // 2))
|
|
buf = io.BytesIO()
|
|
sq.save(buf, 'PNG', optimize=True)
|
|
return buf.getvalue()
|
|
pose = next(f for f in (m.get('thumbFrame', '') + '.obj', 'sit_0.obj', 'idle_0.obj') if os.path.isfile(os.path.join(d, f)))
|
|
with tempfile.TemporaryDirectory() as t:
|
|
out = os.path.join(t, 't.png')
|
|
thumb.render(os.path.join(d, pose), os.path.join(d, 'cat.png'), out, px)
|
|
return open(out, 'rb').read()
|
|
|
|
|
|
def thumb_uri(m, d):
|
|
return 'data:image/png;base64,' + base64.b64encode(thumb_png(m, d, THUMB_PX)).decode()
|
|
|
|
|
|
def icons(spec_path, catalog, out, px=ICON_PX):
|
|
"""The "+" menu icons: out/<id>.png (px square, as the thumbs) for every
|
|
model of the catalog built from spec_path, and out/default.txt (the
|
|
default model's id)."""
|
|
spec = json.load(open(spec_path))
|
|
index = json.load(open(os.path.join(catalog, 'models.json')))
|
|
os.makedirs(out, exist_ok=True)
|
|
for m in spec['models']:
|
|
with open(os.path.join(out, m['id'] + '.png'), 'wb') as f:
|
|
f.write(thumb_png(m, index['models'][m['id']]['dir'], px))
|
|
with open(os.path.join(out, 'default.txt'), 'w') as f:
|
|
f.write(index['default'] + '\n')
|
|
|
|
|
|
def main(spec_path, base, out):
|
|
spec = json.load(open(spec_path))
|
|
errs = validate(spec['models'], spec.get('default'))
|
|
if errs:
|
|
raise SpecError('invalid VR pet model spec (docs/pet-models.md):\n ' + '\n '.join(errs))
|
|
default = spec.get('default') or next(m['id'] for m in spec['models'] if m.get('default') is True)
|
|
os.makedirs(out, exist_ok=True)
|
|
base_id = next((m['id'] for m in spec['models'] if m['kind'] == 'base'), None)
|
|
frame_sets = {}
|
|
if base_id:
|
|
frame_sets[base_id] = json.load(open(os.path.join(base, 'frames.json')))
|
|
shared = sorted(f for f in os.listdir(base) if f.endswith('.obj')) + ['frames.json']
|
|
models, order = {}, []
|
|
for m in sorted(spec['models'], key=lambda m: (m.get('order', 100), m['name'])):
|
|
mid, kind = m['id'], m['kind']
|
|
if kind == 'base':
|
|
d, sub, frames = base, None, base_id
|
|
elif kind == 'gltf':
|
|
d, sub, frames = m['baked'], mid, mid
|
|
os.symlink(d, os.path.join(out, mid))
|
|
frame_sets[mid] = json.load(open(os.path.join(d, 'frames.json')))
|
|
else:
|
|
d, sub, frames = os.path.join(out, mid), mid, base_id
|
|
os.makedirs(d)
|
|
png = os.path.join(d, 'cat.png')
|
|
if kind == 'recolor':
|
|
img, _ = skin.recolor(Image.open(os.path.join(base, 'cat.png')), m)
|
|
img.save(png, optimize=True)
|
|
else:
|
|
Image.open(m['png']).convert('RGB').save(png, optimize=True)
|
|
shutil.copyfile(os.path.join(base, 'cat.mtl'), os.path.join(d, 'cat.mtl'))
|
|
for f in shared:
|
|
shutil.copyfile(os.path.join(base, f), os.path.join(d, f))
|
|
models[mid] = {'name': m['name'], 'kind': kind, 'group': m.get('group', 'animals' if kind == 'gltf' else 'coats'),
|
|
'dir': d, 'sub': sub, 'frames': frames, 'private': m.get('private', False),
|
|
'thumb': thumb_uri(m, d)}
|
|
order.append(mid)
|
|
json.dump({'default': default, 'order': order, 'frameSets': frame_sets, 'models': models},
|
|
open(os.path.join(out, 'models.json'), 'w'), separators=(',', ':'))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
if sys.argv[1] == '--icons':
|
|
icons(*sys.argv[2:5], *[int(a) for a in sys.argv[5:6]])
|
|
else:
|
|
main(*sys.argv[1:4])
|