extends RefCounted
class_name TerraceGeissGraph
## THE TWO TOGETHER (2026-10-06): the Geiss field (UnderseaGeissGraph) as the detail
## octave of the random-height terraces (TerraceGraph), by DRAPING, with CAVES cut into
## the terrace walls by the Geiss lumps turned negative.
##
## surface d = Geiss(x, y - T(x, z), z)
## Geiss is defined around its own ground plane (ground_y, 0 here), so
## evaluating it at the height ABOVE THE TERRACE puts its ground, hard floors
## (~-14 m), shelf and small terraces on every tread. T is continuous, so d is.
## ⚠ On a riser the shift is vertical: Geiss lumps there are sheared up the
## cliff face (the known cost of draping).
## caves d = min(d, (cave_threshold - L(p + far)) + 100 * (1 - mask))
## L = the Geiss LUMPS (pre-warp + 8 octaves; UnderseaGeissGraph.LUMPS) on
## coordinates moved CAVE_OFFSET away, so chambers do not mirror the surface
## lumps; where L > cave_threshold is hollow — big chambers (the coarse
## octaves are 20-40 m features; user 2026-10-06: "big chambers").
## mask = near_riser * below_roof, so ENTRANCES ARE ONLY VIA WALLS (user):
## near_riser — steepness S = max(|T(x±r)|, |T(z±r)|) / 2r at r =
## cave_reach; 0 at S <= 0.15, 1 at S >= 0.4: only rock beside a riser
## below_roof — only y <= T - cave_roof (20 m: deeper than Geiss's deepest
## hollows, ~-15 m), so a chamber can never break a tread's top; it can
## only meet the open air where the surface is a wall
## min() is the solid-minus-solid cut; where mask = 0 the +100 keeps it
## from biting, so d is unchanged there (and continuous everywhere).
##
## ONE graph, every builder writing into it: TerraceGraph x5 (the centre and the four
## steepness taps, sharing ONE set of knob nodes), UnderseaGeissGraph FULL on the shifted
## position (knobs "knob:geiss.<name>"), UnderseaGeissGraph LUMPS for the caves (knobs
## "knob:cave.<name>"), and the composer's own CAVE_KNOBS.
##
## KNOB VALUES come from the two bridged scenes (blender/geiss_terraces.tscn and
## blender/undersea_geiss.tscn); the cave knobs are CAVE_KNOBS defaults for now (no scene
## yet). STRUCTURE does not flow from the scenes: the builders are re-run.
##
## build() ids: "graph", "p", "T", "shifted", "surface" (d before caves), "cave_mask",
## "out".
const GEISS_PREFIX := "geiss."
const CAVE_PREFIX := "cave."
const CAVE_KNOBS := {
"cave_threshold": 5.0, # L above this is hollow; higher = fewer, smaller chambers
"cave_roof": 20.0, # m — no carving above T - cave_roof (entrances via walls only)
"cave_reach": 25.0, # m — how far from a riser the steepness taps look
}
const CAVE_OFFSET := Vector3(5003.7, 0.0, -7001.3)
const M_MIN := 12
static func make(sample_at: Variant = null, tknobs: Dictionary = {}, gknobs: Dictionary = {},
cknobs: Dictionary = {}) -> ProcNodeGraph:
return build(ProcNodeGraph.new(), sample_at, tknobs, gknobs, cknobs)["graph"]
## Knob values from both bridged scenes: [terrace knobs, geiss knobs].
static func scene_knobs() -> Array:
var t := TerraceGraph.load_terraces()
var u := UnderseaGeissGraph.load_graph()
return [UnderseaGeissGraph.knobs_from(t) if t != null else {},
UnderseaGeissGraph.knobs_from(u) if u != null else {}]
## The combined graph with knob values read from both bridged scenes.
static func make_from_scenes() -> ProcNodeGraph:
var kn := scene_knobs()
return make(null, kn[0], kn[1])
## The TOP surface's height at (x, z), on the CPU — for spawns and teleporters, which a
## fixed height puts inside rock on this terrain. Scans down from T + 40 m in 0.5 m steps
## to the first solid sample, then bisects. Caves are below the roof line, so the first
## crossing from above is the walkable top. NAN if no crossing within T ± 40 m.
static func surface_at(x: float, z: float, tknobs: Dictionary = {}, gknobs: Dictionary = {}) -> float:
var ids := build(ProcNodeGraph.new(), Vector3(x, 0.0, z), tknobs, gknobs)
var t := _eval(ids, Vector3(x, 0.0, z), "T")
var hi := t + 40.0
if _eval(ids, Vector3(x, hi, z)) > 0.0:
return NAN
var y := hi
while y > t - 40.0:
var lo := y - 0.5
if _eval(ids, Vector3(x, lo, z)) > 0.0:
var a := lo # solid
var b := y # air
for _i in 24:
var m := (a + b) * 0.5
if _eval(ids, Vector3(x, m, z)) > 0.0:
a = m
else:
b = m
return (a + b) * 0.5
y = lo
return NAN
static func build(graph: ProcNodeGraph, sample_at: Variant = null, tknobs: Dictionary = {},
gknobs: Dictionary = {}, cknobs: Dictionary = {}) -> Dictionary:
var ck := CAVE_KNOBS.duplicate()
ck.merge(cknobs, true)
# ---- the terraces (centre) ----
var tb := TerraceGraph.new()
tb.emit_output = false
var tids := tb.build(graph, sample_at, tknobs)
var p: int = tids["p"]
var sep: int = tids["sep"]
var T: int = tids["T"]
# ---- the surface: Geiss on (x, y - T, z) ----
var ys := _op(graph, UnderseaGeissGraph.M_SUB, sep, 1, T, 0, Vector2(4, 0))
var sh := graph.add_node(CombineXYZNode.new())
graph.connect_sockets(sep, 0, sh, 0)
graph.connect_sockets(ys, 0, sh, 1)
graph.connect_sockets(sep, 2, sh, 2)
graph.get_node(sh).set_graph_position(Vector2(4 * 260, 140))
var gb := UnderseaGeissGraph.new()
gb.p_node = sh
gb.knob_prefix = GEISS_PREFIX
gb.col_offset = 5
gb.emit_output = false # ONE Group Output: the composer's
var gk := gknobs.duplicate()
gk["ground_y"] = 0.0 # Geiss sits ON the terrace
var surface: int = gb.build(graph, null, gk, UnderseaGeissGraph.FULL)["out"]
# ---- composer knobs ----
var kthr := _value(graph, "cave_threshold", float(ck["cave_threshold"]), 0)
var kroof := _value(graph, "cave_roof", float(ck["cave_roof"]), 1)
var kreach := _value(graph, "cave_reach", float(ck["cave_reach"]), 2)
# ---- near a riser: steepness of T over ±cave_reach ----
var taps: Array[int] = []
for d in [Vector3(1, 0, 0), Vector3(-1, 0, 0), Vector3(0, 0, 1), Vector3(0, 0, -1)]:
var off := graph.add_node(VectorMathNode.new()) # d * reach
(graph.get_node(off) as VectorMathNode).set_operation(UnderseaGeissGraph.V_SCALE)
graph.get_node(off).set_input_default(0, d)
graph.connect_sockets(kreach, 0, off, 3)
var pos := graph.add_node(VectorMathNode.new())
(graph.get_node(pos) as VectorMathNode).set_operation(UnderseaGeissGraph.V_ADD)
graph.connect_sockets(p, 0, pos, 0)
graph.connect_sockets(off, 0, pos, 1)
var tx := TerraceGraph.new()
tx._knobs = tb._knobs # ONE set of terrace knob nodes
tx._knob_row = tb._knob_row
tx.keep_knobs = true
tx.emit_output = false
tx.p_node = pos
tx.col_offset = 20 + taps.size() * 4
taps.append(tx.build(graph, null, tknobs)["T"])
tb._knob_row = tx._knob_row
var sx := _op(graph, UnderseaGeissGraph.M_ABS, _op(graph, UnderseaGeissGraph.M_SUB, taps[0], 0, taps[1], 0, Vector2(36, 0)), 0, -1, 0, Vector2(36, 1))
var sz := _op(graph, UnderseaGeissGraph.M_ABS, _op(graph, UnderseaGeissGraph.M_SUB, taps[2], 0, taps[3], 0, Vector2(36, 2)), 0, -1, 0, Vector2(36, 3))
var smax := _op(graph, 13, sx, 0, sz, 0, Vector2(36, 4)) # MAXIMUM
var two_r := _op(graph, UnderseaGeissGraph.M_MUL, kreach, 0, -1, 0, Vector2(36, 5), 2.0)
var steep := _op(graph, TerraceGraph.M_DIV, smax, 0, two_r, 0, Vector2(36, 6))
var near := _ramp(graph, steep, 0.15, 0.4, Vector2(37, 0))
# ---- below the roof line: y <= T - cave_roof ----
var roof_line := _op(graph, UnderseaGeissGraph.M_SUB, T, 0, kroof, 0, Vector2(37, 1))
var under := _op(graph, UnderseaGeissGraph.M_SUB, roof_line, 0, sep, 1, Vector2(37, 2))
var below := _ramp(graph, under, 0.0, 2.0, Vector2(37, 3))
var mask := _op(graph, UnderseaGeissGraph.M_MUL, near, 0, below, 0, Vector2(37, 4))
# ---- the cave volume: Geiss LUMPS, far away ----
var far := graph.add_node(VectorMathNode.new())
(graph.get_node(far) as VectorMathNode).set_operation(UnderseaGeissGraph.V_ADD)
graph.connect_sockets(p, 0, far, 0)
graph.get_node(far).set_input_default(1, CAVE_OFFSET)
graph.get_node(far).set_graph_position(Vector2(38 * 260, 0))
var cb := UnderseaGeissGraph.new()
cb.p_node = far
cb.knob_prefix = CAVE_PREFIX
cb.col_offset = 39
cb.emit_output = false
var lumps: int = cb.build(graph, null, {"ground_y": 0.0}, UnderseaGeissGraph.LUMPS)["out"]
# ---- cut: min(d, (threshold - L) + 100 (1 - mask)) ----
var hollow := _op(graph, UnderseaGeissGraph.M_SUB, kthr, 0, lumps, 0, Vector2(48, 0))
var guard := _op(graph, UnderseaGeissGraph.M_MULADD, mask, 0, -1, 0, Vector2(48, 1), -100.0, 100.0)
var cut := _op(graph, UnderseaGeissGraph.M_ADD, hollow, 0, guard, 0, Vector2(48, 2))
var out := _op(graph, M_MIN, surface, 0, cut, 0, Vector2(48, 3))
var go := graph.add_node(GroupOutputNode.new())
(graph.get_node(go) as GroupOutputNode).set_interface([{"name": "Density", "type": 0}])
graph.connect_sockets(out, 0, go, 0)
graph.get_node(go).set_graph_position(Vector2(49 * 260, 0))
return {"graph": graph, "p": p, "T": T, "shifted": sh, "surface": surface,
"cave_mask": mask, "out": out}
## Evaluate probe `what` of a build() made with a constant Vector, at `p`.
static func _eval(ids: Dictionary, p: Vector3, what: String = "out") -> float:
var g: ProcNodeGraph = ids["graph"]
g.get_node(ids["p"]).set("value", p)
return float(g.evaluate_socket(ids[what], 0))
## A Math node: a <op> b (b = -1: socket 1 = c1), c2 on socket 2 when given.
static func _op(graph: ProcNodeGraph, op: int, a: int, sa: int, b: int, sb: int,
cell: Vector2, c1: float = 0.0, c2: float = NAN) -> int:
var n := graph.add_node(MathNode.new())
(graph.get_node(n) as MathNode).set_operation(op)
graph.connect_sockets(a, sa, n, 0)
if b >= 0:
graph.connect_sockets(b, sb, n, 1)
else:
graph.get_node(n).set_input_default(1, c1)
if not is_nan(c2):
graph.get_node(n).set_input_default(2, c2)
graph.get_node(n).set_graph_position(Vector2(cell.x * 260, cell.y * 140))
return n
## clamp((x - a) / (b - a), 0, 1) as a clamped Map Range.
static func _ramp(graph: ProcNodeGraph, x: int, a: float, b: float, cell: Vector2) -> int:
var n := graph.add_node(MapRangeNode.new())
var m := graph.get_node(n) as MapRangeNode
m.set_clamp(true)
graph.connect_sockets(x, 0, n, 0)
m.set_input_default(1, a)
m.set_input_default(2, b)
m.set_input_default(3, 0.0)
m.set_input_default(4, 1.0)
n_pos(graph, n, cell)
return n
static func n_pos(graph: ProcNodeGraph, n: int, cell: Vector2) -> void:
graph.get_node(n).set_graph_position(Vector2(cell.x * 260, cell.y * 140))
## A composer knob: a Value node labelled "knob:<name>".
static func _value(graph: ProcNodeGraph, name: String, v: float, row: int) -> int:
var n := graph.add_node(ValueNode.new())
graph.get_node(n).set("value", v)
graph.get_node(n).set("label", UnderseaGeissGraph.KNOB_PREFIX + name)
graph.get_node(n).set_graph_position(Vector2(35 * 260, row * 90))
return n