packages feed

moonlight-planar-1.1.0.0: docs/moonblade/rig.py

"""Native crescent OBJ -> solid, analytically weighted Blender sword.

Pure geometry/weight descriptions precede the Blender effect boundary.
Run: blender -b --factory-startup --python rig.py -- INPUT.obj OUTPUT_DIRECTORY
"""
from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from itertools import chain, product
from math import cos, floor, isfinite, pi, sin
from pathlib import Path
import json
import sys

import bmesh
import bpy
from mathutils import Vector


Vec3 = tuple[float, float, float]
Face = tuple[int, ...]
BLADE_LENGTH = 1.2
BONE_COUNT = 6
ROOT = "ROOT_Sword"


class Finish(Enum):
    STEEL = "Moonsteel"
    GOLD = "Pale gold"
    LEATHER = "Midnight leather"
    GLASS = "Moon glass"
    INLAY = "Ivory moon"


class Control(Enum):
    SIDE = "bend_side_deg"
    FLAT = "bend_flat_deg"
    TWIST = "twist_deg"


@dataclass(frozen=True)
class Drive:
    control: Control
    axis: int
    limit: float


DRIVES = (Drive(Control.SIDE, 2, 25.0), Drive(Control.FLAT, 0, 15.0), Drive(Control.TWIST, 1, 25.0))


@dataclass(frozen=True)
class Pose:
    name: str
    side: float = 0.0
    flat: float = 0.0
    twist: float = 0.0

    def value(self, control: Control) -> float:
        match control:
            case Control.SIDE: return self.side
            case Control.FLAT: return self.flat
            case Control.TWIST: return self.twist


@dataclass(frozen=True)
class MeshSpec:
    name: str
    vertices: tuple[Vec3, ...]
    faces: tuple[Face, ...]
    finish: Finish


@dataclass(frozen=True)
class Weight:
    bone: str
    value: float


def bone_name(index: int) -> str:
    return f"BLADE_{index + 1:02d}"


def skin_weights(z: float) -> tuple[Weight, ...]:
    """Adjacent-bone smoothstep partition of unity; the base is rigid."""
    u = max(0.0, min(BONE_COUNT - 1.0, z * BONE_COUNT / BLADE_LENGTH - 0.5))
    lower = floor(u)
    upper = min(BONE_COUNT - 1, lower + 1)
    t = u - lower
    mix = t * t * (3.0 - 2.0 * t)
    return (Weight(bone_name(lower), 1.0),) if lower == upper else tuple(filter(
        lambda weight: weight.value > 0.0,
        (Weight(bone_name(lower), 1.0 - mix), Weight(bone_name(upper), mix)),
    ))


def lathe(name: str, profile: tuple[tuple[float, float], ...], finish: Finish, sides: int = 32,
          flatten: float = 1.0) -> MeshSpec:
    vertices = tuple(map(lambda sample: (
        profile[sample[0]][1] * cos(2 * pi * sample[1] / sides),
        flatten * profile[sample[0]][1] * sin(2 * pi * sample[1] / sides),
        profile[sample[0]][0],
    ), product(range(len(profile)), range(sides))))
    walls = tuple(map(lambda cell: (
        cell[0] * sides + cell[1], cell[0] * sides + (cell[1] + 1) % sides,
        (cell[0] + 1) * sides + (cell[1] + 1) % sides, (cell[0] + 1) * sides + cell[1],
    ), product(range(len(profile) - 1), range(sides))))
    caps = (tuple(reversed(range(sides))), tuple(range((len(profile) - 1) * sides, len(profile) * sides)))
    return MeshSpec(name, vertices, walls + caps, finish)


