OverviewFilesCommitsIssuesDocsReleasesPeers

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

716 lines · gd ref:
extends LevelOperator
class_name CreateBoxOperator
## CREATE — put new brushes on the surface you are aiming at.
##
## Geometry could only ever APPEAR by splitting something that already existed or by
## the two-point fill, which needs two opposing faces to work between. There was no
## way to make a brush where there was nothing.
##
## It sits ON the aimed surface, pushed out along that surface's normal, so a box
## placed against a floor rests on it and one placed against a wall sticks out of it.
## Everything is snapped to the map's lattice — corner and size both — because a box
## that is off-grid is a box nothing else will ever line up with.
##
## MANY SHAPES, ONE PLACEMENT. TrenchBroom's DrawShapeTool dispatches its seven
## parametric shapes from one tool with one set of bounds, and that is the part worth
## copying: where the thing goes, which way it grows, what material it inherits and
## how it reaches the journal are decided ONCE, here, and a shape only says which
## solids fill the bounds. The shapes are a REGISTRY (BrushShapes, editor redesign E4):
## box / stairs / arch are the built-ins, a plugin shape is one script and one
## register(), and Tab cycles them all.

## Indices into shape_ids(): the ParamShapes types, in their Tab order (§13 — the tool
## makes ONLY parametric shapes). ARCH is the tunnel (its round profile is the arch).
enum Shape { BOX, STAIRS, SPIRAL_STAIRS, ARCH, CYLINDER, CONE, SPHERE }

## The tool's old shape ids (the sticky pref) -> the parametric type that replaced them.
const LEGACY_IDS := {"box": "param:box", "stairs": "param:stairs", "arch": "param:tunnel",
	"cylinder": "param:cylinder", "cone": "param:cone", "sphere": "param:sphere"}

## Kept here for callers that read them off the operator; the shapes own them now.
const HEADROOM_M := StairsShape.HEADROOM_M
const AXIAL_MARGIN_M := ArchShape.AXIAL_MARGIN_M
const ARCH_SEGMENTS := ArchShape.DEFAULT_SEGMENTS
## NO CEILING on size or height (#109, 2026-10-12: "scale is currently bounded, should be
## unbounded" · "when creating arches, unit scale is blocked at 32"). The floor stays: a
## shape is at least one grid step (gate_unbounded_scale).

var _shape: int = Shape.BOX
var _size_steps: int = 2
## Height along the surface normal, in grid steps. 0 = THE SAME AS SIZE (a cube, which is
## what the tool always made), so size keeps driving it until the player sets a height.
var _height_steps: int = 0
## Each shape's own values (BrushShape.params()), by shape id then param id.
var _shape_params: Dictionary = {}
var _origin := Vector3.ZERO
var _normal := Vector3.UP
## Which axis _normal faces along, and the view direction the placement is stated
## relative to. Both are set with _origin, from the same hit, so _corners() cannot
## be reading one frame's normal against another frame's aim.
var _axis: int = 1
var _view := Vector3.FORWARD
var _material: int = 0
var _level_id: int = 0
var _valid: bool = false


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


func preview_color() -> Color:
	return Color(0.4, 1.0, 0.5, 0.45)      # green: about to CREATE


## The baked shape behind the current entry — only PLUGIN shapes are baked now (§13.2);
## for a parametric entry this is the first plugin or the box, and nothing reads it.
func shape() -> BrushShape:
	var i := BrushShapes.BUILTIN_COUNT + maxi(0, _shape - ParamShapes.ids().size())
	return BrushShapes.at(mini(i, BrushShapes.count() - 1))


# ---- PARAMETRIC SHAPES (docs/parametric-shapes-design.md §4) ----------------------
#
# After the built-ins, the shape list carries the parametric TYPES (ParamShapes): the
# same placement — bounds, which way they grow, the material they inherit — but the
# confirm writes ONE shape.create (type, params, frame, size) instead of a brush.create
# per piece, and the fold generates the pieces. Their params then stay editable forever
# in the Shape panel, so they need no Adjust Last.
const ParamShapes := preload("res://scenes/maps/shapes/param/param_shapes.gd")


