"""Original city/wilderness asset library. Run with Blender --background --python this_file.

Only Blender's bundled Python and mesh/export/render operators are used.
All GLBs are meters and ground based. Working meshes face Blender -Y;
the export root turns 180 degrees so imported vehicle fronts face glTF/Godot -Z.
"""

import bpy
import json
import math
import random
import struct
from pathlib import Path
from mathutils import Vector

ROOT = Path(__file__).resolve().parents[2]
MODELS = ROOT / "assets/production/models/rebuild"
SOURCE = ROOT / "assets/source_editable/rebuild"
ARTIFACTS = ROOT / "artifacts/rebuild_models"
for folder in (MODELS, SOURCE, ARTIFACTS):
    folder.mkdir(parents=True, exist_ok=True)
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
bpy.context.scene.unit_settings.system = "METRIC"
random.seed(709)


def material(name, color, metal=0.0, rough=0.5, glow=0.0):
    mat = bpy.data.materials.new(name)
    mat.diffuse_color = (*color, 1)
    mat.use_nodes = True
    shader = mat.node_tree.nodes.get("Principled BSDF")
    shader.inputs["Base Color"].default_value = (*color, 1)
    shader.inputs["Metallic"].default_value = metal
    shader.inputs["Roughness"].default_value = rough
    if glow:
        shader.inputs["Emission Color"].default_value = (*color, 1)
        shader.inputs["Emission Strength"].default_value = glow
    return mat


CREAM = material("Ceramic ivory / painted armor", (0.78, 0.79, 0.70), 0.35, 0.35)
BLUE = material("Blue gray structural armor", (0.105, 0.20, 0.28), 0.65, 0.34)
DARK = material("Recesses / graphite steel", (0.035, 0.052, 0.062), 0.65, 0.42)
STEEL = material("Brushed titanium", (0.32, 0.39, 0.41), 0.85, 0.27)
ORANGE = material("Safety orange enamel", (0.95, 0.255, 0.038), 0.30, 0.32)
GLASS = material("Teal armored glazing", (0.023, 0.40, 0.43), 0.48, 0.16)
LIGHT = material("Warm running lights", (1.0, 0.71, 0.25), 0.1, 0.22, 2)
TEAL = material("Cyan status lights", (0.035, 0.8, 0.8), 0.2, 0.25, 1.2)
RUBBER = material("Tire rubber", (0.022, 0.029, 0.035), 0.0, 0.83)
GROUND = material("Concrete plinth", (0.34, 0.39, 0.38), 0.05, 0.85)
ROCK = material("Copper country stone", (0.29, 0.18, 0.10), 0.02, 0.94)
ROCK_LIGHT = material("Weathered sandstone", (0.46, 0.31, 0.18), 0.02, 0.85)
IRON = material("Iron ore metallic seams", (0.19, 0.24, 0.29), 0.84, 0.36)
COPPER = material("Copper ore metallic seams", (0.71, 0.29, 0.10), 0.8, 0.33)
PATINA = material("Copper turquoise patina", (0.07, 0.36, 0.27), 0.18, 0.8)
LEAF = material("Living green foliage", (0.12, 0.32, 0.095), 0.0, 0.91)
LEAF_LIGHT = material("Sunlit green foliage", (0.27, 0.46, 0.13), 0.0, 0.88)
SKIN = material("Warm skin", (0.60, 0.37, 0.24), 0.0, 0.6)


def empty(name, parent=None, loc=(0, 0, 0)):
    ob = bpy.data.objects.new(name, None)
    bpy.context.collection.objects.link(ob)
    ob.parent = parent
    ob.location = loc
    return ob


def finish(ob, name, parent, loc, mat, bevel=0, smooth=False):
    ob.name = name
    ob.parent = parent
    ob.location = loc
    ob.data.materials.append(mat)
    if bevel:
        mod = ob.modifiers.new("Machined edge bevel", "BEVEL")
        mod.width, mod.segments = bevel, 3
        mod = ob.modifiers.new("Weighted surface normals", "WEIGHTED_NORMAL")
        mod.keep_sharp = True
    if smooth:
        for face in ob.data.polygons:
            face.use_smooth = True
    return ob


def box(name, p, loc, size, mat=CREAM, bevel=0.06, rot=(0, 0, 0)):
    bpy.ops.mesh.primitive_cube_add()
    ob = bpy.context.object
    ob.scale = Vector(size) / 2
    bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
    ob.rotation_euler = rot
    return finish(ob, name, p, loc, mat, bevel)


def cyl(name, p, loc, radius, depth, mat=STEEL, rot=(0, 0, 0), vertices=24, bevel=0.025):
    bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius, depth=depth)
    ob = bpy.context.object
    ob.rotation_euler = rot
    return finish(ob, name, p, loc, mat, bevel, True)


def sphere(name, p, loc, scale, mat, subdivisions=2):
    bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=subdivisions, radius=1)
    ob = bpy.context.object
    ob.scale = scale
    bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
    return finish(ob, name, p, loc, mat)


def beam(name, p, a, b, radius, mat=STEEL):
    a, b = Vector(a), Vector(b)
    ob = cyl(name, p, (a + b) / 2, radius, (b - a).length, mat, bevel=radius * 0.15)
    ob.rotation_mode = "QUATERNION"
    ob.rotation_quaternion = (b - a).to_track_quat("Z", "Y")
    return ob


def cone(name, p, loc, radius1, radius2, depth, mat, rot=(0, 0, 0), vertices=20):
    bpy.ops.mesh.primitive_cone_add(vertices=vertices, radius1=radius1, radius2=radius2, depth=depth)
    ob = bpy.context.object
    ob.rotation_euler = rot
    return finish(ob, name, p, loc, mat, 0.015)


def bolts(p, positions, rot=(math.pi / 2, 0, 0), radius=0.035):
    for pos in positions:
        cyl("Hex fastener", p, pos, radius, 0.026, STEEL, rot, 6, 0.005)


def vent(p, pos, width=0.8, height=0.5, count=6, front=True):
    x, y, z = pos
    if front:
        box("Recessed vent frame", p, pos, (width, 0.09, height), DARK, 0.035)
        for i in range(count):
            box("Vent fin", p, (x, y - 0.05, z - height * 0.37 + i * height * 0.74 / (count - 1)), (width * 0.85, 0.06, 0.025), STEEL, 0.007)
    else:
        box("Deck vent surround", p, pos, (width, height, 0.05), DARK, 0.025)
        for i in range(count):
            box("Cooling fin", p, (x, y - height * 0.37 + i * height * 0.74 / (count - 1), z + 0.03), (width * 0.85, 0.027, 0.028), STEEL, 0.006)


