#!/usr/bin/env python3 """Split the downloaded LowPoly Cars Blend into a browsable, reusable asset kit. Run through Blender because the source Blend is not safe to open in the sandbox: blender --factory-startup --background --python blender/tools/split_lowpoly_cars.py -- \ --source /Users/que01/Downloads/011.+LowPoly_Cars_01_blend.blend \ --textures /Users/que01/Downloads/textures111 \ --output assets/models/custom/lowpoly_cars """ import json import math import os import shutil import sys import bpy from mathutils import Matrix, Vector TEXTURE_NAME = "color_512x512.jpg" PREVIEW_SIZE = 640 CESIUM_ALBEDO_GAIN = 2.6 def cli_args(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] values = {} while argv: key = argv.pop(0) if not key.startswith("--"): raise RuntimeError("Expected a --key argument, got " + key) if not argv: raise RuntimeError("Missing value for " + key) values[key[2:]] = argv.pop(0) return values def require_file(path, label): if not path or not os.path.isfile(path): raise RuntimeError(label + " does not exist: " + str(path)) def category_for(name): return name.split("_", 1)[0] def roots(): return sorted( (obj for obj in bpy.data.objects if obj.type == "EMPTY" and obj.children), key=lambda obj: obj.name, ) def descendants(root): found = [] pending = list(root.children) while pending: obj = pending.pop() found.append(obj) pending.extend(obj.children) return [obj for obj in found if obj.type == "MESH"] def world_vertices(meshes): return [obj.matrix_world @ vertex.co for obj in meshes for vertex in obj.data.vertices] def longitudinal_angle(points): """Align the longest ground-plane PCA axis with +X for consistent previews.""" mean_x = sum(point.x for point in points) / len(points) mean_y = sum(point.y for point in points) / len(points) xx = sum((point.x - mean_x) ** 2 for point in points) yy = sum((point.y - mean_y) ** 2 for point in points) xy = sum((point.x - mean_x) * (point.y - mean_y) for point in points) return 0.5 * math.atan2(2.0 * xy, xx - yy) def bounds(objects): points = [obj.matrix_world @ Vector(corner) for obj in objects for corner in obj.bound_box] lower = Vector(tuple(min(point[axis] for point in points) for axis in range(3))) upper = Vector(tuple(max(point[axis] for point in points) for axis in range(3))) return lower, upper def reset_temp_collection(collection): for obj in list(collection.objects): mesh = obj.data if obj.type == "MESH" else None bpy.data.objects.remove(obj, do_unlink=True) if mesh and mesh.users == 0: bpy.data.meshes.remove(mesh) def make_normalized_objects(root, collection): source_meshes = descendants(root) points = world_vertices(source_meshes) if not points: raise RuntimeError(root.name + " has no mesh descendants") lower = Vector(tuple(min(point[axis] for point in points) for axis in range(3))) upper = Vector(tuple(max(point[axis] for point in points) for axis in range(3))) center = Vector(((lower.x + upper.x) / 2.0, (lower.y + upper.y) / 2.0, lower.z)) rotation = Matrix.Rotation(-longitudinal_angle(points), 4, "Z") transform = rotation @ Matrix.Translation(-center) result = [] for source in source_meshes: mesh = source.data.copy() mesh.transform(transform @ source.matrix_world) obj = bpy.data.objects.new(source.name, mesh) collection.objects.link(obj) for material in source.data.materials: mesh.materials.append(material) result.append(obj) return result def select_only(objects): bpy.ops.object.select_all(action="DESELECT") for obj in objects: obj.select_set(True) bpy.context.view_layer.objects.active = objects[0] def bake_cesium_texture(texture_path, output_path): """Bake the Cesium albedo gain into the shared JPEG pixels.""" source = bpy.data.images.load(texture_path, check_existing=False) source.name = "LowPolyCarsSourceTexture" result = bpy.data.images.new("LowPolyCarsCesiumTexture", source.size[0], source.size[1], alpha=False) pixels = list(source.pixels) for index in range(0, len(pixels), 4): pixels[index] = min(1.0, pixels[index] * CESIUM_ALBEDO_GAIN) pixels[index + 1] = min(1.0, pixels[index + 1] * CESIUM_ALBEDO_GAIN) pixels[index + 2] = min(1.0, pixels[index + 2] * CESIUM_ALBEDO_GAIN) pixels[index + 3] = 1.0 result.pixels = pixels result.filepath_raw = output_path result.file_format = "JPEG" result.save() return result def setup_material_texture(image): # The source Blend retains a broken relative image datablock. Loading a # fresh datablock, rather than mutating that stale reference, is required # for both Eevee thumbnails and glTF image export. for material in bpy.data.materials: if not material.use_nodes or not material.node_tree: continue for node in material.node_tree.nodes: if node.type == "TEX_IMAGE": node.image = image def export_gltf(objects, asset_dir): gltf_path = os.path.join(asset_dir, "model.gltf") select_only(objects) bpy.ops.export_scene.gltf( filepath=gltf_path, export_format="GLTF_SEPARATE", use_selection=True, export_keep_originals=True, export_image_format="AUTO", ) with open(gltf_path, "r", encoding="utf-8") as handle: gltf = json.load(handle) for image in gltf.get("images", []): uri = image.get("uri") if not uri: continue # Do not delete the exporter URI. Blender can write it as an absolute # path or a relative traversal back to the user's downloaded source. # The final manifest owns one copied texture under textures/ instead. image["uri"] = "../textures/" + TEXTURE_NAME with open(gltf_path, "w", encoding="utf-8") as handle: json.dump(gltf, handle, indent=2) handle.write("\n") def render_preview(objects, path): scene = bpy.context.scene lower, upper = bounds(objects) span = upper - lower target = (lower + upper) / 2.0 target.z = lower.z + span.z * 0.42 camera_data = bpy.data.cameras.new("AssetPreviewCamera") camera = bpy.data.objects.new("AssetPreviewCamera", camera_data) scene.collection.objects.link(camera) camera.location = Vector((span.x * 1.18, -max(span.x, span.y) * 1.42, span.z * 0.92)) direction = target - camera.location camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() camera.data.lens = 52 scene.camera = camera floor_mesh = bpy.data.meshes.new("AssetPreviewFloor") floor_mesh.from_pydata([(-20, -20, 0), (20, -20, 0), (20, 20, 0), (-20, 20, 0)], [], [(0, 1, 2, 3)]) floor = bpy.data.objects.new("AssetPreviewFloor", floor_mesh) scene.collection.objects.link(floor) floor_material = bpy.data.materials.new("AssetPreviewFloor") floor_material.diffuse_color = (0.13, 0.16, 0.18, 1.0) floor.data.materials.append(floor_material) for name, location, energy, size in ( ("AssetPreviewKey", (span.x, -span.y, span.z * 2.2), 1100, 5.0), ("AssetPreviewFill", (-span.x, -span.y * 0.5, span.z * 1.2), 650, 4.0), ("AssetPreviewRim", (0, span.y, span.z * 2.4), 900, 3.0), ): data = bpy.data.lights.new(name, "AREA") data.energy = energy data.shape = "DISK" data.size = size light = bpy.data.objects.new(name, data) scene.collection.objects.link(light) light.location = location light.rotation_euler = (0, 0, 0) light.rotation_euler = (target - light.location).to_track_quat("-Z", "Y").to_euler() scene.render.engine = "BLENDER_EEVEE_NEXT" scene.render.resolution_x = PREVIEW_SIZE scene.render.resolution_y = PREVIEW_SIZE scene.render.resolution_percentage = 100 scene.render.image_settings.file_format = "PNG" scene.render.filepath = path scene.world.color = (0.035, 0.045, 0.055) bpy.ops.render.render(write_still=True) for obj in [camera, floor] + [obj for obj in scene.objects if obj.name.startswith("AssetPreview") and obj not in {camera, floor}]: if obj.name in bpy.data.objects: bpy.data.objects.remove(obj, do_unlink=True) def metrics(objects): lower, upper = bounds(objects) return { "vertices": sum(len(obj.data.vertices) for obj in objects), "triangles": sum(len(loop.vertices) - 2 for obj in objects for loop in obj.data.polygons), "dimensions": [round(value, 4) for value in upper - lower], "materials": sorted({material.name for obj in objects for material in obj.data.materials if material}), } def gallery_html(assets): cards = "\n".join( "
\"{name}\"

