340 lines
14 KiB
Python
340 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Split the downloaded LowPoly Cars Blend into a browsable, reusable asset kit.
|
|
|
|
Run through Blender because an imported source Blend is not safe to open in the sandbox:
|
|
blender --factory-startup --background --python blender/tools/split_lowpoly_cars.py -- \
|
|
--source /path/to/source-cars.blend \
|
|
--textures /path/to/source-textures \
|
|
--output assets/models/custom/lowpoly_cars
|
|
|
|
This is an optional re-import tool. Completed asset kits do not read the source
|
|
Blend during area builds or Cesium preview generation.
|
|
"""
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import shutil
|
|
import sys
|
|
|
|
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(
|
|
"<article><img src=\"{preview}\" alt=\"{name}\"><h2>{name}</h2>"
|
|
"<p>{category} · {triangles:,} tris</p><p>{dims} m</p><p>{materials}</p></article>".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 """<!doctype html><meta charset=\"utf-8\"><title>LowPoly Cars</title>
|
|
<style>body{margin:24px;font:14px system-ui,sans-serif;background:#edf1f3;color:#16212a}main{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px}article{background:#fff;border:1px solid #cbd5db;border-radius:6px;padding:10px}img{width:100%;aspect-ratio:1;object-fit:contain;background:#20282e}h2{font-size:15px;margin:8px 0 4px}p{margin:3px 0;color:#4a5862}</style><main>""" + cards + "</main>\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
|
|
# Keep verification independent from Blender so the checked-in kit can be
|
|
# validated after the original source has been removed.
|
|
global bpy, Matrix, Vector
|
|
import bpy
|
|
from mathutils import Matrix, Vector
|
|
|
|
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()
|