d233a41ce8
Work-Package: M00-QAR-TERRAIN-HEIGHT-001 Agent: sindo-main-codex Tests: five-point terrain probe, coordination and documentation gates Fidelity: four cameras confirmed above terrain; waterfall tile missing terrain mesh recorded
179 lines
6.5 KiB
GDScript
179 lines
6.5 KiB
GDScript
extends SceneTree
|
|
|
|
## Measures camera clearance against the active rendered terrain mesh.
|
|
## Usage: godot --path . --script res://src/tools/probe_render_terrain_height.gd --
|
|
## [--wait 3.0] [--output user://render_terrain_height/report.json]
|
|
|
|
const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json"
|
|
const TILE_SIZE := 533.33333
|
|
const RAY_HEIGHT := 5000.0
|
|
|
|
|
|
func _initialize() -> void:
|
|
_run_async.call_deferred()
|
|
|
|
|
|
func _run_async() -> void:
|
|
var arguments := OS.get_cmdline_user_args()
|
|
var wait_seconds := float(_argument(arguments, "--wait", "3.0"))
|
|
var output_path := _argument(arguments, "--output", "user://render_terrain_height/report.json")
|
|
var only_filter := _argument(arguments, "--only", "").to_lower()
|
|
var require_all := arguments.has("--require-all")
|
|
var manifest := _load_json(MANIFEST_PATH)
|
|
if manifest.is_empty():
|
|
quit(1)
|
|
return
|
|
var packed_scene: PackedScene = load(String(manifest.get("scene", "")))
|
|
if packed_scene == null:
|
|
push_error("TERRAIN_HEIGHT_PROBE: cannot load streaming scene")
|
|
quit(1)
|
|
return
|
|
var world := packed_scene.instantiate() as Node3D
|
|
if world == null:
|
|
push_error("TERRAIN_HEIGHT_PROBE: streaming scene root is not Node3D")
|
|
quit(1)
|
|
return
|
|
var camera := Camera3D.new()
|
|
camera.current = true
|
|
world.add_child(camera)
|
|
world.set("camera_path", NodePath(camera.name))
|
|
world.set("debug_streaming", false)
|
|
get_root().add_child(world)
|
|
await process_frame
|
|
await process_frame
|
|
|
|
var results: Array[Dictionary] = []
|
|
for checkpoint_variant in manifest.get("checkpoints", []):
|
|
if not (checkpoint_variant is Dictionary):
|
|
continue
|
|
var checkpoint: Dictionary = checkpoint_variant
|
|
if not checkpoint.has("reference_wow_camera"):
|
|
continue
|
|
if not only_filter.is_empty() and not String(checkpoint.get("name", "")).to_lower().contains(only_filter):
|
|
continue
|
|
var camera_position := _vector3(checkpoint.get("camera", []))
|
|
camera.global_position = camera_position
|
|
if world.has_method("_refresh_streaming_targets_at"):
|
|
world.call("_refresh_streaming_targets_at", camera_position, true)
|
|
await create_timer(maxf(0.1, wait_seconds)).timeout
|
|
var terrain_sample := _sample_terrain(world, camera_position)
|
|
terrain_sample["name"] = checkpoint.get("name", "checkpoint")
|
|
terrain_sample["camera_y"] = camera_position.y
|
|
if terrain_sample.has("terrain_height"):
|
|
terrain_sample["camera_clearance"] = camera_position.y - float(terrain_sample.terrain_height)
|
|
results.append(terrain_sample)
|
|
|
|
var report := {
|
|
"schema_version": 1,
|
|
"created_utc": Time.get_datetime_string_from_system(true, true),
|
|
"wait_seconds": wait_seconds,
|
|
"results": results,
|
|
}
|
|
if not _write_json(output_path, report):
|
|
quit(1)
|
|
return
|
|
var sampled_count := 0
|
|
for result in results:
|
|
if result.has("terrain_height"):
|
|
sampled_count += 1
|
|
print("TERRAIN_HEIGHT name=%s status=%s camera_y=%.3f terrain=%s clearance=%s" % [
|
|
result.get("name", "checkpoint"),
|
|
result.get("status", "unknown"),
|
|
float(result.get("camera_y", 0.0)),
|
|
str(result.get("terrain_height", "n/a")),
|
|
str(result.get("camera_clearance", "n/a")),
|
|
])
|
|
print("TERRAIN_HEIGHT_PROBE sampled=%d total=%d report=%s" % [sampled_count, results.size(), output_path])
|
|
world.queue_free()
|
|
var failed := sampled_count == 0 or (require_all and sampled_count != results.size())
|
|
quit(1 if failed else 0)
|
|
|
|
|
|
func _sample_terrain(world: Node3D, world_position: Vector3) -> Dictionary:
|
|
var tile_coordinate := Vector2i(
|
|
int(floor(world_position.x / TILE_SIZE)),
|
|
int(floor(world_position.z / TILE_SIZE)))
|
|
var tile_states: Dictionary = world.get("_tile_states")
|
|
var highest_terrain_height := -INF
|
|
var intersected_tile_key := ""
|
|
var ready_mesh_count := 0
|
|
for tile_y in range(tile_coordinate.y - 1, tile_coordinate.y + 2):
|
|
for tile_x in range(tile_coordinate.x - 1, tile_coordinate.x + 2):
|
|
var tile_key := "%d_%d" % [tile_x, tile_y]
|
|
if not tile_states.has(tile_key):
|
|
continue
|
|
var state: Dictionary = tile_states[tile_key]
|
|
var terrain_height_variant = _intersect_terrain_state(state, world_position)
|
|
if terrain_height_variant == null:
|
|
continue
|
|
ready_mesh_count += 1
|
|
var terrain_height := float(terrain_height_variant)
|
|
if terrain_height > highest_terrain_height:
|
|
highest_terrain_height = terrain_height
|
|
intersected_tile_key = tile_key
|
|
if not is_finite(highest_terrain_height):
|
|
return {
|
|
"status": "no_intersection" if ready_mesh_count > 0 else "mesh_not_ready",
|
|
"tile": "%d_%d" % [tile_coordinate.x, tile_coordinate.y],
|
|
}
|
|
return {
|
|
"status": "sampled",
|
|
"tile": intersected_tile_key,
|
|
"terrain_height": highest_terrain_height,
|
|
}
|
|
|
|
|
|
func _intersect_terrain_state(state: Dictionary, world_position: Vector3):
|
|
var terrain_mesh: Mesh = state.get("quality_terrain_mesh", null)
|
|
if terrain_mesh == null:
|
|
terrain_mesh = state.get("tile_lod_mesh", null)
|
|
if terrain_mesh == null:
|
|
return null
|
|
var triangle_mesh := terrain_mesh.generate_triangle_mesh()
|
|
if triangle_mesh == null:
|
|
return null
|
|
var tile_root := state.get("root", null) as Node3D
|
|
if tile_root == null:
|
|
return null
|
|
var inverse_transform := tile_root.global_transform.affine_inverse()
|
|
var local_ray_origin := inverse_transform * Vector3(world_position.x, RAY_HEIGHT, world_position.z)
|
|
var local_ray_direction := inverse_transform.basis * Vector3.DOWN
|
|
var intersection: Dictionary = triangle_mesh.intersect_ray(local_ray_origin, local_ray_direction)
|
|
if intersection.is_empty():
|
|
return null
|
|
var local_hit: Vector3 = intersection.get("position", Vector3.ZERO)
|
|
var world_hit := tile_root.global_transform * local_hit
|
|
return world_hit.y
|
|
|
|
|
|
func _vector3(value_variant) -> Vector3:
|
|
if not (value_variant is Array) or value_variant.size() != 3:
|
|
return Vector3.ZERO
|
|
return Vector3(float(value_variant[0]), float(value_variant[1]), float(value_variant[2]))
|
|
|
|
|
|
func _argument(arguments: PackedStringArray, name: String, default_value: String) -> String:
|
|
var index := arguments.find(name)
|
|
if index >= 0 and index + 1 < arguments.size():
|
|
return arguments[index + 1]
|
|
return default_value
|
|
|
|
|
|
func _load_json(path: String) -> Dictionary:
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
return {}
|
|
var parsed = JSON.parse_string(file.get_as_text())
|
|
return parsed if parsed is Dictionary else {}
|
|
|
|
|
|
func _write_json(path: String, value: Dictionary) -> bool:
|
|
var absolute_path := ProjectSettings.globalize_path(path)
|
|
if DirAccess.make_dir_recursive_absolute(absolute_path.get_base_dir()) != OK:
|
|
return false
|
|
var file := FileAccess.open(absolute_path, FileAccess.WRITE)
|
|
if file == null:
|
|
return false
|
|
file.store_string(JSON.stringify(value, " "))
|
|
return true
|