OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / operators / knife_operator.gd

521 lines · gd ref:
extends LevelOperator
class_name KnifeOperator
## THE KNIFE — cut the aimed brush, with the cut visible before it happens.
##
## The first user of the operator framework, and the tool that most needed it. As an
## immediate key, `C` had to GUESS the cut plane from where the camera happened to
## point, because there was no moment in which to aim: press and it cut. Worse, a
## mis-aimed cut wrote a record that replicated to every peer before you could see
## what you had done.
##
## Now: press `C` to take up the knife, aim, adjust, and press Enter to cut — or
## Escape, in which case nothing was ever written and no peer ever saw anything.
##
## THE AXIS MOVED INSIDE THE OPERATOR, which is the other half of the fix. `Shift+C`
## used to mean "horizontal cut", but Shift is SPRINT: while sprinting you got the
## horizontal cut whether you wanted it or not — the operator depended on how fast you
## were going. Blender and TrenchBroom both solve this the same way (press G, then
## constrain with X), and so does this: X/Y/Z pick the axis, and the HUD says which.
##
## ---- TWO MODES, one tool -------------------------------------------------------
##
## AXIS (no points placed) — the cut above: a world-axis plane through where you are
## looking, snapped to the map lattice. Every doorway is this cut.
##
## FREE (points placed with `P`) — a plane through 2 or 3 points you place on the
## geometry. This is the missing capability behind "I can't make a ramp": in this
## representation a wedge IS a box with a diagonal cut, and an axis-aligned knife can
## never produce one. Nothing under it needed changing — `split_brush` has always
## taken an arbitrary `ConvexPlane` (dc_brush_store.h:444), `DCLevel::split_brush`
## passes it through unnormalised (dc_level.cpp:138), and a `brush.split` record has
## always carried `n` as three floats (level_persistence.gd:500). The plane was
## arbitrary the whole time; only the aiming was axial.
##
## THE THREE-POINT RULE IS TRENCHBROOM'S, read from the checkout at
## /storage/projects/gamedev/trenchbroom (docs/trenchbroom-parity.md cites it):
##
##   - a point is snapped ON the face it was placed on, not freely in space —
##     Grid::snap(p, plane) (Grid.h:298-329) snaps the two axes that are not the
##     face normal's major axis and solves the third from the plane, so the point
##     stays exactly on the surface you aimed at;
##   - each point carries a HELP VECTOR, the dominant axis of the face under it
##     (ClipToolController.cpp:236-248);
##   - with only two points the third is inferred along the majority help vector,
##     and refused if that leaves the three colinear (ClipTool.cpp:163-209, :218).
##
## So two points on a wall cut it perpendicular to that wall — which is exactly the
## cut a ramp needs, and why two points are worth supporting rather than demanding
## three.
##
## WHERE WE DIVERGE, and it is in our favour: TrenchBroom's clip has three SIDES
## (keep front, keep back, keep both) because its clip destroys what it removes.
## `split_brush` keeps both halves as real named brushes, so there is no side to
## choose — you cut, then delete the half you did not want, and Ctrl+Z brings it
## back. Their clip also rebuilds the polyhedron and has to guess which new face was
## which (Polyhedron_Matcher.h); ours adds one plane to a set and every surviving
## face keeps its own dot.

const AXES := {"x": Vector3.RIGHT, "y": Vector3.UP, "z": Vector3.BACK}

## Three points define a plane; more would be an over-determined fit, which is a
## different tool (and not one this representation needs).
const MAX_POINTS := 3

## Two points closer together than this are the same point: the plane through them
## is whatever floating point noise says it is. A fraction of a grid step, so it
## scales with how finely you are working, exactly like the pick radius.
const SAME_POINT := 0.25

var _brush: int = -1
var _axis: String = "x"
## "global" = the world axis; "local" = the aimed brush's own (map.brush_local_basis).
## The same axis key twice toggles it (#109, 2026-10-13, Blender's X X).
var _space: String = "global"
var _offset_steps: int = 0          # grid steps away from the aim point
var _plane_n := Vector3.RIGHT
var _plane_d: float = 0.0
var _valid: bool = false
## Why the current plane cannot be cut, when the reason is the POINTS rather than the
## geometry. "Nothing happened" with no reason is the failure this whole line of work
## started from.
var _why: String = ""

## Placed cut points, and the dominant face axis each was placed against. Two arrays
## rather than an array of pairs because they are read as vectors, in bulk, by the
## preview and the plane derivation.
var _points := PackedVector3Array()
var _helpers := PackedVector3Array()


