"""Builds the 3D mascots' shared body and every voice's assets in Blender, rigs and animates them, and exports them as glTF.

Run inside Blender (5.2): blender -b src/web/public/voices/models/butler.blend --python scripts/mascots/butler.py. Units are
metres, 1 m to 100 px of the 256 px cell the 2D rigs use; the feet stand on z = 0, the face looks down -Y, the character's
left is +X. The proportions are measured off the drawn rest poses in toon-preview/rest, 2 px to the cell pixel.
Every part is a separate mesh parented rigidly to a bone, so the exporter writes bones as plain nodes and the viewer hangs
the meshes on them; a voice's assets are exported with the bone tree so they keep their place.
"""
import contextlib
import io
import json
import math
import os
import re

import bmesh
import bpy
from mathutils import Matrix, Vector

OUT = os.path.join(os.path.dirname(bpy.data.filepath) or os.getcwd(), "")
PAINT = {
    "skin": 0xF8F0E3, "ink": 0x262533, "trouser": 0x2C2B38, "lens": 0x10101C, "navy": 0x282959, "silver": 0xDCD6D8,
    "blue": 0x5155FF, "indigo": 0x3431B6, "lavender": 0x9B9AFC, "grey": 0xCAC0C5, "lapel": 0x444150, "satin": 0x5E5B6E, "screen": 0x343879, "slate": 0x5B5E80, "lining": 0x2A2670,
    "cream": 0xFCF2E5, "jacket": 0x2C2B38, "hoodie": 0x24232E, "joint": 0x2D2C39, "white": 0xFFFFFF, "underplate": 0x4E4D60, "whistle": 0x5A5CF0,
    "line": 0x0E0E18, "fold": 0xB4ADCB,
}
FPS = 24
FACES = ["rest", "open", "happy", "sad", "angry", "scared", "surprised", "thinking", "sleepy", "wink"]
SIDES = (("left", 1), ("right", -1))

# The shared body, as the drawn butler stands: a squat oval head over a short torso, long arms, thick legs and big shoes.
# The shared head, one for every voice, its shape traced off the drawn colleague, the one voice whose whole head shows: a sphere of
# radius HEAD_R scaled across, through and up (toonBody.json, which faces.py reads too).
DOMAIN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "src", "web", "src", "domain")
BODY = json.load(open(os.path.join(DOMAIN, "toonBody.json")))
HEAD, TORSO = BODY["head"], BODY["torso"]
# The places on the shared body a piece hangs on, with the bones each may use, and every piece's own declaration: its slot, whether
# a torso piece brings sleeves, and the slots it hides (toonSlots.json and toonAssets.json, which the viewer reads too).
SLOTS = json.load(open(os.path.join(DOMAIN, "toonSlots.json")))
CATALOGUE = json.load(open(os.path.join(DOMAIN, "toonAssets.json")))


# A JSON object whose keys are unique: a wardrobe naming one slot twice is refused here instead of keeping the last piece unseen.
def unique(pairs):
    seen = {}
    for key, value in pairs:
        if key in seen:
            raise UnfitAsset.twice(key, seen[key], value)
        seen[key] = value
    return seen
HEAD_AT, HEAD_R, HEAD_SCALE = Vector(HEAD["centre"]), HEAD["radius"], tuple(HEAD["scale"])
CHIN, WAIST, HIP = 1.43, 0.93, 0.92
SHOULDER, ELBOW, WRIST, HAND = 1.30, 0.90, 0.60, 0.47
ARM_X, LEG_X, KNEE, ANKLE = 0.31, 0.17, 0.60, 0.26


def clear():
    for collection in (bpy.data.objects, bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.actions):
        for item in list(collection):
            collection.remove(item)


# A shader input takes linear light, so a colour named in sRGB hex is converted on its way in.
def linear(channel):
    return channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4


def material(name):
    found = bpy.data.materials.get(name)
    if found:
        return found
    made = bpy.data.materials.new(name)
    made.use_nodes = True
    bsdf = next(node for node in made.node_tree.nodes if node.type == "BSDF_PRINCIPLED")
    rgb = [linear(((PAINT[name] >> shift) & 255) / 255) for shift in (16, 8, 0)]
    bsdf.inputs["Base Color"].default_value = (*rgb, 1)
    bsdf.inputs["Roughness"].default_value = 0.9
    return made


def settle(made, name, paint, rotation=(0, 0, 0), scale=(1, 1, 1)):
    made.name = name
    made.data.name = name
    made.rotation_euler = [math.radians(degrees) for degrees in rotation]
    made.scale = scale
    made.data.materials.clear()
    made.data.materials.append(material(paint))
    for polygon in made.data.polygons:
        polygon.use_smooth = True
    bpy.ops.object.select_all(action="DESELECT")
    made.select_set(True)
    bpy.context.view_layer.objects.active = made
    bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
    return made


def bevelled(made, width, segments=4, limit="ANGLE"):
    modifier = made.modifiers.new("bevel", "BEVEL")
    modifier.width, modifier.segments, modifier.limit_method = width, segments, limit
    return made


def ball(name, radius, at, paint, scale=(1, 1, 1), rotation=(0, 0, 0)):
    bpy.ops.mesh.primitive_uv_sphere_add(radius=radius, location=at, segments=40, ring_count=24)
    return settle(bpy.context.object, name, paint, rotation, scale)


# Squares a round shape across: each level of it, an ellipse seen from above, pushed out to a superellipse of the head's power
# (toonBody.json, square), so the face is flatter in front and the sides turn more sharply, as the drawn heads are.
def squared(made, power=HEAD["square"]):
    corners = [Vector(corner) for corner in made.bound_box]
    a, b = (max(abs(corner[k]) for corner in corners) for k in (0, 1))
    for vertex in made.data.vertices:
        x, y = vertex.co.x / a, vertex.co.y / b
        rho = math.hypot(x, y)
        if rho < 1e-6:
            continue
        stretch = ((abs(x) / rho) ** power + (abs(y) / rho) ** power) ** (-1 / power)
        vertex.co.x, vertex.co.y = vertex.co.x * stretch, vertex.co.y * stretch
    return made


def rounded_box(name, size, at, paint, bevel=0.04, rotation=(0, 0, 0), segments=5):
    bpy.ops.mesh.primitive_cube_add(size=1, location=at)
    return bevelled(settle(bpy.context.object, name, paint, rotation, size), bevel, segments, "NONE")


def tube(name, radius, depth, at, paint, rotation=(0, 0, 0), scale=(1, 1, 1)):
    bpy.ops.mesh.primitive_cylinder_add(radius=radius, depth=depth, location=at, vertices=48)
    return bevelled(settle(bpy.context.object, name, paint, rotation, scale), min(radius, depth) * 0.25)


def ring(name, major, minor, at, paint, rotation=(0, 0, 0), scale=(1, 1, 1)):
    bpy.ops.mesh.primitive_torus_add(major_radius=major, minor_radius=minor, location=at, major_segments=48, minor_segments=16)
    return settle(bpy.context.object, name, paint, rotation, scale)


def from_mesh(name, mesh, paint):
    data = bpy.data.meshes.new(name)
    mesh.to_mesh(data)
    mesh.free()
    made = bpy.data.objects.new(name, data)
    bpy.context.collection.objects.link(made)
    return settle(made, name, paint)


# A limb from one joint to the next: a sphere stretched along the line between them, rounded at both ends, squashed across.
def capsule(name, radius, start, end, paint, squash=(1, 1)):
    start, end = Vector(start), Vector(end)
    axis = end - start
    mesh = bmesh.new()
    bmesh.ops.create_uvsphere(mesh, u_segments=32, v_segments=15, radius=radius)
    for vertex in mesh.verts:
        vertex.co.x *= squash[0]
        vertex.co.y *= squash[1]
        vertex.co.z += math.copysign(axis.length / 2, vertex.co.z)
    turn = axis.to_track_quat("Z", "Y").to_matrix().to_4x4()
    bmesh.ops.transform(mesh, matrix=Matrix.Translation((start + end) / 2) @ turn, verts=mesh.verts)
    return from_mesh(name, mesh, paint)


# A tube swept along a line of points, its radius at each point given, flattened across by flat: a plume, a tail, a strap.
def sweep(name, points, radii, paint, flat=1.0, up=(0, 0, 1), sides=20):
    points = [Vector(point) for point in points]
    mesh = bmesh.new()
    rings = []
    for index, (point, radius) in enumerate(zip(points, radii)):
        tangent = (points[min(index + 1, len(points) - 1)] - points[max(index - 1, 0)]).normalized()
        across = tangent.cross(Vector(up)).normalized()
        along = across.cross(tangent)
        rings.append([mesh.verts.new(point + radius * (math.cos(a) * across * flat + math.sin(a) * along)) for a in (2 * math.pi * k / sides for k in range(sides))])
    for first, second in zip(rings, rings[1:]):
        for k in range(sides):
            mesh.faces.new((first[k], first[(k + 1) % sides], second[(k + 1) % sides], second[k]))
    for cap, point, order in ((rings[0], points[0], -1), (rings[-1], points[-1], 1)):
        middle = mesh.verts.new(point)
        for k in range(sides):
            mesh.faces.new((cap[k], cap[(k + 1) % sides], middle)[::order])
    bmesh.ops.recalc_face_normals(mesh, faces=mesh.faces)
    return from_mesh(name, mesh, paint)


