"""Build the Kirk steampunk robot from reference/kirk-front.jpg and kirk-side.jpg.

Run:  blender -b -P tools/build_model.py

Units: helpers take INCHES with z=0 at the bottom of the base plate; the scene is
written in metres (1 in = 0.0254 m) so the GLB is real-world size and the STL is
exported in millimetres.

Every dimension is read from the drawings by pixel position:
  front view: x = (px-424)/174.5 in, z = (1135-py)/174.5 in  (overall 6.00 in)
  side view : y = (px-450)/179 in (front of the robot is -Y),
              z = 0.23 + (1122-py)/179 in
See docs/README.md for the measurement table and the reconciliation of the
drawing's inconsistent dimension labels.
"""
import math
import os

import bmesh
import bpy
from mathutils import Matrix, Vector

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
IN = 0.0254
PLATE_T = 0.23   # plate thickness, in
PLATE_R = 2.10   # plate radius, in (the drawing's plate is ~4.2 in across)

bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.unit_settings.system = "METRIC"
scene.unit_settings.scale_length = 1.0
scene.unit_settings.length_unit = "MILLIMETERS"

# ---------------------------------------------------------------- materials
MATS = {}


def material(name, color, metallic=0.0, rough=0.5, emit=None, emit_strength=0.0, coat=0.0):
    m = bpy.data.materials.new(name)
    m.use_nodes = True
    b = m.node_tree.nodes["Principled BSDF"]
    b.inputs["Base Color"].default_value = (*color, 1)
    b.inputs["Metallic"].default_value = metallic
    b.inputs["Roughness"].default_value = rough
    if coat:
        b.inputs["Coat Weight"].default_value = coat
        b.inputs["Coat Roughness"].default_value = 0.15
    if emit:
        b.inputs["Emission Color"].default_value = (*emit, 1)
        b.inputs["Emission Strength"].default_value = emit_strength
    MATS[name] = m
    return m


material("Brass", (0.78, 0.58, 0.22), metallic=0.10, rough=0.55, coat=0.10)
material("Steel", (0.40, 0.43, 0.49), metallic=0.20, rough=0.55)
material("Glass", (0.36, 0.38, 0.42), metallic=0.0, rough=0.10, coat=0.8)
material("Amber", (0.8, 0.3, 0.02), rough=0.3, emit=(1.0, 0.45, 0.04), emit_strength=0.8)
material("Glow", (1.0, 0.9, 0.5), rough=0.3, emit=(1.0, 0.86, 0.45), emit_strength=1.5)
material("Dark", (0.03, 0.03, 0.035), metallic=0.2, rough=0.5)
material("Plate", (0.70, 0.70, 0.70), metallic=0.20, rough=0.55)

GROUPS = {}  # (group, material) -> [objects]


def _register(obj, group, mat):
    obj.data.materials.clear()
    obj.data.materials.append(MATS[mat])
    obj.name = f"{group}_{obj.name}"
    GROUPS.setdefault((group, mat), []).append(obj)
    return obj


def _smooth(obj, angle=0.75):
    bpy.context.view_layer.objects.active = obj
    obj.select_set(True)
    bpy.ops.object.shade_smooth_by_angle(angle=angle)
    obj.select_set(False)


def _apply_mods(obj):
    bpy.context.view_layer.objects.active = obj
    for m in list(obj.modifiers):
        bpy.ops.object.modifier_apply(modifier=m.name)


def _finish(obj, group, mat, smooth=True):
    _apply_mods(obj)
    if smooth:
        _smooth(obj)
    return _register(obj, group, mat)


def _orient(obj, direction):
    d = Vector(direction).normalized()
    obj.rotation_euler = d.to_track_quat("Z", "Y").to_euler()


def _place(obj, pos):
    obj.location = pos * IN
    bpy.context.view_layer.update()


