OverviewFilesCommitsIssuesDocsReleasesPeers

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

702 lines · gd ref:
extends LevelOperator
class_name BoxCutterOperator
## BOXCUTTER — draw a cutter on the surface you aim at, and it cuts (#120 L2,
## docs/node-tree-and-stacks.md §13.2–13.4).
##
## The mouse is the camera in every builder tool, so drawing is AIMING, as in the create
## and curve tools: aim at a face (it picks the plane and the target), P sets the first
## corner, the aim drags the other across that plane, snapped to the lattice.
##
## KEYS ARE BOXCUTTER'S (its manual; user 2026-10-18), and only while the tool is up:
##   P        the first corner          X        mode: slice → intersect → inset → outset → cut
##   I        inset                     E        extrude: a set depth ([ ] steps it)
##   Space    THROUGH (lazorcut), and commit
##   LMB / Enter commit · Esc / RMB cancel · H help
##
## THE CUTTER is a `prism` shape (§13.1): its frame's y points INTO the surface, its
## bottom sits one grid step ABOVE the surface (so the face is opened cleanly, not left
## coplanar), and it reaches the set depth below — or THROUGH the target (CutDepth,
## measured now and stored). ONE gesture writes the shape and the live op, so one Ctrl+Z
## takes the whole cut back.

const ParamShapes := preload("res://scenes/maps/shapes/param/param_shapes.gd")
const CutDepth := preload("res://scenes/maps/cut_depth.gd")

## In X's cycle order, BoxCutter's: cut (the default), slice, intersect, inset; outset
## (no BoxCutter key) after inset.
const MODES := ["subtract", "slice", "intersect", "inset", "outset"]
const _UNIT := [Vector3.RIGHT, Vector3.UP, Vector3.BACK]

enum Phase { AIM, DRAW }

var _phase := Phase.AIM
var _mode := 0
var _o := Vector3.ZERO           # the drawn plane: a point on it, its normal, two axes in it
var _n := Vector3.UP
var _u := Vector3.BACK
var _v := Vector3.RIGHT
var _a := Vector3.ZERO           # the first corner, on the plane
var _b := Vector3.ZERO           # the other corner
var _target := ""                # the group (or brush key) the cut goes into
var _material := 0
var _level_id := 0
var _depth_steps := 0            # 0 = THROUGH
var _valid := false
var _frozen := false             # placed by place() (gates, Adjust Last): the aim does not move it

# ---- step 4: footprints and surfaces (§13.2, BoxCutter's D pie) ----
const SHAPES := ["box", "circle", "ngon", "custom"]
const SURFACES := ["face", "view", "cursor", "grid"]
var _shape := "box"              # box: corner to corner · circle / ngon: centre, then radius
var _sides := 6                  # ngon
var _segments := 24              # circle
var _points: Array = []          # custom: the P points, on the plane
var _surface := "face"           # which plane the footprint is drawn on

# ---- step 5: modifiers while drawing (§13.4, BoxCutter's keys) ----
## The queue, written as mod.create records on the cutter at commit and then emptied:
## B chamfer · V array (V V radial) · 1 / 2 / 3 mirror x / y / z · Shift+G grid. The same
## key again takes it off. Mirror and radial work about the TARGET's centre, as
## BoxCutter's do about the object.
var _q_chamfer := false
var _q_array := ""               # "" / "array" / "radial"
var _q_mirror := [false, false, false]
var _q_grid := false
var _live := true                # L: false = the cut is written OFF
var _taper := 1.0                # the prism's (D menu)
var _shear_x := 0.0
var _shear_z := 0.0


func _queue_text() -> String:
	var q: Array = []
	for i in 3:
		if _q_mirror[i]:
			q.append("mirror " + "xyz"[i])
	if _q_array != "":
		q.append(_q_array)
	if _q_grid:
		q.append("grid")
	if _q_chamfer:
		q.append("chamfer")
	return ", ".join(q) if not q.is_empty() else "none"


func _clear_queue() -> void:
	_q_chamfer = false
	_q_array = ""
	_q_mirror = [false, false, false]
	_q_grid = false
	_live = true


# ---- step 6: housekeeping (§13.5) ----

