OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / valve220_gate.gd

209 lines · gd ref:
extends SceneTree
## Valve 220 face parsing — hyper-games #31, #32 NEXT-STEPS-4.
##
##     "$HYPER_TPS_GODOT" --headless --script scenes/test/valve220_gate.gd
##
## Quake accepts two face syntaxes and TrenchBroom's Quake config offers both,
## with Valve listed first:
##
##   ( p1 ) ( p2 ) ( p3 ) TEX xoff yoff rot sx sy                        Standard
##   ( p1 ) ( p2 ) ( p3 ) TEX [ ux uy uz uoff ] [ vx vy vz voff ] r s s  Valve 220
##
## Valve carries the UV axes explicitly instead of deriving them from the face
## normal, which is what makes alignment lock survive rotation and shear. Our
## _parse_face strips ( and ) and then reads by token index, so on a Valve line
## token 10 is the literal "[" — xoff becomes 0 and yoff/rot/sx/sy pick up the U
## AXIS COMPONENTS. Nothing errors. The geometry is right and the texturing is
## garbage, which is the worst way for this to fail.
##
## THE FIXTURES ARE LITERAL TEXT, both of them, and they describe the SAME cube
## with the SAME texture placement: the Valve axes written below are what Quake's
## axial rule produces for each of those six normals. So the two files must yield
## a byte-identical projection table. Deriving the Valve axes in code from our own
## rule would let a parser bug write itself into the expected answer.

const TMP := "/tmp/valve220_gate"
const WORLD_SCALE := 0.028
## layer, u_off, v_off, 0 | u_axis, 0 | v_axis, 0 | normal, 0
const FLOATS_PER_FACE := 16
const EPS := 0.0001

var _failures: Array[String] = []
var _lvl: Node3D = null
var _frame := 0


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


## A 64-unit cube at the origin. Six faces, axis-aligned, texture at offset 0,
## rotation 0, scale 1 — the simplest placement there is, so any difference
## between the two files is the parser and nothing else.
##
## POINT ORDER IS NOT DECORATION: _face_plane takes n = (p3-p1) x (p2-p1) and the
## brush is the intersection of n·x <= d, so a face listed the other way round
## points INWARD. Six inward planes bound nothing, and the importer reports
## `degenerate=1` and imports no faces at all — which is how the first draft of
## this gate failed, on its fixture rather than on the code under test.
const STANDARD_MAP := """{
"classname" "worldspawn"
{
( 0 0 64 ) ( 64 64 64 ) ( 64 0 64 ) WBRICK1_5 0 0 0 1 1
( 0 0 0 ) ( 64 64 0 ) ( 0 64 0 ) WBRICK1_5 0 0 0 1 1
( 0 0 0 ) ( 64 0 64 ) ( 64 0 0 ) WBRICK1_5 0 0 0 1 1
( 64 0 0 ) ( 64 64 64 ) ( 64 64 0 ) WBRICK1_5 0 0 0 1 1
( 64 64 0 ) ( 0 64 64 ) ( 0 64 0 ) WBRICK1_5 0 0 0 1 1
( 0 64 0 ) ( 0 0 64 ) ( 0 0 0 ) WBRICK1_5 0 0 0 1 1
}
}
"""

## The same cube, same placement, axes written out. Per _BASEAXIS: a face whose
## normal is +/-Z takes u=(1,0,0) v=(0,-1,0); +/-X takes u=(0,1,0) v=(0,0,-1);
## +/-Y takes u=(1,0,0) v=(0,0,-1). The face ORDER is the same as above, so entry
## i of one table must equal entry i of the other.
const VALVE_MAP := """{
"classname" "worldspawn"
"mapversion" "220"
{
( 0 0 64 ) ( 64 64 64 ) ( 64 0 64 ) WBRICK1_5 [ 1 0 0 0 ] [ 0 -1 0 0 ] 0 1 1
( 0 0 0 ) ( 64 64 0 ) ( 0 64 0 ) WBRICK1_5 [ 1 0 0 0 ] [ 0 -1 0 0 ] 0 1 1
( 0 0 0 ) ( 64 0 64 ) ( 64 0 0 ) WBRICK1_5 [ 1 0 0 0 ] [ 0 0 -1 0 ] 0 1 1
( 64 0 0 ) ( 64 64 64 ) ( 64 64 0 ) WBRICK1_5 [ 0 1 0 0 ] [ 0 0 -1 0 ] 0 1 1
( 64 64 0 ) ( 0 64 64 ) ( 0 64 0 ) WBRICK1_5 [ 1 0 0 0 ] [ 0 0 -1 0 ] 0 1 1
( 0 64 0 ) ( 0 0 64 ) ( 0 0 0 ) WBRICK1_5 [ 0 1 0 0 ] [ 0 0 -1 0 ] 0 1 1
}
}
"""

