← Files Build 3D Game RoomsARCHIVED FILE
skills/build-3d-game-rooms/scripts/blender_compose_room.py
5.87 KB · Oct 7, 2026 · 00:32 UTC
#!/usr/bin/env python3
"""Compose a manifest-driven room in Blender and save a proxy-free source."""
from __future__ import annotations
import argparse
import json
import math
import sys
from pathlib import Path
try:
import bpy
from mathutils import Vector
except ImportError as error: # pragma: no cover - Blender-only entrypoint
raise SystemExit("run blender_compose_room.py through Blender") from error
def arguments():
values = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
parser = argparse.ArgumentParser()
parser.add_argument("--package", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
return parser.parse_args(values)
def load(path: Path):
return json.loads(path.read_text(encoding="utf-8"))
def reset_scene():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
for datablocks in (bpy.data.meshes, bpy.data.curves, bpy.data.materials, bpy.data.cameras, bpy.data.lights):
for block in list(datablocks):
if block.users == 0:
datablocks.remove(block)
def material_for(surface):
spec = surface["material"]
material = bpy.data.materials.new(f"MAT_{surface['id']}")
material.diffuse_color = tuple(spec["baseColor"])
material.use_nodes = True
principled = material.node_tree.nodes.get("Principled BSDF")
principled.inputs["Base Color"].default_value = tuple(spec["baseColor"])
principled.inputs["Metallic"].default_value = spec["metallic"]
principled.inputs["Roughness"].default_value = spec["roughness"]
return material
def create_surface(surface):
bpy.ops.mesh.primitive_cube_add(size=1, location=surface["location"])
obj = bpy.context.object
obj.name = f"SHELL_{surface['id']}"
obj.dimensions = surface["dimensionsMeters"]
obj.rotation_euler = [math.radians(value) for value in surface["rotationDegrees"]]
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
obj.data.materials.append(material_for(surface))
obj["room_surface_id"] = surface["id"]
obj["room_surface_kind"] = surface["kind"]
return obj
def world_bounds(objects):
points = [obj.matrix_world @ Vector(corner) for obj in objects if obj.type in {"MESH", "CURVE"} for corner in obj.bound_box]
if not points:
raise ValueError("imported GLB contains no renderable bounds")
low = Vector((min(point.x for point in points), min(point.y for point in points), min(point.z for point in points)))
high = Vector((max(point.x for point in points), max(point.y for point in points), max(point.z for point in points)))
return low, high
def import_instance(package: Path, asset, instance):
mesh = asset.get("acceptedMesh") or {}
mesh_path = package / mesh.get("path", "")
if not mesh_path.is_file() or mesh_path.suffix.lower() != ".glb":
raise ValueError(f"asset {asset['id']} has no accepted GLB")
existing = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=str(mesh_path))
imported = [obj for obj in bpy.data.objects if obj not in existing]
low, high = world_bounds(imported)
dimensions = high - low
target = Vector(asset["dimensionsMeters"])
if min(dimensions) <= 0:
raise ValueError(f"asset {asset['id']} has a zero-sized axis")
root = bpy.data.objects.new(f"PROP_{instance['id']}", None)
bpy.context.scene.collection.objects.link(root)
for obj in imported:
if obj.parent is None or obj.parent not in imported:
obj.parent = root
obj.matrix_parent_inverse = root.matrix_world.inverted()
obj.name = f"{instance['id']}__{obj.name}"
requested_scale = Vector(instance["scale"])
root.scale = Vector((target.x / dimensions.x, target.y / dimensions.y, target.z / dimensions.z))
root.scale.x *= requested_scale.x
root.scale.y *= requested_scale.y
root.scale.z *= requested_scale.z
root.rotation_euler = [math.radians(value) for value in instance["rotationDegrees"]]
root.location = instance["location"]
root["room_instance_id"] = instance["id"]
root["room_asset_id"] = asset["id"]
root["attachment_face"] = asset["attachmentFace"]
root["semantic_front"] = asset["semanticFront"]
root["meshy_task_id"] = mesh.get("taskId", "")
return root
def create_camera(spec):
data = bpy.data.cameras.new("ProductionCamera")
camera = bpy.data.objects.new("ProductionCamera", data)
bpy.context.scene.collection.objects.link(camera)
camera.location = spec["location"]
direction = Vector(spec["target"]) - camera.location
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
data.angle_y = math.radians(spec["verticalFovDegrees"])
bpy.context.scene.camera = camera
def main():
args = arguments()
package = args.package.resolve()
layout = load(package / "room-layout.json")
manifest = load(package / "props.json")
if layout.get("roomId") != manifest.get("roomId"):
raise SystemExit("room-layout.json and props.json roomId values differ")
assets = {asset["id"]: asset for asset in manifest.get("assets", [])}
reset_scene()
for surface in layout.get("surfaces", []):
create_surface(surface)
for instance in layout.get("instances", []):
asset = assets.get(instance["assetId"])
if not asset:
raise ValueError(f"unknown asset {instance['assetId']}")
import_instance(package, asset, instance)
create_camera(layout["productionCamera"])
scene = bpy.context.scene
scene.unit_settings.system = "METRIC"
scene.unit_settings.scale_length = 1.0
scene["room_id"] = layout["roomId"]
scene["room_manifest"] = str((package / "room-layout.json").resolve())
args.output.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(args.output.resolve()))
print(args.output.resolve())
if __name__ == "__main__":
main()
SHA-256: 35e6301321af87d83b3add357bfa15df541ff475ac60cdf87c038dad9cb3be91