## The last commit's queue, for Shift+R (restore_last_queue).
var _last_queue: Dictionary = {}


## Shift+R: the last cut's modifiers come back (the rest — mode, shape, depth — is sticky).
func restore_last_queue() -> void:
	if _last_queue.is_empty():
		return
	_q_chamfer = bool(_last_queue["chamfer"])
	_q_array = String(_last_queue["array"])
	_q_mirror = (_last_queue["mirror"] as Array).duplicate()
	_q_grid = bool(_last_queue["grid"])


func _remember_queue() -> void:
	_last_queue = {"chamfer": _q_chamfer, "array": _q_array, "mirror": _q_mirror.duplicate(), "grid": _q_grid}


## What the cut goes into: the aimed object, and every SELECTED object with it (each
## brush's group, or its key when it has none), never a cutter.
func _targets() -> Array:
	var out: Array = []
	if _target != "":
		out.append(_target)
	var sel: Variant = map.get("_sel_brushes")
	for b: Variant in (sel as Array if sel is Array else []):
		var bd: Dictionary = level.get_brush(int(b))
		if bd.is_empty() or bool(bd.get("cutter", false)) or bool(bd.get("clearance", false)):
			continue
		var g := String(level.get_brush_group(int(b)))
		var t := g if g != "" else String(level.get_brush_key(int(b)))
		if t != "" and not out.has(t):
			out.append(t)
	return out


## The room's "Cutters" collection, created (inside the caller's gesture) the first time.
func _cutters_collection() -> String:
	var t: Dictionary = map._persist.collection_tree()
	var cols: Dictionary = t.get("cols", {})
	var keys: Array = cols.keys()
	keys.sort()
	for cd: String in keys:
		if String(cols[cd].get("name", "")) == "Cutters":
			return cd
	return String(map._persist.record("col.create", {"name": "Cutters", "parent": ""}))


## The target's centre: the middle of its solid pieces' corners.
func _target_centre() -> Vector3:
	var lo := Vector3(INF, INF, INF)
	var hi := -lo
	for b: int in (level.get_group_brush_ids(_target) as PackedInt32Array):
		var bd: Dictionary = level.get_brush(b)
		if bool(bd.get("clearance", false)) or bool(bd.get("cutter", false)):
			continue
		for c: Vector3 in FaceKit.corners(bd["planes"]):
			lo = lo.min(c)
			hi = hi.max(c)
	return _o if lo.x > hi.x else (lo + hi) * 0.5


## The queued modifiers as [kind, params] for the cutter framed `f` (the prism's frame):
## mirror's offset puts its plane through the target's centre along that frame axis;
## radial pivots there. The rest take the map's defaults.
func _mod_params(f: Transform3D) -> Array:
	var out: Array = []
	var c := _target_centre()
	var b := f.basis.orthonormalized()
	# 1 / 2 / 3 are the WORLD's X / Y / Z (BoxCutter's are the object's); ModFold mirrors along
	# a FRAME axis, so each world axis goes to the frame axis most nearly parallel to it.
	for k in 3:
		if not _q_mirror[k]:
			continue
		var best := 0
		for i in 3:
			if absf(b[i][k]) > absf(b[best][k]):
				best = i
		out.append(["mirror", {"axis": "xyz"[best], "offset": b[best].dot(c - f.origin)}])
	if _q_array == "array":
		out.append(["array", {}])
	elif _q_array == "radial":
		out.append(["radial", {"pivot": [c.x, c.y, c.z]}])
	if _q_grid:
		out.append(["grid", {}])
	if _q_chamfer:
		out.append(["chamfer", {}])
	return out


## Place a CUSTOM footprint directly (gates): the plane, its points, the target.
func place_points(o: Vector3, n: Vector3, pts: Array, target: String) -> void:
	place(o, n, o, o, target)
	_points = pts.duplicate()