## Same cube again, but the U axis is one Quake editors can produce and the axial
## rule CANNOT: 45 degrees in the XY plane on the top face. If the parser ignores
## the written axes and falls back to the axial rule, this comes out identical to
## STANDARD_MAP — so this is the fixture that catches "parsed, then discarded".
const VALVE_SKEW_MAP := """{
"classname" "worldspawn"
"mapversion" "220"
{
( 0 0 64 ) ( 64 64 64 ) ( 64 0 64 ) WBRICK1_5 [ 0.7071 0.7071 0 8 ] [ 0.7071 -0.7071 0 -4 ] 0 1 1
( 0 0 0 ) ( 64 64 0 ) ( 0 64 0 ) WBRICK1_5 [ 1 0 0 0 ] [ 0 -1 0 0 ] 0 1 1
( 0 0 0 ) ( 64 0 64 ) ( 64 0 0 ) WBRICK1_5 [ 1 0 0 0 ] [ 0 0 -1 0 ] 0 1 1
( 64 0 0 ) ( 64 64 64 ) ( 64 64 0 ) WBRICK1_5 [ 0 1 0 0 ] [ 0 0 -1 0 ] 0 1 1
( 64 64 0 ) ( 0 64 64 ) ( 0 64 0 ) WBRICK1_5 [ 1 0 0 0 ] [ 0 0 -1 0 ] 0 1 1
( 0 64 0 ) ( 0 0 64 ) ( 0 0 0 ) WBRICK1_5 [ 0 1 0 0 ] [ 0 0 -1 0 ] 0 1 1
}
}
"""


func _initialize() -> void:
	DirAccess.make_dir_recursive_absolute(TMP)
	_write(TMP + "/standard.map", STANDARD_MAP)
	_write(TMP + "/valve.map", VALVE_MAP)
	_write(TMP + "/valve_skew.map", VALVE_SKEW_MAP)
	_lvl = ClassDB.instantiate("DCLevel") as Node3D
	if _lvl == null:
		print("SKIP — DCLevel not registered; libdc_terrain did not load")
		quit(0)
		return
	root.add_child(_lvl)


func _write(path: String, text: String) -> void:
	var f := FileAccess.open(path, FileAccess.WRITE)
	f.store_string(text)
	f.close()


## DCLevel::_ready builds its MeshInstance3D child and has not run for a node
## added during _initialize — the same staging shell_subtract_gate documents.
func _process(_delta: float) -> bool:
	_frame += 1
	if _frame < 3:
		return false
	_run()
	return true


## Import one map and return its projection table.
func _projections(path: String) -> PackedFloat32Array:
	_lvl.clear()
	var imp := QuakeMapImporter.new()
	imp.import_file(_lvl, path, WORLD_SCALE, Vector3.ZERO, 1.0, null, false, {}, true)
	return imp.projections


func _faces(p: PackedFloat32Array) -> int:
	return p.size() / FLOATS_PER_FACE


## Largest absolute difference between two tables, or -1 if they are different sizes.
func _max_diff(a: PackedFloat32Array, b: PackedFloat32Array) -> float:
	if a.size() != b.size():
		return -1.0
	var worst := 0.0
	for i in a.size():
		worst = maxf(worst, absf(a[i] - b[i]))
	return worst


func _run() -> void:
	print("\n=== Valve 220 face parsing ===")

	var std := _projections(TMP + "/standard.map")
	_check("the standard cube imports", _faces(std) == 6, "%d face(s)" % _faces(std))

	var val := _projections(TMP + "/valve.map")
	_check("the valve cube imports", _faces(val) == 6, "%d face(s)" % _faces(val))
	if _faces(std) != 6 or _faces(val) != 6:
		_finish()
		return

	# The claim: the same placement written two ways lands in the same place.
	var d := _max_diff(std, val)
	_check("valve 220 axes agree with the axial rule", d >= 0.0 and d < EPS,
			"max difference %.6f" % d)
	if d < 0.0 or d >= EPS:
		# Which face, and which number: "they differ" is not a diagnosis.
		for f in mini(_faces(std), _faces(val)):
			var o := f * FLOATS_PER_FACE
			var worst := 0.0
			for i in FLOATS_PER_FACE:
				worst = maxf(worst, absf(std[o + i] - val[o + i]))
			if worst >= EPS:
				print("    face %d: standard u=(%.3f,%.3f,%.3f) off=%.3f | valve u=(%.3f,%.3f,%.3f) off=%.3f"
					% [f, std[o+4], std[o+5], std[o+6], std[o+1],
						   val[o+4], val[o+5], val[o+6], val[o+1]])

	# And the axes must actually be USED, not parsed and thrown away.
	var skew := _projections(TMP + "/valve_skew.map")
	_check("the skewed valve cube imports", _faces(skew) == 6, "%d face(s)" % _faces(skew))
	if _faces(skew) == 6:
		var ds := _max_diff(std, skew)
		_check("a 45-degree U axis is not the axial one", ds < 0.0 or ds > EPS,
				"max difference %.6f — identical means the written axes were discarded" % ds)
		# The top face's U axis, in Godot space. Quake (0.7071,0.7071,0) -> _q2g ->
		# (0.7071, 0, -0.7071), then folded by 1/world_scale and 1/texW downstream,
		# so what is asserted is the DIRECTION, which those factors cannot change.
		var u := Vector3(skew[4], skew[5], skew[6]).normalized()
		var want := Vector3(0.7071, 0.0, -0.7071).normalized()
		_check("the top face carries the written 45-degree axis",
				u.distance_to(want) < 0.01,
				"u=(%.3f,%.3f,%.3f) want (%.3f,%.3f,%.3f)" % [u.x, u.y, u.z, want.x, want.y, want.z])

	_finish()


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