## Every shape the tool offers, by durable id: "param:<type>" for every parametric type,
## then any PLUGIN BrushShape (registered past the built-ins), which stays baked — a
## plugin is a script peers may not have (§13.5a: synced plugin types will be data).
func shape_ids() -> Array:
	var out: Array = []
	for t: String in ParamShapes.ids():
		out.append("param:" + t)
	var all: Array = BrushShapes.all()
	for i in range(BrushShapes.BUILTIN_COUNT, all.size()):
		out.append((all[i] as BrushShape).id())
	return out


func _shape_count() -> int:
	return ParamShapes.ids().size() + maxi(0, BrushShapes.count() - BrushShapes.BUILTIN_COUNT)


func _is_param() -> bool:
	return _shape < ParamShapes.ids().size()


func _ptype() -> Object:
	return ParamShapes.get_type(String(ParamShapes.ids()[_shape])) if _is_param() else null


func _shape_label() -> String:
	return String(_ptype().label()) if _is_param() else shape().label()


## The parametric type's params as the user has set them, resolved (defaults, clamps),
## plus what THIS placement decides — stored in the record, so the fold never reads a
## local grid and every peer generates the same pieces:
##   stairs  auto_riser = one grid step when Auto steps is "one per grid step" (§13.5a)
##   tunnel  wall = the old arch's ring rule (one grid step, or half the smaller radius)
##           unless the player has set a wall
func _pparams() -> Dictionary:
	var tid := String(_ptype().id())
	var given: Dictionary = (_shape_params.get("param:" + tid, {}) as Dictionary).duplicate()
	if tid == "stairs" and _auto_grid:
		given["auto_riser"] = grid_step()
	if tid == "tunnel" and not given.has("wall"):
		var sz: Vector3 = _param_frame()[1]
		var th: float = ArchShape.thickness(sz.z * 0.5, sz.y, grid_step())
		if th > 0.0:
			given["wall"] = th
	return _ptype().resolve(given)


## Auto steps for stairs: false = 0.2 m risers, true = one step per grid step.
var _auto_grid := false


## The shape's FRAME and SIZE from the placement bounds. Local x = the run, pointing
## away from you (stairs climb away, a tunnel bores away); local y = the surface
## normal; z = x × y, so the basis is right-handed. The origin is the bounds corner
## that local (0,0,0) lands on.
func _param_frame() -> Array:
	var c: Array = _corners()
	var lo: Vector3 = c[0]
	var hi: Vector3 = c[1]
	var r := _run_axis()
	var n := _axis
	var third := 3 - r - n
	var x: Vector3 = _UNIT[r] * (1.0 if _view[r] >= 0.0 else -1.0)
	var y: Vector3 = _UNIT[n] * (1.0 if _normal[n] >= 0.0 else -1.0)
	var z := x.cross(y)
	var b := Basis(x, y, z)
	var origin := Vector3.ZERO
	for i in 3:
		var col: Vector3 = x if absf(x[i]) > 0.5 else (y if absf(y[i]) > 0.5 else z)
		origin[i] = lo[i] if col[i] > 0.0 else hi[i]
	var ext := hi - lo
	return [Transform3D(b, origin), Vector3(absf(ext[r]), absf(ext[n]), absf(ext[third]))]


## The parametric shape's pieces, in the world: [{edges, planes}] — the planes through
## the fold's own transform, so the preview IS the geometry the fold will make (and the
## placement / shape gates can measure it before anything is written).
func _param_pieces(clearance: bool) -> Array:
	var fs: Array = _param_frame()
	var frame: Transform3D = fs[0]
	var t := _ptype()
	var local: Array = t.local_clearance(fs[1], _pparams()) if clearance else t.local_pieces(fs[1], _pparams())
	var out: Array = []
	for pc: Dictionary in local:
		var e := PackedVector3Array()
		for p: Vector3 in (pc.get("edges", PackedVector3Array()) as PackedVector3Array):
			e.append(frame * p)
		out.append({"edges": e, "planes": ShapeFold.transform_planes(pc["planes"], frame)})
	return out


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