def rover():
    root = empty("MiningRover")
    body = empty("Body", root)
    box("Suspended chassis", body, (0, 0, 0.83), (2.05, 3.65, 0.42), BLUE, 0.19)
    box("Armored upper hull", body, (0, 0.06, 1.2), (2.0, 3.35, 0.54), CREAM, 0.23)
    box("Cab shell", body, (0, -0.86, 1.62), (1.75, 1.35, 0.95), CREAM, 0.25)
    box("Windshield armored frame", body, (0, -1.49, 1.68), (1.49, 0.11, 0.59), BLUE, 0.10, (0.1, 0, 0))
    for x in (-0.37, 0.37):
        box("Split windshield", body, (x, -1.555, 1.7), (0.66, 0.045, 0.42), GLASS, 0.06, (0.1, 0, 0))
    for side in (-1, 1):
        box("Cab side glazing", body, (side * 0.855, -0.85, 1.72), (0.06, 0.72, 0.43), GLASS, 0.075)
        box("Side reinforcement", body, (side * 1.015, 0.45, 1.26), (0.14, 1.94, 0.28), BLUE, 0.055)
        for y in (-0.25, 0.49, 1.15):
            box("Replaceable armor panel", body, (side * 1.07, y, 1.44), (0.16, 0.57, 0.29), CREAM, 0.045)
        box("Wheel fender", body, (side * 1.28, 0, 1.16), (0.48, 3.1, 0.13), BLUE, 0.05)
        box("Orange fender stripe", body, (side * 1.28, -1.19, 1.235), (0.46, 0.24, 0.035), ORANGE, 0.012)
        box("Lamp casing", body, (side * 0.72, -1.82, 1.02), (0.35, 0.16, 0.20), DARK, 0.06)
        box("Headlamp", body, (side * 0.72, -1.915, 1.04), (0.26, 0.025, 0.10), LIGHT, 0.025)
        beam("Roof grab rail", body, (side * 0.67, -1.05, 2.1), (side * 0.67, -0.35, 2.1), 0.035)
        cyl("Exhaust pipe", body, (side * 0.81, 1.32, 1.8), 0.085, 0.8, DARK)
        cyl("Exhaust heat shield", body, (side * 0.81, 1.32, 1.57), 0.135, 0.40, STEEL)
        cyl("Exhaust cap", body, (side * 0.81, 1.32, 2.21), 0.12, 0.08, BLUE)
    box("Rear cargo rack", body, (0, 1.30, 1.67), (1.25, 0.62, 0.32), BLUE, 0.055)
    vent(body, (0, 1.80, 1.22), 1.2, 0.32)
    vent(body, (0, 1.21, 1.84), 0.93, 0.45, front=False)
    box("Front bumper", body, (0, -1.91, 0.76), (1.94, 0.22, 0.2), STEEL, 0.06)
    bolts(body, [(-0.72, -1.61, 1.40), (0.72, -1.61, 1.40), (-0.72, -1.61, 1.95), (0.72, -1.61, 1.95)])
    wheels = empty("Wheels", root)
    for side, prefix in ((-1, "L"), (1, "R")):
        for y, suffix in ((-1.15, "Front"), (0, "Middle"), (1.15, "Rear")):
            wheel = empty("Wheel_" + prefix + "_" + suffix, wheels, (side * 1.20, y, 0.60))
            cyl("All terrain tire", wheel, (0, 0, 0), 0.58, 0.46, RUBBER, (0, math.pi / 2, 0), 32, 0.08)
            for index in range(18):
                a = index * math.tau / 18
                box("Tread block", wheel, (0, math.sin(a) * 0.571, math.cos(a) * 0.571), (0.49, 0.17, 0.085), RUBBER, 0.018, (-a, 0, 0))
            cyl("Recessed rim", wheel, (side * 0.238, 0, 0), 0.36, 0.035, BLUE, (0, math.pi / 2, 0))
            cyl("Wheel hub", wheel, (side * 0.272, 0, 0), 0.17, 0.075, ORANGE, (0, math.pi / 2, 0))
            for j in range(6):
                a = math.tau * j / 6
                cyl("Rim lug", wheel, (side * 0.274, math.sin(a) * 0.25, math.cos(a) * 0.25), 0.032, 0.035, STEEL, (0, math.pi / 2, 0), 6, 0.005)
    cyl("Turret pedestal", body, (0, 0.2, 1.70), 0.37, 0.50, BLUE)
    turret = empty("Turret", root, (0, 0.2, 1.95))
    cyl("Turret bearing", turret, (0, 0, 0), 0.56, 0.15, DARK)
    cyl("Turret ring", turret, (0, 0, 0.11), 0.48, 0.11, STEEL)
    box("Turret housing", turret, (0, 0.05, 0.35), (1.02, 0.85, 0.47), BLUE, 0.13)
    box("Turret armor", turret, (0, 0.02, 0.59), (0.88, 0.72, 0.15), CREAM, 0.09)
    cyl("Cannon shroud", turret, (0, -0.65, 0.37), 0.15, 0.80, STEEL, (math.pi / 2, 0, 0))
    cyl("Cannon barrel", turret, (0, -1.38, 0.37), 0.09, 0.84, DARK, (math.pi / 2, 0, 0))
    box("Muzzle brake", turret, (0, -1.90, 0.37), (0.29, 0.24, 0.25), BLUE, 0.045)
    cyl("Muzzle bore", turret, (0, -2.031, 0.37), 0.069, 0.02, DARK, (math.pi / 2, 0, 0))
    box("Optic housing", turret, (0.43, -0.38, 0.42), (0.19, 0.17, 0.22), ORANGE, 0.035)
    box("Optic", turret, (0.43, -0.475, 0.44), (0.12, 0.015, 0.11), TEAL, 0.015)
    arm = empty("MiningArm", root, (0.73, -1.63, 1.12))
    beam("Mining arm support", arm, (0, 0, 0), (0.27, -0.8, -0.18), 0.13, BLUE)
    beam("Hydraulic sleeve", arm, (-0.13, -0.06, 0.12), (0.13, -0.58, -0.03), 0.075, ORANGE)
    beam("Hydraulic piston", arm, (0.13, -0.58, -0.03), (0.3, -0.9, -0.12), 0.04, STEEL)
    cyl("Drill motor", arm, (0.27, -0.84, -0.18), 0.29, 0.59, ORANGE, (math.pi / 2, 0, 0))
    for i in range(5):
        cone("Stepped drill cutter", arm, (0.27, -1.18 - i * 0.12, -0.18), 0.27 - i * 0.044, 0.14 - i * 0.025, 0.22, STEEL, (math.pi / 2, 0, 0), 12)
    repair = empty("RepairArm", root, (-0.82, -1.35, 1.17))
    for a, b, r, mat in [((0, 0, 0), (-0.18, -0.44, 0.34), 0.085, ORANGE), ((-0.18, -0.44, 0.34), (-0.2, -0.84, 0.06), 0.07, STEEL)]:
        beam("Repair articulated arm", repair, a, b, r, mat)
        sphere("Repair pivot", repair, a, (0.13, 0.13, 0.13), BLUE)
    box("Repair tool head", repair, (-0.2, -0.9, 0.06), (0.27, 0.29, 0.19), CREAM, 0.045)
    for x in (-0.28, -0.12):
        beam("Welding fork", repair, (x, -1.02, 0.06), (x, -1.26, 0.06), 0.03, STEEL)
    return root