# A flat shape cut from an outline of (x, z) points facing -Y, depth thick, its edges rounded: a shield, a star, a feather.
def slab(name, outline, depth, at, paint, rotation=(0, 0, 0), bevel=0.0):
    mesh = bmesh.new()
    front = [mesh.verts.new((x, -depth / 2, z)) for x, z in outline]
    back = [mesh.verts.new((x, depth / 2, z)) for x, z in outline]
    mesh.faces.new(front[::-1])
    mesh.faces.new(back)
    for k in range(len(outline)):
        mesh.faces.new((front[k], front[(k + 1) % len(outline)], back[(k + 1) % len(outline)], back[k]))
    bmesh.ops.recalc_face_normals(mesh, faces=mesh.faces)
    made = from_mesh(name, mesh, paint)
    made.rotation_euler = [math.radians(degrees) for degrees in rotation]
    made.location = at
    bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
    if bevel:
        bevelled(made, bevel, 3)
    for polygon in made.data.polygons:
        polygon.use_smooth = False
    return made


# A dome: an ellipsoid cut by a plane tipped forward by tilt degrees, height above its centre, the part above it kept.
def dome(name, radii, at, paint, height=0.0, tilt=0.0):
    made = ball(name, 1, (0, 0, 0), paint, scale=radii)
    mesh = bmesh.new()
    mesh.from_mesh(made.data)
    normal = Vector((0, math.sin(math.radians(tilt)), math.cos(math.radians(tilt))))
    bmesh.ops.bisect_plane(mesh, geom=mesh.verts[:] + mesh.edges[:] + mesh.faces[:], plane_co=normal * height, plane_no=normal, clear_inner=True)
    mesh.to_mesh(made.data)
    mesh.free()
    made.location = at
    return made


# Marks a piece as loose cloth for the viewer, which swings it on a spring: the point it hangs from, its free end at rest, and how
# strongly gravity holds it (1 hangs straight down, 0 keeps the carved shape). Both points default to the middle of its top and bottom.
def hanging(made, gravity, pivot=None, tip=None):
    corners = [made.matrix_world @ Vector(corner) for corner in made.bound_box]
    middle = sum(corners, Vector()) / len(corners)
    pivot = Vector(pivot) if pivot else Vector((middle.x, middle.y, max(corner.z for corner in corners)))
    tip = Vector(tip) if tip else Vector((middle.x, middle.y, min(corner.z for corner in corners)))
    made["cloth"] = gravity
    made["pivot"] = list(pivot - made.location)
    made["tip"] = list(tip - made.location)
    return made


# A hanging panel of cloth: a grid from the middle of its top edge down, width_top across at the top and width_bottom at the
# hem, its hem moved sideways by sway and back by lean, rippled into folds that deepen toward a wavy hem, and given thickness.
def drape(name, top, width_top, width_bottom, length, paint, ripples=2.0, depth=0.035, sway=0.0, lean=0.0, hem=0.04, point=0.0, thickness=0.025, columns=14, rows=12):
    mesh = bmesh.new()
    grid = []
    for j in range(rows + 1):
        v = j / rows
        width = width_top + (width_bottom - width_top) * v
        grid.append([
            mesh.verts.new(Vector(top) + Vector((
                (i / columns - 0.5) * width + sway * v,
                depth * math.sin(2 * math.pi * ripples * i / columns) * v ** 0.8 + lean * v,
                -length * v - hem * math.sin(3 * math.pi * i / columns) ** 2 * v ** 3 - point * (1 - abs(2 * i / columns - 1)) * v ** 2,
            )))
            for i in range(columns + 1)
        ])
    for upper, lower in zip(grid, grid[1:]):
        for i in range(columns):
            mesh.faces.new((upper[i], upper[i + 1], lower[i + 1], lower[i]))
    made = from_mesh(name, mesh, paint)
    made.location = Vector(top)
    made.data.transform(Matrix.Translation(-Vector(top)))
    made.modifiers.new("solidify", "SOLIDIFY").thickness = thickness
    return made


# A smooth curve through points (a Catmull-Rom spline), samples points long, for a shape drawn with a flowing line.
def through(points, samples):
    points = [Vector(point) for point in points]
    padded = [points[0]] + points + [points[-1]]
    out = []
    for k in range(samples):
        t = k / (samples - 1) * (len(points) - 1)
        i = min(int(t), len(points) - 2)
        u = t - i
        p0, p1, p2, p3 = padded[i], padded[i + 1], padded[i + 2], padded[i + 3]
        out.append(0.5 * (2 * p1 + (p2 - p0) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u * u + (3 * p1 - p0 - 3 * p2 + p3) * u * u * u))
    return out


def rounded_outline(width, height, corner, steps=6):
    corners = ((width / 2 - corner, height / 2 - corner, 0), (-width / 2 + corner, height / 2 - corner, 90), (-width / 2 + corner, -height / 2 + corner, 180), (width / 2 - corner, -height / 2 + corner, 270))
    return [(x + corner * math.cos(math.radians(start + 90 * k / steps)), z + corner * math.sin(math.radians(start + 90 * k / steps))) for x, z, start in corners for k in range(steps + 1)]


# A heater shield's outline: a gently arched top, sides that fall straight and then curve in to a point at the bottom.
def heater_outline(width, height, steps=14):
    half, top = width / 2, height * 0.45
    right = [(half * math.cos((k / steps) ** 1.7 * math.pi / 2), top - height * k / steps) for k in range(steps + 1)]
    left = [(-x, z) for x, z in reversed(right)][1:]
    arch = [(-half + width * k / steps, top + height * 0.05 * math.sin(math.pi * k / steps)) for k in range(1, steps)]
    return right + left + arch


# A quill's feather as drawn: a pointed leaf with notches cut into its edges, and the lavender vane on one side of its spine.
def quill_outlines(length=0.95, width=0.24, steps=24):
    notch = lambda t: 0.05 * max(0.0, 1 - abs((t * 4) % 1 - 0.5) * 6) if 0.25 < t < 0.85 else 0.0
    edge = [(t, width * math.sin(math.pi * t) ** 0.7 * (1 - 0.25 * t)) for t in (k / steps for k in range(steps + 1))]
    right = [(w - notch(t), t * length) for t, w in edge]
    left = [(-0.8 * w + notch(t + 0.12), t * length) for t, w in reversed(edge)][1:-1]
    vane = [(0.0, 0.04)] + [(0.85 * (w - notch(t)), t * length) for t, w in edge if 0.05 < t < 0.92] + [(0.0, 0.9 * length)]
    return right + left, vane


def star_outline(size, waist=0.16):
    outer, inner = size / 2, size * waist
    return [(math.cos(math.radians(k * 45 + 90)) * (outer if k % 2 == 0 else inner), math.sin(math.radians(k * 45 + 90)) * (outer if k % 2 == 0 else inner)) for k in range(8)]


def tick_outline(size):
    return [(-0.5 * size, 0.05 * size), (-0.32 * size, 0.22 * size), (-0.12 * size, 0.0), (0.34 * size, 0.42 * size), (0.5 * size, 0.26 * size), (-0.12 * size, -0.34 * size)]


def hide(cutter):
    cutter.hide_set(True)
    cutter.hide_render = True
    return cutter


def carve(made, cutter, operation="DIFFERENCE"):
    modifier = made.modifiers.new("carve", "BOOLEAN")
    modifier.operation, modifier.object = operation, cutter
    hide(cutter)
    return made


# Where a point on the head's surface lies, seen straight from the front at (x, z), pushed out by proud, and the turn about Z that faces out there.
# The head by default, or another dome round it, such as the squire's helmet, given by its centre and radii.
def on_head(x, z, proud=0.012, centre=HEAD_AT, radii=None):
    local = Vector((x, 0, z)) - centre
    radii = radii or [HEAD_R * scale for scale in HEAD_SCALE]
    level = math.sqrt(max(1 - (local.z / radii[2]) ** 2, 0))
    y = -level * max(1 - (abs(local.x) / (radii[0] * level or 1)) ** HEAD["square"], 0) ** (1 / HEAD["square"]) * radii[1]
    return centre + Vector((local.x, y - proud, local.z)), (0, 0, math.degrees(math.atan2(-local.x, -y)))


# The head's half-width at a height, for a part that sits on its side.
def head_side(z):
    return HEAD_R * HEAD_SCALE[0] * math.sqrt(max(1 - ((z - HEAD_AT.z) / (HEAD_R * HEAD_SCALE[2])) ** 2, 0))


# The shared head's one visor (toonBody.json, as faces.py draws it): its width and height, and how far its middle sits below the
# head's middle.
def screen():
    width, height = HEAD["visor"]["width"], HEAD["visor"]["height"]
    return width, height, -0.045 * height


# An ink line drawn on the cloth, as the drawings draw folds and seams: a thin stroke along a smooth curve through the points,
# drawn flat in its colour with no outline, full in its middle and tapering to its ends; a closed line, such as a rim, keeps one weight.
def line(name, points, bone, paint="line", radius=0.01, closed=False):
    path = points if closed else through(points, max(8, 3 * len(points)))
    weights = [1.0] * len(path) if closed else [0.3 + 0.7 * math.sin(math.pi * k / (len(path) - 1)) for k in range(len(path))]
    made = sweep(name, path, [radius * weight for weight in weights], paint, sides=8)
    made["ink"] = 1
    return made.name, bone


# Marks a small part, such as a button, to go without the outline, which would swallow it.
def bare(made):
    made["thin"] = 1
    return made


# Points across the front of a limb that stands upright through centre: an arc radius from its axis, span degrees wide,
# its ends lifted by droop (a negative droop lowers them).
def front_arc(centre, radius, span, droop=0.0, steps=8):
    half = math.radians(span) / 2
    return [Vector(centre) + Vector((radius * math.sin(a), -radius * math.cos(a), droop * (a / half) ** 2)) for a in (-half + 2 * half * k / steps for k in range(steps + 1))]


def joints(side):
    x = side * ARM_X
    return {"shoulder": (x, 0, SHOULDER), "elbow": (x, 0, ELBOW), "wrist": (x, 0, WRIST), "hand": (x, 0, HAND)}


def leg_joints(side):
    x = side * LEG_X
    return {"hip": (x, 0, HIP), "knee": (x, 0, KNEE), "ankle": (x, 0, ANKLE)}