func _confirm_param() -> bool:
	var fs: Array = _param_frame()
	var size: Vector3 = fs[1]
	var t := _ptype()
	map._ensure_persistence()
	map._persist.begin_gesture("Add %s" % t.label())
	var dot: String = map._persist.record("shape.create", {"type": t.id(), "gv": t.GV,
		"params": _pparams(), "frame": SceneObjects.xform_to(fs[0]), "size": [size.x, size.y, size.z],
		"material_id": _material, "level_id": _level_id,
		"tex": map.current_face_tex()})          # textured at birth, as the baked tool was (§13.3)
	map._persist.end_gesture()
	if dot == "":
		print("[box] the journal refused that %s" % t.id())
		return false
	map._persist.refold()
	if map.has_method("select_shape"):
		map.select_shape(dot)
	print("[box] created %s %s: %d piece(s) at %s" % [t.id(), dot,
		level.get_group_brush_ids(dot).size(), str(fs[0].origin)])
	ModalControls.summary("Created %s %s" % [t.label(), _size_text()])
	return true


func _height() -> int:
	return _height_steps if _height_steps > 0 else _size_steps


## Footprint then height: "8u x 8u x 8u". For a cube this is the line the tool always printed.
func _size_text() -> String:
	var u: float = grid_units()
	var side := fmt_units(_size_steps * u)
	return "%s x %s x %s" % [side, side, fmt_units(_height() * u)]


## THE MODAL CONVENTION (#120 F3): the size is a bound value (grid steps, at least 1);
## Tab (the shape — the tool's own key, so ModalControls never takes Tab here: one value),
## what it cuts and the aim are rows; the table is the status line and the H overlay.
func setup_modal(m: ModalControls) -> void:
	m.confirm_label = "create"
	m.table([["Tab", "shape", func() -> String:
			return "%s (editable after placing: Shape panel)" % _shape_label() if _is_param() else _what()],
		# The CUT is stated, because it is the half you cannot see in the solid. An arch
		# that quietly created no clearance would look identical here and be unusable.
		["", "cutting", func() -> String:
			if _is_param() or _clearance_pieces().is_empty():
				return ""
			return String(shape().clearance_name(_ctx()))],
		["", "aim", func() -> String: return "" if _valid else "aim at a surface"]])
	m.bind("size", "size", func() -> float: return float(_size_steps),
		func(v: float) -> void: set_tool_setting("size", int(roundf(v))),
		1.0, func() -> String: return " (%s)" % _size_text(), 1.0)


func _what() -> String:
	var c: Array = _corners()
	return String(shape().describe(c[0], c[1], _ctx(), _size_text()))


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


func readout() -> String:
	return "%s %s" % [_shape_label(), _size_text()] if _valid else ""


func on_activate() -> bool:
	update_preview()
	if not _valid:
		print("[box] aim at a surface to place a box against")
		return false
	return true


func on_deactivate() -> void:
	clear_preview()


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_TAB:
			set_tool_setting("shape", _shape + 1)
			print("[box] shape: %s" % _shape_name())
			return true
	# [ ] / wheel (the size), H: ModalControls.
	return modal().handle_key(event)


func _shape_name() -> String:
	return String(shape_ids()[_shape])


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

func load_prefs() -> void:
	super.load_prefs()
	var sid := String(pref("shape", "param:box"))
	var i := shape_ids().find(String(LEGACY_IDS.get(sid, sid)))
	if i >= 0:
		_shape = i
	_auto_grid = bool(pref("auto_grid", false))
	_size_steps = maxi(int(pref("size", _size_steps)), 1)
	_height_steps = maxi(int(pref("height", 0)), 0)
	for s: BrushShape in BrushShapes.all():
		for d: Dictionary in s.params():
			var v: Variant = pref("p.%s.%s" % [s.id(), String(d["id"])], null)
			if v != null:
				_param_dict(s.id())[String(d["id"])] = _coerce(d, v)
	for tid: String in ParamShapes.ids():
		for d: Dictionary in (ParamShapes.get_type(tid).params() as Array):
			var pv: Variant = pref("p.param:%s.%s" % [tid, String(d["id"])], null)
			if pv != null:
				_param_dict("param:" + tid)[String(d["id"])] = pv