def torus(name: str, center: Vec3, radius: float, thickness: float, finish: Finish) -> MeshSpec:
    major, minor = 64, 8
    vertices = tuple(map(lambda sample: (
        center[0] + (radius + thickness * cos(2 * pi * sample[1] / minor)) * cos(2 * pi * sample[0] / major),
        center[1] + thickness * sin(2 * pi * sample[1] / minor),
        center[2] + (radius + thickness * cos(2 * pi * sample[1] / minor)) * sin(2 * pi * sample[0] / major),
    ), product(range(major), range(minor))))
    faces = tuple(map(lambda cell: (
        cell[0] * minor + cell[1], ((cell[0] + 1) % major) * minor + cell[1],
        ((cell[0] + 1) % major) * minor + (cell[1] + 1) % minor,
        cell[0] * minor + (cell[1] + 1) % minor,
    ), product(range(major), range(minor))))
    return MeshSpec(name, vertices, faces, finish)


def diamond(name: str, width: float, height: float, front: float, finish: Finish) -> MeshSpec:
    return MeshSpec(name,
        ((0.0, -0.023, height), (width, -0.023, 0.0), (0.0, -0.023, -height),
         (-width, -0.023, 0.0), (0.0, front, 0.0), (0.0, 0.016, 0.0)),
        tuple(chain.from_iterable(map(lambda index: (
            (index, (index + 1) % 4, 4), ((index + 1) % 4, index, 5),
        ), range(4)))), finish)


def binding(index: int) -> MeshSpec:
    z = -0.068 - index * 0.0155
    return lathe(f"Grip binding {index + 1:02d}",
        ((z - 0.002, 0.028), (z, 0.0295), (z + 0.002, 0.028)), Finish.GOLD, flatten=0.78)


def pommel_disc() -> MeshSpec:
    disc = lathe("Pommel moon glass", ((-0.016, 0.042), (0.0, 0.046), (0.016, 0.042)), Finish.GLASS)
    return MeshSpec(disc.name, tuple(map(lambda v: (v[0], v[2], v[1] - 226 / 750), disc.vertices)), disc.faces, disc.finish)


def crescent_inlay() -> MeshSpec:
    """A shallow closed crescent ribbon, not a texture painted on the pommel."""
    steps = 40
    # Two meridian curves meet at their tips; interior cross-sections remain quads.
    rows = tuple(map(lambda index: index / steps, range(1, steps)))
    tip_top, tip_bottom = (0.015, -0.021, -0.269), (0.015, -0.021, -0.334)
    ribbon = tuple(chain.from_iterable(map(lambda t: (
        (0.015 - 0.047 * sin(pi * t), -0.021, -0.269 - 0.065 * t),
        (0.015 - 0.028 * sin(pi * t), -0.021, -0.269 - 0.065 * t),
    ), rows)))
    front = (tip_top,) + ribbon + (tip_bottom,)
    n = len(front)
    front_faces = ((0, 1, 2),) + tuple(map(lambda row: (
        1 + 2 * row, 3 + 2 * row, 4 + 2 * row, 2 + 2 * row,
    ), range(len(rows) - 1))) + ((n - 3, n - 1, n - 2),)
    boundary = (0,) + tuple(range(1, n - 1, 2)) + (n - 1,) + tuple(reversed(range(2, n - 1, 2)))
    back = tuple(map(lambda v: (v[0], v[1] + 0.002, v[2]), front))
    walls = tuple(map(lambda edge: (edge[0], edge[1], edge[1] + n, edge[0] + n),
        zip(boundary, boundary[1:] + boundary[:1])))
    return MeshSpec("Pommel crescent inlay", front + back,
        front_faces + tuple(map(lambda face: tuple(map(lambda i: i + n, reversed(face))), front_faces)) + walls,
        Finish.INLAY)


def fittings() -> tuple[MeshSpec, ...]:
    return (
        lathe("Leather grip", ((-0.27, 0.022), (-0.25, 0.027), (-0.06, 0.03), (-0.025, 0.032)), Finish.LEATHER, flatten=0.78),
        lathe("Grip lower ferrule", ((-0.271, 0.024), (-0.261, 0.03), (-0.252, 0.03)), Finish.GOLD, flatten=0.8),
        lathe("Grip upper ferrule", ((-0.056, 0.032), (-0.032, 0.034)), Finish.GOLD, flatten=0.8),
        diamond("Guard gemstone setting", 0.043, 0.072, -0.037, Finish.GOLD),
        diamond("Guard moon glass", 0.031, 0.053, -0.052, Finish.GLASS),
        pommel_disc(), torus("Pommel rim", (0.0, -0.012, -226 / 750), 0.045, 0.0028, Finish.GOLD),
        crescent_inlay(),
    ) + tuple(map(binding, range(12)))


