OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / shapes / arch_shape.gd

181 lines · gd ref:
extends BrushShape
class_name ArchShape
## An arch: a ring of wedge-shaped brushes over the upper half of an ellipse whose
## diameter lies on the springing line, tunnelled along the axis you are looking down.
##
## TrenchBroom builds the whole ellipse in a bounds doubled downwards, keeps the
## contiguous upper run, and then has to CUT the two ends against the springing line
## (crossingAtV) because its circle modes put vertices wherever they put them. We
## choose the angles, so t=0 and t=PI ARE the springing line exactly and there is
## nothing to clip — the same solid, without a step that can only be got wrong.

## How many voussoirs the half-arch is cut into by default. Eight is TrenchBroom's default
## circle precision for this tool, and it is the number where the ring reads as a curve
## rather than as a polygon you can count.
const DEFAULT_SEGMENTS := 8

## Clear of each mouth, so the entrance is not flush with the hillside and the
## far end breaks the surface rather than stopping in it.
##
## SYMMETRIC AND SMALL, and THE ARCH'S OWN LENGTH IS WHAT MAKES A TUNNEL.
##
## This briefly measured the hill instead — marching DCTerrain::get_distance
## along the axis and boring to wherever the ground ended. That fixed the symptom
## (a 5 m alcove in a 40 m hill) and got the design backwards: it produced a long
## bare bore with a 3 m ring at its mouth, because the ring was not what set the
## length. The arch IS the tunnel lining. Want a longer tunnel, build a longer
## arch — which is what group scale is for — and the cut follows it by
## construction, one metre clear at each end.
const AXIAL_MARGIN_M := 1.0


func id() -> String:
	return "arch"


func params() -> Array:
	return [{"id": "segments", "label": "Segments", "type": "int", "default": DEFAULT_SEGMENTS,
		"min": 2, "max": 32, "step": 1, "tip": "Voussoirs in the half ring. More = rounder."}]


func segments(ctx: Dictionary) -> int:
	return clampi(int(param(ctx, "segments")), 2, 32)


## How thick the ring is. ONE GRID STEP WHERE THERE IS ROOM FOR ONE, and half the
## smaller radius where there is not.
##
## A fixed grid step was wrong, and silently: the bounds are `size` steps on
## a side, so the span radius is exactly `size / 2` of them — at the DEFAULT
## size of 2 that is one step, the ring was as thick as its own radius, and the
## arch fell through to the box fallback. It still said "created arch", and what it
## created was a cube. Anything that changes what a shape IS has to be visible, so
## the thickness follows the bounds instead and describe() prints it.
##
## Returns 0.0 when even half a radius is below a usable thickness, which is the
## genuinely degenerate case the caller falls back on.
static func thickness(span_radius: float, height: float, grid_step: float) -> float:
	var room: float = minf(span_radius, height) * 0.5
	if room <= 1.0e-4:
		return 0.0
	return minf(grid_step, room)


func _frame(lo: Vector3, hi: Vector3, ctx: Dictionary) -> Dictionary:
	var vert: int = int(ctx["axis"])
	var tun: int = int(ctx["run_axis"])
	var span: int = 3 - vert - tun
	var normal: Vector3 = ctx["normal"]
	var rs: float = (hi[span] - lo[span]) * 0.5
	var height: float = hi[vert] - lo[vert]
	var up: float = 1.0 if normal[vert] >= 0.0 else -1.0
	return {"vert": vert, "tun": tun, "span": span, "rs": rs,
		"cs": (hi[span] + lo[span]) * 0.5, "height": height, "up": up,
		"base": lo[vert] if up > 0.0 else hi[vert],
		"th": thickness(rs, height, float(ctx["grid_step"]))}