# The shared body bare: skin all over, its feet plain and smaller than any shoe, its head without a face; trousers, shoes and the
# face are pieces a voice wears.
def build_body():
    out = [(squared(ball("head", HEAD_R, HEAD_AT, "skin", scale=HEAD_SCALE)).name, "head")] + ears("ear")
    out.append((tube("neck", 0.09, 0.14, (0, 0, CHIN - 0.02), "joint").name, "torso"))
    # The torso as the drawn chibi bodies: an egg, round at the belly, narrowing to the chest and shoulders, wider than it is deep.
    # Its profile, a radius through at each height, is in toonBody.json (torso), where the viewer's joint limits read it too.
    rise = through([Vector((r, 0, z)) for z, r in TORSO["rise"]], 24)
    out.append((sweep("torso", [(0, 0, p.z) for p in rise], [p.x for p in rise], "skin", flat=TORSO["flat"], up=(0, 1, 0), sides=28).name, "torso"))
    out.append((ball("hips", 0.33, (0, 0, HIP + 0.02), "skin", scale=(1, 0.7, 0.35)).name, "hips"))
    for side, x in SIDES:
        arm, leg = joints(x), leg_joints(x)
        out += [
            (capsule(f"upper_arm_{side}", 0.085, arm["shoulder"], arm["elbow"], "skin").name, f"upper_arm_{side}"),
            (capsule(f"forearm_{side}", 0.08, arm["elbow"], arm["wrist"], "skin").name, f"forearm_{side}"),
            (capsule(f"thigh_{side}", 0.10, leg["hip"], leg["knee"], "skin").name, f"thigh_{side}"),
            (capsule(f"shin_{side}", 0.095, leg["knee"], (x * LEG_X, 0, 0.18), "skin").name, f"shin_{side}"),
            # A foot is a rounded half egg, flat on the floor, longer to the toe than to the heel.
            (dome(f"foot_{side}", (0.13, 0.21, 0.14), (x * LEG_X, -0.07, 0.0), "skin").name, f"foot_{side}"),
        ]
    return out


# Plain trousers over the body's legs and hips, as the drawn butler, colleague and homie wear them.
def trousers(prefix, paint="trouser", thigh=0.156, shin=0.146):
    out = [(ball(f"{prefix}_seat", 0.336, (0, 0, HIP + 0.02), paint, scale=(1, 0.7, 0.35)).name, "hips")]
    for side, x in SIDES:
        leg = leg_joints(x)
        out += [
            (capsule(f"{prefix}_thigh_{side}", thigh, leg["hip"], leg["knee"], paint).name, f"thigh_{side}"),
            (capsule(f"{prefix}_shin_{side}", shin, leg["knee"], (x * LEG_X, 0, 0.18), paint).name, f"shin_{side}"),
        ]
    return out


def placed(parts, matrix):
    for name, bone in parts:
        made = bpy.data.objects[name]
        made.matrix_world = matrix @ made.matrix_world
    return parts


# The base head's ears as drawn: a broad cup centred on each side of the head, level with the middle of the visor and clear of it,
# a lavender disc in its outer face.
def ears(prefix, z=HEAD_AT.z - 0.045 * HEAD["visor"]["height"], radius=0.15):
    out = []
    for side, x in SIDES:
        at = x * (head_side(z) + 0.05)
        out += [
            (tube(f"{prefix}_{side}", radius, 0.10, (at, 0, z), "skin", rotation=(0, 90, 0)).name, "head"),
            (ring(f"{prefix}_{side}_rim", radius * 0.72, 0.016, (at + x * 0.05, 0, z), "lapel", rotation=(0, 90, 0)).name, "head"),
            (tube(f"{prefix}_{side}_disc", radius * 0.62, 0.02, (at + x * 0.05, 0, z), "lavender", rotation=(0, 90, 0)).name, "head"),
        ]
    return out


# Splits an outfit into its pieces, each part going to the first named slot whose bones it hangs on:
# outfit(parts, torso="butler_coat", arms="butler_sleeves", feet="butler_shoes").
def outfit(parts, **pieces):
    out = {asset: [] for asset in pieces.values()}
    for name, bone in parts:
        try:
            slot = next(slot for slot in pieces if bone in SLOTS[slot]["bones"])
        except StopIteration as missing:
            raise UnfitAsset.astray(name, {bone}) from missing
        out[pieces[slot]].append((name, bone))
    return out


class UnfitAsset(Exception):
    @classmethod
    def undeclared(cls, asset, missing):
        return cls(f"{asset} does not declare {', '.join(sorted(missing))} in toonAssets.json")

    @classmethod
    def twice(cls, slot, first, second):
        return cls(f"the {slot} slot is filled twice, by {first} and {second}")

    @classmethod
    def misworn(cls, voice, slot, asset):
        return cls(f"{voice} wears {asset} on {slot}, a slot it is not declared for")

    @classmethod
    def unbuilt(cls, voice, asset):
        return cls(f"{voice} wears {asset}, which the build does not make")

    @classmethod
    def mislabelled(cls, asset, cloth):
        return cls(f"{asset} declares cloth {not cloth} but {'has' if cloth else 'has no'} part on the cloth spring")

    @classmethod
    def sleeveless(cls, asset):
        return cls(f"{asset} is a torso piece that does not say whether it brings sleeves")

    @classmethod
    def overreaching(cls, asset, parts):
        return cls(f"{asset} covers {', '.join(sorted(parts))}, which the body has no part of on its slot's bones")

    @classmethod
    def astray(cls, asset, bones):
        return cls(f"{asset} hangs on {', '.join(sorted(bones))}, which its slot does not give it")


# Every piece declares the slot it fills, the voices it is drawn for, whether it needs the cloth spring and the slots it hides, and a
# torso piece whether it brings sleeves.
DECLARED = {"slot", "drawn_for", "cloth", "hides", "covers"}


# Refuses a piece the catalogue does not declare in full, a torso piece that does not say whether it brings sleeves, a piece whose
# cloth claim its parts belie, and a part on a bone its slot does not give it, so a coat cannot carry shoes nor a helmet headphones;
# and a wardrobe that wears a piece on a slot it is not declared for, or one the build does not make.
def check(assets):
    for asset, parts in assets.items():
        declared = CATALOGUE.get(asset, {})
        if DECLARED - declared.keys():
            raise UnfitAsset.undeclared(asset, DECLARED - declared.keys())
        if declared["slot"] == "torso" and "sleeves" not in declared:
            raise UnfitAsset.sleeveless(asset)
        cloth = any("cloth" in bpy.data.objects[name] for name, _ in parts)
        if cloth != declared["cloth"]:
            raise UnfitAsset.mislabelled(asset, cloth)
        allowed = set(SLOTS[declared["slot"]]["bones"]) | (set(SLOTS["arms"]["bones"]) if declared.get("sleeves") else set())
        stray = {bone for _, bone in parts} - allowed
        if stray:
            raise UnfitAsset.astray(asset, stray)
        # A piece stands in for the body's own parts under it, named without their side, only those on its slot's bones.
        under = {re.sub(r"_(left|right)$", "", name) for name, bone in ASSETS["body"] if bone in SLOTS[declared["slot"]]["bones"]}
        if set(declared["covers"]) - under:
            raise UnfitAsset.overreaching(asset, set(declared["covers"]) - under)
    for voice, wears in WARDROBES.items():
        for slot, asset in wears.items():
            if asset not in assets:
                raise UnfitAsset.unbuilt(voice, asset)
            if CATALOGUE[asset]["slot"] != slot:
                raise UnfitAsset.misworn(voice, slot, asset)


# Sleeves over both arms, each with the folds a bent elbow draws in the cloth, in ink on dark cloth and in grey on light.
def sleeves(prefix, paint, upper=0.12, lower=0.11, folds="line"):
    out = []
    for side, x in SIDES:
        arm = joints(x)
        out += [
            (capsule(f"{prefix}_upper_{side}", upper, arm["shoulder"], arm["elbow"], paint).name, f"upper_arm_{side}"),
            (capsule(f"{prefix}_lower_{side}", lower, arm["elbow"], (x * ARM_X, 0, WRIST + 0.04), paint).name, f"forearm_{side}"),
            line(f"{prefix}_fold_upper_{side}", front_arc((x * ARM_X, 0, ELBOW + 0.07), upper + 0.003, 90, droop=-0.03), f"upper_arm_{side}", folds),
            line(f"{prefix}_fold_lower_{side}", front_arc((x * ARM_X, 0, ELBOW - 0.05), lower + 0.003, 80, droop=0.025), f"forearm_{side}", folds),
        ]
    return out


def jacket(prefix, paint, size=(0.66, 0.46, 0.62), folds="line", sleeve=0.12):
    return [(rounded_box(prefix, size, (0, 0, (WAIST + CHIN) / 2 - 0.03), paint, bevel=0.19, segments=8).name, "torso")] + sleeves(f"{prefix}_sleeve", paint, upper=sleeve, lower=sleeve - 0.01, folds=folds)


