OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_box_cutter.gd

300 lines · gd ref:
extends Node
## GATE — THE BOXCUTTER TOOL (#120 L2 §13.2–13.4; box_cutter_operator.gd). Step 3: a rect
## drawn on a face, the modes, depth, one gesture.
##   B0  the map has the operator (Alt+W starts it; keymap_gate checks the binding)
##   B1  a rect on the top face, through (default): ONE gesture writes a prism shape.create
##       and an op.create subtract, the cutter's dot as the op's cutter
##   B2  ... the hole goes right through: air at mid-height and at the bottom, solid beside
##   B3  X once = slice (BoxCutter's X): the committed op is a slice
##   B4  I = inset: a pocket (default depth 0.25) — air near the top, solid below it
##   B5  a set depth (2 grid steps, E's value): air above it, solid below
##   B6  one Ctrl+Z removes the whole commit (shape and op): the target is whole again
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_box_cutter.tscn

var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var level: Node


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")
	map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
	add_child(map)
	for i in 6:
		await get_tree().process_frame
	await _run()
	get_tree().root.get_node("/root/GameMode").set_mode("play")
	if checks < 36:
		print("  **FAIL** only %d checks ran -- setup died before the assertions" % checks)
		fails += 1
	print("\nGATE box_cutter: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
	get_tree().quit(0 if fails == 0 else 1)


func _key(code: Key) -> InputEventKey:
	var e := InputEventKey.new()
	e.keycode = code
	e.physical_keycode = code
	e.pressed = true
	return e


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


func _count(group: String, p: Vector3) -> int:
	var n := 0
	for b: int in (level.get_group_brush_ids(group) as PackedInt32Array):
		var d: Dictionary = level.get_brush(b)
		if bool(d.get("clearance", false)) or bool(d.get("cutter", false)):
			continue
		var pl: PackedFloat32Array = d["planes"]
		var inside := true
		for i in pl.size() / 4:
			if Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2]).dot(p) > pl[i * 4 + 3] + 1.0e-4:
				inside = false
				break
		if inside:
			n += 1
	return n


func _last(kind: String) -> Dictionary:
	var best := {}
	for r: Dictionary in (persist.records(false) as Array):
		if String(r.get("kind", "")) == kind and (best.is_empty() or int(r["seq"]) > int(best["seq"])):
			best = r
	return best


func _n(kind: String) -> int:
	# Live records only: an undone one stays in the journal, flagged.
	return (persist.records(false) as Array).filter(func(r: Dictionary) -> bool: return String(r.get("kind", "")) == kind and not bool(r.get("undone", false))).size()


## Draw the 2 x 2 rect (x 1..3, z 1..3) on top of a target box (x 0..4, y 0..2, z 0..4).
func _draw(op: Node, t: String) -> void:
	op.place(Vector3(1, 2, 1), Vector3.UP, Vector3(1, 2, 1), Vector3(3, 2, 3), t)