def foundation(root, sx, sy):
    box("Concrete footing", root, (0, 0, 0.14), (sx + 0.6, sy + 0.6, 0.28), GROUND, 0.11)
    for x in (-sx / 2, sx / 2):
        for y in (-sy / 2, sy / 2):
            box("Corner reinforcement", root, (x, y, 0.33), (0.32, 0.32, 0.30), BLUE, 0.06)


def rooftop(root, z, width, depth):
    box("Roof perimeter cap", root, (0, 0, z), (width, depth, 0.20), BLUE, 0.1)
    box("Inset insulated roof", root, (0, 0, z + 0.13), (width - 0.44, depth - 0.44, 0.15), CREAM, 0.08)
    vent(root, (0, depth * 0.20, z + 0.25), width * 0.35, depth * 0.30, front=False)
    for x in (-width * 0.36, width * 0.36):
        box("Roof conduit", root, (x, 0, z + 0.23), (0.12, depth * 0.75, 0.11), STEEL, 0.025)


def windows(root, y, z, count, span):
    for i in range(count):
        x = (i - (count - 1) / 2) * span
        box("Glazing surround", root, (x, y, z), (span * 0.83, 0.14, 0.88), BLUE, 0.055)
        box("Recessed cyan window", root, (x, y - 0.08, z + 0.03), (span * 0.68, 0.07, 0.62), GLASS, 0.035)
        box("Window sill", root, (x, y - 0.17, z - 0.45), (span * 0.90, 0.32, 0.10), CREAM, 0.025)


def garage():
    root = empty("CityGarage")
    foundation(root, 8.4, 6.3)
    # Open bay is constructed as real walls, columns and roof, without a fake door image.
    box("Rear service wall", root, (0, 2.92, 2.04), (8.0, 0.38, 3.62), CREAM, 0.15)
    for x in (-3.88, 3.88):
        box("Garage side wall", root, (x, 0, 2.04), (0.4, 6.0, 3.62), CREAM, 0.13)
    box("Workshop floor", root, (0, 0, 0.35), (7.6, 5.8, 0.15), DARK, 0.03)
    for x in (-3.4, 3.4):
        box("Bay reinforced pier", root, (x, -2.90, 1.95), (1.05, 0.78, 3.55), BLUE, 0.14)
        box("Bay pier ivory armor", root, (x, -3.34, 2.32), (0.72, 0.19, 2.57), CREAM, 0.11)
        box("Orange safety plate", root, (x, -3.46, 1.03), (0.72, 0.12, 0.75), ORANGE, 0.03)
        box("Bay vertical light", root, (x - math.copysign(0.36, x), -3.35, 2.49), (0.055, 0.065, 1.50), LIGHT, 0.01)
    box("Bay overhead beam", root, (0, -2.95, 3.59), (7.2, 0.95, 0.70), CREAM, 0.16)
    for x in (-2.0, 2.0):
        box("Service lane marking", root, (x, -0.2, 0.438), (0.09, 5.2, 0.025), ORANGE, 0.005)
        box("Vehicle lift platform", root, (x, 0.48, 0.53), (0.48, 3.1, 0.20), STEEL, 0.05)
        box("Hydraulic lift cylinder", root, (x, 1.52, 1.15), (0.31, 0.36, 1.5), ORANGE, 0.055)
    box("Rear tool wall", root, (0, 2.64, 1.45), (3.9, 0.18, 1.62), BLUE, 0.06)
    for x in (-1.5, -0.75, 0, 0.75, 1.5):
        box("Workshop tool cabinet", root, (x, 2.38, 0.91), (0.64, 0.47, 0.85), ORANGE, 0.06)
    rooftop(root, 3.99, 8.45, 6.35)
    box("Garage monitor enclosure", root, (0, -3.44, 3.6), (2.2, 0.13, 0.37), BLUE, 0.06)
    for x in (-0.65, 0, 0.65):
        box("Bay availability lights", root, (x, -3.515, 3.6), (0.38, 0.025, 0.09), TEAL, 0.013)
    return root


