Spaces:
Running
Running
| # backend/blender/process_model.py | |
| # S4: headless Blender pass for the OMEGA model pipeline. | |
| # Runs INSIDE Blender: blender --background --factory-startup --python process_model.py -- <args> | |
| # Two modes: | |
| # --input model.glb → import, clean, normalize part names, apply template, export | |
| # --blueprint blueprint.json → procedural build from a Forge blueprint (Builder flow) | |
| # Output is always a GLB (no draco — the WebAR GLTFLoader ships without a decoder). | |
| # | |
| # Kept to bpy APIs stable across 4.2 LTS → 5.x: import_scene.gltf, export_scene.gltf, | |
| # object.transform_apply, mesh primitive ops. | |
| import json | |
| import math | |
| import re | |
| import sys | |
| import bpy | |
| def parse_args(): | |
| argv = sys.argv | |
| argv = argv[argv.index("--") + 1:] if "--" in argv else [] | |
| args = {"input": "", "output": "out.glb", "template": "", "blueprint": "", | |
| "target_size": "1.0", "name": "model"} | |
| key = None | |
| for token in argv: | |
| if token.startswith("--"): | |
| key = token[2:].replace("-", "_") | |
| if key not in args: | |
| args[key] = "" | |
| elif key: | |
| args[key] = token | |
| key = None | |
| return args | |
| def clean_scene(): | |
| bpy.ops.wm.read_factory_settings(use_empty=True) | |
| def slug(value): | |
| return re.sub(r"^_+|_+$", "", re.sub(r"[^a-z0-9]+", "_", str(value or "").lower())) | |
| def normalize_part_names(): | |
| """Lowercase, underscore-separated object names so Forge template matching | |
| (client findMatchingBlueprintTemplate / server match_template) hits reliably.""" | |
| renamed = {} | |
| for obj in bpy.data.objects: | |
| clean = slug(obj.name) | |
| if clean and clean != obj.name: | |
| renamed[obj.name] = clean | |
| obj.name = clean | |
| return renamed | |
| def cleanup_meshes(): | |
| """Conservative cleanup: recalc outside normals + drop loose geometry. | |
| Deliberately NO merge-by-distance / decimate — destructive on UV seams.""" | |
| for obj in bpy.data.objects: | |
| if obj.type != "MESH": | |
| continue | |
| bpy.context.view_layer.objects.active = obj | |
| obj.select_set(True) | |
| bpy.ops.object.mode_set(mode="EDIT") | |
| bpy.ops.mesh.select_all(action="SELECT") | |
| bpy.ops.mesh.normals_make_consistent(inside=False) | |
| bpy.ops.mesh.delete_loose() | |
| bpy.ops.object.mode_set(mode="OBJECT") | |
| obj.select_set(False) | |
| def apply_template(template): | |
| """Stamp Forge interactivity metadata as glTF extras (custom properties). | |
| The WebAR client's discoverPartsFromObject reads userData.partType/partActions.""" | |
| if not template: | |
| return 0 | |
| aliases = {slug(k): v for k, v in (template.get("partAliases") or {}).items()} | |
| part_meta = {} | |
| for part in template.get("parts") or []: | |
| part_meta[slug(part.get("name"))] = part | |
| stamped = 0 | |
| for obj in bpy.data.objects: | |
| key = slug(obj.name) | |
| if key in aliases: | |
| obj.name = slug(aliases[key]) | |
| key = obj.name | |
| meta = part_meta.get(key) | |
| if meta: | |
| obj["partType"] = str(meta.get("type") or "detail") | |
| obj["partActions"] = json.dumps(meta.get("actions") or []) | |
| stamped += 1 | |
| return stamped | |
| def center_and_scale(target_size): | |
| """Center at origin, feet on floor (y=0 in glTF +Y-up terms), uniform scale so | |
| the largest dimension equals target_size — honest scale for AR placement.""" | |
| import mathutils | |
| meshes = [o for o in bpy.data.objects if o.type == "MESH"] | |
| if not meshes: | |
| return | |
| mins = mathutils.Vector((math.inf,) * 3) | |
| maxs = mathutils.Vector((-math.inf,) * 3) | |
| for obj in meshes: | |
| for corner in obj.bound_box: | |
| world = obj.matrix_world @ mathutils.Vector(corner) | |
| mins = mathutils.Vector(map(min, mins, world)) | |
| maxs = mathutils.Vector(map(max, maxs, world)) | |
| size = max(maxs - mins) | |
| if size <= 0: | |
| return | |
| scale = float(target_size) / size | |
| center = (mins + maxs) / 2 | |
| for obj in bpy.data.objects: | |
| if obj.parent is None: | |
| obj.location = (obj.location - center) * scale | |
| obj.location.z += (center.z - mins.z) * scale # rest on floor | |
| obj.scale = obj.scale * scale | |
| GEOMETRY_OPS = { | |
| "box": lambda: bpy.ops.mesh.primitive_cube_add(size=0.25), | |
| "cube": lambda: bpy.ops.mesh.primitive_cube_add(size=0.25), | |
| "sphere": lambda: bpy.ops.mesh.primitive_uv_sphere_add(radius=0.15), | |
| "cylinder": lambda: bpy.ops.mesh.primitive_cylinder_add(radius=0.1, depth=0.28), | |
| "cone": lambda: bpy.ops.mesh.primitive_cone_add(radius1=0.12, depth=0.26), | |
| "torus": lambda: bpy.ops.mesh.primitive_torus_add(major_radius=0.14, minor_radius=0.02), | |
| "capsule": lambda: bpy.ops.mesh.primitive_uv_sphere_add(radius=0.08), | |
| "octahedron": lambda: bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=1, radius=0.12), | |
| "icosahedron": lambda: bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=2, radius=0.12), | |
| } | |
| MATERIAL_PRESETS = { | |
| # (base_color RGBA, metallic, roughness, emission_strength) | |
| "matte": ((0.48, 0.74, 1.0, 1.0), 0.1, 0.55, 0.0), | |
| "metal": ((0.56, 0.78, 1.0, 1.0), 0.92, 0.16, 0.0), | |
| "glass": ((0.81, 0.94, 1.0, 0.36), 0.0, 0.05, 0.0), | |
| "glow": ((0.0, 0.78, 1.0, 1.0), 0.0, 0.4, 2.0), | |
| "wood": ((0.55, 0.42, 0.31, 1.0), 0.02, 0.82, 0.0), | |
| } | |
| def make_material(kind): | |
| name = f"forge_{kind}" | |
| mat = bpy.data.materials.get(name) | |
| if mat: | |
| return mat | |
| mat = bpy.data.materials.new(name) | |
| mat.use_nodes = True | |
| bsdf = mat.node_tree.nodes.get("Principled BSDF") | |
| color, metallic, roughness, emission = MATERIAL_PRESETS.get(kind, MATERIAL_PRESETS["matte"]) | |
| if bsdf: | |
| bsdf.inputs["Base Color"].default_value = color | |
| bsdf.inputs["Metallic"].default_value = metallic | |
| bsdf.inputs["Roughness"].default_value = roughness | |
| if emission and "Emission Strength" in bsdf.inputs: | |
| bsdf.inputs["Emission Strength"].default_value = emission | |
| bsdf.inputs["Emission Color"].default_value = color | |
| if color[3] < 1.0: | |
| mat.blend_method = "BLEND" | |
| bsdf.inputs["Alpha"].default_value = color[3] | |
| return mat | |
| def build_from_blueprint(blueprint): | |
| """Procedural Builder mode — mirrors the client's model-forge part vocabulary | |
| so a Gemini blueprint renders identically whether built client-side or here.""" | |
| built = 0 | |
| for part in blueprint.get("parts") or []: | |
| geometry = str(part.get("geometry") or "box").lower() | |
| op = GEOMETRY_OPS.get(geometry, GEOMETRY_OPS["box"]) | |
| op() | |
| obj = bpy.context.active_object | |
| obj.name = slug(part.get("name") or f"part_{built}") | |
| pos = part.get("position") or [0, 0, 0] | |
| rot = part.get("rotation") or [0, 0, 0] | |
| scale = part.get("scale") or [1, 1, 1] | |
| # Blueprint space is glTF (+Y up); Blender is +Z up → swap Y/Z. | |
| obj.location = (float(pos[0]), -float(pos[2]), float(pos[1])) | |
| obj.rotation_euler = (float(rot[0]), -float(rot[2]), float(rot[1])) | |
| obj.scale = (float(scale[0]), float(scale[2]), float(scale[1])) | |
| obj["partType"] = str(part.get("type") or "detail") | |
| obj["partActions"] = json.dumps(part.get("actions") or []) | |
| obj.data.name = obj.name # keep mesh data-block names aligned — GLB scanners see both | |
| obj.data.materials.append(make_material(str(part.get("material") or "matte").lower())) | |
| built += 1 | |
| return built | |
| def export_glb(path): | |
| bpy.ops.export_scene.gltf( | |
| filepath=path, | |
| export_format="GLB", | |
| export_yup=True, | |
| export_extras=True, # partType/partActions custom props → glTF extras | |
| export_apply=True, | |
| export_draco_mesh_compression_enable=False, | |
| ) | |
| def main(): | |
| args = parse_args() | |
| clean_scene() | |
| report = {"mode": "", "parts": 0, "renamed": 0, "stamped": 0, "output": args["output"]} | |
| template = None | |
| if args["template"]: | |
| with open(args["template"], encoding="utf-8") as fh: | |
| template = json.load(fh) | |
| if args["blueprint"]: | |
| with open(args["blueprint"], encoding="utf-8") as fh: | |
| blueprint = json.load(fh) | |
| report["mode"] = "builder-procedural" | |
| report["parts"] = build_from_blueprint(blueprint) | |
| elif args["input"]: | |
| report["mode"] = "forge-process" | |
| bpy.ops.import_scene.gltf(filepath=args["input"]) | |
| report["renamed"] = len(normalize_part_names()) | |
| cleanup_meshes() | |
| report["parts"] = len([o for o in bpy.data.objects if o.type == "MESH"]) | |
| else: | |
| print("OMEGA_BLENDER_RESULT " + json.dumps({"error": "no --input or --blueprint"})) | |
| sys.exit(2) | |
| report["stamped"] = apply_template(template) | |
| center_and_scale(args["target_size"]) | |
| export_glb(args["output"]) | |
| print("OMEGA_BLENDER_RESULT " + json.dumps(report)) | |
| if __name__ == "__main__": | |
| main() | |