saumwacht-godot/tools/verify_phase4_models.gd
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

246 lines
10 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends SceneTree
## Read-only inspection of Godot's imported GLB scenes, including real geometry.
## Run after import: godot --headless --path . --script res://tools/verify_phase4_models.gd
const MANIFEST_PATH: String = "res://assets/phase4_manifest.json"
const EXPECTED_ASSET_COUNT: int = 59
const TOLERANCE: float = 0.003
var _errors: Array[String] = []
var _checked: int = 0
var _passed: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var file: FileAccess = FileAccess.open(MANIFEST_PATH, FileAccess.READ)
if file == null:
_fail("manifest", "Cannot open %s: %s" % [MANIFEST_PATH, FileAccess.get_open_error()])
_finish()
return
var parser: JSON = JSON.new()
var parse_error: Error = parser.parse(file.get_as_text())
file.close()
if parse_error != OK:
_fail("manifest", "JSON line %d: %s" % [parser.get_error_line(), parser.get_error_message()])
_finish()
return
if not parser.data is Dictionary:
_fail("manifest", "Root must be an object.")
_finish()
return
var manifest: Dictionary = parser.data
if manifest.get("phase") != 4:
_fail("manifest", "Expected phase 4.")
if manifest.get("asset_count") != EXPECTED_ASSET_COUNT:
_fail("manifest", "asset_count must be %d." % EXPECTED_ASSET_COUNT)
var entries_value: Variant = manifest.get("assets")
if not entries_value is Array:
_fail("manifest", "assets must be an array.")
_finish()
return
var entries: Array = entries_value
if entries.size() != EXPECTED_ASSET_COUNT:
_fail("manifest", "Expected %d entries, found %d." % [EXPECTED_ASSET_COUNT, entries.size()])
var seen_ids: Dictionary = {}
for index: int in range(entries.size()):
_checked += 1
var errors_before: int = _errors.size()
if not entries[index] is Dictionary:
_fail("entry_%d" % index, "Entry must be an object.")
continue
var entry: Dictionary = entries[index]
var asset_id: String = str(entry.get("id", ""))
if asset_id.is_empty():
_fail("entry_%d" % index, "Asset ID is missing.")
continue
if seen_ids.has(asset_id):
_fail(asset_id, "Duplicate asset ID.")
seen_ids[asset_id] = true
_verify_asset(asset_id, entry)
if _errors.size() == errors_before:
_passed += 1
_finish()
func _verify_asset(asset_id: String, entry: Dictionary) -> void:
for required: String in ["role", "resource_path", "triangles", "mesh_parts", "bounds_godot", "parts"]:
if not entry.has(required):
_fail(asset_id, "Missing field: " + required)
return
if not entry["parts"] is Array or not entry["bounds_godot"] is Dictionary:
_fail(asset_id, "parts must be an array and bounds_godot must be an object.")
return
var bounds_record: Dictionary = entry["bounds_godot"]
var errors_before: int = _errors.size()
var expected_min: Vector3 = _read_vector(asset_id, bounds_record.get("min"), "bounds.min")
var expected_max: Vector3 = _read_vector(asset_id, bounds_record.get("max"), "bounds.max")
if _errors.size() != errors_before:
return
if not _positive_size(expected_max - expected_min):
_fail(asset_id, "Manifest bounds must have finite positive size on every axis.")
return
var resource_path: String = str(entry["resource_path"])
if not resource_path.begins_with("res://") or not ResourceLoader.exists(resource_path):
_fail(asset_id, "Resource is missing: " + resource_path)
return
var packed: PackedScene = ResourceLoader.load(resource_path) as PackedScene
if packed == null:
_fail(asset_id, "Resource did not load as PackedScene: " + resource_path)
return
var instance: Node = packed.instantiate()
if instance == null:
_fail(asset_id, "PackedScene could not instantiate.")
return
root.add_child(instance)
_verify_instance(asset_id, entry, instance, expected_min, expected_max)
root.remove_child(instance)
instance.free()
func _verify_instance(asset_id: String, entry: Dictionary, instance: Node,
expected_min: Vector3, expected_max: Vector3) -> void:
var nodes: Array[Node] = []
_collect_nodes(instance, nodes)
var names: Dictionary = {}
var meshes: Array[MeshInstance3D] = []
for node: Node in nodes:
var logical_name: String = str(node.name)
if not names.has(logical_name):
names[logical_name] = []
var named_nodes: Array = names[logical_name]
named_nodes.append(node)
if node is MeshInstance3D:
meshes.append(node as MeshInstance3D)
var expected_parts: Array = entry["parts"]
var seen_parts: Dictionary = {}
for part_value: Variant in expected_parts:
var part_name: String = str(part_value)
if part_name.is_empty() or seen_parts.has(part_name):
_fail(asset_id, "Empty or duplicate manifest part: " + part_name)
seen_parts[part_name] = true
if not names.has(part_name):
_fail(asset_id, "Missing logical part node: " + part_name)
elif (names[part_name] as Array).size() != 1:
_fail(asset_id, "Logical part node is ambiguous: " + part_name)
_check_origin_and_forward(asset_id, names)
if meshes.size() != int(entry["mesh_parts"]):
_fail(asset_id, "Mesh count %d; expected %d." % [meshes.size(), int(entry["mesh_parts"])])
if meshes.is_empty():
_fail(asset_id, "Scene contains no MeshInstance3D nodes.")
return
var aggregate: AABB = AABB()
var has_bounds: bool = false
var triangles: int = 0
for mesh_node: MeshInstance3D in meshes:
var mesh: Mesh = mesh_node.mesh
if mesh == null:
_fail(asset_id, "%s has no mesh resource." % mesh_node.name)
continue
if mesh.get_surface_count() <= 0:
_fail(asset_id, "%s has no mesh surfaces." % mesh_node.name)
for surface: int in range(mesh.get_surface_count()):
var material: Material = mesh_node.get_active_material(surface)
if material == null or not is_instance_valid(material):
_fail(asset_id, "%s surface %d has no valid material." % [mesh_node.name, surface])
var faces: PackedVector3Array = mesh.get_faces()
if faces.is_empty() or faces.size() % 3 != 0:
_fail(asset_id, "%s has invalid triangle face data." % mesh_node.name)
triangles += int(faces.size() / 3)
# Transform every local mesh box before merging; transforming the merged
# local bounds would give wrong results for rotated or translated parts.
var world_box: AABB = mesh_node.global_transform * mesh.get_aabb()
if not world_box.position.is_finite() or not world_box.size.is_finite():
_fail(asset_id, "%s has non-finite world bounds." % mesh_node.name)
continue
aggregate = aggregate.merge(world_box) if has_bounds else world_box
has_bounds = true
if triangles != int(entry["triangles"]):
_fail(asset_id, "Triangle count %d; expected %d." % [triangles, int(entry["triangles"])])
var role: String = str(entry["role"])
var triangle_cap: int = int(entry.get("triangle_budget", 3000 if role in ["core", "lane"] else 1800))
if triangles <= 0 or triangles > triangle_cap:
_fail(asset_id, "Triangle count %d is outside 1–%d." % [triangles, triangle_cap])
if not has_bounds or not _positive_size(aggregate.size):
_fail(asset_id, "Imported aggregate bounds must have finite positive size.")
return
if not _near_vector(aggregate.position, expected_min) or not _near_vector(aggregate.end, expected_max):
_fail(asset_id, "Bounds %s…%s; expected %s…%s (tolerance %.3f m)." % [
aggregate.position, aggregate.end, expected_min, expected_max, TOLERANCE])
if role != "lane":
if absf(aggregate.position.y) > TOLERANCE:
_fail(asset_id, "Ground origin differs: minimum Y = %.6f m." % aggregate.position.y)
# Older hand-written manifests can omit the catalog's footprint field.
var default_footprint: float = 3.2 if role == "core" else (1.4 if role == "worker" else 1.85)
var footprint: float = float(entry.get("footprint", default_footprint))
if not is_finite(footprint) or footprint <= 0.0:
_fail(asset_id, "Invalid footprint limit.")
elif maxf(aggregate.size.x, aggregate.size.z) > footprint + TOLERANCE:
_fail(asset_id, "XZ extent %.6f exceeds footprint %.3f m." % [maxf(aggregate.size.x, aggregate.size.z), footprint])
func _check_origin_and_forward(asset_id: String, names: Dictionary) -> void:
var root_name: String = "Model_" + asset_id
for required: String in [root_name, "Forward"]:
if not names.has(required) or (names[required] as Array).size() != 1:
_fail(asset_id, "Expected one named node: " + required)
return
var model: Node3D = (names[root_name] as Array)[0] as Node3D
var forward: Node3D = (names["Forward"] as Array)[0] as Node3D
if model == null or forward == null:
_fail(asset_id, "Model root and Forward marker must be Node3D nodes.")
return
var transform: Transform3D = model.global_transform
if not _near_vector(transform.origin, Vector3.ZERO):
_fail(asset_id, "Model root is not at the world origin: %s." % transform.origin)
if not _near_vector(transform.basis.x, Vector3.RIGHT) or not _near_vector(transform.basis.y, Vector3.UP) or not _near_vector(transform.basis.z, Vector3.BACK):
_fail(asset_id, "Model root basis must have identity rotation and scale.")
if not _near_vector(forward.global_position, Vector3(0.0, 0.0, 0.75)):
_fail(asset_id, "Forward marker %s; expected Godot +Z at 0.75 m." % forward.global_position)
if not model.is_ancestor_of(forward):
_fail(asset_id, "Forward marker must be a child of the model root.")
func _collect_nodes(node: Node, result: Array[Node]) -> void:
result.append(node)
for child: Node in node.get_children():
_collect_nodes(child, result)
func _read_vector(asset_id: String, value: Variant, field: String) -> Vector3:
if not value is Array or (value as Array).size() != 3:
_fail(asset_id, field + " must contain three numbers.")
return Vector3.ZERO
var values: Array = value
for number: Variant in values:
if not (number is float or number is int) or not is_finite(float(number)):
_fail(asset_id, field + " must contain finite numbers.")
return Vector3.ZERO
return Vector3(float(values[0]), float(values[1]), float(values[2]))
func _positive_size(size: Vector3) -> bool:
return size.is_finite() and size.x > 0.0 and size.y > 0.0 and size.z > 0.0
func _near_vector(actual: Vector3, expected: Vector3) -> bool:
if not actual.is_finite() or not expected.is_finite():
return false
var difference: Vector3 = (actual - expected).abs()
return maxf(difference.x, maxf(difference.y, difference.z)) <= TOLERANCE
func _fail(asset_id: String, message: String) -> void:
_errors.append("[%s] %s" % [asset_id, message])
func _finish() -> void:
for message: String in _errors:
printerr("FAIL: " + message)
var label: String = "PASS" if _errors.is_empty() else "FAIL"
print("%s: Phase 4 models %d/%d; %d entries checked, %d errors." % [
label, _passed, EXPECTED_ASSET_COUNT, _checked, _errors.size()])
quit(0 if _errors.is_empty() else 1)