POSE_TESTS = (
    Pose("neutral"), Pose("side positive", side=25), Pose("side negative", side=-25),
    Pose("flat positive", flat=15), Pose("flat negative", flat=-15),
    Pose("twist positive", twist=25), Pose("twist negative", twist=-25),
    Pose("combined positive", 25, 15, 25), Pose("combined negative", -25, -15, -25),
    Pose("above limit", 250, 150, 250),
)


# Blender effect boundary: construct data blocks, bind, evaluate, save and render.
def finish_material(finish: Finish, shade: int = 0) -> bpy.types.Material:
    material = bpy.data.materials.new(f"{finish.value} {shade:02d}")
    match finish:
        case Finish.STEEL: color, metal, rough = (0.10 + shade * 0.018, 0.27 + shade * 0.027, 0.38 + shade * 0.031, 1.0), 0.78, 0.3
        case Finish.GOLD: color, metal, rough = (0.48 + shade * 0.035, 0.36 + shade * 0.032, 0.16 + shade * 0.024, 1.0), 0.83, 0.3
        case Finish.LEATHER: color, metal, rough = (0.009, 0.018, 0.028, 1.0), 0.08, 0.68
        case Finish.GLASS: color, metal, rough = (0.028, 0.27, 0.40, 1.0), 0.55, 0.16
        case Finish.INLAY: color, metal, rough = (0.75, 0.84, 0.68, 1.0), 0.4, 0.27
    material.diffuse_color = color
    material.use_nodes = True
    shader = material.node_tree.nodes.get("Principled BSDF")
    shader.inputs["Base Color"].default_value = color
    shader.inputs["Metallic"].default_value = metal
    shader.inputs["Roughness"].default_value = rough
    return material


def clean_mesh(mesh: bpy.types.Mesh, weld: bool = False) -> None:
    editable = bmesh.new()
    editable.from_mesh(mesh)
    if weld:
        bmesh.ops.remove_doubles(editable, verts=tuple(editable.verts), dist=1e-8)
    bmesh.ops.recalc_face_normals(editable, faces=tuple(editable.faces))
    editable.to_mesh(mesh)
    editable.free()
    mesh.update()


def assign_materials(obj: bpy.types.Object, materials: tuple[bpy.types.Material, ...]) -> None:
    tuple(map(obj.data.materials.append, materials))
    obj.data.polygons.foreach_set("material_index", tuple(map(
        lambda face: int((0.5 + 0.5 * sin(face.index * 1.719)) * (len(materials) - 1)), obj.data.polygons)))


def add_uvs(mesh: bpy.types.Mesh) -> None:
    lower_x, upper_x = min(map(lambda v: v.co.x, mesh.vertices)), max(map(lambda v: v.co.x, mesh.vertices))
    lower_z, upper_z = min(map(lambda v: v.co.z, mesh.vertices)), max(map(lambda v: v.co.z, mesh.vertices))
    uv = mesh.uv_layers.new(name="Surface projection")
    values = tuple(chain.from_iterable(map(lambda loop: (
        (mesh.vertices[loop.vertex_index].co.x - lower_x) / max(1e-8, upper_x - lower_x),
        (mesh.vertices[loop.vertex_index].co.z - lower_z) / max(1e-8, upper_z - lower_z),
    ), mesh.loops)))
    uv.data.foreach_set("uv", values)


def create_mesh(spec: MeshSpec) -> bpy.types.Object:
    mesh = bpy.data.meshes.new(spec.name)
    mesh.from_pydata(spec.vertices, (), spec.faces)
    clean_mesh(mesh)
    obj = bpy.data.objects.new(spec.name, mesh)
    bpy.context.scene.collection.objects.link(obj)
    assign_materials(obj, (finish_material(spec.finish),))
    add_uvs(mesh)
    return obj


