OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / clearance_shapes_gate.gd

280 lines · gd ref:
extends SceneTree
## THE SHAPES' CLEARANCE VOLUMES — hyper-games #25 §2.
##
##     "$HYPER_TPS_GODOT" --headless --script scenes/test/clearance_shapes_gate.gd
##
## clearance_gate.gd proves the ENGINE carries a clearance brush correctly. This
## proves the OPERATOR asks for the right one, which is a different question and
## the one with the geometry in it:
##
##   stairs   one box, the flight's own footprint, floor to top PLUS headroom.
##            The headroom is the point — the stepped complement of the treads
##            hugs the flight so tightly that the ceiling of the cut is the next
##            tread up, and you cannot climb a staircase like that.
##   arch     the ring's OUTER ENVELOPE, extended along the tunnel axis and NOT
##            inflated anywhere else, so the terrain meets the outside of the ring
##            rather than standing a margin off it. Exact rather than approximate
##            because the ring's outer skin is the CHORD polyline, not the
##            ellipse, and this is built from the same points.
##   box      none. It is solid through and through; the shell enclosing it is
##            already the whole of what it wants.
##
## Driven through the REAL CreateBoxOperator against a stub map, because the
## clearance is chosen from `_axis`, `_view` and `_normal` — the placement state —
## and a test that called the shape helpers with hand-picked bounds would not be
## exercising the thing that decides them.

const OP := preload("res://scenes/maps/operators/create_box_operator.gd")

var _failures: Array[String] = []


func _check(label: String, ok: bool, detail: String) -> void:
	if not ok:
		_failures.append("%s (%s)" % [label, detail])
	print("  %-52s %s  %s" % [label, "OK  " if ok else "FAIL", detail])


## Enough of map_dc_level for the operator to place something: where you are
## aiming, which way you are looking, and the lattice.
class StubMap extends Node:
	const WORLD_SCALE := 0.028
	const GRID_UNITS: Array[int] = [1, 2, 4, 8, 16, 32, 64]
	var _grid_idx: int = 3                     # 8 units = 0.224 m, the map default
	var _map_center := Vector3.ZERO
	var _aligned_offset := Vector3.ZERO
	var hit_pos := Vector3.ZERO

	# The box asks the map for the current texture (#94 P1 §6).
	func current_face_tex() -> Dictionary:
		return {"tex": "gate/current", "mode": 1}
	var hit_normal := Vector3.UP
	var view_dir := Vector3(0, 0, 1)

	func _crosshair_hit(_at: Variant = null) -> Dictionary:
		return {"position": hit_pos, "normal": hit_normal}

	func _crosshair_ray(_at: Variant = null) -> Dictionary:
		return {"from": hit_pos - view_dir * 5.0, "dir": view_dir}

	func _brush_from_hit(_hit: Dictionary) -> int:
		return -1                              # no surface to inherit material from

	static func snap_to_map_grid(d: float, n: Vector3, units: int,
			map_center: Vector3, aligned_offset: Vector3) -> float:
		var origin: float = n.dot(map_center)
		var quake: float = (d - n.dot(aligned_offset)) / WORLD_SCALE + origin
		var snapped: float = roundf(quake / float(units)) * float(units)
		return (snapped - origin) * WORLD_SCALE + n.dot(aligned_offset)


## `n · x <= d` for every plane, with slack — the store's own convention.
static func _inside(planes: PackedFloat32Array, p: Vector3, eps := 1.0e-4) -> bool:
	for i in range(0, planes.size(), 4):
		if planes[i] * p.x + planes[i+1] * p.y + planes[i+2] * p.z - planes[i+3] > eps:
			return false
	return true


## The corners of every piece, from the edge list the preview draws.
static func _points(pieces: Array) -> PackedVector3Array:
	var out := PackedVector3Array()
	for piece: Dictionary in pieces:
		out.append_array(piece["edges"])
	return out


func _make(shape: int, size_steps: int, normal: Vector3, view: Vector3) -> Node:
	var map := StubMap.new()
	map.hit_normal = normal
	map.view_dir = view
	root.add_child(map)
	var op := OP.new()
	op.map = map
	# A REAL DCLevel, empty. Nothing here confirms, so no brush is ever added —
	# but update_preview() draws into `level`, and a null one takes the preview
	# down with it before any geometry has been asked for.
	op.level = ClassDB.instantiate("DCLevel") as Node3D
	root.add_child(op.level)
	root.add_child(op)
	op._shape = shape
	op._size_steps = size_steps
	op.update_preview()
	return op