# ---------------------------------------------------------------- primitives
def rbox(group, mat, center, size, radius=0.06, rot=(0, 0, 0), segs=4):
    """Rounded box; center/size in inches (already final scale)."""
    bpy.ops.mesh.primitive_cube_add(size=1)
    o = bpy.context.active_object
    o.scale = (size[0] * IN, size[1] * IN, size[2] * IN)
    bpy.ops.object.transform_apply(scale=True)
    bev = o.modifiers.new("b", "BEVEL")
    bev.width = radius * IN
    bev.segments = segs
    bev.limit_method = "NONE"
    o.rotation_euler = rot
    _place(o, Vector(center))
    return _finish(o, group, mat)


def sphere(group, mat, center, radii, seg=24, rings=14, rot=(0, 0, 0)):
    bpy.ops.mesh.primitive_uv_sphere_add(segments=seg, ring_count=rings, radius=1)
    o = bpy.context.active_object
    o.scale = (radii[0] * IN, radii[1] * IN, radii[2] * IN)
    bpy.ops.object.transform_apply(scale=True)
    o.rotation_euler = rot
    _place(o, Vector(center))
    return _finish(o, group, mat)


def superellipsoid(group, mat, center, radii, n=2.6, seg=64, rings=40, rot=(0, 0, 0), n_lo=None):
    """Rounded-square ellipsoid: |x/a|^n + |y/b|^n + |z/c|^n = 1."""
    bm = bmesh.new()
    bmesh.ops.create_uvsphere(bm, u_segments=seg, v_segments=rings, radius=1.0)
    for v in bm.verts:
        d = v.co.copy()
        if d.length == 0:
            continue
        d.normalize()
        if n_lo is None:
            nn = n
        else:  # blend the exponent smoothly from the dome (n) to the boxier lower half (n_lo)
            t = min(1.0, max(0.0, (0.25 - d.z) / 0.75))
            nn = n + (n_lo - n) * t * t * (3 - 2 * t)
        k = (abs(d.x) ** nn + abs(d.y) ** nn + abs(d.z) ** nn) ** (1.0 / nn)
        v.co = Vector((d.x / k * radii[0], d.y / k * radii[1], d.z / k * radii[2])) * IN
    me = bpy.data.meshes.new("se")
    bm.to_mesh(me)
    bm.free()
    o = bpy.data.objects.new("se", me)
    bpy.context.collection.objects.link(o)
    o.rotation_euler = rot
    _place(o, Vector(center))
    return _finish(o, group, mat)


def cyl(group, mat, p0, p1, r, verts=32, bevel=0.0, r1=None):
    p0, p1 = Vector(p0), Vector(p1)
    d = p1 - p0
    if r1 is None:
        bpy.ops.mesh.primitive_cylinder_add(vertices=verts, radius=r * IN, depth=d.length * IN)
    else:
        bpy.ops.mesh.primitive_cone_add(vertices=verts, radius1=r * IN, radius2=r1 * IN, depth=d.length * IN)
    o = bpy.context.active_object
    if bevel:
        bv = o.modifiers.new("b", "BEVEL")
        bv.width = bevel * IN
        bv.segments = 3
    _orient(o, d)
    _place(o, (p0 + p1) / 2)
    return _finish(o, group, mat)


def torus(group, mat, center, major, minor, axis=(0, 0, 1), seg=40, minseg=10, scale=(1, 1, 1)):
    bpy.ops.mesh.primitive_torus_add(
        major_radius=major * IN, minor_radius=minor * IN, major_segments=seg, minor_segments=minseg
    )
    o = bpy.context.active_object
    o.scale = scale
    bpy.ops.object.transform_apply(scale=True)
    _orient(o, axis)
    _place(o, Vector(center))
    return _finish(o, group, mat)


def rivet(group, mat, pos, r=0.028):
    return sphere(group, mat, pos, (r, r, r), seg=10, rings=6)


