Back to Supersonic RC Revive
SOURCE / PINNED RELEASE

Made of little things.

Supersonic RC Revive

Release
1ba42f1ca1d6…
Author-recorded commit
baecad10b1cd…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-143146b0d6f

Archive hash verified: 90d22b206672eba4…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

tools/blender/ssrc_level.py
# SuperSonic RC level tools for Blender (docs/level-editing.md).
#
# As an add-on (Edit > Preferences > Add-ons > Install from Disk): an "SSRC"
# tab in the 3D view sidebar to mark collision objects, place markers,
# validate and export a level .glb the game loads directly.
#
# From the command line:
#   blender -b [file.blend] --python tools/blender/ssrc_level.py -- <command>
#     validate                      check the open level
#     bake vertex|lightmap [--samples N] [--exposure X] [--max-edge M] [--size 512] [--save out.blend] [--glb out.glb]
#     export <out.glb>              validate, then export with the game's settings
#     export-levels <dir>           export every <dir>/*.blend to <dir>/*.glb (pnpm levels:export)
#     template <out.blend> [--glb out.glb]   a new starter level built from primitives
#     convert <packdir> <out.blend> [--glb out.glb]   a pack's level as an editable level
#
# The conventions mirror src/game/level/editorLevel.ts: object custom
# properties ssrc_role / ssrc_collide / ssrc_render / ssrc_ray / ssrc_friction /
# ssrc_name / ssrc_order, empties named ssrc_start / checkpoint_N / prizes_N,
# scene property ssrc_level. Blender metres, Z-up; the game uses inches.

bl_info = {
    "name": "SuperSonic RC level tools",
    "author": "SuperSonic RC Revive",
    "version": (1, 0, 0),
    "blender": (5, 2, 0),
    "location": "View3D > Sidebar > SSRC",
    "description": "Edit and export SuperSonic RC driving levels",
    "category": "Import-Export",
}

import json
import math
import os
import re
import struct
import sys

import bmesh
import bpy
from mathutils import Matrix, Vector

LEVEL_VERSION = 1
INCH = 0.0254
ROLES = ("visual", "collision", "ray", "car_hull", "ignore")
ROLE_DEFAULTS = {
    "visual": dict(render=True, collide="mesh", ray=True),
    "collision": dict(render=False, collide="mesh", ray=False),
    "ray": dict(render=False, collide="none", ray=True),
    "car_hull": dict(render=False, collide="none", ray=False),
    "ignore": dict(render=False, collide="none", ray=False),
}
MARKER = re.compile(r"^(ssrc_start|checkpoint_(\d+)|prizes_(\d+))$", re.I)
# Gameplay radii (src/game/lingo/gameplay.ts), shown as the empties' spheres.
CHECKPOINT_RADIUS = 300 * INCH
PRIZE_RADIUS = 100 * INCH


def base_name(name):
    return re.sub(r"\.\d{3,}$", "", name)


def effective_flags(obj):
    """How the game treats a mesh object (mirrors objectFlags in editorLevel.ts)."""
    base = base_name(obj.name)
    role = obj.get("ssrc_role")
    if role not in ROLE_DEFAULTS:
        role = "collision" if base.upper().startswith("COL_") else "ray" if base.upper().startswith("RAY_") else "visual"
    d = ROLE_DEFAULTS[role]
    render = bool(obj.get("ssrc_render", d["render"]))
    collide = obj.get("ssrc_collide", d["collide"])
    if collide not in ("none", "mesh", "convex"):
        collide = d["collide"]
    ray = bool(obj.get("ssrc_ray", render if role == "visual" else d["ray"]))
    if role == "ignore":
        render, collide, ray = False, "none", False
    name = obj.get("ssrc_name") or re.sub(r"^(COL|RAY)_", "", base, flags=re.I)
    return dict(role=role, render=render, collide=collide, ray=ray, name=name, friction=float(obj.get("ssrc_friction", 0.3)))


def is_level(scene):
    return "ssrc_level" in scene.keys()


def make_level(scene, title=None):
    scene["ssrc_level"] = LEVEL_VERSION
    if title:
        scene["ssrc_title"] = title
    scene.unit_settings.system = "METRIC"
    scene.unit_settings.scale_length = 1.0


def level_objects(scene):
    return list(scene.objects)


def markers(scene):
    out = {}
    for o in level_objects(scene):
        if o.type == "EMPTY":
            m = MARKER.match(base_name(o.name))
            if m:
                out.setdefault(m.group(1).lower(), []).append(o)
    return out


def numbered(scene, prefix):
    """{number: [objects]} for checkpoint_N / prizes_N empties."""
    out = {}
    for o in level_objects(scene):
        m = re.match(rf"^{prefix}_(\d+)$", base_name(o.name), re.I)
        if o.type == "EMPTY" and m:
            out.setdefault(int(m.group(1)), []).append(o)
    return out


def lightmap_image_nodes(mat):
    """Image Texture nodes feeding Emission Color through a UV Map node (the lightmap convention)."""
    if not mat or mat.node_tree is None:
        return []
    found = []
    for node in mat.node_tree.nodes:
        if node.type != "BSDF_PRINCIPLED":
            continue
        sock = node.inputs.get("Emission Color")
        for link in sock.links if sock else []:
            img = link.from_node
            if img.type == "TEX_IMAGE":
                vec = img.inputs["Vector"].links
                uv = vec[0].from_node.uv_map if vec and vec[0].from_node.type == "UVMAP" else None
                found.append((img, uv))
    return found


def validate(scene):
    """[(severity, message)], severity 'ERROR' or 'WARNING'."""
    issues = []
    if not is_level(scene):
        issues.append(("ERROR", "The scene is not marked as an SSRC level (scene property ssrc_level)."))
    objs = level_objects(scene)
    marks = markers(scene)
    for key, lst in marks.items():
        if len(lst) > 1:
            issues.append(("ERROR", f"{len(lst)} markers are named {key}: {', '.join(o.name for o in lst)}."))
    if "ssrc_start" not in marks:
        issues.append(("WARNING", "No ssrc_start empty: the car starts at the original game's start position."))
    cps = sorted(numbered(scene, "checkpoint"))
    if cps and cps != list(range(1, len(cps) + 1)):
        missing = next(i for i in range(1, cps[-1] + 1) if i not in cps)
        issues.append(("ERROR", f"Checkpoints must be numbered 1, 2, 3… without gaps: checkpoint_{missing} is missing, so later ones are ignored."))
    prizes = numbered(scene, "prizes")
    if prizes and max(prizes) > 139:
        issues.append(("ERROR", "Prizes above prizes_139 are ignored by the game."))
    colliders = [o for o in objs if o.type == "MESH" and effective_flags(o)["collide"] != "none"]
    if not colliders:
        issues.append(("ERROR", "Nothing collides: the car would fall forever."))
    hulls = [o for o in objs if o.type == "MESH" and effective_flags(o)["role"] == "car_hull"]
    if len(hulls) > 1:
        issues.append(("WARNING", f"{len(hulls)} car_hull objects; the game uses the last one."))
    for o, keys in stale_exact_data(scene):
        issues.append(("WARNING", f"{o.name} was edited: export drops its converted exact data ({', '.join(keys)}) and uses the mesh."))
    lighting = scene.get("ssrc_lighting")
    for o in objs:
        if o.type in ("CURVE", "FONT", "SURFACE", "META"):
            issues.append(("WARNING", f"{o.name} is a {o.type.lower()}; convert it to a mesh so the game can use it."))
        if o.type != "MESH":
            continue
        f = effective_flags(o)
        if o.data.color_attributes.get("Bake") and not o.get("ssrc_baked") and lighting != "vertex":
            issues.append(("WARNING", f"{o.name} has a Bake colour attribute but no ssrc_baked property, so the game ignores it."))
        if f["collide"] == "convex" and len(o.data.vertices) > 2000:
            issues.append(("WARNING", f"{o.name} is a convex collider with {len(o.data.vertices)} vertices; use a simpler collision twin."))
        for slot in o.material_slots:
            for img, uv in lightmap_image_nodes(slot.material):
                if not uv or uv not in o.data.uv_layers:
                    issues.append(("ERROR", f"{o.name}: lightmap {img.image.name if img.image else img.name} needs a UV Map node naming one of the object's UV maps."))
                elif o.data.uv_layers.find(uv) != 1:
                    issues.append(("WARNING", f"{o.name}: the lightmap UV map {uv} should be the second UV map (the exporter writes it as TEXCOORD_1)."))
    return issues


