#!/usr/bin/env python3
"""house.py — the Teahub drawing hand as code. Everything the dojo has cleared so far, in one place.
Head (yellow), bean / tubes / hands / feet (green), garments + shadows + FX (blue), posed figures (brown).
House deviations from the field guide, set by the creative director: hands are 3 fingers + thumb (thumb 1.15×), feet have 4 toes."""
import math, colorsys, os

INK, LW = "#2B2135", 10
FONT = "font-family=\"system-ui,-apple-system,sans-serif\""
# field-guide pastels
PINK, YEL, ORANGE, MINT, SKY, CREAM, SKIN, SKIN_S, LAV, LAV_S = "#FFB3E6", "#FFE780", "#FFC897", "#B3FFC7", "#A1E9FF", "#FFEAD2", "#FFD0B8", "#E8A68B", "#C5C5FF", "#A4A4F4"
WHITE = "#FFFFFF"
# drawon.py replays a drawing stroke by stroke; with HOUSE_TAGS=1, figure() wraps each part in <g data-part> (and the
# whole figure in <g data-figure>) so the replay can draw a character the way an artist would: head first, feet last.
# Off by default: the output is byte-for-byte what it was.
TAGS = bool(os.environ.get("HOUSE_TAGS"))
def part(name, svg): return f'<g data-part="{name}">{svg}</g>' if TAGS and svg else svg

# ---------- colour ----------
def hex2rgb(h): h = h.lstrip("#"); return tuple(int(h[i:i+2], 16)/255 for i in (0, 2, 4))
def rgb2hex(r, g, b): return "#%02X%02X%02X" % tuple(max(0, min(255, round(c*255))) for c in (r, g, b))
def shade(hexcol, sat_up=0.28, val_down=0.10):
    """the house shadow: same hue, more saturated, a little darker (never grey)."""
    h, s, v = colorsys.rgb_to_hsv(*hex2rgb(hexcol))
    return rgb2hex(*colorsys.hsv_to_rgb(h, min(1, s + sat_up), max(0, v - val_down)))
def hsv(hexcol): return colorsys.rgb_to_hsv(*hex2rgb(hexcol))

# ---------- svg atoms ----------
def txt(x, y, s, size=22, w=650, op=1):
    return f'<text x="{x:.0f}" y="{y:.0f}" {FONT} font-size="{size}" font-weight="{w}" fill="{INK}" opacity="{op}">{s}</text>'
def outline(d, fill, lw=LW):
    return f'<path fill="{fill}" stroke="{INK}" stroke-width="{lw}" stroke-linecap="round" stroke-linejoin="round" d="{d}"/>'
def line(d, lw=LW, col=INK):
    return f'<path fill="none" stroke="{col}" stroke-width="{lw}" stroke-linecap="round" stroke-linejoin="round" d="{d}"/>'
def flat(d, fill, op=1, cls="fx"):
    return f'<path class="{cls}" fill="{fill}" opacity="{op}" d="{d}"/>'
def tube_ink(d, w):  return line(d, w + 2*LW)
def tube_fill(d, w, col): return line(d, w, col)
def tube(d, w, col):  return tube_ink(d, w) + tube_fill(d, w, col)
def circ(cx, cy, r, fill, op=1, cls="fx"):
    return f'<circle class="{cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="{r:.1f}" fill="{fill}" opacity="{op}"/>'
def ellipse_path(cx, cy, rx, ry, rot=0):
    k = 0.5523; c, s = math.cos(math.radians(rot)), math.sin(math.radians(rot))
    def P(x, y): return f"{cx + x*c - y*s:.1f} {cy + x*s + y*c:.1f}"
    return (f"M{P(-rx,0)} C{P(-rx,-ry*k)} {P(-rx*k,-ry)} {P(0,-ry)} C{P(rx*k,-ry)} {P(rx,-ry*k)} {P(rx,0)} "
            f"C{P(rx,ry*k)} {P(rx*k,ry)} {P(0,ry)} C{P(-rx*k,ry)} {P(-rx,ry*k)} {P(-rx,0)} Z")
def blob(cx, cy, rx, ry, fill, lw=LW): return outline(ellipse_path(cx, cy, rx, ry), fill, lw)

# ---------- bezier helpers ----------
def bez(p0, p1, p2, p3, t):
    u = 1-t
    return (u*u*u*p0[0] + 3*u*u*t*p1[0] + 3*u*t*t*p2[0] + t*t*t*p3[0],
            u*u*u*p0[1] + 3*u*u*t*p1[1] + 3*u*t*t*p2[1] + t*t*t*p3[1])
def bez_tan(p0, p1, p2, p3, t):
    u = 1-t
    x = 3*u*u*(p1[0]-p0[0]) + 6*u*t*(p2[0]-p1[0]) + 3*t*t*(p3[0]-p2[0])
    y = 3*u*u*(p1[1]-p0[1]) + 6*u*t*(p2[1]-p1[1]) + 3*t*t*(p3[1]-p2[1])
    L = math.hypot(x, y) or 1
    return (x/L, y/L)
def split(p0, p1, p2, p3, t):
    """de Casteljau: returns (first, second) cubic control tuples"""
    def lerp(a, b): return (a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t)
    a, b, c = lerp(p0, p1), lerp(p1, p2), lerp(p2, p3)
    d, e = lerp(a, b), lerp(b, c); m = lerp(d, e)
    return (p0, a, d, m), (m, e, c, p3)
def cpath(c): return f"M{c[0][0]:.1f} {c[0][1]:.1f} C{c[1][0]:.1f} {c[1][1]:.1f} {c[2][0]:.1f} {c[2][1]:.1f} {c[3][0]:.1f} {c[3][1]:.1f}"
def limb_curve(p, q, bow=0.0, lean=0.0):
    """one cubic from p to q bending as a single curve. bow = sideways bulge (fraction of length, signed);
    lean = how much the first handle leans along the chord (0.35 default)."""
    dx, dy = q[0]-p[0], q[1]-p[1]; L = math.hypot(dx, dy) or 1
    nx, ny = -dy/L, dx/L
    c1 = (p[0] + dx*0.33 + nx*bow*L, p[1] + dy*0.33 + ny*bow*L)
    c2 = (p[0] + dx*0.67 + nx*bow*L, p[1] + dy*0.67 + ny*bow*L)
    return (p, c1, c2, q)

# ---------- head ----------
_BASE = [("M",0,104),("C",-2,44,18,6,70,2),("C",90,1,110,1,130,2),("C",182,6,202,44,200,104),
         ("C",202,124,202,144,200,164),("C",198,218,178,238,130,240),("C",110,241,90,241,70,240),
         ("C",22,238,2,218,0,164),("C",-2,144,-2,124,0,104)]
_SHADOW = [("M",150,6),("C",190,24,204,100,200,164),("C",198,218,178,238,130,240),("C",170,226,184,190,180,140),("C",176,90,170,40,150,6)]
def _scale(seg, x, y, w, h, flip=1):
    sx, sy = w/200, h/240; out = []
    for s in seg:
        c = s[0]; n = s[1:]
        pts = []
        for i in range(0, len(n), 2):
            u = n[i] if flip == 1 else 200-n[i]
            pts.append(f"{x+u*sx:.1f} {y+n[i+1]*sy:.1f}")
        out.append(c + " ".join(pts))
    return " ".join(out) + " Z"
def head_path(x, y, w, h): return _scale(_BASE, x, y, w, h)
def head_shadow_path(x, y, w, h, side=1): return _scale(_SHADOW, x, y, w, h, side)

def ear(x, cy, h, side=1, fill=SKIN):
    r = h*0.06
    d = f"M{x} {cy-r} C{x+side*r*2.1} {cy-r*1.2} {x+side*r*2.1} {cy+r*1.2} {x} {cy+r} C{x-side*r*0.15} {cy+r*0.5} {x-side*r*0.15} {cy-r*0.5} {x} {cy-r} Z"
    return outline(d, fill)

