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>
246 lines
10 KiB
GDScript
246 lines
10 KiB
GDScript
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)
|