def import_native(path: Path) -> tuple[bpy.types.Object, bpy.types.Object]:
    bpy.ops.wm.obj_import(filepath=str(path), forward_axis="Y", up_axis="Z")
    blade, guard = bpy.data.objects["Blade"], bpy.data.objects["Guard"]
    tuple(map(lambda obj: clean_mesh(obj.data, weld=True), (blade, guard)))
    assign_materials(blade, tuple(map(lambda index: finish_material(Finish.STEEL, index), range(8))))
    assign_materials(guard, tuple(map(lambda index: finish_material(Finish.GOLD, index), range(6))))
    tuple(map(lambda obj: add_uvs(obj.data), (blade, guard)))
    return blade, guard


def edit_blade_bone(armature: bpy.types.Armature, index: int) -> None:
    bone = armature.edit_bones.new(bone_name(index))
    bone.head = (0.0, 0.0, index * BLADE_LENGTH / BONE_COUNT)
    bone.tail = (0.0, 0.0, (index + 1) * BLADE_LENGTH / BONE_COUNT)
    bone.parent = armature.edit_bones[ROOT if index == 0 else bone_name(index - 1)]
    bone.use_connect = True


def add_control(root: bpy.types.PoseBone, drive: Drive) -> None:
    root[drive.control.value] = 0.0
    root.id_properties_ui(drive.control.value).update(
        min=-drive.limit, max=drive.limit, soft_min=-drive.limit, soft_max=drive.limit,
        description=f"Total {drive.control.value.replace('_', ' ')}. Clamped; base, grip and guard stay rigid.")


def bone_driver(rig: bpy.types.Object, bone: bpy.types.PoseBone, drive: Drive) -> None:
    driver = bone.driver_add("rotation_euler", drive.axis).driver
    variable = driver.variables.new()
    variable.name = "angle"
    variable.type = "SINGLE_PROP"
    variable.targets[0].id = rig
    variable.targets[0].data_path = f'pose.bones["{ROOT}"]["{drive.control.value}"]'
    driver.expression = f"min(max(angle,{-drive.limit}),{drive.limit})*{pi / 180 / (BONE_COUNT - 1):.16f}"


def configure_blade_bone(rig: bpy.types.Object, index: int) -> None:
    bone = rig.pose.bones[bone_name(index)]
    bone.rotation_mode = "XYZ"
    bone.lock_location = (True, True, True)
    bone.lock_scale = (True, True, True)
    bone.lock_rotation = (True, True, True)
    bone.bone.color.palette = "THEME04"
    if index > 0:
        tuple(map(lambda drive: bone_driver(rig, bone, drive), DRIVES))


def create_rig() -> bpy.types.Object:
    armature = bpy.data.armatures.new("Vesper skeleton")
    rig = bpy.data.objects.new("VESPER | Rig", armature)
    bpy.context.scene.collection.objects.link(rig)
    bpy.context.view_layer.objects.active = rig
    rig.select_set(True)
    bpy.ops.object.mode_set(mode="EDIT")
    root = armature.edit_bones.new(ROOT)
    root.head, root.tail = (0.0, 0.0, -0.28), (0.0, 0.0, 0.0)
    tuple(map(lambda index: edit_blade_bone(armature, index), range(BONE_COUNT)))
    bpy.ops.object.mode_set(mode="OBJECT")
    rig.show_in_front = True
    armature.display_type = "BBONE"
    root_pose = rig.pose.bones[ROOT]
    root_pose.bone.color.palette = "THEME03"
    root_pose.lock_scale = (True, True, True)
    tuple(map(lambda drive: add_control(root_pose, drive), DRIVES))
    tuple(map(lambda index: configure_blade_bone(rig, index), range(BONE_COUNT)))
    return rig