HEART = shade(PINK, 0.75, 0.18)           # heart eyes: the house pink, deepened until it reads on skin

def heart_path(cx, cy, w, h):
    """a heart centred on (cx, cy): two round lobes and a soft point, w wide and h tall"""
    t, b = cy - h*0.30, cy + h*0.55
    return (f"M{cx:.1f} {t:.1f} "
            f"C{cx - w*0.10:.1f} {cy - h*0.62:.1f} {cx - w*0.58:.1f} {cy - h*0.66:.1f} {cx - w*0.5:.1f} {cy - h*0.08:.1f} "
            f"C{cx - w*0.44:.1f} {cy + h*0.22:.1f} {cx - w*0.16:.1f} {cy + h*0.38:.1f} {cx:.1f} {b:.1f} "
            f"C{cx + w*0.16:.1f} {cy + h*0.38:.1f} {cx + w*0.44:.1f} {cy + h*0.22:.1f} {cx + w*0.5:.1f} {cy - h*0.08:.1f} "
            f"C{cx + w*0.58:.1f} {cy - h*0.66:.1f} {cx + w*0.10:.1f} {cy - h*0.62:.1f} {cx:.1f} {t:.1f} Z")

def eye(cx, cy, s, kind="dot"):
    """dot | far (a dot seen from the side) | wide | closed | happy | flat | heart. heart+ is a heart mid-beat (a size
    up, for animation); a -far suffix narrows a heart for the far eye of a three-quarter view."""
    if kind.startswith("heart"):
        k = 1.25 if "+" in kind else 1.0
        return flat(heart_path(cx, cy - s*0.05, s*1.7*k*(0.62 if kind.endswith("-far") else 1.0), s*1.5*k), HEART, 1, "fx")
    if kind == "dot":    return f'<ellipse cx="{cx:.1f}" cy="{cy:.1f}" rx="{s*0.5:.1f}" ry="{s*0.55:.1f}" fill="{INK}"/>'
    if kind == "far":    return f'<ellipse cx="{cx:.1f}" cy="{cy:.1f}" rx="{s*0.28:.1f}" ry="{s*0.55:.1f}" fill="{INK}"/>'
    if kind == "wide":   return f'<ellipse cx="{cx:.1f}" cy="{cy:.1f}" rx="{s*0.62:.1f}" ry="{s*0.7:.1f}" fill="{INK}"/>'
    if kind == "closed": return line(f"M{cx-s*0.55:.1f} {cy:.1f} C{cx-s*0.3:.1f} {cy+s*0.45:.1f} {cx+s*0.3:.1f} {cy+s*0.45:.1f} {cx+s*0.55:.1f} {cy:.1f}")
    if kind == "happy":  return line(f"M{cx-s*0.55:.1f} {cy+s*0.2:.1f} C{cx-s*0.3:.1f} {cy-s*0.35:.1f} {cx+s*0.3:.1f} {cy-s*0.35:.1f} {cx+s*0.55:.1f} {cy+s*0.2:.1f}")
    if kind == "flat":   return line(f"M{cx-s*0.55:.1f} {cy:.1f} C{cx-s*0.2:.1f} {cy+s*0.06:.1f} {cx+s*0.2:.1f} {cy+s*0.06:.1f} {cx+s*0.55:.1f} {cy:.1f}")
    raise ValueError(kind)

def mouth(cx, cy, w, kind="smile"):
    h = w*0.35
    if kind == "none":  return ""         # reduced marks: eyes only
    if kind == "smile": return line(f"M{cx-w/2:.1f} {cy:.1f} C{cx-w*0.25:.1f} {cy+h:.1f} {cx+w*0.25:.1f} {cy+h:.1f} {cx+w/2:.1f} {cy:.1f}")
    if kind == "flat":  return line(f"M{cx-w/2:.1f} {cy:.1f} C{cx-w*0.2:.1f} {cy+2:.1f} {cx+w*0.2:.1f} {cy+2:.1f} {cx+w/2:.1f} {cy:.1f}")
    if kind == "small": return line(f"M{cx-w*0.25:.1f} {cy:.1f} C{cx-w*0.1:.1f} {cy+h*0.4:.1f} {cx+w*0.1:.1f} {cy+h*0.4:.1f} {cx+w*0.25:.1f} {cy:.1f}")
    if kind == "o":     return f'<ellipse cx="{cx:.1f}" cy="{cy+h*0.4:.1f}" rx="{w*0.22:.1f}" ry="{w*0.3:.1f}" fill="{INK}"/>'
    if kind == "open":  return f'<path fill="{INK}" d="M{cx-w/2:.1f} {cy:.1f} C{cx-w*0.4:.1f} {cy+h*1.6:.1f} {cx+w*0.4:.1f} {cy+h*1.6:.1f} {cx+w/2:.1f} {cy:.1f} C{cx+w*0.2:.1f} {cy+3:.1f} {cx-w*0.2:.1f} {cy+3:.1f} {cx-w/2:.1f} {cy:.1f} Z"/>'
    if kind == "grin":  return line(f"M{cx-w*0.7:.1f} {cy-h*0.1:.1f} C{cx-w*0.3:.1f} {cy+h*0.9:.1f} {cx+w*0.3:.1f} {cy+h*0.9:.1f} {cx+w*0.7:.1f} {cy-h*0.1:.1f}")
    if kind == "smirk": return line(f"M{cx-w*0.4:.1f} {cy+h*0.2:.1f} C{cx-w*0.1:.1f} {cy+h*0.5:.1f} {cx+w*0.3:.1f} {cy+h*0.3:.1f} {cx+w*0.5:.1f} {cy-h*0.3:.1f}")
    if kind == "frown": return line(f"M{cx-w/2:.1f} {cy+h*0.6:.1f} C{cx-w*0.25:.1f} {cy-h*0.4:.1f} {cx+w*0.25:.1f} {cy-h*0.4:.1f} {cx+w/2:.1f} {cy+h*0.6:.1f}")
    raise ValueError(kind)

def head(x, y, w, h, fill=SKIN, view="front", eyes="dot", mouth_kind="smile", shadow=True, hair=None, uid="h", human_ears=True):
    """view: front | q (3/4 facing viewer-left) | qr (3/4 facing right) | side | sider | back.
    hair: (colour, kind) kind in tuft|cap|bob|bun|none. human_ears=False for characters whose ears live elsewhere
    (bunny ears are drawn behind the head by the caller). returns svg."""
    ear_ = ear if human_ears else (lambda *a: "")
    out = []
    hp = head_path(x, y, w, h)
    out.append(outline(hp, fill))
    if shadow:
        side = 1
        out.append(f'<clipPath id="{uid}"><path d="{hp}"/></clipPath><g clip-path="url(#{uid})">{flat(head_shadow_path(x, y, w, h, side), shade(fill), cls="s")}</g>')
    ey = y + h/3; s = h*0.085; my = ey + h*0.10
    if view == "front":
        gap = w*0.36
        out += [ear_(x+w, y+h/2, h, 1, fill), ear_(x, y+h/2, h, -1, fill), eye(x+w/2-gap/2, ey, s, eyes), eye(x+w/2+gap/2, ey, s, eyes), mouth(x+w/2, my, gap*0.9, mouth_kind)]
    elif view in ("q", "qr"):
        f = 1 if view == "q" else -1
        nx = x + w*(0.6 if f == 1 else 0.4); fx = x + w*(0.2 if f == 1 else 0.8)
        out += [ear_(x+w, y+h/2, h, 1, fill) if f == 1 else ear_(x, y+h/2, h, -1, fill), eye(nx, ey, s, eyes), eye(fx, ey, s, "far" if eyes == "dot" else eyes + "-far" if eyes.startswith("heart") else eyes), mouth(x+w/2 - f*w*0.12, my, w*0.24, mouth_kind)]
    elif view in ("side", "sider"):
        f = 1 if view == "side" else -1
        ex = x + w*(0.2 if f == 1 else 0.8); e0 = x+3 if f == 1 else x+w-3; hm = w*0.16
        out += [ear_(x+w*0.5, y+h/2, h, f, fill), eye(ex, ey, s, eyes), line(f"M{e0:.1f} {my:.1f} C{e0+f*hm*0.4:.1f} {my+hm*0.5:.1f} {e0+f*hm*0.8:.1f} {my+hm*0.5:.1f} {e0+f*hm:.1f} {my+hm*0.1:.1f}")]
    elif view == "back":
        out += [ear_(x+w, y+h/2, h, 1, fill), ear_(x, y+h/2, h, -1, fill)]
    out = [o for o in out if o]
    if hair: out.append(hair_mass(x, y, w, h, hair[0], hair[1], view))
    return "\n".join(out)