func tool_settings() -> Array:
	# IN shape_ids() ORDER — the enum's value is an index into it (parametric types,
	# then plugins). Every shape is parametric now, so the ◇ marker went with the baked list.
	var labels: Array = []
	for tid: String in ParamShapes.ids():
		labels.append(String(ParamShapes.get_type(tid).label()))
	var all: Array = BrushShapes.all()
	for i in range(BrushShapes.BUILTIN_COUNT, all.size()):
		labels.append((all[i] as BrushShape).label())
	var out: Array = [
		{"id": "shape", "label": "Shape", "type": "enum", "value": _shape, "choices": labels,
			"tip": "What fills the bounds (Tab cycles)."},
		{"id": "size", "label": "Size", "type": "int", "value": _size_steps, "min": 1,
			"step": 1, "unit": "grid", "tip": "Footprint, in grid steps ([ ] or wheel)."},
		{"id": "height", "label": "Height", "type": "int", "value": _height(), "min": 1,
			"step": 1, "unit": "grid",
			"tip": "Along the surface normal, in grid steps. Equal to size = a cube that follows size."},
	]
	if _is_param():
		if String(_ptype().id()) == "stairs":
			out.append({"id": "auto_steps", "label": "Auto steps", "type": "enum", "value": 1 if _auto_grid else 0,
				"choices": ["0.2 m risers", "One per grid step"],
				"tip": "What Steps 0 aims for: comfortable 0.2 m risers, or treads on the lattice."})
		out.append_array(ParamShapes.rows(_ptype(), _pparams()))
		out.append({"id": "create", "label": "Create", "type": "action", "tip": "Place it (Enter)."})
		return out
	var s := shape()
	for d: Dictionary in s.params():
		var row: Dictionary = d.duplicate()
		row["id"] = "p." + String(d["id"])
		row["value"] = s.param(_ctx(), String(d["id"]))
		out.append(row)
	out.append({"id": "create", "label": "Create", "type": "action", "tip": "Place it (Enter)."})
	return out


func set_tool_setting(sid: String, value: Variant) -> bool:
	match sid:
		"shape":
			_shape = posmod(int(value), _shape_count())
			save_pref("shape", _shape_name())
		"size":
			_size_steps = maxi(int(value), 1)
			save_pref("size", _size_steps)
		"height":
			var h := maxi(int(value), 1)
			# Setting it back to the size RE-LINKS it, so size drives it again.
			_height_steps = 0 if h == _size_steps else h
			save_pref("height", _height_steps)
		"create":
			confirm_via_stack()
			return true
		"auto_steps":
			_auto_grid = int(value) == 1
			save_pref("auto_grid", _auto_grid)
		_:
			if not sid.begins_with("p."):
				return false
			var pid := sid.substr(2)
			if _is_param():
				var pv: Variant = ParamShapes.value_of(_ptype(), pid, value)
				if pv == null:
					return false
				_param_dict("param:" + String(_ptype().id()))[pid] = pv
				save_pref("p.param:%s.%s" % [_ptype().id(), pid], pv)
				update_preview()
				return true
			var s := shape()
			var desc: Dictionary = {}
			for d: Dictionary in s.params():
				if String(d["id"]) == pid:
					desc = d
			if desc.is_empty():
				return false
			var v: Variant = _coerce(desc, value)
			_param_dict(s.id())[pid] = v
			save_pref("p.%s.%s" % [s.id(), pid], v)
	update_preview()
	return true


func _param_dict(sid: String) -> Dictionary:
	if not _shape_params.has(sid):
		_shape_params[sid] = {}
	return _shape_params[sid]


static func _coerce(desc: Dictionary, v: Variant) -> Variant:
	match String(desc.get("type", "")):
		"int":
			return clampi(int(v), int(desc.get("min", -2147483647)), int(desc.get("max", 2147483647)))
		"float":
			return clampf(float(v), float(desc.get("min", -INF)), float(desc.get("max", INF)))
		"bool":
			return bool(v)
	return v


# ---- history + adjust last (E5) ---------------------------------------------------

## While adjusting, the placement is the STORED one — the crosshair has moved on.
var _frozen := false


func gesture_label() -> String:
	return "Add %s" % _ptype().label() if _is_param() else shape().label()


func adjust_ids() -> Array:
	var out: Array = []
	if _is_param():
		return out                         # its params stay editable in the Shape panel
	for d: Dictionary in tool_settings():
		if String(d.get("type")) != "action":
			out.append(String(d["id"]))
	return out