## The plane the current SURFACE draws on, as [point, normal], from the aim `hit` ({} when
## there is none). face = the aimed face · view = facing you through the 3D cursor ·
## cursor = the cursor's own plane (its up) · grid = the world's level plane at the
## cursor's height. A surface with no plane yet (face, no aim) returns [].
func surface_plane(hit: Dictionary = {}) -> Array:
	var cur: Node3D = map.get("_cursor3d") as Node3D
	var cp: Vector3 = cur.global_position if cur != null else Vector3.ZERO
	match _surface:
		"view":
			var ray: Dictionary = map._crosshair_ray()
			var d: Vector3 = ray.get("dir", Vector3.FORWARD)
			return [cp, -d.normalized()]
		"cursor":
			return [cp, (cur.global_basis.y if cur != null else Vector3.UP).normalized()]
		"grid":
			return [cp, Vector3.UP]
	return [] if hit.is_empty() else [hit["position"], hit.get("normal", Vector3.UP)]


## D: the tool's menu (BoxCutter's pie) — the shape and the surface.
func _open_menu() -> void:
	var q: Node = map.get("_q_menu")
	if q == null:
		return
	var rows: Array = []
	for s: String in SHAPES:
		rows.append(["Shape: %s%s" % [s.capitalize(), "  ✓" if s == _shape else ""],
			set_tool_setting.bind("shape", s)])
	for s: String in SURFACES:
		rows.append(["Surface: %s%s" % [s.capitalize(), "  ✓" if s == _surface else ""],
			set_tool_setting.bind("surface", s)])
	# Taper and wedge: the prism's values (BoxCutter binds no key; its T is solidify).
	for t: float in [1.0, 0.75, 0.5]:
		rows.append(["Taper: %s%s" % [str(t), "  ✓" if is_equal_approx(t, _taper) else ""],
			set_tool_setting.bind("taper", t)])
	for w: Array in [["none", 0.0, 0.0], ["X", 0.5, 0.0], ["Z", 0.0, 0.5]]:
		rows.append(["Wedge: %s" % w[0], _set_wedge.bind(float(w[1]), float(w[2]))])
	q.open_choices("BoxCutter", rows, map.get_viewport().get_mouse_position())


## Y (BoxCutter's extract): the drawn shape ∩ the targets becomes new hull shapes (an
## "Extract" collection), and NOTHING is cut — no cutter, no op. One gesture.
func _extract_now() -> void:
	var fs := _frame()
	if fs.is_empty() or _targets().is_empty():
		print("[boxcutter] Y: draw a footprint on a target first")
		return
	var prism: Object = ParamShapes.get_type("prism")
	var params: Dictionary = prism.resolve({"poly": fs[2], "taper": _taper, "shear_x": _shear_x, "shear_z": _shear_z})
	var cutters: Array = []
	for pc: Dictionary in (prism.local_pieces(fs[1], params) as Array):
		cutters.append(ShapeFold.transform_planes(pc["planes"], fs[0]))
	map._ensure_persistence()
	map._persist.begin_gesture("BoxCutter extract")
	var made: Array[String] = StackOps.extract_planes(map, _targets(), cutters)
	map._persist.end_gesture()
	map._persist.refold()
	print("[boxcutter] extracted %d shape(s)" % made.size())
	ModalControls.summary("BoxCutter extract · %d shape(s)" % made.size())
	_phase = Phase.AIM
	_frozen = false
	_points.clear()


const ShapeFold := preload("res://scenes/maps/shapes/param/shape_fold.gd")
const StackOps := preload("res://scenes/maps/stack_ops.gd")


func _set_wedge(x: float, z: float) -> void:
	_shear_x = x
	_shear_z = z
	update_preview()


## The footprint in the plane's (u, v): {lo: Vector3, w, l, poly: unit [[u, v], …]}, or {}
## when it has no area yet.
func _footprint() -> Dictionary:
	match _shape:
		"circle", "ngon":
			var dv := _b - _a
			var r := (dv - _n * dv.dot(_n)).length()
			if r < 1.0e-3:
				return {}
			var n := _segments if _shape == "circle" else _sides
			return {"lo": _a - (_u + _v) * r, "w": 2.0 * r, "l": 2.0 * r,
				"poly": ParamShapes.get_type("prism").ngon_poly(n)}
		"custom":
			if _points.size() < 3:
				return {}
			var u0 := INF
			var u1 := -INF
			var v0 := INF
			var v1 := -INF
			for p: Vector3 in _points:
				u0 = minf(u0, (p - _o).dot(_u))
				u1 = maxf(u1, (p - _o).dot(_u))
				v0 = minf(v0, (p - _o).dot(_v))
				v1 = maxf(v1, (p - _o).dot(_v))
			if u1 - u0 < 1.0e-3 or v1 - v0 < 1.0e-3:
				return {}
			var poly: Array = []
			for p2: Vector3 in _points:
				poly.append([((p2 - _o).dot(_u) - u0) / (u1 - u0), ((p2 - _o).dot(_v) - v0) / (v1 - v0)])
			return {"lo": _o + _u * u0 + _v * v0, "w": u1 - u0, "l": v1 - v0, "poly": poly}
	var r4 := _rect()
	var lo: Vector3 = r4[0]
	var w := (r4[1] as Vector3 - lo).length()
	var l := (r4[3] as Vector3 - lo).length()
	if w < 1.0e-3 or l < 1.0e-3:
		return {}
	return {"lo": lo, "w": w, "l": l, "poly": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}


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