def _same_points(flat, coords, tol=1e-3):
    """Whether two point clouds (inches) cover each other within `tol`, duplicates aside."""
    a = [tuple(flat[k:k + 3]) for k in range(0, len(flat), 3)]
    b = list(coords)

    def covered(src, dst):
        grid = {}
        for p in dst:
            grid.setdefault(tuple(math.floor(c) for c in p), []).append(p)
        for p in src:
            cx, cy, cz = (math.floor(c) for c in p)
            if not any(max(abs(p[0] - q[0]), abs(p[1] - q[1]), abs(p[2] - q[2])) <= tol
                       for dx in (-1, 0, 1) for dy in (-1, 0, 1) for dz in (-1, 0, 1)
                       for q in grid.get((cx + dx, cy + dy, cz + dz), ())):
                return False
        return True
    return covered(a, b) and covered(b, a)


def stale_exact_data(scene):
    """[(object, [keys])] whose converted exact data no longer matches the edited object."""
    out = []
    for o in level_objects(scene):
        if o.type != "MESH":
            continue
        mesh = [tuple(c / INCH for c in v.co) for v in o.data.vertices]
        keys = []
        if "ssrc_verts" in o.keys() and not _same_points(list(o["ssrc_verts"]), mesh):
            keys += ["ssrc_verts", "ssrc_faces"]
        if "ssrc_hull_points" in o.keys() and not _same_points(list(o["ssrc_hull_points"]), mesh):
            keys.append("ssrc_hull_points")
        if "ssrc_hull_matrix" in o.keys():
            want = to_metres(w3d_matrix(list(o["ssrc_hull_matrix"])))
            if any(abs(a - b) > 1e-5 for ra, rb in zip(want, o.matrix_world) for a, b in zip(ra, rb)):
                keys.append("ssrc_hull_matrix")
        if keys:
            out.append((o, keys))
    return out


def prune_exact_data(scene):
    for o, keys in stale_exact_data(scene):
        for k in keys:
            if k in o.keys():
                del o[k]
        print(f"{o.name}: edited, dropped {', '.join(keys)}")


EXPORT_SETTINGS = dict(
    export_format="GLB",
    export_extras=True,
    export_apply=True,
    export_yup=True,
    export_texcoords=True,
    export_normals=True,
    export_vertex_color="NAME",
    export_vertex_color_name="Bake",
    export_all_vertex_colors=False,
    use_visible=False,
    use_renderable=False,
    use_selection=False,
    export_cameras=False,
    export_lights=False,
    export_animations=False,
    export_draco_mesh_compression_enable=False,
    export_image_format="AUTO",
)


def export_level(path, scene=None):
    scene = scene or bpy.context.scene
    if not is_level(scene):
        make_level(scene)
    prune_exact_data(scene)
    bpy.ops.export_scene.gltf(filepath=path, **EXPORT_SETTINGS)


# --- baking light ------------------------------------------------------------------------

def bake_targets(scene):
    return [o for o in level_objects(scene) if o.type == "MESH" and effective_flags(o)["render"] and o.visible_get()]


def _select_only(context, objs):
    for o in context.view_layer.objects:
        o.select_set(False)
    for o in objs:
        o.select_set(True)
    context.view_layer.objects.active = objs[0]


def _cycles(scene, samples):
    scene.render.engine = "CYCLES"
    scene.cycles.samples = samples
    scene.render.bake.use_pass_direct = True
    scene.render.bake.use_pass_indirect = True
    scene.render.bake.use_pass_color = False


def _unlink_lightmaps(objs):
    """Disconnect existing lightmaps from Emission (they would light the next bake)."""
    for o in objs:
        for slot in o.material_slots:
            for img, _ in lightmap_image_nodes(slot.material):
                for link in list(img.outputs["Color"].links):
                    slot.material.node_tree.links.remove(link)


def densify(obj, max_edge):
    """Subdivide edges longer than `max_edge` metres: vertex light is only as detailed as the mesh."""
    scale = max(abs(c) for c in obj.matrix_world.to_scale()) or 1.0
    bm = bmesh.new()
    bm.from_mesh(obj.data)
    for _ in range(10):
        long = [e for e in bm.edges if e.calc_length() * scale > max_edge]
        if not long:
            break
        bmesh.ops.subdivide_edges(bm, edges=long, cuts=1, use_grid_fill=True)
    bm.to_mesh(obj.data)
    bm.free()
    obj.data.update()


def bake_vertex(context, exposure=1.0, samples=64, max_edge=1.0):
    """Bake the scene's light (direct + bounce, no surface colour) into each drawn mesh's Bake colour attribute."""
    scene = context.scene
    objs = bake_targets(scene)
    if not objs:
        raise RuntimeError("Nothing to bake: no drawn mesh objects")
    _unlink_lightmaps(objs)
    if max_edge > 0:
        for o in objs:
            densify(o, max_edge)
    _cycles(scene, samples)
    for o in objs:
        me = o.data
        attr = me.color_attributes.get("Bake") or me.color_attributes.new("Bake", "FLOAT_COLOR", "CORNER")
        me.color_attributes.active_color = attr
    _select_only(context, objs)
    bpy.ops.object.bake(type="DIFFUSE", pass_filter={"DIRECT", "INDIRECT"}, target="VERTEX_COLORS")
    for o in objs:
        attr = o.data.color_attributes["Bake"]
        if exposure != 1.0:
            for d in attr.data:
                c = d.color
                d.color = (c[0] * exposure, c[1] * exposure, c[2] * exposure, 1.0)
        o["ssrc_baked"] = True
    scene["ssrc_lighting"] = "vertex"
    return len(objs)


