OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / undersea / terrace_geiss_graph.gd

236 lines · gd ref:
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