def hair_mass(x, y, w, h, col, kind, view="front"):
    """hair as one closed mass sitting on the top of the oblong; the mass is the identity cue."""
    if kind == "none": return ""
    if kind == "tuft":
        tx = x + w*0.6
        return line(f"M{tx:.1f} {y+2:.1f} C{tx:.1f} {y-h*0.06:.1f} {tx+w*0.08:.1f} {y-h*0.08:.1f} {tx+w*0.1:.1f} {y-h*0.14:.1f}", LW+4, INK) + line(f"M{tx:.1f} {y+2:.1f} C{tx:.1f} {y-h*0.06:.1f} {tx+w*0.08:.1f} {y-h*0.08:.1f} {tx+w*0.1:.1f} {y-h*0.14:.1f}", LW-4, col)
    if kind == "cap":    # a beanie: dome + band
        d = f"M{x-6:.1f} {y+h*0.16:.1f} C{x-8:.1f} {y-h*0.5:.1f} {x+w+8:.1f} {y-h*0.5:.1f} {x+w+6:.1f} {y+h*0.16:.1f} C{x+w*0.7:.1f} {y+h*0.12:.1f} {x+w*0.3:.1f} {y+h*0.12:.1f} {x-6:.1f} {y+h*0.16:.1f} Z"
        band = f"M{x-8:.1f} {y+h*0.1:.1f} C{x+w*0.3:.1f} {y+h*0.18:.1f} {x+w*0.7:.1f} {y+h*0.18:.1f} {x+w+8:.1f} {y+h*0.1:.1f} C{x+w+8:.1f} {y+h*0.18:.1f} {x+w+8:.1f} {y+h*0.22:.1f} {x+w+6:.1f} {y+h*0.24:.1f} C{x+w*0.7:.1f} {y+h*0.3:.1f} {x+w*0.3:.1f} {y+h*0.3:.1f} {x-6:.1f} {y+h*0.24:.1f} C{x-8:.1f} {y+h*0.22:.1f} {x-8:.1f} {y+h*0.18:.1f} {x-8:.1f} {y+h*0.1:.1f} Z"
        return outline(d, col) + outline(band, shade(col, 0.2, 0.06))
    if kind == "bob":    # helmet of hair to the ears; bangs stay above the eye line
        d = (f"M{x-10:.1f} {y+h*0.5:.1f} C{x-14:.1f} {y+h*0.1:.1f} {x+w*0.2:.1f} {y-h*0.1:.1f} {x+w*0.5:.1f} {y-h*0.1:.1f} "
             f"C{x+w*0.8:.1f} {y-h*0.1:.1f} {x+w+14:.1f} {y+h*0.1:.1f} {x+w+10:.1f} {y+h*0.5:.1f} "
             f"C{x+w+2:.1f} {y+h*0.3:.1f} {x+w*0.96:.1f} {y+h*0.08:.1f} {x+w*0.75:.1f} {y+h*0.1:.1f} "
             f"C{x+w*0.4:.1f} {y+h*0.12:.1f} {x+w*0.1:.1f} {y+h*0.12:.1f} {x-10:.1f} {y+h*0.5:.1f} Z")
        return outline(d, col)
    if kind == "bun":    # swept mass + a ball on top
        d = f"M{x-4:.1f} {y+h*0.3:.1f} C{x-6:.1f} {y-h*0.04:.1f} {x+w*0.3:.1f} {y-h*0.1:.1f} {x+w*0.55:.1f} {y-h*0.08:.1f} C{x+w*0.85:.1f} {y-h*0.06:.1f} {x+w+6:.1f} {y+h*0.05:.1f} {x+w+4:.1f} {y+h*0.3:.1f} C{x+w*0.7:.1f} {y+h*0.18:.1f} {x+w*0.3:.1f} {y+h*0.18:.1f} {x-4:.1f} {y+h*0.3:.1f} Z"
        return outline(d, col) + blob(x+w*0.62, y-h*0.12, w*0.17, w*0.15, col)
    if kind == "spikes":  # three rounded spikes
        d = f"M{x+w*0.1:.1f} {y+h*0.12:.1f} C{x+w*0.12:.1f} {y-h*0.1:.1f} {x+w*0.26:.1f} {y-h*0.12:.1f} {x+w*0.3:.1f} {y+h*0.04:.1f} C{x+w*0.36:.1f} {y-h*0.18:.1f} {x+w*0.58:.1f} {y-h*0.2:.1f} {x+w*0.6:.1f} {y+h*0.02:.1f} C{x+w*0.68:.1f} {y-h*0.14:.1f} {x+w*0.9:.1f} {y-h*0.1:.1f} {x+w*0.9:.1f} {y+h*0.12:.1f} C{x+w*0.6:.1f} {y+h*0.06:.1f} {x+w*0.4:.1f} {y+h*0.06:.1f} {x+w*0.1:.1f} {y+h*0.12:.1f} Z"
        return outline(d, col)
    return ""

# ---------- body ----------
def bean_path(cx, y0, y1, r, sw, lean=0.0):
    """torso: belly circle radius r sitting on y1, shoulders width sw at y0; lean shifts the shoulders sideways
    (fraction of torso height) so the back is one arc — the line of action."""
    H = y1 - y0; dx = lean*H; sh = sw*0.14; k = 0.55
    return (f"M{cx-sw/2+dx:.1f} {y0+sh:.1f} C{cx-sw/2+dx:.1f} {y0:.1f} {cx+sw/2+dx:.1f} {y0:.1f} {cx+sw/2+dx:.1f} {y0+sh:.1f} "
            f"C{cx+sw/2+dx*0.6:.1f} {y0+H*0.42:.1f} {cx+r:.1f} {y1-r*1.55:.1f} {cx+r:.1f} {y1-r:.1f} "
            f"C{cx+r:.1f} {y1-r*(1-k):.1f} {cx+r*k:.1f} {y1:.1f} {cx:.1f} {y1:.1f} "
            f"C{cx-r*k:.1f} {y1:.1f} {cx-r:.1f} {y1-r*(1-k):.1f} {cx-r:.1f} {y1-r:.1f} "
            f"C{cx-r:.1f} {y1-r*1.55:.1f} {cx-sw/2+dx*0.6:.1f} {y0+H*0.42:.1f} {cx-sw/2+dx:.1f} {y0+sh:.1f} Z")

