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)