def refinery():
    root = empty("CityRefinery")
    foundation(root, 8.6, 6.4)
    box("Refining plant service block", root, (0, 0.6, 1.30), (7.8, 4.0, 2.04), CREAM, 0.17)
    windows(root, -1.44, 1.45, 4, 1.5)
    rooftop(root, 2.43, 8.0, 4.2)
    for x, height in ((-2.65, 4.35), (0, 5.05), (2.65, 3.7)):
        cyl("Vertical process vessel", root, (x, 1.4, height / 2 + 0.35), 0.90, height, CREAM, vertices=32, bevel=0.11)
        cone("Vessel conical shoulder", root, (x, 1.4, height + 0.58), 0.89, 0.38, 0.5, STEEL)
        cyl("Vessel exhaust neck", root, (x, 1.4, height + 1.04), 0.30, 0.50, BLUE)
        cyl("Exhaust rim", root, (x, 1.4, height + 1.3), 0.36, 0.10, STEEL)
        for z in (1.0, 2.6, height + 0.11):
            cyl("Tank clamping band", root, (x, 1.4, z), 0.94, 0.16, BLUE)
        box("Tank vertical status strip", root, (x, 0.475, 2.24), (0.12, 0.055, 1.38), TEAL, 0.022)
        beam("Exterior process pipe", root, (x + 0.60, 0.56, 0.8), (x + 0.60, 0.56, height - 0.15), 0.085, ORANGE)
    beam("Lower feed manifold", root, (-3.25, -1.95, 0.8), (3.3, -1.95, 0.8), 0.15, STEEL)
    for x in (-2.65, 0, 2.65):
        beam("Manifold feeder", root, (x, -1.95, 0.8), (x, 0.8, 0.8), 0.10, ORANGE)
        cyl("Hand valve", root, (x, -2.07, 1.07), 0.23, 0.065, ORANGE, (math.pi / 2, 0, 0))
    box("Input ore hopper", root, (-2.5, -2.52, 0.92), (1.45, 1.05, 1.15), BLUE, 0.16)
    box("Hopper opening", root, (-2.5, -2.52, 1.505), (1.08, 0.7, 0.05), DARK, 0.07)
    for i in range(4):
        box("Refinery front barrier", root, (0.1 + i * 0.86, -3.01, 0.67), (0.60, 0.14, 0.19), ORANGE, 0.04)
    return root


def crate(p, loc, scale=1):
    x, y, z = loc
    box("Cargo container", p, (x, y, z + 0.50 * scale), (1.1 * scale, 0.88 * scale, 1.0 * scale), BLUE, 0.07 * scale)
    for dx in (-0.36, 0.36):
        box("Cargo rib", p, (x + dx * scale, y - 0.46 * scale, z + 0.5 * scale), (0.11 * scale, 0.1 * scale, 0.93 * scale), CREAM, 0.02)
    box("Cargo latch", p, (x, y - 0.51 * scale, z + 0.56 * scale), (0.21 * scale, 0.06 * scale, 0.24 * scale), ORANGE, 0.025)


def warehouse():
    root = empty("CityWarehouse")
    foundation(root, 8.8, 6.4)
    box("Warehouse armored shell", root, (0, 0.3, 2.20), (8.0, 5.6, 3.84), CREAM, 0.35)
    for x in (-3.65, -1.75, 1.75, 3.65):
        box("Structural vertical rib", root, (x, -2.58, 2.32), (0.32, 0.30, 3.35), BLUE, 0.06)
    box("Freight door surround", root, (0, -2.64, 1.85), (3.65, 0.23, 3.0), BLUE, 0.13)
    box("Freight door dark recess", root, (0, -2.80, 1.81), (3.09, 0.12, 2.62), DARK, 0.04)
    for z in range(7):
        box("Roller door slat", root, (0, -2.88, 0.69 + z * 0.34), (2.94, 0.10, 0.27), STEEL, 0.027)
    box("Freight orange sill", root, (0, -3.07, 0.33), (3.8, 0.75, 0.14), ORANGE, 0.035)
    windows(root, -2.61, 3.49, 4, 1.54)
    rooftop(root, 4.15, 8.4, 6.0)
    for x in (-2.95, 2.95):
        box("Roof armored rib", root, (x, 0.3, 4.38), (0.40, 5.85, 0.31), CREAM, 0.08)
        box("Orange structural band", root, (x, -2.60, 2.50), (0.38, 0.13, 0.56), ORANGE, 0.025)
    for pos, scale in (((-3.15, -3.12, 0.3), 0.95), ((3.04, -3.2, 0.3), 0.90), ((3.00, -2.76, 1.20), 0.75)):
        crate(root, pos, scale)
    return root


def housing():
    root = empty("CityHousing")
    foundation(root, 7.7, 6.1)
    box("Residential ground floor", root, (0, 0.2, 1.48), (7.0, 5.3, 2.38), CREAM, 0.30)
    box("Residential upper floor", root, (0, 0.62, 3.5), (6.25, 4.48, 1.83), CREAM, 0.26)
    rooftop(root, 4.52, 6.60, 4.85)
    windows(root, -1.69, 3.51, 3, 1.73)
    for x in (-2.3, 2.3):
        box("Ground floor window frame", root, (x, -2.51, 1.56), (1.42, 0.13, 1.05), BLUE, 0.08)
        box("Ground floor glass", root, (x, -2.60, 1.58), (1.13, 0.065, 0.79), GLASS, 0.055)
    box("Front entry portal", root, (0, -2.60, 1.31), (1.54, 0.31, 2.21), BLUE, 0.10)
    box("Residential entry door", root, (0, -2.785, 1.27), (1.18, 0.07, 1.84), GLASS, 0.06)
    box("Door center mullion", root, (0, -2.83, 1.27), (0.045, 0.04, 1.88), CREAM, 0.007)
    box("Welcoming orange entry awning", root, (0, -2.89, 2.51), (2.1, 1.01, 0.14), ORANGE, 0.06)
    box("Balcony floor", root, (0, -2.01, 2.59), (6.0, 1.03, 0.18), BLUE, 0.06)
    beam("Balcony handrail", root, (-2.94, -2.56, 3.14), (2.94, -2.56, 3.14), 0.043, STEEL)
    for x in (-2.91, -1.94, -0.97, 0, 0.97, 1.94, 2.91):
        beam("Balcony rail post", root, (x, -2.56, 2.64), (x, -2.56, 3.14), 0.025, STEEL)
    for x in (-2.60, 2.60):
        box("Residential planter", root, (x, -2.92, 0.58), (1.25, 0.73, 0.51), CREAM, 0.085)
        for i in range(5):
            sphere("Planter foliage", root, (x - 0.44 + i * 0.22, -2.92, 0.96 + (i % 2) * 0.16), (0.33, 0.32, 0.37), LEAF)
    for y in (-0.4, 0.4, 1.2):
        box("Solar panel", root, (-1.42, y, 4.71), (1.49, 0.69, 0.055), GLASS, 0.025)
    cyl("Communications antenna base", root, (2.13, 1.65, 4.8), 0.16, 0.22, BLUE)
    beam("Communications antenna", root, (2.13, 1.65, 4.82), (2.13, 1.65, 5.7), 0.028, STEEL)
    return root


