forked from woofmeow/straywild
Expand generated terrain with biome-driven props
This commit is contained in:
parent
e8888ed05a
commit
1e24fb0bfc
103 changed files with 5427 additions and 351 deletions
|
|
@ -9,7 +9,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; unrelated collections are ignored.
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -30,10 +34,14 @@ from mathutils import Vector
|
|||
|
||||
|
||||
CHUNK_SIZE_METERS = 10.0
|
||||
DEFAULT_TOLERANCE = 0.001
|
||||
# Match the runtime boundary analyzer's five-millimeter authoring tolerance.
|
||||
DEFAULT_TOLERANCE = 0.005
|
||||
COLLECTION_PATTERN = re.compile(
|
||||
r"^chunk_(?P<number>\d{4})(?:_(?P<label>[a-z0-9]+(?:_[a-z0-9]+)*))?$"
|
||||
)
|
||||
PROP_COLLECTION_PATTERN = re.compile(
|
||||
r"^prop_(?P<label>[a-z0-9]+(?:_[a-z0-9]+)*)$"
|
||||
)
|
||||
ALLOWED_OBJECT_TYPES = {"EMPTY", "MESH"}
|
||||
|
||||
|
||||
|
|
@ -71,6 +79,22 @@ class ChunkSource:
|
|||
bounds: Bounds
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PropSource:
|
||||
stable_id: str
|
||||
label: str
|
||||
source_kind: str
|
||||
source_name: str
|
||||
collection: bpy.types.Collection
|
||||
primary_mesh: bpy.types.Object
|
||||
objects: tuple[bpy.types.Object, ...]
|
||||
bounds: Bounds
|
||||
anchor_world: tuple[float, float, float]
|
||||
|
||||
|
||||
ExportSource = ChunkSource | PropSource
|
||||
|
||||
|
||||
def _parse_arguments() -> argparse.Namespace:
|
||||
script_arguments: list[str] = []
|
||||
if "--" in sys.argv:
|
||||
|
|
@ -82,7 +106,10 @@ def _parse_arguments() -> argparse.Namespace:
|
|||
"--output",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="Directory that will receive chunk_####.glb and chunks.json.",
|
||||
help=(
|
||||
"Directory that will receive chunk_####.glb, prop_*.glb, and "
|
||||
"chunks.json."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tolerance",
|
||||
|
|
@ -140,18 +167,47 @@ def _validate_transform(
|
|||
return problems
|
||||
|
||||
|
||||
def _validate_bounds(bounds: Bounds, tolerance: float) -> list[str]:
|
||||
def _validate_bounds(
|
||||
bounds: Bounds,
|
||||
tolerance: float,
|
||||
allow_open_east_edge: bool = False,
|
||||
allow_open_south_edge: bool = False,
|
||||
) -> 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),
|
||||
}
|
||||
if not allow_open_east_edge:
|
||||
expected_values["maximum X"] = (
|
||||
bounds.maximum[0],
|
||||
expected_half_size,
|
||||
)
|
||||
if not allow_open_south_edge:
|
||||
expected_values["minimum Y"] = (
|
||||
bounds.minimum[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}")
|
||||
if (
|
||||
allow_open_east_edge
|
||||
and bounds.maximum[0] > expected_half_size + tolerance
|
||||
):
|
||||
problems.append(
|
||||
f"maximum X is {bounds.maximum[0]:.6f}, expected no greater than "
|
||||
f"{expected_half_size:.6f}"
|
||||
)
|
||||
if (
|
||||
allow_open_south_edge
|
||||
and bounds.minimum[1] < -expected_half_size - tolerance
|
||||
):
|
||||
problems.append(
|
||||
f"minimum Y is {bounds.minimum[1]:.6f}, expected no less than "
|
||||
f"{-expected_half_size:.6f}"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
|
|
@ -223,7 +279,19 @@ def _discover_chunks(tolerance: float) -> list[ChunkSource]:
|
|||
)
|
||||
|
||||
bounds = _world_bounds(primary_mesh)
|
||||
bound_problems = _validate_bounds(bounds, tolerance)
|
||||
# Coastline meshes may intentionally stop before the positive-X edge,
|
||||
# leaving the surrounding ocean visible. Positive X is the canonical
|
||||
# authored ocean direction; Godot supplies the quarter-turn variants.
|
||||
allow_open_east_edge = "ocean_edge" in label
|
||||
# Corner pieces additionally leave canonical negative Y open. Their
|
||||
# two marked ocean edges rotate together at runtime.
|
||||
allow_open_south_edge = "ocean_edge_corner" in label
|
||||
bound_problems = _validate_bounds(
|
||||
bounds,
|
||||
tolerance,
|
||||
allow_open_east_edge=allow_open_east_edge,
|
||||
allow_open_south_edge=allow_open_south_edge,
|
||||
)
|
||||
errors.extend(
|
||||
f"{collection.name}/{primary_mesh.name}: {problem}"
|
||||
for problem in bound_problems
|
||||
|
|
@ -261,58 +329,309 @@ def _discover_chunks(tolerance: float) -> list[ChunkSource]:
|
|||
return sorted(chunks, key=lambda item: item.number)
|
||||
|
||||
|
||||
def _select_chunk(chunk: ChunkSource) -> None:
|
||||
def _validate_prop_bounds(bounds: Bounds, tolerance: float) -> list[str]:
|
||||
problems: list[str] = []
|
||||
for axis, size in zip("XYZ", bounds.size):
|
||||
if size <= tolerance:
|
||||
problems.append(f"bounds size {axis} is {size:.6f}, expected positive")
|
||||
return problems
|
||||
|
||||
|
||||
def _rebased_prop_bounds(
|
||||
mesh_objects: list[bpy.types.Object],
|
||||
anchor_world: Vector,
|
||||
) -> Bounds:
|
||||
corners: list[Vector] = []
|
||||
dependency_graph = bpy.context.evaluated_depsgraph_get()
|
||||
for mesh_object in mesh_objects:
|
||||
evaluated_object = mesh_object.evaluated_get(dependency_graph)
|
||||
corners.extend(
|
||||
evaluated_object.matrix_world @ Vector(corner) - anchor_world
|
||||
for corner in evaluated_object.bound_box
|
||||
)
|
||||
minimum = tuple(
|
||||
min(corner[axis] for corner in corners) for axis in range(3)
|
||||
)
|
||||
maximum = tuple(
|
||||
max(corner[axis] for corner in corners) for axis in range(3)
|
||||
)
|
||||
return Bounds(minimum=minimum, maximum=maximum)
|
||||
|
||||
|
||||
def _prop_transform_problems(
|
||||
source_objects: tuple[bpy.types.Object, ...],
|
||||
tolerance: float,
|
||||
) -> list[str]:
|
||||
problems: list[str] = []
|
||||
source_set = set(source_objects)
|
||||
for item in source_objects:
|
||||
if item.parent is not None and item.parent not in source_set:
|
||||
problems.append(
|
||||
f"{item.name} is parented outside its exported prop source"
|
||||
)
|
||||
for label, values in (
|
||||
("location", item.location),
|
||||
("rotation", item.rotation_euler),
|
||||
("scale", item.scale),
|
||||
):
|
||||
if not all(math.isfinite(value) for value in values):
|
||||
problems.append(f"{item.name} has a non-finite {label}")
|
||||
for axis, value in zip("XYZ", item.scale):
|
||||
if abs(value) <= tolerance:
|
||||
problems.append(
|
||||
f"{item.name} scale {axis} is {value:.6f}, expected nonzero"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def _make_prop_source(
|
||||
stable_id: str,
|
||||
label: str,
|
||||
source_kind: str,
|
||||
source_name: str,
|
||||
collection: bpy.types.Collection,
|
||||
objects: tuple[bpy.types.Object, ...],
|
||||
tolerance: float,
|
||||
) -> tuple[PropSource | None, list[str]]:
|
||||
errors: list[str] = []
|
||||
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"{source_name}: contains unsupported objects: {details}")
|
||||
|
||||
mesh_objects = [item for item in objects if item.type == "MESH"]
|
||||
named_meshes = [item for item in mesh_objects if item.name == stable_id]
|
||||
if len(named_meshes) == 1:
|
||||
primary_mesh = named_meshes[0]
|
||||
elif len(mesh_objects) == 1:
|
||||
primary_mesh = mesh_objects[0]
|
||||
else:
|
||||
errors.append(
|
||||
f"{source_name}: expected one mesh (preferably named "
|
||||
f"{stable_id}), found {len(mesh_objects)}"
|
||||
)
|
||||
return None, errors
|
||||
|
||||
errors.extend(
|
||||
f"{source_name}: {problem}"
|
||||
for problem in _prop_transform_problems(objects, tolerance)
|
||||
)
|
||||
anchor_vector = primary_mesh.matrix_world.translation.copy()
|
||||
bounds = _rebased_prop_bounds(mesh_objects, anchor_vector)
|
||||
errors.extend(
|
||||
f"{source_name}/{primary_mesh.name}: {problem}"
|
||||
for problem in _validate_prop_bounds(bounds, tolerance)
|
||||
)
|
||||
for mesh_object in mesh_objects:
|
||||
if len(mesh_object.data.vertices) < 3:
|
||||
errors.append(
|
||||
f"{source_name}/{mesh_object.name}: has fewer than three vertices"
|
||||
)
|
||||
if len(mesh_object.data.materials) == 0:
|
||||
errors.append(
|
||||
f"{source_name}/{mesh_object.name}: has no material"
|
||||
)
|
||||
|
||||
return (
|
||||
PropSource(
|
||||
stable_id=stable_id,
|
||||
label=label,
|
||||
source_kind=source_kind,
|
||||
source_name=source_name,
|
||||
collection=collection,
|
||||
primary_mesh=primary_mesh,
|
||||
objects=objects,
|
||||
bounds=bounds,
|
||||
anchor_world=tuple(anchor_vector),
|
||||
),
|
||||
errors,
|
||||
)
|
||||
|
||||
|
||||
def _discover_props(tolerance: float) -> list[PropSource]:
|
||||
props: list[PropSource] = []
|
||||
errors: list[str] = []
|
||||
claimed_objects: dict[int, str] = {}
|
||||
claimed_ids: dict[str, str] = {}
|
||||
|
||||
for collection in sorted(bpy.data.collections, key=lambda item: item.name):
|
||||
match = PROP_COLLECTION_PATTERN.fullmatch(collection.name)
|
||||
if match is None:
|
||||
continue
|
||||
stable_id = collection.name
|
||||
label = match.group("label")
|
||||
objects = tuple(sorted(collection.all_objects, key=lambda item: item.name))
|
||||
if not objects:
|
||||
errors.append(f"{collection.name}: collection is empty")
|
||||
continue
|
||||
for item in objects:
|
||||
object_key = item.as_pointer()
|
||||
previous_source = claimed_objects.get(object_key)
|
||||
if previous_source is not None:
|
||||
errors.append(
|
||||
f"{collection.name}: object {item.name} is also exported by "
|
||||
f"{previous_source}"
|
||||
)
|
||||
else:
|
||||
claimed_objects[object_key] = collection.name
|
||||
prop, source_errors = _make_prop_source(
|
||||
stable_id,
|
||||
label,
|
||||
"collection",
|
||||
collection.name,
|
||||
collection,
|
||||
objects,
|
||||
tolerance,
|
||||
)
|
||||
errors.extend(source_errors)
|
||||
if prop is not None:
|
||||
props.append(prop)
|
||||
claimed_ids[stable_id] = collection.name
|
||||
|
||||
for item in sorted(bpy.data.objects, key=lambda value: value.name):
|
||||
match = PROP_COLLECTION_PATTERN.fullmatch(item.name)
|
||||
if match is None or item.as_pointer() in claimed_objects:
|
||||
continue
|
||||
stable_id = item.name
|
||||
if stable_id in claimed_ids:
|
||||
errors.append(
|
||||
f"{item.name}: duplicates prop ID already used by "
|
||||
f"{claimed_ids[stable_id]}"
|
||||
)
|
||||
continue
|
||||
if item.type != "MESH":
|
||||
errors.append(f"{item.name}: prop object must be a mesh")
|
||||
continue
|
||||
collections = sorted(item.users_collection, key=lambda value: value.name)
|
||||
if not collections:
|
||||
errors.append(f"{item.name}: prop object belongs to no collection")
|
||||
continue
|
||||
prop, source_errors = _make_prop_source(
|
||||
stable_id,
|
||||
match.group("label"),
|
||||
"object",
|
||||
item.name,
|
||||
collections[0],
|
||||
(item,),
|
||||
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
|
||||
|
||||
if errors:
|
||||
raise ExportValidationError("\n".join(errors))
|
||||
return sorted(props, key=lambda item: item.stable_id)
|
||||
|
||||
|
||||
def _validate_distinct_source_membership(
|
||||
chunks: list[ChunkSource],
|
||||
props: list[PropSource],
|
||||
) -> None:
|
||||
claimed_objects: dict[int, str] = {}
|
||||
errors: list[str] = []
|
||||
for source in [*chunks, *props]:
|
||||
source_name = (
|
||||
source.collection.name
|
||||
if isinstance(source, ChunkSource)
|
||||
else source.source_name
|
||||
)
|
||||
for item in source.objects:
|
||||
object_key = item.as_pointer()
|
||||
previous_source = claimed_objects.get(object_key)
|
||||
if previous_source is not None:
|
||||
errors.append(
|
||||
f"{source_name}: object {item.name} is also exported by "
|
||||
f"{previous_source}"
|
||||
)
|
||||
else:
|
||||
claimed_objects[object_key] = source_name
|
||||
if errors:
|
||||
raise ExportValidationError("\n".join(errors))
|
||||
|
||||
|
||||
def _select_source(source: ExportSource) -> 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)
|
||||
selected_objects = set(source.objects)
|
||||
for item in bpy.context.view_layer.objects:
|
||||
item.select_set(item in selected_objects)
|
||||
for item in chunk.objects:
|
||||
for item in source.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
|
||||
bpy.context.view_layer.objects.active = source.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}"
|
||||
def _export_source(source: ExportSource, output_path: Path) -> None:
|
||||
_select_source(source)
|
||||
saved_root_matrices: dict[bpy.types.Object, object] = {}
|
||||
if isinstance(source, PropSource):
|
||||
source_objects = set(source.objects)
|
||||
anchor = Vector(source.anchor_world)
|
||||
for item in source.objects:
|
||||
if item.parent in source_objects:
|
||||
continue
|
||||
saved_root_matrices[item] = item.matrix_world.copy()
|
||||
rebased_matrix = item.matrix_world.copy()
|
||||
rebased_matrix.translation -= anchor
|
||||
item.matrix_world = rebased_matrix
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
try:
|
||||
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,
|
||||
)
|
||||
_validate_exported_object_membership(chunk, output_path)
|
||||
finally:
|
||||
for item, matrix in saved_root_matrices.items():
|
||||
item.matrix_world = matrix
|
||||
if saved_root_matrices:
|
||||
bpy.context.view_layer.update()
|
||||
if result != {"FINISHED"}:
|
||||
source_name = (
|
||||
source.collection.name
|
||||
if isinstance(source, ChunkSource)
|
||||
else source.source_name
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Blender failed to export {source_name}: {result}"
|
||||
)
|
||||
_validate_exported_object_membership(source, output_path)
|
||||
|
||||
|
||||
def _validate_exported_object_membership(
|
||||
chunk: ChunkSource,
|
||||
source: ExportSource,
|
||||
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)
|
||||
"""Confirm selected-only export contains exactly the intended objects."""
|
||||
with output_path.open("rb") as binary_file:
|
||||
header = binary_file.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)
|
||||
|
|
@ -320,13 +639,13 @@ def _validate_exported_object_membership(
|
|||
raise RuntimeError(
|
||||
f"{output_path.name} does not begin with a GLB JSON chunk"
|
||||
)
|
||||
document = json.loads(source.read(json_length))
|
||||
document = json.loads(binary_file.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}
|
||||
expected_names = {item.name for item in source.objects}
|
||||
if exported_names != expected_names:
|
||||
raise RuntimeError(
|
||||
f"{output_path.name} object membership mismatch: expected "
|
||||
|
|
@ -365,14 +684,41 @@ def _manifest_entry(chunk: ChunkSource, output_path: Path) -> dict[str, object]:
|
|||
}
|
||||
|
||||
|
||||
def _prop_manifest_entry(
|
||||
prop: PropSource,
|
||||
output_path: Path,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"id": prop.stable_id,
|
||||
"label": prop.label,
|
||||
"source_kind": prop.source_kind,
|
||||
"source_name": prop.source_name,
|
||||
"collection": prop.collection.name,
|
||||
"primary_mesh": prop.primary_mesh.name,
|
||||
"objects": [item.name for item in prop.objects],
|
||||
"source_anchor_world": _round_vector(prop.anchor_world),
|
||||
"file": output_path.name,
|
||||
"sha256": _sha256(output_path),
|
||||
"bounds": {
|
||||
"minimum": _round_vector(prop.bounds.minimum),
|
||||
"maximum": _round_vector(prop.bounds.maximum),
|
||||
"size": _round_vector(prop.bounds.size),
|
||||
"center": _round_vector(prop.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)
|
||||
props = _discover_props(arguments.tolerance)
|
||||
_validate_distinct_source_membership(chunks, props)
|
||||
print(
|
||||
f"Validated {len(chunks)} terrain chunks from "
|
||||
f"Validated {len(chunks)} terrain chunks and {len(props)} reusable "
|
||||
f"props from "
|
||||
f"{Path(bpy.data.filepath).resolve()}"
|
||||
)
|
||||
for chunk in chunks:
|
||||
|
|
@ -382,6 +728,13 @@ def _run() -> int:
|
|||
f"{_format_vector(chunk.bounds.maximum)} | "
|
||||
f"{len(chunk.objects)} object(s)"
|
||||
)
|
||||
for prop in props:
|
||||
print(
|
||||
f" {prop.stable_id}: reusable {prop.source_kind} prop | bounds "
|
||||
f"{_format_vector(prop.bounds.minimum)} to "
|
||||
f"{_format_vector(prop.bounds.maximum)} | "
|
||||
f"{len(prop.objects)} object(s)"
|
||||
)
|
||||
|
||||
if arguments.dry_run:
|
||||
print("Dry run complete; no files written.")
|
||||
|
|
@ -390,10 +743,11 @@ def _run() -> int:
|
|||
output_directory = arguments.output.expanduser().resolve()
|
||||
output_directory.mkdir(parents=True, exist_ok=True)
|
||||
entries: list[dict[str, object]] = []
|
||||
prop_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)
|
||||
_export_source(chunk, output_path)
|
||||
expected_files.add(output_path.name)
|
||||
entry = _manifest_entry(chunk, output_path)
|
||||
entries.append(entry)
|
||||
|
|
@ -402,12 +756,24 @@ def _run() -> int:
|
|||
f"{entry['sha256']}"
|
||||
)
|
||||
|
||||
for prop in props:
|
||||
output_path = output_directory / f"{prop.stable_id}.glb"
|
||||
_export_source(prop, output_path)
|
||||
expected_files.add(output_path.name)
|
||||
entry = _prop_manifest_entry(prop, output_path)
|
||||
prop_entries.append(entry)
|
||||
print(
|
||||
f"Exported {output_path.name} | {output_path.stat().st_size} bytes | "
|
||||
f"{entry['sha256']}"
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"format_version": 1,
|
||||
"format_version": 2,
|
||||
"chunk_size_meters": CHUNK_SIZE_METERS,
|
||||
"source_blend": str(Path(bpy.data.filepath).resolve()),
|
||||
"blender_version": bpy.app.version_string,
|
||||
"chunks": entries,
|
||||
"props": prop_entries,
|
||||
}
|
||||
manifest_path = output_directory / "chunks.json"
|
||||
manifest_path.write_text(
|
||||
|
|
@ -416,14 +782,15 @@ def _run() -> int:
|
|||
)
|
||||
expected_files.add(manifest_path.name)
|
||||
|
||||
stale_files = sorted(
|
||||
stale_files = sorted({
|
||||
path.name
|
||||
for path in output_directory.glob("chunk_*.glb")
|
||||
for pattern in ("chunk_*.glb", "prop_*.glb")
|
||||
for path in output_directory.glob(pattern)
|
||||
if path.name not in expected_files
|
||||
)
|
||||
})
|
||||
if stale_files:
|
||||
print(
|
||||
"WARNING: stale chunk outputs were retained: "
|
||||
"WARNING: stale generated-world outputs were retained: "
|
||||
+ ", ".join(stale_files)
|
||||
)
|
||||
print(f"Wrote manifest {manifest_path}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue