Refresh generated terrain assets and projection

This commit is contained in:
Alexander Sellite 2026-08-24 16:31:55 -04:00
parent 3608be41ab
commit 05838406d0
53 changed files with 319 additions and 51 deletions

View file

@ -10,10 +10,11 @@ Run through Blender rather than a standalone Python interpreter:
Collections use ``chunk_####_description`` and contain one primary terrain
mesh named ``chunk_####``. Additional production objects may live in the same
collection. Reusable procedural props use individual ``prop_description``
mesh objects. They may be arranged anywhere in the source file because each
object's authored origin becomes the exported runtime anchor. A same-named
``prop_description`` collection remains supported for multi-object props.
Unrelated objects and collections are ignored.
mesh objects. Their child empties are exported with them so authored sockets
remain attached to the prop. Props may be arranged anywhere in the source file
because each root object's authored origin becomes the exported runtime anchor.
A same-named ``prop_description`` collection remains supported for multi-object
props. Unrelated objects and collections are ignored.
"""
from __future__ import annotations
@ -43,6 +44,12 @@ PROP_COLLECTION_PATTERN = re.compile(
r"^prop_(?P<label>[a-z0-9]+(?:_[a-z0-9]+)*)$"
)
ALLOWED_OBJECT_TYPES = {"EMPTY", "MESH"}
UPWARD_SURFACE_MATERIALS = {
"dirt",
"grass_lite",
"sand",
"water_reference",
}
class ExportValidationError(RuntimeError):
@ -167,6 +174,33 @@ def _validate_transform(
return problems
def _validate_upward_surface_normals(
mesh_object: bpy.types.Object,
) -> list[str]:
"""Reject exposed terrain materials whose faces point into the ground."""
downward_counts: dict[str, int] = {}
materials = mesh_object.data.materials
for polygon in mesh_object.data.polygons:
if polygon.normal.z >= -0.25 or polygon.material_index >= len(materials):
continue
material = materials[polygon.material_index]
if material is None:
continue
material_name = re.sub(r"\.\d{3}$", "", material.name)
if material_name not in UPWARD_SURFACE_MATERIALS:
continue
downward_counts[material_name] = (
downward_counts.get(material_name, 0) + 1
)
return [
(
f"{count} {material_name} face(s) point downward; "
"terrain surface normals must face up"
)
for material_name, count in sorted(downward_counts.items())
]
def _validate_bounds(
bounds: Bounds,
tolerance: float,
@ -318,6 +352,10 @@ def _discover_chunks(tolerance: float) -> list[ChunkSource]:
errors.append(
f"{collection.name}/{primary_mesh.name}: mesh has no material"
)
errors.extend(
f"{collection.name}/{primary_mesh.name}: {problem}"
for problem in _validate_upward_surface_normals(primary_mesh)
)
chunks.append(
ChunkSource(
@ -522,20 +560,38 @@ def _discover_props(tolerance: float) -> list[PropSource]:
if not collections:
errors.append(f"{item.name}: prop object belongs to no collection")
continue
objects = tuple(
sorted(
(item, *item.children_recursive),
key=lambda value: value.name,
)
)
overlapping_sources = {
claimed_objects[descendant.as_pointer()]
for descendant in objects
if descendant.as_pointer() in claimed_objects
}
if overlapping_sources:
errors.append(
f"{item.name}: child object is already exported by "
f"{', '.join(sorted(overlapping_sources))}"
)
continue
prop, source_errors = _make_prop_source(
stable_id,
match.group("label"),
"object",
item.name,
collections[0],
(item,),
objects,
tolerance,
)
errors.extend(source_errors)
if prop is not None:
props.append(prop)
claimed_ids[stable_id] = item.name
claimed_objects[item.as_pointer()] = item.name
for descendant in objects:
claimed_objects[descendant.as_pointer()] = item.name
if errors:
raise ExportValidationError("\n".join(errors))