func preview_color() -> Color:
	return Color(1.0, 0.25, 0.2, 0.45)     # red: about to cut


func mode() -> String:
	return String(MODES[_mode])


func setup_modal(m: ModalControls) -> void:
	m.confirm_label = "cut"
	m.table([["P", "first corner", func() -> String: return "set — aim for the other" if _phase == Phase.DRAW else "aim, then P"],
		["X / I", "mode", func() -> String: return mode()],
		["D", "shape / surface", func() -> String: return "%s on %s" % [_shape, _surface]],
		["B V 1-3 ⇧G", "modifiers", func() -> String: return _queue_text()],
		["L", "live", func() -> String: return "on" if _live else "OFF (written off)"],
		["E / Space", "depth", func() -> String: return _depth_text()],
		["", "aim", func() -> String: return "" if _valid else "aim at a surface"]])
	m.bind("depth", "depth", func() -> float: return float(_depth_steps), _set_depth,
		1.0, func() -> String: return " (%s)" % _depth_text(), 1.0)
	# THE DEPTH DOT (§17.9): at the footprint's centre, at the depth; dragged into the
	# surface. THROUGH becomes its step count at the grab (as E does).
	m.handle("depth_dot", "depth", _depth_dot, func() -> Vector3: return -_n,
		func(start: float, metres: float) -> float: return start + metres / grid_step(), _depth_begin)


## The depth dot's place, or null before the first corner.
func _depth_dot() -> Variant:
	if not _valid or (_phase != Phase.DRAW and not _frozen):
		return null
	var fp := _footprint()
	if fp.is_empty():
		return null
	var lo: Vector3 = fp["lo"]
	return lo + _u * (float(fp["w"]) * 0.5) + _v * (float(fp["l"]) * 0.5) - _n * _depth()


func _depth_begin() -> void:
	if _depth_steps == 0:
		_depth_steps = maxi(1, roundi(_through() / grid_step()))


func _set_depth(v: float) -> void:
	_depth_steps = maxi(0, int(roundf(v)))
	update_preview()


func _depth_text() -> String:
	return "through" if _depth_steps == 0 else fmt_units(_depth_steps * grid_units())


func status() -> String:
	return modal().status_text()


func readout() -> String:
	return "%s · %s" % [mode(), _depth_text()] if _valid else ""


func on_activate() -> bool:
	_phase = Phase.AIM
	_frozen = false
	update_preview()
	if not _valid:
		print("[boxcutter] aim at a surface to draw a cutter on")
		return false
	return true


func on_deactivate() -> void:
	_phase = Phase.AIM
	_frozen = false
	clear_preview()