func adjust_snapshot() -> Dictionary:
	if not _valid:
		return {}
	return {"origin": _origin, "normal": _normal, "axis": _axis, "view": _view,
		"material": _material, "level_id": _level_id}


func adjust_restore(snap: Dictionary) -> void:
	_origin = snap["origin"]
	_normal = snap["normal"]
	_axis = int(snap["axis"])
	_view = snap["view"]
	_material = int(snap["material"])
	_level_id = int(snap["level_id"])
	_valid = true
	_frozen = true


func adjust_end() -> void:
	_frozen = false
	clear_preview()


# ---- placement ----------------------------------------------------------------

func update_preview() -> void:
	clear_preview()
	if _frozen:
		draw_lines(_edges())
		return
	_valid = false
	var hit: Dictionary = map._crosshair_hit()
	if hit.is_empty():
		return
	var brush: int = map._brush_from_hit(hit)
	if brush >= 0:
		# Inherit the surface's material, which is what TrenchBroom does with new
		# brushes: a box placed against a brick wall should be brick, not blank.
		var b: Dictionary = level.get_brush(brush)
		_material = int(b.get("material_id", 0))
		_level_id = int(b.get("level_id", 0))
	_normal = hit.get("normal", Vector3.UP)
	var at: Vector3 = hit["position"]
	# Which axis the surface faces along. Everything below is stated relative to it,
	# the way TrenchBroom's moveDeltaForBounds is (Grid.cpp:180).
	_axis = _dominant_axis(_normal)
	var ray: Dictionary = map._crosshair_ray()
	_view = (ray.get("dir", Vector3.FORWARD) as Vector3)
	# Snap the CORNER to the lattice, then grow from there — snapping the centre would
	# put the faces on half-lattice positions, which is exactly what the grid is for
	# avoiding.
	#
	# TOWARDS THE VIEWER on the two axes across the normal, not to the nearest line.
	# Nearest-rounding answers "which lattice line is closest to the hit", which is
	# not the question: aim a hair past a line and the box lands a whole grid square
	# behind where you were pointing.
	#
	# AND NOT AT ALL along the normal of an axial surface. TrenchBroom skips it so
	# you can paste onto one-unit floor detail at grid 8; here it matters far more,
	# because the lattice origin is the map's recentre offset and map_dc_world LIFTS
	# the level — so a floor that is not on the lattice is the ordinary case, and
	# rounding that axis buries the box in it or leaves it hovering.
	# SNAP TARGET FIRST (E2b): near a vertex / edge of the aimed face, the corner goes
	# exactly there — "this box's corner on that brush's corner". The lattice below is
	# the fallback, not overridden.
	var tgt: Variant = map.snap_target_point(at, brush) if map.has_method("snap_target_point") else null
	if tgt != null:
		_origin = tgt
		_valid = true
		draw_lines(_edges())
		return
	var axial: bool = absf(_normal[_axis]) > 0.999
	_origin = Vector3.ZERO
	for i in 3:
		if i == _axis and axial:
			_origin[i] = at[i]
		else:
			_origin[i] = _snap_towards(at[i], _UNIT[i], -_view[i])
	_valid = true
	draw_lines(_edges())


const _UNIT := [Vector3.RIGHT, Vector3.UP, Vector3.BACK]


## Index of the largest-magnitude component — the axis a surface faces along.
static func _dominant_axis(n: Vector3) -> int:
	var ax := absf(n.x)
	var ay := absf(n.y)
	var az := absf(n.z)
	if ax >= ay and ax >= az:
		return 0
	return 1 if ay >= az else 2


## One coordinate on the map lattice, rounded in the direction `toward` rather than
## to the nearest line. Expressed through snap_to_map_grid rather than repeating its
## lattice arithmetic: that function already accounts for the map's recentre offset,
## which naive world-origin snapping gets wrong at grids of 16 and coarser.
func _snap_towards(d: float, axis: Vector3, toward: float) -> float:
	var s: float = map.snap_to_map_grid(d, axis, grid_units(), map._map_center, map._aligned_offset)
	var st: float = grid_step()
	if toward > 0.0 and s < d - 1.0e-6:
		return s + st
	if toward < 0.0 and s > d + 1.0e-6:
		return s - st
	return s