def rock_cluster(name, ore=None):
    root = empty(name)
    for i, (loc, scale) in enumerate([((0, 0.1, 0.64), (1.0, 0.82, 0.90)), ((-0.80, -0.27, 0.28), (0.62, 0.63, 0.48)), ((0.72, -0.43, 0.24), (0.61, 0.48, 0.43)), ((0.1, 0.7, 0.43), (0.66, 0.7, 0.67))]):
        ob = sphere("Weathered rock outcrop", root, loc, scale, ROCK if i % 2 else ROCK_LIGHT)
        for vert in ob.data.vertices:
            vert.co *= random.uniform(0.85, 1.14)
            vert.co.z = max(vert.co.z, -loc[2] + 0.03)
        ob.rotation_euler[2] = random.random() * math.tau
    if ore:
        for i in range(13):
            a = i * 2.4
            x, y = math.cos(a) * (0.30 + i % 3 * 0.21), math.sin(a) * 0.56 - 0.24
            z = 0.70 + (1 - abs(x)) * 0.45
            ob = sphere("Exposed mineral vein", root, (x, y, z), (0.18 + i % 2 * 0.13, 0.11, 0.23), ore, 1)
            ob.rotation_euler = (i * 0.3, i * 0.25, a)
        if ore == COPPER:
            for i in range(5):
                sphere("Oxidized copper seam", root, (-0.55 + i * 0.22, -0.63, 0.55 + i % 2 * 0.17), (0.19, 0.08, 0.15), PATINA, 1)
    return root


def crab():
    root = empty("MechanicalCrab")
    body = empty("Body", root)
    sphere("Faceted carapace", body, (0, 0, 0.91), (0.80, 0.67, 0.36), BLUE)
    box("Armored carapace plate", body, (0, 0.04, 1.10), (1.12, 0.89, 0.31), CREAM, 0.18)
    box("Orange dorsal stripe", body, (0, 0.05, 1.28), (0.18, 0.72, 0.05), ORANGE, 0.02)
    for x in (-0.34, 0.34):
        sphere("Optic socket", body, (x, -0.59, 0.97), (0.21, 0.18, 0.18), DARK)
        cyl("Predatory optic", body, (x, -0.751, 0.98), 0.11, 0.03, ORANGE, (math.pi / 2, 0, 0))
    vent(body, (0, 0.45, 1.22), 0.65, 0.31, front=False)
    for side in (-1, 1):
        for i, y in enumerate((-0.40, 0.08, 0.46)):
            leg = empty("Leg_" + ("L" if side < 0 else "R") + str(i), root)
            a, b, c = (side * 0.62, y, 0.85), (side * (1.13 + (i == 1) * 0.1), y * 1.9, 0.58), (side * 1.31, y * 2.1 - 0.1, 0.12)
            beam("Leg upper armor", leg, a, b, 0.10, CREAM)
            beam("Leg lower actuator", leg, b, c, 0.064, STEEL)
            sphere("Leg hip", leg, a, (0.14, 0.14, 0.14), ORANGE)
            sphere("Leg pivot", leg, b, (0.14, 0.14, 0.14), BLUE)
            box("Clawed foot", leg, c, (0.22, 0.32, 0.16), DARK, 0.045)
        beam("Front claw support", body, (side * 0.55, -0.49, 0.78), (side * 0.74, -1.10, 0.53), 0.075, STEEL)
        for dx in (-0.11, 0.11):
            beam("Pincer", body, (side * 0.74 + dx, -1.08, 0.53), (side * 0.74 + dx * 0.42, -1.44, 0.49), 0.054, ORANGE)
    return root


def scout():
    root = empty("ScoutDrone")
    body = empty("Body", root)
    box("Scout fuselage", body, (0, 0, 0.82), (0.75, 1.43, 0.57), CREAM, 0.24)
    box("Scanner canopy", body, (0, -0.45, 1.03), (0.56, 0.51, 0.28), GLASS, 0.12)
    box("Scout belly frame", body, (0, 0, 0.56), (0.54, 1.18, 0.20), BLUE, 0.07)
    cyl("Forward scanner", body, (0, -0.737, 0.81), 0.17, 0.12, DARK, (math.pi / 2, 0, 0))
    cyl("Scanner lens", body, (0, -0.809, 0.81), 0.12, 0.025, TEAL, (math.pi / 2, 0, 0))
    for side in (-1, 1):
        beam("Rotor outrigger", body, (side * 0.24, 0.02, 0.87), (side * 0.98, 0.04, 0.84), 0.095, BLUE)
        rotor = empty("Rotor_L" if side < 0 else "Rotor_R", root, (side * 0.99, 0.05, 0.87))
        bpy.ops.mesh.primitive_torus_add(major_radius=0.45, minor_radius=0.073, major_segments=32, minor_segments=8)
        finish(bpy.context.object, "Ducted fan ring", rotor, (0, 0, 0), ORANGE)
        cyl("Rotor hub", rotor, (0, 0, 0), 0.11, 0.14, STEEL)
        for a in (0, math.pi / 3, math.pi * 2 / 3):
            box("Rotor blade", rotor, (0, 0, 0), (0.75, 0.065, 0.035), DARK, 0.015, (0, 0, a))
        beam("Landing strut", body, (side * 0.25, 0.18, 0.57), (side * 0.5, 0.1, 0.20), 0.035, STEEL)
        beam("Landing skid", body, (side * 0.5, -0.4, 0.17), (side * 0.5, 0.57, 0.17), 0.04, DARK)
    box("Rear service panel", body, (0, 0.47, 1.06), (0.45, 0.3, 0.05), BLUE, 0.035)
    return root