# A sneaker as drawn: a cream sole shelf the upper sits in, hugging it with a thin ink line at its base and a toe cap that lifts
# a little at the front, the upper in the voice's colour with a side patch, cream laces across a cream tongue, and a high cream
# collar at the ankle. upper is the upper's paint and patch the side stripe's; the coach's upper is cream with a blue stripe.
def sneakers(prefix, upper="blue", patch="cream", size=1.0):
    out = []
    for side, x in SIDES:
        at, toe = x * LEG_X, -0.36
        bone = f"foot_{side}"
        out += [
            (rounded_box(f"{prefix}_{side}_sole", (0.40, 0.62, 0.11), (at, -0.10, 0.055), "cream", bevel=0.05, segments=6).name, bone),
            line(f"{prefix}_{side}_welt", [(at + x * 0.185 * math.cos(math.radians(t)), -0.10 + 0.295 * math.sin(math.radians(t)), 0.105) for t in range(0, 361, 20)], bone, radius=0.008, closed=True),
            (rounded_box(f"{prefix}_{side}", (0.37, 0.56, 0.17), (at, -0.08, 0.19), upper, bevel=0.08, segments=6).name, bone),
            (ball(f"{prefix}_{side}_toe", 0.16, (at, toe + 0.12, 0.14), "cream", scale=(1.15, 0.85, 0.7)).name, bone),
            (rounded_box(f"{prefix}_{side}_tongue", (0.17, 0.22, 0.05), (at, -0.13, 0.27), "cream", bevel=0.02, rotation=(-24, 0, 0)).name, bone),
            (tube(f"{prefix}_{side}_collar", 0.15, 0.10, (at, 0.03, 0.30), "cream").name, bone),
        ]
        out += [line(f"{prefix}_{side}_lace_{k}", [(at - 0.075, -0.06 - 0.075 * k, 0.293 - 0.025 * k), (at + 0.075, -0.06 - 0.075 * k, 0.293 - 0.025 * k)], bone, radius=0.012) for k in range(3)]
        for face, offset in (("out", x * 0.188), ("in", -x * 0.188)):
            out.append((slab(f"{prefix}_{side}_patch_{face}", [(-0.20, 0.05), (0.06, 0.07), (0.14, -0.03), (-0.16, -0.04)], 0.02, (at + offset, -0.06, 0.19), patch, rotation=(0, 0, 90), bevel=0.008).name, bone))
        sized([part for part in out if part[1] == bone], Vector((at, -0.04, 0.0)), size)
    return out


# A dress shoe as drawn: low and long, a sole lip standing out round its foot, the toe lifting a little, the feet apart.
def dress_shoes(prefix):
    out = []
    for side, x in SIDES:
        at, bone = x * LEG_X, f"foot_{side}"
        out += [
            (rounded_box(f"{prefix}_{side}_sole", (0.29, 0.48, 0.04), (at, -0.10, 0.02), "satin", bevel=0.019, segments=4).name, bone),
            (ball(f"{prefix}_{side}", 0.17, (at, -0.10, 0.13), "ink", scale=(0.95, 1.5, 0.78)).name, bone),
            (ball(f"{prefix}_{side}_toe", 0.16, (at, -0.28, 0.12), "ink", scale=(1.0, 0.95, 0.72)).name, bone),
        ]
    return out


# The butler: a bowler tipped to his right, headphones, a tailcoat open over a grey waistcoat, a big bow tie, dress shoes.
def build_butler_assets():
    out = {}
    hat = [
        (dome("hat_crown", (0.30, 0.29, 0.37), (0, 0, 0), "ink").name, "head"),
        (tube("hat_band", 0.303, 0.10, (0, 0, 0.06), "cream").name, "head"),
        (ring("hat_brim", 0.35, 0.05, (0, 0, 0.0), "ink", scale=(1, 0.95, 0.7)).name, "head"),
        (tube("hat_brim_fill", 0.34, 0.035, (0, 0, 0.0), "ink").name, "head"),
    ]
    # The bowler is smaller than the dome and rests on it: its brim sits where the head has narrowed to the brim's width.
    out["butler_hat"] = placed(hat, Matrix.Translation((-0.09, 0.0, 2.11)) @ Matrix.Scale(0.97, 4) @ Matrix.Rotation(math.radians(-20), 4, "Y") @ Matrix.Rotation(math.radians(-6), 4, "X"))
    coat = rounded_box("coat", (0.72, 0.50, 0.60), (0, 0, 1.13), "jacket", bevel=0.19, segments=8)
    carve(coat, rounded_box("coat_cut", (0.30, 0.30, 0.70), (0, -0.30, 1.10), "jacket", bevel=0.0))
    parts = [(coat.name, "torso")]
    for side, x in SIDES:
        # A lapel runs from the collar, broad across the chest, to a point at the waist beside the waistcoat.
        lapel = [(x * 0.09, 1.42), (x * 0.22, 1.38), (x * 0.25, 1.24), (x * 0.17, 0.98), (x * 0.14, 1.02)]
        parts.append((slab(f"lapel_{side}", lapel, 0.03, (0, -0.245, 0), "lapel", bevel=0.01).name, "torso"))
    # The coat goes on below the waist as one long panel round the back, flaring past both legs to the knees, lined in indigo.
    tails = drape("tails", (0, 0.20, 0.98), 0.66, 1.00, 0.58, "jacket", ripples=1.5, depth=0.04, sway=0.06, lean=0.10, hem=0.03)
    lining = drape("tails_lining", (0, 0.175, 0.96), 0.56, 0.90, 0.54, "indigo", ripples=1.5, depth=0.04, sway=0.05, lean=0.09, hem=0.03, thickness=0.015)
    parts += [(hanging(tails, 0.5).name, "torso"), (hanging(lining, 0.5).name, "torso")]
    # The waistcoat opens in a V under the bow tie onto the white shirt, and ends at the waist in two points.
    for side, x in SIDES:
        half = [(x * 0.16, 0.95), (x * 0.16, 1.31), (x * 0.13, 1.37), (0, 1.12), (0, 0.92), (x * 0.08, 0.86)]
        parts.append((slab(f"waistcoat_{side}", half, 0.06, (0, -0.20, 0), "grey", bevel=0.012).name, "torso"))
    parts += [
        (rounded_box("shirt", (0.28, 0.03, 0.32), (0, -0.20, 1.26), "cream", bevel=0.01).name, "torso"),
        (bare(ball("button_shirt", 0.022, (0, -0.24, 1.23), "line")).name, "torso"),
        (bare(ball("button_high", 0.026, (0, -0.24, 1.06), "line")).name, "torso"),
        (bare(ball("button_low", 0.026, (0, -0.24, 0.96), "line")).name, "torso"),
        (ring("collar", 0.20, 0.05, (0, 0.02, CHIN - 0.03), "jacket", scale=(1, 0.95, 0.6)).name, "torso"),
    ]
    for side, x in SIDES:
        parts.append((bare(tube(f"cuff_{side}", 0.125, 0.06, (x * ARM_X, 0, WRIST - 0.01), "cream")).name, f"forearm_{side}"))
    out.update(outfit(parts + sleeves("sleeve", "jacket") + trousers("butler_trousers", thigh=0.15, shin=0.135) + dress_shoes("dress_shoe"), torso="butler_coat", arms="butler_sleeves", legs="butler_trousers", feet="butler_shoes"))
    bow = []
    for side, x in SIDES:
        bow.append((slab(f"bow_{side}", [(0, 0.03), (x * 0.17, 0.09), (x * 0.19, 0.0), (x * 0.17, -0.09), (0, -0.03)], 0.06, (0, -0.25, CHIN - 0.05), "blue", bevel=0.02).name, "torso"))
    bow.append((rounded_box("bow_knot", (0.07, 0.07, 0.09), (0, -0.275, CHIN - 0.05), "blue", bevel=0.025).name, "torso"))
    out["butler_bow_tie"] = bow
    return out


# An open laptop standing on the surface at the origin, its screen raised at the back with the drawn tick on it, hung on bone;
# placed() puts it where it stands.
def laptop(prefix, bone):
    top = Vector((0, 0, 0))
    return [
        (rounded_box(prefix, (0.48, 0.34, 0.04), top + Vector((0, -0.02, 0.035)), "navy", bevel=0.014).name, bone),
        (rounded_box(f"{prefix}_screen", (0.54, 0.035, 0.42), top + Vector((0, 0.16, 0.25)), "navy", bevel=0.018, rotation=(-8, 0, 0)).name, bone),
        (rounded_box(f"{prefix}_glass", (0.47, 0.01, 0.35), top + Vector((0, 0.138, 0.255)), "screen", bevel=0.01, rotation=(-8, 0, 0)).name, bone),
        (rounded_box(f"{prefix}_lip", (0.54, 0.05, 0.035), top + Vector((0, -0.17, 0.03)), "navy", bevel=0.012).name, bone),
        (bare(tube(f"{prefix}_light", 0.115, 0.012, top + Vector((0, 0.128, 0.255)), "blue", rotation=(82, 0, 0))).name, bone),
        (slab(f"{prefix}_tick", tick_outline(0.14), 0.012, top + Vector((0, 0.118, 0.255)), "white", rotation=(-8, 0, 0)).name, bone),
    ]


def build_butler_held(rig):
    hat, _, _ = mitten("butler_hat_hand", rig, "right", "fist", toward=joint(rig, "hand_right", 0.5) + Vector((0, -1, 0)), size=0.75)
    tray_hand, hollow, _ = mitten("butler_tray_hand", rig, "left", "open", toward=joint(rig, "hand_left", 0.5) + Vector((0, 0, 1)))
    forearm = (joint(rig, "forearm_left", 0.0), joint(rig, "forearm_left", 1.0))
    top = hollow + Vector((0, 0, 0.03))
    tray = [
        (tube("tray", 0.40, 0.025, top, "silver").name, "hand_left"),
        (ring("tray_rim", 0.40, 0.022, top + Vector((0, 0, 0.012)), "silver", scale=(1, 1, 0.8)).name, "hand_left"),
    ] + placed(laptop("laptop", "hand_left"), Matrix.Translation(top))
    # The towel folds over the forearm near the wrist, under the tray, and falls on both sides of it.
    middle = forearm[0].lerp(forearm[1], 0.75) + Vector((0, 0, 0.05))
    towel = [
        (hanging(drape("towel", middle + Vector((-0.03, -0.12, 0.03)), 0.30, 0.36, 0.68, "cream", ripples=2.0, sway=0.02), 1.0).name, "forearm_left"),
        (hanging(drape("towel_back", middle + Vector((0.07, 0.10, 0.03)), 0.26, 0.30, 0.50, "cream", ripples=1.5, sway=0.04), 1.0).name, "forearm_left"),
        line("towel_fold_line", [middle + Vector((-0.06, -0.15, -0.10)), middle + Vector((-0.07, -0.16, -0.40)), middle + Vector((-0.05, -0.17, -0.58))], "forearm_left", "fold"),
        (capsule("towel_fold", 0.08, middle + Vector((-0.10, -0.06, 0.03)), middle + Vector((0.14, 0.06, 0.03)), "cream").name, "forearm_left"),
    ]
    return {"butler_tray": tray, "butler_towel": towel, "butler_hands": hat + tray_hand}