def tube(group, mat, pts, r, closed=False, res=6, xscale=1.0):
    """Round bar along a polyline of inch points; xscale flattens it into a fin."""
    cu = bpy.data.curves.new("tube", "CURVE")
    cu.dimensions = "3D"
    cu.bevel_depth = r * IN
    cu.bevel_resolution = res
    cu.use_fill_caps = True
    sp = cu.splines.new("POLY")
    sp.points.add(len(pts) - 1)
    for pt, q in zip(sp.points, pts):
        pt.co = (q[0] * IN, q[1] * IN, q[2] * IN, 1)
    sp.use_cyclic_u = closed
    o = bpy.data.objects.new("tube", cu)
    bpy.context.collection.objects.link(o)
    bpy.context.view_layer.objects.active = o
    o.select_set(True)
    bpy.ops.object.convert(target="MESH")
    o = bpy.context.active_object
    if xscale != 1.0:
        o.scale = (xscale, 1, 1)
        bpy.ops.object.transform_apply(scale=True)
    return _finish(o, group, mat)


def ring_of_rivets(group, mat, center, radius, count, axis="y", r=0.028, start=0.0, arc=2 * math.pi):
    out = []
    for i in range(count):
        a = start + arc * i / count
        if axis == "y":  # ring in XZ plane, facing -Y
            off = Vector((math.cos(a) * radius, 0, math.sin(a) * radius))
        else:  # ring in YZ plane
            off = Vector((0, math.cos(a) * radius, math.sin(a) * radius))
        out.append(rivet(group, mat, Vector(center) + off, r))
    return out



# ================================================================== BUILD
sxs = (-1, 1)
V = Vector


def unit(v):
    return V(v).normalized()


def capsule(group, mat, p0, p1, r, verts=16):
    cyl(group, mat, p0, p1, r, verts=verts)
    sphere(group, mat, p1, (r, r, r), seg=14, rings=8)


# ---- base plate: perforated disc, 4.2 in across
bpy.ops.mesh.primitive_cylinder_add(vertices=112, radius=PLATE_R * IN, depth=PLATE_T * IN)
plate = bpy.context.active_object
bv = plate.modifiers.new("b", "BEVEL")
bv.width = 0.035 * IN
bv.segments = 3
_place(plate, V((0, 0, PLATE_T / 2)))
_apply_mods(plate)
holes = []
for ring, count, off in ((1.90, 40, 0.0), (1.52, 26, 0.5), (1.12, 18, 0.0)):
    for i in range(count):
        a = 2 * math.pi * (i + off) / count
        bpy.ops.mesh.primitive_cylinder_add(vertices=12, radius=0.045 * IN, depth=PLATE_T * 3 * IN)
        h = bpy.context.active_object
        _place(h, V((math.cos(a) * ring, math.sin(a) * ring, PLATE_T / 2)))
        holes.append(h)
bpy.ops.object.select_all(action="DESELECT")
for h in holes:
    h.select_set(True)
bpy.context.view_layer.objects.active = holes[0]
bpy.ops.object.join()
cutter = bpy.context.active_object
bm_ = plate.modifiers.new("holes", "BOOLEAN")
bm_.object = cutter
bm_.operation = "DIFFERENCE"
bm_.solver = "EXACT"
_apply_mods(plate)
bpy.data.objects.remove(cutter, do_unlink=True)
_smooth(plate)
_register(plate, "Base", "Plate")
torus("Base", "Steel", V((0, 0, PLATE_T)), 2.02, 0.03, seg=112, minseg=8)
torus("Base", "Steel", V((0, 0, PLATE_T)), 0.88, 0.02, seg=96, minseg=6)