def person(name, npc=False):
    root = empty(name)
    torso = empty("Torso", root)
    box("Work jacket", torso, (0, 0, 1.17), (0.49, 0.30, 0.57), ORANGE if npc else CREAM, 0.13)
    box("Chest armored panel", torso, (0, -0.16, 1.23), (0.31, 0.065, 0.28), CREAM if npc else BLUE, 0.055)
    box("Utility belt", torso, (0, 0, 0.92), (0.48, 0.35, 0.13), DARK, 0.035)
    for x in (-0.19, 0.19):
        box("Belt pouch", torso, (x, -0.20, 0.91), (0.13, 0.09, 0.14), ORANGE, 0.025)
    box("Life support backpack", torso, (0, 0.22, 1.19), (0.34, 0.17, 0.43), BLUE, 0.065)
    head = empty("Head", root, (0, 0, 1.59))
    sphere("Head", head, (0, 0, 0), (0.17, 0.145, 0.20), SKIN)
    sphere("Helmet shell", head, (0, 0.01, 0.07), (0.193, 0.166, 0.18), ORANGE if npc else CREAM)
    box("Helmet visor frame", head, (0, -0.142, 0.025), (0.30, 0.075, 0.13), BLUE, 0.045)
    box("Helmet face visor", head, (0, -0.186, 0.025), (0.246, 0.029, 0.079), GLASS, 0.025)
    for side in (-1, 1):
        arm = empty("Arm_L" if side < 0 else "Arm_R", root)
        beam("Jacket sleeve", arm, (side * 0.28, 0, 1.37), (side * 0.33, -0.035, 1.03), 0.105, ORANGE if npc else CREAM)
        beam("Forearm glove", arm, (side * 0.33, -0.035, 1.03), (side * 0.34, -0.09, 0.84), 0.076, BLUE)
        sphere("Work glove", arm, (side * 0.34, -0.09, 0.83), (0.08, 0.075, 0.09), DARK)
        leg = empty("Leg_L" if side < 0 else "Leg_R", root)
        beam("Trouser thigh", leg, (side * 0.13, 0, 0.90), (side * 0.14, 0.01, 0.53), 0.112, BLUE)
        beam("Trouser lower leg", leg, (side * 0.14, 0.01, 0.55), (side * 0.14, 0.0, 0.19), 0.090, BLUE)
        box("Knee protector", leg, (side * 0.14, -0.092, 0.52), (0.18, 0.075, 0.16), CREAM, 0.045)
        box("Work boot", leg, (side * 0.14, -0.056, 0.105), (0.21, 0.34, 0.20), DARK, 0.055)
        # Limb pivots belong at the shoulder and hip, so local rotation.x swings naturally.
        for limb, pivot in ((arm, (side * 0.28, 0, 1.37)), (leg, (side * 0.13, 0, 0.90))):
            limb.location = pivot
            for child in limb.children:
                child.location -= Vector(pivot)
    return root


def relay_station():
    root = empty("RelayStation")
    foundation(root, 4.0, 3.2)
    box("Relay equipment shelter", root, (0, 0, 1.32), (3.6, 2.8, 2.1), CREAM, 0.24)
    for x in (-1.30, 1.30):
        box("Relay protective column", root, (x, -1.44, 1.35), (0.29, 0.22, 2.0), BLUE, 0.05)
        box("Status bar", root, (x, -1.565, 1.75), (0.10, 0.03, 0.8), TEAL, 0.018)
    box("Relay access panel", root, (0, -1.445, 1.35), (1.25, 0.18, 1.55), BLUE, 0.09)
    vent(root, (0, -1.55, 1.1), 0.90, 0.55)
    box("Relay control display", root, (0, -1.56, 1.81), (0.82, 0.06, 0.24), GLASS, 0.035)
    rooftop(root, 2.49, 3.9, 3.05)
    cyl("Antenna tower base", root, (-0.85, 0.5, 2.78), 0.23, 0.35, ORANGE)
    beam("Antenna mast", root, (-0.85, 0.5, 2.82), (-0.85, 0.5, 5.6), 0.07, STEEL)
    for z, length in ((3.15, 1.15), (3.72, 0.84), (4.32, 0.64), (4.88, 0.40)):
        beam("Antenna cross element", root, (-0.85 - length / 2, 0.5, z), (-0.85 + length / 2, 0.5, z), 0.035, BLUE)
    sphere("Mast running light", root, (-0.85, 0.5, 5.65), (0.10, 0.10, 0.13), LIGHT)
    beam("Dish mount", root, (0.84, 0.4, 2.55), (0.84, 0.4, 3.35), 0.10, BLUE)
    dish = empty("SatelliteDish", root, (0.84, 0.4, 3.40))
    vertices, faces = [(0, 0, 0)], []
    for ring in range(1, 7):
        r = ring * 0.12
        for i in range(32):
            a = i * math.tau / 32
            vertices.append((r * math.cos(a), r * math.sin(a), r * r * 0.43))
    for i in range(32):
        faces.append((0, 1 + i, 1 + (i + 1) % 32))
    for ring in range(5):
        base, following = 1 + ring * 32, 1 + (ring + 1) * 32
        for i in range(32):
            j = (i + 1) % 32
            faces.append((base + i, following + i, following + j, base + j))
    data = bpy.data.meshes.new("Concave parabolic reflector")
    data.from_pydata(vertices, [], faces)
    data.update()
    ob = bpy.data.objects.new("Concave parabolic reflector", data)
    bpy.context.collection.objects.link(ob)
    finish(ob, ob.name, dish, (0, 0, 0), CREAM)
    shell = ob.modifiers.new("Reflector shell thickness", "SOLIDIFY")
    shell.thickness = 0.035
    for angle in (0, math.tau / 3, math.tau * 2 / 3):
        beam("Dish feed support", dish, (0.66 * math.cos(angle), 0.66 * math.sin(angle), 0.22), (0, 0, 0.68), 0.023, STEEL)
    cyl("Dish receiver", dish, (0, 0, 0.66), 0.10, 0.20, BLUE)
    dish.rotation_euler = (math.radians(58), 0, 0)
    return root


def service_terminal():
    root = empty("ServiceTerminal")
    box("Terminal ground foot", root, (0, 0, 0.09), (0.84, 0.76, 0.18), BLUE, 0.075)
    box("Terminal pedestal", root, (0, 0.1, 0.69), (0.48, 0.42, 1.18), CREAM, 0.12)
    box("Terminal orange accent", root, (0, -0.135, 0.42), (0.29, 0.07, 0.38), ORANGE, 0.035)
    box("Display enclosure", root, (0, 0, 1.4), (0.79, 0.28, 0.65), BLUE, 0.10, (math.radians(14), 0, 0))
    box("Terminal display glass", root, (0, -0.158, 1.42), (0.63, 0.035, 0.46), GLASS, 0.035, (math.radians(14), 0, 0))
    box("Screen horizontal status", root, (-0.08, -0.194, 1.55), (0.34, 0.012, 0.035), TEAL, 0.006)
    for z in (1.37, 1.28):
        box("Screen status indicators", root, (-0.12, -0.155 - (z - 1.42) * 0.24, z), (0.25, 0.013, 0.029), CREAM, 0.005)
    box("Keyboard shelf", root, (0, -0.24, 1.03), (0.65, 0.35, 0.07), BLUE, 0.04)
    for x in (-0.18, -0.06, 0.06, 0.18):
        for y in (-0.30, -0.18):
            box("Terminal control key", root, (x, y, 1.077), (0.077, 0.070, 0.021), ORANGE if x == 0.18 else CREAM, 0.01)
    box("Card reader", root, (0.25, -0.10, 0.78), (0.14, 0.15, 0.22), BLUE, 0.035)
    box("Reader status lamp", root, (0.25, -0.185, 0.82), (0.09, 0.012, 0.025), TEAL, 0.005)
    return root


