OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_op_editor.gd

153 lines · gd ref:
extends Node
## GATE — LIVE CUTS IN THE EDITOR (docs/cutting-and-operations.md §9a, user 2026-10-12
## "live by default").
##
##   1. LIVE SUBTRACT: active = a piece of a box shape (the wall), the other selected =
##      a box shape (the cutter); _csg("subtract") writes ONE op.create and no
##      brush.subtract; the door is air, the cutter is wire (a cutter brush), and the
##      cutters come back as the selection
##   2. PICK: the crosshair on the door finds the CUTTER (dc_pick; it has no collider)
##   3. FALLBACK: two plain ungrouped brushes still cut destructively (brush.subtract)
##   4. MAKE REAL: refused on the cutter; on the wall it bakes the door into plain
##      brushes, applies the op and removes the cutter, in ONE gesture; Ctrl+Z restores
##      the live cut
##
## RUN AS A SCENE: "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_op_editor.tscn

var fails := 0
var checks := 0
var _cam: Camera3D = null


func _ok(what: String, cond: bool, note: Variant = "") -> void:
	checks += 1
	if not cond:
		fails += 1
	print("  %-64s %s  %s" % [what, "OK" if cond else "**FAIL**", str(note)])


func _ready() -> void:
	var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
	gs.route("world:level:*", 1, "peer")
	var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
	if keet != null:
		keet.current_member_id = "gate-author"
	get_tree().root.get_node("/root/GameMode").set_mode("build")
	var map: Node3D = (load("res://scenes/maps/map_test.tscn") as PackedScene).instantiate()
	add_child(map)
	_cam = Camera3D.new()
	add_child(_cam)
	_cam.current = true
	for i in 6:
		await get_tree().process_frame
	await _run(map)
	get_tree().root.get_node("/root/GameMode").set_mode("play")
	if checks < 14:
		print("  **FAIL** only %d checks ran -- setup died before the assertions" % checks)
		fails += 1
	print("\nGATE op_editor: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
	get_tree().quit(0 if fails == 0 else 1)


func _kinds(persist: Node, since: int) -> Array:
	var out: Array = []
	for r: Dictionary in (persist.records() as Array):
		if int(r["seq"]) > since:
			out.append(String(r["kind"]))
	out.sort()
	return out


## Solid at (x, y) through the wall (z 0..1 at W): a level ray along -z.
func _solid(level: Node, p: Vector3) -> bool:
	return not (level.raycast(p + Vector3(0, 0, 6), p + Vector3(0, 0, -6)) as Dictionary).is_empty()


func _box(persist: Node, origin: Vector3, size: Vector3) -> String:
	return persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
		"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, origin)),
		"size": [size.x, size.y, size.z], "material_id": 2, "level_id": 0})


