extends VBoxContainer
## THE STACK PANEL (docs/cutting-and-operations.md §9b, docs/node-tree-and-stacks.md §3.4):
## the selected thing's stack, in the FIXED stage order (generator → copies → cuts → chamfer).
##
## Generator: [Stairs ▾] row 0 (a plain group: [Make parametric])
## [✓] ⇋ Mirror [x ▾] ✕ copy modifiers, in stack order (#120 F2)
## [✓] − Subtract ▾ ◇ cutter ✕ one row per op, in stack order
## [✓] ◢ Chamfer [0.10] ✕ chamfer modifiers, after the cuts
## [Add modifier ▾] mirror / array / radial / chamfer
## cuts ▸ <target> when the selection IS a cutter
##
## Every control is one journal gesture through the map's API (set_op / remove_op), so
## one Ctrl+Z undoes it. No reorder: subtract / intersect / slice commute as sets (§9b).
## Make Real stays on the Shape panel — it bakes the whole stack.
const KINDS := ["subtract", "intersect", "slice", "inset", "outset"]
const KIND_GLYPH := {"subtract": "−", "intersect": "∩", "slice": "⫽", "inset": "⊔", "outset": "⊓"}
const ParamShapes := preload("res://scenes/maps/shapes/param/param_shapes.gd")
const MOD_GLYPH := {"mirror": "⇋", "array": "⋯", "grid": "▦", "radial": "✻", "curve": "∿", "chamfer": "◢", "dice": "⊞"}
const MOD_KINDS := ["mirror", "array", "grid", "radial", "curve", "chamfer", "dice"]
const AXES := ["x", "y", "z"]
var map: Node = null
var _subject := ""
var _ver := -1
var _title: Label = null
var _rows: VBoxContainer = null
var _hint: Label = null
var _ops: Array = [] # what the rows show: [{dot, kind, on, cutter}]
var _cuts: Array = [] # [{dot, kind, on, target}] when the subject is a cutter
var _mods: Array = [] # ModFold states of the subject's modifiers, in stack order
var _is_shape := false
var _gen: HBoxContainer = null
var _add: OptionButton = null
func setup(map_node: Node) -> void:
map = map_node
_title = Label.new()
add_child(_title)
_hint = Label.new()
_hint.text = "No live cuts. Select a cutter shape, then the target, and Subtract."
_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_hint.custom_minimum_size = Vector2(220, 0)
_hint.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
add_child(_hint)
_gen = HBoxContainer.new()
add_child(_gen)
_rows = VBoxContainer.new()
add_child(_rows)
_add = OptionButton.new()
_add.focus_mode = Control.FOCUS_NONE
_add.tooltip_text = "Add a modifier to this shape (one Ctrl+Z takes it back)"
add_child(_add)
_add.item_selected.connect(func(i: int) -> void:
if i > 0:
press("", "add_mod", MOD_KINDS[i - 1])
_add.select(0))
refresh()
func _process(_d: float) -> void:
if map == null:
return
var s: String = map.stack_subject()
var p: Node = map.get("_persist")
var v: int = int(p.get("_ix_ver")) if p != null else -1
if s != _subject or v != _ver:
refresh()
# ---- the gate's API (and the panel's model) ----
func subject() -> String:
return _subject
func op_rows() -> Array:
return _ops
func cut_rows() -> Array:
return _cuts
func mod_rows() -> Array:
return _mods
## Act on one op's row: what = "eye" (toggle), "kind" (value = a KINDS name), "remove",
## "cutter" (select it).
func press(dot: String, what: String, value: Variant = null) -> void:
# THE STACK'S OWN ROWS (#120 F2): generator and modifiers.
match what:
"generator":
map.assign_generator(_subject, String(value))
refresh()
return
"make_parametric":
map.make_parametric()
refresh()
return
"add_mod":
map.add_modifier(String(value), _subject)
refresh()
return
"mod_eye":
for m: Dictionary in _mods:
if String(m["dot"]) == dot:
map.set_modifier(dot, {}, not bool(m["on"]))
refresh()
return
"mod_param":
map.set_modifier(dot, value as Dictionary)
refresh()
return
"mod_remove":
map.remove_modifier(dot)
refresh()
return
var row: Dictionary = {}
for r: Dictionary in _ops + _cuts:
if String(r["dot"]) == dot:
row = r
if row.is_empty():
return
match what:
"eye":
map.set_op(dot, {"on": not bool(row["on"])})
"kind":
if KINDS.has(String(value)) and String(value) != String(row["kind"]):
map.set_op(dot, {"op": String(value)})
"depth":
map.set_op(dot, {"depth": float(value)})
"remove":
map.remove_op(dot)
"cutter":
if row.has("cutter"):
map.select_shape(String(row["cutter"]))
"target":
if row.has("target"):
_select_thing(String(row["target"]))
refresh()
# ---- drawing ----
func refresh() -> void:
if map == null:
return
_subject = map.stack_subject()
var p: Node = map.get("_persist")
_ver = int(p.get("_ix_ver")) if p != null else -1
_ops = map.op_stack(_subject) if _subject != "" else []
_cuts = map.cuts_by(_subject) if _subject != "" else []
_is_shape = _subject != "" and (map._persist.live_shapes() as Dictionary).has(_subject)
_mods = map.modifier_stack(_subject) if _is_shape else []
for c in _rows.get_children():
_rows.remove_child(c)
c.queue_free()
_title.text = ("%s %s" % [_generator_label(), _subject]) if _subject != "" else ""
_hint.visible = _ops.is_empty() and _cuts.is_empty() and _mods.is_empty()
_fill_generator()
_fill_add()
# THE FIXED STAGE ORDER: copies, cuts, chamfers.
for m: Dictionary in _mods:
if String(m["kind"]) != "chamfer":
_rows.add_child(_mod_row(m))
for r: Dictionary in _ops:
_rows.add_child(_op_row(r))
for m2: Dictionary in _mods:
if String(m2["kind"]) == "chamfer":
_rows.add_child(_mod_row(m2))
for c2: Dictionary in _cuts:
_rows.add_child(_cut_row(c2))
## Row 0: the generator. A shape: its type, switchable (map.assign_generator; the old one
## stays its passage), with "Remove generator" when it has an input. A plain group: the
## button that makes it a shape (map.make_parametric) — modifiers need one.
func _fill_generator() -> void:
for c in _gen.get_children():
_gen.remove_child(c)
c.queue_free()
# A LOOSE brush has no group, so no stack subject — but it can still be made a shape.
var loose: bool = _subject == "" and not (map.get("_sel_brushes") as Array).is_empty()
_gen.visible = _subject != "" or loose
if not _gen.visible:
return
var l := Label.new()
l.text = "Generator"
_gen.add_child(l)
if not _is_shape:
var mp := Button.new()
mp.focus_mode = Control.FOCUS_NONE
mp.text = "Make parametric"
mp.tooltip_text = "Turn the selected brush into a shape (box or hull) so it can take a generator and modifiers"
mp.pressed.connect(func() -> void: press("", "make_parametric"))
_gen.add_child(mp)
return
var st: Dictionary = map._persist.shape_state(_subject)
var cur := String(st.get("type", ""))
var inp := String((st.get("input", {}) as Dictionary).get("type", ""))
var ob := OptionButton.new()
ob.focus_mode = Control.FOCUS_NONE
ob.tooltip_text = "Assign a generator: the current solid stays as its passage"
var ids: Array = []
var cur_t: Object = ParamShapes.get_type(cur)
ob.add_item(cur_t.label() if cur_t != null else cur)
ids.append(cur)
if inp != "":
var ti: Object = ParamShapes.get_type(inp)
ob.add_item("Remove generator (back to %s)" % (ti.label() if ti != null else inp))
ids.append(inp)
for tid: String in ParamShapes.ids():
if tid != cur and tid != inp:
ob.add_item((ParamShapes.get_type(tid) as Object).label())
ids.append(tid)
ob.item_selected.connect(func(i: int) -> void:
if i > 0:
press("", "generator", ids[i]))
_gen.add_child(ob)
func _fill_add() -> void:
_add.clear()
_add.add_item("Add modifier…")
for k: String in MOD_KINDS:
_add.add_item("%s %s" % [MOD_GLYPH[k], k.capitalize()])
_add.select(0)
_add.visible = _is_shape
## One modifier: eye, its name, its main knob, ✕.
func _mod_row(m: Dictionary) -> Control:
var dot := String(m["dot"])
var kind := String(m["kind"])
var p: Dictionary = m["params"]
var h := HBoxContainer.new()
var eye := CheckBox.new()
eye.button_pressed = bool(m["on"])
eye.focus_mode = Control.FOCUS_NONE
eye.tooltip_text = "On / off"
eye.toggled.connect(func(_on: bool) -> void: press(dot, "mod_eye"))
h.add_child(eye)
var l := Label.new()
l.text = "%s %s" % [MOD_GLYPH.get(kind, "?"), kind.capitalize()]
h.add_child(l)
match kind:
"mirror":
var ax := OptionButton.new()
ax.focus_mode = Control.FOCUS_NONE
for a: String in AXES:
ax.add_item(a.to_upper())
ax.select(maxi(0, AXES.find(String(p.get("axis", "x")))))
ax.item_selected.connect(func(i: int) -> void: press(dot, "mod_param", {"axis": AXES[i]}))
h.add_child(ax)
"array", "radial":
h.add_child(_spin(int(p.get("count", 2)), 2, 64, 1, func(v: float) -> void:
press(dot, "mod_param", {"count": int(v)})))
"grid", "dice":
# One count per frame axis (X, Y, Z): the whole vector is written each time.
var cn: Array = (p.get("count", [2, 1, 2]) as Array).duplicate()
for ai in 3:
var set_axis := func(v: float) -> void:
cn[ai] = int(v)
press(dot, "mod_param", {"count": cn.duplicate()})
var sp := _spin(int(cn[ai]), 1, 32, 1, set_axis)
sp.tooltip_text = "Copies along %s" % ["X", "Y", "Z"][ai]
h.add_child(sp)
"curve":
# Which curve (by name), and how many along it (0 = fit by the shape's size).
var cb := OptionButton.new()
cb.focus_mode = Control.FOCUS_NONE
cb.tooltip_text = "The curve it follows"
var curves: Dictionary = map._persist.curve_states() if map.get("_persist") != null else {}
var cdots: Array = curves.keys()
cdots.sort()
for cd: String in cdots:
cb.add_item(String(curves[cd]["name"]))
cb.select(cdots.find(String(p.get("curve", ""))))
cb.item_selected.connect(func(i: int) -> void: press(dot, "mod_param", {"curve": cdots[i]}))
h.add_child(cb)
var sp := _spin(int(p.get("count", 0)), 0, 256, 1, func(v: float) -> void:
press(dot, "mod_param", {"count": int(v)}))
sp.tooltip_text = "How many along the curve (0 = fit)"
h.add_child(sp)
"chamfer":
h.add_child(_spin(float(p.get("width", 0.1)), 0.01, 4.0, 0.01, func(v: float) -> void:
press(dot, "mod_param", {"width": v})))
h.add_child(_spin(int(p.get("segments", 1)), 1, 8, 1, func(v: float) -> void:
press(dot, "mod_param", {"segments": int(v)})))
var x := Button.new()
x.focus_mode = Control.FOCUS_NONE
x.text = "✕"
x.tooltip_text = "Remove this modifier (Ctrl+Z undoes)"
x.pressed.connect(func() -> void: press(dot, "mod_remove"))
h.add_child(x)
return h
func _spin(v: float, lo: float, hi: float, step: float, cb: Callable) -> SpinBox:
var sb := SpinBox.new()
sb.min_value = lo
sb.max_value = hi
sb.step = step
sb.value = v
sb.focus_mode = Control.FOCUS_NONE
sb.get_line_edit().focus_mode = Control.FOCUS_CLICK
sb.value_changed.connect(cb)
return sb
func _generator_label() -> String:
var st: Dictionary = map._persist.shape_state(_subject) if map.get("_persist") != null else {}
if st.is_empty():
return "▣ group"
return "◇ %s" % String(st.get("type", "shape")).capitalize()
func _op_row(r: Dictionary) -> Control:
var dot := String(r["dot"])
var h := HBoxContainer.new()
var eye := CheckBox.new()
eye.button_pressed = bool(r["on"])
eye.focus_mode = Control.FOCUS_NONE # the dock rule: a focusable eats Tab
eye.tooltip_text = "On / off (off keeps the cutter, cuts nothing)"
eye.toggled.connect(func(_on: bool) -> void: press(dot, "eye"))
h.add_child(eye)
var kind := OptionButton.new()
kind.focus_mode = Control.FOCUS_NONE
for k: String in KINDS:
kind.add_item("%s %s" % [KIND_GLYPH[k], k.capitalize()])
kind.select(maxi(0, KINDS.find(String(r["kind"]))))
kind.item_selected.connect(func(i: int) -> void: press(dot, "kind", KINDS[i]))
h.add_child(kind)
# Inset / outset: how deep the pocket, how proud the boss (#120 L1 §12.3).
if String(r["kind"]) == "inset" or String(r["kind"]) == "outset":
var dp := _spin(float(r.get("depth", 0.25)), 0.01, 8.0, 0.05, func(v: float) -> void: press(dot, "depth", v))
dp.tooltip_text = "Depth (m)"
h.add_child(dp)
var cut := Button.new()
cut.focus_mode = Control.FOCUS_NONE
cut.text = "◇ cutter"
cut.tooltip_text = "Select the cutter %s (move it to move the hole)" % String(r["cutter"])
cut.pressed.connect(func() -> void: press(dot, "cutter"))
h.add_child(cut)
var x := Button.new()
x.focus_mode = Control.FOCUS_NONE
x.text = "✕"
x.tooltip_text = "Remove this cut (Ctrl+Z undoes)"
x.pressed.connect(func() -> void: press(dot, "remove"))
h.add_child(x)
return h
func _cut_row(r: Dictionary) -> Control:
var dot := String(r["dot"])
var h := HBoxContainer.new()
var l := Label.new()
l.text = "%s cuts ▸" % KIND_GLYPH.get(String(r["kind"]), "−")
if not bool(r["on"]):
l.text += " (off)"
h.add_child(l)
var t := Button.new()
t.focus_mode = Control.FOCUS_NONE
t.text = String(r["target"])
t.tooltip_text = "Select what this cutter cuts"
t.pressed.connect(func() -> void: press(dot, "target"))
h.add_child(t)
return h
func _select_thing(g: String) -> void:
if (map._persist.live_shapes() as Dictionary).has(g):
map.select_shape(g)
else:
map.select_brushes(map.level.get_group_brush_ids(g))