def bake_lightmaps(context, size=512, exposure=1.0, samples=64):
    """Bake each drawn mesh's light into its own image on a second UV map ("Lightmap"), multiplied in by the game."""
    scene = context.scene
    objs = bake_targets(scene)
    if not objs:
        raise RuntimeError("Nothing to bake: no drawn mesh objects")
    _unlink_lightmaps(objs)
    _cycles(scene, samples)
    for o in objs:
        me = o.data
        if not me.uv_layers:
            me.uv_layers.new(name="UVMap")
        if "Lightmap" not in me.uv_layers:
            if len(me.uv_layers) != 1:
                raise RuntimeError(f"{o.name}: the lightmap must be the second UV map; remove the extra UV maps")
            me.uv_layers.new(name="Lightmap")
            me.uv_layers.active = me.uv_layers["Lightmap"]
            _select_only(context, [o])
            bpy.ops.object.mode_set(mode="EDIT")
            bpy.ops.mesh.select_all(action="SELECT")
            bpy.ops.uv.lightmap_pack(PREF_CONTEXT="ALL_FACES", PREF_MARGIN_DIV=0.2)
            bpy.ops.object.mode_set(mode="OBJECT")
        me.uv_layers.active = me.uv_layers["Lightmap"]
        img = bpy.data.images.get(f"LM_{o.name}") or bpy.data.images.new(f"LM_{o.name}", size, size, float_buffer=False)
        # One image per object, so its materials become its own.
        for slot in o.material_slots:
            if slot.material and slot.material.users > 1:
                slot.material = slot.material.copy()
        if not o.material_slots:
            o.data.materials.append(material(f"{o.name}_mat", (0.8, 0.8, 0.8)))
        for slot in o.material_slots:
            nt = slot.material.node_tree
            node = next((n for n in nt.nodes if n.type == "TEX_IMAGE" and n.image == img), None) or nt.nodes.new("ShaderNodeTexImage")
            node.image = img
            node.extension = "EXTEND"
            nt.nodes.active = node
            o["_ssrc_lm_node"] = node.name
    _select_only(context, objs)
    bpy.ops.object.bake(type="DIFFUSE", pass_filter={"DIRECT", "INDIRECT"}, target="IMAGE_TEXTURES", margin=4)
    for o in objs:
        img = bpy.data.images[f"LM_{o.name}"]
        if exposure != 1.0:
            px = list(img.pixels)
            img.pixels = [v * exposure if (i % 4) != 3 else v for i, v in enumerate(px)]
        img.file_format = "JPEG"
        img.pack()
        for slot in o.material_slots:
            nt = slot.material.node_tree
            node = nt.nodes[o["_ssrc_lm_node"]]
            uv = next((n for n in nt.nodes if n.type == "UVMAP" and n.uv_map == "Lightmap"), None) or nt.nodes.new("ShaderNodeUVMap")
            uv.uv_map = "Lightmap"
            nt.links.new(uv.outputs["UV"], node.inputs["Vector"])
            bsdf = next(n for n in nt.nodes if n.type == "BSDF_PRINCIPLED")
            nt.links.new(node.outputs["Color"], bsdf.inputs["Emission Color"])
            bsdf.inputs["Emission Strength"].default_value = 1.0
        del o["_ssrc_lm_node"]
        o.data.uv_layers.active = o.data.uv_layers[0]
        if "ssrc_baked" in o.keys():
            del o["ssrc_baked"]
    scene["ssrc_lighting"] = "lightmap"
    return len(objs)


# --- building levels -----------------------------------------------------------------

def material(name, color, image=None):
    mat = bpy.data.materials.get(name) or bpy.data.materials.new(name)
    if mat.node_tree is None:  # new materials have nodes in Blender 5
        mat.use_nodes = True
    bsdf = mat.node_tree.nodes["Principled BSDF"]
    bsdf.inputs["Base Color"].default_value = (*color[:3], 1)
    bsdf.inputs["Roughness"].default_value = 0.8
    if image is not None:
        tex = mat.node_tree.nodes.new("ShaderNodeTexImage")
        tex.image = image
        mat.node_tree.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
    return mat


def mesh_object(name, verts, faces, mat=None, collection=None, **props):
    me = bpy.data.meshes.new(name)
    me.from_pydata([tuple(v) for v in verts], [], [tuple(f) for f in faces])
    me.update()
    obj = bpy.data.objects.new(name, me)
    (collection or bpy.context.scene.collection).objects.link(obj)
    if mat:
        me.materials.append(mat)
    for k, v in props.items():
        obj[k] = v
    return obj


def box_mesh(cx, cy, z0, sx, sy, sz):
    x0, x1, y0, y1, z1 = cx - sx / 2, cx + sx / 2, cy - sy / 2, cy + sy / 2, z0 + sz
    verts = [(x0, y0, z0), (x1, y0, z0), (x1, y1, z0), (x0, y1, z0), (x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1)]
    faces = [(0, 3, 2, 1), (4, 5, 6, 7), (0, 1, 5, 4), (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7)]
    return verts, faces


def wedge_mesh(cx, y0, width, length, height):
    """A ramp rising along +Y from the floor at y0 to `height` at y0 + length."""
    x0, x1, y1 = cx - width / 2, cx + width / 2, y0 + length
    verts = [(x0, y0, 0), (x1, y0, 0), (x1, y1, 0), (x0, y1, 0), (x1, y1, height), (x0, y1, height)]
    faces = [(0, 3, 2, 1), (0, 1, 4, 5), (1, 2, 4), (3, 0, 5), (2, 3, 5, 4)]
    return verts, faces


def add_marker(scene, kind, location, number=None, rotation_z=0.0):
    if kind == "start":
        name = "ssrc_start"
    else:
        prefix = "checkpoint" if kind == "checkpoint" else "prizes"
        if number is None:
            used = numbered(scene, prefix)
            number = next(i for i in range(1, 10000) if i not in used)
        name = f"{prefix}_{number}"
    obj = bpy.data.objects.new(name, None)
    obj.location = location
    obj.rotation_euler = (0, 0, rotation_z)
    if kind == "start":
        obj.empty_display_type = "ARROWS"
        obj.empty_display_size = 1.0
    else:
        obj.empty_display_type = "SPHERE"
        obj.empty_display_size = CHECKPOINT_RADIUS if kind == "checkpoint" else PRIZE_RADIUS
    scene.collection.objects.link(obj)
    return obj


def renumber(scene, prefix):
    """Rename checkpoint_N / prizes_N empties to 1, 2, 3… in their current order."""
    groups = numbered(scene, prefix)
    ordered = [o for n in sorted(groups) for o in sorted(groups[n], key=lambda o: o.name)]
    for o in ordered:
        o.name = f"__ssrc_tmp_{o.name}"
    for i, o in enumerate(ordered, 1):
        o.name = f"{prefix}_{i}"
    return len(ordered)


