File size: 8,931 Bytes
a31f556
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# 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()