{name}

" "

{category} ยท {triangles:,} tris

{dims} m

{materials}

".format( preview=asset["preview"], name=asset["name"], category=asset["category"], triangles=asset["metrics"]["triangles"], dims=" x ".join(str(value) for value in asset["metrics"]["dimensions"]), materials=", ".join(asset["metrics"]["materials"]), ) for asset in assets ) return """LowPoly Cars
""" + cards + "
\n" def verify(output): manifest_path = os.path.join(output, "manifest.json") require_file(manifest_path, "manifest") with open(manifest_path, "r", encoding="utf-8") as handle: manifest = json.load(handle) if not manifest.get("license"): raise RuntimeError("Manifest must state the source license status") ids = [asset["id"] for asset in manifest.get("assets", [])] if len(ids) != len(set(ids)) or not ids: raise RuntimeError("Manifest has missing or duplicate asset IDs") for asset in manifest["assets"]: for field in ("model", "bin", "preview"): require_file(os.path.join(output, asset[field]), asset["id"] + " " + field) if asset["metrics"]["vertices"] <= 0 or asset["metrics"]["triangles"] <= 0: raise RuntimeError(asset["id"] + " has empty geometry") with open(os.path.join(output, asset["model"]), "r", encoding="utf-8") as handle: gltf = json.load(handle) uris = [image.get("uri") for image in gltf.get("images", [])] if uris != ["../textures/" + TEXTURE_NAME]: raise RuntimeError(asset["id"] + " does not reference the shared texture") require_file(os.path.join(output, "textures", TEXTURE_NAME), "shared texture") print("LOWPOLY_CARS_VERIFY", json.dumps({"assets": len(ids), "ids": ids})) def main(): args = cli_args() if "verify" in args: verify(os.path.abspath(args["verify"])) return source = os.path.abspath(args.get("source", "")) texture_dir = os.path.abspath(args.get("textures", "")) output = os.path.abspath(args.get("output", "")) require_file(source, "source Blend") texture_path = os.path.join(texture_dir, TEXTURE_NAME) require_file(texture_path, "required shared texture") if os.path.exists(output): raise RuntimeError("Output already exists; remove it only after reviewing its contents: " + output) bpy.ops.wm.open_mainfile(filepath=source) # The source Blend is a display lineup. Hide it before thumbnail rendering; # the normalized temporary mesh objects remain renderable. for obj in bpy.context.scene.objects: obj.hide_render = True os.makedirs(output) shared_textures = os.path.join(output, "textures") os.makedirs(shared_textures) # Match the Cesium tree profile by baking the gain into the output pixels. setup_material_texture(bake_cesium_texture(texture_path, os.path.join(shared_textures, TEXTURE_NAME))) temp = bpy.data.collections.new("LowPolyCarsExport") bpy.context.scene.collection.children.link(temp) assets = [] for root in roots(): asset_dir = os.path.join(output, root.name) os.makedirs(asset_dir) objects = make_normalized_objects(root, temp) export_gltf(objects, asset_dir) render_preview(objects, os.path.join(asset_dir, "preview.png")) assets.append({ "id": root.name, "name": root.name.replace("_", " ").title(), "category": category_for(root.name), "sourceRoot": root.name, "model": root.name + "/model.gltf", "bin": root.name + "/model.bin", "preview": root.name + "/preview.png", "metrics": metrics(objects), }) reset_temp_collection(temp) manifest = { "source": os.path.basename(source), "license": "Unknown; verify the original download license before redistribution.", "texture": "textures/" + TEXTURE_NAME, "layout": "Each asset has model.gltf/model.bin/preview.png; the 512px color texture is shared in textures/.", "assets": assets, } with open(os.path.join(output, "manifest.json"), "w", encoding="utf-8") as handle: json.dump(manifest, handle, indent=2) handle.write("\n") with open(os.path.join(output, "index.html"), "w", encoding="utf-8") as handle: handle.write(gallery_html(assets)) verify(output) print("LOWPOLY_CARS_DONE", json.dumps({"assets": len(assets), "output": output})) if __name__ == "__main__": main()