OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / csg_stray_gate.gd

358 lines · gd ref:
extends Node
## CSG STRAY GATE — after SUBTRACT-only carves, is any terrain surface left INSIDE
## the removed volume?
##
##   . scripts/engine.sh
##   "$GODOT" --path . --rendering-driver vulkan --resolution 1280x720 res://scenes/test/csg_stray_gate.tscn
##
## The reported symptom (2026-09-29, screenshots 17-16-18 .. 17-17-32): small floating
## pieces and thin sheets left behind in carved space. With only subtracts, the true
## surface lies OUTSIDE every carved shape, so a triangle whose centroid is inside a
## shape by more than TOL is geometry that should have been removed. Counted for both
## the rendered mesh (debug_visual_faces) and the collision (debug_collision_faces).
##
## Measured four times, each must be 0:
##   near     camera 25 m from the carves, terrain settled
##   revisit  camera flown 1.5 km away (LOD merges) and back (LOD splits), settled --
##            the path a player takes, and the one that serves CACHED pre-meshes
##   rebuilt  rebuild(): every chunk re-meshed, uploads in any order
##   lod1     terrain_min_lod 1: a coarse/fine chunk boundary through the trench
## Plus the SEAM CHECK (_seam_real) at the revisit state.
##
## The strays were a COARSE chunk's overlap strip drawn inside a FINER neighbour
## (hyper-games #88: quad_emit has no ownership trim). DCTerrain.overlap_clip_mode
## cuts that strip at the boundary plane; this gate is its proof.
##
## ⛔ NOT HEADLESS (the mountain's field is GPU-only). Exit 0 = no strays, 1 = strays.

const MAP := "res://scenes/maps/map_mountain.tscn"
const MAP_ID := "map_mountain"
const PROBE := "res://scenes/test/terrain_probe.gd"
const R := 3.0                  # the radius used in the reported session
const TOL := 1.0                # 2 LOD0 voxels (0.5 m): meshing error, not a stray
const SETTLE_FRAMES := 60
const SETTLE_CAP_S := 90.0

var _map: Node
var _cam: Camera3D
var _edits: Array = []          # {kind: "sphere"|"box", c: Vector3}


func _ready() -> void:
	_run.call_deferred()


func _since(t0: int) -> float:
	return (Time.get_ticks_msec() - t0) / 1000.0


func _terrain() -> Node:
	return _map.get_node_or_null(^"MountainTerrain")


func _settle(label: String) -> float:
	var tr := _terrain()
	var t0 := Time.get_ticks_msec()
	var calm := 0
	while calm < SETTLE_FRAMES and _since(t0) < SETTLE_CAP_S:
		await get_tree().process_frame
		var ok: bool = bool(tr.call("is_lod_settled")) and int(tr.call("get_pending_count")) == 0 \
				and int(tr.call("get_collision_queue_size")) == 0 and int(tr.call("get_worker_queue_depth")) == 0
		# overlap re-trims run under a per-frame budget: settled means none pending
		if ok and tr.has_method("get_overlap_stats"):
			ok = int((tr.call("get_overlap_stats") as Dictionary).get("retrim_queue", 0)) == 0
		calm = calm + 1 if ok else 0
	print("[STRAY] settle %s: %.1f s (%s)" % [label, _since(t0), "settled" if calm >= SETTLE_FRAMES else "CAP HIT"])
	return _since(t0)


func _inside(e: Dictionary, p: Vector3) -> bool:
	var d: Vector3 = p - e.c
	if e.kind == "sphere":
		return d.length() < R - TOL
	return maxf(absf(d.x), maxf(absf(d.y), absf(d.z))) < R - TOL


## Triangles whose centroid lies inside any carved shape. Returns [count, samples].
func _strays(faces: PackedVector3Array) -> Array:
	var n := 0
	var samples := []
	for i in range(0, faces.size() - 2, 3):
		var c := (faces[i] + faces[i + 1] + faces[i + 2]) / 3.0
		for e: Dictionary in _edits:
			if _inside(e, c):
				n += 1
				if samples.size() < 4:
					samples.append(c.snappedf(0.01))
				break
	return [n, samples]