def cargo_crate():
    root = empty("CargoCrate")
    crate(root, (0, 0, 0), 1)
    for x in (-0.50, 0.50):
        for y in (-0.38, 0.38):
            box("Cargo stacking corner", root, (x, y, 0.93), (0.16, 0.17, 0.13), STEEL, 0.028)
    box("Cargo top inset", root, (0, 0, 1.01), (0.67, 0.56, 0.032), DARK, 0.027)
    beam("Recessed cargo lifting handle", root, (-0.18, 0, 1.05), (0.18, 0, 1.05), 0.028, STEEL)
    return root


def tree():
    root = empty("FrontierTree")
    cone("Irregular tree trunk", root, (0, 0, 1.12), 0.20, 0.095, 2.24, ROCK, vertices=9)
    for i, (x, y, z, s) in enumerate([(-0.63, 0.12, 2.07, 0.84), (0.57, 0.32, 2.2, 0.89), (0.22, -0.62, 2.39, 0.87), (-0.18, 0.02, 2.98, 0.89), (-0.57, -0.40, 2.65, 0.72), (0.48, 0.03, 2.79, 0.7)]):
        beam("Tree bough", root, (0, 0, 1.17), (x, y, z), 0.065, ROCK)
        ob = sphere("Dense foliage crown", root, (x, y, z), (s, s * 0.82, s * 0.84), LEAF_LIGHT if i % 3 == 0 else LEAF, 2)
        ob.rotation_euler[2] = i * 0.81
    for i in range(5):
        a = i * math.tau / 5
        beam("Exposed tree root", root, (0, 0, 0.16), (0.39 * math.cos(a), 0.39 * math.sin(a), 0.045), 0.064, ROCK)
    return root


def consolidate(root):
    """Bake real bevel geometry, then join by immediate semantic parent to reduce draw calls."""
    bpy.context.view_layer.update()
    groups = {}
    depsgraph = bpy.context.evaluated_depsgraph_get()
    for ob in list(root.children_recursive):
        if ob.type != "MESH":
            continue
        evaluated = ob.evaluated_get(depsgraph)
        ob.data = bpy.data.meshes.new_from_object(evaluated, depsgraph=depsgraph)
        ob.modifiers.clear()
        groups.setdefault(ob.parent, []).append(ob)
    for parent, meshes in groups.items():
        bpy.ops.object.select_all(action="DESELECT")
        for ob in meshes:
            ob.select_set(True)
        bpy.context.view_layer.objects.active = meshes[0]
        bpy.ops.object.join()
        bpy.context.object.name = parent.name + "_Geometry"


def inspect_glb(path, expected_root):
    data = path.read_bytes()
    magic, version, length = struct.unpack_from("<III", data)
    assert (magic, version, length) == (0x46546C67, 2, len(data)), path
    json_size, chunk_type = struct.unpack_from("<II", data, 12)
    assert chunk_type == 0x4E4F534A
    doc = json.loads(data[20:20 + json_size])
    names = [node.get("name", "") for node in doc["nodes"]]
    assert expected_root in names and doc.get("meshes"), (path, names)
    assert all(buffer.get("uri") is None for buffer in doc["buffers"]), "GLB must be self-contained"
    triangles = sum(doc["accessors"][primitive["indices"]]["count"] // 3 for mesh in doc["meshes"] for primitive in mesh["primitives"])
    return {"file": str(path.relative_to(ROOT)).replace("\\", "/"), "bytes": len(data), "meshes": len(doc["meshes"]), "triangles": triangles, "nodes": names}


jobs = [
    ("mining_rover", rover), ("city_garage", garage), ("city_refinery", refinery),
    ("city_warehouse", warehouse), ("city_housing", housing),
    ("ore_iron", lambda: rock_cluster("IronOre", IRON)),
    ("ore_copper", lambda: rock_cluster("CopperOre", COPPER)),
    ("wilderness_rock", lambda: rock_cluster("WildernessRock")),
    ("enemy_crab", crab), ("enemy_scout", scout),
    ("player", lambda: person("Player")), ("npc_worker", lambda: person("NPCWorker", True)),
    ("relay_station", relay_station), ("service_terminal", service_terminal),
    ("cargo_crate", cargo_crate), ("tree", tree),
]
assets, manifest = [], []
for filename, build in jobs:
    root = build()
    consolidate(root)
    bpy.ops.object.select_all(action="DESELECT")
    for ob in [root, *root.children_recursive]:
        ob.select_set(True)
    path = MODELS / (filename + ".glb")
    root.rotation_euler[2] = math.pi
    bpy.ops.export_scene.gltf(filepath=str(path), export_format="GLB", use_selection=True, export_apply=True, export_cameras=False, export_lights=False, export_yup=True)
    root.rotation_euler[2] = 0
    info = inspect_glb(path, root.name)
    if filename == "mining_rover":
        assert {"Body", "Turret", "MiningArm", "RepairArm", "Wheels"}.issubset(info["nodes"])
    bpy.context.view_layer.update()
    corners = [ob.matrix_world @ Vector(corner) for ob in root.children_recursive if ob.type == "MESH" for corner in ob.bound_box]
    extent = [max(v[i] for v in corners) - min(v[i] for v in corners) for i in range(3)]
    info["size_meters_gltf"] = {"width_x": round(extent[0], 3), "height_y": round(extent[2], 3), "depth_z": round(extent[1], 3)}
    info["blender_root"] = root.name
    info["unit"] = "meter"
    if filename in ("player", "npc_worker"):
        for limb in root.children:
            if limb.name.startswith("Arm_"):
                assert abs(limb.location.z - 1.37) < 0.0001
            if limb.name.startswith("Leg_"):
                assert abs(limb.location.z - 0.90) < 0.0001
        info["limb_pivots"] = "Arm_L/Arm_R at shoulders (Blender z=1.37m); Leg_L/Leg_R at hips (z=0.90m); local rotation.x swings each limb fore/aft. NPC names carry .001 suffixes in this library."
    manifest.append(info)
    assets.append(root)
    print("ASSET_COMPLETE " + json.dumps(info), flush=True)

# One genuine rendered contact sheet; exported models retain their full meter scale.
layout = [((-11.4, -5.7, 0), 1.15), ((-11.7, 4.2, 0), 0.70), ((-3.6, 4.2, 0), 0.70), ((4.6, 4.2, 0), 0.70), ((12.7, 4.2, 0), 0.70), ((1.4, -5.7, 0), 1.0), ((4.5, -5.7, 0), 1.0), ((7.5, -5.7, 0), 0.85), ((-6.0, -5.7, 0), 1.2), ((-2.0, -5.7, 0), 1.15), ((11.3, -5.7, 0), 1.55), ((13.2, -5.7, 0), 1.55)]
layout += [((-10.5, 10.7, 0), 0.7), ((-4.5, 10.7, 0), 1.5), ((0.2, 10.7, 0), 1.5), ((5.6, 10.7, 0), 0.95)]
for root, (loc, scale) in zip(assets, layout):
    root.location, root.scale = loc, (scale,) * 3
    root["export_unit"] = "meters; display transform only in this overview scene"
box("Preview floor", None, (0, 0, -0.13), (200, 200, 0.20), material("Warm studio floor", (0.65, 0.68, 0.65), rough=0.88), 0)
scene = bpy.context.scene
scene.world.color = (0.4, 0.4, 0.4)
scene.world.use_nodes = True
scene.world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.65, 0.75, 0.85, 1)
scene.world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.45
for name, loc, energy, size in [("Large softbox", (-8, -10, 22), 4800, 14), ("Sky fill", (13, 1, 16), 3200, 12)]:
    light_data = bpy.data.lights.new(name, "AREA")
    light_data.energy, light_data.shape, light_data.size = energy, "DISK", size
    ob = bpy.data.objects.new(name, light_data)
    bpy.context.collection.objects.link(ob)
    ob.location = loc
    ob.rotation_euler = (Vector((0, 0, 0)) - ob.location).to_track_quat("-Z", "Y").to_euler()