## CRASH PROBE. Pressing `C` kills the process with NO output at all — not a GDScript
## error, not a refusal print, nothing — so the fault is inside activation, before the
## dispatch's own "knife: ..." line ever runs. Every layer under it has been cleared:
## 1183 real cut_previews and 1183 real split_brushes on START.MAP, and the operator
## itself driven over 399 real brushes under both engines and a live Vulkan device.
##
## So the next crash has to name its own step. stdout is line-buffered under a pty
## (`script -q -c "hyper-tps" log`), so the LAST line printed is the step that died.
## Remove this once the site is known.
func _step(what: String) -> void:
	print("[knife.probe] %s" % what)


func op_name() -> String:
	return "knife"


func preview_color() -> Color:
	return Color(1.0, 0.3, 0.3, 0.4)      # red: about to CUT


## The placed points, in the order they were placed. Public because the plane they
## define is the whole state of a free cut — a HUD or a gate that cannot see them
## can only ask whether the tool happens to feel valid.
func points() -> PackedVector3Array:
	return _points


## THE MODAL CONVENTION (#120 F3): the offset is a bound value (signed grid steps); the
## axis (X Y Z, again = local), P (place a point — the free mode), Backspace (take one
## back) and the verdict are rows; the table is the status line and the H overlay.
func setup_modal(m: ModalControls) -> void:
	m.confirm_label = "cut"
	m.table([["X Y Z", "axis (again: local)", func() -> String:
			if not _points.is_empty():
				return "unused (points)"
			return ("local " if _space == "local" else "") + _axis.to_upper()],
		["P", "place a point", func() -> String:
			return "%d/%d pts" % [_points.size(), MAX_POINTS] if not _points.is_empty() else ""],
		["", "Backspace takes a point back", func() -> String: return "yes" if not _points.is_empty() else ""],
		["", "cut", func() -> String:
			if _valid:
				return "ready"
			return "cannot cut: %s" % _why if _why != "" else "cannot cut here"]])
	m.bind("offset", "offset", func() -> float: return float(_offset_steps),
		func(v: float) -> void:
			_offset_steps = int(roundf(v))
			update_preview(),
		1.0, func() -> String: return " × %s" % fmt_units(grid_units()), 1.0, true)


func status() -> String:
	if _brush < 0:
		return "no brush"
	return modal().status_text()


## Refuses if you are not looking at level geometry: better to do nothing visible than
## to enter a mode that cannot act and has to be escaped out of.
func on_activate() -> bool:
	_step("activate: enter")
	var hit: Dictionary = map._crosshair_hit()
	_step("activate: crosshair hit %s" % ("empty" if hit.is_empty() else "ok"))
	if hit.is_empty():
		print("[knife] nothing under the crosshair")
		return false
	_brush = map._brush_from_hit(hit)
	_step("activate: brush %d" % _brush)
	if _brush < 0:
		print("[knife] that is not level geometry")
		return false
	# A SHAPE'S PIECE is read-only (#109 "select, prompt on edit"): a cut piece is an
	# edit the generator cannot reproduce. Make Real first (or Cancel), then the knife
	# again on the same aim.
	if map.has_method("guard_shape_part") and not map.guard_shape_part("knife",
			func() -> void: map._ensure_ops().activate(self), _brush):
		_brush = -1
		return false
	_axis = _axis_facing_camera()
	_space = "global"
	_step("activate: axis %s" % _axis)
	_offset_steps = 0
	# A FRESH TOOL EVERY TIME. Points left over from the last cut would be a plane you
	# cannot see the origin of, aimed at a brush that may not be the one you placed
	# them on.
	_points = PackedVector3Array()
	_helpers = PackedVector3Array()
	_why = ""
	update_preview()
	_step("activate: done")
	return true


func on_deactivate() -> void:
	_brush = -1
	_points = PackedVector3Array()
	_helpers = PackedVector3Array()
	_why = ""
	clear_preview()


## The world axis nearest the camera's RIGHT, so the default cut runs across your view
## — the cut a doorway needs. You can still override it with X/Y/Z.
func _axis_facing_camera() -> String:
	var cam := map.get_viewport().get_camera_3d()
	if cam == null:
		return "x"
	var right: Vector3 = cam.global_transform.basis.x
	return "x" if absf(right.x) >= absf(right.z) else "z"