func _measure(label: String) -> int:
	var tr := _terrain()
	var total := 0
	# One query around the whole cluster of each site; faces are world-space.
	var sites := {}
	for e: Dictionary in _edits:
		sites[e.site] = true
	for s: Vector3 in sites.keys():
		var vis: Array = _strays(tr.call("debug_visual_faces", s, 20.0))
		var col: Array = _strays(tr.call("debug_collision_faces", s, 20.0))
		var ci: Dictionary = tr.call("debug_chunk_info", s)
		print("[STRAY] %-7s site %s  lod %s  visual strays %d %s  collision strays %d %s"
				% [label, str(s.snappedf(0.1)), str(ci.get("lod", "?")), vis[0], str(vis[1]), col[0], str(col[1])])
		total += int(vis[0]) + int(col[0])
	return total


## SEAM CHECK on the REAL overlap clip (hyper-games #88, A2), same LOD tree:
## DCTerrain.overlap_clip_mode re-trims every stored crossing set when changed, so
##   0 = unclipped (the reference), 1 = clip where finer (ships), 2 = clip all (RULER)
## are compared on identical leaves. Rays cross every coarse/fine chunk boundary
## within SEAM_RADIUS (2 m past each rectangle; leaves collected 40 m further). A
## HOLE = a ray whose mode-0 hit is transversal (|n.dir| >= 0.5) and that misses in
## the mode under test even 1 m longer at both ends. Pass: mode 1 has 0 strays and
## 0 holes, and mode 2 HAS holes (the check can fail).
const SEAM_LAYER := 1 << 19
const SEAM_RADIUS := 80.0
var seam_fail := 0

func _drawn_faces(tr: Node, site: Vector3, reach: float) -> PackedVector3Array:
	var faces := PackedVector3Array()
	var n_mesh := 0
	var n_surf := 0
	var n_vert := 0
	var all_meshes: Array = tr.call("debug_chunk_meshes")
	for dm: Dictionary in all_meshes:
		var g0: Vector3 = dm["origin"]
		var lv: float = dm["voxel"]
		var lo: Vector3 = g0 + Vector3(2, 2, 2) * lv
		if site.clamp(lo, lo + Vector3(28, 28, 28) * lv).distance_to(site) > reach:
			continue
		var am: ArrayMesh = dm["mesh"]
		n_mesh += 1
		for si in am.get_surface_count():
			var arr: Array = am.surface_get_arrays(si)
			n_surf += 1
			if arr.size() <= Mesh.ARRAY_INDEX:
				continue
			var vs: PackedVector3Array = arr[Mesh.ARRAY_VERTEX]
			var ix: PackedInt32Array = arr[Mesh.ARRAY_INDEX]
			n_vert += vs.size()
			for t in ix.size():
				faces.append(vs[ix[t]] + g0)
	print("[SEAM]   drawn: %d chunk meshes listed, %d within reach, %d surfaces, %d verts -> %d tris" % [all_meshes.size(), n_mesh, n_surf, n_vert, faces.size() / 3])
	return faces