func handle_key(event: InputEvent) -> bool:
	if not (event is InputEventKey) or not (event as InputEventKey).pressed:
		return false
	var k := event as InputEventKey
	if k.ctrl_pressed or k.alt_pressed:
		return false
	match KeyRules.key_of(k):
		KEY_P:
			if _valid:
				if _shape == "custom":
					if _phase != Phase.DRAW:
						_points.clear()
					_points.append(_b if _phase == Phase.DRAW else _a)
				else:
					_a = _b if _phase == Phase.DRAW else _a
				_phase = Phase.DRAW
				update_preview()
			return true
		KEY_BACKSPACE:
			if _shape == "custom" and not _points.is_empty():
				_points.pop_back()
				update_preview()
			return true
		KEY_D:
			_open_menu()
			return true
		# Modifiers while drawing (BoxCutter's): the same key again takes it off.
		KEY_B:
			_q_chamfer = not _q_chamfer
			return true
		KEY_V:
			_q_array = {"": "array", "array": "radial", "radial": ""}[_q_array]
			return true
		KEY_1, KEY_2, KEY_3:
			var i := int(KeyRules.key_of(k) - KEY_1)
			_q_mirror[i] = not bool(_q_mirror[i])
			return true
		KEY_G:
			if not k.shift_pressed:
				return false                   # bare G is grab, not ours
			_q_grid = not _q_grid
			return true
		KEY_L:
			_live = not _live
			return true
		KEY_Y:
			_extract_now()
			return true
		KEY_X:
			_mode = (_mode + 1) % MODES.size()
			return true
		KEY_I:
			_mode = MODES.find("inset")
			return true
		KEY_E:
			# A set depth, starting from what THROUGH would be, so [ ] steps from there.
			if _depth_steps == 0:
				_depth_steps = maxi(1, roundi(_through() / grid_step()))
			update_preview()
			return true
		KEY_SPACE:
			_depth_steps = 0
			confirm_via_stack()
			return true
	return modal().handle_key(event)


## Place the drawn rect directly: the plane `o` / `n`, corners `a` and `b` on it, the cut
## going into `target` — what Adjust Last and the gates use instead of aiming.
func place(o: Vector3, n: Vector3, a: Vector3, b: Vector3, target: String) -> void:
	_set_plane(o, n)
	_a = a
	_b = b
	_target = target
	_phase = Phase.DRAW
	_valid = true
	_frozen = true


func _set_plane(o: Vector3, n: Vector3) -> void:
	_o = o
	_n = n.normalized()
	var ax := 0
	if absf(_n.y) >= absf(_n.x) and absf(_n.y) >= absf(_n.z):
		ax = 1
	elif absf(_n.z) > absf(_n.x):
		ax = 2
	var axial := absf(_n[ax]) > 0.999
	if axial:
		_u = _UNIT[(ax + 1) % 3]
	else:
		_u = (_n.cross(Vector3.UP) if absf(_n.y) < 0.99 else _n.cross(Vector3.RIGHT)).normalized()
	_v = _n.cross(_u)


## A point on the plane, its two in-plane coordinates on the map lattice (an axial plane;
## a slanted one keeps the lattice of its own u / v from the plane's point).
func _snap(p: Vector3) -> Vector3:
	var out := p
	for i in 3:
		var e: Vector3 = _UNIT[i]
		if absf(_n.dot(e)) > 0.999:
			continue                           # along the normal: stay on the surface
		if absf(_u.dot(e)) > 0.999 or absf(_v.dot(e)) > 0.999:
			out[i] = map.snap_to_map_grid(p[i], e, grid_units(), map._map_center, map._aligned_offset)
	return out


func _aim_on_plane() -> Variant:
	var ray: Dictionary = map._crosshair_ray()
	if ray.is_empty():
		return null
	var dir: Vector3 = ray["dir"]
	var den := _n.dot(dir)
	if absf(den) < 1.0e-4:
		return null
	var t := _n.dot(_o - (ray["from"] as Vector3)) / den
	return null if t < 0.0 else (ray["from"] as Vector3) + dir * t


func update_preview() -> void:
	clear_preview()
	# While the depth dot is held the footprint stays where it is (the pointer drives the
	# depth, not the corner).
	if not _frozen and not modal().grabbing():
		if _phase == Phase.AIM:
			_valid = false
			var hit: Dictionary = map._crosshair_hit()
			if hit.is_empty():
				return
			var brush: int = map._brush_from_hit(hit)
			if brush < 0:
				return
			var b: Dictionary = level.get_brush(brush)
			_material = int(b.get("material_id", 0))
			_level_id = int(b.get("level_id", 0))
			var g := String(level.get_brush_group(brush))
			_target = g if g != "" else String(level.get_brush_key(brush))
			var pl := surface_plane(hit)
			_set_plane(pl[0], pl[1])
			# Off the aimed face, the first point is where the aim crosses the chosen plane.
			var on: Variant = _aim_on_plane() if _surface != "face" else null
			_a = _snap(on if on != null else _o)
			_b = _a + (_u + _v) * grid_step()
			_valid = true
		else:
			var p: Variant = _aim_on_plane()
			if p != null:
				_b = _snap(p)
	if _valid:
		draw_lines(_edges())


