// MetroGeo — polygon-footprint geometry for the METRO scene. Swift twin of
// engine/geo-extrude.ts plus the footprint generator from scenes/metro.ts.
//
// Pure: flat [Float] in, flat [Float] out, no Metal and no scene state — which is why
// it is a separate file from MetroScene and why the logic tests can reach it.
//
// The native renderer BATCHES (see CityScene): a chunk's building edges all land in one
// buffer instead of one mesh per building. So where the web appends THREE objects to a
// Group, these helpers append raw triangle/line vertices to an `inout [Float]`.
//
// Deliberately not a triangulated extrusion: the look is wireframe lines, so a prism is
// bottom ring + top ring + verticals — the same idiom as the city street grid.
import Foundation
import simd
enum MetroGeo {
/// Decode an osm-district.json ring (delta-encoded decimetres) into a flat metre
/// loop [x0,z0,x1,z1,…]. The first pair is absolute, every later pair is a delta.
static func decodeRing(_ r: [Int], scale: Float) -> [Float] {
guard r.count >= 2 else { return [] }
var out: [Float] = [Float(r[0]) * scale, Float(r[1]) * scale]
out.reserveCapacity(r.count)
var x = r[0], z = r[1]
var i = 2
while i + 1 < r.count {
x += r[i]; z += r[i + 1]
out.append(Float(x) * scale); out.append(Float(z) * scale)
i += 2
}
return out
}
static func centroid(_ ring: [Float]) -> (Float, Float) {
let n = ring.count / 2
guard n > 0 else { return (0, 0) }
var cx: Float = 0, cz: Float = 0
for i in 0..<n { cx += ring[i * 2]; cz += ring[i * 2 + 1] }
return (cx / Float(n), cz / Float(n))
}
/// Shrink a ring toward its centroid — the setback tiers of a stepped tower.
static func shrink(_ ring: [Float], _ f: Float) -> [Float] {
let (cx, cz) = centroid(ring)
var out = [Float](); out.reserveCapacity(ring.count)
var i = 0
while i + 1 < ring.count {
out.append(cx + (ring[i] - cx) * f)
out.append(cz + (ring[i + 1] - cz) * f)
i += 2
}
return out
}
/// Append the wireframe prism of `ring` extruded from `baseY` by `height`:
/// per side one bottom edge, one top edge and one vertical — 3 segments, 18 floats.
/// A ring with fewer than 3 vertices or a non-positive height emits nothing (the web
/// returns early for the same case; here it must not append a half prism either).
static func appendPrismEdges(_ buf: inout [Float], ring: [Float], baseY: Float, height: Float) {
let n = ring.count / 2
guard n >= 3, height > 0 else { return }
let topY = baseY + height
buf.reserveCapacity(buf.count + n * 18)
for i in 0..<n {
let j = (i + 1) % n
let xi = ring[i * 2], zi = ring[i * 2 + 1]
let xj = ring[j * 2], zj = ring[j * 2 + 1]
buf.append(contentsOf: [xi, baseY, zi, xj, baseY, zj]) // bottom edge
buf.append(contentsOf: [xi, topY, zi, xj, topY, zj]) // top edge
buf.append(contentsOf: [xi, baseY, zi, xi, topY, zi]) // vertical
}
}
/// A star-shaped polygon: vertices at monotonically increasing angles around
/// (ox, oz), so the ring is always simple and extrudes cleanly. The jitter stays
/// below half a step, which is what preserves the order; `aspect`/`stretchZ` are
/// positive anisotropic scalings and those preserve angular order too.
static func starFootprint(_ rng: inout LCG, ox: Float, oz: Float,
baseR: Float, aspect: Float, stretchZ: Float) -> [Float] {
let nv = 4 + Int(rng.next() * 5) // 4..8 vertices
let off = rng.nextF() * 2 * .pi
let step = (2 * Float.pi) / Float(nv)
var ring = [Float](); ring.reserveCapacity(nv * 2)
for i in 0..<nv {
let ang = off + Float(i) * step + (rng.nextF() - 0.5) * step * 0.6
let rr = baseR * (0.7 + 0.5 * rng.nextF())
ring.append(ox + cos(ang) * rr * aspect)
ring.append(oz + sin(ang) * rr * stretchZ)
}
return ring
}
/// One lit facade window: world position plus the yaw that turns the quad to face
/// outward. The caller batches these into blink buckets.
struct WindowSlot {
let x: Float, y: Float, z: Float, yaw: Float
}
/// Facade windows for a prism, mirroring geo-extrude.ts: one grid per facet, a
/// 35 % fill roll per cell, the quad pushed 5 cm out along the facet's outward
/// normal. Draws only the fill roll: the web also rolls per facet for enemy tint and
/// per window for blinking (geo-extrude.ts), so the two streams are not in step —
/// same distribution, not the same sequence, which parity does not ask for.
static func facadeWindows(ring: [Float], baseY: Float, height: Float,
_ rng: inout LCG) -> [WindowSlot] {
let n = ring.count / 2
let floors = Int(height / 4.2)
guard n >= 3, floors > 0 else { return [] }
let (cx, cz) = centroid(ring)
var out: [WindowSlot] = []
for i in 0..<n {
let j = (i + 1) % n
let ax = ring[i * 2], az = ring[i * 2 + 1]
let bx = ring[j * 2], bz = ring[j * 2 + 1]
let ex = bx - ax, ez = bz - az
let len = (ex * ex + ez * ez).squareRoot()
let cols = Int(len / 3.2)
if cols < 1 { continue }
// outward normal: perpendicular to the edge, flipped away from the centroid
var nx = ez, nz = -ex
let nl = (nx * nx + nz * nz).squareRoot()
if nl > 0 { nx /= nl; nz /= nl }
let mx = (ax + bx) / 2, mz = (az + bz) / 2
if (mx - cx) * nx + (mz - cz) * nz < 0 { nx = -nx; nz = -nz }
let yaw = atan2(nx, nz)
for f in 0..<floors {
for c in 0..<cols {
if rng.nextF() >= 0.35 { continue }
let t = (Float(c) + 0.5) / Float(cols)
out.append(WindowSlot(x: ax + ex * t + nx * 0.05,
y: baseY + 2 + Float(f) * 4.2,
z: az + ez * t + nz * 0.05,
yaw: yaw))
}
}
}
return out
}
/// A window quad (two triangles) rotated by `yaw` around Y, appended flat.
/// The web builds a thin BoxGeometry; a quad is the batched equivalent and matches
/// what CityScene already emits for its own windows.
static func appendWindowQuad(_ buf: inout [Float], _ w: WindowSlot) {
let hw: Float = 0.23, hh: Float = 0.28
let s = sin(w.yaw), c = cos(w.yaw)
let corners: [(Float, Float)] = [(-hw, -hh), (hw, -hh), (hw, hh),
(-hw, -hh), (hw, hh), (-hw, hh)]
for (dx, dy) in corners {
buf.append(w.x + dx * c)
buf.append(w.y + dy)
buf.append(w.z - dx * s)
}
}
}