func _corners() -> Array:
	var s: float = grid_step() * float(_size_steps)
	var h: float = grid_step() * float(_height())
	var ext := Vector3(s, s, s)
	ext[_axis] = h
	var lo: Vector3 = _origin
	# ALONG THE NORMAL: grow away from the surface, so a box placed on a floor rests
	# on it and one placed on a ceiling hangs from it.
	if _normal[_axis] < 0.0:
		lo[_axis] -= h
	# ACROSS IT: grow away from the camera, so the corner under the crosshair is the
	# one nearest you. Growing in world +X/+Y/+Z regardless meant that half the time
	# the box appeared on the far side of the point you had aimed at, and you had to
	# walk round to find out.
	# Looking in -X means standing at +X, so the box goes into -X and its MAX on that
	# axis is the corner under the crosshair. (TrenchBroom: ray.direction[i] < 0.)
	for i in 3:
		if i != _axis and _view[i] < 0.0:
			lo[i] -= s
	return [lo, lo + ext]


## What every shape call is handed — built from the same hit as _origin.
func _ctx() -> Dictionary:
	return {
		"axis": _axis, "normal": _normal, "view": _view, "run_axis": _run_axis(),
		"grid_step": grid_step(), "grid_units": grid_units(),
		"params": _shape_params.get(shape().id(), {}), "fmt": Callable(self, "fmt_units"),
	}


## The SOLIDS that fill the bounds. Everything above this line — where the bounds
## are, which way they grew, what material they inherit — is the same for every
## shape; a shape only answers "what fills it".
func _pieces() -> Array:
	if _is_param():
		return _param_pieces(false)
	var c: Array = _corners()
	return shape().pieces(c[0], c[1], _ctx())


## The AIR those solids need, as its own set of convex volumes — the second half
## of "what fills the bounds", and the half the terrain reads.
func _clearance_pieces() -> Array:
	if _is_param():
		return _param_pieces(true)
	var c: Array = _corners()
	return shape().clearance(c[0], c[1], _ctx())


## Which axis you are looking along, of the two across the aimed surface. Stairs
## climb along it and an arch tunnels along it, so both are oriented by where you
## are standing rather than by world axes — the same reasoning as _corners() growing
## the box away from the camera.
func _run_axis() -> int:
	var best: int = -1
	var bv: float = -1.0
	for i in 3:
		if i == _axis:
			continue
		var m: float = absf(_view[i])
		if m > bv:
			bv = m
			best = i
	return best if best >= 0 else (1 if _axis == 0 else 0)


## The stairs' step count at the current bounds (the operator gates read it).
func _step_count() -> int:
	var c: Array = _corners()
	return (BrushShapes.at(BrushShapes.index_of("stairs")) as StairsShape).step_count(c[0], c[1], _ctx())


## The arch's clearance prism for given bounds, or {} when the ring degenerates to a solid
## (the clearance gate probes it directly).
func _arch_clearance(lo: Vector3, hi: Vector3) -> Dictionary:
	var c: Array = BrushShapes.at(BrushShapes.index_of("arch")).clearance(lo, hi, _ctx())
	return {} if c.is_empty() else c[0]


## A distance in metres rounded UP to whole grid steps (ShapeKit.lattice at this grid).
func _lattice(metres: float) -> float:
	return ShapeKit.lattice(metres, grid_step())


## The arch ring's thickness rule, at the current grid (the operator gates read it).
func _arch_thickness(span_radius: float, height: float) -> float:
	return ArchShape.thickness(span_radius, height, grid_step())


## Every pending solid's edges, as line pairs — AND the clearance's.
##
## The cut is drawn because it is the part you cannot infer from the solid. An
## arch previewed as a ring says nothing about how far the bore reaches into the
## hill, which is the one thing you are actually choosing when you place it.
func _edges() -> PackedVector3Array:
	var out := PackedVector3Array()
	for piece: Dictionary in _pieces():
		out.append_array(piece["edges"])
	for piece: Dictionary in _clearance_pieces():
		out.append_array(piece["edges"])
	return out