def bind_mesh(obj: bpy.types.Object, rig: bpy.types.Object, deform: bool) -> None:
    names = tuple(map(bone_name, range(BONE_COUNT))) if deform else (ROOT,)
    groups = dict(map(lambda name: (name, obj.vertex_groups.new(name=name)), names))
    weights = tuple(map(lambda vertex: (vertex.index, skin_weights(vertex.co.z) if deform else (Weight(ROOT, 1.0),)), obj.data.vertices))
    tuple(map(lambda vertex: tuple(map(lambda weight: groups[weight.bone].add((vertex[0],), weight.value, "REPLACE"), vertex[1])), weights))
    modifier = obj.modifiers.new("Analytic skin | six bones", "ARMATURE")
    modifier.object = rig
    modifier.use_deform_preserve_volume = False
    modifier.use_bone_envelopes = False
    obj.parent = rig


def apply_pose(rig: bpy.types.Object, pose: Pose) -> None:
    root = rig.pose.bones[ROOT]
    tuple(map(lambda drive: root.__setitem__(drive.control.value, pose.value(drive.control)), DRIVES))
    rig.update_tag()
    bpy.context.view_layer.update()


def coordinates(obj: bpy.types.Object) -> tuple[Vec3, ...]:
    evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
    return tuple(map(lambda vertex: tuple(vertex.co), evaluated.data.vertices))


def topology_report(obj: bpy.types.Object) -> dict[str, object]:
    editable = bmesh.new()
    editable.from_mesh(obj.data)
    report = {"object": obj.name, "vertices": len(editable.verts), "faces": len(editable.faces),
              "nonmanifold_edges": sum(map(lambda edge: not edge.is_manifold, editable.edges)),
              "zero_area_faces": sum(map(lambda face: face.calc_area() <= 1e-16, editable.faces)),
              "max_weight_error": max(map(lambda vertex: abs(sum(map(lambda weight: weight.weight, vertex.groups)) - 1.0), obj.data.vertices))}
    editable.free()
    return report


def triangle_area(points: tuple[Vec3, ...], indices: tuple[int, int, int]) -> float:
    a, b, c = tuple(map(lambda index: Vector(points[index]), indices))
    return (b - a).cross(c - a).length * 0.5


def pose_report(rig: bpy.types.Object, blade: bpy.types.Object, rigid: tuple[bpy.types.Object, ...],
                rest: tuple[Vec3, ...], rigid_rest: tuple[tuple[Vec3, ...], ...], pose: Pose) -> dict[str, object]:
    apply_pose(rig, pose)
    posed = coordinates(blade)
    triangles = tuple(map(lambda triangle: tuple(triangle.vertices), blade.data.loop_triangles))
    ratios = tuple(map(lambda triangle: triangle_area(posed, triangle) / triangle_area(rest, triangle), triangles))
    drift = max(chain.from_iterable(map(lambda pair: map(
        lambda points: (Vector(points[0]) - Vector(points[1])).length, zip(coordinates(pair[0]), pair[1])), zip(rigid, rigid_rest))))
    return {"pose": pose.name, "finite": all(map(isfinite, chain.from_iterable(posed))),
            "min_face_area_ratio": min(ratios), "max_face_area_ratio": max(ratios), "rigid_drift_m": drift,
            "tip": posed[max(range(len(rest)), key=lambda index: rest[index][2])],
            "max_vertex_displacement_m": max(map(lambda pair: (Vector(pair[0]) - Vector(pair[1])).length, zip(rest, posed)))}


def validate_rig(rig: bpy.types.Object, blade: bpy.types.Object, rigid: tuple[bpy.types.Object, ...]) -> dict[str, object]:
    apply_pose(rig, Pose("neutral"))
    blade.data.calc_loop_triangles()
    rest, rigid_rest = coordinates(blade), tuple(map(coordinates, rigid))
    topology = tuple(map(topology_report, (blade,) + rigid))
    poses = tuple(map(lambda pose: pose_report(rig, blade, rigid, rest, rigid_rest, pose), POSE_TESTS))
    apply_pose(rig, Pose("neutral"))
    passed = all(map(lambda entry: entry["nonmanifold_edges"] == 0 and entry["zero_area_faces"] == 0 and entry["max_weight_error"] < 1e-6, topology)) and all(map(
        lambda entry: entry["finite"] and entry["min_face_area_ratio"] > 0.15 and entry["max_face_area_ratio"] < 5.0 and entry["rigid_drift_m"] < 1e-7, poses))
    clamped = (Vector(poses[-1]["tip"]) - Vector(poses[-3]["tip"])).length < 1e-7
    return {"passed": passed and clamped, "bounds_enforced": clamped, "topology": topology, "poses": poses,
            "scope": "Sampled bounded poses; not collision certification or physical metal simulation."}