## The rect's corners on the plane, ordered round it: [lo-lo, hi-lo, hi-hi, lo-hi] in (u, v).
func _rect() -> Array:
	var ua := (_a - _o).dot(_u)
	var ub := (_b - _o).dot(_u)
	var va := (_a - _o).dot(_v)
	var vb := (_b - _o).dot(_v)
	var u0 := minf(ua, ub)
	var u1 := maxf(ua, ub)
	var v0 := minf(va, vb)
	var v1 := maxf(va, vb)
	return [_o + _u * u0 + _v * v0, _o + _u * u1 + _v * v0, _o + _u * u1 + _v * v1, _o + _u * u0 + _v * v1]


## How far below the surface the cut reaches: the set depth, or THROUGH the target now.
func _depth() -> float:
	if _depth_steps > 0:
		return float(_depth_steps) * grid_step()
	var d := _through()
	return d if d > 0.0 else grid_step()


func _through() -> float:
	var pieces: Array = []
	for t: String in _targets():                     # through EVERY target
		var before := pieces.size()
		for b: int in (level.get_group_brush_ids(t) as PackedInt32Array):
			var bd: Dictionary = level.get_brush(b)
			if not bool(bd.get("clearance", false)) and not bool(bd.get("cutter", false)):
				pieces.append(bd["planes"])
		if pieces.size() == before:
			var id: int = level.brush_by_key(t) if level.has_method("brush_by_key") else -1
			if id >= 0:
				pieces.append(level.get_brush(id)["planes"])
	return CutDepth.through(pieces, _o, _n, grid_step())


## [frame, size] of the prism: x = u, y = INTO the surface, z = v (right-handed: u × −n = v);
## the bottom one grid step above the surface, the size reaching the depth below it.
func _frame() -> Array:
	var fp := _footprint()
	if fp.is_empty():
		return []
	var m := grid_step()
	var f := Transform3D(Basis(_u, -_n, _v), (fp["lo"] as Vector3) + _n * m)
	return [f, Vector3(float(fp["w"]), _depth() + m, float(fp["l"])), fp["poly"]]


## The footprint's outline on the surface and at the depth, joined: the cut you will get.
func _edges() -> PackedVector3Array:
	var out := PackedVector3Array()
	var fp := _footprint()
	if fp.is_empty():
		return out
	var down := -_n * _depth()
	var lo: Vector3 = fp["lo"]
	var pts: Array = []
	for q: Array in (fp["poly"] as Array):
		pts.append(lo + _u * (float(q[0]) * float(fp["w"])) + _v * (float(q[1]) * float(fp["l"])))
	for i in pts.size():
		var p: Vector3 = pts[i]
		var q2: Vector3 = pts[(i + 1) % pts.size()]
		out.append_array(PackedVector3Array([p, q2, p + down, q2 + down, p, p + down]))
	return out


func tool_settings() -> Array:
	return [
		{"id": "mode", "label": "Mode", "type": "enum", "value": _mode, "choices": MODES,
			"tip": "X cycles, I = inset (BoxCutter's keys)."},
		{"id": "depth", "label": "Depth", "type": "int", "value": _depth_steps, "min": 0, "step": 1,
			"unit": "grid", "tip": "Below the surface, in grid steps; 0 = through (Space)."},
		{"id": "shape", "label": "Shape", "type": "enum", "value": SHAPES.find(_shape), "choices": SHAPES,
			"tip": "The footprint (D menu): box, circle, ngon, custom polygon."},
		{"id": "sides", "label": "Sides", "type": "int", "value": _sides, "min": 3, "max": 64, "step": 1,
			"tip": "An ngon's sides."},
		{"id": "taper", "label": "Taper", "type": "float", "value": _taper, "min": 0.05, "max": 4.0, "step": 0.05,
			"tip": "The far end's size against the footprint (1 = straight)."},
		{"id": "shear_x", "label": "Wedge X", "type": "float", "value": _shear_x, "min": -4.0, "max": 4.0, "step": 0.05,
			"tip": "The far end's offset along the footprint's x, a fraction of its width."},
		{"id": "shear_z", "label": "Wedge Z", "type": "float", "value": _shear_z, "min": -4.0, "max": 4.0, "step": 0.05,
			"tip": "The far end's offset along the footprint's z, a fraction of its length."},
		{"id": "surface", "label": "Surface", "type": "enum", "value": SURFACES.find(_surface), "choices": SURFACES,
			"tip": "Where it is drawn (D menu): the aimed face, the view, the 3D cursor, the grid."},
		{"id": "cut", "label": "Cut", "type": "action", "tip": "Commit (LMB / Enter / Space)."},
	]