def shoe(x, y, w, h, flip=1, fill=YEL):
    """two masses: upper (one closed shape) + sole band ~18% of height, thicker at the heel, bowed. toe points toward +flip."""
    sole = h*0.18; heel = sole*1.35
    tx = x + flip*w; hx = x
    up = (f"M{hx:.1f} {y-heel:.1f} C{hx:.1f} {y-h:.1f} {hx+flip*w*0.25:.1f} {y-h:.1f} {hx+flip*w*0.45:.1f} {y-h*0.92:.1f} "
          f"C{hx+flip*w*0.75:.1f} {y-h*0.8:.1f} {tx:.1f} {y-h*0.55:.1f} {tx:.1f} {y-sole*0.9:.1f} "
          f"C{tx-flip*w*0.3:.1f} {y-sole*0.7:.1f} {hx+flip*w*0.3:.1f} {y-sole*0.8:.1f} {hx:.1f} {y-heel:.1f} Z")
    sl = (f"M{hx:.1f} {y-heel:.1f} C{hx+flip*w*0.3:.1f} {y-heel*0.9:.1f} {tx-flip*w*0.3:.1f} {y-sole*0.8:.1f} {tx:.1f} {y-sole*0.9:.1f} "
          f"C{tx+flip*2:.1f} {y-sole*0.3:.1f} {tx-flip*w*0.1:.1f} {y:.1f} {tx-flip*w*0.2:.1f} {y:.1f} "
          f"C{hx+flip*w*0.4:.1f} {y+2:.1f} {hx+flip*w*0.1:.1f} {y-2:.1f} {hx+flip*4:.1f} {y-heel*0.35:.1f} "
          f"C{hx-flip*2:.1f} {y-heel*0.6:.1f} {hx-flip*2:.1f} {y-heel*0.8:.1f} {hx:.1f} {y-heel:.1f} Z")
    return outline(up, fill) + outline(sl, WHITE) + line(f"M{hx+flip*w*0.42:.1f} {y-h*0.9:.1f} C{hx+flip*w*0.5:.1f} {y-h*0.7:.1f} {hx+flip*w*0.5:.1f} {y-h*0.55:.1f} {hx+flip*w*0.46:.1f} {y-h*0.42:.1f}")

def mitt(cx, cy, r, fill=SKIN): return blob(cx, cy, r, r*1.15, fill)

def finger_curve(p, q, bend=0.0):
    mx, my = (p[0]+q[0])/2, (p[1]+q[1])/2
    nx, ny = -(q[1]-p[1]), (q[0]-p[0]); L = math.hypot(nx, ny) or 1; nx, ny = nx/L, ny/L
    c = (mx + nx*bend*L, my + ny*bend*L)
    return f"M{p[0]:.1f} {p[1]:.1f} Q{c[0]:.1f} {c[1]:.1f} {q[0]:.1f} {q[1]:.1f}"

def hand(cx, cy, pose="open", fw=24, prop=None, rot=0, fill=SKIN, wrist=True, scale=1.0, palm=1.0, join=""):
    """house hand: 3 fingers + thumb. palm = soft disc r≈1.35·fw. thumb 1.15× finger width, shorter, low on the palm.
    rot rotates the whole hand (degrees, 0 = fingers up). grips draw far fingers → prop → palm + near fingers.
    palm < 1 shrinks only the disc (fingers keep their roots and reach): use it when scale > 1 makes the palm wider
    than the wrist, or the disc shows as a fifth lobe in the silhouette. join: svg drawn over the palm's outline and under
    the fingers (the arm's own fill, so the palm grows out of the arm instead of sitting on it as a ringed knob)."""
    fw = fw*scale; R = fw*1.35; TW = fw*1.15; RP = R*palm
    def F(ang, length, bend=0.0, w=fw):
        a = math.radians(ang + rot)
        p = (cx + math.cos(a)*R*0.55, cy - math.sin(a)*R*0.55)
        q = (cx + math.cos(a)*(R*0.55+length), cy - math.sin(a)*(R*0.55+length))
        return (finger_curve(p, q, bend), w)
    fingers, far, near, thumb = [], [], [], None
    if pose == "open":      fingers = [F(a, fw*2.4) for a in (112, 84, 56)]; thumb = F(12, fw*1.7, 0, TW)
    elif pose == "point":   fingers = [F(96, fw*2.8)] + [F(a, fw*0.85) for a in (62, 36)]; thumb = F(140, fw*1.4, 0, TW)
    elif pose == "fist":    fingers = [F(a, fw*0.8) for a in (112, 78, 44)]; thumb = F(150, fw*1.1, 0, TW)
    elif pose == "peace":   fingers = [F(106, fw*2.7), F(74, fw*2.7), F(38, fw*0.85)]; thumb = F(160, fw*1.3, 0, TW)
    elif pose == "pinch":   fingers = [F(84, fw*2.3, -0.22), F(50, fw*0.85), F(26, fw*0.8)]; thumb = F(122, fw*2.1, 0.24, TW)
    elif pose == "thumbsup":fingers = [F(a, fw*0.85) for a in (22, -2, -26)]; thumb = F(95, fw*2.2, 0, TW)
    elif pose == "grip":    far = [F(118, fw*2.0, 0.22)]; near = [F(a, fw*2.0, 0.22) for a in (88, 58)]; thumb = F(150, fw*1.6, -0.15, TW)
    elif pose == "wave":    fingers = [F(a, fw*2.4, 0.08) for a in (120, 92, 64)]; thumb = F(20, fw*1.6, 0, TW)
    palm = outline(ellipse_path(cx, cy, RP, RP*1.15), fill)
    wr = tube(f"M{cx:.1f} {cy+R*0.6:.1f} C{cx:.1f} {cy+R*1.3:.1f} {cx-4:.1f} {cy+R*1.8:.1f} {cx-6:.1f} {cy+R*2.3:.1f}", R*1.5, fill) if wrist else ""
    def ink(fs):  return "".join(tube_ink(d, w) for d, w in fs)
    def fil(fs):  return "".join(tube_fill(d, w, fill) for d, w in fs)
    allf = fingers + ([thumb] if thumb else [])
    if pose != "grip":
        return "\n".join([wr, ink(allf), fil(allf), palm] + ([join] if join else []) + [fil(allf)])
    return "\n".join([wr, ink(far), fil(far), prop or "", ink(near + [thumb]), fil(near + [thumb]), palm] + ([join] if join else [])
                     + [fil(near + [thumb])])

def bare_foot(cx, cy, view="side", flip=1, fill=SKIN):
    """house foot: 4 toes, big toe ~1.6×. ankle = continuous tube."""
    out = []
    def lobe(x, y, r): return blob(x, y, r, r*1.2, fill)
    if view == "side":
        L, H = 200, 70; hx, tx = cx-L*0.4, cx+L*0.5
        out.append(tube(f"M{hx+42:.1f} {cy-H*0.9:.1f} C{hx+40:.1f} {cy-H*1.6:.1f} {hx+36:.1f} {cy-H*2.2:.1f} {hx+34:.1f} {cy-H*2.9:.1f}", 54, fill))
        out.append(outline(f"M{hx:.1f} {cy-H:.1f} C{hx-16:.1f} {cy-H*0.6:.1f} {hx-10:.1f} {cy-6:.1f} {hx+30:.1f} {cy:.1f} C{cx:.1f} {cy+2:.1f} {cx+40:.1f} {cy:.1f} {tx-40:.1f} {cy:.1f} C{tx-10:.1f} {cy+2:.1f} {tx+4:.1f} {cy-10:.1f} {tx:.1f} {cy-22:.1f} C{tx-10:.1f} {cy-44:.1f} {cx+30:.1f} {cy-H*0.75:.1f} {cx-10:.1f} {cy-H*0.95:.1f} Z", fill))
        for dx, dy, r in ((-86,-30,13),(-62,-27,15),(-38,-24,17)): out.append(lobe(tx+dx, cy+dy, r))
        out.append(lobe(tx, cy-20, 26))
    elif view == "front":
        W = 140
        out.append(tube(f"M{cx:.1f} {cy-50:.1f} C{cx:.1f} {cy-110:.1f} {cx-2:.1f} {cy-160:.1f} {cx-4:.1f} {cy-220:.1f}", 56, fill))
        out.append(outline(f"M{cx-W/2:.1f} {cy-40:.1f} C{cx-W/2-6:.1f} {cy-80:.1f} {cx+W/2+6:.1f} {cy-80:.1f} {cx+W/2:.1f} {cy-40:.1f} C{cx+W/2+4:.1f} {cy-10:.1f} {cx+W/2:.1f} {cy+4:.1f} {cx+W/2-20:.1f} {cy+6:.1f} L{cx-W/2+20:.1f} {cy+6:.1f} C{cx-W/2:.1f} {cy+4:.1f} {cx-W/2-4:.1f} {cy-10:.1f} {cx-W/2:.1f} {cy-40:.1f} Z", fill))
        for dx, r in ((46, 13), (22, 14), (-6, 15), (-42, 24)): out.append(lobe(cx+dx, cy+4, r))
    return "\n".join(out)