# A peaked cap fitted to the head: a dome over the crown cut level with the brow, a button on top and a long peak tipped up by
# peak degrees, the whole cap turned about the head by turn degrees and cocked to one side by roll degrees. Worn backwards, the peak hangs over the nape and the
# snapback's opening, with its strap, sits over the brow.
def cap(prefix, turn=0.0, backwards=False, peak=34, reach=0.36, brow=0.085, roll=0.0):
    turn = Matrix.Rotation(math.radians(turn + (180 if backwards else 0)), 4, "Z")
    radii = [HEAD_R * scale * 1.06 for scale in HEAD_SCALE]
    parts = [
        (squared(dome(f"{prefix}_crown", radii, (0, 0, 0), "blue", height=brow, tilt=-12 if backwards else 12)).name, "head"),
        (ball(f"{prefix}_button", 0.04, (0, 0, radii[2] * 1.0), "blue").name, "head"),
        (slab(f"{prefix}_peak_under", [(0.28 * math.cos(math.radians(a)), -(reach - 0.01) * math.sin(math.radians(a))) for a in range(0, 181, 10)], 0.02, (0, -radii[1] + 0.08, brow + 0.045), "indigo", rotation=(-90 - peak, 0, 0)).name, "head"),
        line(f"{prefix}_seam", [(radii[0] * 1.01 * math.cos(math.radians(a)), -radii[1] * 1.01 * math.sin(math.radians(a)), brow + 0.075) for a in range(15, 166, 15)], "head"),
        (slab(f"{prefix}_peak", [(0.29 * math.cos(math.radians(a)), -reach * math.sin(math.radians(a))) for a in range(0, 181, 10)], 0.04, (0, -radii[1] + 0.08, brow + 0.065), "blue", rotation=(-90 - peak, 0, 0), bevel=0.015).name, "head"),
    ]
    if backwards:
        # The opening sits in the cap above its edge, the strap along the edge under it, each on the crown's surface.
        on_crown = lambda z: radii[1] * math.sqrt(max(1 - (z / radii[2]) ** 2, 0)) + 0.01
        opening, strap = brow + 0.09, brow + 0.02
        parts += [
            (slab(f"{prefix}_opening", rounded_outline(0.26, 0.14, 0.06), 0.03, (0, on_crown(opening), opening), "lens", rotation=(-20, 0, 180)).name, "head"),
            (slab(f"{prefix}_strap", rounded_outline(0.30, 0.05, 0.02), 0.025, (0, on_crown(strap), strap), "blue", rotation=(-15, 0, 180), bevel=0.008).name, "head"),
        ]
    return placed(parts, Matrix.Translation(HEAD_AT) @ Matrix.Rotation(math.radians(roll), 4, "Y") @ turn)


def build_coach_assets():
    out = {"coach_cap": cap("coach_cap", turn=25, peak=4, reach=0.58, brow=0.13, roll=-20)}
    suit = jacket("coach_jacket", "jacket", size=(0.78, 0.52, 0.66))
    suit.append((ring("coach_hood", 0.26, 0.09, (0, 0.14, CHIN), "jacket", rotation=(-20, 0, 0), scale=(1, 0.8, 0.7)).name, "torso"))
    suit.append((rounded_box("coach_zip", (0.03, 0.02, 0.56), (0, -0.235, 1.12), "skin", bevel=0.006).name, "torso"))
    front = -0.262
    suit.append(line("coach_waistband", [(-0.19, front, WAIST + 0.07), (0.19, front, WAIST + 0.07)], "torso"))
    suit.append((ball("coach_trouser_seat", 0.34, (0, 0, HIP + 0.02), "trouser", scale=(1, 0.72, 0.37)).name, "hips"))
    for side, x in SIDES:
        arm, leg = joints(x), leg_joints(x)
        suit += [
            (capsule(f"coach_trouser_thigh_{side}", 0.205, leg["hip"], leg["knee"], "trouser").name, f"thigh_{side}"),
            (capsule(f"coach_trouser_shin_{side}", 0.17, leg["knee"], (x * LEG_X, 0, 0.20), "trouser").name, f"shin_{side}"),
            line(f"coach_knee_fold_{side}", front_arc((x * LEG_X, 0, KNEE + 0.02), 0.208, 100, droop=0.035), f"thigh_{side}"),
        ]
        for k, angle in enumerate((10, 32, 54)):
            around = Vector((x * math.cos(math.radians(angle)), -math.sin(math.radians(angle)), 0))
            suit.append((capsule(f"coach_arm_stripe_{side}_{k}", 0.014, Vector(arm["shoulder"]) + Vector((0, 0, 0.10)) + around * 0.122, Vector(arm["elbow"]) + around * 0.122, "skin").name, f"upper_arm_{side}"))
        for k, angle in enumerate((16,)):
            around = Vector((x * math.cos(math.radians(angle)), -math.sin(math.radians(angle)), 0))
            suit.append((capsule(f"coach_leg_stripe_{side}_{k}", 0.028, Vector(leg["hip"]) + around * 0.207, Vector(leg["knee"]) + around * 0.207, "skin").name, f"thigh_{side}"))
            suit.append((capsule(f"coach_shin_stripe_{side}_{k}", 0.028, Vector(leg["knee"]) + around * 0.172, Vector((x * LEG_X, 0, 0.24)) + around * 0.172, "skin").name, f"shin_{side}"))
    out.update(outfit(suit + sneakers("coach_shoe"), torso="coach_jacket", arms="coach_sleeves", legs="coach_trousers", feet="coach_shoes"))
    whistle = Vector((0.05, -0.29, 1.10))
    neck, foot = Vector((0, -0.12, CHIN)), whistle - Vector((0, 0.02, 0.11))
    out["coach_whistle"] = [
        (hanging(sweep(f"coach_lanyard_{side}", through([(x * 0.16, -0.10, CHIN), (x * 0.15, -0.22, CHIN - 0.10), (x * 0.07, -0.28, CHIN - 0.24), whistle + Vector((0, 0, 0.10))], 12), [0.014] * 12, "skin", sides=8), 1.0, neck, foot).name, "torso")
        for side, x in SIDES
    ] + [
        (hanging(capsule("coach_whistle", 0.055, whistle + Vector((0, 0, 0.05)), whistle - Vector((0, 0.02, 0.06)), "whistle"), 1.0, neck, foot).name, "torso"),
        (hanging(ring("coach_whistle_ring", 0.03, 0.01, whistle + Vector((0, 0, 0.10)), "silver", rotation=(90, 0, 0)), 1.0, neck, foot).name, "torso"),
        (hanging(rounded_box("coach_whistle_spout", (0.09, 0.05, 0.045), whistle + Vector((0.08, -0.02, -0.02)), "whistle", bevel=0.015), 1.0, neck, foot).name, "torso"),
        (hanging(bare(rounded_box("coach_whistle_slot", (0.045, 0.02, 0.022), whistle + Vector((0, -0.07, 0.03)), "line", bevel=0.008)), 1.0, neck, foot).name, "torso"),
    ]
    return out


def build_coach_held(rig):
    board = [
        (rounded_box("coach_clipboard", (0.66, 0.04, 0.70), (0, 0, 0), "slate", bevel=0.03).name, "hand_right"),
        (rounded_box("coach_paper", (0.58, 0.01, 0.61), (0, -0.025, -0.01), "cream", bevel=0.01).name, "hand_right"),
        (rounded_box("coach_clip", (0.16, 0.05, 0.07), (0, -0.02, 0.32), "grey", bevel=0.015).name, "hand_right"),
        (rounded_box("coach_clip_tab", (0.07, 0.04, 0.05), (0, -0.02, 0.37), "grey", bevel=0.012).name, "hand_right"),
    ] + [
        (rounded_box(f"coach_bar_{k}", (0.06, 0.015, height), (-0.11 + 0.075 * k, -0.035, -0.17 + height / 2), "blue", bevel=0.008).name, "hand_right")
        for k, height in enumerate((0.10, 0.16, 0.23, 0.31))
    ]
    thumbs, _, _ = mitten("coach_thumb_hand", rig, "left", "thumbs", toward=joint(rig, "hand_left", 0.5) + Vector((0, -1, 0)), size=1.0)
    grip, hollow, _ = mitten("coach_board_hand", rig, "right", "edge")
    at = hollow + Vector((0.24, -0.04, 0.24))
    return {
        "coach_clipboard": placed(board, Matrix.Translation(at) @ Matrix.Scale(0.78, 4)),
        "coach_hands": thumbs + grip,
    }


# A hand as the drawings draw it: a mitten, a broad palm with three outlined fingers and a short thumb, about half a visor across.
# It is built where the pose puts it, round what it holds, in the frame of its posed bone: d runs from the wrist to the fingertips,
# n out of the palm toward what it holds (toward the body unless the grip says), a across the knuckles toward the thumb.
# Each grip lays the fingers its own way and leaves a hollow, the point what it holds is placed at:
# open (at rest, flat), fist (round a handle running along a), thumbs (a fist with the thumb raised), edge (fingers hooked over
# an edge running along a) and wrap (fingers round the near side of a mug standing upright at the hollow).
FINGERS = (-0.09, 0.0, 0.09)
MUG = 0.13


