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)