def collision_twin(obj, convex=False):
    """A hidden COL_ copy of `obj` (optionally its convex hull) that collides instead of it."""
    f = effective_flags(obj)
    me = obj.data.copy()
    me.materials.clear()
    if convex:
        bm = bmesh.new()
        bm.from_mesh(me)
        result = bmesh.ops.convex_hull(bm, input=bm.verts)
        bmesh.ops.delete(bm, geom=[g for g in result["geom_interior"] + result["geom_unused"]], context="VERTS")
        bm.to_mesh(me)
        bm.free()
    twin = bpy.data.objects.new(f"COL_{base_name(obj.name)}", me)
    for coll in obj.users_collection:
        coll.objects.link(twin)
    twin.matrix_world = obj.matrix_world.copy()
    twin["ssrc_role"] = "collision"
    twin["ssrc_name"] = f["name"]
    twin["ssrc_collide"] = "convex" if convex else "mesh"
    if "ssrc_friction" in obj.keys():
        twin["ssrc_friction"] = obj["ssrc_friction"]
    twin.display_type = "WIRE"
    twin.hide_render = True
    obj["ssrc_collide"] = "none"
    return twin


def apply_role(obj, role):
    obj["ssrc_role"] = role
    for k in ("ssrc_render", "ssrc_collide", "ssrc_ray"):
        if k in obj.keys():
            del obj[k]
    hidden = role != "visual"
    obj.display_type = "WIRE" if hidden else "TEXTURED"
    obj.hide_render = hidden


def build_template(title="New level"):
    """A starter level from primitives: a 40 × 60 m room, ramps, a jump, three checkpoints and prizes."""
    bpy.ops.wm.read_factory_settings(use_empty=True)
    scene = bpy.context.scene
    make_level(scene, title)
    floor = material("Floor", (0.55, 0.42, 0.3))
    wall = material("Wall", (0.55, 0.65, 0.85))
    red = material("Ramp", (0.8, 0.2, 0.15))
    yellow = material("Block", (0.95, 0.75, 0.1))
    mesh_object("Floor", *box_mesh(0, 0, -1, 40, 60, 1), floor)
    for name, (cx, cy, sx, sy) in {
        "Wall_east": (20.5, 0, 1, 62), "Wall_west": (-20.5, 0, 1, 62),
        "Wall_north": (0, 30.5, 40, 1), "Wall_south": (0, -30.5, 40, 1),
    }.items():
        mesh_object(name, *box_mesh(cx, cy, 0, sx, sy, 4), wall)
    # A ramp onto a deck, and a kicker with a landing.
    mesh_object("Ramp_up", *wedge_mesh(-10, -12, 6, 10, 2), red, ssrc_collide="convex")
    mesh_object("Deck", *box_mesh(-10, 2, 0, 6, 8, 2), yellow, ssrc_collide="convex")
    mesh_object("Kicker", *wedge_mesh(10, 0, 5, 4, 0.8), red, ssrc_collide="convex")
    for i, (cx, cy) in enumerate([(14, -18), (-14, 22), (12, 22)]):
        mesh_object(f"Block_{i + 1}", *box_mesh(cx, cy, 0, 2.5, 2.5, 2.5), yellow, ssrc_collide="convex")
    add_marker(scene, "start", (0, -24, 0.8))
    add_marker(scene, "checkpoint", (-10, 2, 5.8), 1)
    add_marker(scene, "checkpoint", (10, 18, 3.8), 2)
    add_marker(scene, "checkpoint", (0, -10, 3.8), 3)
    for loc in [(0, -18, 0.8), (-10, 4, 2.8), (10, 10, 0.8), (-16, 26, 0.8)]:
        add_marker(scene, "prize", loc)
    # Light for Bake lighting: a sun plus a soft sky, so faces turned away are not black.
    sun_light = bpy.data.lights.new("Sun", "SUN")
    sun_light.energy = 1.3
    sun = bpy.data.objects.new("Sun", sun_light)
    sun.rotation_euler = (math.radians(40), math.radians(15), math.radians(30))
    scene.collection.objects.link(sun)
    world = bpy.data.worlds.new("Sky")
    if world.node_tree is None:
        world.use_nodes = True
    bg = world.node_tree.nodes.get("Background")
    bg.inputs["Color"].default_value = (0.55, 0.6, 0.7, 1)
    bg.inputs["Strength"].default_value = 0.7
    scene.world = world
    for screen in bpy.data.screens:
        for area in screen.areas:
            for space in area.spaces:
                if space.type == "VIEW_3D":
                    space.clip_end = 2000
    return scene


# --- pack → Blender ------------------------------------------------------------------

def read_glb(path):
    data = open(path, "rb").read()
    if data[:4] != b"glTF":
        raise ValueError(f"{path} is not a GLB")
    jlen = struct.unpack_from("<I", data, 12)[0]
    doc = json.loads(data[20:20 + jlen])
    blen = struct.unpack_from("<I", data, 20 + jlen)[0] if len(data) > 20 + jlen else 0
    return doc, data[28 + jlen:28 + jlen + blen]


def accessor(doc, binary, i):
    acc = doc["accessors"][i]
    width = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}[acc["type"]]
    fmt, size = {5126: ("f", 4), 5125: ("I", 4), 5123: ("H", 2), 5121: ("B", 1)}[acc["componentType"]]
    bv = doc["bufferViews"][acc["bufferView"]]
    base = bv.get("byteOffset", 0) + acc.get("byteOffset", 0)
    stride = bv.get("byteStride", width * size)
    out = []
    for e in range(acc["count"]):
        vals = struct.unpack_from(f"<{width}{fmt}", binary, base + e * stride)
        if acc.get("normalized"):
            vals = tuple(v / ((1 << (size * 8)) - 1) for v in vals)
        out.append(vals)
    return out


def node_matrix(n):
    if "matrix" in n:
        m = n["matrix"]
        return Matrix([m[0:4], m[4:8], m[8:12], m[12:16]]).transposed()
    t = Vector(n.get("translation", (0, 0, 0)))
    r = n.get("rotation", (0, 0, 0, 1))
    from mathutils import Quaternion
    q = Quaternion((r[3], r[0], r[1], r[2]))
    s = n.get("scale", (1, 1, 1))
    return Matrix.Translation(t) @ q.to_matrix().to_4x4() @ Matrix.Diagonal((*s, 1))


def w3d_matrix(m):
    """A W3D/three.js column-major 16-array as a Blender Matrix."""
    return Matrix([m[0:4], m[4:8], m[8:12], m[12:16]]).transposed()


def to_metres(mat):
    """A game-space (inch) transform as a Blender one (metres)."""
    s = Matrix.Diagonal((INCH, INCH, INCH, 1))
    return s @ mat @ s.inverted()


def guard_original(packdir, outputs):
    """Outputs made from ref-out/ (the 2004 game's files) must stay in ref-out/."""
    packdir = os.path.realpath(packdir)
    ref = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "..", "ref-out")
    ref = os.path.realpath(ref)
    original = packdir.startswith(ref + os.sep)
    if original:
        for out in outputs:
            if out and not os.path.realpath(out).startswith(ref + os.sep):
                raise SystemExit(f"{out}: a level made from the original game's files must stay in ref-out/.")
    return original