# ---- boots, shins, knees, thighs, hip plates
for sx in sxs:
    bx = 0.92 * sx
    superellipsoid("Legs", "Brass", V((bx, -0.10, 0.30)), (0.62, 0.92, 0.075), n=3.0, seg=64, rings=20)
    superellipsoid("Legs", "Brass", V((bx, -0.28, 0.44)), (0.54, 0.84, 0.42), n=2.3, seg=64, rings=32)
    rbox("Legs", "Brass", V((bx, 0.36, 0.62)), (0.62, 0.55, 0.42), radius=0.12)
    rim = []
    for i in range(72):
        a = 2 * math.pi * i / 72
        c_, s_ = math.cos(a), math.sin(a)
        rim.append(V((bx + 0.585 * math.copysign(abs(c_) ** (2 / 2.6), c_), -0.20 + 0.88 * math.copysign(abs(s_) ** (2 / 2.6), s_), 0.385)))
    tube("Legs", "Steel", rim, 0.03, closed=True)
    for side in sxs:
        dx = bx + side * 0.33
        ax = V((side, 0, 0))
        c = V((dx, 0.34, 0.50))
        torus("Legs", "Steel", c, 0.17, 0.035, axis=ax, seg=32)
        cyl("Legs", "Brass", c - ax * 0.01, c + ax * 0.05, 0.14, verts=32)
        cyl("Legs", "Steel", c + ax * 0.04, c + ax * 0.09, 0.075, verts=24)
    cyl("Legs", "Steel", V((bx * 0.87, 0.10, 0.62)), V((bx * 0.87, 0.02, 1.05)), 0.27)
    for z in (0.75, 0.86, 0.97):
        torus("Legs", "Steel", V((bx * 0.87, 0.06, z)), 0.28, 0.03, seg=32)
    rbox("Legs", "Brass", V((sx * 0.80, -0.02, 0.95)), (0.86, 0.62, 0.40), radius=0.09, rot=(0, -sx * 0.12, 0))
    c = V((sx * 0.83, -0.35, 0.97))
    torus("Legs", "Steel", c, 0.17, 0.035, axis=(0, -1, 0), seg=40)
    cyl("Legs", "Steel", c + V((0, 0.02, 0)), c + V((0, -0.05, 0)), 0.14, verts=32)
    sphere("Legs", "Brass", c + V((0, -0.06, 0)), (0.095, 0.05, 0.095), seg=20, rings=10)
    cyl("Legs", "Steel", V((sx * 0.68, 0.0, 1.38)), V((sx * 0.78, -0.02, 1.08)), 0.30)
    for t in (0.3, 0.55, 0.8):
        p = V((sx * 0.68, 0.0, 1.38)).lerp(V((sx * 0.78, -0.02, 1.08)), t)
        torus("Legs", "Steel", p, 0.31, 0.03, axis=(sx * 0.1, 0, -0.3), seg=32)
    rbox("Legs", "Brass", V((sx * 0.80, -0.58, 1.42)), (0.46, 0.12, 0.44), radius=0.05, rot=(0, sx * 0.55, 0))
    for dz in (0.14, -0.14):
        rivet("Legs", "Steel", V((sx * 0.80 + dz * sx * 0.6, -0.66, 1.42 + dz)), 0.03)

# ---- pelvis, waist, torso, neck, rear pod
superellipsoid("Torso", "Brass", V((0, 0.05, 1.50)), (0.86, 0.82, 0.52), n=2.4, seg=64, rings=32)
torus("Torso", "Brass", V((0, 0.05, 1.80)), 0.80, 0.045, seg=64, scale=(1.0, 0.96, 1.0))
cyl("Torso", "Steel", V((0, 0.05, 1.80)), V((0, 0.05, 2.04)), 0.72, bevel=0.02)
rbox("Torso", "Steel", V((0, -0.64, 2.02)), (0.92, 0.07, 0.08), radius=0.03)
rbox("Torso", "Brass", V((0, 0.04, 2.47)), (1.62, 1.30, 1.02), radius=0.22, segs=6)
cyl("Torso", "Steel", V((0, 0.04, 2.98)), V((0, 0.04, 3.08)), 0.60, bevel=0.02)
torus("Torso", "Steel", V((0, 0.04, 3.00)), 0.62, 0.05, seg=64)
cyl("Torso", "Steel", V((0, 0.04, 3.02)), V((0, 0.04, 3.20)), 0.40, bevel=0.02)
for z in (3.08, 3.16):
    torus("Torso", "Steel", V((0, 0.04, z)), 0.41, 0.025, seg=40)