# ---------- garments ----------
def opening(cx, cy, rx, ry, fill, rot=0, lw=LW, lip=(0.72, 0.55)):
    """an ellipse with a lip: the opening of a tube (cuff, hem, collar). the lip is a second contour inside, `lip` of
    the opening's width and depth (a wide, flat opening wants a shorter, shallower lip, or it runs into the rim)."""
    return outline(ellipse_path(cx, cy, rx, ry, rot), shade(fill), lw) + line(_arc_inner(cx, cy, rx*lip[0], ry*lip[1], rot), lw)
def _arc_inner(cx, cy, rx, ry, rot):
    k = 0.5523; c, s = math.cos(math.radians(rot)), math.sin(math.radians(rot))
    def P(x, y): return f"{cx + x*c - y*s:.1f} {cy + x*s + y*c:.1f}"
    return f"M{P(-rx,0)} C{P(-rx*k,ry)} {P(rx*k,ry)} {P(rx,0)}"

def sleeve(curve, limb_w, col, frac=0.55, ratio=1.7, cuff_ry=0.3, tuck=False):
    """garment over the first `frac` of a limb curve: a tube ratio× the limb width ending in a cuff (ellipse + lip).
    cuff_ry: the opening's minor axis as a fraction of the sleeve width; on small figures a rounder cuff (~0.42) keeps
    the lip readable inside the constant line weight. tuck: end the tube just short of the opening's centre, so its
    round end of ink stays inside the opening instead of showing as a crescent below it (small figures)."""
    a, b = split(*curve, frac)
    w = limb_w*ratio
    end = a[3]; tx, ty = bez_tan(*curve, frac)
    rot = math.degrees(math.atan2(ty, tx)) + 90
    if tuck:
        d = max(0.0, w/2 + LW*0.5 - w*cuff_ry)     # ink cap radius (w/2 + LW) minus the opening's reach (ry + LW/2)
        lo, hi = 0.0, frac
        for _ in range(30):
            m = (lo + hi)/2
            if math.dist(bez(*curve, m), end) > d: lo = m
            else: hi = m
        a = split(*curve, lo)[0]
    return tube(cpath(a), w, col) + band(a, w*0.08, w*0.46, shade(col)) + opening(end[0], end[1], w/2 + LW*0.4, w*cuff_ry, col, rot)

def band(c, inner, outer, col, n=14, t0=0.06, t1=0.94):
    """a strokeless shadow band along the right-hand side of a cubic tube: polygon between two normal offsets."""
    pts_o, pts_i = [], []
    for k in range(n+1):
        t = t0 + (t1-t0)*k/n
        x, y = bez(*c, t); tx, ty = bez_tan(*c, t)
        nx, ny = -ty, tx
        if nx < 0: nx, ny = -nx, -ny          # the side facing +x (screen right)
        pts_o.append((x + nx*outer, y + ny*outer)); pts_i.append((x + nx*inner, y + ny*inner))
    d = "M" + " L".join(f"{x:.1f} {y:.1f}" for x, y in pts_o) + " L" + " L".join(f"{x:.1f} {y:.1f}" for x, y in reversed(pts_i)) + " Z"
    return flat(d, col, 1, "s")

def fold(p, q, bow=0.12):
    """one curved round-capped dash that touches neither contour"""
    return line(finger_curve(p, q, bow))

# ---------- FX (strokeless) ----------
def lobes(cx, cy, pts, fill, op=1):
    """a cloud/plume built from overlapping discs; pts = [(dx, dy, r)]"""
    return "".join(circ(cx+dx, cy+dy, r, fill, op) for dx, dy, r in pts)

def sparkle(cx, cy, r, fill=WHITE, op=1):
    """four-pointed, concave sides"""
    k = r*0.18
    return flat(f"M{cx:.1f} {cy-r:.1f} Q{cx+k:.1f} {cy-k:.1f} {cx+r:.1f} {cy:.1f} Q{cx+k:.1f} {cy+k:.1f} {cx:.1f} {cy+r:.1f} Q{cx-k:.1f} {cy+k:.1f} {cx-r:.1f} {cy:.1f} Q{cx-k:.1f} {cy-k:.1f} {cx:.1f} {cy-r:.1f} Z", fill, op)

def drop(cx, cy, r, fill, op=1, tilt=0):
    """a droplet: round bottom, soft point up"""
    c, s = math.cos(math.radians(tilt)), math.sin(math.radians(tilt))
    def P(x, y): return f"{cx + x*c - y*s:.1f} {cy + x*s + y*c:.1f}"
    return flat(f"M{P(0,-r*1.6)} C{P(r*0.5,-r*0.6)} {P(r,-r*0.2)} {P(r,r*0.3)} C{P(r,r*0.9)} {P(r*0.55,r*1.3)} {P(0,r*1.3)} C{P(-r*0.55,r*1.3)} {P(-r,r*0.9)} {P(-r,r*0.3)} C{P(-r,-r*0.2)} {P(-r*0.5,-r*0.6)} {P(0,-r*1.6)} Z", fill, op)

def flame(cx, cy, w, h, cols):
    """fire: stacked values, each inner shape echoing the outer silhouette at ~50-60% scale, bottom-aligned"""
    out = []
    for i, col in enumerate(cols):
        sc = 1 - i*0.42
        ww, hh = w*sc, h*sc
        out.append(flat(f"M{cx:.1f} {cy-hh:.1f} C{cx+ww*0.2:.1f} {cy-hh*0.75:.1f} {cx+ww*0.55:.1f} {cy-hh*0.55:.1f} {cx+ww*0.5:.1f} {cy-hh*0.25:.1f} C{cx+ww*0.48:.1f} {cy-hh*0.05:.1f} {cx+ww*0.3:.1f} {cy:.1f} {cx:.1f} {cy:.1f} C{cx-ww*0.3:.1f} {cy:.1f} {cx-ww*0.48:.1f} {cy-hh*0.05:.1f} {cx-ww*0.5:.1f} {cy-hh*0.25:.1f} C{cx-ww*0.55:.1f} {cy-hh*0.55:.1f} {cx-ww*0.2:.1f} {cy-hh*0.75:.1f} {cx:.1f} {cy-hh:.1f} Z", col))
    return "".join(out)

def splash(cx, cy, r, fill, hi=None):
    """main mass + detached droplets at descending sizes"""
    out = [flat(f"M{cx-r:.1f} {cy:.1f} C{cx-r*0.9:.1f} {cy-r*0.9:.1f} {cx-r*0.3:.1f} {cy-r*1.1:.1f} {cx-r*0.1:.1f} {cy-r*0.6:.1f} C{cx+r*0.1:.1f} {cy-r*1.0:.1f} {cx+r*0.7:.1f} {cy-r*1.0:.1f} {cx+r*0.6:.1f} {cy-r*0.4:.1f} C{cx+r*1.1:.1f} {cy-r*0.4:.1f} {cx+r*1.1:.1f} {cy+r*0.3:.1f} {cx+r*0.7:.1f} {cy+r*0.3:.1f} C{cx+r*0.3:.1f} {cy+r*0.5:.1f} {cx-r*0.5:.1f} {cy+r*0.5:.1f} {cx-r:.1f} {cy:.1f} Z", fill)]
    for dx, dy, rr, t in ((-1.25, -0.9, 0.22, -30), (-0.2, -1.5, 0.17, 0), (0.9, -1.3, 0.13, 20), (1.4, -0.3, 0.1, 60)):
        out.append(drop(cx+dx*r, cy+dy*r, rr*r, fill, 1, t))
    if hi: out.append(flat(ellipse_path(cx-r*0.3, cy-r*0.15, r*0.3, r*0.14, -20), hi))
    return "".join(out)

