straywild/tools/blender/export_terrain_chunks.py

438 lines
14 KiB
Python

#!/usr/bin/env python3
"""Export convention-named terrain chunk collections to individual GLBs.
Run through Blender rather than a standalone Python interpreter:
blender --background terrain_chunks.blend \
--python tools/blender/export_terrain_chunks.py -- \
--output /path/to/terrain_chunks
Collections use ``chunk_####_description`` and contain one primary terrain
mesh named ``chunk_####``. Additional production objects may live in the same
collection; unrelated collections are ignored.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import re
import struct
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import bpy
from mathutils import Vector
CHUNK_SIZE_METERS = 10.0
DEFAULT_TOLERANCE = 0.001
COLLECTION_PATTERN = re.compile(
r"^chunk_(?P<number>\d{4})(?:_(?P<label>[a-z0-9]+(?:_[a-z0-9]+)*))?$"
)
ALLOWED_OBJECT_TYPES = {"EMPTY", "MESH"}
class ExportValidationError(RuntimeError):
"""Raised when the source file does not satisfy the chunk convention."""
@dataclass(frozen=True)
class Bounds:
minimum: tuple[float, float, float]
maximum: tuple[float, float, float]
@property
def size(self) -> tuple[float, float, float]:
return tuple(
self.maximum[axis] - self.minimum[axis] for axis in range(3)
)
@property
def center(self) -> tuple[float, float, float]:
return tuple(
(self.minimum[axis] + self.maximum[axis]) * 0.5
for axis in range(3)
)
@dataclass(frozen=True)
class ChunkSource:
number: int
stable_id: str
label: str
collection: bpy.types.Collection
primary_mesh: bpy.types.Object
objects: tuple[bpy.types.Object, ...]
bounds: Bounds
def _parse_arguments() -> argparse.Namespace:
script_arguments: list[str] = []
if "--" in sys.argv:
script_arguments = sys.argv[sys.argv.index("--") + 1 :]
parser = argparse.ArgumentParser(
description="Export NETfishing terrain chunk collections to GLB files."
)
parser.add_argument(
"--output",
type=Path,
required=True,
help="Directory that will receive chunk_####.glb and chunks.json.",
)
parser.add_argument(
"--tolerance",
type=float,
default=DEFAULT_TOLERANCE,
help="World-space validation tolerance in meters.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Validate and report chunks without writing output files.",
)
return parser.parse_args(script_arguments)
def _close_enough(actual: float, expected: float, tolerance: float) -> bool:
return math.isclose(actual, expected, rel_tol=0.0, abs_tol=tolerance)
def _format_vector(values: Iterable[float]) -> str:
return "(" + ", ".join(f"{value:.4f}" for value in values) + ")"
def _world_bounds(mesh_object: bpy.types.Object) -> Bounds:
evaluated_object = mesh_object.evaluated_get(
bpy.context.evaluated_depsgraph_get()
)
world_corners = [
evaluated_object.matrix_world @ Vector(corner)
for corner in evaluated_object.bound_box
]
minimum = tuple(
min(corner[axis] for corner in world_corners) for axis in range(3)
)
maximum = tuple(
max(corner[axis] for corner in world_corners) for axis in range(3)
)
return Bounds(minimum=minimum, maximum=maximum)
def _validate_transform(
mesh_object: bpy.types.Object,
tolerance: float,
) -> list[str]:
problems: list[str] = []
for axis, value in zip("XYZ", mesh_object.location):
if not _close_enough(value, 0.0, tolerance):
problems.append(f"location {axis} is {value:.6f}, expected 0")
for axis, value in zip("XYZ", mesh_object.rotation_euler):
if not _close_enough(value, 0.0, tolerance):
problems.append(f"rotation {axis} is {value:.6f}, expected 0")
for axis, value in zip("XYZ", mesh_object.scale):
if not _close_enough(value, 1.0, tolerance):
problems.append(f"scale {axis} is {value:.6f}, expected 1")
return problems
def _validate_bounds(bounds: Bounds, tolerance: float) -> list[str]:
problems: list[str] = []
expected_half_size = CHUNK_SIZE_METERS * 0.5
expected_values = {
"minimum X": (bounds.minimum[0], -expected_half_size),
"maximum X": (bounds.maximum[0], expected_half_size),
"minimum Y": (bounds.minimum[1], -expected_half_size),
"maximum Y": (bounds.maximum[1], expected_half_size),
}
for label, (actual, expected) in expected_values.items():
if not _close_enough(actual, expected, tolerance):
problems.append(f"{label} is {actual:.6f}, expected {expected:.6f}")
return problems
def _discover_chunks(tolerance: float) -> list[ChunkSource]:
chunks: list[ChunkSource] = []
errors: list[str] = []
claimed_objects: dict[str, str] = {}
claimed_numbers: dict[int, str] = {}
for collection in sorted(bpy.data.collections, key=lambda item: item.name):
match = COLLECTION_PATTERN.fullmatch(collection.name)
if match is None:
continue
number = int(match.group("number"))
stable_id = f"chunk_{number:04d}"
label = match.group("label") or ""
if number in claimed_numbers:
errors.append(
f"{collection.name}: duplicates numeric ID already used by "
f"{claimed_numbers[number]}"
)
continue
claimed_numbers[number] = collection.name
objects = tuple(sorted(collection.all_objects, key=lambda item: item.name))
if not objects:
errors.append(f"{collection.name}: collection is empty")
continue
invalid_objects = [
item for item in objects if item.type not in ALLOWED_OBJECT_TYPES
]
if invalid_objects:
details = ", ".join(
f"{item.name} ({item.type})" for item in invalid_objects
)
errors.append(
f"{collection.name}: contains unsupported objects: {details}"
)
primary_matches = [
item
for item in objects
if item.name == stable_id and item.type == "MESH"
]
if len(primary_matches) != 1:
errors.append(
f"{collection.name}: expected exactly one primary mesh named "
f"{stable_id}, found {len(primary_matches)}"
)
continue
primary_mesh = primary_matches[0]
for item in objects:
previous_collection = claimed_objects.get(item.name)
if previous_collection is not None:
errors.append(
f"{collection.name}: object {item.name} is also exported by "
f"{previous_collection}"
)
else:
claimed_objects[item.name] = collection.name
transform_problems = _validate_transform(primary_mesh, tolerance)
errors.extend(
f"{collection.name}/{primary_mesh.name}: {problem}"
for problem in transform_problems
)
bounds = _world_bounds(primary_mesh)
bound_problems = _validate_bounds(bounds, tolerance)
errors.extend(
f"{collection.name}/{primary_mesh.name}: {problem}"
for problem in bound_problems
)
if len(primary_mesh.data.vertices) < 3:
errors.append(
f"{collection.name}/{primary_mesh.name}: mesh has fewer than "
"three vertices"
)
if len(primary_mesh.data.materials) == 0:
errors.append(
f"{collection.name}/{primary_mesh.name}: mesh has no material"
)
chunks.append(
ChunkSource(
number=number,
stable_id=stable_id,
label=label,
collection=collection,
primary_mesh=primary_mesh,
objects=objects,
bounds=bounds,
)
)
if not chunks:
errors.append(
"No collections matched chunk_####_description (for example, "
"chunk_0000_grass)."
)
if errors:
raise ExportValidationError("\n".join(errors))
return sorted(chunks, key=lambda item: item.number)
def _select_chunk(chunk: ChunkSource) -> None:
if bpy.context.object is not None and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
# Blender's selection operator can miss objects after a glTF export has
# temporarily changed visibility/context. Set every object explicitly so
# one chunk can never leak into a later chunk's selected-only export.
selected_objects = set(chunk.objects)
for item in bpy.context.view_layer.objects:
item.select_set(item in selected_objects)
for item in chunk.objects:
item.hide_select = False
item.hide_viewport = False
item.hide_set(False)
item.select_set(True)
bpy.context.view_layer.objects.active = chunk.primary_mesh
def _export_chunk(chunk: ChunkSource, output_path: Path) -> None:
_select_chunk(chunk)
result = bpy.ops.export_scene.gltf(
filepath=str(output_path),
check_existing=False,
export_format="GLB",
use_selection=True,
export_apply=True,
export_animations=False,
export_cameras=False,
export_lights=False,
export_materials="EXPORT",
export_morph=False,
export_normals=True,
export_skins=False,
export_tangents=False,
export_texcoords=True,
export_yup=True,
export_extras=True,
will_save_settings=False,
)
if result != {"FINISHED"}:
raise RuntimeError(
f"Blender failed to export {chunk.collection.name}: {result}"
)
_validate_exported_object_membership(chunk, output_path)
def _validate_exported_object_membership(
chunk: ChunkSource,
output_path: Path,
) -> None:
"""Confirm selected-only export did not leak objects from other chunks."""
with output_path.open("rb") as source:
header = source.read(20)
if len(header) != 20 or header[:4] != b"glTF":
raise RuntimeError(f"{output_path.name} is not a valid GLB file")
json_length, json_kind = struct.unpack_from("<II", header, 12)
if json_kind != 0x4E4F534A:
raise RuntimeError(
f"{output_path.name} does not begin with a GLB JSON chunk"
)
document = json.loads(source.read(json_length))
exported_names = {
str(node.get("name", ""))
for node in document.get("nodes", [])
if str(node.get("name", ""))
}
expected_names = {item.name for item in chunk.objects}
if exported_names != expected_names:
raise RuntimeError(
f"{output_path.name} object membership mismatch: expected "
f"{sorted(expected_names)}, exported {sorted(exported_names)}"
)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _round_vector(values: Iterable[float]) -> list[float]:
return [round(value, 6) for value in values]
def _manifest_entry(chunk: ChunkSource, output_path: Path) -> dict[str, object]:
return {
"id": chunk.stable_id,
"number": chunk.number,
"label": chunk.label,
"collection": chunk.collection.name,
"primary_mesh": chunk.primary_mesh.name,
"objects": [item.name for item in chunk.objects],
"file": output_path.name,
"sha256": _sha256(output_path),
"bounds": {
"minimum": _round_vector(chunk.bounds.minimum),
"maximum": _round_vector(chunk.bounds.maximum),
"size": _round_vector(chunk.bounds.size),
"center": _round_vector(chunk.bounds.center),
},
}
def _run() -> int:
arguments = _parse_arguments()
if arguments.tolerance <= 0.0:
raise ExportValidationError("--tolerance must be greater than zero")
chunks = _discover_chunks(arguments.tolerance)
print(
f"Validated {len(chunks)} terrain chunks from "
f"{Path(bpy.data.filepath).resolve()}"
)
for chunk in chunks:
print(
f" {chunk.stable_id}: {chunk.label or '(no label)'} | "
f"bounds {_format_vector(chunk.bounds.minimum)} to "
f"{_format_vector(chunk.bounds.maximum)} | "
f"{len(chunk.objects)} object(s)"
)
if arguments.dry_run:
print("Dry run complete; no files written.")
return 0
output_directory = arguments.output.expanduser().resolve()
output_directory.mkdir(parents=True, exist_ok=True)
entries: list[dict[str, object]] = []
expected_files: set[str] = set()
for chunk in chunks:
output_path = output_directory / f"{chunk.stable_id}.glb"
_export_chunk(chunk, output_path)
expected_files.add(output_path.name)
entry = _manifest_entry(chunk, output_path)
entries.append(entry)
print(
f"Exported {output_path.name} | {output_path.stat().st_size} bytes | "
f"{entry['sha256']}"
)
manifest = {
"format_version": 1,
"chunk_size_meters": CHUNK_SIZE_METERS,
"source_blend": str(Path(bpy.data.filepath).resolve()),
"blender_version": bpy.app.version_string,
"chunks": entries,
}
manifest_path = output_directory / "chunks.json"
manifest_path.write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
expected_files.add(manifest_path.name)
stale_files = sorted(
path.name
for path in output_directory.glob("chunk_*.glb")
if path.name not in expected_files
)
if stale_files:
print(
"WARNING: stale chunk outputs were retained: "
+ ", ".join(stale_files)
)
print(f"Wrote manifest {manifest_path}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(_run())
except ExportValidationError as error:
print(f"EXPORT VALIDATION FAILED:\n{error}", file=sys.stderr)
raise SystemExit(2) from error