rbox("Torso", "Brass", V((0, -0.64, 2.52)), (0.62, 0.07, 0.80), radius=0.03)
core = V((0, -0.80, 2.23))
cyl("Torso", "Steel", V((0, -0.58, 2.23)), V((0, -0.80, 2.23)), 0.27, verts=40)
torus("Torso", "Steel", core, 0.25, 0.045, axis=(0, 1, 0), seg=48)
torus("Torso", "Brass", core + V((0, -0.01, 0)), 0.17, 0.035, axis=(0, 1, 0), seg=40)
sphere("Torso", "Amber", core + V((0, -0.01, 0)), (0.125, 0.06, 0.125), seg=32, rings=16)
sphere("Torso", "Glow", core + V((0, -0.035, 0)), (0.06, 0.03, 0.06), seg=16, rings=8)
for sx in sxs:
    rbox("Torso", "Brass", V((sx * 0.55, -0.66, 2.64)), (0.40, 0.07, 0.58), radius=0.03)
    for p in ((0.70, 2.92), (0.38, 2.88), (0.72, 2.36)):
        rivet("Torso", "Steel", V((sx * p[0], -0.70, p[1])), 0.035)
cyl("Torso", "Brass", V((0, 0.75, 2.65)), V((0, 1.20, 2.65)), 0.32, bevel=0.05)
torus("Torso", "Steel", V((0, 1.21, 2.65)), 0.22, 0.03, axis=(0, 1, 0), seg=32)
cyl("Torso", "Steel", V((0, 0.95, 2.95)), V((0, 0.95, 3.50)), 0.045, verts=16)
sphere("Torso", "Dark", V((0, 0.95, 3.54)), (0.07, 0.07, 0.07), seg=16, rings=10)

# pelvis: raised U panel on the front, as drawn
def pel_y(x, z):
    u = abs(x / 0.86)
    w = abs((z - 1.50) / 0.52)
    return 0.05 - 0.82 * max(0.0, 1 - u ** 2.4 - w ** 2.4) ** (1 / 2.4)


for off, r in ((0.0, 0.04), (0.10, 0.02)):
    pu = []
    for i in range(41):
        x = -0.66 + 1.32 * i / 40
        z = 1.80 - off - 0.58 * max(0.0, 1 - (x / 0.66) ** 2) ** 0.8
        pu.append(V((x, pel_y(x, z) - 0.02, z)))
    tube("Torso", "Brass", pu, r)