func handle_key(event: InputEvent) -> bool:
	if not (event is InputEventKey) or not (event as InputEventKey).pressed:
		return false
	match KeyRules.key_of(event as InputEventKey):      # letters by character, [ ] by position (§6)
		KEY_X, KEY_Y, KEY_Z:
			# Claimed even while points are placed, where the axis is unused: letting
			# it fall through would reach the player, and `X` is the flashlight.
			# THE SAME KEY AGAIN = the brush's own axis (Blender X X); again = world.
			var a := char((event as InputEventKey).keycode).to_lower()
			if a == _axis:
				_space = "local" if _space == "global" else "global"
			else:
				_axis = a
				_space = "global"
			_offset_steps = 0        # a new axis means a new offset; keeping it is nonsense
			update_preview()
			return true
		KEY_P:
			return _place_point()
		KEY_BACKSPACE:
			# NOT claimed when there is nothing to take back, so Backspace goes on
			# meaning respawn exactly as it did before.
			if _points.is_empty():
				return false
			_points.remove_at(_points.size() - 1)
			_helpers.remove_at(_helpers.size() - 1)
			update_preview()
			return true
	# [ ] / wheel (the offset), H, Tab: ModalControls.
	return modal().handle_key(event)


# ---- tool settings (E4) -------------------------------------------------------

func tool_settings() -> Array:
	var out: Array = [
		{"id": "axis", "label": "Axis", "type": "enum", "value": axis_index(_axis),
			"choices": AXIS_CHOICES, "tip": "The cut plane's normal (X / Y / Z keys)."},
		{"id": "space", "label": "Space", "type": "enum", "value": 1 if _space == "local" else 0,
			"choices": ["Global", "Local"], "tip": "Local = the brush's own axes (press the axis key twice)."},
		{"id": "offset", "label": "Offset", "type": "int", "value": _offset_steps, "unit": "grid",
			"step": 1, "tip": "Grid steps from the aim point ([ ])."},
	]
	if not _points.is_empty():
		out.append({"id": "clear_points", "label": "Clear points", "type": "action",
			"tip": "Drop the placed points and cut on the axis again."})
	out.append({"id": "cut", "label": "Cut", "type": "action", "tip": "Cut (Enter)."})
	return out


func set_tool_setting(sid: String, value: Variant) -> bool:
	match sid:
		"axis":
			_axis = axis_name(int(value))
			_offset_steps = 0
		"space":
			_space = "local" if int(value) == 1 else "global"
			_offset_steps = 0
		"offset":
			_offset_steps = int(value)
		"clear_points":
			_points = PackedVector3Array()
			_helpers = PackedVector3Array()
		"cut":
			confirm_via_stack()
			return true
		_:
			return false
	update_preview()
	return true


func readout() -> String:
	if _brush < 0:
		return ""
	if not _points.is_empty():
		return "free cut, %d/%d points" % [_points.size(), MAX_POINTS]
	return "cut %s %+d" % [_axis.to_upper(), _offset_steps] if _valid else "cannot cut here"


# ---- placed points ----------------------------------------------------------

## Put a point where you are looking, ON the surface you are looking at.
##
## Returns whether the KEY was claimed, which is not the same as whether a point was
## placed: a refused point is still the knife's key press, and letting it fall through
## would fire whatever else `P` means.
func _place_point() -> bool:
	if _points.size() >= MAX_POINTS:
		print("[knife] %d points already — Backspace to take one back" % MAX_POINTS)
		return true
	var hit: Dictionary = map._crosshair_hit()
	if hit.is_empty():
		print("[knife] no surface under the crosshair to place a point on")
		return true
	var at: Vector3 = hit.get("position", Vector3.ZERO)

	# THE FACE'S PLANE, not the ray's hit normal. They agree on a flat surface, but
	# the face plane is the exact thing the point has to stay on once it is snapped,
	# and it is what the help vector is derived from.
	var n: Vector3 = hit.get("normal", Vector3.UP)
	var d: float = n.dot(at)
	var brush: int = map._brush_from_hit(hit)
	if brush >= 0:
		var face: int = level.pick_face(brush, at)
		if face >= 0:
			var pl: PackedFloat32Array = level.get_brush(brush).get("planes", PackedFloat32Array())
			if face * 4 + 3 < pl.size():
				n = Vector3(pl[face * 4], pl[face * 4 + 1], pl[face * 4 + 2])
				d = pl[face * 4 + 3]

	# A vertex / edge of the aimed face wins when one is near (E2b snap targets); the
	# lattice-on-plane snap is the fallback. Both stay on the face.
	var tgt: Variant = map.snap_target_point(at, brush) if map.has_method("snap_target_point") else null
	var p: Vector3 = tgt if tgt != null else _snap_on_plane(at, n, d)
	for q: Vector3 in _points:
		if q.distance_to(p) < grid_step() * SAME_POINT:
			print("[knife] that is the point you already placed")
			return true
	_points.append(p)
	_helpers.append(_dominant_axis(n))
	update_preview()
	print("[knife] point %d/%d at %s" % [_points.size(), MAX_POINTS, str(p)])
	return true