def key_pose(rig: bpy.types.Object, frame: int, pose: Pose) -> None:
    apply_pose(rig, pose)
    tuple(map(lambda drive: rig.pose.bones[ROOT].keyframe_insert(data_path=f'["{drive.control.value}"]', frame=frame), DRIVES))


def animate_rig(rig: bpy.types.Object) -> None:
    tuple(map(lambda sample: key_pose(rig, sample[0], sample[1]), (
        (1, Pose("neutral")), (25, Pose("side", side=25)), (49, Pose("flat", flat=15)),
        (73, Pose("twist", twist=25)), (97, Pose("combined", -25, -15, -25)), (121, Pose("neutral")),
    )))
    rig.animation_data.action.name = "Vesper | bounded blade demonstration"
    bpy.context.scene.frame_start, bpy.context.scene.frame_end = 1, 120
    bpy.context.scene.render.fps = 24
    bpy.context.scene.frame_set(1)


def aim(obj: bpy.types.Object, target: Vec3) -> None:
    obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler()


def area_light(name: str, location: Vec3, color: Vec3, energy: float, size: float) -> None:
    data = bpy.data.lights.new(name, "AREA")
    data.energy, data.color, data.shape, data.size = energy, color, "DISK", size
    obj = bpy.data.objects.new(name, data)
    bpy.context.scene.collection.objects.link(obj)
    obj.location = location
    aim(obj, (0.0, 0.0, 0.4))


def stage() -> None:
    scene = bpy.context.scene
    scene.render.engine = "CYCLES"
    scene.cycles.samples = 24
    scene.cycles.use_denoising = True
    scene.render.resolution_x, scene.render.resolution_y, scene.render.resolution_percentage = 840, 1080, 100
    scene.render.image_settings.file_format = "PNG"
    scene.world = bpy.data.worlds.new("Midnight studio")
    scene.world.use_nodes = True
    scene.world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.065, 0.1, 0.18, 1.0)
    scene.world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.32
    camera_data = bpy.data.cameras.new("Vesper portrait")
    camera = bpy.data.objects.new("Vesper portrait", camera_data)
    scene.collection.objects.link(camera)
    camera.location = (0.95, -4.8, 1.55)
    camera_data.type, camera_data.ortho_scale = "ORTHO", 1.93
    aim(camera, (0.035, 0.0, 0.43))
    scene.camera = camera
    tuple(map(lambda spec: area_light(*spec), (
        ("Silver softbox", (-2.0, -3.0, 2.5), (0.64, 0.84, 1.0), 420.0, 3.0),
        ("Gold strip", (2.0, -1.5, 1.1), (1.0, 0.75, 0.43), 260.0, 1.4),
        ("Moon rim", (0.5, 2.0, 1.8), (0.23, 0.63, 1.0), 560.0, 2.0),
    )))
    floor_spec = MeshSpec("Studio floor", ((-100, -100, -0.39), (100, -100, -0.39), (100, 100, -0.39), (-100, 100, -0.39)), ((0, 1, 2, 3),), Finish.LEATHER)
    floor_obj = create_mesh(floor_spec)
    floor_obj.data.materials[0].node_tree.nodes.get("Principled BSDF").inputs["Roughness"].default_value = 0.42


