"""Generate the pinned Blender 5.2 OBJ multi-object and negative fixtures for M12-07B.""" import hashlib import json import re import struct import sys from pathlib import Path import bpy OBJECTS = ("M12 OBJ Left", "M12 OBJ Right") MATERIALS = ("M12 OBJ Left Material", "M12 OBJ Right Material") TEXTURE_NAME = "m12_obj_texture.png" def sha256_file(path): digest = hashlib.sha256() with open(path, "rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def text(value): return value.decode("utf-8") if isinstance(value, bytes) else value def runtime_identity(): binary = Path(bpy.app.binary_path) return { "blenderVersion": text(bpy.app.version_string), "versionTuple": list(bpy.app.version), "buildDate": text(bpy.app.build_date), "buildTime": text(bpy.app.build_time), "buildHash": text(bpy.app.build_hash), "buildBranch": text(bpy.app.build_branch), "buildPlatform": text(bpy.app.build_platform), "buildType": text(bpy.app.build_type), "binarySha256": sha256_file(binary), } def create_texture(path): image = bpy.data.images.new("M12 OBJ Texture", width=2, height=2, alpha=True, float_buffer=False) image.pixels = [ 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, ] image.filepath_raw = str(path) image.file_format = "PNG" image.save() return image def material(name, image): value = bpy.data.materials.new(name) value.use_nodes = True nodes = value.node_tree.nodes links = value.node_tree.links principled = nodes.get("Principled BSDF") texture = nodes.new("ShaderNodeTexImage") texture.image = image links.new(texture.outputs["Color"], principled.inputs["Base Color"]) return value def mesh_object(name, offset, material_value): mesh = bpy.data.meshes.new(name + " Mesh") mesh.from_pydata( [(offset - 0.75, -0.75, 0.0), (offset + 0.75, -0.75, 0.0), (offset + 0.0, 0.75, 0.0)], [], [(0, 1, 2)], ) mesh.update() uv = mesh.uv_layers.new(name="UVMap") for loop, value in zip(mesh.loops, ((0.0, 0.0), (1.0, 0.0), (0.5, 1.0))): uv.data[loop.index].uv = value mesh.materials.append(material_value) obj = bpy.data.objects.new(name, mesh) bpy.context.scene.collection.objects.link(obj) return obj def create_scene(output_dir): bpy.ops.wm.read_factory_settings(use_empty=True) image = create_texture(output_dir / TEXTURE_NAME) left_material = material(MATERIALS[0], image) right_material = material(MATERIALS[1], image) left = mesh_object(OBJECTS[0], -1.0, left_material) right = mesh_object(OBJECTS[1], 1.0, right_material) for obj in (left, right): obj.select_set(True) for polygon in obj.data.polygons: polygon.use_smooth = False bpy.context.view_layer.objects.active = left return left, right def export_obj(output_path): result = bpy.ops.wm.obj_export( filepath=str(output_path), export_selected_objects=True, apply_modifiers=False, apply_transform=False, export_eval_mode="DAG_EVAL_VIEWPORT", export_uv=True, export_normals=True, export_colors=False, export_materials=True, export_pbr_extensions=False, export_material_groups=True, export_object_groups=True, export_vertex_groups=False, export_smooth_groups=False, export_triangulated_mesh=False, export_curves_as_nurbs=False, global_scale=1.0, forward_axis="NEGATIVE_Z", up_axis="Y", path_mode="RELATIVE", ) if "FINISHED" not in result: raise RuntimeError("Blender OBJ export did not finish: %s" % (result,)) def parse_obj(path): positions = [] texcoords = [] normals = [] faces = [] material_libraries = [] objects = [] groups = [] current_object = None current_material = None current_groups = [] for raw_line in path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue parts = line.split() kind = parts[0] if kind == "v": positions.append([float(value) for value in parts[1:4]]) elif kind == "vt": texcoords.append([float(value) for value in parts[1:3]]) elif kind == "vn": normals.append([float(value) for value in parts[1:4]]) elif kind == "mtllib": material_libraries.extend(parts[1:]) elif kind == "o": current_object = " ".join(parts[1:]) objects.append(current_object) elif kind == "g": current_groups = parts[1:] for group in current_groups: if group not in groups: groups.append(group) if group.endswith("_Mesh") and group not in objects: current_object = group objects.append(group) elif kind == "usemtl": current_material = " ".join(parts[1:]) elif kind == "f": vertices = [] for token in parts[1:]: indices = token.split("/") vertices.append({ "position": int(indices[0]), "texcoord": int(indices[1]) if len(indices) > 1 and indices[1] else None, "normal": int(indices[2]) if len(indices) > 2 and indices[2] else None, }) faces.append({"object": current_object, "groups": list(current_groups), "material": current_material, "vertices": vertices}) return { "materialLibraries": material_libraries, "objects": objects, "groups": groups, "positions": positions, "texcoords": texcoords, "normals": normals, "faces": faces, } def parse_mtl(path): materials = [] current = None for raw_line in path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue parts = line.split() if parts[0] == "newmtl": current = {"name": " ".join(parts[1:]), "mapKd": None} materials.append(current) elif current is not None and parts[0] == "map_Kd": current["mapKd"] = " ".join(parts[1:]) return materials def negative_index_obj(source, target): lines = [] for raw_line in source.read_text(encoding="utf-8").splitlines(): if not raw_line.startswith("f "): lines.append(raw_line) continue converted = [] for token in raw_line.split()[1:]: position, texcoord, normal = token.split("/") converted.append("%d/%d/%d" % (-int(position), -int(texcoord), -int(normal))) lines.append("f " + " ".join(converted)) target.write_text("\n".join(lines) + "\n", encoding="utf-8") def malformed_obj(source, target): lines = [] replaced = False for raw_line in source.read_text(encoding="utf-8").splitlines(): if raw_line.startswith("f ") and not replaced: lines.append("f 1/1/1 2/2/1") replaced = True else: lines.append(raw_line) target.write_text("\n".join(lines) + "\n", encoding="utf-8") def main(output_dir, report_path): output_dir = Path(output_dir).resolve() report_path = Path(report_path).resolve() output_dir.mkdir(parents=True, exist_ok=True) report_path.parent.mkdir(parents=True, exist_ok=True) obj_path = output_dir / "multi-object.obj" create_scene(output_dir) export_obj(obj_path) mtl_path = obj_path.with_suffix(".mtl") if not mtl_path.exists(): raise RuntimeError("Blender OBJ export did not write the MTL sidecar") negative_path = output_dir / "negative-index.obj" malformed_path = output_dir / "malformed-face.obj" negative_index_obj(obj_path, negative_path) malformed_obj(obj_path, malformed_path) semantic = parse_obj(obj_path) semantic["materials"] = parse_mtl(mtl_path) files = [] for name in (obj_path.name, mtl_path.name, TEXTURE_NAME, negative_path.name, malformed_path.name): item = output_dir / name files.append({"name": name, "byteLength": item.stat().st_size, "sha256": sha256_file(item)}) report = { "schemaVersion": 1, "task": "M12-07B", "operation": "DESKTOP_OBJ_MULTI_OBJECT_AND_NEGATIVE_FIXTURES", "runtime": runtime_identity(), "sourceAnchor": "blender-5.2.0/source/blender/io/wavefront_obj", "operator": "wm.obj_export", "settings": { "forwardAxis": "NEGATIVE_Z", "upAxis": "Y", "globalScale": 1.0, "exportUV": True, "exportNormals": True, "exportMaterials": True, "exportMaterialGroups": True, "exportObjectGroups": True, "pathMode": "RELATIVE", }, "files": files, "semantic": semantic, "negativeIndex": {"file": negative_path.name, "expectedStatus": "ACCEPT_WITH_NEGATIVE_INDICES", "faceCount": 2}, "malformedFace": {"file": malformed_path.name, "expectedCode": "OBJ_FACE_ARITY_INVALID"}, "textureOrigin": {"mtlMapKd": [material["mapKd"] for material in semantic["materials"]], "relative": True, "textureFile": TEXTURE_NAME}, "nextTask": "M12-07C", } report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") print("obj-multi-negative-fixtures-generated objects=%s faces=%s negative=true malformed=true next=%s" % (len(semantic["objects"]), len(semantic["faces"]), report["nextTask"])) if __name__ == "__main__": args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] if len(args) != 2: raise SystemExit("usage: blender --background --python generate-obj-multi-negative-fixtures.py -- OUTPUT_DIR REPORT") main(args[0], args[1])