sun = bpy.data.lights.new("Gentle late morning sun", "SUN")
sun.energy, sun.angle = 1.8, 0.15
sun_ob = bpy.data.objects.new("Gentle late morning sun", sun)
bpy.context.collection.objects.link(sun_ob)
sun_ob.rotation_euler = (math.radians(25), math.radians(-15), math.radians(-30))
camera_data = bpy.data.cameras.new("Orthographic asset review")
camera = bpy.data.objects.new("Orthographic asset review", camera_data)
bpy.context.collection.objects.link(camera)
camera.location = (18, -31, 29)
camera.rotation_euler = (Vector((0.5, 2.3, 0.8)) - camera.location).to_track_quat("-Z", "Y").to_euler()
camera_data.type, camera_data.ortho_scale = "ORTHO", 39
scene.camera = camera
scene.render.engine = "CYCLES"
scene.cycles.samples = 32
scene.cycles.use_denoising = True
scene.render.resolution_x, scene.render.resolution_y = 2200, 1400
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.view_settings.view_transform = "AgX"
scene.render.filepath = str(ARTIFACTS / "blender-model-library.png")
bpy.ops.wm.save_as_mainfile(filepath=str(SOURCE / "frontier_rebuild_library.blend"))
payload = {"generator": "tools/art/rebuild_models.py", "blender": bpy.app.version_string, "source": "assets/source_editable/rebuild/frontier_rebuild_library.blend", "forward": "glTF/Godot -Z; Blender working meshes face -Y and the export root is rotated 180 degrees", "geometry": "Original meshes with baked bevels and PBR materials; no image billboards", "limitations": ["Characters have separate body parts but no skeleton or animation clips.", "No collision shapes or navigation mesh are exported; gameplay owns those.", "Only the overview .blend uses display scaling and layout; independent GLBs retain meter scale."], "assets": manifest}
(ARTIFACTS / "manifest.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
bpy.ops.render.render(write_still=True)
assert (ARTIFACTS / "blender-model-library.png").stat().st_size > 100000

# Round-trip the delivered file, then render that imported geometry as evidence.
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
bpy.ops.import_scene.gltf(filepath=str(MODELS / "mining_rover.glb"))
imported = {ob.name for ob in bpy.context.scene.objects}
assert {"Body", "Turret", "MiningArm", "RepairArm", "Wheels"}.issubset(imported)
assert sum(ob.type == "MESH" for ob in bpy.context.scene.objects) == 10
box("Imported model review floor", None, (0, 0, -0.12), (200, 200, 0.2), GROUND, 0)
for name, pos, power, size in [("GLB key", (-3, 4, 9), 1500, 6), ("GLB fill", (5, -3, 6), 1100, 5)]:
    lamp = bpy.data.lights.new(name, "AREA")
    lamp.energy, lamp.size = power, size
    ob = bpy.data.objects.new(name, lamp)
    bpy.context.collection.objects.link(ob)
    ob.location = pos
    ob.rotation_euler = (Vector((0, 0, 1)) - ob.location).to_track_quat("-Z", "Y").to_euler()
cam = bpy.data.cameras.new("Reimported GLB review")
ob = bpy.data.objects.new("Reimported GLB review", cam)
bpy.context.collection.objects.link(ob)
ob.location = (5.5, 7.5, 5.5)
ob.rotation_euler = (Vector((0, -0.3, 1.1)) - ob.location).to_track_quat("-Z", "Y").to_euler()
cam.type, cam.ortho_scale = "ORTHO", 6.6
scene.camera = ob
scene.render.resolution_x, scene.render.resolution_y = 1400, 1100
scene.render.filepath = str(ARTIFACTS / "glb-rover-import-check.png")
bpy.ops.render.render(write_still=True)
payload["verification"] = {"glb_header_and_self_contained_buffers": str(len(manifest)) + "/" + str(len(manifest)) + " passed", "rover_roundtrip": "Blender GLB reimport: 10 meshes and all five required assembly nodes retained", "rover_import_render": "artifacts/rebuild_models/glb-rover-import-check.png"}
(ARTIFACTS / "manifest.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
print("MODEL_LIBRARY_COMPLETE " + str(ARTIFACTS / "manifest.json"), flush=True)