def convert_pack(packdir, title=None, original=False):
    """Build the open scene from a pack folder's level (level.glb + collision.json + raymesh.bin + markers.json)."""
    bpy.ops.wm.read_factory_settings(use_empty=True)
    scene = bpy.context.scene
    make_level(scene, title)
    if original:
        scene["ssrc_original"] = True
    p = lambda name: os.path.join(packdir, name)
    doc, binary = read_glb(p("level.glb"))
    collision = json.load(open(p("collision.json")))
    mkrs = json.load(open(p("markers.json")))
    table = json.load(open(p("materials.json"))).get("3d", {}) if os.path.exists(p("materials.json")) else {}
    ray = open(p("raymesh.bin"), "rb").read()
    nv, nt = struct.unpack_from("<II", ray, 0)
    rpos = struct.unpack_from(f"<{nv * 3}f", ray, 8)
    ridx = struct.unpack_from(f"<{nt * 3}I", ray, 8 + nv * 12)
    rown = struct.unpack_from(f"<{nt}H", ray, 8 + nv * 12 + nt * 12)
    ray_tris = {}
    for t in range(nt):
        ray_tris.setdefault(rown[t], []).append([tuple(rpos[ridx[t * 3 + k] * 3:ridx[t * 3 + k] * 3 + 3]) for k in range(3)])
    ray_by_name = {name: ray_tris.get(i, []) for i, name in enumerate(mkrs["raymesh_models"])}
    scene["ssrc_gravity"] = collision["gravity"]
    scene["ssrc_havok_scale"] = collision["scale"]

    visuals = bpy.data.collections.new("Level")
    cols = bpy.data.collections.new("Collision")
    rays = bpy.data.collections.new("Ray")
    marks = bpy.data.collections.new("Markers")
    for c in (visuals, cols, rays, marks):
        scene.collection.children.link(c)
    cols.hide_render = rays.hide_render = True

    # Images packed into the .blend straight from the GLB.
    images = {}
    for i, img in enumerate(doc.get("images", [])):
        bv = doc["bufferViews"][img["bufferView"]]
        blob = binary[bv.get("byteOffset", 0):bv.get("byteOffset", 0) + bv["byteLength"]]
        im = bpy.data.images.new(img.get("name") or f"image_{i}", 8, 8)
        im.source = "FILE"
        im.filepath_raw = f"//{im.name}.{'jpg' if img.get('mimeType') == 'image/jpeg' else 'png'}"
        im.pack(data=blob, data_len=len(blob))
        images[i] = im
    lightmap_images = {}

    def lightmap_image(name):
        if name not in lightmap_images:
            path = p(f"lightmaps/{name}.jpg")
            if not os.path.exists(path):
                return None
            im = bpy.data.images.load(path)
            im.name = name
            im.pack()
            lightmap_images[name] = im
        return lightmap_images[name]

    materials = {}

    def blender_material(mi, lightmap):
        key = (mi, lightmap)
        if key in materials:
            return materials[key]
        m = doc.get("materials", [])[mi] if mi is not None else {"name": "default"}
        name = m.get("name", "material")
        mat = bpy.data.materials.new(name if not lightmap else f"{name}+{lightmap}")
        if mat.node_tree is None:  # new materials have nodes in Blender 5
            mat.use_nodes = True
        nodes, links = mat.node_tree.nodes, mat.node_tree.links
        bsdf = nodes["Principled BSDF"]
        pbr = m.get("pbrMetallicRoughness", {})
        f = pbr.get("baseColorFactor", [1, 1, 1, 1])
        w3d = table.get(name)
        diffuse = list(w3d["diffuse"]) if w3d else f[:3]
        ambient = list(w3d["ambient"]) if w3d else [1, 1, 1]
        emissive = list(w3d["emissive"]) if w3d else [0, 0, 0]
        # The game's lightMapManager tweaks for the original level, kept as data.
        if name == "D":
            lightmap, diffuse, ambient = None, [0x44 / 255, 0x11 / 255, 0x11 / 255], [0, 0, 0]
        elif name in ("column", "Ex_Main_02") and not lightmap:
            diffuse, ambient = [0x11 / 255, 0x11 / 255, 0x33 / 255], [0, 0, 0]
        mat["ssrc_diffuse"] = diffuse
        mat["ssrc_ambient"] = ambient
        mat["ssrc_emissive"] = emissive
        bsdf.inputs["Base Color"].default_value = (*diffuse, 1)
        bsdf.inputs["Roughness"].default_value = 0.9
        tex_info = pbr.get("baseColorTexture")
        if tex_info is not None:
            tex = nodes.new("ShaderNodeTexImage")
            tex.image = images[doc["textures"][tex_info["index"]]["source"]]
            uv = nodes.new("ShaderNodeUVMap")
            uv.uv_map = "UVMap"
            links.new(uv.outputs["UV"], tex.inputs["Vector"])
            links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
            if m.get("alphaMode") in ("MASK", "BLEND"):
                links.new(tex.outputs["Alpha"], bsdf.inputs["Alpha"])
        im = lightmap_image(lightmap) if lightmap else None
        if im:
            lm = nodes.new("ShaderNodeTexImage")
            lm.image = im
            lm.extension = "EXTEND"
            uv = nodes.new("ShaderNodeUVMap")
            uv.uv_map = "Lightmap"
            links.new(uv.outputs["UV"], lm.inputs["Vector"])
            links.new(lm.outputs["Color"], bsdf.inputs["Emission Color"])
            bsdf.inputs["Emission Strength"].default_value = 1.0
        materials[key] = (mat, bool(im))
        return materials[key]

    def tri_key(tri):
        return tuple(sorted(tuple(round(c, 2) for c in v) for v in tri))

    # Visual objects: the legacy level.glb (Director Z-up inches; macromelt's SceneRoot dropped).
    order = 0
    def visit(i, parent):
        nonlocal order
        n = doc["nodes"][i]
        world = parent @ node_matrix(n)
        if n.get("name") == "SceneRoot":
            world = Matrix.Identity(4)
        if "mesh" in n:
            mesh = doc["meshes"][n["mesh"]]
            name = n.get("name", "mesh")
            lightmap = (mesh.get("extras") or {}).get("lightmap")
            verts, faces, uv0, uv1, cols_, mats, mat_index = [], [], [], [], [], [], []
            for prim in mesh["primitives"]:
                pos = accessor(doc, binary, prim["attributes"]["POSITION"])
                idx = [i[0] for i in accessor(doc, binary, prim["indices"])] if "indices" in prim else list(range(len(pos)))
                t0 = accessor(doc, binary, prim["attributes"]["TEXCOORD_0"]) if "TEXCOORD_0" in prim["attributes"] else None
                t1 = accessor(doc, binary, prim["attributes"]["TEXCOORD_1"]) if "TEXCOORD_1" in prim["attributes"] else None
                c0 = accessor(doc, binary, prim["attributes"]["COLOR_0"]) if "COLOR_0" in prim["attributes"] else None
                mat, has_lm = blender_material(prim.get("material"), lightmap)
                if mat not in mats:
                    mats.append(mat)
                base = len(verts)
                verts.extend(Vector(v) * INCH for v in pos)
                for k in range(0, len(idx) - 2, 3):
                    tri = idx[k:k + 3]
                    faces.append([base + v for v in tri])
                    mat_index.append(mats.index(mat))
                    # glTF V runs down; Blender's runs up. Legacy lightmaps load with flipY (V up already).
                    uv0.append([(t0[v][0], 1 - t0[v][1]) if t0 else (0, 0) for v in tri])
                    uv1.append([(t1[v][0], t1[v][1]) if t1 else (0, 0) for v in tri])
                    cols_.append([c0[v] if c0 else None for v in tri])
            me = bpy.data.meshes.new(name)
            me.from_pydata([tuple(v) for v in verts], [], faces)
            for mat in mats:
                me.materials.append(mat)
            for poly, mi in zip(me.polygons, mat_index):
                poly.material_index = mi
            uvl = me.uv_layers.new(name="UVMap")
            for poly, tri in zip(me.polygons, uv0):
                for li, uv in zip(poly.loop_indices, tri):
                    uvl.data[li].uv = uv
            if lightmap:
                uvl1 = me.uv_layers.new(name="Lightmap")
                for poly, tri in zip(me.polygons, uv1):
                    for li, uv in zip(poly.loop_indices, tri):
                        uvl1.data[li].uv = uv
            baked = any(c is not None for tri in cols_ for c in tri)
            if baked:
                attr = me.color_attributes.new("Bake", "FLOAT_COLOR", "CORNER")
                for poly, tri in zip(me.polygons, cols_):
                    for li, c in zip(poly.loop_indices, tri):
                        attr.data[li].color = (*c[:3], 1) if len(c) >= 3 else (1, 1, 1, 1)
            me.update()
            obj = bpy.data.objects.new(name, me)
            visuals.objects.link(obj)
            obj.matrix_world = to_metres(world)
            if baked:
                obj["ssrc_baked"] = True
            obj["ssrc_name"] = name
            obj["ssrc_order"] = order
            obj["ssrc_collide"] = "none"
            order += 1
            # Ray mesh: this object's own triangles if they match raymesh.bin, else a RAY_ proxy.
            want = ray_by_name.pop(name, None)
            mine = None
            if want is not None:
                wm = world
                mine = sorted(tri_key([tuple(wm @ Vector(pos)) for pos in t]) for t in
                              ([tuple(Vector(verts[i]) / INCH) for i in f] for f in faces))
                if mine == sorted(tri_key(t) for t in want):
                    obj["ssrc_ray"] = True
                else:
                    obj["ssrc_ray"] = False
                    ray_proxy(name, want)
            else:
                obj["ssrc_ray"] = False
            if name == "car":
                obj["ssrc_role"] = "car_hull"
                obj["ssrc_ray"] = want is not None
                obj.display_type = "WIRE"
                obj.hide_render = True
        for c in n.get("children", []):
            visit(c, world)

    body_tris = {}
    for b in collision["bodies"]:
        v, f = b["verts"], b["faces"]
        body_tris[b["name"]] = sorted(tri_key([tuple(v[i * 3:i * 3 + 3]) for i in f[k:k + 3]]) for k in range(0, len(f), 3))
    rayed_bodies = set()

    def ray_proxy(name, tris):
        # A ray mesh identical to the same-named collision body (the custom levels' coarse
        # geometry) is that body with ssrc_ray, not another object.
        if name in body_tris and name not in rayed_bodies and body_tris[name] == sorted(tri_key(t) for t in tris):
            rayed_bodies.add(name)
            return None
        verts = [Vector(v) * INCH for t in tris for v in t]
        faces = [(k, k + 1, k + 2) for k in range(0, len(verts), 3)]
        o = mesh_object(f"RAY_{name}", verts, faces, collection=rays, ssrc_role="ray", ssrc_name=name)
        o.display_type = "WIRE"
        o.hide_render = True
        return o

    scene_roots = doc["scenes"][doc.get("scene", 0)]["nodes"]
    for r in scene_roots:
        visit(r, Matrix.Identity(4))
    for name, tris in ray_by_name.items():
        if tris:
            ray_proxy(name, tris)

    # Collision bodies, world-space vertices (exact; no transform).
    for i, b in enumerate(collision["bodies"]):
        v = b["verts"]
        verts = [Vector((v[k], v[k + 1], v[k + 2])) * INCH for k in range(0, len(v), 3)]
        f = b["faces"]
        faces = [tuple(f[k:k + 3]) for k in range(0, len(f), 3)]
        # Blender drops the duplicate, loose and degenerate vertices Havok bodies can have; the
        # exact data rides along (ssrc_verts / ssrc_faces) until the mesh is edited.
        clean = [t for t in faces if len(set(t)) == 3]
        o = mesh_object(f"COL_{b['name']}", verts, clean, collection=cols, ssrc_role="collision", ssrc_name=b["name"],
                        ssrc_collide="convex" if b["convex"] else "mesh", ssrc_friction=float(b["friction"]),
                        ssrc_restitution=float(b["restitution"]), ssrc_order=i,
                        ssrc_verts=[float(x) for x in v], ssrc_faces=[int(x) for x in f])
        if b["name"] in rayed_bodies:
            o["ssrc_ray"] = True
        o.display_type = "WIRE"
        o.hide_render = True

    # Car hull from markers.json when the level glb has no `car` proxy.
    hull_objs = [o for o in scene.objects if o.get("ssrc_role") == "car_hull"]
    if hull_objs:
        hull_objs[0]["ssrc_hull_points"] = [float(c) for pnt in mkrs["car_hull"]["positions"] for c in pnt]
        hull_objs[0]["ssrc_hull_matrix"] = [float(c) for c in mkrs["car_hull"]["node_matrix"]]
    else:
        hull = mkrs["car_hull"]
        bm = bmesh.new()
        for pnt in hull["positions"]:
            bm.verts.new(Vector(pnt) * INCH)
        bmesh.ops.convex_hull(bm, input=bm.verts)
        me = bpy.data.meshes.new("car_hull")
        bm.to_mesh(me)
        bm.free()
        o = bpy.data.objects.new("car_hull", me)
        marks.objects.link(o)
        o.matrix_world = to_metres(w3d_matrix(hull["node_matrix"]))
        o["ssrc_role"] = "car_hull"
        o["ssrc_name"] = hull.get("name", "car")
        o["ssrc_hull_points"] = [float(c) for pnt in hull["positions"] for c in pnt]
        o["ssrc_hull_matrix"] = [float(c) for c in hull["node_matrix"]]
        o.display_type = "WIRE"
        o.hide_render = True

    # Marker groups (checkpoints, prizes, start, and the original's other W3D groups).
    for node in mkrs["level"]:
        if node["kind"] != "group":
            continue
        o = bpy.data.objects.new(node["name"], None)
        marks.objects.link(o)
        o.matrix_world = to_metres(w3d_matrix(node["matrix"]))
        if node.get("user"):
            o["ssrc_user"] = node["user"]
        key = node["name"].lower()
        if key.startswith("checkpoint_"):
            o.empty_display_type, o.empty_display_size = "SPHERE", CHECKPOINT_RADIUS
        elif key.startswith("prizes_"):
            o.empty_display_type, o.empty_display_size = "SPHERE", PRIZE_RADIUS
        else:
            o.empty_display_type, o.empty_display_size = "ARROWS", 1.0
    if any(o.get("ssrc_baked") for o in scene.objects):
        scene["ssrc_lighting"] = "vertex"
    for screen in bpy.data.screens:
        for area in screen.areas:
            for space in area.spaces:
                if space.type == "VIEW_3D":
                    space.clip_end = 2000
    return scene