func _seam_real() -> void:
	var tr := _terrain()
	if not tr.has_method("get_overlap_stats"):
		print("[SEAM] no overlap clip on this build")
		seam_fail += 1
		return
	var site := Vector3.ZERO
	var spheres: Array = []
	for e: Dictionary in _edits:
		if e.kind == "sphere":
			site = e.site
			spheres.append(e.c)
	var leaves := []
	for dm: Dictionary in tr.call("debug_chunk_meshes"):
		var lv: float = dm["voxel"]
		var lo: Vector3 = (dm["origin"] as Vector3) + Vector3(2, 2, 2) * lv
		var hi: Vector3 = lo + Vector3(28, 28, 28) * lv
		if site.clamp(lo, hi).distance_to(site) <= SEAM_RADIUS + 40.0:
			leaves.append({"lo": lo, "hi": hi, "lv": lv, "lod": int(dm["lod"])})
	var bounds := []
	for A: Dictionary in leaves:
		for B: Dictionary in leaves:
			if int(A.lod) <= int(B.lod):
				continue
			for ax in 3:
				for side in [0, 1]:
					var plane: float = (A.hi as Vector3)[ax] if side == 1 else (A.lo as Vector3)[ax]
					var other: float = (B.lo as Vector3)[ax] if side == 1 else (B.hi as Vector3)[ax]
					if absf(plane - other) > 0.01:
						continue
					var r0 := (A.lo as Vector3).max(B.lo)
					var r1 := (A.hi as Vector3).min(B.hi)
					var ok := true
					for b in 3:
						if b != ax and r1[b] - r0[b] < 0.5:
							ok = false
					if ok and ((r0 + r1) * 0.5).distance_to(site) <= SEAM_RADIUS:
						bounds.append({"axis": ax, "at": plane, "r0": r0, "r1": r1, "lv": float(B.lv)})
	var rays := []
	for bd: Dictionary in bounds:
		var ax: int = bd.axis
		var step: float = bd.lv
		for ray_ax in 3:
			if ray_ax == ax:
				continue
			var u := 3 - ax - ray_ax
			for off in [-0.5, 0.5]:
				var x0: float = (bd.r0 as Vector3)[u] + step * 0.5
				while x0 < (bd.r1 as Vector3)[u]:
					var a := Vector3.ZERO
					a[ax] = float(bd.at) + off * step
					a[u] = x0
					a[ray_ax] = (bd.r1 as Vector3)[ray_ax] + 2.0
					var b := a
					b[ray_ax] = (bd.r0 as Vector3)[ray_ax] - 2.0
					rays.append([a, b])
					x0 += step
	print("[SEAM] %d leaves, %d coarse/fine boundaries, %d rays; stats %s" % [leaves.size(), bounds.size(), rays.size(), str(tr.call("get_overlap_stats"))])
	var ref_hits := PackedByteArray()
	var ref_trans := {}
	var holes_by_mode := {}
	for mode in [0, 1, 2]:
		var t0 := Time.get_ticks_usec()
		tr.set("overlap_clip_mode", mode)
		var retrim_ms := (Time.get_ticks_usec() - t0) / 1000.0
		await get_tree().process_frame
		var faces := _drawn_faces(tr, site, SEAM_RADIUS + 40.0)
		var strays := 0
		for i in range(0, faces.size() - 2, 3):
			var cen := (faces[i] + faces[i + 1] + faces[i + 2]) / 3.0
			for c: Vector3 in spheres:
				if cen.distance_to(c) < R - TOL:
					strays += 1
					break
		var body := StaticBody3D.new()
		body.collision_layer = SEAM_LAYER
		body.collision_mask = 0
		var cs := CollisionShape3D.new()
		var shape := ConcavePolygonShape3D.new()
		shape.backface_collision = true
		shape.set_faces(faces)
		cs.shape = shape
		body.add_child(cs)
		add_child(body)
		await get_tree().physics_frame
		await get_tree().physics_frame
		var space := body.get_world_3d().direct_space_state
		var hits := PackedByteArray()
		hits.resize(rays.size())
		var holes := 0
		var hole_at := []
		for k in rays.size():
			var a0: Vector3 = rays[k][0]
			var b0: Vector3 = rays[k][1]
			var qp := PhysicsRayQueryParameters3D.create(a0, b0, SEAM_LAYER)
			qp.hit_back_faces = true
			var hit: Dictionary = space.intersect_ray(qp)
			hits[k] = 0 if hit.is_empty() else 1
			if mode == 0 and not hit.is_empty():
				ref_trans[k] = absf((hit["normal"] as Vector3).dot((b0 - a0).normalized())) >= 0.5
			if mode != 0 and ref_hits[k] == 1 and hits[k] == 0 and bool(ref_trans.get(k, false)):
				var dir := (b0 - a0).normalized()
				var qx := PhysicsRayQueryParameters3D.create(a0 - dir, b0 + dir, SEAM_LAYER)
				qx.hit_back_faces = true
				if space.intersect_ray(qx).is_empty():
					holes += 1
					if hole_at.size() < 4:
						hole_at.append(str(a0.snappedf(0.1)))
		if mode == 0:
			ref_hits = hits
		body.queue_free()
		await get_tree().physics_frame
		var nh := 0
		for h in hits:
			nh += h
		holes_by_mode[mode] = holes
		print("[SEAM] mode %d: tris %d  strays %d  ray hits %d/%d  HOLES vs mode 0: %d %s  (mode switch re-trim %.1f ms)"
				% [mode, faces.size() / 3, strays, nh, rays.size(), holes, str(hole_at), retrim_ms])
		if mode == 1 and (strays > 0 or holes > 0):
			seam_fail += 1
	if int(holes_by_mode.get(2, 0)) == 0:
		print("[SEAM] RULER FAILED: mode 2 (clip everything) opened no holes -- the check cannot see one")
		seam_fail += 1
	tr.set("overlap_clip_mode", 1)
	print("[SEAM] %s; stats %s" % ["PASS" if seam_fail == 0 else "FAIL", str(tr.call("get_overlap_stats"))])


