The Gradient interface left native solid density in the air-side gap and snapped the face to voxel levels (concentric ring steps on the FI floor). The owner Gradient path now writes signed distance both sides; match error RMS 0.36 -> 0.11 m. Band and fade-tile culling cut transition cost: whole view vs L24 SW +2.7%, FI +0.2%. Evaluator 16. Deterministic across compilers and workers; interface and passage audits pass. Found: on the real FloatingIslands recipe, the rock-column connected-ends audit fails (all styles); earlier matrices ran SurfaceWorld twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSFYgsdW8ejZSyiMf4qRSX
240 lines
10 KiB
Python
240 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Rasterize upward mesh triangles into a top-down field height map."""
|
|
import argparse
|
|
import array
|
|
import json
|
|
import math
|
|
import struct
|
|
import sys
|
|
|
|
Q16 = 1 << 16
|
|
MASK64 = (1 << 64) - 1
|
|
|
|
|
|
def trunc_div(numerator, denominator):
|
|
return numerator // denominator if numerator >= 0 else -((-numerator) // denominator)
|
|
|
|
|
|
def transition_hash(seed, interface, x, y, z, salt):
|
|
value = 14695981039346656037
|
|
for word in (seed & 0xffffffff, interface & 0xffffffff,
|
|
x & MASK64, y & MASK64, z & MASK64, salt):
|
|
for byte in range(8):
|
|
value ^= (word >> (byte * 8)) & 0xff
|
|
value = (value * 1099511628211) & MASK64
|
|
value ^= value >> 30
|
|
value = (value * 0xbf58476d1ce4e5b9) & MASK64
|
|
value ^= value >> 27
|
|
value = (value * 0x94d049bb133111eb) & MASK64
|
|
value ^= value >> 31
|
|
return ((value >> 32) >> 15) - Q16
|
|
|
|
|
|
def transition_fade(value):
|
|
value = max(0, min(Q16, value))
|
|
square = value * value // Q16
|
|
return square * (3 * Q16 - 2 * value) // Q16
|
|
|
|
|
|
def gradient_height_um(layout, x_um, y_um):
|
|
upper = next(item for item in layout["strates"] if item["order"] == 0)
|
|
lower = next(item for item in layout["strates"] if item["order"] == 1)
|
|
width = upper["transition_width_um"]
|
|
cell = max(1_000_000, layout["strate_layout_chunk_size_um"] * 4)
|
|
ix = x_um // cell
|
|
iy = y_um // cell
|
|
tx = transition_fade(((x_um - ix * cell) * Q16) // cell)
|
|
ty = transition_fade(((y_um - iy * cell) * Q16) // cell)
|
|
|
|
def at(dx, dy):
|
|
noise = transition_hash(0, upper["strate_index"], ix + dx, iy + dy, 0,
|
|
0x47524144)
|
|
return noise
|
|
|
|
def lerp(a, b, t):
|
|
return a + trunc_div((b - a) * t, Q16)
|
|
|
|
low = lerp(lerp(at(0, 0), at(1, 0), tx),
|
|
lerp(at(0, 1), at(1, 1), tx), ty)
|
|
displacement = trunc_div(low * (width // 8), Q16)
|
|
seam = upper["bedrock"]["bottom_um"]
|
|
thickness = upper["bedrock"]["top_um"] - seam
|
|
return seam + displacement + thickness
|
|
|
|
|
|
def read_scene(path):
|
|
triangles = []
|
|
with open(path, "rb") as stream:
|
|
if stream.read(8) != b"VFMSH001":
|
|
raise SystemExit(f"{path}: invalid scene header")
|
|
version, voxel_um, count = struct.unpack("<3I", stream.read(12))
|
|
stream.read(40)
|
|
if version != 1 or count > 100000:
|
|
raise SystemExit("unsupported scene")
|
|
for _ in range(count):
|
|
key = struct.unpack("<4i", stream.read(16))
|
|
surface_vertices, skirt_count, vertex_count, index_count, mask_count = struct.unpack(
|
|
"<5I", stream.read(20))
|
|
positions = array.array("f")
|
|
positions.frombytes(stream.read(vertex_count * 12))
|
|
normals = array.array("f")
|
|
normals.frombytes(stream.read(vertex_count * 12))
|
|
indices = array.array("I")
|
|
indices.frombytes(stream.read(index_count * 4))
|
|
masks = stream.read(mask_count)
|
|
if sys.byteorder != "little":
|
|
positions.byteswap()
|
|
normals.byteswap()
|
|
indices.byteswap()
|
|
for index in range(0, index_count, 3):
|
|
if masks and masks[index // 3]:
|
|
continue
|
|
a, b, c = indices[index:index + 3]
|
|
if max(a, b, c) >= surface_vertices:
|
|
continue
|
|
nz = (normals[a * 3 + 2] + normals[b * 3 + 2]
|
|
+ normals[c * 3 + 2]) / 3.0
|
|
if nz < 0.35:
|
|
continue
|
|
vertices = tuple((positions[v * 3], positions[v * 3 + 1],
|
|
positions[v * 3 + 2]) for v in (a, b, c))
|
|
triangles.append(vertices)
|
|
return voxel_um, triangles
|
|
|
|
|
|
def rasterize(triangles, bounds, width, height):
|
|
x0, x1, y0, y1 = bounds
|
|
values = [None] * (width * height)
|
|
sx, sy = width / (x1 - x0), height / (y1 - y0)
|
|
for tri in triangles:
|
|
(ax, ay, az), (bx, by, bz), (cx, cy, cz) = tri
|
|
den = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
|
if abs(den) < 1.0e-12:
|
|
continue
|
|
left = max(0, int(math.floor((min(ax, bx, cx) - x0) * sx)))
|
|
right = min(width - 1, int(math.ceil((max(ax, bx, cx) - x0) * sx)))
|
|
top = max(0, int(math.floor((y1 - max(ay, by, cy)) * sy)))
|
|
bottom = min(height - 1, int(math.ceil((y1 - min(ay, by, cy)) * sy)))
|
|
for py in range(top, bottom + 1):
|
|
y = y1 - (py + 0.5) / sy
|
|
row = py * width
|
|
for px in range(left, right + 1):
|
|
x = x0 + (px + 0.5) / sx
|
|
u = ((by - cy) * (x - cx) + (cx - bx) * (y - cy)) / den
|
|
v = ((cy - ay) * (x - cx) + (ax - cx) * (y - cy)) / den
|
|
w = 1.0 - u - v
|
|
if min(u, v, w) < -1.0e-6:
|
|
continue
|
|
z = u * az + v * bz + w * cz
|
|
idx = row + px
|
|
if values[idx] is None or z > values[idx]:
|
|
values[idx] = z
|
|
return values
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("scene")
|
|
parser.add_argument("output_ppm")
|
|
parser.add_argument("--bounds", nargs=4, type=float, required=True,
|
|
metavar=("XMIN", "XMAX", "YMIN", "YMAX"))
|
|
parser.add_argument("--size", nargs=2, type=int, default=(640, 640))
|
|
parser.add_argument("--range", nargs=2, type=float, default=(-122.0, -118.0),
|
|
metavar=("ZMIN", "ZMAX"))
|
|
parser.add_argument("--ring-center", nargs=2, type=float,
|
|
metavar=("X", "Y"))
|
|
parser.add_argument("--layout")
|
|
args = parser.parse_args()
|
|
voxel_um, triangles = read_scene(args.scene)
|
|
width, height = args.size
|
|
sx, sy = width / (args.bounds[1] - args.bounds[0]), height / (args.bounds[3] - args.bounds[2])
|
|
values = rasterize(triangles, args.bounds, width, height)
|
|
finite = sorted(value for value in values if value is not None)
|
|
if not finite:
|
|
raise SystemExit("no upward-facing field samples in bounds")
|
|
low, high = args.range
|
|
if high <= low:
|
|
raise SystemExit("height range must increase")
|
|
stops = ((0.00, (31, 52, 92)), (0.24, (41, 119, 153)),
|
|
(0.50, (85, 177, 143)), (0.73, (226, 207, 105)),
|
|
(1.00, (181, 71, 49)))
|
|
with open(args.output_ppm, "wb") as output:
|
|
output.write(f"P6\n{width} {height}\n255\n".encode())
|
|
for value in values:
|
|
if value is None:
|
|
output.write(bytes((18, 22, 30)))
|
|
continue
|
|
t = max(0.0, min(1.0, (value - low) / (high - low)))
|
|
for index in range(len(stops) - 1):
|
|
if t <= stops[index + 1][0]:
|
|
ta, ca = stops[index]
|
|
tb, cb = stops[index + 1]
|
|
blend = (t - ta) / (tb - ta)
|
|
rgb = tuple(round(ca[c] + blend * (cb[c] - ca[c]))
|
|
for c in range(3))
|
|
break
|
|
else:
|
|
rgb = stops[-1][1]
|
|
output.write(bytes(rgb))
|
|
def quantile(p):
|
|
return finite[round((len(finite) - 1) * p)]
|
|
print(f"[RingFieldRaster] voxel_um={voxel_um} triangles={len(triangles)} "
|
|
f"pixels={len(finite)}/{len(values)} height_min_m={finite[0]:.6f} "
|
|
f"height_p05_m={quantile(.05):.6f} height_median_m={quantile(.5):.6f} "
|
|
f"height_p95_m={quantile(.95):.6f} height_max_m={finite[-1]:.6f} "
|
|
f"height_range_m={finite[-1]-finite[0]:.6f} "
|
|
f"map={args.output_ppm}")
|
|
if args.ring_center:
|
|
cx, cy = args.ring_center
|
|
def at_world(x, y):
|
|
fx = (x - args.bounds[0]) * sx - 0.5
|
|
fy = (args.bounds[3] - y) * sy - 0.5
|
|
ix, iy = math.floor(fx), math.floor(fy)
|
|
tx, ty = fx - ix, fy - iy
|
|
samples = []
|
|
for px, py in ((ix, iy), (ix + 1, iy), (ix, iy + 1), (ix + 1, iy + 1)):
|
|
if not (0 <= px < width and 0 <= py < height):
|
|
return None
|
|
value = values[py * width + px]
|
|
if value is None:
|
|
return None
|
|
samples.append(value)
|
|
return ((samples[0] * (1 - tx) + samples[1] * tx) * (1 - ty)
|
|
+ (samples[2] * (1 - tx) + samples[3] * tx) * ty)
|
|
print(f"[RingRadialProbe] center_m=({cx:.3f},{cy:.3f}) "
|
|
"radius_m mean_m angular_sd_m min_m max_m samples")
|
|
for radius in range(1, 17):
|
|
row = [at_world(cx + radius * math.cos(a * math.tau / 32),
|
|
cy + radius * math.sin(a * math.tau / 32))
|
|
for a in range(32)]
|
|
row = [value for value in row if value is not None]
|
|
if row:
|
|
mean = sum(row) / len(row)
|
|
sd = math.sqrt(sum((value - mean) ** 2 for value in row) / len(row))
|
|
print(f" {radius:>2} {mean:.6f} {sd:.6f} {min(row):.6f} "
|
|
f"{max(row):.6f} {len(row)}")
|
|
if args.layout:
|
|
layout = json.load(open(args.layout, encoding="utf-8"))
|
|
if next(item for item in layout["strates"] if item["order"] == 0)["transition_mode"] != "Gradient":
|
|
raise SystemExit("--layout must describe a Gradient owner interface")
|
|
errors = []
|
|
for index, actual in enumerate(values):
|
|
if actual is None:
|
|
continue
|
|
px, py = index % width, index // width
|
|
x = args.bounds[0] + (px + 0.5) / sx
|
|
y = args.bounds[3] - (py + 0.5) / sy
|
|
expected = gradient_height_um(layout, round(x * 1_000_000),
|
|
round(y * 1_000_000)) / 1_000_000.0
|
|
errors.append(actual - expected)
|
|
absolute = sorted(abs(error) for error in errors)
|
|
rms = math.sqrt(sum(error * error for error in errors) / len(errors))
|
|
print(f"[RingGradientMatch] samples={len(errors)} rms_m={rms:.6f} "
|
|
f"p95_abs_m={absolute[round((len(absolute)-1)*.95)]:.6f} "
|
|
f"max_abs_m={absolute[-1]:.6f} "
|
|
"expected=owner-resolved-2d-hash-value-noise")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|