# ---- arms
for sx in sxs:
    g = "ArmL" if sx < 0 else "ArmR"
    cyl(g, "Steel", V((sx * 0.74, 0.05, 2.55)), V((sx * 0.98, 0.05, 2.55)), 0.30)
    rbox(g, "Brass", V((sx * 1.24, 0.02, 2.56)), (0.68, 0.92, 0.74), radius=0.15, rot=(0, sx * 0.5, 0))
    for p in ((1.10, -0.47, 2.72), (1.42, -0.40, 2.52), (1.36, 0.44, 2.66)):
        rivet(g, "Steel", V((sx * p[0], p[1], p[2])), 0.035)
    c = V((sx * 1.60, 0.12, 2.55))
    torus(g, "Steel", c, 0.38, 0.035, axis=(sx, 0, 0), seg=48)
    cyl(g, "Brass", c - V((sx * 0.02, 0, 0)), c + V((sx * 0.05, 0, 0)), 0.30, verts=40)
    cyl(g, "Steel", c + V((sx * 0.04, 0, 0)), c + V((sx * 0.09, 0, 0)), 0.10, verts=24)
    a0, a1 = V((sx * 1.28, 0.0, 2.55)), V((sx * 1.50, -0.02, 2.08))
    cyl(g, "Steel", a0, a1, 0.23)
    for t in (0.15, 0.32, 0.49, 0.66, 0.83, 0.95):
        torus(g, "Steel", a0.lerp(a1, t), 0.25, 0.028, axis=a1 - a0, seg=28)
    superellipsoid(g, "Brass", V((sx * 1.72, 0.16, 1.98)), (0.40, 0.31, 0.56), n=2.3, seg=48, rings=28, rot=(-0.6, 0, 0))
    pc = V((sx * 1.99, 0.12, 2.05))
    pn = unit((sx * 0.9, -0.42, 0.0))
    torus(g, "Brass", pc, 0.27, 0.04, axis=pn, seg=40)
    cyl(g, "Steel", pc - pn * 0.03, pc + pn * 0.02, 0.22, verts=32)
    cyl(g, "Brass", pc, pc + pn * 0.04, 0.10, verts=24)
    cyl(g, "Steel", V((sx * 1.68, -0.02, 1.55)), V((sx * 1.68, -0.06, 1.42)), 0.21)
    rbox(g, "Brass", V((sx * 1.68, -0.08, 1.30)), (0.48, 0.42, 0.50), radius=0.13)
    for fx in (-0.13, 0.0, 0.13):
        capsule(g, "Brass", V((sx * 1.68 + fx, -0.23, 1.30)), V((sx * 1.68 + fx, -0.24, 1.10)), 0.06)
    capsule(g, "Brass", V((sx * 1.52, -0.34, 1.62)), V((sx * 1.40, -0.36, 1.40)), 0.065)

# ---- head (built upright, then pitched about the neck)
HC = V((0, -0.03, 4.27))
HR = (1.27, 1.37, 1.19)
HN_UP, HN_LO = 2.15, 3.4


def surf_y(x, z, front=True):
    t = min(1.0, max(0.0, (0.25 - (z - HC.z) / HR[2]) / 0.75))
    n = HN_UP + (HN_LO - HN_UP) * t * t * (3 - 2 * t)
    u = abs((x - HC.x) / HR[0])
    w = abs((z - HC.z) / HR[2])
    t = max(0.0, 1 - u ** n - w ** n) ** (1 / n)
    return HC.y - HR[1] * t if front else HC.y + HR[1] * t


def surf_z(y, x=0.0):
    """Top of the dome at lateral x and depth y."""
    u = abs(x / HR[0])
    v = abs((y - HC.y) / HR[1])
    return HC.z + HR[2] * max(0.0, 1 - u ** HN_UP - v ** HN_UP) ** (1 / HN_UP)


def face_path(rx, rz, cz, off=0.02, n=2.6, pts=96, lo=0.0, hi=2 * math.pi):
    out = []
    for i in range(pts):
        a = lo + (hi - lo) * i / (pts if hi - lo >= 2 * math.pi - 1e-6 else pts - 1)
        c, sn = math.cos(a), math.sin(a)
        x = rx * math.copysign(abs(c) ** (2 / n), c)
        z = cz + rz * math.copysign(abs(sn) ** (2 / n), sn)
        out.append(V((x, surf_y(x, z) - off, z)))
    return out


superellipsoid("Head", "Brass", HC, HR, n=HN_UP, n_lo=HN_LO, seg=72, rings=44)
# face-plate outline and hood band, laid on the surface
tube("Head", "Brass", face_path(1.27, 1.0, 4.12, off=0.035), 0.055, closed=True)
tube("Head", "Brass", face_path(1.25, 1.06, 4.12, off=0.025, lo=math.radians(8), hi=math.radians(172)), 0.04)
for q in face_path(1.27, 1.0, 4.12, off=0.075, pts=30):
    if abs(q.x) < 1.15:
        rivet("Head", "Steel", q, 0.03)