func _run() -> void:
	persist = map.get("_persist")
	level = map.get("level")
	persist.refold()
	map._ensure_ops()
	var op: Node = map.get_node_or_null("BoxCutter")
	# ---- B0 ----
	_ok("B0 the map has the BoxCutter operator", op != null and op.has_method("place"))
	if op == null:
		return

	# ---- B1 / B2 ----
	var t1 := _box(Vector3(0, 0, 0), Vector3(4, 2, 4))
	var ns := _n("shape.create")
	var no := _n("op.create")
	_draw(op, t1)
	var ok1: bool = op.confirm()
	var sc := _last("shape.create")
	var oc := _last("op.create")
	_ok("B1 one commit writes a prism shape and a subtract op", ok1 and _n("shape.create") == ns + 1 and _n("op.create") == no + 1
		and String(sc.get("type", "")) == "prism" and String(oc.get("op", "")) == "subtract", [sc.get("type"), oc.get("op")])
	_ok("B1 ... the op cuts the target with the new cutter", String(oc.get("cutter", "")) == "%s:%d" % [sc["author"], int(sc["seq"])]
		and Array(oc.get("targets", [])) == [t1], [oc.get("cutter"), oc.get("targets")])
	_ok("B2 through: air at mid-height under the rect", _count(t1, Vector3(2, 1, 2)) == 0)
	_ok("B2 ... and at the very bottom", _count(t1, Vector3(2, 0.05, 2)) == 0)
	_ok("B2 ... solid beside it, once", _count(t1, Vector3(0.5, 1, 0.5)) == 1)

	# ---- B6 (on the first commit) ----
	persist.undo_last()
	persist.refold()
	_ok("B6 one Ctrl+Z removes the whole commit", _n("shape.create") == ns and _n("op.create") == no, [_n("shape.create") - ns, _n("op.create") - no])
	_ok("B6 ... the target is whole again", _count(t1, Vector3(2, 1, 2)) == 1)

	# ---- B3 ----
	var t3 := _box(Vector3(20, 0, 0), Vector3(4, 2, 4))
	op.place(Vector3(21, 2, 1), Vector3.UP, Vector3(21, 2, 1), Vector3(23, 2, 3), t3)
	op.handle_key(_key(KEY_X))
	_ok("B3 X once = slice", String(op.mode()) == "slice", op.mode())
	op.confirm()
	_ok("B3 ... the committed op is a slice", String(_last("op.create").get("op", "")) == "slice")

	# ---- B4 ----
	var t4 := _box(Vector3(40, 0, 0), Vector3(4, 2, 4))
	op.place(Vector3(41, 2, 1), Vector3.UP, Vector3(41, 2, 1), Vector3(43, 2, 3), t4)
	op.handle_key(_key(KEY_I))
	op.confirm()
	_ok("B4 I = inset: air in the top 0.25 under the rect", String(_last("op.create").get("op", "")) == "inset"
		and _count(t4, Vector3(42, 1.9, 2)) == 0, [_last("op.create").get("op"), _count(t4, Vector3(42, 1.9, 2))])
	_ok("B4 ... solid below the pocket", _count(t4, Vector3(42, 1.5, 2)) == 1)

	# ---- B5 ----
	var t5 := _box(Vector3(60, 0, 0), Vector3(4, 2, 4))
	op.place(Vector3(61, 2, 1), Vector3.UP, Vector3(61, 2, 1), Vector3(63, 2, 3), t5)
	op.handle_key(_key(KEY_X))                 # the mode is sticky: inset (B4) -> outset
	op.handle_key(_key(KEY_X))                 # -> back round to subtract
	op.set_tool_setting("depth", 2)
	var g: float = op.grid_step()
	op.confirm()
	_ok("B5 depth 2 steps: air one and a half steps down", String(_last("op.create").get("op", "")) == "subtract"
		and _count(t5, Vector3(62, 2 - 1.5 * g, 2)) == 0, [_last("op.create").get("op"), g])
	_ok("B5 ... solid two and a half steps down", _count(t5, Vector3(62, 2 - 2.5 * g, 2)) == 1)

	# ---- step 4: footprints and surfaces (§13.2) ----
	# B7 circle: P sets the CENTRE, the aim the radius (1 here); a 24-segment footprint.
	var t7 := _box(Vector3(80, 0, 0), Vector3(4, 2, 4))
	op.set_tool_setting("depth", 0)            # the depth is sticky (B5 set 2 steps): through again
	op.set_tool_setting("shape", "circle")
	op.place(Vector3(82, 2, 2), Vector3.UP, Vector3(82, 2, 2), Vector3(83, 2, 2), t7)
	op.confirm()
	var poly7: Array = (_last("shape.create").get("params", {}) as Dictionary).get("poly", [])
	_ok("B7 circle: a 24-point footprint", poly7.size() == 24, poly7.size())
	_ok("B7 ... the centre is cut through", _count(t7, Vector3(82, 1, 2)) == 0)
	_ok("B7 ... the rect's corner (outside the circle) is not", _count(t7, Vector3(81.1, 1, 1.1)) == 1)
	# B8 ngon: 6 sides.
	var t8 := _box(Vector3(90, 0, 0), Vector3(4, 2, 4))
	op.set_tool_setting("shape", "ngon")
	op.set_tool_setting("sides", 6)
	op.place(Vector3(92, 2, 2), Vector3.UP, Vector3(92, 2, 2), Vector3(93, 2, 2), t8)
	op.confirm()
	var poly8: Array = (_last("shape.create").get("params", {}) as Dictionary).get("poly", [])
	_ok("B8 ngon 6: a 6-point footprint, cut at its centre", poly8.size() == 6 and _count(t8, Vector3(92, 1, 2)) == 0, poly8.size())
	# B9 custom polygon: a triangle from three P points.
	var t9 := _box(Vector3(100, 0, 0), Vector3(4, 2, 4))
	op.set_tool_setting("shape", "custom")
	op.place_points(Vector3(101, 2, 1), Vector3.UP, [Vector3(101, 2, 1), Vector3(103, 2, 1), Vector3(101, 2, 3)], t9)
	op.confirm()
	_ok("B9 custom triangle: inside it is cut", _count(t9, Vector3(101.4, 1, 1.4)) == 0)
	_ok("B9 ... past its long side is not", _count(t9, Vector3(102.6, 1, 2.6)) == 1)
	# B10 surfaces: the plane each one draws on.
	var cur: Node3D = map.get("_cursor3d")
	cur.global_position = Vector3(120, 3, 0)
	op.set_tool_setting("surface", "grid")
	var pg: Array = op.surface_plane()
	_ok("B10 grid: the world's level plane at the cursor's height", (pg[1] as Vector3).is_equal_approx(Vector3.UP)
		and is_equal_approx((pg[0] as Vector3).y, 3.0), pg)
	op.set_tool_setting("surface", "cursor")
	var pc: Array = op.surface_plane()
	_ok("B10 cursor: the cursor's own plane (its up)", (pc[1] as Vector3).is_equal_approx(cur.global_basis.y.normalized())
		and (pc[0] as Vector3).is_equal_approx(cur.global_position), pc)
	op.set_tool_setting("surface", "face")
	op.set_tool_setting("shape", "box")
	# B11 D opens the tool's menu: the four shapes and the four surfaces.
	var q: Node = map.get("_q_menu")
	op.handle_key(_key(KEY_D))
	var rows: Array = []
	var menu: PopupMenu = q.get("_menu")
	for i in menu.item_count:
		rows.append(menu.get_item_text(i))
	_ok("B11 D opens the menu with the shapes and surfaces", q.is_open() and str(rows).contains("Circle")
		and str(rows).contains("Custom") and str(rows).contains("Grid") and str(rows).contains("View"), rows)
	menu.hide()

	# ---- step 5: modifiers while drawing (§13.4, BoxCutter's keys) ----
	# M1 1 = mirror X across the TARGET's centre: a hole at x 140.5..141.5 gets its twin at
	# 142.5..143.5 (target x 140..144, centre 142); between them stays solid.
	var tm := _box(Vector3(140, 0, 0), Vector3(4, 2, 4))
	op.place(Vector3(140.5, 2, 1), Vector3.UP, Vector3(140.5, 2, 1), Vector3(141.5, 2, 3), tm)
	op.handle_key(_key(KEY_1))
	op.confirm()
	var cm := _last("shape.create")
	var cdot := "%s:%d" % [cm["author"], int(cm["seq"])]
	_ok("M1 1 queues a mirror on the cutter", String(_last("mod.create").get("target", "")) == cdot
		and String(_last("mod.create").get("mod", "")) == "mirror", [_last("mod.create").get("target"), _last("mod.create").get("mod")])
	_ok("M1 ... the original hole", _count(tm, Vector3(141, 1, 2)) == 0)
	_ok("M1 ... its mirror twin across the target's centre", _count(tm, Vector3(143, 1, 2)) == 0)
	_ok("M1 ... and solid between them", _count(tm, Vector3(142, 1, 2)) == 1)
	# M2 the queue empties after a commit; B and V V queue chamfer and radial; the same key
	# again (B) takes it off.
	var tq := _box(Vector3(160, 0, 0), Vector3(4, 2, 4))
	op.place(Vector3(161, 2, 1), Vector3.UP, Vector3(161, 2, 1), Vector3(163, 2, 3), tq)
	var nm := _n("mod.create")
	op.handle_key(_key(KEY_B))
	op.handle_key(_key(KEY_B))                 # off again
	op.handle_key(_key(KEY_V))
	op.handle_key(_key(KEY_V))                 # V V = radial
	op.confirm()
	# Only THIS cutter's modifiers (the window must not reach M1's mirror).
	var cq := _last("shape.create")
	var cqd := "%s:%d" % [cq["author"], int(cq["seq"])]
	var kinds: Array = []
	for r: Dictionary in (persist.records(false) as Array):
		if String(r.get("kind", "")) == "mod.create" and String(r.get("target", "")) == cqd:
			kinds.append(String(r.get("mod", "")))
	_ok("M2 B B (off again) + V V: exactly one radial, no chamfer, no mirror carried over", _n("mod.create") == nm + 1 and kinds == ["radial"], kinds)
	# M3 Shift+G = grid; L = the cut starts OFF (the target uncut, the op there).
	var tl := _box(Vector3(180, 0, 0), Vector3(4, 2, 4))
	op.place(Vector3(181, 2, 1), Vector3.UP, Vector3(181, 2, 1), Vector3(183, 2, 3), tl)
	var sg := _key(KEY_G)
	sg.shift_pressed = true
	op.handle_key(sg)
	op.handle_key(_key(KEY_L))
	op.confirm()
	_ok("M3 Shift+G queues a grid", String(_last("mod.create").get("mod", "")) == "grid")
	_ok("M3 L: the cut is written OFF — the target is uncut", _count(tl, Vector3(182, 1, 2)) == 1
		and String(_last("op.create").get("op", "")) == "subtract")
	# M4 taper and wedge (the D menu's values) ride in the prism's params.
	var tt := _box(Vector3(200, 0, 0), Vector3(4, 2, 4))
	op.set_tool_setting("taper", 0.5)
	op.set_tool_setting("shear_x", 0.25)
	op.place(Vector3(201, 2, 1), Vector3.UP, Vector3(201, 2, 1), Vector3(203, 2, 3), tt)
	op.confirm()
	var pp: Dictionary = _last("shape.create").get("params", {})
	_ok("M4 taper 0.5 and shear 0.25 are the prism's params", is_equal_approx(float(pp.get("taper", 0)), 0.5)
		and is_equal_approx(float(pp.get("shear_x", 0)), 0.25), pp)
	op.set_tool_setting("taper", 1.0)
	op.set_tool_setting("shear_x", 0.0)

	# ---- step 6: housekeeping (§13.5) ----
	# H1 the whole SELECTION is the target: two boxes selected, one rect across both.
	var ha := _box(Vector3(220, 0, 0), Vector3(4, 2, 4))
	var hb := _box(Vector3(226, 0, 0), Vector3(4, 2, 4))
	var ids := PackedInt32Array()
	ids.append_array(level.get_group_brush_ids(ha))
	ids.append_array(level.get_group_brush_ids(hb))
	map.select_brushes(ids)
	op.place(Vector3(221, 2, 1), Vector3.UP, Vector3(221, 2, 1), Vector3(229, 2, 3), ha)
	op.confirm()
	var tg: Array = Array(_last("op.create").get("targets", []))
	_ok("H1 both selected objects are targets", tg.has(ha) and tg.has(hb), tg)
	_ok("H1 ... both are cut", _count(ha, Vector3(222, 1, 2)) == 0 and _count(hb, Vector3(228, 1, 2)) == 0)
	map.select_brushes(PackedInt32Array())
	# H2 every cutter is linked into ONE collection named "Cutters".
	var cuts: Array = []
	var tree: Dictionary = persist.collection_tree()
	for cd: String in (tree.get("cols", {}) as Dictionary):
		if String(tree["cols"][cd]["name"]) == "Cutters":
			cuts.append(cd)
	var hc := _last("shape.create")
	var hcd := "%s:%d" % [hc["author"], int(hc["seq"])]
	_ok("H2 one Cutters collection", cuts.size() == 1, cuts)
	_ok("H2 ... holding the last cutter", cuts.size() == 1 and (tree["cols"][cuts[0]]["members"] as Array).has("g:" + hcd),
		tree["cols"][cuts[0]]["members"] if cuts.size() == 1 else [])
	# H3 Shift+R repeats with the last cut's modifiers: the queue comes back.
	var tr3 := _box(Vector3(240, 0, 0), Vector3(4, 2, 4))
	op.place(Vector3(241, 2, 1), Vector3.UP, Vector3(241, 2, 1), Vector3(243, 2, 3), tr3)
	op.handle_key(_key(KEY_2))
	op.confirm()
	op.restore_last_queue()
	_ok("H3 repeat restores the last queue (mirror y)", String(op._queue_text()) == "mirror y", op._queue_text())
	_ok("H3 ... and Shift+R repeats the BoxCutter", (map.get("REPEATABLE") as Array).has("builder.box_cutter"))