func set_tool_setting(sid: String, value: Variant) -> bool:
	match sid:
		"mode":
			_mode = posmod(int(value), MODES.size())
		"depth":
			_depth_steps = maxi(0, int(value))
		"shape":
			_shape = String(value) if value is String else String(SHAPES[posmod(int(value), SHAPES.size())])
		"sides":
			_sides = clampi(int(value), 3, 64)
		"taper":
			_taper = clampf(float(value), 0.05, 4.0)
		"shear_x":
			_shear_x = clampf(float(value), -4.0, 4.0)
		"shear_z":
			_shear_z = clampf(float(value), -4.0, 4.0)
		"surface":
			_surface = String(value) if value is String else String(SURFACES[posmod(int(value), SURFACES.size())])
		"cut":
			confirm_via_stack()
			return true
		_:
			return false
	update_preview()
	return true


func gesture_label() -> String:
	return "BoxCutter %s" % mode()


func confirm() -> bool:
	if not _valid or _phase != Phase.DRAW or _target == "":
		print("[boxcutter] draw a rect first: aim, P, aim")
		return false
	var fs := _frame()
	if fs.is_empty():
		print("[boxcutter] the footprint has no area yet: aim away from the first point (custom: 3 points)")
		return false
	var size: Vector3 = fs[1]
	var prism: Object = ParamShapes.get_type("prism")
	map._ensure_persistence()
	map._persist.begin_gesture(gesture_label())
	var dot: String = map._persist.record("shape.create", {"type": "prism", "gv": prism.GV,
		"params": prism.resolve({"poly": fs[2], "taper": _taper, "shear_x": _shear_x, "shear_z": _shear_z}),
		"frame": SceneObjects.xform_to(fs[0]),
		"size": [size.x, size.y, size.z], "material_id": _material, "level_id": _level_id})
	var ok := dot != ""
	if ok:
		var rec := {"op": mode(), "cutter": dot, "targets": _targets()}
		var odot: String = map._persist.record("op.create", rec)
		ok = odot != ""
		if ok and not _live:
			map._persist.record("op.set", {"target": odot, "on": false})     # L: written off
		# The queued modifiers, on the CUTTER (ModFold copies its pieces as cutters).
		var defaults: Dictionary = map.get("MOD_DEFAULTS") if map.get("MOD_DEFAULTS") is Dictionary else {}
		for kp: Array in _mod_params(fs[0]):
			var p: Dictionary = (defaults.get(kp[0], {}) as Dictionary).duplicate(true)
			p.merge(kp[1] as Dictionary, true)
			map._persist.record("mod.create", {"target": dot, "mod": kp[0], "params": p})
		# Every cutter in ONE "Cutters" collection (organisation; the wires are the
		# cutters overlay's, Shift+Alt+C).
		var col := _cutters_collection()
		if col != "":
			map._persist.record("col.link", {"target": col, "members": ["g:" + dot]})
	map._persist.end_gesture()
	if not ok:
		print("[boxcutter] the journal refused the cut")
		return false
	map._persist.refold()
	if map.has_method("_refresh_brush_view"):
		map._refresh_brush_view()
	print("[boxcutter] %s %s into %s, %s" % [mode(), dot, _target, _depth_text()])
	ModalControls.summary("BoxCutter %s · %s" % [mode(), _depth_text()])
	_phase = Phase.AIM
	_frozen = false
	_points.clear()
	_remember_queue()
	_clear_queue()
	return true