{name}
" "{category} ยท {triangles:,} tris
{dims} m
{materials}
#!/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(
" {category} ยท {triangles:,} tris {dims} m {materials}{name}
"
"