## Snap ON a plane, TrenchBroom's Grid::snap(p, onPlane) (Grid.h:298-329): snap the
## two axes that are not the plane normal's major axis, then solve the third from the
## plane. The point lands on the lattice AND stays on the surface it was placed on —
## snapping all three axes independently would lift it off a slanted face, changing
## the plane by however far it drifted.
func _snap_on_plane(p: Vector3, n: Vector3, d: float) -> Vector3:
	var major: int = _dominant_axis_index(n)
	var out: Vector3 = p
	var rest: float = 0.0
	for a in 3:
		if a == major:
			continue
		var dir := Vector3.ZERO
		dir[a] = 1.0
		out[a] = map.snap_to_map_grid(p[a], dir, grid_units(),
									  map._map_center, map._aligned_offset)
		rest += n[a] * out[a]
	if absf(n[major]) < 1e-6:
		return out                          # degenerate normal: leave the point alone
	out[major] = (d - rest) / n[major]
	return out


func _dominant_axis_index(n: Vector3) -> int:
	var major := 0
	for a in [1, 2]:
		if absf(n[a]) > absf(n[major]):
			major = a
	return major


## The positive unit axis a normal most nearly points along — TrenchBroom's
## get_abs_max_component_axis, used as a point's help vector.
func _dominant_axis(n: Vector3) -> Vector3:
	var out := Vector3.ZERO
	out[_dominant_axis_index(n)] = 1.0
	return out


## The help vector for the whole placed set: a majority vote over the points' own,
## counted with SIGN and returned unsigned — ClipTool.cpp:176-209 exactly, ties
## preferring Z and then X. Sign is counted because two points on opposite faces of
## a slab genuinely disagree; it is dropped from the answer because the cut plane is
## the same either way, and a stable choice is what makes two peers agree.
func _help_vector() -> Vector3:
	var counts := PackedInt32Array([0, 0, 0, 0, 0, 0])
	for i in _points.size():
		var h: Vector3 = _helpers[i]
		var a: int = _dominant_axis_index(h)
		counts[a if h[a] > 0.0 else a + 3] += 1
	var first := 0
	for i in range(1, 6):
		if counts[i] > counts[first]:
			first = i
	for i in 6:
		if i == first or counts[i] != counts[first]:
			continue
		# Two axes tied. Prefer Z if either of them is Z, otherwise X.
		if first % 3 == 2 or i % 3 == 2:
			return Vector3.BACK
		return Vector3.RIGHT
	var out := Vector3.ZERO
	out[first % 3] = 1.0
	return out


# ---- the plane ---------------------------------------------------------------

## The cut plane, from the placed points if there are any and from the axis if not.
## Sets `_plane_n` / `_plane_d` / `_why`, and returns whether a plane exists at all —
## which is a separate question from whether it divides the solid.
func _derive_plane() -> bool:
	_why = ""
	if _points.size() >= 2:
		var a: Vector3 = _points[0]
		var b: Vector3 = _points[1]
		# The third point is either placed or inferred along the help vector. Only the
		# DIRECTION matters to a cross product, so TrenchBroom's 128-unit reach
		# (ClipTool.cpp:167) has nothing to contribute here.
		var third := _help_vector()
		if _points.size() >= 3:
			third = _points[2] - a
		var n: Vector3 = (b - a).cross(third)
		if n.length() < 1e-6:
			# Colinear: their canClip refuses the same case (ClipTool.cpp:218).
			_why = "those points are in line"
			return false
		_plane_n = n.normalized()
		# NOT re-snapped. The points are already on the lattice; snapping the offset
		# as well would move the plane off the very points that define it.
		_plane_d = _plane_n.dot(a) + float(_offset_steps) * grid_step()
		return true

	if _points.size() == 1:
		_why = "one point does not make a plane — place another"
		return false

	# AXIS MODE, unchanged: a world-axis plane through the aim, snapped to the map's
	# lattice rather than the world's.
	var hit: Dictionary = map._crosshair_hit()
	var at: Vector3 = hit.get("position", Vector3.ZERO) if not hit.is_empty() else Vector3.ZERO
	if _space == "local" and map.has_method("brush_local_basis"):
		# THE BRUSH'S OWN AXIS: snapped in grid steps from the brush's own extent along
		# it, so a cut on a turned cube lands on the cube's grid, not the world's.
		var basis: Basis = map.brush_local_basis(_brush)
		var ln: Vector3 = basis[axis_index(_axis)].normalized()
		var lo := INF
		for p: Vector3 in level.get_brush_edges_of(_brush):
			lo = minf(lo, ln.dot(p))
		var step: float = grid_step()
		var raw: float = ln.dot(at)
		_plane_n = ln
		_plane_d = (lo + roundf((raw - lo) / step) * step if lo != INF else raw) \
			+ float(_offset_steps) * step
		return true
	var an: Vector3 = AXES[_axis]
	var units: float = grid_units()
	var ad: float = map.snap_to_map_grid(an.dot(at), an, units, map._map_center, map._aligned_offset)
	_plane_n = an
	_plane_d = ad + float(_offset_steps) * float(units) * map.WORLD_SCALE
	return true