def hand_frame(rig, side, toward=None):
    posed = rig.pose.bones[f"hand_{side}"]
    world = rig.matrix_world @ posed.matrix
    d = (world.to_3x3() @ Vector((0, 1, 0))).normalized()
    wrist = world.translation.copy()
    aim = Vector(toward) if toward else Vector((0, 0, wrist.z)) - wrist
    n = (aim - d * aim.dot(d)).normalized()
    a = d.cross(n).normalized()
    if a.y > 0:
        a = -a
    return wrist + d * 0.13, d, n, a


# Grows or shrinks built parts about a point, so one voice's hand or shoe can be sized to its own drawing.
def sized(parts, about, size):
    if size == 1:
        return parts
    matrix = Matrix.Translation(about) @ Matrix.Scale(size, 4) @ Matrix.Translation(-about)
    for name, _ in parts:
        made = bpy.data.objects[name]
        made.data.transform(made.matrix_world.inverted() @ matrix @ made.matrix_world)
    return parts


def mitten(prefix, rig, side, grip, toward=None, size=1.0):
    c, d, n, a = hand_frame(rig, side, toward)
    bone = f"hand_{side}"
    palm = ball(f"{prefix}_palm", 1, (0, 0, 0), "skin")
    palm.data.transform(Matrix((list(a * 0.16) + [0], list(n * 0.115) + [0], list(d * 0.16) + [0], [0, 0, 0, 1])).transposed())
    palm.location = c
    parts = [(palm.name, bone)]
    finger = lambda k, start, end, radius=0.058: parts.append((capsule(f"{prefix}_finger_{k}", radius, start, end, "skin").name, bone))
    thumb = lambda start, end: parts.append((capsule(f"{prefix}_thumb", 0.064, start, end, "skin").name, bone))
    if grip == "open":
        for k, o in enumerate(FINGERS):
            finger(k, c + d * 0.08 + a * o, c + d * 0.24 + a * o * 1.1)
        thumb(c + a * 0.12 - d * 0.02, c + a * 0.23 + d * 0.08 + n * 0.02)
        hollow = c + n * 0.10
    elif grip in ("fist", "thumbs"):
        for k, o in enumerate(FINGERS):
            finger(k, c + d * 0.12 + a * o * 0.9, c + d * 0.12 + a * o * 0.9 + n * 0.13)
        if grip == "fist":
            thumb(c + a * 0.12 + n * 0.02, c + a * 0.04 + n * 0.16)
        else:
            # The thumb stands up from the top of the fist, clear of it, as a thumb up is drawn.
            top = c + d * 0.10 + a * 0.12 + n * 0.08
            parts.append((capsule(f"{prefix}_thumb", 0.075, top, top + Vector((0, 0, 0.15)), "skin").name, bone))
        hollow = c + n * 0.12 + d * 0.05
    elif grip == "edge":
        for k, o in enumerate(FINGERS):
            parts.append((sweep(f"{prefix}_finger_{k}", [c + d * 0.08 + a * o, c + d * 0.20 + a * o + n * 0.02, c + d * 0.23 + a * o + n * 0.13], [0.058] * 3, "skin", sides=12).name, bone))
        thumb(c + a * 0.12 + d * 0.02, c + a * 0.14 + d * 0.07 + n * 0.12)
        hollow = c + d * 0.17 + n * 0.07
    else:
        hollow = c + n * (0.09 + MUG)
        up = Vector((0, 0, 1))
        back = (c - hollow)
        back = (back - up * back.dot(up)).normalized()
        # The fingers curl round the mug's outer side, away from the viewer, so its face stays in view as drawn.
        side = up.cross(back).normalized()
        if side.y < 0:
            side = -side
        for k, o in enumerate((0.06, 0.0, -0.06)):
            arc = [hollow + up * o + (back * math.cos(math.radians(t)) + side * math.sin(math.radians(t))) * (MUG + 0.035) for t in (0, 30, 60, 90, 115)]
            parts.append((sweep(f"{prefix}_finger_{k}", arc, [0.058] * len(arc), "skin", sides=12).name, bone))
        thumb(c + up * 0.10, hollow + up * 0.13 + side * 0.06)
    wrist = c - d * 0.13
    return sized(parts, wrist, size), wrist + (hollow - wrist) * size, (d, n, a)


def build_colleague_assets():
    shirt = jacket("colleague_shirt", "cream", size=(0.72, 0.44, 0.62), folds="lavender")
    for side, x in SIDES:
        shirt.append((slab(f"colleague_collar_{side}", [(0, 0), (x * 0.16, 0.02), (x * 0.06, -0.12)], 0.03, (x * 0.02, -0.215, CHIN - 0.02), "cream", rotation=(0, 0, 0), bevel=0.01).name, "torso"))
    shirt.append((rounded_box("colleague_placket", (0.03, 0.02, 0.50), (0, -0.215, 1.12), "grey", bevel=0.006).name, "torso"))
    for k in range(3):
        shirt.append((ball(f"colleague_button_{k}", 0.02, (0.03, -0.225, 1.28 - 0.13 * k), "blue").name, "torso"))
    # The frames ring the two halves of the shared visor, either side of the bridge.
    width, tall, middle = screen()
    bridge = 0.035
    lens = (width - bridge) / 2
    centre = (bridge + lens) / 2
    glasses = []
    for side, x in SIDES:
        # Her lenses are wider than tall, as drawn, so they frame the middle of the visor's height.
        rim = [on_head(x * centre + px, HEAD_AT.z + middle + pz, 0.035)[0] for px, pz in rounded_outline(lens + 0.02, tall * 0.78, 0.065, steps=4)]
        glasses += [
            (sweep(f"colleague_glass_{side}", rim + rim[:1], [0.017] * (len(rim) + 1), "navy", up=(0, -1, 0), sides=10).name, "head"),
            (capsule(f"colleague_temple_{side}", 0.016, on_head(x * (centre + lens / 2 + 0.01), HEAD_AT.z + middle + 0.04, 0.03)[0], (x * (head_side(HEAD_AT.z - 0.12) + 0.03), -0.02, HEAD_AT.z - 0.12), "navy").name, "head"),
        ]
    glasses.append((capsule("colleague_bridge", 0.016, on_head(-0.02, HEAD_AT.z + middle + 0.03, 0.04)[0], on_head(0.02, HEAD_AT.z + middle + 0.03, 0.04)[0], "navy").name, "head"))
    glint, turn = on_head(centre + lens * 0.3, HEAD_AT.z + middle + tall * 0.25, 0.05)
    glasses.append((bare(slab("colleague_glint", rounded_outline(0.02, 0.065, 0.008), 0.01, glint, "white", rotation=(0, 35, turn[2]))).name, "head"))
    glasses.append((rounded_box("colleague_seam", (0.07, 0.05, 0.12), on_head(0.03, HEAD_AT.z + 0.29, 0.0)[0], "lens", bevel=0.02, rotation=(-50, 0, 0)).name, "head"))
    return {**outfit(shirt + trousers("colleague_trousers") + sneakers("colleague_shoe", size=0.8), torso="colleague_shirt", arms="colleague_sleeves", legs="colleague_trousers", feet="colleague_shoes"), "colleague_glasses": glasses}


def build_colleague_held(rig):
    wrap, mug, (d, n, a) = mitten("colleague_mug_hand", rig, "left", "wrap", toward=joint(rig, "hand_left", 0.5) + Vector((0.4, -1, 0)))
    rest, _, _ = mitten("colleague_free_hand", rig, "right", "open")
    # The mug as drawn: an upright cylinder about as tall as it is wide, its small handle on the side toward her body.
    inward = Vector((-mug.x, -mug.y, 0)).normalized()
    cup = [
        (tube("colleague_mug", MUG, 0.26, mug, "navy").name, "hand_left"),
        (bare(tube("colleague_coffee", MUG - 0.02, 0.012, mug + Vector((0, 0, 0.132)), "lapel")).name, "hand_left"),
        (ring("colleague_mug_handle", 0.05, 0.02, mug + inward * (MUG + 0.03), "navy", rotation=(90, 0, math.degrees(math.atan2(inward.y, inward.x)))).name, "hand_left"),
    ]
    # Her laptop rests across her thighs, as on the drawn desk scene, on the hips so it rides with her seat.
    lap = joint(rig, "thigh_left", 0.6).lerp(joint(rig, "thigh_right", 0.6), 0.5) + Vector((0, 0, 0.17))
    return {"colleague_mug": cup, "colleague_hands": wrap + rest, "colleague_laptop": placed(laptop("colleague_laptop", "hips"), Matrix.Translation(lap) @ Matrix.Rotation(math.pi, 4, "Z"))}


