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)