# --- UI --------------------------------------------------------------------------------

class SSRC_OT_make_level(bpy.types.Operator):
    bl_idname = "ssrc.make_level"
    bl_label = "Make SSRC level"
    bl_description = "Mark this scene as a SuperSonic RC level"
    bl_options = {"REGISTER", "UNDO"}

    def execute(self, context):
        make_level(context.scene)
        return {"FINISHED"}


class SSRC_OT_new_level(bpy.types.Operator):
    bl_idname = "ssrc.new_level"
    bl_label = "New level from template"
    bl_description = "Replace the open file with a starter level (unsaved changes are lost)"

    def invoke(self, context, event):
        return context.window_manager.invoke_confirm(self, event)

    def execute(self, context):
        build_template()
        return {"FINISHED"}


class SSRC_OT_set_role(bpy.types.Operator):
    bl_idname = "ssrc.set_role"
    bl_label = "Set role"
    bl_description = "How the game uses the selected mesh objects"
    bl_options = {"REGISTER", "UNDO"}
    role: bpy.props.EnumProperty(items=[(r, r.replace("_", " ").title(), "") for r in ROLES])

    def execute(self, context):
        for o in context.selected_objects:
            if o.type == "MESH":
                apply_role(o, self.role)
        return {"FINISHED"}