func _run() -> void:
	if DisplayServer.get_name() == "headless":
		print("RESULT: SKIP (needs a display)")
		get_tree().quit(2)
		return
	GameState.enter_map(MAP_ID)
	var mgr := TerrainEditManager.new()
	mgr.name = "TerrainEditManager"
	add_child(mgr)
	_cam = Camera3D.new()
	_cam.far = 20000.0
	add_child(_cam)
	_cam.current = true
	_map = (load(MAP) as PackedScene).instantiate()
	add_child(_map)
	var spawn := Vector3(0, 250, 0)
	var sp := get_tree().get_nodes_in_group("spawn_points")
	if not sp.is_empty():
		spawn = (sp[0] as Node3D).global_position
	_cam.global_position = spawn + Vector3(0, 2, 0)
	_cam.look_at(spawn + Vector3(20, 0, 0))
	await _settle("load")

	# Two sites near the spawn, the reported area: a trench of overlapping hard spheres
	# and one of overlapping boxes, each sunk 1 m into the surface.
	var tr := _terrain()
	var Probe = load(PROBE)
	var plan := [["sphere", Vector3(spawn.x + 20.0, 0, spawn.z)], ["box", Vector3(spawn.x + 20.0, 0, spawn.z + 30.0)]]
	for site_def in plan:
		var col: Vector3 = site_def[1]
		var s = _map.call("_find_surface_gpu", Probe, tr, Vector3(col.x, 1500.0, col.z), 64.0)
		if s == null:
			print("[STRAY] no surface at %s" % str(col)); get_tree().quit(3); return
		var f = _map.call("_find_surface_gpu", Probe, tr, Vector3(col.x, float(s), col.z), 1.0)
		var site := Vector3(col.x, float(f if f != null else s), col.z)
		_cam.global_position = site + Vector3(0, 25, 25)
		_cam.look_at(site)
		await _settle("aim " + site_def[0])
		for k in 8:
			var c := site + Vector3(1.5 * k, -1.0, 0.0)
			if site_def[0] == "sphere":
				mgr.call("apply_sphere", c, R, -3.0, 1)      # op 1 = hard sphere, as RMB type 2
			else:
				mgr.call("apply_edit", c, Vector3(R, R, R), -3.0)
			_edits.append({"kind": site_def[0], "c": c, "site": site})
			await get_tree().create_timer(0.2).timeout   # the reported cadence: ~5 carves/s
	print("[STRAY] %d edits, terrain edit count %d" % [_edits.size(), int(tr.call("get_edit_count"))])

	var last: Vector3 = _edits[-1].site
	_cam.global_position = last + Vector3(0, 25, 25)
	_cam.look_at(last)
	await _settle("near")
	var near := _measure("near")

	_cam.global_position = last + Vector3(1500, 600, 0)
	_cam.look_at(last)
	await _settle("far")
	_cam.global_position = last + Vector3(0, 25, 25)
	_cam.look_at(last)
	await _settle("revisit")
	var revisit := _measure("revisit")
	await _seam_real()

	# REBUILD: every chunk meshed fresh, in whatever order uploads land. A coarse
	# chunk that uploads BEFORE its finer neighbour is drawn must still end up
	# clipped -- visual by re-trim, collision by the clip-only shape swap. This is
	# where 37 collision strays survived when collision was trimmed at upload only.
	tr.call("rebuild")
	await _settle("rebuilt")
	var rebuilt := _measure("rebuilt")
	# UNIFORM LOD 1 (terrain_min_lod is a floor: LOD 2 leaves remain further out):
	# the coarse/fine boundary now runs through the sphere trench at its floor.
	tr.call("set_terrain_min_lod", 1)
	tr.call("rebuild")
	await _settle("uniform lod1")
	var uniform1 := _measure("lod1")
	tr.call("set_terrain_min_lod", 0)

	var total := near + revisit + rebuilt + uniform1
	print("[STRAY] near %d, revisit %d, rebuilt %d, uniform lod1 %d stray triangles; seam %s"
			% [near, revisit, rebuilt, uniform1, "PASS" if seam_fail == 0 else "FAIL"])
	print("RESULT: %s" % ("PASS" if total == 0 and seam_fail == 0 else "FAIL"))
	get_tree().quit(0 if total == 0 and seam_fail == 0 else 1)