# ---------- the figure ----------
SHOULDERS = {"low": (0.1, 1.0), "classic": (-0.3, 0.0)}   # where the arms leave the torso: (out, down) in limb widths
CLASSIC = False   # True: figure() draws as the dojo did (arms tucked under the chin, the collar, legs in passes); the
                  # kata generators set it, so the sheets stay exactly as they were drawn and graded
NEAR = {"q": 1, "qb": 0, "side": 0, "qr": 0, "qbr": 1, "sider": 1}   # which arm and leg is nearer us, by view: in
# three-quarter, the side turned toward us (away from where they look, so the arm on the side they face starts behind the
# chest: a shoulder over the chest reads as a hunch); in profile, the arm the poses draw (the other one hides)
NEAR_CLASSIC = {"q": 0, "qb": 1, "side": 0, "qr": 1, "qbr": 0, "sider": 1}   # the dojo's: the arm on the side they face

def figure(cx, ground, head_h=110, heads=4.7, belly_r=62, sw_f=0.72, lw=30, view="front",
           skin=SKIN, shirt=LAV, pants=SKY, shoes=YEL, hair=None, garments="volume",
           lean=0.0, head_dx=0.0, head_dy=0.0, arms=None, legs=None, hands=("open", "open"), hand_rot=(0, 0),
           eyes="dot", mouth_kind="smile", uid="f", shadow=True, ground_shadow=False, hip_dx=0.0, labels=False,
           hand_scale=(1.0, 1.0), props=(None, None), accessories=(), ground_shadow_w=2.2,
           human_ears=True, skirt=None, legwear=None, under=None, over=None, mid=None, hand_palm=(1.0, 1.0),
           sleeve_frac=0.55, sleeve_ratio=1.7, skirt_len=0.36, skirt_flare=1.32, cuff_ry=0.3,
           shoe_fn=None, shoes_under=False, torso_fold=True, leg_stack=False, hem_frac=0.86, hem_ry=None,
           garment_tuck=False, collar=None, skirt_fold=True, arm_bow=None, leg_bow=None, shoulders=None,
           limbs=None, arm_z=(None, None), front_arm=None, front_leg=None):
    """a whole on-model character. heads = height in heads. lean = torso lean (fraction, +right).
    arms/legs = ((dx,dy),(dx,dy)) hand/foot targets relative to the shoulder/hip, in units of lw; None = hanging.
    garments: 'volume' (sleeves/trousers 1.7×, cuffs with lips) | 'skin' (same width as limb, no openings) | 'none'.
    skirt: a colour → flared skirt from the waist, seen from a little above: the hem is its near edge and the legs
    come out from under it (classic: a hem opening, ellipse + lip, over the legs' tops); the legs are in
    `legwear` (default skin) instead of trousers. under/over: callables(g) → svg drawn first / last, where g is the
    figure's geometry (head box, shoulders, hips, view) — for parts that live behind or on top of everything
    (bunny ears, tails, custom hair). mid: callable(g) drawn just under the torso — above the skirt and the
    far arm of a ¾ view (a tail that belongs to the body, not behind the arm). props: svg strings, or callables(x, y, rot, fw) placed at the hand.
    shoe_fn: callable(x, y, w, h, flip, fill) drawn instead of shoe(). shoes_under: draw the shoes before the trousers, so
    each hem opening sits over its boot top. torso_fold: False drops the shirt's fold dash (a back view whose hair covers
    it). leg_stack: in profile, put both legs on one line so the far leg hides exactly behind the near one instead of
    peeking out as a sliver of solid ink. hem_frac / hem_ry: where the trouser hems sit along the leg and their opening's
    minor axis (default: cuff_ry) — a higher, flatter hem leaves more of the boot below it. garment_tuck: end sleeves
    and trouser legs inside their openings (see sleeve()). collar=False drops the neck opening: on a small figure the
    head hides its upper half and what shows below the chin (outline + lip) closes up into a lens of solid ink.
    skirt_fold=False drops the skirt's fold dash (when a tail or prop sits where it would start).
    arm_bow / leg_bow: how far each limb bows, as a fraction of its length (one number, or one per limb; None keeps the
    defaults: a hanging arm 0.1, an aimed arm -0.2, a standing leg 0.09, an aimed leg 0.16, mirrored per side). The
    rig's bend control (rig.py).
    shoulders: "low" (the default: each arm leaves the torso at its top corner, below the chin, so it doesn't seem to
    grow from the neck), "classic", or (out, down) in limb widths. collar: None draws the neck opening only where enough
    of it shows below the chin to read as one (True, False force it). limbs: "whole" (the default) draws each leg whole,
    skin, trouser and shoe, the far leg first, so a leg that crosses the other covers it properly; "classic" draws all
    the skin, then all the trousers and shoes. Layering: arm_z per arm "front" (over the torso), "behind" (under it, as
    the far arm of a three-quarter view is), "hidden" (not drawn) or None (the view decides; in profile the far arm
    isn't drawn unless asked for); front_arm / front_leg pick which arm or leg is drawn over the other (None: the near
    one). What was decided is left in the geometry as g["layers"] (arm_z, arm_order, leg_order: drawn first to last)."""
    if shoulders is None: shoulders = "classic" if CLASSIC else "low"
    if limbs is None: limbs = "classic" if CLASSIC else "whole"
    if collar is None and CLASSIC: collar = True
    H = head_h*heads; head_w = head_h/1.2
    legs_len = H*0.48
    hip_y = ground - legs_len
    head_y = ground - H + head_dy
    y0 = head_y + head_h*0.88 - head_dy
    vw = {"front": 1.0, "back": 1.0, "q": 0.93, "qr": 0.93, "qb": 0.93, "qbr": 0.93, "side": 0.78, "sider": 0.78}.get(view, 1.0)
    r = belly_r*vw; y1 = hip_y + r*0.3
    sw = min(2*r*sw_f, 2*r)
    head_w = head_w*vw
    hview = {"qb": "back", "qbr": "back"}.get(view, view)
    dx_sh = lean*(y1 - y0)                     # shoulder shift from lean
    g = dict(cx=cx, ground=ground, view=view, head_x=cx + dx_sh + head_dx - head_w/2, head_y=head_y, head_w=head_w,
             head_h=head_h, y0=y0, y1=y1, r=r, sw=sw, hip_y=hip_y, lw=lw, dx_sh=dx_sh, legs_len=legs_len, uid=uid)
    out = []
    if ground_shadow:
        out.append(part("ground", flat(ellipse_path(cx, ground+6, r*ground_shadow_w, lw*0.55), shade(CREAM, 0.25, 0.08), 0.9, "s")))
    if under:
        out.append(part("under", under(g)))
    # ---- legs ----
    leg_pts = []
    for i, s in enumerate((-1, 1)):
        sx = 1 if (leg_stack and vw <= 0.8) else s       # stacked profile: both legs take the near leg's line
        hx = cx + hip_dx + sx*r*(0.45 if vw > 0.8 else 0.18); hy = y1 - r*0.5
        if legs and legs[i]:
            fx, fy = hx + legs[i][0]*lw, hy + legs[i][1]*lw
        else:
            fx, fy = cx + hip_dx + sx*((r*0.55+lw*0.3) if vw > 0.8 else lw*0.35), ground - lw*0.9
        bow = 0.09*sx if not (legs and legs[i]) else 0.16*sx
        lb = leg_bow[i] if isinstance(leg_bow, (tuple, list)) else leg_bow
        if lb is not None: bow = lb*sx
        leg_pts.append((limb_curve((hx, hy), (fx, fy), bow), s, fx, fy))
        g.setdefault("hip_roots", []).append((hx, hy))
    near = (NEAR_CLASSIC if limbs == "classic" else NEAR).get(view)
    leg_order = [0, 1] if near is None else [1 - near, near]          # far leg first
    if front_leg is not None: leg_order = [1 - front_leg, front_leg]
    if limbs == "classic": leg_order = [0, 1]
    whole = limbs != "classic"
    def draw_shoe(i):
        cur, s, fx, fy = leg_pts[i]
        if view in ("front", "back", "q", "qr"):
            flip = s if view in ("front", "back") else (1 if view == "qr" else -1)
        else:
            flip = 1 if view == "sider" else -1
        out.append(part("shoes", (shoe_fn or shoe)(fx - flip*lw*0.7, fy + lw*0.9, lw*2.4, lw*1.5, flip, shoes)))
    def draw_shoes():
        for i in (0, 1): draw_shoe(i)
    def trouser(i):
        out.append(part("legs", sleeve(leg_pts[i][0], lw, pants, frac=hem_frac, ratio=1.6, cuff_ry=cuff_ry if hem_ry is None else hem_ry, tuck=garment_tuck)))
    if skirt:
        hem_y = (y1 - r*0.5) + (ground - (y1 - r*0.5))*skirt_len
        a, b = r*0.86, r*skirt_flare; ry = b*0.2; y_w = y1 - r*1.1; k = 0.5523
        body = (f"M{cx-a:.1f} {y_w:.1f} C{cx-a-r*0.1:.1f} {y_w+(hem_y-y_w)*0.45:.1f} {cx-b+r*0.04:.1f} {hem_y-(hem_y-y_w)*0.25:.1f} {cx-b:.1f} {hem_y:.1f} "
                f"C{cx-b:.1f} {hem_y+ry*k:.1f} {cx-b*k:.1f} {hem_y+ry:.1f} {cx:.1f} {hem_y+ry:.1f} "
                f"C{cx+b*k:.1f} {hem_y+ry:.1f} {cx+b:.1f} {hem_y+ry*k:.1f} {cx+b:.1f} {hem_y:.1f} "
                f"C{cx+b-r*0.04:.1f} {hem_y-(hem_y-y_w)*0.25:.1f} {cx+a+r*0.1:.1f} {y_w+(hem_y-y_w)*0.45:.1f} {cx+a:.1f} {y_w:.1f} Z")
        g.update(hem_y=hem_y, hem_rx=b)
        if whole:
            # seen from a little above, as the rest of her is: the hem is the skirt's near edge, bowing down, and the
            # legs come out from under it, so they go in first and the skirt over them. (The dojo drew an opening you
            # could see into, with the legs behind it: two points of view at once.)
            y_in = hem_y - lw*0.2                  # each leg starts just inside the hem
            for li in leg_order:
                cur = leg_pts[li][0]
                lo, hi = 0.0, 1.0
                for _ in range(30):
                    m = (lo + hi)/2
                    if bez(*cur, m)[1] < y_in: lo = m
                    else: hi = m
                out.append(part("legs", tube(cpath(split(*cur, lo)[1]), lw, legwear or skin)))
                draw_shoe(li)
        out.append(part("skirt", outline(body, skirt)))
        if shadow:
            out.append(part("skirt", f'<clipPath id="{uid}k"><path d="{body}"/></clipPath><g clip-path="url(#{uid}k)">'
                       + flat(f"M{cx+a*0.3:.1f} {y_w:.1f} C{cx+b*0.62:.1f} {y_w+(hem_y-y_w)*0.4:.1f} {cx+b*0.58:.1f} {hem_y-ry:.1f} {cx+b*0.5:.1f} {hem_y+ry*2:.1f} L{cx+b+30:.1f} {hem_y+ry*2:.1f} L{cx+b+30:.1f} {y_w:.1f} Z", shade(skirt), 1, "s") + "</g>"))
        if view != "back" and skirt_fold:     # the back view keeps at most one fold line (the bodice's)
            out.append(part("skirt", fold((cx-b*0.62, hem_y-(hem_y-y_w)*0.42), (cx-b*0.74, hem_y-ry*1.4), 0.12)))
        for li in ([] if whole else leg_order):    # the dojo's: the legs come out of the hem: start them inside it...
            cur, s, fx, fy = leg_pts[li]
            lo, hi = 0.0, 1.0
            for _ in range(30):
                m = (lo + hi)/2
                if bez(*cur, m)[1] < hem_y - ry + lw*0.5 + LW*0.5 + 2: lo = m   # the round top stays inside the opening
                else: hi = m
            out.append(part("legs", tube(cpath(split(*cur, lo)[1]), lw, legwear or skin)))
        if not whole:                              # ...and lay the opening over their tops, as a cuff sits over a wrist
            out.append(part("skirt", opening(cx, hem_y, b, ry, skirt)))
    if not skirt and whole:                     # each leg whole, far to near: a leg that crosses the other covers it
        for li in leg_order:
            out.append(part("legs", tube(cpath(leg_pts[li][0]), lw, pants if garments == "skin" else skin)))
            if shoes_under: draw_shoe(li)
            if garments == "volume": trouser(li)
            if not shoes_under: draw_shoe(li)
    elif not skirt:
        for cur, s, fx, fy in leg_pts:
            out.append(part("legs", tube(cpath(cur), lw, pants if garments == "skin" else skin)))
        if shoes_under: draw_shoes()
        if garments == "volume":
            for i in (0, 1): trouser(i)
    if (skirt and not whole) or (not skirt and not whole and not shoes_under): draw_shoes()
    if mid:
        out.append(part("mid", mid(g)))
    # ---- torso ----
    bp = bean_path(cx, y0, y1, r, sw, lean)
    out.append(part("torso", outline(bp, shirt if garments != "none" else skin)))
    if shadow:
        out.append(part("torso", f'<clipPath id="{uid}t"><path d="{bp}"/></clipPath><g clip-path="url(#{uid}t)">'
                   + flat(f"M{cx+r*0.3:.1f} {y0:.1f} C{cx+r*1.2:.1f} {y0+(y1-y0)*0.3:.1f} {cx+r*1.1:.1f} {y1-r*0.4:.1f} {cx:.1f} {y1+4:.1f} L{cx+r+20:.1f} {y1+4:.1f} L{cx+r+20:.1f} {y0-20:.1f} Z", shade(shirt if garments != "none" else skin), 1, "s") + "</g>"))
    if garments == "volume":
        # collar: ellipse with a lip at the shoulders, and one hem lip at the bean bottom
        out.append(part("torso", line(f"M{cx-r*0.55:.1f} {y1-r*0.12:.1f} C{cx-r*0.2:.1f} {y1+r*0.02:.1f} {cx+r*0.2:.1f} {y1+r*0.02:.1f} {cx+r*0.55:.1f} {y1-r*0.12:.1f}")))
        if torso_fold: out.append(part("torso", fold((cx-r*0.5+dx_sh*0.5, y0+(y1-y0)*0.45), (cx-r*0.2, y1-r*0.6), 0.1)))
    # ---- arms ----
    arm_len = (y1 + legs_len*0.22) - (y0 + sw*0.1)
    arm_pts = []
    out_, down_ = SHOULDERS.get(shoulders, shoulders) if not isinstance(shoulders, (tuple, list)) else shoulders
    for i, s in enumerate((-1, 1)):
        if shoulders == "classic":                 # the dojo's roots: tucked under the chin
            ax = cx + dx_sh + s*(sw/2 - lw*0.3); ay = y0 + sw*0.12
        elif view in ("side", "sider"):            # in profile the shoulder sits mid-torso, a little toward the back
            ax = cx + dx_sh + (1 if view == "side" else -1)*sw*0.06; ay = y0 + sw*0.14 + down_*lw
        else:                                      # out from the top corner of the bean, down below the chin; the near
            # shoulder of a three-quarter view sits further out, so its sleeve caps the torso's edge, not the chest
            o = out_ + (0.3 if shoulders == "low" and view in ("q", "qr", "qb", "qbr") and i == near else 0.0)
            ax = cx + dx_sh + s*(sw/2 + o*lw); ay = y0 + sw*0.14 + down_*lw
        if arms and arms[i]:
            hx, hy = ax + arms[i][0]*lw, ay + arms[i][1]*lw
            bow = -0.2*s
        else:
            hx, hy = cx + s*(r + lw*0.7), ay + arm_len
            bow = 0.1*s
        ab = arm_bow[i] if isinstance(arm_bow, (tuple, list)) else arm_bow
        if ab is not None: bow = ab*s
        arm_pts.append((limb_curve((ax, ay), (hx, hy), bow), s, hx, hy))
        g.setdefault("arm_roots", []).append((ax, ay))
    def draw_arm(i):
        cur, s, hx, hy = arm_pts[i]
        o = [tube(cpath(cur), lw, skin)]
        if garments == "skin": o.append(tube_fill(cpath(split(*cur, 0.55)[0]), lw, shirt))
        if garments == "volume": o.append(sleeve(cur, lw, shirt, frac=sleeve_frac, ratio=sleeve_ratio, cuff_ry=cuff_ry, tuck=garment_tuck))
        tx, ty = bez_tan(*cur, 1.0)
        rot = math.degrees(math.atan2(-ty, tx)) - 90   # fingers continue along the limb
        px, py = hx + tx*lw*0.12, hy + ty*lw*0.12
        pr = props[i](px, py, rot + hand_rot[i], lw*0.38*hand_scale[i]) if callable(props[i]) else props[i]
        pa, join = hand_palm[i], ""
        if whole:              # the palm at least as wide as the wrist, and the arm's fill run over the palm's outline:
            # one silhouette from shoulder to fingertips (a narrower palm left the arm's round end showing past it)
            pa = max(pa, 0.5/(0.38*1.35*hand_scale[i]))
            L = math.hypot(cur[3][0] - cur[0][0], cur[3][1] - cur[0][1]) or 1
            t0 = max(1 - 1.1*lw/L, sleeve_frac + 0.12 if garments == "volume" else 0.5)   # the wrist, clear of the cuff
            if t0 < 0.97: join = tube_fill(cpath(split(*cur, t0)[1]), lw, skin)
        o.append(hand(px, py, hands[i], fw=lw*0.38, rot=rot + hand_rot[i], fill=skin, wrist=False, scale=hand_scale[i], prop=pr, palm=pa, join=join))
        return "\n".join(o)
    zs = []
    for i in (0, 1):
        z = arm_z[i] if isinstance(arm_z, (tuple, list)) else arm_z
        beside = arm_pts[i][1]*(arm_pts[i][2] - (cx + dx_sh)) > r*0.8      # the hand out past its own side of the body
        if z is None and near is None:   # front and back: an arm beside the body is tucked behind its edge (so the
            # shoulder doesn't show as a cap on the chest); one reaching in front of it or across it comes in front
            z = "front" if not whole or not beside else "behind"
        elif z is None:        # three-quarter: the far arm goes behind (unless it reaches across the chest), the near one
            # in front; in profile the far one hides
            if i == near: z = "front"
            elif view in ("q", "qr", "qb", "qbr"): z = "front" if whole and not beside else "behind"
        zs.append(z)
    arm_order = [0, 1] if near is None else [1 - near, near]
    if front_arm is not None: arm_order = [1 - front_arm, front_arm]
    torso_idx = len(out) - (3 if garments == "volume" else 1) - (1 if shadow else 0) - (1 if mid else 0)
    g["layers"] = dict(arm_z=list(zs), arm_order=list(arm_order), leg_order=list(leg_order))   # as drawn: for editors
    for i in arm_order:        # behind: under the torso (and under mid, the tail); front: over it; "hidden": not drawn
        if zs[i] == "behind":
            out.insert(torso_idx, part("arm-far", draw_arm(i))); torso_idx += 1
    for i in arm_order:
        if zs[i] == "front":
            out.append(part("arm" if near is None else ("arm-near" if i == near else "arm-far"), draw_arm(i)))
    if collar is None:         # only where enough of the opening shows below the chin to read as one
        collar = (y0 + sw*0.29) - (head_y + head_h) >= 3*LW
    if garments == "volume" and collar:
        out.append(part("collar", opening(cx + dx_sh, y0 + sw*0.16, head_w*0.66, sw*0.13, shirt)))
    # ---- head ----
    hx0 = cx + dx_sh + head_dx - head_w/2
    out.append(part("head", head(hx0, head_y, head_w, head_h, skin, hview, eyes, mouth_kind, shadow, hair, uid + "h", human_ears)))
    for acc in accessories:
        out.append(part("accessory", accessory(acc, hx0, head_y, head_w, head_h, cx + dx_sh, y0, sw, hview)))
    if over:
        out.append(part("over", over(g)))
    return f'<g data-figure="{uid}">' + "\n".join(out) + "</g>" if TAGS else "\n".join(out)