class SSRC_OT_set_collide(bpy.types.Operator):
    bl_idname = "ssrc.set_collide"
    bl_label = "Set collision"
    bl_description = "none: no collision; mesh: exact triangles; convex: the convex hull (best for boxes, ramps and props)"
    bl_options = {"REGISTER", "UNDO"}
    collide: bpy.props.EnumProperty(items=[("none", "None", ""), ("mesh", "Mesh", ""), ("convex", "Convex", "")])

    def execute(self, context):
        for o in context.selected_objects:
            if o.type == "MESH":
                o["ssrc_collide"] = self.collide
        return {"FINISHED"}


class SSRC_OT_toggle(bpy.types.Operator):
    bl_idname = "ssrc.toggle"
    bl_label = "Toggle"
    bl_options = {"REGISTER", "UNDO"}
    key: bpy.props.StringProperty()

    def execute(self, context):
        for o in context.selected_objects:
            if o.type == "MESH":
                o[self.key] = not effective_flags(o)[self.key.removeprefix("ssrc_")]
        return {"FINISHED"}


class SSRC_OT_add_property(bpy.types.Operator):
    bl_idname = "ssrc.add_property"
    bl_label = "Add property"
    bl_options = {"REGISTER", "UNDO"}
    key: bpy.props.StringProperty()

    def execute(self, context):
        o = context.object
        if o and self.key not in o.keys():
            o[self.key] = {"ssrc_friction": 0.3, "ssrc_name": effective_flags(o)["name"], "ssrc_order": 0}.get(self.key, "")
        return {"FINISHED"}


class SSRC_OT_add_marker(bpy.types.Operator):
    bl_idname = "ssrc.add_marker"
    bl_label = "Add marker"
    bl_description = "Add a marker empty at the 3D cursor"
    bl_options = {"REGISTER", "UNDO"}
    kind: bpy.props.EnumProperty(items=[("start", "Start", ""), ("checkpoint", "Checkpoint", ""), ("prize", "Prize", "")])

    def execute(self, context):
        scene = context.scene
        if self.kind == "start" and "ssrc_start" in markers(scene):
            self.report({"ERROR"}, "The level already has an ssrc_start")
            return {"CANCELLED"}
        obj = add_marker(scene, self.kind, scene.cursor.location.copy())
        for o in context.selected_objects:
            o.select_set(False)
        obj.select_set(True)
        context.view_layer.objects.active = obj
        return {"FINISHED"}


class SSRC_OT_renumber(bpy.types.Operator):
    bl_idname = "ssrc.renumber"
    bl_label = "Renumber markers"
    bl_description = "Number checkpoints and prizes 1, 2, 3… in their current order, closing gaps"
    bl_options = {"REGISTER", "UNDO"}

    def execute(self, context):
        c = renumber(context.scene, "checkpoint")
        p = renumber(context.scene, "prizes")
        self.report({"INFO"}, f"{c} checkpoints, {p} prizes")
        return {"FINISHED"}


class SSRC_OT_collision_twin(bpy.types.Operator):
    bl_idname = "ssrc.collision_twin"
    bl_label = "Make collision twin"
    bl_description = "Add a hidden COL_ copy that collides instead of the selected object (edit it to simplify)"
    bl_options = {"REGISTER", "UNDO"}
    convex: bpy.props.BoolProperty(name="Convex hull", default=True)

    def execute(self, context):
        for o in [o for o in context.selected_objects if o.type == "MESH"]:
            collision_twin(o, self.convex)
        return {"FINISHED"}


class SSRC_OT_bake(bpy.types.Operator):
    bl_idname = "ssrc.bake"
    bl_label = "Bake lighting"
    bl_description = "Bake the scene's lights into the level with Cycles (the game then uses the bake instead of its own lights)"
    mode: bpy.props.EnumProperty(items=[("vertex", "Vertex colours", "Fast, no extra textures; detail follows the mesh density"),
                                        ("lightmap", "Lightmaps", "A texture per object on a second UV map; sharp shadows, larger file")])
    exposure: bpy.props.FloatProperty(name="Exposure", default=1.0, min=0.05, max=8.0)
    max_edge: bpy.props.FloatProperty(name="Subdivide edges over (m)", description="Vertex mode: split longer edges first so the light has vertices to land on (0 keeps the mesh)", default=1.0, min=0.0, max=50.0)
    samples: bpy.props.IntProperty(name="Samples", default=64, min=1, max=4096)
    size: bpy.props.EnumProperty(name="Lightmap size", items=[(s, s, "") for s in ("256", "512", "1024")], default="512")

    def invoke(self, context, event):
        return context.window_manager.invoke_props_dialog(self)

    def execute(self, context):
        try:
            n = bake_vertex(context, self.exposure, self.samples, self.max_edge) if self.mode == "vertex" else bake_lightmaps(context, int(self.size), self.exposure, self.samples)
        except RuntimeError as e:
            self.report({"ERROR"}, str(e))
            return {"CANCELLED"}
        self.report({"INFO"}, f"Baked {n} objects")
        return {"FINISHED"}


class SSRC_OT_validate(bpy.types.Operator):
    bl_idname = "ssrc.validate"
    bl_label = "Validate"
    bl_description = "Check the level for problems the game would hit"

    def execute(self, context):
        issues = validate(context.scene)
        for sev, msg in issues:
            self.report({sev}, msg)
        if not issues:
            self.report({"INFO"}, "No problems found")
        return {"FINISHED"}


