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.
# 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:])