def build_homie_assets():
    hoodie = jacket("homie_hoodie", "hoodie", size=(0.92, 0.56, 0.66), sleeve=0.15)
    hoodie.append((ring("homie_hood", 0.31, 0.12, (0, 0.10, CHIN + 0.0), "hoodie", rotation=(-25, 0, 0), scale=(1.1, 0.9, 0.7)).name, "torso"))
    hoodie += [line(f"homie_knee_fold_{side}", front_arc((x * LEG_X, 0, KNEE + 0.02), 0.153, 100, droop=0.035), f"thigh_{side}") for side, x in SIDES]
    front = -0.252
    hoodie.append(line("homie_pocket", [(-0.16, front, 0.88), (-0.11, front, 1.04), (0.11, front, 1.04), (0.16, front, 0.88)], "torso"))
    hoodie.append(line("homie_hem", [(-0.16, front, WAIST + 0.06), (0.16, front, WAIST + 0.06)], "torso"))
    hoodie += [(hanging(capsule(f"homie_string_{side}", 0.014, (x * 0.08, -0.30, CHIN - 0.08), (x * 0.09, -0.305, CHIN - 0.32), "cream"), 1.0).name, "torso") for side, x in SIDES]
    neck = []
    for side, x in SIDES:
        neck += [
            (tube(f"homie_phone_{side}", 0.12, 0.08, (x * 0.22, -0.20, CHIN - 0.04), "lavender", rotation=(90, 0, -30 * x)).name, "torso"),
            (ring(f"homie_phone_{side}_rim", 0.095, 0.018, (x * 0.235, -0.24, CHIN - 0.04), "cream", rotation=(90, 0, -30 * x)).name, "torso"),
            (tube(f"homie_phone_{side}_disc", 0.075, 0.02, (x * 0.24, -0.245, CHIN - 0.04), "blue", rotation=(90, 0, -30 * x)).name, "torso"),
        ]
    neck.append((sweep("homie_phone_band", through([(-0.22, -0.20, CHIN - 0.04), (-0.10, -0.27, CHIN - 0.12), (0.10, -0.27, CHIN - 0.12), (0.22, -0.20, CHIN - 0.04)], 10), [0.022] * 10, "lavender", sides=10).name, "torso"))
    return {"homie_cap": cap("homie_cap", turn=30, backwards=True, peak=-35, reach=0.5, brow=0.15), **outfit(hoodie + trousers("homie_joggers", "hoodie") + sneakers("homie_shoe"), torso="homie_hoodie", arms="homie_sleeves", legs="homie_trousers", feet="homie_shoes"), "homie_headphones": neck}


# The squire wears a helmet over his own head: a taller shell with an opening for the visor, a crest, a slot on the brow and a plume.
def build_squire_assets():
    radii = [HEAD_R * scale + room for scale, room in zip(HEAD_SCALE, (0.033, 0.033, 0.041))]
    shell = squared(ball("squire_helmet", 1, (0, 0, 0), "skin", scale=radii))
    shell.location = HEAD_AT + Vector((0, 0.01, 0.04))
    # The opening is the shared visor itself: the shell is cut just round it, so no band of the head shows between screen and
    # helmet, and the shell's own outline draws the border, so no inked edge widens the screen.
    wide, tall, middle = screen()
    opening = rounded_box("squire_helmet_cut", (wide + 0.04, 0.6, tall + 0.03), HEAD_AT + Vector((0, -0.4, middle)), "skin", bevel=0.08, segments=6)
    carve(shell, opening)
    helmet = [
        (shell.name, "head"),
        (sweep("squire_crest", through([HEAD_AT + Vector((0, -0.36, 0.20)), HEAD_AT + Vector((0, -0.24, 0.40)), HEAD_AT + Vector((0, 0.02, 0.47)), HEAD_AT + Vector((0, 0.28, 0.38))], 12), [0.05] * 12, "skin", flat=0.7, up=(0, 1, 0)).name, "head"),
        (sweep("squire_brow", [on_head(x, HEAD_AT.z + 0.10, 0.03, shell.location, radii)[0] for x in (-0.28, -0.14, 0.0, 0.14, 0.28)], [0.035] * 5, "skin").name, "head"),
        (rounded_box("squire_slot", (0.06, 0.05, 0.16), on_head(0, HEAD_AT.z + 0.23, 0.07)[0], "lens", bevel=0.02, rotation=(-30, 0, 0)).name, "head"),
    ]
    # The plume as drawn: three locks from one root, the top one longest, each swelling and curling down at its tip.
    root = Vector((0.0, 0.05, HEAD_AT.z + 0.36))
    for k, (reach, rise, drop) in enumerate(((0.92, 0.20, 0.30), (0.82, 0.13, 0.28), (0.70, 0.06, 0.26))):
        # Each lock sweeps out to the side rather than back, so it shows its arc from the front, and curls down past the helmet.
        tip = root + Vector((-1.25 * reach, 0.12, -drop - 0.10))
        lock = through([root, root + Vector((-0.10 * reach, 0.04, rise + 0.08)), root + Vector((-0.55 * reach, 0.08, rise + 0.06)), root + Vector((-1.02 * reach, 0.11, rise - 0.12)), tip], 18)
        thickness = [0.03 + (0.15 - 0.02 * k) * max(math.sin(math.pi * j / (len(lock) - 1)), 0) ** 0.7 for j in range(len(lock))]
        helmet.append((hanging(sweep(f"squire_plume_{k}", lock, thickness, "blue" if k != 1 else "indigo", flat=0.55, up=(0, 0, 1)), 0.25, lock[0], lock[-1]).name, "head"))
    armour = [
        (rounded_box("squire_plate", (0.62, 0.44, 0.62), (0, 0, (WAIST + CHIN) / 2 - 0.03), "skin", bevel=0.19, segments=8).name, "torso"),
        (ring("squire_gorget", 0.19, 0.045, (0, -0.01, CHIN - 0.13), "underplate", scale=(1, 0.9, 0.6)).name, "torso"),
        (ring("squire_scarf", 0.22, 0.08, (0, -0.03, CHIN - 0.04), "blue", rotation=(10, 0, 0), scale=(1, 0.95, 0.7)).name, "torso"),
        (hanging(slab("squire_scarf_tail", [(0, 0), (0.18, -0.02), (0.06, -0.22)], 0.04, (0.04, -0.24, CHIN - 0.06), "blue", bevel=0.01), 1.0).name, "torso"),
        (rounded_box("squire_belt", (0.64, 0.46, 0.08), (0, 0, WAIST + 0.02), "joint", bevel=0.03).name, "torso"),
        (tube("squire_hips", 0.30, 0.10, (0, 0, HIP + 0.02), "underplate", scale=(1, 0.72, 1)).name, "hips"),
        (hanging(drape("squire_cape", (0, 0.25, CHIN - 0.04), 0.62, 1.15, 1.10, "indigo", ripples=2.0, depth=0.05, sway=0.75, lean=0.30, hem=0.08), 0.8).name, "torso"),
    ]
    for side, x in SIDES:
        leg = leg_joints(x)
        armour += [
            (ball(f"squire_pauldron_{side}", 0.15, (x * (ARM_X + 0.02), 0, SHOULDER + 0.04), "skin", scale=(1, 1, 0.8)).name, "torso"),
            (ring(f"squire_pauldron_{side}_under", 0.145, 0.04, (x * (ARM_X + 0.02), 0, SHOULDER - 0.05), "underplate").name, "torso"),
        ] + [
            (ring(f"squire_vambrace_{side}_{k}", 0.088, 0.016, (x * ARM_X, 0, ELBOW - 0.07 - 0.08 * k), "underplate").name, f"forearm_{side}") for k in range(3)
        ] + [
            (capsule(f"squire_greave_{side}", 0.11, (x * LEG_X, 0, KNEE - 0.06), (x * LEG_X, 0, 0.24), "skin").name, f"shin_{side}"),
            (capsule(f"squire_cuisse_{side}", 0.115, (x * LEG_X, 0, HIP - 0.02), (x * LEG_X, 0, KNEE + 0.06), "skin").name, f"thigh_{side}"),
            (tube(f"squire_knee_{side}", 0.12, 0.07, (x * LEG_X, 0, KNEE), "underplate").name, f"shin_{side}"),
            (tube(f"squire_ankle_{side}", 0.112, 0.05, (x * LEG_X, 0, ANKLE + 0.02), "underplate").name, f"shin_{side}"),
        ]
    return {
        "squire_helmet": helmet,
        **outfit(armour + trousers("squire_undersuit", thigh=0.105, shin=0.10) + sneakers("squire_boot"), torso="squire_armour", arms="squire_vambraces", hips="squire_belt", legs="squire_greaves", feet="squire_boots"),
    }


# The homie's hands lie open over his folded forearms.
def build_homie_held(rig):
    return {"homie_hands": mitten("homie_left_hand", rig, "left", "open", size=0.6)[0] + mitten("homie_right_hand", rig, "right", "open", size=0.6)[0]}


def build_squire_held(rig):
    hooked, grip, _ = mitten("squire_shield_hand", rig, "right", "edge")
    at = grip + Vector((-0.36, -0.03, -0.45 * 1.02 + 0.13))
    shield = [
        (slab("squire_shield", heater_outline(0.98, 1.02), 0.06, at, "cream", rotation=(0, 0, 18), bevel=0.02).name, "hand_right"),
        (slab("squire_shield_face", heater_outline(0.76, 0.76), 0.03, at + Vector((0, -0.03, 0.01)), "ink", rotation=(0, 0, 18)).name, "hand_right"),
        (slab("squire_star", star_outline(0.44, 0.18), 0.02, at + Vector((-0.01, -0.05, -0.02)), "blue", rotation=(0, 0, 18)).name, "hand_right"),
    ]
    fist, hilt, (d, n, a) = mitten("squire_quill_hand", rig, "left", "fist", toward=joint(rig, "hand_left", 0.5) + Vector((0, -1, 0)))
    turn = 24
    up = Vector((math.sin(math.radians(turn)), 0, math.cos(math.radians(turn))))
    feather, vane = quill_outlines()
    quill = [
        (capsule("squire_hilt", 0.04, hilt - up * 0.22, hilt + up * 0.10, "navy").name, "hand_left"),
        (ball("squire_pommel", 0.055, hilt - up * 0.25, "navy").name, "hand_left"),
        (ring("squire_grip_ring", 0.045, 0.015, hilt - up * 0.17, "cream", rotation=(0, turn, 0)).name, "hand_left"),
        (ring("squire_guard", 0.06, 0.025, hilt + up * 0.13, "cream", rotation=(0, turn, 0)).name, "hand_left"),
        (slab("squire_feather", feather, 0.03, hilt + up * 0.15, "cream", rotation=(0, turn, 0), bevel=0.01).name, "hand_left"),
        (slab("squire_vane", vane, 0.02, hilt + up * 0.15 - Vector((0, 0.02, 0)), "lavender", rotation=(0, turn, 0)).name, "hand_left"),
        (capsule("squire_spine", 0.018, hilt - up * 0.30, hilt + up * 1.0, "blue").name, "hand_left"),
    ]
    return {"squire_shield": shield, "squire_quill": quill, "squire_hands": hooked + fist}