func _initialize() -> void:
	print("clearance shapes gate")

	# ---- BOX: nothing to clear ------------------------------------------
	var box := _make(0, 2, Vector3.UP, Vector3(0, 0, 1))
	_check("a box asks for no clearance", box._clearance_pieces().is_empty(),
		"%d volumes" % box._clearance_pieces().size())

	# ---- STAIRS: one box, enclosing the flight, plus headroom ------------
	# Placed on a floor, looking +Z, so the flight rises in +Y and climbs in +Z.
	var st := _make(1, 4, Vector3.UP, Vector3(0, 0, 1))
	var st_solid: Array = st._pieces()
	var st_clear: Array = st._clearance_pieces()
	_check("stairs ask for exactly one clearance volume", st_clear.size() == 1,
		"%d" % st_clear.size())
	if st_clear.size() != 1:
		return _finish()
	var st_planes: PackedFloat32Array = st_clear[0]["planes"]

	# EVERY corner of every step inside it. The cut has to contain the flight, or
	# terrain is left standing in the middle of the staircase.
	var outside := 0
	for p: Vector3 in _points(st_solid):
		if not _inside(st_planes, p):
			outside += 1
	_check("the cut encloses every step", outside == 0,
		"%d corner(s) outside" % outside)

	# AND RISES ABOVE IT. This is the assertion that separates this cut from the
	# stepped complement: a point one grid step above the top tread, in the middle
	# of the flight, must be inside.
	var c: Array = st._corners()
	var top: Vector3 = c[1]
	var mid_x: float = (c[0].x + c[1].x) * 0.5
	var mid_z: float = (c[0].z + c[1].z) * 0.5
	var step: float = st.grid_step()
	_check("and it clears the top tread by a grid step",
		_inside(st_planes, Vector3(mid_x, top.y + step, mid_z)),
		"+%.3f m" % step)
	# The headroom is finite: well above the target it must stop.
	_check("but not indefinitely",
		not _inside(st_planes, Vector3(mid_x, top.y + 20.0, mid_z)),
		"+20 m is outside")
	# HEADROOM_M is a target, quantized UP — never less than asked for.
	var head := 0.0
	while _inside(st_planes, Vector3(mid_x, top.y + head + step * 0.5, mid_z)):
		head += step
	_check("headroom reaches the 2.0 m target", head >= OP.HEADROOM_M - 1.0e-3,
		"%.3f m at grid %d" % [head, st.grid_units()])

	# ---- ARCH: the ring's own envelope, extended along the tunnel --------
	# Size 8 so _arch_thickness has room for a real ring rather than the solid
	# fallback — at the default 2 the arch IS a box and has no bore.
	var ar := _make(2, 8, Vector3.UP, Vector3(0, 0, 1))
	var ar_solid: Array = ar._pieces()
	var ar_clear: Array = ar._clearance_pieces()
	_check("an arch asks for one clearance volume", ar_clear.size() == 1,
		"%d" % ar_clear.size())
	_check("and the ring really is a ring here", ar_solid.size() == OP.ARCH_SEGMENTS,
		"%d voussoirs" % ar_solid.size())
	if ar_clear.size() != 1:
		return _finish()
	var ar_planes: PackedFloat32Array = ar_clear[0]["planes"]

	# THE ENVELOPE CONTAINS THE RING. Every voussoir corner inside it — this is
	# what "the terrain meets the outer wall" means in the direction that matters:
	# no part of the ring is left with hillside in it.
	var ar_outside := 0
	for p: Vector3 in _points(ar_solid):
		if not _inside(ar_planes, p):
			ar_outside += 1
	_check("the cut encloses the whole ring", ar_outside == 0,
		"%d corner(s) outside" % ar_outside)

	# AND THE BORE. A point on the springing line at the centre of the span, which
	# is inside no voussoir at all — the passage itself.
	var ac: Array = ar._corners()
	var centre := Vector3((ac[0].x + ac[1].x) * 0.5, ac[0].y + 0.1,
		(ac[0].z + ac[1].z) * 0.5)
	var in_ring := false
	for piece: Dictionary in ar_solid:
		if _inside(piece["planes"], centre):
			in_ring = true
	_check("the bore is not inside any voussoir", not in_ring, "it is air")
	_check("and the cut contains it", _inside(ar_planes, centre), "the passage")

	# NOT INFLATED RADIALLY. Directly above the crown by a clear margin must be
	# OUTSIDE — if the envelope were padded, the terrain would stand off the ring
	# instead of meeting it.
	_check("nothing is added above the crown",
		not _inside(ar_planes, Vector3(centre.x, ac[1].y + 0.5, centre.z)),
		"0.5 m above is outside")

	# EXTENDED ALONG THE TUNNEL. Past both mouths, which is the one margin that
	# exists — without it the bore stops inside the hill.
	var m: float = ar._lattice(OP.AXIAL_MARGIN_M)
	var lo_mouth := Vector3(centre.x, centre.y, ac[0].z - m * 0.5)
	var hi_mouth := Vector3(centre.x, centre.y, ac[1].z + m * 0.5)
	_check("the cut reaches past the near mouth", _inside(ar_planes, lo_mouth),
		"-%.3f m" % (m * 0.5))
	_check("and past the far mouth", _inside(ar_planes, hi_mouth),
		"+%.3f m" % (m * 0.5))
	_check("the axial margin meets the 1.0 m target", m >= OP.AXIAL_MARGIN_M - 1.0e-3,
		"%.3f m at grid %d" % [m, ar.grid_units()])

	# THE CUT IS THE ARCH PLUS ONE METRE AT EACH END, and nothing else.
	#
	# It briefly measured the hill instead, marching the terrain SDF and boring to
	# wherever the ground ended. That fixed a symptom and got the design backwards:
	# a long bare bore with a short ring at its mouth, because the ring was not
	# what set the length. THE ARCH IS THE TUNNEL LINING — a longer tunnel is a
	# longer arch, and the cut follows it.
	var tun_axis: int = ar._run_axis()
	var abore := INF
	var bbore := -INF
	for v: Vector3 in (ar_clear[0]["edges"] as PackedVector3Array):
		abore = minf(abore, v[tun_axis])
		bbore = maxf(bbore, v[tun_axis])
	var bore_len: float = bbore - abore
	var arch_len: float = ac[1][tun_axis] - ac[0][tun_axis]
	var want_len: float = arch_len + 2.0 * m
	_check("the cut is the arch plus a margin each end",
		absf(bore_len - want_len) < 1.0e-3,
		"%.3f m for a %.3f m arch (+%.3f each end)" % [bore_len, arch_len, m])
	# SYMMETRIC. One end reaching further than the other was the shape of the
	# measured-bore version, and it is what made the ring stop short of the cut.
	_check("and it is symmetric about the arch",
		absf((ac[0][tun_axis] - abore) - (bbore - ac[1][tun_axis])) < 1.0e-3,
		"near %.3f far %.3f" % [ac[0][tun_axis] - abore, bbore - ac[1][tun_axis]])

	# THE ARCH'S LENGTH DRIVES IT. Resize and the cut tracks, still plus one metre
	# each end — which is the whole reason group scale is the way to a longer
	# tunnel.
	ar._size_steps = 16
	ar.update_preview()
	var grown: Array = ar._clearance_pieces()
	var gc: Array = ar._corners()
	var glo := INF
	var ghi := -INF
	for v: Vector3 in (grown[0]["edges"] as PackedVector3Array):
		glo = minf(glo, v[tun_axis])
		ghi = maxf(ghi, v[tun_axis])
	var grown_arch: float = gc[1][tun_axis] - gc[0][tun_axis]
	_check("a longer arch gives a longer cut, still +1 m each end",
		absf((ghi - glo) - (grown_arch + 2.0 * m)) < 1.0e-3,
		"%.3f m for a %.3f m arch" % [ghi - glo, grown_arch])
	ar._size_steps = 8
	ar.update_preview()

	# ---- the DEGENERATE guard --------------------------------------------
	# When the bounds have no room for a ring, _arch_pieces returns ONE SOLID BOX
	# and there is no bore. Cutting a tunnel through that would be a passage
	# through a cube — solid on the outside, hollow for no reason.
	#
	# Asserted on the guard directly rather than by picking a small size: at the
	# smallest the operator offers, _arch_thickness still finds room (it takes half
	# the radius when a grid step will not fit), so no reachable size reproduces
	# it. A test that claimed size 2 was degenerate would have been asserting
	# something that stopped being true when that fallback was fixed.
	var deg := _make(2, 8, Vector3.UP, Vector3(0, 0, 1))
	_check("_arch_thickness reports no room at zero extent",
		deg._arch_thickness(0.0, 0.0) == 0.0, "0.0")
	var flat: Vector3 = deg._corners()[0]
	_check("and a flat arch asks for no clearance",
		deg._arch_clearance(flat, flat).is_empty(), "no volume")

	_finish()


func _finish() -> void:
	print("\nRESULT: %s" % ("PASS" if _failures.is_empty() else "FAIL"))
	for f: String in _failures:
		print("  - %s" % f)
	quit(0 if _failures.is_empty() else 1)