## Show the exact face the cut would create. The preview comes from the store's
## cut_preview, which builds the candidate half and returns its new face — so what is
## drawn is what a split will produce, rather than a second computation of the same
## polygon that could drift from it.
func update_preview() -> void:
	if _brush < 0:
		return
	_step("preview: clear")
	clear_preview()
	_valid = false
	_step("preview: derive")
	if not _derive_plane():
		_step("preview: no plane (%s)" % _why)
		draw_points(_points)                 # the points are still worth seeing
		return
	# THE CRASH IS IN HERE, and it is the store, not the operator: the probe's last
	# line in a v869 log is this one, and "poly N pts" never follows. Offline it is
	# unreproducible — 1183 brushes on a fresh import, on a carved one, with faces
	# moved, with planes that miss, on planes lying exactly on a face, and on brush
	# ids that do not exist. All survive.
	#
	# The one condition the harness cannot recreate is the level the GAME is holding:
	# built by a journal fold, 1200 brushes against a fresh import's 1183, its splits
	# replayed through restore_brush + add_face. So describe the brush before touching
	# it — plane count against key count against live faces — and REFUSE a brush whose
	# arrays disagree, rather than handing it to the store and dying.
	var bi: Dictionary = level.get_brush(_brush)
	var np: int = int(bi.get("planes", PackedFloat32Array()).size() / 4)
	var nk: int = int(bi.get("plane_keys", PackedStringArray()).size())
	var nlive: int = int(bi.get("live_faces", 0))
	_step("preview: brush=%d planes=%d keys=%d live=%d key=%s"
		% [_brush, np, nk, nlive, str(bi.get("key", "?"))])
	if np == 0 or nk != np:
		_why = "that brush's plane set is inconsistent (%d planes, %d keys)" % [np, nk]
		_step("preview: REFUSED — %s" % _why)
		draw_points(_points)
		return
	_step("preview: cut_preview brush=%d n=%s d=%.4f" % [_brush, str(_plane_n), _plane_d])
	var poly: PackedVector3Array = level.get_cut_preview(_brush, _plane_n, _plane_d)
	_step("preview: poly %d pts" % poly.size())
	_valid = poly.size() >= 3
	if _valid:
		_step("preview: draw_polygon")
		draw_polygon(poly)   # red, per preview_color
	_step("preview: draw_points %d" % _points.size())
	draw_points(_points)
	_step("preview: done")


func confirm() -> bool:
	if _brush < 0 or not _valid:
		print("[knife] nothing to cut here%s" % ("" if _why == "" else " — " + _why))
		return false
	var dot: String = map._persist.reserve_dot()
	var made: int = level.split_brush(_brush, _plane_n, _plane_d, dot)
	if made <= 0:
		print("[knife] the store refused that plane")
		return false
	level.rebuild()
	map._refresh_brush_view()
	map._persist.record_at(dot, "brush.split", {
		"brush_key": level.get_brush_key(_brush),
		"n": [_plane_n.x, _plane_n.y, _plane_n.z],
		"d": _plane_d,
	})
	var how := _axis.to_upper() + (" local" if _space == "local" else "")
	if not _points.is_empty():
		how = "%d placed points" % _points.size()
	print("[knife] cut brush %d on %s at %.3f -> %s" % [_brush, how, _plane_d, dot])
	ModalControls.summary("Cut the brush on %s" % how)
	return true