for sx in sxs:
    ex, ez = sx * 0.77, 4.10
    ys = surf_y(ex, ez) - 0.02
    e = V((ex, ys, ez))
    cyl("Head", "Brass", e + V((0, 0.55, 0)), e + V((0, -0.04, 0)), 0.51, verts=48, bevel=0.03)
    torus("Head", "Brass", e + V((0, -0.05, 0)), 0.48, 0.06, axis=(0, 1, 0), seg=56, minseg=12)
    cyl("Head", "Steel", e + V((0, 0.10, 0)), e + V((0, -0.065, 0)), 0.43, verts=48)
    sphere("Head", "Glass", e + V((0, 0.06, 0)), (0.38, 0.24, 0.38), seg=48, rings=24)
    mc = V((sx * 1.18, 0.20, 4.08))
    superellipsoid("Head", "Brass", mc, (0.48, 0.64, 0.56), n=2.6, seg=56, rings=32)
    for r, mn in ((0.48, 0.04), (0.34, 0.035), (0.22, 0.03)):
        torus("Head", "Steel", V((sx * (1.655 - 0.5 * (r / 0.6) ** 2 * 0.3), 0.20, 4.08)), r, mn, axis=(sx, 0, 0), seg=64)
    cyl("Head", "Steel", V((sx * 1.60, 0.20, 4.08)), V((sx * 1.70, 0.20, 4.08)), 0.15, verts=32)
    sphere("Head", "Glass", V((sx * 1.70, 0.20, 4.08)), (0.05, 0.10, 0.10), seg=24, rings=12)
    # stalk and lamp
    b, m, t = V((sx * 0.52, 0.26, 5.16)), V((sx * 0.86, 0.18, 5.32)), V((sx * 1.18, 0.06, 5.50))
    sphere("Head", "Steel", b, (0.22, 0.22, 0.22), seg=20, rings=12)
    cyl("Head", "Steel", b, m, 0.17)
    sphere("Head", "Steel", m, (0.21, 0.21, 0.21), seg=20, rings=12)
    cyl("Head", "Steel", m, t, 0.17)
    torus("Head", "Steel", b.lerp(m, 0.5), 0.17, 0.022, axis=m - b, seg=24)
    torus("Head", "Steel", m.lerp(t, 0.5), 0.17, 0.022, axis=t - m, seg=24)
    L = V((sx * 1.42, -0.05, 5.57))
    n = unit((sx * 0.53, -0.70, 0.48))
    rot = n.to_track_quat("Z", "Y").to_euler()
    sphere("Head", "Brass", L - n * 0.03, (0.36, 0.52, 0.12), seg=48, rings=24, rot=rot)
    torus("Head", "Brass", L + n * 0.06, 0.36, 0.05, axis=n, seg=48, minseg=10, scale=(0.70, 1.0, 1.0))
    torus("Head", "Steel", L + n * 0.05, 0.31, 0.02, axis=n, seg=48, scale=(0.70, 1.0, 1.0))
    sphere("Head", "Amber", L + n * 0.02, (0.25, 0.38, 0.15), seg=40, rings=16, rot=rot)
    sphere("Head", "Glow", L + n * 0.05 - V((0, 0, 0.0)), (0.11, 0.18, 0.11), seg=24, rings=12, rot=rot)
    # lower cheek buttons on the face-plate corners
    bx, bz = sx * 0.93, 3.46
    bc = V((bx, surf_y(bx, bz) + 0.0, bz))
    bc = bc + V((-sx * 0.02, 0.05, 0.03))
    torus("Head", "Brass", bc, 0.17, 0.04, axis=(sx * 0.4, -1, -0.25), seg=32)
    cyl("Head", "Steel", bc + V((0, 0.03, 0)), bc + V((0, -0.035, 0)), 0.15, verts=24)
    sphere("Head", "Brass", bc + V((0, -0.04, 0)), (0.075, 0.035, 0.075), seg=16, rings=8)