def accessory(kind, hx, hy, hw, hh, scx, sy0, sw, view):
    """small identity props: glasses | scarf | bow | badge"""
    ey = hy + hh/3
    if kind == "glasses" and view in ("front", "q", "qr"):
        g = hw*0.36; r = hw*0.115
        if view == "front": xs = (hx+hw/2-g/2, hx+hw/2+g/2)
        else: xs = (hx+hw*0.6, hx+hw*0.2) if view == "q" else (hx+hw*0.4, hx+hw*0.8)
        return line(ellipse_path(xs[0], ey, r, r*0.85)) + line(ellipse_path(xs[1], ey, r if view == "front" else r*0.7, r*0.85)) + line(f"M{min(xs)+r*0.9:.1f} {ey:.1f} L{max(xs)-r*0.9:.1f} {ey:.1f}")
    if kind == "scarf":
        return outline(ellipse_path(scx, sy0 + sw*0.2, sw*0.46, sw*0.14), PINK) + outline(f"M{scx+hw*0.2:.1f} {sy0+sw*0.26:.1f} C{scx+hw*0.5:.1f} {sy0+sw*0.6:.1f} {scx+hw*0.5:.1f} {sy0+sw*0.9:.1f} {scx+hw*0.3:.1f} {sy0+sw*1.1:.1f} C{scx+hw*0.15:.1f} {sy0+sw*0.9:.1f} {scx+hw*0.1:.1f} {sy0+sw*0.6:.1f} {scx+hw*0.2:.1f} {sy0+sw*0.26:.1f} Z", PINK)
    if kind == "bow":
        bx, by = hx + hw*0.78, hy - hh*0.02
        return outline(f"M{bx:.1f} {by:.1f} C{bx-hw*0.3:.1f} {by-hh*0.18:.1f} {bx-hw*0.3:.1f} {by+hh*0.1:.1f} {bx:.1f} {by:.1f} C{bx+hw*0.3:.1f} {by-hh*0.18:.1f} {bx+hw*0.3:.1f} {by+hh*0.1:.1f} {bx:.1f} {by:.1f} Z", YEL) + blob(bx, by, hw*0.06, hw*0.06, YEL)
    if kind == "badge":
        return blob(scx - sw*0.22, sy0 + sw*0.55, sw*0.1, sw*0.1, YEL)
    return ""