GUIDE = """VESPER - RIGGED CRESCENT MOONBLADE

Select VESPER | Rig. In Pose Mode select ROOT_Sword.
Move/rotate ROOT_Sword to place the complete rigid sword.
N sidebar > Item > Properties (Custom Properties):
  bend_side_deg: -25 to +25 degrees
  bend_flat_deg: -15 to +15 degrees
  twist_deg:     -25 to +25 degrees
Zero all three for a rigid sword. Blender drivers clamp even scripted values.
The first blade segment and the grip, guard, gemstone and pommel stay rigid.
Six connected blade bones use analytic smoothstep weights, at most two per vertex.
No automatic weights, cloth solver, simulation cache or corrective shape keys.

Frames 1-120: a five-second sampled bend/twist demonstration. Frame 1 is neutral.
Clear the 'Vesper | bounded blade demonstration' action to pose without animation.
Native .blend keeps the controls. GLB retains skin/bones and baked animation;
Blender custom-property drivers are not portable glTF controls.

Solid native CDT blade and guard; fuller is an actual open tunnel. Material
facets are authored; projection UVs are provided, not a unique bake-ready atlas.
This is a stylized rigged prop, not a collision-certified or physical metal model.
"""


def save_asset(rig: bpy.types.Object, objects: tuple[bpy.types.Object, ...], output: Path) -> None:
    bpy.ops.object.select_all(action="DESELECT")
    tuple(map(lambda obj: obj.select_set(True), (rig,) + objects))
    bpy.context.view_layer.objects.active = rig
    bpy.ops.export_scene.gltf(filepath=str(output / "vesper-rig.glb"), export_format="GLB", use_selection=True,
        export_animations=True, export_animation_mode="SCENE", export_force_sampling=True,
        export_bake_animation=True, export_frame_range=True, export_def_bones=True,
        export_skins=True, export_materials="EXPORT", export_extras=True)
    bpy.ops.object.select_all(action="DESELECT")
    rig.select_set(True)
    bpy.context.view_layer.objects.active = rig
    bpy.ops.object.mode_set(mode="POSE")
    rig.data.bones.active = rig.data.bones[ROOT]
    rig.pose.bones[ROOT].select = True
    tuple(map(configure_viewport, chain.from_iterable(map(
        lambda screen: chain.from_iterable(map(lambda area: filter(lambda space: space.type == "VIEW_3D", area.spaces), screen.areas)),
        bpy.data.screens))))
    bpy.data.texts.new("START HERE - Vesper controls").write(GUIDE)
    bpy.ops.wm.save_as_mainfile(filepath=str(output / "vesper-rig.blend"))
    (output / "README.txt").write_text(GUIDE)


def configure_viewport(space: bpy.types.SpaceView3D) -> None:
    space.region_3d.view_perspective = "CAMERA"
    space.show_region_ui = True
    space.shading.type = "SOLID"
    space.shading.color_type = "MATERIAL"
    space.overlay.show_floor = False


def main() -> None:
    args = tuple(sys.argv[sys.argv.index("--") + 1:]) if "--" in sys.argv else ()
    if len(args) != 2:
        raise SystemExit("usage: blender -b --factory-startup --python rig.py -- INPUT.obj OUTPUT_DIRECTORY")
    source, output = Path(args[0]), Path(args[1])
    output.mkdir(parents=True, exist_ok=True)
    bpy.ops.wm.read_factory_settings(use_empty=True)
    blade, guard = import_native(source)
    ornaments = tuple(map(create_mesh, fittings()))
    rig = create_rig()
    bind_mesh(blade, rig, deform=True)
    tuple(map(lambda obj: bind_mesh(obj, rig, deform=False), (guard,) + ornaments))
    report = validate_rig(rig, blade, (guard,) + ornaments)
    (output / "deformation-checks.json").write_text(json.dumps(report, indent=2))
    print(json.dumps({"passed": report["passed"], "poses": report["poses"]}), flush=True)
    if not report["passed"]:
        raise SystemExit("Rig checks failed; see deformation-checks.json")
    animate_rig(rig)
    stage()
    save_asset(rig, (blade, guard) + ornaments, output)
    bpy.context.scene.render.filepath = str(output / "vesper-rig.png")
    bpy.ops.render.render(write_still=True)


if __name__ == "__main__":
    main()