func confirm() -> bool:
	if not _valid:
		return false
	if _is_param():
		return _confirm_param()
	var pieces: Array = _pieces()
	if pieces.is_empty():
		print("[box] nothing to create")
		return false
	# THE CLEARANCE GOES THROUGH THE SAME LOOP, not a second path beside it. To
	# the store and the journal it is a brush like any other — the flag is the
	# only difference — and a parallel create is how the two would drift on the
	# day one of them learned something the other did not. The count is kept so
	# the flag can be set per piece and so the report can say what was made.
	var clearance: Array = _clearance_pieces()
	var n_solid: int = pieces.size()
	pieces.append_array(clearance)
	var made_clearance := 0

	# ONE REBUILD, ONE REFRESH, at the end. A flight of sixteen steps rebuilding the
	# level sixteen times is sixteen times the mesh work for fifteen states nobody
	# sees, and build_shell is over a second on a real map.
	# ONE GROUP FOR THE WHOLE INVOCATION — hyper-games #28. An arch is eight
	# voussoirs and a clearance volume and they are one THING; until this existed
	# nothing said so, so moving the ring left its clearance behind and the terrain
	# kept a passage where the arch no longer was.
	#
	# The key is the FIRST piece's dot, which costs no new record and no new kind:
	# it rides the brush.create already being written. A group of one is still a
	# group, so a plain box gets one too and needs no special case anywhere.
	var group := ""
	var made_dots: Array = []
	# TEXTURED AT BIRTH (#94 P1 §6): the map's current texture on every plane of a
	# solid piece. Clearance volumes are air — never drawn — so they get none.
	var cur_tex: Dictionary = map.current_face_tex()
	for i in pieces.size():
		var piece: Dictionary = pieces[i]
		var is_clearance: bool = i >= n_solid
		var planes: PackedFloat32Array = piece["planes"]
		var face_tex: Array = []
		if not is_clearance:
			for _p in planes.size() / 4:
				face_tex.append(cur_tex.duplicate())
		# TWO-PHASE, like the fill: the create needs its durable name before it can be
		# applied, but the store may still refuse the plane set — and a record for a
		# brush that does not exist would be replayed by every peer forever.
		var dot: String = map._persist.reserve_dot()
		if group == "":
			group = dot                    # the first piece names the group
		var made: int = level.add_brush(planes, _material, PackedInt32Array(), _level_id,
			false, dot, is_clearance, group, face_tex)
		if made <= 0:
			# PARTIAL IS NOT FAILURE, and it is not silence either. The pieces already
			# in the store are real geometry the journal must name, so they are
			# recorded and the refusal is reported rather than rolled back — an
			# "undo" of brushes some peer may already have replayed is a worse thing
			# to own than a flight one step short.
			print("[box] the store refused a %s %s (%d of %d made)" %
				[_shape_name(), "clearance volume" if is_clearance else "piece",
				made_dots.size(), pieces.size()])
			continue
		var plist: Array = []
		for f: float in planes:
			plist.append(f)
		# RECORDED, so it replicates and undoes with the shape it belongs to. A
		# peer that replayed the stairs without their headroom would build a
		# flight buried in hillside, and would not be wrong about anything it
		# could see locally.
		map._persist.record_at(dot, "brush.create", {
			"planes": plist, "proj": [], "material_id": _material,
			"level_id": _level_id, "sky": false, "clearance": is_clearance,
			"group": group, "face_tex": face_tex,
		})
		made_dots.append(dot)
		if is_clearance:
			made_clearance += 1

	if made_dots.is_empty():
		print("[box] the store refused that %s" % _shape_name())
		return false
	level.rebuild()
	map._refresh_brush_view()
	# The clearance is COUNTED SEPARATELY. "created stairs: 3 brushes" for a
	# two-step flight would read as an off-by-one in the shape rather than as the
	# cut that makes it usable.
	print("[box] created %s: %d brush(es)%s at %s (%s)" %
		[_shape_name(), made_dots.size() - made_clearance,
		(" + %d clearance" % made_clearance) if made_clearance > 0 else "",
		str(_origin), _size_text()])
	ModalControls.summary("Created %s %s" % [_shape_name(), _size_text()])
	return true