class SSRC_OT_export(bpy.types.Operator):
    bl_idname = "ssrc.export"
    bl_label = "Export level (.glb)"
    bl_description = "Validate, then export a .glb the game loads (Asset pack… on the title screen)"
    filepath: bpy.props.StringProperty(subtype="FILE_PATH")
    filter_glob: bpy.props.StringProperty(default="*.glb", options={"HIDDEN"})

    def invoke(self, context, event):
        if not self.filepath:
            self.filepath = bpy.path.ensure_ext(bpy.data.filepath[:-6] if bpy.data.filepath else "level", ".glb")
        context.window_manager.fileselect_add(self)
        return {"RUNNING_MODAL"}

    def execute(self, context):
        issues = validate(context.scene)
        errors = [m for s, m in issues if s == "ERROR"]
        for sev, msg in issues:
            self.report({sev}, msg)
        if errors:
            return {"CANCELLED"}
        export_level(bpy.path.ensure_ext(self.filepath, ".glb"), context.scene)
        self.report({"INFO"}, f"Exported {self.filepath}")
        return {"FINISHED"}


class SSRC_PT_level(bpy.types.Panel):
    bl_label = "SuperSonic RC level"
    bl_space_type = "VIEW_3D"
    bl_region_type = "UI"
    bl_category = "SSRC"

    def draw(self, context):
        layout = self.layout
        scene = context.scene
        if not is_level(scene):
            layout.operator("ssrc.make_level")
            layout.operator("ssrc.new_level")
            return
        col = layout.column(align=True)
        if "ssrc_title" in scene.keys():
            col.prop(scene, '["ssrc_title"]', text="Title")
        obj = context.object
        if obj and obj.type == "MESH":
            f = effective_flags(obj)
            box = layout.box()
            box.label(text=f"{obj.name} → {f['name']}", icon="MESH_DATA")
            row = box.row(align=True)
            for r in ROLES:
                op = row.operator("ssrc.set_role", text=r.replace("_", " ").title(), depress=f["role"] == r)
                op.role = r
            row = box.row(align=True)
            row.label(text="Collision")
            for c in ("none", "mesh", "convex"):
                op = row.operator("ssrc.set_collide", text=c.title(), depress=f["collide"] == c)
                op.collide = c
            row = box.row(align=True)
            row.operator("ssrc.toggle", text="Drawn", depress=f["render"]).key = "ssrc_render"
            row.operator("ssrc.toggle", text="Wheels/camera ray", depress=f["ray"]).key = "ssrc_ray"
            for key, label in (("ssrc_friction", "Friction"), ("ssrc_name", "Game name"), ("ssrc_order", "Order")):
                if key in obj.keys():
                    box.prop(obj, f'["{key}"]', text=label)
                else:
                    box.operator("ssrc.add_property", text=f"Set {label.lower()}").key = key
            box.operator("ssrc.collision_twin")
        elif obj and obj.type == "EMPTY":
            box = layout.box()
            m = MARKER.match(base_name(obj.name))
            box.label(text=f"Marker: {m.group(1)}" if m else "Empty (exported as a group)", icon="EMPTY_AXIS")
        box = layout.box()
        box.label(text="Markers")
        row = box.row(align=True)
        row.operator("ssrc.add_marker", text="Start").kind = "start"
        row.operator("ssrc.add_marker", text="Checkpoint").kind = "checkpoint"
        row.operator("ssrc.add_marker", text="Prize").kind = "prize"
        box.operator("ssrc.renumber")
        box = layout.box()
        lighting = scene.get("ssrc_lighting") or "game lights"
        box.label(text=f"Lighting: {lighting}", icon="LIGHT_SUN")
        row = box.row(align=True)
        row.operator("ssrc.bake", text="Bake vertex").mode = "vertex"
        row.operator("ssrc.bake", text="Bake lightmaps").mode = "lightmap"
        col = layout.column(align=True)
        col.operator("ssrc.validate", icon="CHECKMARK")
        col.operator("ssrc.export", icon="EXPORT")


CLASSES = (
    SSRC_OT_make_level, SSRC_OT_new_level, SSRC_OT_set_role, SSRC_OT_set_collide, SSRC_OT_toggle,
    SSRC_OT_add_property, SSRC_OT_add_marker, SSRC_OT_renumber, SSRC_OT_collision_twin, SSRC_OT_bake, SSRC_OT_validate,
    SSRC_OT_export, SSRC_PT_level,
)


def register():
    for c in CLASSES:
        bpy.utils.register_class(c)


def unregister():
    for c in reversed(CLASSES):
        bpy.utils.unregister_class(c)


# --- command line ------------------------------------------------------------------------

def report(issues):
    for sev, msg in issues:
        print(f"{sev}: {msg}")
    return not any(s == "ERROR" for s, _ in issues)


def cli(argv):
    if not argv:
        raise SystemExit(__doc__ if __doc__ else "usage: see the header of ssrc_level.py")
    cmd, args = argv[0], argv[1:]
    opt = lambda name: args[args.index(name) + 1] if name in args else None
    if cmd == "validate":
        ok = report(validate(bpy.context.scene))
        raise SystemExit(0 if ok else 1)
    if cmd == "export":
        if not report(validate(bpy.context.scene)):
            raise SystemExit(1)
        export_level(os.path.abspath(args[0]))
        print(f"exported {args[0]}")
    elif cmd == "export-levels":
        folder = os.path.abspath(args[0] if args else "levels")
        blends = sorted(f for f in os.listdir(folder) if f.endswith(".blend")) if os.path.isdir(folder) else []
        failed = []
        for f in blends:
            bpy.ops.wm.open_mainfile(filepath=os.path.join(folder, f))
            print(f"{f}:")
            if not report(validate(bpy.context.scene)):
                failed.append(f)
                continue
            out = os.path.join(folder, f[:-6] + ".glb")
            export_level(out)
            print(f"exported {os.path.relpath(out)}")
        if not blends:
            print(f"no .blend files in {folder}")
        if failed:
            raise SystemExit(f"not exported (fix the errors): {', '.join(failed)}")
    elif cmd == "bake":
        mode = args[0] if args else "vertex"
        samples = int(opt("--samples") or 64)
        exposure = float(opt("--exposure") or 1.0)
        n = bake_vertex(bpy.context, exposure, samples, float(opt("--max-edge") or 1.0)) if mode == "vertex" else bake_lightmaps(bpy.context, int(opt("--size") or 512), exposure, samples)
        if opt("--save"):
            bpy.ops.wm.save_as_mainfile(filepath=os.path.abspath(opt("--save")))
        if opt("--glb"):
            export_level(os.path.abspath(opt("--glb")))
        print(f"baked {n} objects ({mode})")
    elif cmd == "template":
        build_template(opt("--title") or "New level")
        bpy.ops.wm.save_as_mainfile(filepath=os.path.abspath(args[0]))
        if opt("--glb"):
            export_level(os.path.abspath(opt("--glb")))
        print(f"wrote {args[0]}")
    elif cmd == "convert":
        packdir, out = args[0], args[1]
        original = guard_original(packdir, [out, opt("--glb")])
        convert_pack(packdir, opt("--title"), original)
        report(validate(bpy.context.scene))
        bpy.ops.wm.save_as_mainfile(filepath=os.path.abspath(out), compress=True)
        if opt("--glb"):
            export_level(os.path.abspath(opt("--glb")))
        print(f"wrote {out}")
    else:
        raise SystemExit(f"unknown command {cmd}")


if __name__ == "__main__":
    if not hasattr(bpy.types, "SSRC_PT_level"):
        register()
    if "--" in sys.argv:
        cli(sys.argv[sys.argv.index("--") + 1:])