600 lines
44 KiB
Python
600 lines
44 KiB
Python
#!/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 ""
|