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