# crest fin lying on the forehead, with visor and antenna cluster
fin = [V((0, y, surf_z(y) + 0.07)) for y in (-1.12, -0.95, -0.75, -0.55, -0.35, -0.15, 0.0)]
tube("Head", "Brass", fin, 0.11, xscale=2.0)
rbox("Head", "Steel", fin[0] + V((0, -0.02, -0.02)), (0.30, 0.20, 0.10), radius=0.035, rot=(0.6, 0, 0))
rbox("Head", "Steel", V((0, -0.16, surf_z(-0.16) + 0.12)), (0.20, 0.20, 0.12), radius=0.03)
for dx, h in ((-0.07, 0.16), (0.0, 0.26), (0.07, 0.12)):
    z0 = surf_z(-0.16) + 0.16
    cyl("Head", "Steel", V((dx, -0.16, z0)), V((dx, -0.16, z0 + h)), 0.03, verts=12)
sphere("Head", "Amber", V((0.0, -0.16, surf_z(-0.16) + 0.16 + 0.26)), (0.035, 0.035, 0.035), seg=12, rings=8)
# nose, mouth, rear tab
nz = 3.98
sphere("Head", "Steel", V((0, surf_y(0, nz) - 0.005, nz)), (0.045, 0.03, 0.045), seg=14, rings=8)
mz = 3.47
sphere("Head", "Dark", V((0, surf_y(0, mz) + 0.005, mz)), (0.25, 0.04, 0.035), seg=24, rings=10)
rbox("Head", "Steel", V((0, surf_y(0, 4.36, front=False) + 0.06, 4.36)), (0.16, 0.28, 0.20), radius=0.04)
# pitch the head forward about the neck
piv = V((0, 0.05, 3.05)) * IN
rotm = Matrix.Translation(V((0, 0.17, 0)) * IN) @ Matrix.Translation(piv) @ Matrix.Rotation(math.radians(8.0), 4, "X") @ Matrix.Translation(-piv)
for (grp, mat), objs in GROUPS.items():
    if grp == "Head":
        for o in objs:
            o.matrix_world = rotm @ o.matrix_world
bpy.context.view_layer.update()

# ================================================================== FINALISE


def join_group(objs):
    bpy.ops.object.select_all(action="DESELECT")
    for o in objs:
        o.select_set(True)
    bpy.context.view_layer.objects.active = objs[0]
    bpy.ops.object.join()
    return bpy.context.active_object


for (group, mat), objs in list(GROUPS.items()):
    merged = join_group(objs)
    merged.name = f"{group}-{mat}"
    merged.data.name = merged.name

mins = [1e9] * 3
maxs = [-1e9] * 3
for o in bpy.context.scene.objects:
    if o.type != "MESH":
        continue
    for v in o.data.vertices:
        w = o.matrix_world @ v.co
        for i in range(3):
            mins[i] = min(mins[i], w[i])
            maxs[i] = max(maxs[i], w[i])
print("BBOX inches: x[%.2f,%.2f] y[%.2f,%.2f] z[%.2f,%.2f]" % tuple(v / IN for p in zip(mins, maxs) for v in p))

os.makedirs(OUT, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT, "kirk.blend"))
bpy.ops.export_scene.gltf(
    filepath=os.path.join(OUT, "kirk.glb"),
    export_format="GLB",
    export_apply=True,
    export_yup=True,
    export_cameras=False,
    export_lights=False,
)
bpy.ops.object.select_all(action="DESELECT")
meshes = [o for o in bpy.context.scene.objects if o.type == "MESH"]
for o in meshes:
    o.select_set(True)
bpy.context.view_layer.objects.active = meshes[0]
bpy.ops.wm.stl_export(filepath=os.path.join(OUT, "kirk.stl"), export_selected_objects=True, global_scale=1000.0)
print("EXPORTED", OUT)