func pieces(lo: Vector3, hi: Vector3, ctx: Dictionary) -> Array:
	var f: Dictionary = _frame(lo, hi, ctx)
	var th: float = f["th"]
	if th <= 0.0:
		# Only when the bounds themselves have no room for a ring — not because a
		# constant happened to be too big for them. One solid, rather than eight
		# voussoirs of no volume, which is how a store gets asked to accept
		# degenerate brushes.
		return [ShapeKit.box_piece(lo, hi)]
	var span: int = f["span"]
	var vert: int = f["vert"]
	var tun: int = f["tun"]
	var rs: float = f["rs"]
	var cs: float = f["cs"]
	var height: float = f["height"]
	var up: float = f["up"]
	var base: float = f["base"]
	var w0: float = lo[tun]
	var w1: float = hi[tun]
	var nseg: int = segments(ctx)

	var outer: Array = []
	var inner: Array = []
	for j in nseg + 1:
		var t: float = PI * float(j) / float(nseg)
		outer.append(Vector2(cs + rs * cos(t), base + up * height * sin(t)))
		inner.append(Vector2(cs + (rs - th) * cos(t), base + up * (height - th) * sin(t)))

	var out: Array = []
	for j in nseg:
		var v: Array = []
		for pt: Array in [[outer[j], w0], [outer[j], w1], [outer[j + 1], w0], [outer[j + 1], w1],
				[inner[j], w0], [inner[j], w1], [inner[j + 1], w0], [inner[j + 1], w1]]:
			var p2: Vector2 = pt[0]
			var q := Vector3.ZERO
			q[span] = p2.x
			q[vert] = p2.y
			q[tun] = pt[1]
			v.append(q)
		var piece: Dictionary = ShapeKit.hex_piece(v)
		if not piece.is_empty():
			out.append(piece)
	return out


## THE ARCH CUT IS THE RING'S OWN OUTER ENVELOPE, extended along the tunnel axis
## and NOT INFLATED ANYWHERE ELSE — so the terrain meets the outside of the ring
## instead of standing a margin off it.
##
## Exact, not approximate, and that is worth stating because it looks like it
## should not be. The ring's outer surface is not the ellipse: pieces() builds
## each voussoir from outer[j] and outer[j+1], so the skin is the CHORD polyline.
## Building this volume from the same `outer` points (same segment count) gives planes
## identical to the voussoirs' own outer faces, and the two coincide with nothing
## between them. Inflating radially "to be safe" would open exactly the gap it sounds
## like it closes.
##
## What is left is the residual the shell itself carries — a half-cell fringe,
## 0.125 m at the default 0.25 m cell (dc_level_shell.h), which is under the
## terrain's own 0.224 m voxel. Sub-voxel, so it reads as flush.
##
## The AXIAL margin is the one that is real: without it the bore stops at the
## bounds and the passage ends inside the hill.
func clearance(lo: Vector3, hi: Vector3, ctx: Dictionary) -> Array:
	var f: Dictionary = _frame(lo, hi, ctx)
	# The SAME degenerate test pieces() uses. When it falls back to a solid
	# box there is no ring and no bore, so there is nothing to cut either — and
	# answering otherwise would carve a tunnel through a solid cube.
	if float(f["th"]) <= 0.0:
		return []
	var tun: int = f["tun"]
	var m: float = ShapeKit.lattice(AXIAL_MARGIN_M, float(ctx["grid_step"]))
	var nseg: int = segments(ctx)
	# outer[0] and outer[nseg] sit ON the springing line, so the profile
	# closes across it and the closing edge IS the base plane. No separate floor
	# plane, and nothing to clip — the same reason pieces() needs no
	# crossingAtV step.
	var profile: Array[Vector2] = []
	for j in nseg + 1:
		var t: float = PI * float(j) / float(nseg)
		profile.append(Vector2(float(f["cs"]) + float(f["rs"]) * cos(t),
			float(f["base"]) + float(f["up"]) * float(f["height"]) * sin(t)))
	var p: Dictionary = ShapeKit.prism_piece(profile, f["span"], f["vert"], tun, lo[tun] - m, hi[tun] + m)
	return [] if p.is_empty() else [p]


func describe(lo: Vector3, hi: Vector3, ctx: Dictionary, size_text: String) -> String:
	# The REAL thickness, in the same units as everything else on this line.
	# A ring that quietly became a solid used to print the same words as one
	# that did not.
	var f: Dictionary = _frame(lo, hi, ctx)
	var th: float = f["th"]
	var st: float = maxf(float(ctx["grid_step"]), 1.0e-6)
	var tu: float = th / st * float(ctx["grid_units"])
	var fmt: Callable = ctx["fmt"]
	return ("arch, %s thick in %s" % [String(fmt.call(tu)), size_text]) if th > 0.0 \
		else ("solid — no room for an arch in %s" % size_text)


func clearance_name(ctx: Dictionary) -> String:
	var st: float = maxf(float(ctx["grid_step"]), 1.0e-6)
	var u: float = ShapeKit.lattice(AXIAL_MARGIN_M, st) / st * float(ctx["grid_units"])
	return "through, %s units past each mouth" % String.num(u, 0)