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