"""Generate the pinned Blender 5.2 single-Mesh OBJ fixture for M12-07A.""" import hashlib import json import sys from pathlib import Path import bpy OBJ_NAME = "M12 OBJ Single Mesh" MATERIAL_NAMES = ("M12 OBJ Red", "M12 OBJ Blue") 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_fixture(): bpy.ops.wm.read_factory_settings(use_empty=True) mesh = bpy.data.meshes.new(OBJ_NAME + " Mesh") mesh.from_pydata( [(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)], [], [(0, 1, 2), (0, 2, 3)], ) mesh.update() obj = bpy.data.objects.new(OBJ_NAME, mesh) bpy.context.scene.collection.objects.link(obj) uv_layer = mesh.uv_layers.new(name="UVMap") uv_by_vertex = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)) for loop in mesh.loops: uv_layer.data[loop.index].uv = uv_by_vertex[loop.vertex_index] colors = ((0.8, 0.1, 0.05, 1.0), (0.05, 0.2, 0.85, 1.0)) for name, color in zip(MATERIAL_NAMES, colors): material = bpy.data.materials.new(name) material.diffuse_color = color material.metallic = 0.0 material.roughness = 0.5 mesh.materials.append(material) mesh.polygons[0].material_index = 0 mesh.polygons[1].material_index = 1 for polygon in mesh.polygons: polygon.use_smooth = False obj.select_set(True) bpy.context.view_layer.objects.active = obj return obj 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=False, 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 number(value): parsed = float(value) return 0.0 if parsed == 0.0 else parsed def parse_obj(path): positions = [] texcoords = [] normals = [] faces = [] material_libraries = [] objects = [] 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([number(value) for value in parts[1:4]]) elif kind == "vt": texcoords.append([number(value) for value in parts[1:3]]) elif kind == "vn": normals.append([number(value) for value in parts[1:4]]) elif kind == "mtllib": material_libraries.extend(parts[1:]) elif kind == "o": objects.append(" ".join(parts[1:])) elif kind == "g": current_groups = parts[1:] 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({"vertices": vertices, "material": current_material, "groups": list(current_groups)}) return { "materialLibraries": material_libraries, "objects": objects, "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:]), "properties": {}} materials.append(current) elif current is not None: values = parts[1:] current["properties"][parts[0]] = [number(value) for value in values] if all_value_numbers(values) else " ".join(values) return materials def all_value_numbers(values): if not values: return False try: for value in values: float(value) return True except ValueError: return False 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 / "single-mesh.obj" create_fixture() 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") semantic = parse_obj(obj_path) semantic["materials"] = parse_mtl(mtl_path) report = { "schemaVersion": 1, "task": "M12-07A", "operation": "DESKTOP_OBJ_SINGLE_MESH_FIXTURE", "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, }, "files": [ {"name": obj_path.name, "byteLength": obj_path.stat().st_size, "sha256": sha256_file(obj_path)}, {"name": mtl_path.name, "byteLength": mtl_path.stat().st_size, "sha256": sha256_file(mtl_path)}, ], "semantic": semantic, "nextTask": "M12-07B", } report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") print( "obj-single-mesh-fixture-generated positions=%s texcoords=%s normals=%s faces=%s materials=%s next=%s" % ( len(semantic["positions"]), len(semantic["texcoords"]), len(semantic["normals"]), len(semantic["faces"]), len(semantic["materials"]), 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-single-mesh-fixture.py -- OUTPUT_DIR REPORT") main(args[0], args[1])