func _run(map: Node3D) -> void:
	var persist: Node = map.get("_persist")
	var level: Node = map.get("level")
	if level == null or not level.has_method("get_cutter_brush_ids"):
		_ok("this DCLevel build knows cutters (dc-terrain f0efefd+)", false)
		return
	const W := Vector3(60, 0, 60)                # clear of map_test's own geometry
	var wall: String = _box(persist, W, Vector3(8, 4, 1))
	var cutter: String = _box(persist, W + Vector3(3, -1, -1), Vector3(2, 4, 3))
	persist.refold()
	var wall_ids: PackedInt32Array = level.get_group_brush_ids(wall)
	var cut_ids: PackedInt32Array = level.get_group_brush_ids(cutter)
	_ok("setup: a wall shape and a cutter shape", wall_ids.size() == 1 and cut_ids.size() == 1,
		"%d / %d" % [wall_ids.size(), cut_ids.size()])
	if wall_ids.is_empty() or cut_ids.is_empty():
		return

	# ---- 1. live subtract ----
	var sel := PackedInt32Array(cut_ids)
	sel.append(wall_ids[0])                      # the ACTIVE brush is the last selected
	map.select_brushes(sel)
	var seq0: int = persist._max_seq_seen()
	map._csg("subtract")
	var made: Array = _kinds(persist, seq0)
	_ok("1. ONE record: an op.create", made == ["op.create"], made)
	_ok("1. the door is air", not _solid(level, W + Vector3(4, 1, 0)))
	_ok("1. the wall beside it is solid", _solid(level, W + Vector3(1, 1, 0)) and _solid(level, W + Vector3(7, 1, 0)))
	var cutters: PackedInt32Array = level.get_cutter_brush_ids()
	_ok("1. the cutter is a cutter brush", cutters.size() == 1, cutters)
	var sel_now: Array = map.get("_sel_brushes")
	_ok("1. the cutter is the selection now", sel_now.size() == 1 and cutters.has(int(sel_now[0])), sel_now)
	var g0: Dictionary = (persist.gestures() as Array)[0]
	_ok("1. one gesture 'Live subtract'", String(g0["label"]) == "Live subtract", g0)

	# ---- 2. pick ----
	var door := W + Vector3(4, 1, 0.5)
	_cam.global_position = door + Vector3(0, 0, 6)
	_cam.look_at(door, Vector3.UP)
	await get_tree().process_frame
	# AT THE SCREEN CENTRE: in build mode the pointer is free, and with no argument the
	# ray leaves from the mouse — (0, 0) headless, which aims at the sky.
	var hit: Dictionary = map._crosshair_hit(get_viewport().get_visible_rect().size * 0.5)
	_ok("2. the crosshair on the door finds the cutter", cutters.has(int(hit.get("brush", -1))), hit)

	# ---- 3. destructive fallback ----
	var a: String = persist.record("brush.create", {"planes": _planes(W + Vector3(0, 0, 10), Vector3(4, 4, 1)),
		"proj": [], "material_id": 2, "level_id": 0, "sky": false, "group": ""})
	var b: String = persist.record("brush.create", {"planes": _planes(W + Vector3(1, -1, 9), Vector3(1, 3, 3)),
		"proj": [], "material_id": 2, "level_id": 0, "sky": false, "group": ""})
	persist.refold()
	var ia: int = level.brush_by_key(a)
	var ib: int = level.brush_by_key(b)
	map.select_brushes(PackedInt32Array([ib, ia]))
	var seq1: int = persist._max_seq_seen()
	map._csg("subtract")
	var made2: Array = _kinds(persist, seq1)
	_ok("3. plain brushes still cut destructively (brush.subtract, no op)",
		made2.has("brush.subtract") and not made2.has("op.create"), made2)

	# ---- 4. make real ----
	_ok("4. Make Real on the cutter is refused", int(map.make_real(cutter)) == 0)
	var seq2: int = persist._max_seq_seen()
	var n: int = map.make_real(wall)
	var made3: Array = _kinds(persist, seq2)
	_ok("4. Make Real on the wall bakes its pieces", n >= 3, n)
	_ok("4. ... and applies the op and removes the cutter", made3.has("op.apply") and made3.has("shape.remove")
		and made3.has("shape.detach"), made3)
	_ok("4. the door is still air, now in plain brushes", not _solid(level, W + Vector3(4, 1, 0)))
	_ok("4. no live cutter remains", (level.get_cutter_brush_ids() as PackedInt32Array).is_empty())
	_ok("4. the cutter shape is gone", not (persist.live_shapes() as Dictionary).has(cutter))
	persist.undo_last()
	persist.refold()
	_ok("4. ONE Ctrl+Z: the live cut is back", (level.get_cutter_brush_ids() as PackedInt32Array).size() == 1
		and (persist.live_shapes() as Dictionary).has(wall))
	_ok("4. ... and the door still air", not _solid(level, W + Vector3(4, 1, 0)))


## Box planes (n·x <= d), flat floats — a brush.create payload.
func _planes(o: Vector3, s: Vector3) -> Array:
	var hi := o + s
	return [-1, 0, 0, -o.x, 1, 0, 0, hi.x, 0, -1, 0, -o.y, 0, 1, 0, hi.y, 0, 0, -1, -o.z, 0, 0, 1, hi.z]