620 lines
33 KiB
HTML
620 lines
33 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>House Hand rig</title>
|
|
<meta name="description" content="Pose Bunny and Unicorn by dragging their hands, feet and head, then blend two poses into one animated SVG.">
|
|
<meta property="og:title" content="House Hand rig">
|
|
<meta property="og:description" content="Pose Bunny and Unicorn by dragging their hands, feet and head, then blend two poses into one animated SVG.">
|
|
<link rel="icon" href="../favicon.svg" type="image/svg+xml">
|
|
<style>
|
|
@font-face { font-family: "Fredoka"; src: url("../fonts/fredoka.woff2") format("woff2"); font-weight: 300 700;
|
|
font-display: swap; }
|
|
:root {
|
|
--paper: #FFEAD2; /* the house cream, the sheet every character is drawn on */
|
|
--sheet: #FFF6EC;
|
|
--ink: #2B2135; /* the house line */
|
|
--muted: #6A5672;
|
|
--rule: #E9CFB2;
|
|
--pink: #FF8FD6;
|
|
--stage-w: min(100%, calc((100vh - 262px) * 0.8)); /* the stage and its strip fit the first screen */
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html { color-scheme: light; }
|
|
body { margin: 0; background: var(--paper); color: var(--ink);
|
|
font: 400 1rem/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
h1, legend, button, .brand, .chips label { font-family: "Fredoka", ui-rounded, "SF Pro Rounded", system-ui, sans-serif; }
|
|
a { color: var(--ink); text-decoration-color: var(--pink); text-decoration-thickness: 3px; text-underline-offset: 0.22em; }
|
|
a:hover { text-decoration-color: var(--ink); }
|
|
:focus-visible { outline: 3px solid var(--ink); outline-offset: 3px; border-radius: 8px; }
|
|
.wrap { max-width: 1240px; margin: 0 auto; padding: 0 clamp(16px, 3vw, 32px) 56px; }
|
|
|
|
header { padding: 14px 0 16px; }
|
|
.brand { font-weight: 600; font-size: 1.15rem; text-decoration: none; }
|
|
h1 { margin: 6px 0 6px; font-weight: 600; font-size: clamp(2rem, 3.6vw, 2.5rem); line-height: 1; letter-spacing: -0.015em; }
|
|
header p { margin: 0; color: var(--muted); max-width: 72ch; }
|
|
|
|
.app { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 24px 40px; align-items: start; }
|
|
.stage-col { position: sticky; top: 12px; }
|
|
.frame { width: var(--stage-w); min-width: 240px; margin: 0 auto; border: 3px solid var(--ink); border-radius: 24px;
|
|
background: var(--paper); overflow: hidden; }
|
|
.stage { position: relative; width: 100%; aspect-ratio: 640 / 800; touch-action: none; }
|
|
.stage > svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
|
#loading { position: absolute; inset: 0; display: grid; place-items: center; padding: 28px; text-align: center;
|
|
color: var(--muted); font-size: 1.05rem; }
|
|
.handle { cursor: grab; }
|
|
.handle .hit { fill: transparent; }
|
|
.handle .dot { fill: rgba(255, 143, 214, 0.38); stroke: var(--ink); stroke-width: 3; }
|
|
.handle:hover .dot, .handle.on .dot { fill: var(--pink); }
|
|
.handle:focus { outline: none; }
|
|
.handle:focus-visible .dot { fill: var(--pink); stroke-width: 6; }
|
|
.handle.body .dot { fill: rgba(255, 246, 236, 0.55); stroke-dasharray: 7 5; }
|
|
.handle.body .pip { fill: var(--ink); }
|
|
.handle.body:hover .dot, .handle.body.on .dot, .handle.body:focus-visible .dot { fill: var(--pink); stroke-dasharray: none; }
|
|
#handles.dragging, #handles.dragging .handle { cursor: grabbing; }
|
|
|
|
button { font-weight: 500; font-size: 1rem; line-height: 1; color: var(--ink); background: var(--sheet);
|
|
border: 3px solid var(--ink); border-radius: 999px; padding: 0.55em 1.05em; cursor: pointer; }
|
|
button:hover { background: #fff; }
|
|
button:active { transform: translateY(1px); }
|
|
button.primary { background: var(--pink); font-weight: 600; }
|
|
button.primary:hover { background: #FFA9E0; }
|
|
button[disabled] { opacity: 0.45; cursor: default; transform: none; }
|
|
button[disabled]:hover { background: var(--sheet); }
|
|
button.primary[disabled]:hover { background: var(--pink); }
|
|
|
|
.keys { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px;
|
|
border-top: 3px solid var(--ink); background: var(--sheet); }
|
|
.key { display: flex; align-items: center; gap: 6px; }
|
|
.keys .primary { margin-left: auto; }
|
|
.thumb { position: relative; width: 36px; height: 45px; padding: 0; border: 2px dashed var(--muted); border-radius: 10px;
|
|
background: var(--paper); overflow: hidden; display: grid; place-items: center; font-weight: 600; font-size: 1.1rem; }
|
|
.thumb:not([disabled]) { border: 2px solid var(--ink); }
|
|
.thumb[disabled] { opacity: 1; color: var(--muted); }
|
|
.thumb img { position: absolute; inset: 0; width: 100%; height: 100%; }
|
|
.thumb span { position: relative; }
|
|
.thumb img + span { position: absolute; left: 4px; top: 3px; font-size: 0.8rem; }
|
|
.exports { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px auto 0; width: var(--stage-w); min-width: 240px; }
|
|
.note { min-height: 3em; margin: 10px auto 0; width: var(--stage-w); min-width: 240px; color: var(--muted);
|
|
font-size: 0.95rem; }
|
|
|
|
.panel { display: grid; }
|
|
fieldset { min-width: 0; margin: 0; padding: 14px 0 16px; border: 0; border-top: 3px solid var(--rule); display: grid; gap: 10px; }
|
|
legend { float: left; width: 100%; padding: 0 0 2px; font-weight: 500; font-size: 1.25rem; line-height: 1.2; }
|
|
.row { display: grid; grid-template-columns: 8.4em minmax(0, 1fr) 3.4em; align-items: center; gap: 10px; }
|
|
.row.pick { grid-template-columns: 8.4em minmax(0, 1fr); }
|
|
.row output { text-align: right; white-space: nowrap; color: var(--muted); font-size: 0.92rem; font-variant-numeric: tabular-nums; }
|
|
input[type=range] { width: 100%; margin: 0; accent-color: var(--pink); }
|
|
select { width: 100%; font: inherit; color: var(--ink); background: var(--sheet); border: 2px solid var(--ink);
|
|
border-radius: 10px; padding: 0.3em 0.45em; }
|
|
.chips { display: flex; flex-wrap: wrap; gap: 7px; }
|
|
.chips input { position: absolute; opacity: 0; pointer-events: none; }
|
|
.chips label { border: 2px solid var(--ink); border-radius: 999px; padding: 0.3em 0.85em; cursor: pointer;
|
|
background: var(--sheet); font-weight: 500; line-height: 1.2; }
|
|
.chips input:checked + label { background: var(--pink); }
|
|
.chips input:focus-visible + label { outline: 3px solid var(--ink); outline-offset: 2px; }
|
|
.presets { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
.presets button { padding: 0.45em 0.9em; }
|
|
.hint { margin: 0; color: var(--muted); font-size: 0.92rem; }
|
|
.stack { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
|
.stack li { display: flex; align-items: center; gap: 8px; min-height: 2.4em; padding: 0 6px 0 12px;
|
|
border: 2px solid var(--ink); border-radius: 12px; background: var(--sheet); }
|
|
.stack li > span:first-child { flex: 1; }
|
|
.stack li.fixed { border-style: dashed; border-color: var(--muted); background: transparent; color: var(--muted); }
|
|
.stack li.line { min-height: 0; padding: 4px 0 0; border: 0; border-top: 2px dashed var(--muted); border-radius: 0;
|
|
background: transparent; color: var(--muted); font-size: 0.85rem; }
|
|
.stack li.gone { color: var(--muted); background: transparent; }
|
|
.stack button { width: 2em; height: 2em; padding: 0; display: grid; place-items: center; font-size: 0.8rem; }
|
|
.stack button svg { width: 12px; height: 12px; }
|
|
.panel-actions { display: flex; gap: 8px; }
|
|
.panel-actions button { padding: 0.45em 0.9em; }
|
|
[hidden] { display: none !important; }
|
|
|
|
@media (max-width: 860px) {
|
|
.app { grid-template-columns: 1fr; }
|
|
.stage-col { position: static; }
|
|
:root { --stage-w: min(100%, calc(72vh * 0.8)); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<header>
|
|
<a class="brand" href="../">house hand</a>
|
|
<h1>Pose and animate</h1>
|
|
<p>Drag the pink handles to move the hands, feet and head, and the ring at the hips to move the body. Save two poses
|
|
as A and B, then play: the blend is one SVG
|
|
that animates itself. Everything is drawn by <a href="../house.py">house.py</a>, running in your browser.
|
|
<a href="hello.svg">Watch Bunny and Unicorn say hello</a>, a longer piece animated with the same rig.</p>
|
|
</header>
|
|
|
|
<div class="app">
|
|
<div class="stage-col">
|
|
<div class="frame">
|
|
<div class="stage">
|
|
<svg id="art" viewBox="0 0 640 800" aria-hidden="true"></svg>
|
|
<svg id="handles" viewBox="0 0 640 800" role="group" aria-label="Pose handles: move each with the arrow keys, or Shift and the arrow keys for bigger steps"></svg>
|
|
<div id="loading" role="status">Loading Python in your browser. About 10 MB, the first time only.</div>
|
|
</div>
|
|
<div class="keys">
|
|
<div class="key">
|
|
<button class="thumb" id="loadA" type="button" disabled aria-label="Pose A is empty"><span>A</span></button>
|
|
<button id="saveA" type="button" disabled>Save A</button>
|
|
</div>
|
|
<div class="key">
|
|
<button class="thumb" id="loadB" type="button" disabled aria-label="Pose B is empty"><span>B</span></button>
|
|
<button id="saveB" type="button" disabled>Save B</button>
|
|
</div>
|
|
<button class="primary" id="play" type="button" disabled>Play</button>
|
|
</div>
|
|
</div>
|
|
<div class="exports">
|
|
<button id="dlStill" type="button" disabled>Download SVG</button>
|
|
<button id="dlAnim" type="button" disabled>Download animated SVG</button>
|
|
<button id="copyPose" type="button" disabled>Copy pose</button>
|
|
</div>
|
|
<p class="note" id="note" aria-live="polite"></p>
|
|
</div>
|
|
|
|
<form class="panel" id="panel" onsubmit="return false">
|
|
<fieldset>
|
|
<legend>Character</legend>
|
|
<div class="chips" role="radiogroup" aria-label="Character">
|
|
<input type="radio" name="who" id="who-bunny" value="bunny" checked><label for="who-bunny">Bunny</label>
|
|
<input type="radio" name="who" id="who-unicorn" value="unicorn"><label for="who-unicorn">Unicorn</label>
|
|
</div>
|
|
<div class="chips" role="radiogroup" aria-label="View">
|
|
<input type="radio" name="view" id="v-front" value="front" checked><label for="v-front">front</label>
|
|
<input type="radio" name="view" id="v-q" value="q"><label for="v-q">¾ left</label>
|
|
<input type="radio" name="view" id="v-qr" value="qr"><label for="v-qr">¾ right</label>
|
|
<input type="radio" name="view" id="v-side" value="side"><label for="v-side">side left</label>
|
|
<input type="radio" name="view" id="v-sider" value="sider"><label for="v-sider">side right</label>
|
|
<input type="radio" name="view" id="v-back" value="back"><label for="v-back">back</label>
|
|
</div>
|
|
</fieldset>
|
|
<fieldset>
|
|
<legend>Poses</legend>
|
|
<div class="presets" id="presets"></div>
|
|
</fieldset>
|
|
<fieldset>
|
|
<legend>Hands</legend>
|
|
<label class="row pick"><span>Left hand</span><select data-k="hands.0"></select></label>
|
|
<label class="row"><span>Left turn</span><input type="range" data-k="hand_rot.0" data-unit="°" min="-120" max="120" step="1"><output></output></label>
|
|
<label class="row pick"><span>Right hand</span><select data-k="hands.1"></select></label>
|
|
<label class="row"><span>Right turn</span><input type="range" data-k="hand_rot.1" data-unit="°" min="-120" max="120" step="1"><output></output></label>
|
|
<p class="hint">Left and right as you see them. In profile only the near arm shows.</p>
|
|
</fieldset>
|
|
<fieldset>
|
|
<legend>Body</legend>
|
|
<label class="row"><span>Lean</span><input type="range" data-k="lean" min="-0.3" max="0.3" step="0.01"><output></output></label>
|
|
<label class="row"><span>Across</span><input type="range" data-k="body_dx" min="-200" max="200" step="1"><output></output></label>
|
|
<label class="row"><span>Hop</span><input type="range" data-k="hop" min="-48" max="90" step="1"><output></output></label>
|
|
<label class="row"><span>Left arm bend</span><input type="range" data-k="arm_bend.0" min="-0.5" max="0.5" step="0.01"><output></output></label>
|
|
<label class="row"><span>Right arm bend</span><input type="range" data-k="arm_bend.1" min="-0.5" max="0.5" step="0.01"><output></output></label>
|
|
<label class="row"><span>Left leg bend</span><input type="range" data-k="leg_bend.0" min="-0.4" max="0.4" step="0.01"><output></output></label>
|
|
<label class="row"><span>Right leg bend</span><input type="range" data-k="leg_bend.1" min="-0.4" max="0.4" step="0.01"><output></output></label>
|
|
<p class="hint">Drag the ring at the hips to move the body while the feet stay planted: down to crouch, up to
|
|
stretch, sideways to shift the weight. Hold Shift to move the whole character, feet and all.</p>
|
|
</fieldset>
|
|
<fieldset id="layers">
|
|
<legend>Layers</legend>
|
|
<ol class="stack" id="stack" aria-label="Layers, front to back"></ol>
|
|
<div class="panel-actions"><button type="button" id="layersAuto">Auto</button></div>
|
|
<p class="hint">Front to back. Move an arm past the body to bring it in front or put it behind, or below the line to
|
|
hide it. Auto lets the view decide: in ¾ the arm and leg nearer you are on top.</p>
|
|
</fieldset>
|
|
<fieldset>
|
|
<legend>Face</legend>
|
|
<label class="row pick"><span>Eyes</span><select data-k="eyes"></select></label>
|
|
<label class="row pick"><span>Mouth</span><select data-k="mouth_kind"></select></label>
|
|
</fieldset>
|
|
<fieldset id="bunnyEars">
|
|
<legend>Ears</legend>
|
|
<label class="row pick"><span>Her right ear</span><select data-k="ears.0.0"></select></label>
|
|
<label class="row"><span>Tilt</span><input type="range" data-k="ears.0.1" data-unit="°" min="-40" max="40" step="1"><output></output></label>
|
|
<label class="row pick"><span>Her left ear</span><select data-k="ears.1.0"></select></label>
|
|
<label class="row"><span>Tilt</span><input type="range" data-k="ears.1.1" data-unit="°" min="-40" max="40" step="1"><output></output></label>
|
|
</fieldset>
|
|
<fieldset id="unicornBits" hidden>
|
|
<legend>Ears and tail</legend>
|
|
<label class="row pick"><span>Ears</span><select data-k="mood"></select></label>
|
|
<label class="row"><span>Tail swish</span><input type="range" data-k="swish" min="0" max="1" step="0.01"><output></output></label>
|
|
<p class="hint">The tail and the long mane show in ¾, profile and back views.</p>
|
|
</fieldset>
|
|
<fieldset>
|
|
<legend>Blend</legend>
|
|
<label class="row"><span>Loop length</span><input type="range" id="secs" data-unit=" s" min="0.6" max="3" step="0.1" value="1.2"><output id="secsOut">1.2 s</output></label>
|
|
<p class="hint">Everything you drag or slide morphs from A to B. What can't morph, like another view or face, switches
|
|
halfway.</p>
|
|
</fieldset>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/pyodide@0.26.4/pyodide.js"></script>
|
|
<script>
|
|
(() => {
|
|
const PYODIDE = "https://cdn.jsdelivr.net/npm/pyodide@0.26.4/";
|
|
const PY_FILES = ["house.py", "gen_phly.py", "gen_bunny.py", "gen_unicorn.py", "rig.py", "rigplay.py"];
|
|
const OPTIONS = {
|
|
hands: ["open", "wave", "point", "fist", "peace", "pinch", "thumbsup"],
|
|
eyes: ["dot", "happy", "wide", "closed", "flat", "heart"],
|
|
mouth_kind: ["smile", "open", "grin", "small", "o", "flat", "smirk", "frown", "none"],
|
|
ears: ["up", "flop", "hang", "droop"],
|
|
mood: ["up", "alert", "out", "droop"],
|
|
};
|
|
const STAND_BOW = 0.09, ARM_BOW = -0.2; // figure()'s own bends: a standing leg, an aimed arm
|
|
// arms in limb widths from each shoulder, legs from each hip; left and right as you see them
|
|
const PRESETS = {
|
|
rest: { arms: [[-3.3, 6.9], [3.95, 6.65]], hands: ["open", "open"], hand_rot: [-34, 54] },
|
|
wave: { arms: [[-3.3, 6.9], [4.4, -2.9]], hands: ["open", "wave"], hand_rot: [-34, -16], head_dx: -3 },
|
|
cheer: { arms: [[-4.6, -2.6], [4.6, -2.6]], hands: ["open", "open"], hand_rot: [16, -16], hop: 24, head_dy: -4 },
|
|
point: { arms: [[-3.3, 6.9], [6.2, 0.6]], hands: ["open", "point"], hand_rot: [-34, 0], lean: 0.04, head_dx: 6 },
|
|
hop: { arms: [[-4.2, 1.5], [4.2, 1.5]], hands: ["open", "open"], hand_rot: [0, 0], hop: 40,
|
|
legs: [[-1.6, 6.2], [1.6, 6.2]], leg_bend: [-0.12, -0.12] },
|
|
shrug: { arms: [[-3.2, 3.4], [3.2, 3.4]], hands: ["open", "open"], hand_rot: [70, -70], head_dy: 8 },
|
|
};
|
|
const BLEND_FIELDS = { who: "character", view: "view", eyes: "eyes", mouth_kind: "mouth" };
|
|
const HANDLES = [["body", 0, "body"], ["hand", 0, "left hand"], ["hand", 1, "right hand"], ["foot", 0, "left foot"],
|
|
["foot", 1, "right foot"], ["head", 0, "head"]];
|
|
const LAYER_KEYS = ["arm_z", "front_arm", "front_leg"];
|
|
const SVGNS = "http://www.w3.org/2000/svg";
|
|
|
|
const $ = s => document.querySelector(s);
|
|
const art = $("#art"), hs = $("#handles"), note = $("#note"), panel = $("#panel");
|
|
let rp = null, pose = null, standing = null, geo = null, playing = false, animSvg = null, drag = null;
|
|
const keys = { A: null, B: null }, thumbs = { A: null, B: null };
|
|
const say = text => { note.textContent = text || ""; };
|
|
const r3 = v => Math.round(v*1000)/1000;
|
|
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
const get = (o, path) => path.split(".").reduce((v, k) => v == null ? v : v[k], o);
|
|
const put = (o, path, val) => { const ks = path.split("."), last = ks.pop(); ks.reduce((v, k) => v[k], o)[last] = val; };
|
|
const fmt = (el, v) => Number(v).toFixed(+el.step < 1 ? 2 : 0).replace("-", "\u2212") + (el.dataset.unit || "");
|
|
const py = (fn, ...args) => rp[fn](...args.map(a => typeof a === "string" || typeof a === "number" ? a : JSON.stringify(a)));
|
|
|
|
// ---------- the panel ----------
|
|
function fillSelects() {
|
|
for (const sel of panel.querySelectorAll("select")) {
|
|
for (const v of OPTIONS[sel.dataset.k.split(".")[0]]) sel.add(new Option(v, v));
|
|
}
|
|
for (const name of Object.keys(PRESETS)) {
|
|
const b = document.createElement("button");
|
|
b.type = "button"; b.textContent = name; b.onclick = () => applyPreset(name);
|
|
$("#presets").append(b);
|
|
}
|
|
}
|
|
function syncPanel() {
|
|
for (const el of panel.querySelectorAll("[data-k]")) {
|
|
const v = get(pose, el.dataset.k);
|
|
if (v === undefined) continue;
|
|
el.value = v;
|
|
const out = el.parentElement.querySelector("output");
|
|
if (out) out.textContent = fmt(el, v);
|
|
}
|
|
panel.querySelector(`[name=who][value="${pose.who}"]`).checked = true;
|
|
panel.querySelector(`[name=view][value="${pose.view}"]`).checked = true;
|
|
$("#bunnyEars").hidden = pose.who !== "bunny";
|
|
$("#unicornBits").hidden = pose.who !== "unicorn";
|
|
}
|
|
panel.addEventListener("input", e => {
|
|
const el = e.target;
|
|
if (!rp) return;
|
|
if (el.name === "who" || el.name === "view") {
|
|
pose[el.name] = el.value;
|
|
delete pose.ears; // each view has its own ear pair...
|
|
for (const k of LAYER_KEYS) delete pose[k]; // ...and its own near and far limbs
|
|
pose = JSON.parse(py("ready", pose)); // legs planted where this view stands them
|
|
standing = structuredClone(pose.legs);
|
|
syncPanel(); stop(); render();
|
|
return;
|
|
}
|
|
if (!el.dataset.k) return;
|
|
const v = el.type === "range" ? parseFloat(el.value) : el.value;
|
|
put(pose, el.dataset.k, v);
|
|
const out = el.parentElement.querySelector("output");
|
|
if (out) out.textContent = fmt(el, v);
|
|
stop(); render();
|
|
});
|
|
function applyPreset(name) {
|
|
Object.assign(pose, { lean: 0, hop: 0, head_dx: 0, head_dy: 0, arm_bend: [ARM_BOW, ARM_BOW],
|
|
leg_bend: [STAND_BOW, STAND_BOW], legs: structuredClone(standing) },
|
|
structuredClone(PRESETS[name]));
|
|
syncPanel(); stop(); render();
|
|
say(`${name[0].toUpperCase()}${name.slice(1)}. Drag a handle to make it yours.`);
|
|
}
|
|
|
|
// ---------- drawing ----------
|
|
let queued = false;
|
|
function render() {
|
|
if (queued) return;
|
|
queued = true;
|
|
requestAnimationFrame(() => {
|
|
queued = false;
|
|
if (playing) return;
|
|
const snap = structuredClone(pose);
|
|
geo = JSON.parse(py("render", snap));
|
|
geo.pose = snap; // the pose these positions belong to
|
|
art.innerHTML = `<rect width="640" height="800" fill="#FFEAD2"/>${geo.svg}`;
|
|
placeHandles();
|
|
renderStack();
|
|
});
|
|
}
|
|
const handleEls = HANDLES.map(([kind, i, name]) => {
|
|
const g = document.createElementNS(SVGNS, "g");
|
|
g.setAttribute("class", "handle"); g.setAttribute("tabindex", "0"); g.setAttribute("role", "button");
|
|
g.setAttribute("aria-roledescription", "drag handle"); g.setAttribute("aria-label", `Move the ${name}`);
|
|
g.setAttribute("display", "none");
|
|
g.dataset.kind = kind; g.dataset.i = i;
|
|
if (kind === "body") g.classList.add("body");
|
|
g.innerHTML = kind === "body" ? `<circle class="hit" r="32"/><circle class="dot" r="19"/><circle class="pip" r="4.5"/>`
|
|
: `<circle class="hit" r="30"/><circle class="dot" r="${kind === "head" ? 18 : 15}"/>`;
|
|
hs.append(g);
|
|
return g;
|
|
});
|
|
const handleAt = (kind, i) => kind === "hand" ? geo.hands[i] : kind === "foot" ? geo.feet[i] :
|
|
kind === "body" ? geo.body : geo.head;
|
|
function placeHandles() {
|
|
for (const el of handleEls) {
|
|
const p = !playing && geo && handleAt(el.dataset.kind, +el.dataset.i);
|
|
if (!p) { el.setAttribute("display", "none"); continue; }
|
|
el.removeAttribute("display");
|
|
el.setAttribute("transform", `translate(${p[0].toFixed(1)} ${(p[1] - geo.hop).toFixed(1)})`);
|
|
}
|
|
}
|
|
|
|
// ---------- layers: what's drawn over what, front to back ----------
|
|
const NAMES = { arm: ["Left arm", "Right arm"], leg: ["Left leg", "Right leg"] };
|
|
const CHEVRON = d => `<svg viewBox="0 0 12 12" aria-hidden="true"><path d="${d}" fill="none" stroke="currentColor"
|
|
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
const UP = CHEVRON("M2 8 L6 4 L10 8"), DOWN = CHEVRON("M2 4 L6 8 L10 4");
|
|
let refocus = null;
|
|
function armSequence() { // the arms, the body and the line below which nothing is drawn: front to back
|
|
const { arm_z: z, arm_order: order } = geo.layers, top = [...order].reverse();
|
|
return [...top.filter(i => z[i] === "front"), "body", ...top.filter(i => z[i] === "behind"), "line",
|
|
...[0, 1].filter(i => z[i] !== "front" && z[i] !== "behind")];
|
|
}
|
|
function renderStack() {
|
|
const list = $("#stack");
|
|
if (!geo || !geo.layers) return;
|
|
const seq = armSequence(), legs = [...geo.layers.leg_order].reverse();
|
|
const item = (kind, i, up, down) => `<li><span>${NAMES[kind][i]}</span>` +
|
|
`<button type="button" data-move="${kind} ${i} -1" aria-label="Move the ${NAMES[kind][i].toLowerCase()} up a layer"${up ? "" : " disabled"}>${UP}</button>` +
|
|
`<button type="button" data-move="${kind} ${i} 1" aria-label="Move the ${NAMES[kind][i].toLowerCase()} down a layer"${down ? "" : " disabled"}>${DOWN}</button></li>`;
|
|
let html = `<li class="fixed"><span>Head</span></li>`;
|
|
const armAt = k => item("arm", seq[k], k > 0, k < seq.length - 1);
|
|
let k = 0;
|
|
for (; seq[k] !== "body"; k++) html += armAt(k);
|
|
html += `<li class="fixed"><span>${pose.who === "bunny" ? "Body and skirt" : "Body"}</span></li>`;
|
|
for (k++; seq[k] !== "line"; k++) html += armAt(k);
|
|
legs.forEach((i, j) => { html += item("leg", i, j > 0, j < 1); });
|
|
html += `<li class="line"><span>Not drawn</span></li>`;
|
|
for (k++; k < seq.length; k++) html += armAt(k).replace("<li>", `<li class="gone">`);
|
|
list.innerHTML = html;
|
|
if (refocus) {
|
|
const b = list.querySelector(`[data-move="${refocus}"]`) ||
|
|
list.querySelector(`[data-move^="${refocus.split(" ").slice(0, 2).join(" ")} "]:not([disabled])`);
|
|
if (b && !b.disabled) b.focus(); else if (b) b.parentElement.querySelector("button:not([disabled])")?.focus();
|
|
refocus = null;
|
|
}
|
|
}
|
|
$("#stack").addEventListener("click", e => {
|
|
const b = e.target.closest("button[data-move]");
|
|
if (!b || !geo) return;
|
|
const [kind, si, sd] = b.dataset.move.split(" "), i = +si, d = +sd;
|
|
if (kind === "leg") {
|
|
pose.front_leg = d < 0 ? i : 1 - i; // up puts this leg over the other, down under it
|
|
} else {
|
|
const seq = armSequence(), at = seq.indexOf(i), to = at + d;
|
|
[seq[at], seq[to]] = [seq[to], seq[at]];
|
|
const body = seq.indexOf("body"), line = seq.indexOf("line");
|
|
pose.arm_z = [0, 1].map(a => { const p = seq.indexOf(a); return p < body ? "front" : p < line ? "behind" : "hidden"; });
|
|
pose.front_arm = seq.indexOf(0) < seq.indexOf(1) ? 0 : 1; // the higher of the two, where they share a side
|
|
if (pose.arm_z[i] === "hidden") say(`${NAMES.arm[i]} hidden. Move it up past the line to draw it again.`);
|
|
}
|
|
refocus = b.dataset.move;
|
|
stop(); render();
|
|
});
|
|
$("#layersAuto").onclick = () => {
|
|
pose.arm_z = [null, null]; pose.front_arm = null; pose.front_leg = null;
|
|
stop(); render();
|
|
say("Layers back to auto: the view decides which arm and leg go on top.");
|
|
};
|
|
|
|
// ---------- dragging, with a pointer or the arrow keys ----------
|
|
function moveTo(kind, i, x, y, whole) { // put this handle at (x, y), in the drawing's coordinates before the hop
|
|
const { lw, pose: was } = geo;
|
|
x = clamp(x, 16, 624); y = clamp(y, 16, 784);
|
|
if (kind === "body") {
|
|
const [bx, by] = geo.body, ddx = x - bx, ddy = y - by;
|
|
if (whole) { // the whole character, feet and all, along the floor
|
|
pose.body_dx = clamp(Math.round(was.body_dx + ddx), -200, 200);
|
|
syncPanel(); render(); return;
|
|
}
|
|
// the body goes and the feet stay: each leg takes the move back, as far as the legs reach
|
|
const legsFor = f => was.legs.map(([lx, ly]) => [lx - f*ddx/lw, ly - f*ddy/lw]);
|
|
const reach = f => legsFor(f).every(([lx, ly]) => { const n = Math.hypot(lx, ly); return n >= 2 && n <= 9.2; })
|
|
&& was.hop - f*ddy >= -48; // a deep crouch, no lower
|
|
let f = 1;
|
|
if (!reach(1)) {
|
|
let lo = 0, hi = 1;
|
|
for (let k = 0; k < 20; k++) { const m = (lo + hi)/2; if (reach(m)) lo = m; else hi = m; }
|
|
f = lo;
|
|
}
|
|
const dx = clamp(f*ddx, -200 - was.body_dx, 200 - was.body_dx), k = ddx ? dx/ddx : 1;
|
|
pose.legs = legsFor(Math.min(f, k)).map(([a, b]) => [r3(a), r3(b)]);
|
|
pose.body_dx = r3(was.body_dx + Math.min(f, k)*ddx);
|
|
pose.hop = r3(was.hop - Math.min(f, k)*ddy);
|
|
syncPanel(); render(); return;
|
|
}
|
|
if (kind === "hand") {
|
|
const [ax, ay] = geo.arm_anchors[i], [hx, hy] = geo.hands[i];
|
|
const ox = hx - (ax + was.arms[i][0]*lw), oy = hy - (ay + was.arms[i][1]*lw); // the hand sits past the arm's end
|
|
const dx = (x - ox - ax)/lw, dy = (y - oy - ay)/lw;
|
|
const len = Math.hypot(dx, dy), k = len > 9.5 ? 9.5/len : len < 1.5 ? 1.5/Math.max(len, 1e-6) : 1;
|
|
pose.arms[i] = [r3(dx*k), r3(dy*k)];
|
|
} else if (kind === "foot") {
|
|
const [ax, ay] = geo.leg_anchors[i];
|
|
const floor = Math.max(standing[0][1], standing[1][1]) + was.hop/lw; // not through the floor
|
|
const dx = (x - ax)/lw, dy = Math.min((y - ay)/lw, floor);
|
|
const len = Math.hypot(dx, dy), k = len > 9.2 ? 9.2/len : len < 2 ? 2/Math.max(len, 1e-6) : 1;
|
|
pose.legs[i] = [r3(dx*k), r3(dy*k)];
|
|
} else {
|
|
pose.head_dx = clamp(Math.round(was.head_dx + x - geo.head[0]), -36, 36);
|
|
pose.head_dy = clamp(Math.round(was.head_dy + y - geo.head[1]), -20, 20);
|
|
}
|
|
render();
|
|
}
|
|
function svgPoint(e) {
|
|
const p = hs.createSVGPoint(); p.x = e.clientX; p.y = e.clientY;
|
|
return p.matrixTransform(hs.getScreenCTM().inverse());
|
|
}
|
|
hs.addEventListener("pointerdown", e => {
|
|
const h = e.target.closest(".handle");
|
|
if (!h || !geo || playing) return;
|
|
e.preventDefault();
|
|
hs.setPointerCapture(e.pointerId);
|
|
const kind = h.dataset.kind, i = +h.dataset.i, p = svgPoint(e), [x, y] = handleAt(kind, i);
|
|
drag = { kind, i, el: h, dx: p.x - x, dy: p.y + geo.hop - y }; // where on the handle it was picked up
|
|
h.classList.add("on"); hs.classList.add("dragging");
|
|
});
|
|
hs.addEventListener("pointermove", e => {
|
|
if (!drag) return;
|
|
const p = svgPoint(e);
|
|
moveTo(drag.kind, drag.i, p.x - drag.dx, p.y + geo.hop - drag.dy, e.shiftKey);
|
|
});
|
|
function endDrag() {
|
|
if (!drag) return;
|
|
drag.el.classList.remove("on"); hs.classList.remove("dragging"); drag = null;
|
|
}
|
|
for (const t of ["pointerup", "pointercancel", "lostpointercapture"]) hs.addEventListener(t, endDrag);
|
|
hs.addEventListener("keydown", e => {
|
|
const h = e.target.closest && e.target.closest(".handle");
|
|
const d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
|
|
if (!h || !d || !geo || playing) return;
|
|
e.preventDefault();
|
|
const kind = h.dataset.kind, i = +h.dataset.i, step = e.shiftKey ? 24 : 6, [x, y] = handleAt(kind, i);
|
|
moveTo(kind, i, x + d[0]*step, y + d[1]*step);
|
|
});
|
|
|
|
// ---------- A, B and the blend ----------
|
|
function saveKey(k) {
|
|
keys[k] = structuredClone(pose);
|
|
if (thumbs[k]) URL.revokeObjectURL(thumbs[k]);
|
|
thumbs[k] = URL.createObjectURL(new Blob([py("still", keys[k])], { type: "image/svg+xml" }));
|
|
const b = $(`#load${k}`);
|
|
b.innerHTML = `<img alt="" src="${thumbs[k]}"><span>${k}</span>`;
|
|
b.disabled = false; b.setAttribute("aria-label", `Put pose ${k} back on the stage`);
|
|
animSvg = null;
|
|
const both = keys.A && keys.B;
|
|
$("#play").disabled = $("#dlAnim").disabled = !both;
|
|
const other = k === "A" ? "B" : "A";
|
|
say(both ? `${k} saved. Press Play to see A blend into B and back.` : `${k} saved. Now make pose ${other} and press Save ${other}.`);
|
|
if (playing) { stop(); play(); }
|
|
}
|
|
function loadKey(k) {
|
|
pose = structuredClone(keys[k]);
|
|
standing = JSON.parse(py("standing", pose)).legs;
|
|
syncPanel(); stop(); render();
|
|
say(`Pose ${k} is back on the stage. Change it and save it again.`);
|
|
}
|
|
function buildAnim() {
|
|
const res = JSON.parse(py("animate_ab", keys.A, keys.B, +$("#secs").value));
|
|
if (!res.error) return res.svg;
|
|
const diff = Object.keys(BLEND_FIELDS).filter(f => keys.A[f] !== keys.B[f]).map(f => BLEND_FIELDS[f]);
|
|
const list = diff.length > 1 ? `${diff.slice(0, -1).join(", ")} and ${diff[diff.length - 1]}` : diff[0];
|
|
say(diff.length ? `A and B can't blend: different ${list}. Save both with the same character, view, eyes and mouth.`
|
|
: `A and B can't blend: they're drawn with different shapes. Save both with the same character, view, eyes and mouth.`);
|
|
return null;
|
|
}
|
|
function play() {
|
|
say("Blending A and B…");
|
|
setTimeout(() => { // let the message paint first
|
|
if (!animSvg) animSvg = buildAnim();
|
|
if (!animSvg) return;
|
|
playing = true;
|
|
$("#play").textContent = "Stop";
|
|
placeHandles();
|
|
art.innerHTML = animSvg.replace(/^<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "");
|
|
if (art.setCurrentTime) art.setCurrentTime(0);
|
|
say("Playing: one SVG that animates itself, with no script. Download it below, or stop to keep posing.");
|
|
}, 30);
|
|
}
|
|
function stop() {
|
|
if (!playing) return;
|
|
playing = false;
|
|
$("#play").textContent = "Play";
|
|
say("Stopped. Keep posing, or press Play again.");
|
|
render();
|
|
}
|
|
function download(name, text) {
|
|
const a = document.createElement("a");
|
|
a.href = URL.createObjectURL(new Blob([text], { type: "image/svg+xml" }));
|
|
a.download = name;
|
|
document.body.append(a); a.click(); a.remove();
|
|
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
|
|
}
|
|
$("#saveA").onclick = () => saveKey("A");
|
|
$("#saveB").onclick = () => saveKey("B");
|
|
$("#loadA").onclick = () => loadKey("A");
|
|
$("#loadB").onclick = () => loadKey("B");
|
|
$("#play").onclick = () => playing ? stop() : play();
|
|
$("#secs").oninput = e => {
|
|
$("#secsOut").textContent = `${(+e.target.value).toFixed(1)} s`;
|
|
animSvg = null;
|
|
if (playing) { playing = false; play(); }
|
|
};
|
|
$("#dlStill").onclick = () => { download(`${pose.who}-pose.svg`, py("still", pose)); say("Downloaded this pose as an SVG."); };
|
|
$("#dlAnim").onclick = () => {
|
|
const svg = animSvg || buildAnim();
|
|
if (!svg) return;
|
|
animSvg = svg;
|
|
download(`${keys.A.who}-a-to-b.svg`, svg);
|
|
say(`Downloaded the blend: ${Math.round(svg.length/1000)} KB, and it plays in any browser.`);
|
|
};
|
|
$("#copyPose").onclick = async () => {
|
|
const text = JSON.stringify(pose);
|
|
try { await navigator.clipboard.writeText(text); say("Pose copied, as JSON. Paste it to an agent with rigplay.py, or keep it."); }
|
|
catch (err) { say(text); }
|
|
};
|
|
|
|
// ---------- start ----------
|
|
async function boot() {
|
|
fillSelects();
|
|
const loading = $("#loading");
|
|
const fail = text => { loading.textContent = text; };
|
|
if (typeof loadPyodide !== "function") {
|
|
fail("Python didn't load. The page gets it from cdn.jsdelivr.net, so it needs to be online. Reload to try again.");
|
|
return;
|
|
}
|
|
try {
|
|
const pyodide = await loadPyodide({ indexURL: PYODIDE });
|
|
loading.textContent = "Loading the drawing code…";
|
|
const texts = await Promise.all(PY_FILES.map(async f => {
|
|
const r = await fetch("py/" + f);
|
|
if (!r.ok) throw new Error(`py/${f} answered ${r.status}`);
|
|
return r.text();
|
|
}));
|
|
pyodide.FS.mkdirTree("/rig");
|
|
PY_FILES.forEach((f, k) => pyodide.FS.writeFile("/rig/" + f, texts[k]));
|
|
pyodide.runPython("import sys; sys.path.insert(0, '/rig')");
|
|
rp = pyodide.pyimport("rigplay");
|
|
pose = JSON.parse(py("ready", { who: "bunny", view: "front", eyes: "happy", mouth_kind: "open", lean: 0, hop: 0,
|
|
head_dx: 0, head_dy: 0, ...PRESETS.rest }));
|
|
} catch (err) {
|
|
fail(`The rig didn't load (${err.message}). Reload to try again.`);
|
|
return;
|
|
}
|
|
standing = structuredClone(pose.legs);
|
|
syncPanel();
|
|
loading.remove();
|
|
for (const id of ["saveA", "saveB", "dlStill", "copyPose"]) $("#" + id).disabled = false;
|
|
render();
|
|
say("Drag a pink handle, or start from a pose.");
|
|
}
|
|
boot();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|