# Each voice's assets and what it holds; the rest pose it holds them in comes from the viewer's toonPoses.json, read here so the
# held pieces are placed in the very pose the viewer shows.
POSES = json.load(open(os.path.join(DOMAIN, "toonPoses.json")))
# What each voice wears, one piece per slot (toonWardrobes.json, which the viewer dresses from).
WARDROBES = json.load(open(os.path.join(DOMAIN, "toonWardrobes.json")), object_pairs_hook=unique)
HELD = {"butler": build_butler_held, "coach": build_coach_held, "colleague": build_colleague_held, "homie": build_homie_held, "squire": build_squire_held}
ASSETS_OF = {"butler": build_butler_assets, "coach": build_coach_assets, "colleague": build_colleague_assets, "homie": build_homie_assets, "squire": build_squire_assets}
# A voice's held pieces are built in its grip pose, the rest pose with the arms as its props were drawn into them (toonPoses.json, grip),
# and hang rigid on the hands from there, so the rest pose can turn an arm and the prop turns with the hand.
VOICES = {voice: {"assets": ASSETS_OF[voice], "held": HELD[voice], "grip": {**POSES[voice]["rest"], **POSES[voice].get("grip", {})}} for voice in POSES}


# A point along a posed bone, from its head (0) to its tail (1), in the world.
def joint(rig, bone, along=0.0):
    posed = rig.pose.bones[bone]
    return rig.matrix_world @ posed.head.lerp(posed.tail, along)


def build_rig():
    armature = bpy.data.armatures.new("rig")
    rig = bpy.data.objects.new("rig", armature)
    bpy.context.collection.objects.link(rig)
    bpy.context.view_layer.objects.active = rig
    bpy.ops.object.mode_set(mode="EDIT")

    def bone(name, head, tail, parent=None):
        made = armature.edit_bones.new(name)
        made.head, made.tail = head, tail
        if parent:
            made.parent = armature.edit_bones[parent]

    bone("hips", (0, 0, HIP - 0.04), (0, 0, HIP + 0.04))
    bone("torso", (0, 0, HIP + 0.04), (0, 0, CHIN - 0.05), "hips")
    bone("head", (0, 0, CHIN - 0.05), (0, 0, HEAD_AT.z + 0.34), "torso")
    for side, x in SIDES:
        arm, leg = joints(x), leg_joints(x)
        bone(f"upper_arm_{side}", arm["shoulder"], arm["elbow"], "torso")
        bone(f"forearm_{side}", arm["elbow"], arm["wrist"], f"upper_arm_{side}")
        bone(f"hand_{side}", arm["wrist"], (x * ARM_X, 0, HAND - 0.14), f"forearm_{side}")
        bone(f"thigh_{side}", leg["hip"], leg["knee"], "hips")
        bone(f"shin_{side}", leg["knee"], leg["ankle"], f"thigh_{side}")
        bone(f"foot_{side}", leg["ankle"], (x * LEG_X, -0.22, 0.06), f"shin_{side}")
    bpy.ops.object.mode_set(mode="OBJECT")
    for posed in rig.pose.bones:
        posed.rotation_mode = "XYZ"
    return rig


def attach(rig, obj, bone):
    world = obj.matrix_world.copy()
    obj.parent, obj.parent_type, obj.parent_bone = rig, "BONE", bone
    obj.matrix_world = world
    obj["bone"] = bone


def pose(rig, turns):
    for posed in rig.pose.bones:
        posed.rotation_euler, posed.location, posed.scale = (0, 0, 0), (0, 0, 0), (1, 1, 1)
    for name, turn in turns.items():
        posed = rig.pose.bones[name]
        posed.rotation_euler = [math.radians(turn.get(axis, 0)) for axis in ("rx", "ry", "rz")]
        posed.scale = (turn.get("s", 1),) * 3
        posed.location = (0, turn.get("lift", 0), 0)
    bpy.context.view_layer.update()


def key(rig, name, frames, tracks):
    action = bpy.data.actions.new(name)
    data = rig.animation_data or rig.animation_data_create()
    data.action = action
    data.action_slot = action.slots.new(id_type="OBJECT", name="rig")
    for bone, keys in tracks.items():
        posed = rig.pose.bones[bone]
        for frame, values in keys:
            posed.rotation_euler = [math.radians(values.get(axis, 0)) for axis in ("rx", "ry", "rz")]
            posed.location = [values.get(axis, 0) for axis in ("x", "y", "z")]
            posed.scale = [values.get(axis, 1) for axis in ("sx", "sy", "sz")]
            for path in ("rotation_euler", "location", "scale"):
                posed.keyframe_insert(path, frame=frame + 1)
    action.frame_range, action.use_frame_range = (1, frames + 1), True
    data.action = None
    track = data.nla_tracks.new()
    track.name = name
    track.strips.new(name, 1, action).action_slot = action.slots[0]


STILL = {}


# Every move the viewer plays, as scripts/mascots/moves.py writes them to toonMoves.json: each keyed as a clip on the shared body.
def animate(rig):
    for move in json.load(open(os.path.join(DOMAIN, "toonMoves.json"))):
        key(rig, move["name"], move["frames"], move["tracks"])


def cutters():
    return {modifier.object for obj in bpy.data.objects for modifier in obj.modifiers if modifier.type == "BOOLEAN"}


# The largest a figure is shown, in pixels to a metre (the preview's large view and the overlay, a 2.33 m figure about 460 px tall),
# and how far a chord may stray from the curve it stands for there: half a pixel, so every curve and silhouette stays smooth.
# No vertex is closer to the next than SPACING_PX there, so a thin part such as a lace is not tiled finer than it can show.
SHOWN_PX, STRAY_PX, SPACING_PX = 197, 0.5, 5.0


# Thins every part to the triangles its size needs at the largest view: its curvature taken as half its middle dimension, the
# segments round it that keep each chord within STRAY_PX of the curve, and as many triangles as tile its surface at that spacing.
def decimate():
    hidden = cutters()
    for obj in bpy.data.objects:
        if obj.type != "MESH" or obj in hidden or "cloth" in obj or "decimate" in obj.modifiers or not obj.data.polygons:
            continue
        radius = sorted(obj.dimensions)[1] / 2 * SHOWN_PX
        segments = max(6, math.ceil(math.pi / math.acos(max(1 - STRAY_PX / max(radius, STRAY_PX), -1))))
        spacing = max(2 * math.pi * radius / segments, SPACING_PX)
        area = sum(polygon.area for polygon in obj.data.polygons) * SHOWN_PX ** 2
        triangles = sum(len(polygon.vertices) - 2 for polygon in obj.data.polygons)
        # A flat round part, a disc or a rim, needs its segments round its edge whatever its area: both faces and the side between.
        obj.modifiers.new("decimate", "DECIMATE").ratio = min(1.0, max(2 * area / spacing ** 2, 6 * segments) / triangles)


def export(name, names, animations):
    bpy.ops.object.select_all(action="DESELECT")
    for each in names + ["rig"]:
        bpy.data.objects[each].select_set(True)
    with contextlib.redirect_stdout(io.StringIO()):
        bpy.ops.export_scene.gltf(
            filepath=os.path.join(OUT, name + ".glb"), export_format="GLB", use_selection=True, export_apply=True, export_extras=True,
            export_animations=animations, export_animation_mode="NLA_TRACKS", export_materials="EXPORT", export_texcoords=True,
        )


# Every asset of every voice by name, each a list of (mesh, bone); the body's own meshes under "body".
ASSETS = {}


def hang(rig, assets):
    for asset, parts in assets.items():
        ASSETS.setdefault(asset, []).extend(parts)
        for name, bone in parts:
            attach(rig, bpy.data.objects[name], bone)


def build():
    clear()
    bpy.context.scene.render.fps = FPS
    body = build_body()
    rig = build_rig()
    hang(rig, {"body": body})
    for voice, design in VOICES.items():
        pose(rig, {})
        hang(rig, {asset: parts for asset, parts in design["assets"]().items() if asset not in ASSETS})
        if design["held"]:
            pose(rig, design["grip"])
            hang(rig, design["held"](rig))
    pose(rig, {})
    check({asset: parts for asset, parts in ASSETS.items() if asset != "body"})
    for name, bone in sum(ASSETS.values(), []):
        made = bpy.data.objects[name]
        made.name = made.data.name = name + "_mesh"
    animate(rig)
    return rig


# Shows one voice dressed on the body with its rest face, for a look in the viewport.
def show(voice, assets):
    shown = {name + "_mesh" for asset in ["body"] + assets for name, bone in ASSETS[asset]}
    hidden_ones = cutters()
    for obj in bpy.data.objects:
        if obj.type == "MESH" and obj not in hidden_ones:
            hidden = obj.name not in shown
            obj.hide_set(hidden)
            obj.hide_viewport = hidden
            obj.hide_render = hidden
    rig = bpy.data.objects["rig"]
    for track in rig.animation_data.nla_tracks:
        track.mute = True
    pose(rig, VOICES[voice]["rest"])


def export_all():
    for track in bpy.data.objects["rig"].animation_data.nla_tracks:
        track.mute = False
    pose(bpy.data.objects["rig"], {})
    for name, parts in ASSETS.items():
        export(name, [each + "_mesh" for each, bone in parts], name == "body")


if __name__ == "__main__":
    build()
    decimate()
    export_all()
    bpy.ops.wm.save_mainfile()
